mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
perf: systematic performance optimizations (#623)
This commit is contained in:
parent
ef75e8d0c2
commit
60a092686b
28 changed files with 711 additions and 311 deletions
81
docs/performance-audit.md
Normal file
81
docs/performance-audit.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Orca Performance Audit
|
||||
|
||||
Audit date: 2026-04-13
|
||||
Branch: `Jinwoo-H/performance-improvement`
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — High Impact, Low Complexity
|
||||
|
||||
| # | Area | Issue | File(s) | Status |
|
||||
|---|------|-------|---------|--------|
|
||||
| 1 | Renderer | `App.tsx` has 53 separate Zustand subscriptions — nearly every state change re-renders the root and cascades through the entire tree | `src/renderer/src/App.tsx:40-180` | DONE (53→22 subs, memo barriers on 4 children) |
|
||||
| 2 | Renderer | `Terminal.tsx` has unscoped `useAppStore.subscribe()` — fires on every store mutation, does O(worktrees×tabs) scan each time | `src/renderer/src/components/Terminal.tsx:645-659` | DONE |
|
||||
| 3 | Terminal | No IPC batching — every PTY data chunk is a separate `webContents.send` call, hundreds/sec under load | `src/main/ipc/pty.ts:173-177` | DONE |
|
||||
| 4 | Terminal | Divider drag calls `fitAddon.fit()` on every `pointermove` pixel — xterm reflow can take 500ms+ with large scrollback | `src/renderer/src/lib/pane-manager/pane-divider.ts:90-119` | DONE |
|
||||
| 5 | Main | Startup blocks: `openCodeHookService.start()` and `runtimeRpc.start()` awaited sequentially before window opens | `src/main/index.ts:131-168` | DONE |
|
||||
| 6 | Main | `persistence.ts` uses `readFileSync`/`writeFileSync`/`renameSync` on the main thread — blocks during startup and every 300ms save | `src/main/persistence.ts:59-125` | DONE |
|
||||
| 7 | Worktree | `worktrees:listAll` iterates repos sequentially with `await` — total time = sum of all repos instead of max | `src/main/ipc/worktrees.ts:53-84` | DONE |
|
||||
| 8 | Browser | Reverse Map scan O(N) on every mouse/load/permission event in `BrowserManager` | `src/main/browser/browser-manager.ts:91-96` | DONE |
|
||||
| 9 | Browser | `before-mouse-event` listener fires for ALL mouse events on ALL guests, even background ones | `src/main/browser/browser-guest-ui.ts:78-93` | DONE |
|
||||
| 10 | Worktree | `refreshGitHubForWorktree` bypasses 5-min cache TTL on every worktree switch — fires GitHub API calls on rapid tab switching | `src/renderer/src/store/slices/worktrees.ts:467-489` | DONE |
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — Medium Impact
|
||||
|
||||
| # | Area | Issue | File(s) | Status |
|
||||
|---|------|-------|---------|--------|
|
||||
| 11 | Main | `git/repo.ts`: `execSync('gh api user ...')` and chains of 5 sync git processes block main thread | `src/main/git/repo.ts:87-138` | TODO |
|
||||
| 12 | Main | `hooks.ts`: `readFileSync`/`writeFileSync`/`mkdirSync`/`gitExecFileSync` in IPC handlers | `src/main/hooks.ts:113-416` | TODO |
|
||||
| 13 | Renderer | `useSettings` returns entire `GlobalSettings` (~30+ fields) — any setting change re-renders all consumers | `src/renderer/src/store/selectors.ts` | TODO |
|
||||
| 14 | Renderer | Tab title changes bump `sortEpoch` for ALL worktrees → triggers WorktreeList re-sort on every PTY title event | `src/renderer/src/store/slices/terminals.ts:325` | TODO |
|
||||
| 15 | Renderer | `CacheTimer`: one `setInterval` per mounted card (20 cards = 20 intervals/sec), each with O(n) selector work | `src/renderer/src/components/sidebar/CacheTimer.tsx:28-48` | TODO |
|
||||
| 16 | Renderer | Three simultaneous 3-second polling intervals per active worktree (git status, worktrees, stale conflict) | `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts` | TODO |
|
||||
| 17 | Renderer | 6 components missing `React.memo`: `SourceControl`, `RightSidebar`, `EditorPanel`, `ChecksPanel`, `FileExplorer`, `TabBar` | Various files in `src/renderer/src/components/` | DONE (8 components wrapped) |
|
||||
| 18 | Worktree | Git polling fires immediately on every worktree switch (burst of `git status` + `git worktree list`) | `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts:69-103` | REVERTED (150ms debounce caused visible flash on switch) |
|
||||
| 19 | Worktree | `FileExplorer` `dirCache` discarded on every worktree switch — re-fetches entire tree from scratch | `src/renderer/src/components/right-sidebar/useFileExplorerTree.ts:27,81-96` | TODO |
|
||||
| 20 | Terminal | `pty:resize` uses `ipcRenderer.invoke` (round-trip) instead of fire-and-forget `send` | `src/preload/index.ts:203-205` | DONE |
|
||||
| 21 | Terminal | Flow control watermarks defined but never enforced for local PTYs — unbounded output floods renderer | `src/main/providers/local-pty-provider.ts:330-332` | TODO |
|
||||
| 22 | Browser | `BrowserPane` subscribes to entire `browserPagesByWorkspace` map — any tab's navigation re-renders all panes | `src/renderer/src/components/browser-pane/BrowserPane.tsx:312` | TODO |
|
||||
| 23 | Browser | `findPage`/`findWorkspace` do O(N) `Object.values().flat().find()` scans on every navigation event | `src/renderer/src/store/slices/browser.ts:221-240` | TODO |
|
||||
| 24 | Browser | Download progress IPC fires at full Chromium frequency (many/sec) | `src/main/browser/browser-manager.ts:421-430` | TODO |
|
||||
| 25 | Main | `detectConflictOperation` runs 4 `existsSync` + `readFile` on every 3s poll before git status | `src/main/git/status.ts:236-265` | DONE |
|
||||
| 26 | Main | `getBranchCompare`: `loadBranchChanges` and `countAheadCommits` run sequentially | `src/main/git/status.ts:374-375` | DONE |
|
||||
| 27 | Main | `addWorktree` calls 4-5 synchronous git processes from IPC handler | `src/main/git/worktree.ts:116-157` | DONE |
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — Lower Impact / Architectural
|
||||
|
||||
| # | Area | Issue | File(s) | Status |
|
||||
|---|------|-------|---------|--------|
|
||||
| 28 | Terminal | SSH relay `FrameDecoder` uses `Buffer.concat` on every chunk (quadratic copy) | `src/relay/protocol.ts:84` | TODO |
|
||||
| 29 | Terminal | SSH relay replay buffer uses string concatenation (quadratic allocation) | `src/relay/pty-handler.ts:68-72` | TODO |
|
||||
| 30 | Terminal | `extractLastOscTitle` regex runs on every PTY chunk with no fast-path bail | `src/shared/agent-detection.ts:23-39` | TODO |
|
||||
| 31 | Terminal | Binary search calls `serialize()` up to 16× at shutdown | `src/renderer/src/components/terminal-pane/TerminalPane.tsx:579-596` | TODO |
|
||||
| 32 | Browser | `capturePage()` captures full viewport then crops — should pass rect directly | `src/main/browser/browser-grab-screenshot.ts:36` | TODO |
|
||||
| 33 | Browser | Parked webviews retain full 100vw×100vh compositor surfaces | `src/renderer/src/components/browser-pane/BrowserPane.tsx:94-107` | TODO |
|
||||
| 34 | Browser | `onBeforeSendHeaders` intercepts every HTTPS request even when UA override unused | `src/main/browser/browser-session-registry.ts:126-137` | TODO |
|
||||
| 35 | Main | `setBackgroundThrottling(false)` wastes CPU when window is minimized | `src/main/window/createMainWindow.ts:97` | TODO |
|
||||
| 36 | Main | `warmSystemFontFamilies()` competes with startup I/O | `src/main/system-fonts.ts:30-32` | TODO |
|
||||
| 37 | Renderer | Session persistence effect has 15 deps — fires on every tab title change | `src/renderer/src/App.tsx:239-283` | TODO |
|
||||
| 38 | Renderer | Per-card `fetchPRForBranch` on mount — 30 worktrees = 30 simultaneous IPC calls | `src/renderer/src/components/sidebar/WorktreeCard.tsx:128-132` | TODO |
|
||||
| 39 | Renderer | Synchronous full `monaco-editor` import blocks EditorPanel chunk evaluation | `src/renderer/src/components/editor/EditorPanel.tsx:7` | TODO |
|
||||
| 40 | Worktree | `removeWorktree` runs `git worktree list` twice (pre and post removal) | `src/main/git/worktree.ts:163-208` | TODO |
|
||||
| 41 | Worktree | `worktrees:list` can trigger duplicate `git worktree list` when cache is dirty | `src/main/ipc/worktrees.ts:86-116` | TODO |
|
||||
| 42 | Renderer | SSH targets initialized sequentially in a `for...await` loop | `src/renderer/src/hooks/useIpcEvents.ts:232-252` | TODO |
|
||||
|
||||
---
|
||||
|
||||
## Key Themes
|
||||
|
||||
1. **Zustand subscription granularity** — `App.tsx` subscribes to 53 slices, `Terminal.tsx` subscribes to everything, `useSettings` returns the full object. Almost any state change cascades through the entire tree.
|
||||
|
||||
2. **Synchronous I/O on the main thread** — `persistence.ts`, `hooks.ts`, `git/repo.ts`, and `git/worktree.ts` use `readFileSync`/`writeFileSync`/`execSync` in startup and IPC handler paths.
|
||||
|
||||
3. **Unthrottled high-frequency events** — PTY data (no IPC batching), divider drag (fit on every pixel), download progress (no throttle), `before-mouse-event` (all mouse events on all guests), CacheTimer (20 intervals/sec).
|
||||
|
||||
4. **Sequential operations that could be parallel** — Startup server binds, repo iteration in `listAll`, git ref probing, `loadBranchChanges`/`countAheadCommits`, SSH target init.
|
||||
|
||||
5. **Aggressive polling** — Three 3-second intervals per worktree, per-card 1-second cache timers, per-card 5-minute issue polling, 250ms error-page detection.
|
||||
179
docs/performance-implementation-plan.md
Normal file
179
docs/performance-implementation-plan.md
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
# Performance Improvement — Implementation Plan
|
||||
|
||||
Branch: `Jinwoo-H/performance-improvement`
|
||||
|
||||
---
|
||||
|
||||
## Completed (Tier 1)
|
||||
|
||||
### Fix 3 — PTY IPC Data Batching
|
||||
**Files:** `src/main/ipc/pty.ts`
|
||||
**Change:** Added 8ms flush-window batching for PTY data. Instead of calling `webContents.send('pty:data', ...)` on every `node-pty` onData event, data is accumulated per-PTY in a `Map<string, string>` and flushed once per 8ms interval. Reduces IPC round-trips from hundreds/sec to ~120/sec under high throughput. Interactive latency stays below one frame (16ms).
|
||||
|
||||
### Fix 4 — Divider Drag Throttled to rAF
|
||||
**Files:** `src/renderer/src/lib/pane-manager/pane-divider.ts`
|
||||
**Change:** Wrapped `refitPanesUnder` calls in `requestAnimationFrame` guard during `onPointerMove`. Previously `fitAddon.fit()` ran on every pointer event (~250Hz), each triggering a full xterm.js reflow (500ms+ with large scrollback). Now capped at 60fps. Cleanup on `onPointerUp` cancels pending rAF and runs one final refit.
|
||||
|
||||
### Fix 5 — Startup Parallelization
|
||||
**Files:** `src/main/index.ts`
|
||||
**Change:** `openCodeHookService.start()`, `runtimeRpc.start()`, and `openMainWindow()` now run concurrently via `Promise.all` instead of three sequential `await`s. Window creation no longer blocked by server bind operations.
|
||||
|
||||
### Fix 6 — Async Persistence Writes
|
||||
**Files:** `src/main/persistence.ts`, `src/main/persistence.test.ts`
|
||||
**Change:** Debounced `scheduleSave()` now calls `writeToDiskAsync()` using `fs/promises` (writeFile, rename, mkdir) instead of `writeFileSync`/`renameSync`. Synchronous `writeToDiskSync()` retained only for `flush()` at shutdown. Added `waitForPendingWrite()` for test await support.
|
||||
|
||||
### Fix 7 — Parallel Worktree Listing
|
||||
**Files:** `src/main/ipc/worktrees.ts`
|
||||
**Change:** `worktrees:listAll` handler now uses `Promise.all(repos.map(...))` instead of sequential `for...of` loop. Total time = slowest repo, not sum of all repos. Each repo's `listRepoWorktrees` spawns `git worktree list` subprocess independently.
|
||||
|
||||
### Fix 8 — BrowserManager Reverse Map
|
||||
**Files:** `src/main/browser/browser-manager.ts`
|
||||
**Change:** Added `tabIdByWebContentsId` reverse Map maintained in sync with `webContentsIdByTabId`. Replaced two O(N) `[...entries()].find()` scans with O(1) `.get()` lookups. Updated `registerGuest`, `unregisterGuest`, and `unregisterAll` to keep both maps in sync.
|
||||
|
||||
### Fix 9 — Context Menu Listener Scoping
|
||||
**Files:** `src/main/browser/browser-guest-ui.ts`
|
||||
**Change:** `before-mouse-event` listener is now installed only when a context menu is open (on `context-menu` event) and removed on first `mouseDown` (dismiss). Previously fired for every mouse event on every guest surface.
|
||||
|
||||
### Fix 10 — GitHub Cache TTL on Worktree Switch
|
||||
**Files:** `src/renderer/src/store/slices/github.ts`, `src/renderer/src/store/slices/worktrees.ts`, `src/renderer/src/store/slices/store-cascades.test.ts`
|
||||
**Change:** Added `refreshGitHubForWorktreeIfStale()` that checks cache age before fetching. `setActiveWorktree` now calls this instead of `refreshGitHubForWorktree` (which always force-refreshes). Eliminates unnecessary GitHub API calls on rapid worktree switching. Force-refresh still available via explicit user action.
|
||||
|
||||
### Fix 20 (Tier 2 bonus) — PTY Resize Fire-and-Forget
|
||||
**Files:** `src/main/ipc/pty.ts`, `src/preload/index.ts`
|
||||
**Change:** `pty:resize` changed from `ipcMain.handle`/`ipcRenderer.invoke` (round-trip) to `ipcMain.on`/`ipcRenderer.send` (fire-and-forget). Halves IPC traffic for terminal resize events since the renderer never awaited the response anyway.
|
||||
|
||||
---
|
||||
|
||||
## Completed (Tier 1 — continued)
|
||||
|
||||
### Fix 1b — Session Persistence Extracted from React
|
||||
**Files:** `src/renderer/src/App.tsx`
|
||||
**Change:** Replaced the session-persistence `useEffect` (which had ~15 Zustand subscriptions as deps) with a single `useAppStore.subscribe()` call that runs outside React's render cycle. The subscriber debounces writes to disk via `window.setTimeout(150ms)`. This removed 12 `useAppStore` subscriptions from App's render cycle (`activeRepoId`, `terminalLayoutsByTabId`, `openFiles`, `activeFileIdByWorktree`, `activeTabTypeByWorktree`, `activeTabIdByWorktree`, `browserTabsByWorktree`, `browserPagesByWorkspace`, `activeBrowserTabIdByWorktree`, `unifiedTabsByWorktree`, `groupsByWorktree`, `activeGroupIdByWorktree`) — none of which ever drove JSX.
|
||||
|
||||
### Fix 1a — Consolidated Action Subscriptions
|
||||
**Files:** `src/renderer/src/App.tsx`
|
||||
**Change:** Consolidated 19 stable action-ref subscriptions (`toggleSidebar`, `fetchRepos`, `openModal`, `setRightSidebarTab`, etc.) into a single `useShallow` selector returning an `actions` object. Since Zustand actions are referentially stable, the shallow equality check always passes and this subscription never triggers a re-render. All call sites updated to `actions.fetchRepos()`, `actions.toggleSidebar()`, etc.
|
||||
|
||||
### Fix 1c — React.memo Barriers for Children
|
||||
**Files:** `src/renderer/src/components/sidebar/index.tsx`, `src/renderer/src/components/Terminal.tsx`, `src/renderer/src/components/right-sidebar/index.tsx`, `src/renderer/src/components/status-bar/StatusBar.tsx`
|
||||
**Change:** Wrapped `Sidebar`, `Terminal`, `RightSidebar`, and `StatusBar` in `React.memo`. These components accept no props from App — they read state from the store directly. The memo barrier prevents App's remaining re-renders (from layout state like `sidebarWidth`, `activeView`) from cascading into the full component tree.
|
||||
|
||||
### Fix 2 — Terminal.tsx Scoped Subscribe
|
||||
**Files:** `src/renderer/src/components/Terminal.tsx`
|
||||
**Change:** The `useAppStore.subscribe()` that destroys orphaned browser webviews now short-circuits with a reference equality check (`state.browserTabsByWorktree === prevBrowserTabs`). Previously fired on every store mutation; now only runs the O(tabs) scan when `browserTabsByWorktree` actually changes.
|
||||
|
||||
**Total App.tsx subscription reduction: 53 → 22 (58% fewer)**
|
||||
|
||||
---
|
||||
|
||||
## Planned — Tier 2
|
||||
|
||||
### Fix 11 — Async Git Username/Login
|
||||
**Files:** `src/main/git/repo.ts`
|
||||
**Change:** Replace `execSync('gh api user ...')` and `gitExecFileSync` chains in `getGhLogin`, `getGitUsername`, and `getDefaultBaseRef` with their async equivalents. `getDefaultBaseRefAsync` already exists — remove the sync variant and migrate all callers. The `Store.hydrateRepo` call in `getRepos()` is synchronous and uses `getGitUsername` — convert to a lazy-populate pattern where the `gitUsername` field is initially empty and filled by an async hydration pass after construction.
|
||||
|
||||
### Fix 12 — Async Hooks File I/O
|
||||
**Files:** `src/main/hooks.ts`
|
||||
**Change:** Convert `loadHooks`, `hasHooksFile`, `hasUnrecognizedOrcaYamlKeys`, `readIssueCommand`, `writeIssueCommand`, and `createWorktreeRunnerScript` from `readFileSync`/`writeFileSync`/`mkdirSync`/`gitExecFileSync` to `fs/promises` + `gitExecFileAsync`. Update all callers in `src/main/ipc/worktrees.ts` to await the new async versions.
|
||||
|
||||
### Fix 13 — Narrow `useSettings` Selector
|
||||
**Files:** `src/renderer/src/store/selectors.ts`, all 10 consumers
|
||||
**Change:** The current `useSettings = () => useAppStore((s) => s.settings)` returns the entire GlobalSettings object (~30 fields). Any setting change re-renders every consumer. Replace with field-specific selectors or use `useShallow` at each call site to select only the fields used by that component:
|
||||
- `App.tsx` only uses `settings.theme` → `useAppStore((s) => s.settings?.theme)`
|
||||
- `MonacoEditor.tsx` uses font/tab/theme → `useShallow` for those 3 fields
|
||||
- `AddWorktreeDialog.tsx` uses one field → direct selector
|
||||
|
||||
### Fix 14 — Narrow sortEpoch Bumping
|
||||
**Files:** `src/renderer/src/store/slices/terminals.ts`
|
||||
**Change:** `updateTabTitle` currently bumps `sortEpoch` on every title string change for background worktrees (line 354). Title strings change frequently during agent runs (shell prompts, command names). Only bump `sortEpoch` when the agent working/idle status boundary is actually crossed. This requires comparing the old and new title against the agent-status detection logic before deciding to increment.
|
||||
|
||||
### Fix 15 — Shared CacheTimer Interval
|
||||
**Files:** `src/renderer/src/components/sidebar/CacheTimer.tsx`
|
||||
**Change:** Each `CacheTimer` instance creates its own 1-second `setInterval`. With 20 visible cards this means 20 intervals firing per second, each running a Zustand selector that iterates `Object.keys(s.cacheTimerByKey)`. Replace with a single shared interval at the module level (or in the store slice) that updates a `remainingByWorktreeId` map in one `set()` call. Components subscribe to only their specific worktree's entry.
|
||||
|
||||
### Fix 16 — Consolidate Git Status Polling
|
||||
**Files:** `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts`
|
||||
**Change:** Three `setInterval(fn, 3000)` calls run simultaneously: git status, fetchWorktrees, and stale conflict poll. The worktree list poll (every 3s) is aggressive — branch changes inside terminals are low-frequency. Consolidate into a single interval:
|
||||
- Git status poll: keep at 3s (drives diff gutter, status badge)
|
||||
- Worktree list poll: increase to 15s (only needed when user runs `git checkout` in terminal)
|
||||
- Stale conflict poll: keep at 3s but only when stale worktrees exist (already gated)
|
||||
|
||||
### Fix 17 — React.memo on Heavy Components
|
||||
**Files:** `SourceControl.tsx`, `EditorPanel.tsx`, `FileExplorer.tsx`, `TabBar.tsx`
|
||||
**Change:** Wrapped all four in `React.memo`. Combined with the Tier 1 memo barriers on Sidebar/Terminal/RightSidebar/StatusBar, a total of 8 heavy components now prevent parent re-render cascades.
|
||||
|
||||
### Fix 18 — Debounce Git Polling on Worktree Switch
|
||||
**Files:** `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts`
|
||||
**Change:** Replaced the immediate `void fetchStatus()` and `void fetchWorktrees(activeRepoId)` calls with 150ms `setTimeout` debounces. The interval polling continues as before. Rapid worktree switching now only fires one git status + one git worktree list subprocess instead of N.
|
||||
|
||||
### Fix 19 — Cache FileExplorer dirCache Per Worktree
|
||||
**Files:** `src/renderer/src/components/right-sidebar/useFileExplorerTree.ts`
|
||||
**Change:** `dirCache` is local `useState` — reset on every worktree switch. Cache the directory tree per worktree in a `useRef<Map<string, DirCache>>()` at the hook level. On switch, restore from cache instantly (with a background revalidation fetch). This makes repeated worktree switches O(1) for the file explorer.
|
||||
|
||||
### Fix 21 — Local PTY Flow Control
|
||||
**Files:** `src/main/providers/local-pty-provider.ts`, `src/renderer/src/components/terminal-pane/pty-dispatcher.ts`
|
||||
**Change:** Wire up the already-defined `PTY_FLOW_HIGH_WATERMARK` (100KB) and `PTY_FLOW_LOW_WATERMARK` (5KB) constants. Track pending bytes per PTY in the renderer's `EagerPtyBuffer`. When pending exceeds high watermark, send an IPC message to pause the node-pty stream. Resume when acknowledged down to low watermark. The `acknowledgeDataEvent` channel is already plumbed — just needs implementation.
|
||||
|
||||
### Fix 22 — Narrow BrowserPane Selector
|
||||
**Files:** `src/renderer/src/components/browser-pane/BrowserPane.tsx`
|
||||
**Change:** Line 312: `useAppStore((s) => s.browserPagesByWorkspace)` subscribes to the entire map. Any tab's navigation re-renders all BrowserPane instances. Narrow to: `useAppStore((s) => s.browserPagesByWorkspace[browserTab.id] ?? EMPTY_BROWSER_PAGES)`.
|
||||
|
||||
### Fix 23 — Index-Based findPage/findWorkspace
|
||||
**Files:** `src/renderer/src/store/slices/browser.ts`
|
||||
**Change:** `findWorkspace` and `findPage` (lines 221-240) use `Object.values().flat().find()` on every navigation event. Accept `worktreeId`/`workspaceId` as a hint parameter and do direct key access: `browserTabsByWorktree[worktreeId]?.find(...)` instead of flattening across all worktrees.
|
||||
|
||||
### Fix 24 — Throttle Download Progress
|
||||
**Files:** `src/main/browser/browser-manager.ts`
|
||||
**Change:** `download.item.on('updated', ...)` fires at full Chromium frequency. Add per-download throttle timer — only call `sendDownloadProgress` at most once per 250ms. Clear throttle timer on download done/cancel.
|
||||
|
||||
### Fix 25 — Parallelize detectConflictOperation with git status
|
||||
**Files:** `src/main/git/status.ts`
|
||||
**Change:** `getStatus()` now kicks off both `detectConflictOperation()` and `git status` concurrently. The conflict detection promise is started first and awaited before the status result, preserving error semantics while overlapping I/O.
|
||||
|
||||
### Fix 26 — Parallelize getBranchCompare
|
||||
**Files:** `src/main/git/status.ts`
|
||||
**Change:** `loadBranchChanges` and `countAheadCommits` now run via `Promise.all` instead of sequentially. These are independent git subprocess calls.
|
||||
|
||||
### Fix 27 — Async addWorktree
|
||||
**Files:** `src/main/git/worktree.ts`, `src/main/ipc/worktree-remote.ts`, `src/main/runtime/orca-runtime.ts`
|
||||
**Change:** Converted `addWorktree` from synchronous (`gitExecFileSync`) to async (`gitExecFileAsync`). This was the last major sync git operation on the main thread — 4-5 sequential subprocess calls that blocked the event loop during worktree creation. Updated callers and all 7 tests.
|
||||
|
||||
---
|
||||
|
||||
## Benchmark Validation Results (2026-04-14)
|
||||
|
||||
Ran 6 targeted benchmarks to validate optimization claims. Full scripts in `benchmarks/`.
|
||||
|
||||
| Fix | What was measured | Result | Verdict |
|
||||
|-----|-------------------|--------|---------|
|
||||
| 3 — PTY Batching | IPC calls/sec: unbatched vs 8ms window | 5000→125 calls/sec (98% reduction) | **Validated — high impact** |
|
||||
| 5/7 — Parallelization | Sequential vs parallel subprocess at N=5 | 119ms→46ms (2.6x, 73ms saved) | **Validated — high impact** |
|
||||
| 6 — Async I/O | Main-thread blocking: sync vs fire-and-forget | 439µs→21µs per write (21x less blocking) | **Validated — high impact** |
|
||||
| 1 — Zustand Subs | Selector cost: 53 subs vs 18 subs | 2.61µs→1.59µs per mutation (1.6x) | **Validated — moderate** (real win is cascade prevention via React.memo) |
|
||||
| 8 — Reverse Map | entries().find() vs Map.get() at N=10 | 409ns→1ns (479x) but 0.04ms/sec total | **Deprioritize** — micro-optimization |
|
||||
| 23 — flat().find() | Object.values().flat().find() vs direct at 50 tabs | 1958ns→62ns (31x) but 0.02ms/sec total | **Deprioritize** — micro-optimization |
|
||||
|
||||
### Deprioritized based on benchmarks
|
||||
|
||||
The following fixes are technically correct but save <0.1ms/sec at realistic load. Moved to "nice to have":
|
||||
- Fix 8 (Reverse Map) — already implemented, keep as-is
|
||||
- Fix 14 (sortEpoch) — per-mutation overhead is negligible
|
||||
- Fix 15 (CacheTimer shared interval) — 20 intervals/sec is fine for modern JS engines
|
||||
- Fix 22 (BrowserPane selector) — sub-microsecond per render
|
||||
- Fix 23 (findPage/findWorkspace) — 0.02ms/sec at 10 nav/sec
|
||||
- Fix 24 (Download progress throttle) — infrequent event
|
||||
- Fix 26 (getBranchCompare parallel) — implemented anyway since it was a one-liner
|
||||
|
||||
---
|
||||
|
||||
## Key Themes
|
||||
|
||||
1. **Zustand subscription granularity** — Fixes 1, 2, 13, 14, 17, 22 all reduce the blast radius of state changes on React re-renders.
|
||||
|
||||
2. **Synchronous I/O on main thread** — Fixes 6, 11, 12, 27 convert blocking filesystem and git operations to async.
|
||||
|
||||
3. **Unthrottled high-frequency events** — Fixes 3, 4, 9, 15, 20, 24 cap event processing to reasonable rates.
|
||||
|
||||
4. **Sequential → parallel** — Fixes 5, 7, 25, 26 run independent async operations concurrently.
|
||||
|
||||
5. **Aggressive polling** — Fixes 16, 18 consolidate and debounce polling intervals.
|
||||
|
|
@ -74,22 +74,46 @@ export function setupGuestContextMenu(args: {
|
|||
})
|
||||
}
|
||||
|
||||
guest.on('context-menu', handler)
|
||||
const dismissHandler = (_event: Electron.Event, mouse: Electron.MouseInputEvent): void => {
|
||||
if (mouse.type !== 'mouseDown') {
|
||||
return
|
||||
// Why: `before-mouse-event` fires for every mouse event (move, down, up,
|
||||
// scroll) on the guest. Installing the dismiss listener only while a context
|
||||
// menu is open avoids an IPC dispatch per mouse event on idle guests.
|
||||
let dismissHandler: ((_event: Electron.Event, mouse: Electron.MouseInputEvent) => void) | null =
|
||||
null
|
||||
|
||||
const removeDismissListener = (): void => {
|
||||
if (dismissHandler) {
|
||||
try {
|
||||
guest.off('before-mouse-event', dismissHandler)
|
||||
} catch {
|
||||
/* guest may already be destroyed */
|
||||
}
|
||||
dismissHandler = null
|
||||
}
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
if (!renderer) {
|
||||
return
|
||||
}
|
||||
renderer.send('browser:context-menu-dismissed', { browserPageId: browserTabId })
|
||||
}
|
||||
guest.on('before-mouse-event', dismissHandler)
|
||||
|
||||
const contextMenuHandler = (_event: Electron.Event, params: Electron.ContextMenuParams): void => {
|
||||
handler(_event, params)
|
||||
|
||||
removeDismissListener()
|
||||
dismissHandler = (_evt: Electron.Event, mouse: Electron.MouseInputEvent): void => {
|
||||
if (mouse.type !== 'mouseDown') {
|
||||
return
|
||||
}
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
if (renderer) {
|
||||
renderer.send('browser:context-menu-dismissed', { browserPageId: browserTabId })
|
||||
}
|
||||
removeDismissListener()
|
||||
}
|
||||
guest.on('before-mouse-event', dismissHandler)
|
||||
}
|
||||
|
||||
guest.on('context-menu', contextMenuHandler)
|
||||
|
||||
return () => {
|
||||
try {
|
||||
guest.off('context-menu', handler)
|
||||
guest.off('before-mouse-event', dismissHandler)
|
||||
guest.off('context-menu', contextMenuHandler)
|
||||
removeDismissListener()
|
||||
} catch {
|
||||
// Why: browser tabs can outlive the guest webContents briefly during
|
||||
// teardown. Cleanup should be best-effort instead of throwing while the
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ function safeOrigin(rawUrl: string): string {
|
|||
|
||||
class BrowserManager {
|
||||
private readonly webContentsIdByTabId = new Map<string, number>()
|
||||
// Why: reverse map enables O(1) guest→tab lookups instead of O(N) linear
|
||||
// scans on every mouse event, load failure, permission, and popup event.
|
||||
private readonly tabIdByWebContentsId = new Map<number, string>()
|
||||
private readonly rendererWebContentsIdByTabId = new Map<string, number>()
|
||||
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
|
||||
private readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
|
||||
|
|
@ -89,10 +92,7 @@ class BrowserManager {
|
|||
private readonly grabSessionController = new BrowserGrabSessionController()
|
||||
|
||||
private resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null {
|
||||
return (
|
||||
[...this.webContentsIdByTabId.entries()].find(([, id]) => id === guestWebContentsId)?.[0] ??
|
||||
null
|
||||
)
|
||||
return this.tabIdByWebContentsId.get(guestWebContentsId) ?? null
|
||||
}
|
||||
|
||||
private resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null {
|
||||
|
|
@ -221,6 +221,7 @@ class BrowserManager {
|
|||
}
|
||||
|
||||
this.webContentsIdByTabId.set(browserTabId, webContentsId)
|
||||
this.tabIdByWebContentsId.set(webContentsId, browserTabId)
|
||||
this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId)
|
||||
|
||||
this.setupContextMenu(browserTabId, guest)
|
||||
|
|
@ -261,6 +262,10 @@ class BrowserManager {
|
|||
this.cancelDownloadInternal(downloadId, 'Tab closed before download was accepted.')
|
||||
}
|
||||
}
|
||||
const wcId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (wcId !== undefined) {
|
||||
this.tabIdByWebContentsId.delete(wcId)
|
||||
}
|
||||
this.webContentsIdByTabId.delete(browserTabId)
|
||||
this.rendererWebContentsIdByTabId.delete(browserTabId)
|
||||
}
|
||||
|
|
@ -275,6 +280,7 @@ class BrowserManager {
|
|||
this.unregisterGuest(browserTabId)
|
||||
}
|
||||
this.policyAttachedGuestIds.clear()
|
||||
this.tabIdByWebContentsId.clear()
|
||||
this.pendingLoadFailuresByGuestId.clear()
|
||||
this.pendingPermissionEventsByGuestId.clear()
|
||||
this.pendingPopupEventsByGuestId.clear()
|
||||
|
|
@ -477,6 +483,7 @@ class BrowserManager {
|
|||
const guest = webContents.fromId(webContentsId)
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
this.webContentsIdByTabId.delete(browserTabId)
|
||||
this.tabIdByWebContentsId.delete(webContentsId)
|
||||
return false
|
||||
}
|
||||
guest.openDevTools({ mode: 'detach' })
|
||||
|
|
@ -506,6 +513,7 @@ class BrowserManager {
|
|||
const guest = webContents.fromId(guestId)
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
this.webContentsIdByTabId.delete(browserTabId)
|
||||
this.tabIdByWebContentsId.delete(guestId)
|
||||
return null
|
||||
}
|
||||
return guest
|
||||
|
|
@ -666,9 +674,7 @@ class BrowserManager {
|
|||
guestWebContentsId: number,
|
||||
loadError: { code: number; description: string; validatedUrl: string }
|
||||
): void {
|
||||
const browserTabId = [...this.webContentsIdByTabId.entries()].find(
|
||||
([, webContentsId]) => webContentsId === guestWebContentsId
|
||||
)?.[0]
|
||||
const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId)
|
||||
if (!browserTabId) {
|
||||
// Why: some localhost failures happen before the renderer finishes
|
||||
// registering which tab owns this guest. Queue the failure by guest ID so
|
||||
|
|
|
|||
|
|
@ -23,13 +23,17 @@ const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
|
|||
*/
|
||||
export async function getStatus(worktreePath: string): Promise<GitStatusResult> {
|
||||
const entries: GitStatusEntry[] = []
|
||||
const conflictOperation = await detectConflictOperation(worktreePath)
|
||||
|
||||
// Why: detectConflictOperation (4 existsSync + readFile) and git status are
|
||||
// independent. Running them concurrently saves one round-trip of I/O latency.
|
||||
const conflictPromise = detectConflictOperation(worktreePath)
|
||||
const statusPromise = gitExecFileAsync(['status', '--porcelain=v2', '--untracked-files=all'], {
|
||||
cwd: worktreePath
|
||||
})
|
||||
const conflictOperation = await conflictPromise
|
||||
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['status', '--porcelain=v2', '--untracked-files=all'],
|
||||
{ cwd: worktreePath }
|
||||
)
|
||||
const { stdout } = await statusPromise
|
||||
|
||||
// [Fix]: Split by /\r?\n/ instead of '\n' to correctly parse git output on Windows,
|
||||
// avoiding trailing \r characters in parsed paths.
|
||||
|
|
@ -371,8 +375,10 @@ export async function getBranchCompare(
|
|||
}
|
||||
|
||||
try {
|
||||
const entries = await loadBranchChanges(worktreePath, mergeBase, headOid)
|
||||
const commitsAhead = await countAheadCommits(worktreePath, baseOid, headOid)
|
||||
const [entries, commitsAhead] = await Promise.all([
|
||||
loadBranchChanges(worktreePath, mergeBase, headOid),
|
||||
countAheadCommits(worktreePath, baseOid, headOid)
|
||||
])
|
||||
summary.changedFiles = entries.length
|
||||
summary.commitsAhead = commitsAhead
|
||||
summary.status = 'ready'
|
||||
|
|
|
|||
|
|
@ -206,29 +206,29 @@ describe('addWorktree', () => {
|
|||
translateWslOutputPathsMock.mockClear()
|
||||
})
|
||||
|
||||
it('creates the worktree without touching the local base ref by default', () => {
|
||||
gitExecFileSyncMock.mockReturnValueOnce(undefined)
|
||||
it('creates the worktree without touching the local base ref by default', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
||||
|
||||
expect(gitExecFileSyncMock.mock.calls).toEqual([
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['worktree', 'add', '-b', 'feature/test', '/repo-feature', 'origin/main'], { cwd: '/repo' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('fast-forwards with reset --hard when localBranch is checked out in primary worktree', () => {
|
||||
it('fast-forwards with reset --hard when localBranch is checked out in primary worktree', async () => {
|
||||
const worktreeListOutput =
|
||||
'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-other\nHEAD def456\nbranch refs/heads/feature\n'
|
||||
gitExecFileSyncMock
|
||||
.mockReturnValueOnce('') // merge-base --is-ancestor
|
||||
.mockReturnValueOnce(worktreeListOutput) // worktree list --porcelain
|
||||
.mockReturnValueOnce('') // status --porcelain (in /repo)
|
||||
.mockReturnValueOnce(undefined) // reset --hard (in /repo)
|
||||
.mockReturnValueOnce(undefined) // worktree add
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
|
||||
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
||||
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo)
|
||||
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo)
|
||||
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
|
||||
expect(gitExecFileSyncMock.mock.calls).toEqual([
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['merge-base', '--is-ancestor', 'main', 'origin/main'], { cwd: '/repo' }],
|
||||
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }],
|
||||
[['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }],
|
||||
|
|
@ -237,57 +237,57 @@ describe('addWorktree', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('fast-forwards with reset --hard in sibling worktree when localBranch is checked out there', () => {
|
||||
it('fast-forwards with reset --hard in sibling worktree when localBranch is checked out there', async () => {
|
||||
const worktreeListOutput =
|
||||
'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\nworktree /repo-main-wt\nHEAD def456\nbranch refs/heads/main\n'
|
||||
gitExecFileSyncMock
|
||||
.mockReturnValueOnce('') // merge-base --is-ancestor
|
||||
.mockReturnValueOnce(worktreeListOutput) // worktree list --porcelain
|
||||
.mockReturnValueOnce('') // status --porcelain (in /repo-main-wt)
|
||||
.mockReturnValueOnce(undefined) // reset --hard (in /repo-main-wt)
|
||||
.mockReturnValueOnce(undefined) // worktree add
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
|
||||
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
||||
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo-main-wt)
|
||||
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo-main-wt)
|
||||
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
|
||||
expect(gitExecFileSyncMock.mock.calls[2]).toEqual([
|
||||
expect(gitExecFileAsyncMock.mock.calls[2]).toEqual([
|
||||
['status', '--porcelain', '--untracked-files=no'],
|
||||
expect.objectContaining({ cwd: '/repo-main-wt' })
|
||||
])
|
||||
expect(gitExecFileSyncMock.mock.calls[3]).toEqual([
|
||||
expect(gitExecFileAsyncMock.mock.calls[3]).toEqual([
|
||||
['reset', '--hard', 'origin/main'],
|
||||
expect.objectContaining({ cwd: '/repo-main-wt' })
|
||||
])
|
||||
})
|
||||
|
||||
it('uses update-ref when localBranch is not checked out in any worktree', () => {
|
||||
it('uses update-ref when localBranch is not checked out in any worktree', async () => {
|
||||
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n'
|
||||
gitExecFileSyncMock
|
||||
.mockReturnValueOnce('') // merge-base --is-ancestor
|
||||
.mockReturnValueOnce(worktreeListOutput) // worktree list --porcelain
|
||||
.mockReturnValueOnce(undefined) // update-ref
|
||||
.mockReturnValueOnce(undefined) // worktree add
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
|
||||
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
||||
.mockResolvedValueOnce({ stdout: '' }) // update-ref
|
||||
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
|
||||
expect(gitExecFileSyncMock.mock.calls[2]).toEqual([
|
||||
expect(gitExecFileAsyncMock.mock.calls[2]).toEqual([
|
||||
['update-ref', 'refs/heads/main', 'origin/main'],
|
||||
expect.objectContaining({ cwd: '/repo' })
|
||||
])
|
||||
})
|
||||
|
||||
it('skips update when the owning worktree is dirty', () => {
|
||||
it('skips update when the owning worktree is dirty', async () => {
|
||||
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
gitExecFileSyncMock
|
||||
.mockReturnValueOnce('') // merge-base --is-ancestor
|
||||
.mockReturnValueOnce(worktreeListOutput) // worktree list --porcelain
|
||||
.mockReturnValueOnce(' M package.json\n') // status --porcelain (dirty)
|
||||
.mockReturnValueOnce(undefined) // worktree add
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
|
||||
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
||||
.mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain (dirty)
|
||||
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
|
||||
// No reset --hard or update-ref — just merge-base, worktree list, status, worktree add
|
||||
expect(gitExecFileSyncMock.mock.calls).toHaveLength(4)
|
||||
expect(gitExecFileSyncMock.mock.calls[3]?.[0]).toEqual([
|
||||
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(4)
|
||||
expect(gitExecFileAsyncMock.mock.calls[3]?.[0]).toEqual([
|
||||
'worktree',
|
||||
'add',
|
||||
'-b',
|
||||
|
|
@ -297,15 +297,13 @@ describe('addWorktree', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('skips updating the local branch when it has diverged', () => {
|
||||
gitExecFileSyncMock.mockImplementationOnce(() => {
|
||||
throw new Error('not a fast-forward')
|
||||
})
|
||||
gitExecFileSyncMock.mockReturnValueOnce(undefined)
|
||||
it('skips updating the local branch when it has diverged', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward'))
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
|
||||
expect(gitExecFileSyncMock.mock.calls).toEqual([
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[
|
||||
['merge-base', '--is-ancestor', 'main', 'origin/main'],
|
||||
expect.objectContaining({ cwd: '/repo' })
|
||||
|
|
@ -317,23 +315,23 @@ describe('addWorktree', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('uses the remote name from the base ref instead of hardcoding origin', () => {
|
||||
it('uses the remote name from the base ref instead of hardcoding origin', async () => {
|
||||
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
gitExecFileSyncMock
|
||||
.mockReturnValueOnce('') // merge-base --is-ancestor
|
||||
.mockReturnValueOnce(worktreeListOutput) // worktree list --porcelain
|
||||
.mockReturnValueOnce('') // status --porcelain
|
||||
.mockReturnValueOnce(undefined) // reset --hard
|
||||
.mockReturnValueOnce(undefined) // worktree add
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
|
||||
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
||||
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain
|
||||
.mockResolvedValueOnce({ stdout: '' }) // reset --hard
|
||||
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true)
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true)
|
||||
|
||||
expect(gitExecFileSyncMock.mock.calls[0]?.[0]).toEqual([
|
||||
expect(gitExecFileAsyncMock.mock.calls[0]?.[0]).toEqual([
|
||||
'merge-base',
|
||||
'--is-ancestor',
|
||||
'main',
|
||||
'upstream/main'
|
||||
])
|
||||
expect(gitExecFileSyncMock.mock.calls[3]?.[0]).toEqual(['reset', '--hard', 'upstream/main'])
|
||||
expect(gitExecFileAsyncMock.mock.calls[3]?.[0]).toEqual(['reset', '--hard', 'upstream/main'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { posix, win32 } from 'path'
|
||||
import type { GitWorktreeInfo } from '../../shared/types'
|
||||
import { gitExecFileAsync, gitExecFileSync, translateWslOutputPaths } from './runner'
|
||||
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
|
||||
|
||||
function normalizeLocalBranchRef(branch: string): string {
|
||||
return branch.replace(/^refs\/heads\//, '')
|
||||
|
|
@ -91,13 +91,13 @@ export async function listWorktrees(repoPath: string): Promise<GitWorktreeInfo[]
|
|||
* @param branch - Branch name for the new worktree
|
||||
* @param baseBranch - Optional base branch to create from (defaults to HEAD)
|
||||
*/
|
||||
export function addWorktree(
|
||||
export async function addWorktree(
|
||||
repoPath: string,
|
||||
worktreePath: string,
|
||||
branch: string,
|
||||
baseBranch?: string,
|
||||
refreshLocalBaseRef = false
|
||||
): void {
|
||||
): Promise<void> {
|
||||
// Why: Some users want Orca-created worktrees to make plain commands like
|
||||
// `git diff main...HEAD` work out of the box, while others do not want
|
||||
// worktree creation to mutate their local main/master ref at all. Keep this
|
||||
|
|
@ -113,16 +113,17 @@ export function addWorktree(
|
|||
// would silently destroy unpushed local commits if the branch has diverged from
|
||||
// remote. `merge-base --is-ancestor` returns exit 0 when localBranch is an
|
||||
// ancestor of baseBranch — i.e. the update is a safe fast-forward.
|
||||
gitExecFileSync(['merge-base', '--is-ancestor', localBranch, baseBranch], {
|
||||
await gitExecFileAsync(['merge-base', '--is-ancestor', localBranch, baseBranch], {
|
||||
cwd: repoPath
|
||||
})
|
||||
// Why: We need to find which worktree (if any) has localBranch checked
|
||||
// out, because moving the ref without updating that worktree's files would
|
||||
// leave it looking massively dirty. A sibling worktree we don't control is
|
||||
// just as vulnerable as the primary one.
|
||||
const worktreeListOutput = gitExecFileSync(['worktree', 'list', '--porcelain'], {
|
||||
cwd: repoPath
|
||||
})
|
||||
const { stdout: worktreeListOutput } = await gitExecFileAsync(
|
||||
['worktree', 'list', '--porcelain'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath))
|
||||
const fullRef = `refs/heads/${localBranch}`
|
||||
const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef)
|
||||
|
|
@ -131,16 +132,17 @@ export function addWorktree(
|
|||
// Why: localBranch is checked out in a worktree. We can only safely
|
||||
// update if that worktree is clean, and we must use `reset --hard`
|
||||
// (run inside that worktree) so the files move with the ref.
|
||||
const status = gitExecFileSync(['status', '--porcelain', '--untracked-files=no'], {
|
||||
cwd: ownerWorktree.path
|
||||
})
|
||||
const { stdout: status } = await gitExecFileAsync(
|
||||
['status', '--porcelain', '--untracked-files=no'],
|
||||
{ cwd: ownerWorktree.path }
|
||||
)
|
||||
if (!status.trim()) {
|
||||
gitExecFileSync(['reset', '--hard', baseBranch], { cwd: ownerWorktree.path })
|
||||
await gitExecFileAsync(['reset', '--hard', baseBranch], { cwd: ownerWorktree.path })
|
||||
}
|
||||
} else {
|
||||
// Why: localBranch is not checked out anywhere, so there is no working
|
||||
// tree to desync. `update-ref` is safe here.
|
||||
gitExecFileSync(['update-ref', fullRef, baseBranch], { cwd: repoPath })
|
||||
await gitExecFileAsync(['update-ref', fullRef, baseBranch], { cwd: repoPath })
|
||||
}
|
||||
} catch {
|
||||
// merge-base fails if the local branch doesn't exist or has diverged;
|
||||
|
|
@ -154,7 +156,7 @@ export function addWorktree(
|
|||
if (baseBranch) {
|
||||
args.push(baseBranch)
|
||||
}
|
||||
gitExecFileSync(args, { cwd: repoPath })
|
||||
await gitExecFileAsync(args, { cwd: repoPath })
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -128,14 +128,6 @@ app.whenReady().then(async () => {
|
|||
rateLimits.setCodexHomePathResolver(() => codexAccounts!.getSelectedManagedHomePath())
|
||||
runtime = new OrcaRuntimeService(store, stats)
|
||||
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
|
||||
try {
|
||||
await openCodeHookService.start()
|
||||
} catch (error) {
|
||||
// Why: OpenCode hooks only enrich status detection. Orca should still boot
|
||||
// even if the local loopback server cannot bind on this launch.
|
||||
console.error('[opencode] Failed to start local hook server:', error)
|
||||
}
|
||||
|
||||
registerAppMenu({
|
||||
onCheckForUpdates: () => checkForUpdatesFromMenu(),
|
||||
onOpenSettings: () => {
|
||||
|
|
@ -158,14 +150,18 @@ app.whenReady().then(async () => {
|
|||
runtime,
|
||||
userDataPath: app.getPath('userData')
|
||||
})
|
||||
try {
|
||||
await runtimeRpc.start()
|
||||
} catch (error) {
|
||||
// Why: the local RPC transport enables the future CLI, but Orca should
|
||||
// still boot as an editor if the socket cannot be opened on this launch.
|
||||
console.error('[runtime] Failed to start local RPC transport:', error)
|
||||
}
|
||||
const win = openMainWindow()
|
||||
|
||||
// Why: both server binds are independent and neither blocks window creation.
|
||||
// Parallelizing them with the window open shaves ~100-200ms off cold start.
|
||||
const [win] = await Promise.all([
|
||||
Promise.resolve(openMainWindow()),
|
||||
openCodeHookService.start().catch((error) => {
|
||||
console.error('[opencode] Failed to start local hook server:', error)
|
||||
}),
|
||||
runtimeRpc.start().catch((error) => {
|
||||
console.error('[runtime] Failed to start local RPC transport:', error)
|
||||
})
|
||||
])
|
||||
|
||||
// Why: the macOS notification permission dialog must fire after the window
|
||||
// is visible and focused. If it fires before the window exists, the system
|
||||
|
|
|
|||
|
|
@ -121,7 +121,6 @@ export function registerPtyHandlers(
|
|||
// Remove any previously registered handlers so we can re-register them
|
||||
// (e.g. when macOS re-activates the app and creates a new window).
|
||||
ipcMain.removeHandler('pty:spawn')
|
||||
ipcMain.removeHandler('pty:resize')
|
||||
ipcMain.removeHandler('pty:kill')
|
||||
ipcMain.removeHandler('pty:hasChildProcesses')
|
||||
ipcMain.removeHandler('pty:getForegroundProcess')
|
||||
|
|
@ -172,13 +171,46 @@ export function registerPtyHandlers(
|
|||
// Wire up provider events → renderer IPC
|
||||
localDataUnsub?.()
|
||||
localExitUnsub?.()
|
||||
|
||||
// Why: batching PTY data into short flush windows (8ms ≈ half a frame)
|
||||
// reduces IPC round-trips from hundreds/sec to ~120/sec under high
|
||||
// throughput, with no perceptible latency increase for interactive use.
|
||||
const pendingData = new Map<string, string>()
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const PTY_BATCH_INTERVAL_MS = 8
|
||||
|
||||
const flushPendingData = (): void => {
|
||||
flushTimer = null
|
||||
if (mainWindow.isDestroyed()) {
|
||||
pendingData.clear()
|
||||
return
|
||||
}
|
||||
for (const [id, data] of pendingData) {
|
||||
mainWindow.webContents.send('pty:data', { id, data })
|
||||
}
|
||||
pendingData.clear()
|
||||
}
|
||||
|
||||
localDataUnsub = localProvider.onData((payload) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('pty:data', payload)
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const existing = pendingData.get(payload.id)
|
||||
pendingData.set(payload.id, existing ? existing + payload.data : payload.data)
|
||||
if (!flushTimer) {
|
||||
flushTimer = setTimeout(flushPendingData, PTY_BATCH_INTERVAL_MS)
|
||||
}
|
||||
})
|
||||
localExitUnsub = localProvider.onExit((payload) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
// Why: flush any batched data for this PTY before sending the exit event,
|
||||
// otherwise the last ≤8ms of output is silently lost because the renderer
|
||||
// tears down the terminal on pty:exit before the batch timer fires.
|
||||
const remaining = pendingData.get(payload.id)
|
||||
if (remaining) {
|
||||
mainWindow.webContents.send('pty:data', { id: payload.id, data: remaining })
|
||||
pendingData.delete(payload.id)
|
||||
}
|
||||
mainWindow.webContents.send('pty:exit', payload)
|
||||
}
|
||||
})
|
||||
|
|
@ -258,7 +290,11 @@ export function registerPtyHandlers(
|
|||
getProviderForPty(args.id).write(args.id, args.data)
|
||||
})
|
||||
|
||||
ipcMain.handle('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
|
||||
// Why: resize is fire-and-forget — the renderer doesn't need a reply.
|
||||
// Using ipcMain.on (not .handle) halves IPC traffic by avoiding the
|
||||
// empty acknowledgement message back to the renderer.
|
||||
ipcMain.removeAllListeners('pty:resize')
|
||||
ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
|
||||
getProviderForPty(args.id).resize(args.id, args.cols, args.rows)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ export async function createLocalWorktree(
|
|||
// Fetch is best-effort — don't block worktree creation if offline
|
||||
}
|
||||
|
||||
addWorktree(
|
||||
await addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,7 @@ import { rm } from 'fs/promises'
|
|||
import type { Store } from '../persistence'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { deleteWorktreeHistoryDir } from '../terminal-history'
|
||||
import type {
|
||||
CreateWorktreeArgs,
|
||||
CreateWorktreeResult,
|
||||
Worktree,
|
||||
WorktreeMeta
|
||||
} from '../../shared/types'
|
||||
import type { CreateWorktreeArgs, CreateWorktreeResult, WorktreeMeta } from '../../shared/types'
|
||||
import { removeWorktree } from '../git/worktree'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
|
||||
|
|
@ -57,31 +52,36 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
|
|||
// itself calls listWorktrees for every repo below.
|
||||
await ensureAuthorizedRootsCache(store)
|
||||
const repos = store.getRepos()
|
||||
const allWorktrees: Worktree[] = []
|
||||
|
||||
for (const repo of repos) {
|
||||
let gitWorktrees
|
||||
if (isFolderRepo(repo)) {
|
||||
gitWorktrees = [createFolderWorktree(repo)]
|
||||
} else if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
// Why: when SSH is disconnected the provider is null. Skip this repo
|
||||
// so the renderer keeps its cached worktree list instead of clearing it.
|
||||
if (!provider) {
|
||||
continue
|
||||
// Why: repos are listed in parallel so total time = slowest repo, not
|
||||
// the sum of all repos. Each listRepoWorktrees spawns `git worktree list`.
|
||||
const results = await Promise.all(
|
||||
repos.map(async (repo) => {
|
||||
try {
|
||||
let gitWorktrees
|
||||
if (isFolderRepo(repo)) {
|
||||
gitWorktrees = [createFolderWorktree(repo)]
|
||||
} else if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
if (!provider) {
|
||||
return []
|
||||
}
|
||||
gitWorktrees = await provider.listWorktrees(repo.path)
|
||||
} else {
|
||||
gitWorktrees = await listRepoWorktrees(repo)
|
||||
}
|
||||
return gitWorktrees.map((gw) => {
|
||||
const worktreeId = `${repo.id}::${gw.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
return mergeWorktree(repo.id, gw, meta, repo.displayName)
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
gitWorktrees = await provider.listWorktrees(repo.path)
|
||||
} else {
|
||||
gitWorktrees = await listRepoWorktrees(repo)
|
||||
}
|
||||
for (const gw of gitWorktrees) {
|
||||
const worktreeId = `${repo.id}::${gw.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
allWorktrees.push(mergeWorktree(repo.id, gw, meta, repo.displayName))
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return allWorktrees
|
||||
return results.flat()
|
||||
})
|
||||
|
||||
ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => {
|
||||
|
|
|
|||
|
|
@ -355,7 +355,8 @@ describe('Store', () => {
|
|||
// The 300ms debounce hasn't elapsed yet
|
||||
|
||||
vi.advanceTimersByTime(300)
|
||||
// Now the save should have fired
|
||||
// The timer fired; wait for the async disk write to complete
|
||||
await store.waitForPendingWrite()
|
||||
|
||||
const persisted = readDataFile() as { repos: Repo[] }
|
||||
expect(persisted.repos).toHaveLength(1)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { app } from 'electron'
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'fs'
|
||||
import { writeFile, rename, mkdir, rm } from 'fs/promises'
|
||||
import { join, dirname } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import type { PersistedState, Repo, WorktreeMeta, GlobalSettings } from '../shared/types'
|
||||
|
|
@ -54,6 +55,8 @@ function normalizeSortBy(sortBy: unknown): 'name' | 'recent' | 'repo' {
|
|||
export class Store {
|
||||
private state: PersistedState
|
||||
private writeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private pendingWrite: Promise<void> | null = null
|
||||
private writeGeneration = 0
|
||||
private gitUsernameCache = new Map<string, string>()
|
||||
|
||||
constructor() {
|
||||
|
|
@ -100,25 +103,50 @@ export class Store {
|
|||
}
|
||||
this.writeTimer = setTimeout(() => {
|
||||
this.writeTimer = null
|
||||
try {
|
||||
this.writeToDisk()
|
||||
} catch (err) {
|
||||
console.error('[persistence] Failed to write state:', err)
|
||||
}
|
||||
this.pendingWrite = this.writeToDiskAsync()
|
||||
.catch((err) => {
|
||||
console.error('[persistence] Failed to write state:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingWrite = null
|
||||
})
|
||||
}, 300)
|
||||
}
|
||||
|
||||
private writeToDisk(): void {
|
||||
/** Wait for any in-flight async disk write to complete. Used in tests. */
|
||||
async waitForPendingWrite(): Promise<void> {
|
||||
if (this.pendingWrite) {
|
||||
await this.pendingWrite
|
||||
}
|
||||
}
|
||||
|
||||
// Why: async writes avoid blocking the main Electron thread on every
|
||||
// debounced save (every 300ms during active use).
|
||||
private async writeToDiskAsync(): Promise<void> {
|
||||
const gen = this.writeGeneration
|
||||
const dataFile = getDataFile()
|
||||
const dir = dirname(dataFile)
|
||||
await mkdir(dir, { recursive: true }).catch(() => {})
|
||||
const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
||||
await writeFile(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
|
||||
// Why: if flush() ran while this async write was in-flight, it bumped
|
||||
// writeGeneration and already wrote the latest state synchronously.
|
||||
// Renaming this stale tmp file would overwrite the fresh data.
|
||||
if (this.writeGeneration !== gen) {
|
||||
await rm(tmpFile).catch(() => {})
|
||||
return
|
||||
}
|
||||
await rename(tmpFile, dataFile)
|
||||
}
|
||||
|
||||
// Why: synchronous variant kept only for flush() at shutdown, where the
|
||||
// process may exit before an async write completes.
|
||||
private writeToDiskSync(): void {
|
||||
const dataFile = getDataFile()
|
||||
const dir = dirname(dataFile)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
// Why: synchronous flushes can race the debounced writer during shutdown or
|
||||
// beforeunload persistence. A shared `.tmp` path lets one rename steal the
|
||||
// temp file from the other, which surfaces as ENOENT even though the final
|
||||
// state may already be on disk. Use a unique temp file per write so atomic
|
||||
// replaces remain race-safe across platforms.
|
||||
const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
||||
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
|
||||
renameSync(tmpFile, dataFile)
|
||||
|
|
@ -323,8 +351,12 @@ export class Store {
|
|||
clearTimeout(this.writeTimer)
|
||||
this.writeTimer = null
|
||||
}
|
||||
// Why: bump writeGeneration so any in-flight async writeToDiskAsync skips
|
||||
// its rename, preventing a stale snapshot from overwriting this sync write.
|
||||
this.writeGeneration++
|
||||
this.pendingWrite = null
|
||||
try {
|
||||
this.writeToDisk()
|
||||
this.writeToDiskSync()
|
||||
} catch (err) {
|
||||
console.error('[persistence] Failed to flush state:', err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -627,7 +627,7 @@ export class OrcaRuntimeService {
|
|||
// Why: matching the editor behavior keeps CLI creation usable offline.
|
||||
}
|
||||
|
||||
addWorktree(
|
||||
await addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ const api = {
|
|||
},
|
||||
|
||||
resize: (id: string, cols: number, rows: number): void => {
|
||||
ipcRenderer.invoke('pty:resize', { id, cols, rows })
|
||||
ipcRenderer.send('pty:resize', { id, cols, rows })
|
||||
},
|
||||
|
||||
kill: (id: string): Promise<void> => ipcRenderer.invoke('pty:kill', { id }),
|
||||
|
|
|
|||
|
|
@ -61,10 +61,36 @@ function isEditableTarget(target: EventTarget | null): boolean {
|
|||
}
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
const toggleSidebar = useAppStore((s) => s.toggleSidebar)
|
||||
// Why: Zustand actions are referentially stable, but each individual
|
||||
// useAppStore(s => s.someAction) still registers a subscription that React
|
||||
// must check on every store mutation. Consolidating 19 action refs into one
|
||||
// useShallow subscription means one equality check instead of 19.
|
||||
const actions = useAppStore(
|
||||
useShallow((s) => ({
|
||||
toggleSidebar: s.toggleSidebar,
|
||||
fetchRepos: s.fetchRepos,
|
||||
fetchAllWorktrees: s.fetchAllWorktrees,
|
||||
fetchSettings: s.fetchSettings,
|
||||
initGitHubCache: s.initGitHubCache,
|
||||
refreshAllGitHub: s.refreshAllGitHub,
|
||||
hydrateWorkspaceSession: s.hydrateWorkspaceSession,
|
||||
hydrateEditorSession: s.hydrateEditorSession,
|
||||
hydrateBrowserSession: s.hydrateBrowserSession,
|
||||
fetchBrowserSessionProfiles: s.fetchBrowserSessionProfiles,
|
||||
fetchDetectedBrowsers: s.fetchDetectedBrowsers,
|
||||
reconnectPersistedTerminals: s.reconnectPersistedTerminals,
|
||||
hydratePersistedUI: s.hydratePersistedUI,
|
||||
openModal: s.openModal,
|
||||
closeModal: s.closeModal,
|
||||
toggleRightSidebar: s.toggleRightSidebar,
|
||||
setRightSidebarOpen: s.setRightSidebarOpen,
|
||||
setRightSidebarTab: s.setRightSidebarTab,
|
||||
updateSettings: s.updateSettings
|
||||
}))
|
||||
)
|
||||
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const activeRepoId = useAppStore((s) => s.activeRepoId)
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const activeTabId = useAppStore((s) => s.activeTabId)
|
||||
const activeAgentCount = useAppStore((s) =>
|
||||
|
|
@ -86,21 +112,7 @@ function App(): React.JSX.Element {
|
|||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
|
||||
const canExpandPaneByTabId = useAppStore((s) => s.canExpandPaneByTabId)
|
||||
const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
|
||||
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
|
||||
const fetchRepos = useAppStore((s) => s.fetchRepos)
|
||||
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
|
||||
const fetchSettings = useAppStore((s) => s.fetchSettings)
|
||||
const initGitHubCache = useAppStore((s) => s.initGitHubCache)
|
||||
const refreshAllGitHub = useAppStore((s) => s.refreshAllGitHub)
|
||||
const hydrateWorkspaceSession = useAppStore((s) => s.hydrateWorkspaceSession)
|
||||
const hydrateEditorSession = useAppStore((s) => s.hydrateEditorSession)
|
||||
const hydrateBrowserSession = useAppStore((s) => s.hydrateBrowserSession)
|
||||
const fetchBrowserSessionProfiles = useAppStore((s) => s.fetchBrowserSessionProfiles)
|
||||
const fetchDetectedBrowsers = useAppStore((s) => s.fetchDetectedBrowsers)
|
||||
const reconnectPersistedTerminals = useAppStore((s) => s.reconnectPersistedTerminals)
|
||||
const hydratePersistedUI = useAppStore((s) => s.hydratePersistedUI)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const sidebarWidth = useAppStore((s) => s.sidebarWidth)
|
||||
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
|
||||
|
|
@ -109,27 +121,10 @@ function App(): React.JSX.Element {
|
|||
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
|
||||
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
|
||||
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
|
||||
|
||||
// Editor state for session persistence
|
||||
const openFiles = useAppStore((s) => s.openFiles)
|
||||
const activeFileIdByWorktree = useAppStore((s) => s.activeFileIdByWorktree)
|
||||
const activeTabTypeByWorktree = useAppStore((s) => s.activeTabTypeByWorktree)
|
||||
const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree)
|
||||
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
|
||||
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
|
||||
const activeBrowserTabIdByWorktree = useAppStore((s) => s.activeBrowserTabIdByWorktree)
|
||||
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
|
||||
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
|
||||
const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree)
|
||||
|
||||
// Right sidebar + editor state
|
||||
const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar)
|
||||
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
|
||||
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
|
||||
const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen)
|
||||
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
const isFullScreen = useAppStore((s) => s.isFullScreen)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
|
||||
// Subscribe to IPC push events
|
||||
useIpcEvents()
|
||||
|
|
@ -140,9 +135,6 @@ function App(): React.JSX.Element {
|
|||
useGitStatusPolling()
|
||||
useGlobalFileDrop()
|
||||
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
|
||||
// Fetch initial data + hydrate GitHub cache from disk
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -153,24 +145,24 @@ function App(): React.JSX.Element {
|
|||
|
||||
void (async () => {
|
||||
try {
|
||||
await fetchRepos()
|
||||
await fetchAllWorktrees()
|
||||
await actions.fetchRepos()
|
||||
await actions.fetchAllWorktrees()
|
||||
const persistedUI = await window.api.ui.get()
|
||||
const session = await window.api.session.get()
|
||||
if (!cancelled) {
|
||||
hydratePersistedUI(persistedUI)
|
||||
hydrateWorkspaceSession(session)
|
||||
hydrateEditorSession(session)
|
||||
hydrateBrowserSession(session)
|
||||
await fetchBrowserSessionProfiles()
|
||||
await fetchDetectedBrowsers()
|
||||
await reconnectPersistedTerminals(abortController.signal)
|
||||
actions.hydratePersistedUI(persistedUI)
|
||||
actions.hydrateWorkspaceSession(session)
|
||||
actions.hydrateEditorSession(session)
|
||||
actions.hydrateBrowserSession(session)
|
||||
await actions.fetchBrowserSessionProfiles()
|
||||
await actions.fetchDetectedBrowsers()
|
||||
await actions.reconnectPersistedTerminals(abortController.signal)
|
||||
syncZoomCSSVar()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to hydrate workspace session:', error)
|
||||
if (!cancelled) {
|
||||
hydratePersistedUI({
|
||||
actions.hydratePersistedUI({
|
||||
lastActiveRepoId: null,
|
||||
lastActiveWorktreeId: null,
|
||||
sidebarWidth: 280,
|
||||
|
|
@ -187,7 +179,7 @@ function App(): React.JSX.Element {
|
|||
dismissedUpdateVersion: null,
|
||||
lastUpdateCheckAt: null
|
||||
})
|
||||
hydrateWorkspaceSession({
|
||||
actions.hydrateWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
|
|
@ -197,30 +189,18 @@ function App(): React.JSX.Element {
|
|||
// Why: hydrateWorkspaceSession no longer sets workspaceSessionReady.
|
||||
// The error path has no worktrees to reconnect, but must still flip
|
||||
// the flag so auto-tab-creation and session writes are unblocked.
|
||||
await reconnectPersistedTerminals()
|
||||
await actions.reconnectPersistedTerminals()
|
||||
}
|
||||
}
|
||||
void fetchSettings()
|
||||
void initGitHubCache()
|
||||
void actions.fetchSettings()
|
||||
void actions.initGitHubCache()
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
abortController.abort()
|
||||
}
|
||||
}, [
|
||||
fetchRepos,
|
||||
fetchAllWorktrees,
|
||||
fetchSettings,
|
||||
initGitHubCache,
|
||||
hydratePersistedUI,
|
||||
hydrateWorkspaceSession,
|
||||
hydrateEditorSession,
|
||||
hydrateBrowserSession,
|
||||
fetchBrowserSessionProfiles,
|
||||
fetchDetectedBrowsers,
|
||||
reconnectPersistedTerminals
|
||||
])
|
||||
}, [actions])
|
||||
|
||||
useEffect(() => {
|
||||
setRuntimeGraphStoreStateGetter(useAppStore.getState)
|
||||
|
|
@ -238,51 +218,30 @@ function App(): React.JSX.Element {
|
|||
}
|
||||
}, [workspaceSessionReady])
|
||||
|
||||
// Why: session persistence never drives JSX — it only writes to disk.
|
||||
// Using a Zustand subscribe() outside React removes ~15 subscriptions from
|
||||
// App's render cycle, eliminating re-renders on every tab/file/browser change.
|
||||
useEffect(() => {
|
||||
if (!workspaceSessionReady) {
|
||||
return
|
||||
let timer: number | null = null
|
||||
const unsub = useAppStore.subscribe((state) => {
|
||||
if (!state.workspaceSessionReady) {
|
||||
return
|
||||
}
|
||||
if (timer) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null
|
||||
void window.api.session.set(buildWorkspaceSessionPayload(state))
|
||||
}, 150)
|
||||
})
|
||||
return () => {
|
||||
unsub()
|
||||
if (timer) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
void window.api.session.set(
|
||||
buildWorkspaceSessionPayload({
|
||||
activeRepoId,
|
||||
activeWorktreeId,
|
||||
activeTabId,
|
||||
tabsByWorktree,
|
||||
terminalLayoutsByTabId,
|
||||
activeTabIdByWorktree,
|
||||
openFiles,
|
||||
activeFileIdByWorktree,
|
||||
activeTabTypeByWorktree,
|
||||
browserTabsByWorktree,
|
||||
browserPagesByWorkspace,
|
||||
activeBrowserTabIdByWorktree,
|
||||
unifiedTabsByWorktree,
|
||||
groupsByWorktree,
|
||||
activeGroupIdByWorktree
|
||||
})
|
||||
)
|
||||
}, 150)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [
|
||||
workspaceSessionReady,
|
||||
activeRepoId,
|
||||
activeWorktreeId,
|
||||
activeTabId,
|
||||
tabsByWorktree,
|
||||
terminalLayoutsByTabId,
|
||||
openFiles,
|
||||
activeFileIdByWorktree,
|
||||
activeTabTypeByWorktree,
|
||||
activeTabIdByWorktree,
|
||||
browserTabsByWorktree,
|
||||
browserPagesByWorkspace,
|
||||
activeBrowserTabIdByWorktree,
|
||||
unifiedTabsByWorktree,
|
||||
groupsByWorktree,
|
||||
activeGroupIdByWorktree
|
||||
])
|
||||
}, [])
|
||||
|
||||
// On shutdown, capture terminal scrollback buffers and flush to disk.
|
||||
// Runs synchronously in beforeunload: capture → Zustand set → sendSync → flush.
|
||||
|
|
@ -397,12 +356,12 @@ function App(): React.JSX.Element {
|
|||
useEffect(() => {
|
||||
const handler = (): void => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
refreshAllGitHub()
|
||||
actions.refreshAllGitHub()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', handler)
|
||||
return () => document.removeEventListener('visibilitychange', handler)
|
||||
}, [refreshAllGitHub])
|
||||
}, [actions])
|
||||
|
||||
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
|
||||
const hasTabBar = tabs.length >= 2
|
||||
|
|
@ -462,14 +421,14 @@ function App(): React.JSX.Element {
|
|||
// Cmd/Ctrl+B — toggle left sidebar
|
||||
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'b') {
|
||||
e.preventDefault()
|
||||
toggleSidebar()
|
||||
actions.toggleSidebar()
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+L — toggle right sidebar
|
||||
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'l') {
|
||||
e.preventDefault()
|
||||
toggleRightSidebar()
|
||||
actions.toggleRightSidebar()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -479,23 +438,23 @@ function App(): React.JSX.Element {
|
|||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
openModal('create-worktree')
|
||||
actions.openModal('create-worktree')
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab
|
||||
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'e') {
|
||||
e.preventDefault()
|
||||
setRightSidebarTab('explorer')
|
||||
setRightSidebarOpen(true)
|
||||
actions.setRightSidebarTab('explorer')
|
||||
actions.setRightSidebarOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+F — toggle right sidebar / search tab
|
||||
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault()
|
||||
setRightSidebarTab('search')
|
||||
setRightSidebarOpen(true)
|
||||
actions.setRightSidebarTab('search')
|
||||
actions.setRightSidebarOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -509,24 +468,14 @@ function App(): React.JSX.Element {
|
|||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
setRightSidebarTab('source-control')
|
||||
setRightSidebarOpen(true)
|
||||
actions.setRightSidebarTab('source-control')
|
||||
actions.setRightSidebarOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [
|
||||
activeView,
|
||||
activeWorktreeId,
|
||||
openModal,
|
||||
closeModal,
|
||||
repos,
|
||||
toggleSidebar,
|
||||
toggleRightSidebar,
|
||||
setRightSidebarTab,
|
||||
setRightSidebarOpen
|
||||
])
|
||||
}, [activeView, activeWorktreeId, actions, repos])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen overflow-hidden">
|
||||
|
|
@ -549,7 +498,7 @@ function App(): React.JSX.Element {
|
|||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="sidebar-toggle"
|
||||
onClick={toggleSidebar}
|
||||
onClick={actions.toggleSidebar}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<PanelLeft size={16} />
|
||||
|
|
@ -658,7 +607,7 @@ function App(): React.JSX.Element {
|
|||
<button
|
||||
className="titlebar-agent-hovercard-hide"
|
||||
onClick={() => {
|
||||
void updateSettings({ showTitlebarAgentActivity: false })
|
||||
void actions.updateSettings({ showTitlebarAgentActivity: false })
|
||||
toast('Agent activity badge hidden', {
|
||||
description: 'You can turn it back on in Settings → Appearance.',
|
||||
duration: Infinity,
|
||||
|
|
@ -703,7 +652,7 @@ function App(): React.JSX.Element {
|
|||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="sidebar-toggle mr-2"
|
||||
onClick={toggleRightSidebar}
|
||||
onClick={actions.toggleRightSidebar}
|
||||
aria-label="Toggle right sidebar"
|
||||
>
|
||||
<PanelRight size={16} />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable max-lines */
|
||||
|
||||
import { useEffect, useCallback, useRef, useState, lazy, Suspense } from 'react'
|
||||
import React, { useEffect, useCallback, useRef, useState, lazy, Suspense } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
|
||||
import { useAppStore } from '../store'
|
||||
|
|
@ -30,7 +30,7 @@ import { reconcileTabOrder } from './tab-bar/reconcile-order'
|
|||
|
||||
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
|
||||
|
||||
export default function Terminal(): React.JSX.Element | null {
|
||||
function Terminal(): React.JSX.Element | null {
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
|
|
@ -716,7 +716,12 @@ export default function Terminal(): React.JSX.Element | null {
|
|||
// and destroys orphaned webview elements to prevent memory leaks.
|
||||
const prevBrowserTabIdsRef = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
let prevBrowserTabs = useAppStore.getState().browserTabsByWorktree
|
||||
return useAppStore.subscribe((state) => {
|
||||
if (state.browserTabsByWorktree === prevBrowserTabs) {
|
||||
return
|
||||
}
|
||||
prevBrowserTabs = state.browserTabsByWorktree
|
||||
const currentIds = new Set(
|
||||
Object.values(state.browserTabsByWorktree)
|
||||
.flat()
|
||||
|
|
@ -1023,3 +1028,5 @@ export default function Terminal(): React.JSX.Element | null {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Terminal)
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ type FileContent = {
|
|||
|
||||
type DiffContent = GitDiffResult
|
||||
|
||||
export default function EditorPanel({
|
||||
function EditorPanelInner({
|
||||
activeFileId: activeFileIdProp
|
||||
}: {
|
||||
activeFileId?: string | null
|
||||
|
|
@ -761,3 +761,5 @@ export default function EditorPanel({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(EditorPanelInner)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { useFileExplorerImport } from './useFileExplorerImport'
|
|||
import { useFileExplorerTree } from './useFileExplorerTree'
|
||||
import { useFileExplorerWatch } from './useFileExplorerWatch'
|
||||
|
||||
export default function FileExplorer(): React.JSX.Element {
|
||||
function FileExplorerInner(): React.JSX.Element {
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
const expandedDirs = useAppStore((s) => s.expandedDirs)
|
||||
|
|
@ -433,3 +433,5 @@ export default function FileExplorer(): React.JSX.Element {
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FileExplorerInner)
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ const CONFLICT_KIND_LABELS: Record<GitConflictKind, string> = {
|
|||
both_deleted: 'Both deleted'
|
||||
}
|
||||
|
||||
export default function SourceControl(): React.JSX.Element {
|
||||
function SourceControlInner(): React.JSX.Element {
|
||||
const sourceControlRef = useRef<HTMLDivElement>(null)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
|
||||
|
|
@ -857,6 +857,9 @@ export default function SourceControl(): React.JSX.Element {
|
|||
)
|
||||
}
|
||||
|
||||
const SourceControl = React.memo(SourceControlInner)
|
||||
export default SourceControl
|
||||
|
||||
function CompareSummary({
|
||||
summary,
|
||||
onChangeBaseRef,
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [
|
|||
}
|
||||
]
|
||||
|
||||
export default function RightSidebar(): React.JSX.Element {
|
||||
function RightSidebarInner(): React.JSX.Element {
|
||||
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
|
||||
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
|
||||
const setRightSidebarWidth = useAppStore((s) => s.setRightSidebarWidth)
|
||||
|
|
@ -246,6 +246,9 @@ export default function RightSidebar(): React.JSX.Element {
|
|||
)
|
||||
}
|
||||
|
||||
const RightSidebar = React.memo(RightSidebarInner)
|
||||
export default RightSidebar
|
||||
|
||||
// ─── Status indicator dot color mapping ──────
|
||||
const STATUS_DOT_COLOR: Record<CheckStatus, string> = {
|
||||
success: 'bg-emerald-500',
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import AddRepoDialog from './AddRepoDialog'
|
|||
const MIN_WIDTH = 220
|
||||
const MAX_WIDTH = 500
|
||||
|
||||
export default function Sidebar(): React.JSX.Element {
|
||||
function Sidebar(): React.JSX.Element {
|
||||
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
|
||||
const sidebarWidth = useAppStore((s) => s.sidebarWidth)
|
||||
const setSidebarWidth = useAppStore((s) => s.setSidebarWidth)
|
||||
|
|
@ -82,3 +82,5 @@ export default function Sidebar(): React.JSX.Element {
|
|||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Sidebar)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
interaction menus, and compact-layout behavior together so the hover/click
|
||||
states stay consistent across Claude and Codex. */
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
ContextMenu,
|
||||
|
|
@ -409,7 +409,7 @@ function ProviderDetailsMenu({
|
|||
// StatusBar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function StatusBar(): React.JSX.Element | null {
|
||||
function StatusBarInner(): React.JSX.Element | null {
|
||||
const rateLimits = useAppStore((s) => s.rateLimits)
|
||||
const refreshRateLimits = useAppStore((s) => s.refreshRateLimits)
|
||||
const statusBarVisible = useAppStore((s) => s.statusBarVisible)
|
||||
|
|
@ -541,3 +541,5 @@ export function StatusBar(): React.JSX.Element | null {
|
|||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const StatusBar = React.memo(StatusBarInner)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
|
|
@ -70,7 +70,7 @@ type TabItem =
|
|||
| { type: 'editor'; id: string; data: OpenFile & { tabId?: string } }
|
||||
| { type: 'browser'; id: string; data: BrowserTabState }
|
||||
|
||||
export default function TabBar({
|
||||
function TabBarInner({
|
||||
tabs,
|
||||
activeTabId,
|
||||
worktreeId,
|
||||
|
|
@ -356,3 +356,5 @@ export default function TabBar({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TabBarInner)
|
||||
|
|
|
|||
|
|
@ -87,6 +87,11 @@ function attachDividerDrag(
|
|||
nextFlex = nextSize
|
||||
}
|
||||
|
||||
// Why: fitAddon.fit() triggers a full xterm.js reflow which can take
|
||||
// hundreds of ms with large scrollbacks. Gating behind rAF caps refit
|
||||
// to once per paint frame instead of once per pointer event (~250Hz).
|
||||
let refitRafId: number | null = null
|
||||
|
||||
const onPointerMove = (e: PointerEvent): void => {
|
||||
if (!dragging || !prevEl || !nextEl) {
|
||||
return
|
||||
|
|
@ -113,9 +118,16 @@ function attachDividerDrag(
|
|||
prevEl.style.flex = `${newPrev} 1 0%`
|
||||
nextEl.style.flex = `${newNext} 1 0%`
|
||||
|
||||
// Refit terminals in affected panes
|
||||
callbacks.refitPanesUnder(prevEl)
|
||||
callbacks.refitPanesUnder(nextEl)
|
||||
// Refit terminals in affected panes (throttled to one per animation frame)
|
||||
if (refitRafId === null) {
|
||||
const p = prevEl
|
||||
const n = nextEl
|
||||
refitRafId = requestAnimationFrame(() => {
|
||||
refitRafId = null
|
||||
callbacks.refitPanesUnder(p)
|
||||
callbacks.refitPanesUnder(n)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerUp = (e: PointerEvent): void => {
|
||||
|
|
@ -123,8 +135,19 @@ function attachDividerDrag(
|
|||
return
|
||||
}
|
||||
dragging = false
|
||||
if (refitRafId !== null) {
|
||||
cancelAnimationFrame(refitRafId)
|
||||
refitRafId = null
|
||||
}
|
||||
divider.releasePointerCapture(e.pointerId)
|
||||
divider.classList.remove('is-dragging')
|
||||
// Final refit at the exact drop position
|
||||
if (prevEl) {
|
||||
callbacks.refitPanesUnder(prevEl)
|
||||
}
|
||||
if (nextEl) {
|
||||
callbacks.refitPanesUnder(nextEl)
|
||||
}
|
||||
prevEl = null
|
||||
nextEl = null
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export type GitHubSlice = {
|
|||
initGitHubCache: () => Promise<void>
|
||||
refreshAllGitHub: () => void
|
||||
refreshGitHubForWorktree: (worktreeId: string) => void
|
||||
refreshGitHubForWorktreeIfStale: (worktreeId: string) => void
|
||||
}
|
||||
|
||||
export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (set, get) => ({
|
||||
|
|
@ -392,5 +393,45 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
if (worktree.linkedIssue) {
|
||||
void get().fetchIssue(repo.path, worktree.linkedIssue)
|
||||
}
|
||||
},
|
||||
|
||||
// Why: worktree switches previously force-refreshed GitHub data on every
|
||||
// click, bypassing the 5-min TTL. This variant only fetches when stale,
|
||||
// avoiding unnecessary API calls and latency during rapid switching.
|
||||
refreshGitHubForWorktreeIfStale: (worktreeId) => {
|
||||
const state = get()
|
||||
let worktree: Worktree | undefined
|
||||
for (const worktrees of Object.values(state.worktreesByRepo)) {
|
||||
worktree = worktrees.find((w) => w.id === worktreeId)
|
||||
if (worktree) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!worktree) {
|
||||
return
|
||||
}
|
||||
|
||||
const repo = state.repos.find((r) => r.id === worktree.repoId)
|
||||
if (!repo) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const branch = worktree.branch.replace(/^refs\/heads\//, '')
|
||||
const prKey = `${repo.path}::${branch}`
|
||||
const prEntry = state.prCache[prKey]
|
||||
const prStale = !prEntry || now - prEntry.fetchedAt >= CACHE_TTL
|
||||
|
||||
if (!worktree.isBare && branch && prStale) {
|
||||
void get().fetchPRForBranch(repo.path, branch, { force: true })
|
||||
}
|
||||
|
||||
if (worktree.linkedIssue) {
|
||||
const issueKey = `${repo.path}::${worktree.linkedIssue}`
|
||||
const issueEntry = state.issueCache[issueKey]
|
||||
if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) {
|
||||
void get().fetchIssue(repo.path, worktree.linkedIssue)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -310,7 +310,8 @@ describe('setActiveWorktree', () => {
|
|||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', sortOrder: 123, isUnread: false })]
|
||||
},
|
||||
refreshGitHubForWorktree: vi.fn()
|
||||
refreshGitHubForWorktree: vi.fn(),
|
||||
refreshGitHubForWorktreeIfStale: vi.fn()
|
||||
})
|
||||
|
||||
store.getState().setActiveWorktree(worktreeId)
|
||||
|
|
@ -569,7 +570,8 @@ describe('setActiveWorktree', () => {
|
|||
activeFileIdByWorktree: { [wt]: fileId },
|
||||
// User was on the terminal, not the editor
|
||||
activeTabTypeByWorktree: { [wt]: 'terminal' },
|
||||
refreshGitHubForWorktree: vi.fn()
|
||||
refreshGitHubForWorktree: vi.fn(),
|
||||
refreshGitHubForWorktreeIfStale: vi.fn()
|
||||
})
|
||||
|
||||
store.getState().setActiveWorktree(wt)
|
||||
|
|
|
|||
|
|
@ -475,10 +475,11 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
}
|
||||
}
|
||||
|
||||
// Refresh GitHub data (PR + issue status) on every explicit worktree selection.
|
||||
// Re-selecting the active worktree is a user-driven refresh path for stale PR state.
|
||||
// Why: force-refreshing GitHub data on every switch burned API rate limit
|
||||
// quota and added 200-800ms latency. Only refresh when cache is actually
|
||||
// stale (>5 min old). Users can still force-refresh via the sidebar button.
|
||||
if (worktreeId) {
|
||||
get().refreshGitHubForWorktree(worktreeId)
|
||||
get().refreshGitHubForWorktreeIfStale(worktreeId)
|
||||
}
|
||||
|
||||
if (!worktreeId || !findWorktreeById(get().worktreesByRepo, worktreeId)) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue