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/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4f36b83972d..d06bbeb298b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -244,8 +244,12 @@ jobs: if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'; then E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1) fi + E2E_PROJECT_ARGS=() + if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then + E2E_PROJECT_ARGS+=(--project=electron-headful) + fi xvfb-run --auto-servernum env "${E2E_ENV[@]}" \ - pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 + pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}" - name: Upload Playwright traces if: failure() 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 8d20a00df05..6d7292f3bf3 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-12", + "updatedAt": "2026-08-13", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -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", @@ -1817,19 +1992,24 @@ "providers": ["remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["remote-runtime"], - "coverageNotes": "A headed macOS desktop host and separate paired client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, browser close, forced capability rejection after caller preflight, and a forced post-create reconciliation timeout. The same fault oracles pass against a live headless server. Unit contracts cover absent, unknown, and mixed-version capabilities, visible rejection, empty-split cleanup, owning-runtime routing, exact client materialization, confirmed rollback, ambiguous cleanup, and teardown. Linux and Windows live journeys remain gaps.", + "coverageNotes": "A headed macOS desktop host and separate paired client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, browser close, forced capability rejection after caller preflight, a forced post-create reconciliation timeout, and navigation held beyond the client create deadline. The same reconciliation fault oracles pass against a live headless server. Unit contracts cover absent, unknown, and mixed-version capabilities, visible rejection, empty-split cleanup, owning-runtime routing, canonical preallocated page identity, navigation-independent acknowledgement, exact client materialization, confirmed rollback, ambiguous cleanup, and teardown. Linux and Windows live journeys remain gaps.", "motivatingLinks": [ "https://linear.app/stably/issue/STA-3872/unable-to-open-html-files-in-remote-server", "https://linear.app/stably/issue/STA-4025/p1-browser-create-reports-success-when-tab-reconcile-fails-after-pr", "https://linear.app/stably/issue/STA-4063/p1-paired-html-preview-rejects-outside-cleanup-after-pr-13909", + "https://linear.app/stably/issue/STA-4231/p1-owner-pinned-paired-browser-create-can-time-out-after-host-tab", + "https://github.com/stablyai/orca/pull/14363", "https://github.com/stablyai/orca/pull/13876" ], - "invariant": "Opening one HTML preview from a paired client succeeds only after exactly one browser page on the worktree-owning runtime materializes as exactly one client browser workspace and unified tab in the requested group. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback. Preview focus and close preserve the source editor and terminal.", - "oracle": "Run one unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host. Require the original unsupported error or visible toast, zero RPCs, and no retained new split. Run it on the scan target, latest main, candidate, and candidate with the cleanup boundary disabled. In headed paired Electron, arm capability rejection after the caller preflight, invoke the visible HTML side-preview action, require the failure toast, and compare exact host/client browser, editor, terminal, and group inventories to baseline. Repeat against headless serve. Retain the separate exact-page reconciliation rollback oracle.", + "invariant": "Opening one HTML preview from a paired client succeeds only after exactly one browser page on the worktree-owning runtime materializes as exactly one client browser workspace and unified tab in the requested group. Owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback. Preview focus and close preserve the source editor and terminal.", + "oracle": "Run one unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host. Require the original unsupported error or visible toast, zero RPCs, and no retained new split. Run it on the scan target, latest main, candidate, and candidate with the cleanup boundary disabled. In headed paired Electron, arm capability rejection after the caller preflight, invoke the visible HTML side-preview action, require the failure toast, and compare exact host/client browser, editor, terminal, and group inventories to baseline. Hold a real navigation response beyond the 15-second client deadline after host creation; require the first RPC to return the exact host inventory page ID, one host page, and no retry. Repeat reconciliation faults against headless serve. Retain the separate exact-page reconciliation rollback oracle.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-browser-create-navigation-deadline.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", @@ -1848,7 +2028,10 @@ "src/renderer/src/runtime/web-runtime-session.test.ts", "src/renderer/src/runtime/web-session-tabs-sync.test.ts", "src/renderer/src/runtime/remote-server-parity.test.ts", + "src/renderer/src/hooks/useIpcEvents.test.ts", + "src/renderer/src/store/slices/browser.test.ts", "tests/e2e/paired-remote-html-browser-focus.spec.ts", + "tests/e2e/paired-browser-create-navigation-deadline.spec.ts", "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts" ], "assertionRefs": [ @@ -1884,12 +2067,36 @@ "file": "src/renderer/src/runtime/web-runtime-session.test.ts", "assertions": [ "a post-create timeout returns false only after exact host close and a confirming snapshot", + "owner-pinned creation launches navigation separately and materializes without awaiting it", + "a lost create acknowledgement cleans the capability-gated canonical page ID", "unconfirmed cleanup rejects so callers cannot create a competing local fallback", "the canonical placement and a newly-created empty split are released on rollback", "one failed preview cannot remove a shared split still reserved after an environment ownership change", "if every overlapping creation fails, the last reservation closes the caller-created empty split" ] }, + { + "file": "src/main/runtime/orca-runtime-browser.test.ts", + "assertions": [ + "explicit renderer cleanup bypasses guest registration while retaining typed missing-page behavior", + "offscreen cleanup rejects a page outside the requested worktree" + ] + }, + { + "file": "src/renderer/src/hooks/useIpcEvents.test.ts", + "assertions": [ + "renderer-backed creation preserves the requested canonical page ID while keeping its workspace identity independent", + "closing an absent renderer page returns browser_tab_not_found instead of an untyped failure", + "explicit cleanup cannot close a page owned by another worktree" + ] + }, + { + "file": "src/renderer/src/store/slices/browser.test.ts", + "assertions": [ + "a requested canonical page ID is stored on the page rather than reused as its workspace ID", + "duplicate canonical page IDs are rejected before a second tab can materialize" + ] + }, { "file": "src/renderer/src/runtime/web-session-browser-placement.test.ts", "assertions": [ @@ -1921,6 +2128,13 @@ "preview creation leaves the editor active, an explicit browser click is authoritative, and close preserves the editor and terminal" ] }, + { + "file": "tests/e2e/paired-browser-create-navigation-deadline.spec.ts", + "assertions": [ + "a held navigation exceeds the former create deadline while the first RPC returns the exact authoritative host page ID", + "host inventory contains exactly one new page and the user retry branch is never entered" + ] + }, { "file": "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts", "assertions": [ @@ -2075,6 +2289,24 @@ "result": "failed", "durationSeconds": 12.3, "summary": "Candidate d05217c40d with only the side-preview focusOnCreate:false call disabled failed the byte-identical 2ec1dcf1 oracle because preview creation activated the browser instead of preserving editor focus." + }, + { + "date": "2026-08-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts", + "result": "passed", + "durationSeconds": 10.83, + "summary": "Five post-review host, IPC, store, and client lifecycle files passed 1,356 tests with one pre-existing skip, including both shards that failed before the canonical page/workspace identity and typed missing-page cleanup corrections." + }, + { + "date": "2026-08-13", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-browser-create-navigation-deadline.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 18.1, + "summary": "Two isolated headed Electron/CDP journeys passed: the direct owner-pinned call returned one canonical host ID before held navigation, and the real remote-pane context-menu route materialized that one page without retry or duplicate." } ], "runtimeBudget": { @@ -2087,11 +2319,11 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing." + "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical STA-4231 headed oracle is red on latest-main b19d99bd00: both 15-second creates return runtime_timeout without a page ID while the host retains two distinct pages after retry. The relevant behavior is unchanged through 0a0ae974d4. The candidate is green with one returned canonical ID, one host/client page, and no retry; disabling only the navigation-independent acknowledgement is red with the same two orphaned pages. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing." }, "performanceBudget": { "required": true, - "evidence": "The transaction adds no timer, polling, retry, subprocess, RPC method, stream opcode, or host publication. Capability rejection performs zero RPCs and one bounded empty-group close. Successful creation adds one post-operation list; known-page failure adds one exact close and one confirming list. The E2E-only one-shot faults are build-gated, retain one promise/flag and a bounded page-id set, and reset after each run. Pending client placements remain capped at 128 and clear on adoption, failure, or teardown." + "evidence": "The transaction adds no timer, polling, retry, subprocess, RPC method, stream opcode, or global host publication. Owner-pinned success performs one create RPC followed by one asynchronous goto, retains one page, and performs zero retry or cleanup calls. A lost create acknowledgement performs one exact close by the preallocated canonical ID and at most one confirming snapshot. Headless goto publishes one worktree-scoped session snapshot after navigation. Capability rejection performs zero RPCs and one bounded empty-group close. The E2E-only one-shot faults are build-gated, retain one promise/flag and a bounded page-id set, and reset after each run. Pending client placements remain capped at 128 and clear on adoption, failure, or teardown." }, "promotionCriteria": [ "Collect Linux and physical Windows paired-server evidence.", @@ -8413,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" @@ -8442,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", @@ -8474,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", @@ -8521,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.", @@ -11999,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/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index d44039a2ca8..c600d233437 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -75,7 +75,11 @@ describe('PR E2E gate contract', () => { expect(changedRun.run).toContain('. != "tests/e2e/ssh-startup-exec-readiness.spec.ts"') expect(changedRun.run).toContain('. != "tests/e2e/paired-startup-exec-readiness.spec.ts"') expect(changedRun.run).toContain('if [ "${#TEST_FILES[@]}" -eq 0 ]') - expect(changedRun.run).toContain('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1') + expect(changedRun.run).toContain('grep -l \'@headful\' "${TEST_FILES[@]}"') + expect(changedRun.run).toContain('E2E_PROJECT_ARGS+=(--project=electron-headful)') + expect(changedRun.run).toContain( + 'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"' + ) }) it('keeps startup-exec live parity in the isolated SSH lane', () => { diff --git a/config/scripts/remote-shared-control-retirement-probe.ts b/config/scripts/remote-shared-control-retirement-probe.ts index 30edeb9802a..629ba1fddde 100644 --- a/config/scripts/remote-shared-control-retirement-probe.ts +++ b/config/scripts/remote-shared-control-retirement-probe.ts @@ -5,7 +5,7 @@ import { resolveEnvironment, resolveEnvironmentPairingOffer } from '../../src/shared/runtime-environment-store' -import type { MemorySnapshot } from '../../src/shared/types' +import type { MemorySnapshot } from '../../src/shared/process-stats-types' import type { RuntimeStatus } from '../../src/shared/runtime-types' async function main(): Promise { 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/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 5fd6fb39d43..9cf10c94356 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -96,7 +96,7 @@ import { WORKSPACE_SORT_OPTIONS as SORT_OPTIONS } from '../../../src/worktree/workspace-list-picker-options' import type { RepoSummary } from '../../../src/worktree/host-worktree-rpc-types' -import type { WorkspaceStatusDefinition } from '../../../../src/shared/types' +import type { WorkspaceStatusDefinition } from '../../../../src/shared/worktree/types' import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses' function isErrorVerdict(v: ConnectionVerdict): boolean { diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c0bcca64b98..0ef8a42ee92 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -277,7 +277,8 @@ import { import { colors } from '../../../../src/theme/mobile-theme' import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton' import { styles } from '../../../../src/session/mobile-session-styles' -import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types' +import type { DiffComment } from '../../../../../src/shared/diff-comment-types' +import type { TerminalQuickCommand } from '../../../../../src/shared/terminal-quick-command-types' import type { DiffCommentActions, DiffNotesDelivery, diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index 7ef451df8aa..717e4640516 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -149,13 +149,13 @@ import { scheduleMobileTaskCopyFeedbackReset } from '../../../src/tasks/mobile-task-copy-feedback-timer' import type { - BaseRefSearchResult, GitHubOwnerRepo, - ProviderCheckSummary, - PersistedTrustedOrcaHooks, - SparsePreset, - TuiAgent -} from '../../../../src/shared/types' + ProviderCheckSummary +} from '../../../../src/shared/github/pull-request-types' +import type { PersistedTrustedOrcaHooks } from '../../../../src/shared/orca-yaml-hook-types' +import type { BaseRefSearchResult } from '../../../../src/shared/repo-types' +import type { TuiAgent } from '../../../../src/shared/tui-agent' +import type { SparsePreset } from '../../../../src/shared/worktree/create-types' import type { SshConnectionState } from '../../../../src/shared/ssh-types' import type { HostedReviewDecision } from '../../../../src/shared/hosted-review' import { diff --git a/mobile/scripts/mock-server-rpc-handlers.ts b/mobile/scripts/mock-server-rpc-handlers.ts index 5971b9dee94..f67db5bc2f2 100644 --- a/mobile/scripts/mock-server-rpc-handlers.ts +++ b/mobile/scripts/mock-server-rpc-handlers.ts @@ -7,7 +7,7 @@ import { applyTerminalQuickCommandMutation, type TerminalQuickCommandMutation } from '../../src/shared/terminal-quick-commands' -import type { TerminalQuickCommand } from '../../src/shared/types' +import type { TerminalQuickCommand } from '../../src/shared/terminal-quick-command-types' import { handleMockFilePreviewRequest } from './mock-server-file-preview-data' import { handleMockGitRequest } from './mock-server-git-state' import { handleMockAccountRequest } from './mock-server-account-rpc' diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index abad3a84780..942639ca51a 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -7,11 +7,11 @@ 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 { buildMobileAiVaultResumeLaunch, createMobileAiVaultResumeMutationRegistry, - readMobileRuntimeHostPlatform, readMobileRuntimeTerminalWindowsShell, resolveMobileAiVaultResumePlatform, resumeAiVaultSessionInTerminal, diff --git a/mobile/src/components/MobileAgentIcon.tsx b/mobile/src/components/MobileAgentIcon.tsx index c5557ab3934..7f0cf0e0f91 100644 --- a/mobile/src/components/MobileAgentIcon.tsx +++ b/mobile/src/components/MobileAgentIcon.tsx @@ -1,7 +1,7 @@ import { Image, StyleSheet, Text, View } from 'react-native' import type { ImageSourcePropType } from 'react-native' import { Terminal } from 'lucide-react-native' -import type { TuiAgent } from '../../../src/shared/types' +import type { TuiAgent } from '../../../src/shared/tui-agent' import Svg, { Defs, G, LinearGradient, Path, Stop } from 'react-native-svg' import { colors } from '../theme/mobile-theme' import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' diff --git a/mobile/src/components/MobileDiffReviewBody.tsx b/mobile/src/components/MobileDiffReviewBody.tsx index 76555aeadc6..cc0a7aa95bd 100644 --- a/mobile/src/components/MobileDiffReviewBody.tsx +++ b/mobile/src/components/MobileDiffReviewBody.tsx @@ -1,7 +1,7 @@ import { ActivityIndicator, FlatList, Pressable, Text, View } from 'react-native' import { RefreshCw } from 'lucide-react-native' import type { RefObject } from 'react' -import type { DiffComment } from '../../../src/shared/types' +import type { DiffComment } from '../../../src/shared/diff-comment-types' import { colors } from '../theme/mobile-theme' import { MobileDiffReviewLine } from './MobileDiffReviewLine' import type { diff --git a/mobile/src/components/MobileDiffReviewDrawers.tsx b/mobile/src/components/MobileDiffReviewDrawers.tsx index c6e05b1cd0a..1ff763fc9b8 100644 --- a/mobile/src/components/MobileDiffReviewDrawers.tsx +++ b/mobile/src/components/MobileDiffReviewDrawers.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from 'react-native' import { Check, Copy, FileText, Plus, Send, Trash2, X } from 'lucide-react-native' -import type { DiffComment } from '../../../src/shared/types' +import type { DiffComment } from '../../../src/shared/diff-comment-types' import { colors } from '../theme/mobile-theme' import type { ActionSheetAction } from './ActionSheetModal' import { ActionSheetModal } from './ActionSheetModal' diff --git a/mobile/src/components/MobileDiffReviewFileSummary.tsx b/mobile/src/components/MobileDiffReviewFileSummary.tsx index a27910f4d6f..3645ed60161 100644 --- a/mobile/src/components/MobileDiffReviewFileSummary.tsx +++ b/mobile/src/components/MobileDiffReviewFileSummary.tsx @@ -1,6 +1,6 @@ import { Pressable, Text, View } from 'react-native' import { ArrowDown, ArrowUp } from 'lucide-react-native' -import type { DiffComment } from '../../../src/shared/types' +import type { DiffComment } from '../../../src/shared/diff-comment-types' import { colors } from '../theme/mobile-theme' import type { MobileDiffReviewQueueItem } from '../session/mobile-diff-review-queue' import { MOBILE_GIT_STATUS_LABELS } from '../source-control/mobile-git-status' diff --git a/mobile/src/components/MobileDiffReviewLine.tsx b/mobile/src/components/MobileDiffReviewLine.tsx index 1c1008321f6..ae882d56525 100644 --- a/mobile/src/components/MobileDiffReviewLine.tsx +++ b/mobile/src/components/MobileDiffReviewLine.tsx @@ -1,6 +1,6 @@ import { Pressable, StyleSheet, Text, View } from 'react-native' import { MessageSquare } from 'lucide-react-native' -import type { DiffComment } from '../../../src/shared/types' +import type { DiffComment } from '../../../src/shared/diff-comment-types' import type { MobileDiffLine } from '../session/mobile-diff-lines' import type { MobileHighlightedDiffLine } from '../session/mobile-file-syntax' import { mobileDiffLineNumber, mobileDiffLinePrefix } from '../source-control/mobile-diff-format' 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 3fca3261ecc..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' @@ -29,8 +29,11 @@ import { type SetupHookTrust } from '../tasks/setup-hook-trust' import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents' -import type { PersistedTrustedOrcaHooks, TuiAgent } from '../../../src/shared/types' +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, @@ -63,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' @@ -200,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({}) @@ -694,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 @@ -785,10 +802,7 @@ function NewWorktreeModalContent({ > - Create Workspace - - Pick a repository and agent to spin up a new workspace. - + Create worktree {loading ? ( @@ -797,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} @@ -995,7 +999,7 @@ function NewWorktreeModalContent({ ) : ( - {sshGate.requiresConnection ? 'Connect Repository' : 'Create Workspace'} + {sshGate.requiresConnection ? 'Connect target' : 'Create worktree'} )} @@ -1024,10 +1028,10 @@ function NewWorktreeModalContent({ /> handleRepoSelected(item.repo)} onClose={() => transitionDrawer('form')} renderIcon={(item) => { @@ -1035,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/mobile-agent-icon-assets.test.ts b/mobile/src/components/mobile-agent-icon-assets.test.ts index edbe71760c7..f4f6325d806 100644 --- a/mobile/src/components/mobile-agent-icon-assets.test.ts +++ b/mobile/src/components/mobile-agent-icon-assets.test.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import { describe, expect, it } from 'vitest' -import type { TuiAgent } from '../../../src/shared/types' +import type { TuiAgent } from '../../../src/shared/tui-agent' import { MOBILE_TUI_AGENT_FAVICON_DOMAINS } from '../tasks/mobile-tui-agents' // Agents that render a hand-authored glyph in MobileAgentIcon and therefore diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts index 3f93b3dfaeb..38a48373734 100644 --- a/mobile/src/components/mobile-agent-icon-assets.ts +++ b/mobile/src/components/mobile-agent-icon-assets.ts @@ -1,5 +1,5 @@ import type { ImageSourcePropType } from 'react-native' -import type { TuiAgent } from '../../../src/shared/types' +import type { TuiAgent } from '../../../src/shared/tui-agent' // Why: mobile previously rendered these agent icons from Google's favicon // service, which is unreachable in some regions (e.g. mainland China) and 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-agent-selection.ts b/mobile/src/components/new-worktree-agent-selection.ts index 22101c00a30..1e8836a5fed 100644 --- a/mobile/src/components/new-worktree-agent-selection.ts +++ b/mobile/src/components/new-worktree-agent-selection.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../src/shared/types' +import type { TuiAgent } from '../../../src/shared/tui-agent' import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents' import { pickWorkspaceAgent } from '../tasks/workspace-agent-selection' 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/pr-sidebar/PRActionsSection.tsx b/mobile/src/components/pr-sidebar/PRActionsSection.tsx index 5fae6803889..8f331ef11ba 100644 --- a/mobile/src/components/pr-sidebar/PRActionsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRActionsSection.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react' import { ActivityIndicator, Pressable, Text, View } from 'react-native' import { GitMerge, Link2Off } from 'lucide-react-native' import { colors } from '../../theme/mobile-theme' -import type { GitHubPRMergeMethod, PRInfo } from '../../../../src/shared/types' +import type { GitHubPRMergeMethod, PRInfo } from '../../../../src/shared/github/pull-request-types' import type { RpcClient } from '../../transport/rpc-client' import type { MobilePrActions } from '../../session/use-mobile-pr-actions' import { unlinkMobilePr } from '../../source-control/mobile-pr-link' diff --git a/mobile/src/components/pr-sidebar/PRCheckDetail.tsx b/mobile/src/components/pr-sidebar/PRCheckDetail.tsx index 7086681c434..3023cdce72c 100644 --- a/mobile/src/components/pr-sidebar/PRCheckDetail.tsx +++ b/mobile/src/components/pr-sidebar/PRCheckDetail.tsx @@ -1,6 +1,6 @@ import { ActivityIndicator, ScrollView, Text, View } from 'react-native' import { colors } from '../../theme/mobile-theme' -import type { PRCheckRunDetails } from '../../../../src/shared/types' +import type { PRCheckRunDetails } from '../../../../src/shared/github/check-types' import { presentCheckDetail, type CheckDetailJob } from './pr-check-detail-content' import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles' diff --git a/mobile/src/components/pr-sidebar/PRChecksSection.tsx b/mobile/src/components/pr-sidebar/PRChecksSection.tsx index 53ac41655c5..12c08929c41 100644 --- a/mobile/src/components/pr-sidebar/PRChecksSection.tsx +++ b/mobile/src/components/pr-sidebar/PRChecksSection.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { ActivityIndicator, Linking, Pressable, Text, View } from 'react-native' import { ChevronDown, ChevronRight, ExternalLink, RotateCw, Sparkles } from 'lucide-react-native' import { colors } from '../../theme/mobile-theme' -import type { PRCheckDetail } from '../../../../src/shared/types' +import type { PRCheckDetail } from '../../../../src/shared/github/check-types' import type { RpcClient } from '../../transport/rpc-client' import { fetchPRCheckDetails, type GitHubPrRepoSlug } from '../../session/github-pr-rpc' import type { MobilePrActions } from '../../session/use-mobile-pr-actions' diff --git a/mobile/src/components/pr-sidebar/PRCommentCard.tsx b/mobile/src/components/pr-sidebar/PRCommentCard.tsx index 23d768b7814..5aee8cc0a00 100644 --- a/mobile/src/components/pr-sidebar/PRCommentCard.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentCard.tsx @@ -1,7 +1,11 @@ import { memo, useState } from 'react' import { Image, Linking, Pressable, Text, View } from 'react-native' import { Check, CornerDownRight, ExternalLink, Pencil, Trash2, Undo2 } from 'lucide-react-native' -import type { GitHubReaction, GitHubReactionContent, PRComment } from '../../../../src/shared/types' +import type { + GitHubReaction, + GitHubReactionContent, + PRComment +} from '../../../../src/shared/github/comment-types' import { colors } from '../../theme/mobile-theme' import { canEditComment, isResolvableComment } from '../../session/pr-comment-actions' import { ConfirmModal } from '../ConfirmModal' diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts b/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts index 08fc02c357b..e01b251ce05 100644 --- a/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts +++ b/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts @@ -1,7 +1,8 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { GitHubWorkItemDetails, PRComment } from '../../../../src/shared/types' +import type { PRComment } from '../../../../src/shared/github/comment-types' +import type { GitHubWorkItemDetails } from '../../../../src/shared/github/work-item-types' import { PRCommentsSection } from './PRCommentsSection' vi.mock('react-native', () => ({ diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx index 8fb5de31037..b8ce48362f2 100644 --- a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx @@ -1,7 +1,8 @@ import { useMemo, useState } from 'react' import { ActivityIndicator, Pressable, Text, View } from 'react-native' import { ChevronDown, ChevronRight } from 'lucide-react-native' -import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/types' +import type { PRState } from '../../../../src/shared/github/pull-request-types' +import type { GitHubWorkItemDetails } from '../../../../src/shared/github/work-item-types' import type { GitHubPrRepoSlug } from '../../session/github-pr-rpc' import { colors } from '../../theme/mobile-theme' import { canAddRootComment } from '../../session/pr-comment-actions' diff --git a/mobile/src/components/pr-sidebar/PRConflictingFilesSection.tsx b/mobile/src/components/pr-sidebar/PRConflictingFilesSection.tsx index 95c95d30573..0a95854d4fd 100644 --- a/mobile/src/components/pr-sidebar/PRConflictingFilesSection.tsx +++ b/mobile/src/components/pr-sidebar/PRConflictingFilesSection.tsx @@ -3,7 +3,7 @@ import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-nati import * as Clipboard from 'expo-clipboard' import { Check, Copy, FileWarning, Sparkles } from 'lucide-react-native' import { colors } from '../../theme/mobile-theme' -import type { PRInfo } from '../../../../src/shared/types' +import type { PRInfo } from '../../../../src/shared/github/pull-request-types' import { PRSection } from './PRSection' import { resolveConflictDisplay } from './pr-conflict-presentation' import { prConflictStyles as styles } from './pr-conflict-styles' diff --git a/mobile/src/components/pr-sidebar/PRReviewersSection.tsx b/mobile/src/components/pr-sidebar/PRReviewersSection.tsx index bc09ecd3502..d374dd629c8 100644 --- a/mobile/src/components/pr-sidebar/PRReviewersSection.tsx +++ b/mobile/src/components/pr-sidebar/PRReviewersSection.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import { ActivityIndicator, Pressable, Text, View } from 'react-native' import { UserPlus, X } from 'lucide-react-native' import { colors } from '../../theme/mobile-theme' -import type { GitHubWorkItemDetails } from '../../../../src/shared/types' +import type { GitHubWorkItemDetails } from '../../../../src/shared/github/work-item-types' import type { RpcClient } from '../../transport/rpc-client' import type { MobilePrActions } from '../../session/use-mobile-pr-actions' import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state' diff --git a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx index 335108e07ae..c053e9b2a65 100644 --- a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx +++ b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx @@ -2,7 +2,8 @@ import { useState } from 'react' import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' import { ArrowRight, ExternalLink, Pencil } from 'lucide-react-native' import { colors } from '../../theme/mobile-theme' -import type { GitHubWorkItemDetails, PRInfo } from '../../../../src/shared/types' +import type { PRInfo } from '../../../../src/shared/github/pull-request-types' +import type { GitHubWorkItemDetails } from '../../../../src/shared/github/work-item-types' import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-action' import { prStateBadge } from './pr-checks-presentation' import { statusColor } from './pr-sidebar-status-color' diff --git a/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx b/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx index 8a3a161f358..6cdb63ca38e 100644 --- a/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx +++ b/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' import { Check } from 'lucide-react-native' import { colors } from '../../theme/mobile-theme' -import type { GitHubAssignableUser } from '../../../../src/shared/types' +import type { GitHubAssignableUser } from '../../../../src/shared/github/pull-request-types' import type { RpcClient } from '../../transport/rpc-client' import { fetchAssignableUsers } from '../../session/github-pr-rpc' import { BottomDrawer } from '../BottomDrawer' diff --git a/mobile/src/components/pr-sidebar/pr-actions-state.ts b/mobile/src/components/pr-sidebar/pr-actions-state.ts index 7aa1456bc55..f2bd679af1a 100644 --- a/mobile/src/components/pr-sidebar/pr-actions-state.ts +++ b/mobile/src/components/pr-sidebar/pr-actions-state.ts @@ -1,4 +1,4 @@ -import type { GitHubPRMergeMethod, PRState } from '../../../../src/shared/types' +import type { GitHubPRMergeMethod, PRState } from '../../../../src/shared/github/pull-request-types' // Which actions the PR actions section may offer for a given PR state. Merged PRs // expose only unlink (+ open-on-host elsewhere); closed PRs add reopen; open/draft diff --git a/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts b/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts index 5453a0209b6..166e6e0c189 100644 --- a/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts +++ b/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts @@ -1,4 +1,4 @@ -import type { PRInfo } from '../../../../src/shared/types' +import type { PRInfo } from '../../../../src/shared/github/pull-request-types' type MobilePRAutoMergeAvailabilityInput = Pick< PRInfo, diff --git a/mobile/src/components/pr-sidebar/pr-check-detail-content.test.ts b/mobile/src/components/pr-sidebar/pr-check-detail-content.test.ts index 8956a90b4b8..5876405d1bb 100644 --- a/mobile/src/components/pr-sidebar/pr-check-detail-content.test.ts +++ b/mobile/src/components/pr-sidebar/pr-check-detail-content.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { PRCheckRunDetails } from '../../../../src/shared/types' +import type { PRCheckRunDetails } from '../../../../src/shared/github/check-types' import { presentCheckDetail } from './pr-check-detail-content' function details(over: Partial): PRCheckRunDetails { diff --git a/mobile/src/components/pr-sidebar/pr-check-detail-content.ts b/mobile/src/components/pr-sidebar/pr-check-detail-content.ts index b1f63cbd370..0a99fc23709 100644 --- a/mobile/src/components/pr-sidebar/pr-check-detail-content.ts +++ b/mobile/src/components/pr-sidebar/pr-check-detail-content.ts @@ -3,7 +3,7 @@ import type { PRCheckJob, PRCheckRunDetails, PRCheckStep -} from '../../../../src/shared/types' +} from '../../../../src/shared/github/check-types' // Pure mapping from the github.prCheckDetails payload to the rows the mobile // expanded check detail renders. No React/native imports so it stays unit-testable diff --git a/mobile/src/components/pr-sidebar/pr-checks-presentation.test.ts b/mobile/src/components/pr-sidebar/pr-checks-presentation.test.ts index 044644b7dc4..cf40a6b323e 100644 --- a/mobile/src/components/pr-sidebar/pr-checks-presentation.test.ts +++ b/mobile/src/components/pr-sidebar/pr-checks-presentation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { PRCheckDetail } from '../../../../src/shared/types' +import type { PRCheckDetail } from '../../../../src/shared/github/check-types' import { checkOutcome, firstFailingCheckKey, diff --git a/mobile/src/components/pr-sidebar/pr-checks-presentation.ts b/mobile/src/components/pr-sidebar/pr-checks-presentation.ts index e929a13bf40..277680fb9a5 100644 --- a/mobile/src/components/pr-sidebar/pr-checks-presentation.ts +++ b/mobile/src/components/pr-sidebar/pr-checks-presentation.ts @@ -1,4 +1,8 @@ -import type { PRCheckDetail, PRState, ProviderCheckSummary } from '../../../../src/shared/types' +import type { PRCheckDetail } from '../../../../src/shared/github/check-types' +import type { + PRState, + ProviderCheckSummary +} from '../../../../src/shared/github/pull-request-types' import { classifyCheckOutcome, summarizeProviderChecks, diff --git a/mobile/src/components/pr-sidebar/pr-conflict-presentation.test.ts b/mobile/src/components/pr-sidebar/pr-conflict-presentation.test.ts index 07172a52ae3..8f68ead0d6f 100644 --- a/mobile/src/components/pr-sidebar/pr-conflict-presentation.test.ts +++ b/mobile/src/components/pr-sidebar/pr-conflict-presentation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { PRInfo } from '../../../../src/shared/types' +import type { PRInfo } from '../../../../src/shared/github/pull-request-types' import { buildMergeabilityRefreshCommands, hasMergeConflicts, diff --git a/mobile/src/components/pr-sidebar/pr-conflict-presentation.ts b/mobile/src/components/pr-sidebar/pr-conflict-presentation.ts index 0b227806519..54d2e33cb80 100644 --- a/mobile/src/components/pr-sidebar/pr-conflict-presentation.ts +++ b/mobile/src/components/pr-sidebar/pr-conflict-presentation.ts @@ -1,4 +1,4 @@ -import type { PRInfo } from '../../../../src/shared/types' +import type { PRInfo } from '../../../../src/shared/github/pull-request-types' // Pure presentation logic for the PR sidebar's conflicting-files section. No // React/native imports so it is unit-testable under the node Vitest config (KTD5). 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/QuickCommandRow.tsx b/mobile/src/session/QuickCommandRow.tsx index 401305b298c..8815d4da5f0 100644 --- a/mobile/src/session/QuickCommandRow.tsx +++ b/mobile/src/session/QuickCommandRow.tsx @@ -4,7 +4,7 @@ import * as Clipboard from 'expo-clipboard' import { Check, Copy, Pencil, Play, Trash2 } from 'lucide-react-native' import { colors, spacing, typography } from '../theme/mobile-theme' import { MobileAgentIcon } from '../components/MobileAgentIcon' -import type { TerminalQuickCommand } from '../../../src/shared/types' +import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types' import { getQuickCommandDisplayPreview, getTerminalQuickCommandBody, diff --git a/mobile/src/session/QuickCommandsList.tsx b/mobile/src/session/QuickCommandsList.tsx index e8781163691..02a6809bb60 100644 --- a/mobile/src/session/QuickCommandsList.tsx +++ b/mobile/src/session/QuickCommandsList.tsx @@ -3,7 +3,8 @@ import { Check, Plus, Search } from 'lucide-react-native' import { colors, spacing } from '../theme/mobile-theme' import { MobileAgentIcon } from '../components/MobileAgentIcon' import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' -import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types' +import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types' +import type { TuiAgent } from '../../../src/shared/tui-agent' import { supportsTerminalAgentQuickCommand } from '../terminal/quick-commands' import { QuickCommandRow } from './QuickCommandRow' diff --git a/mobile/src/session/QuickCommandsSheet.test.ts b/mobile/src/session/QuickCommandsSheet.test.ts index 9951742b64f..2ce520a4eaf 100644 --- a/mobile/src/session/QuickCommandsSheet.test.ts +++ b/mobile/src/session/QuickCommandsSheet.test.ts @@ -1,7 +1,7 @@ import { createElement, type ReactNode } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { TerminalQuickCommand } from '../../../src/shared/types' +import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types' import type { RpcClient } from '../transport/rpc-client' import { MAX_QUICK_COMMANDS } from '../terminal/quick-commands' import { QuickCommandsSheet } from './QuickCommandsSheet' diff --git a/mobile/src/session/QuickCommandsSheet.tsx b/mobile/src/session/QuickCommandsSheet.tsx index c4dbff9473d..3342f122665 100644 --- a/mobile/src/session/QuickCommandsSheet.tsx +++ b/mobile/src/session/QuickCommandsSheet.tsx @@ -4,7 +4,7 @@ import { ChevronLeft } from 'lucide-react-native' import { colors, spacing } from '../theme/mobile-theme' import { BottomDrawer } from '../components/BottomDrawer' import type { RpcClient } from '../transport/rpc-client' -import type { TerminalQuickCommand } from '../../../src/shared/types' +import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types' import { getQuickCommandPreview, MAX_QUICK_COMMANDS, diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index 9efc0e50f1a..d021a48bc6f 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -5,11 +5,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' function session(overrides: Partial = {}): AiVaultSession { diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index aa8c1bf98a6..11608084814 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -13,7 +13,7 @@ import { resolveTuiAgentLaunchEnv } from '../../../src/shared/tui-agent-launch-defaults' import { normalizeAiVaultResumeFilePath } from '../../../src/shared/ai-vault-resume-path' -import type { TuiAgent } from '../../../src/shared/types' +import type { TuiAgent } from '../../../src/shared/tui-agent' import { parseWslUncPath } from '../../../src/shared/wsl-paths' import { resolveWindowsShellStartupFamily } from '../../../src/shared/windows-terminal-shell' import type { RpcClient } from '../transport/rpc-client' @@ -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> @@ -237,16 +223,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/github-pr-comment-parsers.ts b/mobile/src/session/github-pr-comment-parsers.ts index 93f6eab6f31..5d5d49b746f 100644 --- a/mobile/src/session/github-pr-comment-parsers.ts +++ b/mobile/src/session/github-pr-comment-parsers.ts @@ -1,4 +1,8 @@ -import type { GitHubReaction, GitHubReactionContent, PRComment } from '../../../src/shared/types' +import type { + GitHubReaction, + GitHubReactionContent, + PRComment +} from '../../../src/shared/github/comment-types' import { isRecord, readBoolean, readNumber, readString } from './github-pr-value-readers' // Defensive parsers for the PR conversation comments carried by diff --git a/mobile/src/session/github-pr-mutations.ts b/mobile/src/session/github-pr-mutations.ts index d9fa04ce06a..bd1d9c423b9 100644 --- a/mobile/src/session/github-pr-mutations.ts +++ b/mobile/src/session/github-pr-mutations.ts @@ -1,4 +1,4 @@ -import type { GitHubPRMergeMethod } from '../../../src/shared/types' +import type { GitHubPRMergeMethod } from '../../../src/shared/github/pull-request-types' import type { RpcClient } from '../transport/rpc-client' import { buildGithubPrParams, githubPrRepoSlugParam, type GitHubPrRepoSlug } from './github-pr-rpc' diff --git a/mobile/src/session/github-pr-parsers.ts b/mobile/src/session/github-pr-parsers.ts index 25c516f7106..05a59200d1c 100644 --- a/mobile/src/session/github-pr-parsers.ts +++ b/mobile/src/session/github-pr-parsers.ts @@ -1,15 +1,19 @@ import type { - GitHubAssignableUser, - GitHubPRReviewSummary, - GitHubWorkItem, - GitHubWorkItemDetails, PRCheckAnnotation, PRCheckDetail, PRCheckJob, PRCheckRunDetails, - PRCheckStep, + PRCheckStep +} from '../../../src/shared/github/check-types' +import type { + GitHubAssignableUser, + GitHubPRReviewSummary, PRInfo -} from '../../../src/shared/types' +} from '../../../src/shared/github/pull-request-types' +import type { + GitHubWorkItem, + GitHubWorkItemDetails +} from '../../../src/shared/github/work-item-types' import { readPRComments } from './github-pr-comment-parsers' import type { HostedReviewInfo } from '../../../src/shared/hosted-review' import { diff --git a/mobile/src/session/github-pr-rpc.ts b/mobile/src/session/github-pr-rpc.ts index a34ed49581c..485184993ec 100644 --- a/mobile/src/session/github-pr-rpc.ts +++ b/mobile/src/session/github-pr-rpc.ts @@ -1,10 +1,6 @@ -import type { - GitHubAssignableUser, - GitHubWorkItemDetails, - PRCheckDetail, - PRCheckRunDetails, - PRInfo -} from '../../../src/shared/types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../src/shared/github/check-types' +import type { GitHubAssignableUser, PRInfo } from '../../../src/shared/github/pull-request-types' +import type { GitHubWorkItemDetails } from '../../../src/shared/github/work-item-types' import type { HostedReviewInfo } from '../../../src/shared/hosted-review' import { normalizeGitHubPRForBranchOutcome, diff --git a/mobile/src/session/github-pr-value-readers.ts b/mobile/src/session/github-pr-value-readers.ts index 9074d94142c..083601923b8 100644 --- a/mobile/src/session/github-pr-value-readers.ts +++ b/mobile/src/session/github-pr-value-readers.ts @@ -1,16 +1,16 @@ +import type { PRCheckDetail } from '../../../src/shared/github/check-types' import type { CheckStatus, GitHubAssignableUser, - ProviderCheckSummary, GitHubPRMergeMethod, GitHubPRMergeMethodSettings, GitHubPRReviewSummary, GitHubRepositoryIdentity, - PRCheckDetail, PRMergeableState, PRReviewDecision, - PRState -} from '../../../src/shared/types' + PRState, + ProviderCheckSummary +} from '../../../src/shared/github/pull-request-types' import type { HostedReviewProvider } from '../../../src/shared/hosted-review' // Primitive + enum value readers shared by the github.* PR parsers. Each narrows diff --git a/mobile/src/session/mobile-diff-comment-edit.test.ts b/mobile/src/session/mobile-diff-comment-edit.test.ts index 8e6482d8bb0..49328064cd5 100644 --- a/mobile/src/session/mobile-diff-comment-edit.test.ts +++ b/mobile/src/session/mobile-diff-comment-edit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { DiffComment } from '../../../src/shared/types' +import type { DiffComment } from '../../../src/shared/diff-comment-types' import { clearSentMobileDiffComments, countUnsentMobileDiffComments, diff --git a/mobile/src/session/mobile-diff-comment-edit.ts b/mobile/src/session/mobile-diff-comment-edit.ts index 3da63af3e1e..93ba15c0844 100644 --- a/mobile/src/session/mobile-diff-comment-edit.ts +++ b/mobile/src/session/mobile-diff-comment-edit.ts @@ -1,4 +1,4 @@ -import type { DiffComment } from '../../../src/shared/types' +import type { DiffComment } from '../../../src/shared/diff-comment-types' export type UpdateMobileDiffCommentInput = { id: string diff --git a/mobile/src/session/mobile-diff-comments.test.ts b/mobile/src/session/mobile-diff-comments.test.ts index e86d6e81bab..8897b5e32e4 100644 --- a/mobile/src/session/mobile-diff-comments.test.ts +++ b/mobile/src/session/mobile-diff-comments.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { DiffComment } from '../../../src/shared/types' +import type { DiffComment } from '../../../src/shared/diff-comment-types' import { addMobileDiffComment, formatDiffComments, diff --git a/mobile/src/session/mobile-diff-comments.ts b/mobile/src/session/mobile-diff-comments.ts index 7fc7140fd42..333895ea95a 100644 --- a/mobile/src/session/mobile-diff-comments.ts +++ b/mobile/src/session/mobile-diff-comments.ts @@ -1,4 +1,4 @@ -import type { DiffComment, DiffReviewScope } from '../../../src/shared/types' +import type { DiffComment, DiffReviewScope } from '../../../src/shared/diff-comment-types' import { formatDiffComment, formatDiffComments } from '../../../src/shared/diff-comments-format' export { formatDiffComment, formatDiffComments } 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 { it('returns none for empty check lists', () => { diff --git a/mobile/src/tasks/github-check-summary.ts b/mobile/src/tasks/github-check-summary.ts index 8a69db32291..413c41c7c31 100644 --- a/mobile/src/tasks/github-check-summary.ts +++ b/mobile/src/tasks/github-check-summary.ts @@ -1,5 +1,5 @@ import { summarizeProviderChecks } from '../../../src/shared/provider-check-summary' -import type { ProviderCheckSummary } from '../../../src/shared/types' +import type { ProviderCheckSummary } from '../../../src/shared/github/pull-request-types' export type GitHubCheckLike = { status: string diff --git a/mobile/src/tasks/gitlab-check-summary.ts b/mobile/src/tasks/gitlab-check-summary.ts index b5834a1d8b9..cdb82d543cd 100644 --- a/mobile/src/tasks/gitlab-check-summary.ts +++ b/mobile/src/tasks/gitlab-check-summary.ts @@ -3,7 +3,7 @@ import { mapGitLabPipelineJobStatusToConclusion } from '../../../src/shared/gitlab-pipeline-checks' import { summarizeProviderChecks } from '../../../src/shared/provider-check-summary' -import type { ProviderCheckSummary } from '../../../src/shared/types' +import type { ProviderCheckSummary } from '../../../src/shared/github/pull-request-types' type GitLabPipelineJobLike = { status: string } diff --git a/mobile/src/tasks/mobile-agent-catalog.ts b/mobile/src/tasks/mobile-agent-catalog.ts index 0f6e0e370a3..c5da71ee272 100644 --- a/mobile/src/tasks/mobile-agent-catalog.ts +++ b/mobile/src/tasks/mobile-agent-catalog.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../src/shared/types' +import type { TuiAgent } from '../../../src/shared/tui-agent' import { MOBILE_TUI_AGENT_AUTO_PICK_ORDER, MOBILE_TUI_AGENT_FAVICON_DOMAINS, diff --git a/mobile/src/tasks/mobile-hosted-check-status.ts b/mobile/src/tasks/mobile-hosted-check-status.ts index c1b6076c969..141e830dec1 100644 --- a/mobile/src/tasks/mobile-hosted-check-status.ts +++ b/mobile/src/tasks/mobile-hosted-check-status.ts @@ -1,4 +1,7 @@ -import type { ProviderCheckSummary, PRMergeableState } from '../../../src/shared/types' +import type { + PRMergeableState, + ProviderCheckSummary +} from '../../../src/shared/github/pull-request-types' import { getProviderChecksLabel } from '../../../src/shared/provider-check-summary' export type MobileHostedReviewStatus = { diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts index 2da0184e0e7..598e762aa77 100644 --- a/mobile/src/tasks/mobile-tui-agents.ts +++ b/mobile/src/tasks/mobile-tui-agents.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../src/shared/types' +import type { TuiAgent } from '../../../src/shared/tui-agent' // Why: mobile tests run from the mobile package only, so runtime imports of // desktop shared modules can break Vitest transforms in CI. Keep this list diff --git a/mobile/src/tasks/setup-hook-trust.test.ts b/mobile/src/tasks/setup-hook-trust.test.ts index 0e2d91bb7ac..81222ce16ee 100644 --- a/mobile/src/tasks/setup-hook-trust.test.ts +++ b/mobile/src/tasks/setup-hook-trust.test.ts @@ -6,7 +6,7 @@ import { trustedOrcaHooksWithSetupApproval, wasSetupHookPreviouslyApproved } from './setup-hook-trust' -import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types' import type { RpcClient } from '../transport/rpc-client' describe('setup hook trust', () => { diff --git a/mobile/src/tasks/setup-hook-trust.ts b/mobile/src/tasks/setup-hook-trust.ts index 04c52152f45..72381393989 100644 --- a/mobile/src/tasks/setup-hook-trust.ts +++ b/mobile/src/tasks/setup-hook-trust.ts @@ -1,4 +1,4 @@ -import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types' import type { RpcClient } from '../transport/rpc-client' export type SetupHookTrust = { diff --git a/mobile/src/tasks/smart-source-fan-out.ts b/mobile/src/tasks/smart-source-fan-out.ts index e2217597ab9..3050effb7fc 100644 --- a/mobile/src/tasks/smart-source-fan-out.ts +++ b/mobile/src/tasks/smart-source-fan-out.ts @@ -1,9 +1,7 @@ -import type { - BaseRefSearchResult, - GitHubWorkItem, - GitLabWorkItem, - LinearIssue -} from '../../../src/shared/types' +import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types' +import type { GitLabWorkItem } from '../../../src/shared/gitlab-types' +import type { LinearIssue } from '../../../src/shared/linear/issue-types' +import type { BaseRefSearchResult } from '../../../src/shared/repo-types' import { isSmartWorkspaceSourceQueryWithinLimit, type SmartNameMode diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts index 534d36fd0f4..21afd4157de 100644 --- a/mobile/src/tasks/smart-source-paste-intent.ts +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -1,4 +1,5 @@ -import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types' +import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types' +import type { GitLabWorkItem } from '../../../src/shared/gitlab-types' import { normalizeGitHubLinkQuery, parseGitHubIssueOrPRLink, diff --git a/mobile/src/tasks/smart-source-search-requests.ts b/mobile/src/tasks/smart-source-search-requests.ts index 5875cd4d642..876e2ef1a20 100644 --- a/mobile/src/tasks/smart-source-search-requests.ts +++ b/mobile/src/tasks/smart-source-search-requests.ts @@ -1,9 +1,7 @@ -import type { - BaseRefSearchResult, - GitHubWorkItem, - GitLabWorkItem, - LinearIssue -} from '../../../src/shared/types' +import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types' +import type { GitLabWorkItem } from '../../../src/shared/gitlab-types' +import type { LinearIssue } from '../../../src/shared/linear/issue-types' +import type { BaseRefSearchResult } from '../../../src/shared/repo-types' import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' import { extractLinearIssueReadItems } from './linear-mobile-issue-read' diff --git a/mobile/src/tasks/use-mobile-composer-source.ts b/mobile/src/tasks/use-mobile-composer-source.ts index 6785e2fe30b..5d6c373d454 100644 --- a/mobile/src/tasks/use-mobile-composer-source.ts +++ b/mobile/src/tasks/use-mobile-composer-source.ts @@ -1,5 +1,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' -import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types' +import type { GitLabWorkItem } from '../../../src/shared/gitlab-types' +import type { LinearIssue } from '../../../src/shared/linear/issue-types' import { resolveComposerManualBranchNameChange } from '../../../src/shared/composer-branch-selection' import { resolveGitHubWorkItemIdentity } from '../../../src/shared/new-workspace/github-work-item-identity' import { getForkPushWarning } from '../../../src/shared/new-workspace/fork-push-warning' diff --git a/mobile/src/tasks/use-smart-workspace-source.ts b/mobile/src/tasks/use-smart-workspace-source.ts index 42edcd48e8a..86c6ca2204a 100644 --- a/mobile/src/tasks/use-smart-workspace-source.ts +++ b/mobile/src/tasks/use-smart-workspace-source.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types' +import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types' +import type { GitLabWorkItem } from '../../../src/shared/gitlab-types' import { buildSmartWorkspaceSourceRows, getSmartWorkspaceEmptyHint, diff --git a/mobile/src/tasks/workspace-agent-selection.ts b/mobile/src/tasks/workspace-agent-selection.ts index da406452882..24dc376a663 100644 --- a/mobile/src/tasks/workspace-agent-selection.ts +++ b/mobile/src/tasks/workspace-agent-selection.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../src/shared/types' +import type { TuiAgent } from '../../../src/shared/tui-agent' import { filterEnabledMobileTuiAgents, isMobileTuiAgent, diff --git a/mobile/src/tasks/workspace-create-params.ts b/mobile/src/tasks/workspace-create-params.ts index 546f15efe75..8693944230a 100644 --- a/mobile/src/tasks/workspace-create-params.ts +++ b/mobile/src/tasks/workspace-create-params.ts @@ -1,9 +1,9 @@ +import type { TuiAgent } from '../../../src/shared/tui-agent' import type { CreateSparseCheckoutRequest, - GitPushTarget, - SetupDecision, - TuiAgent -} from '../../../src/shared/types' + SetupDecision +} from '../../../src/shared/worktree/create-types' +import type { GitPushTarget } from '../../../src/shared/worktree/types' import { getWorkspaceSourceName } from '../../../src/shared/new-workspace/workspace-source' import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' import type { WorkspaceAgentChoice } from './workspace-agent-selection' diff --git a/mobile/src/tasks/worktree-create-capability.test.ts b/mobile/src/tasks/worktree-create-capability.test.ts index cd12cdbefa2..8b1e55efcae 100644 --- a/mobile/src/tasks/worktree-create-capability.test.ts +++ b/mobile/src/tasks/worktree-create-capability.test.ts @@ -18,7 +18,7 @@ function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): 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/terminal/quick-commands.test.ts b/mobile/src/terminal/quick-commands.test.ts index 964fbc9b4d2..584dd8f4812 100644 --- a/mobile/src/terminal/quick-commands.test.ts +++ b/mobile/src/terminal/quick-commands.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { TerminalQuickCommand } from '../../../src/shared/types' +import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types' import { buildMobileQuickCommandLaunch, getQuickCommandDisplayPreview, diff --git a/mobile/src/terminal/quick-commands.ts b/mobile/src/terminal/quick-commands.ts index d6e6b86caf3..625d2702bc7 100644 --- a/mobile/src/terminal/quick-commands.ts +++ b/mobile/src/terminal/quick-commands.ts @@ -1,4 +1,5 @@ -import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types' +import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types' +import type { TuiAgent } from '../../../src/shared/tui-agent' import { applyTerminalQuickCommandMutation, flattenTerminalQuickCommand, 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 3081d0fa8a1..ea5dfef717c 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -66,6 +66,23 @@ function deferred() { } describe('stable logical RPC client', () => { + 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 9f472c1cd84..1ee47fcda5a 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/mobile/src/worktree/mobile-workspace-statuses.ts b/mobile/src/worktree/mobile-workspace-statuses.ts index 2c1322d8258..1fedbb6ef73 100644 --- a/mobile/src/worktree/mobile-workspace-statuses.ts +++ b/mobile/src/worktree/mobile-workspace-statuses.ts @@ -1,4 +1,4 @@ -import type { WorkspaceStatusDefinition } from '../../../src/shared/types' +import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/types' export const DEFAULT_MOBILE_WORKSPACE_STATUS_ID = 'in-progress' diff --git a/mobile/src/worktree/use-workspace-sections.ts b/mobile/src/worktree/use-workspace-sections.ts index bc30bc3f91e..d28546b80bb 100644 --- a/mobile/src/worktree/use-workspace-sections.ts +++ b/mobile/src/worktree/use-workspace-sections.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import type { WorkspaceStatusDefinition } from '../../../src/shared/types' +import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/types' import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings' import { buildSections, diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index d798a7fe5fa..2ff06f4fee3 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -1,4 +1,4 @@ -import type { WorkspaceStatusDefinition } from '../../../src/shared/types' +import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/types' import { DEFAULT_MOBILE_WORKSPACE_STATUSES, coerceMobileWorkspaceStatuses, diff --git a/mobile/src/worktree/workspace-view-settings.ts b/mobile/src/worktree/workspace-view-settings.ts index 32a4ac73a63..a90b54590d9 100644 --- a/mobile/src/worktree/workspace-view-settings.ts +++ b/mobile/src/worktree/workspace-view-settings.ts @@ -3,7 +3,7 @@ // RPCs). Keeping these settings in the same global store is what lets a grouping // or filter change on the phone show up on desktop and vice-versa. -import type { WorkspaceStatusDefinition } from '../../../src/shared/types' +import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/types' import { coerceMobileWorkspaceStatuses } from './mobile-workspace-statuses' export type MobileGroupMode = 'none' | 'workspaceStatus' | 'repo' | 'prStatus' diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index fd2c7b20402..0f7f95bc17e 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -23,7 +23,10 @@ import { } from '../../shared/windows-batch-spawn' import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import type { RuntimeStatus } from '../../shared/runtime-types' -import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' +import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState +} from '../../shared/managed-account-types' import { type InteractiveLoginSession, withInteractiveLoginCleanup diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index c7f62461303..c42531364a8 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultPersistedState } from '../../shared/constants' -import type { PersistedState } from '../../shared/types' +import type { PersistedState } from '../../shared/persisted-state-types' const { applyAgentStatusHooksEnabledMock, diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 800493f53c5..7e21c4973a2 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -13,7 +13,8 @@ import { import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import { getDefaultPersistedState } from '../../shared/constants' import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' -import type { GlobalSettings, PersistedState } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { PersistedState } from '../../shared/persisted-state-types' import { applyAgentStatusHooksEnabled, getManagedAgentHookStatuses, diff --git a/src/cli/handlers/automations.ts b/src/cli/handlers/automations.ts index cbbe7517a06..ab592504c00 100644 --- a/src/cli/handlers/automations.ts +++ b/src/cli/handlers/automations.ts @@ -13,7 +13,8 @@ import { type TaskSourceContext, type WorkspaceRunContext } from '../../shared/task-source-context' -import type { ProjectHostSetup, TuiAgent } from '../../shared/types' +import type { ProjectHostSetup } from '../../shared/project-types' +import type { TuiAgent } from '../../shared/tui-agent' import { DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS diff --git a/src/cli/handlers/diagnostics.ts b/src/cli/handlers/diagnostics.ts index ca2ffc36855..340bd6afa16 100644 --- a/src/cli/handlers/diagnostics.ts +++ b/src/cli/handlers/diagnostics.ts @@ -1,4 +1,4 @@ -import type { MemorySnapshot } from '../../shared/types' +import type { MemorySnapshot } from '../../shared/process-stats-types' import type { CommandHandler } from '../dispatch' import { formatMemorySnapshot, printResult } from '../format' diff --git a/src/cli/handlers/project.ts b/src/cli/handlers/project.ts index cc5f32e2cd2..c3868001f4c 100644 --- a/src/cli/handlers/project.ts +++ b/src/cli/handlers/project.ts @@ -8,9 +8,9 @@ import type { ProjectHostSetupExistingFolderArgs, ProjectHostSetupResult, ProjectHostSetupUpdateArgs, - ProjectHostSetupUpdateResult, - RepoKind -} from '../../shared/types' + ProjectHostSetupUpdateResult +} from '../../shared/project-types' +import type { RepoKind } from '../../shared/repo-types' import type { CommandHandler } from '../dispatch' import { formatProjectHostSetupCreateResult, diff --git a/src/cli/handlers/vm.ts b/src/cli/handlers/vm.ts index e000ec15afe..1bffd99f00e 100644 --- a/src/cli/handlers/vm.ts +++ b/src/cli/handlers/vm.ts @@ -19,7 +19,7 @@ import { runEphemeralVmRecipeCleanup, runEphemeralVmRecipeStart } from '../../shared/ephemeral-vm-recipe-runner' -import type { OrcaVmRecipe } from '../../shared/types' +import type { OrcaVmRecipe } from '../../shared/orca-yaml-hook-types' export const VM_HANDLERS: Record = { 'vm recipe doctor': async ({ flags, cwd, json }) => { diff --git a/src/cli/project-format.ts b/src/cli/project-format.ts index d5569a0306b..82265411146 100644 --- a/src/cli/project-format.ts +++ b/src/cli/project-format.ts @@ -5,7 +5,7 @@ import type { ProjectHostSetupDeleteResult, ProjectHostSetupResult, ProjectHostSetupUpdateResult -} from '../shared/types' +} from '../shared/project-types' export function formatProjectList(result: { projects: Project[] }): string { if (result.projects.length === 0) { diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 086ea23eb43..996f708d76f 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -21,7 +21,9 @@ import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, - SKILL_INSTALL_RESULT_V2_CAPABILITY + SKILL_INSTALL_RESULT_V2_CAPABILITY, + WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' vi.mock('./launch', () => ({ @@ -66,7 +68,9 @@ describe('CLI remote WebSocket transport', () => { clientCapabilities: [ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, - SKILL_INSTALL_RESULT_V2_CAPABILITY + SKILL_INSTALL_RESULT_V2_CAPABILITY, + WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY ] }) ) diff --git a/src/cli/workspace-format.ts b/src/cli/workspace-format.ts index 613e6f1ee7e..01dd0dbabcf 100644 --- a/src/cli/workspace-format.ts +++ b/src/cli/workspace-format.ts @@ -10,7 +10,7 @@ import type { RuntimeWorktreePsResult, RuntimeWorktreeRecord } from '../shared/runtime-types' -import type { MemorySnapshot, WorktreeMemory } from '../shared/types' +import type { MemorySnapshot, WorktreeMemory } from '../shared/process-stats-types' export function formatMemorySnapshot(snapshot: MemorySnapshot): string { const topWorktrees = [...snapshot.worktrees].sort((a, b) => b.memory - a.memory).slice(0, 10) diff --git a/src/cli/worktree-project-target.ts b/src/cli/worktree-project-target.ts index 6626270655a..6f4541f3783 100644 --- a/src/cli/worktree-project-target.ts +++ b/src/cli/worktree-project-target.ts @@ -1,4 +1,4 @@ -import type { ProjectHostSetup } from '../shared/types' +import type { ProjectHostSetup } from '../shared/project-types' import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime-client' diff --git a/src/main/active-view-preference.ts b/src/main/active-view-preference.ts index 5bd78b14713..eb6098f8c1d 100644 --- a/src/main/active-view-preference.ts +++ b/src/main/active-view-preference.ts @@ -1,7 +1,7 @@ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { mkdir, rename, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' -import type { TopLevelView } from '../shared/types' +import type { TopLevelView } from '../shared/ui-chrome-types' import { isTopLevelView } from '../shared/top-level-view' const ACTIVE_VIEW_FILE_NAME = 'active-view.json' diff --git a/src/main/agent-hooks/first-work-branch-rename-test-harness.ts b/src/main/agent-hooks/first-work-branch-rename-test-harness.ts index 427e2539722..86276a038f5 100644 --- a/src/main/agent-hooks/first-work-branch-rename-test-harness.ts +++ b/src/main/agent-hooks/first-work-branch-rename-test-harness.ts @@ -1,5 +1,6 @@ import type { vi } from 'vitest' -import type { GlobalSettings, Repo } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' import type { FirstWorkBranchRenameDeps, diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 7690215eedb..cb1107a8622 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings, Repo } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' const { diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index ea2614a344a..45f78e75e8a 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -1,5 +1,6 @@ // On first agent work in a fresh workspace, replace the auto-generated creature branch (e.g. `you/Nautilus`) with a short work-derived name. -import type { GlobalSettings, Repo } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { parsePaneKey } from '../../shared/stable-pane-id' diff --git a/src/main/agent-hooks/first-work-folder-rename.test.ts b/src/main/agent-hooks/first-work-folder-rename.test.ts index e9a88921943..d8a8a54161e 100644 --- a/src/main/agent-hooks/first-work-folder-rename.test.ts +++ b/src/main/agent-hooks/first-work-folder-rename.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings, Repo } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' import { renameWorktreeFolderOnFirstWork, type FirstWorkFolderRenameDeps diff --git a/src/main/agent-hooks/first-work-folder-rename.ts b/src/main/agent-hooks/first-work-folder-rename.ts index 79b3f98493e..df1a46b53ee 100644 --- a/src/main/agent-hooks/first-work-folder-rename.ts +++ b/src/main/agent-hooks/first-work-folder-rename.ts @@ -5,7 +5,8 @@ // worktree identity so meta, tabs, and the live PTY session carry over. It is // best-effort and local-only — remote/Windows/locked/dest-taken all degrade to // "folder kept" without disturbing the rename that already succeeded. -import type { GlobalSettings, Repo } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, getRepoIdFromWorktreeId, 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/local-agent-cli-presence.ts b/src/main/agent-hooks/local-agent-cli-presence.ts index 256e6520456..5810362c27a 100644 --- a/src/main/agent-hooks/local-agent-cli-presence.ts +++ b/src/main/agent-hooks/local-agent-cli-presence.ts @@ -9,7 +9,7 @@ import { isSafeExecutableBasename } from '../../shared/managed-agent-command-token' import type { ManagedAgentHookTarget } from '../../shared/managed-agent-hook-targets' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' export type LocalCliPresenceState = 'found' | 'missing' | 'unknown' diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index e99f5659339..6f9f969668c 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -4,7 +4,7 @@ import { isManagedAgentHookTarget } from '../../shared/managed-agent-hook-targets' import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence' import { MANAGED_AGENT_HOOK_INSTALLERS, diff --git a/src/main/agent-hooks/managed-hook-detection-commands.ts b/src/main/agent-hooks/managed-hook-detection-commands.ts index b7358807801..2200d183c2e 100644 --- a/src/main/agent-hooks/managed-hook-detection-commands.ts +++ b/src/main/agent-hooks/managed-hook-detection-commands.ts @@ -5,7 +5,7 @@ import { } from '../../shared/managed-agent-command-token' import { MANAGED_AGENT_HOOK_TARGETS } from '../../shared/managed-agent-hook-targets' import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import type { TuiAgentDetectionCommand } from '../ipc/tui-agent-detection-commands' export type ManagedHookDetectionSettings = Partial< 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/restored-subagent-liveness-sweep.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.ts index e44a47f11d0..3402ab7bf5b 100644 --- a/src/main/agent-hooks/restored-subagent-liveness-sweep.ts +++ b/src/main/agent-hooks/restored-subagent-liveness-sweep.ts @@ -4,7 +4,8 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' -import type { FolderWorkspace, ProjectGroup } from '../../shared/types' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../shared/project-group-types' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { parseWorkspaceKey } from '../../shared/workspace-scope' 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 8a10080168c..f819414ad4b 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -76,7 +76,7 @@ import { type AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' -import type { LegacyPaneKeyAliasEntry } from '../../shared/types' +import type { LegacyPaneKeyAliasEntry } from '../../shared/persisted-state-types' import { getAgentResumeArgv, normalizeAgentProviderSession, @@ -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/ai-vault/remote-session-scanner-test-fixtures.ts b/src/main/ai-vault/remote-session-scanner-test-fixtures.ts index c3496590168..b113a298f84 100644 --- a/src/main/ai-vault/remote-session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/remote-session-scanner-test-fixtures.ts @@ -1,4 +1,4 @@ -import type { DirEntry } from '../../shared/types' +import type { DirEntry } from '../../shared/filesystem-entry-types' import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' // Shared by the remote AI Vault scanner suites: an in-memory filesystem whose diff --git a/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts index 297f4b78b06..accc60d008f 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts @@ -94,9 +94,7 @@ describe('OpenCode source discovery with a stalled WSL data directory', () => { // silent [] here reads as "no OpenCode sessions" on a clean scan. await expect(discoveries).resolves.toHaveLength(1) expect( - issues.some( - (issue) => issue.agent === 'opencode' && issue.path === `${WSL_HOME}/opencode` - ) + issues.some((issue) => issue.agent === 'opencode' && issue.path === `${WSL_HOME}/opencode`) ).toBe(true) } finally { restoreEnv('XDG_DATA_HOME', previousXdg) diff --git a/src/main/automations/headless-workspace-create.test.ts b/src/main/automations/headless-workspace-create.test.ts index b29496eb283..3e25ad87440 100644 --- a/src/main/automations/headless-workspace-create.test.ts +++ b/src/main/automations/headless-workspace-create.test.ts @@ -1,7 +1,7 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' import type { Automation } from '../../shared/automations-types' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import { buildHeadlessAutomationWorktreeCreateArgs } from './headless-workspace-create' const repoPath = path.join('tmp', 'orca') diff --git a/src/main/automations/headless-workspace-create.ts b/src/main/automations/headless-workspace-create.ts index 7e6e2f43e18..2047a105c44 100644 --- a/src/main/automations/headless-workspace-create.ts +++ b/src/main/automations/headless-workspace-create.ts @@ -1,6 +1,6 @@ import type { Automation, AutomationRun } from '../../shared/automations-types' import { buildAutomationWorkspaceProvenance } from '../../shared/automation-workspace-provenance' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import type { OrcaRuntimeService } from '../runtime/orca-runtime' type HeadlessAutomationRunForWorkspace = Pick diff --git a/src/main/automations/run-target-resolution.test.ts b/src/main/automations/run-target-resolution.test.ts index 6e48fb0aac0..73820e316c8 100644 --- a/src/main/automations/run-target-resolution.test.ts +++ b/src/main/automations/run-target-resolution.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import type { Store } from '../persistence' import type { Automation } from '../../shared/automations-types' import type { WorkspaceRunContext } from '../../shared/task-source-context' -import type { ProjectHostSetup, Repo } from '../../shared/types' +import type { ProjectHostSetup } from '../../shared/project-types' +import type { Repo } from '../../shared/repo-types' import { resolveAutomationRunTarget } from './run-target-resolution' function makeRepo(overrides: Partial = {}): Repo { diff --git a/src/main/automations/run-target-resolution.ts b/src/main/automations/run-target-resolution.ts index 1168bf8fb6a..fcd96007ea0 100644 --- a/src/main/automations/run-target-resolution.ts +++ b/src/main/automations/run-target-resolution.ts @@ -2,7 +2,8 @@ import type { Store } from '../persistence' import type { Automation } from '../../shared/automations-types' import { getAutomationLegacyRepoId } from '../../shared/automation-run-identity' import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' -import type { ProjectHostSetup, Repo } from '../../shared/types' +import type { ProjectHostSetup } from '../../shared/project-types' +import type { Repo } from '../../shared/repo-types' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' export type AutomationRunTargetResult = diff --git a/src/main/automations/service-precheck.test.ts b/src/main/automations/service-precheck.test.ts index 49122511184..a9f7d336b78 100644 --- a/src/main/automations/service-precheck.test.ts +++ b/src/main/automations/service-precheck.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import { AutomationService } from './service' const runAutomationPrecheckMock = vi.hoisted(() => vi.fn()) diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 1c9c314b41b..27f96400258 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import { toRuntimeExecutionHostId } from '../../shared/execution-host' import { AutomationService } from './service' diff --git a/src/main/automations/workspace-provenance.ts b/src/main/automations/workspace-provenance.ts index 44936edc888..8174ca11293 100644 --- a/src/main/automations/workspace-provenance.ts +++ b/src/main/automations/workspace-provenance.ts @@ -2,11 +2,11 @@ import { z } from 'zod' import type { Automation, AutomationRun } from '../../shared/automations-types' import { getAutomationRunRepoId } from '../../shared/automation-run-identity' import { buildAutomationWorkspaceProvenance } from '../../shared/automation-workspace-provenance' +import type { Repo } from '../../shared/repo-types' import type { AutomationWorkspaceProvenance, - AutomationWorkspaceProvenanceRequest, - Repo -} from '../../shared/types' + AutomationWorkspaceProvenanceRequest +} from '../../shared/worktree/types' import { beginAutomationDispatchTokenUse, finishAutomationDispatchTokenUse, diff --git a/src/main/azure-devops/pull-request-mappers.ts b/src/main/azure-devops/pull-request-mappers.ts index 062a99fa0b4..132f98260cf 100644 --- a/src/main/azure-devops/pull-request-mappers.ts +++ b/src/main/azure-devops/pull-request-mappers.ts @@ -1,4 +1,4 @@ -import type { CheckStatus, PRMergeableState } from '../../shared/types' +import type { CheckStatus, PRMergeableState } from '../../shared/github/pull-request-types' export type RawAzureDevOpsStatus = { state?: string | null diff --git a/src/main/bitbucket/client.ts b/src/main/bitbucket/client.ts index deb856a66d1..86c22aeb193 100644 --- a/src/main/bitbucket/client.ts +++ b/src/main/bitbucket/client.ts @@ -1,4 +1,4 @@ -import type { CheckStatus } from '../../shared/types' +import type { CheckStatus } from '../../shared/github/pull-request-types' import { deriveBitbucketBuildStatus, mapBitbucketPullRequest, diff --git a/src/main/bitbucket/pull-request-mappers.ts b/src/main/bitbucket/pull-request-mappers.ts index 4f68c320235..7b9c5a07e84 100644 --- a/src/main/bitbucket/pull-request-mappers.ts +++ b/src/main/bitbucket/pull-request-mappers.ts @@ -1,4 +1,4 @@ -import type { CheckStatus, PRMergeableState } from '../../shared/types' +import type { CheckStatus, PRMergeableState } from '../../shared/github/pull-request-types' export type RawBitbucketPullRequest = { id?: number diff --git a/src/main/browser/browser-backend.ts b/src/main/browser/browser-backend.ts index 697eb7c178f..939684e49b3 100644 --- a/src/main/browser/browser-backend.ts +++ b/src/main/browser/browser-backend.ts @@ -10,6 +10,7 @@ export type BrowserBackendCreateTab = { url: string worktreeId?: string profileId?: string + browserPageId?: string } export type BrowserBackend = { diff --git a/src/main/browser/browser-certificate-challenge.ts b/src/main/browser/browser-certificate-challenge.ts index 2e49685b5d3..94c1192a887 100644 --- a/src/main/browser/browser-certificate-challenge.ts +++ b/src/main/browser/browser-certificate-challenge.ts @@ -1,4 +1,4 @@ -import type { BrowserCertificateFailure } from '../../shared/types' +import type { BrowserCertificateFailure } from '../../shared/browser-workspace-types' import { SUPPORTED_CERTIFICATE_ERROR_CODE } from './browser-certificate-identity' export const CERTIFICATE_CHALLENGE_TTL_MS = 5 * 60_000 diff --git a/src/main/browser/browser-certificate-trust-controller.ts b/src/main/browser/browser-certificate-trust-controller.ts index 53d4810e169..9a2fad879ed 100644 --- a/src/main/browser/browser-certificate-trust-controller.ts +++ b/src/main/browser/browser-certificate-trust-controller.ts @@ -1,5 +1,8 @@ import { randomUUID } from 'node:crypto' -import type { BrowserCertificateFailure, BrowserCertificateProceedResult } from '../../shared/types' +import type { + BrowserCertificateFailure, + BrowserCertificateProceedResult +} from '../../shared/browser-workspace-types' import { isEligibleLocalCertificateHost, toSecureCertificateEndpoint 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 e841f3168d8..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 { @@ -72,7 +72,7 @@ import type { BrowserCookieImportResult, BrowserCookieImportSummary, BrowserSessionProfileSource -} from '../../shared/types' +} from '../../shared/browser-workspace-types' import { browserSessionRegistry } from './browser-session-registry' import { isGoogleSourceBoundCookie, @@ -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/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index fbcac378412..0b10e9e189c 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -47,11 +47,11 @@ import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mod import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' import type { - BrowserViewportOverride, BrowserCertificateFailure, BrowserLoadError, - BrowserSessionUserAgentMode -} from '../../shared/types' + BrowserSessionUserAgentMode, + BrowserViewportOverride +} from '../../shared/browser-workspace-types' import { type BrowserAnnotationViewportBridgeOptions, BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index b0b477a8ef8..79296a0cdbf 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -22,7 +22,7 @@ import type { BrowserSessionProfile, BrowserSessionProfileCreateOptions, BrowserSessionProfileScope -} from '../../shared/types' +} from '../../shared/browser-workspace-types' import { browserManager } from './browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' diff --git a/src/main/browser/browser-session-user-agent-mode.ts b/src/main/browser/browser-session-user-agent-mode.ts index 8fa8f71d1a9..94051d12ff7 100644 --- a/src/main/browser/browser-session-user-agent-mode.ts +++ b/src/main/browser/browser-session-user-agent-mode.ts @@ -1,6 +1,6 @@ import type { Session } from 'electron' -import type { BrowserSessionUserAgentMode } from '../../shared/types' +import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types' const userAgentModeBySession = new WeakMap() diff --git a/src/main/browser/offscreen-browser-backend.ts b/src/main/browser/offscreen-browser-backend.ts index 5eb6e9b8435..64e96ede29d 100644 --- a/src/main/browser/offscreen-browser-backend.ts +++ b/src/main/browser/offscreen-browser-backend.ts @@ -23,7 +23,10 @@ export class OffscreenBrowserBackend implements BrowserBackend { constructor(private readonly browserManager: BrowserManager) {} async createTab(params: BrowserBackendCreateTab): Promise<{ browserPageId: string }> { - const browserPageId = randomUUID() + const browserPageId = params.browserPageId ?? randomUUID() + if (this.windowsByPageId.has(browserPageId)) { + throw new Error(`Browser page ${browserPageId} already exists`) + } // Why: profiles map to Electron partitions; using the profile's partition // makes cookies/storage persist in the same SQLite DB the desktop path uses. const profile = params.profileId diff --git a/src/main/claude-accounts/claude-duplicate-account.test.ts b/src/main/claude-accounts/claude-duplicate-account.test.ts index 23b2fd2f994..99ab959689a 100644 --- a/src/main/claude-accounts/claude-duplicate-account.test.ts +++ b/src/main/claude-accounts/claude-duplicate-account.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { ClaudeManagedAccount } from '../../shared/types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import { findDuplicateClaudeAccount, type ClaudeAccountIdentityCandidate diff --git a/src/main/claude-accounts/claude-duplicate-account.ts b/src/main/claude-accounts/claude-duplicate-account.ts index 55e57165541..4546556e5ac 100644 --- a/src/main/claude-accounts/claude-duplicate-account.ts +++ b/src/main/claude-accounts/claude-duplicate-account.ts @@ -1,4 +1,4 @@ -import type { ClaudeManagedAccount } from '../../shared/types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import { getClaudeWslSelectionKey } from './runtime-selection' export type ClaudeAccountIdentityCandidate = { diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index 15ec69062f4..55fef514361 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -15,7 +15,8 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { getDefaultSettings } from '../../shared/constants' -import type { ClaudeManagedAccount, GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from './oauth-refresh' const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index b655d454ac3..4e808bbb55a 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process' import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { app } from 'electron' -import type { ClaudeManagedAccount } from '../../shared/types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import type { Store } from '../persistence' import { writeFileAtomically } from '../codex-accounts/fs-utils' import type { ClaudeEnvPatch } from './environment' diff --git a/src/main/claude-accounts/runtime-selection.test.ts b/src/main/claude-accounts/runtime-selection.test.ts index 997da720de1..b7f64aede99 100644 --- a/src/main/claude-accounts/runtime-selection.test.ts +++ b/src/main/claude-accounts/runtime-selection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { ClaudeManagedAccount, GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import { getSelectedClaudeAccountIdForTarget, pruneInvalidClaudeRuntimeSelection, diff --git a/src/main/claude-accounts/runtime-selection.ts b/src/main/claude-accounts/runtime-selection.ts index 8f3a3a4397b..eda5b271cb5 100644 --- a/src/main/claude-accounts/runtime-selection.ts +++ b/src/main/claude-accounts/runtime-selection.ts @@ -1,8 +1,8 @@ +import type { GlobalSettings } from '../../shared/global-settings-types' import type { ClaudeManagedAccount, - ClaudeManagedAccountRuntimeSelection, - GlobalSettings -} from '../../shared/types' + ClaudeManagedAccountRuntimeSelection +} from '../../shared/managed-account-types' export type ClaudeAccountSelectionTarget = { runtime?: 'host' | 'wsl' diff --git a/src/main/claude-accounts/service.test.ts b/src/main/claude-accounts/service.test.ts index 6572bae7fca..0bff847063b 100644 --- a/src/main/claude-accounts/service.test.ts +++ b/src/main/claude-accounts/service.test.ts @@ -6,7 +6,7 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSyn import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' -import type { ClaudeManagedAccount } from '../../shared/types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import { deleteActiveClaudeKeychainCredentialsStrict, readActiveClaudeKeychainCredentials, diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 853ad4a77f0..adbe1f4613d 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -9,7 +9,7 @@ import type { ClaudeManagedAccount, ClaudeManagedAccountSummary, ClaudeRateLimitAccountsState -} from '../../shared/types' +} from '../../shared/managed-account-types' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' import { resolveClaudeCommand } from '../codex-cli/command' 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/codex-accounts/codex-auth-identity.ts b/src/main/codex-accounts/codex-auth-identity.ts index 5c62dbfe272..2bad1469536 100644 --- a/src/main/codex-accounts/codex-auth-identity.ts +++ b/src/main/codex-accounts/codex-auth-identity.ts @@ -1,4 +1,4 @@ -import type { CodexManagedAccount } from '../../shared/types' +import type { CodexManagedAccount } from '../../shared/managed-account-types' type CodexAuthIdentity = { email: string | null diff --git a/src/main/codex-accounts/legacy-shared-auth-migration.test.ts b/src/main/codex-accounts/legacy-shared-auth-migration.test.ts index 353535fe3c9..8481ede1431 100644 --- a/src/main/codex-accounts/legacy-shared-auth-migration.test.ts +++ b/src/main/codex-accounts/legacy-shared-auth-migration.test.ts @@ -11,7 +11,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { CodexManagedAccount } from '../../shared/types' +import type { CodexManagedAccount } from '../../shared/managed-account-types' import type * as CodexAccountFs from './fs-utils' const writeFailure = vi.hoisted(() => ({ failNextAuthWrite: false })) diff --git a/src/main/codex-accounts/legacy-shared-auth-migration.ts b/src/main/codex-accounts/legacy-shared-auth-migration.ts index b21d858cf04..a9ecd4c2ea5 100644 --- a/src/main/codex-accounts/legacy-shared-auth-migration.ts +++ b/src/main/codex-accounts/legacy-shared-auth-migration.ts @@ -1,6 +1,6 @@ import { lstatSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import type { CodexManagedAccount } from '../../shared/types' +import type { CodexManagedAccount } from '../../shared/managed-account-types' import { writeFileAtomically } from './fs-utils' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' import { codexAuthMatchesManagedAccount, compareCodexAuthFreshness } from './codex-auth-identity' diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts index abd6a4881ee..d65a9344acc 100644 --- a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts +++ b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { CodexManagedAccount } from '../../shared/managed-account-types' import { readStoredCodexCredentialState, waitForManagedCodexAuthReady diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.ts b/src/main/codex-accounts/managed-codex-auth-readiness.ts index 5b7938ea30c..3aea6cbd526 100644 --- a/src/main/codex-accounts/managed-codex-auth-readiness.ts +++ b/src/main/codex-accounts/managed-codex-auth-readiness.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import type { CodexAccountSelectionTarget } from './runtime-selection' const AUTH_READY_TIMEOUT_MS = 1_500 diff --git a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts index d137a65632e..d771ab9cbfd 100644 --- a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts +++ b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts @@ -3,7 +3,8 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type * as NodeOs from 'node:os' -import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { CodexManagedAccount } from '../../shared/managed-account-types' const testState = { userData: '', home: '' } const previousEnv: Record = {} diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index 0efe90bb77c..be9901799ac 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { CodexManagedAccount } from '../../shared/managed-account-types' import type * as NodeOs from 'node:os' import { readHookTrustEntries } from '../codex/config-toml-trust' diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 639d690cb06..22f5be4d53a 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -16,7 +16,8 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { CodexManagedAccount } from '../../shared/managed-account-types' import type * as ShellStartupEnv from '../pty/shell-startup-env' const testState = { diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 2d4b8239aad..195e2a719df 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -28,7 +28,7 @@ import { win32 as pathWin32 } from 'node:path' import { app } from 'electron' -import type { CodexManagedAccount } from '../../shared/types' +import type { CodexManagedAccount } from '../../shared/managed-account-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { Store } from '../persistence' import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' diff --git a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts index 337aaaf5375..a299d32ee98 100644 --- a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts +++ b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { CodexRuntimeHomeService } from './runtime-home-service' const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') diff --git a/src/main/codex-accounts/runtime-selection.test.ts b/src/main/codex-accounts/runtime-selection.test.ts index df98d20242b..bd007fdc3b4 100644 --- a/src/main/codex-accounts/runtime-selection.test.ts +++ b/src/main/codex-accounts/runtime-selection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { CodexManagedAccount } from '../../shared/managed-account-types' import { getSelectedCodexAccountIdForTarget, pruneInvalidCodexRuntimeSelection, diff --git a/src/main/codex-accounts/runtime-selection.ts b/src/main/codex-accounts/runtime-selection.ts index 5ac9f91b013..c60dbdb4c9d 100644 --- a/src/main/codex-accounts/runtime-selection.ts +++ b/src/main/codex-accounts/runtime-selection.ts @@ -1,8 +1,8 @@ +import type { GlobalSettings } from '../../shared/global-settings-types' import type { CodexManagedAccount, - CodexManagedAccountRuntimeSelection, - GlobalSettings -} from '../../shared/types' + CodexManagedAccountRuntimeSelection +} from '../../shared/managed-account-types' // Why: the renderer's switch-time lane guard has to key panes the same way a // launch does, so the lane vocabulary lives in shared rather than in main. import { diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 789b864159b..d9a1c1125ff 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -14,7 +14,8 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' -import type { CodexRateLimitAccountsState, GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { CodexRateLimitAccountsState } from '../../shared/managed-account-types' import type { ProviderRateLimits, RateLimitState } from '../../shared/rate-limit-types' import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' import type { CodexResetCreditAttemptLedger } from '../../shared/codex-reset-credit-attempt-ledger' diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index eef834af323..54eaacef7ef 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -11,7 +11,7 @@ import type { CodexManagedAccountSummary, CodexRateLimitAccountsState, CodexSystemDefaultIdentity -} from '../../shared/types' +} from '../../shared/managed-account-types' import type { CodexRateLimitResetOutcome, CodexRateLimitResetResult, diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index 51a9a47508d..456b9bc08be 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { CodexManagedAccount } from '../../shared/managed-account-types' import { resolveCodexPaneLaunchAccount } from './codex-pane-launch-account' const SYSTEM_HOME = '/Users/example/.codex' diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index 1bb3cca9c28..b99700da081 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { getCodexSelectionLaneKey, diff --git a/src/main/codex/codex-session-source-home.ts b/src/main/codex/codex-session-source-home.ts index 0060051e924..959cb7ab468 100644 --- a/src/main/codex/codex-session-source-home.ts +++ b/src/main/codex/codex-session-source-home.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' /** * Resolves the user-configured Codex *session history* source home, if any. diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index ebc953a4be7..bf2f7e5b5ea 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { _internals, forgetCodexPaneAccount, diff --git a/src/main/codex/codex-stale-pane-accounts.ts b/src/main/codex/codex-stale-pane-accounts.ts index b74529abc29..e3c63697cb9 100644 --- a/src/main/codex/codex-stale-pane-accounts.ts +++ b/src/main/codex/codex-stale-pane-accounts.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { getSelectedCodexAccountIdForTarget } from '../codex-accounts/runtime-selection' import { forgetCodexPaneAccount, 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-create-or-attach-result.ts b/src/main/daemon/daemon-create-or-attach-result.ts index d7a2ac64ba8..d6668342487 100644 --- a/src/main/daemon/daemon-create-or-attach-result.ts +++ b/src/main/daemon/daemon-create-or-attach-result.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../shared/types' +import type { TuiAgent } from '../../shared/tui-agent' import type { ShellReadyState, TerminalSnapshot } from './types' import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' 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 04982f9643c..4d47720e159 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, @@ -1670,6 +1774,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() @@ -1780,6 +1887,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() @@ -2071,25 +2181,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() } } @@ -2115,6 +2243,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) { @@ -2157,22 +2296,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) + } + } ) } @@ -2690,6 +2852,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 a6eade9f621..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, @@ -69,7 +72,7 @@ import { parsePtySessionId } from './pty-session-id' import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths' import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' -import type { TuiAgent } from '../../shared/types' +import type { TuiAgent } from '../../shared/tui-agent' import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables @@ -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.test.ts b/src/main/daemon/session.test.ts index c0c2a119618..2da1b700ec1 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SESSION_FORCE_KILL_RETRY_MS, Session } from './session' import { HeadlessEmulator } from './headless-emulator' import type { SessionState, ShellReadyState } from './types' -import type { TuiAgent } from '../../shared/types' +import type { TuiAgent } from '../../shared/tui-agent' const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) vi.mock('../pty-descendant-termination', () => ({ diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 840ea36449b..f25a6fc5377 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -19,7 +19,7 @@ import { } from '../shell-prompt-readiness-probe' import { isPowerShellProcess } from '../../shared/shell-process-detection' import { killWithDescendantSweep } from '../pty-descendant-termination' -import type { TuiAgent } from '../../shared/types' +import type { TuiAgent } from '../../shared/tui-agent' import { randomUUID } from 'node:crypto' import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { @@ -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-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 114aae91ad2..dce8d052cef 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -1,5 +1,5 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' -import type { TuiAgent } from '../../shared/types' +import type { TuiAgent } from '../../shared/tui-agent' import type { ShellReadyState, TerminalSnapshot } from './types' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' import type { diff --git a/src/main/daemon/terminal-host-options.ts b/src/main/daemon/terminal-host-options.ts index b483914bba3..241f333e815 100644 --- a/src/main/daemon/terminal-host-options.ts +++ b/src/main/daemon/terminal-host-options.ts @@ -1,5 +1,5 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' -import type { TuiAgent } from '../../shared/types' +import type { TuiAgent } from '../../shared/tui-agent' import type { SubprocessHandle } from './session' import type { TakePendingOutputResult, TerminalSnapshot } from './types' diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index ea7302dcc6b..d324d080d42 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -6,7 +6,7 @@ import { type SubprocessHandle } from './session' import { TerminalHost } from './terminal-host' -import type { TuiAgent } from '../../shared/types' +import type { TuiAgent } from '../../shared/tui-agent' const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) vi.mock('../pty-descendant-termination', () => ({ 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/ephemeral-vm-recipe-runner.test.ts b/src/main/ephemeral-vm-recipe-runner.test.ts index f5c3f4fc9fa..239eebac97f 100644 --- a/src/main/ephemeral-vm-recipe-runner.test.ts +++ b/src/main/ephemeral-vm-recipe-runner.test.ts @@ -12,7 +12,7 @@ import { runEphemeralVmRecipeStart, runEphemeralVmRecipeSuspend } from './ephemeral-vm-recipe-runner' -import type { OrcaVmRecipe } from '../shared/types' +import type { OrcaVmRecipe } from '../shared/orca-yaml-hook-types' const tmpRoots: string[] = [] diff --git a/src/main/ephemeral-vm-runtime-service.test.ts b/src/main/ephemeral-vm-runtime-service.test.ts index 80cc500b284..b3516d0e791 100644 --- a/src/main/ephemeral-vm-runtime-service.test.ts +++ b/src/main/ephemeral-vm-runtime-service.test.ts @@ -12,7 +12,7 @@ import { provisionEphemeralVmRuntime, resumeEphemeralVmRuntime } from './ephemeral-vm-runtime-service' -import type { OrcaVmRecipe } from '../shared/types' +import type { OrcaVmRecipe } from '../shared/orca-yaml-hook-types' const tempDirs: string[] = [] diff --git a/src/main/ephemeral-vm-runtime-service.ts b/src/main/ephemeral-vm-runtime-service.ts index ddbecb7cf6b..ed419b2f950 100644 --- a/src/main/ephemeral-vm-runtime-service.ts +++ b/src/main/ephemeral-vm-runtime-service.ts @@ -1,4 +1,4 @@ -import type { OrcaVmRecipe } from '../shared/types' +import type { OrcaVmRecipe } from '../shared/orca-yaml-hook-types' import { listEphemeralVmRuntimes, updateEphemeralVmRuntimeStatus, diff --git a/src/main/folder-workspace-diff-comments.ts b/src/main/folder-workspace-diff-comments.ts index 1a7435e8930..cc4481cbccf 100644 --- a/src/main/folder-workspace-diff-comments.ts +++ b/src/main/folder-workspace-diff-comments.ts @@ -1,4 +1,5 @@ -import type { DiffComment, FolderWorkspace } from '../shared/types' +import type { DiffComment } from '../shared/diff-comment-types' +import type { FolderWorkspace } from '../shared/folder-workspace-types' // Why shape-only: this replaces folder-workspaces.ts's verbatim `Array.isArray(raw.diffComments)` read. // Filtering members would make the fix itself a new deletion path for user-authored prose. diff --git a/src/main/ghostty/index.test.ts b/src/main/ghostty/index.test.ts index 08902b5375d..9247cb450c6 100644 --- a/src/main/ghostty/index.test.ts +++ b/src/main/ghostty/index.test.ts @@ -1,5 +1,5 @@ import type { Store } from '../persistence' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { afterEach, describe, expect, it, vi } from 'vitest' const { statMock, readFileMock } = vi.hoisted(() => ({ diff --git a/src/main/ghostty/index.ts b/src/main/ghostty/index.ts index 4827a29741d..e6e041aacfb 100644 --- a/src/main/ghostty/index.ts +++ b/src/main/ghostty/index.ts @@ -1,6 +1,6 @@ import { readFile, stat } from 'node:fs/promises' import { platform } from 'node:os' -import type { GlobalSettings, GhosttyImportPreview } from '../../shared/types' +import type { GhosttyImportPreview, GlobalSettings } from '../../shared/global-settings-types' import type { Store } from '../persistence' import { findGhosttyConfigPaths } from './discovery' import { parseGhosttyConfig } from './parser' diff --git a/src/main/ghostty/mapper.ts b/src/main/ghostty/mapper.ts index c3734cd04c5..ad8b8d050c8 100644 --- a/src/main/ghostty/mapper.ts +++ b/src/main/ghostty/mapper.ts @@ -1,8 +1,5 @@ -import type { - GlobalSettings, - TerminalColorOverrides, - GhosttyImportPreview -} from '../../shared/types' +import type { GhosttyImportPreview, GlobalSettings } from '../../shared/global-settings-types' +import type { TerminalColorOverrides } from '../../shared/terminal-color-overrides' import { HEX_COLOR_RE } from '../../shared/color-validation' import { parsePaddingValue, parseStrictInt } from './numeric-config-values' diff --git a/src/main/ghostty/theme-import.test.ts b/src/main/ghostty/theme-import.test.ts index c2d2ec61ab9..dff669ee7b1 100644 --- a/src/main/ghostty/theme-import.test.ts +++ b/src/main/ghostty/theme-import.test.ts @@ -1,5 +1,5 @@ import type { Store } from '../persistence' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { statMock, readFileMock } = vi.hoisted(() => ({ diff --git a/src/main/git/push-target-validation.ts b/src/main/git/push-target-validation.ts index bfb85cc4a27..8e3375c100f 100644 --- a/src/main/git/push-target-validation.ts +++ b/src/main/git/push-target-validation.ts @@ -1,4 +1,4 @@ -import type { GitPushTarget } from '../../shared/types' +import type { GitPushTarget } from '../../shared/worktree/types' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' import { gitExecFileAsync } from './runner' diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index 5c3f40cafe0..687102b1184 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -5,7 +5,7 @@ import { import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream' import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name' import { resolveGitRemoteRebaseSource } from '../../shared/git-rebase-source' -import type { GitPushTarget } from '../../shared/types' +import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { validateGitPushTarget } from './push-target-validation' diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 26291ec5d39..50d8a278be2 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' import { gitExecFileSync, gitExecFileAsync } from './runner' -import type { BaseRefSearchResult } from '../../shared/types' +import type { BaseRefSearchResult } from '../../shared/repo-types' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 31f364789de..378bbd56dc2 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -4,18 +4,20 @@ import { readFile, stat } from 'node:fs/promises' import * as path from 'node:path' import type { GitBranchChangeEntry, - GitBranchChangeStatus, GitBranchCompareResult, GitBranchCompareSummary, GitCommitCompareResult, + GitDiffResult +} from '../../shared/git-diff-compare-types' +import type { + GitBranchChangeStatus, GitConflictKind, GitConflictOperation, - GitDiffResult, GitFileStatus, GitStatusEntry, GitStatusResult, GitUpstreamStatus -} from '../../shared/types' +} from '../../shared/git-status-types' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import { getEffectiveGitUpstreamStatus, diff --git a/src/main/git/upstream.ts b/src/main/git/upstream.ts index d36b5f0a53d..18c2037493f 100644 --- a/src/main/git/upstream.ts +++ b/src/main/git/upstream.ts @@ -1,4 +1,5 @@ -import type { GitPushTarget, GitUpstreamStatus } from '../../shared/types' +import type { GitUpstreamStatus } from '../../shared/git-status-types' +import type { GitPushTarget } from '../../shared/worktree/types' import { upstreamOnlyCommitsArePatchEquivalent } from '../../shared/git-upstream-status' import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' import { getEffectiveGitUpstreamStatus } from '../../shared/git-effective-upstream' diff --git a/src/main/git/worktree-shared-directories.ts b/src/main/git/worktree-shared-directories.ts index 6ad9e6096ed..d725e80c8f8 100644 --- a/src/main/git/worktree-shared-directories.ts +++ b/src/main/git/worktree-shared-directories.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import { checkIgnoredPaths } from './check-ignored-paths' import type { GitRuntimeOptions } from './git-runtime-options' import { loadHooks } from '../hooks' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' // Why: a fresh worktree has no node_modules/.cache, and copying them is slow and // duplicates disk; `orca.yaml` names the ones every worktree should share instead. diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 11439bff0ae..005be57351f 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -15,11 +15,11 @@ import { } from '../worktree-trash' import { parseWslPath } from '../wsl' import type { - GitWorktreeInfo, LocalBaseRefRefreshResult, - LocalBaseRefUpdateSuggestion, - RemoveWorktreeResult -} from '../../shared/types' + LocalBaseRefUpdateSuggestion +} from '../../shared/worktree/base-ref-drift-types' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree/submodule-removal' import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' diff --git a/src/main/gitea/pull-request-mappers.ts b/src/main/gitea/pull-request-mappers.ts index eee0bd5d946..9e877f14bb8 100644 --- a/src/main/gitea/pull-request-mappers.ts +++ b/src/main/gitea/pull-request-mappers.ts @@ -1,4 +1,4 @@ -import type { CheckStatus, PRMergeableState } from '../../shared/types' +import type { CheckStatus, PRMergeableState } from '../../shared/github/pull-request-types' export type RawGiteaPullRequest = { number?: number diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 2457c7f21f4..5391539d353 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -1,28 +1,31 @@ /* eslint-disable max-lines -- Why: co-locating all GitHub client functions keeps acquire/release and error handling consistent. */ +import type { ClassifiedError } from '../../shared/classified-error' import type { - ClassifiedError, - GitPushTarget, - IssueSourcePreference, - ListWorkItemsResult, - PRInfo, - PRConflictSummary, - PRRefreshOutcome, - PRMergeableState, - PRReviewDecision, - PRCheckDetail, - PRCheckRunDetails, - GitHubCommentResult, - GitHubReactionContent, - GitHubPRReviewCommentInput, - PRComment, - GitHubViewer, - GitHubWorkItem, - GitHubPullRequestStateUpdate, GitHubRerunPRChecksResult, + PRCheckDetail, + PRCheckRunDetails +} from '../../shared/github/check-types' +import type { + GitHubCommentResult, + GitHubPRReviewCommentInput, + GitHubReactionContent, + PRComment +} from '../../shared/github/comment-types' +import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' +import type { GitHubPRMergeMethod, GitHubPRMergeMethodSettings, - GitHubPRStack -} from '../../shared/types' + GitHubPRStack, + GitHubViewer, + PRConflictSummary, + PRInfo, + PRMergeableState, + PRReviewDecision +} from '../../shared/github/pull-request-types' +import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/work-item-types' +import type { GitHubPullRequestStateUpdate } from '../../shared/issue-mutation-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import type { GitPushTarget } from '../../shared/worktree/types' import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' import { normalizeHostedReviewBaseRef, diff --git a/src/main/github/comment-reactions.ts b/src/main/github/comment-reactions.ts index 1bb831f30ca..89ee5ee8d6b 100644 --- a/src/main/github/comment-reactions.ts +++ b/src/main/github/comment-reactions.ts @@ -1,4 +1,4 @@ -import type { GitHubReaction, GitHubReactionContent } from '../../shared/types' +import type { GitHubReaction, GitHubReactionContent } from '../../shared/github/comment-types' type GitHubGraphQLReactionContent = | 'THUMBS_UP' diff --git a/src/main/github/conflict-summary-cache.ts b/src/main/github/conflict-summary-cache.ts index 99a2715ce6d..0980bf2a8eb 100644 --- a/src/main/github/conflict-summary-cache.ts +++ b/src/main/github/conflict-summary-cache.ts @@ -1,4 +1,4 @@ -import type { PRConflictSummary } from '../../shared/types' +import type { PRConflictSummary } from '../../shared/github/pull-request-types' // Why 60s: the hottest coordinator cadences that re-derive a CONFLICTING PR // (10s mergeability-pending, 2.5s manual-pending) previously each ran a diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index a8c52c46085..b6d19ce1d2a 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -1,4 +1,4 @@ -import type { PRConflictSummary } from '../../shared/types' +import type { PRConflictSummary } from '../../shared/github/pull-request-types' import { isUnsupportedMergeTreeMergeBaseError, isUnsupportedMergeTreeWriteTreeError diff --git a/src/main/github/gh-error-classification.ts b/src/main/github/gh-error-classification.ts index 4d2453899ac..551365f2c9c 100644 --- a/src/main/github/gh-error-classification.ts +++ b/src/main/github/gh-error-classification.ts @@ -1,4 +1,4 @@ -import type { ClassifiedError } from '../../shared/types' +import type { ClassifiedError } from '../../shared/classified-error' // Why: gh CLI surfaces API errors as unstructured stderr. Map known // patterns to typed errors so callers can show user-friendly messages. diff --git a/src/main/github/github-api-repository.ts b/src/main/github/github-api-repository.ts index 24037c4f73c..d7fabcd4f5b 100644 --- a/src/main/github/github-api-repository.ts +++ b/src/main/github/github-api-repository.ts @@ -1,4 +1,5 @@ -import type { GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { IssueSourcePreference } from '../../shared/repo-types' import { githubRepoIdentityKey, isDefaultGitHubHost diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index 89f8bf7bbd7..f91975097fc 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -1,5 +1,5 @@ import { ghExecFileAsync } from '../git/runner' -import type { GitHubOwnerRepo } from '../../shared/types' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions diff --git a/src/main/github/github-owner-repo-selection.ts b/src/main/github/github-owner-repo-selection.ts index 2e7c6aa0f4c..39cd0d2da0e 100644 --- a/src/main/github/github-owner-repo-selection.ts +++ b/src/main/github/github-owner-repo-selection.ts @@ -1,4 +1,4 @@ -import type { IssueSourcePreference } from '../../shared/types' +import type { IssueSourcePreference } from '../../shared/repo-types' import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key' import { getOwnerRepoForRemote, diff --git a/src/main/github/github-pr-stack-async-merge.ts b/src/main/github/github-pr-stack-async-merge.ts index 1b3909af035..d5375a468ac 100644 --- a/src/main/github/github-pr-stack-async-merge.ts +++ b/src/main/github/github-pr-stack-async-merge.ts @@ -1,4 +1,4 @@ -import type { GitHubPRMergeMethod } from '../../shared/types' +import type { GitHubPRMergeMethod } from '../../shared/github/pull-request-types' import { ghExecFileAsync } from '../git/runner' import { acquire, release } from './gh-utils' import { diff --git a/src/main/github/github-pr-stack.test.ts b/src/main/github/github-pr-stack.test.ts index 17cd6475941..243fdc057ab 100644 --- a/src/main/github/github-pr-stack.test.ts +++ b/src/main/github/github-pr-stack.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { GitHubPRStack } from '../../shared/types' +import type { GitHubPRStack } from '../../shared/github/pull-request-types' const { ghExecFileAsyncMock, rateLimitGuardMock, noteRateLimitSpendMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), diff --git a/src/main/github/github-pr-stack.ts b/src/main/github/github-pr-stack.ts index a11cf597769..3dc65f6175a 100644 --- a/src/main/github/github-pr-stack.ts +++ b/src/main/github/github-pr-stack.ts @@ -5,7 +5,7 @@ import type { PRMergeableState, PRReviewDecision, PRState -} from '../../shared/types' +} from '../../shared/github/pull-request-types' import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key' import { ghExecFileAsync } from '../git/runner' import { diff --git a/src/main/github/github-remote-identity-parsing.ts b/src/main/github/github-remote-identity-parsing.ts index 02c21fb8983..32c640b8796 100644 --- a/src/main/github/github-remote-identity-parsing.ts +++ b/src/main/github/github-remote-identity-parsing.ts @@ -1,5 +1,5 @@ import { normalizeGitHubRemoteHost } from '../../shared/git-remote-host-alias' -import type { GitHubOwnerRepo } from '../../shared/types' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string } diff --git a/src/main/github/github-repository-identity.ts b/src/main/github/github-repository-identity.ts index e348fbc6459..b860c7b7d31 100644 --- a/src/main/github/github-repository-identity.ts +++ b/src/main/github/github-repository-identity.ts @@ -1,6 +1,6 @@ import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' import { readRemoteUrl } from '../git/remote-url-probe' -import type { GitHubOwnerRepo } from '../../shared/types' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { readLocalGitConfigSignature } from './local-git-config-signature' import { diff --git a/src/main/github/github-ssh-host-alias-resolution.ts b/src/main/github/github-ssh-host-alias-resolution.ts index a4988471977..82c0f8b3fb4 100644 --- a/src/main/github/github-ssh-host-alias-resolution.ts +++ b/src/main/github/github-ssh-host-alias-resolution.ts @@ -1,4 +1,4 @@ -import type { GitHubOwnerRepo } from '../../shared/types' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import { commandExecFileAsync } from '../git/runner' import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { parseWslPath } from '../wsl' diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index e2a4dd52677..40cf4e5fa26 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -2,17 +2,15 @@ comment operations keeps the shared acquire/release + error-classification pattern obvious. Each function is short; the file is long because the surface is broad. */ +import type { ClassifiedError } from '../../shared/classified-error' +import type { GitHubCommentResult, PRComment } from '../../shared/github/comment-types' +import type { GitHubAssignableUser, IssueInfo } from '../../shared/github/pull-request-types' import type { - ClassifiedError, - GitHubAssignableUser, GitHubCreateIssueFields, GitHubCreateIssueResult, - GitHubCommentResult, - GitHubIssueUpdate, - IssueInfo, - IssueSourcePreference, - PRComment -} from '../../shared/types' + GitHubIssueUpdate +} from '../../shared/issue-mutation-types' +import type { IssueSourcePreference } from '../../shared/repo-types' import { mapIssueInfo } from './mappers' import type { LocalGitExecOptions, OwnerRepo } from './gh-utils' import { diff --git a/src/main/github/mappers.ts b/src/main/github/mappers.ts index 11ce858cdfb..84fa2b4d43d 100644 --- a/src/main/github/mappers.ts +++ b/src/main/github/mappers.ts @@ -1,4 +1,5 @@ -import type { PRInfo, IssueInfo, CheckStatus, PRCheckDetail } from '../../shared/types' +import type { PRCheckDetail } from '../../shared/github/check-types' +import type { CheckStatus, IssueInfo, PRInfo } from '../../shared/github/pull-request-types' import { derivePRCheckStatusFromRollup } from '../../shared/pr-check-status' // ── REST API check-runs mapping ─────────────────────────────────────── diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 30fc44b3c66..aca21c7d146 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -1,7 +1,8 @@ /* eslint-disable max-lines -- Why: coordinator tests cover queueing, coalescing, request timestamps, and follow-up scheduling against shared module state. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GitHubPRRefreshCandidate, PRInfo } from '../../shared/types' +import type { GitHubPRRefreshCandidate } from '../../shared/github/pull-request-refresh-types' +import type { PRInfo } from '../../shared/github/pull-request-types' import { isWslUncPath } from '../../shared/wsl-paths' const { diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 190cc82c6f9..2ff9d0e410b 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -7,7 +7,7 @@ import type { GitHubPRRefreshReason, GitHubPRRefreshSkippedReason, PRRefreshOutcome -} from '../../shared/types' +} from '../../shared/github/pull-request-refresh-types' import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' import { getOriginGitHubApiRepository } from './github-api-repository' import { ghRepoExecOptions, githubRepoContext } from './gh-utils' diff --git a/src/main/github/pr-refresh-error-classification.ts b/src/main/github/pr-refresh-error-classification.ts index 12c42d092b2..894ddc729fd 100644 --- a/src/main/github/pr-refresh-error-classification.ts +++ b/src/main/github/pr-refresh-error-classification.ts @@ -1,4 +1,4 @@ -import type { PRRefreshErrorType } from '../../shared/types' +import type { PRRefreshErrorType } from '../../shared/github/pull-request-refresh-types' import { classifyGitHubUnavailable } from '../../shared/github/api-availability' import { extractExecError } from '../git/exec-error' diff --git a/src/main/github/pr-start-point.ts b/src/main/github/pr-start-point.ts index 05c012c1ab6..118cbc474ef 100644 --- a/src/main/github/pr-start-point.ts +++ b/src/main/github/pr-start-point.ts @@ -1,4 +1,5 @@ -import type { GitHubPrStartPoint, GitPushTarget, IssueSourcePreference } from '../../shared/types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import type { GitHubPrStartPoint, GitPushTarget } from '../../shared/worktree/types' import { fetchCompareBaseRefWithLocalFallback } from '../git/compare-base-ref-fetch' import { isMissingRemoteRefGitError, diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index 5f6044debbf..efe27d452e2 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -18,7 +18,9 @@ import { } from './internals' import { classifyProjectError, rateLimitedError } from './project-error-classification' import { githubProjectHost } from '../../../shared/github/project-identity' -import type { GitHubAssignableUser, GitHubWorkItemDetails, PRComment } from '../../../shared/types' +import type { PRComment } from '../../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { GitHubWorkItemDetails } from '../../../shared/github/work-item-types' import type { AddIssueCommentBySlugArgs, ClearProjectItemFieldArgs, diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts index 4a48823164d..f75d2810321 100644 --- a/src/main/github/rate-limit.ts +++ b/src/main/github/rate-limit.ts @@ -8,7 +8,7 @@ import type { GetRateLimitResult, GitHubRateLimitBucket, GitHubRateLimitSnapshot -} from '../../shared/types' +} from '../../shared/github/rate-limit-types' import { isDefaultGitHubHost } from '../../shared/github/repository-identity-key' import { isWslUncPath } from '../../shared/wsl-paths' import { acquire, release } from './gh-utils' diff --git a/src/main/github/review-head-remote.ts b/src/main/github/review-head-remote.ts index 2a0dd53ca7f..e408d626ad2 100644 --- a/src/main/github/review-head-remote.ts +++ b/src/main/github/review-head-remote.ts @@ -1,4 +1,4 @@ -import type { IssueSourcePreference } from '../../shared/types' +import type { IssueSourcePreference } from '../../shared/repo-types' import { pickPreferredGitRemote } from '../../shared/preferred-git-remote' import { getDefaultRemote } from '../git/repo' import { getGitHubApiRepositoryForRemote } from './github-api-repository' diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 2df6d9200e9..276ee9d33eb 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -1,17 +1,18 @@ /* eslint-disable max-lines -- Why: groups the PR/Issue fetch paths and file-contents resolver so caching/rate-limit strategy lives in one place. */ +import type { PRCheckDetail } from '../../shared/github/check-types' +import type { + GitHubIssueTimelineItem, + GitHubIssueTimelineTarget, + PRComment +} from '../../shared/github/comment-types' import type { GitHubAssignableUser, GitHubPRFile, GitHubPRFileContents, - GitHubPRFileViewedState, - GitHubIssueTimelineItem, - GitHubIssueTimelineTarget, - GitHubWorkItem, - GitHubWorkItemDetails, - IssueSourcePreference, - PRCheckDetail, - PRComment -} from '../../shared/types' + GitHubPRFileViewedState +} from '../../shared/github/pull-request-types' +import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../shared/github/work-item-types' +import type { IssueSourcePreference } from '../../shared/repo-types' import { ghExecFileAsync, acquire, diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 138b2cac93d..9e7d03c01b2 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -1,26 +1,26 @@ /* eslint-disable max-lines -- co-locates GitLab MR/issue/work-item operations sharing one acquire/release pattern. */ +import type { ClassifiedError } from '../../shared/classified-error' import type { - ClassifiedError, + GetGitLabRateLimitResult, GitLabAssignableUser, GitLabAuthDiagnostic, GitLabDiscussionResolveResult, GitLabJobTraceResult, + GitLabMRInlineCommentInput, + GitLabMRReviewersUpdateResult, GitLabPagedResult, GitLabPipelineJob, GitLabRateLimitSnapshot, - GitLabMRInlineCommentInput, - GitLabMRReviewersUpdateResult, GitLabRetryJobResult, GitLabTodo, GitLabViewer, GitLabWorkItem, - GetGitLabRateLimitResult, - IssueSourcePreference, ListMergeRequestsResult, MRComment, MRInfo, MRListState -} from '../../shared/types' +} from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } from './mappers' import { acquire, diff --git a/src/main/gitlab/gitlab-preload-args.ts b/src/main/gitlab/gitlab-preload-args.ts index be59907b41f..483f02f9c11 100644 --- a/src/main/gitlab/gitlab-preload-args.ts +++ b/src/main/gitlab/gitlab-preload-args.ts @@ -1,4 +1,4 @@ -import type { MRListState } from '../../shared/types' +import type { MRListState } from '../../shared/gitlab-types' export type GitLabIssueListState = 'opened' | 'closed' | 'all' diff --git a/src/main/gitlab/gitlab-project-recents.ts b/src/main/gitlab/gitlab-project-recents.ts index 7d81f2f3667..44a919262e6 100644 --- a/src/main/gitlab/gitlab-project-recents.ts +++ b/src/main/gitlab/gitlab-project-recents.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { computeNextGitLabRecents } from '../../shared/gitlab-projects' export type GitLabProjectRecentsStore = { diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index ee7203ce668..96186f1ccf2 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -2,7 +2,7 @@ import { glabExecFileAsync } from '../git/runner' import { isTransientGitProbeError, readRemoteUrl } from '../git/remote-url-probe' import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' -import type { IssueSourcePreference } from '../../shared/types' +import type { IssueSourcePreference } from '../../shared/repo-types' import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' import { _resetGlabUnauthenticatedHosts, diff --git a/src/main/gitlab/glab-error-classification.ts b/src/main/gitlab/glab-error-classification.ts index 75af66ca4ea..1c8eb6212b8 100644 --- a/src/main/gitlab/glab-error-classification.ts +++ b/src/main/gitlab/glab-error-classification.ts @@ -1,4 +1,4 @@ -import type { ClassifiedError } from '../../shared/types' +import type { ClassifiedError } from '../../shared/classified-error' import { GlabNonListResponseError } from './glab-api-response' // Why: glab CLI surfaces API errors as unstructured stderr. Map known diff --git a/src/main/gitlab/issues.ts b/src/main/gitlab/issues.ts index 8635805337d..9bf57d6beb9 100644 --- a/src/main/gitlab/issues.ts +++ b/src/main/gitlab/issues.ts @@ -2,15 +2,15 @@ co-locating issue list/create/update/comment operations keeps the shared acquire/release + error-classification pattern obvious. Each function is short; the file is long because the surface is broad. */ +import type { ClassifiedError } from '../../shared/classified-error' import type { - ClassifiedError, GitLabAssignableUser, GitLabCommentResult, GitLabIssueInfo, GitLabIssueUpdate, - IssueSourcePreference, MRComment -} from '../../shared/types' +} from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' import { mapGitLabIssueInfo } from './mappers' // prettier-ignore import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListFetchError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, parseGlabJsonList, type LocalGitExecOptions, type ProjectRef } from './gl-utils' diff --git a/src/main/gitlab/mappers.ts b/src/main/gitlab/mappers.ts index cbc47eef6ae..645392ef2cb 100644 --- a/src/main/gitlab/mappers.ts +++ b/src/main/gitlab/mappers.ts @@ -1,10 +1,5 @@ -import type { - CheckStatus, - GitLabIssueInfo, - GitLabWorkItem, - MRInfo, - MRState -} from '../../shared/types' +import type { CheckStatus } from '../../shared/github/pull-request-types' +import type { GitLabIssueInfo, GitLabWorkItem, MRInfo, MRState } from '../../shared/gitlab-types' import { mapGitLabPipelineJobStatusToCheckStatus, mapGitLabPipelineJobStatusToConclusion diff --git a/src/main/gitlab/project-ref-parser.ts b/src/main/gitlab/project-ref-parser.ts index 9477cb8baa0..575e6093179 100644 --- a/src/main/gitlab/project-ref-parser.ts +++ b/src/main/gitlab/project-ref-parser.ts @@ -1,4 +1,4 @@ -import type { GitLabProjectRef } from '../../shared/types' +import type { GitLabProjectRef } from '../../shared/gitlab-types' export type ProjectRef = GitLabProjectRef diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts index 638a35dd847..3155727eeaf 100644 --- a/src/main/gitlab/work-item-details.ts +++ b/src/main/gitlab/work-item-details.ts @@ -10,9 +10,9 @@ import type { GitLabPipelineJob, GitLabWorkItem, GitLabWorkItemDetails, - IssueSourcePreference, MRComment -} from '../../shared/types' +} from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers' import { acquire, diff --git a/src/main/hooks-runner.test.ts b/src/main/hooks-runner.test.ts index 5ca5903d66f..61ba70d85ed 100644 --- a/src/main/hooks-runner.test.ts +++ b/src/main/hooks-runner.test.ts @@ -3,7 +3,7 @@ import type * as NodeFs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' import { describe, expect, it, vi } from 'vitest' diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 6cb9878f79c..0f8a1817e7a 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: hook parsing, shell selection, and execution-path regressions are tightly coupled, so these cases stay in one file to preserve the behavior matrix across platforms. */ -import type { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' import type * as GitRunner from './git/runner' import { join } from 'node:path' diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 21b56b8a597..cbbb2edddee 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -23,12 +23,14 @@ import { addWorktreeSetupWslInteropEnv } from './pty/wsl-orca-env' import type { HookCommandSourcePolicy, OrcaHooks, - Repo, - SetupDecision, - SetupRunPolicy, + SetupRunPolicy +} from '../shared/orca-yaml-hook-types' +import type { Repo } from '../shared/repo-types' +import type { SetupDecision } from '../shared/worktree/create-types' +import type { WorktreeDefaultTabsLaunch, WorktreeSetupLaunch -} from '../shared/types' +} from '../shared/worktree/launch-types' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { SetupRunnerShell } from '../shared/setup-runner-command' diff --git a/src/main/index.ts b/src/main/index.ts index 714d6dda4a9..1cc68b7860b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -111,7 +111,7 @@ import { resolveUpdateInstallMode } from './updater' import { configureRemoteServerUpdater } from './runtime/remote-server-updater' -import type { UpdateCheckOptions } from '../shared/types' +import type { UpdateCheckOptions } from '../shared/update-status-types' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { installServeSupervisorDisconnectQuit, diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index dfa7fa3d1eb..d976382c288 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -5,7 +5,8 @@ import { pathToFileURL } from 'node:url' import { app, BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from 'electron' import { is } from '@electron-toolkit/utils' import type { AppIdentity } from '../../shared/app-identity' -import type { FloatingTerminalCwdRequest, MarkdownDocument } from '../../shared/types' +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' +import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' import { relaunchApp } from '../app-relaunch' import type { Store } from '../persistence' import { getDevInstanceIdentity } from '../startup/dev-instance-identity' diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 3d2800040ea..88c6e140afa 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -23,13 +23,13 @@ import type { BrowserExtractHoverResult } from '../../shared/browser-grab-types' import type { - BrowserCookieImportResult, BrowserCertificateProceedResult, + BrowserCookieImportResult, BrowserSessionProfile, BrowserSessionProfileCreateOptions, BrowserSessionProfileScope, BrowserViewportOverride -} from '../../shared/types' +} from '../../shared/browser-workspace-types' import { isValidBrowserAnnotationViewportBridgeMarkers, isValidBrowserAnnotationViewportBridgeToken, diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index 0c111a5f6d8..f8c81fe0189 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -3,7 +3,7 @@ import type { CodexAccountAddTarget, CodexAccountService } from '../codex-accoun import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { listRecordedCodexPaneLanes } from '../codex/codex-pane-account-registry' import { forgetStaleCodexPanes, listStaleCodexPanes } from '../codex/codex-stale-pane-accounts' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' export function registerCodexAccountHandlers( codexAccounts: CodexAccountService, diff --git a/src/main/ipc/ephemeral-vm-recipe-context.ts b/src/main/ipc/ephemeral-vm-recipe-context.ts index c922d6f13b1..6fa591da9c1 100644 --- a/src/main/ipc/ephemeral-vm-recipe-context.ts +++ b/src/main/ipc/ephemeral-vm-recipe-context.ts @@ -4,7 +4,7 @@ import type { EphemeralVmRecipeDoctorResult } from '../../shared/ephemeral-vm-re import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store' import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes' import { isFolderRepo, isGitRepoKind } from '../../shared/repo-kind' -import type { OrcaVmRecipe } from '../../shared/types' +import type { OrcaVmRecipe } from '../../shared/orca-yaml-hook-types' export type EphemeralVmRecipeListResult = { status: 'ok' | 'error' diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index c6fa78b126d..396337a01c8 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -6,7 +6,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' import type * as RepoWorktrees from '../repo-worktrees' import { listRepoWorktrees } from '../repo-worktrees' -import type { FolderWorkspace, GitWorktreeInfo, ProjectGroup, Repo } from '../../shared/types' +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 { GitWorktreeInfo } from '../../shared/worktree/types' import { AUTHORIZED_EXTERNAL_PATHS_MAX, authorizeExternalPath, diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index b172c2c1894..fb93ac7f8d7 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -7,7 +7,9 @@ import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees' import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { getProjectGroupSubtreeIds } from '../../shared/project-groups' -import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../shared/project-group-types' +import type { Repo } from '../../shared/repo-types' export const PATH_ACCESS_DENIED_MESSAGE = 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.' diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts index e997ddd2e02..0bff6336f66 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -1,4 +1,4 @@ -import type { SearchOptions, SearchResult } from '../../shared/types' +import type { SearchOptions, SearchResult } from '../../shared/code-search-types' import { buildGitGrepArgs, buildSubmatchRegex, diff --git a/src/main/ipc/filesystem-watcher-large-batch.test.ts b/src/main/ipc/filesystem-watcher-large-batch.test.ts index e605cd6167c..cfb99c217c1 100644 --- a/src/main/ipc/filesystem-watcher-large-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-large-batch.test.ts @@ -32,7 +32,7 @@ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesyste import { stat } from 'node:fs/promises' import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' import type { Event as WatcherEvent } from '@parcel/watcher' -import type { FsChangedPayload } from '../../shared/types' +import type { FsChangedPayload } from '../../shared/filesystem-entry-types' import { WATCH_BATCH_MAX_WAIT_MS, WATCH_BATCH_TRAILING_MS diff --git a/src/main/ipc/filesystem-watcher-remote-batch.test.ts b/src/main/ipc/filesystem-watcher-remote-batch.test.ts index 8666a40c906..befc9a194e2 100644 --- a/src/main/ipc/filesystem-watcher-remote-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-batch.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { FsChangeEvent } from '../../shared/types' +import type { FsChangeEvent } from '../../shared/filesystem-entry-types' const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted( () => ({ diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 571f6c46b3a..0a337967386 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -3,7 +3,7 @@ import { ipcMain, type WebContents } from 'electron' import * as path from 'node:path' import { stat } from 'node:fs/promises' import type { Event as WatcherEvent } from '@parcel/watcher' -import type { FsChangeEvent, FsChangedPayload } from '../../shared/types' +import type { FsChangeEvent, FsChangedPayload } from '../../shared/filesystem-entry-types' import { isWindowsAbsolutePathLike, normalizeRuntimePathForComparison diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 44239bdac74..2a5111e5d13 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -9,25 +9,24 @@ import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync, wslAwareSpawn } import { parseWslPath, toWindowsWslPath } from '../wsl' import { tryDeleteWslUncPath } from '../wsl-unc-delete' import type { Store } from '../persistence' +import type { SearchOptions, SearchResult } from '../../shared/code-search-types' +import type { DirEntry, MarkdownDocument } from '../../shared/filesystem-entry-types' import type { - DirEntry, GitBranchCompareResult, GitCommitCompareResult, + GitDiffResult +} from '../../shared/git-diff-compare-types' +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitConflictOperation, - GitDiffResult, - GitForkSyncExpectedUpstream, - GitForkSyncResult, - GlobalSettings, GitStagingArea, - GitPushTarget, - GitUpstreamStatus, GitStatusResult, - MarkdownDocument, - SearchOptions, - SearchResult, - Repo, - TuiAgent -} from '../../shared/types' + GitUpstreamStatus +} from '../../shared/git-status-types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' +import type { TuiAgent } from '../../shared/tui-agent' +import type { GitPushTarget } from '../../shared/worktree/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { SshMutationExpectation } from '../../shared/ssh-types' import { sortDirEntries } from '../../shared/file-name-sort' diff --git a/src/main/ipc/floating-workspace-directory.test.ts b/src/main/ipc/floating-workspace-directory.test.ts index 335bf1e96bc..2b372586cf1 100644 --- a/src/main/ipc/floating-workspace-directory.test.ts +++ b/src/main/ipc/floating-workspace-directory.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, realpath, rm, symlink, unlink } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' const { appGetPathMock, authorizeExternalPathMock } = vi.hoisted(() => ({ appGetPathMock: vi.fn(), diff --git a/src/main/ipc/floating-workspace-directory.ts b/src/main/ipc/floating-workspace-directory.ts index bf1373e9f50..9e5f7d34cb7 100644 --- a/src/main/ipc/floating-workspace-directory.ts +++ b/src/main/ipc/floating-workspace-directory.ts @@ -2,7 +2,8 @@ import { constants as fsConstants } from 'node:fs' import { access, mkdir, realpath, stat } from 'node:fs/promises' import path from 'node:path' import { app } from 'electron' -import type { GlobalSettings, FloatingTerminalCwdRequest } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' import type { Store } from '../persistence' import { authorizeExternalPath } from './filesystem-auth' diff --git a/src/main/ipc/github-work-item-args.ts b/src/main/ipc/github-work-item-args.ts index 0aff921ee24..2497c8e93f9 100644 --- a/src/main/ipc/github-work-item-args.ts +++ b/src/main/ipc/github-work-item-args.ts @@ -1,5 +1,5 @@ import type { TaskSourceContext } from '../../shared/task-source-context' -import type { IssueSourcePreference } from '../../shared/types' +import type { IssueSourcePreference } from '../../shared/repo-types' export type WorkItemArgs = { repoPath: string diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index eda32115b19..36516790446 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -4,19 +4,20 @@ reviewable as one surface. Splitting by feature area would risk drifting validation/gate conventions across handler files. */ import { ipcMain } from 'electron' import { resolve } from 'node:path' +import type { GitHubReactionContent } from '../../shared/github/comment-types' import type { - Repo, - GitHubCreateIssueFields, - GitHubIssueUpdate, - GitHubOwnerRepo, - GitHubPullRequestStateUpdate, GitHubPRRefreshCandidate, GitHubPRRefreshEnqueueResult, GitHubPRRefreshReason, - GitHubReactionContent, - PRRefreshOutcome, - GitHubPRFile -} from '../../shared/types' + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' +import type { + GitHubCreateIssueFields, + GitHubIssueUpdate, + GitHubPullRequestStateUpdate +} from '../../shared/issue-mutation-types' +import type { Repo } from '../../shared/repo-types' import { getRepoExecutionHostId } from '../../shared/execution-host' import type { TaskSourceContext } from '../../shared/task-source-context' import type { Store } from '../persistence' diff --git a/src/main/ipc/gitlab.test.ts b/src/main/ipc/gitlab.test.ts index 2ff888a7bd8..87a298e56aa 100644 --- a/src/main/ipc/gitlab.test.ts +++ b/src/main/ipc/gitlab.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import { toSshExecutionHostId } from '../../shared/execution-host' const ORIGINAL_PLATFORM = process.platform diff --git a/src/main/ipc/gitlab.ts b/src/main/ipc/gitlab.ts index e68c4b8f6d8..cc668841038 100644 --- a/src/main/ipc/gitlab.ts +++ b/src/main/ipc/gitlab.ts @@ -8,9 +8,9 @@ import type { GitLabIssueUpdate, GitLabMRInlineCommentInput, GitLabMRUpdate, - GitLabWorkItem, - Repo -} from '../../shared/types' + GitLabWorkItem +} from '../../shared/gitlab-types' +import type { Repo } from '../../shared/repo-types' import { getRepoExecutionHostId } from '../../shared/execution-host' import type { TaskSourceContext } from '../../shared/task-source-context' import type { Store } from '../persistence' diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 736668e1f0d..119647d9957 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -6,7 +6,7 @@ import type { HostedReviewCreationEligibilityArgs, HostedReviewForBranchArgs } from '../../shared/hosted-review' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' import { diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index c8edc638a3b..b7faa6fd333 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -25,7 +25,7 @@ import type { JiraIssueFilter, JiraIssueUpdate, JiraSiteSelection -} from '../../shared/types' +} from '../../shared/jira-types' const VALID_FILTERS = new Set(['assigned', 'reported', 'all', 'done']) const issueSummaryRequests = new JiraCancellableRequests() diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index 0905c691f4c..dd64a777ab4 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -26,11 +26,9 @@ import { listTeams, getTeamStates, getTeamLabels, getTeamMembers } from '../line import type { LinearListFilter } from '../linear/issues' import { clampLinearIssueListLimit } from '../../shared/linear/issue-read-limits' import { optionalParsedLinearIssueAttributeFilter } from '../../shared/linear/issue-attribute-filter' -import type { - LinearCustomViewModel, - LinearIssueUpdate, - LinearWorkspaceSelection -} from '../../shared/types' +import type { LinearIssueUpdate } from '../../shared/issue-mutation-types' +import type { LinearCustomViewModel } from '../../shared/linear/project-types' +import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-types' const VALID_FILTERS = new Set(['assigned', 'created', 'all', 'completed']) diff --git a/src/main/ipc/local-worktree-runtime-options.ts b/src/main/ipc/local-worktree-runtime-options.ts index f9850a7a399..d0d1a67e3cd 100644 --- a/src/main/ipc/local-worktree-runtime-options.ts +++ b/src/main/ipc/local-worktree-runtime-options.ts @@ -5,7 +5,7 @@ import { type LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { splitWorktreeId } from '../../shared/worktree/id' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' function comparableLocalPath(value: string): string { const normalized = resolve(value) diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts index 713adc9e0fb..38466da6087 100644 --- a/src/main/ipc/markdown-documents.ts +++ b/src/main/ipc/markdown-documents.ts @@ -1,6 +1,6 @@ import { readdir } from 'node:fs/promises' import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path' -import type { MarkdownDocument } from '../../shared/types' +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' function normalizeRelativePath(path: string): string { return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') diff --git a/src/main/ipc/memory.ts b/src/main/ipc/memory.ts index 1c57d4d6fe3..f8bf96c1967 100644 --- a/src/main/ipc/memory.ts +++ b/src/main/ipc/memory.ts @@ -1,5 +1,5 @@ import { ipcMain } from 'electron' -import type { MemorySnapshot } from '../../shared/types' +import type { MemorySnapshot } from '../../shared/process-stats-types' import type { Store } from '../persistence' import { collectMemorySnapshot } from '../memory/collector' diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index 1213e8f1d92..a2553f05a3c 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -1,4 +1,4 @@ -import type { NotificationDispatchRequest } from '../../shared/types' +import type { NotificationDispatchRequest } from '../../shared/notification-settings-types' const NOTIFICATION_AGENT_LABEL_MAX_LENGTH = 40 const NOTIFICATION_TITLE_CONTEXT_MAX_LENGTH = 80 diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 031cdf63378..26d87f8f323 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -14,13 +14,13 @@ import twoToneSoundPath from '../../../resources/notification-sounds/two-tone.mp import type { Store } from '../persistence' import type { NotificationDeliveryProbeResult, + NotificationDismissResult, NotificationDispatchRequest, NotificationDispatchResult, - NotificationDismissResult, NotificationPermissionStatusResult, NotificationSettings, NotificationSoundDataResult -} from '../../shared/types' +} from '../../shared/notification-settings-types' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { buildNotificationOptions } from './notification-options' diff --git a/src/main/ipc/onboarding.ts b/src/main/ipc/onboarding.ts index c5449d6a80b..840f2fc695f 100644 --- a/src/main/ipc/onboarding.ts +++ b/src/main/ipc/onboarding.ts @@ -1,6 +1,6 @@ import { ipcMain } from 'electron' import { sanitizeOnboardingUpdate, type Store } from '../persistence' -import type { OnboardingState } from '../../shared/types' +import type { OnboardingState } from '../../shared/onboarding-state-types' export function registerOnboardingHandlers(store: Store): void { ipcMain.removeHandler('onboarding:get') diff --git a/src/main/ipc/pet-bundle.ts b/src/main/ipc/pet-bundle.ts index 1b701a7a105..6a07647cfae 100644 --- a/src/main/ipc/pet-bundle.ts +++ b/src/main/ipc/pet-bundle.ts @@ -1,4 +1,4 @@ -import type { SpriteAnimation } from '../../shared/types' +import type { SpriteAnimation } from '../../shared/pet-types' import { CODEX_PET_ANIMATIONS, CODEX_PET_DEFAULT_ANIMATION, diff --git a/src/main/ipc/pet.test.ts b/src/main/ipc/pet.test.ts index 5e857bb1244..64e5126eedd 100644 --- a/src/main/ipc/pet.test.ts +++ b/src/main/ipc/pet.test.ts @@ -39,7 +39,7 @@ vi.mock('electron', () => ({ })) import { registerPetHandlers } from './pet' -import type { CustomPet } from '../../shared/types' +import type { CustomPet } from '../../shared/pet-types' describe('registerPetHandlers', () => { let tempDir: string diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts index df48249f001..8b10079ba0e 100644 --- a/src/main/ipc/pet.ts +++ b/src/main/ipc/pet.ts @@ -6,7 +6,7 @@ import { pipeline } from 'node:stream/promises' import { randomUUID } from 'node:crypto' import { basename, dirname, extname, isAbsolute, join, normalize, resolve, sep } from 'node:path' import { z } from 'zod' -import type { CustomPet } from '../../shared/types' +import type { CustomPet } from '../../shared/pet-types' import { applyCodexPetDefaults, readWebpDimensionsFromBuffer, diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 5619495c1cf..dc125b7a9a5 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -1,5 +1,8 @@ import { ipcMain } from 'electron' -import type { PathSource, ShellHydrationFailureReason } from '../../shared/types' +import type { + PathSource, + ShellHydrationFailureReason +} from '../../shared/shell-path-hydration-types' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' import { getAzureDevOpsAuthStatus } from '../azure-devops/client' import { getBitbucketAuthStatus } from '../bitbucket/client' diff --git a/src/main/ipc/pty-hidden-delivery-gate.ts b/src/main/ipc/pty-hidden-delivery-gate.ts index 43fa8567b88..0b199d0d1ab 100644 --- a/src/main/ipc/pty-hidden-delivery-gate.ts +++ b/src/main/ipc/pty-hidden-delivery-gate.ts @@ -9,7 +9,7 @@ * bytes (dispatcher sidecars) registers delivery * interest, which suppresses the gate for that PTY. */ -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' export type HiddenPtyDeliveryGateSettings = Pick< GlobalSettings, diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 88306e95a0d..21eecad6fd5 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -11,15 +11,19 @@ import { import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession } from '../../shared/constants' -import type { TuiAgent } from '../../shared/types' +import type { TuiAgent } from '../../shared/tui-agent' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority' 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 680bc78b4ec..3356b6f7147 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -15,7 +15,8 @@ export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-rea import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { PtyBindingSourceExpectation, Store } from '../persistence' import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session-terminal-persistence-retirement' -import type { GlobalSettings, TuiAgent } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { TuiAgent } from '../../shared/tui-agent' import { toSshExecutionHostId } from '../../shared/execution-host' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' @@ -1863,7 +1864,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/remote-watcher-event-batch.test.ts b/src/main/ipc/remote-watcher-event-batch.test.ts index 19e946d4007..99f7afee014 100644 --- a/src/main/ipc/remote-watcher-event-batch.test.ts +++ b/src/main/ipc/remote-watcher-event-batch.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FsChangeEvent } from '../../shared/types' +import type { FsChangeEvent } from '../../shared/filesystem-entry-types' import { createRemoteWatcherEventBatch } from './remote-watcher-event-batch' const ROOT = '/home/u/repo' diff --git a/src/main/ipc/remote-watcher-event-batch.ts b/src/main/ipc/remote-watcher-event-batch.ts index 713c6068032..85e78d2045e 100644 --- a/src/main/ipc/remote-watcher-event-batch.ts +++ b/src/main/ipc/remote-watcher-event-batch.ts @@ -1,4 +1,4 @@ -import type { FsChangeEvent } from '../../shared/types' +import type { FsChangeEvent } from '../../shared/filesystem-entry-types' import { isWindowsAbsolutePathLike, normalizeRuntimePathForComparison diff --git a/src/main/ipc/remote-workspace-patch-queue.test.ts b/src/main/ipc/remote-workspace-patch-queue.test.ts index 1c97ad6b724..5ce4ac6b0b1 100644 --- a/src/main/ipc/remote-workspace-patch-queue.test.ts +++ b/src/main/ipc/remote-workspace-patch-queue.test.ts @@ -6,7 +6,7 @@ import type { RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' import type { SshTarget } from '../../shared/ssh-types' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' const { getActiveMultiplexerMock, diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index 6da587ac86c..38dc15c4af7 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -6,7 +6,7 @@ import type { RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' import type { SshTarget } from '../../shared/ssh-types' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' const { getActiveMultiplexerMock, diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 361f66bb21f..1e6e402e3bc 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -14,7 +14,7 @@ import type { RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' import type { SshTarget } from '../../shared/ssh-types' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index 121ff86b2e3..1e5df10f1a2 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import type { ExecutionHostId } from '../../shared/execution-host' -import type { PersistedUIState, WorkspaceSessionState } from '../../shared/types' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { Store } from '../persistence' type StageBeforeUnloadSyncArgs = { diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts index d6880848850..cdcaffd8357 100644 --- a/src/main/ipc/repos-add-linked-worktree.test.ts +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it, vi, beforeEach } from 'vitest' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' const { handleMock, 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-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts index 049db10c5e3..5b52fbca393 100644 --- a/src/main/ipc/repos-sparse-presets.test.ts +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as CryptoModule from 'node:crypto' -import type { SparsePreset } from '../../shared/types' +import type { SparsePreset } from '../../shared/worktree/create-types' const { handleMock, randomUUIDMock, mockStore } = vi.hoisted(() => ({ handleMock: vi.fn(), diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 97d31dc36bf..b16bc788b91 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -6,14 +6,14 @@ import { homedir } from 'node:os' import { z } from 'zod' import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { - BaseRefSearchResult, - Project, - Repo, + NestedRepoScanResult, ProjectGroup, - FolderWorkspace, - ProjectGroupImportResult, - ProjectUpdateArgs, + ProjectGroupImportResult +} from '../../shared/project-group-types' +import type { + Project, ProjectHostSetupCreateArgs, ProjectHostSetupCreateResult, ProjectHostSetupDeleteArgs, @@ -22,10 +22,10 @@ import type { ProjectHostSetupResult, ProjectHostSetupUpdateArgs, ProjectHostSetupUpdateResult, - NestedRepoScanResult, - BaseRefDefaultResult, - SparsePreset -} from '../../shared/types' + ProjectUpdateArgs +} from '../../shared/project-types' +import type { BaseRefDefaultResult, BaseRefSearchResult, Repo } from '../../shared/repo-types' +import type { SparsePreset } from '../../shared/worktree/create-types' import type { FolderWorkspacePathStatusRequest } from '../../shared/folder-workspace-path-status' import { isFolderRepo } from '../../shared/repo-kind' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' @@ -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 0a884aedfba..a05dc315bec 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -42,13 +42,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 fbd3e7630d9..dfa4dc7eba4 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' @@ -28,11 +28,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/session.ts b/src/main/ipc/session.ts index 98015aa64a7..7c70acb4f45 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -1,6 +1,9 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' -import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../shared/types' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../shared/workspace-session-state-types' export function registerSessionHandlers(store: Store): void { // Why: hostId is an optional second arg so an older renderer that invokes diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 98e918300ab..ddf9272240b 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron' import type { Store } from '../persistence' -import type { GlobalSettings, PersistedState } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { PersistedState } from '../../shared/persisted-state-types' import { listSystemFontFamilies } from '../system-fonts' import { previewGhosttyImport } from '../ghostty/index' import { previewWarpThemeImport } from '../warp-themes' diff --git a/src/main/ipc/source-control-ai-linked-issue.ts b/src/main/ipc/source-control-ai-linked-issue.ts index aa0696dd913..bd8a28d075b 100644 --- a/src/main/ipc/source-control-ai-linked-issue.ts +++ b/src/main/ipc/source-control-ai-linked-issue.ts @@ -1,7 +1,7 @@ import { resolve } from 'node:path' import type { Store } from '../persistence' import { isLinkedIssueNumber } from '../../shared/source-control-ai-action-variables' -import type { WorktreeMeta } from '../../shared/types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' export type LinkedIssueLookupArgs = { diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index f2a779ee412..c7270e50b03 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -2,7 +2,7 @@ import { ipcMain } from 'electron' import type { SshConnectionManager } from '../ssh/ssh-connection-manager' import type { SshExecOptions } from '../ssh/ssh-connection-utils' import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' -import type { FilesystemPathFlavor } from '../../shared/types' +import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' import { sortDirEntries } from '../../shared/file-name-sort' export type RemoteDirEntry = { diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts index 2645d98dff5..9b28270551a 100644 --- a/src/main/ipc/telemetry.test.ts +++ b/src/main/ipc/telemetry.test.ts @@ -1,7 +1,7 @@ // Telemetry IPC boundary tests: the renderer is in the threat model, so handlers drop malformed calls, cap consent mutations, and derive `via` main-side. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import type { Store } from '../persistence' const handlers = new Map unknown>() diff --git a/src/main/ipc/ui.ts b/src/main/ipc/ui.ts index 8d2abe92d84..0e967381160 100644 --- a/src/main/ipc/ui.ts +++ b/src/main/ipc/ui.ts @@ -1,6 +1,6 @@ import { BrowserWindow, ipcMain, webContents, type WebContents } from 'electron' import type { Store } from '../persistence' -import type { PersistedUIState } from '../../shared/types' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import { isFeatureInteractionId } from '../../shared/feature-interactions' let trustedUIRendererWebContentsId: number | null = null diff --git a/src/main/ipc/workspace-cleanup-activity.test.ts b/src/main/ipc/workspace-cleanup-activity.test.ts index bfb1c080db3..a0b68ccb683 100644 --- a/src/main/ipc/workspace-cleanup-activity.test.ts +++ b/src/main/ipc/workspace-cleanup-activity.test.ts @@ -1,6 +1,9 @@ +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, Worktree } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' +import type { Worktree } from '../../shared/worktree/types' import { resolveWorkspaceCleanupActivityWorktree } from './workspace-cleanup-activity' const REPO: Repo = { @@ -134,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 56ac36f6b29..0d7c44b602c 100644 --- a/src/main/ipc/workspace-cleanup-activity.ts +++ b/src/main/ipc/workspace-cleanup-activity.ts @@ -1,12 +1,17 @@ -import { lstat, readFile } from 'node:fs/promises' +import { lstat, open, readFile } from 'node:fs/promises' import path from 'node:path' -import type { Repo, Worktree } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' +import type { Worktree } from '../../shared/worktree/types' import { getPersistedWorkspaceCleanupActivityAt } from '../../shared/workspace-cleanup' 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) @@ -16,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 } @@ -34,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 @@ -96,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 e25af90964c..58c56316acc 100644 --- a/src/main/ipc/workspace-cleanup-candidate.ts +++ b/src/main/ipc/workspace-cleanup-candidate.ts @@ -1,6 +1,8 @@ import type { IGitProvider } from '../providers/types' import { isFolderRepo } from '../../shared/repo-kind' -import type { Repo, Worktree } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' +import type { Worktree } from '../../shared/worktree/types' +import { getWorktreeExecutionHostId } from '../../shared/execution-host' import { applyWorkspaceCleanupPolicy, createWorkspaceCleanupFingerprint, @@ -23,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) @@ -50,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 = { @@ -58,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, @@ -102,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 f7128d26827..e01484cb24a 100644 --- a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -1,12 +1,14 @@ import { basename } from 'node:path' +import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../shared/execution-host' import type { Store } from '../persistence' -import type { Repo, WorktreeMeta } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import { applyWorkspaceCleanupPolicy, createWorkspaceCleanupFingerprint, type WorkspaceCleanupCandidate } from '../../shared/workspace-cleanup' -import { splitWorktreeId } from '../../shared/worktree/id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { getNewestWorkspaceCleanupDiffCommentAt, getWorkspaceCleanupInactivityReasonsForWorkspace, @@ -17,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[] = [] @@ -37,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)) @@ -51,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({ @@ -59,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 04945327f98..a54829c7c25 100644 --- a/src/main/ipc/workspace-cleanup-git-evidence.ts +++ b/src/main/ipc/workspace-cleanup-git-evidence.ts @@ -1,10 +1,13 @@ import { getStatus } from '../git/status' import { gitExecFileAsync } from '../git/runner' import type { IGitProvider } from '../providers/types' -import type { GitStatusResult, Repo, Worktree } from '../../shared/types' +import type { GitStatusResult } from '../../shared/git-status-types' +import type { Repo } from '../../shared/repo-types' +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' @@ -30,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 @@ -47,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'] @@ -74,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) { @@ -94,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( @@ -108,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-local-git-routing.test.ts b/src/main/ipc/workspace-cleanup-local-git-routing.test.ts index 656b904d0cd..a10085fae77 100644 --- a/src/main/ipc/workspace-cleanup-local-git-routing.test.ts +++ b/src/main/ipc/workspace-cleanup-local-git-routing.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' const { listRepoWorktreesMock, getLocalProjectWorktreeGitOptionsMock } = vi.hoisted(() => ({ listRepoWorktreesMock: vi.fn(), 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 4f0a84c8a86..93af63562c2 100644 --- a/src/main/ipc/workspace-cleanup-scan-primitives.ts +++ b/src/main/ipc/workspace-cleanup-scan-primitives.ts @@ -1,9 +1,22 @@ import { basename } from 'node:path' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' 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 41f94a07814..da6f9821356 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -1,22 +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 { GitWorktreeInfo, Repo, Worktree } from '../../shared/types' +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, @@ -26,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 @@ -46,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( @@ -92,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 @@ -112,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) @@ -130,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) }) @@ -184,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 5d5538ca3df..b35167906c8 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -2,13 +2,11 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ipcMain } from 'electron' import type { Store } from '../persistence' -import type { - DiffComment, - GitStatusResult, - GitWorktreeInfo, - Repo, - WorktreeMeta -} from '../../shared/types' +import type { DiffComment } from '../../shared/diff-comment-types' +import type { GitStatusResult } from '../../shared/git-status-types' +import type { Repo } from '../../shared/repo-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup' const { @@ -19,7 +17,6 @@ const { gitExecFileAsyncMock, getLocalProjectWorktreeGitOptionsMock, getSshGitProviderMock, - getSshPtyProviderMock, listRegisteredPtysMock } = vi.hoisted(() => ({ lstatMock: vi.fn(), @@ -29,7 +26,6 @@ const { gitExecFileAsyncMock: vi.fn(), getLocalProjectWorktreeGitOptionsMock: vi.fn(), getSshGitProviderMock: vi.fn(), - getSshPtyProviderMock: vi.fn(), listRegisteredPtysMock: vi.fn() })) @@ -71,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 = { @@ -163,7 +165,6 @@ describe('workspace cleanup scan', () => { gitExecFileAsyncMock.mockReset() getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) getSshGitProviderMock.mockReset() - getSshPtyProviderMock.mockReset() listRegisteredPtysMock.mockReset() listRegisteredPtysMock.mockReturnValue([]) lstatMock.mockResolvedValue({ mtimeMs: 0 }) @@ -248,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']) ) @@ -831,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/worktree-base-directory-change-collector.ts b/src/main/ipc/worktree-base-directory-change-collector.ts index c77f12104ee..1f1f37dcedd 100644 --- a/src/main/ipc/worktree-base-directory-change-collector.ts +++ b/src/main/ipc/worktree-base-directory-change-collector.ts @@ -1,4 +1,4 @@ -import type { FsChangeEvent } from '../../shared/types' +import type { FsChangeEvent } from '../../shared/filesystem-entry-types' import { classifyWorktreeBaseChange, type WorktreeBaseWatchTarget diff --git a/src/main/ipc/worktree-base-directory-watch-targets.ts b/src/main/ipc/worktree-base-directory-watch-targets.ts index 1283333ee95..cfa825e09ab 100644 --- a/src/main/ipc/worktree-base-directory-watch-targets.ts +++ b/src/main/ipc/worktree-base-directory-watch-targets.ts @@ -3,7 +3,8 @@ import { realpath, stat } from 'node:fs/promises' import type { Stats } from 'node:fs' import type { Store } from '../persistence' import type { FileStat, IFilesystemProvider } from '../providers/types' -import type { GlobalSettings, Repo } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { isFolderRepo } from '../../shared/repo-kind' import { diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index d8f58ff8503..9ce0712c9a0 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { join, sep } from 'node:path' -import type { GlobalSettings, Repo } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' import type { WorktreeBasePollEvent, WorktreeBasePollerOptions diff --git a/src/main/ipc/worktree-common-git-directory.ts b/src/main/ipc/worktree-common-git-directory.ts index 7fb464f3c94..2a334055c17 100644 --- a/src/main/ipc/worktree-common-git-directory.ts +++ b/src/main/ipc/worktree-common-git-directory.ts @@ -1,6 +1,6 @@ import type { Stats } from 'node:fs' import { readFile, stat } from 'node:fs/promises' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import { getRuntimePathBasename, normalizeRuntimePathSeparators, diff --git a/src/main/ipc/worktree-folder-rename-target.ts b/src/main/ipc/worktree-folder-rename-target.ts index 9cdbe7188c2..d471d41b2c7 100644 --- a/src/main/ipc/worktree-folder-rename-target.ts +++ b/src/main/ipc/worktree-folder-rename-target.ts @@ -1,5 +1,5 @@ import { posix } from 'node:path' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' import { computeWorktreePath } from './worktree-logic' diff --git a/src/main/ipc/worktree-head-identity-reader.ts b/src/main/ipc/worktree-head-identity-reader.ts index e99de115480..0e44376c00b 100644 --- a/src/main/ipc/worktree-head-identity-reader.ts +++ b/src/main/ipc/worktree-head-identity-reader.ts @@ -1,6 +1,6 @@ import { readdir, readFile } from 'node:fs/promises' import { basename, dirname, isAbsolute, join } from 'node:path' -import type { WorktreeHeadIdentity } from '../../shared/types' +import type { WorktreeHeadIdentity } from '../../shared/worktree/types' // Why: the whole point of this reader is replacing `git worktree list` fanout // with bounded metadata-file reads, so head freshness never re-creates the diff --git a/src/main/ipc/worktree-linked-work-item-metadata.ts b/src/main/ipc/worktree-linked-work-item-metadata.ts index e0b4259a2fc..e07e78ddfcb 100644 --- a/src/main/ipc/worktree-linked-work-item-metadata.ts +++ b/src/main/ipc/worktree-linked-work-item-metadata.ts @@ -1,4 +1,5 @@ -import type { Worktree, WorktreeMeta } from '../../shared/types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { Worktree } from '../../shared/worktree/types' type LinkedWorkItemMetadata = Pick< Worktree, diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 45c6cbdff03..5e3878924f2 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -1,5 +1,6 @@ import { resolve, relative, isAbsolute, posix, sep, win32 } from 'node:path' -import type { GlobalSettings, OrcaWorkspaceLayout, Repo } from '../../shared/types' +import type { GlobalSettings, OrcaWorkspaceLayout } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' import { isWindowsAbsolutePathLike, resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree/id' diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts index 6b6accf724f..4fdfe07c72a 100644 --- a/src/main/ipc/worktree-metadata-merge.ts +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -1,5 +1,6 @@ import { basename } from 'node:path' -import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata' import { normalizeWorkspaceCreatorProvenance } from '../../shared/workspace-creator-provenance' diff --git a/src/main/ipc/worktree-push-target-cleanup.test.ts b/src/main/ipc/worktree-push-target-cleanup.test.ts index 24234aaa0ab..b24d98c39f6 100644 --- a/src/main/ipc/worktree-push-target-cleanup.test.ts +++ b/src/main/ipc/worktree-push-target-cleanup.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, type Mock } from 'vitest' -import type { GitPushTarget, WorktreeMeta } from '../../shared/types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitPushTarget } from '../../shared/worktree/types' import { cleanupUnusedWorktreePushTargetRemoteWithExec, sameGitHubRemoteUrl, diff --git a/src/main/ipc/worktree-push-target-cleanup.ts b/src/main/ipc/worktree-push-target-cleanup.ts index 0fd1faf10ad..2bda01c5a89 100644 --- a/src/main/ipc/worktree-push-target-cleanup.ts +++ b/src/main/ipc/worktree-push-target-cleanup.ts @@ -4,7 +4,7 @@ // the multi-fork cleanup matrix is unit-testable without a real repo. import type { Store } from '../persistence' -import type { GitPushTarget } from '../../shared/types' +import type { GitPushTarget } from '../../shared/worktree/types' import { parseGitHubOwnerRepo } from '../github/gh-utils' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' diff --git a/src/main/ipc/worktree-push-target-setup.test.ts b/src/main/ipc/worktree-push-target-setup.test.ts index 429c660e6e9..a93a22bb4be 100644 --- a/src/main/ipc/worktree-push-target-setup.test.ts +++ b/src/main/ipc/worktree-push-target-setup.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, type Mock } from 'vitest' -import type { GitPushTarget } from '../../shared/types' +import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRemoteExec } from './worktree-push-target-cleanup' import { configureCreatedWorktreePushTargetWithExec, diff --git a/src/main/ipc/worktree-push-target-setup.ts b/src/main/ipc/worktree-push-target-setup.ts index e6a99ab4443..4101055ba65 100644 --- a/src/main/ipc/worktree-push-target-setup.ts +++ b/src/main/ipc/worktree-push-target-setup.ts @@ -4,7 +4,7 @@ // remote-reuse / unique-naming / fetch behavior is unit-testable without a real // repo. The store-aware ownership decision stays with the caller via a predicate. -import type { GitPushTarget } from '../../shared/types' +import type { GitPushTarget } from '../../shared/worktree/types' import { parseGitHubOwnerRepo } from '../github/gh-utils' import type { GitRemoteExec } from './worktree-push-target-cleanup' diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 8d9e70481cd..d4d91dcd79b 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -6,21 +6,25 @@ import { posix, win32 } from 'node:path' import { existsSync } from 'node:fs' import { randomUUID } from 'node:crypto' import type { Store } from '../persistence' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' +import type { + LocalBaseRefRefreshResult, + LocalBaseRefUpdateSuggestion +} from '../../shared/worktree/base-ref-drift-types' +import type { + CreateWorktreeArgs, + CreateWorktreeResult, + WorktreeCreateBaseFallback +} from '../../shared/worktree/create-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { AutomationWorkspaceProvenance, CliWorkspaceProvenance, - CreateWorktreeArgs, - CreateWorktreeResult, GitPushTarget, - GlobalSettings, - LocalBaseRefRefreshResult, - LocalBaseRefUpdateSuggestion, - Repo, Worktree, - WorktreeCreateBaseFallback, - WorktreeHeadIdentity, - WorktreeMeta -} from '../../shared/types' + WorktreeHeadIdentity +} from '../../shared/worktree/types' import { getPRForBranch } from '../github/client' import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree' import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' 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 02f23bc094a..b9ea11b85c8 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -4,7 +4,9 @@ import type * as GitUsernameModule from '../git/git-username' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import type { CreateWorktreeResult, GitWorktreeInfo, Repo, Worktree } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' +import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' import * as localWorktreeFilesystem from '../local-worktree-filesystem' @@ -234,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, @@ -301,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(), @@ -406,6 +439,11 @@ describe('registerWorktreeHandlers', () => { getSshPtyProviderMock, deleteWorktreeHistoryDirMock, advertisedUrlWatcherForgetWorktreeMock, + pruneCleanupScanSnapshotMock, + pruneCleanupScanSnapshotsMock, + pruneSpaceAnalysisSnapshotMock, + pruneSpaceAnalysisSnapshotsMock, + recordRemovalSnapshotPruneMock, findExistingWorktreeSymlinkPathsMock, removeWorktreeLinkedPathsMock ]) { @@ -2707,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') @@ -2724,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, { @@ -8293,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 e56dd3cc13d..f85e0fad8ba 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -17,26 +17,36 @@ 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 { - AutomationWorkspaceProvenance, AdoptProvisionedRootArgs, - CliWorkspaceProvenance, CreateWorktreeArgs, CreateWorktreeResult, + ForceDeleteWorktreeBranchResult, + RemoveWorktreeResult +} from '../../shared/worktree/create-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { + AutomationWorkspaceProvenance, + CliWorkspaceProvenance, DetectedWorktree, DetectedWorktreeListResult, - ForceDeleteWorktreeBranchResult, GitHubPrStartPoint, GitPushTarget, GitWorktreeInfo, - OrcaHooks, - Repo, - RemoveWorktreeResult, - Worktree, - WorktreeLineage, - WorkspaceLineage, - WorktreeMeta -} from '../../shared/types' + Worktree +} from '../../shared/worktree/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' import { getRepoExecutionHostId, @@ -69,7 +79,7 @@ import { } from '../../shared/worktree/ownership' import { createWorktreeVisibilitySourceMatcher, - normalizeCustomWorktreeVisibilitySources + resolveCustomWorktreeVisibilitySources } from '../../shared/worktree/visibility-sources' import { assertWorktreeCleanForRemoval, @@ -161,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 @@ -325,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) { @@ -340,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( @@ -857,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}` @@ -1009,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({ @@ -1094,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) @@ -2071,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 } } ) @@ -2482,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 }) @@ -2594,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, @@ -2644,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, @@ -2681,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, @@ -2741,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 ?? {} @@ -2840,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 ?? {} @@ -2986,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, @@ -3023,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() @@ -3055,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) @@ -3117,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/jira/client.ts b/src/main/jira/client.ts index cd3497fb2be..f9c4e8943c0 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -20,7 +20,7 @@ import type { JiraSite, JiraSiteSelection, JiraViewer -} from '../../shared/types' +} from '../../shared/jira-types' import { clearAttachmentImagesForSite } from './attachment-image-cache' // Why: Atlassian's XSRF filter rejects POST/PUT REST calls that carry a browser diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index e2eade0924e..0629f6abfd3 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -20,7 +20,7 @@ import type { JiraStatus, JiraTransition, JiraUser -} from '../../shared/types' +} from '../../shared/jira-types' import { acquire, apiBasePath, diff --git a/src/main/kimi/kimi-runtime-home.test.ts b/src/main/kimi/kimi-runtime-home.test.ts index d56abb658d1..a1977e8d857 100644 --- a/src/main/kimi/kimi-runtime-home.test.ts +++ b/src/main/kimi/kimi-runtime-home.test.ts @@ -9,7 +9,7 @@ vi.mock('../wsl', () => wslMocks) vi.mock('node:os', () => ({ homedir: () => 'C:\\Users\\neil' })) import { getHostKimiHome, getKimiRuntimeTarget, resolveKimiHome } from './kimi-runtime-home' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' function settings(overrides: Partial): GlobalSettings { return overrides as GlobalSettings diff --git a/src/main/kimi/kimi-runtime-home.ts b/src/main/kimi/kimi-runtime-home.ts index 9b0cf799638..709cb755879 100644 --- a/src/main/kimi/kimi-runtime-home.ts +++ b/src/main/kimi/kimi-runtime-home.ts @@ -5,7 +5,7 @@ import { resolveLocalAccountRuntimeTarget, type LocalAccountRuntimeTarget } from '../../shared/local-account-runtime' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' export type KimiHomeResolution = LocalAccountRuntimeTarget & { diff --git a/src/main/linear/client.ts b/src/main/linear/client.ts index 7058d130597..714fb6c165e 100644 --- a/src/main/linear/client.ts +++ b/src/main/linear/client.ts @@ -17,7 +17,7 @@ import type { LinearViewer, LinearWorkspace, LinearWorkspaceSelection -} from '../../shared/types' +} from '../../shared/linear/workspace-types' // ── Concurrency limiter — max 4 parallel Linear API calls ──────────── const MAX_CONCURRENT = 4 diff --git a/src/main/linear/issue-context-client.ts b/src/main/linear/issue-context-client.ts index 1e3a45bd301..47c2bb219dc 100644 --- a/src/main/linear/issue-context-client.ts +++ b/src/main/linear/issue-context-client.ts @@ -1,6 +1,6 @@ import type { LinearSearchIssueSummary, LinearSearchResult } from '../../shared/linear/agent-access' import { clampLinearSearchLimit } from '../../shared/linear/agent-access' -import type { LinearWorkspace } from '../../shared/types' +import type { LinearWorkspace } from '../../shared/linear/workspace-types' import { acquire, clearToken, diff --git a/src/main/linear/issue-context-current.test.ts b/src/main/linear/issue-context-current.test.ts index fd3804bbe9b..022b75525ff 100644 --- a/src/main/linear/issue-context-current.test.ts +++ b/src/main/linear/issue-context-current.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { LinearWorkspace } from '../../shared/types' +import type { LinearWorkspace } from '../../shared/linear/workspace-types' const { connectedWorkspaces } = vi.hoisted(() => ({ connectedWorkspaces: [] as LinearWorkspace[] diff --git a/src/main/linear/issue-context-fanout.ts b/src/main/linear/issue-context-fanout.ts index c28f03a2c9c..77b6186b37e 100644 --- a/src/main/linear/issue-context-fanout.ts +++ b/src/main/linear/issue-context-fanout.ts @@ -1,5 +1,5 @@ import type { LinearErrorCode, LinearWorkspaceCandidate } from '../../shared/linear/agent-access' -import type { LinearWorkspace } from '../../shared/types' +import type { LinearWorkspace } from '../../shared/linear/workspace-types' import { getClients, getStatus, type LinearClientForWorkspace } from './client' import { LinearAgentAccessError, diff --git a/src/main/linear/issue-context-workspaces.ts b/src/main/linear/issue-context-workspaces.ts index 47ad620a0a3..e1c70d2f37f 100644 --- a/src/main/linear/issue-context-workspaces.ts +++ b/src/main/linear/issue-context-workspaces.ts @@ -1,5 +1,5 @@ import type { LinearWorkspaceCandidate } from '../../shared/linear/agent-access' -import type { LinearWorkspace } from '../../shared/types' +import type { LinearWorkspace } from '../../shared/linear/workspace-types' import { linearError } from './issue-context-errors' export function resolveWorkspaceSelector( diff --git a/src/main/linear/issue-context.test.ts b/src/main/linear/issue-context.test.ts index b88dff4563e..e94c490dbc1 100644 --- a/src/main/linear/issue-context.test.ts +++ b/src/main/linear/issue-context.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LinearClientForWorkspace } from './client' -import type { LinearWorkspace } from '../../shared/types' +import type { LinearWorkspace } from '../../shared/linear/workspace-types' const getClients = vi.fn() const getStatus = vi.fn() diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index 301bc66024a..43e9cf7b699 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -1,14 +1,13 @@ /* eslint-disable max-lines -- Why: Linear issue reads and mutations share the same workspace fan-out/error handling, so keeping them together avoids drifting auth-clearing behavior between operations. */ +import type { LinearIssueUpdate } from '../../shared/issue-mutation-types' +import type { LinearComment, LinearIssue } from '../../shared/linear/issue-types' import type { - LinearIssue, - LinearIssueUpdate, - LinearComment, LinearCollectionResult, LinearWorkspaceError, LinearWorkspaceSelection -} from '../../shared/types' +} from '../../shared/linear/workspace-types' import type { LinearClient } from '@linear/sdk' import { loadLinearSdk } from './linear-sdk' import { diff --git a/src/main/linear/linear-team-pages.ts b/src/main/linear/linear-team-pages.ts index abc1c485a8b..624f57c6d8e 100644 --- a/src/main/linear/linear-team-pages.ts +++ b/src/main/linear/linear-team-pages.ts @@ -1,4 +1,9 @@ -import type { LinearLabel, LinearMember, LinearTeam, LinearWorkflowState } from '../../shared/types' +import type { + LinearLabel, + LinearMember, + LinearTeam, + LinearWorkflowState +} from '../../shared/linear/workspace-types' import { buildLinearTeamUrl } from '../../shared/linear/links' import type { LinearClientForWorkspace } from './client' diff --git a/src/main/linear/mappers.ts b/src/main/linear/mappers.ts index 7ea55be7fca..026d70400bf 100644 --- a/src/main/linear/mappers.ts +++ b/src/main/linear/mappers.ts @@ -1,5 +1,5 @@ import type { Issue, IssueSearchResult } from '@linear/sdk' -import type { LinearIssue, LinearIssueChildSummary } from '../../shared/types' +import type { LinearIssue, LinearIssueChildSummary } from '../../shared/linear/issue-types' type IssueWithChildren = Issue & { children: Issue['children'] diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index d55d2f6e6a6..5063a87b17c 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -1,17 +1,19 @@ /* eslint-disable max-lines -- Why: Linear project and custom-view reads share raw GraphQL selection sets, workspace fan-out, and partial-failure mapping. */ +import type { LinearIssue } from '../../shared/linear/issue-types' +import type { + LinearCustomViewModel, + LinearCustomViewSummary, + LinearProjectDetail, + LinearProjectMemberSummary, + LinearProjectSummary +} from '../../shared/linear/project-types' import type { LinearCollectionResult, LinearConcreteWorkspaceId, - LinearCustomViewModel, - LinearCustomViewSummary, - LinearIssue, - LinearProjectDetail, - LinearProjectMemberSummary, - LinearProjectSummary, LinearWorkspaceError, LinearWorkspaceSelection -} from '../../shared/types' +} from '../../shared/linear/workspace-types' import { LINEAR_ISSUE_API_PAGE_SIZE_MAX, clampLinearIssueListLimit diff --git a/src/main/linear/teams.ts b/src/main/linear/teams.ts index 004c8fbd92a..8a103929b7e 100644 --- a/src/main/linear/teams.ts +++ b/src/main/linear/teams.ts @@ -1,12 +1,12 @@ import type { Team } from '@linear/sdk' import type { - LinearTeam, - LinearWorkflowState, LinearLabel, LinearMember, + LinearTeam, + LinearWorkflowState, LinearWorkspaceError, LinearWorkspaceSelection -} from '../../shared/types' +} from '../../shared/linear/workspace-types' import { acquire, release, getClients, isAuthError, clearToken } from './client' import { fetchAllTeamLabels, diff --git a/src/main/linux-package-install-command.ts b/src/main/linux-package-install-command.ts index 6a0c6d2d526..60a8ecdca00 100644 --- a/src/main/linux-package-install-command.ts +++ b/src/main/linux-package-install-command.ts @@ -1,6 +1,6 @@ import { statSync } from 'node:fs' import path from 'node:path' -import type { LinuxRootPackageType } from '../shared/types' +import type { LinuxRootPackageType } from '../shared/update-status-types' // Why: an absolute but user-writable PATH entry must never be treated as a trusted package manager. const TRUSTED_EXECUTABLE_DIRECTORIES = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'] diff --git a/src/main/linux-package-install-diagnostic.ts b/src/main/linux-package-install-diagnostic.ts index 3d0afd7fdf9..bdef7c5450e 100644 --- a/src/main/linux-package-install-diagnostic.ts +++ b/src/main/linux-package-install-diagnostic.ts @@ -1,5 +1,5 @@ import os from 'node:os' -import type { LinuxPackageInstallFailureReason } from '../shared/types' +import type { LinuxPackageInstallFailureReason } from '../shared/update-status-types' /** The redacted text shown locally, paired with the reason classified from the ORIGINAL output. */ export type LinuxPackageInstallDiagnostic = { diff --git a/src/main/linux-package-update-recovery.test.ts b/src/main/linux-package-update-recovery.test.ts index 1d9bf8d8650..ec2738b823b 100644 --- a/src/main/linux-package-update-recovery.test.ts +++ b/src/main/linux-package-update-recovery.test.ts @@ -4,7 +4,7 @@ import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeFs from 'node:fs' -import type { LinuxPackageInstallRecovery } from '../shared/types' +import type { LinuxPackageInstallRecovery } from '../shared/update-status-types' import type * as RecoveryModule from './linux-package-update-recovery' const { showItemInFolderMock, getPackageTypeMock, buildCommandMock, hashPasses } = vi.hoisted( diff --git a/src/main/linux-package-update-recovery.ts b/src/main/linux-package-update-recovery.ts index f854b63c5b8..e5269a2077b 100644 --- a/src/main/linux-package-update-recovery.ts +++ b/src/main/linux-package-update-recovery.ts @@ -4,7 +4,10 @@ import fsp from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { shell } from 'electron' -import type { LinuxPackageInstallRecovery, LinuxRootPackageType } from '../shared/types' +import type { + LinuxPackageInstallRecovery, + LinuxRootPackageType +} from '../shared/update-status-types' import { getLinuxRootPackageType } from './linux-update-package-type' import { buildLinuxPackageInstallCommand } from './linux-package-install-command' diff --git a/src/main/linux-update-package-type.ts b/src/main/linux-update-package-type.ts index c8af66649a5..5acc83a4cf3 100644 --- a/src/main/linux-update-package-type.ts +++ b/src/main/linux-update-package-type.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import path from 'node:path' import { app } from 'electron' -import type { LinuxRootPackageType } from '../shared/types' +import type { LinuxRootPackageType } from '../shared/update-status-types' export type { LinuxRootPackageType } diff --git a/src/main/local-project-runtime-resolution.ts b/src/main/local-project-runtime-resolution.ts index e86a2f31927..6f3e7b57f09 100644 --- a/src/main/local-project-runtime-resolution.ts +++ b/src/main/local-project-runtime-resolution.ts @@ -1,5 +1,6 @@ import type { Store } from './persistence' -import type { Project, Repo } from '../shared/types' +import type { Project } from '../shared/project-types' +import type { Repo } from '../shared/repo-types' import { resolveProjectExecutionRuntime, type ProjectExecutionRuntimeResolution diff --git a/src/main/local-worktree-removal-recovery.ts b/src/main/local-worktree-removal-recovery.ts index 578743029f6..630dd338c20 100644 --- a/src/main/local-worktree-removal-recovery.ts +++ b/src/main/local-worktree-removal-recovery.ts @@ -1,4 +1,5 @@ -import type { GitWorktreeInfo, RemoveWorktreeResult } from '../shared/types' +import type { RemoveWorktreeResult } from '../shared/worktree/create-types' +import type { GitWorktreeInfo } from '../shared/worktree/types' import { assertWorktreeUnlockedForRemoval } from '../shared/worktree/removal' import { areWorktreePathsEqual, formatWorktreeRemovalError } from './ipc/worktree-logic' import { gitExecFileAsync } from './git/runner' diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index c1fe58989ad..1f000706c1e 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -27,7 +27,12 @@ import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' import { app } from 'electron' -import type { AppMemory, MemorySnapshot, SessionMemory, WorktreeMemory } from '../../shared/types' +import type { + AppMemory, + MemorySnapshot, + SessionMemory, + WorktreeMemory +} from '../../shared/process-stats-types' import type { Store } from '../persistence' import { ORPHAN_WORKTREE_ID } from '../../shared/constants' import { listRegisteredPtys } from './pty-registry' diff --git a/src/main/memory/host-memory.ts b/src/main/memory/host-memory.ts index 5622bd27d72..5d7ee54746d 100644 --- a/src/main/memory/host-memory.ts +++ b/src/main/memory/host-memory.ts @@ -2,7 +2,7 @@ import { execFile } from 'node:child_process' import { readFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import type { HostAvailableMemorySource, HostMemory } from '../../shared/types' +import type { HostAvailableMemorySource, HostMemory } from '../../shared/process-stats-types' const MEMORY_PRESSURE_TIMEOUT_MS = 1_000 const MEMORY_PRESSURE_MAX_BUFFER = 64 * 1024 diff --git a/src/main/memory/hydrate-local-pty-registry.test.ts b/src/main/memory/hydrate-local-pty-registry.test.ts index 714f18b3cba..7b9a90bfb73 100644 --- a/src/main/memory/hydrate-local-pty-registry.test.ts +++ b/src/main/memory/hydrate-local-pty-registry.test.ts @@ -11,7 +11,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import type { SessionInfo } from '../daemon/types' import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' import type { Store } from '../persistence' diff --git a/src/main/memory/process-memory-metric.ts b/src/main/memory/process-memory-metric.ts index 79716cb1f8d..49d9fded195 100644 --- a/src/main/memory/process-memory-metric.ts +++ b/src/main/memory/process-memory-metric.ts @@ -1,5 +1,5 @@ import os from 'node:os' -import type { ProcessMemoryMetric } from '../../shared/types' +import type { ProcessMemoryMetric } from '../../shared/process-stats-types' export function getProcessMemoryMetric( platform: NodeJS.Platform = os.platform() diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index cc99c69cbc7..2f77e7f8941 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -5,7 +5,7 @@ import { type KeybindingActionId, type KeybindingOverrides } from '../../shared/keybindings' -import type { UpdateCheckOptions } from '../../shared/types' +import type { UpdateCheckOptions } from '../../shared/update-status-types' import { translateMain } from '../i18n/main-i18n' import { createAppMenuSelectionItem } from './app-menu-selection-item' diff --git a/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts b/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts index af14882b371..4d84e49b3c5 100644 --- a/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts +++ b/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts @@ -130,4 +130,48 @@ describe('Codex WSL scan gate', () => { }) ).rejects.toBe(refusal) }) + + it('reports the abort, not a refusal, when the hook-path probe races a caller abort', async () => { + const controller = new AbortController() + const abortReason = new Error('caller went away') + mocks.gate.mockImplementation(async (options: { operation?: string; path: string }) => { + if (options.operation === 'access') { + controller.abort(abortReason) + throw new WslTranscriptFsError('timeout', 'slow share') + } + return [] + }) + + await expect( + resolveSessionFilePath( + 'codex', + 'session-id', + { + transcriptPath: `${WSL_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`, + codexSessionsDirs: [LOCAL_SESSIONS_DIR] + }, + controller.signal + ) + ).rejects.toBe(abortReason) + }) + + // The scan layer's own waiter already wins this race; the resolver's post-catch + // signal check is the backstop if that ever stops holding. + it('reports the abort, not a refusal, when a scanned root is aborted mid-scan', async () => { + const controller = new AbortController() + const abortReason = new Error('caller went away') + mocks.gate.mockImplementation(async () => { + controller.abort(abortReason) + throw new WslTranscriptFsError('timeout', 'slow share') + }) + + await expect( + resolveSessionFilePath( + 'codex', + 'session-id', + { codexSessionsDirs: [WSL_SESSIONS_DIR] }, + controller.signal + ) + ).rejects.toBe(abortReason) + }) }) diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index f0376e01f02..a45850f2d46 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -108,6 +108,8 @@ export async function resolveSessionFilePath( return hostReadable } } catch (error) { + // A caller abort that races the refusal stays authoritative. + signal?.throwIfAborted() // Why: the id-based search may still hit; surface the refusal only when // it does not, so a stalled distro reads as unavailable, never "missing". unavailable = wslTranscriptFsRefusal(error) @@ -232,6 +234,8 @@ async function findCodexRolloutInDirs( return files } } catch (error) { + // A caller abort that races the refusal stays authoritative. + signal?.throwIfAborted() // Why: one stalled distro must not hide another root's hit. unavailable = wslTranscriptFsRefusal(error) } diff --git a/src/main/orca-profiles/profile-index-store.ts b/src/main/orca-profiles/profile-index-store.ts index 8c1b4490f45..7897e299a21 100644 --- a/src/main/orca-profiles/profile-index-store.ts +++ b/src/main/orca-profiles/profile-index-store.ts @@ -9,7 +9,7 @@ import { import { randomUUID } from 'node:crypto' import { dirname } from 'node:path' import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../shared/secure-file' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { createDefaultLocalOrcaProfile, DEFAULT_LOCAL_ORCA_PROFILE_ID, diff --git a/src/main/orca-profiles/profile-project-presence.test.ts b/src/main/orca-profiles/profile-project-presence.test.ts index ebbc05b8e14..56bfed5f500 100644 --- a/src/main/orca-profiles/profile-project-presence.test.ts +++ b/src/main/orca-profiles/profile-project-presence.test.ts @@ -8,7 +8,8 @@ import { type OrcaProfileIndex, type OrcaProfileKind } from '../../shared/orca-profiles' -import type { PersistedState, Repo } from '../../shared/types' +import type { PersistedState } from '../../shared/persisted-state-types' +import type { Repo } from '../../shared/repo-types' const testState = { dir: '' } diff --git a/src/main/orca-profiles/profile-project-session-state.ts b/src/main/orca-profiles/profile-project-session-state.ts index 7b5eb1e4491..acb252007ca 100644 --- a/src/main/orca-profiles/profile-project-session-state.ts +++ b/src/main/orca-profiles/profile-project-session-state.ts @@ -1,6 +1,6 @@ import { getDefaultWorkspaceSession } from '../../shared/constants' import type { ExecutionHostId } from '../../shared/execution-host' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { isRepoWorktreeId, diff --git a/src/main/orca-profiles/profile-project-session-transfer.ts b/src/main/orca-profiles/profile-project-session-transfer.ts index 4736599df23..d5884096bd9 100644 --- a/src/main/orca-profiles/profile-project-session-transfer.ts +++ b/src/main/orca-profiles/profile-project-session-transfer.ts @@ -1,14 +1,12 @@ import { getDefaultWorkspaceSession } from '../../shared/constants' import type { ExecutionHostId } from '../../shared/execution-host' +import type { BrowserPage, BrowserWorkspace } from '../../shared/browser-workspace-types' +import type { Tab, TabGroup } from '../../shared/tab-types' +import type { TerminalTab } from '../../shared/terminal-tab-types' import type { - BrowserPage, - BrowserWorkspace, PersistedOpenFile, - Tab, - TabGroup, - TerminalTab, WorkspaceSessionState -} from '../../shared/types' +} from '../../shared/workspace-session-state-types' import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' import { isRepoWorktreeId, diff --git a/src/main/orca-profiles/profile-project-source-removal.ts b/src/main/orca-profiles/profile-project-source-removal.ts index edb923e6293..c698491eb19 100644 --- a/src/main/orca-profiles/profile-project-source-removal.ts +++ b/src/main/orca-profiles/profile-project-source-removal.ts @@ -1,4 +1,4 @@ -import type { WorkspaceKey } from '../../shared/types' +import type { WorkspaceKey } from '../../shared/folder-workspace-types' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { rebuildRepoBackedProjectState, diff --git a/src/main/orca-profiles/profile-project-state-file.test.ts b/src/main/orca-profiles/profile-project-state-file.test.ts index 223d1229555..305ba53d869 100644 --- a/src/main/orca-profiles/profile-project-state-file.test.ts +++ b/src/main/orca-profiles/profile-project-state-file.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { getDefaultPersistedState } from '../../shared/constants' -import type { PersistedState, Project, ProjectHostSetup, Repo } from '../../shared/types' +import type { PersistedState } from '../../shared/persisted-state-types' +import type { Project, ProjectHostSetup } from '../../shared/project-types' +import type { Repo } from '../../shared/repo-types' import { rebuildRepoBackedProjectState } from './profile-project-state-file' const upstreamIdentity = { diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index 7e00533350f..f7dedcff179 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -6,14 +6,11 @@ import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shar import type { ExecutionHostId } from '../../shared/execution-host' import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' import { carryProjectStateThroughIdentityChange } from '../../shared/project-identity-succession' -import type { - PersistedState, - Project, - ProjectHostSetup, - Repo, - SparsePreset, - WorkspaceSessionState -} from '../../shared/types' +import type { PersistedState } from '../../shared/persisted-state-types' +import type { Project, ProjectHostSetup } from '../../shared/project-types' +import type { Repo } from '../../shared/repo-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { SparsePreset } from '../../shared/worktree/create-types' import { getOrcaProfileDataFile } from './profile-index-store' export type TransferProfileState = PersistedState diff --git a/src/main/orca-profiles/profile-project-transfer-payload.ts b/src/main/orca-profiles/profile-project-transfer-payload.ts index f8e458df4df..de7c8b73171 100644 --- a/src/main/orca-profiles/profile-project-transfer-payload.ts +++ b/src/main/orca-profiles/profile-project-transfer-payload.ts @@ -2,7 +2,10 @@ import { randomUUID } from 'node:crypto' import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' import type { SshTarget } from '../../shared/ssh-types' -import type { PersistedState, Repo, SparsePreset, WorkspaceKey } from '../../shared/types' +import type { WorkspaceKey } from '../../shared/folder-workspace-types' +import type { PersistedState } from '../../shared/persisted-state-types' +import type { Repo } from '../../shared/repo-types' +import type { SparsePreset } from '../../shared/worktree/create-types' import { parseWorkspaceKey } from '../../shared/workspace-scope' import type { TransferProfileState } from './profile-project-state-file' import { rebuildRepoBackedProjectState } from './profile-project-state-file' diff --git a/src/main/orca-profiles/profile-project-transfer.test.ts b/src/main/orca-profiles/profile-project-transfer.test.ts index 3c3a4643387..ecb61684d62 100644 --- a/src/main/orca-profiles/profile-project-transfer.test.ts +++ b/src/main/orca-profiles/profile-project-transfer.test.ts @@ -7,7 +7,9 @@ import { ORCA_PROFILE_INDEX_SCHEMA_VERSION, type OrcaProfileIndex } from '../../shared/orca-profiles' -import type { PersistedState, Repo, WorktreeMeta } from '../../shared/types' +import type { PersistedState } from '../../shared/persisted-state-types' +import type { Repo } from '../../shared/repo-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { SshTarget } from '../../shared/ssh-types' const testState = { dir: '' } diff --git a/src/main/orca-profiles/profile-project-worktree-identity.ts b/src/main/orca-profiles/profile-project-worktree-identity.ts index 58aa55da8b3..cdc8d9182d2 100644 --- a/src/main/orca-profiles/profile-project-worktree-identity.ts +++ b/src/main/orca-profiles/profile-project-worktree-identity.ts @@ -1,6 +1,7 @@ import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { getRepoExecutionHostId } from '../../shared/execution-host' -import type { Repo, WorkspaceKey } from '../../shared/types' +import type { WorkspaceKey } from '../../shared/folder-workspace-types' +import type { Repo } from '../../shared/repo-types' import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' diff --git a/src/main/persistence-duplicate-repo-id-host-scope.test.ts b/src/main/persistence-duplicate-repo-id-host-scope.test.ts index 2f46dc9ca26..ade0038ee9b 100644 --- a/src/main/persistence-duplicate-repo-id-host-scope.test.ts +++ b/src/main/persistence-duplicate-repo-id-host-scope.test.ts @@ -8,7 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import type { Project, ProjectHostSetup, Repo } from '../shared/types' +import type { Project, ProjectHostSetup } from '../shared/project-types' +import type { Repo } from '../shared/repo-types' import { getDefaultPersistedState } from '../shared/constants' const testState = { dir: '' } diff --git a/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts b/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts index 4c04ab22d01..cfb7f555b08 100644 --- a/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts +++ b/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { performance } from 'node:perf_hooks' import { afterEach, describe, expect, it, vi } from 'vitest' import { normalizeFeatureInteractions } from '../shared/feature-interactions' -import type { PersistedState } from '../shared/types' +import type { PersistedState } from '../shared/persisted-state-types' vi.mock('electron', () => ({ app: { getPath: () => tmpdir() }, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 5c3caa63550..820f4600465 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -13,19 +13,14 @@ import { } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import type { - PersistedState, - Project, - ProjectGroup, - ProjectHostSetup, - Repo, - GlobalSettings, - TerminalPaneLayoutNode, - TerminalTab, - WorktreeLineage, - WorkspaceLineage, - WorkspaceSessionState -} from '../shared/types' +import type { GlobalSettings } from '../shared/global-settings-types' +import type { PersistedState } from '../shared/persisted-state-types' +import type { ProjectGroup } from '../shared/project-group-types' +import type { Project, ProjectHostSetup } from '../shared/project-types' +import type { Repo } from '../shared/repo-types' +import type { TerminalPaneLayoutNode, TerminalTab } from '../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/lineage-types' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT } from '../shared/terminal-scrollback-limits' import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history' @@ -42,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: '' } @@ -4858,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' })) @@ -4913,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( @@ -7148,6 +7250,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 c0dba9e9484..3d17e52b886 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -40,10 +40,22 @@ import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' import { normalizeAutomationPrecheck } from '../shared/automation-precheck' import { normalizeProxyUrl } from '../shared/network-proxy' import { normalizeKagiSessionLink } from '../shared/browser-url' +import type { FolderWorkspace, WorkspaceKey } from '../shared/folder-workspace-types' +import type { GlobalSettings, OrcaWorkspaceLayout } from '../shared/global-settings-types' +import type { NotificationSettings } from '../shared/notification-settings-types' +import type { + OnboardingChecklistState, + OnboardingOutcome, + OnboardingState +} from '../shared/onboarding-state-types' +import type { + LegacyPaneKeyAliasEntry, + PersistedMobileClientTabSelections, + PersistedState +} from '../shared/persisted-state-types' +import type { ProjectGroup } from '../shared/project-group-types' import type { - PersistedState, Project, - ProjectUpdateArgs, ProjectHostSetup, ProjectHostSetupCreateArgs, ProjectHostSetupCreateResult, @@ -51,29 +63,22 @@ import type { ProjectHostSetupDeleteResult, ProjectHostSetupUpdateArgs, ProjectHostSetupUpdateResult, - RepoProjectHostSetupMethod, - Repo, - ProjectGroup, - FolderWorkspace, - SparsePreset, - PersistedMobileClientTabSelections, - WorktreeMeta, - WorktreeLineage, - WorkspaceLineage, - WorkspaceKey, - GlobalSettings, - OrcaWorkspaceLayout, - NotificationSettings, - OnboardingChecklistState, - OnboardingOutcome, - OnboardingState, - LegacyPaneKeyAliasEntry, - TerminalPaneLayoutNode, + ProjectUpdateArgs, + RepoProjectHostSetupMethod +} from '../shared/project-types' +import type { Repo } from '../shared/repo-types' +import type { TerminalLayoutSnapshot, - TerminalTab, + TerminalPaneLayoutNode, + TerminalTab +} from '../shared/terminal-tab-types' +import type { WorkspaceSessionPatch, WorkspaceSessionState -} from '../shared/types' +} from '../shared/workspace-session-state-types' +import type { SparsePreset } from '../shared/worktree/create-types' +import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../shared/worktree/meta-types' import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings, normalizeProjectRuntimePreference @@ -181,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, @@ -221,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 { @@ -3210,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 @@ -3479,6 +3496,7 @@ export class Store { parsed.featureInteractionTelemetryBuckets ), projectGroups: normalizedProjectGroups, + repos: migratedExternalVisibility.repos, folderWorkspaces: normalizeFolderWorkspaces( parsed.folderWorkspaces, normalizedProjectGroups @@ -3497,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 ), @@ -4308,6 +4327,10 @@ export class Store { // ── Repos ────────────────────────────────────────────────────────── + getProfileStorageDirectory(): string { + return dirname(this.dataFile) + } + getRepos(): Repo[] { return this.state.repos.map((repo) => this.hydrateRepo(repo)) } @@ -4997,11 +5020,9 @@ export class Store { | 'symlinkPaths' | 'issueSourcePreference' | 'forkSyncMode' - | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' - | 'agentWorktreeVisibility' | 'customWorktreeVisibilitySources' | 'worktreeVisibilitySourcePreferences' | 'projectGroupId' @@ -5009,6 +5030,8 @@ export class Store { | 'projectHostSetupMethod' > > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null }, @@ -5070,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 @@ -5953,6 +5992,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 @@ -6217,6 +6264,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/plugins/plugin-approved-vm-recipes.ts b/src/main/plugins/plugin-approved-vm-recipes.ts index ab193a5280f..2ee20c63714 100644 --- a/src/main/plugins/plugin-approved-vm-recipes.ts +++ b/src/main/plugins/plugin-approved-vm-recipes.ts @@ -1,4 +1,4 @@ -import type { OrcaVmRecipe } from '../../shared/types' +import type { OrcaVmRecipe } from '../../shared/orca-yaml-hook-types' import type { PluginService } from './plugin-service' export async function getApprovedPluginVmRecipes( diff --git a/src/main/plugins/plugin-enablement.test.ts b/src/main/plugins/plugin-enablement.test.ts index 0ce3013d8a9..f0698b80a1b 100644 --- a/src/main/plugins/plugin-enablement.test.ts +++ b/src/main/plugins/plugin-enablement.test.ts @@ -1,7 +1,7 @@ import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../shared/constants' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' import type { Store } from '../persistence' import { applyPluginConsent, applyPluginEnablement } from './plugin-enablement' diff --git a/src/main/plugins/plugin-vm-recipe-registry.ts b/src/main/plugins/plugin-vm-recipe-registry.ts index ac2b90d9b64..eb7204fef7e 100644 --- a/src/main/plugins/plugin-vm-recipe-registry.ts +++ b/src/main/plugins/plugin-vm-recipe-registry.ts @@ -1,4 +1,4 @@ -import type { OrcaVmRecipe } from '../../shared/types' +import type { OrcaVmRecipe } from '../../shared/orca-yaml-hook-types' import { parsePluginVmRecipeArtifact } from '../../shared/plugins/plugin-vm-recipe-artifact' import { PLUGIN_VM_RECIPE_MAX_BYTES, diff --git a/src/main/project-groups/folder-workspace-path-status.test.ts b/src/main/project-groups/folder-workspace-path-status.test.ts index 495f4690434..25314576bc3 100644 --- a/src/main/project-groups/folder-workspace-path-status.test.ts +++ b/src/main/project-groups/folder-workspace-path-status.test.ts @@ -9,7 +9,8 @@ import { inferFolderWorkspacePathConnection } from './folder-workspace-path-status' import type { IFilesystemProvider } from '../providers/types' -import type { ProjectGroup, Repo } from '../../shared/types' +import type { ProjectGroup } from '../../shared/project-group-types' +import type { Repo } from '../../shared/repo-types' function makeGroup(overrides: Partial = {}): ProjectGroup { return { diff --git a/src/main/project-groups/folder-workspace-path-status.ts b/src/main/project-groups/folder-workspace-path-status.ts index d8df44da72c..dbbd34a7034 100644 --- a/src/main/project-groups/folder-workspace-path-status.ts +++ b/src/main/project-groups/folder-workspace-path-status.ts @@ -5,7 +5,9 @@ import type { FolderWorkspacePathStatusRequest } from '../../shared/folder-workspace-path-status' import { getProjectGroupSubtreeIds } from '../../shared/project-groups' -import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types' +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 { IFilesystemProvider } from '../providers/types' type FolderWorkspacePathStatusStore = { diff --git a/src/main/project-groups/nested-repo-discovery.ts b/src/main/project-groups/nested-repo-discovery.ts index 30453748767..d2df48e7970 100644 --- a/src/main/project-groups/nested-repo-discovery.ts +++ b/src/main/project-groups/nested-repo-discovery.ts @@ -6,7 +6,7 @@ import type { NestedRepoCandidate, NestedRepoScanOptions, NestedRepoScanResult -} from '../../shared/types' +} from '../../shared/project-group-types' import { isGitRepo } from '../git/repo' type NestedRepoDirectoryEntry = { diff --git a/src/main/project-groups/nested-repo-import-target.test.ts b/src/main/project-groups/nested-repo-import-target.test.ts index 60b1cf9b526..c630e102fb3 100644 --- a/src/main/project-groups/nested-repo-import-target.test.ts +++ b/src/main/project-groups/nested-repo-import-target.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { join } from 'node:path' -import type { GitWorktreeInfo } from '../../shared/types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' import { listWorktreeGraph } from '../git/worktree' import { createNestedRepoImportTargetResolver, diff --git a/src/main/project-groups/nested-repo-import-target.ts b/src/main/project-groups/nested-repo-import-target.ts index bde2abb0ba9..be722716856 100644 --- a/src/main/project-groups/nested-repo-import-target.ts +++ b/src/main/project-groups/nested-repo-import-target.ts @@ -1,4 +1,4 @@ -import type { GitWorktreeInfo } from '../../shared/types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { listWorktreeGraph } from '../git/worktree' diff --git a/src/main/project-groups/nested-repo-import.test.ts b/src/main/project-groups/nested-repo-import.test.ts index be7a46e81ee..2b591fa9a71 100644 --- a/src/main/project-groups/nested-repo-import.test.ts +++ b/src/main/project-groups/nested-repo-import.test.ts @@ -4,7 +4,7 @@ import { resolveNestedRepoImportPaths, resolveNestedRepoSelection } from './nested-repo-import' -import type { ProjectGroup } from '../../shared/types' +import type { ProjectGroup } from '../../shared/project-group-types' function createGroupRecorder(): { groups: ProjectGroup[] diff --git a/src/main/project-groups/nested-repo-import.ts b/src/main/project-groups/nested-repo-import.ts index bcaf3ec25dd..17aee4a2994 100644 --- a/src/main/project-groups/nested-repo-import.ts +++ b/src/main/project-groups/nested-repo-import.ts @@ -1,4 +1,8 @@ -import type { NestedRepoScanResult, ProjectGroup, ProjectGroupImportMode } from '../../shared/types' +import type { + NestedRepoScanResult, + ProjectGroup, + ProjectGroupImportMode +} from '../../shared/project-group-types' import { getRuntimePathBasename, isPathInsideOrEqual, diff --git a/src/main/project-runtime-git-options.test.ts b/src/main/project-runtime-git-options.test.ts index 077bd43afd8..bff4694acb0 100644 --- a/src/main/project-runtime-git-options.test.ts +++ b/src/main/project-runtime-git-options.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type { Store } from './persistence' -import type { Project, Repo } from '../shared/types' +import type { Project } from '../shared/project-types' +import type { Repo } from '../shared/repo-types' import { getLocalProjectGitExecOptions, resolveLocalProjectRuntimeForRepo diff --git a/src/main/project-runtime-git-options.ts b/src/main/project-runtime-git-options.ts index 1c4e4945f32..32399aa5976 100644 --- a/src/main/project-runtime-git-options.ts +++ b/src/main/project-runtime-git-options.ts @@ -1,5 +1,5 @@ import type { Store } from './persistence' -import type { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' import { resolveLocalProjectRuntimeForRepo } from './local-project-runtime-resolution' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' diff --git a/src/main/providers/filesystem-provider-contract.ts b/src/main/providers/filesystem-provider-contract.ts index 855f36da98c..942cb93f090 100644 --- a/src/main/providers/filesystem-provider-contract.ts +++ b/src/main/providers/filesystem-provider-contract.ts @@ -1,4 +1,5 @@ -import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' +import type { SearchOptions, SearchResult } from '../../shared/code-search-types' +import type { DirEntry, FsChangeEvent } from '../../shared/filesystem-entry-types' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' export type FileStat = { @@ -18,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/git-provider-contract.ts b/src/main/providers/git-provider-contract.ts index d13bf5addf9..f70f3ddcc72 100644 --- a/src/main/providers/git-provider-contract.ts +++ b/src/main/providers/git-provider-contract.ts @@ -1,17 +1,17 @@ import type { - GitStatusResult, - GitDiffResult, GitBranchCompareResult, GitCommitCompareResult, + GitDiffResult +} from '../../shared/git-diff-compare-types' +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitConflictOperation, - GitForkSyncExpectedUpstream, - GitForkSyncResult, - GitPushTarget, GitStagingArea, - GitUpstreamStatus, - GitWorktreeInfo, - RemoveWorktreeResult -} from '../../shared/types' + GitStatusResult, + GitUpstreamStatus +} from '../../shared/git-status-types' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { GitProviderStatusOptions } from './git-provider-status-options' 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/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 2ce5b43229b..2ad19ebbe6f 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../shared/types' +import type { TuiAgent } from '../../shared/tui-agent' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index ebed657ee21..76bb4179069 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -1,5 +1,5 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' -import type { TuiAgent } from '../../shared/types' +import type { TuiAgent } from '../../shared/tui-agent' import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' 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-watch.ts b/src/main/providers/ssh-filesystem-provider-watch.ts index 8ef523860bd..95ef73632eb 100644 --- a/src/main/providers/ssh-filesystem-provider-watch.ts +++ b/src/main/providers/ssh-filesystem-provider-watch.ts @@ -1,5 +1,5 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { FsChangeEvent } from '../../shared/types' +import type { FsChangeEvent } from '../../shared/filesystem-entry-types' import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { PromiseSettlementWaiters } from '../../shared/promise-settlement-waiters' diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 74f31b8e6bf..6adda586065 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -16,12 +16,14 @@ import { } from './ssh-filesystem-provider-watch' import type { IFilesystemProvider, + FileReadLimits, FileStat, FileReadResult, FileUploadSession, TerminalArtifactAccessOptions } from './types' -import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' +import type { SearchOptions, SearchResult } from '../../shared/code-search-types' +import type { DirEntry, FsChangeEvent } from '../../shared/filesystem-entry-types' import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' import { isWindowsRemoteHost, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' @@ -87,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/ssh-filesystem-watch-notifications.test.ts b/src/main/providers/ssh-filesystem-watch-notifications.test.ts index 3380fe42e89..d50b32bc826 100644 --- a/src/main/providers/ssh-filesystem-watch-notifications.test.ts +++ b/src/main/providers/ssh-filesystem-watch-notifications.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { FsChangeEvent } from '../../shared/types' +import type { FsChangeEvent } from '../../shared/filesystem-entry-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' import type { WatchRegistration } from './ssh-filesystem-provider-watch' diff --git a/src/main/providers/ssh-filesystem-watch-notifications.ts b/src/main/providers/ssh-filesystem-watch-notifications.ts index c08afb21095..aed30f16473 100644 --- a/src/main/providers/ssh-filesystem-watch-notifications.ts +++ b/src/main/providers/ssh-filesystem-watch-notifications.ts @@ -1,4 +1,4 @@ -import type { FsChangeEvent } from '../../shared/types' +import type { FsChangeEvent } from '../../shared/filesystem-entry-types' import { createNormalizedPathInsideOrEqualMatcher, normalizeRuntimePathForComparison diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 51e2b91e1c2..1875e479f12 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -5,19 +5,19 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { GitProviderStatusOptions, IGitProvider } from './types' import type { - GitStatusResult, - GitDiffResult, GitBranchCompareResult, GitCommitCompareResult, + GitDiffResult +} from '../../shared/git-diff-compare-types' +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitConflictOperation, - GitForkSyncExpectedUpstream, - GitForkSyncResult, - GitPushTarget, GitStagingArea, - GitUpstreamStatus, - GitWorktreeInfo, - RemoveWorktreeResult -} from '../../shared/types' + GitStatusResult, + GitUpstreamStatus +} from '../../shared/git-status-types' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' import { JsonRpcErrorCode } from '../ssh/relay-protocol' 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/provisioned-root-ssh-adoption.test.ts b/src/main/provisioned-root-ssh-adoption.test.ts index d8d2c73c2ef..9d89fb27217 100644 --- a/src/main/provisioned-root-ssh-adoption.test.ts +++ b/src/main/provisioned-root-ssh-adoption.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from './persistence' -import type { AdoptProvisionedRootArgs, GitWorktreeInfo, Repo, WorktreeMeta } from '../shared/types' +import type { Repo } from '../shared/repo-types' +import type { AdoptProvisionedRootArgs } from '../shared/worktree/create-types' +import type { WorktreeMeta } from '../shared/worktree/meta-types' +import type { GitWorktreeInfo } from '../shared/worktree/types' import { listEphemeralVmRuntimes, upsertEphemeralVmRuntime diff --git a/src/main/provisioned-root-ssh-adoption.ts b/src/main/provisioned-root-ssh-adoption.ts index 0b67772a2e7..c8ba0bd340c 100644 --- a/src/main/provisioned-root-ssh-adoption.ts +++ b/src/main/provisioned-root-ssh-adoption.ts @@ -1,12 +1,12 @@ import { randomUUID } from 'node:crypto' import type { Store } from './persistence' +import type { Repo } from '../shared/repo-types' import type { AdoptProvisionedRootArgs, - AutomationWorkspaceProvenance, - CreateWorktreeResult, - Repo, - WorktreeMeta -} from '../shared/types' + CreateWorktreeResult +} from '../shared/worktree/create-types' +import type { WorktreeMeta } from '../shared/worktree/meta-types' +import type { AutomationWorkspaceProvenance } from '../shared/worktree/types' import { isRuntimeOwnedSshTargetId, toSshExecutionHostId } from '../shared/execution-host' import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import { 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/rate-limits/account-runtime-target-sync.ts b/src/main/rate-limits/account-runtime-target-sync.ts index d78a4b68910..5b59b19552d 100644 --- a/src/main/rate-limits/account-runtime-target-sync.ts +++ b/src/main/rate-limits/account-runtime-target-sync.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import type { RateLimitState } from '../../shared/rate-limit-types' import type { RateLimitService } from './service' import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target' diff --git a/src/main/rate-limits/claude-rate-limit-target.ts b/src/main/rate-limits/claude-rate-limit-target.ts index dba2e396669..b28e8cfed8a 100644 --- a/src/main/rate-limits/claude-rate-limit-target.ts +++ b/src/main/rate-limits/claude-rate-limit-target.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { getClaudeWslSelectionKey, normalizeClaudeRuntimeSelection, diff --git a/src/main/rate-limits/codex-rate-limit-target.ts b/src/main/rate-limits/codex-rate-limit-target.ts index 61c116bab2b..7d7232a3e87 100644 --- a/src/main/rate-limits/codex-rate-limit-target.ts +++ b/src/main/rate-limits/codex-rate-limit-target.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { getWslSelectionKey, normalizeCodexRuntimeSelection, diff --git a/src/main/rate-limits/initial-account-rate-limit-target.test.ts b/src/main/rate-limits/initial-account-rate-limit-target.test.ts index 528a7527968..485fa8bcef6 100644 --- a/src/main/rate-limits/initial-account-rate-limit-target.test.ts +++ b/src/main/rate-limits/initial-account-rate-limit-target.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { getDefaultSettings } from '../../shared/constants' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target' import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target' diff --git a/src/main/rate-limits/initial-account-rate-limit-target.ts b/src/main/rate-limits/initial-account-rate-limit-target.ts index d60dab69daf..729f8f17407 100644 --- a/src/main/rate-limits/initial-account-rate-limit-target.ts +++ b/src/main/rate-limits/initial-account-rate-limit-target.ts @@ -1,5 +1,5 @@ import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' type AccountRuntimeSelection = { host: string | null diff --git a/src/main/repo-git-remote-identity-enrichment.test.ts b/src/main/repo-git-remote-identity-enrichment.test.ts index 24f498b03fc..b396e8a7e38 100644 --- a/src/main/repo-git-remote-identity-enrichment.test.ts +++ b/src/main/repo-git-remote-identity-enrichment.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { GitRemoteIdentity } from '../shared/git-remote-identity' -import type { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' import { type GitRemoteIdentityProbe, probeGitRemoteIdentity } from './repo-git-remote-identity' import { enrichMissingRepoGitRemoteIdentities, diff --git a/src/main/repo-git-remote-identity-enrichment.ts b/src/main/repo-git-remote-identity-enrichment.ts index e48177f2e62..145e33ddf90 100644 --- a/src/main/repo-git-remote-identity-enrichment.ts +++ b/src/main/repo-git-remote-identity-enrichment.ts @@ -1,4 +1,4 @@ -import type { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' import { probeGitRemoteIdentity } from './repo-git-remote-identity' const NO_IDENTITY_RETRY_TTL_MS = 5 * 60 * 1000 diff --git a/src/main/repo-git-username-enrichment.test.ts b/src/main/repo-git-username-enrichment.test.ts index bcecb8a2340..06ba252a6e6 100644 --- a/src/main/repo-git-username-enrichment.test.ts +++ b/src/main/repo-git-username-enrichment.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' import type { ResolvedGitUsername } from './git/git-username' const resolveLocalGitUsernameDetailedMock = vi.hoisted(() => vi.fn()) diff --git a/src/main/repo-git-username-enrichment.ts b/src/main/repo-git-username-enrichment.ts index c5ae5c6d7b9..44b08ea73b1 100644 --- a/src/main/repo-git-username-enrichment.ts +++ b/src/main/repo-git-username-enrichment.ts @@ -1,4 +1,4 @@ -import type { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' import { resolveLocalGitUsernameDetailed } from './git/git-username' type RepoUsernameStore = { diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index 5fa931f06c5..4ea5f5d59d0 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -1,5 +1,6 @@ import { readFile, stat } from 'node:fs/promises' -import type { GitHubRepositoryIdentity, RepoKind } from '../shared/types' +import type { GitHubRepositoryIdentity } from '../shared/github/pull-request-types' +import type { RepoKind } from '../shared/repo-types' import { faviconUrlFromWebsite, githubAvatarIcon, diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index 4f45e7d8564..f4123c18889 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -1,4 +1,5 @@ -import type { GitWorktreeInfo, Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' +import type { GitWorktreeInfo } from '../shared/worktree/types' import { listWorktrees } from './git/worktree' import { isFolderRepo } from '../shared/repo-kind' import { getSshGitProvider } from './providers/ssh-git-dispatch' diff --git a/src/main/runtime/claude-agent-teams-service.ts b/src/main/runtime/claude-agent-teams-service.ts index 1b0199c6bd0..34ef35ecb36 100644 --- a/src/main/runtime/claude-agent-teams-service.ts +++ b/src/main/runtime/claude-agent-teams-service.ts @@ -26,7 +26,8 @@ export class ClaudeAgentTeamsService { leaderHandle: string baseEnv: Record shimDir: string - shimBin: string + /** Absolute path only; null leaves the var unset so the shim refuses to guess a cwd-relative CLI. */ + shimBin: string | null }): AgentTeamsLaunchEnv { const teamId = `team-${randomUUID()}` const token = randomBytes(32).toString('base64url') @@ -47,8 +48,10 @@ export class ClaudeAgentTeamsService { ORCA_AGENT_TEAMS_TEAM_ID: teamId, ORCA_AGENT_TEAMS_TOKEN: token, ORCA_AGENT_TEAMS_LEADER_PANE: leaderPane, - ORCA_AGENT_TEAMS_SHIM_DIR: args.shimDir, - ORCA_AGENT_TEAMS_SHIM_BIN: args.shimBin + ORCA_AGENT_TEAMS_SHIM_DIR: args.shimDir + } + if (args.shimBin) { + env.ORCA_AGENT_TEAMS_SHIM_BIN = args.shimBin } if (args.baseEnv.ORCA_PAIRING_CODE) { env.ORCA_PAIRING_CODE = args.baseEnv.ORCA_PAIRING_CODE diff --git a/src/main/runtime/claude-agent-teams-shim-env.test.ts b/src/main/runtime/claude-agent-teams-shim-env.test.ts index 362291e3c26..7c62d2ae01a 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.test.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.test.ts @@ -1,3 +1,5 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -5,7 +7,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { buildClaudeAgentTeamsLaunchPlan, ensureClaudeAgentTeamsShimDir, - resolveClaudeAgentTeamsShimBin + resolveClaudeAgentTeamsShimBin, + windowsClaudeAgentTeamsShimScript } from './claude-agent-teams-shim-env' const roots: string[] = [] @@ -88,4 +91,105 @@ describe('claude agent teams shim env', () => { expect(resolveClaudeAgentTeamsShimBin({ PATH: root })).toBe(cliPath) }) + + it('refuses to resolve a CLI through relative PATH entries or a bare override', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-')) + roots.push(root) + for (const name of ['orca', 'orca-ide', 'orca.cmd']) { + const path = join(root, name) + await writeFile(path, '#!/usr/bin/env sh\n', 'utf8') + if (process.platform !== 'win32') { + await chmod(path, 0o755) + } + } + + expect(resolveClaudeAgentTeamsShimBin({ PATH: '.' })).toBeNull() + expect(resolveClaudeAgentTeamsShimBin({ PATH: '' })).toBeNull() + expect( + resolveClaudeAgentTeamsShimBin({ PATH: '.', ORCA_AGENT_TEAMS_SHIM_BIN: 'orca' }) + ).toBeNull() + // Why: a bare override is still honored when it maps to a real absolute PATH entry. + expect(resolveClaudeAgentTeamsShimBin({ PATH: root, ORCA_AGENT_TEAMS_SHIM_BIN: 'orca' })).toBe( + join(root, 'orca') + ) + }) + + it.skipIf(process.platform !== 'win32')( + 'resolves through the Windows `Path` env spelling', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-')) + roots.push(root) + const cliPath = join(root, 'orca.cmd') + await writeFile(cliPath, '@echo off\r\n', 'utf8') + + expect(resolveClaudeAgentTeamsShimBin({ Path: root })).toBe(cliPath) + } + ) + + it('falls back to in-process teammates when no absolute CLI can be qualified', async () => { + const createTeamEnv = (): Record => { + throw new Error('native shim env must not be built without a qualified CLI') + } + + await expect( + buildClaudeAgentTeamsLaunchPlan({ + command: 'claude', + mode: 'native-panes-shim', + baseEnv: { PATH: '.' }, + createTeamEnv + }) + ).resolves.toEqual({ + command: 'claude --teammate-mode in-process', + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } + }) + }) + + it.skipIf(process.platform === 'win32')( + 'never runs a cwd-resolved orca when the shim bin is unqualified', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-shim-')) + roots.push(root) + await ensureClaudeAgentTeamsShimDir(root) + const cwd = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cwd-')) + roots.push(cwd) + const marker = join(cwd, 'hijacked') + for (const name of ['orca', 'orca-ide']) { + const decoy = join(cwd, name) + await writeFile(decoy, `#!/usr/bin/env sh\ntouch ${JSON.stringify(marker)}\n`, 'utf8') + await chmod(decoy, 0o755) + } + + const hijack = spawnSync(join(root, 'tmux'), ['display-message', '-p', '#{pane_id}'], { + cwd, + env: { PATH: `.:${process.env.PATH ?? ''}` }, + encoding: 'utf8' + }) + + expect(hijack.status).toBe(127) + expect(hijack.stderr).toContain('absolute path') + expect(existsSync(marker)).toBe(false) + + const cli = join(cwd, 'fake-orca') + await writeFile(cli, '#!/usr/bin/env sh\necho "ran $*"\n', 'utf8') + await chmod(cli, 0o755) + const qualified = spawnSync(join(root, 'tmux'), ['list-panes'], { + cwd, + env: { PATH: `.:${process.env.PATH ?? ''}`, ORCA_AGENT_TEAMS_SHIM_BIN: cli }, + encoding: 'utf8' + }) + + expect(qualified.status).toBe(0) + expect(qualified.stdout.trim()).toBe('ran agent-teams-tmux list-panes') + } + ) + + it('writes a Windows shim that rejects an unqualified shim bin', () => { + const script = windowsClaudeAgentTeamsShimScript() + + expect(script).not.toMatch(/^set "ORCA_AGENT_TEAMS_SHIM_BIN=orca/m) + expect(script).toContain('if "%ORCA_SHIM_BIN:~1,1%"==":" goto :run') + // Why: `call` would re-expand `%2`-style tmux pane args as batch parameters. + expect(script).toContain('\r\n"%ORCA_SHIM_BIN%" agent-teams-tmux %*\r\n') + expect(script).toContain('exit /b 127') + }) }) diff --git a/src/main/runtime/claude-agent-teams-shim-env.ts b/src/main/runtime/claude-agent-teams-shim-env.ts index 7f6c3a1922e..b8ff1b5a5c0 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.ts @@ -1,7 +1,7 @@ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { accessSync, constants, existsSync } from 'node:fs' import { homedir } from 'node:os' -import { delimiter, dirname, join } from 'node:path' +import { delimiter, dirname, isAbsolute, join } from 'node:path' import { addClaudeTeammateModeAuto, addClaudeTeammateModeInProcess, @@ -9,6 +9,7 @@ import { type ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' import { getOrcaCliCommandNameForPlatform } from '../../shared/orca-cli-command-name' +import { resolvePathEnvKey } from '../pty/windows-path-segment-merge' export type ClaudeAgentTeamsLaunchPlan = { command: string @@ -20,7 +21,7 @@ export async function ensureClaudeAgentTeamsShimDir(root = defaultShimRoot()): P await mkdir(root, { recursive: true }) await writeIfChanged(join(root, 'tmux'), unixShimScript()) if (process.platform === 'win32') { - await writeIfChanged(join(root, 'tmux.cmd'), windowsShimScript()) + await writeIfChanged(join(root, 'tmux.cmd'), windowsClaudeAgentTeamsShimScript()) } return root } @@ -41,8 +42,15 @@ export async function buildClaudeAgentTeamsLaunchPlan(args: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } } } - const shimDir = await ensureClaudeAgentTeamsShimDir() const shimBin = resolveClaudeAgentTeamsShimBin(args.baseEnv) + if (!shimBin) { + // Why: without an absolute CLI path the shim would resolve a bare `orca` against the pane cwd, so degrade instead. + return { + command: addClaudeTeammateModeInProcess(args.command), + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } + } + } + const shimDir = await ensureClaudeAgentTeamsShimDir() const env = args.createTeamEnv(shimDir, shimBin) return { command: addClaudeTeammateModeAuto(args.command), @@ -51,20 +59,27 @@ export async function buildClaudeAgentTeamsLaunchPlan(args: { } } +/** Absolute path to the Orca CLI that backs the tmux shim, or null when none can be qualified. */ export function resolveClaudeAgentTeamsShimBin( env: Record = process.env -): string { - if (env.ORCA_AGENT_TEAMS_SHIM_BIN) { - return env.ORCA_AGENT_TEAMS_SHIM_BIN +): string | null { + // Why: Windows callers pass an env spelt `Path`; reading only `PATH` there would find no CLI at all. + const pathValue = env[resolvePathEnvKey(env, process.platform)] + const override = env.ORCA_AGENT_TEAMS_SHIM_BIN + if (override) { + // Why: a bare override name would be resolved by the shim's shell against its cwd, so qualify it or ignore it. + const qualified = isAbsolute(override) ? override : findExecutableOnPath(override, pathValue) + if (qualified) { + return qualified + } } const bundled = bundledLauncherPath() if (bundled && isExecutableFile(bundled)) { return bundled } return ( - findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', env.PATH) ?? - findExecutableOnPath(getOrcaCliCommandNameForPlatform(process.platform), env.PATH) ?? - getOrcaCliCommandNameForPlatform(process.platform) + findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', pathValue) ?? + findExecutableOnPath(getOrcaCliCommandNameForPlatform(process.platform), pathValue) ) } @@ -90,7 +105,8 @@ function bundledLauncherPath(): string | null { function findExecutableOnPath(command: string, pathValue: string | undefined): string | null { for (const directory of pathValue?.split(delimiter) ?? []) { - if (!directory) { + // Why: empty and relative PATH entries resolve against a cwd we do not control, which is the hijack we are avoiding. + if (!directory || !isAbsolute(directory)) { continue } const candidate = join(directory, command) @@ -113,23 +129,42 @@ function isExecutableFile(candidate: string): boolean { } } +// Why: an unqualified command name is resolved against the invoking pane's cwd (always on cmd.exe, and via `.`/empty +// PATH entries on POSIX), so a stray `orca` next to the agent's files would run with the team token. Demand a +// fully-qualified binary instead of guessing one. function unixShimScript(): string { return [ '#!/usr/bin/env sh', 'set -eu', - `exec "\${ORCA_AGENT_TEAMS_SHIM_BIN:-${getOrcaCliCommandNameForPlatform(process.platform)}}" agent-teams-tmux "$@"`, + 'orca_bin=${ORCA_AGENT_TEAMS_SHIM_BIN:-}', + 'case $orca_bin in', + ' /*|[A-Za-z]:[\\\\/]*) ;;', + ' *)', + ' echo "orca agent-teams tmux shim: ORCA_AGENT_TEAMS_SHIM_BIN must be an absolute path" >&2', + ' exit 127', + ' ;;', + 'esac', + 'exec "$orca_bin" agent-teams-tmux "$@"', '' ].join('\n') } -function windowsShimScript(): string { +export function windowsClaudeAgentTeamsShimScript(): string { return [ '@echo off', 'setlocal', - 'if "%ORCA_AGENT_TEAMS_SHIM_BIN%"=="" (', - ` set "ORCA_AGENT_TEAMS_SHIM_BIN=${getOrcaCliCommandNameForPlatform(process.platform)}"`, - ')', - '"%ORCA_AGENT_TEAMS_SHIM_BIN%" agent-teams-tmux %*', + 'set "ORCA_SHIM_BIN=%ORCA_AGENT_TEAMS_SHIM_BIN%"', + 'if not defined ORCA_SHIM_BIN goto :unqualified', + 'if "%ORCA_SHIM_BIN:~1,1%"==":" goto :run', + 'if "%ORCA_SHIM_BIN:~0,2%"=="\\\\" goto :run', + 'goto :unqualified', + ':run', + // Why: no `call` — its extra percent-expansion pass would rewrite tmux pane args such as `%2` into batch parameters. + '"%ORCA_SHIM_BIN%" agent-teams-tmux %*', + 'exit /b %ERRORLEVEL%', + ':unqualified', + 'echo orca agent-teams tmux shim: ORCA_AGENT_TEAMS_SHIM_BIN must be an absolute path 1>&2', + 'exit /b 127', '' ].join('\r\n') } diff --git a/src/main/runtime/client-session-tab-selection-persistence.ts b/src/main/runtime/client-session-tab-selection-persistence.ts index 358144cb49d..7ae391a040d 100644 --- a/src/main/runtime/client-session-tab-selection-persistence.ts +++ b/src/main/runtime/client-session-tab-selection-persistence.ts @@ -1,7 +1,7 @@ import type { PersistedMobileClientTabSelection, PersistedMobileClientTabSelections -} from '../../shared/types' +} from '../../shared/persisted-state-types' function normalizeClientSessionTabSelection( raw: unknown diff --git a/src/main/runtime/client-session-tab-selection.test.ts b/src/main/runtime/client-session-tab-selection.test.ts index b0db68c5ca5..eb83e6144ca 100644 --- a/src/main/runtime/client-session-tab-selection.test.ts +++ b/src/main/runtime/client-session-tab-selection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' -import type { PersistedMobileClientTabSelections } from '../../shared/types' +import type { PersistedMobileClientTabSelections } from '../../shared/persisted-state-types' import { activateClientSessionTabSelection, ClientSessionTabSelectionStore, diff --git a/src/main/runtime/client-session-tab-selection.ts b/src/main/runtime/client-session-tab-selection.ts index 7c8f84f88d2..94aa42419de 100644 --- a/src/main/runtime/client-session-tab-selection.ts +++ b/src/main/runtime/client-session-tab-selection.ts @@ -2,7 +2,7 @@ import type { RuntimeMobileSessionClientTab, RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' -import type { PersistedMobileClientTabSelections } from '../../shared/types' +import type { PersistedMobileClientTabSelections } from '../../shared/persisted-state-types' import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence' export type ClientSessionTabSelection = { diff --git a/src/main/runtime/file-watcher-host.test.ts b/src/main/runtime/file-watcher-host.test.ts index 10f88f228f5..72608084a9a 100644 --- a/src/main/runtime/file-watcher-host.test.ts +++ b/src/main/runtime/file-watcher-host.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FsChangeEvent } from '../../shared/types' +import type { FsChangeEvent } from '../../shared/filesystem-entry-types' import type { WatcherProcessCallback, WatcherProcessHooks, diff --git a/src/main/runtime/file-watcher-host.ts b/src/main/runtime/file-watcher-host.ts index 59a8071bd58..e7f1d29e41d 100644 --- a/src/main/runtime/file-watcher-host.ts +++ b/src/main/runtime/file-watcher-host.ts @@ -1,4 +1,4 @@ -import type { FsChangeEvent } from '../../shared/types' +import type { FsChangeEvent } from '../../shared/filesystem-entry-types' import { forgetRuntimeWatcherProcessRoot, resetRuntimeWatcherProcessForTest, diff --git a/src/main/runtime/folder-workspace-pty-identity.test.ts b/src/main/runtime/folder-workspace-pty-identity.test.ts index db6b1c1927a..5c3c28e7ff6 100644 --- a/src/main/runtime/folder-workspace-pty-identity.test.ts +++ b/src/main/runtime/folder-workspace-pty-identity.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { makePaneKey } from '../../shared/stable-pane-id' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' import { OrcaRuntimeService } from './orca-runtime' diff --git a/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts b/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts index 338535562a4..5f846fee2d7 100644 --- a/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts +++ b/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts @@ -9,7 +9,7 @@ import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { OrcaRuntimeService } from './orca-runtime' // Freshness predicate of shouldApplyWebSessionTabsSnapshot in diff --git a/src/main/runtime/graph-sync-payload-partition.test.ts b/src/main/runtime/graph-sync-payload-partition.test.ts index 6533a53f9fb..fdf0bce733c 100644 --- a/src/main/runtime/graph-sync-payload-partition.test.ts +++ b/src/main/runtime/graph-sync-payload-partition.test.ts @@ -9,7 +9,7 @@ import type { RuntimeMobileSessionTabsSnapshot, RuntimeSyncWindowGraphResult } from '../../shared/runtime-types' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { OrcaRuntimeService } from './orca-runtime' const WT_A = 'repo-1::/tmp/worktree-a' diff --git a/src/main/runtime/headless-tab-group-split-layout.ts b/src/main/runtime/headless-tab-group-split-layout.ts index da7351d49bd..9e3d56b5f73 100644 --- a/src/main/runtime/headless-tab-group-split-layout.ts +++ b/src/main/runtime/headless-tab-group-split-layout.ts @@ -1,5 +1,5 @@ import type { RuntimeMobileSessionTabGroup } from '../../shared/runtime-types' -import type { TabGroupLayoutNode } from '../../shared/types' +import type { TabGroupLayoutNode } from '../../shared/tab-types' /** * Headless ("Orca server") tab-GROUP split operations (distinct from terminal diff --git a/src/main/runtime/headless-terminal-query-reply-policy.ts b/src/main/runtime/headless-terminal-query-reply-policy.ts index 05b1883efff..fca7c623331 100644 --- a/src/main/runtime/headless-terminal-query-reply-policy.ts +++ b/src/main/runtime/headless-terminal-query-reply-policy.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../shared/types' +import type { TuiAgent } from '../../shared/tui-agent' /* oxlint-disable no-control-regex -- XTVERSION replies are DCS control sequences. */ const XTVERSION_REPLY = new RegExp('^\u001bP>\\|[^\u001b]*\u001b\\\\$') diff --git a/src/main/runtime/headless-terminal-split-layout.test.ts b/src/main/runtime/headless-terminal-split-layout.test.ts index 0fa0d33b1c0..bb745d49c61 100644 --- a/src/main/runtime/headless-terminal-split-layout.test.ts +++ b/src/main/runtime/headless-terminal-split-layout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { TerminalLayoutSnapshot } from '../../shared/types' +import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' import { buildHeadlessTerminalSplitLayout, countTerminalLayoutLeaves, diff --git a/src/main/runtime/headless-terminal-split-layout.ts b/src/main/runtime/headless-terminal-split-layout.ts index 834625f9ef3..681d70451e8 100644 --- a/src/main/runtime/headless-terminal-split-layout.ts +++ b/src/main/runtime/headless-terminal-split-layout.ts @@ -1,4 +1,7 @@ -import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../shared/types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../shared/terminal-tab-types' export function terminalLayoutContainsLeaf( node: TerminalPaneLayoutNode | null | undefined, diff --git a/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts b/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts index 22cf484a13e..329dd2319d2 100644 --- a/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts +++ b/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { IPtyProvider } from '../providers/types' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import type { OrcaRuntimeService } from './orca-runtime' import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation' diff --git a/src/main/runtime/missing-worktree-terminal-reconciliation.ts b/src/main/runtime/missing-worktree-terminal-reconciliation.ts index 25cd1f2129d..11f888a5404 100644 --- a/src/main/runtime/missing-worktree-terminal-reconciliation.ts +++ b/src/main/runtime/missing-worktree-terminal-reconciliation.ts @@ -1,5 +1,5 @@ import type { IPtyProvider } from '../providers/types' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import { splitWorktreeId } from '../../shared/worktree/id' import { mapWithConcurrency } from '../../shared/map-with-concurrency' import type { OrcaRuntimeService } from './orca-runtime' diff --git a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts index 017b0f6ab47..e94155f342c 100644 --- a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts @@ -1,4 +1,5 @@ -import type { WorkspaceSessionState, WorkspaceVisibleTabType } from '../../shared/types' +import type { WorkspaceVisibleTabType } from '../../shared/tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { pruneTabGroupLayoutAfterRetirement, repairMobileSessionTabGroupsAfterRetirement, diff --git a/src/main/runtime/mobile-session-terminal-retirement.ts b/src/main/runtime/mobile-session-terminal-retirement.ts index acd75fb5580..9aaf69c7fb4 100644 --- a/src/main/runtime/mobile-session-terminal-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-retirement.ts @@ -4,11 +4,11 @@ import type { RuntimeMobileSessionTabsSnapshot, RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' +import type { TabGroupLayoutNode } from '../../shared/tab-types' import type { - TabGroupLayoutNode, TerminalLayoutSnapshot, TerminalPaneLayoutNode -} from '../../shared/types' +} from '../../shared/terminal-tab-types' export type RetiredTerminalSurface = { worktreeId: string diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts index 50fd09a4639..5d683dc77dd 100644 --- a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket from 'ws' import { parsePairingCode } from '../../shared/pairing' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' -import type { PersistedMobileClientTabSelections } from '../../shared/types' +import type { PersistedMobileClientTabSelections } from '../../shared/persisted-state-types' import { OrcaRuntimeService } from './orca-runtime' import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' import { OrcaRuntimeRpcServer } from './runtime-rpc' diff --git a/src/main/runtime/orca-runtime-automations.test.ts b/src/main/runtime/orca-runtime-automations.test.ts index 0c6d32d215d..14236869a29 100644 --- a/src/main/runtime/orca-runtime-automations.test.ts +++ b/src/main/runtime/orca-runtime-automations.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import type { Automation } from '../../shared/automations-types' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' const repo: Repo = { id: 'repo-1', diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts index 5cc74aa4ad4..007a091745d 100644 --- a/src/main/runtime/orca-runtime-browser.test.ts +++ b/src/main/runtime/orca-runtime-browser.test.ts @@ -329,6 +329,56 @@ describe('RuntimeBrowserCommands browser screencast', () => { ) }) + it('returns the renderer page identity before navigation settles', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const navigation = deferred<{ title: string; url: string }>() + const webContents = { send: vi.fn() } + webContents.send = vi.fn((_channel: string, data: { requestId: string }) => { + const handler = ipcMainOnMock.mock.calls.find( + ([eventName]) => eventName === 'browser:tabCreateReply' + )?.[1] as + | ((event: unknown, reply: { requestId: string; browserPageId?: string }) => void) + | undefined + handler?.({ sender: webContents } as never, { + requestId: data.requestId, + browserPageId: 'page-slow-navigation' + }) + }) + const bridge = { + getRegisteredTabs: vi.fn(() => new Map([['page-slow-navigation', 101]])), + goto: vi.fn(() => navigation.promise), + setActiveTab: vi.fn() + } as unknown as AgentBrowserBridge + const commands = new RuntimeBrowserCommands( + createHost({ + getAgentBrowserBridge: () => bridge, + getAvailableAuthoritativeWindow: vi.fn(() => ({}) as never), + getAuthoritativeWindow: vi.fn(() => ({ webContents }) as never) + }) + ) + + let created: { browserPageId: string } | null = null + const creation = commands + .browserTabCreate({ + worktree: 'id:wt-1', + url: 'https://example.com/slow', + waitForRegistration: true + }) + .then((result) => { + created = result + return result + }) + await vi.waitFor(() => expect(bridge.goto).toHaveBeenCalledOnce()) + await new Promise((resolve) => setImmediate(resolve)) + + try { + expect(created).toEqual({ browserPageId: 'page-slow-navigation' }) + } finally { + navigation.resolve({ title: 'Slow page', url: 'https://example.com/slow' }) + await creation + } + }) + it('rejects unknown explicit profile ids before requesting a renderer tab', async () => { const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') const send = vi.fn() @@ -509,7 +559,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { expect(result.browserPageId).toBe('page-target') }) - it('wakes the requested page before closing a page-scoped tab', async () => { + it('closes the requested renderer page without waiting for guest registration', async () => { const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true }) const send = vi.fn((channel: string, data: { requestId?: string }) => { @@ -537,11 +587,8 @@ describe('RuntimeBrowserCommands browser screencast', () => { commands.browserTabClose({ worktree: 'id:wt-1', page: 'page-target' }) ).resolves.toEqual({ closed: true }) - expect(send).toHaveBeenCalledWith('browser:activateView', { - worktreeId: 'wt-1', - browserPageId: 'page-target' - }) - expect(waitForTabRegistrationMock).toHaveBeenCalledWith('page-target') + expect(send).not.toHaveBeenCalledWith('browser:activateView', expect.anything()) + expect(waitForTabRegistrationMock).not.toHaveBeenCalled() expect(waitForWorktreeTabRegistrationMock).not.toHaveBeenCalled() expect(send).toHaveBeenCalledWith( 'browser:requestTabClose', @@ -549,6 +596,42 @@ describe('RuntimeBrowserCommands browser screencast', () => { ) }) + it('lets the renderer close an acknowledged page whose guest never registered', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const send = vi.fn((channel: string, data: { requestId?: string }) => { + if (channel !== 'browser:requestTabClose') { + return + } + const handler = ipcMainOnMock.mock.calls.find( + ([eventName]) => eventName === 'browser:tabCloseReply' + )?.[1] as ((event: unknown, reply: { requestId: string }) => void) | undefined + handler?.({} as never, { requestId: data.requestId ?? '' }) + }) + const bridge = { + getRegisteredTabs: vi.fn(() => new Map()), + getActivePageId: vi.fn(() => null), + getActiveWebContentsId: vi.fn(() => null) + } as unknown as AgentBrowserBridge + const authoritativeWindow = { webContents: { send } } as never + const commands = new RuntimeBrowserCommands( + createHost({ + getAgentBrowserBridge: () => bridge, + getAvailableAuthoritativeWindow: vi.fn(() => authoritativeWindow), + getAuthoritativeWindow: vi.fn(() => authoritativeWindow) + }) + ) + + await expect( + commands.browserTabClose({ worktree: 'id:wt-1', page: 'page-canonical' }) + ).resolves.toEqual({ closed: true }) + + expect(waitForTabRegistrationMock).not.toHaveBeenCalled() + expect(send).toHaveBeenCalledWith( + 'browser:requestTabClose', + expect.objectContaining({ tabId: 'page-canonical', worktreeId: 'wt-1' }) + ) + }) + it('lets a new same-page stream take over an active stale stream', async () => { const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false }) @@ -648,6 +731,34 @@ describe('RuntimeBrowserCommands headless offscreen routing', () => { expect(setActiveTab).toHaveBeenCalledWith(202, 'wt-1') }) + it('publishes a headless session snapshot after explicit navigation', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false }) + const notifyHeadlessBrowserSessionTabsChanged = vi.fn() + const bridge = { + getRegisteredTabs: vi.fn(() => new Map([['page-offscreen', 202]])), + getActivePageId: vi.fn(() => 'page-offscreen'), + goto: vi.fn(async () => ({ title: 'Loaded', url: 'https://example.com/loaded' })) + } as unknown as AgentBrowserBridge + const commands = new RuntimeBrowserCommands( + createHost({ + getAgentBrowserBridge: () => bridge, + getAvailableAuthoritativeWindow: vi.fn(() => null), + notifyHeadlessBrowserSessionTabsChanged + }) + ) + + await expect( + commands.browserGoto({ + worktree: 'id:wt-1', + page: 'page-offscreen', + url: 'https://example.com/loaded' + }) + ).resolves.toEqual({ title: 'Loaded', url: 'https://example.com/loaded' }) + + expect(notifyHeadlessBrowserSessionTabsChanged).toHaveBeenCalledWith('wt-1') + }) + it('rejects tab creation when neither a renderer nor an offscreen backend is available', async () => { const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') const commands = new RuntimeBrowserCommands( @@ -686,6 +797,10 @@ describe('RuntimeBrowserCommands headless offscreen routing', () => { expect(closeTab).toHaveBeenCalledWith('page-offscreen') // The renderer close IPC must not be used in headless mode. expect(ipcMainOnMock).not.toHaveBeenCalledWith('browser:tabCloseReply', expect.anything()) + await expect( + commands.browserTabClose({ worktree: 'id:wt-1', page: 'page-other' }) + ).rejects.toMatchObject({ code: 'browser_tab_not_found' }) + expect(closeTab).toHaveBeenCalledTimes(1) }) it('closes the active headless tab on an implicit close', async () => { diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 6070cefd374..e076eeadad0 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -52,7 +52,7 @@ import type { import type { BrowserCertificateProceedResult, BrowserSessionUserAgentMode -} from '../../shared/types' +} from '../../shared/browser-workspace-types' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' @@ -156,6 +156,7 @@ export type RuntimeBrowserCommandHost = { browserPageId: string, targetGroupId?: string ): void + notifyHeadlessBrowserSessionTabsChanged?(worktreeId: string): void } export class RuntimeBrowserCommands { @@ -358,6 +359,9 @@ export class RuntimeBrowserCommands { if (pageId) { this.notifyRendererNavigation(pageId, result.url, result.title) } + if (!this.host.getAvailableAuthoritativeWindow() && target.worktreeId) { + this.host.notifyHeadlessBrowserSessionTabsChanged?.(target.worktreeId) + } return result } @@ -1294,6 +1298,7 @@ export class RuntimeBrowserCommands { async browserTabCreate(params: { url?: string worktree?: string + page?: string profileId?: string waitForRegistration?: boolean activate?: boolean @@ -1322,7 +1327,8 @@ export class RuntimeBrowserCommands { worktreeId, params.profileId, params.activate, - params.targetGroupId + params.targetGroupId, + params.page ) } const { browserPageId } = await this.createBrowserTabInRenderer( @@ -1330,7 +1336,8 @@ export class RuntimeBrowserCommands { worktreeId, params.profileId, params.profileId ? sessionPartition : undefined, - params.activate + params.activate, + params.page ) // Why: the webview must mount and register before the tab is operable, so wait here (returning the ID anyway on timeout). @@ -1351,9 +1358,19 @@ export class RuntimeBrowserCommands { // Why: the webview loads about:blank first; route navigation through the bridge so its registered owner remains authoritative. if (url && url !== 'about:blank') { - try { + const navigate = async (): Promise => { const result = await bridge.goto(url, worktreeId, browserPageId) this.notifyRendererNavigation(browserPageId, result.url, result.title) + if (!this.host.getAvailableAuthoritativeWindow() && worktreeId) { + this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktreeId) + } + } + if (params.waitForRegistration === true) { + void navigate().catch(() => {}) + return { browserPageId } + } + try { + await navigate() } catch { // Tab exists but navigation failed — caller can retry with explicit goto } @@ -1587,22 +1604,15 @@ export class RuntimeBrowserCommands { worktree?: string }): Promise<{ closed: boolean }> { const bridge = this.requireAgentBrowserBridge() - const pageTarget = - typeof params.page === 'string' && params.page.length > 0 - ? await this.resolveBrowserCommandTarget({ worktree: params.worktree, page: params.page }) - : null - const worktreeId = - pageTarget?.worktreeId ?? (await this.resolveBrowserWorktreeId(params.worktree)) + const explicitPage = typeof params.page === 'string' && params.page.length > 0 + const worktreeId = explicitPage + ? params.worktree + ? (await this.host.resolveWorktreeSelector(params.worktree)).id + : undefined + : await this.resolveBrowserWorktreeId(params.worktree) let tabId: string | null = null if (typeof params.page === 'string' && params.page.length > 0) { - if (!bridge.getRegisteredTabs(worktreeId).has(params.page)) { - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${params.page} was not found${scope}` - ) - } tabId = params.page } else if (params.index !== undefined) { const tabs = bridge.getRegisteredTabs(worktreeId) @@ -1622,20 +1632,31 @@ export class RuntimeBrowserCommands { } // Why: headless serve has no renderer to ask, so destroy the offscreen page directly. - const offscreen = this.host.getAvailableAuthoritativeWindow() - ? null - : this.host.getOffscreenBrowserBackend() + const authoritativeWindow = this.host.getAvailableAuthoritativeWindow() + const offscreen = authoritativeWindow ? null : this.host.getOffscreenBrowserBackend() if (offscreen) { // Why: resolve the active page for implicit close so we don't report success while closing nothing. const resolvedTabId = tabId ?? bridge.getActivePageId(worktreeId) if (!resolvedTabId) { return { closed: false } } + if (explicitPage && !bridge.getRegisteredTabs(worktreeId).has(resolvedTabId)) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${resolvedTabId} was not found${scope}` + ) + } await offscreen.closeTab(resolvedTabId) return { closed: true } } - const win = this.host.getAuthoritativeWindow() + if (!authoritativeWindow && tabId && !bridge.getRegisteredTabs(worktreeId).has(tabId)) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError('browser_tab_not_found', `Browser page ${tabId} was not found${scope}`) + } + + const win = authoritativeWindow ?? this.host.getAuthoritativeWindow() const requestId = randomUUID() await new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -1645,7 +1666,7 @@ export class RuntimeBrowserCommands { const handler = ( _event: Electron.IpcMainEvent, - reply: { requestId: string; error?: string } + reply: { requestId: string; error?: string; code?: 'browser_tab_not_found' } ): void => { if (reply.requestId !== requestId) { return @@ -1653,7 +1674,11 @@ export class RuntimeBrowserCommands { clearTimeout(timer) ipcMain.removeListener('browser:tabCloseReply', handler) if (reply.error) { - reject(new Error(reply.error)) + reject( + reply.code === 'browser_tab_not_found' + ? new BrowserError('browser_tab_not_found', reply.error) + : new Error(reply.error) + ) } else { resolve() } @@ -1706,9 +1731,15 @@ export class RuntimeBrowserCommands { worktreeId?: string, profileId?: string, activate?: boolean, - targetGroupId?: string + targetGroupId?: string, + requestedPageId?: string ): Promise<{ browserPageId: string }> { - const { browserPageId } = await offscreen.createTab({ url, worktreeId, profileId }) + const { browserPageId } = await offscreen.createTab({ + url, + worktreeId, + profileId, + ...(requestedPageId ? { browserPageId: requestedPageId } : {}) + }) const bridge = this.host.getAgentBrowserBridge() const wcId = bridge?.getRegisteredTabs(worktreeId).get(browserPageId) if (bridge && wcId != null) { @@ -1726,7 +1757,8 @@ export class RuntimeBrowserCommands { worktreeId: string | undefined, profileId: string | undefined, sessionPartition: string | undefined, - activate?: boolean + activate?: boolean, + requestedPageId?: string ): Promise<{ browserPageId: string }> { const win = this.host.getAuthoritativeWindow() const requestId = randomUUID() @@ -1757,6 +1789,7 @@ export class RuntimeBrowserCommands { requestId, url, worktreeId, + ...(requestedPageId ? { browserPageId: requestedPageId } : {}), // Why: keep these undefined (not null) when no profile is chosen so the renderer still applies default-profile inheritance. sessionProfileId: profileId, sessionPartition, diff --git a/src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts b/src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts index 23f7adc4df8..5735b6dabc1 100644 --- a/src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts +++ b/src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { EmulatorSessionInfo } from '../emulator/emulator-types' -import type { FolderWorkspace } from '../../shared/types' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' import { OrcaRuntimeService } from './orca-runtime' const FOLDER_WORKSPACE_ID = 'folder-workspace-1' diff --git a/src/main/runtime/orca-runtime-emulator.ts b/src/main/runtime/orca-runtime-emulator.ts index 7c82980c2a3..2e08614b8df 100644 --- a/src/main/runtime/orca-runtime-emulator.ts +++ b/src/main/runtime/orca-runtime-emulator.ts @@ -11,7 +11,7 @@ import type { EmulatorGesturePoint } from '../emulator/emulator-gesture-sender' import type { EmulatorSessionInfo } from '../emulator/emulator-types' import type { SimulatorDevice } from '../emulator/simctl-simulator-devices' import type { EmulatorDevice } from '../emulator/backends/emulator-backend' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' // Settings slice the emulator surface needs; keeps the host contract honest (no widening cast). type EmulatorHostSettings = Pick< diff --git a/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts b/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts index f25f6c9051a..28d734cab63 100644 --- a/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts +++ b/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FsChangeEvent } from '../../shared/types' +import type { FsChangeEvent } from '../../shared/filesystem-entry-types' const { resolveAuthorizedPathMock, diff --git a/src/main/runtime/orca-runtime-files-watch.test.ts b/src/main/runtime/orca-runtime-files-watch.test.ts index 41c57b5c44f..e89bc26958b 100644 --- a/src/main/runtime/orca-runtime-files-watch.test.ts +++ b/src/main/runtime/orca-runtime-files-watch.test.ts @@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events' import type * as Fs from 'node:fs' import type * as FsPromises from 'node:fs/promises' import type * as FilesystemAuth from '../ipc/filesystem-auth' -import type { FsChangeEvent } from '../../shared/types' +import type { FsChangeEvent } from '../../shared/filesystem-entry-types' import { WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate' 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 fd8f76796a6..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, @@ -20,15 +19,9 @@ import { } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { basename, dirname, extname, join } from 'node:path' -import type { - DirEntry, - FsChangeEvent, - GitWorktreeInfo, - MarkdownDocument, - SearchOptions, - SearchResult, - Worktree -} from '../../shared/types' +import type { SearchOptions, SearchResult } from '../../shared/code-search-types' +import type { DirEntry, FsChangeEvent, MarkdownDocument } from '../../shared/filesystem-entry-types' +import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import { isPathInsideOrEqual, isRuntimePathAbsolute, @@ -37,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 { @@ -82,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 @@ -96,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 @@ -1165,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, @@ -1176,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() } @@ -1279,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 } @@ -1502,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) { @@ -1511,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( @@ -2392,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()) { @@ -2401,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-branch-diff.test.ts b/src/main/runtime/orca-runtime-git-branch-diff.test.ts index 4c469758760..aba155a6895 100644 --- a/src/main/runtime/orca-runtime-git-branch-diff.test.ts +++ b/src/main/runtime/orca-runtime-git-branch-diff.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' const getSshGitProviderMock = vi.hoisted(() => vi.fn()) 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.test.ts b/src/main/runtime/orca-runtime-git.test.ts index 68c6ff798c9..26b993704f2 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import type * as GitStatusModule from '../git/status' import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation' import type * as PullRequestContextModule from '../text-generation/pull-request-context' diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 67812240e43..c90f6cfef27 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -2,21 +2,21 @@ import type { GitBranchCompareResult, GitCommitCompareResult, + GitDiffResult +} from '../../shared/git-diff-compare-types' +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitConflictOperation, - GitDiffResult, - GitForkSyncExpectedUpstream, - GitForkSyncResult, - GitPushTarget, GitStagingArea, GitStatusResult, - GitUpstreamStatus, - GitWorktreeInfo, - GlobalSettings, - Repo, - TuiAgent, - Worktree -} from '../../shared/types' + GitUpstreamStatus +} from '../../shared/git-status-types' +import type { GlobalSettings } from '../../shared/global-settings-types' +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 { @@ -342,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) @@ -355,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 ) } @@ -523,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) @@ -539,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) @@ -573,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-headless-hydration-repo-gate.test.ts b/src/main/runtime/orca-runtime-headless-hydration-repo-gate.test.ts index 8bca2c77ed4..75f0c4fd31e 100644 --- a/src/main/runtime/orca-runtime-headless-hydration-repo-gate.test.ts +++ b/src/main/runtime/orca-runtime-headless-hydration-repo-gate.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession } from '../../shared/constants' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { OrcaRuntimeService } from './orca-runtime' const WORKTREE_ID = 'repo::/worktree' diff --git a/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts b/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts index 39298111388..9b9bcfac784 100644 --- a/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts +++ b/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeMeta } from '../../shared/types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type * as GitStatusModule from '../git/status' import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation' import type * as WorktreeModule from '../git/worktree' diff --git a/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts index 1534be0deaa..30ef460b587 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { OrcaRuntimeService } from './orca-runtime' const CONNECTION_ID = 'conn-1' diff --git a/src/main/runtime/orca-runtime-terminal-retirement.test.ts b/src/main/runtime/orca-runtime-terminal-retirement.test.ts index 342017557cc..ae441b48d44 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement.test.ts @@ -5,7 +5,7 @@ import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { sanitizeWorkspaceSessionTerminalRetirements } from './mobile-session-terminal-persistence-retirement' import { OrcaRuntimeService } from './orca-runtime' 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 3aa6ae47603..ca79f9e55a8 100644 --- a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' -import type { TerminalLayoutSnapshot, WorkspaceSessionState } from '../../shared/types' +import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { makePaneKey } from '../../shared/stable-pane-id' import { OrcaRuntimeService } from './orca-runtime' @@ -375,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 9beb782cebc..3ee35d809df 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -10,16 +10,13 @@ import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { basename, join, win32 } from 'node:path' import { ipcMain } from 'electron' -import type { - FolderWorkspace, - ProjectGroup, - Tab, - TerminalLayoutSnapshot, - WorktreeLineage, - WorktreeMeta, - WorkspaceLineage, - WorkspaceSessionState -} from '../../shared/types' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../shared/project-group-types' +import type { Tab } from '../../shared/tab-types' +import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' import { reviewHeadRemoteRefComponent, @@ -66,6 +63,7 @@ import { resolveWorktreeScanCacheTtlMs, type RuntimeTerminalAgentStatusEvent } from './orca-runtime' +import { RUNTIME_GRAPH_RELOAD_TIMEOUT_MS } from './runtime-graph-reload-lifecycle' import { appendRecentPtyPathCandidates, recentTerminalPathCandidatesIncludePath, @@ -1690,6 +1688,7 @@ describe('OrcaRuntimeService', () => { } as never) expect(runtime.getClientSettings()).toMatchObject({ + worktreeVisibilityDefaults: { external: 'hide' }, experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true, minimaxGroupId: 'group-42', @@ -1815,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 = { @@ -2492,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() @@ -2507,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() @@ -3883,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 () => { @@ -8246,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() @@ -14263,6 +14495,8 @@ describe('OrcaRuntimeService', () => { command: 'claude --resume claude-session', env: { CLAUDE_PROFILE: 'captured', + // Why: native panes need an absolute CLI; without one the plan degrades to in-process teammates. + ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca-ide', ORCA_AGENT_TEAMS_TEAM_ID: 'stale-team', ORCA_AGENT_TEAMS_TOKEN: 'stale-token', TMUX: '/tmp/orca-claude-agent-teams/stale-team,0,1' @@ -14273,6 +14507,7 @@ describe('OrcaRuntimeService', () => { agentArgs: '--teammate-mode auto', agentEnv: { CLAUDE_PROFILE: 'captured', + ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca-ide', ORCA_AGENT_TEAMS_TEAM_ID: 'stale-team', ORCA_AGENT_TEAMS_TOKEN: 'stale-token', TMUX: '/tmp/orca-claude-agent-teams/stale-team,0,1' @@ -14287,6 +14522,7 @@ describe('OrcaRuntimeService', () => { expect(spawnCall?.env).toMatchObject({ CLAUDE_PROFILE: 'captured', CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca-ide', TMUX_PANE: '%1' }) expect(spawnCall?.env?.ORCA_AGENT_TEAMS_TEAM_ID).toMatch(/^team-/) @@ -16394,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() @@ -16494,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 { @@ -16522,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) @@ -36060,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 e8df563c478..926318587da 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -248,24 +248,52 @@ import type { AutomationUpdateInput, AutomationWorkspaceMode } from '../../shared/automations-types' +import type { DirEntry, FilesystemPathFlavor } from '../../shared/filesystem-entry-types' +import type { FolderWorkspace, WorkspaceKey } from '../../shared/folder-workspace-types' +import type { + GitHubPRReviewCommentInput, + GitHubReactionContent +} from '../../shared/github/comment-types' +import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' +import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' +import type { ListWorkItemsResult } from '../../shared/github/work-item-types' +import type { + GitLabIssueUpdate, + GitLabMRInlineCommentInput, + GitLabProjectRef, + GitLabWorkItem, + MRListState +} from '../../shared/gitlab-types' +import type { GlobalSettings } from '../../shared/global-settings-types' import type { - AutomationWorkspaceProvenance, - CliWorkspaceProvenance, - BaseRefSearchResult, - CreateWorktreeResult, - DetectedWorktree, - DetectedWorktreeListResult, - ForceDeleteWorktreeBranchResult, - GitHubPrStartPoint, - GitPushTarget, - BranchPrefixStrategy, - GitWorktreeInfo, GitHubCreateIssueFields, - GitHubOwnerRepo, - GlobalSettings, - PersistedUIState, + GitHubIssueUpdate, + GitHubPullRequestStateUpdate, + LinearIssueUpdate +} from '../../shared/issue-mutation-types' +import type { + JiraConnectArgs, + JiraCreateIssueArgs, + JiraIssueFilter, + JiraIssueUpdate, + JiraSiteSelection +} from '../../shared/jira-types' +import type { LinearCustomViewModel, LinearProjectSummary } from '../../shared/linear/project-types' +import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-types' +import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState +} from '../../shared/managed-account-types' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import type { MemorySnapshot, StatsSummary } from '../../shared/process-stats-types' +import type { + NestedRepoScanResult, + ProjectGroup, + ProjectGroupImportMode, + ProjectGroupImportResult +} from '../../shared/project-group-types' +import type { Project, - ProjectUpdateArgs, ProjectHostSetup, ProjectHostSetupCloneArgs, ProjectHostSetupCreateArgs, @@ -276,60 +304,47 @@ import type { ProjectHostSetupResult, ProjectHostSetupUpdateArgs, ProjectHostSetupUpdateResult, - Repo, - RemoveWorktreeResult, - StatsSummary, - Worktree, - WorktreeLineage, - WorkspaceLineage, - WorkspaceKey, - WorktreeLineageWarning, - WorktreeMeta, - WorktreeBaseStatusEvent, - WorktreeRemoteBranchConflictEvent, - WorktreeStartupLaunch, - LinearCustomViewModel, - JiraConnectArgs, - JiraCreateIssueArgs, - JiraIssueFilter, - JiraIssueUpdate, - JiraSiteSelection, - LinearIssueUpdate, - LinearProjectSummary, - LinearWorkspaceSelection, - NestedRepoScanResult, - ProjectGroup, - FolderWorkspace, - ProjectGroupImportMode, - ProjectGroupImportResult, - MemorySnapshot, - Tab, - TabGroupLayoutNode, - TerminalQuickCommand, + ProjectUpdateArgs +} from '../../shared/project-types' +import type { BaseRefSearchResult, Repo } from '../../shared/repo-types' +import type { Tab, TabGroupLayoutNode } from '../../shared/tab-types' +import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types' +import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, - TerminalTab, - TuiAgent, - WorkspaceCreateTelemetrySource, - WorkspaceSessionState, + TerminalTab +} from '../../shared/terminal-tab-types' +import type { TuiAgent } from '../../shared/tui-agent' +import type { BranchPrefixStrategy } from '../../shared/ui-chrome-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../shared/workspace-source' +import type { + WorktreeBaseStatusEvent, + WorktreeRemoteBranchConflictEvent +} from '../../shared/worktree/base-ref-drift-types' +import type { + CreateWorktreeResult, + ForceDeleteWorktreeBranchResult, + RemoveWorktreeResult +} from '../../shared/worktree/create-types' +import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' +import type { + WorkspaceLineage, + WorktreeLineage, + WorktreeLineageWarning +} from '../../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { + AutomationWorkspaceProvenance, + CliWorkspaceProvenance, + DetectedWorktree, + DetectedWorktreeListResult, + GitHubPrStartPoint, + GitPushTarget, + GitWorktreeInfo, WorkspaceLinkedItem, - DirEntry, - FilesystemPathFlavor, - GitHubIssueUpdate, - GitHubPullRequestStateUpdate, - GitHubPRFile, - GitHubPRReviewCommentInput, - GitHubReactionContent, - GitLabIssueUpdate, - GitLabMRInlineCommentInput, - GitLabProjectRef, - GitLabWorkItem, - ListWorkItemsResult, - MRListState, - PRRefreshOutcome, - ClaudeRateLimitAccountsState, - CodexRateLimitAccountsState -} from '../../shared/types' + Worktree +} from '../../shared/worktree/types' import type { TaskSourceContext } from '../../shared/task-source-context' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' import { @@ -444,11 +459,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, @@ -565,7 +585,7 @@ import { import { isAgentScratchRepoRootPath } from '../../shared/agent-scratch-worktrees' import { createWorktreeVisibilitySourceMatcher, - normalizeCustomWorktreeVisibilitySources, + resolveCustomWorktreeVisibilitySources, type WorktreeVisibilitySourceMatcher } from '../../shared/worktree/visibility-sources' import { @@ -789,6 +809,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, @@ -1242,6 +1270,7 @@ type RuntimeStore = { localBaseRefSuggestionDismissed?: boolean branchPrefix: string branchPrefixCustom: string + worktreeVisibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults'] defaultTuiAgent?: GlobalSettings['defaultTuiAgent'] disabledTuiAgents?: GlobalSettings['disabledTuiAgents'] agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] @@ -1907,10 +1936,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. @@ -2803,6 +2832,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() @@ -2823,6 +2857,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() @@ -3592,6 +3638,7 @@ export class OrcaRuntimeService { getClientSettings(): Pick< GlobalSettings, + | 'worktreeVisibilityDefaults' | 'defaultTuiAgent' | 'disabledTuiAgents' | 'agentCmdOverrides' @@ -3619,6 +3666,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 ?? {}, @@ -3671,6 +3719,7 @@ export class OrcaRuntimeService { async updateClientSettings( updates: Pick< Partial, + | 'worktreeVisibilityDefaults' | 'agentStatusHooksEnabled' | 'defaultTuiAgent' | 'disabledTuiAgents' @@ -3691,6 +3740,7 @@ export class OrcaRuntimeService { ): Promise< Pick< GlobalSettings, + | 'worktreeVisibilityDefaults' | 'defaultTuiAgent' | 'disabledTuiAgents' | 'agentCmdOverrides' @@ -3717,6 +3767,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) || @@ -6302,11 +6355,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) { @@ -6383,13 +6443,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 @@ -6557,9 +6649,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)) @@ -10029,13 +10122,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 @@ -18254,7 +18351,7 @@ export class OrcaRuntimeService { suffixFailureError?: string } = {} ): Promise { - const renderGate = this.createClaudeAgentPromptRenderGate(ptyId) + const renderGate = this.createAgentPromptRenderGate(ptyId) let wrotePasteBytes = false let completedPaste = false try { @@ -18305,13 +18402,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 @@ -18344,7 +18442,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) { @@ -18352,11 +18456,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() }) @@ -18369,7 +18475,9 @@ export class OrcaRuntimeService { if (settled) { return } - hardTimer = setTimeout(finish, CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS) + if (!hardTimer) { + armHardTimer() + } await rendered }, dispose: () => { @@ -18655,7 +18763,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 @@ -18664,11 +18775,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. @@ -19691,7 +19816,6 @@ export class OrcaRuntimeService { badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: 'git', - externalWorktreeVisibility: 'hide', externalWorktreeVisibilityLegacy: false, ...(group ? { @@ -19828,12 +19952,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) @@ -19949,12 +20068,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) @@ -20123,7 +20237,6 @@ export class OrcaRuntimeService { ...detected, addedAt: Date.now(), kind: 'git', - externalWorktreeVisibility: 'hide', externalWorktreeVisibilityLegacy: false } this.store.addRepo(repo) @@ -20171,7 +20284,6 @@ export class OrcaRuntimeService { | 'kind' | 'symlinkPaths' | 'issueSourcePreference' - | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' @@ -20182,6 +20294,7 @@ export class OrcaRuntimeService { | 'projectGroupOrder' > > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null } @@ -20194,6 +20307,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 @@ -21971,22 +22087,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 { @@ -21998,25 +22127,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, @@ -22036,7 +22175,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}` @@ -22048,7 +22187,8 @@ export class OrcaRuntimeService { const detectedWorktree = this.toRuntimeDetectedWorktree( repo, worktree, - worktreeVisibilitySourceMatcher + worktreeVisibilitySourceMatcher, + visibilitySettings ) if (scan.ok) { return detectedWorktree @@ -22115,17 +22255,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) { @@ -22140,7 +22283,7 @@ export class OrcaRuntimeService { repo.id, createWorktreeVisibilitySourceMatcher( [repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])], - normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [] + resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults) ) ]) ) @@ -22149,9 +22292,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, @@ -28538,9 +28682,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 @@ -29365,16 +29517,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() @@ -29382,21 +29556,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 @@ -29414,6 +29643,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') @@ -29623,6 +29895,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[] @@ -30210,6 +30491,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 } @@ -30242,7 +30528,6 @@ export class OrcaRuntimeService { if (!this.store) { return { worktrees: [], platformByRepoId: new Map() } } - const now = Date.now() const metaById = this.store.getAllWorktreeMeta() ?? {} const repos = this.store.getRepos() const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(this.requireStore(), repos) @@ -30252,87 +30537,49 @@ 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(), this.store?.getAllWorktreeLineage?.() ?? {} ) // Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca. + // Why stamped on completion, not entry: a compute that spent longer than the TTL would otherwise publish an + // already-expired entry, so the very next poll recomputes and every caller repeats the same slow path. if (generation === this.resolvedWorktreeGeneration) { this.resolvedWorktreeCache = { worktrees, platformByRepoId, - expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS + expiresAt: Date.now() + RESOLVED_WORKTREE_CACHE_TTL_MS } } 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 @@ -30490,37 +30737,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> { @@ -36334,7 +36551,9 @@ export class OrcaRuntimeService { getAvailableAuthoritativeWindow: () => this.getAvailableAuthoritativeWindow(), getOffscreenBrowserBackend: () => this.offscreenBrowserBackend, // Why: bind directly, not a wrapper arrow — a hand-listed wrapper dropped targetGroupId, so a right-split browser landed in the left. - markHeadlessBrowserSessionTabActive: this.markHeadlessBrowserSessionTabActive.bind(this) + markHeadlessBrowserSessionTabActive: this.markHeadlessBrowserSessionTabActive.bind(this), + notifyHeadlessBrowserSessionTabsChanged: (worktreeId) => + this.notifyMobileSessionTabsChanged(worktreeId) }) private readonly emulatorCommands = new RuntimeEmulatorCommands({ @@ -36849,13 +37068,19 @@ 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 -// Why so generous: a healthy but slow host (100+ linked worktrees, Windows Defender, cold dentry -// cache, cloud placeholders) must still get to reuse its scan. Well under WORKTREE_LIST_TIMEOUT_MS, -// and expiring yields `null` — the existing "cannot prove unchanged" sentinel, so a real scan runs. -// Exceeding RESOLVED_WORKTREE_REPO_TIMEOUT_MS is deliberate, not a bug: the payoff is skipping a -// `git worktree list` subprocess, not cutting this caller's latency — that caller is already capped. -export const WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS = 10_000 -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. +const WORKTREE_SCAN_FALLBACK_ALLOWANCE_MS = 1500 +// Why derived from the caller's budget instead of a generous absolute: this wait runs *inside* +// RESOLVED_WORKTREE_REPO_TIMEOUT_MS, so outlasting it buys nothing — the caller has already given up +// and restored persisted rows — while turning a reusable scan into a full-budget stall that repeats +// on every TTL expiry. Subtracting keeps that invariant true by construction if either side moves. +// Why not smaller: the probe reads a subset of what the fallback scan reads, so a probe too slow to +// fit is a scan that will not fit either — waiting is strictly better right up to the budget. +// Expiring yields `null`, the existing "cannot prove unchanged" sentinel, so a real scan runs. +export const WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS = + RESOLVED_WORKTREE_REPO_TIMEOUT_MS - WORKTREE_SCAN_FALLBACK_ALLOWANCE_MS export function resolveWorktreeScanCacheTtlMs(repo: Pick): number { return !repo.connectionId && isAgentScratchRepoRootPath(repo.path) @@ -36941,21 +37166,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/orchestration-worker-workspace-resolution.test.ts b/src/main/runtime/orchestration-worker-workspace-resolution.test.ts index 4ab96e249c7..d95139045a7 100644 --- a/src/main/runtime/orchestration-worker-workspace-resolution.test.ts +++ b/src/main/runtime/orchestration-worker-workspace-resolution.test.ts @@ -29,7 +29,10 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' -import type { FolderWorkspace, ProjectGroup, Repo, WorktreeMeta } from '../../shared/types' +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 { WorktreeMeta } from '../../shared/worktree/meta-types' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index a4c10384735..dcf4bccdb24 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { getDefaultRepoHookSettings } from '../../shared/constants' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import { parsePairingCode } from '../../shared/pairing' import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection' import { RemoteRuntimeSharedControlConnection } from '../../shared/remote-runtime-shared-control-connection' diff --git a/src/main/runtime/remote-server-updater.ts b/src/main/runtime/remote-server-updater.ts index a61caa3d591..736713f4424 100644 --- a/src/main/runtime/remote-server-updater.ts +++ b/src/main/runtime/remote-server-updater.ts @@ -2,7 +2,7 @@ import type { RemoteServerUpdateInstallResult, RemoteServerUpdaterSnapshot } from '../../shared/remote-server-update' -import type { UpdateCheckOptions } from '../../shared/types' +import type { UpdateCheckOptions } from '../../shared/update-status-types' type RemoteServerUpdaterAdapter = { getSnapshot: (runtimeId: string) => RemoteServerUpdaterSnapshot diff --git a/src/main/runtime/repo-icon-fork-backfill.test.ts b/src/main/runtime/repo-icon-fork-backfill.test.ts index dc7ffb85463..aacba27f191 100644 --- a/src/main/runtime/repo-icon-fork-backfill.test.ts +++ b/src/main/runtime/repo-icon-fork-backfill.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import * as client from '../github/client' import { OrcaRuntimeService } from './orca-runtime' diff --git a/src/main/runtime/repo-worktree-resolution-scan.ts b/src/main/runtime/repo-worktree-resolution-scan.ts index 7c84e87ad84..052118cf2bf 100644 --- a/src/main/runtime/repo-worktree-resolution-scan.ts +++ b/src/main/runtime/repo-worktree-resolution-scan.ts @@ -1,4 +1,4 @@ -import type { GitWorktreeInfo } from '../../shared/types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' import { listWorktreesStrict } from '../git/worktree' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' 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/dispatcher-feature-interactions.test.ts b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts index e33da2b2661..d0f1e6fbc39 100644 --- a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts +++ b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' -import type { PersistedUIState } from '../../../shared/types' +import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' import { getDefaultUIState } from '../../../shared/constants' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' import { 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 1cef6a10213..a5ecbbdde33 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -18,6 +18,10 @@ import type { MobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memo import { MobileE2EEDesktopOutboundOwner } from './mobile-e2ee-desktop-outbound-owner' import { parseRuntimeClientCapabilities } from './runtime-client-capabilities' import type { RuntimeCapability } from '../../../shared/protocol-version' +import type { EventProps } from '../../../shared/telemetry-events' +import { track } from '../../telemetry/client' + +type OutboundBudgetEmitter = EventProps<'remote_outbound_budget_close'>['emitter'] const HANDSHAKE_TIMEOUT_MS = 10_000 const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 @@ -147,13 +151,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 => { @@ -161,7 +165,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)) { @@ -298,12 +302,21 @@ 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: OutboundBudgetEmitter): void { + try { + track('remote_outbound_budget_close', { emitter }) + } catch { + // Telemetry is best-effort; closing the unsafe socket remains authoritative. + } + this.onError(1013, 'Outbound reply buffer overflow') } private sendEncryptedControl(message: unknown): void { @@ -311,9 +324,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 3105fab77f4..532b28ffa34 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -20,6 +20,7 @@ import { SKILL_INSTALL_RPC_ERROR_CODE, classifySkillInstallFailureCode } from '../../../shared/skill-install-failure' +import { GIT_DIFF_TOO_LARGE_CODE } from '../../../shared/git-diff-transport-budget' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -110,6 +111,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'stale_delivery', 'waiter_exists', 'invalid_argument', + GIT_DIFF_TOO_LARGE_CODE, ARTIFACT_SHARING_DISABLED_CODE, AGENT_SKILL_SHARING_DISABLED_CODE, AGENT_SKILL_NOT_SHAREABLE_CODE, diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index 41484d20768..8e8478c7b39 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -24,7 +24,6 @@ import { TabCurrent, TabSetProfile, TabClose, - TabCreate, TabList, TabProfileClone, TabShow, @@ -32,6 +31,7 @@ import { Upload, Wait } from './browser-schemas' +import { BrowserTabCreateParams } from './browser-tab-create-schema' import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods' const CertificateProceed = BrowserTarget.extend({ @@ -112,7 +112,7 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'browser.tabCreate', - params: TabCreate, + params: BrowserTabCreateParams, handler: async (params, { runtime }) => runtime.browserTabCreate(params) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index a9a2a440f8f..47027451aeb 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -84,7 +84,6 @@ export const Eval = BrowserTarget.extend({ }) export const TabList = z.object({ worktree: OptionalString }) - // Why: --index xor --page must be present. The refine guards that invariant // so the dispatcher surfaces a single legible error instead of either shape // leaking into the runtime. @@ -111,18 +110,6 @@ export const TabSwitch = BrowserTarget.extend({ { message: 'Missing required --index (non-negative integer) or --page' } ) -export const TabCreate = z.object({ - url: OptionalString, - worktree: OptionalString, - profileId: OptionalString, - waitForRegistration: z.boolean().optional(), - // User-initiated opens focus the tab; agent/automation opens stay background. - activate: z.boolean().optional(), - // Why: the split group whose "+" was clicked, so a headless host places the - // new browser tab there instead of coalescing into the first/active group. - targetGroupId: OptionalString -}) - export const TabShow = z.object({ page: requiredString('Missing required --page'), worktree: OptionalString diff --git a/src/main/runtime/rpc/methods/browser-tab-create-schema.ts b/src/main/runtime/rpc/methods/browser-tab-create-schema.ts new file mode 100644 index 00000000000..f431b0ec225 --- /dev/null +++ b/src/main/runtime/rpc/methods/browser-tab-create-schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { OptionalString } from '../schemas' + +export const BrowserTabCreateParams = z.object({ + url: OptionalString, + worktree: OptionalString, + page: OptionalString, + profileId: OptionalString, + waitForRegistration: z.boolean().optional(), + activate: z.boolean().optional(), + targetGroupId: OptionalString +}) diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 93135a578cf..a7940828d88 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -18,10 +18,12 @@ import { WORKTREE_CARD_PROPERTIES } from '../../../../shared/worktree/card-properties' import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest' -import type { TaskProvider } from '../../../../shared/types' +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 a8323413b23..a1f24c28a9f 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -8,7 +8,7 @@ import { MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH } from '../../../../shared/terminal-quick-commands' import { DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../../../shared/worktree/card-properties' -import type { PersistedUIState } from '../../../../shared/types' +import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest } from '../core' import { RpcDispatcher } from '../dispatcher' @@ -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/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 73326756cd9..70ad9e9df57 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -1,4 +1,4 @@ -import type { PersistedUIState } from '../../../../shared/types' +import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' import { defineMethod, type RpcMethod } from '../core' import { FeatureInteractionIdParam, diff --git a/src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts b/src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts index 75af9a11bd8..81cd8b7814a 100644 --- a/src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts +++ b/src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { FsChangeEvent } from '../../../../shared/types' +import type { FsChangeEvent } from '../../../../shared/filesystem-entry-types' import { createFileWatchEventBatcher } from './file-watch-event-batcher' describe('createFileWatchEventBatcher', () => { diff --git a/src/main/runtime/rpc/methods/file-watch-event-batcher.ts b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts index 24e93e2affc..da2a21d3d9b 100644 --- a/src/main/runtime/rpc/methods/file-watch-event-batcher.ts +++ b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts @@ -2,7 +2,7 @@ import { WATCH_BATCH_MAX_WAIT_MS, WATCH_BATCH_TRAILING_MS } from '../../../../shared/filesystem-watch-batch-window' -import type { FsChangeEvent } from '../../../../shared/types' +import type { FsChangeEvent } from '../../../../shared/filesystem-entry-types' import { MAX_BATCHED_WATCHER_EVENTS } from '../../../ipc/filesystem-watcher-event-batch' export function createFileWatchEventBatcher( 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 9363551507e..a11369ff9e0 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -1,6 +1,7 @@ /* 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 type { GlobalSettings } from '../../../../shared/types' +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 { GitBranchCompare, @@ -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/orchestration-federation.ts b/src/main/runtime/rpc/methods/orchestration-federation.ts index f1c68310931..83cbbfe385f 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { buildDispatchPreamble } from '../../orchestration/preamble' import { OrchestrationError } from '../../orchestration/orchestration-error' import { defineMethod, type RpcMethod } from '../core' diff --git a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts index 76c60064691..f907571e2bc 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts @@ -6,7 +6,7 @@ import { } from '../../../../shared/agent-session-option-catalog' import { resolveAgentSessionOptionLaunch } from '../../../../shared/agent-session-option-launch' import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { OrchestrationError } from '../../orchestration/orchestration-error' export type OrchestrationWorkerLaunchSelection = { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts index f6bb103f37e..dad044732ce 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts @@ -1,5 +1,5 @@ import { isTuiAgent } from '../../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationError } from '../../orchestration/orchestration-error' import type { FederationAttachStartInput } from './orchestration-federation-start-schema' diff --git a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts index 9672560ab1e..3d4a0b6bf55 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts @@ -1,5 +1,5 @@ import type { AgentLaunchPreferences } from '../../../../shared/agent-session-host-authority' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { OrcaRuntimeService } from '../../orca-runtime' import type { OrchestrationDb } from '../../orchestration/db' diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index a71e3299929..d1d94d897a5 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { buildDispatchPreamble } from '../../orchestration/preamble' import { OrchestrationError } from '../../orchestration/orchestration-error' import { defineMethod, type RpcMethod } from '../core' 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/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index fc7bdc2adc3..1f44e17ea0b 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../../../../shared/terminal-quick-commands' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' import { TAB_ACTIVATION_INTENTS } from '../../../../shared/tab-activation-intent' diff --git a/src/main/runtime/rpc/methods/task-resume-state-schema.ts b/src/main/runtime/rpc/methods/task-resume-state-schema.ts index 06e90f8a811..fc85cad6270 100644 --- a/src/main/runtime/rpc/methods/task-resume-state-schema.ts +++ b/src/main/runtime/rpc/methods/task-resume-state-schema.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import type { TaskResumeState as TaskResumeStateType } from '../../../../shared/types' +import type { TaskResumeState as TaskResumeStateType } from '../../../../shared/ui-chrome-types' import type { AssertNoMissingKeys } from './ui-state-schema-parity' /** diff --git a/src/main/runtime/rpc/methods/terminal-orphan.ts b/src/main/runtime/rpc/methods/terminal-orphan.ts index 979c6aa747c..97296d0fac5 100644 --- a/src/main/runtime/rpc/methods/terminal-orphan.ts +++ b/src/main/runtime/rpc/methods/terminal-orphan.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import type { TabGroupLayoutNode } from '../../../../shared/types' +import type { TabGroupLayoutNode } from '../../../../shared/tab-types' import { isPtyIncarnationId, type PtyIncarnationId } from '../../../../shared/pty-incarnation' import { defineMethod, type RpcAnyMethod } from '../core' import { OptionalString, requiredString } from '../schemas' diff --git a/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts b/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts index 396710af3d3..18661f10b57 100644 --- a/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts +++ b/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import type { TerminalQuickCommand } from '../../../../shared/types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import { MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH, MAX_QUICK_COMMAND_ID_LENGTH, 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 a1ccf86c254..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/types' +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 a3e562e8516..79428938705 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { workspaceSourceSchema } from '../../../../shared/telemetry-events' import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' @@ -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/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/workspace-creator-context.ts b/src/main/runtime/rpc/workspace-creator-context.ts index de0b27965fd..6bf1adf9949 100644 --- a/src/main/runtime/rpc/workspace-creator-context.ts +++ b/src/main/runtime/rpc/workspace-creator-context.ts @@ -1,4 +1,4 @@ -import type { WorkspaceCreatorProvenance } from '../../../shared/types' +import type { WorkspaceCreatorProvenance } from '../../../shared/worktree/types' import type { RpcContext } from './core' export function resolveRpcWorkspaceCreatorProvenance( 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-folder-workspace.test.ts b/src/main/runtime/runtime-folder-workspace.test.ts index 06d97809c90..a801fdc6031 100644 --- a/src/main/runtime/runtime-folder-workspace.test.ts +++ b/src/main/runtime/runtime-folder-workspace.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { Repo, WorktreeMeta } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' import { getRuntimeFolderWorkspaceInstanceId, diff --git a/src/main/runtime/runtime-folder-workspace.ts b/src/main/runtime/runtime-folder-workspace.ts index 63f548a1037..18c226221bd 100644 --- a/src/main/runtime/runtime-folder-workspace.ts +++ b/src/main/runtime/runtime-folder-workspace.ts @@ -1,5 +1,7 @@ import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' -import type { Repo, Worktree, WorktreeMeta } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { Worktree } from '../../shared/worktree/types' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' import { normalizeWorkspaceCreatorProvenance } from '../../shared/workspace-creator-provenance' 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-remove-project-host-scope.test.ts b/src/main/runtime/runtime-remove-project-host-scope.test.ts index 50bc15d13c9..ab9a781d0db 100644 --- a/src/main/runtime/runtime-remove-project-host-scope.test.ts +++ b/src/main/runtime/runtime-remove-project-host-scope.test.ts @@ -6,7 +6,7 @@ * is reachable — and since #11994 every paired device now learns about it immediately. */ import { describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import { OrcaRuntimeService } from './orca-runtime' function makeRepos(): Repo[] { 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/selected-review-branch.ts b/src/main/runtime/selected-review-branch.ts index f874ab8a1ab..5feda3bd55f 100644 --- a/src/main/runtime/selected-review-branch.ts +++ b/src/main/runtime/selected-review-branch.ts @@ -1,4 +1,4 @@ -import type { GitPushTarget } from '../../shared/types' +import type { GitPushTarget } from '../../shared/worktree/types' import type { ForgeProviderId } from '../source-control/forge-provider' import type { getPRForBranch } from '../github/client' diff --git a/src/main/runtime/terminal-list-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-list-stale-leaf-liveness.test.ts index 5cbb967da76..158dff47c37 100644 --- a/src/main/runtime/terminal-list-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-list-stale-leaf-liveness.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' // run6-review-pr-11959 repro: leaf.connected mirrors the graph (`ptyId !== null`), // so a restored leaf whose PTY no provider owns must be demoted from the diff --git a/src/main/runtime/terminal-model-query-authority.ts b/src/main/runtime/terminal-model-query-authority.ts index df8f05798a4..a1dd4aac2d8 100644 --- a/src/main/runtime/terminal-model-query-authority.ts +++ b/src/main/runtime/terminal-model-query-authority.ts @@ -8,7 +8,7 @@ * renderer's native-Windows-ConPTY determination, recorded per PTY at spawn * so the runtime emulator can register the DA1 override before byte zero. */ -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { isWslUncPath } from '../../shared/wsl-paths' import { isHiddenPtyDeliveryGateEnabled, diff --git a/src/main/runtime/terminal-orphan-topology.ts b/src/main/runtime/terminal-orphan-topology.ts index bcb0b6e60ca..b55a351a1b0 100644 --- a/src/main/runtime/terminal-orphan-topology.ts +++ b/src/main/runtime/terminal-orphan-topology.ts @@ -1,4 +1,4 @@ -import type { TabGroupLayoutNode } from '../../shared/types' +import type { TabGroupLayoutNode } from '../../shared/tab-types' function collectLayoutGroupIds(node: TabGroupLayoutNode | null | undefined, ids: string[]): void { if (!node) { diff --git a/src/main/runtime/terminal-restore-record-seed.test.ts b/src/main/runtime/terminal-restore-record-seed.test.ts index 8e3b49620d4..bdc98f6ef92 100644 --- a/src/main/runtime/terminal-restore-record-seed.test.ts +++ b/src/main/runtime/terminal-restore-record-seed.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' // STA repro (post-restart blind orchestrator incident): after an app relaunch // the daemon survives and spawn reattaches silently, but the restore payload diff --git a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts index 8394140cb54..f64e960615b 100644 --- a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' // STA repro (silent-send incident): `orca terminal send` to a leaf whose ptyId // no provider in this process owns was a silent no-op reported as success — diff --git a/src/main/runtime/windows-drive-listing.ts b/src/main/runtime/windows-drive-listing.ts index 1b9dbac062c..af4eda39f94 100644 --- a/src/main/runtime/windows-drive-listing.ts +++ b/src/main/runtime/windows-drive-listing.ts @@ -1,6 +1,6 @@ import { stat } from 'node:fs/promises' import type { Stats } from 'node:fs' -import type { FilesystemPathFlavor } from '../../shared/types' +import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' export type DriveListing = { resolvedPath: string diff --git a/src/main/runtime/workspace-session-failed-write-rollback.ts b/src/main/runtime/workspace-session-failed-write-rollback.ts index f770511bb27..4f9e79a03d7 100644 --- a/src/main/runtime/workspace-session-failed-write-rollback.ts +++ b/src/main/runtime/workspace-session-failed-write-rollback.ts @@ -1,5 +1,5 @@ import { isDeepStrictEqual } from 'node:util' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' const MISSING = Symbol('missing') type RollbackValue = unknown diff --git a/src/main/runtime/workspace-session-terminal-membership-authority.ts b/src/main/runtime/workspace-session-terminal-membership-authority.ts index 556d488edc2..c4d258d8317 100644 --- a/src/main/runtime/workspace-session-terminal-membership-authority.ts +++ b/src/main/runtime/workspace-session-terminal-membership-authority.ts @@ -1,10 +1,9 @@ +import type { Tab, TabGroup } from '../../shared/tab-types' import type { - Tab, - TabGroup, TerminalLayoutSnapshot, - TerminalPaneLayoutNode, - WorkspaceSessionState -} from '../../shared/types' + TerminalPaneLayoutNode +} from '../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { pruneTabGroupLayoutAfterRetirement } from './mobile-session-terminal-retirement' 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 e1bf370de1d..1aa49b76d8e 100644 --- a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -42,6 +42,7 @@ import { 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' @@ -49,6 +50,10 @@ const WORKTREE_PATH = '/Users/me/dev/app-feature' const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` const MAIN_WORKTREE_ID = `${REPO_ID}::${REPO_PATH}` const SCAN_TTL_MS = 30_000 +// A busy host (100+ linked worktrees, Defender, cold dentry cache, cloud placeholders) can spend +// seconds in pure stat time. Reuse has to survive that, or trimming the probe's deadline to fit the +// caller budget just trades a repeating stall for a repeating `git worktree list`. +const SLOW_BUT_HEALTHY_PROBE_MS = 3_000 function makeMeta(overrides: Record = {}) { return { @@ -116,11 +121,14 @@ function makeRuntime( ): { runtime: OrcaRuntimeService list: () => Promise + store: ReturnType } { - const runtime = new OrcaRuntimeService(makeStore(options) as never) + const store = makeStore(options) + const runtime = new OrcaRuntimeService(store as never) return { runtime, - list: () => (runtime as unknown as RuntimeInternals).listResolvedWorktrees() + list: () => (runtime as unknown as RuntimeInternals).listResolvedWorktrees(), + store } } @@ -128,6 +136,11 @@ function scanCount(): number { return listWorktreesStrictMock.mock.calls.length } +/** Scanned rows carry the mocked tips; the persisted-row fallback publishes empty ones. */ +function heads(worktrees: unknown): string[] { + return (worktrees as { git: { head: string } }[]).map((worktree) => worktree.git.head).sort() +} + /** Let every already-settled promise chain run without advancing the clock, so a stall stays a stall. */ async function drainMicrotasks(): Promise { for (let tick = 0; tick < 200; tick += 1) { @@ -455,6 +468,80 @@ describe('worktree scan admin-fingerprint gate', () => { } }) + it('bounds the awaited probe by the caller per-repo budget', () => { + // The wait runs inside that budget, so outlasting it can only strand the caller on persisted rows. + expect(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS).toBeLessThan( + RESOLVED_WORKTREE_REPO_TIMEOUT_MS + ) + }) + + it('still reuses the scan when a slow probe answers inside its deadline', async () => { + vi.useFakeTimers() + try { + const { list } = makeRuntime() + await list() + expect(scanCount()).toBe(1) + + const releaseSlowProbe = stallProbeOnce() + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + const second = list() + + // Deliberately an absolute figure, not one derived from the deadline: this is the lower end of + // the band, and a self-referential advance would still pass however far the deadline is cut. + await vi.advanceTimersByTimeAsync(SLOW_BUT_HEALTHY_PROBE_MS) + releaseSlowProbe('fp-1') + await second + expect(scanCount()).toBe(1) + } finally { + vi.useRealTimers() + } + }) + + it('still returns scanned rows within the per-repo budget when the probe stalls', async () => { + vi.useFakeTimers() + try { + const { list } = makeRuntime() + await list() + + stallProbeOnce() + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + const second = list() + const secondSettled = trackSettled(second) + + // The probe's own deadline has to end the wait; if the caller's fallback gets there first the + // repo answers with persisted rows on every TTL expiry. + await vi.advanceTimersByTimeAsync(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS) + await drainMicrotasks() + expect(secondSettled()).toBe(true) + expect(scanCount()).toBe(2) + expect(heads(await second)).toEqual(['abc', 'def']) + } finally { + vi.useRealTimers() + } + }) + + it('does not publish an already-expired snapshot after a slow compute', async () => { + vi.useFakeTimers() + try { + const { list, store } = makeRuntime() + await list() + + stallProbeOnce() + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + const second = list() + await vi.advanceTimersByTimeAsync(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS) + await second + + // The probe deadline outlasts the snapshot TTL, so a start-stamped entry is born expired and + // the next poll repeats the whole wait instead of reading the answer that just landed. + const getRepos = vi.spyOn(store, 'getRepos') + await list() + expect(getRepos).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('shares one probe and one scan across concurrent callers', async () => { const { list } = makeRuntime() @@ -464,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/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index e0721d116f2..7b35e02a144 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildSkillDiscoverySources, clearSkillRootScanCache, discoverSkills } from './discovery' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' function makeRepo(path: string, connectionId: string | null = null): Repo { return { diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index be7ebc94ae0..0dbb61ca7ff 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -2,7 +2,7 @@ import { open, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, relative, sep } from 'node:path' import { summarizeSkillMarkdown } from '../../shared/skill-metadata' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import type { DiscoveredSkill, SkillDiscoveryResult, diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index 7cc8bf020e1..91221a55413 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -8,7 +8,7 @@ import type { SkillSourceKind } from '../../shared/skills' import type { AgentType } from '../../shared/agent-status-types' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { SkillProviderRootOverrides } from './skill-provider-destinations' import { resolveEnvironmentSkillProviderRoots } from './skill-provider-runtime-roots' diff --git a/src/main/skills/skill-discovery-target.test.ts b/src/main/skills/skill-discovery-target.test.ts index f136536b189..18cb430bb68 100644 --- a/src/main/skills/skill-discovery-target.test.ts +++ b/src/main/skills/skill-discovery-target.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SkillDiscoveryResult } from '../../shared/skills' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' const { nativeScans, wslScans } = vi.hoisted(() => ({ nativeScans: [] as unknown[], diff --git a/src/main/skills/skill-discovery-target.ts b/src/main/skills/skill-discovery-target.ts index 73b52f4db70..296a356e182 100644 --- a/src/main/skills/skill-discovery-target.ts +++ b/src/main/skills/skill-discovery-target.ts @@ -1,4 +1,4 @@ -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' import { getDefaultWslDistro, getWslHome, parseWslPath, toLinuxPath } from '../wsl' import { clearSkillRootScanCache, discoverSkills } from './discovery' diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts index f2cb31e1357..04e778c42dc 100644 --- a/src/main/skills/skill-freshness-inventory.test.ts +++ b/src/main/skills/skill-freshness-inventory.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' import { afterEach, describe, expect, it } from 'vitest' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import type { SkillBundleFileIdentity, SkillCurrentBundleEntry, diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts index f502707321e..f9c051988b3 100644 --- a/src/main/skills/skill-freshness-inventory.ts +++ b/src/main/skills/skill-freshness-inventory.ts @@ -1,6 +1,6 @@ import { lstat } from 'node:fs/promises' import { join } from 'node:path' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import { SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, type SkillFreshnessInstallation, diff --git a/src/main/source-control/forge-review-mappers.ts b/src/main/source-control/forge-review-mappers.ts index 5d13599ccc2..c8e3fdf3899 100644 --- a/src/main/source-control/forge-review-mappers.ts +++ b/src/main/source-control/forge-review-mappers.ts @@ -1,6 +1,7 @@ import type { HostedReviewInfo } from '../../shared/hosted-review' import { hostedReviewInfoFromGitHubPRInfo } from '../../shared/hosted-review-github' -import type { MRInfo, PRInfo } from '../../shared/types' +import type { PRInfo } from '../../shared/github/pull-request-types' +import type { MRInfo } from '../../shared/gitlab-types' import type { AzureDevOpsPullRequestInfo } from '../azure-devops/pull-request-mappers' import type { BitbucketPullRequestInfo } from '../bitbucket/pull-request-mappers' import type { GiteaPullRequestInfo } from '../gitea/pull-request-mappers' diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 2149c1ab000..9c9be75ab24 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -24,7 +24,7 @@ import { getRepoSlug } from '../github/client' import { isDefaultGitHubHost } from '../../shared/github/repository-identity-key' import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils' import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' -import type { GitUpstreamStatus } from '../../shared/types' +import type { GitUpstreamStatus } from '../../shared/git-status-types' import { gitOptionalLocksDisabledEnv } from '../git/runner' import { parsePorcelainV1Records, type PorcelainV1Record } from '../git/porcelain-v1-records' import { findExistingWorktreeSymlinkPaths } from '../git/worktree-symlink-detection' diff --git a/src/main/source-control/pull-request-linked-issue.ts b/src/main/source-control/pull-request-linked-issue.ts index 2e73cd30d8d..94ea1432473 100644 --- a/src/main/source-control/pull-request-linked-issue.ts +++ b/src/main/source-control/pull-request-linked-issue.ts @@ -1,7 +1,7 @@ import type { HostedReviewProvider } from '../../shared/hosted-review' import type { PullRequestLinkedIssue } from '../../shared/pull-request-generation' import { isLinkedIssueNumber } from '../../shared/source-control-ai-action-variables' -import type { WorkspaceLinkedItem } from '../../shared/types' +import type { WorkspaceLinkedItem } from '../../shared/worktree/types' import { getIssue as getGitHubIssue } from '../github/issues' import { getIssue as getGitLabIssue } from '../gitlab/issues' diff --git a/src/main/speech/model-manager-download-resume.test.ts b/src/main/speech/model-manager-download-resume.test.ts index 60645d1aa28..2fb4329e5c5 100644 --- a/src/main/speech/model-manager-download-resume.test.ts +++ b/src/main/speech/model-manager-download-resume.test.ts @@ -37,6 +37,7 @@ type ModelManagerInternals = { redirectCount?: number, resumeOffset?: number ) => Promise + getPartialDownloadBytes: (filePath: string) => number } type ScriptedResponse = { @@ -288,11 +289,13 @@ describe('ModelManager download resume', () => { const manager = new ModelManager(dir) as unknown as ModelManagerInternals const filePath = join(dir, 'model.bin') let bytesWritten = 0 + // Why: the ceiling costs 4096 iterations, so read progress from memory — + // real per-iteration file I/O stalls this test under parallel load. + vi.spyOn(manager, 'getPartialDownloadBytes').mockImplementation(() => bytesWritten) // Advances one byte per request against a total larger than the request // ceiling, so it makes forward progress forever without ever completing. const downloadFileMock = vi.spyOn(manager, 'downloadFile').mockImplementation(() => { bytesWritten += 1 - writeFileSync(filePath, Buffer.alloc(bytesWritten)) return Promise.resolve() }) diff --git a/src/main/speech/speech-model-deletion.test.ts b/src/main/speech/speech-model-deletion.test.ts index 5ce3788d7e1..e78fc0d175d 100644 --- a/src/main/speech/speech-model-deletion.test.ts +++ b/src/main/speech/speech-model-deletion.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultVoiceSettings } from '../../shared/constants' import type { VoiceSettings } from '../../shared/speech-types' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { SPEECH_MODEL_CATALOG } from './model-catalog' import { deleteLocalSpeechModel } from './speech-model-deletion' 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/ssh/ssh-target-id-migration.test.ts b/src/main/ssh/ssh-target-id-migration.test.ts index 7628428654e..59ba19d4dc9 100644 --- a/src/main/ssh/ssh-target-id-migration.test.ts +++ b/src/main/ssh/ssh-target-id-migration.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest' -import type { PersistedUIState, TerminalTab, WorkspaceSessionState } from '../../shared/types' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import type { TerminalTab } from '../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { migrateUiHostScopeSshTargetId, migrateWorkspaceSessionSshTargetId diff --git a/src/main/ssh/ssh-target-id-migration.ts b/src/main/ssh/ssh-target-id-migration.ts index f7fe153a2be..18d3f075bf0 100644 --- a/src/main/ssh/ssh-target-id-migration.ts +++ b/src/main/ssh/ssh-target-id-migration.ts @@ -1,4 +1,5 @@ -import type { PersistedUIState, WorkspaceSessionState } from '../../shared/types' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { parseAppSshPtyId, toAppSshPtyId } from '../../shared/ssh-pty-id' import { toSshExecutionHostId } from '../../shared/execution-host' import { normalizeManualRepoOrder } from '../../shared/manual-repo-order' diff --git a/src/main/star-nag/service.test.ts b/src/main/star-nag/service.test.ts index 60f166ff200..2e52ada5442 100644 --- a/src/main/star-nag/service.test.ts +++ b/src/main/star-nag/service.test.ts @@ -3,7 +3,7 @@ Electron/IPC harness; splitting the narrow service suite would duplicate setup and make the prompt-session edge cases harder to compare. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { STAR_NAG_INITIAL_THRESHOLD } from '../../shared/constants' -import type { PersistedUIState } from '../../shared/types' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' import { StarNagService } from './service' diff --git a/src/main/startup/dev-education-suppression.test.ts b/src/main/startup/dev-education-suppression.test.ts index ab969b3faf4..e84541f0f68 100644 --- a/src/main/startup/dev-education-suppression.test.ts +++ b/src/main/startup/dev-education-suppression.test.ts @@ -8,7 +8,8 @@ import { import { CONTEXTUAL_TOUR_IDS } from '../../shared/contextual-tours' import { FEATURE_INTERACTION_IDS } from '../../shared/feature-interactions' import { FEATURE_TIP_IDS } from '../../shared/feature-tips' -import type { OnboardingState, PersistedUIState } from '../../shared/types' +import type { OnboardingState } from '../../shared/onboarding-state-types' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import { DEV_SHOW_FIRST_RUN_EDUCATION_ENV, shouldSuppressDevEducation, diff --git a/src/main/startup/dev-education-suppression.ts b/src/main/startup/dev-education-suppression.ts index 9b3897169b8..bea97e7bbc8 100644 --- a/src/main/startup/dev-education-suppression.ts +++ b/src/main/startup/dev-education-suppression.ts @@ -5,7 +5,7 @@ import { type FeatureInteractionState } from '../../shared/feature-interactions' import { FEATURE_TIP_IDS } from '../../shared/feature-tips' -import type { PersistedUIState } from '../../shared/types' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { Store } from '../persistence' export const DEV_SHOW_FIRST_RUN_EDUCATION_ENV = 'ORCA_DEV_SHOW_FIRST_RUN_EDUCATION' diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 38a16816507..db41fe3f462 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process' import { delimiter, win32 as pathWin32 } from 'node:path' -import type { ShellHydrationFailureReason } from '../../shared/types' +import type { ShellHydrationFailureReason } from '../../shared/shell-path-hydration-types' import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' import { WindowsShellPathOwnership, windowsPathSegmentKey } from './windows-shell-path-ownership' diff --git a/src/main/stats/collector.ts b/src/main/stats/collector.ts index ccc516f95f4..27647af20ad 100644 --- a/src/main/stats/collector.ts +++ b/src/main/stats/collector.ts @@ -1,6 +1,6 @@ import { app } from 'electron' import { join } from 'node:path' -import type { StatsSummary } from '../../shared/types' +import type { StatsSummary } from '../../shared/process-stats-types' import type { StatsEvent, StatsAggregates } from './types' import { loadStatsFile, STATS_SCHEMA_VERSION } from './stats-file-loader' import { StatsSnapshotWriter } from './stats-snapshot-writer' diff --git a/src/main/synthetic-title-frame-routing.ts b/src/main/synthetic-title-frame-routing.ts index 3bb10a030fa..551e871fd01 100644 --- a/src/main/synthetic-title-frame-routing.ts +++ b/src/main/synthetic-title-frame-routing.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../shared/types' +import type { GlobalSettings } from '../shared/global-settings-types' /** * Why: with the side-effect kill switch off, renderer byte parsers are the diff --git a/src/main/telemetry/client-test-harness.ts b/src/main/telemetry/client-test-harness.ts index 8e0dbb58230..4f3e4054be1 100644 --- a/src/main/telemetry/client-test-harness.ts +++ b/src/main/telemetry/client-test-harness.ts @@ -1,7 +1,7 @@ import type { PostHog } from 'posthog-node' import { vi } from 'vitest' import type { CommonProps } from '../../shared/telemetry-events' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import type { Store } from '../persistence' import { resetBurstCapsForSession } from './burst-cap' import { diff --git a/src/main/telemetry/cohort-classifier.test.ts b/src/main/telemetry/cohort-classifier.test.ts index 2d1593cde8e..ac4f8d14173 100644 --- a/src/main/telemetry/cohort-classifier.test.ts +++ b/src/main/telemetry/cohort-classifier.test.ts @@ -3,7 +3,7 @@ // at most one warn per session. See docs/onboarding-funnel-cohort-addendum.md. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import type { Store } from '../persistence' import { _resetSessionWarnFlagForTests, diff --git a/src/main/telemetry/consent.test.ts b/src/main/telemetry/consent.test.ts index 12ee8ad0058..46a54c04b6d 100644 --- a/src/main/telemetry/consent.test.ts +++ b/src/main/telemetry/consent.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { resolveConsent, _resetMisconfigWarnCacheForTests } from './consent' // A minimal GlobalSettings stub — the resolver only reads `settings.telemetry`, diff --git a/src/main/telemetry/consent.ts b/src/main/telemetry/consent.ts index 85ed0dfd481..20b78526406 100644 --- a/src/main/telemetry/consent.ts +++ b/src/main/telemetry/consent.ts @@ -6,7 +6,7 @@ // runtime only and never mutate `GlobalSettings.telemetry.optedIn`. Unsetting // the variable on the next launch restores the user's stored preference. -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import type { TelemetryConsentState } from '../../shared/telemetry-consent-types' // Discriminated union instead of a boolean: the Privacy pane (PR 3) needs the diff --git a/src/main/telemetry/install-id.test.ts b/src/main/telemetry/install-id.test.ts index 69591a72663..c2fa51f2c1d 100644 --- a/src/main/telemetry/install-id.test.ts +++ b/src/main/telemetry/install-id.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest' import type { Store } from '../persistence' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { generateInstallId, readInstallId } from './install-id' // Minimal in-memory store stand-in. The real `Store` pulls in Electron + fs, diff --git a/src/main/telemetry/onboarding-cohort-classifier.test.ts b/src/main/telemetry/onboarding-cohort-classifier.test.ts index 25aa5e2f67e..e74b0deb8d5 100644 --- a/src/main/telemetry/onboarding-cohort-classifier.test.ts +++ b/src/main/telemetry/onboarding-cohort-classifier.test.ts @@ -5,7 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ONBOARDING_FINAL_STEP } from '../../shared/constants' -import type { GlobalSettings, OnboardingState } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { OnboardingState } from '../../shared/onboarding-state-types' import type { Store } from '../persistence' import { _resetSessionWarnFlagForTests, diff --git a/src/main/terminal-scrollback-snapshot-async-migration.ts b/src/main/terminal-scrollback-snapshot-async-migration.ts index b1a064657c1..1768c0a20a1 100644 --- a/src/main/terminal-scrollback-snapshot-async-migration.ts +++ b/src/main/terminal-scrollback-snapshot-async-migration.ts @@ -1,4 +1,4 @@ -import type { WorkspaceSessionState } from '../shared/types' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { collectTerminalScrollbackSnapshotRefs, deleteTerminalScrollbackSnapshot, diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index de387c356c9..296dd4a081b 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -12,7 +12,7 @@ import { import { mkdir, rename, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { app } from 'electron' -import type { WorkspaceSessionState } from '../shared/types' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 1163f54bb42..2da0f012479 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -8,7 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../shared/constants' import { sourceControlAiSettingsFromLegacy } from '../../shared/source-control-ai' import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' import { cancelGenerateCommitMessageLocal, cancelGeneratePullRequestFieldsLocal, diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index a13372de858..655c6de0e7d 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -2,7 +2,9 @@ spawn failure handling, and output normalization; keeping them together prevents those paths from drifting. */ import { spawn, type ChildProcess } from 'node:child_process' -import type { GlobalSettings, Repo, TuiAgent } from '../../shared/types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' +import type { TuiAgent } from '../../shared/tui-agent' import { buildCommitMessagePrompt, splitGeneratedCommitMessage, diff --git a/src/main/updater-changelog.ts b/src/main/updater-changelog.ts index a534075cfc6..3af55b872de 100644 --- a/src/main/updater-changelog.ts +++ b/src/main/updater-changelog.ts @@ -1,5 +1,5 @@ import { net } from 'electron' -import type { ChangelogData } from '../shared/types' +import type { ChangelogData } from '../shared/update-status-types' import { compareVersions } from './updater-fallback' type ChangelogEntry = { diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index eb1ca193510..b77cd8a8cc5 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -1,5 +1,5 @@ import { app, autoUpdater as nativeUpdater } from 'electron' -import type { UpdateStatus } from '../shared/types' +import type { UpdateStatus } from '../shared/update-status-types' import { consumeMacInstallGuardBypass, deferMacQuitUntilInstallerReady, diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts index a1c77b315d6..22cfb049555 100644 --- a/src/main/updater-fallback.ts +++ b/src/main/updater-fallback.ts @@ -1,4 +1,4 @@ -import type { UpdateStatus } from '../shared/types' +import type { UpdateStatus } from '../shared/update-status-types' import { compareAppVersions, isPrereleaseAppVersion, diff --git a/src/main/updater-linux-package-recovery-actions.test.ts b/src/main/updater-linux-package-recovery-actions.test.ts index 75f593b7f57..4e9d50ecdf5 100644 --- a/src/main/updater-linux-package-recovery-actions.test.ts +++ b/src/main/updater-linux-package-recovery-actions.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { UpdateStatus } from '../shared/types' +import type { UpdateStatus } from '../shared/update-status-types' import type * as UpdaterModule from './updater' const { diff --git a/src/main/updater-mac-install.ts b/src/main/updater-mac-install.ts index 8012f79d8fc..e2441d64180 100644 --- a/src/main/updater-mac-install.ts +++ b/src/main/updater-mac-install.ts @@ -1,5 +1,5 @@ import { app } from 'electron' -import type { UpdateStatus } from '../shared/types' +import type { UpdateStatus } from '../shared/update-status-types' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' const MAC_INSTALL_READY_TIMEOUT_MS = 15000 diff --git a/src/main/updater.fallback.test.ts b/src/main/updater.fallback.test.ts index f946aafeb6f..9cbcc97c85e 100644 --- a/src/main/updater.fallback.test.ts +++ b/src/main/updater.fallback.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { UpdateStatus } from '../shared/types' +import type { UpdateStatus } from '../shared/update-status-types' import { compareVersions, isBenignCheckFailure, diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 1f5cdc06994..f42d58b5c8e 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type * as UpdaterModule from './updater' import type * as RecoveryModule from './linux-package-update-recovery' -import type { UpdateStatus } from '../shared/types' +import type { UpdateStatus } from '../shared/update-status-types' type RevalidationVerdict = Awaited< ReturnType diff --git a/src/main/updater.ts b/src/main/updater.ts index 96bf163e54e..f89c00331a7 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -7,7 +7,7 @@ import type { UpdateCheckOptions, UpdateSource, UpdateStatus -} from '../shared/types' +} from '../shared/update-status-types' import type { RemoteServerUpdateInstallResult, RemoteServerUpdaterSnapshot, diff --git a/src/main/usage-worktree-metadata.ts b/src/main/usage-worktree-metadata.ts index 8c4f3a94ba6..ed6889a800b 100644 --- a/src/main/usage-worktree-metadata.ts +++ b/src/main/usage-worktree-metadata.ts @@ -1,5 +1,5 @@ import { basename } from 'node:path' -import type { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../shared/worktree/id' import { isFolderRepo } from '../shared/repo-kind' import type { Store } from './persistence' diff --git a/src/main/usage/usage-worktree-refs.ts b/src/main/usage/usage-worktree-refs.ts index f76ed7c0a58..f9ea5b7b9f6 100644 --- a/src/main/usage/usage-worktree-refs.ts +++ b/src/main/usage/usage-worktree-refs.ts @@ -1,4 +1,4 @@ -import type { Repo } from '../../shared/types' +import type { Repo } from '../../shared/repo-types' import type { UsageWorktreeRef } from '../usage-worktree-metadata' import type { UsageScanWorktreeRef } from './usage-provider-contract' diff --git a/src/main/warp-themes/parser.ts b/src/main/warp-themes/parser.ts index fa4ad55c40b..e37731a8e85 100644 --- a/src/main/warp-themes/parser.ts +++ b/src/main/warp-themes/parser.ts @@ -1,6 +1,6 @@ import path from 'node:path' import { parseDocument } from 'yaml' -import type { TerminalColorOverrides } from '../../shared/types' +import type { TerminalColorOverrides } from '../../shared/terminal-color-overrides' import { hasUsableTerminalThemeColors, makeCustomTerminalThemeSelection, 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 ad8551acb6d..4ad095bef15 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -4,12 +4,9 @@ import { randomUUID } from 'node:crypto' import { app, ipcMain } from 'electron' import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' import type { Store } from '../persistence' -import type { - CreateWorktreeResult, - ReleaseBuildListResult, - UpdateCheckOptions, - WorktreeStartupLaunch -} from '../../shared/types' +import type { ReleaseBuildListResult, UpdateCheckOptions } from '../../shared/update-status-types' +import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import { RELEASE_CHANNELS, type ReleaseChannel } from '../../shared/release-channel' import { acknowledgePendingTccPromptNotice, @@ -67,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 @@ -321,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. @@ -404,7 +405,7 @@ function registerRuntimeWindowLifecycle( }) } ipcMain.on('terminal:tabCreateReply', handler) - send('ui:createTerminal', { + const sent = send('ui:createTerminal', { requestId, worktreeId, ptyId: opts.ptyId, @@ -427,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', { @@ -492,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..62538b59715 --- /dev/null +++ b/src/main/window/renderer-document-navigation.test.ts @@ -0,0 +1,137 @@ +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-capacity.test.ts b/src/main/workspace-space-analysis-capacity.test.ts index ebf1e0ae634..42a2a2cee48 100644 --- a/src/main/workspace-space-analysis-capacity.test.ts +++ b/src/main/workspace-space-analysis-capacity.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type * as NodeProcess from 'node:process' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' import type * as WorkspaceSpaceScanBudgetModule from '../shared/workspace-space-scan-budget' import type { Store } from './persistence' diff --git a/src/main/workspace-space-analysis-du-timeout.test.ts b/src/main/workspace-space-analysis-du-timeout.test.ts index ecd2e97eb8b..de467d2a7d2 100644 --- a/src/main/workspace-space-analysis-du-timeout.test.ts +++ b/src/main/workspace-space-analysis-du-timeout.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeProcess from 'node:process' -import type { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' import type { Store } from './persistence' const { execFileMock, listRepoWorktreesMock } = vi.hoisted(() => ({ 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 15baa04fc04..d6fbc4bb2d7 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, 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 { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' import type { Store } from './persistence' const { @@ -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 bdb8d953aab..9b36c2d3afa 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -7,7 +7,9 @@ import { platform } from 'node:process' import type { Dirent } from 'node:fs' import type { Store } from './persistence' import { isFolderRepo } from '../shared/repo-kind' -import type { DirEntry, GitWorktreeInfo, Repo, Worktree } from '../shared/types' +import type { DirEntry } from '../shared/filesystem-entry-types' +import type { Repo } from '../shared/repo-types' +import type { GitWorktreeInfo, Worktree } from '../shared/worktree/types' import type { WorkspaceSpaceAnalysis, WorkspaceSpaceDirectoryScanResult, @@ -34,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 @@ -309,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, @@ -701,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( @@ -733,6 +742,7 @@ async function scanRepo( worktrees: [], summary: { repoId: repo.id, + executionHostId: getRepoExecutionHostId(repo), displayName: repo.displayName, path: repo.path, isRemote: Boolean(repo.connectionId), @@ -790,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 @@ -809,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/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts index fc43980202e..a7665fc4e15 100644 --- a/src/main/worktree-create-base-prefetch.ts +++ b/src/main/worktree-create-base-prefetch.ts @@ -1,5 +1,5 @@ import { isFolderRepo } from '../shared/repo-kind' -import type { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref' import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe' import { getBaseRefDefault } from './git/repo' diff --git a/src/main/worktree-create-timing.ts b/src/main/worktree-create-timing.ts index 3dc5546c53c..433a9ac0098 100644 --- a/src/main/worktree-create-timing.ts +++ b/src/main/worktree-create-timing.ts @@ -1,4 +1,7 @@ -import type { WorktreeCreateTiming, WorktreeCreateTimingPhase } from '../shared/types' +import type { + WorktreeCreateTiming, + WorktreeCreateTimingPhase +} from '../shared/worktree/create-types' type TimingClock = () => number diff --git a/src/main/worktree-lineage-pruning.test.ts b/src/main/worktree-lineage-pruning.test.ts index e9465a9f6cc..db393e864d5 100644 --- a/src/main/worktree-lineage-pruning.test.ts +++ b/src/main/worktree-lineage-pruning.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import type { Repo, WorktreeLineage, WorkspaceLineage, WorktreeMeta } from '../shared/types' +import type { Repo } from '../shared/repo-types' +import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../shared/worktree/meta-types' import { worktreeWorkspaceKey } from '../shared/workspace-scope' import { pruneLineageForMissingRepoWorktrees } from './worktree-lineage-pruning' diff --git a/src/main/worktree-lineage-pruning.ts b/src/main/worktree-lineage-pruning.ts index b9dbe956f57..34a99ef6e95 100644 --- a/src/main/worktree-lineage-pruning.ts +++ b/src/main/worktree-lineage-pruning.ts @@ -1,5 +1,7 @@ import { randomUUID } from 'node:crypto' -import type { GitWorktreeInfo, Repo, WorktreeLineage, WorkspaceLineage } from '../shared/types' +import type { Repo } from '../shared/repo-types' +import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/lineage-types' +import type { GitWorktreeInfo } from '../shared/worktree/types' import { getRepoExecutionHostId } from '../shared/execution-host' import { isWorkspaceKey, parseWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' import type { Store } from './persistence' diff --git a/src/main/worktree-removal-authority.test.ts b/src/main/worktree-removal-authority.test.ts index cbc9e2ed24c..4d8229f3f1f 100644 --- a/src/main/worktree-removal-authority.test.ts +++ b/src/main/worktree-removal-authority.test.ts @@ -4,7 +4,7 @@ import { isWorktreePathMissing, stripOrcaProvenanceMetaUpdates } from './worktree-removal-safety' -import type { WorktreeMeta } from '../shared/types' +import type { WorktreeMeta } from '../shared/worktree/meta-types' describe('isWorktreePathMissing', () => { it('recognizes missing-path errors from local and remote stat providers', async () => { diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index 5a64b8c90e5..968bbd96bb5 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { homedir } from 'node:os' -import type { GitWorktreeInfo } from '../shared/types' +import type { GitWorktreeInfo } from '../shared/worktree/types' import { canCleanupUnregisteredOrcaLeftoverDirectory, canSafelyRemoveOrphanedWorktreeDirectory, diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index ef303c18220..b37a2477bc2 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -2,7 +2,9 @@ import { lstat, readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { posix, win32 } from 'node:path' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' -import type { GitWorktreeInfo, Repo, WorktreeMeta } from '../shared/types' +import type { Repo } from '../shared/repo-types' +import type { WorktreeMeta } from '../shared/worktree/meta-types' +import type { GitWorktreeInfo } from '../shared/worktree/types' import { areWorktreePathsEqual } from './ipc/worktree-logic' import { gitFileProvesOrphanedWorktreeDirectory, diff --git a/src/main/worktree-removal-session-partition-fencing.test.ts b/src/main/worktree-removal-session-partition-fencing.test.ts index 53b4dc805a6..89b7feafee6 100644 --- a/src/main/worktree-removal-session-partition-fencing.test.ts +++ b/src/main/worktree-removal-session-partition-fencing.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import type { TerminalTab } from '../shared/types' +import type { TerminalTab } from '../shared/terminal-tab-types' import { getDefaultWorkspaceSession } from '../shared/constants' import { toRuntimeExecutionHostId } from '../shared/execution-host' diff --git a/src/main/worktree-root-preparation.test.ts b/src/main/worktree-root-preparation.test.ts index 16adf035efe..36ad1426a79 100644 --- a/src/main/worktree-root-preparation.test.ts +++ b/src/main/worktree-root-preparation.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' const { mkdirMock, authorizeExternalPathMock } = vi.hoisted(() => ({ mkdirMock: vi.fn(), diff --git a/src/main/worktree-root-preparation.ts b/src/main/worktree-root-preparation.ts index 3edcf311012..d4cf7ead15a 100644 --- a/src/main/worktree-root-preparation.ts +++ b/src/main/worktree-root-preparation.ts @@ -1,5 +1,6 @@ import { mkdir } from 'node:fs/promises' -import type { GlobalSettings, Repo } from '../shared/types' +import type { GlobalSettings } from '../shared/global-settings-types' +import type { Repo } from '../shared/repo-types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' import { isFolderRepo } from '../shared/repo-kind' import { computeWorkspaceRoot, getWorktreePathSettings } from './ipc/worktree-logic' diff --git a/src/main/worktree-trash.test.ts b/src/main/worktree-trash.test.ts index 49a8af75a1d..5bec9f53475 100644 --- a/src/main/worktree-trash.test.ts +++ b/src/main/worktree-trash.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from 'node:fs/promise import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { Repo } from '../shared/types' +import type { Repo } from '../shared/repo-types' import { collectWorktreeTrashSweepRoots, getWorktreeTrashRoot, diff --git a/src/main/worktree-trash.ts b/src/main/worktree-trash.ts index 5c0aa32be2b..cec17bf87cd 100644 --- a/src/main/worktree-trash.ts +++ b/src/main/worktree-trash.ts @@ -8,7 +8,8 @@ import { dirname, join } from 'node:path' import { removeHostTree } from './host-tree-removal' import { isFolderRepo } from '../shared/repo-kind' import { computeWorkspaceRoot, getWorktreePathSettings } from './ipc/worktree-logic' -import type { GlobalSettings, Repo } from '../shared/types' +import type { GlobalSettings } from '../shared/global-settings-types' +import type { Repo } from '../shared/repo-types' import { parseWslPath } from './wsl' export const WORKTREE_TRASH_DIR_NAME = '.orca-worktree-trash' diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index 43e8542cddd..e3688b57f5b 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -1,4 +1,7 @@ -import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' +import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState +} from '../../shared/managed-account-types' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' import type { GrokAccountStatus } from '../../shared/rate-limit-types' diff --git a/src/preload/api/agent-hook-api.ts b/src/preload/api/agent-hook-api.ts index df782419c5c..c4116aa2b6d 100644 --- a/src/preload/api/agent-hook-api.ts +++ b/src/preload/api/agent-hook-api.ts @@ -1,4 +1,5 @@ -import type { OrcaHooks, WorktreeSetupLaunch } from '../../shared/types' +import type { OrcaHooks } from '../../shared/orca-yaml-hook-types' +import type { WorktreeSetupLaunch } from '../../shared/worktree/launch-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { SetupScriptImportCandidate } from '../../shared/setup-script-imports' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index a03960fd531..ec18110e4a9 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -5,12 +5,10 @@ import type { WriteTerminalRenderDesyncEvidenceArgs, WriteTerminalRenderDesyncEvidenceResult } from '../../shared/terminal-render-desync-evidence' -import type { - FloatingTerminalCwdRequest, - MarkdownDocument, - PersistedUIState, - WorkspaceSessionState -} from '../../shared/types' +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' export type AppApi = { /** Returns the app identity currently exposed to native chrome and the titlebar. */ diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index b6591162d19..6b352eff527 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -20,16 +20,16 @@ import type { BrowserPopupEvent } from '../../shared/browser-guest-events' import type { - BrowserCookieImportResult, BrowserCertificateFailure, BrowserCertificateProceedResult, + BrowserCookieImportResult, BrowserLoadError, BrowserSessionProfile, BrowserSessionProfileCreateOptions, BrowserSessionProfileScope, BrowserSessionProfileSource, BrowserViewportOverride -} from '../../shared/types' +} from '../../shared/browser-workspace-types' export type BrowserApi = { registerGuest: (args: { diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index e3b6ea202fd..96d0eae30c4 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -1,4 +1,4 @@ -import type { OrcaHooks } from '../../shared/types' +import type { OrcaHooks } from '../../shared/orca-yaml-hook-types' import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' import type { EphemeralVmRecipeDoctorResult } from '../../shared/ephemeral-vm-recipes' import type { EphemeralVmRecipeResultWarning } from '../../shared/ephemeral-vm-recipe-diagnostics' diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index fdc1ac73250..63dd919b76d 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -1,10 +1,9 @@ +import type { SearchOptions, SearchResult } from '../../shared/code-search-types' import type { DirEntry, FsChangedPayload, - MarkdownDocument, - SearchOptions, - SearchResult -} from '../../shared/types' + MarkdownDocument +} from '../../shared/filesystem-entry-types' import type { LocalLogTailChangedPayload, LocalLogTailReadArgs, diff --git a/src/preload/api/git-inspection-api.ts b/src/preload/api/git-inspection-api.ts index c23bcdeecaa..b71234d2d0c 100644 --- a/src/preload/api/git-inspection-api.ts +++ b/src/preload/api/git-inspection-api.ts @@ -1,13 +1,15 @@ import type { GitBranchCompareResult, GitCommitCompareResult, + GitDiffResult +} from '../../shared/git-diff-compare-types' +import type { GitConflictOperation, - GitDiffResult, - GitPushTarget, GitStagingArea, GitStatusResult, GitUpstreamStatus -} from '../../shared/types' +} from '../../shared/git-status-types' +import type { GitPushTarget } from '../../shared/worktree/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { CommitMessageAgentCapability, diff --git a/src/preload/api/git-operation-api.ts b/src/preload/api/git-operation-api.ts index fee33db4efd..d71f95c8537 100644 --- a/src/preload/api/git-operation-api.ts +++ b/src/preload/api/git-operation-api.ts @@ -1,9 +1,6 @@ -import type { - GitForkSyncExpectedUpstream, - GitForkSyncResult, - GitPushTarget, - TuiAgent -} from '../../shared/types' +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { TuiAgent } from '../../shared/tui-agent' +import type { GitPushTarget } from '../../shared/worktree/types' import type { HostedReviewProvider } from '../../shared/hosted-review' import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' import type { SourceControlAiSettings } from '../../shared/source-control-ai-types' diff --git a/src/preload/api/github-pull-request-api.ts b/src/preload/api/github-pull-request-api.ts index 245453cb18f..76f407598b2 100644 --- a/src/preload/api/github-pull-request-api.ts +++ b/src/preload/api/github-pull-request-api.ts @@ -1,25 +1,28 @@ import type { AppStarSource } from '../../shared/gh-star-source' import type { GhAuthDiagnostic } from '../../shared/github/auth-types' import type { TaskSourceContext } from '../../shared/task-source-context' +import type { PRCheckDetail, PRCheckRunDetails } from '../../shared/github/check-types' import type { - GetRateLimitResult, GitHubCommentResult, - GitHubOwnerRepo, - GitHubPRFile, - GitHubPRFileContents, + GitHubPRReviewCommentInput, + GitHubReactionContent, + PRComment +} from '../../shared/github/comment-types' +import type { GitHubPRRefreshCandidate, GitHubPRRefreshEnqueueResult, GitHubPRRefreshEvent, GitHubPRRefreshReason, - GitHubPRReviewCommentInput, - GitHubReactionContent, - GitHubViewer, - PRCheckDetail, - PRCheckRunDetails, - PRComment, - PRInfo, PRRefreshOutcome -} from '../../shared/types' +} from '../../shared/github/pull-request-refresh-types' +import type { + GitHubOwnerRepo, + GitHubPRFile, + GitHubPRFileContents, + GitHubViewer, + PRInfo +} from '../../shared/github/pull-request-types' +import type { GetRateLimitResult } from '../../shared/github/rate-limit-types' import type { GitHubRepoSelectorArgs } from './github-work-item-api' export type GithubPullRequestApi = { diff --git a/src/preload/api/github-work-item-api.ts b/src/preload/api/github-work-item-api.ts index e302e4517a8..064a8ed890b 100644 --- a/src/preload/api/github-work-item-api.ts +++ b/src/preload/api/github-work-item-api.ts @@ -27,17 +27,18 @@ import type { UpdatePullRequestBySlugArgs } from '../../shared/github/project-types' import type { TaskSourceContext } from '../../shared/task-source-context' +import type { GitHubCommentResult } from '../../shared/github/comment-types' import type { GitHubAssignableUser, - GitHubCommentResult, - GitHubCreateIssueResult, - GitHubIssueUpdate, GitHubOwnerRepo, + IssueInfo +} from '../../shared/github/pull-request-types' +import type { GitHubWorkItem, GitHubWorkItemDetails, - IssueInfo, ListWorkItemsResult -} from '../../shared/types' +} from '../../shared/github/work-item-types' +import type { GitHubCreateIssueResult, GitHubIssueUpdate } from '../../shared/issue-mutation-types' export type GitHubRepoSelectorArgs = { repoPath: string diff --git a/src/preload/api/gitlab-api.ts b/src/preload/api/gitlab-api.ts index b1467a891f4..ecde3880083 100644 --- a/src/preload/api/gitlab-api.ts +++ b/src/preload/api/gitlab-api.ts @@ -1,6 +1,6 @@ import type { TaskSourceContext } from '../../shared/task-source-context' +import type { ClassifiedError } from '../../shared/classified-error' import type { - ClassifiedError, GetGitLabRateLimitResult, GitLabAssignableUser, GitLabAuthDiagnostic, @@ -21,7 +21,7 @@ import type { ListMergeRequestsResult, MRInfo, MRListState -} from '../../shared/types' +} from '../../shared/gitlab-types' export type GitLabRepoSelectorArgs = { repoPath: string diff --git a/src/preload/api/jira-api.ts b/src/preload/api/jira-api.ts index d452bd9728d..1294850c284 100644 --- a/src/preload/api/jira-api.ts +++ b/src/preload/api/jira-api.ts @@ -14,7 +14,7 @@ import type { JiraTransition, JiraUser, JiraViewer -} from '../../shared/types' +} from '../../shared/jira-types' export type JiraApi = { connect: (args: { diff --git a/src/preload/api/linear-api.ts b/src/preload/api/linear-api.ts index 613837c4fa4..0b924953d86 100644 --- a/src/preload/api/linear-api.ts +++ b/src/preload/api/linear-api.ts @@ -1,21 +1,22 @@ import type { LinearIssueAttributeFilter } from '../../shared/linear/issue-attribute-filter' +import type { LinearIssueUpdate } from '../../shared/issue-mutation-types' +import type { LinearComment, LinearIssue } from '../../shared/linear/issue-types' import type { - LinearCollectionResult, - LinearComment, - LinearConnectionStatus, LinearCustomViewModel, LinearCustomViewSummary, - LinearIssue, - LinearIssueUpdate, + LinearProjectDetail, + LinearProjectSummary +} from '../../shared/linear/project-types' +import type { + LinearCollectionResult, + LinearConnectionStatus, LinearLabel, LinearMember, - LinearProjectDetail, - LinearProjectSummary, LinearTeam, LinearViewer, LinearWorkflowState, LinearWorkspaceSelection -} from '../../shared/types' +} from '../../shared/linear/workspace-types' export type LinearApi = { connect: (args: { diff --git a/src/preload/api/onboarding-api.ts b/src/preload/api/onboarding-api.ts index 678a6b38294..ba9f10c1a18 100644 --- a/src/preload/api/onboarding-api.ts +++ b/src/preload/api/onboarding-api.ts @@ -1,4 +1,4 @@ -import type { OnboardingState } from '../../shared/types' +import type { OnboardingState } from '../../shared/onboarding-state-types' export type OnboardingApi = { get: () => Promise diff --git a/src/preload/api/os-permission-api.ts b/src/preload/api/os-permission-api.ts index fc452df1196..8718cfc29a5 100644 --- a/src/preload/api/os-permission-api.ts +++ b/src/preload/api/os-permission-api.ts @@ -11,13 +11,13 @@ import type { LocalNetworkConnectionTestResult } from '../../shared/developer-permissions-types' import type { - NotificationDispatchRequest, - NotificationDispatchResult, NotificationDeliveryProbeResult, NotificationDismissResult, + NotificationDispatchRequest, + NotificationDispatchResult, NotificationPermissionStatusResult, NotificationSoundResult -} from '../../shared/types' +} from '../../shared/notification-settings-types' export type NotificationsApi = { dispatch: (args: NotificationDispatchRequest) => Promise diff --git a/src/preload/api/pet-api.ts b/src/preload/api/pet-api.ts index e0031cb8e36..8dffdda300b 100644 --- a/src/preload/api/pet-api.ts +++ b/src/preload/api/pet-api.ts @@ -1,4 +1,4 @@ -import type { CustomPet } from '../../shared/types' +import type { CustomPet } from '../../shared/pet-types' export type PetApi = { import: () => Promise diff --git a/src/preload/api/preflight-api.ts b/src/preload/api/preflight-api.ts index f63099a07ec..8535bb35f8c 100644 --- a/src/preload/api/preflight-api.ts +++ b/src/preload/api/preflight-api.ts @@ -1,5 +1,8 @@ import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' -import type { PathSource, ShellHydrationFailureReason } from '../../shared/types' +import type { + PathSource, + ShellHydrationFailureReason +} from '../../shared/shell-path-hydration-types' export type PreflightStatus = { git: { installed: boolean } diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 774196b6a01..d69d906047d 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -14,7 +14,7 @@ import type { import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' -import type { TuiAgent } from '../../shared/types' +import type { TuiAgent } from '../../shared/tui-agent' import type { PtyManagementApi } from './pty-management-api' export type PtyApi = { diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index 1734c35bdc7..3fa28d5145e 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -4,13 +4,13 @@ import type { ListReposForExecutionHostArgs } from '../../shared/host-repo-catalog-contract' import type { - BaseRefDefaultResult, - BaseRefSearchResult, NestedRepoScanResult, - Project, ProjectGroup, ProjectGroupImportMode, - ProjectGroupImportResult, + ProjectGroupImportResult +} from '../../shared/project-group-types' +import type { + Project, ProjectHostSetup, ProjectHostSetupCreateArgs, ProjectHostSetupCreateResult, @@ -20,9 +20,9 @@ import type { ProjectHostSetupResult, ProjectHostSetupUpdateArgs, ProjectHostSetupUpdateResult, - ProjectUpdateArgs, - Repo -} from '../../shared/types' + ProjectUpdateArgs +} from '../../shared/project-types' +import type { BaseRefDefaultResult, BaseRefSearchResult, Repo } from '../../shared/repo-types' export type RepositoryApi = { list: () => Promise @@ -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 1acc43f5f2c..b796949b7f6 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> getTerminalFitOverrides: () => Promise< diff --git a/src/preload/api/settings-api.ts b/src/preload/api/settings-api.ts index b50843160dd..30f84c59b39 100644 --- a/src/preload/api/settings-api.ts +++ b/src/preload/api/settings-api.ts @@ -3,7 +3,7 @@ import type { WarpThemeImportPreview, WarpThemeImportSource } from '../../shared/terminal-custom-themes' -import type { GhosttyImportPreview, GlobalSettings } from '../../shared/types' +import type { GhosttyImportPreview, GlobalSettings } from '../../shared/global-settings-types' export type SettingsApi = { get: () => Promise diff --git a/src/preload/api/ssh-api.ts b/src/preload/api/ssh-api.ts index b25f1713e16..6740819fa19 100644 --- a/src/preload/api/ssh-api.ts +++ b/src/preload/api/ssh-api.ts @@ -9,7 +9,7 @@ import type { SshTarget, SshTargetAddResult } from '../../shared/ssh-types' -import type { FilesystemPathFlavor } from '../../shared/types' +import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' export type SshApi = { listTargets: () => Promise diff --git a/src/preload/api/telemetry-api.ts b/src/preload/api/telemetry-api.ts index 9fc70f908b7..99f9fbad4b0 100644 --- a/src/preload/api/telemetry-api.ts +++ b/src/preload/api/telemetry-api.ts @@ -1,5 +1,5 @@ import type { TelemetryConsentState } from '../../shared/telemetry-consent-types' -import type { MemorySnapshot, StatsSummary } from '../../shared/types' +import type { MemorySnapshot, StatsSummary } from '../../shared/process-stats-types' export type StatsApi = { getSummary: () => Promise diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 49de419bf39..60d6b87f1b9 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -1,10 +1,10 @@ +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import type { TuiAgent } from '../../shared/tui-agent' import type { - PersistedUIState, - TuiAgent, WorktreeDefaultTabsLaunch, WorktreeSetupLaunch, WorktreeStartupLaunch -} from '../../shared/types' +} from '../../shared/worktree/launch-types' import type { FeatureInteractionId } from '../../shared/feature-interactions' import type { KeybindingActionId } from '../../shared/keybindings' import type { BrowserFindSource } from '../../shared/browser-find-source' @@ -65,6 +65,7 @@ export type UiCommandEventApi = { requestId: string url: string worktreeId?: string + browserPageId?: string sessionProfileId?: string | null sessionPartition?: string activate?: boolean @@ -83,7 +84,11 @@ export type UiCommandEventApi = { onRequestTabClose: ( callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void ) => () => void - replyTabClose: (reply: { requestId: string; error?: string }) => void + replyTabClose: (reply: { + requestId: string + error?: string + code?: 'browser_tab_not_found' + }) => void onNewTerminalTab: (callback: () => void) => () => void onFocusBrowserAddressBar: (callback: () => void) => () => void onFindInBrowserPage: (source: BrowserFindSource, callback: () => void) => () => void diff --git a/src/preload/api/updater-api.ts b/src/preload/api/updater-api.ts index f6a43da9834..0a3590e8035 100644 --- a/src/preload/api/updater-api.ts +++ b/src/preload/api/updater-api.ts @@ -3,7 +3,7 @@ import type { ReleaseBuildListResult, UpdateCheckOptions, UpdateStatus -} from '../../shared/types' +} from '../../shared/update-status-types' import type { ReleaseChannel } from '../../shared/release-channel' export type UpdaterApi = { 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/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index 35eb17486d7..1d2a87bfeb1 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -1,9 +1,8 @@ +import type { IssueInfo, PRInfo } from '../../shared/github/pull-request-types' import type { - IssueInfo, - PRInfo, WorkspaceSessionPatch, WorkspaceSessionState -} from '../../shared/types' +} from '../../shared/workspace-session-state-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { RemoteWorkspaceChangedEvent, diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index 96089d10e88..b6564d3f5db 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -17,25 +17,28 @@ import type { HostLineageSnapshot, ListDesktopLineageForHostArgs } from '../../shared/host-lineage-contract' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { + WorktreeBaseStatusEvent, + WorktreeRemoteBranchConflictEvent +} from '../../shared/worktree/base-ref-drift-types' import type { AdoptProvisionedRootArgs, CreateWorktreeArgs, CreateWorktreeResult, - DetectedWorktreeListResult, - FolderWorkspace, ForceDeleteWorktreeBranchResult, + RemoveWorktreeResult, + SparsePreset +} from '../../shared/worktree/create-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { + DetectedWorktreeListResult, GitHubPrStartPoint, GitPushTarget, - RemoveWorktreeResult, - SparsePreset, - WorkspaceLineage, Worktree, - WorktreeBaseStatusEvent, - WorktreeHeadIdentity, - WorktreeLineage, - WorktreeMeta, - WorktreeRemoteBranchConflictEvent -} from '../../shared/types' + WorktreeHeadIdentity +} from '../../shared/worktree/types' export type WorktreeApi = { list: (args: { repoId: string }) => Promise @@ -90,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 1bc04cf1464..9ad5d0aeb5a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -61,54 +61,55 @@ import type { } from '../shared/plugins/plugin-panel-bridge' import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request' import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' +import type { BrowserViewportOverride } from '../shared/browser-workspace-types' +import type { SearchResult } from '../shared/code-search-types' import type { - BaseRefSearchResult, - BaseRefDefaultResult, - BrowserViewportOverride, - CustomPet, - FsChangedPayload, FilesystemPathFlavor, - GetRateLimitResult, + FsChangedPayload, + MarkdownDocument +} from '../shared/filesystem-entry-types' +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../shared/git-fork-sync' +import type { GitStagingArea, GitUpstreamStatus } from '../shared/git-status-types' +import type { GitHubCommentResult, GitHubReactionContent } from '../shared/github/comment-types' +import type { GitHubPRRefreshCandidate, GitHubPRRefreshEvent, - GitHubPRRefreshReason, - GitHubAssignableUser, - GitHubCommentResult, - GitHubCreateIssueResult, - GitHubOwnerRepo, - GitHubReactionContent, - GitHubWorkItem, - JiraProjectStatusOrder, - GitPushTarget, - GitStagingArea, - GitForkSyncExpectedUpstream, - GitForkSyncResult, - GitUpstreamStatus, - GhosttyImportPreview, - ListWorkItemsResult, - LinearProjectDetail, - MemorySnapshot, + GitHubPRRefreshReason +} from '../shared/github/pull-request-refresh-types' +import type { GitHubAssignableUser, GitHubOwnerRepo } from '../shared/github/pull-request-types' +import type { GetRateLimitResult } from '../shared/github/rate-limit-types' +import type { GitHubWorkItem, ListWorkItemsResult } from '../shared/github/work-item-types' +import type { GhosttyImportPreview } from '../shared/global-settings-types' +import type { GitHubCreateIssueResult } from '../shared/issue-mutation-types' +import type { JiraProjectStatusOrder } from '../shared/jira-types' +import type { LinearProjectDetail } from '../shared/linear/project-types' +import type { + NotificationDeliveryProbeResult, NotificationDismissResult, NotificationDispatchResult, - NotificationDeliveryProbeResult, NotificationPermissionStatusResult, NotificationSoundDataResult, NotificationSoundPathResult, - NotificationSoundResult, - NestedRepoScanResult, - OnboardingState, - PersistedUIState, - FloatingTerminalCwdRequest, - MarkdownDocument, - SearchResult, - TuiAgent, - UpdateStatus, + NotificationSoundResult +} from '../shared/notification-settings-types' +import type { OnboardingState } from '../shared/onboarding-state-types' +import type { PersistedUIState } from '../shared/persisted-ui-state-types' +import type { CustomPet } from '../shared/pet-types' +import type { MemorySnapshot } from '../shared/process-stats-types' +import type { NestedRepoScanResult } from '../shared/project-group-types' +import type { BaseRefDefaultResult, BaseRefSearchResult } from '../shared/repo-types' +import type { TuiAgent } from '../shared/tui-agent' +import type { FloatingTerminalCwdRequest } from '../shared/ui-chrome-types' +import type { UpdateStatus } from '../shared/update-status-types' +import type { WorktreeBaseStatusEvent, + WorktreeRemoteBranchConflictEvent +} from '../shared/worktree/base-ref-drift-types' +import type { WorktreeDefaultTabsLaunch, - WorktreeHeadIdentity, - WorktreeRemoteBranchConflictEvent, WorktreeSetupLaunch -} from '../shared/types' +} from '../shared/worktree/launch-types' +import type { GitPushTarget, WorktreeHeadIdentity } from '../shared/worktree/types' import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' import type { PtyListedSession } from '../shared/pty-listed-session' import type { @@ -165,9 +166,9 @@ import type { import type { RuntimeBrowserDriverState, RuntimeMobileSessionTabMove, + RuntimeRendererSyncWindowGraph, RuntimeStatus, RuntimeSyncWindowGraphResult, - RuntimeSyncWindowGraph, RuntimeTerminalCreateRequestPayload, RuntimeTerminalDriverState, RuntimeTerminalPresentation @@ -935,14 +936,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 = ( @@ -3757,6 +3767,7 @@ const api = { requestId: string url: string worktreeId?: string + browserPageId?: string sessionProfileId?: string | null sessionPartition?: string activate?: boolean @@ -3768,6 +3779,7 @@ const api = { requestId: string url: string worktreeId?: string + browserPageId?: string sessionProfileId?: string | null sessionPartition?: string activate?: boolean @@ -3816,7 +3828,11 @@ const api = { ipcRenderer.on('browser:requestTabClose', listener) return () => ipcRenderer.removeListener('browser:requestTabClose', listener) }, - replyTabClose: (reply: { requestId: string; error?: string }): void => { + replyTabClose: (reply: { + requestId: string + error?: string + code?: 'browser_tab_not_found' + }): void => { ipcRenderer.send('browser:tabCloseReply', reply) }, onNewTerminalTab: (callback: () => void): (() => void) => { @@ -4413,7 +4429,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/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index 0a177dd55b0..2dd0ad316aa 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -4,7 +4,7 @@ import { prepareRendererForAppRestart, type UpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation' -import type { UpdateStatus } from '../shared/types' +import type { UpdateStatus } from '../shared/update-status-types' import { ORCA_APP_RESTART_ABORTED_EVENT, ORCA_APP_RESTART_STARTED_EVENT, diff --git a/src/preload/ssh-authority-forwarding.test.ts b/src/preload/ssh-authority-forwarding.test.ts index 1292d09e032..34179e1e7fa 100644 --- a/src/preload/ssh-authority-forwarding.test.ts +++ b/src/preload/ssh-authority-forwarding.test.ts @@ -5,7 +5,7 @@ import type { HostQualifiedDetectedWorktreeResult, ListDetectedWorktreesArgs } from '../shared/detected-worktree-provider-contract' -import type { DetectedWorktreeListResult } from '../shared/types' +import type { DetectedWorktreeListResult } from '../shared/worktree/types' function listDetectedVariableTypeProbe(api: PreloadApi, args: ListDetectedWorktreesArgs) { return api.worktrees.listDetected(args) diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts index c884c499dac..42a79469d42 100644 --- a/src/relay/agent-exec-handler.test.ts +++ b/src/relay/agent-exec-handler.test.ts @@ -1,5 +1,5 @@ import { execFile, spawn } from 'node:child_process' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as ChildProcess from 'node:child_process' import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness' import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard' @@ -18,10 +18,29 @@ const execFileMock = vi.mocked(execFile) type AgentExecResult = { exitCode: number | null; timedOut: boolean } +const GUARD_OWNED_ENV_RE = /^(?:GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)|WSLENV)$/ + describe('AgentExecHandler', () => { + let ambientGuardEnv: Record = {} + beforeEach(() => { spawnMock.mockReset() execFileMock.mockReset() + // Why: the guard rewrites these, so an already-guarded runner (Orca guards + // its own agent terminals) would not see its ambient values passed through. + ambientGuardEnv = {} + for (const key of Object.keys(process.env).filter((name) => GUARD_OWNED_ENV_RE.test(name))) { + ambientGuardEnv[key] = process.env[key] + delete process.env[key] + } + }) + + afterEach(() => { + for (const [key, value] of Object.entries(ambientGuardEnv)) { + if (value !== undefined) { + process.env[key] = value + } + } }) it('executes a non-interactive command with captured output and stdin', async () => { diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index 83726c686bf..a9df29dd2f2 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -15,7 +15,7 @@ import { SEARCH_TIMEOUT_MS as SHARED_SEARCH_TIMEOUT_MS } from '../shared/text-search' import { IMAGE_FILE_MIME_TYPES } from '../shared/image-file-extensions' -import type { SearchResult as SharedSearchResult } from '../shared/types' +import type { SearchResult as SharedSearchResult } from '../shared/code-search-types' import { absorbPendingRipgrepSpawnError, isRipgrepUnavailableAfterLaunchFailure, diff --git a/src/relay/git-handler-push-target.ts b/src/relay/git-handler-push-target.ts index dfbed1a9186..40765491c56 100644 --- a/src/relay/git-handler-push-target.ts +++ b/src/relay/git-handler-push-target.ts @@ -1,6 +1,6 @@ import { assertGitPushTargetShape } from '../shared/git-push-target-validation' import { gitRefTargetsBranchOnRemote } from '../shared/git-remote-branch-name' -import type { GitPushTarget } from '../shared/types' +import type { GitPushTarget } from '../shared/worktree/types' type RelayGit = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index a9d0b63ea8a..f87311c2cb3 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -8,7 +8,7 @@ import { readFile } from 'node:fs/promises' import { parseUnmergedEntry } from './git-handler-utils' import type { GitExec } from './git-handler-ops' import type { RelayGitStreamExec } from './git-stdout-stream' -import type { GitUpstreamStatus } from '../shared/types' +import type { GitUpstreamStatus } from '../shared/git-status-types' import { StatusPorcelainParser } from '../shared/git-status-porcelain-parser' import { splitRemoteBranchName } from '../shared/git-effective-upstream' import { readOrProbeNoEffectiveUpstreamStatus } from './git-status-upstream-negative-cache' diff --git a/src/relay/git-handler-worktree-remove.ts b/src/relay/git-handler-worktree-remove.ts index 80bf08c25fe..474e03bab88 100644 --- a/src/relay/git-handler-worktree-remove.ts +++ b/src/relay/git-handler-worktree-remove.ts @@ -1,5 +1,5 @@ import * as path from 'node:path' -import type { RemoveWorktreeResult } from '../shared/types' +import type { RemoveWorktreeResult } from '../shared/worktree/create-types' import { assertWorktreeUnlockedForRemoval } from '../shared/worktree/removal' import { isSubmoduleWorktreeRemovalRefusal } from '../shared/worktree/submodule-removal' import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup' diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 3e8a20ea992..0558f710970 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -61,7 +61,7 @@ import { upstreamOnlyCommitsArePatchEquivalent } from '../shared/git-upstream-st import { assertGitPushTargetShape } from '../shared/git-push-target-validation' import { getPublishTargetStatus, type GitCommandRunner } from '../shared/git-publish-target-status' import { resolveGitRemoteRebaseSource } from '../shared/git-rebase-source' -import type { GitPushTarget } from '../shared/types' +import type { GitPushTarget } from '../shared/worktree/types' import { getEffectiveGitUpstreamStatus, resolveEffectiveGitUpstream diff --git a/src/relay/git-status-upstream-negative-cache.ts b/src/relay/git-status-upstream-negative-cache.ts index e3ad9239cc9..f3dcd2df663 100644 --- a/src/relay/git-status-upstream-negative-cache.ts +++ b/src/relay/git-status-upstream-negative-cache.ts @@ -1,7 +1,7 @@ import { createGitConfigSnapshotRunner } from '../shared/git-config-snapshot-runner' import { getEffectiveGitUpstreamStatus } from '../shared/git-effective-upstream' import type { GitCommandRunner } from '../shared/git-effective-upstream' -import type { GitUpstreamStatus } from '../shared/types' +import type { GitUpstreamStatus } from '../shared/git-status-types' const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 5 * 60_000 const MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES = 512 diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index faa12d5093b..c077378ebda 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -44,7 +44,7 @@ import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env' import { isTuiAgent } from '../shared/tui-agent-config' -import type { TuiAgent } from '../shared/types' +import type { TuiAgent } from '../shared/tui-agent' import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' import { stripInheritedBuildModeEnv } from '../main/pty/build-mode-env' import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir' diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bd962d1f95b..ce913a3d1cb 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -176,7 +176,8 @@ import { selectFloatingVisibleTabCount } from './store/selectors' import { selectActiveTerminalChromeState } from './store/active-terminal-chrome-selector' import type { VirtualizedScrollAnchor } from './hooks/useVirtualizedScrollAnchor' import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-types' -import type { OnboardingState, UpdateStatus } from '../../shared/types' +import type { OnboardingState } from '../../shared/onboarding-state-types' +import type { UpdateStatus } from '../../shared/update-status-types' import { getFeatureTipsAppOpenDecision, isCliFeatureTipCompleted diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index bba079e9f6d..fea34534873 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -186,22 +186,22 @@ import { import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { buildFixBrokenChecksPrompt, getBrokenChecks } from '@/components/pr-checks-fix-prompt' +import type { GitBranchChangeEntry, GitDiffResult } from '../../../shared/git-diff-compare-types' +import type { PRCheckDetail } from '../../../shared/github/check-types' import type { + GitHubIssueTimelineItem, + GitHubIssueTimelineTarget, + PRComment +} from '../../../shared/github/comment-types' +import type { + GitHubAssignableUser, GitHubOwnerRepo, GitHubPRFile, GitHubPRFileContents, GitHubPRFileViewedState, - GitHubWorkItem, - GitHubWorkItemDetails, - GitHubIssueTimelineItem, - GitHubIssueTimelineTarget, - GitHubAssignableUser, - GitHubPRMergeMethod, - GitBranchChangeEntry, - GitDiffResult, - PRCheckDetail, - PRComment -} from '../../../shared/types' + GitHubPRMergeMethod +} from '../../../shared/github/pull-request-types' +import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../../shared/github/work-item-types' import { getTaskSourceCacheScope, getTaskSourceRuntimeSettings, diff --git a/src/renderer/src/components/GitLabItemDialog.tsx b/src/renderer/src/components/GitLabItemDialog.tsx index 4d3b0f0d722..7dcf2fba06d 100644 --- a/src/renderer/src/components/GitLabItemDialog.tsx +++ b/src/renderer/src/components/GitLabItemDialog.tsx @@ -44,12 +44,12 @@ import { import { useAppStore } from '@/store' import type { GitLabAssignableUser, - GitLabPipelineJob, GitLabMRUpdate, + GitLabPipelineJob, GitLabWorkItem, GitLabWorkItemDetails, MRComment -} from '../../../shared/types' +} from '../../../shared/gitlab-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index 969765c4dca..b1f2078900c 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -45,7 +45,7 @@ import type { JiraPriority, JiraTransition, JiraUser -} from '../../../shared/types' +} from '../../../shared/jira-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index bcf5c66ec1f..701863a1ef9 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -3,7 +3,7 @@ import { AlertTriangle, ExternalLink, FolderPlus, GitBranchPlus, Star, X } from import { cn } from '../lib/utils' import { useAppStore } from '../store' import { isGitRepoKind } from '../../../shared/repo-kind' -import type { Repo } from '../../../shared/types' +import type { Repo } from '../../../shared/repo-types' import { dismissPreflightIssue, githubProjectKeys, diff --git a/src/renderer/src/components/LinearIssueTextEditor.tsx b/src/renderer/src/components/LinearIssueTextEditor.tsx index e00f508a7ac..42db6a5c4c8 100644 --- a/src/renderer/src/components/LinearIssueTextEditor.tsx +++ b/src/renderer/src/components/LinearIssueTextEditor.tsx @@ -8,7 +8,7 @@ import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { linearUpdateIssue } from '@/runtime/runtime-linear-client' -import type { LinearIssue } from '../../../shared/types' +import type { LinearIssue } from '../../../shared/linear/issue-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { getLinearIssueTextSavePlan, diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index 690f2d42655..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, @@ -70,9 +71,9 @@ import { import type { LinearComment, LinearIssue, - LinearIssueChildSummary, - LinearProjectSummary -} from '../../../shared/types' + LinearIssueChildSummary +} from '../../../shared/linear/issue-types' +import type { LinearProjectSummary } from '../../../shared/linear/project-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' @@ -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/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 82053896d36..5cbd0cc2a3b 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -57,7 +57,7 @@ import { getLinearStatePillStyle } from '@/components/linear-state-pill-style' import { LinearPriorityIcon } from '@/components/linear-priority-icon' -import type { LinearIssue, LinearComment } from '../../../shared/types' +import type { LinearComment, LinearIssue } from '../../../shared/linear/issue-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { linearAddIssueComment, diff --git a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx index e0685b55fbe..0c44cf153e5 100644 --- a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx +++ b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx @@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, screen, type RenderResult } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { LinuxPackageInstallRecovery } from '../../../shared/types' +import type { LinuxPackageInstallRecovery } from '../../../shared/update-status-types' import { LinuxPackageInstallRecoveryCard } from './LinuxPackageInstallRecoveryCard' const RELEASE_URL = 'https://github.com/stablyai/orca/releases/tag/v1.4.200' diff --git a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx index 3d89b7ee6b7..69a0f73d627 100644 --- a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx +++ b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { LinuxPackageInstallInstructions, LinuxPackageInstallRecovery -} from '../../../shared/types' +} from '../../../shared/update-status-types' import { UpdateErrorCardContent } from './UpdateErrorCardContent' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index b9683150d17..4606a625c07 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -34,16 +34,13 @@ import { } from '@/lib/text-control-paste' import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' -import type { - GitHubWorkItem, - GitLabWorkItem, - JiraIssue, - LinearIssue, - SetupAgentStartupPolicy, - OrcaHooks, - SparsePreset, - TuiAgent -} from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import type { GitLabWorkItem } from '../../../shared/gitlab-types' +import type { JiraIssue } from '../../../shared/jira-types' +import type { LinearIssue } from '../../../shared/linear/issue-types' +import type { OrcaHooks, SetupAgentStartupPolicy } from '../../../shared/orca-yaml-hook-types' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { SparsePreset } from '../../../shared/worktree/create-types' import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect' import SmartWorkspaceNameField, { type SmartWorkspaceNameSelection diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index aa46a10f7bf..77b9cf64e52 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -19,12 +19,10 @@ import { import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import type { - GitHubWorkItem, - TuiAgent, - WorkspaceCreateTelemetrySource, - WorkspaceStatus -} from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../shared/workspace-source' +import type { WorkspaceStatus } from '../../../shared/worktree/types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' import { getWorkspaceComposerInitialFocusTarget } from '@/lib/workspace-composer-initial-focus' diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index f47b452b573..e495583ab4d 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -193,20 +193,18 @@ import { saveSourceControlActionRecipe, type SourceControlAiWriteTarget } from '../../../shared/source-control-ai-recipe-save' +import type { GitBranchChangeEntry, GitDiffResult } from '../../../shared/git-diff-compare-types' +import type { PRCheckDetail } from '../../../shared/github/check-types' +import type { PRComment } from '../../../shared/github/comment-types' import type { + GitHubAssignableUser, GitHubOwnerRepo, GitHubPRFile, GitHubPRFileContents, GitHubPRFileViewedState, - GitHubWorkItem, - GitHubWorkItemDetails, - GitHubAssignableUser, - GitHubPRMergeMethod, - GitBranchChangeEntry, - GitDiffResult, - PRCheckDetail, - PRComment -} from '../../../shared/types' + GitHubPRMergeMethod +} from '../../../shared/github/pull-request-types' +import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../../shared/github/work-item-types' import { getTaskSourceCacheScope, getTaskSourceRuntimeSettings, diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index d7cfc3e9e5e..cf6e884b7b0 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -351,34 +351,38 @@ import { resolveGitHubPRMergeMethods } from '../../../shared/github/pull-request-merge-methods' import type { - GitHubOwnerRepo, GitHubAssignableUser, - GitHubPRMergeMethod, - GitHubIssueUpdate, - GitHubWorkItem, - GitLabTodo, - GitLabWorkItem, + GitHubOwnerRepo, + GitHubPRMergeMethod +} from '../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import type { GitLabProjectRef, GitLabTodo, GitLabWorkItem } from '../../../shared/gitlab-types' +import type { GitHubIssueUpdate } from '../../../shared/issue-mutation-types' +import type { JiraCreateField, - LinearCollectionResult, - LinearCustomViewModel, - LinearCustomViewSummary, JiraIssue, JiraIssueType, - JiraProject, - JiraProjectStatusOrder, JiraPriority, - LinearIssue, + JiraProject, + JiraProjectStatusOrder +} from '../../../shared/jira-types' +import type { LinearIssue } from '../../../shared/linear/issue-types' +import type { + LinearCustomViewModel, + LinearCustomViewSummary, LinearProjectDetail, - LinearProjectSummary, + LinearProjectSummary +} from '../../../shared/linear/project-types' +import type { + LinearCollectionResult, LinearTeam, - LinearWorkspaceSelection, LinearWorkflowState, - Repo, - TaskProvider, - TaskViewPresetId -} from '../../../shared/types' + LinearWorkspaceSelection +} from '../../../shared/linear/workspace-types' +import type { Repo } from '../../../shared/repo-types' +import type { TaskProvider } from '../../../shared/task-providers' +import type { TaskViewPresetId } from '../../../shared/ui-chrome-types' import type { PreflightStatus } from '../../../preload/api-types' -import type { GitLabProjectRef } from '../../../shared/gitlab-types' import { LINEAR_ISSUE_LIST_MAX, clampLinearIssueListLimit diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index d555a92d22c..d6878135189 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -38,7 +38,9 @@ import { import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload' import { preventUnloadAndScheduleShutdownCheckpointReset } from '@/lib/shutdown-checkpoint-guard' import EditorAutosaveController from './editor/EditorAutosaveController' -import type { Tab, TabGroupLayoutNode, TerminalTab, TuiAgent } from '../../../shared/types' +import type { Tab, TabGroupLayoutNode } from '../../../shared/tab-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../shared/tui-agent' import { hasFeatureInteraction } from '../../../shared/feature-interactions' import BrowserPane from './browser-pane/BrowserPane' import { RetainedBrowserPaneOverlayLayer } from './browser-pane/BrowserPaneOverlayLayer' diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx index 4e4aec4d2b5..4cb297b7ca4 100644 --- a/src/renderer/src/components/UpdateCard.error-card.test.tsx +++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx @@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, screen, type RenderResult } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { LinuxPackageInstallRecovery } from '../../../shared/types' +import type { LinuxPackageInstallRecovery } from '../../../shared/update-status-types' import { useAppStore } from '../store' import { UpdateCard } from './UpdateCard' diff --git a/src/renderer/src/components/UpdateCard.test.ts b/src/renderer/src/components/UpdateCard.test.ts index 6eed56fad3e..d5103ed30fc 100644 --- a/src/renderer/src/components/UpdateCard.test.ts +++ b/src/renderer/src/components/UpdateCard.test.ts @@ -1,7 +1,7 @@ import { createStore, type StoreApi } from 'zustand/vanilla' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultUIState } from '../../../shared/constants' -import type { ChangelogData, UpdateStatus } from '../../../shared/types' +import type { ChangelogData, UpdateStatus } from '../../../shared/update-status-types' import { createUISlice } from '../store/slices/ui' import type { AppState } from '../store/types' import { isHttp2ProtocolError } from './UpdateCard' diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index b47e9fedd72..bb2b2d194c3 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -6,7 +6,7 @@ import { Card } from './ui/card' import { Button } from './ui/button' import { Progress } from './ui/progress' import { AlertCircle, Check, Loader2, Minus, X } from 'lucide-react' -import type { ChangelogData } from '../../../shared/types' +import type { ChangelogData } from '../../../shared/update-status-types' import { UpdateErrorCardContent, type UpdateErrorCardModel } from './UpdateErrorCardContent' import { LinuxPackageInstallRecoveryCard } from './LinuxPackageInstallRecoveryCard' import { diff --git a/src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx index 709d57777c7..0637cad8d42 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx @@ -7,7 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactI18Next from 'react-i18next' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -import type { GitHubWorkItem, LinearIssue, Repo } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import type { LinearIssue } from '../../../shared/linear/issue-types' +import type { Repo } from '../../../shared/repo-types' import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import { resolveWorkspaceCreationTarget } from '@/lib/project-host-workspace-target' import { WORKTREE_PALETTE_QUERY_MAX_BYTES } from '@/lib/worktree-palette-query-bounds' diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 4f29ebb5c24..284299520b7 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -51,9 +51,9 @@ import { isAutomationGeneratedWorkspace, isCliCreatedWorkspace, isDetachedHeadWorkspace, - isDefaultBranchWorkspace, isSleepingSweepExemptWorkspace } from '@/components/sidebar/visible-worktrees' +import { isDefaultBranchWorkspace } from '@/components/sidebar/default-branch-workspace' import { EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, getPairedDeviceIdsByEnvironment, @@ -200,7 +200,10 @@ import { getSettingsFocusedExecutionHostId, isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' -import type { GitHubWorkItem, LinearIssue, TerminalTab, Worktree } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import type { LinearIssue } from '../../../shared/linear/issue-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { Worktree } from '../../../shared/worktree/types' import { isGitRepoKind } from '../../../shared/repo-kind' import { buildTaskSourceContextFromRepo, @@ -724,6 +727,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) @@ -1495,7 +1499,9 @@ function WorktreeJumpPaletteContent({ } for (const repoId of buildImportedWorktreesCardCandidates({ repos, - detectedWorktreesByRepo + detectedWorktreesByRepo, + settings, + visibilityDefaultsByHost: worktreeVisibilityDefaultsByHost }).keys()) { ids.add(repoId) } @@ -1503,7 +1509,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/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index 05aec890947..e20c9455261 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -6,7 +6,9 @@ import { type AgentStatusEntry } from '../../../../shared/agent-status-types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import { formatAgentTypeLabel } from '@/lib/agent-status' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 8c8058fbeeb..6157c796d71 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -61,7 +61,9 @@ import { type ActivityPortalReadinessLatch, type ActivityPortalReadinessStatus } from './activity-portal-readiness-oscillation' -import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { AGENT_STATUS_STALE_AFTER_MS, diff --git a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx index a668162753a..aa752c4b50f 100644 --- a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx +++ b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx @@ -31,7 +31,7 @@ import { } from '@/lib/launch-agent-session-continuation' import { useAppStore } from '@/store' import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { chooseInitialContinuationAgent } from './agent-session-continuation-selection' type AgentSessionContinuationDialogProps = { diff --git a/src/renderer/src/components/agent-session-continuation/agent-session-continuation-selection.ts b/src/renderer/src/components/agent-session-continuation/agent-session-continuation-selection.ts index 3a7617caacd..8297a104d3e 100644 --- a/src/renderer/src/components/agent-session-continuation/agent-session-continuation-selection.ts +++ b/src/renderer/src/components/agent-session-continuation/agent-session-continuation-selection.ts @@ -1,5 +1,5 @@ import { isTuiAgent } from '../../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' export function chooseInitialContinuationAgent(args: { availableAgents: TuiAgent[] diff --git a/src/renderer/src/components/agent/AgentCombobox.test.tsx b/src/renderer/src/components/agent/AgentCombobox.test.tsx index 08b8b2d37c1..629aea0ee15 100644 --- a/src/renderer/src/components/agent/AgentCombobox.test.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.test.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' import { AGENT_FAVICON_ASSETS } from '@/lib/agent-favicon-assets' import AgentCombobox from './AgentCombobox' diff --git a/src/renderer/src/components/agent/AgentCombobox.tsx b/src/renderer/src/components/agent/AgentCombobox.tsx index a4fa9202543..1c2ef729db9 100644 --- a/src/renderer/src/components/agent/AgentCombobox.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.tsx @@ -22,7 +22,7 @@ import { searchAgentPickerEntries } from '@/lib/agent-picker-search' import { cn } from '@/lib/utils' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { createAgentComboboxCommandState, resolveAgentComboboxCommandState, diff --git a/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx b/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx index 9c53f03f7b7..ee45c71c30e 100644 --- a/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx +++ b/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx @@ -4,7 +4,7 @@ import { act, createElement, type ComponentProps, type ReactNode } 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 type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { AgentsPane } from '@/components/settings/AgentsPane' import { resetWindowsTerminalCapabilitiesForTests } from '@/lib/windows-terminal-capabilities' diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index 6ac0c7bc66c..569d1a8a2ea 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -6,15 +6,13 @@ import type { AutomationSchedulePreset, AutomationWorkspaceMode } from '../../../../shared/automations-types' -import type { - GlobalSettings, - OrcaHooks, - ProjectHostSetup, - Repo, - SetupDecision, - TuiAgent, - Worktree -} from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { SetupDecision } from '../../../../shared/worktree/create-types' +import type { Worktree } from '../../../../shared/worktree/types' import { isValidAutomationCronSchedule, isValidAutomationSchedule diff --git a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx index 160dac72d8b..2c68beee9b3 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx @@ -7,13 +7,11 @@ import AgentCombobox from '@/components/agent/AgentCombobox' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import type { AutomationWorkspaceMode } from '../../../../shared/automations-types' -import type { - GlobalSettings, - OrcaHooks, - ProjectHostSetup, - Repo, - Worktree -} from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { AgentCatalogEntry } from '@/lib/agent-catalog' import { Field } from './automation-page-parts' import { AutomationMissedRunGraceField } from './AutomationMissedRunGraceField' diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx index 93019241d63..cde61bd6201 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx @@ -27,7 +27,9 @@ import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { SshConnectionState } from '../../../../shared/ssh-types' -import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceHostAvailability } from '../task-source-context-summary' import type { AutomationHostTarget } from './automation-host-client' diff --git a/src/renderer/src/components/automations/AutomationProjectCombobox.tsx b/src/renderer/src/components/automations/AutomationProjectCombobox.tsx index 65d0176bf29..73fc44bddb5 100644 --- a/src/renderer/src/components/automations/AutomationProjectCombobox.tsx +++ b/src/renderer/src/components/automations/AutomationProjectCombobox.tsx @@ -11,7 +11,7 @@ import { isRepoSearchQueryTooLarge, searchRepos } from '@/lib/repo-search' import { cn } from '@/lib/utils' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { getAutomationProjectGroupForRepo, getAutomationProjectGroups, diff --git a/src/renderer/src/components/automations/AutomationRunHistory.tsx b/src/renderer/src/components/automations/AutomationRunHistory.tsx index 777eaff3fa2..323accb7259 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react' import { Badge } from '@/components/ui/badge' import { cn } from '@/lib/utils' import type { AutomationRun } from '../../../../shared/automations-types' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { formatAutomationDateTime, getAutomationRunStatusLabel, diff --git a/src/renderer/src/components/automations/AutomationSetupDecisionField.tsx b/src/renderer/src/components/automations/AutomationSetupDecisionField.tsx index 2731dba8164..56a05f881b4 100644 --- a/src/renderer/src/components/automations/AutomationSetupDecisionField.tsx +++ b/src/renderer/src/components/automations/AutomationSetupDecisionField.tsx @@ -5,7 +5,9 @@ import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { getVisibleAutomationSetupDecision } from './automation-setup-decision' import type { AutomationCreateTarget, AutomationDraft } from './AutomationEditorDialog' -import type { OrcaHooks, ProjectHostSetup, Repo } from '../../../../shared/types' +import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' type AutomationSetupDecisionFieldProps = { createTarget: AutomationCreateTarget diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx index 48ffceba88f..4de2a9ed795 100644 --- a/src/renderer/src/components/automations/AutomationsDetailPane.tsx +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -11,7 +11,7 @@ import type { ExternalAutomationRun, AutomationRun } from '../../../../shared/automations-types' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { AutomationDetail } from './AutomationDetail' import { HermesCronOutputView } from './HermesCronOutputView' diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx index a792839885c..b946d846b05 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -10,7 +10,9 @@ import type { ExternalAutomationManager } from '../../../../shared/automations-types' import type { SshConnectionState } from '../../../../shared/ssh-types' -import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceHostAvailability } from '../task-source-context-summary' import type { AutomationHostTarget } from './automation-host-client' diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index a17d79e4197..4445cf21eeb 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -28,7 +28,8 @@ import { import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import type { PreflightStatus } from '../../../../preload/api-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { OrcaHooks, Repo } from '../../../../shared/types' +import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' +import type { Repo } from '../../../../shared/repo-types' import { getWorktreePathBasenameFromId } from '../../../../shared/worktree/id' import { buildAutomationRrule, diff --git a/src/renderer/src/components/automations/CreateFromPicker.test.tsx b/src/renderer/src/components/automations/CreateFromPicker.test.tsx index 46cb4ded084..3aaeb3f2a7f 100644 --- a/src/renderer/src/components/automations/CreateFromPicker.test.tsx +++ b/src/renderer/src/components/automations/CreateFromPicker.test.tsx @@ -3,7 +3,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/types' +import type { Repo } from '../../../../shared/repo-types' import { CreateFromPicker } from './CreateFromPicker' import { getRuntimeRepoBaseRefDefault, diff --git a/src/renderer/src/components/automations/CreateFromPicker.tsx b/src/renderer/src/components/automations/CreateFromPicker.tsx index 083ba015da7..a392627ee18 100644 --- a/src/renderer/src/components/automations/CreateFromPicker.tsx +++ b/src/renderer/src/components/automations/CreateFromPicker.tsx @@ -11,7 +11,8 @@ import { } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { useAppStore } from '@/store' import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' import { diff --git a/src/renderer/src/components/automations/WorkspaceCombobox.tsx b/src/renderer/src/components/automations/WorkspaceCombobox.tsx index 0951a2e23b2..ffad3e6ccbb 100644 --- a/src/renderer/src/components/automations/WorkspaceCombobox.tsx +++ b/src/renderer/src/components/automations/WorkspaceCombobox.tsx @@ -10,7 +10,7 @@ import { } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { translate } from '@/i18n/i18n' export function WorkspaceCombobox({ diff --git a/src/renderer/src/components/automations/automation-draft-model.ts b/src/renderer/src/components/automations/automation-draft-model.ts index 2abf4511388..827f84543a4 100644 --- a/src/renderer/src/components/automations/automation-draft-model.ts +++ b/src/renderer/src/components/automations/automation-draft-model.ts @@ -1,7 +1,7 @@ import { getAgentCatalog } from '@/lib/agent-catalog' import type { AutomationPrecheck } from '../../../../shared/automations-types' import { buildAutomationCronSchedule } from '../../../../shared/automation-schedules' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import type { AutomationDraft } from './AutomationEditorDialog' export const AUTOMATION_DEFAULT_TIME = '09:00' diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index 36e1ff0560a..b5e28fcb6b1 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -6,7 +6,7 @@ import type { AutomationUpdateInput } from '../../../../shared/automations-types' import { parseExecutionHostId } from '../../../../shared/execution-host' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' type RuntimeAutomationCreateInput = Omit< AutomationCreateInput, diff --git a/src/renderer/src/components/automations/automation-project-groups.test.ts b/src/renderer/src/components/automations/automation-project-groups.test.ts index 4423c381505..b8039169496 100644 --- a/src/renderer/src/components/automations/automation-project-groups.test.ts +++ b/src/renderer/src/components/automations/automation-project-groups.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { getAutomationProjectGroupForRepo, getAutomationProjectGroups, diff --git a/src/renderer/src/components/automations/automation-project-groups.ts b/src/renderer/src/components/automations/automation-project-groups.ts index eacea1f8c41..4dd4c2a6c39 100644 --- a/src/renderer/src/components/automations/automation-project-groups.ts +++ b/src/renderer/src/components/automations/automation-project-groups.ts @@ -1,6 +1,6 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { getProjectIdentityKey } from '../../../../shared/project-host-setup-projection' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' export type AutomationProjectGroup = { projectKey: string diff --git a/src/renderer/src/components/automations/automation-run-context.test.ts b/src/renderer/src/components/automations/automation-run-context.test.ts index 345b03d56f7..b2f1dc2f4ae 100644 --- a/src/renderer/src/components/automations/automation-run-context.test.ts +++ b/src/renderer/src/components/automations/automation-run-context.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { ProjectHostSetup, Repo } from '../../../../shared/types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' import { buildAutomationRunContextForRepo } from './automation-run-context' function repo(id: string, path = `/repos/${id}`): Repo { diff --git a/src/renderer/src/components/automations/automation-run-context.ts b/src/renderer/src/components/automations/automation-run-context.ts index 65bad47c9f3..49894f78159 100644 --- a/src/renderer/src/components/automations/automation-run-context.ts +++ b/src/renderer/src/components/automations/automation-run-context.ts @@ -2,7 +2,8 @@ import { buildWorkspaceRunContext, type WorkspaceRunContext } from '../../../../shared/task-source-context' -import type { ProjectHostSetup, Repo } from '../../../../shared/types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' export function buildAutomationRunContextForRepo(args: { repoId: string diff --git a/src/renderer/src/components/automations/automation-run-open-target.ts b/src/renderer/src/components/automations/automation-run-open-target.ts index 9ee8087be85..d3de0eb7fc7 100644 --- a/src/renderer/src/components/automations/automation-run-open-target.ts +++ b/src/renderer/src/components/automations/automation-run-open-target.ts @@ -1,6 +1,9 @@ import type { AutomationRun } from '../../../../shared/automations-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../../../shared/terminal-tab-types' export type AutomationRunPaneTarget = { tabId: string diff --git a/src/renderer/src/components/automations/automation-run-workspace-display.ts b/src/renderer/src/components/automations/automation-run-workspace-display.ts index 7d7a5e81be3..5ea6d23230e 100644 --- a/src/renderer/src/components/automations/automation-run-workspace-display.ts +++ b/src/renderer/src/components/automations/automation-run-workspace-display.ts @@ -1,5 +1,5 @@ import type { AutomationRun } from '../../../../shared/automations-types' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' export type AutomationRunWorkspaceDisplay = { rowLabel: string diff --git a/src/renderer/src/components/automations/automation-setup-decision.test.ts b/src/renderer/src/components/automations/automation-setup-decision.test.ts index b46d76da980..87d1bf19a14 100644 --- a/src/renderer/src/components/automations/automation-setup-decision.test.ts +++ b/src/renderer/src/components/automations/automation-setup-decision.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { ProjectHostSetup, Repo } from '../../../../shared/types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' import { getAutomationSetupDecisionDraftValue, getVisibleAutomationSetupDecision, diff --git a/src/renderer/src/components/automations/automation-setup-decision.ts b/src/renderer/src/components/automations/automation-setup-decision.ts index f565e439f34..9df3f0298fc 100644 --- a/src/renderer/src/components/automations/automation-setup-decision.ts +++ b/src/renderer/src/components/automations/automation-setup-decision.ts @@ -1,11 +1,8 @@ import type { AutomationWorkspaceMode } from '../../../../shared/automations-types' -import type { - OrcaHooks, - ProjectHostSetup, - Repo, - SetupDecision, - SetupRunPolicy -} from '../../../../shared/types' +import type { OrcaHooks, SetupRunPolicy } from '../../../../shared/orca-yaml-hook-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { SetupDecision } from '../../../../shared/worktree/create-types' import { getSetupConfig } from '@/lib/new-workspace' type AutomationSetupSource = { diff --git a/src/renderer/src/components/automations/automation-target-availability.test.ts b/src/renderer/src/components/automations/automation-target-availability.test.ts index 8c9ab713e88..2b94251144a 100644 --- a/src/renderer/src/components/automations/automation-target-availability.test.ts +++ b/src/renderer/src/components/automations/automation-target-availability.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import type { Automation } from '../../../../shared/automations-types' import type { RuntimeStatus } from '../../../../shared/runtime-types' -import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getAutomationTargetAvailability } from './automation-target-availability' function makeAutomation(overrides: Partial = {}): Automation { diff --git a/src/renderer/src/components/automations/automation-target-availability.ts b/src/renderer/src/components/automations/automation-target-availability.ts index ca7c166b5a8..40a6b3dc8f6 100644 --- a/src/renderer/src/components/automations/automation-target-availability.ts +++ b/src/renderer/src/components/automations/automation-target-availability.ts @@ -12,7 +12,9 @@ import type { AutomationHostTarget } from './automation-host-client' import type { SshConnectionState } from '../../../../shared/ssh-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { RuntimeStatus } from '../../../../shared/runtime-types' -import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { TaskSourceHostAvailability } from '../task-source-context-summary' export type AutomationTargetAvailability = diff --git a/src/renderer/src/components/automations/automation-templates.ts b/src/renderer/src/components/automations/automation-templates.ts index 5f4af77b7e4..b4abe3017df 100644 --- a/src/renderer/src/components/automations/automation-templates.ts +++ b/src/renderer/src/components/automations/automation-templates.ts @@ -1,5 +1,5 @@ import type { AutomationSchedulePreset } from '../../../../shared/automations-types' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { translate } from '@/i18n/i18n' import { createLocalizedCatalog } from '@/i18n/localized-catalog' diff --git a/src/renderer/src/components/automations/use-automation-list-search.ts b/src/renderer/src/components/automations/use-automation-list-search.ts index 235c08cdb1e..95ac89262a0 100644 --- a/src/renderer/src/components/automations/use-automation-list-search.ts +++ b/src/renderer/src/components/automations/use-automation-list-search.ts @@ -1,7 +1,7 @@ import { useDeferredValue, useEffect, useMemo } from 'react' import type { Automation } from '../../../../shared/automations-types' import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { automationListSearchIndexMatches, buildAutomationListSearchIndex, diff --git a/src/renderer/src/components/browser-pane/BrowserAddressBar.test.tsx b/src/renderer/src/components/browser-pane/BrowserAddressBar.test.tsx index 0cf202ff2d8..2f147fc98cc 100644 --- a/src/renderer/src/components/browser-pane/BrowserAddressBar.test.tsx +++ b/src/renderer/src/components/browser-pane/BrowserAddressBar.test.tsx @@ -3,7 +3,7 @@ import { act, type ReactNode, useRef, useState } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { BrowserHistoryEntry } from '../../../../shared/types' +import type { BrowserHistoryEntry } from '../../../../shared/browser-workspace-types' import BrowserAddressBar from './BrowserAddressBar' const mocks = vi.hoisted(() => ({ diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 9158962d88c..064d2e46d30 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -68,7 +68,7 @@ import type { BrowserLoadError, BrowserPage as BrowserPageState, BrowserWorkspace as BrowserWorkspaceState -} from '../../../../shared/types' +} from '../../../../shared/browser-workspace-types' import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl, diff --git a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx index 111890288f4..13aa6d109a3 100644 --- a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx @@ -3,7 +3,8 @@ import { cleanup, render } from '@testing-library/react' import { Suspense } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { BrowserTab as BrowserTabState, Tab, TabGroup } from '../../../../shared/types' +import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' +import type { Tab, TabGroup } from '../../../../shared/tab-types' type MockAppState = { browserTabsByWorktree: Record diff --git a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx index 67d6aa4da9e..f0ea554f9fe 100644 --- a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx @@ -2,7 +2,8 @@ import { memo, useCallback, useLayoutEffect, useMemo, useState } from 'react' import { registerBrowserOverlaySlotViewport } from './browser-page-viewport' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' -import type { BrowserTab as BrowserTabState, Tab, TabGroup } from '../../../../shared/types' +import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' +import type { Tab, TabGroup } from '../../../../shared/tab-types' import BrowserPane, { type BrowserFindShortcutScope } from './BrowserPane' import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor' import { useBrowserAutomationVisibilityForAny } from './browser-automation-visibility' diff --git a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx index f712d33abd2..46362bbf93e 100644 --- a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx +++ b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx @@ -4,7 +4,7 @@ import { emitBrowserCookieImportToast } from '@/lib/browser-cookie-import-toast' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' import { shouldShowBrowserImportHint } from './browser-import-hint-visibility' -import type { BrowserViewportPresetId } from '../../../../shared/types' +import type { BrowserViewportPresetId } from '../../../../shared/browser-workspace-types' import { browserViewportPresetToOverride, getBrowserViewportPreset diff --git a/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.test.ts b/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.test.ts index 5da8a237944..65ba9a3809c 100644 --- a/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.test.ts +++ b/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { BrowserHistoryEntry } from '../../../../shared/types' +import type { BrowserHistoryEntry } from '../../../../shared/browser-workspace-types' import { BROWSER_ADDRESS_BAR_QUERY_MAX_BYTES, buildBrowserAddressBarSuggestions, diff --git a/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.ts b/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.ts index 6ac0fe12c52..cff0a3b54ca 100644 --- a/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.ts +++ b/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.ts @@ -6,7 +6,7 @@ import { SEARCH_ENGINE_LABELS, type SearchEngine } from '../../../../shared/browser-url' -import type { BrowserHistoryEntry } from '../../../../shared/types' +import type { BrowserHistoryEntry } from '../../../../shared/browser-workspace-types' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/browser-pane/browser-guest-worktree-retention.test.ts b/src/renderer/src/components/browser-pane/browser-guest-worktree-retention.test.ts index 5557664f602..9d1f2bc411d 100644 --- a/src/renderer/src/components/browser-pane/browser-guest-worktree-retention.test.ts +++ b/src/renderer/src/components/browser-pane/browser-guest-worktree-retention.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { BrowserPage, BrowserWorkspace } from '../../../../shared/types' +import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-workspace-types' import { BROWSER_GUEST_HIDDEN_WORKTREE_RETENTION_LIMIT, browserTabVisibilityPageIds, diff --git a/src/renderer/src/components/browser-pane/browser-guest-worktree-retention.ts b/src/renderer/src/components/browser-pane/browser-guest-worktree-retention.ts index 322899c279f..22cd358d4fc 100644 --- a/src/renderer/src/components/browser-pane/browser-guest-worktree-retention.ts +++ b/src/renderer/src/components/browser-pane/browser-guest-worktree-retention.ts @@ -1,4 +1,4 @@ -import type { BrowserPage, BrowserWorkspace } from '../../../../shared/types' +import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-workspace-types' // Why 4: every hidden worktree that retains browser guests keeps one Electron // guest process per page alive purely for instant revisits, so guest memory diff --git a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx index af760df4a6b..7c2e41d2904 100644 --- a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx +++ b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx @@ -8,7 +8,7 @@ import type { BrowserCertificateProceedFailureReason, BrowserCertificateProceedResult, BrowserLoadError -} from '../../../../shared/types' +} from '../../../../shared/browser-workspace-types' import { isEligibleLocalCertificateHost } from '../../../../shared/browser-url' import { normalizeCertificateError } from '../../../../shared/browser-certificate-errors' import { diff --git a/src/renderer/src/components/browser-pane/browser-notices.ts b/src/renderer/src/components/browser-pane/browser-notices.ts index 3c644571ec5..701a074186e 100644 --- a/src/renderer/src/components/browser-pane/browser-notices.ts +++ b/src/renderer/src/components/browser-pane/browser-notices.ts @@ -3,7 +3,7 @@ import type { BrowserPermissionDeniedEvent, BrowserPopupEvent } from '../../../../shared/browser-guest-events' -import type { BrowserLoadError } from '../../../../shared/types' +import type { BrowserLoadError } from '../../../../shared/browser-workspace-types' import { isChromiumCertificateErrorCode } from '../../../../shared/browser-certificate-errors' import { translate } from '@/i18n/i18n' import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery' diff --git a/src/renderer/src/components/browser-pane/browser-pane-page-selection.test.ts b/src/renderer/src/components/browser-pane/browser-pane-page-selection.test.ts index 54455d706cc..f97398619f5 100644 --- a/src/renderer/src/components/browser-pane/browser-pane-page-selection.test.ts +++ b/src/renderer/src/components/browser-pane/browser-pane-page-selection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { getBrowserPagesForWorkspace } from './browser-pane-page-selection' -import type { BrowserPage } from '../../../../shared/types' +import type { BrowserPage } from '../../../../shared/browser-workspace-types' function makeBrowserPage(id: string): BrowserPage { return { diff --git a/src/renderer/src/components/browser-pane/browser-pane-page-selection.ts b/src/renderer/src/components/browser-pane/browser-pane-page-selection.ts index 240b0dec6e9..38b8b7d3f95 100644 --- a/src/renderer/src/components/browser-pane/browser-pane-page-selection.ts +++ b/src/renderer/src/components/browser-pane/browser-pane-page-selection.ts @@ -1,4 +1,4 @@ -import type { BrowserPage } from '../../../../shared/types' +import type { BrowserPage } from '../../../../shared/browser-workspace-types' const EMPTY_BROWSER_PAGES: BrowserPage[] = [] diff --git a/src/renderer/src/components/browser-pane/browser-toolbar-menu-dropdown.tsx b/src/renderer/src/components/browser-pane/browser-toolbar-menu-dropdown.tsx index 508eb059ca2..88d63df7dd6 100644 --- a/src/renderer/src/components/browser-pane/browser-toolbar-menu-dropdown.tsx +++ b/src/renderer/src/components/browser-pane/browser-toolbar-menu-dropdown.tsx @@ -16,7 +16,10 @@ import { import { BrowserCookieImportDisclosure } from '@/components/BrowserCookieImportDisclosure' import { useAppStore } from '@/store' import { BROWSER_FAMILY_LABELS } from '../../../../shared/constants' -import type { BrowserSessionProfile, BrowserViewportPresetId } from '../../../../shared/types' +import type { + BrowserSessionProfile, + BrowserViewportPresetId +} from '../../../../shared/browser-workspace-types' type DetectedBrowserEntry = { family: string diff --git a/src/renderer/src/components/cmd-j/PaletteCreateWorktreeRow.tsx b/src/renderer/src/components/cmd-j/PaletteCreateWorktreeRow.tsx index 2079a0c8fbf..e2d126beae4 100644 --- a/src/renderer/src/components/cmd-j/PaletteCreateWorktreeRow.tsx +++ b/src/renderer/src/components/cmd-j/PaletteCreateWorktreeRow.tsx @@ -5,7 +5,7 @@ import { JiraIcon } from '@/components/icons/JiraIcon' import { CommandItem } from '@/components/ui/command' import { CREATE_WORKTREE_ITEM_ID } from '@/lib/worktree-palette-create-action' import type { CmdJTaskUrlCreatePreview } from '@/lib/worktree-palette-task-url-match' -import type { LinearIssue } from '../../../../shared/types' +import type { LinearIssue } from '../../../../shared/linear/issue-types' import { translate } from '@/i18n/i18n' function TaskUrlProviderIcon({ diff --git a/src/renderer/src/components/cmd-j/palette-filter-options.test.ts b/src/renderer/src/components/cmd-j/palette-filter-options.test.ts index bdfaef30ad8..021e92a2359 100644 --- a/src/renderer/src/components/cmd-j/palette-filter-options.test.ts +++ b/src/renderer/src/components/cmd-j/palette-filter-options.test.ts @@ -1,7 +1,9 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' import { getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' -import type { Project, ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { buildSidebarHostOptions } from '../sidebar/sidebar-host-options' import { buildPaletteFilterModel, diff --git a/src/renderer/src/components/cmd-j/palette-filter-options.ts b/src/renderer/src/components/cmd-j/palette-filter-options.ts index 9eb5fdce4ac..2670eb80e28 100644 --- a/src/renderer/src/components/cmd-j/palette-filter-options.ts +++ b/src/renderer/src/components/cmd-j/palette-filter-options.ts @@ -3,7 +3,9 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' -import type { Project, ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getProjectHeaderRevealTarget, type ProjectGroupingModel diff --git a/src/renderer/src/components/cmd-j/palette-filter.ts b/src/renderer/src/components/cmd-j/palette-filter.ts index 075af323799..f521a6ad92a 100644 --- a/src/renderer/src/components/cmd-j/palette-filter.ts +++ b/src/renderer/src/components/cmd-j/palette-filter.ts @@ -1,5 +1,5 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { resolveRepoFilterHostId, resolveWorktreeFilterHostId, diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.ts b/src/renderer/src/components/cmd-j/palette-host-badge.ts index 01cdc72a60f..8ac94adeae4 100644 --- a/src/renderer/src/components/cmd-j/palette-host-badge.ts +++ b/src/renderer/src/components/cmd-j/palette-host-badge.ts @@ -1,4 +1,4 @@ -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID, diff --git a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx index f19bf3a91d2..9e8a51a6e80 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx @@ -7,7 +7,7 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { PaletteLiveStatusProvider, PaletteRecentTabStatusDot, diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx index 03a2af8928d..5c755ad4164 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -26,7 +26,9 @@ import { } from '@/components/tab-bar/terminal-tab-activity-status' import { translate } from '@/i18n/i18n' import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' -import type { BrowserWorkspace, TerminalTab, Worktree } from '../../../../shared/types' +import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' /** Confines the app's hottest status subscriptions here so only the dots re-render on their churn. */ type PaletteLiveStatus = { diff --git a/src/renderer/src/components/cmd-j/palette-project-results.ts b/src/renderer/src/components/cmd-j/palette-project-results.ts index d0fcc15636c..e45e2a8e29f 100644 --- a/src/renderer/src/components/cmd-j/palette-project-results.ts +++ b/src/renderer/src/components/cmd-j/palette-project-results.ts @@ -4,7 +4,9 @@ import { normalizeCmdJPaletteQuery, uniqueNormalizedCmdJPaletteKeywords } from './palette-query-tokens' -import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' import { translate } from '@/i18n/i18n' import { getProjectGroupHeaderKey, diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index bf875ebe9a2..e9f7b3ec3fa 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -13,7 +13,9 @@ import { } from './palette-results' import { hasCmdJProjectSearchCandidates, searchCmdJProjectResults } from './palette-project-results' import type { SettingsNavSection } from '@/lib/settings-navigation-types' -import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' const noopRun: CmdJQuickAction['run'] = async () => ({ status: 'ok' }) const available: CmdJQuickAction['isAvailable'] = () => ({ available: true }) diff --git a/src/renderer/src/components/cmd-j/quick-action-context.test.ts b/src/renderer/src/components/cmd-j/quick-action-context.test.ts index 7ce2e107613..325252c4dc2 100644 --- a/src/renderer/src/components/cmd-j/quick-action-context.test.ts +++ b/src/renderer/src/components/cmd-j/quick-action-context.test.ts @@ -8,7 +8,7 @@ import { } from './quick-action-context' import { getCmdJQuickActions } from './quick-actions' import type { AppState } from '@/store/types' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' type GroupState = Pick diff --git a/src/renderer/src/components/cmd-j/quick-action-context.ts b/src/renderer/src/components/cmd-j/quick-action-context.ts index 79007eae750..88a8cfad533 100644 --- a/src/renderer/src/components/cmd-j/quick-action-context.ts +++ b/src/renderer/src/components/cmd-j/quick-action-context.ts @@ -1,6 +1,6 @@ import type { AppState } from '@/store/types' import { findWorktreeById } from '@/store/slices/worktree-helpers' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import type { SshConnectionStatus } from '../../../../shared/ssh-types' import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts index e401aa0665b..250830fd2d7 100644 --- a/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts +++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { PRInfo, Repo, Worktree } from '../../../../shared/types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts index 992fab427a9..f78f566c247 100644 --- a/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts +++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts @@ -5,7 +5,8 @@ import { getRepoHostIdentityForParts } from '@/store/slices/repo-host-identity' import type { AppState } from '@/store/types' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { selectChecksPanelReview } from '../right-sidebar/checks-panel-review' type WorktreeChecksReviewIndexArgs = { diff --git a/src/renderer/src/components/confirmation-skip-preference.ts b/src/renderer/src/components/confirmation-skip-preference.ts index 8311f2428b2..c7eea096fd4 100644 --- a/src/renderer/src/components/confirmation-skip-preference.ts +++ b/src/renderer/src/components/confirmation-skip-preference.ts @@ -1,7 +1,7 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import type { SettingsNavTarget } from '@/lib/settings-navigation-types' -import type { GlobalSettings } from '../../../shared/types' +import type { GlobalSettings } from '../../../shared/global-settings-types' /** Persists the preference and keeps its reversal one click away. */ export function persistConfirmationSkipPreference({ diff --git a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx index 841c6c4bb44..94ffb8d342d 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx @@ -19,7 +19,7 @@ import { type CrashReportDiagnosticBundle, type CrashReportRecord } from '../../../../shared/crash-reporting' -import type { GitHubViewer } from '../../../../shared/types' +import type { GitHubViewer } from '../../../../shared/github/pull-request-types' import { translate } from '@/i18n/i18n' import { CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID, diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.tsx index db5741e5e07..7b7afc56bc7 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.tsx @@ -8,7 +8,7 @@ import type { DashboardWorkspace } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { AgentMapCanvas, type AgentMapCanvasHandle } from './AgentMapCanvas' import { ALL_AGENT_MAP_HOSTS, filterAgentMapCards, type AgentMapState } from './agent-map-filter' import { updateAgentMapLayout, type AgentMapLayoutCache } from './agent-map-layout' diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx index 62439665fc7..84290f36f40 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx @@ -15,7 +15,7 @@ import type { DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentMapAgentNode, AgentMapProjectRing, AgentMapLayout } from './agent-map-layout' import type { AgentMapFlareStatus } from './agent-map-node-metadata' import { AgentMapScene } from './AgentMapScene' diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx index c4a298ed48e..a4914bbdad5 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -3,7 +3,7 @@ import { RepoIconGlyph } from '@/components/repo/repo-icon' import { translate } from '@/i18n/i18n' import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentMapAgentNode, AgentMapLayout, diff --git a/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx b/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx index e2208e0be0d..17b992aaaff 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx @@ -17,7 +17,7 @@ import type { DashboardSleepWorkspaceArgs, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' export type AgentMapSnapshotWorkspaceMenuRequest = { id: number 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/AgentMapWorkspaceContextMenu.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx index 057a593da7e..c5477a997e9 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx @@ -5,7 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { TooltipProvider } from '@/components/ui/tooltip' import { useAppStore } from '@/store' -import type { ProjectGroup, Repo, Worktree } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { DashboardCard } from '../../../../shared/dashboard-snapshot' import { AgentMap } from './AgentMap' import * as StoreSelectors from '@/store/selectors' diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index fd362f45609..f4999a012bd 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -7,7 +7,7 @@ import { translate } from '@/i18n/i18n' import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' import { agentTypeToIconAgent } from '@/lib/agent-status' import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentMapAgentNode, AgentMapProjectRing, diff --git a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx index a7c0b08f64e..e428d43f5f6 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx +++ b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx @@ -6,7 +6,7 @@ import type { DashboardSleepWorkspaceArgs, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { AgentMap } from './AgentMap' import type { AgentMapState } from './agent-map-filter' diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-compatibility.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-compatibility.ts index 0693bdff81a..96db5963714 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-compatibility.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-compatibility.ts @@ -1,6 +1,6 @@ import type { Terminal } from '@xterm/xterm' import { Unicode11Addon } from '@xterm/addon-unicode11' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider' import { installWindowsCtrlAltChordRepair } from '@/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification' import { attachTerminalMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-mouse-wheel' diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-ligatures.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-ligatures.ts index cc0cb2036fd..354edfeead0 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-ligatures.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-ligatures.ts @@ -1,5 +1,5 @@ import type { Terminal } from '@xterm/xterm' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures' import { TerminalLigaturesAddon } from '@/lib/pane-manager/terminal-ligatures-addon' diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts index af4974cdef3..319ec241293 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { describe, expect, it } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { buildPreviewAppearanceOptions, buildPreviewTerminalOptions diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts index aaf13a8c4bc..284f6510558 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts @@ -1,5 +1,5 @@ import type { ITerminalInitOnlyOptions, ITerminalOptions, ITheme } from '@xterm/xterm' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx b/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx index a20c534e4d3..198885bdf67 100644 --- a/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx +++ b/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx @@ -3,7 +3,7 @@ import type { DashboardSleepWorkspaceArgs, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentMapProjectRing, AgentMapWorktreeRing } from './agent-map-layout' import { AgentMapSnapshotWorkspaceMenu, 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/dashboard/AgentDashboardSettingsMenu.tsx b/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx index 1cc3b6ed03b..eb462a2d3a9 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { SettingsSegmentedControl, SettingsSwitch } from '../settings/SettingsFormControls' -import type { AgentDashboardMode } from '../../../../shared/types' +import type { AgentDashboardMode } from '../../../../shared/ui-chrome-types' import { translate } from '@/i18n/i18n' type AgentDashboardSettingsMenuProps = { diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 0fb978de62b..5be1c78d574 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -2,7 +2,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ComponentProps } from 'react' import { describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { TooltipProvider } from '../ui/tooltip' import DashboardAgentRow from './DashboardAgentRow' import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' diff --git a/src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts b/src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts index 028670a91a3..dcc5d754de2 100644 --- a/src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts +++ b/src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { DashboardAgentRow } from './useDashboardData' import { buildAgentRowLineageTree } from './agent-row-lineage-model' diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts index 822a3b80e96..51a3aada421 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { makePaneKey } from '../../../../shared/stable-pane-id' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import type { FolderWorkspace, ProjectGroup, TerminalTab } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts index d2b85f6ced0..424c936ab0f 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalTab, Worktree } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' const batchMocks = vi.hoisted(() => ({ release: vi.fn(), diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts index d4e9bdc565a..884a8ebcaf9 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -7,7 +7,8 @@ import { } from '../../../../shared/agent-status-types' import { DASHBOARD_MAX_LABEL_LENGTH } from '../../../../shared/dashboard-snapshot' import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalTab, Worktree } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import { selectRuntimeAgentOrchestrationBatch } from '../sidebar/worktree-agent-orchestration-batch' import type * as DashboardSnapshotWorkspacesModule from './dashboard-snapshot-workspaces' import type * as AgentRowLineageModule from './agent-row-lineage' diff --git a/src/renderer/src/components/dashboard/dashboard-card-context.test.ts b/src/renderer/src/components/dashboard/dashboard-card-context.test.ts index ab61f16d857..f4152c20701 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-context.test.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-context.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { PRInfo, Repo, Worktree } from '../../../../shared/types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { diff --git a/src/renderer/src/components/dashboard/dashboard-card-context.ts b/src/renderer/src/components/dashboard/dashboard-card-context.ts index f376a52163c..a0ed03d4d85 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-context.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-context.ts @@ -4,7 +4,8 @@ import type { AppState } from '@/store/types' import type { DashboardCardReview } from '../../../../shared/dashboard-snapshot' import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' import { isPositiveHostedReviewNumber } from '../../../../shared/hosted-review' -import type { Repo, Worktree, WorkspaceStatusDefinition } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types' import { DEFAULT_WORKSPACE_STATUSES, getWorkspaceStatus diff --git a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts index b1b0f16c3ee..74ca8250315 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts @@ -1,6 +1,6 @@ import type { AppState } from '@/store/types' import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { getConnectionIdFromState } from '@/lib/connection-context' diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts index a0aacdcc504..c160b4dd37c 100644 --- a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { DASHBOARD_MAX_LAUNCH_WORKTREES } from '../../../../shared/dashboard-snapshot' import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' -import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { buildDashboardWorktreeLaunchOptions } from './dashboard-worktree-launch-options' diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts index 16ca6c1f795..44c76a74fee 100644 --- a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts @@ -10,7 +10,7 @@ import { filterEnabledTuiAgents, TUI_AGENT_AUTO_PICK_ORDER } from '../../../../shared/tui-agent-selection' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' export type DashboardLaunchDetectionState = Pick< diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index 06926f919a0..2433ea0e88f 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -3,7 +3,7 @@ import type { AgentStatusState, AgentType } from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' export type DashboardAgentRow = { /** Row identity. For 'subagent' rows this is a synthetic key (the child has diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts index d2481a9201e..f3b7da6254e 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts @@ -4,7 +4,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { useAppStore } from '@/store' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { GlobalSettings, Repo, TerminalTab, Worktree } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot' const NOW = 1_000_000_000 diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts index 095bdedcb67..2e3b60f1157 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts @@ -5,7 +5,9 @@ import { useAppStore } from '@/store' import { resetAgentPaneAuthorityAliasesForTests } from '@/store/slices/agent-pane-authority' import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import { collectRetainedAgentsOnDisappear, useRetainedAgentsSync } from './useRetainedAgents' const initialAppState = useAppStore.getInitialState() diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index 6fa1f94fda7..883da974142 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -4,7 +4,10 @@ import { useAppStore } from '@/store' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import type { DashboardAgentRow } from './useDashboardData' import type { RetainedAgentEntry } from '@/store/slices/agent-status' -import type { FolderWorkspace, Repo, TerminalTab, Worktree } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' import { diff --git a/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts b/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts index 3d33575d649..88bb584a18e 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts @@ -4,7 +4,10 @@ import { type AgentStatusEntry, type AgentStatusState } from '../../../../shared/agent-status-types' -import type { FolderWorkspace, Repo, TerminalTab, Worktree } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { makePaneKey } from '../../../../shared/stable-pane-id' import { buildRetainedAgentsSyncSnapshot } from './useRetainedAgents' diff --git a/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx b/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx index ef83b076201..d71e608e525 100644 --- a/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx +++ b/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx @@ -4,7 +4,7 @@ import { createRef, type MutableRefObject, act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DictationState } from '../../../../shared/speech-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { DictationInsertionTarget } from './dictation-insertion-target' import { useHoldDictationGesture } from './use-hold-dictation-gesture' diff --git a/src/renderer/src/components/dictation/use-hold-dictation-gesture.ts b/src/renderer/src/components/dictation/use-hold-dictation-gesture.ts index f1316b9a4ac..554af340c9c 100644 --- a/src/renderer/src/components/dictation/use-hold-dictation-gesture.ts +++ b/src/renderer/src/components/dictation/use-hold-dictation-gesture.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, type MutableRefObject } from 'react' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../shared/keybindings' import type { DictationState } from '../../../../shared/speech-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { DictationInsertionTarget } from './dictation-insertion-target' type HoldDictationGestureOptions = { diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx index 2c3214070d9..833099b3ad4 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import * as monaco from 'monaco-editor' import type { editor as monacoEditor, IDisposable } from 'monaco-editor' import { createRoot, type Root } from 'react-dom/client' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import { getCommentBodyLayoutLineCount } from '@/lib/comment-body-line-count' import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat' import { formatDiffComments } from '@/lib/diff-comments-format' diff --git a/src/renderer/src/components/editor/ChangesModeView.tsx b/src/renderer/src/components/editor/ChangesModeView.tsx index 9fac8364e80..2004f576d5c 100644 --- a/src/renderer/src/components/editor/ChangesModeView.tsx +++ b/src/renderer/src/components/editor/ChangesModeView.tsx @@ -1,7 +1,8 @@ import React from 'react' import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import type { OpenFile } from '@/store/slices/editor' -import type { GitDiffResult, GitStatusEntry } from '../../../../shared/types' +import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { ConflictBanner } from './ConflictComponents' import { getDiffContentSignature } from './diff-content-signature' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/editor/CheckRunAnnotations.tsx b/src/renderer/src/components/editor/CheckRunAnnotations.tsx index f7b53071508..5d7dfe0d878 100644 --- a/src/renderer/src/components/editor/CheckRunAnnotations.tsx +++ b/src/renderer/src/components/editor/CheckRunAnnotations.tsx @@ -1,7 +1,7 @@ import React from 'react' import { ExternalLink } from 'lucide-react' import { translate } from '@/i18n/i18n' -import type { PRCheckAnnotation } from '../../../../shared/types' +import type { PRCheckAnnotation } from '../../../../shared/github/check-types' import { cancelAnnotationRevealFrame, getOpenableAnnotationLine, diff --git a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx index 6003be19e05..edf79624cc5 100644 --- a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx +++ b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx @@ -3,7 +3,7 @@ import { ExternalLink, LoaderCircle, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' import { SourceControlFixSplitButton } from '@/components/right-sidebar/source-control-fix-split-button' import { translate } from '@/i18n/i18n' import { useCheckRunDetailsFixWithAI } from './check-run-details-fix-with-ai' diff --git a/src/renderer/src/components/editor/CheckRunJobs.tsx b/src/renderer/src/components/editor/CheckRunJobs.tsx index 6b3bfa0816e..3fedd9f7569 100644 --- a/src/renderer/src/components/editor/CheckRunJobs.tsx +++ b/src/renderer/src/components/editor/CheckRunJobs.tsx @@ -3,7 +3,7 @@ import { CheckCircle2, ChevronDown, CircleDashed, MinusCircle, XCircle } from 'l import { CheckJobLogTail } from '@/components/right-sidebar/check-job-log-tail' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' -import type { PRCheckJob, PRCheckStep } from '../../../../shared/types' +import type { PRCheckJob, PRCheckStep } from '../../../../shared/github/check-types' import { resolveStepOutcome, summarizeJobSteps, type StepOutcome } from './check-job-step-status' function StepOutcomeIcon({ outcome }: { outcome: StepOutcome }): React.JSX.Element { diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts b/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts index 97721b1b6b7..7a21281213d 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts @@ -11,7 +11,8 @@ import { getFilteredCombinedDiffFileTreeEntries, isCombinedDiffFileTreeQueryTooLarge } from './combined-diff-file-tree-model' -import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' describe('CombinedDiffFileTree navigation mapping', () => { it('disambiguates uncommitted entries with the same path by area', () => { diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx index fc59c2dd75c..99a53af8795 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx @@ -10,7 +10,8 @@ import { compactSourceControlTree, flattenSourceControlTree } from '@/components/right-sidebar/source-control-tree' -import type { GitBranchChangeEntry, GitStagingArea, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' +import type { GitStagingArea, GitStatusEntry } from '../../../../shared/git-status-types' import { getEntryExtension, getFilteredCombinedDiffFileTreeEntries, diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index fd87b515349..b004ef3a799 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -42,12 +42,9 @@ import { import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { OpenFile } from '@/store/slices/editor' -import type { - DiffComment, - GitBranchChangeEntry, - GitDiffResult, - GitStatusEntry -} from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' +import type { GitBranchChangeEntry, GitDiffResult } from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { Check, Copy, MessageSquare, PanelLeftOpen, Sparkles, Trash2, WrapText } from 'lucide-react' import { toast } from 'sonner' import { DiffSectionItem } from './DiffSectionItem' diff --git a/src/renderer/src/components/editor/ConflictComponents.tsx b/src/renderer/src/components/editor/ConflictComponents.tsx index 32159e23f00..b89db1280ea 100644 --- a/src/renderer/src/components/editor/ConflictComponents.tsx +++ b/src/renderer/src/components/editor/ConflictComponents.tsx @@ -13,7 +13,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import type { ConflictReviewEntry, OpenFile } from '@/store/slices/editor' -import type { GitConflictKind, GitStatusEntry } from '../../../../shared/types' +import type { GitConflictKind, GitStatusEntry } from '../../../../shared/git-status-types' import { ConflictReviewFileTree } from './ConflictReviewFileTree' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/editor/ConflictReviewFileTree.tsx b/src/renderer/src/components/editor/ConflictReviewFileTree.tsx index f3e5e02c425..6a3ce686694 100644 --- a/src/renderer/src/components/editor/ConflictReviewFileTree.tsx +++ b/src/renderer/src/components/editor/ConflictReviewFileTree.tsx @@ -10,7 +10,7 @@ import { type SourceControlTreeNode } from '@/components/right-sidebar/source-control-tree' import type { ConflictReviewEntry } from '@/store/slices/editor' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { translate } from '@/i18n/i18n' type ConflictReviewTreeEntry = ConflictReviewEntry & { diff --git a/src/renderer/src/components/editor/DiffNotesSendMenu.tsx b/src/renderer/src/components/editor/DiffNotesSendMenu.tsx index 8fc9daefbe8..8e49dceaf9f 100644 --- a/src/renderer/src/components/editor/DiffNotesSendMenu.tsx +++ b/src/renderer/src/components/editor/DiffNotesSendMenu.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo } from 'react' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import { useAppStore } from '@/store' import { formatDiffComments } from '@/lib/diff-comments-format' import { NotesSendMenu, type NotesSendMenuScope } from './NotesSendMenu' diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index c6931fe5240..22905daf464 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -13,7 +13,7 @@ import { } from '../diff-comments/diff-comment-popover-position' import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options' import { DiffSectionHeader } from './DiffSectionHeader' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import { isDiffComment } from '@/lib/diff-comment-compat' import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts' import { DiffSectionBody } from './DiffSectionBody' diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index e2f364a2c81..36b515b8f53 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -14,7 +14,7 @@ import { getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position' import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import { isDiffComment } from '@/lib/diff-comment-compat' import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts' import { diffEditorScrollbarOptions } from './diff-editor-scrollbar-options' diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index a9da4e54065..01be65d3465 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -14,7 +14,8 @@ import { getNextConflictNavigationIndex } from './ConflictComponents' import type { MarkdownViewMode, OpenFile, PendingEditorReveal } from '@/store/slices/editor' -import type { GitStatusEntry, GitDiffResult } from '../../../../shared/types' +import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { getMarkdownRenderMode } from './markdown-render-mode' import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode' import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit' diff --git a/src/renderer/src/components/editor/MarkdownPreview.test.ts b/src/renderer/src/components/editor/MarkdownPreview.test.ts index b6fd98c6467..12f29758a83 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.test.ts +++ b/src/renderer/src/components/editor/MarkdownPreview.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { decodeMarkdownPreviewAnchor, deriveMarkdownPreviewSourceRoot, diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index fe73dc07858..519072c63c8 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -41,7 +41,9 @@ import { getConnectionIdForFile } from '@/lib/connection-context' import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution' import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache' import { detectLanguage } from '@/lib/language-detect' -import type { DiffComment, MarkdownDocument, Worktree } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' +import type { Worktree } from '../../../../shared/worktree/types' import { fileUrlToAbsolutePath, getMarkdownPreviewLinkTarget, diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 15718697a54..ba1a967746b 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -4,7 +4,8 @@ import React, { useRef, useCallback, useEffect, useLayoutEffect, useMemo, useSta import Editor, { type OnMount } from '@monaco-editor/react' import type { editor } from 'monaco-editor' import { toast } from 'sonner' -import type { MarkdownDocument, DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import { useAppStore } from '@/store' import { scrollTopCache, cursorPositionCache, setWithLRU } from '@/lib/scroll-cache' import '@/lib/monaco-setup' diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx index 92e7e21da80..1ae74985923 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -1,7 +1,7 @@ import React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { makePaneKey } from '../../../../shared/stable-pane-id' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { ReviewNotesSendMenuContent } from './ReviewNotesSendMenuContent' diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 56eb1e9d728..6678cda3569 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { useEditorState, type Editor } from '@tiptap/react' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import { useAppStore } from '@/store' import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' import { useLocalImagePick } from './useLocalImagePick' diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index d3a7b423daa..12bc5cc35c3 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -19,7 +19,7 @@ import type { NotesSendMenuScope } from './NotesSendMenu' import type { MarkdownReviewNote } from '@/lib/markdown-review-notes' import type { RichMarkdownAnnotationTarget } from './rich-markdown-review-annotations' import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' function shouldFocusEmptyEditorFromSurfaceClick( event: React.MouseEvent, diff --git a/src/renderer/src/components/editor/RichMarkdownReviewNoteLayer.tsx b/src/renderer/src/components/editor/RichMarkdownReviewNoteLayer.tsx index 881cef3ff1d..0aa90569f98 100644 --- a/src/renderer/src/components/editor/RichMarkdownReviewNoteLayer.tsx +++ b/src/renderer/src/components/editor/RichMarkdownReviewNoteLayer.tsx @@ -1,5 +1,5 @@ import { Check, Copy } from 'lucide-react' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import { DiffCommentCard } from '../diff-comments/DiffCommentCard' import { NotesSendMenu } from './NotesSendMenu' import { diff --git a/src/renderer/src/components/editor/check-annotation-path.test.ts b/src/renderer/src/components/editor/check-annotation-path.test.ts index 95f23378473..b28fa77214a 100644 --- a/src/renderer/src/components/editor/check-annotation-path.test.ts +++ b/src/renderer/src/components/editor/check-annotation-path.test.ts @@ -3,7 +3,7 @@ import { getOpenableAnnotationLine, resolveAnnotationPathInsideWorktree } from './check-annotation-path' -import type { PRCheckAnnotation } from '../../../../shared/types' +import type { PRCheckAnnotation } from '../../../../shared/github/check-types' function annotation(path: string | null, startLine = 1): PRCheckAnnotation { return { diff --git a/src/renderer/src/components/editor/check-annotation-path.ts b/src/renderer/src/components/editor/check-annotation-path.ts index b18baea1475..4087d22669c 100644 --- a/src/renderer/src/components/editor/check-annotation-path.ts +++ b/src/renderer/src/components/editor/check-annotation-path.ts @@ -1,4 +1,4 @@ -import type { PRCheckAnnotation } from '../../../../shared/types' +import type { PRCheckAnnotation } from '../../../../shared/github/check-types' import { relativePathInsideRoot, resolveRuntimePath } from '../../../../shared/cross-platform-path' const WORKFLOW_PSEUDO_ANNOTATION_PATH = '.github' diff --git a/src/renderer/src/components/editor/check-job-step-status.ts b/src/renderer/src/components/editor/check-job-step-status.ts index a6d4d07007d..c81797818ae 100644 --- a/src/renderer/src/components/editor/check-job-step-status.ts +++ b/src/renderer/src/components/editor/check-job-step-status.ts @@ -1,4 +1,4 @@ -import type { PRCheckJob, PRCheckStep } from '../../../../shared/types' +import type { PRCheckJob, PRCheckStep } from '../../../../shared/github/check-types' export type StepOutcome = 'success' | 'failure' | 'skipped' | 'pending' diff --git a/src/renderer/src/components/editor/check-run-details-fix-context.ts b/src/renderer/src/components/editor/check-run-details-fix-context.ts index f0e99b12e2f..d794265ec60 100644 --- a/src/renderer/src/components/editor/check-run-details-fix-context.ts +++ b/src/renderer/src/components/editor/check-run-details-fix-context.ts @@ -10,7 +10,8 @@ import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { findWorktreeById } from '@/store/slices/worktree-helpers' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { PRCheckDetail, PRCheckRunDetails, Repo } from '../../../../shared/types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' +import type { Repo } from '../../../../shared/repo-types' import { translate } from '@/i18n/i18n' export function resolveCheckRunDetailsFixCheck( diff --git a/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts b/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts index 10531e2224a..ae46dc37187 100644 --- a/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts +++ b/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts @@ -1,11 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { - PRCheckDetail, - PRCheckRunDetails, - PRInfo, - Repo, - Worktree -} from '../../../../shared/types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getCheckRunDetailsFixDisabledReason, isCheckRunDetailsFixCandidate, diff --git a/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts b/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts index 80059758ca7..48cae27613a 100644 --- a/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts +++ b/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts @@ -24,7 +24,7 @@ import type { SourceControlActionRecipe, SourceControlLaunchActionId } from '../../../../shared/source-control-ai-actions' -import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' import { translate } from '@/i18n/i18n' export { diff --git a/src/renderer/src/components/editor/check-run-details-tab.ts b/src/renderer/src/components/editor/check-run-details-tab.ts index 02e833b61b6..b274b1564c0 100644 --- a/src/renderer/src/components/editor/check-run-details-tab.ts +++ b/src/renderer/src/components/editor/check-run-details-tab.ts @@ -1,9 +1,6 @@ import type { GitLabProjectRef } from '../../../../shared/gitlab-types' -import type { - GitHubRepositoryIdentity, - PRCheckDetail, - PRCheckRunDetails -} from '../../../../shared/types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' +import type { GitHubRepositoryIdentity } from '../../../../shared/github/pull-request-types' export type OpenCheckRunDetailsState = { contextKey: string diff --git a/src/renderer/src/components/editor/combined-diff-entries.test.ts b/src/renderer/src/components/editor/combined-diff-entries.test.ts index c3ff00ecab0..312d6baa8c2 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.test.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.test.ts @@ -5,7 +5,8 @@ import { resolveCombinedUncommittedSnapshotEntries, shouldAutoReloadCombinedDiffFromGitStatus } from './combined-diff-entries' -import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' describe('getCombinedUncommittedEntries', () => { it('filters unresolved conflicts from live entries', () => { diff --git a/src/renderer/src/components/editor/combined-diff-entries.ts b/src/renderer/src/components/editor/combined-diff-entries.ts index 73d2fe44a70..ab64df67d47 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.ts @@ -1,5 +1,6 @@ import type { OpenFile } from '@/store/slices/editor' -import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import type { CombinedDiffFileTreeMode } from './combined-diff-file-tree-model' /** diff --git a/src/renderer/src/components/editor/combined-diff-file-tree-model.ts b/src/renderer/src/components/editor/combined-diff-file-tree-model.ts index 93d38853647..fc3c440fc95 100644 --- a/src/renderer/src/components/editor/combined-diff-file-tree-model.ts +++ b/src/renderer/src/components/editor/combined-diff-file-tree-model.ts @@ -1,5 +1,6 @@ import { basename } from '@/lib/path' -import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' import { buildSourceControlTree, diff --git a/src/renderer/src/components/editor/combined-diff-file-tree-row.tsx b/src/renderer/src/components/editor/combined-diff-file-tree-row.tsx index 7f47909cb10..eb6dc11fbf0 100644 --- a/src/renderer/src/components/editor/combined-diff-file-tree-row.tsx +++ b/src/renderer/src/components/editor/combined-diff-file-tree-row.tsx @@ -6,12 +6,12 @@ import { getFileTypeIcon } from '@/lib/file-type-icons' import { basename, dirname, joinPath } from '@/lib/path' import { cn } from '@/lib/utils' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' +import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' import type { - GitBranchChangeEntry, GitFileStatus, GitStagingArea, GitStatusEntry -} from '../../../../shared/types' +} from '../../../../shared/git-status-types' import { getCombinedDiffFileTreeSectionKey, type CombinedDiffBranchTreeArea, diff --git a/src/renderer/src/components/editor/combined-diff-section-cache-match.test.ts b/src/renderer/src/components/editor/combined-diff-section-cache-match.test.ts index 970a2155d00..1eea28d9d89 100644 --- a/src/renderer/src/components/editor/combined-diff-section-cache-match.test.ts +++ b/src/renderer/src/components/editor/combined-diff-section-cache-match.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import type { DiffSection } from './diff-section-types' import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match' diff --git a/src/renderer/src/components/editor/combined-diff-section-cache-match.ts b/src/renderer/src/components/editor/combined-diff-section-cache-match.ts index a53e3a1aa43..7f25e96f7c3 100644 --- a/src/renderer/src/components/editor/combined-diff-section-cache-match.ts +++ b/src/renderer/src/components/editor/combined-diff-section-cache-match.ts @@ -1,4 +1,5 @@ -import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import type { CombinedDiffFileTreeMode } from './combined-diff-file-tree-model' import { getCombinedDiffFileTreeSectionKey } from './combined-diff-file-tree-model' import type { DiffSection } from './diff-section-types' diff --git a/src/renderer/src/components/editor/combined-diff-section-connection.test.ts b/src/renderer/src/components/editor/combined-diff-section-connection.test.ts index 894f6df7830..89254a6f6b1 100644 --- a/src/renderer/src/components/editor/combined-diff-section-connection.test.ts +++ b/src/renderer/src/components/editor/combined-diff-section-connection.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' -import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' import { useAppStore } from '@/store' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection' diff --git a/src/renderer/src/components/editor/combined-diff-section-load-state.test.ts b/src/renderer/src/components/editor/combined-diff-section-load-state.test.ts index 996e66c2f5a..0b647561738 100644 --- a/src/renderer/src/components/editor/combined-diff-section-load-state.test.ts +++ b/src/renderer/src/components/editor/combined-diff-section-load-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GitDiffResult } from '../../../../shared/types' +import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' import type { LargeDiffRenderLimit } from './large-diff-render-limit' import { isUnchangedDiffSectionReload, diff --git a/src/renderer/src/components/editor/diff-section-layout.test.ts b/src/renderer/src/components/editor/diff-section-layout.test.ts index 809ca80cab3..f5b4d4145f6 100644 --- a/src/renderer/src/components/editor/diff-section-layout.test.ts +++ b/src/renderer/src/components/editor/diff-section-layout.test.ts @@ -5,7 +5,7 @@ import { getDiffSectionEstimatedHeight, isIntrinsicHeightImageDiff } from './diff-section-layout' -import type { GitDiffResult } from '../../../../shared/types' +import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' describe('diff section layout', () => { it('uses Monaco measured content height for text diffs', () => { diff --git a/src/renderer/src/components/editor/diff-section-layout.ts b/src/renderer/src/components/editor/diff-section-layout.ts index 17e642641ee..6fb4c6c46b0 100644 --- a/src/renderer/src/components/editor/diff-section-layout.ts +++ b/src/renderer/src/components/editor/diff-section-layout.ts @@ -1,4 +1,4 @@ -import type { GitDiffResult } from '../../../../shared/types' +import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' import { countLinesLikeSplit, type DiffLineCounts } from './large-diff-render-limit' const DIFF_LINE_HEIGHT = 19 diff --git a/src/renderer/src/components/editor/diff-section-types.ts b/src/renderer/src/components/editor/diff-section-types.ts index 0c5d659f4be..fe2d23bf273 100644 --- a/src/renderer/src/components/editor/diff-section-types.ts +++ b/src/renderer/src/components/editor/diff-section-types.ts @@ -1,4 +1,5 @@ -import type { GitDiffResult, GitStatusEntry } from '../../../../shared/types' +import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import type { LargeDiffRenderLimit } from './large-diff-render-limit' export type DiffSection = { diff --git a/src/renderer/src/components/editor/editor-cmd-save-target.test.ts b/src/renderer/src/components/editor/editor-cmd-save-target.test.ts index 518f8c8b859..f53e6c5f1f8 100644 --- a/src/renderer/src/components/editor/editor-cmd-save-target.test.ts +++ b/src/renderer/src/components/editor/editor-cmd-save-target.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { Tab } from '../../../../shared/types' +import type { Tab } from '../../../../shared/tab-types' import { getEditorCmdSaveFileId } from './editor-cmd-save-target' function makeTab(contentType: Tab['contentType'], entityId: string): Tab { diff --git a/src/renderer/src/components/editor/editor-cmd-save-target.ts b/src/renderer/src/components/editor/editor-cmd-save-target.ts index 0d021a87790..75159e0dbff 100644 --- a/src/renderer/src/components/editor/editor-cmd-save-target.ts +++ b/src/renderer/src/components/editor/editor-cmd-save-target.ts @@ -1,5 +1,6 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { Tab, TabContentType, TopLevelView } from '../../../../shared/types' +import type { Tab, TabContentType } from '../../../../shared/tab-types' +import type { TopLevelView } from '../../../../shared/ui-chrome-types' export const EDITOR_TAB_CONTENT_TYPES = new Set([ 'editor', diff --git a/src/renderer/src/components/editor/editor-external-watch-path-index.ts b/src/renderer/src/components/editor/editor-external-watch-path-index.ts index f1cb7054c67..3ce5f2a432b 100644 --- a/src/renderer/src/components/editor/editor-external-watch-path-index.ts +++ b/src/renderer/src/components/editor/editor-external-watch-path-index.ts @@ -1,7 +1,7 @@ import { joinPath } from '@/lib/path' import { getExternalFileChangeRelativePath } from '@/components/right-sidebar/useFileExplorerWatch' import type { OpenFile } from '@/store/slices/editor' -import type { FsChangedPayload } from '../../../../shared/types' +import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types' import { getLocalWindowsWslPathIdentity, normalizeRuntimePathForComparison, diff --git a/src/renderer/src/components/editor/editor-header.ts b/src/renderer/src/components/editor/editor-header.ts index a3fbafbd6da..c511d401d3b 100644 --- a/src/renderer/src/components/editor/editor-header.ts +++ b/src/renderer/src/components/editor/editor-header.ts @@ -1,5 +1,6 @@ import type { OpenFile } from '@/store/slices/editor' -import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { getEditorDisplayLabel } from './editor-labels' export type EditorHeaderCopyState = { diff --git a/src/renderer/src/components/editor/editor-panel-content-types.ts b/src/renderer/src/components/editor/editor-panel-content-types.ts index 1c137bfb632..dcbccfbc640 100644 --- a/src/renderer/src/components/editor/editor-panel-content-types.ts +++ b/src/renderer/src/components/editor/editor-panel-content-types.ts @@ -1,4 +1,4 @@ -import type { GitDiffResult } from '../../../../shared/types' +import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' /** * Thrown when a worktree's host owner is not yet known (the backing repo has diff --git a/src/renderer/src/components/editor/editor-panel-diff-reload.test.ts b/src/renderer/src/components/editor/editor-panel-diff-reload.test.ts index 2463ce680d8..35a3d32e20c 100644 --- a/src/renderer/src/components/editor/editor-panel-diff-reload.test.ts +++ b/src/renderer/src/components/editor/editor-panel-diff-reload.test.ts @@ -4,7 +4,7 @@ import { isReloadableSingleFileDiffTab, shouldReloadDiffOnGitStatusChange } from './editor-panel-diff-reload' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' function makeDiffFile(overrides: Partial = {}): OpenFile { return { diff --git a/src/renderer/src/components/editor/editor-panel-diff-reload.ts b/src/renderer/src/components/editor/editor-panel-diff-reload.ts index 4b58a471239..8bcf59ab38b 100644 --- a/src/renderer/src/components/editor/editor-panel-diff-reload.ts +++ b/src/renderer/src/components/editor/editor-panel-diff-reload.ts @@ -1,5 +1,5 @@ import type { OpenFile } from '@/store/slices/editor' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' export function isReloadableSingleFileDiffTab(file: OpenFile): boolean { return ( diff --git a/src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts b/src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts index 524776853d5..0dedf058495 100644 --- a/src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts +++ b/src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { selectEditorPanelGitBranchEntries, selectEditorPanelGitStatusEntries diff --git a/src/renderer/src/components/editor/editor-panel-render-model.test.ts b/src/renderer/src/components/editor/editor-panel-render-model.test.ts index fc7e66a78ea..1475ce35bf3 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.test.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { OpenFile } from '@/store/slices/editor' import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import type { FileContent } from './editor-panel-content-types' import { getEditorPanelRenderModel } from './editor-panel-render-model' diff --git a/src/renderer/src/components/editor/large-diff-section-content.ts b/src/renderer/src/components/editor/large-diff-section-content.ts index df60ada45ac..f83ef383bcc 100644 --- a/src/renderer/src/components/editor/large-diff-section-content.ts +++ b/src/renderer/src/components/editor/large-diff-section-content.ts @@ -1,4 +1,4 @@ -import type { GitDiffResult } from '../../../../shared/types' +import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' import type { LargeDiffRenderLimit } from './large-diff-render-limit' export function shouldPruneLargeDiffContent( diff --git a/src/renderer/src/components/editor/markdown-doc-completions.test.ts b/src/renderer/src/components/editor/markdown-doc-completions.test.ts index 9a6a8278af5..a08261e2ff6 100644 --- a/src/renderer/src/components/editor/markdown-doc-completions.test.ts +++ b/src/renderer/src/components/editor/markdown-doc-completions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import { getMarkdownDocCompletionContext, getMarkdownDocCompletionDocuments, diff --git a/src/renderer/src/components/editor/markdown-doc-completions.ts b/src/renderer/src/components/editor/markdown-doc-completions.ts index c41965ad96a..b7b81a72eee 100644 --- a/src/renderer/src/components/editor/markdown-doc-completions.ts +++ b/src/renderer/src/components/editor/markdown-doc-completions.ts @@ -1,4 +1,4 @@ -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' export type MarkdownDocCompletionContext = { diff --git a/src/renderer/src/components/editor/markdown-doc-links.test.ts b/src/renderer/src/components/editor/markdown-doc-links.test.ts index d5cfaade6e7..9a7f9859b83 100644 --- a/src/renderer/src/components/editor/markdown-doc-links.test.ts +++ b/src/renderer/src/components/editor/markdown-doc-links.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import Markdown from 'react-markdown' -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import { createMarkdownDocumentIndex, createMarkdownDocLinkHref, diff --git a/src/renderer/src/components/editor/markdown-doc-links.ts b/src/renderer/src/components/editor/markdown-doc-links.ts index 82cd7133b38..3f5e4f7f6af 100644 --- a/src/renderer/src/components/editor/markdown-doc-links.ts +++ b/src/renderer/src/components/editor/markdown-doc-links.ts @@ -1,4 +1,4 @@ -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import { slugMarkdownHeading } from './markdown-heading-slug' export const MARKDOWN_DOC_LINK_PREFIX = '#orca-doc-link=' diff --git a/src/renderer/src/components/editor/markdown-document-list-request.test.ts b/src/renderer/src/components/editor/markdown-document-list-request.test.ts index 085b8599fb8..e94501a9140 100644 --- a/src/renderer/src/components/editor/markdown-document-list-request.test.ts +++ b/src/renderer/src/components/editor/markdown-document-list-request.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { getMarkdownDocumentListRequestKey, diff --git a/src/renderer/src/components/editor/markdown-document-list-request.ts b/src/renderer/src/components/editor/markdown-document-list-request.ts index 7e9cc0f32b3..a97ada9abbf 100644 --- a/src/renderer/src/components/editor/markdown-document-list-request.ts +++ b/src/renderer/src/components/editor/markdown-document-list-request.ts @@ -1,4 +1,4 @@ -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import { listRuntimeMarkdownDocuments, type RuntimeFileOperationArgs diff --git a/src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts b/src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts index b8b19f085e8..20a387648db 100644 --- a/src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts +++ b/src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector' diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts index 4d33a1f3985..3f6861c7897 100644 --- a/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts +++ b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts @@ -1,6 +1,6 @@ import type { OnMount } from '@monaco-editor/react' import type { IDisposable } from 'monaco-editor' -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import { getMarkdownDocCompletionContext, getMarkdownDocCompletionDocuments diff --git a/src/renderer/src/components/editor/rich-markdown-annotation-submit-highlights.ts b/src/renderer/src/components/editor/rich-markdown-annotation-submit-highlights.ts index fba5d07bdc4..973b2198c40 100644 --- a/src/renderer/src/components/editor/rich-markdown-annotation-submit-highlights.ts +++ b/src/renderer/src/components/editor/rich-markdown-annotation-submit-highlights.ts @@ -1,5 +1,5 @@ import type { Editor } from '@tiptap/react' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import { richMarkdownAnnotationHighlightPluginKey } from './rich-markdown-annotation-highlight' import { getRichMarkdownAnnotationHighlightRanges, diff --git a/src/renderer/src/components/editor/rich-markdown-commands.tsx b/src/renderer/src/components/editor/rich-markdown-commands.tsx index 5da14987ab4..607885d43e0 100644 --- a/src/renderer/src/components/editor/rich-markdown-commands.tsx +++ b/src/renderer/src/components/editor/rich-markdown-commands.tsx @@ -1,6 +1,6 @@ import type React from 'react' import type { Editor } from '@tiptap/react' -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import { stripMarkdownExtension } from './markdown-doc-links' export type DocLinkMenuState = { diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link.ts b/src/renderer/src/components/editor/rich-markdown-doc-link.ts index bbc1b26b294..e2245ec472a 100644 --- a/src/renderer/src/components/editor/rich-markdown-doc-link.ts +++ b/src/renderer/src/components/editor/rich-markdown-doc-link.ts @@ -1,7 +1,7 @@ import { Node } from '@tiptap/core' import { type EditorState, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import type { MarkdownDocumentIndex } from './markdown-doc-links' import { createMarkdownDocumentIndex, diff --git a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts index 9078f0e769d..3555330fac3 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts @@ -11,7 +11,7 @@ import { } from './markdown-internal-links' import { scrollToAnchorInEditor } from './markdown-anchor-scroll' import { getRichMarkdownCommentAtPos } from './rich-markdown-review-annotations' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import { translate } from '@/i18n/i18n' import { classifyHtmlSuperscriptLinkAction, diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index 275720cb5ab..7fd8385eeb8 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -29,7 +29,7 @@ import { } from './rich-markdown-image-context' import { getRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck' import type { MutableRefObject, Dispatch, SetStateAction } from 'react' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport' import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' import { diff --git a/src/renderer/src/components/editor/rich-markdown-editor-props.ts b/src/renderer/src/components/editor/rich-markdown-editor-props.ts index 04a20c13c75..93ed3310212 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-props.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-props.ts @@ -1,5 +1,5 @@ import type React from 'react' -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' export type RichMarkdownEditorProps = { fileId: string diff --git a/src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts b/src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts index 90591cfdb17..8e3b4dead62 100644 --- a/src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts @@ -29,7 +29,7 @@ import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard import { resolveRichMarkdownWorktreeRoot } from './useRichMarkdownSuperscriptLinkSetup' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type { AppState } from '@/store/types' -import type { FolderWorkspace } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import { inspectRichMarkdownSourceOwningSlice, RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT diff --git a/src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts b/src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts index 95c1fa67b8d..28688d73b59 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Editor } from '@tiptap/core' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import { countRichMarkdownReviewMarkdownLines, getRichMarkdownAnnotationButtonLeft, diff --git a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts index a7ef2307401..619d128bb65 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts @@ -1,7 +1,7 @@ import type { Dispatch, SetStateAction } from 'react' import type { Editor } from '@tiptap/react' import type { JSONContent } from '@tiptap/core' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight' import { getRichMarkdownLineRangeFromBlocks, diff --git a/src/renderer/src/components/editor/rich-markdown-review-note-layout.test.ts b/src/renderer/src/components/editor/rich-markdown-review-note-layout.test.ts index 98e47f5214e..4327f65d007 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-note-layout.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-note-layout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import { shouldExpandRichMarkdownReviewRail, stackRichMarkdownReviewNotePositions diff --git a/src/renderer/src/components/editor/rich-markdown-review-note-layout.ts b/src/renderer/src/components/editor/rich-markdown-review-note-layout.ts index 1a24de6dd21..7f8cab5244e 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-note-layout.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-note-layout.ts @@ -1,4 +1,4 @@ -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import { getCommentBodyLayoutLineCount } from '@/lib/comment-body-line-count' export type RichMarkdownReviewNotePosition = { diff --git a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts index d458312f6a1..c54996d0ffa 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts @@ -1,5 +1,5 @@ import type { Editor } from '@tiptap/react' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import { buildRichMarkdownCommentBlocks, getRichMarkdownCommentAnchorTop diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index e72ff75858e..14b70465087 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OpenFile } from '@/store/slices/editor' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import type { DiffContent, FileContent } from './editor-panel-content-types' const mocks = vi.hoisted(() => ({ diff --git a/src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx index 560bc217d86..317fa625779 100644 --- a/src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx @@ -4,7 +4,7 @@ import { act, useLayoutEffect, useMemo } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OpenFile } from '@/store/slices/editor' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import type { DiffContent, FileContent } from './editor-panel-content-types' const mocks = vi.hoisted(() => ({ diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index 5e8e5a17fb8..40694193c75 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { statRuntimePath } from '@/runtime/runtime-file-client' diff --git a/src/renderer/src/components/editor/useRichMarkdownMenuController.ts b/src/renderer/src/components/editor/useRichMarkdownMenuController.ts index d62a47d0308..6d73e618d03 100644 --- a/src/renderer/src/components/editor/useRichMarkdownMenuController.ts +++ b/src/renderer/src/components/editor/useRichMarkdownMenuController.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useRef, useState } from 'react' import type React from 'react' -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import { getMarkdownDocCompletionDocuments } from './markdown-doc-completions' import type { DocLinkMenuRow, DocLinkMenuState } from './rich-markdown-commands' import { filterRichMarkdownSlashCommands } from './rich-markdown-slash-command-filter' diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts index 585cec52c0b..6094570d39e 100644 --- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import type { Editor } from '@tiptap/react' -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types' import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' import { syncDocLinkMenu, type DocLinkMenuState } from './rich-markdown-commands' import { normalizeEmptyListItems } from './rich-markdown-normalize' diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewController.ts b/src/renderer/src/components/editor/useRichMarkdownReviewController.ts index cc4e828bc94..f78b403e1c1 100644 --- a/src/renderer/src/components/editor/useRichMarkdownReviewController.ts +++ b/src/renderer/src/components/editor/useRichMarkdownReviewController.ts @@ -15,7 +15,7 @@ import { flushPendingProseMirrorSelection } from './rich-markdown-selection-flus import { useRichMarkdownReviewData } from './useRichMarkdownReviewData' import { useRichMarkdownReviewCopyFeedback } from './useRichMarkdownReviewCopyFeedback' import { useRichMarkdownReviewRailController } from './useRichMarkdownReviewRailController' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' type UseRichMarkdownReviewControllerOptions = { addDiffComment: AppState['addDiffComment'] diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewData.ts b/src/renderer/src/components/editor/useRichMarkdownReviewData.ts index e0dc9286034..458dce00035 100644 --- a/src/renderer/src/components/editor/useRichMarkdownReviewData.ts +++ b/src/renderer/src/components/editor/useRichMarkdownReviewData.ts @@ -7,7 +7,7 @@ import { type MarkdownReviewNote } from '@/lib/markdown-review-notes' import type { NotesSendMenuScope } from './NotesSendMenu' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' import { translate } from '@/i18n/i18n' type UseRichMarkdownReviewDataOptions = { diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewEditorEffects.ts b/src/renderer/src/components/editor/useRichMarkdownReviewEditorEffects.ts index b79cb031753..7a8abdec3c9 100644 --- a/src/renderer/src/components/editor/useRichMarkdownReviewEditorEffects.ts +++ b/src/renderer/src/components/editor/useRichMarkdownReviewEditorEffects.ts @@ -2,7 +2,7 @@ import { useEffect, type RefObject } from 'react' import type { Editor } from '@tiptap/react' import { richMarkdownAnnotationHighlightPluginKey } from './rich-markdown-annotation-highlight' import { getRichMarkdownAnnotationHighlightRanges } from './rich-markdown-review-annotations' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' type UseRichMarkdownReviewEditorEffectsOptions = { canAnnotateRichMarkdown: boolean diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts b/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts index 0004f3aab87..45603510138 100644 --- a/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts +++ b/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts @@ -12,7 +12,7 @@ import { } from './rich-markdown-review-annotations' import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' import { measureRichMarkdownReviewNotePositions } from './rich-markdown-review-note-positioning' -import type { DiffComment } from '../../../../shared/types' +import type { DiffComment } from '../../../../shared/diff-comment-types' type UseRichMarkdownReviewRailControllerOptions = { canAnnotateRichMarkdown: boolean diff --git a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx index 476c79e232d..17b5de71fc3 100644 --- a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx +++ b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx @@ -1,4 +1,4 @@ -import type { Tab } from '../../../../shared/types' +import type { Tab } from '../../../../shared/tab-types' import { EmulatorPaneToolbar } from './emulator-pane-toolbar' import { EmulatorDeviceFrame } from './emulator-device-frame' import { MobileEmulatorAgentSetupGuideLayer } from './MobileEmulatorAgentSetupGuideLayer' diff --git a/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx b/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx index 8d15f611003..410d9d3ac66 100644 --- a/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx +++ b/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' -import type { Tab, TabGroup } from '../../../../shared/types' +import type { Tab, TabGroup } from '../../../../shared/tab-types' import EmulatorPane from './EmulatorPane' import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor' diff --git a/src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts b/src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts index a2e39a00841..c224d5e1c8c 100644 --- a/src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts +++ b/src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { getDefaultVoiceSettings } from '../../../../shared/constants' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { getFeatureTipForModal } from './feature-tip-modal-state' function makeSettings(voiceEnabled = false): Pick { diff --git a/src/renderer/src/components/feature-tips/feature-tip-modal-state.ts b/src/renderer/src/components/feature-tips/feature-tip-modal-state.ts index ca54215e73d..780b40894b1 100644 --- a/src/renderer/src/components/feature-tips/feature-tip-modal-state.ts +++ b/src/renderer/src/components/feature-tips/feature-tip-modal-state.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { FeatureInteractionState } from '../../../../shared/feature-interactions' import { FEATURE_TIPS, diff --git a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts index 99b281aab47..cac13f108fd 100644 --- a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts +++ b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { getDefaultOnboardingState, getDefaultVoiceSettings } from '../../../../shared/constants' import type { CliInstallStatus } from '../../../../shared/cli-install-types' -import type { GlobalSettings, OnboardingState } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { OnboardingState } from '../../../../shared/onboarding-state-types' import { getFeatureTipsAppOpenDecision, isCliFeatureTipCompleted } from './feature-tip-startup-gate' const existingUserOnboarding: OnboardingState = { diff --git a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts index a7eef0b8ead..4ad80028fd8 100644 --- a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts +++ b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts @@ -5,7 +5,8 @@ import { } from '../../../../shared/feature-tips' import type { CliInstallStatus } from '../../../../shared/cli-install-types' import type { FeatureInteractionState } from '../../../../shared/feature-interactions' -import type { GlobalSettings, OnboardingState } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { OnboardingState } from '../../../../shared/onboarding-state-types' import { shouldShowOnboarding } from '../onboarding/should-show-onboarding' export type FeatureTipsAppOpenDecision = diff --git a/src/renderer/src/components/feature-wall/AiCommitPrSettingsFields.tsx b/src/renderer/src/components/feature-wall/AiCommitPrSettingsFields.tsx index 52460dbadbc..7560b4a6cb3 100644 --- a/src/renderer/src/components/feature-wall/AiCommitPrSettingsFields.tsx +++ b/src/renderer/src/components/feature-wall/AiCommitPrSettingsFields.tsx @@ -7,7 +7,7 @@ import { type CommitMessageAgentCapability, type CommitMessageModelCapability } from '../../../../shared/commit-message-agent-spec' -import type { CommitMessageAiSettings } from '../../../../shared/types' +import type { CommitMessageAiSettings } from '../../../../shared/commit-message-ai-types' import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' import { Input } from '../ui/input' diff --git a/src/renderer/src/components/feature-wall/FeatureWallBody.tsx b/src/renderer/src/components/feature-wall/FeatureWallBody.tsx index 0eb0314d89e..b08fc2f669c 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallBody.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallBody.tsx @@ -4,7 +4,7 @@ import type { FeatureWallOpenSourceTelemetry } from '../../../../shared/telemetr import type { AgentsStep } from '../../../../shared/agents-orchestration-steps' import type { WorkbenchStep } from '../../../../shared/workbench-steps' import type { ReviewStep } from '../../../../shared/review-steps' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills' import { cn } from '@/lib/utils' import { PreviewMedia, RelatedFeatures } from './FeatureWallPreview' diff --git a/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx b/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx index dadf8f1134d..0fcea197023 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx @@ -23,7 +23,7 @@ import { import { AgentStep } from '../onboarding/AgentStep' import { NotificationStep } from '../onboarding/NotificationStep' import { useAppStore } from '@/store' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.test.tsx b/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.test.tsx index b7b64877156..0a0b4f0ce46 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.test.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.test.tsx @@ -14,7 +14,7 @@ import { } from './FeatureWallSetupWorkflowActions' import { useAppStore } from '@/store' import { toast } from 'sonner' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' vi.mock('sonner', () => ({ toast: { diff --git a/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.tsx b/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.tsx index e92264899ee..2649029c9b5 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.tsx @@ -7,7 +7,9 @@ import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { getDefaultRepoHookSettings } from '../../../../shared/constants' import { isGitRepoKind } from '../../../../shared/repo-kind' -import type { Repo, RepoHookSettings, Worktree } from '../../../../shared/types' +import type { RepoHookSettings } from '../../../../shared/orca-yaml-hook-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getRepositoryLocalCommandsSectionId } from '../settings/repository-settings-targets' import { requestContextualTourWhenReady, diff --git a/src/renderer/src/components/feature-wall/FeatureWallTourPanel.tsx b/src/renderer/src/components/feature-wall/FeatureWallTourPanel.tsx index 93646baa2f8..bb620423115 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallTourPanel.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallTourPanel.tsx @@ -6,7 +6,7 @@ import type { } from '../../../../shared/feature-wall-workflows' import type { ReviewStep, ReviewStepId } from '../../../../shared/review-steps' import type { FeatureWallOpenSourceTelemetry } from '../../../../shared/telemetry-events' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { WorkbenchStep, WorkbenchStepId } from '../../../../shared/workbench-steps' import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills' import { cn } from '@/lib/utils' diff --git a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx index a201b6f60cd..0520cc05564 100644 --- a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx +++ b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx @@ -1,7 +1,7 @@ import type { JSX } from 'react' import { Switch } from '@/components/ui/switch' import { getAgentAwakeDescription, getAgentAwakeTitle } from '../settings/agent-awake-copy' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { computerAwakeSettingsForMode, diff --git a/src/renderer/src/components/feature-wall/agents-orchestration/UsageAccountsCard.tsx b/src/renderer/src/components/feature-wall/agents-orchestration/UsageAccountsCard.tsx index a32fd8c38f9..bda09726912 100644 --- a/src/renderer/src/components/feature-wall/agents-orchestration/UsageAccountsCard.tsx +++ b/src/renderer/src/components/feature-wall/agents-orchestration/UsageAccountsCard.tsx @@ -10,7 +10,7 @@ import { useMountedRef } from '@/hooks/useMountedRef' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState -} from '../../../../../shared/types' +} from '../../../../../shared/managed-account-types' import { getFeatureWallUsageProviderConnection } from '../feature-wall-usage-tracking' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/feature-wall/ai-commit-pr-settings-helpers.ts b/src/renderer/src/components/feature-wall/ai-commit-pr-settings-helpers.ts index 8ed5d238b01..5dc505a0a10 100644 --- a/src/renderer/src/components/feature-wall/ai-commit-pr-settings-helpers.ts +++ b/src/renderer/src/components/feature-wall/ai-commit-pr-settings-helpers.ts @@ -1,4 +1,6 @@ -import type { CommitMessageAiSettings, GlobalSettings, TuiAgent } from '../../../../shared/types' +import type { CommitMessageAiSettings } from '../../../../shared/commit-message-ai-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { getCommitMessageAgentCapability, isCustomAgentId, diff --git a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts index ae23c2876ab..d46fd15ac91 100644 --- a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts +++ b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts @@ -8,7 +8,7 @@ import { getFeatureWallSetupStepsForSection, getFirstIncompleteFeatureWallSetupStepId } from '../../../../shared/feature-wall-setup-steps' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' function makeInput( overrides: Partial = {} diff --git a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts index 35196cb4fb2..585df18a9ac 100644 --- a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts +++ b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts @@ -4,7 +4,8 @@ import { FEATURE_WALL_SETUP_STEPS, type FeatureWallSetupStepId } from '../../../../shared/feature-wall-setup-steps' -import type { GlobalSettings, Worktree } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Worktree } from '../../../../shared/worktree/types' export type FeatureWallSetupProgressInput = { ready?: boolean diff --git a/src/renderer/src/components/feature-wall/useAiCommitPrSettings.ts b/src/renderer/src/components/feature-wall/useAiCommitPrSettings.ts index c187d6f18ec..1bdda194061 100644 --- a/src/renderer/src/components/feature-wall/useAiCommitPrSettings.ts +++ b/src/renderer/src/components/feature-wall/useAiCommitPrSettings.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react' -import type { CommitMessageAiSettings, TuiAgent } from '../../../../shared/types' +import type { CommitMessageAiSettings } from '../../../../shared/commit-message-ai-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { CUSTOM_AGENT_ID, getCommitMessageAgentCapability, diff --git a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx b/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx index 01a12086c7c..7e1b8d7a7a2 100644 --- a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx @@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import { FloatingBrowserSlot } from './FloatingBrowserSlot' import { getBrowserOverlaySlotViewport } from '@/components/browser-pane/browser-page-viewport' -import type { BrowserTab } from '../../../../shared/types' +import type { BrowserTab } from '../../../../shared/browser-workspace-types' // Why: BrowserPane mounts a real Electron we can't run in jsdom; stub // it so the test isolates the slot-root registration that BrowserPane depends on. diff --git a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx b/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx index 2013ffe4993..76b819f0876 100644 --- a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react' import BrowserPane from '@/components/browser-pane/BrowserPane' import { registerBrowserOverlaySlotViewport } from '@/components/browser-pane/browser-page-viewport' -import type { BrowserTab as BrowserTabState } from '../../../../shared/types' +import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' // Why: BrowserPane mounts its persistent Electron into a slot viewport // root keyed by the browser tab id. The main workspace registers that root via diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalIconContextMenu.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalIconContextMenu.tsx index 8947efc01f3..abd460a80fd 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalIconContextMenu.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalIconContextMenu.tsx @@ -8,7 +8,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' -import type { FloatingTerminalTriggerLocation } from '../../../../shared/types' +import type { FloatingTerminalTriggerLocation } from '../../../../shared/ui-chrome-types' import { translate } from '@/i18n/i18n' type FloatingTerminalIconContextMenuProps = { diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index 43e74374d9d..4e1913eb967 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -4,7 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { KeybindingOverrides, TerminalShortcutPolicy } from '../../../../shared/keybindings' -import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types' +import type { BrowserTab } from '../../../../shared/browser-workspace-types' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { OpenFile } from '@/store/slices/editor' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' import { diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 2f60cb89902..f37a6968c36 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -85,7 +85,9 @@ import { ModifierDoubleTapDetector, toModifierDoubleTapEvent } from '../../../../shared/modifier-double-tap-detector' -import type { BrowserTab as BrowserTabState, Tab, TerminalTab } from '../../../../shared/types' +import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' +import type { Tab } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution' import { FloatingBrowserSlot } from './FloatingBrowserSlot' import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog' diff --git a/src/renderer/src/components/github-checks-tab-state.test.ts b/src/renderer/src/components/github-checks-tab-state.test.ts index 66a0c06b722..f9b3a3c8001 100644 --- a/src/renderer/src/components/github-checks-tab-state.test.ts +++ b/src/renderer/src/components/github-checks-tab-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { PRCheckDetail, PRCheckRunDetails } from '../../../shared/types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../shared/github/check-types' import { beginGitHubChecksTabDetails, createGitHubChecksTabState, diff --git a/src/renderer/src/components/github-checks-tab-state.ts b/src/renderer/src/components/github-checks-tab-state.ts index e408eb6ef9d..18c12cf089b 100644 --- a/src/renderer/src/components/github-checks-tab-state.ts +++ b/src/renderer/src/components/github-checks-tab-state.ts @@ -1,4 +1,4 @@ -import type { PRCheckDetail, PRCheckRunDetails } from '../../../shared/types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../shared/github/check-types' export type CheckDetailsLoadState = { requestId?: number diff --git a/src/renderer/src/components/github-pr-merge-state.ts b/src/renderer/src/components/github-pr-merge-state.ts index d67a3b8a3ef..f26edd5896b 100644 --- a/src/renderer/src/components/github-pr-merge-state.ts +++ b/src/renderer/src/components/github-pr-merge-state.ts @@ -1,10 +1,10 @@ import type { CheckStatus, - ProviderCheckSummary, PRMergeableState, PRReviewDecision, - PRState -} from '../../../shared/types' + PRState, + ProviderCheckSummary +} from '../../../shared/github/pull-request-types' import { canEnableGitHubPRAutoMerge } from '../../../shared/github/pull-request-auto-merge-availability' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/github-pr-reviewer-display.test.ts b/src/renderer/src/components/github-pr-reviewer-display.test.ts index fca899f118c..3512b578e29 100644 --- a/src/renderer/src/components/github-pr-reviewer-display.test.ts +++ b/src/renderer/src/components/github-pr-reviewer-display.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { GITHUB_PR_REVIEWER_INPUT_MAX_BYTES, appendGitHubPRRequestedReviewers, diff --git a/src/renderer/src/components/github-pr-reviewer-display.ts b/src/renderer/src/components/github-pr-reviewer-display.ts index 695c5eab90a..01ba370e0a9 100644 --- a/src/renderer/src/components/github-pr-reviewer-display.ts +++ b/src/renderer/src/components/github-pr-reviewer-display.ts @@ -1,4 +1,5 @@ -import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' type ReviewDisplayItem = Pick diff --git a/src/renderer/src/components/github-project/ProjectCell.tsx b/src/renderer/src/components/github-project/ProjectCell.tsx index 877f426311a..ef22f2ffcaa 100644 --- a/src/renderer/src/components/github-project/ProjectCell.tsx +++ b/src/renderer/src/components/github-project/ProjectCell.tsx @@ -25,7 +25,7 @@ import type { GitHubProjectUser, ListIssueTypesBySlugResult } from '../../../../shared/github/project-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' type Props = { diff --git a/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx b/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx index 2b778e29c10..bf176e802c3 100644 --- a/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx +++ b/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx @@ -12,7 +12,7 @@ import { VisuallyHidden } from 'radix-ui' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' import type { GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog' import { SlugDialogBody } from './slug-dialog/SlugDialogBody' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' type Props = { diff --git a/src/renderer/src/components/github-project/ProjectRow.tsx b/src/renderer/src/components/github-project/ProjectRow.tsx index d57a807702e..57ad63784c3 100644 --- a/src/renderer/src/components/github-project/ProjectRow.tsx +++ b/src/renderer/src/components/github-project/ProjectRow.tsx @@ -12,7 +12,7 @@ import type { GitHubProjectFieldMutationValue, GitHubProjectRow as GitHubProjectRowType } from '../../../../shared/github/project-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' const PROJECT_FROZEN_COLUMN_SURFACE_CLASS = diff --git a/src/renderer/src/components/github-project/ProjectViewList.tsx b/src/renderer/src/components/github-project/ProjectViewList.tsx index 8bd6963b4c7..e54493418d6 100644 --- a/src/renderer/src/components/github-project/ProjectViewList.tsx +++ b/src/renderer/src/components/github-project/ProjectViewList.tsx @@ -22,7 +22,7 @@ import type { GitHubProjectSortDirection, GitHubProjectTable } from '../../../../shared/github/project-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' type SortOverride = { fieldId: string; direction: GitHubProjectSortDirection } diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index 7cac639ba8a..3996a562715 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -41,7 +41,7 @@ import type { GitHubProjectViewSummary, ListProjectViewsResult } from '../../../../shared/github/project-types' -import type { GitHubWorkItem } from '../../../../shared/types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' import ProjectPicker, { type ResolvedProjectSelection } from './ProjectPicker' import ProjectViewList from './ProjectViewList' import ProjectItemSlugDialog from './ProjectItemSlugDialog' @@ -65,6 +65,7 @@ import { githubProjectHost, githubProjectIdentityKey } from '../../../../shared/github/project-identity' +import { buildProjectWorkItem } from './project-work-item' type Props = { selectedRepoIds: ReadonlySet @@ -94,43 +95,6 @@ function getProjectViewSourceScope(settings: Parameters label.name), - updatedAt: row.updatedAt, - author: null, - repoId, - prRepo - } -} - export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JSX.Element { const settings = useAppStore((s) => s.settings) const projectViewCache = useAppStore((s) => s.projectViewCache) diff --git a/src/renderer/src/components/github-project/project-row-filtering.test.ts b/src/renderer/src/components/github-project/project-row-filtering.test.ts index 636382aa53c..fae4ecf6ffc 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.test.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github/project-types' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { filterProjectTableRowsByOpenRepos, filterProjectTableRowsBySelectedRepos, diff --git a/src/renderer/src/components/github-project/project-row-filtering.ts b/src/renderer/src/components/github-project/project-row-filtering.ts index b4c1b3738a3..0e9a3108043 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.ts @@ -1,5 +1,5 @@ import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github/project-types' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' export type ProjectRowSlugLookup = ( slug: string | null | undefined, diff --git a/src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts b/src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts index b7273b5e306..0ee72795a97 100644 --- a/src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts +++ b/src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts @@ -1,5 +1,3 @@ -// @vitest-environment happy-dom - import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -21,7 +19,7 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) describe('ProjectViewWrapper GitHub source context boundary', () => { it('builds project work items with a host-pinned repository identity', async () => { - const { buildProjectWorkItem } = await import('./ProjectViewWrapper') + const { buildProjectWorkItem } = await import('./project-work-item') const row: GitHubProjectRow = { id: 'PVTI_1', itemType: 'PULL_REQUEST', diff --git a/src/renderer/src/components/github-project/project-work-item.ts b/src/renderer/src/components/github-project/project-work-item.ts new file mode 100644 index 00000000000..692e0fa487b --- /dev/null +++ b/src/renderer/src/components/github-project/project-work-item.ts @@ -0,0 +1,40 @@ +import { githubProjectHost } from '../../../../shared/github/project-identity' +import type { GitHubProjectRow } from '../../../../shared/github/project-types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' + +export function buildProjectWorkItem( + row: GitHubProjectRow, + repoId: string, + host?: string +): GitHubWorkItem | null { + if (row.itemType !== 'ISSUE' && row.itemType !== 'PULL_REQUEST') { + return null + } + if (row.content.number == null || !row.content.url) { + return null + } + const [owner, repo] = row.content.repository?.split('/') ?? [] + // Why: Project rows can reach mutation controls before detail hydration, so + // preserve their host-bearing repository identity on the initial item. + const prRepo = owner && repo ? { owner, repo, host: githubProjectHost(host) } : undefined + return { + id: `${row.itemType === 'PULL_REQUEST' ? 'pr' : 'issue'}:${row.content.number}`, + type: row.itemType === 'PULL_REQUEST' ? 'pr' : 'issue', + number: row.content.number, + title: row.content.title, + state: + row.content.state === 'MERGED' + ? 'merged' + : row.content.state === 'CLOSED' + ? 'closed' + : row.content.isDraft + ? 'draft' + : 'open', + url: row.content.url, + labels: row.content.labels.map((label) => label.name), + updatedAt: row.updatedAt, + author: null, + repoId, + prRepo + } +} diff --git a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx index b609030dc03..cccdefb3f31 100644 --- a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import type { GlobalSettings } from '../../../../../shared/types' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' export function AssigneesEditor({ diff --git a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx index 748edc1fe74..d5ebf6f5740 100644 --- a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx @@ -12,7 +12,8 @@ import { } from '@/lib/comment-body-submit-state' import { useRepoSlugIndex } from '@/lib/repo-slug-index' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import type { GlobalSettings, PRComment } from '../../../../../shared/types' +import type { PRComment } from '../../../../../shared/github/comment-types' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' import type { GitHubProjectCommentMutationResult, GitHubProjectMutationResult diff --git a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx index 877cf47ca7c..96d64b7ad2a 100644 --- a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata' -import type { GlobalSettings } from '../../../../../shared/types' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' export function LabelsEditor({ diff --git a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx index a8b20151a34..e04c3129958 100644 --- a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx @@ -7,7 +7,8 @@ import { Input } from '@/components/ui/input' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import type { GitHubWorkItemDetails, GlobalSettings } from '../../../../../shared/types' +import type { GitHubWorkItemDetails } from '../../../../../shared/github/work-item-types' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' import type { GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog' import { LabelsEditor } from './LabelsEditor' import { AssigneesEditor } from './AssigneesEditor' diff --git a/src/renderer/src/components/github/CommentCodeContext.tsx b/src/renderer/src/components/github/CommentCodeContext.tsx index 8dd390f9586..8539ba9fad3 100644 --- a/src/renderer/src/components/github/CommentCodeContext.tsx +++ b/src/renderer/src/components/github/CommentCodeContext.tsx @@ -16,12 +16,12 @@ import { import { getPrCommentCodeContext } from '@/components/github/pr-comment-code-context' import { getPRFileContentsRenderLimit } from '@/components/github/pr-file-diff-mapping' import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { PRComment } from '../../../../shared/github/comment-types' import type { GitHubOwnerRepo, GitHubPRFile, - GitHubPRFileContents, - PRComment -} from '../../../../shared/types' + GitHubPRFileContents +} from '../../../../shared/github/pull-request-types' const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcerpt')) diff --git a/src/renderer/src/components/github/CommentReactions.tsx b/src/renderer/src/components/github/CommentReactions.tsx index 21aa4ac2316..fc9027e89ca 100644 --- a/src/renderer/src/components/github/CommentReactions.tsx +++ b/src/renderer/src/components/github/CommentReactions.tsx @@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' import { GITHUB_REACTION_ORDER } from '@/lib/pr-comment-reactions' -import type { GitHubReaction, GitHubReactionContent } from '../../../../shared/types' +import type { GitHubReaction, GitHubReactionContent } from '../../../../shared/github/comment-types' const REACTION_EMOJI: Record = { '+1': '👍', diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx index 9fa818066fd..52d7906df08 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx @@ -27,7 +27,7 @@ import { } from '@/components/github/github-markdown-composer-tabbar' import { hasBoundedGitHubMarkdownImageUrlText } from '@/components/github/github-markdown-image-url' import { useImageInput } from '@/components/github/use-image-input' -import type { GitHubOwnerRepo } from '../../../../shared/types' +import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { diff --git a/src/renderer/src/components/github/IssueSourceIndicator.tsx b/src/renderer/src/components/github/IssueSourceIndicator.tsx index c9615562e81..264b4ef7242 100644 --- a/src/renderer/src/components/github/IssueSourceIndicator.tsx +++ b/src/renderer/src/components/github/IssueSourceIndicator.tsx @@ -1,5 +1,5 @@ import React from 'react' -import type { GitHubOwnerRepo } from '../../../../shared/types' +import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { cn } from '@/lib/utils' import { diff --git a/src/renderer/src/components/github/IssueSourceSelector.tsx b/src/renderer/src/components/github/IssueSourceSelector.tsx index e428d53f525..fc20eae5c4c 100644 --- a/src/renderer/src/components/github/IssueSourceSelector.tsx +++ b/src/renderer/src/components/github/IssueSourceSelector.tsx @@ -1,5 +1,6 @@ import React from 'react' -import type { GitHubOwnerRepo, IssueSourcePreference } from '../../../../shared/types' +import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' +import type { IssueSourcePreference } from '../../../../shared/repo-types' import { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' diff --git a/src/renderer/src/components/github/PRAssigneesPanel.tsx b/src/renderer/src/components/github/PRAssigneesPanel.tsx index 24391745120..d759d93880a 100644 --- a/src/renderer/src/components/github/PRAssigneesPanel.tsx +++ b/src/renderer/src/components/github/PRAssigneesPanel.tsx @@ -19,7 +19,8 @@ import { getTaskSourceRuntimeSettings, type TaskSourceContext } from '../../../../shared/task-source-context' -import type { GitHubAssignableUser, GitHubWorkItem } from '../../../../shared/types' +import type { GitHubAssignableUser } from '../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' export function PRAssigneesPanel({ item, diff --git a/src/renderer/src/components/github/PRFilterDropdowns.tsx b/src/renderer/src/components/github/PRFilterDropdowns.tsx index d22cdbb8f4b..ddf74929515 100644 --- a/src/renderer/src/components/github/PRFilterDropdowns.tsx +++ b/src/renderer/src/components/github/PRFilterDropdowns.tsx @@ -17,9 +17,9 @@ import { } from '@/components/github/PRFilterSections' import type { GitHubAssignableUser, - GitHubOwnerRepo, - GlobalSettings -} from '../../../../shared/types' + GitHubOwnerRepo +} from '../../../../shared/github/pull-request-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { ParsedTaskQuery } from '../../../../shared/task-query' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/github/github-markdown-composer-preview-pane.tsx b/src/renderer/src/components/github/github-markdown-composer-preview-pane.tsx index 706f8785f8e..01fc334b2a6 100644 --- a/src/renderer/src/components/github/github-markdown-composer-preview-pane.tsx +++ b/src/renderer/src/components/github/github-markdown-composer-preview-pane.tsx @@ -1,5 +1,5 @@ import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import type { GitHubOwnerRepo } from '../../../../shared/types' +import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' import { translate } from '@/i18n/i18n' export function GitHubMarkdownComposerPreviewPane({ diff --git a/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts b/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts index e20b151f747..c8ef5e977e3 100644 --- a/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts +++ b/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GitHubAssignableUser } from '../../../../shared/types' +import type { GitHubAssignableUser } from '../../../../shared/github/pull-request-types' import { GITHUB_PR_REVIEWER_QUERY_MAX_BYTES, filterGitHubPRReviewerCandidates, diff --git a/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.ts b/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.ts index 3ae5bc4a236..f38bd80e9a0 100644 --- a/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.ts +++ b/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.ts @@ -1,5 +1,5 @@ import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -import type { GitHubAssignableUser } from '../../../../shared/types' +import type { GitHubAssignableUser } from '../../../../shared/github/pull-request-types' export type GitHubPRReviewerQueryState = { query: string diff --git a/src/renderer/src/components/github/github-rate-limit-display.tsx b/src/renderer/src/components/github/github-rate-limit-display.tsx index 9b7002476ce..17d81a90532 100644 --- a/src/renderer/src/components/github/github-rate-limit-display.tsx +++ b/src/renderer/src/components/github/github-rate-limit-display.tsx @@ -4,7 +4,10 @@ import { cn } from '@/lib/utils' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import type { GetRateLimitResult, GitHubRateLimitSnapshot } from '../../../../shared/types' +import type { + GetRateLimitResult, + GitHubRateLimitSnapshot +} from '../../../../shared/github/rate-limit-types' import { getProviderRateLimitScope } from '@/components/settings/provider-account-scope' import { ProviderHostScopeControl } from '@/components/settings/ProviderHostScopeControl' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/github/github-work-item-assignee-filter.ts b/src/renderer/src/components/github/github-work-item-assignee-filter.ts index cda49753314..7d30ce248b1 100644 --- a/src/renderer/src/components/github/github-work-item-assignee-filter.ts +++ b/src/renderer/src/components/github/github-work-item-assignee-filter.ts @@ -1,4 +1,4 @@ -import type { GitHubAssignableUser } from '../../../../shared/types' +import type { GitHubAssignableUser } from '../../../../shared/github/pull-request-types' import { isGitHubWorkItemOptionFilterQueryTooLarge } from './github-work-item-option-filter-bounds' export function filterGitHubWorkItemAssignees( diff --git a/src/renderer/src/components/github/github-work-item-comment-mutations.ts b/src/renderer/src/components/github/github-work-item-comment-mutations.ts index a42804365bf..f0f6fda3b82 100644 --- a/src/renderer/src/components/github/github-work-item-comment-mutations.ts +++ b/src/renderer/src/components/github/github-work-item-comment-mutations.ts @@ -5,7 +5,7 @@ import { getGitHubSourceRuntimeHost } from '@/lib/github-source-runtime-context' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { GitHubOwnerRepo } from '../../../../shared/types' +import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' export function addIssueCommentForRepo(args: { repoId?: string diff --git a/src/renderer/src/components/github/github-work-item-edit-mutations.ts b/src/renderer/src/components/github/github-work-item-edit-mutations.ts index e81adba1f0e..5c40aeb2328 100644 --- a/src/renderer/src/components/github/github-work-item-edit-mutations.ts +++ b/src/renderer/src/components/github/github-work-item-edit-mutations.ts @@ -13,7 +13,8 @@ import { getTaskSourceRuntimeSettings, type TaskSourceContext } from '../../../../shared/task-source-context' -import type { GitHubOwnerRepo, GitHubWorkItem } from '../../../../shared/types' +import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' // Why: for a Project row whose repo differs from the active workspace, mutations must target the row's actual repo via slug-addressed IPCs, else edits silently apply to the workspace's repo. // Why: these edit IPCs return `{ ok, error }`; callers throw on `!ok` so useImmediateMutation (which expects throws on failure) works unchanged. diff --git a/src/renderer/src/components/github/github-work-item-identity.ts b/src/renderer/src/components/github/github-work-item-identity.ts index f4808e17f3f..b4b06754406 100644 --- a/src/renderer/src/components/github/github-work-item-identity.ts +++ b/src/renderer/src/components/github/github-work-item-identity.ts @@ -1,5 +1,6 @@ import { githubProjectHost } from '../../../../shared/github/project-identity' -import type { GitHubOwnerRepo, GitHubWorkItem } from '../../../../shared/types' +import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' // Why: the dialog lacks repository context, so recover its host-aware identity from the canonical item URL. export function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { diff --git a/src/renderer/src/components/github/pr-check-presentation.ts b/src/renderer/src/components/github/pr-check-presentation.ts index 8b500171866..fb50d0a780a 100644 --- a/src/renderer/src/components/github/pr-check-presentation.ts +++ b/src/renderer/src/components/github/pr-check-presentation.ts @@ -1,5 +1,5 @@ import { getCheckConclusion } from '@/components/pr-check-counts' -import type { PRCheckDetail } from '../../../../shared/types' +import type { PRCheckDetail } from '../../../../shared/github/check-types' export function getCheckStatusLabel(check: PRCheckDetail): string { const conclusion = getCheckConclusion(check) diff --git a/src/renderer/src/components/github/pr-file-content-size.test.ts b/src/renderer/src/components/github/pr-file-content-size.test.ts index 700a9579ec4..d63bb18798d 100644 --- a/src/renderer/src/components/github/pr-file-content-size.test.ts +++ b/src/renderer/src/components/github/pr-file-content-size.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GitHubPRFileContents } from '../../../../shared/types' +import type { GitHubPRFileContents } from '../../../../shared/github/pull-request-types' import { getPRFileContentsCacheByteCount } from './pr-file-content-size' describe('PR file content size', () => { diff --git a/src/renderer/src/components/github/pr-file-content-size.ts b/src/renderer/src/components/github/pr-file-content-size.ts index 0e20c92bd1d..164dae1b8eb 100644 --- a/src/renderer/src/components/github/pr-file-content-size.ts +++ b/src/renderer/src/components/github/pr-file-content-size.ts @@ -1,5 +1,8 @@ import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '@/components/editor/large-diff-render-limit' -import type { GitHubPRFile, GitHubPRFileContents } from '../../../../shared/types' +import type { + GitHubPRFile, + GitHubPRFileContents +} from '../../../../shared/github/pull-request-types' import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits' export const PR_FILE_CONTENT_CACHE_MAX_BYTES = MAX_RENDERED_DIFF_COMBINED_CHARACTERS * 4 diff --git a/src/renderer/src/components/github/pr-file-diff-mapping.ts b/src/renderer/src/components/github/pr-file-diff-mapping.ts index d592a0d8140..dc990ed8f69 100644 --- a/src/renderer/src/components/github/pr-file-diff-mapping.ts +++ b/src/renderer/src/components/github/pr-file-diff-mapping.ts @@ -5,14 +5,13 @@ import { type LargeDiffRenderLimit } from '@/components/editor/large-diff-render-limit' import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { GitBranchChangeEntry, GitDiffResult } from '../../../../shared/git-diff-compare-types' +import type { PRComment } from '../../../../shared/github/comment-types' import type { - GitBranchChangeEntry, - GitDiffResult, GitHubOwnerRepo, GitHubPRFile, - GitHubPRFileContents, - PRComment -} from '../../../../shared/types' + GitHubPRFileContents +} from '../../../../shared/github/pull-request-types' export const PR_DIFF_OVERSCAN = 5 diff --git a/src/renderer/src/components/github/work-item-state-presentation.tsx b/src/renderer/src/components/github/work-item-state-presentation.tsx index fadc502fb91..b7f24298e94 100644 --- a/src/renderer/src/components/github/work-item-state-presentation.tsx +++ b/src/renderer/src/components/github/work-item-state-presentation.tsx @@ -1,6 +1,7 @@ import React from 'react' import { GitHubUserAvatar } from '@/components/github/github-user-avatar' -import type { GitHubAssignableUser, GitHubWorkItem } from '../../../../shared/types' +import type { GitHubAssignableUser } from '../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' export function formatRelativeTime(input: string): string { diff --git a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx index a85c566b0d6..66e18cc4fca 100644 --- a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx +++ b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx @@ -5,7 +5,10 @@ import { cn } from '@/lib/utils' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import type { GetGitLabRateLimitResult, GitLabRateLimitSnapshot } from '../../../../shared/types' +import type { + GetGitLabRateLimitResult, + GitLabRateLimitSnapshot +} from '../../../../shared/gitlab-types' import { getProviderRateLimitScope } from '@/components/settings/provider-account-scope' import { ProviderHostScopeControl } from '@/components/settings/ProviderHostScopeControl' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/jira-issue-sorter.ts b/src/renderer/src/components/jira-issue-sorter.ts index 875aaff0ef0..7fe071dc61e 100644 --- a/src/renderer/src/components/jira-issue-sorter.ts +++ b/src/renderer/src/components/jira-issue-sorter.ts @@ -1,4 +1,4 @@ -import type { JiraIssue, JiraPriority } from '../../../shared/types' +import type { JiraIssue, JiraPriority } from '../../../shared/jira-types' import { compareNumericLocaleText } from '@/lib/locale-text-collators' export type JiraIssueSortColumn = 'key' | 'title' | 'status' | 'priority' | 'assignee' | 'updated' diff --git a/src/renderer/src/components/jira-project-picker-filter.test.ts b/src/renderer/src/components/jira-project-picker-filter.test.ts index c270f361e4e..2c41742c5fc 100644 --- a/src/renderer/src/components/jira-project-picker-filter.test.ts +++ b/src/renderer/src/components/jira-project-picker-filter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { JiraProject } from '../../../shared/types' +import type { JiraProject } from '../../../shared/jira-types' import { JIRA_PROJECT_PICKER_QUERY_MAX_BYTES, filterJiraProjectPickerProjects, diff --git a/src/renderer/src/components/jira-project-picker-filter.ts b/src/renderer/src/components/jira-project-picker-filter.ts index 0a5ee32176a..0e8d060bf50 100644 --- a/src/renderer/src/components/jira-project-picker-filter.ts +++ b/src/renderer/src/components/jira-project-picker-filter.ts @@ -1,5 +1,5 @@ import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' -import type { JiraProject } from '../../../shared/types' +import type { JiraProject } from '../../../shared/jira-types' export const JIRA_PROJECT_PICKER_QUERY_MAX_BYTES = 2 * 1024 diff --git a/src/renderer/src/components/landing-preflight-dismissal.test.ts b/src/renderer/src/components/landing-preflight-dismissal.test.ts index 20b50442241..cfc316bc4b4 100644 --- a/src/renderer/src/components/landing-preflight-dismissal.test.ts +++ b/src/renderer/src/components/landing-preflight-dismissal.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../shared/types' +import type { Repo } from '../../../shared/repo-types' import { dismissPreflightIssue, githubProjectKeys, diff --git a/src/renderer/src/components/landing-preflight-dismissal.ts b/src/renderer/src/components/landing-preflight-dismissal.ts index 1fae17b0a3d..d688d5a0c4c 100644 --- a/src/renderer/src/components/landing-preflight-dismissal.ts +++ b/src/renderer/src/components/landing-preflight-dismissal.ts @@ -2,7 +2,7 @@ import { getProjectIdentityKey, isGitHubBackedRepo } from '../../../shared/project-host-setup-projection' -import type { Repo } from '../../../shared/types' +import type { Repo } from '../../../shared/repo-types' // Why: GitHub-CLI setup nudges are dismissible, but the dismissal must lapse // when the user adds a NEW GitHub-backed project — that's the moment the CLI diff --git a/src/renderer/src/components/landing-preflight-issues.test.ts b/src/renderer/src/components/landing-preflight-issues.test.ts index 99c315088f0..75ba7a5e006 100644 --- a/src/renderer/src/components/landing-preflight-issues.test.ts +++ b/src/renderer/src/components/landing-preflight-issues.test.ts @@ -4,7 +4,7 @@ import { hasGitHubBackedProject, type LandingPreflightStatus } from './landing-preflight-issues' -import type { Repo } from '../../../shared/types' +import type { Repo } from '../../../shared/repo-types' function repo(overrides: Partial & Pick): Repo { return { diff --git a/src/renderer/src/components/landing-preflight-issues.ts b/src/renderer/src/components/landing-preflight-issues.ts index 73324510a08..10e7ec81baf 100644 --- a/src/renderer/src/components/landing-preflight-issues.ts +++ b/src/renderer/src/components/landing-preflight-issues.ts @@ -1,6 +1,6 @@ import { translate } from '@/i18n/i18n' import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' -import type { Repo } from '../../../shared/types' +import type { Repo } from '../../../shared/repo-types' export type PreflightIssue = { id: string diff --git a/src/renderer/src/components/landing-preflight-runtime-boundary.test.ts b/src/renderer/src/components/landing-preflight-runtime-boundary.test.ts index a52c516f161..f282ca5f15a 100644 --- a/src/renderer/src/components/landing-preflight-runtime-boundary.test.ts +++ b/src/renderer/src/components/landing-preflight-runtime-boundary.test.ts @@ -2,7 +2,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PreflightStatus } from '../../../preload/api-types' -import type { Repo } from '../../../shared/types' +import type { Repo } from '../../../shared/repo-types' import { useAppStore } from '../store' import { useLandingPreflightRuntime } from './landing-preflight-runtime' diff --git a/src/renderer/src/components/linear-api-key-dialog.tsx b/src/renderer/src/components/linear-api-key-dialog.tsx index 0f00205d26e..272814e901b 100644 --- a/src/renderer/src/components/linear-api-key-dialog.tsx +++ b/src/renderer/src/components/linear-api-key-dialog.tsx @@ -1,6 +1,6 @@ import { useId, useMemo, useState } from 'react' import { ExternalLink, LoaderCircle, Lock } from 'lucide-react' -import type { LinearWorkspace } from '../../../shared/types' +import type { LinearWorkspace } from '../../../shared/linear/workspace-types' import { buildLinearPersonalApiKeySettingsUrl, buildLinearWorkspaceApiSettingsUrl diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx index 2352f25b876..65b08bbc168 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx +++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx @@ -3,7 +3,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { LinearTeam } from '../../../shared/types' +import type { LinearTeam } from '../../../shared/linear/workspace-types' import { clearLinearIssueAttributeFacet, countLinearIssueAttributeFilters, diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx index e914a40c064..a8fa09cbf12 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx +++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx @@ -12,7 +12,7 @@ import { emptyLinearIssueAttributeFilter, type LinearIssueAttributeFilter } from '../../../shared/linear/issue-attribute-filter' -import type { LinearTeam } from '../../../shared/types' +import type { LinearTeam } from '../../../shared/linear/workspace-types' import { LinearIssueFilterSectionDetail, LinearIssueFilterSectionMenu, diff --git a/src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts b/src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts index 1b7cdf164d2..91532889d37 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts +++ b/src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { resolveLinearIssueAttributeFilterPrimaryTeam } from './linear-issue-attribute-filter-primary-team' -import type { LinearTeam } from '../../../shared/types' +import type { LinearTeam } from '../../../shared/linear/workspace-types' const teams: LinearTeam[] = [ { id: 't-b', name: 'Backend', key: 'BE' }, diff --git a/src/renderer/src/components/linear-issue-attribute-filter-primary-team.ts b/src/renderer/src/components/linear-issue-attribute-filter-primary-team.ts index 53fe674b04b..75264f69d49 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-primary-team.ts +++ b/src/renderer/src/components/linear-issue-attribute-filter-primary-team.ts @@ -1,4 +1,4 @@ -import type { LinearTeam } from '../../../shared/types' +import type { LinearTeam } from '../../../shared/linear/workspace-types' function compareTeamNameId(a: LinearTeam, b: LinearTeam): number { const nameCmp = a.name.localeCompare(b.name) diff --git a/src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts b/src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts index 167080f61b0..bd56b936d88 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts +++ b/src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { LinearTeam } from '../../../shared/types' +import type { LinearTeam } from '../../../shared/linear/workspace-types' import { resolveLinearIssueAttributeFilterTeamIds, unionLinearMetadataById diff --git a/src/renderer/src/components/linear-issue-attribute-filter-team-ids.ts b/src/renderer/src/components/linear-issue-attribute-filter-team-ids.ts index ba586332e4c..dea77e52595 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-team-ids.ts +++ b/src/renderer/src/components/linear-issue-attribute-filter-team-ids.ts @@ -1,4 +1,4 @@ -import type { LinearTeam } from '../../../shared/types' +import type { LinearTeam } from '../../../shared/linear/workspace-types' /** * Resolve which Linear team ids should feed attribute-filter metadata. diff --git a/src/renderer/src/components/linear-issue-text-draft-state.ts b/src/renderer/src/components/linear-issue-text-draft-state.ts index 44b5eb08fbb..867a5eede9f 100644 --- a/src/renderer/src/components/linear-issue-text-draft-state.ts +++ b/src/renderer/src/components/linear-issue-text-draft-state.ts @@ -1,4 +1,4 @@ -import type { LinearIssue } from '../../../shared/types' +import type { LinearIssue } from '../../../shared/linear/issue-types' type LinearIssueTextDraftSource = Pick diff --git a/src/renderer/src/components/linear-issue-text-save-plan.ts b/src/renderer/src/components/linear-issue-text-save-plan.ts index 13553ea301d..43a28d360f9 100644 --- a/src/renderer/src/components/linear-issue-text-save-plan.ts +++ b/src/renderer/src/components/linear-issue-text-save-plan.ts @@ -1,4 +1,4 @@ -import type { LinearIssue } from '../../../shared/types' +import type { LinearIssue } from '../../../shared/linear/issue-types' export type LinearIssueTextField = 'title' | 'description' 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/linear-issue-workspace-text.test.ts b/src/renderer/src/components/linear-issue-workspace-text.test.ts index eecc60bddf5..42eb037916d 100644 --- a/src/renderer/src/components/linear-issue-workspace-text.test.ts +++ b/src/renderer/src/components/linear-issue-workspace-text.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { LinearIssue } from '../../../shared/types' +import type { LinearIssue } from '../../../shared/linear/issue-types' import { buildLinearIssueBranchName } from './linear-issue-workspace-text' function makeIssue(branchName?: string): LinearIssue { diff --git a/src/renderer/src/components/linear-issue-workspace-text.ts b/src/renderer/src/components/linear-issue-workspace-text.ts index e047495d40b..57f7aae1945 100644 --- a/src/renderer/src/components/linear-issue-workspace-text.ts +++ b/src/renderer/src/components/linear-issue-workspace-text.ts @@ -1,5 +1,5 @@ import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' -import type { LinearIssue } from '../../../shared/types' +import type { LinearIssue } from '../../../shared/linear/issue-types' import { getUsableLinearBranchName } from '../../../shared/new-workspace/workspace-source' import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' diff --git a/src/renderer/src/components/linear-project-view-surfaces.tsx b/src/renderer/src/components/linear-project-view-surfaces.tsx index 58970a8215a..4717375b182 100644 --- a/src/renderer/src/components/linear-project-view-surfaces.tsx +++ b/src/renderer/src/components/linear-project-view-surfaces.tsx @@ -22,9 +22,9 @@ import { cn } from '@/lib/utils' import type { LinearCustomViewSummary, LinearProjectDetail, - LinearProjectSummary, - LinearWorkspaceError -} from '../../../shared/types' + LinearProjectSummary +} from '../../../shared/linear/project-types' +import type { LinearWorkspaceError } from '../../../shared/linear/workspace-types' import { translate } from '@/i18n/i18n' type LinearProjectLike = LinearProjectSummary & { diff --git a/src/renderer/src/components/linear-scope-selector.test.ts b/src/renderer/src/components/linear-scope-selector.test.ts index 65c752c6fe0..3e8d69f69c7 100644 --- a/src/renderer/src/components/linear-scope-selector.test.ts +++ b/src/renderer/src/components/linear-scope-selector.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { LinearTeam, LinearWorkspace } from '../../../shared/types' +import type { LinearTeam, LinearWorkspace } from '../../../shared/linear/workspace-types' import { LINEAR_SCOPE_TEAM_FILTER_QUERY_MAX_BYTES, filterLinearScopeTeams, diff --git a/src/renderer/src/components/linear-scope-selector.tsx b/src/renderer/src/components/linear-scope-selector.tsx index 26bfb33df2e..e6a1712fab1 100644 --- a/src/renderer/src/components/linear-scope-selector.tsx +++ b/src/renderer/src/components/linear-scope-selector.tsx @@ -1,6 +1,10 @@ import { useCallback, useMemo, useState } from 'react' import { Check, ChevronDown, KeyRound } from 'lucide-react' -import type { LinearTeam, LinearWorkspace, LinearWorkspaceSelection } from '../../../shared/types' +import type { + LinearTeam, + LinearWorkspace, + LinearWorkspaceSelection +} from '../../../shared/linear/workspace-types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { Button } from '@/components/ui/button' diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts index 826d70aea9f..f9664bacfa5 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { subscribeToPtyData } from '../terminal-pane/pty-data-sidecar-subscriptions' import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream' diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts index 5f84fc4b7ba..3dde6373391 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store/types' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' const mocks = vi.hoisted(() => ({ toastLoading: vi.fn(() => 'toast-1'), diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index 11b9e5af7ad..3cad7fc157f 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -1,4 +1,5 @@ -import type { Tab, TuiAgent } from '../../../../shared/types' +import type { Tab } from '../../../../shared/tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentType } from '../../../../shared/agent-status-types' import { isNativeChatSupportedAgent, diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts index 1022d8e8441..17dae96d98d 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { AppState } from '@/store/types' import { resolveNativeChatFileLink, diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.ts b/src/renderer/src/components/native-chat/native-chat-file-link.ts index eee08ceae2b..44c1e83b6ab 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.ts @@ -1,5 +1,5 @@ import { routeNativeChatHref } from '../../../../shared/native-chat-href-routing' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { parseExplicitFileLinkTarget, resolveExplicitFileLinkTarget diff --git a/src/renderer/src/components/native-chat/native-chat-launch-session-options.ts b/src/renderer/src/components/native-chat/native-chat-launch-session-options.ts index 2257bb77120..2271d4bd1d8 100644 --- a/src/renderer/src/components/native-chat/native-chat-launch-session-options.ts +++ b/src/renderer/src/components/native-chat/native-chat-launch-session-options.ts @@ -1,4 +1,5 @@ -import type { GlobalSettings, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import { decideInitialAgentTabViewMode, diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts index 1262f010ca5..2dfc25e77f8 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts @@ -1,8 +1,8 @@ import type { TerminalLayoutSnapshot, - TerminalPaneLayoutNode, - TuiAgent -} from '../../../../shared/types' + TerminalPaneLayoutNode +} from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' function layoutNodeContainsLeaf(node: TerminalPaneLayoutNode | null, leafId: string): boolean { if (!node) { diff --git a/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts b/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts index 1193450e3cb..42f9605fdea 100644 --- a/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { resolveNativeChatSession } from './native-chat-pane-resolution' function entry( diff --git a/src/renderer/src/components/native-chat/native-chat-pane-resolution.ts b/src/renderer/src/components/native-chat/native-chat-pane-resolution.ts index 181895a4878..a363b977ff1 100644 --- a/src/renderer/src/components/native-chat/native-chat-pane-resolution.ts +++ b/src/renderer/src/components/native-chat/native-chat-pane-resolution.ts @@ -1,5 +1,5 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { isNativeChatSupportedAgent } from './native-chat-availability' /** Inputs that resolve the active pane to the agent/session/pty triple the diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts index 3f3b3cf8e7b..6841d9a0685 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { selectNativeChatRuntimeEnvironmentId, type NativeChatRuntimeOwnerState diff --git a/src/renderer/src/components/native-chat/native-chat-view-types.ts b/src/renderer/src/components/native-chat/native-chat-view-types.ts index ca374bab11c..de095f9b232 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-types.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { NativeChatSession } from '../../../../shared/native-chat-types' import type { NativeChatContextMenuActions } from './use-native-chat-context-menu' diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index 3096077e820..90c84470cee 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' -import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { canToggleNativeChat } from './native-chat-availability' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx index 062101e7545..b11b8b20ac1 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx @@ -3,7 +3,7 @@ import React, { act } from 'react' import { cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { JiraIssue } from '../../../../shared/types' +import type { JiraIssue } from '../../../../shared/jira-types' import SmartWorkspaceNameField, { type SmartWorkspaceNameSelection } from './SmartWorkspaceNameField' diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 66dd756e18f..58e771477dd 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -74,14 +74,11 @@ import { } from '../../../../shared/new-workspace/smart-workspace-linear-intent' import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../../shared/new-workspace/smart-workspace-source-query' import { filterAvailableTaskProviders } from '../../../../shared/task-providers' -import type { - BaseRefSearchResult, - GitHubWorkItem, - GitLabWorkItem, - JiraIssue, - JiraSite, - LinearIssue -} from '../../../../shared/types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { GitLabWorkItem } from '../../../../shared/gitlab-types' +import type { JiraIssue, JiraSite } from '../../../../shared/jira-types' +import type { LinearIssue } from '../../../../shared/linear/issue-types' +import type { BaseRefSearchResult } from '../../../../shared/repo-types' import { resolveSmartWorkspaceCommandValue } from './smart-workspace-command-value' import { isComposerFieldToFieldFocus } from './smart-workspace-source-popover-focus' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/new-workspace/run-target-options.ts b/src/renderer/src/components/new-workspace/run-target-options.ts index a07a094cf80..b95d128831d 100644 --- a/src/renderer/src/components/new-workspace/run-target-options.ts +++ b/src/renderer/src/components/new-workspace/run-target-options.ts @@ -1,4 +1,4 @@ -import type { OrcaHooks } from '../../../../shared/types' +import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import type { NeedsSetupProjectHostOption, ProjectHostSetupOption, diff --git a/src/renderer/src/components/new-workspace/use-jira-source-connection.test.tsx b/src/renderer/src/components/new-workspace/use-jira-source-connection.test.tsx index e29f5bd7acf..1da6ffaad00 100644 --- a/src/renderer/src/components/new-workspace/use-jira-source-connection.test.tsx +++ b/src/renderer/src/components/new-workspace/use-jira-source-connection.test.tsx @@ -4,7 +4,7 @@ import { act, renderHook } from '@testing-library/react' import type * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { JiraConnectionStatus } from '../../../../shared/types' +import type { JiraConnectionStatus } from '../../../../shared/jira-types' import { getJiraSourceConnectionRevisionKey, useJiraSourceConnection diff --git a/src/renderer/src/components/new-workspace/use-jira-source-connection.ts b/src/renderer/src/components/new-workspace/use-jira-source-connection.ts index 49884020d1b..703e87cf9e7 100644 --- a/src/renderer/src/components/new-workspace/use-jira-source-connection.ts +++ b/src/renderer/src/components/new-workspace/use-jira-source-connection.ts @@ -5,7 +5,7 @@ import { getTaskSourceRuntimeSettings, type TaskSourceContext } from '../../../../shared/task-source-context' -import type { JiraConnectionStatus } from '../../../../shared/types' +import type { JiraConnectionStatus } from '../../../../shared/jira-types' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' export type JiraSourceConnection = { diff --git a/src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx b/src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx index 1f6df54bfb2..fe4adf51e85 100644 --- a/src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx +++ b/src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx @@ -2,7 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { JiraConnectionStatus, JiraIssue, JiraSite } from '../../../../shared/types' +import type { JiraConnectionStatus, JiraIssue, JiraSite } from '../../../../shared/jira-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' import { useJiraUrlSource } from './use-jira-url-source' diff --git a/src/renderer/src/components/new-workspace/use-jira-url-source.ts b/src/renderer/src/components/new-workspace/use-jira-url-source.ts index 0441e37edc9..f3a922a7171 100644 --- a/src/renderer/src/components/new-workspace/use-jira-url-source.ts +++ b/src/renderer/src/components/new-workspace/use-jira-url-source.ts @@ -15,7 +15,7 @@ import { } from '../../../../shared/task-source-context' import { parseExecutionHostId } from '../../../../shared/execution-host' import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { JiraIssue, JiraSite } from '../../../../shared/types' +import type { JiraIssue, JiraSite } from '../../../../shared/jira-types' import { canReuseLoadedJiraStatus, type JiraSourceConnection } from './use-jira-source-connection' const LOOKUP_DEBOUNCE_MS = 200 diff --git a/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts b/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts index 44fddd40d08..aa82e46ea46 100644 --- a/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts +++ b/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { orderProjectIdsByRecency } from './use-recent-project-ids' function worktree(partial: Partial): Worktree { diff --git a/src/renderer/src/components/new-workspace/use-recent-project-ids.ts b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts index 3c68485ac52..e4f4c26a017 100644 --- a/src/renderer/src/components/new-workspace/use-recent-project-ids.ts +++ b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { useAllWorktrees } from '@/store/selectors' import { NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX } from '@/lib/new-workspace-project-options' diff --git a/src/renderer/src/components/notification-sound-options.ts b/src/renderer/src/components/notification-sound-options.ts index 1f0ca95597b..b1485939d9c 100644 --- a/src/renderer/src/components/notification-sound-options.ts +++ b/src/renderer/src/components/notification-sound-options.ts @@ -13,7 +13,7 @@ import { type LucideIcon } from 'lucide-react' import { basename } from '@/lib/path' -import type { GlobalSettings } from '../../../shared/types' +import type { GlobalSettings } from '../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { createLocalizedCatalog } from '@/i18n/localized-catalog' diff --git a/src/renderer/src/components/notifications/mac-notification-permission-card.tsx b/src/renderer/src/components/notifications/mac-notification-permission-card.tsx index 2bc94664d1c..fca7aab97d5 100644 --- a/src/renderer/src/components/notifications/mac-notification-permission-card.tsx +++ b/src/renderer/src/components/notifications/mac-notification-permission-card.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { BellRing, Check, Settings, TriangleAlert } from 'lucide-react' -import type { NotificationDeliveryProbeResult } from '../../../../shared/types' +import type { NotificationDeliveryProbeResult } from '../../../../shared/notification-settings-types' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/onboarding/AgentStep.tsx b/src/renderer/src/components/onboarding/AgentStep.tsx index d5e18e8070f..234d854d27e 100644 --- a/src/renderer/src/components/onboarding/AgentStep.tsx +++ b/src/renderer/src/components/onboarding/AgentStep.tsx @@ -5,7 +5,7 @@ import { cn } from '@/lib/utils' import { Checkbox } from '@/components/ui/checkbox' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { translate } from '@/i18n/i18n' const AGENT_GRID_MAX_ROWS = 4 diff --git a/src/renderer/src/components/onboarding/GhosttyDiscoveryRow.tsx b/src/renderer/src/components/onboarding/GhosttyDiscoveryRow.tsx index a9e44f218b6..5c5d8641f53 100644 --- a/src/renderer/src/components/onboarding/GhosttyDiscoveryRow.tsx +++ b/src/renderer/src/components/onboarding/GhosttyDiscoveryRow.tsx @@ -1,5 +1,5 @@ import { Check } from 'lucide-react' -import type { GhosttyImportPreview } from '../../../../shared/types' +import type { GhosttyImportPreview } from '../../../../shared/global-settings-types' import ghosttyIcon from '../../../../../resources/ghostty.svg' import { translate } from '@/i18n/i18n' import type { DiscoveryState } from './ThemeStep' diff --git a/src/renderer/src/components/onboarding/NotificationStep.test.tsx b/src/renderer/src/components/onboarding/NotificationStep.test.tsx index 04bd27f9833..854dcbe763c 100644 --- a/src/renderer/src/components/onboarding/NotificationStep.test.tsx +++ b/src/renderer/src/components/onboarding/NotificationStep.test.tsx @@ -1,6 +1,6 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { NotificationStep } from './NotificationStep' import { resolveMacNotificationPermissionState } from '../notifications/mac-notification-permission-card' diff --git a/src/renderer/src/components/onboarding/NotificationStep.tsx b/src/renderer/src/components/onboarding/NotificationStep.tsx index 711677552dd..2f37329ace1 100644 --- a/src/renderer/src/components/onboarding/NotificationStep.tsx +++ b/src/renderer/src/components/onboarding/NotificationStep.tsx @@ -1,7 +1,7 @@ import { useCallback, useRef, useState } from 'react' import { BellRing, FileAudio, Upload } from 'lucide-react' import { toast } from 'sonner' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Button } from '@/components/ui/button' import { Select, diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index f32b401fff2..9e82e42547c 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -3,7 +3,7 @@ import { cn } from '@/lib/utils' import { isEditableTarget } from '@/lib/editable-target' import { getScreenSubmitModifierLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import type { OnboardingState } from '../../../../shared/types' +import type { OnboardingState } from '../../../../shared/onboarding-state-types' import { AgentStep } from './AgentStep' import { ThemeStep } from './ThemeStep' import { NotificationStep } from './NotificationStep' diff --git a/src/renderer/src/components/onboarding/ThemeStep.tsx b/src/renderer/src/components/onboarding/ThemeStep.tsx index afb0bded49a..bb797a12a6e 100644 --- a/src/renderer/src/components/onboarding/ThemeStep.tsx +++ b/src/renderer/src/components/onboarding/ThemeStep.tsx @@ -5,11 +5,8 @@ import { cn } from '@/lib/utils' import { track } from '@/lib/telemetry' import { useMountedRef } from '@/hooks/useMountedRef' import { GhosttyDiscoveryRow } from './GhosttyDiscoveryRow' -import type { - DiscoveryStatusEmitted, - GhosttyImportPreview, - GlobalSettings -} from '../../../../shared/types' +import type { GhosttyImportPreview, GlobalSettings } from '../../../../shared/global-settings-types' +import type { DiscoveryStatusEmitted } from '../../../../shared/onboarding-state-types' import { translate } from '@/i18n/i18n' import { ChromePreview } from './theme-chrome-preview' diff --git a/src/renderer/src/components/onboarding/WindowsTerminalStep.test.tsx b/src/renderer/src/components/onboarding/WindowsTerminalStep.test.tsx index f7047d37bbb..aee8ce2aa2d 100644 --- a/src/renderer/src/components/onboarding/WindowsTerminalStep.test.tsx +++ b/src/renderer/src/components/onboarding/WindowsTerminalStep.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { WindowsTerminalStep } from './WindowsTerminalStep' function createSettings(overrides: Partial = {}): GlobalSettings { diff --git a/src/renderer/src/components/onboarding/WindowsTerminalStep.tsx b/src/renderer/src/components/onboarding/WindowsTerminalStep.tsx index a821dd22bb5..257e9d47b9c 100644 --- a/src/renderer/src/components/onboarding/WindowsTerminalStep.tsx +++ b/src/renderer/src/components/onboarding/WindowsTerminalStep.tsx @@ -2,7 +2,7 @@ import { Check } from 'lucide-react' import { useCallback, useState } from 'react' import type { BuiltInWindowsTerminalShell } from '../../../../shared/windows-terminal-shell' import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { cn } from '@/lib/utils' import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { SettingsSegmentedControl } from '../settings/SettingsFormControls' diff --git a/src/renderer/src/components/onboarding/agent-picked-payload.ts b/src/renderer/src/components/onboarding/agent-picked-payload.ts index 90d146effeb..2c2cabc9441 100644 --- a/src/renderer/src/components/onboarding/agent-picked-payload.ts +++ b/src/renderer/src/components/onboarding/agent-picked-payload.ts @@ -7,7 +7,11 @@ import { tuiAgentToAgentKind } from '@/lib/telemetry' import type { EventProps } from '../../../../shared/telemetry-events' -import type { PathSource, ShellHydrationFailureReason, TuiAgent } from '../../../../shared/types' +import type { + PathSource, + ShellHydrationFailureReason +} from '../../../../shared/shell-path-hydration-types' +import type { TuiAgent } from '../../../../shared/tui-agent' export type AgentPickedSnapshot = { agent: TuiAgent diff --git a/src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts b/src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts index 33dff5e8409..8e11d1d9eec 100644 --- a/src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { resolveOnboardingSettingsHydration } from './onboarding-settings-hydration' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' const settings = { theme: 'light', diff --git a/src/renderer/src/components/onboarding/onboarding-settings-hydration.ts b/src/renderer/src/components/onboarding/onboarding-settings-hydration.ts index 8c0a9e3536d..fc4241d0d1b 100644 --- a/src/renderer/src/components/onboarding/onboarding-settings-hydration.ts +++ b/src/renderer/src/components/onboarding/onboarding-settings-hydration.ts @@ -1,4 +1,5 @@ -import type { GlobalSettings, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TuiAgent } from '../../../../shared/tui-agent' export type OnboardingSettingsHydrationUpdate = { settingsHydrated: boolean diff --git a/src/renderer/src/components/onboarding/should-show-onboarding.ts b/src/renderer/src/components/onboarding/should-show-onboarding.ts index c50352a5653..d96f2f8e6f0 100644 --- a/src/renderer/src/components/onboarding/should-show-onboarding.ts +++ b/src/renderer/src/components/onboarding/should-show-onboarding.ts @@ -1,4 +1,4 @@ -import type { OnboardingState } from '../../../../shared/types' +import type { OnboardingState } from '../../../../shared/onboarding-state-types' // Why: split out so App.tsx can gate the lazy without an // eager static import path that pulls the whole flow into the main chunk. diff --git a/src/renderer/src/components/onboarding/show-onboarding-event.ts b/src/renderer/src/components/onboarding/show-onboarding-event.ts index 716c3b4d22f..0e6f139b676 100644 --- a/src/renderer/src/components/onboarding/show-onboarding-event.ts +++ b/src/renderer/src/components/onboarding/show-onboarding-event.ts @@ -1,4 +1,4 @@ -import type { OnboardingState } from '../../../../shared/types' +import type { OnboardingState } from '../../../../shared/onboarding-state-types' const ONBOARDING_REOPENED_EVENT = 'orca:onboarding-reopened' diff --git a/src/renderer/src/components/onboarding/theme-chrome-preview.tsx b/src/renderer/src/components/onboarding/theme-chrome-preview.tsx index 68ef3120ef8..49fdb60a680 100644 --- a/src/renderer/src/components/onboarding/theme-chrome-preview.tsx +++ b/src/renderer/src/components/onboarding/theme-chrome-preview.tsx @@ -1,5 +1,5 @@ import { cn } from '@/lib/utils' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' export function ChromePreview({ variant }: { variant: GlobalSettings['theme'] }) { if (variant === 'system') { diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts index 1dfbc0c708a..9a35faac37a 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts @@ -4,7 +4,7 @@ import { createElement, useEffect, act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultOnboardingState } from '../../../../shared/constants' -import type { OnboardingState } from '../../../../shared/types' +import type { OnboardingState } from '../../../../shared/onboarding-state-types' const trackMock = vi.hoisted(() => vi.fn()) diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts index 12a8c6e22a2..93b4b62c153 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts @@ -3,7 +3,9 @@ import { track } from '@/lib/telemetry' import { useAppStore } from '@/store' import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../../../../shared/constants' import type { EventProps } from '../../../../shared/telemetry-events' -import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { OnboardingState } from '../../../../shared/onboarding-state-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { applyAgentPermissionMode } from '../../../../shared/tui-agent-permissions' import type { StepId, StepNumber } from './use-onboarding-flow-types' diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index b5376b363f1..33f7e94d1e2 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -8,7 +8,9 @@ import { track } from '@/lib/telemetry' import { buildAgentPickedPayload } from './agent-picked-payload' import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../../../../shared/constants' import type { EventProps } from '../../../../shared/telemetry-events' -import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { OnboardingState } from '../../../../shared/onboarding-state-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { STEPS, type StepNumber } from './use-onboarding-flow-types' import { persistStep, useCloseWith, usePersistCurrentStep } from './use-onboarding-flow-persistence' import { resolveOnboardingSettingsHydration } from './onboarding-settings-hydration' diff --git a/src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.ts b/src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.ts index 341f8b73fe6..922f7b866a0 100644 --- a/src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.ts +++ b/src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.ts @@ -1,6 +1,6 @@ import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' import type { EventProps } from '../../../../shared/telemetry-events' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' type WindowsTerminalSnapshot = EventProps<'onboarding_windows_terminal_snapshot'> diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx index 052ff64750a..5f16da6986d 100644 --- a/src/renderer/src/components/pet/PetOverlay.tsx +++ b/src/renderer/src/components/pet/PetOverlay.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' import { usePetUrl } from './usePetUrl' import type { DetectedSpriteCacheEntry } from './pet-blob-cache' -import type { CustomPet } from '../../../../shared/types' +import type { CustomPet } from '../../../../shared/pet-types' import { useAppStore } from '../../store' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' import { diff --git a/src/renderer/src/components/pet/pet-overlay-hit-area.test.tsx b/src/renderer/src/components/pet/pet-overlay-hit-area.test.tsx index adb257ce4b5..360bfdea7c1 100644 --- a/src/renderer/src/components/pet/pet-overlay-hit-area.test.tsx +++ b/src/renderer/src/components/pet/pet-overlay-hit-area.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import type { DetectedSpriteCacheEntry } from './pet-blob-cache' -import type { CustomPet } from '../../../../shared/types' +import type { CustomPet } from '../../../../shared/pet-types' type PetUrlState = | { url: string; ready: boolean; sprite: null; detected: null } diff --git a/src/renderer/src/components/pet/usePetUrl.ts b/src/renderer/src/components/pet/usePetUrl.ts index efc9fc05568..9809b228532 100644 --- a/src/renderer/src/components/pet/usePetUrl.ts +++ b/src/renderer/src/components/pet/usePetUrl.ts @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { CustomPet } from '../../../../shared/types' +import type { CustomPet } from '../../../../shared/pet-types' import { applyCodexSpriteTimingDefaults } from '../../../../shared/codex-pet-sprite-defaults' import { useAppStore } from '../../store' import { BUNDLED_PET, findBundledPet, isBundledPetId } from './pet-models' diff --git a/src/renderer/src/components/pr-check-counts.test.ts b/src/renderer/src/components/pr-check-counts.test.ts index 8f2fa37c2ef..782597e3231 100644 --- a/src/renderer/src/components/pr-check-counts.test.ts +++ b/src/renderer/src/components/pr-check-counts.test.ts @@ -4,7 +4,7 @@ import { getProviderChecksLabel, summarizeProviderChecks } from '../../../shared/provider-check-summary' -import type { PRCheckDetail } from '../../../shared/types' +import type { PRCheckDetail } from '../../../shared/github/check-types' function check(conclusion: PRCheckDetail['conclusion'], name = String(conclusion)): PRCheckDetail { return { name, status: 'completed', conclusion, url: null } diff --git a/src/renderer/src/components/pr-check-counts.ts b/src/renderer/src/components/pr-check-counts.ts index e5b6d65e482..341c96141c1 100644 --- a/src/renderer/src/components/pr-check-counts.ts +++ b/src/renderer/src/components/pr-check-counts.ts @@ -1,6 +1,6 @@ import { translate } from '../i18n/i18n' import { summarizeProviderChecks } from '../../../shared/provider-check-summary' -import type { PRCheckDetail } from '../../../shared/types' +import type { PRCheckDetail } from '../../../shared/github/check-types' export type PRCheckCounts = { passing: number diff --git a/src/renderer/src/components/pr-checks-fix-prompt.test.ts b/src/renderer/src/components/pr-checks-fix-prompt.test.ts index b3f2eadf600..10623b46df9 100644 --- a/src/renderer/src/components/pr-checks-fix-prompt.test.ts +++ b/src/renderer/src/components/pr-checks-fix-prompt.test.ts @@ -6,7 +6,7 @@ import { getCheckDetailsPromptKey, truncateLogTailForPrompt } from './pr-checks-fix-prompt' -import type { PRCheckDetail, PRCheckRunDetails } from '../../../shared/types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../shared/github/check-types' const failingCheck: PRCheckDetail = { name: 'unit', diff --git a/src/renderer/src/components/pr-comments-resolution-prompt.test.ts b/src/renderer/src/components/pr-comments-resolution-prompt.test.ts index 902481e9ba3..9ff4fad596c 100644 --- a/src/renderer/src/components/pr-comments-resolution-prompt.test.ts +++ b/src/renderer/src/components/pr-comments-resolution-prompt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { PRComment } from '../../../shared/types' +import type { PRComment } from '../../../shared/github/comment-types' import { groupPRComments } from '../../../shared/pr-comment-groups' import { buildPRCommentsResolutionPrompt, diff --git a/src/renderer/src/components/pr-comments-resolution-prompt.ts b/src/renderer/src/components/pr-comments-resolution-prompt.ts index d86a4e8d653..8f83fabc3d3 100644 --- a/src/renderer/src/components/pr-comments-resolution-prompt.ts +++ b/src/renderer/src/components/pr-comments-resolution-prompt.ts @@ -1,4 +1,4 @@ -import type { PRComment } from '../../../shared/types' +import type { PRComment } from '../../../shared/github/comment-types' import type { PRCommentGroup } from '../../../shared/pr-comment-groups' export type PRCommentsResolutionReviewKind = 'PR' | 'MR' diff --git a/src/renderer/src/components/provider-check-classification-parity.test.ts b/src/renderer/src/components/provider-check-classification-parity.test.ts index 688f61a978e..bedb0663739 100644 --- a/src/renderer/src/components/provider-check-classification-parity.test.ts +++ b/src/renderer/src/components/provider-check-classification-parity.test.ts @@ -9,7 +9,8 @@ import { summarizeProviderChecks } from '../../../shared/provider-check-summary' import type { GitLabPipelineJob } from '../../../shared/gitlab-types' -import type { PRCheckDetail, ProviderCheckSummary } from '../../../shared/types' +import type { PRCheckDetail } from '../../../shared/github/check-types' +import type { ProviderCheckSummary } from '../../../shared/github/pull-request-types' function completed(conclusion: string): PRCheckDetail { return { diff --git a/src/renderer/src/components/quick-open-file-list.react.test.tsx b/src/renderer/src/components/quick-open-file-list.react.test.tsx index fc00fac9891..fa2a288d7ac 100644 --- a/src/renderer/src/components/quick-open-file-list.react.test.tsx +++ b/src/renderer/src/components/quick-open-file-list.react.test.tsx @@ -3,7 +3,8 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace, ProjectGroup } from '../../../shared/types' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../shared/project-group-types' import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' diff --git a/src/renderer/src/components/quick-open-file-list.test.ts b/src/renderer/src/components/quick-open-file-list.test.ts index fa6e695e998..be5370b4d8c 100644 --- a/src/renderer/src/components/quick-open-file-list.test.ts +++ b/src/renderer/src/components/quick-open-file-list.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Worktree } from '../../../shared/types' +import type { Worktree } from '../../../shared/worktree/types' import { buildExcludePathPrefixes } from '../../../shared/quick-open-filter' import { getRuntimeFileListTarget, diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts index bb539749353..80a8dbac6d9 100644 --- a/src/renderer/src/components/quick-open-file-list.ts +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -1,7 +1,7 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: quick-open file lists are fetched over local or SSH runtime IPC, so loading/error/results track the request lifecycle. */ import { useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' -import type { Worktree } from '../../../shared/types' +import type { Worktree } from '../../../shared/worktree/types' import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { createBrowserUuid } from '@/lib/browser-uuid' import { cancelRuntimeFileList, listRuntimeFiles } from '@/runtime/runtime-file-client' diff --git a/src/renderer/src/components/repo/NestedRepoChecklist.test.tsx b/src/renderer/src/components/repo/NestedRepoChecklist.test.tsx index ab8f3dda663..05864c4a050 100644 --- a/src/renderer/src/components/repo/NestedRepoChecklist.test.tsx +++ b/src/renderer/src/components/repo/NestedRepoChecklist.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { NestedRepoChecklist } from './NestedRepoChecklist' -import type { NestedRepoScanResult } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' const scan: NestedRepoScanResult = { selectedPath: '/workspace/platform', diff --git a/src/renderer/src/components/repo/NestedRepoChecklist.tsx b/src/renderer/src/components/repo/NestedRepoChecklist.tsx index 95ea1a41897..77bb3378900 100644 --- a/src/renderer/src/components/repo/NestedRepoChecklist.tsx +++ b/src/renderer/src/components/repo/NestedRepoChecklist.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react' import { GitBranch } from 'lucide-react' -import type { NestedRepoScanResult } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' import { cn } from '@/lib/utils' import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/repo/NestedRepoScanLimitNotice.test.ts b/src/renderer/src/components/repo/NestedRepoScanLimitNotice.test.ts index 44506bcd5e6..f3279d16939 100644 --- a/src/renderer/src/components/repo/NestedRepoScanLimitNotice.test.ts +++ b/src/renderer/src/components/repo/NestedRepoScanLimitNotice.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { NestedRepoScanResult } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' import { nestedRepoScanLimitText } from './NestedRepoScanLimitNotice' describe('nestedRepoScanLimitText', () => { diff --git a/src/renderer/src/components/repo/NestedRepoScanLimitNotice.tsx b/src/renderer/src/components/repo/NestedRepoScanLimitNotice.tsx index 59066a6675b..38fa6524a18 100644 --- a/src/renderer/src/components/repo/NestedRepoScanLimitNotice.tsx +++ b/src/renderer/src/components/repo/NestedRepoScanLimitNotice.tsx @@ -1,6 +1,6 @@ import { CircleHelp } from 'lucide-react' import { useState } from 'react' -import type { NestedRepoScanResult } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/repo/RepoCombobox.tsx b/src/renderer/src/components/repo/RepoCombobox.tsx index 868c7be2d22..f47833eeca9 100644 --- a/src/renderer/src/components/repo/RepoCombobox.tsx +++ b/src/renderer/src/components/repo/RepoCombobox.tsx @@ -14,7 +14,7 @@ import { isGitRepoKind } from '../../../../shared/repo-kind' import { searchRepos } from '@/lib/repo-search' import { cn } from '@/lib/utils' import { useMountedRef } from '@/hooks/useMountedRef' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import RepoBadgeLabel from './RepoBadgeLabel' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/repo/repo-fork-indicator.tsx b/src/renderer/src/components/repo/repo-fork-indicator.tsx index 896a5217730..b1dcf93fa15 100644 --- a/src/renderer/src/components/repo/repo-fork-indicator.tsx +++ b/src/renderer/src/components/repo/repo-fork-indicator.tsx @@ -1,6 +1,6 @@ import React from 'react' import { GitFork } from 'lucide-react' -import type { GitHubRepositoryIdentity } from '../../../../shared/types' +import type { GitHubRepositoryIdentity } from '../../../../shared/github/pull-request-types' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { cn } from '@/lib/utils' diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 2d63be3bacf..cfa9030d5f0 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -58,17 +58,15 @@ import { type PRCommentsListSelectionClearRequest } from './pr-comments-list-selection' import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' +import type { GitHubReactionContent, PRComment } from '../../../../shared/github/comment-types' +import type { PRRefreshErrorType } from '../../../../shared/github/pull-request-refresh-types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' import type { GitLabDiscussionResolveResult, GitLabProjectRef, - GitLabWorkItemDetails, - PRInfo, - PRCheckDetail, - PRCheckRunDetails, - PRComment, - GitHubReactionContent, - PRRefreshErrorType -} from '../../../../shared/types' + GitLabWorkItemDetails +} from '../../../../shared/gitlab-types' import { getConnectionId } from '@/lib/connection-context' import { buildResolvePullRequestConflictsPrompt, diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index e8191d1b3e7..d52457324c0 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -54,7 +54,7 @@ import { useFileExplorerSelection } from './useFileExplorerSelection' import { useFileExplorerVisibleRowProjection } from './useFileExplorerVisibleRowProjection' import { translate } from '@/i18n/i18n' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab' -import type { RightSidebarExplorerView } from '../../../../shared/types' +import type { RightSidebarExplorerView } from '../../../../shared/ui-chrome-types' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { createNewTerminalTab } from '@/components/terminal/terminal-tab-create' import { useWorkspaceFileBrowserActionPredicate } from '@/lib/file-preview' diff --git a/src/renderer/src/components/right-sidebar/FileExplorerQueryStrip.tsx b/src/renderer/src/components/right-sidebar/FileExplorerQueryStrip.tsx index 23b89e096a0..cfa1c1a30e1 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerQueryStrip.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerQueryStrip.tsx @@ -1,6 +1,6 @@ import React from 'react' import { FileExplorerViewSwitch } from './FileExplorerViewSwitch' -import type { RightSidebarExplorerView } from '../../../../shared/types' +import type { RightSidebarExplorerView } from '../../../../shared/ui-chrome-types' type FileExplorerQueryStripProps = { view: RightSidebarExplorerView diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index a5e232cb29e..545dc284da8 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -43,7 +43,7 @@ import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' -import type { GitFileStatus } from '../../../../shared/types' +import type { GitFileStatus } from '../../../../shared/git-status-types' import { STATUS_LABELS } from './status-display' import { RENAME_HOTSPOT_ATTR } from './file-explorer-dir-toggle-timing' import type { TreeNode } from './file-explorer-types' diff --git a/src/renderer/src/components/right-sidebar/FileExplorerViewSwitch.tsx b/src/renderer/src/components/right-sidebar/FileExplorerViewSwitch.tsx index ec64942f1cf..f7e32b63c98 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerViewSwitch.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerViewSwitch.tsx @@ -1,7 +1,7 @@ import type React from 'react' import { translate } from '@/i18n/i18n' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' -import type { RightSidebarExplorerView } from '../../../../shared/types' +import type { RightSidebarExplorerView } from '../../../../shared/ui-chrome-types' type FileExplorerViewSwitchProps = { view: RightSidebarExplorerView diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx index d72e7a5ca19..59327faddf5 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx @@ -2,7 +2,7 @@ import React from 'react' import type { Virtualizer } from '@tanstack/react-virtual' import { dirname, normalizeRelativePath } from '@/lib/path' import { cn } from '@/lib/utils' -import type { GitFileStatus } from '../../../../shared/types' +import type { GitFileStatus } from '../../../../shared/git-status-types' import { FileExplorerRow, InlineInputRow, type InlineInput } from './FileExplorerRow' import { shouldShowIgnoredDecoration, STATUS_COLORS } from './status-display' import type { DirCache, TreeNode } from './file-explorer-types' diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx index 3cd868a5baf..4ee957c5001 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx @@ -3,13 +3,11 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - FolderWorkspace, - PRCheckDetail, - Repo, - Worktree, - WorkspaceLineage -} from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { PRCheckDetail } from '../../../../shared/github/check-types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorkspaceLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx index f2e3bdda640..4c03a998d79 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees' import { FolderWorkspacePrChecksRow } from './FolderWorkspacePrChecksRow' import type { ParentPrChecksRefreshOutcome, ParentPrChecksRow } from './parent-pr-checks-rows' diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx index de88bd8454d..67c3a0fc222 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx @@ -3,7 +3,9 @@ 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 { CheckStatus, Repo, Worktree } from '../../../../shared/types' +import type { CheckStatus } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { ParentPrChecksRow, ParentPrChecksRowStatus } from './parent-pr-checks-rows' vi.mock('@/i18n/i18n', () => ({ diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx index cd07701bde7..ac87481de9b 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx @@ -3,7 +3,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { cn } from '@/lib/utils' import { openHttpLink } from '@/lib/http-link-routing' import { translate } from '@/i18n/i18n' -import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' import { CHECK_COLOR, CHECK_ICON, diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.test.tsx index ddeb4704433..2fcdd1cea93 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.test.tsx @@ -3,7 +3,9 @@ import { act, type CSSProperties, type MouseEvent, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo, Worktree, WorktreeLineage, WorkspaceLineage } from '../../../../shared/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 { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { LINEAGE_CHILDREN_INLINE_OFFSET, diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx index 2a2a5a78588..94c46a545d2 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx @@ -5,7 +5,7 @@ import { } from '@/components/sidebar/worktree-list-indentation' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees' import { useState } from 'react' diff --git a/src/renderer/src/components/right-sidebar/GitHistoryCommitFiles.tsx b/src/renderer/src/components/right-sidebar/GitHistoryCommitFiles.tsx index 4bf5da10cb8..d32e82cdd0f 100644 --- a/src/renderer/src/components/right-sidebar/GitHistoryCommitFiles.tsx +++ b/src/renderer/src/components/right-sidebar/GitHistoryCommitFiles.tsx @@ -10,7 +10,8 @@ import { getFileTypeIcon } from '@/lib/file-type-icons' import { basename, dirname } from '@/lib/path' import { translate } from '@/i18n/i18n' import { formatGitHistoryTimestamp } from './git-history-format' -import type { GitBranchChangeEntry, GitFileStatus } from '../../../../shared/types' +import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' +import type { GitFileStatus } from '../../../../shared/git-status-types' // State for a single commit's lazily-loaded file list. Owned by GitHistoryPanel, // populated through the onLoadCommitFiles loader supplied by SourceControl. 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/GitHistoryPanel.tsx b/src/renderer/src/components/right-sidebar/GitHistoryPanel.tsx index bc60150cd77..c7cb02f4247 100644 --- a/src/renderer/src/components/right-sidebar/GitHistoryPanel.tsx +++ b/src/renderer/src/components/right-sidebar/GitHistoryPanel.tsx @@ -5,7 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { cn } from '@/lib/utils' import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu' import type { GitHistoryItem, GitHistoryResult } from '../../../../shared/git-history' -import type { GitBranchChangeEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' import { buildDefaultGitHistoryColorMap, buildGitHistoryViewModels 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/right-sidebar/GitHubPRStackMap.test.tsx b/src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx index e29d0b74361..e33e952f2fc 100644 --- a/src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx +++ b/src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx @@ -3,7 +3,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GitHubPRStack } from '../../../../shared/types' +import type { GitHubPRStack } from '../../../../shared/github/pull-request-types' import { GitHubPRStackMap } from './GitHubPRStackMap' const stack: GitHubPRStack = { diff --git a/src/renderer/src/components/right-sidebar/GitHubPRStackMap.tsx b/src/renderer/src/components/right-sidebar/GitHubPRStackMap.tsx index e0187b37709..1bbaf2b019b 100644 --- a/src/renderer/src/components/right-sidebar/GitHubPRStackMap.tsx +++ b/src/renderer/src/components/right-sidebar/GitHubPRStackMap.tsx @@ -1,6 +1,9 @@ import { useMemo, useState } from 'react' import { ChevronDown, GitBranch, GitPullRequest } from 'lucide-react' -import type { GitHubPRStack, GitHubPRStackEntry } from '../../../../shared/types' +import type { + GitHubPRStack, + GitHubPRStackEntry +} from '../../../../shared/github/pull-request-types' import { cn } from '@/lib/utils' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx index cace5ee7d98..a98ea57ab7a 100644 --- a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx +++ b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx @@ -12,7 +12,9 @@ import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' -import type { PRInfo, Repo, Worktree } from '../../../../shared/types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { resolveGitHubPRMergeMethods } from '../../../../shared/github/pull-request-merge-methods' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' import { presentGitLabMRMergeState } from './gitlab-mr-merge-state' diff --git a/src/renderer/src/components/right-sidebar/SearchResultItems.test.tsx b/src/renderer/src/components/right-sidebar/SearchResultItems.test.tsx index 1364bdc87d0..18599c352c3 100644 --- a/src/renderer/src/components/right-sidebar/SearchResultItems.test.tsx +++ b/src/renderer/src/components/right-sidebar/SearchResultItems.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Button } from '@/components/ui/button' import { FileResultRow } from './SearchResultItems' -import type { SearchFileResult } from '../../../../shared/types' +import type { SearchFileResult } from '../../../../shared/code-search-types' type ReactElementLike = { type: unknown diff --git a/src/renderer/src/components/right-sidebar/SearchResultItems.tsx b/src/renderer/src/components/right-sidebar/SearchResultItems.tsx index a7499929ea9..2dc3d1e2f52 100644 --- a/src/renderer/src/components/right-sidebar/SearchResultItems.tsx +++ b/src/renderer/src/components/right-sidebar/SearchResultItems.tsx @@ -12,7 +12,7 @@ import { ContextMenuItem } from '@/components/ui/context-menu' import { normalizeSearchFileMatchCount } from '../../../../shared/search-match-count' -import type { SearchFileResult, SearchMatch } from '../../../../shared/types' +import type { SearchFileResult, SearchMatch } from '../../../../shared/code-search-types' import { translate } from '@/i18n/i18n' // ─── Toggle Button ──────────────────────────────────────── diff --git a/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx b/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx index 952e1a0d99b..b1fc60b7d5e 100644 --- a/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx +++ b/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx @@ -1,6 +1,10 @@ import React from 'react' import { useVirtualizer } from '@tanstack/react-virtual' -import type { SearchFileResult, SearchMatch, SearchResult } from '../../../../shared/types' +import type { + SearchFileResult, + SearchMatch, + SearchResult +} from '../../../../shared/code-search-types' import type { SearchRow } from './search-rows' import { FileResultRow, MatchResultRow } from './SearchResultItems' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx index 9f2529973dc..02f6ce6f027 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' -import type { GitBranchCompareSummary } from '../../../../shared/types' +import type { GitBranchCompareSummary } from '../../../../shared/git-diff-compare-types' import { clearBranchLineTotalRequestGateForTests, getBranchLineTotalMergeBase diff --git a/src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts index f00bbe9d78c..e740362f60d 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts +++ b/src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts @@ -12,7 +12,8 @@ import { shouldRefreshBranchCompareForStatusHead, shouldShowCompareSummary } from './SourceControl' -import type { GitBranchCompareSummary, GitUpstreamStatus } from '../../../../shared/types' +import type { GitBranchCompareSummary } from '../../../../shared/git-diff-compare-types' +import type { GitUpstreamStatus } from '../../../../shared/git-status-types' type ReactElementLike = { type: unknown diff --git a/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx index d3125ee86c7..c1dc570d558 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import SourceControl from './SourceControl' const mocks = vi.hoisted(() => { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx index 13dc2479894..0afa6769ea1 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx @@ -6,9 +6,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { GitBranchChangeEntry, - GitBranchCompareSummary, - GitStatusEntry -} from '../../../../shared/types' + GitBranchCompareSummary +} from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import SourceControl from './SourceControl' const mocks = vi.hoisted(() => { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index bbfe20317f5..1bab1037bde 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -201,17 +201,19 @@ import { isBehindOnlyUpstream, shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status' +import type { DiffComment } from '../../../../shared/diff-comment-types' import type { - DiffComment, GitBranchChangeEntry, - GitBranchCompareSummary, + GitBranchCompareSummary +} from '../../../../shared/git-diff-compare-types' +import type { GitConflictOperation, - GitPushTarget, GitStatusEntry, - GitUpstreamStatus, - SourceControlViewMode, - TuiAgent -} from '../../../../shared/types' + GitUpstreamStatus +} from '../../../../shared/git-status-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { SourceControlViewMode } from '../../../../shared/ui-chrome-types' +import type { GitPushTarget } from '../../../../shared/worktree/types' import type { HostedReviewCreationEligibility, HostedReviewInfo, diff --git a/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx index 5ff61b17d43..2625ab54441 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import SourceControl from './SourceControl' import { SOURCE_CONTROL_FILE_ROW_HEIGHT_PX, diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx index 2b87618bd5e..610b6d74b20 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx @@ -6,7 +6,9 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions' -import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { TuiAgent } from '../../../../shared/tui-agent' const mocks = vi.hoisted(() => ({ ensureDetectedAgents: vi.fn(), diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx index 3a431ea03e0..e6532d3eba3 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx @@ -10,7 +10,7 @@ import type { SourceControlActionRecipe, SourceControlLaunchActionId } from '../../../../shared/source-control-ai-actions' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { LaunchSource } from '../../../../shared/telemetry-events' import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' import { SourceControlAgentActionDialogForm } from './SourceControlAgentActionDialogForm' diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx index 6b923a0f58a..8e506ec9ca4 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx @@ -2,7 +2,8 @@ import React, { type ReactNode } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { SourceControlAgentActionDialogForm } from './SourceControlAgentActionDialogForm' -import type { GlobalSettings, Repo } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' vi.mock('@/components/agent/AgentCombobox', () => ({ default: ({ value }: { value: string | null }) => diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx index 8f9fff3e657..596a1c19f0f 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx @@ -24,7 +24,9 @@ import type { AgentCatalogEntry } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' import type { SourceControlLaunchActionId } from '../../../../shared/source-control-ai-actions' import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' -import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips' import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match' import type { SourceControlAgentScopeNote } from './source-control-agent-action-dialog-result' diff --git a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx index 56c3e52f8b9..f48431b1ef2 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx @@ -12,7 +12,8 @@ import { type ResolvedSourceControlAiGenerationParams } from '../../../../shared/source-control-ai' import type { SourceControlTextActionId } from '../../../../shared/source-control-ai-actions' -import type { GlobalSettings, Repo } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' import { buildBranchNamePrompt } from '../../../../shared/branch-name-from-work' import { buildCommitMessagePrompt } from '../../../../shared/commit-message-generation' diff --git a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx index dc59ac3da85..56402b58850 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx @@ -22,7 +22,9 @@ import type { ResolvedSourceControlAiGenerationParams } from '../../../../shared import { formatLinkedIssueTemplateValue } from '../../../../shared/source-control-ai-action-variables' import type { SourceControlTextActionId } from '../../../../shared/source-control-ai-actions' import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' -import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { toast } from 'sonner' import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips' import { sourceControlTextGenerationDefaultsMatchTarget } from './source-control-text-generation-defaults' diff --git a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationParams.ts b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationParams.ts index eb1a5a98fb6..e3c75f3175e 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationParams.ts +++ b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationParams.ts @@ -4,7 +4,7 @@ import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec' import type { ResolvedSourceControlAiGenerationParams } from '../../../../shared/source-control-ai' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' export type CommitMessageGenerationAgentChoice = | ResolvedSourceControlAiGenerationParams['agentId'] diff --git a/src/renderer/src/components/right-sidebar/active-checks-status.test.ts b/src/renderer/src/components/right-sidebar/active-checks-status.test.ts index 7ee80e0e8c5..c81c059a78c 100644 --- a/src/renderer/src/components/right-sidebar/active-checks-status.test.ts +++ b/src/renderer/src/components/right-sidebar/active-checks-status.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { getActiveChecksStatus } from './active-checks-status' import type { AppState } from '../../store/types' -import type { PRInfo } from '../../../../shared/types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' function makePR(status: PRInfo['checksStatus']): PRInfo { return { diff --git a/src/renderer/src/components/right-sidebar/active-checks-status.ts b/src/renderer/src/components/right-sidebar/active-checks-status.ts index a7af63812ca..b2a0a540a5b 100644 --- a/src/renderer/src/components/right-sidebar/active-checks-status.ts +++ b/src/renderer/src/components/right-sidebar/active-checks-status.ts @@ -1,6 +1,6 @@ import type { AppState } from '../../store/types' import { getRepoMapFromState, getWorktreeMapFromState } from '../../store/selectors' -import type { CheckStatus } from '../../../../shared/types' +import type { CheckStatus } from '../../../../shared/github/pull-request-types' import { getGitHubPRCacheKey } from '../../store/slices/github-cache-key' import { getHostedReviewCacheKey } from '../../store/slices/hosted-review-cache-identity' diff --git a/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx b/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx index dd67762bbb7..4bab9fd907e 100644 --- a/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx +++ b/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx @@ -1,7 +1,7 @@ import React from 'react' import { MoreHorizontal } from 'lucide-react' import type { ActiveRightSidebarTab } from '@/store/slices/editor' -import type { CheckStatus } from '../../../../shared/types' +import type { CheckStatus } from '../../../../shared/github/pull-request-types' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { RIGHT_SIDEBAR_HEADER_NO_DRAG_CLASS_NAME } from './right-sidebar-titlebar-drag-regions' diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts index 013684c7870..7cb9c647af3 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts @@ -3,7 +3,10 @@ import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' import type { AgentStatusState } from '../../../../shared/agent-status-types' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../../../shared/terminal-tab-types' export type AiVaultOriginalPaneTarget = { paneKey: string diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts index bf11a0af23b..237da2fecc8 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { deriveAiVaultScopeSessionPaths, deriveAiVaultWorkspaceScopePaths diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts index bcd3391a4fc..cfdef42f3e0 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts @@ -3,7 +3,8 @@ import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' -import type { ProjectHostSetup, Worktree } from '../../../../shared/types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Worktree } from '../../../../shared/worktree/types' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' export function deriveAiVaultWorkspaceScopePaths( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-identity.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-identity.ts index f2101f75011..a4effa09096 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-identity.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-identity.ts @@ -1,5 +1,5 @@ import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' -import { areValuesEqual } from '@/store/slices/repo-identity-reconcile' +import { structuralValuesEqual } from '../../../../shared/structural-value-equality' import { reuseEqualCatalogRows } from '@/store/slices/worktree-catalog-reconciliation' // One instance is shared by every mounted hook, so it is frozen: an in-place @@ -24,7 +24,9 @@ export function reuseAiVaultListResult( return incoming } const sessions = reuseEqualCatalogRows(current.sessions, incoming.sessions) - const issues = areValuesEqual(current.issues, incoming.issues) ? current.issues : incoming.issues + const issues = structuralValuesEqual(current.issues, incoming.issues) + ? current.issues + : incoming.issues if ( sessions === current.sessions && issues === current.issues && diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 43504842aaa..4ede3cafe71 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -18,7 +18,7 @@ import { } from '@/lib/ai-vault-resume-target' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' import { prepareAiVaultSessionForResume } from '@/lib/ai-vault-session-resume-preparation' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { translate } from '@/i18n/i18n' import { agentLabel } from './ai-vault-session-filters' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index 6c46a144f86..b3c5d0aa841 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { Project, ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { buildAiVaultProjectContext, buildAiVaultSessionProjectById, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index 80ed7f3ced7..f9ad52dd198 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -6,7 +6,9 @@ import { } from '../../../../shared/execution-host' import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { createNormalizedPathInsideOrEqualMatcher, normalizeRuntimePathForComparison diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts index 97803382577..583e668cd2a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { resolveAiVaultSessionLaunchTarget } from './ai-vault-session-launch-actions' diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index 9465144b705..d021fc66b1f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -1,4 +1,5 @@ -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { canResumeAiVaultSessionOnTarget, getAiVaultResumeWorkspaceExecutionHostId, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx index 90ea7cd900c..cc71cc9b55a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx @@ -2,7 +2,8 @@ import { renderHook } from '@testing-library/react' import { describe, expect, it } from 'vitest' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { resolveAiVaultSessionWorktreeDisplay, useAiVaultSessionWorktreeMap, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts index 7f5f0d07b0c..5ceeb12523f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { aiVaultWorktreeCompactPath, aiVaultWorktreeJumpTooltip, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts index a82a68c8f1e..15e489d1b44 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts @@ -13,7 +13,8 @@ import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { aiVaultWorktreeCompactPath } from './ai-vault-session-worktree-affordances' export { diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index 7093101eeee..10d17bd5dc8 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -1,6 +1,6 @@ import { planSourceControlAgentActionLaunch } from '@/lib/source-control-agent-action-plan' import { useAppStore } from '@/store' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' diff --git a/src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx b/src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx index 8555d53b345..4fac43a4ea1 100644 --- a/src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' -import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' import { ChecksList } from './checks-panel-content' globalThis.IS_REACT_ACT_ENVIRONMENT = true diff --git a/src/renderer/src/components/right-sidebar/checks-panel-async-result-key.ts b/src/renderer/src/components/right-sidebar/checks-panel-async-result-key.ts index fc95acb9a7c..7cdc9326a36 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-async-result-key.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-async-result-key.ts @@ -1,4 +1,4 @@ -import type { GitHubOwnerRepo } from '../../../../shared/types' +import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' function normalizedPRRepoIdentity(prRepo?: GitHubOwnerRepo | null): string { diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx index 69fd873c89b..78f6eac58f7 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx @@ -2,7 +2,9 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' -import type { PRCheckDetail, PRComment, PRInfo } from '../../../../shared/types' +import type { PRCheckDetail } from '../../../../shared/github/check-types' +import type { PRComment } from '../../../../shared/github/comment-types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' import { buildMergeabilityRecalculationCommands, CheckJobLogTail, diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index ad0824e424f..222fb8f0a0a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -88,16 +88,14 @@ import { type PRCommentPresentationClasses } from './pr-comment-presentation' import type { GitLabProjectRef } from '../../../../shared/gitlab-types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' +import type { GitHubReactionContent, PRComment } from '../../../../shared/github/comment-types' import type { - PRInfo, - PRCheckDetail, - PRCheckRunDetails, - PRComment, - GitHubReactionContent, GitHubRepositoryIdentity, PRConflictSummary, + PRInfo, PRMergeableState -} from '../../../../shared/types' +} from '../../../../shared/github/pull-request-types' import { useCheckDetailsResize } from './check-details-resize' import { RightPanelCommentComposer, diff --git a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts index 78063f3b211..22078dfda37 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts @@ -1,4 +1,5 @@ -import type { GitPushTarget, GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types' +import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/git-status-types' +import type { GitPushTarget } from '../../../../shared/worktree/types' export type ChecksPanelGitStatusContextInput = { repoId: string | null | undefined diff --git a/src/renderer/src/components/right-sidebar/checks-panel-polling.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-polling.test.ts index 55e453ded19..c4a8568526a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-polling.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-polling.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { PRCheckDetail } from '../../../../shared/types' +import type { PRCheckDetail } from '../../../../shared/github/check-types' import { CHECKS_PANEL_BASE_POLL_INTERVAL_MS, CHECKS_PANEL_MAX_POLL_INTERVAL_MS, diff --git a/src/renderer/src/components/right-sidebar/checks-panel-polling.ts b/src/renderer/src/components/right-sidebar/checks-panel-polling.ts index 42573079584..89d234a745e 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-polling.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-polling.ts @@ -1,4 +1,4 @@ -import type { PRCheckDetail } from '../../../../shared/types' +import type { PRCheckDetail } from '../../../../shared/github/check-types' export const CHECKS_PANEL_BASE_POLL_INTERVAL_MS = 30_000 export const CHECKS_PANEL_MAX_POLL_INTERVAL_MS = 120_000 diff --git a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.ts b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.ts index 2ade777964d..3558b4bad06 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.ts @@ -1,5 +1,5 @@ import type { HostedReviewProvider } from '../../../../shared/hosted-review' -import type { GitHubPRRefreshReason } from '../../../../shared/types' +import type { GitHubPRRefreshReason } from '../../../../shared/github/pull-request-refresh-types' type ChecksPanelPRRefreshRequestInput = { cachedHasPR: boolean | null diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts index b4486bec769..33b1d0f9168 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts @@ -1,4 +1,4 @@ -import type { GitHubPRRefreshSkippedReason } from '../../../../shared/types' +import type { GitHubPRRefreshSkippedReason } from '../../../../shared/github/pull-request-refresh-types' import { translate } from '@/i18n/i18n' import { getGitHubUnavailableEmptyStateCopy } from './github-refresh-error-copy' import { diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts index d0534124397..7c4260463fa 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts @@ -3,7 +3,7 @@ import type { HostedReviewCreationEligibility, HostedReviewLookupOutcome } from '../../../../shared/hosted-review' -import type { PRRefreshErrorType } from '../../../../shared/types' +import type { PRRefreshErrorType } from '../../../../shared/github/pull-request-refresh-types' import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs' import type { ChecksPanelReviewLookup } from './checks-panel-review-lookup-authority' // Single source of truth for the hard-refresh error set, shared with the diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-state-model.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-state-model.ts index 4b43615ebd9..647f9303190 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-review-state-model.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-state-model.ts @@ -1,5 +1,8 @@ import type { HostedReviewCreationBlockedReason } from '../../../../shared/hosted-review' -import type { GitHubPRRefreshSkippedReason, PRRefreshErrorType } from '../../../../shared/types' +import type { + GitHubPRRefreshSkippedReason, + PRRefreshErrorType +} from '../../../../shared/github/pull-request-refresh-types' import type { ChecksPanelReviewLookup } from './checks-panel-review-lookup-authority' /** diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-review.test.ts index decd6585c8a..6f741b5889f 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-review.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-review.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { gitHubPRToChecksPanelReview, selectChecksPanelReview } from './checks-panel-review' -import type { PRInfo } from '../../../../shared/types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' function makePR(overrides: Partial = {}): PRInfo { diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review.ts b/src/renderer/src/components/right-sidebar/checks-panel-review.ts index a51ad79079e..17f34421174 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-review.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-review.ts @@ -1,4 +1,4 @@ -import type { PRInfo } from '../../../../shared/types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' diff --git a/src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.test.ts index fe817238eb9..5ca81c860da 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { resolveChecksPanelTerminalPtyId, resolveChecksPanelWorktreeFromTerminalCwd diff --git a/src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.ts b/src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.ts index ab92bbd1469..6aedcdab0dd 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.ts @@ -5,7 +5,8 @@ import { isRuntimePathAbsolute, normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' -import type { TerminalLayoutSnapshot, Worktree } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' type TerminalPtyContext = { activeTabId: string | null diff --git a/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts b/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts index e808af6da27..0faedae8114 100644 --- a/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts +++ b/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts @@ -8,7 +8,7 @@ import { runDiscardAllForArea, type DiscardAllArea } from './discard-all-sequence' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' function entry(partial: Partial & { path: string }): GitStatusEntry { return { diff --git a/src/renderer/src/components/right-sidebar/discard-all-sequence.ts b/src/renderer/src/components/right-sidebar/discard-all-sequence.ts index c52854fcd91..24f0093c6e1 100644 --- a/src/renderer/src/components/right-sidebar/discard-all-sequence.ts +++ b/src/renderer/src/components/right-sidebar/discard-all-sequence.ts @@ -1,4 +1,4 @@ -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' export type DiscardAllArea = 'staged' | 'unstaged' | 'untracked' diff --git a/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts index 3f36fb04672..86ff9cef435 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import type { TreeNode } from './file-explorer-types' import { buildAddProjectFromFolderModalData, diff --git a/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.ts b/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.ts index b8af63bcb6c..162dcac7575 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.ts @@ -1,6 +1,6 @@ import { isFolderRepo } from '../../../../shared/repo-kind' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import type { TreeNode } from './file-explorer-types' export type AddProjectFromFolderModalData = { diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts index eb8f7142846..d904c910e0b 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts @@ -1,5 +1,5 @@ import { joinPath, normalizeRelativePath } from '@/lib/path' -import type { DirEntry } from '../../../../shared/types' +import type { DirEntry } from '../../../../shared/filesystem-entry-types' import { sortDirEntries } from '../../../../shared/file-name-sort' import { readRuntimeDirectory } from '@/runtime/runtime-file-client' import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types' diff --git a/src/renderer/src/components/right-sidebar/file-explorer-entries.ts b/src/renderer/src/components/right-sidebar/file-explorer-entries.ts index dfb8591fd2f..4e5b6e7b423 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-entries.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-entries.ts @@ -1,4 +1,4 @@ -import type { DirEntry } from '../../../../shared/types' +import type { DirEntry } from '../../../../shared/filesystem-entry-types' export function shouldIncludeFileExplorerEntry(entry: DirEntry): boolean { return entry.name !== '.git' && entry.name !== 'node_modules' diff --git a/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts index b3fdc583522..cbb6b6bb25f 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { SetStateAction } from 'react' -import type { DirEntry } from '../../../../shared/types' +import type { DirEntry } from '../../../../shared/filesystem-entry-types' import type { DirCache } from './file-explorer-types' import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' import { refreshFileExplorerExpandedDirs } from './file-explorer-expanded-dirs-refresh' diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts index 0a6c2606b01..d5bb840feeb 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { useAppStore } from '@/store' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' diff --git a/src/renderer/src/components/right-sidebar/file-explorer-reset.ts b/src/renderer/src/components/right-sidebar/file-explorer-reset.ts index 9ed03fecda9..83b17724310 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-reset.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-reset.ts @@ -1,4 +1,4 @@ -import type { RightSidebarExplorerView } from '../../../../shared/types' +import type { RightSidebarExplorerView } from '../../../../shared/ui-chrome-types' export function getVisibleFileExplorerWorktreePath({ explorerView, diff --git a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts index eddc593d31f..a98a38d4edc 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { useAppStore } from '@/store' diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts index 259d3da312c..d7b1380aefa 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { FsChangeEvent } from '../../../../shared/types' +import type { FsChangeEvent } from '../../../../shared/filesystem-entry-types' import type { DirCache } from './file-explorer-types' import { processFileExplorerFsPayload } from './file-explorer-watch-reconcile' diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts index 538657edb76..1d3905911ef 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts @@ -1,5 +1,5 @@ import type { Dispatch, SetStateAction } from 'react' -import type { FsChangedPayload } from '../../../../shared/types' +import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types' import type { DirCache } from './file-explorer-types' import { isPathInsideOrEqual, diff --git a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts index ed765b0886d..80bf23187a6 100644 --- a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts +++ b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { - FolderWorkspace, - Worktree, - WorktreeLineage, - WorkspaceLineage -} from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host' import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees' diff --git a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts index ec65782b488..577f21aa1f1 100644 --- a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts +++ b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts @@ -1,10 +1,7 @@ import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' -import type { - FolderWorkspace, - Worktree, - WorktreeLineage, - WorkspaceLineage -} from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order' import { getProjectedWorktreeLineageChildrenByParentId } from '../sidebar/worktree-lineage-projection' diff --git a/src/renderer/src/components/right-sidebar/fork-push-target-label.test.ts b/src/renderer/src/components/right-sidebar/fork-push-target-label.test.ts index 17c0992b3e3..f2e4ccf0d60 100644 --- a/src/renderer/src/components/right-sidebar/fork-push-target-label.test.ts +++ b/src/renderer/src/components/right-sidebar/fork-push-target-label.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GitPushTarget } from '../../../../shared/types' +import type { GitPushTarget } from '../../../../shared/worktree/types' import { describeForkPushTarget } from './fork-push-target-label' function target(overrides: Partial): GitPushTarget { diff --git a/src/renderer/src/components/right-sidebar/fork-push-target-label.ts b/src/renderer/src/components/right-sidebar/fork-push-target-label.ts index 7967dcf152a..daff8d89b3c 100644 --- a/src/renderer/src/components/right-sidebar/fork-push-target-label.ts +++ b/src/renderer/src/components/right-sidebar/fork-push-target-label.ts @@ -1,4 +1,4 @@ -import type { GitPushTarget } from '../../../../shared/types' +import type { GitPushTarget } from '../../../../shared/worktree/types' // Why: a fork-PR worktree pushes to a contributor's fork, not origin. Render // "owner:branch" from the fork remote URL when available so the maintainer can diff --git a/src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts index 034cc9e570c..8ab2492f4aa 100644 --- a/src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts @@ -7,11 +7,11 @@ import { normalizeRuntimePathForComparison, relativePathInsideRoot } from '../../../../shared/cross-platform-path' +import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types' import type { ActiveRightSidebarTab, - FsChangedPayload, RightSidebarExplorerView -} from '../../../../shared/types' +} from '../../../../shared/ui-chrome-types' import type { OpenFile } from '@/store/slices/editor' import { ORCA_WORKTREE_FILE_CHANGE_EVENT, diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts index 3437d8d227a..4727ebd173b 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts @@ -9,7 +9,7 @@ import { refreshGitStatusForWorktreeStrict, type GitStatusRefreshDeps } from './git-status-refresh' -import type { GitStatusResult } from '../../../../shared/types' +import type { GitStatusResult } from '../../../../shared/git-status-types' const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c' diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts index 2a3f8ba564e..dd03ae54f87 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts @@ -5,7 +5,7 @@ import { refreshGitStatusForWorktreeStrict, type GitStatusRefreshDeps } from './git-status-refresh' -import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/types' +import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/git-status-types' function makeDeps(): GitStatusRefreshDeps { return { diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 50f2cf068c7..ec654b2c175 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -6,12 +6,9 @@ import { invalidateAutomaticPushTargetUpstreamStatusCache, storeCachedAutomaticPushTargetUpstreamStatus } from './push-target-upstream-refresh-cache' -import type { - GitPushTarget, - GitStatusResult, - GitUpstreamStatus, - GlobalSettings -} from '../../../../shared/types' +import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/git-status-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { GitPushTarget } from '../../../../shared/worktree/types' import { beginAutomaticUpstreamRefresh, beginStrictUpstreamRefresh, diff --git a/src/renderer/src/components/right-sidebar/github-pr-stack-confirmation.ts b/src/renderer/src/components/right-sidebar/github-pr-stack-confirmation.ts index e219f68952d..e3e1e84f1a9 100644 --- a/src/renderer/src/components/right-sidebar/github-pr-stack-confirmation.ts +++ b/src/renderer/src/components/right-sidebar/github-pr-stack-confirmation.ts @@ -1,6 +1,9 @@ import type { ConfirmationDialogOptions } from '@/components/confirmation-dialog-context' import { translate } from '@/i18n/i18n' -import type { GitHubPRMergeMethod, GitHubPRStack } from '../../../../shared/types' +import type { + GitHubPRMergeMethod, + GitHubPRStack +} from '../../../../shared/github/pull-request-types' import { getGitHubPRStackMergeScope } from './github-pr-stack-merge' export function buildGitHubPRStackMergeConfirmation({ diff --git a/src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts b/src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts index be889a5eb74..44145a61bc0 100644 --- a/src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts +++ b/src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GitHubPRStack } from '../../../../shared/types' +import type { GitHubPRStack } from '../../../../shared/github/pull-request-types' import { getGitHubPRStackMergeBlocker, getGitHubPRStackMergeScope, diff --git a/src/renderer/src/components/right-sidebar/github-pr-stack-merge.ts b/src/renderer/src/components/right-sidebar/github-pr-stack-merge.ts index 8eacfb0380b..5d127d5f736 100644 --- a/src/renderer/src/components/right-sidebar/github-pr-stack-merge.ts +++ b/src/renderer/src/components/right-sidebar/github-pr-stack-merge.ts @@ -1,4 +1,7 @@ -import type { GitHubPRStack, GitHubPRStackEntry } from '../../../../shared/types' +import type { + GitHubPRStack, + GitHubPRStackEntry +} from '../../../../shared/github/pull-request-types' import { translate } from '@/i18n/i18n' export type GitHubPRStackMergeScope = { diff --git a/src/renderer/src/components/right-sidebar/github-refresh-error-copy.ts b/src/renderer/src/components/right-sidebar/github-refresh-error-copy.ts index 298c892e5b0..b22ed9ce23c 100644 --- a/src/renderer/src/components/right-sidebar/github-refresh-error-copy.ts +++ b/src/renderer/src/components/right-sidebar/github-refresh-error-copy.ts @@ -1,4 +1,4 @@ -import type { PRRefreshUpstreamErrorType } from '../../../../shared/types' +import type { PRRefreshUpstreamErrorType } from '../../../../shared/github/pull-request-refresh-types' import { translate } from '@/i18n/i18n' export type ChecksPanelErrorCopy = { title: string; description: string } diff --git a/src/renderer/src/components/right-sidebar/hosted-review-github-actions.ts b/src/renderer/src/components/right-sidebar/hosted-review-github-actions.ts index 2481e5b4293..8940f8adf5b 100644 --- a/src/renderer/src/components/right-sidebar/hosted-review-github-actions.ts +++ b/src/renderer/src/components/right-sidebar/hosted-review-github-actions.ts @@ -1,4 +1,5 @@ -import type { GitHubPRMergeMethod, PRInfo, Repo } from '../../../../shared/types' +import type { GitHubPRMergeMethod, PRInfo } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-github-pr-cache.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-github-pr-cache.ts index 8487c6619ef..ac8d9223fc4 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-github-pr-cache.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-github-pr-cache.ts @@ -1,5 +1,7 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { PRInfo, Repo, Worktree } from '../../../../shared/types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-hosted-review-cache.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-hosted-review-cache.ts index e6db09a2736..854af41074a 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-hosted-review-cache.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-hosted-review-cache.ts @@ -1,5 +1,6 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { PRInfo, Worktree } from '../../../../shared/types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getWorktreeCardPrDisplay, isCachedMergedBranchPRCurrentForWorktree diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts index 75c288da7fd..025817b1160 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { buildParentPrChecksProjection } from './parent-pr-checks-rows' import { createParentPrChecksProjectionSelector } from './parent-pr-checks-projection-selector' diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts index 9cf77aa2565..df7f6ff4e2d 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { getParentPrChecksRefreshCandidates, diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts index 2404950b422..edde3fcd750 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts @@ -1,9 +1,7 @@ -import type { - GitHubRepositoryIdentity, - PRCheckDetail, - Repo, - Worktree -} from '../../../../shared/types' +import type { PRCheckDetail } from '../../../../shared/github/check-types' +import type { GitHubRepositoryIdentity } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { isFolderRepo } from '../../../../shared/repo-kind' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-row-status.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-row-status.ts index 4f48f655cf6..478ccec9a49 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-row-status.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-row-status.ts @@ -1,4 +1,4 @@ -import type { CheckStatus } from '../../../../shared/types' +import type { CheckStatus } from '../../../../shared/github/pull-request-types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { translate } from '@/i18n/i18n' import type { diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-row-types.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-row-types.ts index 18fc4459da6..2904b5565fc 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-row-types.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-row-types.ts @@ -1,11 +1,11 @@ +import type { PRCheckDetail } from '../../../../shared/github/check-types' import type { CheckStatus, GitHubRepositoryIdentity, - PRCheckDetail, - PRInfo, - Repo, - Worktree -} from '../../../../shared/types' + PRInfo +} from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { AppState } from '@/store' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts index c9e2ebc18cc..4a071f6638e 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import type { PRCheckDetail, PRInfo, Repo, Worktree } from '../../../../shared/types' +import type { PRCheckDetail } from '../../../../shared/github/check-types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts index 72f4ab921bc..10d0367f620 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts @@ -1,4 +1,6 @@ -import type { PRCheckDetail, Repo, Worktree } from '../../../../shared/types' +import type { PRCheckDetail } from '../../../../shared/github/check-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' import { isFolderRepo } from '../../../../shared/repo-kind' diff --git a/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts b/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts index 757c5abc303..5122a653ef9 100644 --- a/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts +++ b/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts @@ -1,4 +1,4 @@ -import type { PRComment } from '../../../../shared/types' +import type { PRComment } from '../../../../shared/github/comment-types' /** Posted when a selected review comment is sent to AI. */ export const PR_COMMENT_AI_FIXING_REPLY = 'Fixing. Will be in the next commit' diff --git a/src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.test.ts b/src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.test.ts index 5cbd180ed3c..2ee6834ecb6 100644 --- a/src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.test.ts +++ b/src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { PRComment } from '../../../../shared/types' +import type { PRComment } from '../../../../shared/github/comment-types' import { markPRCommentThreadResolved, restorePRCommentThreadSnapshot diff --git a/src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.ts b/src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.ts index 2af2a42c3d0..eb5b947f77a 100644 --- a/src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.ts +++ b/src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.ts @@ -1,4 +1,4 @@ -import type { PRComment } from '../../../../shared/types' +import type { PRComment } from '../../../../shared/github/comment-types' export function markPRCommentThreadResolved( comments: PRComment[], diff --git a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts index 3ae9663f314..a0b5a0b19e2 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { PRComment } from '../../../../shared/types' +import type { PRComment } from '../../../../shared/github/comment-types' import type { PRCommentGroup } from '../../../../shared/pr-comment-groups' import { acknowledgePRCommentsAfterAiLaunch, diff --git a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts index 6c427f84acc..8f21d3c7920 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts @@ -1,4 +1,5 @@ -import type { PRComment, PRInfo } from '../../../../shared/types' +import type { PRComment } from '../../../../shared/github/comment-types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { getPRCommentGroupRoot, type PRCommentGroup } from '../../../../shared/pr-comment-groups' import { isResolvablePRCommentGroup } from '../pr-comments-resolution-prompt' diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx index c6f0130d1c1..94a2c20b45e 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx @@ -65,7 +65,7 @@ vi.mock('@/components/ui/dropdown-menu', () => { DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} } }) -import type { PRComment } from '../../../../shared/types' +import type { PRComment } from '../../../../shared/github/comment-types' import type { PRCommentGroup } from '../../../../shared/pr-comment-groups' import { MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS, diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts index 6245f6ff131..b82c2b4c277 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts @@ -5,7 +5,7 @@ import { type PRCommentGroup } from '../../../../shared/pr-comment-groups' import { isPRCommentGroupQueueableForAI } from '@/lib/pr-comment-action-state' -import type { PRComment } from '../../../../shared/types' +import type { PRComment } from '../../../../shared/github/comment-types' export type PRCommentsListSelection = { isSelectingForAI: boolean diff --git a/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts b/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts index 6ec8514351c..50b76dd829b 100644 --- a/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts +++ b/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts @@ -10,7 +10,8 @@ import { invalidateAutomaticPushTargetUpstreamStatusCache, storeCachedAutomaticPushTargetUpstreamStatus } from './push-target-upstream-refresh-cache' -import type { GitPushTarget, GitStatusResult, GitUpstreamStatus } from '../../../../shared/types' +import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/git-status-types' +import type { GitPushTarget } from '../../../../shared/worktree/types' const pushTarget: GitPushTarget = { remoteName: 'fork', diff --git a/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts b/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts index bb79bd2a2c2..e8f0a8c39fb 100644 --- a/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts +++ b/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts @@ -1,9 +1,6 @@ -import type { - GitPushTarget, - GitStatusResult, - GitUpstreamStatus, - GlobalSettings -} from '../../../../shared/types' +import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/git-status-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { GitPushTarget } from '../../../../shared/worktree/types' const AUTOMATIC_PUSH_TARGET_UPSTREAM_REFRESH_TTL_MS = 60_000 const MAX_AUTOMATIC_PUSH_TARGET_UPSTREAM_CACHE_ENTRIES = 1024 diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index 278117bb68b..b9b86e93228 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -1,6 +1,8 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' -import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { LaunchSource } from '../../../../shared/telemetry-events' import type { SourceControlActionRecipe, diff --git a/src/renderer/src/components/right-sidebar/search-match-open.ts b/src/renderer/src/components/right-sidebar/search-match-open.ts index 0e2bf3b74ac..3e870396428 100644 --- a/src/renderer/src/components/right-sidebar/search-match-open.ts +++ b/src/renderer/src/components/right-sidebar/search-match-open.ts @@ -1,6 +1,6 @@ import { detectLanguage } from '@/lib/language-detect' import type { FileSearchResultOwner } from '@/lib/file-search-result-owner' -import type { SearchFileResult, SearchMatch } from '../../../../shared/types' +import type { SearchFileResult, SearchMatch } from '../../../../shared/code-search-types' export function cancelRevealFrame(frameRef: React.RefObject): void { if (frameRef.current !== null) { diff --git a/src/renderer/src/components/right-sidebar/search-rows.ts b/src/renderer/src/components/right-sidebar/search-rows.ts index e9697febea3..031cca36710 100644 --- a/src/renderer/src/components/right-sidebar/search-rows.ts +++ b/src/renderer/src/components/right-sidebar/search-rows.ts @@ -1,4 +1,8 @@ -import type { SearchFileResult, SearchMatch, SearchResult } from '../../../../shared/types' +import type { + SearchFileResult, + SearchMatch, + SearchResult +} from '../../../../shared/code-search-types' export type SearchRow = | { diff --git a/src/renderer/src/components/right-sidebar/source-control-action-error.ts b/src/renderer/src/components/right-sidebar/source-control-action-error.ts index 3e54a65d9a0..2e3ab396df1 100644 --- a/src/renderer/src/components/right-sidebar/source-control-action-error.ts +++ b/src/renderer/src/components/right-sidebar/source-control-action-error.ts @@ -1,5 +1,5 @@ import type { RemoteOpKind } from './source-control-primary-action' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' export type AbortActionErrorKind = 'abort_merge' | 'abort_rebase' export type SourceControlActionErrorKind = RemoteOpKind | AbortActionErrorKind diff --git a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts index 3c806438ceb..398ce251cd9 100644 --- a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' -import type { GlobalSettings, Repo } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match' function settings(): GlobalSettings { diff --git a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts index 2c1078e9a06..83055b58e77 100644 --- a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts +++ b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts @@ -10,7 +10,8 @@ import { type SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions' import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' -import type { GlobalSettings, Repo } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' type NormalizedSourceControlActionRecipe = { agentId: SourceControlActionRecipe['agentId'] | null diff --git a/src/renderer/src/components/right-sidebar/source-control-actions.ts b/src/renderer/src/components/right-sidebar/source-control-actions.ts index 3ae829cd710..db4efa16b82 100644 --- a/src/renderer/src/components/right-sidebar/source-control-actions.ts +++ b/src/renderer/src/components/right-sidebar/source-control-actions.ts @@ -1,4 +1,4 @@ -import type { GitStagingArea } from '../../../../shared/types' +import type { GitStagingArea } from '../../../../shared/git-status-types' export type SourceControlAction = 'discard' | 'stage' | 'unstage' diff --git a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts index 5609880477a..6078d17f4b3 100644 --- a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts +++ b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts @@ -1,7 +1,7 @@ import type { getAgentCatalog } from '@/lib/agent-catalog' import type { useAppStore } from '@/store' import type { useRepoById } from '@/store/selectors' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' export type SourceControlAgentScopeNote = { diff --git a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts index ac0448b70d2..a88cc65617d 100644 --- a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts +++ b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts @@ -1,6 +1,6 @@ import { getAgentCatalog } from '@/lib/agent-catalog' import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' import { translate } from '@/i18n/i18n' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' diff --git a/src/renderer/src/components/right-sidebar/source-control-ai-controller-types.ts b/src/renderer/src/components/right-sidebar/source-control-ai-controller-types.ts index a3c851be789..6a76186f851 100644 --- a/src/renderer/src/components/right-sidebar/source-control-ai-controller-types.ts +++ b/src/renderer/src/components/right-sidebar/source-control-ai-controller-types.ts @@ -1,10 +1,7 @@ import type { AppState } from '@/store' -import type { - GitConflictOperation, - GitStatusEntry, - GlobalSettings, - Repo -} from '../../../../shared/types' +import type { GitConflictOperation, GitStatusEntry } from '../../../../shared/git-status-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' export type SourceControlAiStoreSnapshot = Pick< AppState, diff --git a/src/renderer/src/components/right-sidebar/source-control-ai-settings-navigation.ts b/src/renderer/src/components/right-sidebar/source-control-ai-settings-navigation.ts index 259bd6f43c0..a5352c08795 100644 --- a/src/renderer/src/components/right-sidebar/source-control-ai-settings-navigation.ts +++ b/src/renderer/src/components/right-sidebar/source-control-ai-settings-navigation.ts @@ -1,6 +1,6 @@ import { getRepositorySourceControlAiSectionId } from '@/components/settings/repository-settings-targets' import type { AppState } from '@/store' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' export function openSourceControlAiSettingsTarget({ activeRepo, diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx index 9ccad4f3acc..3d3f6ce6549 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx @@ -2,7 +2,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' import { describe, expect, it, vi } from 'vitest' import { SourceControlBranchContextRow } from './source-control-branch-context-row' -import type { GitBranchCompareSummary } from '../../../../shared/types' +import type { GitBranchCompareSummary } from '../../../../shared/git-diff-compare-types' import type { GitBranchLineTotal } from '../../../../shared/git-status-types' vi.mock('@/components/ui/tooltip', () => ({ diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx index 42a531a10d3..f22348a6f3f 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx @@ -1,7 +1,7 @@ import React from 'react' import { ExternalLink, Loader2, RefreshCw } from 'lucide-react' -import type { GitBranchCompareSummary, GitUpstreamStatus } from '../../../../shared/types' -import type { GitBranchLineTotal } from '../../../../shared/git-status-types' +import type { GitBranchCompareSummary } from '../../../../shared/git-diff-compare-types' +import type { GitBranchLineTotal, GitUpstreamStatus } from '../../../../shared/git-status-types' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts b/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts index be3823e6c3f..25384eb6cd9 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts @@ -6,7 +6,7 @@ import { shouldShowSourceControlBranchContextChrome, shouldShowSourceControlBranchContextRow } from './source-control-branch-context-stats' -import type { GitBranchCompareSummary } from '../../../../shared/types' +import type { GitBranchCompareSummary } from '../../../../shared/git-diff-compare-types' const readySummary: GitBranchCompareSummary = { baseRef: 'origin/main', diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.ts b/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.ts index ed4b801897d..e724a51d366 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.ts +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.ts @@ -1,4 +1,5 @@ -import type { GitBranchCompareSummary, GitUpstreamStatus } from '../../../../shared/types' +import type { GitBranchCompareSummary } from '../../../../shared/git-diff-compare-types' +import type { GitUpstreamStatus } from '../../../../shared/git-status-types' import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts index e71afce388c..0a1dfd8607c 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts @@ -13,7 +13,7 @@ import { shouldAttemptCreateHostedReviewForIntent, shouldGenerateHostedReviewDetailsForIntent } from './source-control-create-pr-intent-flow' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' describe('source-control Create PR intent flow helpers', () => { it('matches async completions only to the original repo, worktree, path, branch, and base', () => { diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts index 20d8e002ba0..3a392535ef5 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts @@ -10,7 +10,7 @@ import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' -import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types' +import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/git-status-types' import { summarizeCommitFailure } from './commit-failure-summary' import { getStageAllPaths } from './discard-all-sequence' diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts index 48e28160af6..496924874f2 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts @@ -3,7 +3,7 @@ import { getDiscardAreaConfirmationCopy, getDiscardEntryConfirmationCopy } from './source-control-discard-confirmation' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' function entry(partial: Partial & { path: string }): GitStatusEntry { return { diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts index ff020c434ec..d65a9d86ab2 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts +++ b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts @@ -1,5 +1,5 @@ import { basename } from '@/lib/path' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import type { DiscardAllArea } from './discard-all-sequence' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.test.tsx b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.test.tsx index 3dd4246a719..a445ee0da72 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ButtonHTMLAttributes, ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' type CapturedButtonProps = ButtonHTMLAttributes & { children?: ReactNode diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx index 6741a72ac55..52a63734c51 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx @@ -10,7 +10,7 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import type { DiscardAllArea } from './discard-all-sequence' import { getDiscardAreaConfirmationCopy, diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts index 8ee0f549aa0..8c8b3b0905b 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts @@ -3,7 +3,7 @@ import type { PrimaryActionInputs } from './source-control-primary-action' import { canSubmitCommit, resolveCommitDisabledReason } from './source-control-commit-eligibility' -import type { GitConflictOperation } from '../../../../shared/types' +import type { GitConflictOperation } from '../../../../shared/git-status-types' import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status' import { supportsHostedReviewCreation } from '../../../../shared/hosted-review-creation-providers' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/right-sidebar/source-control-entry-actions.test.ts b/src/renderer/src/components/right-sidebar/source-control-entry-actions.test.ts index f9f3bcba206..214e9387f46 100644 --- a/src/renderer/src/components/right-sidebar/source-control-entry-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-entry-actions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { canDiscardStatusEntry, canStageStatusEntry, diff --git a/src/renderer/src/components/right-sidebar/source-control-entry-actions.ts b/src/renderer/src/components/right-sidebar/source-control-entry-actions.ts index b9bd2fe6a57..8495f231188 100644 --- a/src/renderer/src/components/right-sidebar/source-control-entry-actions.ts +++ b/src/renderer/src/components/right-sidebar/source-control-entry-actions.ts @@ -1,4 +1,4 @@ -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { isStageableStatusEntry } from './discard-all-sequence' /** diff --git a/src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx b/src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx index 26fed796035..9879af5b5f1 100644 --- a/src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx @@ -12,7 +12,7 @@ import type { SourceControlActionRecipe, SourceControlLaunchActionId } from '../../../../shared/source-control-ai-actions' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { LaunchSource } from '../../../../shared/telemetry-events' import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/right-sidebar/source-control-header-overflow-menu.tsx b/src/renderer/src/components/right-sidebar/source-control-header-overflow-menu.tsx index 8959c50efa0..55db724a5af 100644 --- a/src/renderer/src/components/right-sidebar/source-control-header-overflow-menu.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-header-overflow-menu.tsx @@ -1,6 +1,6 @@ import React from 'react' import { List, ListTree, MessageSquare, MoreHorizontal, RefreshCw, Settings2 } from 'lucide-react' -import type { SourceControlViewMode } from '../../../../shared/types' +import type { SourceControlViewMode } from '../../../../shared/ui-chrome-types' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx index fac0f0b6ded..19ccba393fd 100644 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx @@ -2,7 +2,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' import { describe, expect, it, vi } from 'vitest' import { SourceControlHeaderToolbar } from './source-control-header-toolbar' -import type { GitBranchCompareSummary } from '../../../../shared/types' +import type { GitBranchCompareSummary } from '../../../../shared/git-diff-compare-types' import type { GitBranchLineTotal } from '../../../../shared/git-status-types' import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import type { PrimaryAction } from './source-control-primary-action' diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.ts b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.ts index 667445e0244..11b79d1b6a0 100644 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.ts @@ -3,7 +3,7 @@ import { getNextSourceControlViewMode, shouldShowSourceControlCompareUnavailableCard } from './source-control-header-toolbar' -import type { GitBranchCompareSummary } from '../../../../shared/types' +import type { GitBranchCompareSummary } from '../../../../shared/git-diff-compare-types' const readySummary: GitBranchCompareSummary = { baseRef: 'origin/main', diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx index e04ca32eaca..41a2ca38273 100644 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx @@ -1,11 +1,8 @@ import React, { useCallback, useEffect, useRef } from 'react' import { GitPullRequestArrow, Loader2, Search, X } from 'lucide-react' -import type { - GitBranchCompareSummary, - GitUpstreamStatus, - SourceControlViewMode -} from '../../../../shared/types' -import type { GitBranchLineTotal } from '../../../../shared/git-status-types' +import type { GitBranchCompareSummary } from '../../../../shared/git-diff-compare-types' +import type { GitBranchLineTotal, GitUpstreamStatus } from '../../../../shared/git-status-types' +import type { SourceControlViewMode } from '../../../../shared/ui-chrome-types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { PrimaryAction } from './source-control-primary-action' import { Button } from '@/components/ui/button' diff --git a/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.ts b/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.ts index bd87f24c4c0..131e7b22733 100644 --- a/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.ts +++ b/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.ts @@ -1,4 +1,5 @@ -import type { GitPushTarget, GitUpstreamStatus } from '../../../../shared/types' +import type { GitUpstreamStatus } from '../../../../shared/git-status-types' +import type { GitPushTarget } from '../../../../shared/worktree/types' import type { HostedReviewState } from '../../../../shared/hosted-review' import { isPositiveHostedReviewNumber } from '../../../../shared/hosted-review' import { getPublishTargetDisplayName } from '../../../../shared/git-publish-target-status' diff --git a/src/renderer/src/components/right-sidebar/source-control-manual-review-url.ts b/src/renderer/src/components/right-sidebar/source-control-manual-review-url.ts index 4b5f44227f3..70e9cd6e877 100644 --- a/src/renderer/src/components/right-sidebar/source-control-manual-review-url.ts +++ b/src/renderer/src/components/right-sidebar/source-control-manual-review-url.ts @@ -1,5 +1,5 @@ import type { HostedReviewProvider } from '../../../../shared/hosted-review' -import type { GitPushTarget } from '../../../../shared/types' +import type { GitPushTarget } from '../../../../shared/worktree/types' import { branchFromRef, normalizeProvider, diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts index da34c8a253d..d4043333442 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts @@ -1,5 +1,6 @@ import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' -import type { GitUpstreamStatus, PRState } from '../../../../shared/types' +import type { GitUpstreamStatus } from '../../../../shared/git-status-types' +import type { PRState } from '../../../../shared/github/pull-request-types' import type { SourceControlPrimaryActionKind, SourceControlRemoteOpKind diff --git a/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts b/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts index fa563470d66..e63df719130 100644 --- a/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { buildSourceControlDisplaySections, getConflictReviewEntries, diff --git a/src/renderer/src/components/right-sidebar/source-control-section-order.ts b/src/renderer/src/components/right-sidebar/source-control-section-order.ts index 557db90b556..02e03a646ea 100644 --- a/src/renderer/src/components/right-sidebar/source-control-section-order.ts +++ b/src/renderer/src/components/right-sidebar/source-control-section-order.ts @@ -1,5 +1,6 @@ import { normalizeSourceControlGroupOrder } from '../../../../shared/source-control-group-order' -import type { GitStatusEntry, SourceControlGroupOrder } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' +import type { SourceControlGroupOrder } from '../../../../shared/ui-chrome-types' export const SOURCE_CONTROL_AREAS = ['unstaged', 'staged', 'untracked'] as const export type SourceControlSectionArea = (typeof SOURCE_CONTROL_AREAS)[number] diff --git a/src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts b/src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts index 595ba62fe34..a8c3b2acf59 100644 --- a/src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { compareGitStatusEntries } from './source-control-status-sort' function entry(path: string, conflictStatus?: GitStatusEntry['conflictStatus']): GitStatusEntry { diff --git a/src/renderer/src/components/right-sidebar/source-control-status-sort.ts b/src/renderer/src/components/right-sidebar/source-control-status-sort.ts index ccedb093ffb..ff188a6cf76 100644 --- a/src/renderer/src/components/right-sidebar/source-control-status-sort.ts +++ b/src/renderer/src/components/right-sidebar/source-control-status-sort.ts @@ -1,4 +1,4 @@ -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { compareFileNames } from '../../../../shared/file-name-sort' export function compareGitStatusEntries(a: GitStatusEntry, b: GitStatusEntry): number { diff --git a/src/renderer/src/components/right-sidebar/source-control-submodule-expansion.test.ts b/src/renderer/src/components/right-sidebar/source-control-submodule-expansion.test.ts index c17d057f5c8..c93cd05203f 100644 --- a/src/renderer/src/components/right-sidebar/source-control-submodule-expansion.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-submodule-expansion.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { buildSubmoduleChildNodes, collectListSelectionEntries, diff --git a/src/renderer/src/components/right-sidebar/source-control-submodule-expansion.ts b/src/renderer/src/components/right-sidebar/source-control-submodule-expansion.ts index aab15360590..c7796eeaf28 100644 --- a/src/renderer/src/components/right-sidebar/source-control-submodule-expansion.ts +++ b/src/renderer/src/components/right-sidebar/source-control-submodule-expansion.ts @@ -1,4 +1,4 @@ -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { basename } from '@/lib/path' import type { SourceControlSectionArea } from './source-control-section-order' import type { SourceControlTreeNode } from './source-control-tree' diff --git a/src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.test.ts b/src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.test.ts index b52e87482ab..acba48929f1 100644 --- a/src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' -import type { GlobalSettings, Repo } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' import { generationParamsToActionRecipe, sourceControlTextGenerationDefaultsMatchTarget diff --git a/src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.ts b/src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.ts index 08bc9fb6d9d..33f3e572d0c 100644 --- a/src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.ts +++ b/src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.ts @@ -8,7 +8,8 @@ import { type SourceControlTextActionId } from '../../../../shared/source-control-ai-actions' import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' -import type { GlobalSettings, Repo } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match' type TextGenerationRecipeConfiguration = { diff --git a/src/renderer/src/components/right-sidebar/source-control-tree.test.ts b/src/renderer/src/components/right-sidebar/source-control-tree.test.ts index 421b5f3cd06..6d9251801d2 100644 --- a/src/renderer/src/components/right-sidebar/source-control-tree.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-tree.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { buildGitStatusSourceControlTree, buildSourceControlTree, diff --git a/src/renderer/src/components/right-sidebar/source-control-tree.ts b/src/renderer/src/components/right-sidebar/source-control-tree.ts index 2ecca32c0bd..eb4a01a7ea3 100644 --- a/src/renderer/src/components/right-sidebar/source-control-tree.ts +++ b/src/renderer/src/components/right-sidebar/source-control-tree.ts @@ -1,5 +1,5 @@ import { normalizeRelativePath } from '@/lib/path' -import type { GitStatusEntry, GitStagingArea } from '../../../../shared/types' +import type { GitStagingArea, GitStatusEntry } from '../../../../shared/git-status-types' import { compareFileNames } from '../../../../shared/file-name-sort' import { splitPathSegments } from './path-tree' import { compareGitStatusEntries } from './source-control-status-sort' diff --git a/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts b/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts index eaf61d70be7..413f1c51c0a 100644 --- a/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts +++ b/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo } from 'react' -import type { GitConflictOperation, Repo } from '../../../../shared/types' +import type { GitConflictOperation } from '../../../../shared/git-status-types' +import type { Repo } from '../../../../shared/repo-types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' diff --git a/src/renderer/src/components/right-sidebar/status-display.ts b/src/renderer/src/components/right-sidebar/status-display.ts index 92fa3e7769b..17ce74ab4f5 100644 --- a/src/renderer/src/components/right-sidebar/status-display.ts +++ b/src/renderer/src/components/right-sidebar/status-display.ts @@ -1,4 +1,4 @@ -import type { GitFileStatus, GitStatusEntry } from '../../../../shared/types' +import type { GitFileStatus, GitStatusEntry } from '../../../../shared/git-status-types' import { joinPath, normalizeRelativePath } from '@/lib/path' import { splitPathSegments } from './path-tree' diff --git a/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts b/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts index 7d194d2bf00..d79f9a16fbe 100644 --- a/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts @@ -3,7 +3,8 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers' diff --git a/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.ts b/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.ts index eb902500547..a0754151973 100644 --- a/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.ts +++ b/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.ts @@ -6,7 +6,7 @@ import { useAllWorktrees, useRepoMap } from '@/store/selectors' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { resolveChecksPanelTerminalPtyId, resolveChecksPanelWorktreeFromTerminalCwd diff --git a/src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.ts b/src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.ts index c28ae16f9b0..8508d61f5e7 100644 --- a/src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.ts +++ b/src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect } from 'react' import { getConnectionId } from '@/lib/connection-context' import { setRuntimeGitStatusUpstreamRefWatch } from '@/runtime/runtime-git-client' -import type { GitStatusResult } from '../../../../shared/types' +import type { GitStatusResult } from '../../../../shared/git-status-types' import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' export function useGitStatusUpstreamRefWatch(args: { diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx index 09fd8fe4133..daf0dffe1b7 100644 --- a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx @@ -3,7 +3,8 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { PRInfo, Repo } from '../../../../shared/types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' import { useHostedReviewActions, type HostedReviewActionInfo } from './use-hosted-review-actions' const confirmationMocks = vi.hoisted(() => ({ diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts index 6830fe86eee..44b8eb7940f 100644 --- a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts +++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts @@ -3,7 +3,8 @@ import { toast } from 'sonner' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import type { GitHubPRAutoMergeAction } from '@/components/github-pr-merge-state' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { PRInfo, Repo, GitHubPRMergeMethod } from '../../../../shared/types' +import type { GitHubPRMergeMethod, PRInfo } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' import { mergeGitHubHostedReview, setGitHubHostedReviewAutoMerge, diff --git a/src/renderer/src/components/right-sidebar/use-source-control-recovery-ai.ts b/src/renderer/src/components/right-sidebar/use-source-control-recovery-ai.ts index 3d679a55789..27ec43a3323 100644 --- a/src/renderer/src/components/right-sidebar/use-source-control-recovery-ai.ts +++ b/src/renderer/src/components/right-sidebar/use-source-control-recovery-ai.ts @@ -3,7 +3,7 @@ import type { SourceControlActionRecipe, SourceControlLaunchActionId } from '../../../../shared/source-control-ai-actions' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { buildFixCommitFailurePrompt } from './source-control-ai-prompts' import { summarizeCommitFailure } from './commit-failure-summary' import { diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index c9720c751bf..85347b2e6bd 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -13,7 +13,7 @@ import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefDetails } from '@/runtime/runtime-repo-client' -import type { Repo, BaseRefSearchResult } from '../../../../shared/types' +import type { BaseRefSearchResult, Repo } from '../../../../shared/repo-types' import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs' import { diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx index 9c665f4cce6..49ca1844cba 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx @@ -2,7 +2,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { DirEntry } from '../../../../shared/types' +import type { DirEntry } from '../../../../shared/filesystem-entry-types' import { useFileExplorerTree } from './useFileExplorerTree' const readDirectoryMock = vi.hoisted(() => vi.fn()) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx index 13b7b1c1c5a..4bf3de4b551 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx @@ -2,7 +2,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FsChangedPayload } from '../../../../shared/types' +import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types' import type { FileExplorerOperationOwner, FileExplorerTreeRefreshOutcome diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index d8b218b6409..33e439e4a11 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react' -import type { FsChangedPayload } from '../../../../shared/types' +import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types' import type { DirCache, FileExplorerOperationOwner, diff --git a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts index f0935b625a0..e66e647c26f 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts @@ -2,7 +2,11 @@ import type React from 'react' import { useCallback, useDeferredValue, useEffect, useMemo, useRef } from 'react' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' -import type { SearchFileResult, SearchMatch, SearchResult } from '../../../../shared/types' +import type { + SearchFileResult, + SearchMatch, + SearchResult +} from '../../../../shared/code-search-types' import { buildSearchRows } from './search-rows' import { cancelRevealFrame, openMatchResult } from './search-match-open' import type { SearchQueryRowProps } from './SearchQueryRow' diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx index 62bbf4f9bec..c68f5effa4e 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx @@ -2,7 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { SearchResult } from '../../../../shared/types' +import type { SearchResult } from '../../../../shared/code-search-types' import { useFileSearchRunner } from './useFileSearchRunner' const mocks = vi.hoisted(() => ({ diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts index 20feac4a610..7950a86114f 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts @@ -10,7 +10,7 @@ import { } from '@/runtime/runtime-file-search-bounds' import { searchRuntimeFiles } from '@/runtime/runtime-file-client' import { useAppStore } from '@/store' -import type { SearchResult } from '../../../../shared/types' +import type { SearchResult } from '../../../../shared/code-search-types' import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' const SEARCH_DEBOUNCE_MS = 300 diff --git a/src/renderer/src/components/right-sidebar/useGitHistoryCommitActions.ts b/src/renderer/src/components/right-sidebar/useGitHistoryCommitActions.ts index c60c6f1f3e0..532912c65eb 100644 --- a/src/renderer/src/components/right-sidebar/useGitHistoryCommitActions.ts +++ b/src/renderer/src/components/right-sidebar/useGitHistoryCommitActions.ts @@ -12,7 +12,10 @@ import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { resolveDefaultAgentForNewTab } from '@/lib/agent-tab-shortcuts' import { translate } from '@/i18n/i18n' import type { GitHistoryItem } from '../../../../shared/git-history' -import type { GitBranchChangeEntry, GitCommitCompareResult } from '../../../../shared/types' +import type { + GitBranchChangeEntry, + GitCommitCompareResult +} from '../../../../shared/git-diff-compare-types' import { shouldOpenSourceControlRowAsPreview, type SourceControlRowOpenEvent diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts index 348bf8a1f36..7d565090d69 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts @@ -3,7 +3,8 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { makeOpenFile, makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers' diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index 47d8d5e8fde..b1c6d3586d1 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as React from 'react' -import type { FsChangedPayload, GitPushTarget, GitStatusResult } from '../../../../shared/types' +import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types' +import type { GitStatusResult } from '../../../../shared/git-status-types' +import type { GitPushTarget } from '../../../../shared/worktree/types' import { DEFAULT_GIT_STATUS_LIMIT } from '../../../../shared/git-status-limit' const worktree = { id: 'repo-1::/repo', repoId: 'repo-1', path: '/repo' } diff --git a/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts b/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts index c3dd7a10a9c..0d582ca508c 100644 --- a/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts +++ b/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts @@ -3,7 +3,9 @@ import type { SourceControlActionRecipe, SourceControlLaunchActionId } from '../../../../shared/source-control-ai-actions' -import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { isSourceControlAgentDetectedAndEnabled } from './source-control-agent-action-dialog-support' import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match' diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts index 4c129d862e4..d002d9d3a34 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts @@ -8,7 +8,7 @@ import { useAppStore } from '@/store' import { useRepoById } from '@/store/selectors' import { renderSourceControlActionCommandTemplate } from '../../../../shared/source-control-ai-actions' import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { SourceControlAgentActionDialogProps } from './SourceControlAgentActionDialog' import type { UseSourceControlAgentActionDialogResult } from './source-control-agent-action-dialog-result' import { useSavedSourceControlAgentActionAutoStart } from './useSavedSourceControlAgentActionAutoStart' diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 3336348a2e5..7af2c97f8c6 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -5,7 +5,9 @@ import type { SourceControlLaunchActionId } from '../../../../shared/source-control-ai-actions' import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' -import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart' diff --git a/src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts b/src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts index 666e406e1cb..ccafa2ddc99 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { getSelectionRangeKeys, reconcileSelectionKeys, diff --git a/src/renderer/src/components/right-sidebar/useSourceControlSelection.ts b/src/renderer/src/components/right-sidebar/useSourceControlSelection.ts index e14ee5cdc5d..0fa525724a5 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlSelection.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlSelection.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useRef, type RefObject } from 'react' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import type { SourceControlRowOpenEvent } from './source-control-split-open' function isMacPlatform(): boolean { diff --git a/src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx b/src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx index 5b97bb8068f..527d641b843 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx +++ b/src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx @@ -15,7 +15,7 @@ import { useSourceControlSubmoduleStatus, type UseSourceControlSubmoduleStatusResult } from './useSourceControlSubmoduleStatus' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' const roots: Root[] = [] diff --git a/src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.ts b/src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.ts index fc91420b7eb..c7c799561f6 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/git-status-types' import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitSubmoduleStatus, type RuntimeGitContext } from '@/runtime/runtime-git-client' import { diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index 795caf9b5cf..bf7656856d5 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -2,7 +2,7 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { i18n } from '../../i18n/i18n' import { useAppStore } from '../../store' import { AccountsPane } from './AccountsPane' diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index c8500b99cfa..13b93c24596 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -4,12 +4,12 @@ error handling and restart prompts below; splitting them into separate files would scatter those flows without a meaningful abstraction boundary. */ import { useEffect, useRef, useState } from 'react' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, - CodexSystemDefaultIdentity, - GlobalSettings -} from '../../../../shared/types' + CodexSystemDefaultIdentity +} from '../../../../shared/managed-account-types' import { resolveLocalAccountRuntimeTarget } from '../../../../shared/local-account-runtime' import { getRendererAppPlatform } from '../../lib/renderer-app-platform' import { Badge } from '../ui/badge' diff --git a/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.test.ts b/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.test.ts index 2de229d49d0..08a392fdecc 100644 --- a/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.test.ts +++ b/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { createHttpProxyBypassRulesDraftState, createHttpProxyUrlDraftState, diff --git a/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.tsx b/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.tsx index e5b8a3b81fe..6597dd1e15b 100644 --- a/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.tsx +++ b/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.tsx @@ -1,7 +1,7 @@ import type React from 'react' import { useState } from 'react' import { ChevronDown } from 'lucide-react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { normalizeProxyBypassRules, normalizeProxyUrl } from '../../../../shared/network-proxy' import { cn } from '@/lib/utils' import { useAppStore } from '../../store' diff --git a/src/renderer/src/components/settings/AdvancedPane.tsx b/src/renderer/src/components/settings/AdvancedPane.tsx index f2b014e2805..df05dc71507 100644 --- a/src/renderer/src/components/settings/AdvancedPane.tsx +++ b/src/renderer/src/components/settings/AdvancedPane.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from 'react' import { Info, Loader2, RotateCw } from 'lucide-react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { useMountedRef } from '@/hooks/useMountedRef' import { Button } from '../ui/button' import { Label } from '../ui/label' diff --git a/src/renderer/src/components/settings/AgentAwakeSetting.tsx b/src/renderer/src/components/settings/AgentAwakeSetting.tsx index 216dff6facf..4d27b9ab9c6 100644 --- a/src/renderer/src/components/settings/AgentAwakeSetting.tsx +++ b/src/renderer/src/components/settings/AgentAwakeSetting.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Label } from '../ui/label' import { getAgentAwakeDescription, diff --git a/src/renderer/src/components/settings/AgentCacheTimerSection.tsx b/src/renderer/src/components/settings/AgentCacheTimerSection.tsx index 554e9541090..2e99b4f7e6a 100644 --- a/src/renderer/src/components/settings/AgentCacheTimerSection.tsx +++ b/src/renderer/src/components/settings/AgentCacheTimerSection.tsx @@ -1,6 +1,6 @@ import type React from 'react' import { Timer } from 'lucide-react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { useAppStore } from '../../store' import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' diff --git a/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx b/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx index 9c5f495b883..c22f5a9050a 100644 --- a/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { Label } from '../ui/label' import { SearchableSetting } from './SearchableSetting' diff --git a/src/renderer/src/components/settings/AgentRuntimeSetting.tsx b/src/renderer/src/components/settings/AgentRuntimeSetting.tsx index 220a380b943..877ce8d0065 100644 --- a/src/renderer/src/components/settings/AgentRuntimeSetting.tsx +++ b/src/renderer/src/components/settings/AgentRuntimeSetting.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { GlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime' import { normalizeGlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index 3b2f7bb964b..1081f5d4553 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -2,7 +2,8 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' -import type { GlobalSettings, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { useAppStore } from '../../store' import { getAgentGeneratedTabTitlesTitle } from './agent-generated-tab-title-copy' diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 70ba5f64c3f..682468c5d06 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -11,7 +11,8 @@ import { RefreshCw, Terminal } from 'lucide-react' -import type { GlobalSettings, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' import { useDetectedAgents, type AgentDetectionTarget } from '@/hooks/useDetectedAgents' import { useAppStore } from '@/store' diff --git a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx index 551485cb317..b78e9de335d 100644 --- a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx +++ b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx @@ -1,6 +1,6 @@ import type React from 'react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { UIZoomControl } from './UIZoomControl' import { SearchableSetting } from './SearchableSetting' diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 139ade8064f..4a89d66cd03 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -6,7 +6,8 @@ import { I18nextProvider } from 'react-i18next' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { i18n } from '@/i18n/i18n' import { getDefaultSettings } from '../../../../shared/constants' -import type { GlobalSettings, StatusBarItem } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' const mocks = vi.hoisted(() => ({ state: { diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index f6c71840654..3c460b32694 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -2,7 +2,7 @@ import type React from 'react' import { useLayoutEffect, useState } from 'react' import { AppWindow, PanelLeft, TerminalSquare } from 'lucide-react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { AppearanceSection } from './AppearanceSection' import { AppearanceInterfaceSection } from './AppearanceInterfaceSection' diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 1d7e5d8ec2c..c8d63b3866a 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -1,6 +1,7 @@ import type React from 'react' -import type { GlobalSettings, StatusBarItem } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' import type { FeatureInteractionId } from '../../../../shared/feature-interaction-catalog' import { SearchableSetting } from './SearchableSetting' import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure' diff --git a/src/renderer/src/components/settings/ArtifactsSettingsPane.tsx b/src/renderer/src/components/settings/ArtifactsSettingsPane.tsx index f561908d50a..60dd3e0c0c9 100644 --- a/src/renderer/src/components/settings/ArtifactsSettingsPane.tsx +++ b/src/renderer/src/components/settings/ArtifactsSettingsPane.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { ArrowRight, Files } from 'lucide-react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Button } from '@/components/ui/button' import { SettingsSwitchRow } from './SettingsFormControls' import { useAppStore } from '@/store' diff --git a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx index d31d9b69533..d44bc20c887 100644 --- a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx +++ b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { ChevronDown } from 'lucide-react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { SourceControlAiSettingsPatch, SourceControlAiSettings diff --git a/src/renderer/src/components/settings/AutomationsSettingsPane.tsx b/src/renderer/src/components/settings/AutomationsSettingsPane.tsx index 5984698b65b..991081e4739 100644 --- a/src/renderer/src/components/settings/AutomationsSettingsPane.tsx +++ b/src/renderer/src/components/settings/AutomationsSettingsPane.tsx @@ -1,5 +1,5 @@ import { ArrowRight, CalendarClock } from 'lucide-react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Button } from '@/components/ui/button' import { SettingsSwitchRow } from './SettingsFormControls' import { useAppStore } from '@/store' diff --git a/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx b/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx index b0c55e87204..55edebeea9a 100644 --- a/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx +++ b/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { SearchableSetting } from './SearchableSetting' import { SettingsSwitchRow } from './SettingsFormControls' import { diff --git a/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx b/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx index fa7f26f135b..7ec1bc47ff3 100644 --- a/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx +++ b/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Label } from '../ui/label' import { Switch } from '../ui/switch' import { SearchableSetting } from './SearchableSetting' diff --git a/src/renderer/src/components/settings/BrowserLocalhostWorktreeLabelsSetting.tsx b/src/renderer/src/components/settings/BrowserLocalhostWorktreeLabelsSetting.tsx index 1dfc0492318..fb18663e497 100644 --- a/src/renderer/src/components/settings/BrowserLocalhostWorktreeLabelsSetting.tsx +++ b/src/renderer/src/components/settings/BrowserLocalhostWorktreeLabelsSetting.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { SearchableSetting } from './SearchableSetting' import { SettingsSwitchRow } from './SettingsFormControls' diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 1bc078cfcca..c0b2a370e67 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { useAppStore } from '../../store' import { matchesSettingsSearch } from './settings-search' import { getBrowserPaneSearchEntries } from './browser-search' diff --git a/src/renderer/src/components/settings/BrowserProfileRow.tsx b/src/renderer/src/components/settings/BrowserProfileRow.tsx index a6324661901..05447bb7758 100644 --- a/src/renderer/src/components/settings/BrowserProfileRow.tsx +++ b/src/renderer/src/components/settings/BrowserProfileRow.tsx @@ -1,7 +1,10 @@ import { Import, Loader2, Trash2 } from 'lucide-react' import { toast } from 'sonner' import { emitBrowserCookieImportToast } from '@/lib/browser-cookie-import-toast' -import type { BrowserCookieImportSummary, BrowserSessionProfile } from '../../../../shared/types' +import type { + BrowserCookieImportSummary, + BrowserSessionProfile +} from '../../../../shared/browser-workspace-types' import { Button } from '../ui/button' import { DropdownMenu, diff --git a/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx b/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx index 8fa1e6c9a60..3f4fbbb992d 100644 --- a/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx +++ b/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx @@ -1,5 +1,5 @@ import { Plus } from 'lucide-react' -import type { BrowserSessionProfile } from '../../../../shared/types' +import type { BrowserSessionProfile } from '../../../../shared/browser-workspace-types' import { Button } from '../ui/button' import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' diff --git a/src/renderer/src/components/settings/BrowserTerminalLinkActionsSetting.tsx b/src/renderer/src/components/settings/BrowserTerminalLinkActionsSetting.tsx index 2ba4fa50a00..b658052db81 100644 --- a/src/renderer/src/components/settings/BrowserTerminalLinkActionsSetting.tsx +++ b/src/renderer/src/components/settings/BrowserTerminalLinkActionsSetting.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' import { SearchableSetting } from './SearchableSetting' diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index e37a45ef3d6..32cbb3e5766 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { FolderOpen, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import type { CliInstallStatus } from '../../../../shared/cli-install-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { ORCA_CLI_SKILL_INSTALL_COMMAND, ORCA_CLI_SKILL_NAME, diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index 2b68c2e0945..913b6356322 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings, normalizeGlobalWindowsRuntimeDefault diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx index fba530089bf..95c3e2567b8 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx @@ -1,7 +1,7 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types' import { getCommitMessageModelDiscoveryHostKey, diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index ecc01cc4c11..d14ff189c72 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -1,6 +1,7 @@ import { useRef } from 'react' import type React from 'react' -import type { GlobalSettings, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { SourceControlAiSettingsPatch, SourceControlAiSettings diff --git a/src/renderer/src/components/settings/CompareAgainstUpstreamSetting.tsx b/src/renderer/src/components/settings/CompareAgainstUpstreamSetting.tsx index 0fb266867b0..532e25be0d2 100644 --- a/src/renderer/src/components/settings/CompareAgainstUpstreamSetting.tsx +++ b/src/renderer/src/components/settings/CompareAgainstUpstreamSetting.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { SearchableSetting } from './SearchableSetting' import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' diff --git a/src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.tsx b/src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.tsx index 827def4c2b2..a952e37eea8 100644 --- a/src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.tsx +++ b/src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { GlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime' import { normalizeGlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' diff --git a/src/renderer/src/components/settings/EditorFontFamilySetting.tsx b/src/renderer/src/components/settings/EditorFontFamilySetting.tsx index 911a4e2fde1..f771fa24c7a 100644 --- a/src/renderer/src/components/settings/EditorFontFamilySetting.tsx +++ b/src/renderer/src/components/settings/EditorFontFamilySetting.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { SearchableSetting } from './SearchableSetting' import { FontAutocomplete, SettingsRow } from './SettingsFormControls' diff --git a/src/renderer/src/components/settings/EditorWordWrapSetting.tsx b/src/renderer/src/components/settings/EditorWordWrapSetting.tsx index b3dfbb853c4..c9283daf731 100644 --- a/src/renderer/src/components/settings/EditorWordWrapSetting.tsx +++ b/src/renderer/src/components/settings/EditorWordWrapSetting.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { SearchableSetting } from './SearchableSetting' import { Label } from '../ui/label' diff --git a/src/renderer/src/components/settings/EphemeralVmRecipeRow.tsx b/src/renderer/src/components/settings/EphemeralVmRecipeRow.tsx index 1e09e65c4ab..8a42283ff51 100644 --- a/src/renderer/src/components/settings/EphemeralVmRecipeRow.tsx +++ b/src/renderer/src/components/settings/EphemeralVmRecipeRow.tsx @@ -1,6 +1,6 @@ import { Play } from 'lucide-react' import type React from 'react' -import type { OrcaHooks } from '../../../../shared/types' +import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import { Button } from '../ui/button' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/settings/EphemeralVmsExperimentalSetting.tsx b/src/renderer/src/components/settings/EphemeralVmsExperimentalSetting.tsx index 543744136cc..dd4db727bc4 100644 --- a/src/renderer/src/components/settings/EphemeralVmsExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsExperimentalSetting.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { Label } from '../ui/label' import { EphemeralVmsPane } from './EphemeralVmsPane' diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index e230ba7b670..54264e1a4f6 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { getDefaultSettings } from '../../../../shared/constants' import { ExperimentalPane } from './ExperimentalPane' import { getExperimentalPaneSearchEntries } from './experimental-search' diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 25c161d0da9..bccd7989a90 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Label } from '../ui/label' import { Switch } from '../ui/switch' import { useAppStore } from '../../store' diff --git a/src/renderer/src/components/settings/FloatingWorkspacePane.tsx b/src/renderer/src/components/settings/FloatingWorkspacePane.tsx index 14ace880d92..16c7b55c9cd 100644 --- a/src/renderer/src/components/settings/FloatingWorkspacePane.tsx +++ b/src/renderer/src/components/settings/FloatingWorkspacePane.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { FolderOpen } from 'lucide-react' -import type { FloatingTerminalTriggerLocation, GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { FloatingTerminalTriggerLocation } from '../../../../shared/ui-chrome-types' import { Button } from '../ui/button' import { Input } from '../ui/input' import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group' diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index b8d710b6886..6b3b211f628 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -1,6 +1,6 @@ import type React from 'react' import { useState } from 'react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, MAX_EDITOR_AUTO_SAVE_DELAY_MS, diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index 021fc5d7111..ba7847f7ce0 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { useAppStore } from '../../store' import { Separator } from '../ui/separator' import { CliSection } from './CliSection' @@ -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 474628a5cf0..41f803040c1 100644 --- a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx @@ -1,19 +1,27 @@ import type React from 'react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { OpenInMenuSetting } from './OpenInMenuSetting' 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/InputPane.tsx b/src/renderer/src/components/settings/InputPane.tsx index 55f64d550da..286c7ebe351 100644 --- a/src/renderer/src/components/settings/InputPane.tsx +++ b/src/renderer/src/components/settings/InputPane.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Label } from '../ui/label' import { Switch } from '../ui/switch' import { SearchableSetting } from './SearchableSetting' diff --git a/src/renderer/src/components/settings/LeftSidebarAppearanceSetting.tsx b/src/renderer/src/components/settings/LeftSidebarAppearanceSetting.tsx index bf4cb1a81b7..165d92a2817 100644 --- a/src/renderer/src/components/settings/LeftSidebarAppearanceSetting.tsx +++ b/src/renderer/src/components/settings/LeftSidebarAppearanceSetting.tsx @@ -1,5 +1,6 @@ import type React from 'react' -import type { GlobalSettings, LeftSidebarAppearanceMode } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { LeftSidebarAppearanceMode } from '../../../../shared/ui-chrome-types' import { DEFAULT_LEFT_SIDEBAR_TINT_COLOR, DEFAULT_LEFT_SIDEBAR_TINT_OPACITY, diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx index ce9b3d8db4a..e4ceac4c892 100644 --- a/src/renderer/src/components/settings/McpConfigSection.tsx +++ b/src/renderer/src/components/settings/McpConfigSection.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertCircle, FileCode2, LoaderCircle, Plus, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import { useMountedRef } from '@/hooks/useMountedRef' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { canInspectLocalMcpConfigRoot, diff --git a/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx b/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx index e4f5adaa47f..6453e27d3f7 100644 --- a/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Loader2, RefreshCw } from 'lucide-react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { cn } from '@/lib/utils' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { Badge } from '../ui/badge' diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 464b1ca7797..d7ea704c553 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' diff --git a/src/renderer/src/components/settings/NotificationSoundSection.tsx b/src/renderer/src/components/settings/NotificationSoundSection.tsx index da1fe4e11cc..e90489741ae 100644 --- a/src/renderer/src/components/settings/NotificationSoundSection.tsx +++ b/src/renderer/src/components/settings/NotificationSoundSection.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { toast } from 'sonner' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Label } from '../ui/label' import { Slider } from '../ui/slider' import { diff --git a/src/renderer/src/components/settings/NotificationsPane.test.tsx b/src/renderer/src/components/settings/NotificationsPane.test.tsx index 08d43709a3e..7828efad6ce 100644 --- a/src/renderer/src/components/settings/NotificationsPane.test.tsx +++ b/src/renderer/src/components/settings/NotificationsPane.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderToStaticMarkup } from 'react-dom/server' -import type { GlobalSettings, NotificationDispatchRequest } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { NotificationDispatchRequest } from '../../../../shared/notification-settings-types' import { getNotificationSoundOptions } from '@/components/notification-sound-options' import { createNotificationVolumeDraftState, diff --git a/src/renderer/src/components/settings/NotificationsPane.tsx b/src/renderer/src/components/settings/NotificationsPane.tsx index 5d1bb6a23d3..e80f3e49512 100644 --- a/src/renderer/src/components/settings/NotificationsPane.tsx +++ b/src/renderer/src/components/settings/NotificationsPane.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Button } from '../ui/button' import { Separator } from '../ui/separator' import { BellRing, Bot, Siren } from 'lucide-react' diff --git a/src/renderer/src/components/settings/OpenInMenuSetting.tsx b/src/renderer/src/components/settings/OpenInMenuSetting.tsx index b6c3f50f4ae..aa18ec951f1 100644 --- a/src/renderer/src/components/settings/OpenInMenuSetting.tsx +++ b/src/renderer/src/components/settings/OpenInMenuSetting.tsx @@ -1,7 +1,8 @@ import type React from 'react' import { useState } from 'react' import { Check, ChevronDown, Pencil, Trash2 } from 'lucide-react' -import type { GlobalSettings, OpenInApplication } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { OpenInApplication } from '../../../../shared/ui-chrome-types' import { OPEN_IN_APPLICATIONS_MAX } from '../../../../shared/open-in-applications' import { Button } from '../ui/button' import { diff --git a/src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx b/src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx index db731518101..74f12f0779c 100644 --- a/src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx +++ b/src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx @@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PluginHostListEntry } from '../../../../preload/api-types' import { getDefaultSettings } from '../../../../shared/constants' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { PluginsSettingsSection } from './PluginsSettingsSection' vi.mock('./SettingsSection', () => ({ diff --git a/src/renderer/src/components/settings/PluginsSettingsSection.tsx b/src/renderer/src/components/settings/PluginsSettingsSection.tsx index 30b54aa4284..c76fd6b846f 100644 --- a/src/renderer/src/components/settings/PluginsSettingsSection.tsx +++ b/src/renderer/src/components/settings/PluginsSettingsSection.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { Plus } from 'lucide-react' import type { PluginHostInstallSource, PluginHostListEntry } from '../../../../preload/api-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { Button } from '../ui/button' import { PluginConsentDialog } from './PluginConsentDialog' diff --git a/src/renderer/src/components/settings/PrivacyPane.test.ts b/src/renderer/src/components/settings/PrivacyPane.test.ts index 751e1b5627a..5739e39e1d5 100644 --- a/src/renderer/src/components/settings/PrivacyPane.test.ts +++ b/src/renderer/src/components/settings/PrivacyPane.test.ts @@ -15,7 +15,7 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TelemetryConsentState } from '../../../../shared/telemetry-consent-types' const { getConsentStateMock, setOptInMock } = vi.hoisted(() => ({ diff --git a/src/renderer/src/components/settings/PrivacyPane.tsx b/src/renderer/src/components/settings/PrivacyPane.tsx index f36765e4ffc..9d24b34ffd3 100644 --- a/src/renderer/src/components/settings/PrivacyPane.tsx +++ b/src/renderer/src/components/settings/PrivacyPane.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { ShieldCheck } from 'lucide-react' import { useMountedRef } from '@/hooks/useMountedRef' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TelemetryConsentState } from '../../../../shared/telemetry-consent-types' import { Label } from '../ui/label' import { Switch } from '../ui/switch' diff --git a/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx b/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx index 94ea998bb8e..6caa188c9d1 100644 --- a/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx +++ b/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx @@ -5,7 +5,7 @@ import { createRoot } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' -import type { Project } from '../../../../shared/types' +import type { Project } from '../../../../shared/project-types' import { ProjectWindowsRuntimeSetting } from './ProjectWindowsRuntimeSetting' const project: Project = { diff --git a/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.tsx b/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.tsx index 84beb4296e5..8dc75a4bf16 100644 --- a/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.tsx +++ b/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.tsx @@ -1,4 +1,5 @@ -import type { GlobalSettings, Project, ProjectUpdateArgs } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Project, ProjectUpdateArgs } from '../../../../shared/project-types' import type { LocalWindowsRuntimePreference } from '../../../../shared/project-execution-runtime' import { normalizeProjectRuntimePreference, diff --git a/src/renderer/src/components/settings/QuickCommandsList.tsx b/src/renderer/src/components/settings/QuickCommandsList.tsx index 3a0042b32a8..d86fe657010 100644 --- a/src/renderer/src/components/settings/QuickCommandsList.tsx +++ b/src/renderer/src/components/settings/QuickCommandsList.tsx @@ -1,9 +1,9 @@ import { Check, Copy, Pencil, TerminalSquare, Trash2 } from 'lucide-react' +import type { Repo } from '../../../../shared/repo-types' import type { - Repo, TerminalQuickCommand, TerminalQuickCommandScope -} from '../../../../shared/types' +} from '../../../../shared/terminal-quick-command-types' import { getTerminalQuickCommandBody, getTerminalQuickCommandScope, diff --git a/src/renderer/src/components/settings/QuickCommandsPane.tsx b/src/renderer/src/components/settings/QuickCommandsPane.tsx index a4f1ea85fe4..2305a034227 100644 --- a/src/renderer/src/components/settings/QuickCommandsPane.tsx +++ b/src/renderer/src/components/settings/QuickCommandsPane.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { GlobalSettings, TerminalQuickCommand } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands' import { createTerminalQuickCommandDraft, diff --git a/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx b/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx index 749d554b783..c878d34b015 100644 --- a/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx +++ b/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx @@ -1,6 +1,6 @@ import { Check, ChevronsUpDown } from 'lucide-react' import type { Dispatch, SetStateAction } from 'react' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { Button } from '../ui/button' import { Command, CommandItem, CommandList } from '../ui/command' import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' diff --git a/src/renderer/src/components/settings/QuickCommandsToolbar.tsx b/src/renderer/src/components/settings/QuickCommandsToolbar.tsx index 65e034eeb3f..202e148b1e3 100644 --- a/src/renderer/src/components/settings/QuickCommandsToolbar.tsx +++ b/src/renderer/src/components/settings/QuickCommandsToolbar.tsx @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from 'react' import { Plus, Search } from 'lucide-react' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import { Button } from '../ui/button' import { Input } from '../ui/input' diff --git a/src/renderer/src/components/settings/RecentTabOrderControl.tsx b/src/renderer/src/components/settings/RecentTabOrderControl.tsx index 84e54c617b5..adb8432973b 100644 --- a/src/renderer/src/components/settings/RecentTabOrderControl.tsx +++ b/src/renderer/src/components/settings/RecentTabOrderControl.tsx @@ -1,5 +1,5 @@ import React from 'react' -import type { CtrlTabOrderMode } from '../../../../shared/types' +import type { CtrlTabOrderMode } from '../../../../shared/tab-types' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { SettingsRow } from './SettingsFormControls' diff --git a/src/renderer/src/components/settings/RepositoryForkSyncSection.tsx b/src/renderer/src/components/settings/RepositoryForkSyncSection.tsx index f3279d59f1e..3a399f16e38 100644 --- a/src/renderer/src/components/settings/RepositoryForkSyncSection.tsx +++ b/src/renderer/src/components/settings/RepositoryForkSyncSection.tsx @@ -1,7 +1,8 @@ import { useRef, useState } from 'react' import { RefreshCw } from 'lucide-react' import { toast } from 'sonner' -import type { ForkSyncMode, GitForkSyncResult, Repo } from '../../../../shared/types' +import type { ForkSyncMode, GitForkSyncResult } from '../../../../shared/git-fork-sync' +import type { Repo } from '../../../../shared/repo-types' import { Button } from '../ui/button' import { SearchableSetting } from './SearchableSetting' import { SettingsSegmentedControl } from './SettingsFormControls' diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts index 6ca17d9fca3..114c7195e82 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts +++ b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts @@ -3,7 +3,7 @@ import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { getLocalCommandSourcePolicyNotice, RepositoryHooksSection } from './RepositoryHooksSection' vi.mock('@/store', () => ({ diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx index bec7ba52a28..e6270dce42b 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -4,11 +4,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { HookCommandSourcePolicy, OrcaHooks, - Repo, RepoHookSettings, SetupAgentStartupPolicy, SetupRunPolicy -} from '../../../../shared/types' +} from '../../../../shared/orca-yaml-hook-types' +import type { Repo } from '../../../../shared/repo-types' import { AlertTriangle, ChevronRight, Plus } from 'lucide-react' import { toast } from 'sonner' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx b/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx index e6edcbe68dc..a3527747681 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx @@ -5,7 +5,7 @@ import type { ProjectHostSetup, ProjectHostSetupCreateResult, ProjectHostSetupResult -} from '../../../../shared/types' +} from '../../../../shared/project-types' import { translate } from '@/i18n/i18n' import { Button } from '../ui/button' import { Label } from '../ui/label' diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx index c448002aa6b..7af24b68947 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx @@ -9,7 +9,8 @@ import { RUNTIME_PROTOCOL_VERSION, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' import { useAppStore } from '../../store' import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx index 9fa983138bf..71b292059a4 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -7,7 +7,8 @@ import { } from '../../../../shared/execution-host' import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' -import type { ProjectHostSetup, Repo } from '../../../../shared/types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' import { useAppStore } from '../../store' import { getProjectHostSetupProjectionFromState } from '../../store/selectors' import { cn } from '../../lib/utils' diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx index 81cdefa0a55..f6a4badabbb 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx @@ -9,7 +9,8 @@ import { WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RuntimeStatus } from '../../../../shared/runtime-types' -import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' import { useAppStore } from '../../store' import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' diff --git a/src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx b/src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx index 996261ab3e9..2d2da75d483 100644 --- a/src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx +++ b/src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx @@ -3,7 +3,7 @@ import { 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/types' +import type { Repo } from '../../../../shared/repo-types' import { RepositoryIconPicker } from './RepositoryIconPicker' vi.mock('@/runtime/runtime-rpc-client', () => ({ diff --git a/src/renderer/src/components/settings/RepositoryIconPicker.tsx b/src/renderer/src/components/settings/RepositoryIconPicker.tsx index 51edc955fb5..033bf24391e 100644 --- a/src/renderer/src/components/settings/RepositoryIconPicker.tsx +++ b/src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { RotateCcw } from 'lucide-react' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import type { RepoIcon } from '../../../../shared/repo-icon' import { DEFAULT_REPO_BADGE_COLOR } from '../../../../shared/constants' import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' diff --git a/src/renderer/src/components/settings/RepositoryPane.test.ts b/src/renderer/src/components/settings/RepositoryPane.test.ts index 47c00a0a601..54518e4932b 100644 --- a/src/renderer/src/components/settings/RepositoryPane.test.ts +++ b/src/renderer/src/components/settings/RepositoryPane.test.ts @@ -5,7 +5,9 @@ import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' -import type { Project, Repo, TerminalTab } from '../../../../shared/types' +import type { Project } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { useAppStore } from '../../store' import { diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index ecec034cec2..02874e5edc4 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -1,11 +1,7 @@ import { useCallback, useRef, useState } from 'react' -import type { - OrcaHooks, - Project, - ProjectUpdateArgs, - Repo, - RepoHookSettings -} from '../../../../shared/types' +import type { OrcaHooks, RepoHookSettings } from '../../../../shared/orca-yaml-hook-types' +import type { Project, ProjectUpdateArgs } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { Button } from '../ui/button' @@ -33,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[] = [] @@ -52,7 +53,7 @@ type RepositoryPaneProps = { repoId: string, updates: RepositoryPaneRepoUpdate, options?: { hostId?: ExecutionHostId } - ) => void + ) => void | Promise removeProject: (repoId: string) => void project?: Project | null selectedProjectSetupId?: string @@ -95,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)) ) @@ -170,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') ]) @@ -351,8 +367,14 @@ export function RepositoryPane({ + fetchWorktrees(repoId, { + executionHostId: selectedHostId, + requireAuthoritative: true + }) + } forceVisible={forceFullPaneForRepoMatch} /> diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx index 9fcfe68c077..a9a766d8b83 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx @@ -1,6 +1,6 @@ import type React from 'react' import { Terminal } from 'lucide-react' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec' import type { RepoSourceControlAiOverrides, diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx index 7e600b11bf6..9eec7bf2319 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' import type React from 'react' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { normalizeSourceControlAiSettings } from '../../../../shared/source-control-ai' import type { SourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save' import { useAppStore } from '../../store' diff --git a/src/renderer/src/components/settings/RepositoryWindowsRuntimeSection.tsx b/src/renderer/src/components/settings/RepositoryWindowsRuntimeSection.tsx index 23831b7f601..7c5743c196b 100644 --- a/src/renderer/src/components/settings/RepositoryWindowsRuntimeSection.tsx +++ b/src/renderer/src/components/settings/RepositoryWindowsRuntimeSection.tsx @@ -1,4 +1,5 @@ -import type { GlobalSettings, Project, ProjectUpdateArgs } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Project, ProjectUpdateArgs } from '../../../../shared/project-types' import { SearchableSetting } from './SearchableSetting' import type { SettingsSearchEntry } from './settings-search' import { matchesSettingsSearch } from './settings-search' diff --git a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx index eaf2b453988..a01d7d99ffe 100644 --- a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx +++ b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx @@ -3,7 +3,8 @@ 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/types' +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 e9bef371510..afd07379f19 100644 --- a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx @@ -1,18 +1,30 @@ -import type { GlobalSettings, Repo } from '../../../../shared/types' +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 } @@ -20,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') + } + )} +

+
+ +
+
+ ({ useSettingsSetupGuideProgress: vi.fn() diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index c56e71b96d0..11f73b3016d 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -3,7 +3,8 @@ import { useMemo } from 'react' import { ArrowLeft, Search, Server } from 'lucide-react' import type { RepoIcon } from '../../../../shared/repo-icon' import type { SettingsNavIcon, SettingsNavInstallStatus } from '@/lib/settings-navigation-types' -import type { GitHubRepositoryIdentity, GlobalSettings } from '../../../../shared/types' +import type { GitHubRepositoryIdentity } from '../../../../shared/github/pull-request-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ShortcutKeyCombo } from '../ShortcutKeyCombo' import { cn } from '@/lib/utils' diff --git a/src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx b/src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx index cb0b4c8dc5a..1bdacd6e024 100644 --- a/src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx +++ b/src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx @@ -1,5 +1,6 @@ import { Terminal } from 'lucide-react' -import type { GlobalSettings, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { CustomAgentId } from '../../../../shared/commit-message-agent-spec' import { CUSTOM_AGENT_ID, isCustomAgentId } from '../../../../shared/commit-message-agent-spec' import { diff --git a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx index 56906be96d3..9d00e6505cd 100644 --- a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx +++ b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx @@ -1,7 +1,8 @@ import { useMemo } from 'react' import type React from 'react' import { toast } from 'sonner' -import type { GlobalSettings, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { isFolderRepo } from '../../../../shared/repo-kind' import type { SourceControlAiSettings, diff --git a/src/renderer/src/components/settings/SparsePresetSettingsSection.test.tsx b/src/renderer/src/components/settings/SparsePresetSettingsSection.test.tsx index bcdfccf6e51..271f66d6570 100644 --- a/src/renderer/src/components/settings/SparsePresetSettingsSection.test.tsx +++ b/src/renderer/src/components/settings/SparsePresetSettingsSection.test.tsx @@ -1,6 +1,6 @@ import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SparsePreset } from '../../../../shared/types' +import type { SparsePreset } from '../../../../shared/worktree/create-types' import { SparsePresetSettingsSection } from './SparsePresetSettingsSection' const storeMock = vi.hoisted(() => ({ diff --git a/src/renderer/src/components/settings/SparsePresetSettingsSection.tsx b/src/renderer/src/components/settings/SparsePresetSettingsSection.tsx index a59f8344e44..4c641bb8125 100644 --- a/src/renderer/src/components/settings/SparsePresetSettingsSection.tsx +++ b/src/renderer/src/components/settings/SparsePresetSettingsSection.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { Plus } from 'lucide-react' -import type { SparsePreset } from '../../../../shared/types' +import type { SparsePreset } from '../../../../shared/worktree/create-types' import { useAppStore } from '../../store' import { parseSparsePresetDirectories } from '@/lib/sparse-preset-draft' import { useMountedRef } from '@/hooks/useMountedRef' diff --git a/src/renderer/src/components/settings/TasksPane.test.tsx b/src/renderer/src/components/settings/TasksPane.test.tsx index e87df95c0db..80376ab87d7 100644 --- a/src/renderer/src/components/settings/TasksPane.test.tsx +++ b/src/renderer/src/components/settings/TasksPane.test.tsx @@ -4,7 +4,8 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings, TaskProvider } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TaskProvider } from '../../../../shared/task-providers' import type { TaskProviderReadiness } from './task-source-setup-state' import { TasksPane } from './TasksPane' diff --git a/src/renderer/src/components/settings/TasksPane.tsx b/src/renderer/src/components/settings/TasksPane.tsx index c5b547b3850..8b1eb14929a 100644 --- a/src/renderer/src/components/settings/TasksPane.tsx +++ b/src/renderer/src/components/settings/TasksPane.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { Github, Gitlab } from 'lucide-react' -import type { GlobalSettings, TaskProvider } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TaskProvider } from '../../../../shared/task-providers' import { TASK_PROVIDERS, normalizeVisibleTaskProviders, diff --git a/src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx b/src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx index a9f361d7ce3..f06fe63775a 100644 --- a/src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx +++ b/src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx @@ -3,7 +3,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { TerminalAdvancedSection } from './TerminalAdvancedSection' vi.mock('@/i18n/i18n', () => ({ diff --git a/src/renderer/src/components/settings/TerminalAdvancedSection.tsx b/src/renderer/src/components/settings/TerminalAdvancedSection.tsx index 27d29ed4241..a0a2df5c569 100644 --- a/src/renderer/src/components/settings/TerminalAdvancedSection.tsx +++ b/src/renderer/src/components/settings/TerminalAdvancedSection.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_MAX, DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN, diff --git a/src/renderer/src/components/settings/TerminalAdvancedTypographyControls.tsx b/src/renderer/src/components/settings/TerminalAdvancedTypographyControls.tsx index b6fd19db228..870314eb64e 100644 --- a/src/renderer/src/components/settings/TerminalAdvancedTypographyControls.tsx +++ b/src/renderer/src/components/settings/TerminalAdvancedTypographyControls.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { DEFAULT_TERMINAL_FONT_WEIGHT, DEFAULT_TERMINAL_FONT_WEIGHT_BOLD, diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx index d0ee90eb03c..b13f535a83a 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { matchesSettingsSearch, normalizeSettingsSearchQuery, diff --git a/src/renderer/src/components/settings/TerminalCursorAppearanceSection.tsx b/src/renderer/src/components/settings/TerminalCursorAppearanceSection.tsx index 09219834edd..5188f0e3424 100644 --- a/src/renderer/src/components/settings/TerminalCursorAppearanceSection.tsx +++ b/src/renderer/src/components/settings/TerminalCursorAppearanceSection.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { NumberField, SettingsRow, diff --git a/src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx b/src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx index d5e6ecc54bd..949dffbb6a7 100644 --- a/src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx +++ b/src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx @@ -3,7 +3,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { TerminalFontSizeSetting } from './TerminalFontSizeSetting' vi.mock('@/i18n/i18n', () => ({ diff --git a/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx b/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx index 61a451c7377..a879136717f 100644 --- a/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx +++ b/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx @@ -1,5 +1,5 @@ import { Minus, Plus } from 'lucide-react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Button } from '../ui/button' import { Input } from '../ui/input' import { SettingsRow } from './SettingsFormControls' diff --git a/src/renderer/src/components/settings/TerminalInteractionSection.tsx b/src/renderer/src/components/settings/TerminalInteractionSection.tsx index 4ad9d67e762..f7c92e998a4 100644 --- a/src/renderer/src/components/settings/TerminalInteractionSection.tsx +++ b/src/renderer/src/components/settings/TerminalInteractionSection.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { RotateCcw } from 'lucide-react' import { Slider } from '../ui/slider' import { Button } from '../ui/button' diff --git a/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx b/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx index 0e4036f2478..f02cbf3d614 100644 --- a/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx +++ b/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { useDetectedOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt' import { translate } from '@/i18n/i18n' import { SearchableSetting } from './SearchableSetting' diff --git a/src/renderer/src/components/settings/TerminalPane.tsx b/src/renderer/src/components/settings/TerminalPane.tsx index a2e463edfa2..aacaeffab13 100644 --- a/src/renderer/src/components/settings/TerminalPane.tsx +++ b/src/renderer/src/components/settings/TerminalPane.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Separator } from '../ui/separator' import { matchesSettingsSearch } from './settings-search' import { useAppStore } from '../../store' diff --git a/src/renderer/src/components/settings/TerminalPaneAppearanceSection.tsx b/src/renderer/src/components/settings/TerminalPaneAppearanceSection.tsx index bb4e3f79397..f9d07b8d63f 100644 --- a/src/renderer/src/components/settings/TerminalPaneAppearanceSection.tsx +++ b/src/renderer/src/components/settings/TerminalPaneAppearanceSection.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { DEFAULT_TERMINAL_INACTIVE_PANE_OPACITY } from '../../../../shared/constants' import { NumberField, SettingsSubsectionHeader } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' diff --git a/src/renderer/src/components/settings/TerminalRenderingSection.tsx b/src/renderer/src/components/settings/TerminalRenderingSection.tsx index 5917247171f..47e3017d353 100644 --- a/src/renderer/src/components/settings/TerminalRenderingSection.tsx +++ b/src/renderer/src/components/settings/TerminalRenderingSection.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { SettingsRow, SettingsSegmentedControl, diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx index 211424b09be..fd2ffa0345e 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Mock } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' type Cleanup = () => void diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx index 20b70ca2139..b3a881f9cd0 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx @@ -14,7 +14,7 @@ import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-lig import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' import { PREVIEW_BUFFER } from './terminal-preview-content' import { SettingsSwitch } from './SettingsFormControls' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' // Why: pinned so PREVIEW_BUFFER never wraps; 36 cols fits the 32-char longest line + margin (larger fonts clip, not wrap). diff --git a/src/renderer/src/components/settings/TerminalSetupScriptSection.tsx b/src/renderer/src/components/settings/TerminalSetupScriptSection.tsx index c3b6f549cd8..92bb6fa44a3 100644 --- a/src/renderer/src/components/settings/TerminalSetupScriptSection.tsx +++ b/src/renderer/src/components/settings/TerminalSetupScriptSection.tsx @@ -1,4 +1,5 @@ -import type { GlobalSettings, SetupScriptLaunchMode } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { SetupScriptLaunchMode } from '../../../../shared/worktree/launch-types' import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group' import { SettingsRow, SettingsSubsectionHeader } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' diff --git a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts index 0114ea3ebf4..39cd8763001 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts +++ b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { UseWarpThemeImportReturn } from './useWarpThemeImport' let themeTarget: 'dark' | 'light' | undefined = 'dark' diff --git a/src/renderer/src/components/settings/TerminalThemeSections.tsx b/src/renderer/src/components/settings/TerminalThemeSections.tsx index 1fa765bb271..133801aa497 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.tsx +++ b/src/renderer/src/components/settings/TerminalThemeSections.tsx @@ -1,5 +1,5 @@ import { useState, type Dispatch, type ReactNode, type SetStateAction } from 'react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { ColorField, SettingsSegmentedControl, diff --git a/src/renderer/src/components/settings/TerminalWindowSection.tsx b/src/renderer/src/components/settings/TerminalWindowSection.tsx index 0c38f5c00b6..863d7fa11b0 100644 --- a/src/renderer/src/components/settings/TerminalWindowSection.tsx +++ b/src/renderer/src/components/settings/TerminalWindowSection.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from 'react' import { RotateCw } from 'lucide-react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Button } from '../ui/button' import { Label } from '../ui/label' import { Switch } from '../ui/switch' diff --git a/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx b/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx index 8a191087092..6009e551653 100644 --- a/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx +++ b/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' import { SettingsRow, diff --git a/src/renderer/src/components/settings/VoicePane.test.tsx b/src/renderer/src/components/settings/VoicePane.test.tsx index 84f6bbd5393..1cf41781125 100644 --- a/src/renderer/src/components/settings/VoicePane.test.tsx +++ b/src/renderer/src/components/settings/VoicePane.test.tsx @@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DeveloperPermissionRequestResult } from '../../../../shared/developer-permissions-types' import type { SpeechModelManifest } from '../../../../shared/speech-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { getDefaultVoiceSettings } from '../../../../shared/constants' import { handleVoiceDictationToggle, VoicePane } from './VoicePane' diff --git a/src/renderer/src/components/settings/VoicePane.tsx b/src/renderer/src/components/settings/VoicePane.tsx index f6729301702..7d4d938325f 100644 --- a/src/renderer/src/components/settings/VoicePane.tsx +++ b/src/renderer/src/components/settings/VoicePane.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { getDefaultVoiceSettings } from '../../../../shared/constants' import type { SpeechModelManifest, VoiceSettings } from '../../../../shared/speech-types' import { Separator } from '../ui/separator' diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx index d55a2336af7..26b86e50373 100644 --- a/src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx +++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx @@ -4,7 +4,7 @@ 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 type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { WorkspaceDirectorySetting } from './WorkspaceDirectorySetting' vi.mock('../sidebar/use-sidebar-host-scope-options', () => ({ diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx index 8633b735a96..6ca5e985170 100644 --- a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx +++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useId, useRef, useState } from 'react' import { FolderOpen, RotateCcw } from 'lucide-react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { getEffectiveHostSetting, getHostSettingOverride, diff --git a/src/renderer/src/components/settings/WorktreeSymlinksSection.tsx b/src/renderer/src/components/settings/WorktreeSymlinksSection.tsx index 33a918a9cdf..02f90518df3 100644 --- a/src/renderer/src/components/settings/WorktreeSymlinksSection.tsx +++ b/src/renderer/src/components/settings/WorktreeSymlinksSection.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { Folder, Link2, Plus, X } from 'lucide-react' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { Button } from '../ui/button' import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '../ui/command' import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' diff --git a/src/renderer/src/components/settings/appearance-interface-summary.ts b/src/renderer/src/components/settings/appearance-interface-summary.ts index e37a4f2aa66..ee2497be488 100644 --- a/src/renderer/src/components/settings/appearance-interface-summary.ts +++ b/src/renderer/src/components/settings/appearance-interface-summary.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { getUiLanguageChoiceLabel, SHOW_UI_LANGUAGE_SETTING, diff --git a/src/renderer/src/components/settings/appearance-status-bar-antigravity-toggle-search.ts b/src/renderer/src/components/settings/appearance-status-bar-antigravity-toggle-search.ts index 68c6513bb72..0b0474e85e7 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-antigravity-toggle-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-antigravity-toggle-search.ts @@ -1,4 +1,4 @@ -import type { StatusBarItem } from '../../../../shared/types' +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' diff --git a/src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts b/src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts index 3ff4295238e..f2709bbe8a7 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts @@ -1,4 +1,4 @@ -import type { StatusBarItem } from '../../../../shared/types' +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 02403c24f4b..9be73da6a91 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -1,4 +1,4 @@ -import type { StatusBarItem } from '../../../../shared/types' +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' diff --git a/src/renderer/src/components/settings/codex-account-auth-warning.ts b/src/renderer/src/components/settings/codex-account-auth-warning.ts index 9db1e3ae8e5..85fce048857 100644 --- a/src/renderer/src/components/settings/codex-account-auth-warning.ts +++ b/src/renderer/src/components/settings/codex-account-auth-warning.ts @@ -2,7 +2,7 @@ import type { ProviderRateLimits, RateLimitRuntimeTarget } from '../../../../shared/rate-limit-types' -import type { CodexSystemDefaultIdentity } from '../../../../shared/types' +import type { CodexSystemDefaultIdentity } from '../../../../shared/managed-account-types' import { isCodexAuthError } from '../../../../shared/codex-auth-errors' type AccountRuntime = { diff --git a/src/renderer/src/components/settings/codex-session-source-home-control.test.tsx b/src/renderer/src/components/settings/codex-session-source-home-control.test.tsx index 731398c3d0b..fdf27134754 100644 --- a/src/renderer/src/components/settings/codex-session-source-home-control.test.tsx +++ b/src/renderer/src/components/settings/codex-session-source-home-control.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { buildCodexSessionSourceHomeControl } from './codex-session-source-home-control' function settingsWith( diff --git a/src/renderer/src/components/settings/codex-session-source-home-control.tsx b/src/renderer/src/components/settings/codex-session-source-home-control.tsx index 6a49a7b6042..b459d9ffb9d 100644 --- a/src/renderer/src/components/settings/codex-session-source-home-control.tsx +++ b/src/renderer/src/components/settings/codex-session-source-home-control.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { Info } from 'lucide-react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { normalizeGlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime' import { Button } from '../ui/button' import { Input } from '../ui/input' diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index a79f994d5fa..3df690ce398 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -23,6 +23,25 @@ export const getGeneralWorkspaceSearchEntries = createLocalizedCatalog(() => [ ...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/notification-settings-copy.ts b/src/renderer/src/components/settings/notification-settings-copy.ts index f2d06aeab16..266e2183d75 100644 --- a/src/renderer/src/components/settings/notification-settings-copy.ts +++ b/src/renderer/src/components/settings/notification-settings-copy.ts @@ -1,5 +1,5 @@ import { toast } from 'sonner' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' type SystemNotificationSettingsCopy = { diff --git a/src/renderer/src/components/settings/provider-account-scope.ts b/src/renderer/src/components/settings/provider-account-scope.ts index 5687de9adf8..f6745b3ed9c 100644 --- a/src/renderer/src/components/settings/provider-account-scope.ts +++ b/src/renderer/src/components/settings/provider-account-scope.ts @@ -1,6 +1,6 @@ import { translate } from '@/i18n/i18n' import { getLocalExecutionHostLabel } from '../../../../shared/execution-host' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' export type ProviderAccountScope = { label: string diff --git a/src/renderer/src/components/settings/provider-account-visibility.test.ts b/src/renderer/src/components/settings/provider-account-visibility.test.ts index 7c18062c5de..551584779ca 100644 --- a/src/renderer/src/components/settings/provider-account-visibility.test.ts +++ b/src/renderer/src/components/settings/provider-account-visibility.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState -} from '../../../../shared/types' +} from '../../../../shared/managed-account-types' import { getProviderAccountActiveIdForView, getProviderAccountRuntime, diff --git a/src/renderer/src/components/settings/provider-account-visibility.ts b/src/renderer/src/components/settings/provider-account-visibility.ts index a572d2f4ab2..cb6ac050814 100644 --- a/src/renderer/src/components/settings/provider-account-visibility.ts +++ b/src/renderer/src/components/settings/provider-account-visibility.ts @@ -3,7 +3,7 @@ import type { ClaudeRateLimitAccountsState, CodexManagedAccountRuntimeSelection, CodexRateLimitAccountsState -} from '../../../../shared/types' +} from '../../../../shared/managed-account-types' type ProviderAccount = | ClaudeRateLimitAccountsState['accounts'][number] diff --git a/src/renderer/src/components/settings/repository-git-author-search-entries.ts b/src/renderer/src/components/settings/repository-git-author-search-entries.ts index eb202d55209..e18437c05cc 100644 --- a/src/renderer/src/components/settings/repository-git-author-search-entries.ts +++ b/src/renderer/src/components/settings/repository-git-author-search-entries.ts @@ -1,4 +1,4 @@ -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import type { SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' diff --git a/src/renderer/src/components/settings/repository-git-hooks-search-entries.ts b/src/renderer/src/components/settings/repository-git-hooks-search-entries.ts index a5323265f5b..281622b66db 100644 --- a/src/renderer/src/components/settings/repository-git-hooks-search-entries.ts +++ b/src/renderer/src/components/settings/repository-git-hooks-search-entries.ts @@ -1,4 +1,4 @@ -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import type { SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' 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 2aacea52b61..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 @@ -1,10 +1,32 @@ -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import type { SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' 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/settings/repository-host-setup-options.ts b/src/renderer/src/components/settings/repository-host-setup-options.ts index c66d8fd1442..987bcd80f53 100644 --- a/src/renderer/src/components/settings/repository-host-setup-options.ts +++ b/src/renderer/src/components/settings/repository-host-setup-options.ts @@ -4,7 +4,7 @@ import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { ProjectHostSetup, ProjectHostSetupState } from '../../../../shared/types' +import type { ProjectHostSetup, ProjectHostSetupState } from '../../../../shared/project-types' import { translate } from '@/i18n/i18n' export type SetupHostOption = { diff --git a/src/renderer/src/components/settings/repository-icon-github.test.ts b/src/renderer/src/components/settings/repository-icon-github.test.ts index 2926d0fd2b4..3abb42ae89d 100644 --- a/src/renderer/src/components/settings/repository-icon-github.test.ts +++ b/src/renderer/src/components/settings/repository-icon-github.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { githubAvatarIcon } from '../../../../shared/repo-icon' import { buildRepositoryGitHubAvatarUpdate, diff --git a/src/renderer/src/components/settings/repository-icon-github.ts b/src/renderer/src/components/settings/repository-icon-github.ts index 0a44a88f030..f3dbee71fe7 100644 --- a/src/renderer/src/components/settings/repository-icon-github.ts +++ b/src/renderer/src/components/settings/repository-icon-github.ts @@ -1,4 +1,5 @@ -import type { GitHubRepositoryIdentity, Repo } from '../../../../shared/types' +import type { GitHubRepositoryIdentity } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' import { githubAvatarIcon, githubAvatarSlug, type RepoIcon } from '../../../../shared/repo-icon' import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' import { callRuntimeRpc, type getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' diff --git a/src/renderer/src/components/settings/repository-identity-search.ts b/src/renderer/src/components/settings/repository-identity-search.ts index 239090948ec..0be209f66d1 100644 --- a/src/renderer/src/components/settings/repository-identity-search.ts +++ b/src/renderer/src/components/settings/repository-identity-search.ts @@ -1,4 +1,4 @@ -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { normalizeSettingsSearchQuery } from './settings-search' export function matchesRepositoryIdentitySearch(query: string, repo: Repo): boolean { diff --git a/src/renderer/src/components/settings/repository-search.ts b/src/renderer/src/components/settings/repository-search.ts index ea878ee2b8e..c9d2a91682f 100644 --- a/src/renderer/src/components/settings/repository-search.ts +++ b/src/renderer/src/components/settings/repository-search.ts @@ -1,4 +1,4 @@ -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { isFolderRepo } from '../../../../shared/repo-kind' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import type { SettingsSearchEntry } from './settings-search' diff --git a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts index 370f60f3d2f..079b3d2cb43 100644 --- a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts +++ b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts @@ -7,7 +7,7 @@ import { type SourceControlActionId } from '../../../../shared/source-control-ai-actions' import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { completeRepoActionRecipe } from './repository-source-control-ai-labels' import { SOURCE_CONTROL_TEXT_ACTION_ID_SET } from './source-control-action-recipe-options' diff --git a/src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts b/src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts index 9cce9bc30fe..a27db245e6d 100644 --- a/src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts +++ b/src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import type { GlobalSettings, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec' import type { RepoSourceControlAiOverrides, diff --git a/src/renderer/src/components/settings/repository-source-control-ai-labels.ts b/src/renderer/src/components/settings/repository-source-control-ai-labels.ts index 64f0a111080..afaa7c9e9eb 100644 --- a/src/renderer/src/components/settings/repository-source-control-ai-labels.ts +++ b/src/renderer/src/components/settings/repository-source-control-ai-labels.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES, resolveSourceControlActionCommandTemplate, diff --git a/src/renderer/src/components/settings/setting-labels.ts b/src/renderer/src/components/settings/setting-labels.ts index 95eb3a26c7c..c2cec96322f 100644 --- a/src/renderer/src/components/settings/setting-labels.ts +++ b/src/renderer/src/components/settings/setting-labels.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' export const SETTING_LABELS: Partial> = { terminalFontSize: 'Font Size', diff --git a/src/renderer/src/components/settings/settings-project-list.test.ts b/src/renderer/src/components/settings/settings-project-list.test.ts index 41f82a095bc..2b60d077ef3 100644 --- a/src/renderer/src/components/settings/settings-project-list.test.ts +++ b/src/renderer/src/components/settings/settings-project-list.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import type { ProjectHostSetup, Repo } from '../../../../shared/types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' import { buildRepoIdToHostSelection, buildRepoIdToRepresentative, diff --git a/src/renderer/src/components/settings/settings-project-list.ts b/src/renderer/src/components/settings/settings-project-list.ts index e963c034822..14de9f8d848 100644 --- a/src/renderer/src/components/settings/settings-project-list.ts +++ b/src/renderer/src/components/settings/settings-project-list.ts @@ -1,4 +1,5 @@ -import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID, diff --git a/src/renderer/src/components/settings/shortcut-definition-catalog.ts b/src/renderer/src/components/settings/shortcut-definition-catalog.ts index 882f74ab7fc..758fcbb2202 100644 --- a/src/renderer/src/components/settings/shortcut-definition-catalog.ts +++ b/src/renderer/src/components/settings/shortcut-definition-catalog.ts @@ -5,7 +5,7 @@ import { type KeybindingDefinition, type KeybindingOverrides } from '../../../../shared/keybindings' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { ActivePluginCommand } from '@/store/plugin-panels' import { buildPluginCommandKeybindingDefinitions } from '@/lib/plugin-command-keybindings' import { disabledAgentTabActionIds, groupDefinitions, type ShortcutGroup } from './shortcut-groups' diff --git a/src/renderer/src/components/settings/shortcut-groups.ts b/src/renderer/src/components/settings/shortcut-groups.ts index 054a95baf27..495dbbfdd41 100644 --- a/src/renderer/src/components/settings/shortcut-groups.ts +++ b/src/renderer/src/components/settings/shortcut-groups.ts @@ -5,7 +5,7 @@ import { type KeybindingDefinition } from '../../../../shared/keybindings' import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' export type ShortcutGroup = { title: string diff --git a/src/renderer/src/components/settings/source-control-action-recipe-options.ts b/src/renderer/src/components/settings/source-control-action-recipe-options.ts index 1f109d5ed75..dfeaeda128b 100644 --- a/src/renderer/src/components/settings/source-control-action-recipe-options.ts +++ b/src/renderer/src/components/settings/source-control-action-recipe-options.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { SOURCE_CONTROL_TEXT_ACTION_IDS, type SourceControlActionId diff --git a/src/renderer/src/components/settings/sparse-preset-settings-row.tsx b/src/renderer/src/components/settings/sparse-preset-settings-row.tsx index 44cc9775a06..3c0fa0c4a0c 100644 --- a/src/renderer/src/components/settings/sparse-preset-settings-row.tsx +++ b/src/renderer/src/components/settings/sparse-preset-settings-row.tsx @@ -1,5 +1,5 @@ import { Bookmark, LoaderCircle, Pencil, Trash2 } from 'lucide-react' -import type { SparsePreset } from '../../../../shared/types' +import type { SparsePreset } from '../../../../shared/worktree/create-types' import { cn } from '@/lib/utils' import { Button } from '../ui/button' import { formatSparsePresetUpdatedAt } from './sparse-preset-date' diff --git a/src/renderer/src/components/settings/task-source-setup-state.test.ts b/src/renderer/src/components/settings/task-source-setup-state.test.ts index d2e4e6a5c27..6eec46a34db 100644 --- a/src/renderer/src/components/settings/task-source-setup-state.test.ts +++ b/src/renderer/src/components/settings/task-source-setup-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { TaskProvider } from '../../../../shared/types' +import type { TaskProvider } from '../../../../shared/task-providers' import { getAutoExpandedTaskProvider, getIncompleteVisibleTaskProviders, diff --git a/src/renderer/src/components/settings/task-source-setup-state.ts b/src/renderer/src/components/settings/task-source-setup-state.ts index 3ad27d48fa9..628de69dbf0 100644 --- a/src/renderer/src/components/settings/task-source-setup-state.ts +++ b/src/renderer/src/components/settings/task-source-setup-state.ts @@ -1,4 +1,4 @@ -import type { TaskProvider } from '../../../../shared/types' +import type { TaskProvider } from '../../../../shared/task-providers' import type { IntegrationStatusTone } from '@/components/integration-status-pill' export type TaskProviderReadiness = { diff --git a/src/renderer/src/components/settings/terminal-window-color-groups.tsx b/src/renderer/src/components/settings/terminal-window-color-groups.tsx index 652d9ffc04c..41021681b06 100644 --- a/src/renderer/src/components/settings/terminal-window-color-groups.tsx +++ b/src/renderer/src/components/settings/terminal-window-color-groups.tsx @@ -1,4 +1,4 @@ -import type { TerminalColorOverrides } from '../../../../shared/types' +import type { TerminalColorOverrides } from '../../../../shared/terminal-color-overrides' import { translate } from '@/i18n/i18n' export const COLOR_OVERRIDE_GROUPS: { diff --git a/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx b/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx index cb2cb636692..d00a358996f 100644 --- a/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx +++ b/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx @@ -3,7 +3,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { TaskProvider } from '../../../../shared/types' +import type { TaskProvider } from '../../../../shared/task-providers' import type { TaskProviderReadiness } from './task-source-setup-state' import { useTaskSourceProviderReadiness } from './use-task-source-provider-readiness' diff --git a/src/renderer/src/components/settings/use-task-source-provider-readiness.ts b/src/renderer/src/components/settings/use-task-source-provider-readiness.ts index 97db089a677..6b9fa6171ba 100644 --- a/src/renderer/src/components/settings/use-task-source-provider-readiness.ts +++ b/src/renderer/src/components/settings/use-task-source-provider-readiness.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import type { TaskProvider } from '../../../../shared/types' +import type { TaskProvider } from '../../../../shared/task-providers' import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, useInstalledAgentSkillNames diff --git a/src/renderer/src/components/settings/useGhosttyImport.test.ts b/src/renderer/src/components/settings/useGhosttyImport.test.ts index 26326b99286..ee3cb6560b6 100644 --- a/src/renderer/src/components/settings/useGhosttyImport.test.ts +++ b/src/renderer/src/components/settings/useGhosttyImport.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { GhosttyImportPreview, GlobalSettings } from '../../../../shared/types' +import type { GhosttyImportPreview, GlobalSettings } from '../../../../shared/global-settings-types' const mockStateValues: unknown[] = [] let mockStateIndex = 0 diff --git a/src/renderer/src/components/settings/useGhosttyImport.ts b/src/renderer/src/components/settings/useGhosttyImport.ts index 8031008d21a..39e25f9d044 100644 --- a/src/renderer/src/components/settings/useGhosttyImport.ts +++ b/src/renderer/src/components/settings/useGhosttyImport.ts @@ -1,5 +1,5 @@ import { useState } from 'react' -import type { GhosttyImportPreview, GlobalSettings } from '../../../../shared/types' +import type { GhosttyImportPreview, GlobalSettings } from '../../../../shared/global-settings-types' import { useMountedRef } from '../../hooks/useMountedRef' export type UseGhosttyImportReturn = { diff --git a/src/renderer/src/components/settings/useWarpThemeImport.test.ts b/src/renderer/src/components/settings/useWarpThemeImport.test.ts index cc0a31288dd..c76850cfbda 100644 --- a/src/renderer/src/components/settings/useWarpThemeImport.test.ts +++ b/src/renderer/src/components/settings/useWarpThemeImport.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { MAX_TERMINAL_CUSTOM_THEMES, type WarpThemeImportPreview diff --git a/src/renderer/src/components/settings/useWarpThemeImport.ts b/src/renderer/src/components/settings/useWarpThemeImport.ts index 615df1a0f54..94eadcda7ba 100644 --- a/src/renderer/src/components/settings/useWarpThemeImport.ts +++ b/src/renderer/src/components/settings/useWarpThemeImport.ts @@ -1,6 +1,6 @@ import { useState } from 'react' import { toast } from 'sonner' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { MAX_TERMINAL_CUSTOM_THEMES, normalizeTerminalCustomThemes, diff --git a/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts b/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts index f74ffeaa612..629e9ff0a23 100644 --- a/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts +++ b/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts @@ -1,6 +1,7 @@ import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { ComputerUsePermissionStatusResult } from '../../../../shared/computer-use-permissions-types' -import type { GlobalSettings, Repo } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' export type SetupScriptProbeState = { signature: string | null diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx index 7310d450a69..cb793b2bab6 100644 --- a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import type * as ReactModule from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' type ButtonCapture = { label: string diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx index f3ef3b43c22..a7873acf0fc 100644 --- a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx @@ -12,7 +12,7 @@ import { import { Button } from '@/components/ui/button' import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { finishProjectAddWithDefaultCheckout } from './project-added-default-checkout' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index abdd169d4fd..4e3bcdc17fd 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -4,7 +4,7 @@ import type { ComponentProps } from 'react' import { Dialog } from '@/components/ui/dialog' import { TooltipProvider } from '@/components/ui/tooltip' import { AddRepoDialogStepContent } from './AddRepoDialogStepContent' -import type { NestedRepoScanResult } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' const nestedScan: NestedRepoScanResult = { selectedPath: '/workspace/platform', diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index 9a0da649f8c..5e405d7c134 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -6,7 +6,7 @@ import { AddRepoLocalStartStep } from './AddRepoStartSteps' import { AddRepoServerPathStartStep } from './AddRepoServerStartStep' import { AddRepoNestedImportStep } from './AddRepoNestedImportStep' import type { AddRepoDialogStep } from './add-repo-dialog-types' -import type { NestedRepoScanResult } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' import type { GitAvailability } from './create-project-defaults' diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx index ff243a3a5cc..437f9e4d875 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AddRepoNestedImportStep } from './AddRepoNestedImportStep' import { TooltipProvider } from '@/components/ui/tooltip' import { Dialog } from '@/components/ui/dialog' -import type { NestedRepoScanResult } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' const scan: NestedRepoScanResult = { selectedPath: '/workspace/platform', diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx index ef41edbf1a1..6366214bb66 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx @@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { NestedRepoChecklist } from '@/components/repo/NestedRepoChecklist' -import type { NestedRepoScanResult } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' import { NestedRepoScanLimitNotice } from '../repo/NestedRepoScanLimitNotice' import { getRuntimePathBasename } from '../../../../shared/cross-platform-path' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts b/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts index fc8829474ee..6ca5d28f16f 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactModule from 'react' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' const mocks = vi.hoisted(() => ({ stateValues: [] as unknown[], diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index 0acdaa888b1..248bcd87058 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' -import type { NestedRepoScanResult } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types' import { createNestedRepoTelemetryAttemptId } from '../../../../shared/nested-repo-telemetry' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx index 65700ef574b..a36eca15297 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx @@ -1,7 +1,9 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ButtonHTMLAttributes, ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' const mocks = vi.hoisted(() => { const state = { diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx index 427b96308cc..8e442f27d39 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx @@ -1,6 +1,6 @@ import { Workflow } from 'lucide-react' import type { JSX } from 'react' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { DeleteWorktreeDirtyChangeHint } from './DeleteWorktreeDirtyChangeHint' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx index e6165e8c437..9f557af4324 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx @@ -1,7 +1,7 @@ import type { JSX } from 'react' import { LoaderCircle } from 'lucide-react' import { ScrollArea } from '@/components/ui/scroll-area' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { DeleteWorktreeDirtyChangeHint } from './DeleteWorktreeDirtyChangeHint' type DeleteState = { diff --git a/src/renderer/src/components/sidebar/HiddenWorktreeRecoveryList.tsx b/src/renderer/src/components/sidebar/HiddenWorktreeRecoveryList.tsx index 8f842bee693..567428702b8 100644 --- a/src/renderer/src/components/sidebar/HiddenWorktreeRecoveryList.tsx +++ b/src/renderer/src/components/sidebar/HiddenWorktreeRecoveryList.tsx @@ -4,7 +4,8 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { translate } from '@/i18n/i18n' -import type { DetectedWorktreeListResult, Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types' import { getHiddenImportableExternalWorktrees, getVisibleNonOrcaWorktrees diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx index a392374762d..5b78c067d46 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx @@ -1,7 +1,8 @@ import { renderToStaticMarkup } from 'react-dom/server' import type * as ReactModule from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' type ButtonCapture = { label: string diff --git a/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx b/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx index f3580472439..8a9651aa6a8 100644 --- a/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx +++ b/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import type * as ReactModule from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' const mocks = vi.hoisted(() => ({ state: { diff --git a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx index dc43f2bb8fe..9ec07ccf651 100644 --- a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx +++ b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx @@ -20,7 +20,7 @@ import { import { driveBreadcrumbPath, splitBrowsePath } from './remote-file-browser-drive-paths' import { browseRuntimeServerDirectory } from '@/runtime/runtime-server-directory-browser' import { translate } from '@/i18n/i18n' -import type { FilesystemPathFlavor } from '../../../../shared/types' +import type { FilesystemPathFlavor } from '../../../../shared/filesystem-entry-types' type RemoteFileBrowserProps = ( | { targetId: string; runtimeEnvironmentId?: never } diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts b/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts index aa3dc5bc07d..18a1941298c 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts @@ -6,7 +6,7 @@ import { } from './setup-script-prompt-render-state' import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' import { getRepoHostIdentityForParts } from '@/store/slices/repo-host-identity' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' function repoIdentity(repoId: string, hostId: string): string { return getRepoHostIdentityForParts(repoId, hostId) diff --git a/src/renderer/src/components/sidebar/Sidebar.test.tsx b/src/renderer/src/components/sidebar/Sidebar.test.tsx index 6704020676b..098ed7f203c 100644 --- a/src/renderer/src/components/sidebar/Sidebar.test.tsx +++ b/src/renderer/src/components/sidebar/Sidebar.test.tsx @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { cleanup, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' const mocks = vi.hoisted(() => ({ state: {} as Record, diff --git a/src/renderer/src/components/sidebar/SidebarFeedbackDialog.tsx b/src/renderer/src/components/sidebar/SidebarFeedbackDialog.tsx index 1bdd1050a68..f0d034edefe 100644 --- a/src/renderer/src/components/sidebar/SidebarFeedbackDialog.tsx +++ b/src/renderer/src/components/sidebar/SidebarFeedbackDialog.tsx @@ -13,7 +13,7 @@ import { } from '@/components/ui/dialog' import { useMountedRef } from '@/hooks/useMountedRef' import { cn } from '@/lib/utils' -import type { GitHubViewer } from '../../../../shared/types' +import type { GitHubViewer } from '../../../../shared/github/pull-request-types' import { translate } from '@/i18n/i18n' import { extractImageFilesFromDataTransfer, diff --git a/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx b/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx index b310ba988c6..9f3920fa615 100644 --- a/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx @@ -6,7 +6,10 @@ import { DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu' import { ALL_EXECUTION_HOSTS_SCOPE, type ExecutionHostId } from '../../../../shared/execution-host' -import type { VisibleWorkspaceHostIds, WorkspaceHostScope } from '../../../../shared/types' +import type { + VisibleWorkspaceHostIds, + WorkspaceHostScope +} from '../../../../shared/ui-chrome-types' import { getSidebarHostHealthLabel, type SidebarHostOption } from './sidebar-host-options' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index d76022b77ac..87b51ee5b23 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -6,7 +6,8 @@ import { waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { getDefaultSettings } from '../../../../shared/constants' -import type { GlobalSettings, Repo } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' import { i18n } from '../../i18n/i18n' import { PSEUDO_LOCALIZATION_LOCALE } from '../../i18n/pseudo-localization' diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index d8165ef534b..3133f7aa6d1 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -3,7 +3,7 @@ import { Bell, BookOpen, CalendarClock, EyeOff, Files, Search, Smartphone } from import { useTranslation } from 'react-i18next' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount' import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' diff --git a/src/renderer/src/components/sidebar/SidebarProjectFilterPanel.tsx b/src/renderer/src/components/sidebar/SidebarProjectFilterPanel.tsx index b9a705a9295..9057038b427 100644 --- a/src/renderer/src/components/sidebar/SidebarProjectFilterPanel.tsx +++ b/src/renderer/src/components/sidebar/SidebarProjectFilterPanel.tsx @@ -11,7 +11,7 @@ import { } from '@/components/ui/command' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { searchRepos } from '@/lib/repo-search' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { translate } from '@/i18n/i18n' function projectCommandFilter(_value: string, search: string, keywords?: string[]): number { diff --git a/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx index 8aa62ac9f61..65623ee8983 100644 --- a/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx @@ -6,7 +6,7 @@ import { DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu' import { SidebarProjectFilterPanel } from './SidebarProjectFilterPanel' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { translate } from '@/i18n/i18n' function getProjectFilterVisibilityLabel({ 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/WorkspaceKanbanCard.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx index cd7b94dd725..aa515e40eff 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx @@ -1,7 +1,8 @@ import React from 'react' import { Pin } from 'lucide-react' import { Badge } from '@/components/ui/badge' -import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types' import WorktreeCard from './WorktreeCard' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx index f1dd7234eaf..f7fcb4d4bc8 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx @@ -5,7 +5,9 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WORKTREE_PALETTE_QUERY_MAX_BYTES } from '@/lib/worktree-palette-query-bounds' import { useAppStore } from '@/store' -import type { Repo, Worktree, WorktreeMeta } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { Worktree } from '../../../../shared/worktree/types' import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' import type { WorkspaceKanbanLaneView } from './workspace-kanban-search' diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx index 25ae48868db..cf56ebe275f 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx @@ -4,7 +4,8 @@ import React, { act } 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 { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' import type * as WorkspaceBoardTaskStatusSync from './workspace-board-task-status-sync' diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index b457c378a15..1158e844abd 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -49,7 +49,8 @@ import { shouldWriteManualOrderForGroupDrop, type WorktreeDragGroup } from './worktree-manual-order' -import type { WorkspaceStatus, Worktree, WorktreeMeta } from '../../../../shared/types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types' import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses' import { STATUS_BAR_RESERVE_HEIGHT, WORKSPACE_TOP_CHROME_HEIGHT } from './workspace-chrome-metrics' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.test.tsx index ce59a22718b..fd0f12c45a2 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.test.tsx @@ -1,7 +1,7 @@ import React, { isValidElement } from 'react' import { describe, expect, it, vi } from 'vitest' import { SheetHeader, SheetTitle } from '@/components/ui/sheet' -import type { WorkspaceStatusDefinition } from '../../../../shared/types' +import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/types' import SidebarFilter from './SidebarFilter' import WorkspaceKanbanDrawerHeader from './WorkspaceKanbanDrawerHeader' import WorkspaceKanbanSearchField from './WorkspaceKanbanSearchField' diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx index 051a46057c7..0df7680333c 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx @@ -2,7 +2,7 @@ import React from 'react' import { X } from 'lucide-react' import { Button } from '@/components/ui/button' import { SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet' -import type { WorkspaceStatusDefinition } from '../../../../shared/types' +import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/types' import SidebarFilter from './SidebarFilter' import WorkspaceKanbanSearchField from './WorkspaceKanbanSearchField' import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu' diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx index d5b9b5c4176..b1ccfcccd3e 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx @@ -11,7 +11,8 @@ import { cleanup, render } from '@testing-library/react' import { createRef } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' const WINDOW_START = 20 const WINDOW_END = 25 diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx index 49b9232839b..6b3ce32689a 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' -import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types' import WorkspaceKanbanCard from './WorkspaceKanbanCard' import { registerWorkspaceKanbanVirtualLaneLayout } from './workspace-kanban-virtual-lane-layout' diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx index 161bb9ff597..f5a81bdb3bc 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx @@ -2,7 +2,8 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import React, { createRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo, WorkspaceStatusDefinition, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types' const virtualWindow = vi.hoisted(() => ({ startIndex: 0, visibleCount: 4 })) const animationFrames = new Map() diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx index 48d77c9b7f7..a13468fc340 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx @@ -8,12 +8,12 @@ import React, { useState } from 'react' import { useVirtualizer, type Range } from '@tanstack/react-virtual' +import type { Repo } from '../../../../shared/repo-types' import type { - Repo, WorkspaceStatus, WorkspaceStatusDefinition, Worktree -} from '../../../../shared/types' +} from '../../../../shared/worktree/types' import type { WorkspaceKanbanLaneView } from './workspace-kanban-search' import { extractWorkspaceKanbanLaneRange } from './workspace-kanban-lane-range' import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane' diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx index bba46379d77..40dead0e341 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx @@ -2,7 +2,7 @@ import { createRoot, type Root } from 'react-dom/client' import { act, type ReactNode } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { WorkspaceStatusDefinition } from '../../../../shared/types' +import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/types' const statuses: WorkspaceStatusDefinition[] = [{ id: 'todo', label: 'Todo' }] ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx index a7fd4693143..d4bceb02494 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx @@ -10,7 +10,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { SettingsSwitch } from '../settings/SettingsFormControls' -import type { WorkspaceStatusDefinition } from '../../../../shared/types' +import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/types' import { getWorkspaceStatusVisualMeta } from './workspace-status' import WorkspaceStatusAppearancePopover from './WorkspaceStatusAppearancePopover' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx index 91987985d59..fc2a3e5e9bc 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx @@ -3,7 +3,8 @@ 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, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { serializeWorkspaceLaneFullIds } from './workspace-kanban-filtered-drop-index' import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane' diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx index cceda5e12ee..1379246c5be 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx @@ -1,11 +1,11 @@ import React, { useMemo, useRef } from 'react' import { Plus } from 'lucide-react' +import type { Repo } from '../../../../shared/repo-types' import type { - Repo, WorkspaceStatus, WorkspaceStatusDefinition, Worktree -} from '../../../../shared/types' +} from '../../../../shared/worktree/types' import { WORKSPACE_BOARD_COLUMN_WIDTH_MAX, WORKSPACE_BOARD_COLUMN_WIDTH_MIN diff --git a/src/renderer/src/components/sidebar/WorkspaceStatusAppearancePopover.tsx b/src/renderer/src/components/sidebar/WorkspaceStatusAppearancePopover.tsx index c018d7ae048..fb0ececf03a 100644 --- a/src/renderer/src/components/sidebar/WorkspaceStatusAppearancePopover.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceStatusAppearancePopover.tsx @@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import type { WorkspaceStatusDefinition } from '../../../../shared/types' +import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/types' import { getWorkspaceStatusColorOptions, getWorkspaceStatusIconOptions, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx index f9b3976a826..73927976237 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx @@ -3,7 +3,10 @@ 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 { GlobalSettings, Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' const openModal = vi.fn() const setRenamingWorktreeId = vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index 707ce16ec61..a3097460ea7 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -3,7 +3,10 @@ import React, { type ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { GlobalSettings, Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' const fetchHostedReviewForBranch = vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx index 5aee22c8b16..e8376a64f08 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx @@ -4,7 +4,10 @@ import React, { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' -import type { GlobalSettings, Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' const fetchHostedReviewForBranch = vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx index 98c2a1b3712..6d9fa6d1b5d 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx @@ -3,7 +3,9 @@ import type { ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' const fetchHostedReviewForBranch = vi.fn() const fetchIssue = vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx index cbbf5a35b86..1e26254e712 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx @@ -1,7 +1,9 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' const fetchHostedReviewForBranch = vi.fn() const fetchIssue = vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx index 02927202e29..7c9669a22f9 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx @@ -2,13 +2,11 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { - GlobalSettings, - PRInfo, - Repo, - Worktree, - WorktreeCardProperty -} from '../../../../shared/types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' const fetchHostedReviewForBranch = vi.fn() const fetchIssue = vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx index 9da7873b388..47017f87fab 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx @@ -1,7 +1,10 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings, Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' const fetchHostedReviewForBranch = vi.fn() const fetchIssue = vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index 4c31d82e153..1ae9026cc74 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -2,13 +2,11 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { - GlobalSettings, - PRInfo, - Repo, - Worktree, - WorktreeCardProperty -} from '../../../../shared/types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' import { COMPACT_WORKTREE_CARD_PROPERTIES } from '../../../../shared/worktree/card-properties' import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index 7456cce2faa..ffef859d9ca 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -1,13 +1,11 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - GitConflictOperation, - GlobalSettings, - Repo, - Worktree, - WorktreeCardProperty -} from '../../../../shared/types' +import type { GitConflictOperation } from '../../../../shared/git-status-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' import type WorktreeCardComponent from './WorktreeCard' import type * as WorkspaceDeleteQuickAction from './workspace-delete-quick-action' diff --git a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx index 19ca3a4e441..1fe2f4b9530 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx @@ -1,7 +1,9 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' import type WorktreeCardComponent from './WorktreeCard' const fetchHostedReviewForBranch = vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.test.ts b/src/renderer/src/components/sidebar/WorktreeCard.test.ts index abc03a3c4fd..35c351f3457 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeCard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' vi.mock('@/lib/agent-status', () => ({ detectAgentStatusFromTitle: vi.fn((title: string) => { diff --git a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx index 7d8ebd2de99..1ba6a45ce48 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx @@ -1,7 +1,7 @@ import React from 'react' import { CalendarClock, PlayCircle } from 'lucide-react' import type { ExecutionHostId } from '../../../../shared/execution-host' -import type { AutomationWorkspaceProvenance } from '../../../../shared/types' +import type { AutomationWorkspaceProvenance } from '../../../../shared/worktree/types' import { WorktreeCardDetailSection, WorktreeCardDetailSectionContent diff --git a/src/renderer/src/components/sidebar/WorktreeCardCliDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardCliDetailSection.tsx index c5903340002..b636fc015e6 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardCliDetailSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardCliDetailSection.tsx @@ -1,6 +1,6 @@ import React from 'react' import { SquareTerminal } from 'lucide-react' -import type { CliWorkspaceProvenance } from '../../../../shared/types' +import type { CliWorkspaceProvenance } from '../../../../shared/worktree/types' import { formatAgentTypeLabel } from '../../../../shared/agent-type-label' import { WorktreeCardDetailSection, diff --git a/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx index 535ab541435..1c9cd781052 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx @@ -10,7 +10,10 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu' -import type { AgentActivityDisplayMode, WorktreeCardProperty } from '../../../../shared/types' +import type { + AgentActivityDisplayMode, + WorktreeCardProperty +} from '../../../../shared/ui-chrome-types' import { AGENT_ACTIVITY_DISPLAY_OPTIONS, CARD_LAYOUT_OPTIONS, diff --git a/src/renderer/src/components/sidebar/WorktreeCardHelpers.tsx b/src/renderer/src/components/sidebar/WorktreeCardHelpers.tsx index 2d2d081b067..5d5900af933 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardHelpers.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardHelpers.tsx @@ -1,5 +1,7 @@ import React from 'react' -import type { CheckStatus, GitConflictOperation, TerminalTab } from '../../../../shared/types' +import type { GitConflictOperation } from '../../../../shared/git-status-types' +import type { CheckStatus } from '../../../../shared/github/pull-request-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' // ── Pure helper functions ──────────────────────────────────────────── diff --git a/src/renderer/src/components/sidebar/WorktreeCardMetadataStatusBadges.tsx b/src/renderer/src/components/sidebar/WorktreeCardMetadataStatusBadges.tsx index 3cd3fece64c..21b064cc323 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMetadataStatusBadges.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMetadataStatusBadges.tsx @@ -4,7 +4,7 @@ import { CircleCheck, CircleDot, CircleX, Clock, GitMerge } from 'lucide-react' import { cn } from '@/lib/utils' import { PullRequestIcon, checksLabel } from './WorktreeCardHelpers' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' -import type { IssueInfo } from '../../../../shared/types' +import type { IssueInfo } from '../../../../shared/github/pull-request-types' import { translate } from '@/i18n/i18n' function MetadataStatusBadge({ diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts index 31f2e297f68..b4fc426a79e 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts @@ -14,7 +14,8 @@ import { selectMenuScopedMap, shouldRevealWorktreeDeveloperMenu } from './WorktreeContextMenu' -import type { Worktree, WorktreeLineage, WorkspaceStatusDefinition } from '../../../../shared/types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types' describe('shouldRevealWorktreeDeveloperMenu', () => { it('stays hidden for an ordinary right-click', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index be61dd64d3d..d2c20fff721 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -34,12 +34,12 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { useAllWorktrees, useRepoById, useRepoMap, useWorktreeMap } from '@/store/selectors' import { cn } from '@/lib/utils' +import type { Repo } from '../../../../shared/repo-types' import type { - Repo, - Worktree, WorkspaceStatus, - WorkspaceStatusDefinition -} from '../../../../shared/types' + WorkspaceStatusDefinition, + Worktree +} from '../../../../shared/worktree/types' import { deferWorktreeContextMenuDeleteIntent, type WorktreeContextMenuDeleteIntent diff --git a/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx index 7816939efb2..6955e14ac60 100644 --- a/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx @@ -8,7 +8,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' vi.mock('@/store', () => { const state = { diff --git a/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx index 5c9dda301d3..8278eb3482b 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx @@ -4,7 +4,9 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' globalThis.IS_REACT_ACT_ENVIRONMENT = true diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx index 5a644f0d265..01c9847d980 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -20,13 +20,11 @@ import type { AgentStatusEntry, AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' -import type { - Repo, - TerminalTab, - Worktree, - WorktreeCardProperty, - WorktreeLineage -} from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { clearWorktreeAgentExpansionStateForTests } from './worktree-card-agents-expansion-state' globalThis.IS_REACT_ACT_ENVIRONMENT = true 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 4e8896fb44d..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 @@ -2,15 +2,13 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { beforeAll, describe, expect, it, vi } from 'vitest' -import type { - FolderWorkspace, - ProjectGroup, - Repo, - Worktree, - WorktreeLineage -} from '../../../../shared/types' +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 { 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(() => ({ @@ -23,7 +21,6 @@ type WorktreeListComponent = React.ComponentType<{ }> let WorktreeList: WorktreeListComponent -let getPinnedWorktreeRevealCollapsedGroupKeys: typeof WorktreeListModule.getPinnedWorktreeRevealCollapsedGroupKeys function makeFolderWorkspacePathStatusMockState(): Record { return { @@ -830,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.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index 52f3a591473..d36aa264f02 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -4,12 +4,10 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { - Repo, - Worktree, - WorktreeCardProperty, - WorktreeLineage -} from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { FLUSH_CARD_MIN_CONTENT_INSET, LINEAGE_CHILDREN_INLINE_OFFSET, diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx index 00c9e47a0c8..47cc038799d 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx @@ -3,14 +3,11 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - Repo, - WorkspaceStatus, - Worktree, - WorktreeCardProperty, - WorktreeLineage, - WorktreeMeta -} from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types' import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-status-defaults' import { WORKSPACE_STATUS_DRAG_IDS_TYPE, diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 0202dd41d70..dbbee941a4f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -1,5209 +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 { - Worktree, - Repo, - FolderWorkspace, - ProjectGroup, - ProjectOrderBy, - WorktreeLineage, - WorktreeMeta, - WorkspaceLineage, - WorkspaceStatus, - WorkspaceStatusDefinition -} from '../../../../shared/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 type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' 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 @@ -5214,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, @@ -5231,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) @@ -5242,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( () => ({ @@ -5609,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 } : {}) }) @@ -6037,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', { @@ -6182,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) { @@ -6328,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} @@ -6803,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} @@ -6821,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/WorktreeMetaDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx index 66de27601e5..13b4bbac1b8 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx @@ -4,13 +4,11 @@ import { act, type ReactNode } from 'react' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' -import type { - FolderWorkspace, - LinearIssue, - Repo, - Worktree, - WorktreeMeta -} from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { LinearIssue } from '../../../../shared/linear/issue-types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' // Why: Radix tooltips need a provider the dialog does not own, and the menu's diff --git a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx index d5242211256..ad4636860df 100644 --- a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx @@ -15,7 +15,8 @@ import { OpenInApplicationIcon } from '@/lib/open-in-app-catalog' import { getExternalEditorOpenCapability } from '@/lib/external-editor-open-capability' import { NO_OPEN_IN_APPLICATIONS } from '@/lib/open-in-application-selection' import type { ShellOpenExternalEditorResult } from '../../../../shared/shell-open-types' -import type { GlobalSettings, OpenInApplication } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { OpenInApplication } from '../../../../shared/ui-chrome-types' import { translate } from '@/i18n/i18n' export { getLocalFileManagerLabel } from '@/lib/local-file-manager-label' diff --git a/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts b/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts index 4f7df50e9d3..f26f6f96f5d 100644 --- a/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { getWorktreeParentPickerFocusRestoreTarget, handleWorktreeParentPickerKeyDown, diff --git a/src/renderer/src/components/sidebar/WorktreeParentPickerRow.tsx b/src/renderer/src/components/sidebar/WorktreeParentPickerRow.tsx index 1d78102cfb7..7a747061449 100644 --- a/src/renderer/src/components/sidebar/WorktreeParentPickerRow.tsx +++ b/src/renderer/src/components/sidebar/WorktreeParentPickerRow.tsx @@ -5,7 +5,8 @@ import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-stat import { translate } from '@/i18n/i18n' import { branchDisplayName } from './WorktreeCardHelpers' import StatusIndicator from './StatusIndicator' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' // Why: presentational and memoized — the picker resolves every row's status // from one batched store read so the rows themselves hold no subscriptions. diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx index 7dfd864ce95..a4c5bf799eb 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx @@ -4,7 +4,11 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { DetectedWorktree, DetectedWorktreeListResult, Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { + DetectedWorktree, + DetectedWorktreeListResult +} from '../../../../shared/worktree/types' globalThis.IS_REACT_ACT_ENVIRONMENT = true @@ -15,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 } })) @@ -130,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) @@ -168,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 @@ -180,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) { @@ -189,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 { @@ -214,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() @@ -573,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: { @@ -597,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' @@ -783,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 baa73875d79..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, @@ -35,25 +30,32 @@ import WorktreeVisibilitySourceList, { type WorktreeVisibilitySourceAddResult, type WorktreeVisibilitySourceRow } from './WorktreeVisibilitySourceList' -import type { CustomWorktreeVisibilitySource, Repo } from '../../../../shared/types' +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 f7caa8bdee6..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, - DetectedWorktree, + ExternalWorktreeVisibility, Repo -} from '../../../../shared/types' +} 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/active-worktree-focus-after-delete.ts b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts index 18b24dcfafe..f1042c8466c 100644 --- a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts +++ b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts @@ -2,7 +2,8 @@ import { useAppStore } from '@/store' import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' type AppStoreState = ReturnType diff --git a/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts b/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts index 57a202a57b4..2c33f406f62 100644 --- a/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts +++ b/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { buildAddRepoExistingWorkspacesDetectedEvent, buildAddRepoExistingWorkspacesTelemetry, diff --git a/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.ts b/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.ts index 695cb983f43..d74cf898923 100644 --- a/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.ts +++ b/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.ts @@ -2,7 +2,7 @@ import type { AddRepoExistingWorkspaceSource, EventProps } from '../../../../shared/telemetry-events' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order' type ExistingWorkspacesDetectedProps = EventProps<'add_repo_existing_workspaces_detected'> diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts index de9c6f430cf..8f1808f4c24 100644 --- a/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts +++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { finalizeImportedRepoAfterSkip, type AddRepoSkipFinalizationState diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts index 7c9c69357ce..f22a8d9d251 100644 --- a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts +++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts @@ -1,5 +1,5 @@ -import type { Worktree } from '../../../../shared/types' -import { isDefaultBranchWorkspace } from './visible-worktrees' +import type { Worktree } from '../../../../shared/worktree/types' +import { isDefaultBranchWorkspace } from './default-branch-workspace' export type AddRepoSkipFinalizationState = { activeRepoId: string | null diff --git a/src/renderer/src/components/sidebar/add-repo-store-upsert.ts b/src/renderer/src/components/sidebar/add-repo-store-upsert.ts index 1593b3cda1c..4a085462948 100644 --- a/src/renderer/src/components/sidebar/add-repo-store-upsert.ts +++ b/src/renderer/src/components/sidebar/add-repo-store-upsert.ts @@ -4,7 +4,7 @@ import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { useAppStore } from '@/store' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' diff --git a/src/renderer/src/components/sidebar/complete-nested-folder-open.ts b/src/renderer/src/components/sidebar/complete-nested-folder-open.ts index 329027b6af2..f02fff1c428 100644 --- a/src/renderer/src/components/sidebar/complete-nested-folder-open.ts +++ b/src/renderer/src/components/sidebar/complete-nested-folder-open.ts @@ -1,6 +1,6 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' -import type { NestedRepoScanResult } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' import type { CapturedRuntimeOwner } from './add-repo-runtime-owner' import { trackNestedFolderOpen } from './track-nested-folder-open' diff --git a/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts b/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts index 9f06dda9df2..a0452e2f508 100644 --- a/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts +++ b/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from 'vitest' -import { - computeVisibleWorktreeIds, - isDefaultBranchWorkspace, - type SidebarFilterState -} from './visible-worktrees' -import type { Repo, Worktree } from '../../../../shared/types' +import { computeVisibleWorktreeIds, type SidebarFilterState } from './visible-worktrees' +import { isDefaultBranchWorkspace } from './default-branch-workspace' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' /** diff --git a/src/renderer/src/components/sidebar/default-branch-workspace.ts b/src/renderer/src/components/sidebar/default-branch-workspace.ts index 5289b3d2d4c..43404232066 100644 --- a/src/renderer/src/components/sidebar/default-branch-workspace.ts +++ b/src/renderer/src/components/sidebar/default-branch-workspace.ts @@ -1,4 +1,4 @@ -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' /** Keeps provisioned roots visible because they are the recipe-created workspace, not a source-repo row. */ export function isDefaultBranchWorkspace(worktree: Worktree): boolean { diff --git a/src/renderer/src/components/sidebar/delete-worktree-dialog-copy.ts b/src/renderer/src/components/sidebar/delete-worktree-dialog-copy.ts index 7f761fcbd8d..862df883b54 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-dialog-copy.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-dialog-copy.ts @@ -1,5 +1,6 @@ import { isFolderRepo } from '../../../../shared/repo-kind' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' type WorktreeRepoRef = Pick diff --git a/src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.ts b/src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.ts index 3a0f709caae..339251046d5 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.ts @@ -1,4 +1,5 @@ -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { WorktreeDeleteState } from '../../store/slices/worktree-helpers' import { isFolderWorkspaceDelete } from './delete-worktree-dialog-copy' 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/delete-worktree-preference-toast.ts b/src/renderer/src/components/sidebar/delete-worktree-preference-toast.ts index 188e1bdbbaf..9c048e0829c 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-preference-toast.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-preference-toast.ts @@ -1,7 +1,7 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import type { SettingsNavTarget } from '@/lib/settings-navigation-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' export function persistDeleteWorktreeConfirmSkipPreference({ updateSettings, diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts index 8a33151f73a..c8042ed6b4a 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos' const repo: Repo = { diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts index 57aee59c1bd..29e30d196bf 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts @@ -1,4 +1,5 @@ -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { WorktreeGroupBy } from './worktree-list-groups' export function getEmptyProjectPlaceholderRepoIds(args: { diff --git a/src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts b/src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts index c3cd300892e..465f1362a59 100644 --- a/src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts +++ b/src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts @@ -4,7 +4,7 @@ import type { MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { getFocusedAgentPaneKeyForWorktree, type FocusedAgentRowHighlightState diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts index dc971a0603e..4ca0078e2b3 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts @@ -1,12 +1,8 @@ import { describe, expect, it } from 'vitest' -import type { - CheckStatus, - PRInfo, - Repo, - Worktree, - WorktreeLineage, - WorkspaceLineage -} from '../../../../shared/types' +import type { CheckStatus, PRInfo } from '../../../../shared/github/pull-request-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 { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts index 3f71970ec33..5914520603e 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts @@ -1,5 +1,7 @@ import type { AppState } from '@/store/types' -import type { Repo, Worktree, WorktreeLineage, WorkspaceLineage } from '../../../../shared/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 { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' import { buildParentPrChecksRows, diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.test.ts index 0f43496ee54..73648ebfd02 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { ProjectGroup, Repo } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' import { getFolderSourceRepos, getFolderWorkspacePrimaryActionLabel diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts index c6b920bd9d8..a63a2caf920 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts @@ -17,14 +17,12 @@ import { } from '../../../../shared/execution-host' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { isGitRepoKind } from '../../../../shared/repo-kind' -import type { - FolderWorkspace, - GitHubWorkItem, - GitLabWorkItem, - LinearIssue, - ProjectGroup, - Repo -} from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { GitLabWorkItem } from '../../../../shared/gitlab-types' +import type { LinearIssue } from '../../../../shared/linear/issue-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx index 6c5f3523561..e13c5c2c14c 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx @@ -3,7 +3,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ProjectGroup } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' import { useAppStore } from '@/store' import { useFolderWorkspaceComposerPathStatus } from './folder-workspace-composer-path-status' diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts index 51369b6b8ea..911c373191d 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts @@ -7,7 +7,7 @@ import { getFolderWorkspacePathStatusTitle } from '@/lib/folder-workspace-path-status' import { isConfirmedStaleFolderPathStatus } from '../../../../shared/folder-workspace-path-status' -import type { ProjectGroup } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' export function useFolderWorkspaceComposerPathStatus( projectGroup: ProjectGroup | null, diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index 4f129edf853..51432bc1e5f 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -1,7 +1,8 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type * as NewWorkspaceModule from '@/lib/new-workspace' diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 2b64f589e68..35b3240ccb6 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -16,7 +16,9 @@ import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation' import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' -import type { FolderWorkspace, ProjectGroup, TuiAgent } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { isWslUncPath } from '../../../../shared/wsl-paths' import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell' import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' diff --git a/src/renderer/src/components/sidebar/host-rename-remove.ts b/src/renderer/src/components/sidebar/host-rename-remove.ts index dc1dfec6c84..03ade9762fe 100644 --- a/src/renderer/src/components/sidebar/host-rename-remove.ts +++ b/src/renderer/src/components/sidebar/host-rename-remove.ts @@ -4,7 +4,8 @@ import { getHostSettingOverride, setHostSettingOverride } from '../../../../shared/host-setting-overrides' -import type { GlobalSettings, HostSettingOverrides } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { HostSettingOverrides } from '../../../../shared/host-setting-overrides' type OverridesSlice = Pick type OverridesMap = Partial> diff --git a/src/renderer/src/components/sidebar/host-section-rows.test.ts b/src/renderer/src/components/sidebar/host-section-rows.test.ts index 702356e26a6..1d8cc55a14b 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.test.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../shared/types' +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 { Worktree } from '../../../../shared/worktree/types' import { PINNED_GROUP_KEY, type Row } from './worktree-list-groups' import { addHostSectionRows, type HostSectionRow } from './host-section-rows' diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts index 7ca084d7520..1371f57ed05 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.ts @@ -11,7 +11,9 @@ import { import type { ExecutionHostHealth } from '../../../../shared/execution-host-registry' import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' import type { SshConnectionStatus } from '../../../../shared/ssh-types' -import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types' +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 { Row } from './worktree-list-groups' export type HostHeaderRow = { diff --git a/src/renderer/src/components/sidebar/imported-worktrees-card-actions.ts b/src/renderer/src/components/sidebar/imported-worktrees-card-actions.ts index ea4f758d3df..a40688cbb8a 100644 --- a/src/renderer/src/components/sidebar/imported-worktrees-card-actions.ts +++ b/src/renderer/src/components/sidebar/imported-worktrees-card-actions.ts @@ -1,4 +1,4 @@ -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { mergeExternalWorktreeInboxPaths } from '../../../../shared/external-worktree-inbox' export type ImportedWorktreeCardActionState = { diff --git a/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts b/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts index 5a6ed56abfc..1ea7886d55a 100644 --- a/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts +++ b/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts @@ -4,12 +4,12 @@ import { buildImportedWorktreesCardCandidates, getHiddenImportedWorktrees } from './imported-worktrees-card-candidates' +import type { Repo } from '../../../../shared/repo-types' import type { DetectedWorktree, DetectedWorktreeListResult, - Repo, Worktree -} from '../../../../shared/types' +} from '../../../../shared/worktree/types' const repo: Repo = { id: 'repo-1', 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 e827d1662ef..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,4 +1,11 @@ -import type { DetectedWorktreeListResult, Repo, Worktree } from '../../../../shared/types' +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' import { isGitRepoKind } from '../../../../shared/repo-kind' import { @@ -19,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)) @@ -40,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/linear-agent-skill-runtime.ts b/src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts index 46b047a4bc1..d816c387313 100644 --- a/src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts +++ b/src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { SkillDiscoveryTarget } from '../../../../shared/skills' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.test.tsx b/src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.test.tsx index 88e5b8cb236..8c31110242c 100644 --- a/src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.test.tsx +++ b/src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.test.tsx @@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import { toast } from 'sonner' import type { AppState } from '@/store/types' -import type { LocalBaseRefUpdateSuggestion } from '../../../../shared/types' +import type { LocalBaseRefUpdateSuggestion } from '../../../../shared/worktree/base-ref-drift-types' import { showLocalBaseRefUpdateSuggestionToast } from './local-base-ref-suggestion-toast' vi.mock('sonner', () => ({ diff --git a/src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.tsx b/src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.tsx index 97bd71e196a..18b0e619cd7 100644 --- a/src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.tsx +++ b/src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.tsx @@ -1,6 +1,6 @@ import { toast } from 'sonner' import type { AppState } from '@/store/types' -import type { LocalBaseRefUpdateSuggestion } from '../../../../shared/types' +import type { LocalBaseRefUpdateSuggestion } from '../../../../shared/worktree/base-ref-drift-types' import { Button } from '../ui/button' import { KEEP_LOCAL_MAIN_UP_TO_DATE_SECTION_ID, diff --git a/src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.test.ts b/src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.test.ts index 3435870dbd4..9f3abcc281a 100644 --- a/src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.test.ts +++ b/src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { importNewExternalWorktreeInboxPaths, keepNewExternalWorktreeInboxHidden, diff --git a/src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.ts b/src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.ts index 289d068d97f..f9a4534410d 100644 --- a/src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.ts +++ b/src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.ts @@ -1,4 +1,4 @@ -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { mergeExternalWorktreeInboxPaths } from '../../../../shared/external-worktree-inbox' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.test.ts b/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.test.ts index d099a63945c..cfab500f410 100644 --- a/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.test.ts +++ b/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' import type { DetectedWorktree, DetectedWorktreeListResult, - Repo, Worktree -} from '../../../../shared/types' +} from '../../../../shared/worktree/types' import { buildNewExternalWorktreesInboxCandidates } from './new-external-worktrees-inbox-candidates' const repo: Repo = { 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 aeda87cf710..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,4 +1,11 @@ -import type { DetectedWorktreeListResult, Repo, Worktree } from '../../../../shared/types' +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' import { isGitRepoKind } from '../../../../shared/repo-kind' import type { NewExternalWorktreesInboxCandidate } from './worktree-list-groups' @@ -8,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)) @@ -26,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/preserved-branch-toast.test.tsx b/src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx index 24f768af4af..3424c372581 100644 --- a/src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx +++ b/src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import { toast } from 'sonner' -import type { RemoveWorktreeResult } from '../../../../shared/types' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' import { showPreservedBranchToast } from './preserved-branch-toast' vi.mock('sonner', () => ({ diff --git a/src/renderer/src/components/sidebar/preserved-branch-toast.tsx b/src/renderer/src/components/sidebar/preserved-branch-toast.tsx index 03529ad4657..43b01513f53 100644 --- a/src/renderer/src/components/sidebar/preserved-branch-toast.tsx +++ b/src/renderer/src/components/sidebar/preserved-branch-toast.tsx @@ -1,6 +1,7 @@ import { Trash2 } from 'lucide-react' import { toast } from 'sonner' -import type { RemoveWorktreeResult, Worktree } from '../../../../shared/types' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { Worktree } from '../../../../shared/worktree/types' import { translate } from '@/i18n/i18n' import { Button } from '../ui/button' diff --git a/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts b/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts index 8b4b42d8d44..24d1fd7b76a 100644 --- a/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultOnboardingState } from '../../../../shared/constants' +import type { Repo } from '../../../../shared/repo-types' import type { DetectedWorktree, DetectedWorktreeListResult, - Repo, Worktree -} from '../../../../shared/types' +} from '../../../../shared/worktree/types' import { finishProjectAddWithDefaultCheckout, getProjectDefaultCheckout, diff --git a/src/renderer/src/components/sidebar/project-added-default-checkout.ts b/src/renderer/src/components/sidebar/project-added-default-checkout.ts index dabf089a8a0..33ab7dadd07 100644 --- a/src/renderer/src/components/sidebar/project-added-default-checkout.ts +++ b/src/renderer/src/components/sidebar/project-added-default-checkout.ts @@ -5,7 +5,7 @@ import type { AddRepoDefaultCheckoutHandoffSource, EventProps } from '../../../../shared/telemetry-events' -import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/types' +import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/worktree/types' import { relativePathInsideRoot } from '../../../../shared/cross-platform-path' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization' 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/project-group-header-drag-commit.test.ts b/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts index 3c1b0384f41..43ff9581712 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { commitProjectGroupHeaderDragDrop } from './project-group-header-drag-commit' import type { ProjectGroupHeaderDragSession } from './project-group-header-drag-contract' -import type { ProjectGroup } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' function group(id: string, overrides: Partial = {}): ProjectGroup { return { diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts index 6f3db2f1260..225b1792c95 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts @@ -1,6 +1,6 @@ import { getProjectGroupTabOrderUpdatesForSidebarDrop } from './project-group-header-drop' import type { ProjectGroupHeaderDragSession } from './project-group-header-drag-contract' -import type { ProjectGroup } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' export function commitProjectGroupHeaderDragDrop(args: { session: ProjectGroupHeaderDragSession diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts b/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts index bedcefcfc59..9021d9c7636 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts @@ -5,7 +5,7 @@ import type { ProjectGroupHeaderDragRect } from './project-group-header-drop' import { REPO_HEADER_ACTION_SELECTOR } from './project-header-drag-contract' -import type { ProjectGroup } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' export type ProjectGroupDragState = { draggingGroupId: string | null diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts b/src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts index ef53dc54175..efb8c7b3959 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { createProjectGroupHeaderDragSession } from './project-group-header-drag-start' -import type { ProjectGroup } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' function group(id: string, overrides: Partial = {}): ProjectGroup { return { diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-start.ts b/src/renderer/src/components/sidebar/project-group-header-drag-start.ts index 89eb5bd9dd0..46616c2053d 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drag-start.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drag-start.ts @@ -10,7 +10,7 @@ import { isProjectGroupHeaderDragHandleTarget, type ProjectGroupHeaderDragSession } from './project-group-header-drag-contract' -import type { ProjectGroup } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' export function createProjectGroupHeaderDragSession(args: { event: PointerEvent diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.test.ts b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts index 8fa20c47b49..3e843cf7543 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drop.test.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts @@ -9,7 +9,8 @@ import { mapSidebarProjectGroupDropIndexToSiblingInsertIndex } from './project-group-header-drop' import type { Row } from './worktree-list-groups' -import type { ProjectGroup, Repo } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' function group(id: string, overrides: Partial = {}): ProjectGroup { return { diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.ts b/src/renderer/src/components/sidebar/project-group-header-drop.ts index 81382372784..73f4415cd00 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drop.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drop.ts @@ -3,7 +3,7 @@ import { type WorktreeSidebarHeaderDropPreview } from './worktree-sidebar-header-drop-preview' import type { Row } from './worktree-list-groups' -import type { ProjectGroup } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' export type ProjectGroupHeaderDragBucketKey = string diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts index 7b5a72def55..32fc47acef3 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { commitProjectHeaderDragDrop } from './project-header-drag-commit' import type { ProjectHeaderDragSession } from './project-header-drag-contract' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' function makeRepo(id: string, overrides: Partial = {}): Repo { return { diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.ts index 2ab63663d71..0a89742097b 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.ts @@ -6,7 +6,7 @@ import { mapSidebarRepoDropIndexToAllRepoInsertAt } from './project-header-drop' import type { ProjectHeaderDragSession } from './project-header-drag-contract' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' export function commitProjectHeaderDragDrop(args: { session: ProjectHeaderDragSession diff --git a/src/renderer/src/components/sidebar/project-header-drag-contract.ts b/src/renderer/src/components/sidebar/project-header-drag-contract.ts index 9923c8c0979..f28f34eaffb 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-contract.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-contract.ts @@ -1,7 +1,7 @@ import type { PointerEvent } from 'react' import type { ProjectHeaderDragBucketKey, ProjectHeaderDragRect } from './project-header-drop' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' export type RepoDragState = { draggingRepoId: string | null diff --git a/src/renderer/src/components/sidebar/project-header-drag-start.test.ts b/src/renderer/src/components/sidebar/project-header-drag-start.test.ts index 6fa80295e67..da1a07c26cf 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-start.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-start.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { createProjectHeaderDragSession } from './project-header-drag-start' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' function createRepo(id: string, projectGroupId: string | null = null): Repo { return { diff --git a/src/renderer/src/components/sidebar/project-header-drag-start.ts b/src/renderer/src/components/sidebar/project-header-drag-start.ts index 04c306a869e..0a0429f1bf4 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-start.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-start.ts @@ -10,7 +10,7 @@ import { isRepoHeaderActionTarget, type ProjectHeaderDragSession } from './project-header-drag-contract' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' export function createProjectHeaderDragSession(args: { event: PointerEvent diff --git a/src/renderer/src/components/sidebar/project-header-drop.test.ts b/src/renderer/src/components/sidebar/project-header-drop.test.ts index c0382a5f162..92baaf72b73 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.test.ts @@ -12,7 +12,7 @@ import { mapSidebarRepoDropIndexToAllRepoInsertAt } from './project-header-drop' import type { Row } from './worktree-list-groups' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' describe('getProjectHeaderDragBucketKey', () => { it('uses ungrouped for repos without a project group', () => { diff --git a/src/renderer/src/components/sidebar/project-header-drop.ts b/src/renderer/src/components/sidebar/project-header-drop.ts index 1f7418f805c..c2de72bcaf3 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.ts @@ -4,7 +4,7 @@ import { type WorktreeSidebarHeaderDropPreview } from './worktree-sidebar-header-drop-preview' import type { Row } from './worktree-list-groups' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' export type ProjectHeaderDragBucketKey = string diff --git a/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts index 69232f64f6f..d568c1d8508 100644 --- a/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts +++ b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts @@ -1,4 +1,4 @@ -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' export type PromptCacheCountdownSelection = { diff --git a/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.ts b/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.ts index 52e507aadf2..7e68874dfae 100644 --- a/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.ts +++ b/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.ts @@ -59,4 +59,4 @@ export function driveBreadcrumbPath( const kept = segments.slice(0, endIndex + 1) return kept.length === 0 ? driveRoot : `${driveRoot}${kept.join('\\')}` } -import type { FilesystemPathFlavor } from '../../../../shared/types' +import type { FilesystemPathFlavor } from '../../../../shared/filesystem-entry-types' diff --git a/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts b/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts index a9508a76364..54c0107376d 100644 --- a/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts +++ b/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts @@ -1,7 +1,7 @@ import { translate } from '@/i18n/i18n' import { shouldHandleTextControlPaste } from '@/lib/text-control-paste' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -import type { FilesystemPathFlavor } from '../../../../shared/types' +import type { FilesystemPathFlavor } from '../../../../shared/filesystem-entry-types' import { driveRootOf, isDrivePath, diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts index 0f549db9f23..2cb14d6894d 100644 --- a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it } from 'vitest' import { useAppStore } from '@/store' import { getVisibleWorktreeIds, setVisibleWorktreeIds } from './visible-worktrees' import type { AppState } from '@/store/types' -import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../shared/types' +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 { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' const initialState = useAppStore.getInitialState() diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts index fccdc79955c..bc2c038e8d2 100644 --- a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts @@ -1,4 +1,4 @@ -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import type { AppState } from '@/store/types' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { diff --git a/src/renderer/src/components/sidebar/repo-header-create-state.test.ts b/src/renderer/src/components/sidebar/repo-header-create-state.test.ts index a6421b15c5b..6f4a121cb4c 100644 --- a/src/renderer/src/components/sidebar/repo-header-create-state.test.ts +++ b/src/renderer/src/components/sidebar/repo-header-create-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { SshConnectionStatus } from '../../../../shared/ssh-types' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { getRepoHeaderCreateState } from './repo-header-create-state' function makeRepo(overrides: Partial = {}): Repo { diff --git a/src/renderer/src/components/sidebar/repo-header-create-state.ts b/src/renderer/src/components/sidebar/repo-header-create-state.ts index 6b369f1e287..6b061932a9e 100644 --- a/src/renderer/src/components/sidebar/repo-header-create-state.ts +++ b/src/renderer/src/components/sidebar/repo-header-create-state.ts @@ -1,5 +1,5 @@ import type { SshConnectionStatus } from '../../../../shared/ssh-types' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getSelectedRepoSshGate } from '../../lib/new-workspace-ssh-gate' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts b/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts index 0e80337118d..2b7a6c79900 100644 --- a/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts +++ b/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts @@ -1,4 +1,6 @@ -import type { GlobalSettings, Repo, Worktree } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' import { findRepoForHost } from '@/store/slices/repo-host-identity' import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' diff --git a/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts b/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts index e46f6163985..c6945a7f041 100644 --- a/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' import { computeClearFilterActions, - isDefaultBranchWorkspace, isSleepingSweepExemptionNarrowingList, sidebarHasActiveFilters } from './visible-worktrees' -import type { Worktree } from '../../../../shared/types' +import { isDefaultBranchWorkspace } from './default-branch-workspace' +import type { Worktree } from '../../../../shared/worktree/types' function makeWorktree(id: string, repoId = 'repo1'): Worktree { return { diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.ts b/src/renderer/src/components/sidebar/sidebar-host-options.ts index dc764112bcc..29d03ec1419 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.ts @@ -1,4 +1,6 @@ -import type { GlobalSettings, Repo, WorkspaceHostScope } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorkspaceHostScope } from '../../../../shared/ui-chrome-types' import { ALL_EXECUTION_HOSTS_SCOPE, LOCAL_EXECUTION_HOST_ID, diff --git a/src/renderer/src/components/sidebar/sidebar-project-drop.ts b/src/renderer/src/components/sidebar/sidebar-project-drop.ts index fc61c049e38..1793bfc9bcc 100644 --- a/src/renderer/src/components/sidebar/sidebar-project-drop.ts +++ b/src/renderer/src/components/sidebar/sidebar-project-drop.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' export type SidebarProjectDropPathResolution = diff --git a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts index e804131b6ae..717dd2302d3 100644 --- a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts +++ b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts @@ -1,4 +1,7 @@ -import type { AgentActivityDisplayMode, WorktreeCardProperty } from '../../../../shared/types' +import type { + AgentActivityDisplayMode, + WorktreeCardProperty +} from '../../../../shared/ui-chrome-types' import { TASK_WORKTREE_CARD_PROPERTIES } from '../../../../shared/constants' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index 8723cf937fc..d04aed078bf 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -11,7 +11,8 @@ import { resolveAttention, type PaneInput } from './smart-attention' -import type { TerminalLayoutSnapshot, TerminalTab, Worktree } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' function hookPane(entry: AgentStatusEntry): PaneInput { return { kind: 'hook', entry } diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 5238a9091eb..44abd4082b7 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -4,7 +4,8 @@ import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsuppor import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' import type { AgentStatus } from '../../../../shared/agent-detection' -import type { TerminalLayoutSnapshot, TerminalTab, Worktree } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStateHistoryEntry, diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index 75af8fae1cc..60700e59c95 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import { buildWorktreeComparator, CREATE_GRACE_MS, diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index 84474df4047..b30d60f755a 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -1,4 +1,6 @@ -import type { Worktree, Repo, TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { AgentStatusEntry, MigrationUnsupportedPtyEntry diff --git a/src/renderer/src/components/sidebar/ssh-host-remove-resolution.ts b/src/renderer/src/components/sidebar/ssh-host-remove-resolution.ts index 4949ab3db6c..3c0d41d8cc6 100644 --- a/src/renderer/src/components/sidebar/ssh-host-remove-resolution.ts +++ b/src/renderer/src/components/sidebar/ssh-host-remove-resolution.ts @@ -1,4 +1,5 @@ -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { SshConnectionState } from '../../../../shared/ssh-types' /** diff --git a/src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.ts b/src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.ts index 72243c5f370..6a723349809 100644 --- a/src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.ts +++ b/src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.ts @@ -1,4 +1,4 @@ -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import type { SshConnectionState } from '../../../../shared/ssh-types' import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' diff --git a/src/renderer/src/components/sidebar/track-nested-folder-open.ts b/src/renderer/src/components/sidebar/track-nested-folder-open.ts index add9c4aae19..19871701d3f 100644 --- a/src/renderer/src/components/sidebar/track-nested-folder-open.ts +++ b/src/renderer/src/components/sidebar/track-nested-folder-open.ts @@ -3,7 +3,7 @@ import { buildNestedRepoImportActionTelemetry, type NestedRepoTelemetryRuntimeKind } from '../../../../shared/nested-repo-telemetry' -import type { NestedRepoScanResult } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' export function trackNestedFolderOpen(args: { attemptId: string | null diff --git a/src/renderer/src/components/sidebar/use-add-repo-remote-nested-scan.ts b/src/renderer/src/components/sidebar/use-add-repo-remote-nested-scan.ts index c57557f0cc1..436a0618ff0 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-remote-nested-scan.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-remote-nested-scan.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react' import { track } from '@/lib/telemetry' import { buildNestedRepoScanTelemetry } from '../../../../shared/nested-repo-telemetry' -import type { NestedRepoScanResult } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' export function useAddRepoRemoteNestedScan({ setActiveNestedScanId, diff --git a/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts b/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts index 2a88a430c50..02972d6a907 100644 --- a/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts +++ b/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts @@ -3,7 +3,8 @@ import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { getRuntimeGitStatus } from '@/runtime/runtime-git-client' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { isFolderWorkspaceDelete } from './delete-worktree-dialog-copy' export function useDeleteWorktreeStatusHydration({ 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/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index 180ef7d6daf..1262dc92edb 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { computeVisibleWorktreeIds } from './visible-worktrees' import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host' diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts index 19a67a31663..d03add3da13 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef } from 'react' import type React from 'react' -import type { WorkspaceStatus, Worktree } from '../../../../shared/types' +import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types' import { CARD_SELECTOR, getCardDropTarget, diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-create-worktree.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-create-worktree.ts index aae79fc9d8b..bd218580cb9 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-create-worktree.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-create-worktree.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { useAppStore } from '@/store' -import type { WorkspaceStatus } from '../../../../shared/types' +import type { WorkspaceStatus } from '../../../../shared/worktree/types' export function useWorkspaceKanbanCreateWorktree(): { canCreateWorktree: boolean diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-search.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-search.ts index df5f3e0455f..e782b419ab3 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-search.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-search.ts @@ -1,6 +1,7 @@ import { useCallback, useDeferredValue, useMemo, useState } from 'react' import { isWorktreePaletteQueryTooLarge } from '@/lib/worktree-palette-query-bounds' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { matchWorkspaceBoardWorktrees } from './workspace-kanban-search' function areWorktreeIdSetsEqual(a: ReadonlySet, b: ReadonlySet): boolean { diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-selection.test.tsx b/src/renderer/src/components/sidebar/use-workspace-kanban-selection.test.tsx index 7d20d7c7523..abf1067895e 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-selection.test.tsx +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-selection.test.tsx @@ -3,7 +3,7 @@ import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { useWorkspaceKanbanSelection } from './use-workspace-kanban-selection' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts index 54efeccdec3..f1a20a57147 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { areWorktreeSelectionsEqual, getWorktreeSelectionIntent, diff --git a/src/renderer/src/components/sidebar/use-workspace-status-drop.ts b/src/renderer/src/components/sidebar/use-workspace-status-drop.ts index eac45bb668d..5a5b441521d 100644 --- a/src/renderer/src/components/sidebar/use-workspace-status-drop.ts +++ b/src/renderer/src/components/sidebar/use-workspace-status-drop.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react' import type React from 'react' -import type { WorkspaceStatus } from '../../../../shared/types' +import type { WorkspaceStatus } from '../../../../shared/worktree/types' import { hasWorkspaceDragData, readWorkspaceDragDataIds } from './workspace-status' const WORKSPACE_STATUS_DROP_TARGET = '[data-workspace-status-drop-target]' diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx index aac20f34b97..17ff8d18cd3 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx @@ -2,7 +2,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import { useWorktreeActivityStatus } from './use-worktree-activity-status' const LEAF_ID = '11111111-1111-4111-8111-111111111111' diff --git a/src/renderer/src/components/sidebar/use-worktree-card-linked-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-linked-details.ts index fcf91b71a93..a2ec8e0774e 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-linked-details.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-linked-details.ts @@ -2,7 +2,8 @@ import React from 'react' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' -import type { IssueInfo, LinearIssue } from '../../../../shared/types' +import type { IssueInfo } from '../../../../shared/github/pull-request-types' +import type { LinearIssue } from '../../../../shared/linear/issue-types' import { getWorktreeCardJiraIssueDisplay } from './worktree-card-jira-issue-display' import type { WorktreeCardIssueDisplay } from './WorktreeCardMeta' import { diff --git a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx index 693dba8f412..146f4e10916 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx @@ -5,7 +5,8 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it } from 'vitest' import { useAppStore } from '@/store' import { readStoreListenerCount } from '@/store/store-listener-census' -import type { GlobalSettings, Worktree } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Worktree } from '../../../../shared/worktree/types' import { usePromptCacheCountdownStartedAt } from './CacheTimer' import { useWorktreeCardSecondaryDetails } from './use-worktree-card-secondary-details' import { useWorktreeAgentRows } from './useWorktreeAgentRows' diff --git a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts index ebc7cf483c8..64e9da831a6 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts @@ -1,6 +1,6 @@ import React, { useCallback } from 'react' -import type { GitHubWorkItem } from '../../../../shared/types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' import { hasWorktreeCardDetails } from './WorktreeCardMeta' import { usePromptCacheCountdownStartedAt } from './CacheTimer' import { useWorktreeAgentRows } from './useWorktreeAgentRows' diff --git a/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts b/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts index 336725efc1e..6e581d9c77e 100644 --- a/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts +++ b/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' import { findIndexedWorktreeOwner } from '@/lib/worktree-runtime-owner-index' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' import type { IssueLinkProvider } from '../../../../shared/issue-link-input' diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts index 57958126563..b9cd2bdd976 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactModule from 'react' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' const mocks = vi.hoisted(() => ({ stateValues: [] as unknown[], diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts index 24d27523942..f181929ddff 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts @@ -3,7 +3,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { getCloneDestinationAutoFill } from './clone-defaults' import type { AddRepoDialogStep } from './add-repo-dialog-types' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts index acc96258cb6..e3759cc13a0 100644 --- a/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactModule from 'react' -import type { NestedRepoScanResult, Repo } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' vi.mock('react', async (importOriginal) => { const actual = await importOriginal() diff --git a/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts b/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts index 859698f19f8..cf4c6b13ba5 100644 --- a/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts @@ -8,7 +8,8 @@ import { type NestedRepoTelemetryRuntimeKind } from '../../../../shared/nested-repo-telemetry' import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events' -import type { NestedRepoScanResult, Repo } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' import type { WorktreeFetchOptions } from '@/store/slices/worktree-helpers' import type { RepoSlice } from '@/store/slices/repos' import { createNestedRepoScanId } from './add-repo-dialog-types' diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts index 6f56f454edd..4ed1f739f1a 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactModule from 'react' -import type { NestedRepoScanResult, ProjectGroupImportResult, Repo } from '../../../../shared/types' +import type { + NestedRepoScanResult, + ProjectGroupImportResult +} from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' vi.mock('react', async (importOriginal) => { const actual = await importOriginal() diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts index c0c505cc97c..d52109bf0d7 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts @@ -10,7 +10,10 @@ import { type NestedRepoTelemetryRuntimeKind } from '../../../../shared/nested-repo-telemetry' import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events' -import type { NestedRepoScanResult, ProjectGroupImportResult } from '../../../../shared/types' +import type { + NestedRepoScanResult, + ProjectGroupImportResult +} from '../../../../shared/project-group-types' import type { WorktreeFetchOptions } from '@/store/slices/worktree-helpers' import { translate } from '@/i18n/i18n' import { worktreeRefreshOptions, type CapturedRuntimeOwner } from './add-repo-runtime-owner' diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedReviewController.ts b/src/renderer/src/components/sidebar/useAddRepoNestedReviewController.ts index a5f47153736..f015883762b 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedReviewController.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedReviewController.ts @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from 'react' import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events' -import type { ProjectGroupImportResult } from '../../../../shared/types' +import type { ProjectGroupImportResult } from '../../../../shared/project-group-types' import type { WorktreeFetchOptions } from '@/store/slices/worktree-helpers' import { useAddRepoNestedImportFlow } from './useAddRepoNestedImportFlow' import { useAddRepoNestedReviewState } from './useAddRepoNestedReviewState' diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedReviewState.ts b/src/renderer/src/components/sidebar/useAddRepoNestedReviewState.ts index a8f41592fbd..8e44b5a4076 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedReviewState.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedReviewState.ts @@ -1,6 +1,6 @@ import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from 'react' import type { NestedRepoTelemetryRuntimeKind } from '../../../../shared/nested-repo-telemetry' -import type { NestedRepoScanResult } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' import { defaultProjectGroupNameForPath, type AddRepoDialogStep } from './add-repo-dialog-types' type ShowNestedRepoReviewArgs = { diff --git a/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts index 53d2b334112..2d0135bb420 100644 --- a/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactModule from 'react' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' const mocks = vi.hoisted(() => ({ stateValues: [] as unknown[], diff --git a/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.ts b/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.ts index a02bdbcf3f4..9810be00be8 100644 --- a/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.ts @@ -8,7 +8,8 @@ import { type NestedRepoTelemetryRuntimeKind } from '../../../../shared/nested-repo-telemetry' import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events' -import type { NestedRepoScanResult, Repo } from '../../../../shared/types' +import type { NestedRepoScanResult } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' import type { WorktreeFetchOptions } from '@/store/slices/worktree-helpers' import { createNestedRepoScanId } from './add-repo-dialog-types' import { worktreeRefreshOptions } from './add-repo-runtime-owner' diff --git a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts index 832ce5b26dc..117e09d6fbe 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactModule from 'react' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' const mocks = vi.hoisted(() => ({ stateValues: [] as unknown[], diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts index a9c6c8c6525..103fb59f966 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -8,7 +8,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { isGitRepoKind } from '../../../../shared/repo-kind' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { translate } from '@/i18n/i18n' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' diff --git a/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx b/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx index cb4e364d1b2..672d7130b5b 100644 --- a/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx +++ b/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx @@ -4,7 +4,7 @@ import { act } 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 { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { useSetupScriptPromptRevalidation } from './useSetupScriptPromptRevalidation' import type { SetupScriptPromptState } from './setup-script-prompt-render-state' import { getRepoHostIdentityForParts } from '@/store/slices/repo-host-identity' diff --git a/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.ts b/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.ts index ce6a5e78fce..867b21052e7 100644 --- a/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.ts +++ b/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react' import { useAppStore } from '@/store' import { isGitRepoKind } from '../../../../shared/repo-kind' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import type { SetupScriptPromptState } from './setup-script-prompt-render-state' diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index 913e197691a..bd410ddc2a2 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -3,7 +3,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage' import { makePaneKey } from '../../../../shared/stable-pane-id' diff --git a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts index c96aa38d50d..c18c3cbceed 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types' +import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { getVisibleWorktreeBrowserActivityTabs, getVisibleWorktreeTerminalActivityTabs diff --git a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts index d843e8d20c1..683b984cca8 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts @@ -1,4 +1,5 @@ -import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types' +import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' export type TerminalActivityTab = Pick export type BrowserActivityTab = Pick diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 07e2d32a4b3..597d4b91e61 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { computeVisibleWorktreeIds } from './visible-worktrees' import { getPairedDeviceIdsByEnvironment } from './workspace-creator-visibility' -import type { Repo, TerminalTab, Worktree, WorktreeLineage } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' function makeTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab { diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 927033c5bfa..270809d49da 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -1,4 +1,7 @@ -import type { Worktree, Repo, TerminalTab, WorktreeLineage } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort' import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { useAppStore } from '@/store' @@ -28,8 +31,6 @@ import { } from './workspace-creator-visibility' import { isDefaultBranchWorkspace } from './default-branch-workspace' -export { isDefaultBranchWorkspace } from './default-branch-workspace' - /** * Whether the "Hide sleeping" sweep must keep this row (#8873). * diff --git a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts index 17c0c2d2f14..880b03289d9 100644 --- a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts +++ b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import type { - LinearIssue, - LinearWorkflowState, - WorkspaceStatusDefinition, - Worktree -} from '../../../../shared/types' +import type { LinearIssue } from '../../../../shared/linear/issue-types' +import type { LinearWorkflowState } from '../../../../shared/linear/workspace-types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types' import type { LinearMutationResult } from '@/runtime/runtime-linear-client' import { getWorkspaceBoardTaskStatusSyncRequest, diff --git a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts index 15409ddcfe6..505459a99fd 100644 --- a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts +++ b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts @@ -5,13 +5,13 @@ import { type LinearMutationResult, type RuntimeLinearSettings } from '@/runtime/runtime-linear-client' +import type { LinearIssue } from '../../../../shared/linear/issue-types' +import type { LinearWorkflowState } from '../../../../shared/linear/workspace-types' import type { - LinearIssue, - LinearWorkflowState, WorkspaceStatus, WorkspaceStatusDefinition, Worktree -} from '../../../../shared/types' +} from '../../../../shared/worktree/types' import { getWorkspaceStatus } from '../../../../shared/workspace-statuses' export type WorkspaceBoardTaskStatusSyncResult = { diff --git a/src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts b/src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts index 1cbd5eedb08..f21c61c5279 100644 --- a/src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts +++ b/src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import { filterFolderWorkspacesFromOtherDevices } from './workspace-creator-visibility' function makeFolderWorkspace( diff --git a/src/renderer/src/components/sidebar/workspace-creator-visibility.ts b/src/renderer/src/components/sidebar/workspace-creator-visibility.ts index 1cc4e208cfd..ca0b929a40c 100644 --- a/src/renderer/src/components/sidebar/workspace-creator-visibility.ts +++ b/src/renderer/src/components/sidebar/workspace-creator-visibility.ts @@ -1,6 +1,7 @@ import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' -import type { FolderWorkspace, Worktree } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' import { normalizeWorkspaceCreatorProvenance } from '../../../../shared/workspace-creator-provenance' diff --git a/src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts b/src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts index b5679409cc5..b368cb93133 100644 --- a/src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts +++ b/src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host' -import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' function makeWorktree(id: string, path: string): Worktree { diff --git a/src/renderer/src/components/sidebar/workspace-delete-lineage.ts b/src/renderer/src/components/sidebar/workspace-delete-lineage.ts index ca6c78c1b67..55d62fc71fd 100644 --- a/src/renderer/src/components/sidebar/workspace-delete-lineage.ts +++ b/src/renderer/src/components/sidebar/workspace-delete-lineage.ts @@ -1,4 +1,5 @@ -import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getProjectedWorktreeLineageChildrenByParentId } from './worktree-lineage-projection' type WorkspaceDeleteLineage = { diff --git a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts index dd59d430ab9..768fd3cd2bd 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts @@ -1,4 +1,4 @@ -import type { WorkspaceStatus } from '../../../../shared/types' +import type { WorkspaceStatus } from '../../../../shared/worktree/types' import { resolveWorkspaceKanbanVirtualLaneDropIndex, resolveWorkspaceKanbanVirtualLaneDropIndicatorY diff --git a/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts index d5b42c45cde..0eb1d42d377 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { WORKTREE_PALETTE_QUERY_MAX_BYTES } from '@/lib/worktree-palette-query-bounds' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { buildWorkspaceKanbanLaneViews, matchWorkspaceBoardWorktrees diff --git a/src/renderer/src/components/sidebar/workspace-kanban-search.ts b/src/renderer/src/components/sidebar/workspace-kanban-search.ts index a7f8b0840d0..c76fb230610 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-search.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-search.ts @@ -1,6 +1,7 @@ import { isWorktreePaletteQueryTooLarge } from '@/lib/worktree-palette-query-bounds' import { searchWorktrees, type PaletteMatchedField } from '@/lib/worktree-palette-search' -import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types' export type WorkspaceKanbanLaneView = { items: readonly Worktree[] diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts index 5a4a3d62b4c..bdd9928ff17 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types' import { serializeWorkspaceLaneFullIds } from './workspace-kanban-filtered-drop-index' import { buildWorkspaceKanbanSidebarDropUpdates, diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts index 8475d91cdd8..53600b5b20b 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts @@ -1,9 +1,9 @@ +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import type { WorkspaceStatus, WorkspaceStatusDefinition, - Worktree, - WorktreeMeta -} from '../../../../shared/types' + Worktree +} from '../../../../shared/worktree/types' import { parseWorkspaceLaneFullIds, resolveFullLaneDropIndex diff --git a/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts index 00fbbe84067..e705a12f9fe 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { groupWorkspaceKanbanWorktrees } from './workspace-kanban-worktree-groups' function worktree({ diff --git a/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.ts b/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.ts index 1f85cf3682c..b26ba6e7367 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.ts @@ -1,4 +1,8 @@ -import type { WorkspaceStatus, WorkspaceStatusDefinition, Worktree } from '../../../../shared/types' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition, + Worktree +} from '../../../../shared/worktree/types' import type { SortBy } from './smart-sort' import { getWorkspaceStatus } from './workspace-status' import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order' diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts index cc9d4646584..f4a06ccfada 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getWorkspaceLineageMenuActions, hasSleepableWorkspaceActivity diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts index 64dd1260534..1ff8c20ea6c 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { tabHasLivePty } from '@/lib/tab-has-live-pty' -import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' type WorkspaceActivityMaps = { diff --git a/src/renderer/src/components/sidebar/workspace-status.ts b/src/renderer/src/components/sidebar/workspace-status.ts index a35f7dff4fd..5229831dcac 100644 --- a/src/renderer/src/components/sidebar/workspace-status.ts +++ b/src/renderer/src/components/sidebar/workspace-status.ts @@ -1,6 +1,6 @@ import React from 'react' import { CircleDot } from 'lucide-react' -import type { WorkspaceStatus, WorkspaceStatusDefinition } from '../../../../shared/types' +import type { WorkspaceStatus, WorkspaceStatusDefinition } from '../../../../shared/worktree/types' import { DEFAULT_WORKSPACE_STATUS_COLOR_ID, DEFAULT_WORKSPACE_STATUS_ICON_ID, diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index bc1f06cc7b5..ed086d7c94e 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { shallow } from 'zustand/shallow' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { selectWorktreeAgentActivitySummary, type AgentActivityInput diff --git a/src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts b/src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts index b52b0dd9024..098c7eadcb4 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts @@ -4,7 +4,7 @@ import { type AgentStatusEntry } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { createWorktreeAgentFreshnessSelector } from './worktree-agent-freshness-selector' const WT_1_PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') diff --git a/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts b/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts index 808664cbc7c..665cbb07896 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts @@ -5,7 +5,7 @@ import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { EMPTY_WORKTREE_AGENT_ORCHESTRATION, selectRuntimeAgentOrchestrationBatch diff --git a/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts b/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts index 2bd549481c0..e7881d95aff 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts @@ -4,7 +4,7 @@ import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { EMPTY_WORKTREE_AGENT_ORCHESTRATION, diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts b/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts index d722d39c4bb..9a0b6185a1a 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts @@ -1,6 +1,6 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' export function effectiveWorktreeAgentRowStartedAt(entry: AgentStatusEntry): number { return entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts index 46f75858ee9..29b30fa34ba 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts @@ -3,7 +3,7 @@ import type { AgentStatusEntry, MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { makePaneKey } from '../../../../shared/stable-pane-id' import { getLiveEntriesFullRebuildCountForTests } from './worktree-agent-live-index-patch' diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts index 75de25923f2..e9a6b5fde0b 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts @@ -13,7 +13,7 @@ import { recordLiveEntriesFullRebuild } from './worktree-agent-live-index-patch' import { selectWorktreeAgentOrchestration } from './worktree-agent-orchestration-index' -import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = [] const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES: MigrationUnsupportedPtyEntry[] = [] diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-agent-rows.ts index da4a33650e6..1fbca0fc743 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-rows.ts @@ -16,7 +16,7 @@ import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, TerminalTab -} from '../../../../shared/types' +} from '../../../../shared/terminal-tab-types' import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' import { buildTitleDerivedAgentRows, diff --git a/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts b/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts index e5256b48312..7a009b384fa 100644 --- a/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts +++ b/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts @@ -1,4 +1,4 @@ -import type { WorktreeCardProperty } from '../../../../shared/types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' import { translate } from '@/i18n/i18n' export const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [ diff --git a/src/renderer/src/components/sidebar/worktree-card-header.tsx b/src/renderer/src/components/sidebar/worktree-card-header.tsx index 0c063e28dee..8fb715b01ca 100644 --- a/src/renderer/src/components/sidebar/worktree-card-header.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-header.tsx @@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { resolveRepoHeaderColor } from './project-header-color' import { formatSparseDirectoryPreview, shouldBeginWorktreeRename } from './worktree-card-model' import type { WorktreeCardPresentation } from './worktree-card-presentation' diff --git a/src/renderer/src/components/sidebar/worktree-card-jira-issue-display.ts b/src/renderer/src/components/sidebar/worktree-card-jira-issue-display.ts index b9373e1092a..a072c51c33f 100644 --- a/src/renderer/src/components/sidebar/worktree-card-jira-issue-display.ts +++ b/src/renderer/src/components/sidebar/worktree-card-jira-issue-display.ts @@ -1,4 +1,5 @@ -import type { Worktree, WorktreeCardProperty } from '../../../../shared/types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { WorktreeCardJiraIssueDisplay } from './worktree-card-meta-types' function withoutRepeatedJiraIdentifier(title: string, identifier: string): string { diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts index 75373a14699..0be771e7534 100644 --- a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts +++ b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts @@ -1,9 +1,9 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { IssueInfo } from '../../../../shared/github/pull-request-types' import type { AutomationWorkspaceProvenance, - CliWorkspaceProvenance, - IssueInfo -} from '../../../../shared/types' + CliWorkspaceProvenance +} from '../../../../shared/worktree/types' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' import type { WorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' diff --git a/src/renderer/src/components/sidebar/worktree-card-model.ts b/src/renderer/src/components/sidebar/worktree-card-model.ts index 2331b92d2cd..e10cf2da703 100644 --- a/src/renderer/src/components/sidebar/worktree-card-model.ts +++ b/src/renderer/src/components/sidebar/worktree-card-model.ts @@ -1,6 +1,7 @@ import type React from 'react' -import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' export type WorktreeRenameRequest = { diff --git a/src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts b/src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts index 8cc6d45ac5a..7e677708b50 100644 --- a/src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { PRInfo } from '../../../../shared/types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' import { getWorktreeCardPrDisplay, isCachedMergedBranchPRCurrentForWorktree diff --git a/src/renderer/src/components/sidebar/worktree-card-pr-display.ts b/src/renderer/src/components/sidebar/worktree-card-pr-display.ts index 27027e09183..12dcddfac03 100644 --- a/src/renderer/src/components/sidebar/worktree-card-pr-display.ts +++ b/src/renderer/src/components/sidebar/worktree-card-pr-display.ts @@ -1,5 +1,6 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { PRInfo, Worktree } from '../../../../shared/types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { Worktree } from '../../../../shared/worktree/types' type LinkedReviewMetadataProvider = Exclude diff --git a/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts b/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts index 0030e7d931e..7d366b845e8 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts @@ -4,7 +4,7 @@ import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, TerminalTab -} from '../../../../shared/types' +} from '../../../../shared/terminal-tab-types' import { selectLivePtyIdsForWorktree, selectTerminalLayoutRootsForWorktree, diff --git a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts index e06894441cf..128afafe03d 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts @@ -1,5 +1,5 @@ import type { AppState } from '@/store/types' -import type { TerminalPaneLayoutNode } from '../../../../shared/types' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' // Why: these selectors return fresh maps whose top-level values preserve // underlying per-tab references, so callers must compare them shallowly. diff --git a/src/renderer/src/components/sidebar/worktree-delete-execution.ts b/src/renderer/src/components/sidebar/worktree-delete-execution.ts index 889587306d0..7d0ca496a1a 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-execution.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-execution.ts @@ -7,13 +7,14 @@ import { isPathInsideOrEqual, normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast' 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 71ef9e87cda..f57a83a6b65 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-request.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-request.ts @@ -1,4 +1,4 @@ -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' export type WorktreeBatchDeleteOptions = { @@ -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-lineage-drag-drop.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts index 9241dd2cf11..65ae3befbc7 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getCyclicProjectedWorktreeLineageIds } from './worktree-lineage-projection' import { getReorderedWorktreeIdsToUnnest, diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts index f171ce5d2a6..5f63768aac3 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts @@ -1,4 +1,5 @@ -import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getLineageRenderInfo } from './worktree-lineage-projection' const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]' diff --git a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts index 040a40eb737..337140f586c 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts @@ -2,7 +2,8 @@ import { getCyclicWorktreeLineageChildIds, isValidResolvedWorktreeLineageEdge } from '../../../../shared/resolved-worktree-lineage' -import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' export type LineageRenderInfo = | { state: 'none' } diff --git a/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts index 9b472ab7be7..61eca2889de 100644 --- a/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { FolderWorkspace, ProjectGroup, Worktree } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { getFolderWorkspaceRevealGroupKeys, diff --git a/src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts index 21c70db9384..de8ca2c278c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts +++ b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts @@ -1,4 +1,6 @@ -import type { FolderWorkspace, ProjectGroup, Worktree } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getProjectGroupHeaderKey } from './worktree-list-groups' 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 c5a7f9606b2..7e6fe4b3b58 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -20,16 +20,12 @@ import { REPO_HEADER_ACTION_REVEAL_CLASS } from './repo-header-action-button-class' import { getWorktreeLineageAncestors } from './worktree-lineage-projection' -import type { - DetectedWorktree, - Project, - ProjectHostSetup, - FolderWorkspace, - Repo, - ProjectGroup, - Worktree, - WorktreeLineage -} from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { DetectedWorktree, Worktree } from '../../../../shared/worktree/types' const LOCAL_HOST_LABEL = getExecutionHostLabel('local') @@ -67,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', @@ -3989,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') }) @@ -4009,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-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index a154eea8263..068da94edb7 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -1,18 +1,17 @@ /* eslint-disable max-lines -- Why: sidebar row construction keeps every grouping mode in one pure module so reveal, virtualized rendering, and tests share the same flat row contract. */ import { CircleX, FolderTree, List, Pin } from 'lucide-react' import type React from 'react' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { ProjectOrderBy } from '../../../../shared/ui-chrome-types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { DetectedWorktree, - Project, - ProjectHostSetup, - FolderWorkspace, - Repo, - ProjectGroup, - ProjectOrderBy, - Worktree, - WorktreeLineage, - WorkspaceStatusDefinition -} from '../../../../shared/types' + WorkspaceStatusDefinition, + Worktree +} from '../../../../shared/worktree/types' import { branchName } from '../../lib/git-utils' import { getWorkspaceStatus, diff --git a/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts b/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts index 2321971231e..179e3ee8379 100644 --- a/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' import { getFolderPathStatusRouteOptionsForRows, getFolderWorkspaceExecutionHostIdForRows, diff --git a/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts b/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts index 655c4e4f2ff..8071b56bcb7 100644 --- a/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts +++ b/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts @@ -7,7 +7,8 @@ import { type ExecutionHostScope } from '../../../../shared/execution-host' import type { FolderWorkspacePathStatusRequest } from '../../../../shared/folder-workspace-path-status' -import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' /** null means "no host filter" — every host is visible. */ export function getVisibleSidebarHostIdSet( 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 67f3760c91c..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,12 +1,10 @@ import { describe, expect, it } from 'vitest' -import { - canKeepImportedWorktreesHidden, - getRenderRowKey, - getWorktreeDragGroups, - getWorktreeDragIndexes, - renderRowContainsWorktree -} from './WorktreeList' -import type { Repo, Worktree } from '../../../../shared/types' +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' const repo: Repo = { diff --git a/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.test.ts b/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.test.ts index 4960445c342..714fe0e64b4 100644 --- a/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' -import type { FolderWorkspace } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import { EMPTY_WORKTREE_LIST_REVIEW_CACHE_INPUTS, selectWorktreeListReviewCacheInputs, diff --git a/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.ts b/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.ts index 9ea780c89ea..690da091f3c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.ts @@ -1,5 +1,5 @@ import type { AppState } from '@/store/types' -import type { WorktreeCardProperty } from '../../../../shared/types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' import type { WorktreeGroupBy } from './worktree-list-groups' export type WorktreeListReviewCacheState = Pick< 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 924a4edc88a..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,18 +1,20 @@ 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, GROUP_HEADER_ROW_HEIGHT, getActiveStickyHeaderIndexForScroll } from './worktree-list-virtual-rows' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import type { Row } from './worktree-list-groups' const repo: Repo = { diff --git a/src/renderer/src/components/sidebar/worktree-list-sticky-headers.test.ts b/src/renderer/src/components/sidebar/worktree-list-sticky-headers.test.ts index 777560a9484..1c8efdfe9a0 100644 --- a/src/renderer/src/components/sidebar/worktree-list-sticky-headers.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-sticky-headers.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { buildRows } from './worktree-list-groups' import { getStickyHeaderIndexes } from './worktree-list-virtual-rows' -import type { ProjectGroup, Repo, Worktree } from '../../../../shared/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { Row } from './worktree-list-groups' const repo: Repo = { 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-meta-updates.test.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts index 3ce6ffb74b4..38985cf7330 100644 --- a/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts +++ b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { WorktreeMeta } from '../../../../shared/types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import { buildWorktreeMetaUpdates, type WorktreeMetaDraft, diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.ts index 20afdf8af26..b8f06c43d8c 100644 --- a/src/renderer/src/components/sidebar/worktree-meta-updates.ts +++ b/src/renderer/src/components/sidebar/worktree-meta-updates.ts @@ -5,7 +5,8 @@ import { } from '../../../../shared/linear/links' import { parseIssueLinkInput, type IssueLinkProvider } from '../../../../shared/issue-link-input' import type { WorkspaceSourceProvider } from '../../../../shared/new-workspace/workspace-source' -import type { WorkspaceLinkedItem, WorktreeMeta } from '../../../../shared/types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { WorkspaceLinkedItem } from '../../../../shared/worktree/types' export type WorktreeMetaSavedPayload = { worktreeId: string diff --git a/src/renderer/src/components/sidebar/worktree-parent-candidates.ts b/src/renderer/src/components/sidebar/worktree-parent-candidates.ts index 981ea7e3a7d..b95d2c1fbd3 100644 --- a/src/renderer/src/components/sidebar/worktree-parent-candidates.ts +++ b/src/renderer/src/components/sidebar/worktree-parent-candidates.ts @@ -1,5 +1,7 @@ import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' -import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { canAssignWorktreeParent } from './worktree-parent-eligibility' import { getCyclicProjectedWorktreeLineageIds } from './worktree-lineage-projection' diff --git a/src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts b/src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts index 7dfe0360f02..64400b7dedd 100644 --- a/src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts +++ b/src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { join } from 'node:path' -import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { canAssignWorktreeParent } from './worktree-parent-eligibility' import { getEligibleWorktreeParents, isEligibleWorktreeParent } from './worktree-parent-candidates' diff --git a/src/renderer/src/components/sidebar/worktree-parent-eligibility.ts b/src/renderer/src/components/sidebar/worktree-parent-eligibility.ts index fe2c4261064..f620f19133d 100644 --- a/src/renderer/src/components/sidebar/worktree-parent-eligibility.ts +++ b/src/renderer/src/components/sidebar/worktree-parent-eligibility.ts @@ -1,4 +1,5 @@ -import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' import { getCyclicProjectedWorktreeLineageIds, getLineageRenderInfo diff --git a/src/renderer/src/components/sidebar/worktree-parent-picker-filtering.ts b/src/renderer/src/components/sidebar/worktree-parent-picker-filtering.ts index 3c07c35b1e4..b15154440e6 100644 --- a/src/renderer/src/components/sidebar/worktree-parent-picker-filtering.ts +++ b/src/renderer/src/components/sidebar/worktree-parent-picker-filtering.ts @@ -1,6 +1,6 @@ import { defaultFilter } from 'cmdk' import { branchDisplayName } from './WorktreeCardHelpers' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' export function getWorktreeParentPickerItemValue(candidate: Worktree): string { return `${candidate.displayName} ${branchDisplayName(candidate.branch)} ${candidate.path}` 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-sidebar-row-preference.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts index cd4672dc9a7..c8b49f10b1f 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../shared/types' +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 { Worktree } from '../../../../shared/worktree/types' import type { HostSectionRow } from './host-section-rows' import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-preference' diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts index c73107cbed8..0495375b691 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts @@ -1,5 +1,5 @@ import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' -import type { Worktree } from '../../../../shared/types' +import type { Worktree } from '../../../../shared/worktree/types' import type { HostSectionRow } from './host-section-rows' import { PINNED_GROUP_KEY, 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-subagent-child-rows.ts b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts index 2a76bb0799c..5135eea6933 100644 --- a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts @@ -1,6 +1,6 @@ import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' /** Row-identity key for an in-process subagent child row. The NUL separator * cannot appear in real pane keys, so synthetic keys can never collide with diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index 44c3e7f2639..6fcd0a6719c 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage' -import type { TerminalLayoutSnapshot, TerminalTab, TuiAgent } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { makePaneKey } from '../../../../shared/stable-pane-id' import { buildWorktreeAgentRows } from './worktree-agent-rows' diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 9224fe2cba3..70289b4b3fb 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -14,7 +14,7 @@ import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, TerminalTab -} from '../../../../shared/types' +} from '../../../../shared/terminal-tab-types' import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner 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-update-error.ts b/src/renderer/src/components/sidebar/worktree-visibility-update-error.ts index 74605160e68..3e82a24d0ac 100644 --- a/src/renderer/src/components/sidebar/worktree-visibility-update-error.ts +++ b/src/renderer/src/components/sidebar/worktree-visibility-update-error.ts @@ -1,5 +1,5 @@ import { translate } from '@/i18n/i18n' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' export function worktreeVisibilityUpdateError( updateSucceeded: boolean, 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/skills/SkillsPage.test.tsx b/src/renderer/src/components/skills/SkillsPage.test.tsx index 4499d8ea87a..75b5101032c 100644 --- a/src/renderer/src/components/skills/SkillsPage.test.tsx +++ b/src/renderer/src/components/skills/SkillsPage.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { fireEvent } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills' import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' diff --git a/src/renderer/src/components/skills/skill-install-workspace-choices.test.ts b/src/renderer/src/components/skills/skill-install-workspace-choices.test.ts index f5a789fcb75..45bd7620f1e 100644 --- a/src/renderer/src/components/skills/skill-install-workspace-choices.test.ts +++ b/src/renderer/src/components/skills/skill-install-workspace-choices.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { FolderWorkspace, Repo, Worktree } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices' describe('skillInstallWorkspaceChoices', () => { diff --git a/src/renderer/src/components/skills/skill-install-workspace-choices.ts b/src/renderer/src/components/skills/skill-install-workspace-choices.ts index 7a7fbf2bfad..08de9f2d2a3 100644 --- a/src/renderer/src/components/skills/skill-install-workspace-choices.ts +++ b/src/renderer/src/components/skills/skill-install-workspace-choices.ts @@ -1,4 +1,6 @@ -import type { FolderWorkspace, Repo, Worktree } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' export type SkillInstallWorkspaceChoice = { id: string diff --git a/src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx b/src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx index 8ff4f7bfed8..a25f4337c9c 100644 --- a/src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx +++ b/src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx @@ -7,7 +7,7 @@ import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { parseSparsePresetDirectories } from '@/lib/sparse-preset-draft' import { useMountedRef } from '@/hooks/useMountedRef' -import type { SparsePreset } from '../../../../shared/types' +import type { SparsePreset } from '../../../../shared/worktree/create-types' import { translate } from '@/i18n/i18n' import { SparseCheckoutPresetDraftForm, diff --git a/src/renderer/src/components/stats/UsageBreakdownSection.test.tsx b/src/renderer/src/components/stats/UsageBreakdownSection.test.tsx new file mode 100644 index 00000000000..c6e5ba63fa0 --- /dev/null +++ b/src/renderer/src/components/stats/UsageBreakdownSection.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { UsageBreakdownSection } from './UsageBreakdownSection' + +afterEach(cleanup) + +describe('UsageBreakdownSection', () => { + it('wraps long model identifiers instead of truncating them', () => { + const firstModel = 'fireworks-ai/accounts/fireworks/models/deepseek-v3p2' + const secondModel = 'fireworks-ai/accounts/fireworks/models/qwen3-coder-480b' + + render( + + ) + + for (const model of [firstModel, secondModel]) { + const label = screen.getByText(model) + expect(label).toHaveClass('min-w-0', 'break-words') + expect(label).not.toHaveClass('truncate') + } + }) +}) diff --git a/src/renderer/src/components/stats/UsageBreakdownSection.tsx b/src/renderer/src/components/stats/UsageBreakdownSection.tsx index 84186694ca3..19b3ad7c224 100644 --- a/src/renderer/src/components/stats/UsageBreakdownSection.tsx +++ b/src/renderer/src/components/stats/UsageBreakdownSection.tsx @@ -44,7 +44,7 @@ export function UsageBreakdownSection({ {rows.slice(0, 5).map((row) => (
- {row.label} + {row.label} {formatTokens(row.tokens)}
diff --git a/src/renderer/src/components/status-bar/PetStatusSegment.tsx b/src/renderer/src/components/status-bar/PetStatusSegment.tsx index b927e29351e..48cd2f0292b 100644 --- a/src/renderer/src/components/status-bar/PetStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/PetStatusSegment.tsx @@ -15,7 +15,7 @@ import { } from '@/components/ui/dropdown-menu' import { useAppStore } from '../../store' import { BUNDLED_PET, BUNDLED_PETS, findBundledPet, isBundledPetId } from '../pet/pet-models' -import { PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../shared/types' +import { PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../shared/pet-types' import { translate } from '@/i18n/i18n' // Why: cluster pet-related controls (show/hide, character picker, custom diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx index 3bf507646da..783202de0cb 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx @@ -4,7 +4,7 @@ import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' -import type { BrowserWorkspace } from '../../../../shared/types' +import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' vi.mock('@/store', () => { diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index c4690a1c79a..6ddfef2251c 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -31,10 +31,11 @@ import { useAppStore } from '../../store' import { useWorktreeMap } from '../../store/selectors' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions' -import type { AppMemory, BrowserWorkspace, UsageValues, Worktree } from '../../../../shared/types' +import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { AppMemory, UsageValues } from '../../../../shared/process-stats-types' +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, @@ -907,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( () => @@ -1480,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 (
@@ -1007,7 +1026,7 @@ function BreakdownList({ )}
- ) : worktree.topLevelItems.length === 0 ? ( + ) : topLevelItemCount === 0 ? (
{translate( 'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079', @@ -1020,6 +1039,7 @@ function BreakdownList({ {worktree.topLevelItems.slice(0, 12).map((item) => ( ))} + {omittedItem ? : null}
)} diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts index a2fb18c1193..8536518728e 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest' -import type { - BrowserWorkspace, - MemorySnapshot, - TerminalTab, - Worktree, - WorktreeMemory -} from '../../../../shared/types' +import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { MemorySnapshot, WorktreeMemory } from '../../../../shared/process-stats-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' import { requiresKillConfirmation } from './resource-session-kill-confirmation' import type { DaemonSession, MergeContext } from './resource-usage-merge-types' diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index 672e7c622c2..7f858242952 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -17,7 +17,11 @@ * See docs/resource-usage-merge-spec.md for the full design. */ -import type { MemorySnapshot, SessionMemory, WorktreeMemory } from '../../../../shared/types' +import type { + MemorySnapshot, + SessionMemory, + WorktreeMemory +} from '../../../../shared/process-stats-types' import { parsePtySessionId } from '../../../../shared/pty-session-id-format' import { parsePaneKey as parseStablePaneKey } from '../../../../shared/stable-pane-id' import { diff --git a/src/renderer/src/components/status-bar/resource-memory-metric-copy.ts b/src/renderer/src/components/status-bar/resource-memory-metric-copy.ts index a847f7d53a9..50448bc4ae6 100644 --- a/src/renderer/src/components/status-bar/resource-memory-metric-copy.ts +++ b/src/renderer/src/components/status-bar/resource-memory-metric-copy.ts @@ -1,4 +1,4 @@ -import type { ProcessMemoryMetric } from '../../../../shared/types' +import type { ProcessMemoryMetric } from '../../../../shared/process-stats-types' import { translate } from '@/i18n/i18n' export type ResourceMemoryMetricCopy = { diff --git a/src/renderer/src/components/status-bar/resource-session-bindings.test.ts b/src/renderer/src/components/status-bar/resource-session-bindings.test.ts index 3845234d6ed..9cfefa99f3b 100644 --- a/src/renderer/src/components/status-bar/resource-session-bindings.test.ts +++ b/src/renderer/src/components/status-bar/resource-session-bindings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import { buildResourceSessionBindingIndex, countUnboundDaemonSessions, diff --git a/src/renderer/src/components/status-bar/resource-session-bindings.ts b/src/renderer/src/components/status-bar/resource-session-bindings.ts index 96240f82268..a0a46140072 100644 --- a/src/renderer/src/components/status-bar/resource-session-bindings.ts +++ b/src/renderer/src/components/status-bar/resource-session-bindings.ts @@ -1,4 +1,4 @@ -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import { mayDestroyWithoutOwnerEvidence } from '../../../../shared/pty-listed-session' import type { DaemonSession } from './resource-usage-merge-types' diff --git a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts index c365329c110..5943b744dfd 100644 --- a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts +++ b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts @@ -1,9 +1,6 @@ -import type { - BrowserWorkspace, - TerminalLayoutSnapshot, - TerminalTab, - Worktree -} from '../../../../shared/types' +import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { AgentOwnershipEvidence, PtyListedSession diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts index 4ded63147db..145abef3a90 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts @@ -1,4 +1,5 @@ -import type { StatusBarItem, TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' // Why: CLI-backed usage bars are surface noise when the underlying // CLI isn't installed (e.g. a fresh Ubuntu install showing "Gemini Usage" diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index e96fa09550a..f1afd97f5f4 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -1,5 +1,5 @@ import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' export type UsageProviderSettings = Pick< GlobalSettings, diff --git a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts index 262cf1595f1..5e0c4a162de 100644 --- a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { ClaudeRateLimitAccountsState, - CodexRateLimitAccountsState, - GlobalSettings -} from '../../../../shared/types' + CodexRateLimitAccountsState +} from '../../../../shared/managed-account-types' import { buildClaudeStatusSwitchGroups, buildCodexStatusSwitchGroups, diff --git a/src/renderer/src/components/status-bar/use-available-status-bar-toggles.ts b/src/renderer/src/components/status-bar/use-available-status-bar-toggles.ts index f693aa84d60..cda54a7b8bc 100644 --- a/src/renderer/src/components/status-bar/use-available-status-bar-toggles.ts +++ b/src/renderer/src/components/status-bar/use-available-status-bar-toggles.ts @@ -1,5 +1,5 @@ import { useAppStore } from '../../store' -import type { StatusBarItem } from '../../../../shared/types' +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' import { isStatusBarItemAvailable } from './status-bar-agent-gating' /** Subscribes to detected-agent state and returns the toggles filtered to diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts index 702f3c1d70a..1d600faed2a 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts @@ -18,7 +18,8 @@ import { } from './workspace-space-presentation' import { getWorkspaceDecisionDetails } from './WorkspaceSpaceManagerPanel' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' function row(overrides: Partial): WorkspaceSpaceWorktree { return { diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts index ad09532a652..5aab2dcefc5 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -6,7 +6,7 @@ import { type MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' import type { WorkspaceSpaceItem, diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index c1e994301b9..951f2f9f132 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { BrowserTab as BrowserTabState } from '../../../../shared/types' +import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' const reactHookRuntime = vi.hoisted(() => ({ states: [] as unknown[], diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index e0ecca579d2..2ea3d2d42e5 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -21,7 +21,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' import { redactKagiSessionToken } from '../../../../shared/browser-url' -import type { BrowserTab as BrowserTabState } from '../../../../shared/types' +import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab' import { getLiveBrowserUrl } from '../browser-pane/browser-runtime' import type { TabDragItemData } from '../tab-group/useTabDragSplit' diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 4af4e4fcfc4..1dcc0e96c16 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -12,7 +12,7 @@ import { getFileTypeIcon } from '@/lib/file-type-icons' import { useRepoById, useWorktreeById } from '@/store/selectors' import { useAppStore } from '@/store' import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display' -import type { GitFileStatus } from '../../../../shared/types' +import type { GitFileStatus } from '../../../../shared/git-status-types' import type { OpenFile } from '../../store/slices/editor' import { getUntitledFileRoot } from '@/components/editor/untitled-file-rename-path' import { preventMiddleButtonDefault } from './middle-button-default-guard' diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index d2e086f7496..6af2508048c 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -8,7 +8,7 @@ import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTar import { useDetectedAgents } from '@/hooks/useDetectedAgents' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { LaunchSource } from '../../../../shared/telemetry-events' import { DEFAULT_DISABLED_TUI_AGENTS, diff --git a/src/renderer/src/components/tab-bar/RecentTabSwitcher.test.tsx b/src/renderer/src/components/tab-bar/RecentTabSwitcher.test.tsx index e0a0ab9f7f0..31f01c65996 100644 --- a/src/renderer/src/components/tab-bar/RecentTabSwitcher.test.tsx +++ b/src/renderer/src/components/tab-bar/RecentTabSwitcher.test.tsx @@ -3,7 +3,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Tab } from '../../../../shared/types' +import type { Tab } from '../../../../shared/tab-types' import type { AppState } from '../../store/types' const { activateCyclableTabMock, getStateMock } = vi.hoisted(() => ({ diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index cec2a0f0082..b1417b57c13 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -6,7 +6,7 @@ import { useTabAgent } from '@/lib/use-tab-agent' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { useAppStore } from '../../store' import { diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 86d48961284..c10118dc45d 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -17,7 +17,7 @@ import { DropdownMenuShortcut, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { useAppStore } from '../../store' import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx index bb51c99d14c..0480bbcac87 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx @@ -3,7 +3,9 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Tab, TabGroup, TerminalTab, Worktree } from '../../../../shared/types' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { TabEntryOption } from './tab-create-entry-action' import type { TabAgentLaunchOption } from './tab-agent-launch-options' // Unmocked on purpose: the empty-query message must stay in step with the diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx index 776e7939b3e..ae831a418f9 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx @@ -13,7 +13,7 @@ import { import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { runQuickCommandInNewTab } from '@/lib/run-quick-command-in-new-tab' -import type { TerminalQuickCommand } from '../../../../shared/types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { translate } from '@/i18n/i18n' import { TabBarQuickCommandsMenu } from './TabBarQuickCommandsMenu' diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx index 44c75c39586..431157b0731 100644 --- a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx @@ -1,7 +1,8 @@ import { AgentStateDot } from '@/components/AgentStateDot' import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' -import type { TerminalTab, TuiAgent } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' import { ShellIcon } from './shell-icons' import { diff --git a/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts index 37302887f6d..574d6507232 100644 --- a/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts +++ b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { isLocalPathOpenBlocked } from '@/lib/local-path-open-guard' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' diff --git a/src/renderer/src/components/tab-bar/group-tab-order.test.ts b/src/renderer/src/components/tab-bar/group-tab-order.test.ts index 5835288bbc8..797fea97848 100644 --- a/src/renderer/src/components/tab-bar/group-tab-order.test.ts +++ b/src/renderer/src/components/tab-bar/group-tab-order.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Tab, TabGroup } from '../../../../shared/types' +import type { Tab, TabGroup } from '../../../../shared/tab-types' import type { AppState } from '../../store/types' import { getActiveTabNavOrder, getGroupVisibleTabOrder } from './group-tab-order' diff --git a/src/renderer/src/components/tab-bar/group-tab-order.ts b/src/renderer/src/components/tab-bar/group-tab-order.ts index 8a16e51786b..d9396e3a53c 100644 --- a/src/renderer/src/components/tab-bar/group-tab-order.ts +++ b/src/renderer/src/components/tab-bar/group-tab-order.ts @@ -1,4 +1,4 @@ -import type { Tab, TabGroup } from '../../../../shared/types' +import type { Tab, TabGroup } from '../../../../shared/tab-types' import type { AppState } from '../../store/types' import { reconcileTabOrder } from './reconcile-order' diff --git a/src/renderer/src/components/tab-bar/open-tab-search-entries.ts b/src/renderer/src/components/tab-bar/open-tab-search-entries.ts index 1b16a7d845f..4a808bcf6ce 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search-entries.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search-entries.ts @@ -1,7 +1,8 @@ // Builds one worktree's tabs, browser pages and simulator tabs into the shapes // the three Cmd+J engines search. -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import { buildSearchableBrowserPages } from '@/lib/browser-palette-page-entries' import type { SearchableBrowserPage } from '@/lib/browser-palette-search' import { diff --git a/src/renderer/src/components/tab-bar/open-tab-search.test.ts b/src/renderer/src/components/tab-bar/open-tab-search.test.ts index 1476bdaa3bb..75103fe80c3 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search.test.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { - BrowserPage, - BrowserWorkspace, - Tab, - TabContentType, - Worktree -} from '../../../../shared/types' +import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { Tab, TabContentType } from '../../../../shared/tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { SearchableBrowserPage } from '@/lib/browser-palette-search' import type { SearchableSimulatorTab } from '@/lib/simulator-palette-search' import type { SearchableWorkspaceTab } from '@/lib/workspace-tab-palette-search' diff --git a/src/renderer/src/components/tab-bar/recent-tab-switching.test.ts b/src/renderer/src/components/tab-bar/recent-tab-switching.test.ts index 7f52e4ae1d1..49e0a4da7f6 100644 --- a/src/renderer/src/components/tab-bar/recent-tab-switching.test.ts +++ b/src/renderer/src/components/tab-bar/recent-tab-switching.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AppState } from '../../store/types' -import type { Tab } from '../../../../shared/types' +import type { Tab } from '../../../../shared/tab-types' import { buildRecentTabSwitcherModel, getNextRecentTabSwitcherIndex, diff --git a/src/renderer/src/components/tab-bar/recent-tab-switching.ts b/src/renderer/src/components/tab-bar/recent-tab-switching.ts index 89070ba14ec..a2b5fba9fab 100644 --- a/src/renderer/src/components/tab-bar/recent-tab-switching.ts +++ b/src/renderer/src/components/tab-bar/recent-tab-switching.ts @@ -1,4 +1,4 @@ -import type { CtrlTabOrderMode, Tab, TabContentType, TabGroup } from '../../../../shared/types' +import type { CtrlTabOrderMode, Tab, TabContentType, TabGroup } from '../../../../shared/tab-types' import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution' import type { AppState } from '../../store/types' import { sanitizeRecentTabIds } from '../../store/slices/tab-group-state' diff --git a/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts b/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts index b15f6f7c004..bc9fc575c20 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts @@ -1,6 +1,6 @@ import { getAgentCatalog } from '@/lib/agent-catalog' import { normalizeMatchQuery, tokenizeMatchValue } from './query-token-match' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' export type TabAgentLaunchOption = { agent: TuiAgent diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts index e460318e609..38d6f2e267f 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { shallow } from 'zustand/shallow' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { findTabAgentEntry } from '../native-chat/native-chat-tab-agent-entry' import { createTabBarAgentProjectionSelector, diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts index b320d5e0ddc..420949992d8 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts @@ -1,5 +1,5 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' -import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { isNativeChatTabWideFallbackSafe, resolveNativeChatActiveLayoutLeafId diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-model.ts b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts index 89e2891908d..0eee66d7b68 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-model.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts @@ -1,9 +1,6 @@ -import type { - BrowserTab as BrowserTabState, - Tab, - TerminalTab, - WorkspaceVisibleTabType -} from '../../../../shared/types' +import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' +import type { Tab, WorkspaceVisibleTabType } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' import type { OpenFile } from '../../store/slices/editor' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' diff --git a/src/renderer/src/components/tab-bar/tab-bar-props.ts b/src/renderer/src/components/tab-bar/tab-bar-props.ts index 6f8d93ec0a7..59d2ef12fff 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-props.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-props.ts @@ -1,8 +1,6 @@ -import type { - BrowserTab as BrowserTabState, - TerminalTab, - WorkspaceVisibleTabType -} from '../../../../shared/types' +import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' +import type { WorkspaceVisibleTabType } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { OpenFile } from '../../store/slices/editor' import type { HoveredTabInsertion } from '../tab-group/useTabDragSplit' import type { TabCreateEntryArgs } from './tab-create-entry-action' diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index c37597e2932..6e1c49ddc94 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -163,8 +163,6 @@ export function renderTabBarSurface({ .join(' ')} > {renderedItems} - {/* Why: a short end inset keeps a last tab's close control off the chevron and fade. */} -
diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts index 8a11f2262ee..5f4a887574a 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { useAppStore } from '@/store' import { diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-props.ts b/src/renderer/src/components/tab-bar/tab-create-entry-props.ts index 56a4bca3abc..ba44b1d932e 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-props.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-props.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { TabCreateEntryArgs } from './tab-create-entry-action' import type { TabAgentLaunchOption } from './tab-agent-launch-options' import type { TabCreateMenuOption } from './tab-create-menu-options' diff --git a/src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts index 5c9abb972a7..01dae6c0f98 100644 --- a/src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts +++ b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '../../store' -import type { Tab } from '../../../../shared/types' +import type { Tab } from '../../../../shared/tab-types' import { canMoveTabToNewPaneColumn, moveTabToNewPaneColumn } from './tab-move-to-pane-column' const WT = 'wt-1' diff --git a/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.test.ts b/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.test.ts deleted file mode 100644 index f520afaf568..00000000000 --- a/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it } from 'vitest' -import { - computeTabStripEndInset, - computeTabStripScrollLeft, - findLastTabStripTab, - isLastTabStripTab, - syncTabStripEndPad, - TAB_STRIP_REVEAL_INSET_PX -} from './tab-strip-active-tab-scroll' - -function mockBox( - el: HTMLElement, - box: { - offsetLeft?: number - offsetWidth?: number - clientWidth?: number - scrollWidth?: number - } -): void { - Object.defineProperties(el, { - offsetLeft: { configurable: true, value: box.offsetLeft ?? 0 }, - offsetWidth: { configurable: true, value: box.offsetWidth ?? 0 }, - clientWidth: { configurable: true, value: box.clientWidth ?? 0 }, - scrollWidth: { configurable: true, value: box.scrollWidth ?? 0 } - }) -} - -describe('computeTabStripEndInset', () => { - it('is zero when tabs already fit', () => { - expect( - computeTabStripEndInset({ - stripClientWidth: 400, - lastTabWidth: 180, - contentWidth: 360 - }) - ).toBe(0) - }) - - it('uses the fade-sized inset when the last tab overflows', () => { - expect( - computeTabStripEndInset({ - stripClientWidth: 400, - lastTabWidth: 180, - contentWidth: 900 - }) - ).toBe(TAB_STRIP_REVEAL_INSET_PX) - }) - - it('shrinks the inset when the last tab almost fills the strip', () => { - expect( - computeTabStripEndInset({ - stripClientWidth: 200, - lastTabWidth: 190, - contentWidth: 400 - }) - ).toBe(10) - }) - - it('is zero when the last tab is at least as wide as the strip', () => { - expect( - computeTabStripEndInset({ - stripClientWidth: 180, - lastTabWidth: 180, - contentWidth: 400 - }) - ).toBe(0) - }) -}) - -// Derives scrollWidth from the live pad policy so tests can't assert unreachable geometry. -function lastTabGeometry({ - stripClientWidth, - lastTabWidth, - lastTabOffsetLeft -}: { - stripClientWidth: number - lastTabWidth: number - lastTabOffsetLeft: number -}): { - stripScrollWidth: number - stripClientWidth: number - tabOffsetLeft: number - tabWidth: number -} { - const contentWidth = lastTabOffsetLeft + lastTabWidth - const pad = computeTabStripEndInset({ stripClientWidth, lastTabWidth, contentWidth }) - return { - stripScrollWidth: contentWidth + pad, - stripClientWidth, - tabOffsetLeft: lastTabOffsetLeft, - tabWidth: lastTabWidth - } -} - -describe('computeTabStripScrollLeft', () => { - it('stops a normal-width last tab at the fade inset because the pad caps the range', () => { - const geometry = lastTabGeometry({ - stripClientWidth: 400, - lastTabWidth: 180, - lastTabOffsetLeft: 720 - }) - const scrollLeft = computeTabStripScrollLeft({ - ...geometry, - stripScrollLeft: 0, - inline: 'center' - }) - - expect(scrollLeft).toBe(geometry.stripScrollWidth - geometry.stripClientWidth) - const tabEnd = geometry.tabOffsetLeft + geometry.tabWidth - scrollLeft - expect(geometry.stripClientWidth - tabEnd).toBe(TAB_STRIP_REVEAL_INSET_PX) - }) - - it('centers a last tab once the pad covers the required trailing range', () => { - const geometry = lastTabGeometry({ - stripClientWidth: 200, - lastTabWidth: 190, - lastTabOffsetLeft: 210 - }) - - expect(computeTabStripScrollLeft({ ...geometry, stripScrollLeft: 0, inline: 'center' })).toBe( - geometry.tabOffsetLeft + geometry.tabWidth / 2 - geometry.stripClientWidth / 2 - ) - }) - - it('keeps a fully visible middle tab in place', () => { - expect( - computeTabStripScrollLeft({ - stripScrollWidth: 900, - stripClientWidth: 400, - stripScrollLeft: 200, - tabOffsetLeft: 280, - tabWidth: 120, - inline: 'nearest' - }) - ).toBe(200) - }) - - it('scrolls far enough that a clipped trailing tab clears the fade inset', () => { - expect( - computeTabStripScrollLeft({ - stripScrollWidth: 900, - stripClientWidth: 400, - stripScrollLeft: 0, - tabOffsetLeft: 320, - tabWidth: 180, - inline: 'nearest' - }) - ).toBe(320 + 180 - 400 + TAB_STRIP_REVEAL_INSET_PX) - }) -}) - -describe('tab strip last-tab helpers', () => { - it('treats the last data-tab-id node as the last tab and ignores the end pad', () => { - const strip = document.createElement('div') - const first = document.createElement('div') - first.setAttribute('data-tab-id', 'one') - const last = document.createElement('div') - last.setAttribute('data-tab-id', 'two') - const pad = document.createElement('div') - pad.setAttribute('data-tab-strip-end-pad', '') - strip.append(first, last, pad) - - expect(findLastTabStripTab(strip)).toBe(last) - expect(isLastTabStripTab(strip, last)).toBe(true) - expect(isLastTabStripTab(strip, first)).toBe(false) - }) - - it('sizes the end pad to the fade inset when the last tab overflows', () => { - const strip = document.createElement('div') - const last = document.createElement('div') - last.setAttribute('data-tab-id', 'end') - const pad = document.createElement('div') - pad.setAttribute('data-tab-strip-end-pad', '') - strip.append(last, pad) - mockBox(strip, { clientWidth: 400 }) - mockBox(last, { offsetLeft: 720, offsetWidth: 180 }) - - expect(syncTabStripEndPad(strip)).toBe(TAB_STRIP_REVEAL_INSET_PX) - expect(pad.style.width).toBe(`${TAB_STRIP_REVEAL_INSET_PX}px`) - }) - - it('clears the end pad when every tab already fits', () => { - const strip = document.createElement('div') - const last = document.createElement('div') - last.setAttribute('data-tab-id', 'end') - const pad = document.createElement('div') - pad.setAttribute('data-tab-strip-end-pad', '') - pad.style.width = '110px' - strip.append(last, pad) - mockBox(strip, { clientWidth: 400 }) - mockBox(last, { offsetLeft: 0, offsetWidth: 180 }) - - expect(syncTabStripEndPad(strip)).toBe(0) - expect(pad.style.width).toBe('0px') - }) -}) diff --git a/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.ts b/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.ts deleted file mode 100644 index fd9551885d4..00000000000 --- a/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.ts +++ /dev/null @@ -1,102 +0,0 @@ -export const TAB_STRIP_TAB_ID_SELECTOR = '[data-tab-id]' -export const TAB_STRIP_END_PAD_ATTR = 'data-tab-strip-end-pad' - -// Why: matches the 1.5rem fade mask so nearest-reveal keeps the close control out of the fade. -export const TAB_STRIP_REVEAL_INSET_PX = 24 - -export function findLastTabStripTab(strip: ParentNode): HTMLElement | null { - const tabs = strip.querySelectorAll(TAB_STRIP_TAB_ID_SELECTOR) - return [...tabs].at(-1) ?? null -} - -export function isLastTabStripTab(strip: ParentNode, tab: Element): boolean { - return findLastTabStripTab(strip) === tab -} - -export function computeTabStripEndInset({ - stripClientWidth, - lastTabWidth, - contentWidth -}: { - stripClientWidth: number - lastTabWidth: number - contentWidth: number -}): number { - if (stripClientWidth <= 0 || lastTabWidth <= 0 || contentWidth <= stripClientWidth) { - return 0 - } - // Why: a half-viewport pad centered the last tab but hid earlier tabs and - // created a drop dead-zone; a fade-sized inset keeps the close control clear. - // Trade-off: a center request only lands centered while the strip is within - // 2x this inset of the last tab's width; wider gaps settle at end + inset. - return Math.min(TAB_STRIP_REVEAL_INSET_PX, Math.max(0, stripClientWidth - lastTabWidth)) -} - -export function computeTabStripScrollLeft({ - stripScrollWidth, - stripClientWidth, - stripScrollLeft, - tabOffsetLeft, - tabWidth, - inline -}: { - stripScrollWidth: number - stripClientWidth: number - stripScrollLeft: number - tabOffsetLeft: number - tabWidth: number - inline: 'nearest' | 'center' -}): number { - const maxScrollLeft = Math.max(0, stripScrollWidth - stripClientWidth) - if (inline === 'center') { - return clamp(tabOffsetLeft + tabWidth / 2 - stripClientWidth / 2, 0, maxScrollLeft) - } - - const tabStart = tabOffsetLeft - const tabEnd = tabOffsetLeft + tabWidth - const viewStart = stripScrollLeft + TAB_STRIP_REVEAL_INSET_PX - const viewEnd = stripScrollLeft + stripClientWidth - TAB_STRIP_REVEAL_INSET_PX - if (tabStart >= viewStart && tabEnd <= viewEnd) { - return stripScrollLeft - } - if (tabEnd > viewEnd) { - return clamp(tabEnd - stripClientWidth + TAB_STRIP_REVEAL_INSET_PX, 0, maxScrollLeft) - } - return clamp(tabStart - TAB_STRIP_REVEAL_INSET_PX, 0, maxScrollLeft) -} - -export function syncTabStripEndPad(strip: HTMLElement): number { - const lastTab = findLastTabStripTab(strip) - const pad = computeTabStripEndInset({ - stripClientWidth: strip.clientWidth, - lastTabWidth: lastTab?.offsetWidth ?? 0, - contentWidth: lastTab ? lastTab.offsetLeft + lastTab.offsetWidth : 0 - }) - const spacer = strip.querySelector(`[${TAB_STRIP_END_PAD_ATTR}]`) - if (spacer) { - const nextWidth = `${pad}px` - if (spacer.style.width !== nextWidth) { - spacer.style.width = nextWidth - } - } - return pad -} - -export function scrollTabStripTabIntoView( - strip: HTMLElement, - tab: HTMLElement, - inline: 'nearest' | 'center' -): void { - strip.scrollLeft = computeTabStripScrollLeft({ - stripScrollWidth: strip.scrollWidth, - stripClientWidth: strip.clientWidth, - stripScrollLeft: strip.scrollLeft, - tabOffsetLeft: tab.offsetLeft, - tabWidth: tab.offsetWidth, - inline - }) -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)) -} diff --git a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts index 1d5b78df7c7..77af550b748 100644 --- a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts +++ b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts @@ -6,11 +6,6 @@ import { type TabStripScrollMetrics } from './tab-strip-scroll-metrics' import { isTabStripPointerGestureActive } from './tab-strip-pointer-gesture' -import { - findLastTabStripTab, - scrollTabStripTabIntoView, - syncTabStripEndPad -} from './tab-strip-active-tab-scroll' const TAB_STRIP_SCROLL_FRACTION = 0.75 const TAB_STRIP_MIN_SCROLL_STEP_PX = 120 @@ -56,10 +51,6 @@ export function useTabStripOverflowNavigation({ const tabStripRef = useRef(null) const prevStripLenRef = useRef<{ worktreeId: string; len: number } | null>(null) const stickToEndRef = useRef(false) - const activeVisibleTabIdRef = useRef(activeVisibleTabId) - useLayoutEffect(() => { - activeVisibleTabIdRef.current = activeVisibleTabId - }) const [tabStripOverflowState, setTabStripOverflowState] = useState( EMPTY_TAB_STRIP_OVERFLOW_STATE ) @@ -118,7 +109,6 @@ export function useTabStripOverflowNavigation({ onScroll() const handleStripResize = (): void => { - syncTabStripEndPad(el) updateTabStripOverflowState() // If the user is pinned to the right edge, keep it pinned even as tab // labels (e.g. "Terminal 5" -> branch name) expand and change scrollWidth. @@ -148,7 +138,6 @@ export function useTabStripOverflowNavigation({ } if (!prev || prev.worktreeId !== worktreeId) { prevStripLenRef.current = { worktreeId, len: tabCount } - syncTabStripEndPad(strip) updateTabStripOverflowState() return } @@ -159,7 +148,6 @@ export function useTabStripOverflowNavigation({ if (!el) { return } - syncTabStripEndPad(el) el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth) updateTabStripOverflowState() } @@ -167,28 +155,17 @@ export function useTabStripOverflowNavigation({ requestAnimationFrame(scrollToEnd) } if (tabCount > prev.len && !pointerGestureActive) { - const revealNewEndTab = (): void => { + const scrollToEnd = (): void => { const el = tabStripRef.current if (!el) { return } - syncTabStripEndPad(el) - const lastTab = findLastTabStripTab(el) - const activeId = activeVisibleTabIdRef.current - const activeTab = - activeId == null - ? null - : el.querySelector(`[data-tab-id="${CSS.escape(activeId)}"]`) - // Why: only a newly opened last tab should pin to the end; a restored - // mid-strip tab must not yank the viewport to the last tab. - if (lastTab && (activeTab == null || lastTab === activeTab)) { - scrollTabStripTabIntoView(el, lastTab, 'center') - stickToEndRef.current = true - } + el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth) + stickToEndRef.current = true updateTabStripOverflowState() } - revealNewEndTab() - requestAnimationFrame(revealNewEndTab) + scrollToEnd() + requestAnimationFrame(scrollToEnd) } prevStripLenRef.current = { worktreeId, len: tabCount } updateTabStripOverflowState() @@ -212,8 +189,7 @@ export function useTabStripOverflowNavigation({ requestAnimationFrame(updateTabStripOverflowState) return } - syncTabStripEndPad(strip) - scrollTabStripTabIntoView(strip, activeTab, 'nearest') + activeTab.scrollIntoView({ block: 'nearest', inline: 'nearest' }) requestAnimationFrame(updateTabStripOverflowState) }, [activeVisibleTabId, updateTabStripOverflowState]) diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index 8105e0f3263..05579cb7b4d 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -1,12 +1,10 @@ import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { - BrowserTab as BrowserTabState, - GitFileStatus, - TerminalTab, - TuiAgent -} from '../../../../shared/types' +import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' +import type { GitFileStatus } from '../../../../shared/git-status-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { OpenFile } from '../../store/slices/editor' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import BrowserTab from './BrowserTab' diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index 133597431e0..e475ae49a4d 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { hasUnreadAgentCompletionForTerminalTab, resetTerminalTabActivityFlagsCacheForTest, diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index abdcc584881..afba5dc4c90 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -5,7 +5,7 @@ import { type AgentStatusEntry } from '../../../../shared/agent-status-types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' // Why: a terminal tab is a container of panes, exactly like a worktree card is // a container of tabs. Reuse the WorktreeCard status vocabulary and resolver so diff --git a/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts index 4259ff8ade3..caa14841166 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { resetTerminalTabActivityFlagsCacheForTest, resolveTerminalTabActivityStatus diff --git a/src/renderer/src/components/tab-bar/use-open-tab-search.test.ts b/src/renderer/src/components/tab-bar/use-open-tab-search.test.ts index 43a92cbf1f4..65c5cdb0908 100644 --- a/src/renderer/src/components/tab-bar/use-open-tab-search.test.ts +++ b/src/renderer/src/components/tab-bar/use-open-tab-search.test.ts @@ -2,16 +2,11 @@ import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it } from 'vitest' -import type { - BrowserPage, - BrowserWorkspace, - Repo, - Tab, - TabContentType, - TabGroup, - TerminalTab, - Worktree -} from '../../../../shared/types' +import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Tab, TabContentType, TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { useOpenTabSearch } from './use-open-tab-search' diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index 7eb28f2bcc8..d12b275b556 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { translate } from '@/i18n/i18n' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts index 0137c1f7cd7..3da80876b98 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' -import type { GitFileStatus, Tab } from '../../../../shared/types' +import type { GitFileStatus } from '../../../../shared/git-status-types' +import type { Tab } from '../../../../shared/tab-types' import type { TabBarProps } from './tab-bar-props' import { buildOrderedTabItems, diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts index df75eeae463..25058e3f44a 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts @@ -4,7 +4,7 @@ import { renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { useTabBarQuickCommandSearchInput } from './use-tab-bar-quick-command-search-input' -import type { TerminalQuickCommand } from '../../../../shared/types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' const command = { id: 'cmd-1', label: 'Run tests' } as unknown as TerminalQuickCommand diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts index 4515de2a071..298c77f8777 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts @@ -1,6 +1,9 @@ import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' -import type { GitFileStatus, GlobalSettings, Tab, TuiAgent } from '../../../../shared/types' +import type { GitFileStatus } from '../../../../shared/git-status-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Tab } from '../../../../shared/tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import { useAppStore } from '../../store' import { buildStatusMap } from '../right-sidebar/status-display' diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.context-menu.test.ts b/src/renderer/src/components/tab-group/TabGroupPanel.context-menu.test.ts index 58aa57400c4..1c90210820e 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.context-menu.test.ts +++ b/src/renderer/src/components/tab-group/TabGroupPanel.context-menu.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Tab, TabContentType } from '../../../../shared/types' +import type { Tab, TabContentType } from '../../../../shared/tab-types' import { resolveGroupTabFromVisibleId } from './tab-group-visible-id' function tab(id: string, entityId: string, contentType: TabContentType, sortOrder: number): Tab { diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx index 9a97ae673e4..77f04fb3023 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { DndContext, DragOverlay } from '@dnd-kit/core' -import type { TabGroupLayoutNode } from '../../../../shared/types' +import type { TabGroupLayoutNode } from '../../../../shared/tab-types' import { useAppStore } from '../../store' import TabGroupPanel from './TabGroupPanel' import TabDragPreview from '../tab-bar/TabDragPreview' diff --git a/src/renderer/src/components/tab-group/tab-drag-data.ts b/src/renderer/src/components/tab-group/tab-drag-data.ts index ad55f0edd10..572385a0d14 100644 --- a/src/renderer/src/components/tab-group/tab-drag-data.ts +++ b/src/renderer/src/components/tab-group/tab-drag-data.ts @@ -1,4 +1,5 @@ -import type { TabGroup, TuiAgent } from '../../../../shared/types' +import type { TabGroup } from '../../../../shared/tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { TabSplitDirection } from '../../store/slices/tabs' export type TabDropZone = 'center' | TabSplitDirection diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts index d6a373fc865..019b3e297ee 100644 --- a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest' -import type { Tab } from '../../../../shared/types' +import type { Tab } from '../../../../shared/tab-types' import { useAppStore } from '../../store' import { applyDragPreviewTab, diff --git a/src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts b/src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts index ceca4d16962..7f72c716fe3 100644 --- a/src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts +++ b/src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/types' +import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types' import type { TabDragItemData } from './useTabDragSplit' import { captureTabGroupPanelGeometrySnapshot, diff --git a/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts b/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts index 80fefad4080..d7bab07f12f 100644 --- a/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts +++ b/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts @@ -1,5 +1,5 @@ import type { DragEndEvent, DragMoveEvent, DragOverEvent } from '@dnd-kit/core' -import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/types' +import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types' import { isPaneColumnSplitDropNoOp } from '../../store/slices/pane-column-split-drop-no-op' import { resolvePaneColumnEdgeZone, diff --git a/src/renderer/src/components/tab-group/tab-group-visible-id.ts b/src/renderer/src/components/tab-group/tab-group-visible-id.ts index 1e94a3df964..92cca52df7d 100644 --- a/src/renderer/src/components/tab-group/tab-group-visible-id.ts +++ b/src/renderer/src/components/tab-group/tab-group-visible-id.ts @@ -1,4 +1,4 @@ -import type { Tab } from '../../../../shared/types' +import type { Tab } from '../../../../shared/tab-types' export function resolveGroupTabFromVisibleId( groupTabs: readonly Tab[], diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts index fa825031762..2b372c2c2c4 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts @@ -4,7 +4,7 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/types' +import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types' import { useAppStore } from '../../store' import type { TabDragItemData } from './useTabDragSplit' import { shouldActivateTabDragFromDistanceSample } from './tab-drag-pointer-sensor' diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index ce79cf03281..a3c5c49ce9c 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -14,7 +14,7 @@ import { useSensor, useSensors } from '@dnd-kit/core' -import type { TabGroup } from '../../../../shared/types' +import type { TabGroup } from '../../../../shared/tab-types' import { useAppStore } from '../../store' import { mirrorWebRuntimeTabMove } from '../tab-bar/web-runtime-tab-move-mirror' import { diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index d2dcdcb965b..6bb2f7a5992 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -3,12 +3,9 @@ import { useCallback, useMemo } from 'react' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' import type { OpenFile } from '@/store/slices/editor' -import type { - BrowserTab as BrowserTabState, - Tab, - TabGroup, - TerminalTab -} from '../../../../shared/types' +import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution' import { useAppStore } from '../../store' import { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup' diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index d06bf9ee623..968189a5417 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' import { workItemsCacheKey, type CacheEntry } from '@/store/slices/github' -import type { GitHubWorkItem, LinearCollectionResult, LinearIssue } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import type { LinearIssue } from '../../../shared/linear/issue-types' +import type { LinearCollectionResult } from '../../../shared/linear/workspace-types' import { buildTaskPageRepoSourceState, selectTaskPageUnresolvedSourceRepos, diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index 525fa51a336..403a8a97dd1 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -4,7 +4,7 @@ import { type WorkItemsCacheError, type WorkItemsCacheSources } from '@/store/slices/github' -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { taskPageWorkItemKey, taskPageWorkItemStatusSignature, diff --git a/src/renderer/src/components/task-page-checks-pill.ts b/src/renderer/src/components/task-page-checks-pill.ts index ba3252a46f3..d119f1eb69e 100644 --- a/src/renderer/src/components/task-page-checks-pill.ts +++ b/src/renderer/src/components/task-page-checks-pill.ts @@ -1,5 +1,5 @@ import { getProviderChecksLabel } from '../../../shared/provider-check-summary' -import type { ProviderCheckSummary } from '../../../shared/types' +import type { ProviderCheckSummary } from '../../../shared/github/pull-request-types' type ChecksPillItem = { checksSummary?: ProviderCheckSummary } diff --git a/src/renderer/src/components/task-page-default-repo-selection.test.ts b/src/renderer/src/components/task-page-default-repo-selection.test.ts index 957cd67a6e5..d87ceeec5ba 100644 --- a/src/renderer/src/components/task-page-default-repo-selection.test.ts +++ b/src/renderer/src/components/task-page-default-repo-selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Repo } from '../../../shared/types' +import type { Repo } from '../../../shared/repo-types' import { getDefaultTaskRepoSelection, getTaskEligibleRepos, diff --git a/src/renderer/src/components/task-page-default-repo-selection.ts b/src/renderer/src/components/task-page-default-repo-selection.ts index e1b2eca2ebd..e5c41c08b18 100644 --- a/src/renderer/src/components/task-page-default-repo-selection.ts +++ b/src/renderer/src/components/task-page-default-repo-selection.ts @@ -5,7 +5,7 @@ import { isProjectRemoteIdentityPending } from '../../../shared/project-host-setup-projection' import { isGitRepoKind } from '../../../shared/repo-kind' -import type { Repo } from '../../../shared/types' +import type { Repo } from '../../../shared/repo-types' export type TaskProjectPickerGroup = { projectKey: string diff --git a/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts b/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts index 2f4d1646d21..b0bde81eb82 100644 --- a/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts +++ b/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { getTaskSourceCacheScope } from '../../../shared/task-source-context' import { assertTaskPageGitHubDialogStateAuthority } from './task-page-github-dialog-state-authority' import { diff --git a/src/renderer/src/components/task-page-github-dialog-state-authority.ts b/src/renderer/src/components/task-page-github-dialog-state-authority.ts index a372762e141..2918e1eb4d0 100644 --- a/src/renderer/src/components/task-page-github-dialog-state-authority.ts +++ b/src/renderer/src/components/task-page-github-dialog-state-authority.ts @@ -1,4 +1,4 @@ -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { getTaskSourceCacheScope, type TaskSourceContext diff --git a/src/renderer/src/components/task-page-github-resume-cache.ts b/src/renderer/src/components/task-page-github-resume-cache.ts index c42ac9a0c3d..8cd4d14b029 100644 --- a/src/renderer/src/components/task-page-github-resume-cache.ts +++ b/src/renderer/src/components/task-page-github-resume-cache.ts @@ -1,4 +1,4 @@ -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' export const TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT = 5 export const TASK_PAGE_GITHUB_RESUME_CACHE_TTL_MS = 10 * 60_000 diff --git a/src/renderer/src/components/task-page-github-status-actions.test.ts b/src/renderer/src/components/task-page-github-status-actions.test.ts index b18226f6f88..d2105e722dd 100644 --- a/src/renderer/src/components/task-page-github-status-actions.test.ts +++ b/src/renderer/src/components/task-page-github-status-actions.test.ts @@ -5,7 +5,7 @@ import { getTaskPageGitHubDuplicateTargetErrorMessage, validateTaskPageGitHubDuplicateTarget } from './task-page-github-status-actions' -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' describe('TaskPage GitHub status actions', () => { it('builds completed and not planned close updates', () => { diff --git a/src/renderer/src/components/task-page-github-status-actions.ts b/src/renderer/src/components/task-page-github-status-actions.ts index 1d3ed687767..3722678a310 100644 --- a/src/renderer/src/components/task-page-github-status-actions.ts +++ b/src/renderer/src/components/task-page-github-status-actions.ts @@ -1,8 +1,8 @@ +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { GitHubIssueCloseReason, - GitHubIssueUpdate, - GitHubWorkItem -} from '../../../shared/types' + GitHubIssueUpdate +} from '../../../shared/issue-mutation-types' export type TaskPageGitHubCloseAction = | { stateReason: Exclude } diff --git a/src/renderer/src/components/task-page-github-status-state.ts b/src/renderer/src/components/task-page-github-status-state.ts index 45adc29016d..a61cb20ed09 100644 --- a/src/renderer/src/components/task-page-github-status-state.ts +++ b/src/renderer/src/components/task-page-github-status-state.ts @@ -1,4 +1,4 @@ -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' type GitHubStatusItem = Pick diff --git a/src/renderer/src/components/task-page-github-task-kind.ts b/src/renderer/src/components/task-page-github-task-kind.ts index 772fa2d1a6e..ab1b59bf896 100644 --- a/src/renderer/src/components/task-page-github-task-kind.ts +++ b/src/renderer/src/components/task-page-github-task-kind.ts @@ -1,6 +1,6 @@ import { getTaskPresetQuery } from '../../../shared/task-preset-query' import { parseTaskQuery } from '../../../shared/task-query' -import type { TaskViewPresetId } from '../../../shared/types' +import type { TaskViewPresetId } from '../../../shared/ui-chrome-types' import type { GitHubTaskKind } from '@/components/task-page-localized-options' export function isPRFocusedTaskView(preset: TaskViewPresetId | null, query: string): boolean { diff --git a/src/renderer/src/components/task-page-github-work-item-filter-membership.ts b/src/renderer/src/components/task-page-github-work-item-filter-membership.ts index b51ddd8bda3..e3607396b7b 100644 --- a/src/renderer/src/components/task-page-github-work-item-filter-membership.ts +++ b/src/renderer/src/components/task-page-github-work-item-filter-membership.ts @@ -1,5 +1,6 @@ import type { ParsedTaskQuery } from '../../../shared/task-query' -import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { StickyHideEntry } from './task-page-github-work-item-mutation-registry' function includesLogin(users: readonly GitHubAssignableUser[] | undefined, login: string): boolean { diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts b/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts index 25616c3c059..6e98c29cb9b 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts @@ -1,5 +1,6 @@ import type { ParsedTaskQuery } from '../../../shared/task-query' -import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { recomputeTaskPageGitHubItemSoftHide, shouldSoftHideTaskPageGitHubWorkItem diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-lifecycle.ts b/src/renderer/src/components/task-page-github-work-item-mutation-lifecycle.ts index 3bad977b3be..8a693381628 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-lifecycle.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-lifecycle.ts @@ -1,5 +1,5 @@ import type { ParsedTaskQuery } from '../../../shared/task-query' -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { applyTaskPageGitHubListOps } from './task-page-github-work-item-mutation-patches' import { diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-pages.ts b/src/renderer/src/components/task-page-github-work-item-mutation-pages.ts index 65b9ffba648..6b62509a05b 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-pages.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-pages.ts @@ -1,4 +1,4 @@ -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { getRegistryMergedTaskPageGitHubWorkItem } from './task-page-github-work-item-mutation-composition' import { diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-patches.test.ts b/src/renderer/src/components/task-page-github-work-item-mutation-patches.test.ts index dd5a305ccea..f32558adf09 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-patches.test.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-patches.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { applyTaskPageGitHubListOps, buildTaskPageGitHubWorkItemMutationPatch, diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-patches.ts b/src/renderer/src/components/task-page-github-work-item-mutation-patches.ts index f06db3f1ef8..c72d0026105 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-patches.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-patches.ts @@ -1,4 +1,5 @@ -import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { TaskPageGitHubCloseAction } from './task-page-github-status-actions' import { taskPageGitHubListOpKey, diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts b/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts index bfd575ea4ab..6988ff7f7b3 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts @@ -1,4 +1,4 @@ -import type { GitHubAssignableUser } from '../../../shared/types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' import type { PendingOp, StickyHideEntry, diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts b/src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts index fd931a8045b..a24a20e33b6 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import type { ParsedTaskQuery } from '../../../shared/task-query' -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { adoptQuietSearchFieldsForItem, advanceTaskPageQuietRevalidateScope, diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-types.ts b/src/renderer/src/components/task-page-github-work-item-mutation-types.ts index f12310afc88..2aaa09e3bd0 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-types.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-types.ts @@ -1,5 +1,5 @@ import type { ParsedTaskQuery } from '../../../shared/task-query' -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import type { TaskPageGitHubMutationIntent } from './task-page-github-work-item-mutation-patches' import type { TaskPageGitHubMutationKey } from './task-page-github-work-item-mutation-registry' diff --git a/src/renderer/src/components/task-page-github-work-item-mutations.test.ts b/src/renderer/src/components/task-page-github-work-item-mutations.test.ts index ad59620ef68..f50c40d4021 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutations.test.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutations.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ParsedTaskQuery } from '../../../shared/task-query' -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { adoptQuietSearchFieldsForItem, applyPendingTaskPageGitHubMutationsToItems, diff --git a/src/renderer/src/components/task-page-github-work-item-mutations.ts b/src/renderer/src/components/task-page-github-work-item-mutations.ts index 450c0abc4e9..28e05a1ccb4 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutations.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutations.ts @@ -1,4 +1,4 @@ -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { getTaskSourceCacheScope, type TaskSourceContext diff --git a/src/renderer/src/components/task-page-github-work-item-quiet-adopt.ts b/src/renderer/src/components/task-page-github-work-item-quiet-adopt.ts index 269f07164ae..5547986ab2e 100644 --- a/src/renderer/src/components/task-page-github-work-item-quiet-adopt.ts +++ b/src/renderer/src/components/task-page-github-work-item-quiet-adopt.ts @@ -1,4 +1,4 @@ -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { loginSetOfUsers, loginSetsEqual } from './task-page-github-work-item-mutation-patches' import { diff --git a/src/renderer/src/components/task-page-github-work-item-quiet-revalidate.ts b/src/renderer/src/components/task-page-github-work-item-quiet-revalidate.ts index 5895eb28f85..a482726f8c8 100644 --- a/src/renderer/src/components/task-page-github-work-item-quiet-revalidate.ts +++ b/src/renderer/src/components/task-page-github-work-item-quiet-revalidate.ts @@ -1,4 +1,4 @@ -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { adoptQuietSearchFieldsForItem, diff --git a/src/renderer/src/components/task-page-github-work-item-registry-types.ts b/src/renderer/src/components/task-page-github-work-item-registry-types.ts index a1e93064812..fecd93c4465 100644 --- a/src/renderer/src/components/task-page-github-work-item-registry-types.ts +++ b/src/renderer/src/components/task-page-github-work-item-registry-types.ts @@ -1,4 +1,5 @@ -import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' export type TaskPageGitHubListFamily = 'assignees' | 'reviewRequests' export type TaskPageGitHubMutationKey = { diff --git a/src/renderer/src/components/task-page-github-work-item-status.ts b/src/renderer/src/components/task-page-github-work-item-status.ts index 5e54688305b..d5eb8cf7102 100644 --- a/src/renderer/src/components/task-page-github-work-item-status.ts +++ b/src/renderer/src/components/task-page-github-work-item-status.ts @@ -1,5 +1,5 @@ import { translate } from '@/i18n/i18n' -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' export type GitHubWorkItemStatusItem = Pick diff --git a/src/renderer/src/components/task-page-jira-cache-selectors.test.ts b/src/renderer/src/components/task-page-jira-cache-selectors.test.ts index f5f60d3cbe2..bc4a4c07ffa 100644 --- a/src/renderer/src/components/task-page-jira-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-jira-cache-selectors.test.ts @@ -3,7 +3,7 @@ import { getTaskSourceCacheScope, type TaskSourceContext } from '../../../shared/task-source-context' -import type { JiraIssue } from '../../../shared/types' +import type { JiraIssue } from '../../../shared/jira-types' import { findTaskPageJiraIssue } from './task-page-jira-cache-selectors' function jiraSourceContext(environmentId: string): TaskSourceContext { diff --git a/src/renderer/src/components/task-page-jira-cache-selectors.ts b/src/renderer/src/components/task-page-jira-cache-selectors.ts index 5426c404dbf..969160f13f8 100644 --- a/src/renderer/src/components/task-page-jira-cache-selectors.ts +++ b/src/renderer/src/components/task-page-jira-cache-selectors.ts @@ -3,7 +3,7 @@ import { getTaskSourceCacheScope, type TaskSourceContext } from '../../../shared/task-source-context' -import type { JiraIssue } from '../../../shared/types' +import type { JiraIssue } from '../../../shared/jira-types' type JiraIssueCache = Record> type JiraSearchCache = Record> diff --git a/src/renderer/src/components/task-page-jira-create-fields.test.ts b/src/renderer/src/components/task-page-jira-create-fields.test.ts index 8cc24872386..11210e347db 100644 --- a/src/renderer/src/components/task-page-jira-create-fields.test.ts +++ b/src/renderer/src/components/task-page-jira-create-fields.test.ts @@ -8,7 +8,7 @@ import { getJiraCreateOptionPayload, isVisibleJiraCreateField } from './task-page-jira-create-fields' -import type { JiraCreateField } from '../../../shared/types' +import type { JiraCreateField } from '../../../shared/jira-types' function field(overrides: Partial = {}): JiraCreateField { return { key: 'customfield_1', name: 'Custom', required: true, ...overrides } diff --git a/src/renderer/src/components/task-page-jira-create-fields.ts b/src/renderer/src/components/task-page-jira-create-fields.ts index 568621ee3e7..932e64c2bb1 100644 --- a/src/renderer/src/components/task-page-jira-create-fields.ts +++ b/src/renderer/src/components/task-page-jira-create-fields.ts @@ -1,5 +1,5 @@ import { buildJiraCreateTextAdf } from '@/components/jira-create-adf' -import type { JiraCreateField } from '../../../shared/types' +import type { JiraCreateField } from '../../../shared/jira-types' const JIRA_CREATE_SYSTEM_FIELD_KEYS = new Set(['project', 'issuetype', 'summary', 'description']) diff --git a/src/renderer/src/components/task-page-jira-grouping.test.ts b/src/renderer/src/components/task-page-jira-grouping.test.ts index 44d8bce4fbf..fbfa5e5b069 100644 --- a/src/renderer/src/components/task-page-jira-grouping.test.ts +++ b/src/renderer/src/components/task-page-jira-grouping.test.ts @@ -14,7 +14,7 @@ import { getSingleJiraProjectScope, loadTaskPageJiraProjectStatusOrder } from './task-page-jira-status-order' -import type { JiraIssue, JiraProjectStatusOrder } from '../../../shared/types' +import type { JiraIssue, JiraProjectStatusOrder } from '../../../shared/jira-types' const { jiraGetProjectStatusOrderMock } = vi.hoisted(() => ({ jiraGetProjectStatusOrderMock: vi.fn() diff --git a/src/renderer/src/components/task-page-jira-issue-list.tsx b/src/renderer/src/components/task-page-jira-issue-list.tsx index 104f591ab76..989e0911591 100644 --- a/src/renderer/src/components/task-page-jira-issue-list.tsx +++ b/src/renderer/src/components/task-page-jira-issue-list.tsx @@ -6,7 +6,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' -import type { JiraIssue, JiraProjectStatusOrder } from '../../../shared/types' +import type { JiraIssue, JiraProjectStatusOrder } from '../../../shared/jira-types' export type TaskPageJiraIssueSection = { key: string diff --git a/src/renderer/src/components/task-page-jira-item-source-context.test.ts b/src/renderer/src/components/task-page-jira-item-source-context.test.ts index 53917453655..a635fb86f30 100644 --- a/src/renderer/src/components/task-page-jira-item-source-context.test.ts +++ b/src/renderer/src/components/task-page-jira-item-source-context.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { JiraIssue, JiraSite } from '../../../shared/types' +import type { JiraIssue, JiraSite } from '../../../shared/jira-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { bindTaskPageJiraItemSourceContext } from './task-page-jira-item-source-context' diff --git a/src/renderer/src/components/task-page-jira-item-source-context.ts b/src/renderer/src/components/task-page-jira-item-source-context.ts index 1790797e3e6..be8ec6d4006 100644 --- a/src/renderer/src/components/task-page-jira-item-source-context.ts +++ b/src/renderer/src/components/task-page-jira-item-source-context.ts @@ -1,4 +1,4 @@ -import type { JiraIssue, JiraSite } from '../../../shared/types' +import type { JiraIssue, JiraSite } from '../../../shared/jira-types' import { normalizeTaskSourceContext, type TaskSourceContext diff --git a/src/renderer/src/components/task-page-jira-load-state.ts b/src/renderer/src/components/task-page-jira-load-state.ts index 77826b78126..0a542095ab0 100644 --- a/src/renderer/src/components/task-page-jira-load-state.ts +++ b/src/renderer/src/components/task-page-jira-load-state.ts @@ -1,4 +1,4 @@ -import type { JiraIssue } from '../../../shared/types' +import type { JiraIssue } from '../../../shared/jira-types' export type TaskPageJiraLoadError = { title: string diff --git a/src/renderer/src/components/task-page-jira-project-selection.test.ts b/src/renderer/src/components/task-page-jira-project-selection.test.ts index 82638f607cb..5f0bd24c884 100644 --- a/src/renderer/src/components/task-page-jira-project-selection.test.ts +++ b/src/renderer/src/components/task-page-jira-project-selection.test.ts @@ -4,7 +4,7 @@ import { compareJiraProjectsByDisplayLabel, getJiraProjectSelectionKey } from './task-page-jira-project-selection' -import type { JiraProject } from '../../../shared/types' +import type { JiraProject } from '../../../shared/jira-types' function project(overrides: Partial = {}): JiraProject { return { id: '1', key: 'AA', name: 'Alpha', ...overrides } diff --git a/src/renderer/src/components/task-page-jira-project-selection.ts b/src/renderer/src/components/task-page-jira-project-selection.ts index fe004b6cdf4..48a9af0dfc2 100644 --- a/src/renderer/src/components/task-page-jira-project-selection.ts +++ b/src/renderer/src/components/task-page-jira-project-selection.ts @@ -1,4 +1,4 @@ -import type { JiraProject } from '../../../shared/types' +import type { JiraProject } from '../../../shared/jira-types' export function getJiraProjectSelectionKey(project: JiraProject): string { return `${project.siteId ?? 'selected'}:${project.id}` diff --git a/src/renderer/src/components/task-page-jira-sorting.test.ts b/src/renderer/src/components/task-page-jira-sorting.test.ts index 767fbf7c1cb..0cdca7a6fe6 100644 --- a/src/renderer/src/components/task-page-jira-sorting.test.ts +++ b/src/renderer/src/components/task-page-jira-sorting.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { JiraIssue, JiraPriority } from '../../../shared/types' +import type { JiraIssue, JiraPriority } from '../../../shared/jira-types' import { getJiraPriorityWeight, sortJiraIssues } from './jira-issue-sorter' describe('TaskPage Jira sorting functionality', () => { diff --git a/src/renderer/src/components/task-page-jira-status-order.ts b/src/renderer/src/components/task-page-jira-status-order.ts index 8cb6ed39c37..e064f5732dc 100644 --- a/src/renderer/src/components/task-page-jira-status-order.ts +++ b/src/renderer/src/components/task-page-jira-status-order.ts @@ -1,4 +1,4 @@ -import type { JiraIssue, JiraProjectStatusOrder } from '../../../shared/types' +import type { JiraIssue, JiraProjectStatusOrder } from '../../../shared/jira-types' import { jiraGetProjectStatusOrder, type RuntimeJiraSettings } from '@/runtime/runtime-jira-client' import { createMetadataRequestStore, loadMetadata } from '@/hooks/metadata-request-cache' diff --git a/src/renderer/src/components/task-page-linear-cache-selectors.ts b/src/renderer/src/components/task-page-linear-cache-selectors.ts index d13536ea93f..31ac2730b27 100644 --- a/src/renderer/src/components/task-page-linear-cache-selectors.ts +++ b/src/renderer/src/components/task-page-linear-cache-selectors.ts @@ -1,5 +1,6 @@ import type { CacheEntry } from '@/store/slices/github' -import type { LinearCollectionResult, LinearIssue } from '../../../shared/types' +import type { LinearIssue } from '../../../shared/linear/issue-types' +import type { LinearCollectionResult } from '../../../shared/linear/workspace-types' import { sortedStrings } from './task-page-work-item-signatures' type LinearIssueCache = Record> diff --git a/src/renderer/src/components/task-page-linear-in-orca-issues.test.ts b/src/renderer/src/components/task-page-linear-in-orca-issues.test.ts index 28804568fec..818bc9e60e5 100644 --- a/src/renderer/src/components/task-page-linear-in-orca-issues.test.ts +++ b/src/renderer/src/components/task-page-linear-in-orca-issues.test.ts @@ -7,7 +7,7 @@ import { linkedLinearIssueRefsSignature, readLinkedLinearIssuesWithLimit } from './task-page-linear-in-orca-issues' -import type { LinearIssue } from '../../../shared/types' +import type { LinearIssue } from '../../../shared/linear/issue-types' function issue( partial: Partial & Pick diff --git a/src/renderer/src/components/task-page-linear-in-orca-issues.ts b/src/renderer/src/components/task-page-linear-in-orca-issues.ts index 55ae9293b85..36c66ea2c4c 100644 --- a/src/renderer/src/components/task-page-linear-in-orca-issues.ts +++ b/src/renderer/src/components/task-page-linear-in-orca-issues.ts @@ -1,4 +1,6 @@ -import type { LinearIssue, LinearWorkspace, Worktree } from '../../../shared/types' +import type { LinearIssue } from '../../../shared/linear/issue-types' +import type { LinearWorkspace } from '../../../shared/linear/workspace-types' +import type { Worktree } from '../../../shared/worktree/types' import { parseLinearIssueInput } from '../../../shared/linear/links' import { getTaskSourceCacheScope, diff --git a/src/renderer/src/components/task-page-linear-team-selection.test.ts b/src/renderer/src/components/task-page-linear-team-selection.test.ts index a3d1a207b12..6eb1878fbbd 100644 --- a/src/renderer/src/components/task-page-linear-team-selection.test.ts +++ b/src/renderer/src/components/task-page-linear-team-selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { LinearTeam } from '../../../shared/types' +import type { LinearTeam } from '../../../shared/linear/workspace-types' import { reconcileLinearTeamSelection } from './task-page-linear-team-selection' function team(id: string): LinearTeam { diff --git a/src/renderer/src/components/task-page-linear-team-selection.ts b/src/renderer/src/components/task-page-linear-team-selection.ts index f6d7e52fa1f..3ec82284a59 100644 --- a/src/renderer/src/components/task-page-linear-team-selection.ts +++ b/src/renderer/src/components/task-page-linear-team-selection.ts @@ -1,4 +1,4 @@ -import type { LinearTeam } from '../../../shared/types' +import type { LinearTeam } from '../../../shared/linear/workspace-types' export function reconcileLinearTeamSelection( availableTeams: LinearTeam[], diff --git a/src/renderer/src/components/task-page-list-chrome-visibility.ts b/src/renderer/src/components/task-page-list-chrome-visibility.ts index e8b95e96288..e589ef2e579 100644 --- a/src/renderer/src/components/task-page-list-chrome-visibility.ts +++ b/src/renderer/src/components/task-page-list-chrome-visibility.ts @@ -1,4 +1,4 @@ -import type { TaskProvider } from '../../../shared/types' +import type { TaskProvider } from '../../../shared/task-providers' export type TaskPageListChromeVisibilityState = { taskSource: TaskProvider diff --git a/src/renderer/src/components/task-page-localized-options.tsx b/src/renderer/src/components/task-page-localized-options.tsx index 506f27add17..cd14317489d 100644 --- a/src/renderer/src/components/task-page-localized-options.tsx +++ b/src/renderer/src/components/task-page-localized-options.tsx @@ -15,7 +15,8 @@ import { type LinearViewMode } from '../../../shared/linear/issue-view-resume-state' import { getTaskPresetQuery } from '../../../shared/task-preset-query' -import type { TaskProvider, TaskViewPresetId } from '../../../shared/types' +import type { TaskProvider } from '../../../shared/task-providers' +import type { TaskViewPresetId } from '../../../shared/ui-chrome-types' export type GitLabTaskFilter = 'opened' | 'merged' | 'closed' | 'all' export type GitLabIssueFilter = 'opened' | 'assigned-to-me' diff --git a/src/renderer/src/components/task-page-new-issue-draft.test.ts b/src/renderer/src/components/task-page-new-issue-draft.test.ts index c5058f50989..42a63a68568 100644 --- a/src/renderer/src/components/task-page-new-issue-draft.test.ts +++ b/src/renderer/src/components/task-page-new-issue-draft.test.ts @@ -6,7 +6,7 @@ import { resolveVanishedNewIssueRepoReset } from './task-page-new-issue-draft' import type { NewIssueDraft } from '@/store/slices/new-issue-draft' -import type { GitHubAssignableUser } from '../../../shared/types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' const assignee: GitHubAssignableUser = { login: 'octocat', name: 'Octo', avatarUrl: '' } diff --git a/src/renderer/src/components/task-page-new-issue-draft.ts b/src/renderer/src/components/task-page-new-issue-draft.ts index e252692f379..8b6de307fb7 100644 --- a/src/renderer/src/components/task-page-new-issue-draft.ts +++ b/src/renderer/src/components/task-page-new-issue-draft.ts @@ -1,4 +1,4 @@ -import type { GitHubAssignableUser } from '../../../shared/types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' import type { NewIssueDraft } from '@/store/slices/new-issue-draft' export type NewIssueOpenSeed = { diff --git a/src/renderer/src/components/task-page-pr-check-summary.test.ts b/src/renderer/src/components/task-page-pr-check-summary.test.ts index ccd63d94c04..bc9360d4d4f 100644 --- a/src/renderer/src/components/task-page-pr-check-summary.test.ts +++ b/src/renderer/src/components/task-page-pr-check-summary.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { deriveTaskPagePRCheckSummary } from './task-page-pr-check-summary' -import type { PRCheckDetail } from '../../../shared/types' +import type { PRCheckDetail } from '../../../shared/github/check-types' function check(patch: Partial): PRCheckDetail { return { diff --git a/src/renderer/src/components/task-page-pr-check-summary.ts b/src/renderer/src/components/task-page-pr-check-summary.ts index c7531040923..156e92b70ab 100644 --- a/src/renderer/src/components/task-page-pr-check-summary.ts +++ b/src/renderer/src/components/task-page-pr-check-summary.ts @@ -1,5 +1,6 @@ import { summarizeProviderChecks } from '../../../shared/provider-check-summary' -import type { PRCheckDetail, ProviderCheckSummary } from '../../../shared/types' +import type { PRCheckDetail } from '../../../shared/github/check-types' +import type { ProviderCheckSummary } from '../../../shared/github/pull-request-types' export function deriveTaskPagePRCheckSummary(checks: PRCheckDetail[]): ProviderCheckSummary { return summarizeProviderChecks(checks) diff --git a/src/renderer/src/components/task-page-pr-delta-summary.ts b/src/renderer/src/components/task-page-pr-delta-summary.ts index 9711d087cb0..091011a38ed 100644 --- a/src/renderer/src/components/task-page-pr-delta-summary.ts +++ b/src/renderer/src/components/task-page-pr-delta-summary.ts @@ -1,4 +1,4 @@ -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' export function formatPRDelta(item: GitHubWorkItem): string | null { const parts: string[] = [] diff --git a/src/renderer/src/components/task-page-work-item-pagination.test.ts b/src/renderer/src/components/task-page-work-item-pagination.test.ts index 670c862be98..50b88579158 100644 --- a/src/renderer/src/components/task-page-work-item-pagination.test.ts +++ b/src/renderer/src/components/task-page-work-item-pagination.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { accumulateWorkItemPages, applyEmptyPageClamp, diff --git a/src/renderer/src/components/task-page-work-item-pagination.ts b/src/renderer/src/components/task-page-work-item-pagination.ts index 8209b61bdf0..2876831f000 100644 --- a/src/renderer/src/components/task-page-work-item-pagination.ts +++ b/src/renderer/src/components/task-page-work-item-pagination.ts @@ -1,4 +1,5 @@ -import type { ClassifiedError, GitHubWorkItem } from '../../../shared/types' +import type { ClassifiedError } from '../../../shared/classified-error' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' /** * Cross-repo Tasks pagination is cursor-based on `updatedAt`: each page's oldest diff --git a/src/renderer/src/components/task-page-work-item-signatures.ts b/src/renderer/src/components/task-page-work-item-signatures.ts index 67875e2e15e..8abb2f07a19 100644 --- a/src/renderer/src/components/task-page-work-item-signatures.ts +++ b/src/renderer/src/components/task-page-work-item-signatures.ts @@ -1,4 +1,4 @@ -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' export function taskPageWorkItemKey(item: GitHubWorkItem): string { return `${item.repoId}\u0000${item.id}` diff --git a/src/renderer/src/components/task-project-source-combobox-model.ts b/src/renderer/src/components/task-project-source-combobox-model.ts index 248adfcfa11..8229014e362 100644 --- a/src/renderer/src/components/task-project-source-combobox-model.ts +++ b/src/renderer/src/components/task-project-source-combobox-model.ts @@ -1,5 +1,5 @@ import { getRepoExecutionHostId } from '../../../shared/execution-host' -import type { Repo } from '../../../shared/types' +import type { Repo } from '../../../shared/repo-types' import type { TaskProjectPickerGroup } from './task-page-default-repo-selection' export function selectedTaskProjectGroups( diff --git a/src/renderer/src/components/task-project-source-combobox.tsx b/src/renderer/src/components/task-project-source-combobox.tsx index 0f3d0e33697..555089e75f7 100644 --- a/src/renderer/src/components/task-project-source-combobox.tsx +++ b/src/renderer/src/components/task-project-source-combobox.tsx @@ -7,7 +7,7 @@ import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { isRepoSearchQueryTooLarge, searchRepos } from '@/lib/repo-search' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import type { Repo } from '../../../shared/types' +import type { Repo } from '../../../shared/repo-types' import type { TaskProjectPickerGroup } from './task-page-default-repo-selection' import { getSelectedTaskProjectSource, diff --git a/src/renderer/src/components/task-source-context-summary.ts b/src/renderer/src/components/task-source-context-summary.ts index be1d5fa0790..af5e2879d0c 100644 --- a/src/renderer/src/components/task-source-context-summary.ts +++ b/src/renderer/src/components/task-source-context-summary.ts @@ -3,7 +3,7 @@ import { getExecutionHostLabel } from '../../../shared/execution-host' import type { ExecutionHostScope } from '../../../shared/execution-host' import type { ExecutionHostHealth } from '../../../shared/execution-host-registry' import type { SshConnectionStatus } from '../../../shared/ssh-types' -import type { TaskProvider } from '../../../shared/types' +import type { TaskProvider } from '../../../shared/task-providers' import type { TaskProviderIdentity, TaskSourceContext } from '../../../shared/task-source-context' export type TaskSourceContextSummary = { diff --git a/src/renderer/src/components/task-source-provider-availability.ts b/src/renderer/src/components/task-source-provider-availability.ts index e01e0fef057..5038ab7ea6a 100644 --- a/src/renderer/src/components/task-source-provider-availability.ts +++ b/src/renderer/src/components/task-source-provider-availability.ts @@ -1,5 +1,5 @@ import { parseExecutionHostId } from '../../../shared/execution-host' -import type { TaskProvider } from '../../../shared/types' +import type { TaskProvider } from '../../../shared/task-providers' import type { PreflightStatus } from '../../../preload/api-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import type { TaskSourceHostAvailability } from './task-source-context-summary' diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index c983b389760..73a9d508f4c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -25,7 +25,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { shouldIgnoreTerminalMenuPointerDownOutside } from './terminal-context-menu-dismiss' -import type { TerminalQuickCommand } from '../../../../shared/types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel' import type { KeybindingOverrides } from '../../../../shared/keybindings' diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 5546390b6db..79b7ab6bf13 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -152,7 +152,10 @@ import { isHostAuthoritativeLayout, planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation' -import type { TerminalQuickCommand, TerminalQuickCommandScope } from '../../../../shared/types' +import type { + TerminalQuickCommand, + TerminalQuickCommandScope +} from '../../../../shared/terminal-quick-command-types' import { createRemotePaneLayoutPusher, type RemotePaneLayoutPusher diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 2196f7ed094..2cb20783f53 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' -import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { useAppStore } from '../../store' import { findActivityTerminalPortal, diff --git a/src/renderer/src/components/terminal-pane/TerminalQuickCommandsSubmenu.tsx b/src/renderer/src/components/terminal-pane/TerminalQuickCommandsSubmenu.tsx index ed650dc1463..df32a3d2505 100644 --- a/src/renderer/src/components/terminal-pane/TerminalQuickCommandsSubmenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalQuickCommandsSubmenu.tsx @@ -8,7 +8,7 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu' -import type { TerminalQuickCommand } from '../../../../shared/types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' import { AgentIcon } from '@/lib/agent-catalog' diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index 097ecd792d5..a388ec17457 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -1,5 +1,5 @@ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { RecognizedAgentProcess } from '../../../../shared/agent-process-recognition' import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' diff --git a/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts b/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts index 6275cc9553c..4ba348d3f32 100644 --- a/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts +++ b/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts @@ -8,7 +8,7 @@ * consumers can use it. */ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' /** Delay before BEL/completion OS notifications so the richer * agent-task-complete notification can win a same-burst BEL race. */ diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts index 1ad6528c935..b1d0ddb6747 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts @@ -10,7 +10,7 @@ */ import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/types' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' import type { AppState } from '@/store' import { useAppStore } from '@/store' import { getWorktreeMapFromState } from '@/store/selectors' diff --git a/src/renderer/src/components/terminal-pane/command-code-output-ownership.ts b/src/renderer/src/components/terminal-pane/command-code-output-ownership.ts index 65f5f196ca0..ec0b65cd49a 100644 --- a/src/renderer/src/components/terminal-pane/command-code-output-ownership.ts +++ b/src/renderer/src/components/terminal-pane/command-code-output-ownership.ts @@ -1,5 +1,5 @@ import type { AgentType } from '../../../../shared/agent-status-types' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' export function canCommandCodeOutputOwnPane(args: { foregroundAgent?: TuiAgent | null diff --git a/src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts b/src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts index 97e81b97135..b15bae2ec5b 100644 --- a/src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts +++ b/src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { composeActiveTerminalTheme } from './terminal-appearance' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' describe('composeActiveTerminalTheme', () => { function settingsWith(partial: Partial): GlobalSettings { 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/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index 626168ef265..597d5e54b58 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeAll, vi } from 'vitest' -import type { TerminalPaneLayoutNode } from '../../../../shared/types' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' // --------------------------------------------------------------------------- // Provide a minimal HTMLElement so `instanceof HTMLElement` passes in Node env diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index 2f8d0fea2f9..c3807cffeac 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -2,7 +2,7 @@ import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, TerminalPaneSplitDirection -} from '../../../../shared/types' +} from '../../../../shared/terminal-tab-types' import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { POST_REPLAY_MODE_RESET } from '../../../../shared/terminal-mode-reset-profiles' import type { PaneManager } from '@/lib/pane-manager/pane-manager' diff --git a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts index 20f24fb6986..4bf7b230ffa 100644 --- a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts +++ b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' export type NativeChatLeafTitlePane = { diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index 189b91573b6..2a279abd110 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -3,7 +3,7 @@ import { recognizeAgentProcess } from '../../../../shared/agent-process-recognition' import { isShellProcess } from '../../../../shared/shell-process-detection' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' // Why: settle after exec, then place the final generic retry beyond sequential diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index b2b5163ac93..9fef3423cbf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -6,7 +6,8 @@ import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinat import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' -import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig 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 6fbe70501f8..98a26a1d6a0 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' @@ -23,7 +27,8 @@ import type { AgentType } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' import type { SshConnectionState } from '../../../../shared/ssh-types' -import type { TerminalLayoutSnapshot, TuiAgent } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' @@ -496,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, @@ -10211,7 +10218,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]) ) @@ -15256,6 +15263,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() @@ -15442,6 +15453,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') @@ -15486,10 +15547,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() @@ -15539,27 +15597,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() }) @@ -15822,6 +15881,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({ @@ -15935,6 +15997,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() @@ -15990,6 +16055,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() }) @@ -16456,7 +16525,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() @@ -17354,7 +17423,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 e2efbb0a9c4..14a514cfee5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -310,7 +310,8 @@ import { isExpectedAgentProcess, recognizeAgentProcessFromCommandLine } from '../../../../shared/agent-process-recognition' -import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { createDraftPasteReadyScanner } from '../../../../shared/draft-paste-ready-scanner' import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content' @@ -6375,6 +6376,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, @@ -7075,11 +7090,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) { @@ -7311,9 +7326,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) } @@ -8025,7 +8047,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)) @@ -8326,7 +8350,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/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 1ba841bd823..57e0c9e2e4e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -11,7 +11,7 @@ import type { StartupCommandDelivery } from '../../../../shared/codex-startup-de import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalOscColorQueryReplyColors } from '../../../../shared/terminal-osc-color-reply' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { PtyDataMeta } from './pty-dispatcher' import type { RemoteRuntimeSnapshotOutcome } from '../../runtime/remote-runtime-terminal-multiplexer' diff --git a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts index d6d777f728c..737339f4705 100644 --- a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts @@ -6,7 +6,7 @@ * before the dedupe and 1 after. */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' const updateWebRuntimePaneLayout = vi.fn() vi.mock('@/runtime/web-runtime-session', () => ({ diff --git a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts index 506bd7ea42f..963d4b78355 100644 --- a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts +++ b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts @@ -1,4 +1,4 @@ -import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' import { updateWebRuntimePaneLayout } from '@/runtime/web-runtime-session' diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts index f9f9a63792b..806197d5323 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts @@ -7,7 +7,7 @@ import { import { useAppStore } from '@/store' import { makePaneKey } from '../../../../shared/stable-pane-id' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { translate } from '@/i18n/i18n' type PrepareAgentSessionContinuationFromPaneArgs = { diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts index 959b4a9a7e4..211c9589b64 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts @@ -11,7 +11,7 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { slugifyForWorkspaceName } from '../../../../shared/workspace-name' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { isWslUncPath } from '../../../../shared/wsl-paths' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index a0d5bff3a04..bb61a50fbbc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -1,6 +1,6 @@ import type { ITheme } from '@xterm/xterm' import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures' import { diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx index 902c3254557..5f4db2d3878 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx @@ -3,7 +3,8 @@ import { act, StrictMode } from 'react' import type * as ReactModule from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx index 08fadd307fe..769c7e45c67 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx @@ -17,7 +17,7 @@ import { act, useEffect, useRef, useState } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts b/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts index a72867e3c22..75ca204c5cd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { ParkVerdictFlipRecord } from './terminal-park-verdict-flip-telemetry' import { withholdUnparkableTerminalTabs } from './terminal-cold-park-withheld-tabs' diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.ts b/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.ts index 35be4446cb5..d35270bd170 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.ts +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.ts @@ -6,7 +6,7 @@ * is returned because it is also the only remaining recheck wakeup once damping * has stopped the churn that was waking the parking effect. */ -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { getParkVerdictUnparkPinUntilMs, type ParkVerdictFlipRecord diff --git a/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts b/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts index 6503d13f58c..685ba20b9c9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts +++ b/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../../../shared/terminal-fit-restore-deadline' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts index 0950b05508e..96d6a0eed07 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts @@ -7,7 +7,7 @@ * picks which observer records mode-2031 subscriptions (byte scan vs fact), * so it must never flip per chunk. */ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' // Why: cached once per session — the blocking read should only ever run on // the pre-hydration startup path, never per pane bind. diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts index 2efcff11f4d..a6aa4d676a7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -3,7 +3,7 @@ import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-str import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format' import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' // Why: cold-park hysteresis keeps a hidden pane mounted for 30s so quick tab // flips never pay a re-hydrate; hot-retain keeps a bounded recently-visible diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 856f976cce0..105ca4fd43e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -8,7 +8,7 @@ import { type ColdParkRetainCandidate, type TerminalColdParkPolicyOverrides } from './terminal-hidden-view-parking' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' // Why these sizes: a retained hidden pane costs a measured ~2.5MB of V8 heap // at the 5k-row default scrollback and ~19MB at 50k (plus per-pane queues), diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts index 89fb02cbd67..674d0620c1b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' type PaneKeyboardProtocolStartup = { launchAgent?: TuiAgent diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts index 782113e1641..8a610702558 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' const LEAF_1 = '11111111-1111-4111-8111-111111111111' diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts index 7ca537b014d..4287e4eeee2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts @@ -1,4 +1,7 @@ -import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../../../shared/terminal-tab-types' import { collectLeafIdsInOrder, normalizeTerminalLayoutSnapshot, diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts index 5f05376fd58..187f0a0d081 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../../../shared/terminal-tab-types' import { normalizeTerminalLayoutSnapshot, resolvePtyBoundActiveLeafId, diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts index 9f21c39056b..37870c03d70 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts @@ -1,4 +1,7 @@ -import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../../../shared/terminal-tab-types' import { isTerminalLeafId, type TerminalLeafId } from '../../../../shared/stable-pane-id' import { mintStablePaneId } from '@/lib/pane-manager/mint-stable-pane-id' import { normalizeTerminalLayoutPtyOwnership } from './terminal-layout-pty-ownership' diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts index a7e3fa4fc5a..a22eb50b97f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import type { TerminalPaneLayoutNode } from '../../../../shared/types' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' import { normalizeTerminalLayoutPtyOwnership } from './terminal-layout-pty-ownership' it('prunes deeply nested duplicate ownership without recursive stack growth', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts index 92a0def1df1..ddacb05ccd8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { normalizeTerminalLayoutSnapshot } from './terminal-layout-leaf-ids' import { normalizeTerminalLayoutPtyOwnership } from './terminal-layout-pty-ownership' diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts index 4c44c3e6ead..3687621f151 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts @@ -1,4 +1,5 @@ -import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { collectLeafIds, pruneLeaves } from './terminal-pane-layout-tree' type TerminalLayoutPtyOwnershipNormalization = { snapshot: TerminalLayoutSnapshot @@ -10,68 +11,6 @@ type DuplicatePtyLeafReplacements = { retainedLeafIdByRemovedLeafId: Map } -function collectLeafIds(node: TerminalPaneLayoutNode | null | undefined): string[] { - if (!node) { - return [] - } - const leafIds: string[] = [] - const pending = [node] - while (pending.length > 0) { - const current = pending.pop()! - if (current.type === 'leaf') { - leafIds.push(current.leafId) - continue - } - pending.push(current.second, current.first) - } - return leafIds -} - -function pruneLeaves( - node: TerminalPaneLayoutNode, - retainedLeafIdByRemovedLeafId: ReadonlyMap, - retainedSelfLeafIds: Set -): TerminalPaneLayoutNode | null { - const pending: { node: TerminalPaneLayoutNode; visited: boolean }[] = [{ node, visited: false }] - const pruned: (TerminalPaneLayoutNode | null)[] = [] - while (pending.length > 0) { - const current = pending.pop()! - if (current.node.type === 'leaf') { - const retainedLeafId = retainedLeafIdByRemovedLeafId.get(current.node.leafId) - if (!retainedLeafId) { - pruned.push(current.node) - } else if ( - retainedLeafId === current.node.leafId && - !retainedSelfLeafIds.has(current.node.leafId) - ) { - retainedSelfLeafIds.add(current.node.leafId) - pruned.push(current.node) - } else { - pruned.push(null) - } - continue - } - if (!current.visited) { - pending.push({ node: current.node, visited: true }) - pending.push({ node: current.node.second, visited: false }) - pending.push({ node: current.node.first, visited: false }) - continue - } - const second = pruned.pop() ?? null - const first = pruned.pop() ?? null - if (first && second) { - pruned.push( - first === current.node.first && second === current.node.second - ? current.node - : { ...current.node, first, second } - ) - } else { - pruned.push(first ?? second) - } - } - return pruned[0] ?? null -} - function resolveRetainedLeafId( leafId: string, retainedLeafIdByRemovedLeafId: ReadonlyMap diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts index 50b51941701..06b321d0d04 100644 --- a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts @@ -3,7 +3,7 @@ import { isHostAuthoritativeLayout, planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation' -import type { TerminalPaneLayoutNode } from '../../../../shared/types' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' const LOCAL_PTY = 'pty-local-123' const REMOTE_PTY = 'remote:env-1@@term_abc' diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts index 152c90f82e1..7de00009a73 100644 --- a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts @@ -1,4 +1,7 @@ -import type { TerminalPaneLayoutNode, TerminalPaneSplitDirection } from '../../../../shared/types' +import type { + TerminalPaneLayoutNode, + TerminalPaneSplitDirection +} from '../../../../shared/terminal-tab-types' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' /** 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-notification-pane-visibility.ts b/src/renderer/src/components/terminal-pane/terminal-notification-pane-visibility.ts index ac4eb9717d0..00166365084 100644 --- a/src/renderer/src/components/terminal-pane/terminal-notification-pane-visibility.ts +++ b/src/renderer/src/components/terminal-pane/terminal-notification-pane-visibility.ts @@ -1,5 +1,5 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' type NotificationPaneVisibilityState = { activeWorktreeId: string | null diff --git a/src/renderer/src/components/terminal-pane/terminal-notification-state.ts b/src/renderer/src/components/terminal-pane/terminal-notification-state.ts index a14b5b8e39e..ac2d1c6d227 100644 --- a/src/renderer/src/components/terminal-pane/terminal-notification-state.ts +++ b/src/renderer/src/components/terminal-pane/terminal-notification-state.ts @@ -3,7 +3,7 @@ import type { useAppStore } from '@/store' import { getWorktreeMapFromState } from '@/store/selectors' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalPaneLayoutNode } from '../../../../shared/types' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' type StoreSnapshot = ReturnType diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts index 2325241098f..4d0487b5bad 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts @@ -1,4 +1,4 @@ -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' export function shouldClearLaunchAgentForClosedPane( tab: Pick | null | undefined, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-tree.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-tree.ts new file mode 100644 index 00000000000..5e36a9904ba --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-tree.ts @@ -0,0 +1,63 @@ +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' + +export function collectLeafIds(node: TerminalPaneLayoutNode | null | undefined): string[] { + if (!node) { + return [] + } + const leafIds: string[] = [] + const pending = [node] + while (pending.length > 0) { + const current = pending.pop()! + if (current.type === 'leaf') { + leafIds.push(current.leafId) + continue + } + pending.push(current.second, current.first) + } + return leafIds +} + +export function pruneLeaves( + node: TerminalPaneLayoutNode, + retainedLeafIdByRemovedLeafId: ReadonlyMap, + retainedSelfLeafIds: Set +): TerminalPaneLayoutNode | null { + const pending: { node: TerminalPaneLayoutNode; visited: boolean }[] = [{ node, visited: false }] + const pruned: (TerminalPaneLayoutNode | null)[] = [] + while (pending.length > 0) { + const current = pending.pop()! + if (current.node.type === 'leaf') { + const retainedLeafId = retainedLeafIdByRemovedLeafId.get(current.node.leafId) + if (!retainedLeafId) { + pruned.push(current.node) + } else if ( + retainedLeafId === current.node.leafId && + !retainedSelfLeafIds.has(current.node.leafId) + ) { + retainedSelfLeafIds.add(current.node.leafId) + pruned.push(current.node) + } else { + pruned.push(null) + } + continue + } + if (!current.visited) { + pending.push({ node: current.node, visited: true }) + pending.push({ node: current.node.second, visited: false }) + pending.push({ node: current.node.first, visited: false }) + continue + } + const second = pruned.pop() ?? null + const first = pruned.pop() ?? null + if (first && second) { + pruned.push( + first === current.node.first && second === current.node.second + ? current.node + : { ...current.node, first, second } + ) + } else { + pruned.push(first ?? second) + } + } + return pruned[0] ?? null +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts index 8cfbc886925..d421ca04102 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import { detachTerminalPaneToTab, resolveTerminalTabStripDropTarget, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts index f6cac5c5aca..0d8f7d72de9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -1,6 +1,6 @@ import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager' import type { AppState } from '@/store' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' const TAB_GROUP_STRIP_SELECTOR = '[data-tab-group-strip-id][data-worktree-id]' diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.ts index 5376cb9ed13..0b2939aade8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.ts @@ -1,4 +1,4 @@ -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { useAppStore } from '@/store' import { collectLeafIdsInOrder, diff --git a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts index e8e96e8e58c..39d49d02c86 100644 --- a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts @@ -1,4 +1,4 @@ -import type { TerminalQuickCommand } from '../../../../shared/types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import { buildTerminalQuickCommandInput, flattenTerminalQuickCommand, diff --git a/src/renderer/src/components/terminal-pane/terminal-renderer-policy.ts b/src/renderer/src/components/terminal-pane/terminal-renderer-policy.ts index f6d225dcd09..4ae94968f75 100644 --- a/src/renderer/src/components/terminal-pane/terminal-renderer-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-renderer-policy.ts @@ -1,6 +1,6 @@ import { isGeminiTerminalTitle } from '@/lib/agent-status' import type { AgentType } from '../../../../shared/agent-status-types' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' export type TerminalGpuAccelerationMode = GlobalSettings['terminalGpuAcceleration'] 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-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts index dcbf3782f4b..9e2b375939c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts @@ -1,6 +1,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' -import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits' const LEAF_ID = '11111111-1111-4111-8111-111111111111' as const diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts index 006a76b913f..f43d8026bd0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -1,4 +1,4 @@ -import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 578a4147375..527fbb46b95 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -12,7 +12,7 @@ * where pre-mount output produces no attention side effects. The one exception * is a PTY whose consumer just unregistered: see the handoff buffer below. */ -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { 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/terminal-tab-lookup.test.ts b/src/renderer/src/components/terminal-pane/terminal-tab-lookup.test.ts index a31fc9b0ec4..a3f7d7320f1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-lookup.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-lookup.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { getCachedTerminalTabForWorktree } from './terminal-tab-lookup' function makeTab(id: string): TerminalTab { diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-lookup.ts b/src/renderer/src/components/terminal-pane/terminal-tab-lookup.ts index 74447f0a87e..576ad570e00 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-lookup.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-lookup.ts @@ -1,4 +1,4 @@ -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' const terminalTabLookupByArray = new WeakMap>() diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-park-candidates.ts b/src/renderer/src/components/terminal-pane/terminal-tab-park-candidates.ts index cb0e357b280..527bbac536d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-park-candidates.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-park-candidates.ts @@ -1,5 +1,5 @@ /** Builds the stateful per-tab cold-park candidate list. */ -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TerminalTabColdParkCandidate } from './terminal-hidden-view-parking' import type { TerminalTabActivationOrder } from './terminal-tab-activation-order' diff --git a/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts b/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts index 18872bf04bb..c4eaff387dc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { Tab } from '../../../../shared/types' +import type { Tab } from '../../../../shared/tab-types' import { getCachedTerminalGroupIdForWorktree, getCachedUnifiedTerminalTabForWorktree diff --git a/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.ts b/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.ts index 2113121b29f..11889344b75 100644 --- a/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.ts +++ b/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.ts @@ -1,4 +1,4 @@ -import type { Tab } from '../../../../shared/types' +import type { Tab } from '../../../../shared/tab-types' const terminalTabLookupByUnifiedTabs = new WeakMap>() diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts index aeecb1a6f48..f785b59ff12 100644 --- a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts @@ -9,7 +9,7 @@ * are app-global, so identical snapshots publish once. */ import type { ITheme } from '@xterm/xterm' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' import type { TerminalViewAttributes, diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts index 1ca3490ef56..0fa4e74dd88 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts @@ -4,7 +4,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { buildAgentNotificationId } from '../../../../shared/agent-notification-id' type MockState = { diff --git a/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx index f3ea986601f..5d364aba721 100644 --- a/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx +++ b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx @@ -2,7 +2,7 @@ import { act, StrictMode, useEffect } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true diff --git a/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts index 1ee7ba8c2d0..ca533cccd88 100644 --- a/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts +++ b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import { useAppStore } from '../../store' import { disposeParkedTerminalWatchersForWorktree, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index c8264945744..b785d085772 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -7,7 +7,7 @@ import type { PtyTransport } from './pty-transport' import { getConnectionId } from '@/lib/connection-context' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { PaneCwdMap } from './resolve-split-cwd' -import type { TerminalQuickCommand } from '../../../../shared/types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' import { sendTerminalQuickCommandToPane } from './terminal-quick-command-dispatch' import { pasteTerminalText } from './terminal-bracketed-paste' 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 97e6b7f31bd..3dc04d3c323 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 @@ -57,13 +57,10 @@ import { } from '@/lib/http-link-routing' import { resolveTerminalHttpLinkSourceOwner } from './terminal-http-link-source-owner' import { canOpenWorkspaceBrowserTabOnRuntime } from '@/lib/workspace-browser-tab-open' -import type { - GlobalSettings, - SetupSplitDirection, - TerminalTab, - TerminalLayoutSnapshot, - TuiAgent -} from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry' import type { EventProps } from '../../../../shared/telemetry-events' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' @@ -92,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 { @@ -985,6 +986,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') @@ -1082,6 +1084,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/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts index 783ff7af574..58f4cbd2c46 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' const mocks = vi.hoisted(() => ({ storeState: { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 94df8daf738..c078eed66c9 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -7,7 +7,7 @@ * render a slot as null. */ import { useEffect, useMemo, useRef, useState } from 'react' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { useAppStore } from '../../store' import { findActivityTerminalPortal, 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/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx index af196b2c628..cca68ca54aa 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx @@ -1,6 +1,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import { ChevronDown } from 'lucide-react' -import type { Repo, TerminalQuickCommand } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import type { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands' import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' import { Button } from '@/components/ui/button' diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx index 19967b0daba..5402ddb8dc4 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx @@ -1,5 +1,6 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' -import type { TerminalQuickCommand, TuiAgent } from '../../../../shared/types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { isTerminalAgentQuickCommand, supportsTerminalAgentQuickCommand diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx index 050dc3a1dfa..3b9bd8095e3 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx @@ -3,7 +3,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { TerminalQuickCommand } from '../../../../shared/types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import { TerminalQuickCommandDialog } from './TerminalQuickCommandDialog' const mountedRoots: Root[] = [] diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx index 1c9c615d72d..0e8eadc2ff3 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx @@ -1,10 +1,10 @@ import { useRef, useState } from 'react' +import type { Repo } from '../../../../shared/repo-types' import type { - Repo, TerminalQuickCommand, - TerminalQuickCommandScope, - TuiAgent -} from '../../../../shared/types' + TerminalQuickCommandScope +} from '../../../../shared/terminal-quick-command-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { getTerminalQuickCommandAction, getTerminalQuickCommandScope, diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandLabelField.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandLabelField.tsx index a337be4361e..7fb30e8d683 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandLabelField.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandLabelField.tsx @@ -1,5 +1,5 @@ import type { Dispatch, SetStateAction } from 'react' -import type { TerminalQuickCommand } from '../../../../shared/types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandScopeField.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandScopeField.tsx index 58cd0ea8fd2..7af1f31b53f 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandScopeField.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandScopeField.tsx @@ -1,9 +1,9 @@ import type { Dispatch, SetStateAction } from 'react' +import type { Repo } from '../../../../shared/repo-types' import type { - Repo, TerminalQuickCommand, TerminalQuickCommandScope -} from '../../../../shared/types' +} from '../../../../shared/terminal-quick-command-types' import { Label } from '@/components/ui/label' import { Select, diff --git a/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-agent-options.ts b/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-agent-options.ts index c0ad04b4159..82b44ae990a 100644 --- a/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-agent-options.ts +++ b/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-agent-options.ts @@ -1,7 +1,7 @@ import type { AgentCatalogEntry } from '@/lib/agent-catalog' import { getAgentCatalog } from '@/lib/agent-catalog' import { supportsTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' -import type { TuiAgent } from '../../../../shared/types' +import type { TuiAgent } from '../../../../shared/tui-agent' const QUICK_COMMAND_AGENT_PRESENTATION_ORDER = [ 'claude', diff --git a/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts b/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts index d4397d4fd2e..becc121fb15 100644 --- a/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts +++ b/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { TerminalQuickCommand } from '../../../../shared/types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import { createTerminalQuickCommandDialogDraftMemory, switchTerminalQuickCommandDialogAction diff --git a/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.ts b/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.ts index ce61efb4d1a..01dc86367c7 100644 --- a/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.ts +++ b/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.ts @@ -2,7 +2,8 @@ import { getTerminalQuickCommandScope, isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' -import type { TerminalQuickCommand, TuiAgent } from '../../../../shared/types' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' +import type { TuiAgent } from '../../../../shared/tui-agent' export type TerminalQuickCommandDialogAction = 'terminal-command' | 'agent-prompt' diff --git a/src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx b/src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx index ab3ff3ac45e..3e8395b1327 100644 --- a/src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx +++ b/src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx @@ -3,7 +3,8 @@ import { act, useMemo } from 'react' import { createRoot } from 'react-dom/client' import { afterEach, describe, expect, it } from 'vitest' import { useAppStore } from '@/store' -import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { useActiveTerminalRepair } from './use-active-terminal-repair' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true diff --git a/src/renderer/src/components/terminal/active-terminal-repair.test.ts b/src/renderer/src/components/terminal/active-terminal-repair.test.ts index f62cd0e4da7..4dfb08cf084 100644 --- a/src/renderer/src/components/terminal/active-terminal-repair.test.ts +++ b/src/renderer/src/components/terminal/active-terminal-repair.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { resolveRepairedActiveTerminalTabId, shouldRepairActiveTerminalTab diff --git a/src/renderer/src/components/terminal/active-terminal-repair.ts b/src/renderer/src/components/terminal/active-terminal-repair.ts index 81dc5b2c6d3..539644b6fc8 100644 --- a/src/renderer/src/components/terminal/active-terminal-repair.ts +++ b/src/renderer/src/components/terminal/active-terminal-repair.ts @@ -1,4 +1,5 @@ -import type { TerminalTab, WorkspaceVisibleTabType } from '../../../../shared/types' +import type { WorkspaceVisibleTabType } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' export function shouldRepairActiveTerminalTab(args: { activeTabType: WorkspaceVisibleTabType diff --git a/src/renderer/src/components/terminal/split-group-mount.test.ts b/src/renderer/src/components/terminal/split-group-mount.test.ts index 79316fbe6b1..4a66f6d6f80 100644 --- a/src/renderer/src/components/terminal/split-group-mount.test.ts +++ b/src/renderer/src/components/terminal/split-group-mount.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { getEffectiveLayoutForWorktree, anyMountedWorktreeHasLayout } from './split-group-mount' -import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/types' +import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types' function makeGroup(id: string, worktreeId: string): TabGroup { return { id, worktreeId, activeTabId: null, tabOrder: [] } diff --git a/src/renderer/src/components/terminal/split-group-mount.ts b/src/renderer/src/components/terminal/split-group-mount.ts index ca10c331bc5..f9fccd56683 100644 --- a/src/renderer/src/components/terminal/split-group-mount.ts +++ b/src/renderer/src/components/terminal/split-group-mount.ts @@ -1,4 +1,4 @@ -import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/types' +import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types' /** * Derive the effective layout for a worktree: either its explicit layout diff --git a/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts b/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts index f9a649eb2ad..3bca59bd476 100644 --- a/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts @@ -1,4 +1,4 @@ -import type { TabContentType } from '../../../../shared/types' +import type { TabContentType } from '../../../../shared/tab-types' import { hasUnroutableTerminalWorktreeOwner, resolveTerminalWorktreeRoute diff --git a/src/renderer/src/components/terminal/use-active-terminal-repair.ts b/src/renderer/src/components/terminal/use-active-terminal-repair.ts index d495c80c330..81197f29fd2 100644 --- a/src/renderer/src/components/terminal/use-active-terminal-repair.ts +++ b/src/renderer/src/components/terminal/use-active-terminal-repair.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react' -import type { TerminalTab, WorkspaceVisibleTabType } from '../../../../shared/types' +import type { WorkspaceVisibleTabType } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { resolveRepairedActiveTerminalTabId } from './active-terminal-repair' type ActiveTerminalRepairInput = { diff --git a/src/renderer/src/components/ui/repo-multi-combobox.test.ts b/src/renderer/src/components/ui/repo-multi-combobox.test.ts index a0a4a747e31..3248e743afc 100644 --- a/src/renderer/src/components/ui/repo-multi-combobox.test.ts +++ b/src/renderer/src/components/ui/repo-multi-combobox.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import { getRepoMultiComboboxDetail } from './repo-multi-combobox' function repo(overrides: Partial = {}): Repo { diff --git a/src/renderer/src/components/ui/repo-multi-combobox.tsx b/src/renderer/src/components/ui/repo-multi-combobox.tsx index f3dfef437d0..5af4313ebda 100644 --- a/src/renderer/src/components/ui/repo-multi-combobox.tsx +++ b/src/renderer/src/components/ui/repo-multi-combobox.tsx @@ -11,7 +11,7 @@ import { import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { searchRepos } from '@/lib/repo-search' import { cn } from '@/lib/utils' -import type { Repo } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { translate } from '@/i18n/i18n' 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 e2546210158..00000000000 --- a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { Repo, Worktree } from '../../../../shared/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 a0103360f5f..00000000000 --- a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Repo, Worktree } from '../../../../shared/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-fixtures.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts index 4538c9619c5..17f5f2f7c05 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts @@ -1,7 +1,8 @@ import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' import type { WorkspaceCleanupFilters } from './workspace-cleanup-presentation' 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 bfcc64cc0fa..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,10 +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, Worktree } from '../../../../shared/types' +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' @@ -39,6 +41,11 @@ export type WorkspaceCleanupRendererStateInputs = Pick< 'worktreesByRepo' | 'hostedReviewCache' | 'repos' | 'settings' > +export type WorkspaceCleanupReviewLookup = { + reposById: ReadonlyMap + worktreesByRepoAndId: ReadonlyMap +} + export { filterWorkspaceCleanupCandidates, getWorkspaceCleanupGitLabel, @@ -49,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 { @@ -84,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, @@ -98,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' ? ( +