From 696590918ccb54e179014e1b6a215928c235dc49 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:37:23 -0700 Subject: [PATCH] feat: Quick Jump to Worktree palette (Cmd+J) (#469) * wip in the design doc * fix: use Ctrl+Shift+J for worktree palette on non-darwin platforms To avoid colliding with Ctrl+J (Line Feed) on Windows/Linux, we now use Ctrl+Shift+J for the worktree jump palette on those platforms, leaving Cmd+J for macOS. * refactor: migrate QuickOpen to cmdk and unify overlay state - Migrated `QuickOpen.tsx` to use `cmdk` (`CommandDialog`) for visual and behavioral consistency with the new worktree jump palette, while keeping the existing custom fuzzy match algorithm. - Unified the overlay state systems (`activeModal`, `quickOpenVisible`, `worktreePaletteVisible`) into a single `activeModal` union type. - This automatically handles mutual exclusion without boilerplate toggle logic spread across components. * fix: forward QuickOpen and worktree shortcuts from browser guests Added main-process interceptors for `Cmd/Ctrl+P` and `Cmd/Ctrl+1-9` so that QuickOpen and numeric worktree jumping continue to work even when an embedded browser guest (webview) has keyboard focus. * fix: address review findings - Set spawnEnv.SHELL before pty.spawn() in the fallback loop so the child process inherits the correct SHELL value instead of the stale original. - Remove dead Cmd+P and Cmd+1-9 renderer keydown handlers from App.tsx; these are now handled via IPC from createMainWindow.ts before-input-event (the IPC handlers in useIpcEvents.ts have the same view-state guards). --- docs/quickly-jump-to-worktree.md | 278 ++++++++++ package.json | 1 + pnpm-lock.yaml | 21 + src/main/ipc/pty.test.ts | 114 ++++ src/main/ipc/pty.ts | 208 +++++--- src/main/menu/register-app-menu.test.ts | 13 + src/main/menu/register-app-menu.ts | 9 + src/main/window/createMainWindow.test.ts | 50 ++ src/main/window/createMainWindow.ts | 22 + src/preload/api-types.d.ts | 3 + src/preload/index.ts | 15 + src/renderer/src/App.tsx | 55 +- src/renderer/src/components/QuickOpen.tsx | 186 +++---- .../src/components/WorktreeJumpPalette.tsx | 491 ++++++++++++++++++ .../src/components/settings/ShortcutsPane.tsx | 5 + .../src/components/sidebar/AddRepoDialog.tsx | 16 +- .../components/sidebar/AddWorktreeDialog.tsx | 35 +- .../src/components/sidebar/smart-sort.ts | 38 +- .../components/sidebar/visible-worktrees.ts | 30 +- .../sidebar/worktree-list-groups.ts | 5 +- src/renderer/src/components/ui/command.tsx | 155 ++++++ src/renderer/src/hooks/useIpcEvents.ts | 36 +- src/renderer/src/lib/git-utils.ts | 4 + src/renderer/src/lib/worktree-activation.ts | 64 +++ src/renderer/src/store/slices/editor.ts | 8 - src/renderer/src/store/slices/ui.ts | 2 + 26 files changed, 1546 insertions(+), 318 deletions(-) create mode 100644 docs/quickly-jump-to-worktree.md create mode 100644 src/renderer/src/components/WorktreeJumpPalette.tsx create mode 100644 src/renderer/src/components/ui/command.tsx create mode 100644 src/renderer/src/lib/git-utils.ts diff --git a/docs/quickly-jump-to-worktree.md b/docs/quickly-jump-to-worktree.md new file mode 100644 index 00000000..74f515c8 --- /dev/null +++ b/docs/quickly-jump-to-worktree.md @@ -0,0 +1,278 @@ +# Design Document: Quick Jump to Worktree (Issue #426) + +## 1. Overview + +As Orca scales to support multiple parallel agents and tasks, users frequently need to switch between dozens of active worktrees. Navigating via the sidebar becomes inefficient at scale. + +This document outlines the design for a "Quick Jump to Worktree" feature: a globally accessible Command Palette-style dialog that allows users to search across all their active worktrees by name, repository, comment, PR metadata, and issue metadata, and jump to them instantly. This feature is intended to be the central, beating heart of navigation within Orca. + +## 2. User Experience (UX) + +### 2.1 The Shortcut: `Cmd+J` (macOS) / `Ctrl+Shift+J` (Windows/Linux) + +To establish this palette as the central "Switch Worktree" action in Orca, `**Cmd+J**` (macOS) and `**Ctrl+Shift+J**` (Windows/Linux) are the chosen shortcuts. + +**Why `Cmd+J` / `Ctrl+Shift+J`?** + +- **Matches the action honestly:** This palette switches between existing worktrees. "Jump" is a better semantic fit than "Open" because the user is navigating, not creating a new file-open flow. +- **Avoids `Ctrl+J` (Line Feed) conflict:** On Windows and Linux, `Ctrl+J` translates to a Line Feed (`\n`) in bash, zsh, and almost all readline-based CLI applications. For many terminal power users, `Ctrl+J` and `Ctrl+M` (Carriage Return) are used interchangeably with the physical `Enter` key to execute commands. In Vim, it is used for navigation or inserting newlines, and in Emacs it maps to `newline-and-indent`. Intercepting `Ctrl+J` globally would severely disrupt core terminal workflows. Thus, `Ctrl+Shift+J` is used on these platforms. (On macOS, `Cmd` is an OS-level modifier, so `Cmd+J` safely avoids this issue). +- **Avoids `Cmd+K` conflict:** In terminal-heavy apps, `Cmd+K` is universally expected to "Clear Terminal". Overriding it breaks developer muscle memory. +- **Avoids `Cmd+P` conflict:** `Cmd+P` is already in use for Quick Open File (`QuickOpen.tsx`). +- **Avoids `Ctrl+E` (readline):** `Ctrl+E` is "end of line" in bash/zsh readline. Stealing it in a terminal-heavy app would break shell navigation muscle memory — the same class of conflict that rules out `Cmd+K`. +- **Discoverability:** The shortcut should be registered in the Electron Application Menu (e.g., `View -> Open Worktree Palette`) so users can discover it visually. + +### 2.2 The Interface + +When the shortcut is pressed, a modal dialog appears at the center top of the screen (similar to VS Code's palette or Spotlight). + +- **Input:** A text input focused automatically. +- **List:** A scrollable list of worktrees, constrained to `max-h-[min(400px,60vh)]` to prevent the palette from overflowing the viewport when many worktrees are present. +- **Default state (empty query):** When the palette opens with no query, the full list of non-archived worktrees is shown in recent-sort order. The data source is `worktreesByRepo`, filtered by `!w.isArchived` (same filter applied by `computeVisibleWorktreeIds` in `visible-worktrees.ts`). The palette intentionally ignores the sidebar's `showActiveOnly` and `filterRepoIds` filters — it is a global jump tool, not a filtered view. No truncation — the list is scrollable and the expected count (<200) does not require pagination. +- **Sorting (Recent Semantics):** The palette **always** uses `recent` sort order regardless of the sidebar's current `sortBy` setting. Alphabetical or repo-grouped sort would be a poor default for a "jump to" palette — recency is what the user almost always wants. Internally, this means calling `buildWorktreeComparator` from `smart-sort.ts` with `sortBy: 'recent'`. This gives the same smart-sort signals as the sidebar in recent mode: active agent work, permission-needed state, unread state, live terminals, PR signal, linked issue, and recency (`lastActivityAt`), with the same cold-start fallback to persisted `sortOrder` until live PTY state is available (see the `!hasAnyLivePty` branch in `getVisibleWorktreeIds()`). +- **Visual Hierarchy & Highlights:** Because search covers multiple fields simultaneously, the list items must visually clarify *why* a result matched. If the match is inside a comment, display a truncated snippet of that comment centered around the matched range, with the matching text highlighted. +- **Multi-repo disambiguation:** Each list item always displays the repository name (e.g., `stablyai/orca`) alongside the worktree name. This is required because the palette spans all repos — without it, two worktrees named "main" from different repos would be indistinguishable. +- **Empty State:** Two cases: (1) If the user has 0 non-archived worktrees, display "No active worktrees. Create one to get started." (2) If worktrees exist but none match the search query, display "No worktrees match your search." Both use ``. +- **Search fields:** The search input will match against: + - Worktree `displayName` + - Worktree `branch`, normalized via `branchName()` to strip the `refs/heads/` prefix (e.g., `refs/heads/feature/auth-fix` → `feature/auth-fix`) + - Repository name (e.g., `stablyai/orca`) + - Full `comment` text attached to the worktree + - Linked PR number/title. Two paths: (a) auto-detected PR via `prCache` (cache key: `${repo.path}::${branch}`), which has both number and title; (b) manual `linkedPR` fallback, which has number only (no title to search against). If `prCache` has a hit, prefer it; otherwise fall back to `linkedPR` number matching. + - Linked issue number/title. The issue number comes from `w.linkedIssue`; the title comes from `issueCache` (cache key: `${repo.path}::${w.linkedIssue}`). Number matching works even without a cache hit; title matching requires the cache entry to be populated. + - **Cache freshness caveat:** PR and issue data is populated by `refreshGitHubForWorktree`, which runs on worktree activation, and by `refreshAllGitHub`, which runs on window re-focus (`visibilitychange`). On startup, `initGitHubCache` loads previously persisted PR/issue data from disk, so worktrees fetched in prior sessions start with warm caches. Worktrees that have never been activated, were not covered by a `refreshAllGitHub` pass, and have no persisted cache entry will have empty caches — PR/issue title search will silently miss them. This is acceptable: the gap is limited to brand-new worktrees between creation and the next activation or window re-focus cycle. Number-based matching (e.g., `#304`) always works because it checks `w.linkedPR` / `w.linkedIssue` directly, without the cache. + - `**#`-prefix handling:** A leading `#` in the query is stripped before matching PR/issue numbers (e.g., `#304` matches number `304`), with a guard against bare `#` which would produce an empty string and match everything. This mirrors the existing `matchesSearch()` behavior. +- **Navigation:** `Up` / `Down` arrows to navigate the list, `Enter` to select. `Escape` closes the modal. + +## 3. Technical Architecture + +### 3.1 UI Components + +Orca uses `shadcn/ui`. We will add the **Command** component, which wraps the `cmdk` library. + +**New dependency:** `cmdk` (~4KB gzipped) will be added as a direct dependency in `package.json`. It is already present in `node_modules` as a transitive dependency, but not directly importable. + +```bash +pnpm dlx shadcn@latest add command +``` + +Note: `dialog.tsx` already exists in `src/renderer/src/components/ui/`. The shadcn `CommandDialog` uses Radix Dialog internally; verify it shares the same Radix instance to avoid duplicate bundles. If the installed `cmdk` version pins a different `@radix-ui/react-dialog` than the existing `dialog.tsx`, align `dialog.tsx` to the shadcn-installed version to prevent a double-bundled Radix. + +**z-index:** The `CommandDialog` must use `z-50` or higher to reliably overlay the terminal and sidebar, consistent with `QuickOpen.tsx` which uses `z-50` on its fixed overlay container. + +- `**WorktreeJumpPalette.tsx`:** A new component mounted at the root of the app (inside `App.tsx`, alongside the existing ``) to ensure it can be summoned from anywhere. +- `**CommandDialog`:** The shadcn component used to render the modal. + +### 3.2 Keyboard Shortcut + +The shortcut follows the **same renderer-side `keydown` pattern** already used by `Cmd+P` (QuickOpen) and `Cmd+1–9` (worktree jump) in `App.tsx`. + +The existing `onKeyDown` handler in `App.tsx` (inside a `useEffect`) has two zones: shortcuts registered **before** the `isEditableTarget` guard fire from any focus context including xterm.js and contentEditable elements; shortcuts **after** the guard only fire from non-editable targets. `Cmd+P` and `Cmd+1–9` are in the pre-guard zone. `Cmd+J` must also be placed there so it works when a terminal has focus — no main-process `before-input-event` interception is needed. + +**Implementation:** Add a new branch to the existing `onKeyDown` handler in `App.tsx`, before the `isEditableTarget` guard: + +```tsx +// Cmd/Ctrl+J — toggle worktree jump palette +if (mod && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'j') { + e.preventDefault() + if (worktreePaletteVisible) { + setWorktreePaletteVisible(false) + } else { + closeModal() + setQuickOpenVisible(false) + setWorktreePaletteVisible(true) + } + return +} +``` + +**Toggle semantics:** If the palette is already open, `Cmd+J` closes it (matching the toggle behavior users expect from palette shortcuts). The overlay mutual-exclusion clearing (`closeModal`, `setQuickOpenVisible(false)`) only runs on open, not on close. + +**No `activeWorktreeId` or `activeView` guard:** Unlike `Cmd+P` (which requires both `activeView !== 'settings'` and `activeWorktreeId !== null`), the palette has neither guard. Users should be able to open the palette even when no worktree is active (e.g., fresh session with repos but no worktree selected yet) or from the settings view. The escape/cancel path must handle `previousWorktreeId === null` gracefully — focus falls to the document body. + +**Overlay mutual exclusion:** The codebase has three independent overlay state systems: `activeModal` (union type in `ui.ts`), `quickOpenVisible` (boolean in `editor.ts`), and the new `worktreePaletteVisible` (boolean in `ui.ts`). All three must be mutually exclusive — only one overlay can be open at a time. The mechanism: + +1. `**Cmd+J` handler** (palette open): Before setting `worktreePaletteVisible(true)`, call `closeModal()` (dismisses any active modal) and `setQuickOpenVisible(false)` (dismisses QuickOpen). +2. `**Cmd+P` handler** (QuickOpen open): Before setting `quickOpenVisible(true)`, call `setWorktreePaletteVisible(false)`. (It already calls `closeModal()` implicitly by not conflicting with the modal system.) +3. `**openModal()` wrapper**: Extend `openModal` in `ui.ts` to also call `setWorktreePaletteVisible(false)` when opening a modal. This covers all modal-open paths (Cmd+N, delete confirmation, etc.) without requiring each callsite to know about the palette. `quickOpenVisible` lives in the editor slice, so `openModal` cannot directly clear it from within the UI slice. This is safe because of how QuickOpen's focus model works: QuickOpen auto-focuses its `` on mount (via `requestAnimationFrame` in a `useEffect`), and `isEditableTarget` returns `true` for `` elements. Therefore, all keyboard-triggered `openModal` paths (`Cmd+N`, etc.) that are gated behind `isEditableTarget` will not fire while QuickOpen has focus. Mouse-triggered `openModal` paths (e.g., `WorktreeCard` double-click calling `openModal('edit-meta')`) fire on the sidebar, which is visually behind the QuickOpen overlay — the click would first dismiss QuickOpen via its backdrop `onClick` handler, closing it before the modal opens. + +This prevents z-index stacking and confusing multi-overlay states. + +**Tech debt note:** Three independent overlay state systems (`activeModal`, `quickOpenVisible`, `worktreePaletteVisible`) is O(n²) in the number of overlay types — every new overlay must know about all others. A follow-up issue should be filed to unify them into a single `activeOverlay` union type, but this is out of scope for the current feature. + +**Menu registration:** Register a `View -> Open Worktree Palette` entry in `register-app-menu.ts` for discoverability, consistent with Section 2.1. The entry must use a **display-only shortcut hint** — do **not** set `accelerator: 'CmdOrCtrl+J'`. In Electron, menu accelerators intercept key events at the main-process level *before* the renderer's `keydown` handler fires (this is how `CmdOrCtrl+,` for Settings works — its `click` handler runs in the main process via `onOpenSettings`). If `CmdOrCtrl+J` were registered as a real accelerator, the renderer `keydown` handler would never see the event, and the overlay mutual-exclusion logic (which runs in the renderer) would be bypassed. Instead, show the shortcut text in the menu label (e.g., `label: 'Open Worktree Palette\tCmdOrCtrl+J'`) without binding `accelerator`, matching the pattern used by `Cmd+P` (QuickOpen), which has no menu entry at all and relies solely on the renderer handler. + +### 3.3 State Management + +- **Visibility state:** Add `worktreePaletteVisible: boolean` and `setWorktreePaletteVisible: (v: boolean) => void` to the UI slice (`store/slices/ui.ts`). Note: the existing `quickOpenVisible` lives in the editor slice, not UI. The palette visibility belongs in UI because it is a global navigation concern, not editor-specific state. +- **Palette session state:** `query` and `selectedIndex` are ephemeral to the palette component and should live in React component state (not Zustand). They reset on every open. +- **Render optimization:** When `worktreePaletteVisible === false`, the `` should not render its children. The shadcn `CommandDialog` unmounts content when `open={false}` by default, which is sufficient. +- **Recent-sort ordering:** Always use `recent` sort regardless of the sidebar's `sortBy` setting. The cold/warm branching logic currently lives in the fallback path of `getVisibleWorktreeIds()` in `visible-worktrees.ts`: it checks `hasAnyLivePty` from `tabsByWorktree`, and if cold-start (no live PTYs yet), falls back to persisted `sortOrder` descending with alphabetical `displayName` fallback; otherwise it calls `buildWorktreeComparator('recent', ...)`. Note: `getVisibleWorktreeIds()` is only the Cmd+1–9 fallback — the primary sidebar sort happens inside `WorktreeList`'s render pipeline via `sortEpoch`. To avoid duplicating the cold/warm branching in the palette, extract a `sortWorktreesRecent(worktrees, tabsByWorktree, repoMap, prCache)` helper in `smart-sort.ts` that encapsulates the cold/warm detection and returns the sorted array. Both the `getVisibleWorktreeIds()` fallback path and the palette import this shared helper. + +### 3.4 Data Layer & Search + +The palette needs access to all worktrees known to Orca. + +- **Data source:** Read from the existing `worktreesByRepo` in Zustand (already populated via `fetchAllWorktrees` on startup and kept in sync via IPC push events). No new IPC channel is needed. Filter out archived worktrees (`!w.isArchived`) before searching or displaying. Do **not** apply the sidebar's `showActiveOnly` or `filterRepoIds` filters — the palette is a global jump tool that surfaces all non-archived worktrees regardless of the sidebar's filter state. Because the palette reads directly from `worktreesByRepo`, it reactively updates if a worktree is created or deleted via IPC push while the palette is open — no special stale-list handling is needed. + +#### Search implementation + +The sidebar already has a `matchesSearch()` function in `worktree-list-groups.ts` that does **substring matching** (`includes(q)`) against displayName, branch, repo, comment, PR, and issue fields. The palette search builds on this foundation but extends it. Note: `branchName()` (used to strip `refs/heads/` prefixes) is currently exported from `worktree-list-groups.ts` — a sidebar-specific module that imports Lucide icons (`CircleCheckBig`, `CircleDot`, etc.) at the top level. Importing `branchName` from it would pull the entire module (including unused icon components) into the palette's bundle. `smart-sort.ts` has its own duplicate: `branchDisplayName()` doing the identical `branch.replace(/^refs\/heads\//, '')`. Extract `branchName()` to a shared utility (`lib/git-utils.ts`) in Phase 1, and update `worktree-list-groups.ts` and `smart-sort.ts` to import from there. This is a 3-line function — the extraction is trivial and avoids the bundle bloat. + +1. **Matching strategy: substring, not fuzzy.** Use the same case-insensitive substring matching as `matchesSearch()`. True fuzzy matching (ordered-character, like `QuickOpen.tsx`'s `fuzzyMatch`) is not appropriate here — worktree names and comments are short enough that substring search provides good recall without false positives. +2. **Structured match metadata:** Unlike `matchesSearch()` (which returns `boolean`), the palette search helper returns a result object: + +```ts +type MatchRange = { start: number; end: number } + +type PaletteMatchBase = { worktreeId: string } + +/** Empty query — all non-archived worktrees shown, no match metadata. */ +type PaletteMatchAll = PaletteMatchBase & { + matchedField: null + matchRange: null +} + +/** Comment match — includes a truncated snippet centered on the matched range. */ +type PaletteMatchComment = PaletteMatchBase & { + matchedField: 'comment' + matchRange: MatchRange + snippet: string +} + +/** Non-comment field match — range within the matched field's display value. */ +type PaletteMatchField = PaletteMatchBase & { + matchedField: 'displayName' | 'branch' | 'repo' | 'pr' | 'issue' + matchRange: MatchRange +} + +type PaletteMatch = PaletteMatchAll | PaletteMatchComment | PaletteMatchField +``` + +3. **Field priority order:** When multiple fields match, report the first match by priority: `displayName` > `branch` > `repo` > `comment` > `pr` > `issue`. This determines which badge/highlight is shown. +4. **Comment snippet extraction:** Search against the full `comment` text. Only the *rendered snippet* is truncated — extract ~80 characters of surrounding context centered on the matched range. Clamping: `snippetStart = Math.max(0, matchStart - 40)`, `snippetEnd = Math.min(comment.length, matchEnd + 40)`. After clamping, snap to word boundaries: scan `snippetStart` backward (up to 10 chars) to the nearest whitespace or string start; scan `snippetEnd` forward (up to 10 chars) to the nearest whitespace or string end. This avoids cutting words mid-character (e.g., `…e implementation of th…` → `…the implementation of the…`). Prepend `…` if `snippetStart > 0`; append `…` if `snippetEnd < comment.length`. +5. `**cmdk` wiring:** Render with `shouldFilter={false}` so the palette controls filtering. Pass only the filtered result set to ``: + +```tsx + handleSelectWorktree(worktree.id)} +> + {/* Render worktree row with match badge + highlighted range */} + +``` + +6. **Performance:** Keep `value` compact (`worktree.id`) and do not stuff full comments into `keywords`. For the expected worktree count (<200), synchronous filtering on every keystroke is fast enough — no debounce is needed. If worktree counts exceed 500 or filter times exceed 16ms (one frame), add list virtualization via `@tanstack/react-virtual` (already a project dependency). The search contract (`PaletteMatch[]` in, `` out) does not change either way. + +### 3.5 Action (Worktree Activation) + +#### Existing callsite analysis + +The codebase has several worktree activation paths with inconsistent step coverage: + + +| Step | `WorktreeCard` click | `Cmd+1–9` | `AddRepoDialog` | `AddWorktreeDialog` | +| ------------------------------------ | -------------------- | --------- | --------------- | ------------------- | +| Set `activeRepoId` | No | No | Yes | Yes | +| Set `activeView` | No | No | Yes | Yes | +| `setActiveWorktree()` | Yes | Yes | Yes | Yes | +| `ensureWorktreeHasInitialTerminal()` | No | No | Yes | Yes | +| `revealWorktreeInSidebar()` | No | Yes | Yes | Yes | + + +Sidebar card clicks and `Cmd+1–9` work without setting `activeRepoId` because `activeRepoId` is only consumed by the "Create Worktree" dialog (to pre-select a repo) and session persistence — it does not gate rendering or data fetching for the switched-to worktree. Similarly, `ensureWorktreeHasInitialTerminal` is only needed for newly created worktrees that have never been opened; existing worktrees already have terminal tabs. + +#### Palette activation sequence + +The palette should match what `Cmd+1–9` does today (the closest analog: jumping to a visible worktree from any context), plus a few extras justified by the palette's cross-repo scope: + +1. **Set `activeRepoId`:** If the target worktree's `repoId` differs from the current `activeRepoId`, call `setActiveRepo(repoId)`. This keeps session persistence and the "Create Worktree" repo pre-selection accurate. Sidebar clicks skip this because they operate within a single repo group; the palette does not have that constraint. +2. **Switch `activeView`:** If `activeView` is `'settings'`, set it to `'terminal'` so the main content area renders the worktree surface. `Cmd+1–9` does not handle this because it refuses to fire at all from the settings view (gated on `activeView !== 'settings'` in the `onKeyDown` handler); the palette intentionally has no such guard so users can jump to a worktree directly from settings. +3. **Call `setActiveWorktree(worktreeId)`:** This runs Orca's existing activation sequence: sets `activeWorktreeId`, restores per-worktree editor state (`activeFileId`, `activeTabType`, `activeBrowserTabId`), restores the last-active terminal tab, clears unread state, bumps dead PTY generations, and triggers `refreshGitHubForWorktree` to ensure PR/issue/checks data is current for the newly active worktree. +4. **Ensure a focusable surface:** If the worktree has no terminal tabs (i.e., `tabsByWorktree[worktreeId]` is empty), call `ensureWorktreeHasInitialTerminal` (`worktree-activation.ts`). This handles worktrees that were created externally (e.g., via CLI or IPC push) and never opened in the UI. The function already no-ops when tabs exist, so the guard is `existingTabs.length > 0` inside the function itself. +5. **Reveal in sidebar:** Call `revealWorktreeInSidebar(worktreeId)` to ensure the selected worktree is visible (handles collapsed groups and scroll position). +6. **Close the palette.** + +#### Shared helper + +The five activation steps above overlap heavily with `AddRepoDialog.handleOpenWorktree` and `AddWorktreeDialog`'s post-create flow. With three callsites now sharing the same core sequence, extract a shared `activateAndRevealWorktree(worktreeId: string, opts?: { setup?: WorktreeSetupLaunch })` helper in `worktree-activation.ts` that covers the common steps: set `activeRepoId` (cross-repo), switch `activeView` (from settings), `setActiveWorktree`, `ensureWorktreeHasInitialTerminal`, clear sidebar filters that would hide the target, and `revealWorktreeInSidebar`. + +**Sidebar filter clearing:** The helper must clear any sidebar filter state that would prevent the target card from being rendered, because `revealWorktreeInSidebar` relies on the worktree card being *rendered* in the sidebar (the `pendingRevealWorktreeId` effect in `WorktreeList` finds the target in the rendered `rows` array via `findIndex`). If sidebar filters exclude the target, the card is never rendered and the reveal silently no-ops — the user selects a worktree and nothing visually happens. `AddWorktreeDialog` already handles this inline (clears both `searchQuery` and `filterRepoIds` before activation); the shared helper absorbs that responsibility. Specifically: + +- Clear `filterRepoIds` if it is non-empty and does not include the target worktree's repo. +- Clear `searchQuery` unconditionally if it is non-empty. Even if the target repo is visible, an active text search might exclude the specific worktree being jumped to. + +Callsite-specific extras that remain inline after calling the shared helper: + +- `**AddWorktreeDialog`:** `setSidebarOpen(true)`, open right sidebar if `rightSidebarOpenByDefault`. +- `**AddRepoDialog`:** `closeModal()` (the palette closes itself separately). +- **Palette:** close the palette, focus management (Section 3.5 Focus management). + +The helper derives `repoId` internally via `findWorktreeById(worktreesByRepo, worktreeId)` (`worktree-helpers.ts:45`) — the caller only passes `worktreeId`. If the worktree is not found (e.g., deleted between palette open and select), the helper returns early without side effects. + +#### Focus management (v1 — simple strategy) + +- **On select:** After closing the palette, use a double `requestAnimationFrame` (nested rAF) to focus the active surface (terminal xterm instance or Monaco editor) for the target worktree. The first rAF waits for React to commit the state change (palette closes); the second waits for the target worktree's surface layout to settle after Radix Dialog unmounts. Use `onCloseAutoFocus` on the `CommandDialog` with `e.preventDefault()` to prevent Radix from stealing focus to the trigger element. **Fragility note:** the double-rAF is a pragmatic v1 choice — it assumes Radix unmounts within two frames, which depends on the CSS transition duration and reduced-motion settings. If this proves unreliable, replace with a short `setTimeout` matching the actual animation duration or listen for the dialog's `onAnimationEnd`. +- **On escape:** Same double-rAF approach, but focus the active surface for the *current* worktree (the one that was active before the palette opened). Track `previousWorktreeId` as a ref inside the component. If `previousWorktreeId` is `null` (no worktree was active when the palette opened), skip the focus call — focus falls to the document body. +- **Degradation:** If the target surface is not mounted in time (e.g., cold worktree that was created externally and has never been opened — its terminal is still spawning after `ensureWorktreeHasInitialTerminal`), the focus call silently no-ops and focus falls to the document body. The user can click to focus. This is the **common case for externally-created worktrees**, not just a rare edge case — but it is acceptable for v1 because the worktree content still renders correctly; only auto-focus is lost. +- **Future improvement:** A full `focusReturnTarget` system that records the exact xterm/editor/UI element and a `pendingFocus` state for async mount scenarios. This is deferred because the codebase has no existing focus-tracking infrastructure and the simple strategy covers the common case. + +### 3.6 Accessibility + +The `cmdk` library provides built-in ARIA support: + +- `role="combobox"` on the input +- `role="listbox"` / `role="option"` on the list and items +- `aria-activedescendant` for keyboard navigation +- `aria-expanded` on the dialog + +**Additional requirements:** + +- Announce filtered result count changes to screen readers via an `aria-live="polite"` region (e.g., "3 worktrees found"). +- Match-field badges (e.g., `Branch`, `Comment`) should include `aria-label` text so screen readers convey why the result matched. + +## 4. Implementation Phases + +**Phase 1: Component, Shortcut & Data** + +- Add `cmdk` via `pnpm dlx shadcn@latest add command`. +- Extract `branchName()` to `lib/git-utils.ts`; update imports in `worktree-list-groups.ts` and `smart-sort.ts` (consolidating the duplicate `branchDisplayName()`). +- Extract `sortWorktreesRecent()` helper in `smart-sort.ts` (encapsulates cold/warm branching from `getVisibleWorktreeIds()`); update `getVisibleWorktreeIds()` to use it. +- Create `WorktreeJumpPalette.tsx`, mount in `App.tsx`. +- Add `worktreePaletteVisible` to the UI slice. +- Add `Cmd/Ctrl+J` toggle handler to the existing `onKeyDown` in `App.tsx`. +- Wire real worktree data from `worktreesByRepo` (filtered by `!isArchived`) with sidebar-consistent recent ordering and both empty states (no worktrees / no search results). +- Handle startup race: if `worktreesByRepo` is empty but repos exist (data still loading), show a "Loading worktrees..." state instead of the misleading "No active worktrees" empty state. Guard: `Object.keys(worktreesByRepo).length === 0 && repos.length > 0`. Note: `worktreesByRepo` is populated per-repo as individual `fetchWorktrees` calls complete, so once any repo's worktrees arrive, the guard flips to showing partial results — this is intentional (partial results are more useful than a spinner) but means the list may grow incrementally during the first few seconds after launch. +- Define and implement the search result model: `PaletteMatch` with matched field, character ranges, and comment snippet extraction. +- Render with `shouldFilter={false}` and the manual search helper. +- Visual baseline: follow shadcn `CommandDialog` defaults. Use the same palette width as `QuickOpen.tsx` (`w-[660px] max-w-[90vw]`). Item rows show worktree name, repo label, and a muted match-field badge. Active/highlighted item uses `bg-accent`. Detailed visual polish (match highlighting, snippet rendering) is deferred to Phase 3. + +**Phase 2: Activation & Focus** + +- Extract `activateAndRevealWorktree` shared helper in `worktree-activation.ts` per Section 3.5. +- Wire the palette to use the shared helper. Refactor `AddRepoDialog` and `AddWorktreeDialog` to use it as well. +- Defensive select handler: before activating, verify the target worktree still exists in `worktreesByRepo`. If deleted between palette open and selection, show a toast and no-op instead of setting `activeWorktreeId` to a stale ID. +- Implement v1 focus management (`requestAnimationFrame` + `onCloseAutoFocus` prevention). +- Handle escape/cancel with `previousWorktreeId` ref. +- Register display-only `View -> Open Worktree Palette` menu entry (shortcut hint in label, no `accelerator` binding) per Section 3.2. + +**Phase 3: Polish** + +- Accessibility: `aria-live` result count announcements, badge `aria-label` text. +- Visual polish: match highlighting, comment snippet rendering, field badges. + +**Future work (out of scope)** + +- Evaluate migrating `QuickOpen.tsx` (currently a custom overlay with manual keyboard handling) to `cmdk`/`CommandDialog` for visual and behavioral consistency with the palette. This is a separate project — `QuickOpen` has its own fuzzy matching, file-loading, and keyboard handling that would need reworking. +- Unify the three overlay state systems (`activeModal`, `quickOpenVisible`, `worktreePaletteVisible`) into a single `activeOverlay` union type (see tech debt note in Section 3.2). + +## 5. Alternatives Considered + +- `**Cmd+O` (Open):** Standard app semantic, but less honest for this feature because the palette switches between existing worktrees rather than opening a new file or workspace. Rejected in favor of `Cmd+J`, which better matches the action users are taking. +- `**Ctrl+E` (Explore):** Initially considered for Windows/Linux. Rejected because `Ctrl+E` is "end of line" in bash/zsh readline — stealing it in a terminal-heavy app breaks shell navigation muscle memory. +- `**Ctrl+Alt+O`:** Initially considered for Windows/Linux but rejected to avoid `AltGr` collisions on international keyboards (e.g., Polish, German layouts). +- `**Cmd+1...9` (Direct jumping):** Doesn't scale past 9 worktrees and requires the user to memorize sidebar positions. Already implemented as a complementary feature. +- `**Cmd+K`:** Rejected due to conflict with "Clear Terminal". +- `**Cmd+P`:** Rejected because it is already used for file searching (`QuickOpen.tsx`). +- **Main-process `before-input-event` interception:** Initially proposed for the keyboard shortcut to bypass xterm focus. Rejected because the existing renderer-side `keydown` handler (used by `Cmd+P`, `Cmd+1–9`, etc.) already fires before the `isEditableTarget` guard and works from terminal focus. Adding main-process interception would require a new IPC channel and multi-window targeting logic for no benefit. + diff --git a/package.json b/package.json index 462e18f1..502b050c 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "dompurify": "^3.3.3", "electron-updater": "^6.8.3", "hosted-git-info": "^9.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fe02253..9ab67a12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) dompurify: specifier: ^3.3.3 version: 3.3.3 @@ -3097,6 +3100,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} @@ -8753,6 +8762,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + code-block-writer@13.0.3: {} color-convert@2.0.1: diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 7fdf5fc5..6fba2153 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -185,4 +185,118 @@ describe('registerPtyHandlers', () => { } } }) + + it('falls back to a system shell when SHELL points to a missing binary', () => { + const originalShell = process.env.SHELL + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + existsSyncMock.mockImplementation((targetPath: string) => targetPath !== '/opt/homebrew/bin/bash') + + try { + process.env.SHELL = '/opt/homebrew/bin/bash' + + registerPtyHandlers(mainWindow as never) + const result = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + }) + + expect(result).toEqual({ id: expect.any(String) }) + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock).toHaveBeenCalledWith( + '/bin/zsh', + ['-l'], + expect.objectContaining({ cwd: '/tmp' }) + ) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Primary shell "/opt/homebrew/bin/bash" failed') + ) + } finally { + warnSpy.mockRestore() + if (originalShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = originalShell + } + } + }) + + it('falls back when SHELL points to a non-executable binary', () => { + const originalShell = process.env.SHELL + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + accessSyncMock.mockImplementation((targetPath: string) => { + if (targetPath === '/opt/homebrew/bin/bash') { + throw new Error('permission denied') + } + }) + + try { + process.env.SHELL = '/opt/homebrew/bin/bash' + + registerPtyHandlers(mainWindow as never) + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + }) + + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock).toHaveBeenCalledWith( + '/bin/zsh', + ['-l'], + expect.objectContaining({ cwd: '/tmp' }) + ) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Shell "/opt/homebrew/bin/bash" is not executable') + ) + } finally { + warnSpy.mockRestore() + if (originalShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = originalShell + } + } + }) + + it('prefers args.env.SHELL and normalizes the child env after fallback', () => { + const originalShell = process.env.SHELL + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + existsSyncMock.mockImplementation((targetPath: string) => targetPath !== '/opt/homebrew/bin/bash') + + try { + process.env.SHELL = '/bin/bash' + + registerPtyHandlers(mainWindow as never) + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + env: { SHELL: '/opt/homebrew/bin/bash' } + }) + + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock).toHaveBeenCalledWith( + '/bin/zsh', + ['-l'], + expect.objectContaining({ + cwd: '/tmp', + env: expect.objectContaining({ SHELL: '/bin/zsh' }) + }) + ) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Primary shell "/opt/homebrew/bin/bash" failed') + ) + } finally { + warnSpy.mockRestore() + if (originalShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = originalShell + } + } + }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index b50ecddb..faf0ecb0 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1,5 +1,5 @@ import { basename } from 'path' -import { existsSync, accessSync, statSync, constants as fsConstants } from 'fs' +import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'fs' import { type BrowserWindow, ipcMain } from 'electron' import * as pty from 'node-pty' import type { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -17,6 +17,62 @@ const ptyShellName = new Map() // created by the new page, killing them and leaving blank terminals. let loadGeneration = 0 const ptyLoadGeneration = new Map() +let didEnsureSpawnHelperExecutable = false + +function getShellValidationError(shellPath: string): string | null { + if (!existsSync(shellPath)) { + return ( + `Shell "${shellPath}" does not exist. ` + `Set a valid SHELL environment variable or install zsh/bash.` + ) + } + try { + accessSync(shellPath, fsConstants.X_OK) + } catch { + return `Shell "${shellPath}" is not executable. Check file permissions.` + } + return null +} + +function ensureNodePtySpawnHelperExecutable(): void { + if (didEnsureSpawnHelperExecutable || process.platform === 'win32') { + return + } + didEnsureSpawnHelperExecutable = true + + try { + const unixTerminalPath = require.resolve('node-pty/lib/unixTerminal.js') + const packageRoot = basename(unixTerminalPath) === 'unixTerminal.js' + ? unixTerminalPath.replace(/[/\\]lib[/\\]unixTerminal\.js$/, '') + : unixTerminalPath + const candidates = [ + `${packageRoot}/build/Release/spawn-helper`, + `${packageRoot}/build/Debug/spawn-helper`, + `${packageRoot}/prebuilds/${process.platform}-${process.arch}/spawn-helper` + ].map((candidate) => + candidate.replace('app.asar/', 'app.asar.unpacked/').replace('node_modules.asar/', 'node_modules.asar.unpacked/') + ) + + for (const candidate of candidates) { + if (!existsSync(candidate)) { + continue + } + const mode = statSync(candidate).mode + if ((mode & 0o111) !== 0) { + return + } + // Why: node-pty's Unix backend launches this helper before the requested + // shell binary. Some package-manager/install paths strip the execute bit + // from the prebuilt helper, which makes every PTY spawn fail with the + // misleading "posix_spawnp failed" shell error even when /bin/zsh exists. + chmodSync(candidate, mode | 0o755) + return + } + } catch (error) { + console.warn( + `[pty] Failed to ensure node-pty spawn-helper is executable: ${error instanceof Error ? error.message : String(error)}` + ) + } +} export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRuntimeService): void { // Remove any previously registered handlers so we can re-register them @@ -122,28 +178,17 @@ export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRun effectiveCwd = cwd validationCwd = cwd } else { - shellPath = process.env.SHELL || '/bin/zsh' + // Why: startup commands can pass env overrides for the PTY. Prefer an + // explicit SHELL override when present, but still validate/fallback it + // exactly like the inherited process shell so stale config can't brick + // terminal creation. + shellPath = args.env?.SHELL || process.env.SHELL || '/bin/zsh' shellArgs = ['-l'] effectiveCwd = cwd validationCwd = cwd } - // Why: node-pty's posix_spawnp error is opaque (no errno). Pre-validate - // the shell binary and cwd so we can surface actionable diagnostics - // instead of a bare "posix_spawnp failed" message. - if (process.platform !== 'win32') { - if (!existsSync(shellPath)) { - throw new Error( - `Shell "${shellPath}" does not exist. ` + - `Set a valid SHELL environment variable or install zsh/bash.` - ) - } - try { - accessSync(shellPath, fsConstants.X_OK) - } catch { - throw new Error(`Shell "${shellPath}" is not executable. Check file permissions.`) - } - } + ensureNodePtySpawnHelperExecutable() if (!existsSync(validationCwd)) { throw new Error( @@ -173,67 +218,82 @@ export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRun spawnEnv.LANG ??= 'en_US.UTF-8' let ptyProcess: pty.IPty | undefined - try { - ptyProcess = pty.spawn(shellPath, shellArgs, { - name: 'xterm-256color', - cols: args.cols, - rows: args.rows, - cwd: effectiveCwd, - env: spawnEnv - }) - } catch (err) { - // Why: node-pty.spawn can throw if posix_spawnp fails for reasons - // not caught by the pre-validation above (e.g. architecture mismatch - // of the native addon, PTY allocation failure, or resource limits). - // Try common fallback shells before giving up — the user's SHELL - // env may point to a broken or incompatible binary. - const primaryError = err instanceof Error ? err.message : String(err) + let primaryError: string | null = null + if (process.platform !== 'win32') { + primaryError = getShellValidationError(shellPath) + } - if (process.platform !== 'win32') { - const fallbackShells = ['/bin/zsh', '/bin/bash', '/bin/sh'].filter((s) => s !== shellPath) - for (const fallback of fallbackShells) { - try { - accessSync(fallback, fsConstants.X_OK) - } catch { - continue - } - try { - ptyProcess = pty.spawn(fallback, ['-l'], { - name: 'xterm-256color', - cols: args.cols, - rows: args.rows, - cwd: effectiveCwd, - env: spawnEnv - }) - // Fallback succeeded — update shellPath for the basename tracking below. - console.warn( - `[pty] Primary shell "${shellPath}" failed (${primaryError}), fell back to "${fallback}"` - ) - shellPath = fallback - break - } catch { - // Fallback also failed — try next. - } - } - } - - if (!ptyProcess) { - const diag = [ - `shell: ${shellPath}`, - `cwd: ${effectiveCwd}`, - `arch: ${process.arch}`, - `platform: ${process.platform} ${process.getSystemVersion?.() ?? ''}` - ].join(', ') - throw new Error( - `Failed to spawn shell "${shellPath}": ${primaryError} (${diag}). ` + - `If this persists, please file an issue.` - ) + if (!primaryError) { + try { + ptyProcess = pty.spawn(shellPath, shellArgs, { + name: 'xterm-256color', + cols: args.cols, + rows: args.rows, + cwd: effectiveCwd, + env: spawnEnv + }) + } catch (err) { + // Why: node-pty.spawn can throw if posix_spawnp fails for reasons + // not caught by the validation above (e.g. architecture mismatch + // of the native addon, PTY allocation failure, or resource limits). + primaryError = err instanceof Error ? err.message : String(err) + } + } + + if (!ptyProcess && process.platform !== 'win32') { + // Why: a stale login shell path (common after Homebrew/bash changes) + // should not brick Orca terminals. Fall back to system shells so the + // user still gets a working terminal while the bad SHELL config remains. + const configuredShellPath = shellPath + const fallbackShells = ['/bin/zsh', '/bin/bash', '/bin/sh'].filter((s) => s !== configuredShellPath) + for (const fallback of fallbackShells) { + if (getShellValidationError(fallback)) { + continue + } + try { + // Why: set SHELL to the fallback *before* spawning so the child + // process inherits the correct value. Leaving the stale original + // SHELL in the env would confuse shell startup logic and any + // subprocesses that inspect $SHELL. + spawnEnv.SHELL = fallback + ptyProcess = pty.spawn(fallback, ['-l'], { + name: 'xterm-256color', + cols: args.cols, + rows: args.rows, + cwd: effectiveCwd, + env: spawnEnv + }) + console.warn( + `[pty] Primary shell "${configuredShellPath}" failed (${primaryError ?? 'unknown error'}), fell back to "${fallback}"` + ) + shellPath = fallback + break + } catch { + // Fallback also failed — try next. + } } } - // Should be unreachable — the catch block throws when no fallback succeeds. if (!ptyProcess) { - throw new Error('PTY process was not created') + const diag = [ + `shell: ${shellPath}`, + `cwd: ${effectiveCwd}`, + `arch: ${process.arch}`, + `platform: ${process.platform} ${process.getSystemVersion?.() ?? ''}` + ].join(', ') + throw new Error( + `Failed to spawn shell "${shellPath}": ${primaryError ?? 'unknown error'} (${diag}). ` + + `If this persists, please file an issue.` + ) + } + + if (process.platform !== 'win32') { + // Why: after a successful fallback, update spawnEnv.SHELL to match what + // was actually launched. The value was already set inside the fallback loop + // before spawn, but we also need shellPath to reflect the fallback for the + // ptyShellName map below. (Primary-path spawns already have the correct + // SHELL from process.env / args.env.) + spawnEnv.SHELL = shellPath } const proc = ptyProcess ptyProcesses.set(id, proc) diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index b38ddbfe..a3d3d6a5 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -108,4 +108,17 @@ describe('registerAppMenu', () => { expect(reloadIgnoringCacheMock).toHaveBeenCalledTimes(1) expect(reloadMock).not.toHaveBeenCalled() }) + + it('shows the worktree palette shortcut as a display-only menu hint', () => { + registerAppMenu(buildMenuOptions()) + + const template = buildFromTemplateMock.mock.calls[0][0] as Electron.MenuItemConstructorOptions[] + const viewMenu = template.find((item) => item.label === 'View') + const submenu = viewMenu?.submenu as Electron.MenuItemConstructorOptions[] + const expectedLabel = `Open Worktree Palette\t${process.platform === 'darwin' ? 'Cmd+J' : 'Ctrl+Shift+J'}` + const paletteItem = submenu.find((item) => item.label === expectedLabel) + + expect(paletteItem).toBeDefined() + expect(paletteItem?.accelerator).toBeUndefined() + }) }) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 603ea987..23ef09cd 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -107,6 +107,15 @@ export function registerAppMenu({ click: () => onZoomOut() }, { type: 'separator' }, + { + // Why: display-only shortcut hint — do NOT set `accelerator` here. + // Menu accelerators intercept key events at the main-process level + // before the renderer's keydown handler fires. The overlay + // mutual-exclusion logic (which runs in the renderer) would be + // bypassed if this were a real accelerator binding. + label: `Open Worktree Palette\t${process.platform === 'darwin' ? 'Cmd+J' : 'Ctrl+Shift+J'}` + }, + { type: 'separator' }, { role: 'togglefullscreen' } ] }, diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 3f9ceb28..58f12c0e 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -1,3 +1,4 @@ +/* oxlint-disable max-lines */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { browserWindowMock, openExternalMock, attachGuestPoliciesMock, isMock } = vi.hoisted(() => ({ @@ -281,6 +282,55 @@ describe('createMainWindow', () => { expect(webContents.send).not.toHaveBeenCalled() }) + it('forwards ctrl/cmd+j to the worktree palette toggle event', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null) + + const isDarwin = process.platform === 'darwin' + for (const input of [ + { type: 'keyDown', code: 'KeyJ', key: 'j', meta: isDarwin, control: !isDarwin, alt: false, shift: !isDarwin }, + { type: 'keyDown', code: 'KeyJ', key: '', meta: isDarwin, control: !isDarwin, alt: false, shift: !isDarwin } + ]) { + const preventDefault = vi.fn() + windowHandlers['before-input-event']({ preventDefault } as never, input as never) + expect(preventDefault).toHaveBeenCalledTimes(1) + } + + expect(webContents.send).toHaveBeenCalledTimes(2) + expect(webContents.send).toHaveBeenNthCalledWith(1, 'ui:toggleWorktreePalette') + expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette') + }) + it('toggles devtools on F12 in development', () => { isMock.dev = true diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 1550c6bd..3a130439 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -200,6 +200,28 @@ export function createMainWindow(store: Store | null): BrowserWindow { } else if (input.key === '0' && !input.shift) { event.preventDefault() mainWindow.webContents.send('terminal:zoom', 'reset') + } else if ( + input.code === 'KeyJ' && + ((process.platform === 'darwin' && !input.shift) || + (process.platform !== 'darwin' && input.shift)) + ) { + // Why: embedded browser guests can keep keyboard focus inside Chromium's + // guest webContents, which bypasses the renderer's window-level keydown + // listener. Forward the worktree-switch shortcut through the main window + // so Cmd+J (macOS) or Ctrl+Shift+J (Win/Linux) works consistently from browser tabs too. + // We use Ctrl+Shift+J on Win/Linux because Ctrl+J is the ASCII Line Feed control code + // and intercepting it would break standard terminal usage (like Enter in shells or Vim). + event.preventDefault() + mainWindow.webContents.send('ui:toggleWorktreePalette') + } else if (input.code === 'KeyP' && !input.shift) { + // Forward Cmd/Ctrl+P to trigger Quick Open + event.preventDefault() + mainWindow.webContents.send('ui:openQuickOpen') + } else if (input.key >= '1' && input.key <= '9' && !input.shift) { + // Forward Cmd/Ctrl+1-9 for quick worktree switching + event.preventDefault() + const index = parseInt(input.key, 10) - 1 + mainWindow.webContents.send('ui:jumpToWorktreeIndex', index) } }) diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 7b61b312..6d387fc6 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -340,6 +340,9 @@ export type PreloadApi = { get: () => Promise set: (args: Partial) => Promise onOpenSettings: (callback: () => void) => () => void + onToggleWorktreePalette: (callback: () => void) => () => void + onOpenQuickOpen: (callback: () => void) => () => void + onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void onActivateWorktree: ( callback: (data: { repoId: string; worktreeId: string; setup?: WorktreeSetupLaunch }) => void ) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 2b1e9a29..0fcd04ce 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -554,6 +554,21 @@ const api = { ipcRenderer.on('ui:openSettings', listener) return () => ipcRenderer.removeListener('ui:openSettings', listener) }, + onToggleWorktreePalette: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleWorktreePalette', listener) + return () => ipcRenderer.removeListener('ui:toggleWorktreePalette', listener) + }, + onOpenQuickOpen: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openQuickOpen', listener) + return () => ipcRenderer.removeListener('ui:openQuickOpen', listener) + }, + onJumpToWorktreeIndex: (callback: (index: number) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) + ipcRenderer.on('ui:jumpToWorktreeIndex', listener) + return () => ipcRenderer.removeListener('ui:jumpToWorktreeIndex', listener) + }, onActivateWorktree: ( callback: (data: { repoId: string diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e51b245a..37771b7e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -17,13 +17,13 @@ import Landing from './components/Landing' import Settings from './components/settings/Settings' import RightSidebar from './components/right-sidebar' import QuickOpen from './components/QuickOpen' +import WorktreeJumpPalette from './components/WorktreeJumpPalette' import { ZoomOverlay } from './components/ZoomOverlay' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' import { setRuntimeGraphStoreStateGetter, setRuntimeGraphSyncEnabled } from './runtime/sync-runtime-graph' -import { getVisibleWorktreeIds } from './components/sidebar/visible-worktrees' import { useGlobalFileDrop } from './hooks/useGlobalFileDrop' import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload' import { buildWorkspaceSessionPayload } from './lib/workspace-session' @@ -97,7 +97,7 @@ function App(): React.JSX.Element { const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth) const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen) const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) - const setQuickOpenVisible = useAppStore((s) => s.setQuickOpenVisible) + const closeModal = useAppStore((s) => s.closeModal) const isFullScreen = useAppStore((s) => s.isFullScreen) // Subscribe to IPC push events @@ -383,47 +383,11 @@ function App(): React.JSX.Element { // Accept Cmd on macOS, Ctrl on other platforms const mod = isMac ? e.metaKey : e.ctrlKey - // Why: Cmd/Ctrl+P must be handled before the isEditableTarget guard - // because contentEditable elements (e.g. the Tiptap rich markdown - // editor) would otherwise swallow the event, making quick-open - // unreachable while the rich editor has focus. - if ( - mod && - !e.altKey && - !e.shiftKey && - e.key.toLowerCase() === 'p' && - activeView !== 'settings' && - activeWorktreeId !== null - ) { - e.preventDefault() - setQuickOpenVisible(true) - return - } - - // Why: Cmd/Ctrl+1–9 must be handled before the isEditableTarget guard so - // the shortcut fires from any focus context — including sidebar search - // input, Monaco editor, and contentEditable elements. This follows the - // same pattern as Cmd+P above. - if ( - mod && - !e.altKey && - !e.shiftKey && - e.key >= '1' && - e.key <= '9' && - activeView !== 'settings' - ) { - const index = parseInt(e.key, 10) - 1 - const visibleIds = getVisibleWorktreeIds() - if (index < visibleIds.length) { - // Prevent the digit from being typed into the focused input/editor - e.preventDefault() - const store = useAppStore.getState() - store.setActiveWorktree(visibleIds[index]) - // Scroll sidebar to reveal the activated card - store.revealWorktreeInSidebar(visibleIds[index]) - } - return - } + // Note: Cmd/Ctrl+P (quick-open) and Cmd/Ctrl+1-9 (jump-to-worktree) are + // handled via before-input-event in createMainWindow.ts, which forwards + // them as IPC events. The IPC handlers in useIpcEvents.ts apply the same + // view-state guards (activeView !== 'settings', etc.). This approach + // ensures the shortcuts work even when a browser guest has focus. if (isEditableTarget(e.target)) { return @@ -486,12 +450,12 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, openModal, + closeModal, repos, toggleSidebar, toggleRightSidebar, setRightSidebarTab, - setRightSidebarOpen, - setQuickOpenVisible + setRightSidebarOpen ]) return ( @@ -588,6 +552,7 @@ function App(): React.JSX.Element { {showSidebar && rightSidebarOpen ? : null} + diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index 39d7d859..5dbeb379 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -1,8 +1,16 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Search, File } from 'lucide-react' +/* oxlint-disable max-lines */ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { File } from 'lucide-react' import { useAppStore } from '@/store' import { detectLanguage } from '@/lib/language-detect' import { joinPath } from '@/lib/path' +import { + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandItem +} from '@/components/ui/command' /** * Simple fuzzy match: checks if all characters in the query appear in order @@ -45,19 +53,16 @@ function fuzzyMatch(query: string, target: string): number { } export default function QuickOpen(): React.JSX.Element | null { - const visible = useAppStore((s) => s.quickOpenVisible) - const setVisible = useAppStore((s) => s.setQuickOpenVisible) + const visible = useAppStore((s) => s.activeModal === 'quick-open') + const closeModal = useAppStore((s) => s.closeModal) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const openFile = useAppStore((s) => s.openFile) const [query, setQuery] = useState('') const [files, setFiles] = useState([]) - const [selectedIndex, setSelectedIndex] = useState(0) const [loading, setLoading] = useState(false) const [loadError, setLoadError] = useState(null) - const inputRef = useRef(null) - const listRef = useRef(null) // Find active worktree path const worktreePath = useMemo(() => { @@ -81,13 +86,11 @@ export default function QuickOpen(): React.JSX.Element | null { if (!worktreePath) { setFiles([]) - setSelectedIndex(0) return } let cancelled = false setQuery('') - setSelectedIndex(0) setFiles([]) setLoadError(null) setLoading(true) @@ -113,9 +116,6 @@ export default function QuickOpen(): React.JSX.Element | null { } }) - // Focus input after mount - requestAnimationFrame(() => inputRef.current?.focus()) - return () => { cancelled = true } @@ -138,21 +138,12 @@ export default function QuickOpen(): React.JSX.Element | null { return results.slice(0, 50) }, [files, query]) - // Scroll selected item into view - useEffect(() => { - if (!listRef.current) { - return - } - const item = listRef.current.children[selectedIndex] as HTMLElement | undefined - item?.scrollIntoView({ block: 'nearest' }) - }, [selectedIndex]) - const handleSelect = useCallback( (relativePath: string) => { if (!activeWorktreeId || !worktreePath) { return } - setVisible(false) + closeModal() openFile({ filePath: joinPath(worktreePath, relativePath), relativePath, @@ -161,114 +152,71 @@ export default function QuickOpen(): React.JSX.Element | null { mode: 'edit' }) }, - [activeWorktreeId, worktreePath, openFile, setVisible] + [activeWorktreeId, worktreePath, openFile, closeModal] ) - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - setVisible(false) - return - } - if (e.key === 'ArrowDown') { - e.preventDefault() - if (filtered.length > 0) { - setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)) - } - return - } - if (e.key === 'ArrowUp') { - e.preventDefault() - if (filtered.length > 0) { - setSelectedIndex((i) => Math.max(i - 1, 0)) - } - return - } - if (e.key === 'Enter') { - e.preventDefault() - const item = filtered[selectedIndex] - if (item) { - handleSelect(item.path) - } + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + closeModal() } }, - [setVisible, filtered, selectedIndex, handleSelect] + [closeModal] ) - if (!visible) { - return null - } + const handleCloseAutoFocus = useCallback((e: Event) => { + // Why: prevent Radix from stealing focus to the trigger element. + e.preventDefault() + }, []) return ( -
setVisible(false)} + -
e.stopPropagation()} - > - {/* Search input */} -
- - { - setQuery(e.target.value) - setSelectedIndex(0) - }} - onKeyDown={handleKeyDown} - spellCheck={false} - /> -
- - {/* Results list — only rendered when there is content to avoid empty padding */} - {(loading || query.trim() || filtered.length > 0) && ( -
- {loading && ( -
- Loading files... -
- )} - {!loading && loadError && ( -
- Could not load files: {loadError} -
- )} - {filtered.length === 0 && query.trim() && ( -
- No matching files -
- )} - {filtered.map((item, idx) => { - const lastSlash = item.path.lastIndexOf('/') - const dir = lastSlash >= 0 ? item.path.slice(0, lastSlash) : '' - const filename = item.path.slice(lastSlash + 1) - - return ( - - ) - })} + + + {loading ? ( +
+ Loading files...
+ ) : loadError ? ( +
{loadError}
+ ) : filtered.length === 0 ? ( + No matching files. + ) : ( + filtered.map((item) => { + const lastSlash = item.path.lastIndexOf('/') + const dir = lastSlash >= 0 ? item.path.slice(0, lastSlash) : '' + const filename = item.path.slice(lastSlash + 1) + + return ( + handleSelect(item.path)} + className="flex items-center gap-2 px-3 py-1.5" + > + + {filename} + {dir && {dir}} + + ) + }) )} +
+ {/* Accessibility: announce result count changes */} +
+ {query.trim() ? `${filtered.length} files found` : ''}
-
+ ) } diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx new file mode 100644 index 00000000..c26eaaed --- /dev/null +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -0,0 +1,491 @@ +/* oxlint-disable max-lines */ +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandItem +} from '@/components/ui/command' +import { branchName } from '@/lib/git-utils' +import { sortWorktreesRecent } from '@/components/sidebar/smart-sort' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import type { Worktree, Repo } from '../../../shared/types' + +// ─── Search result types ──────────────────────────────────────────── + +type MatchRange = { start: number; end: number } + +type PaletteMatchBase = { worktreeId: string } + +/** Empty query — all non-archived worktrees shown, no match metadata. */ +type PaletteMatchAll = PaletteMatchBase & { + matchedField: null + matchRange: null +} + +/** Comment match — includes a truncated snippet centered on the matched range. */ +type PaletteMatchComment = PaletteMatchBase & { + matchedField: 'comment' + matchRange: MatchRange + snippet: string + /** Offset of the snippet start within the original comment, for highlight calculation. */ + snippetOffset: number +} + +/** Non-comment field match — range within the matched field's display value. */ +type PaletteMatchField = PaletteMatchBase & { + matchedField: 'displayName' | 'branch' | 'repo' | 'pr' | 'issue' + matchRange: MatchRange +} + +type PaletteMatch = PaletteMatchAll | PaletteMatchComment | PaletteMatchField + +// ─── Search logic ─────────────────────────────────────────────────── + +function extractCommentSnippet( + comment: string, + matchStart: number, + matchEnd: number +): { snippet: string; snippetOffset: number } { + let snippetStart = Math.max(0, matchStart - 40) + let snippetEnd = Math.min(comment.length, matchEnd + 40) + + // Snap to word boundaries (scan up to 10 chars) + for (let i = 0; i < 10 && snippetStart > 0; i++) { + if (/\s/.test(comment[snippetStart - 1])) { + break + } + snippetStart-- + } + for (let i = 0; i < 10 && snippetEnd < comment.length; i++) { + if (/\s/.test(comment[snippetEnd])) { + break + } + snippetEnd++ + } + + const prefix = snippetStart > 0 ? '\u2026' : '' + const suffix = snippetEnd < comment.length ? '\u2026' : '' + const snippet = prefix + comment.slice(snippetStart, snippetEnd) + suffix + + return { snippet, snippetOffset: snippetStart - prefix.length } +} + +function searchWorktrees( + worktrees: Worktree[], + query: string, + repoMap: Map, + prCache: Record | null, + issueCache: Record | null +): PaletteMatch[] { + if (!query) { + return worktrees.map((w) => ({ + worktreeId: w.id, + matchedField: null, + matchRange: null + })) + } + + const q = query.toLowerCase() + const results: PaletteMatch[] = [] + + for (const w of worktrees) { + // Field priority: displayName > branch > repo > comment > pr > issue + const nameIdx = w.displayName.toLowerCase().indexOf(q) + if (nameIdx !== -1) { + results.push({ + worktreeId: w.id, + matchedField: 'displayName', + matchRange: { start: nameIdx, end: nameIdx + q.length } + }) + continue + } + + const branch = branchName(w.branch) + const branchIdx = branch.toLowerCase().indexOf(q) + if (branchIdx !== -1) { + results.push({ + worktreeId: w.id, + matchedField: 'branch', + matchRange: { start: branchIdx, end: branchIdx + q.length } + }) + continue + } + + const repoName = repoMap.get(w.repoId)?.displayName ?? '' + const repoIdx = repoName.toLowerCase().indexOf(q) + if (repoIdx !== -1) { + results.push({ + worktreeId: w.id, + matchedField: 'repo', + matchRange: { start: repoIdx, end: repoIdx + q.length } + }) + continue + } + + if (w.comment) { + const commentIdx = w.comment.toLowerCase().indexOf(q) + if (commentIdx !== -1) { + const { snippet, snippetOffset } = extractCommentSnippet( + w.comment, + commentIdx, + commentIdx + q.length + ) + results.push({ + worktreeId: w.id, + matchedField: 'comment', + matchRange: { start: commentIdx, end: commentIdx + q.length }, + snippet, + snippetOffset + }) + continue + } + } + + // Strip leading '#' for number matching, guard against bare '#' + const numQuery = q.startsWith('#') ? q.slice(1) : q + if (!numQuery) { + continue + } + + // PR matching + const repo = repoMap.get(w.repoId) + const branchForPR = branchName(w.branch) + const prKey = repo && branchForPR ? `${repo.path}::${branchForPR}` : '' + const pr = prKey && prCache ? prCache[prKey]?.data : undefined + + if (pr) { + const prNumStr = String(pr.number) + const prNumIdx = prNumStr.indexOf(numQuery) + if (prNumIdx !== -1) { + results.push({ + worktreeId: w.id, + matchedField: 'pr', + matchRange: { start: prNumIdx, end: prNumIdx + numQuery.length } + }) + continue + } + const prTitleIdx = pr.title.toLowerCase().indexOf(q) + if (prTitleIdx !== -1) { + results.push({ + worktreeId: w.id, + matchedField: 'pr', + matchRange: { start: prTitleIdx, end: prTitleIdx + q.length } + }) + continue + } + } else if (w.linkedPR != null) { + const prNumStr = String(w.linkedPR) + const prNumIdx = prNumStr.indexOf(numQuery) + if (prNumIdx !== -1) { + results.push({ + worktreeId: w.id, + matchedField: 'pr', + matchRange: { start: prNumIdx, end: prNumIdx + numQuery.length } + }) + continue + } + } + + // Issue matching + if (w.linkedIssue != null) { + const issueNumStr = String(w.linkedIssue) + const issueNumIdx = issueNumStr.indexOf(numQuery) + if (issueNumIdx !== -1) { + results.push({ + worktreeId: w.id, + matchedField: 'issue', + matchRange: { start: issueNumIdx, end: issueNumIdx + numQuery.length } + }) + continue + } + const issueKey = repo ? `${repo.path}::${w.linkedIssue}` : '' + const issue = issueKey && issueCache ? issueCache[issueKey]?.data : undefined + if (issue?.title) { + const issueTitleIdx = issue.title.toLowerCase().indexOf(q) + if (issueTitleIdx !== -1) { + results.push({ + worktreeId: w.id, + matchedField: 'issue', + matchRange: { start: issueTitleIdx, end: issueTitleIdx + q.length } + }) + continue + } + } + } + } + + return results +} + +// ─── Highlight helper ─────────────────────────────────────────────── + +function HighlightedText({ + text, + matchRange +}: { + text: string + matchRange: MatchRange | null +}): React.JSX.Element { + if (!matchRange) { + return <>{text} + } + const before = text.slice(0, matchRange.start) + const match = text.slice(matchRange.start, matchRange.end) + const after = text.slice(matchRange.end) + return ( + <> + {before} + {match} + {after} + + ) +} + +// ─── Field badge labels ───────────────────────────────────────────── + +const FIELD_BADGES: Record = { + branch: 'Branch', + repo: 'Repo', + comment: 'Comment', + pr: 'PR', + issue: 'Issue' +} + +// ─── Component ────────────────────────────────────────────────────── + +export default function WorktreeJumpPalette(): React.JSX.Element | null { + const visible = useAppStore((s) => s.activeModal === 'worktree-palette') + const closeModal = useAppStore((s) => s.closeModal) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const repos = useAppStore((s) => s.repos) + const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const prCache = useAppStore((s) => s.prCache) + const issueCache = useAppStore((s) => s.issueCache) + const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + + const [query, setQuery] = useState('') + const previousWorktreeIdRef = useRef(null) + + const repoMap = useMemo(() => new Map(repos.map((r) => [r.id, r])), [repos]) + + // All non-archived worktrees sorted by recent signals + const sortedWorktrees = useMemo(() => { + const all: Worktree[] = Object.values(worktreesByRepo).flat().filter((w) => !w.isArchived) + return sortWorktreesRecent(all, tabsByWorktree, repoMap, prCache) + }, [worktreesByRepo, tabsByWorktree, repoMap, prCache]) + + // Search results + const matches = useMemo( + () => searchWorktrees(sortedWorktrees, query.trim(), repoMap, prCache, issueCache), + [sortedWorktrees, query, repoMap, prCache, issueCache] + ) + + // Build a map of worktreeId -> Worktree for quick lookup + const worktreeMap = useMemo(() => { + const map = new Map() + for (const w of sortedWorktrees) { + map.set(w.id, w) + } + return map + }, [sortedWorktrees]) + + // Loading state: repos exist but worktreesByRepo is still empty + const isLoading = + repos.length > 0 && Object.keys(worktreesByRepo).length === 0 + + const handleOpenChange = useCallback( + (open: boolean) => { + if (open) { + previousWorktreeIdRef.current = activeWorktreeId + setQuery('') + } else { + closeModal() + } + }, + [closeModal, activeWorktreeId] + ) + + const focusActiveSurface = useCallback(() => { + // Why: double rAF — first waits for React to commit state (palette closes), + // second waits for the target worktree surface layout to settle after Radix + // Dialog unmounts. Pragmatic v1 choice per design doc Section 3.5. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const xterm = document.querySelector('.xterm-helper-textarea') as HTMLElement | null + if (xterm) { + xterm.focus() + return + } + // Fallback: try Monaco editor + const monaco = document.querySelector('.monaco-editor textarea') as HTMLElement | null + if (monaco) { + monaco.focus() + } + }) + }) + }, []) + + const handleSelect = useCallback( + (worktreeId: string) => { + const state = useAppStore.getState() + const wt = findWorktreeById(state.worktreesByRepo, worktreeId) + if (!wt) { + toast.error('Worktree no longer exists') + return + } + activateAndRevealWorktree(worktreeId) + closeModal() + focusActiveSurface() + }, + [closeModal, focusActiveSurface] + ) + + const handleCloseAutoFocus = useCallback((e: Event) => { + // Why: prevent Radix from stealing focus to the trigger element. We manage + // focus ourselves via the double-rAF approach. + e.preventDefault() + }, []) + + // Result count for screen readers + const resultCount = matches.length + const hasWorktrees = sortedWorktrees.length > 0 + + return ( + + + + {isLoading ? ( +
+ Loading worktrees... +
+ ) : !hasWorktrees ? ( + No active worktrees. Create one to get started. + ) : matches.length === 0 ? ( + No worktrees match your search. + ) : ( + matches.map((match) => { + const w = worktreeMap.get(match.worktreeId) + if (!w) { + return null + } + const repo = repoMap.get(w.repoId) + const repoName = repo?.displayName ?? '' + const branch = branchName(w.branch) + + return ( + handleSelect(w.id)} + className="flex items-center gap-2 px-3 py-2" + > +
+
+ + {match.matchedField === 'displayName' ? ( + + ) : ( + w.displayName + )} + + {/* Repo badge for multi-repo disambiguation */} + {repoName && ( + + {match.matchedField === 'repo' ? ( + + ) : ( + repoName + )} + + )} + {/* Match-field badge */} + {match.matchedField && FIELD_BADGES[match.matchedField] && ( + + {FIELD_BADGES[match.matchedField]} + + )} +
+ {/* Secondary info: branch + optional match snippet */} +
+ + {match.matchedField === 'branch' ? ( + + ) : ( + branch + )} + + {match.matchedField === 'comment' && 'snippet' in match && ( + <> + | + + + + + )} + {match.matchedField === 'pr' && ( + <> + | + PR #{w.linkedPR} + + )} + {match.matchedField === 'issue' && ( + <> + | + Issue #{w.linkedIssue} + + )} +
+
+
+ ) + }) + )} +
+ {/* Accessibility: announce result count changes */} +
+ {query.trim() ? `${resultCount} worktrees found` : ''} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/ShortcutsPane.tsx b/src/renderer/src/components/settings/ShortcutsPane.tsx index 8b3f5d03..b47e9034 100644 --- a/src/renderer/src/components/settings/ShortcutsPane.tsx +++ b/src/renderer/src/components/settings/ShortcutsPane.tsx @@ -33,6 +33,11 @@ const SHORTCUT_GROUP_DEFINITIONS: ShortcutGroupDefinition[] = [ searchKeywords: ['shortcut', 'global', 'file'], keys: ({ mod }) => [mod, 'P'] }, + { + action: 'Switch worktree', + searchKeywords: ['shortcut', 'global', 'worktree', 'switch', 'jump'], + keys: ({ mod, shift }) => mod === '⌘' ? [mod, 'J'] : [mod, shift, 'J'] + }, { action: 'Create worktree', searchKeywords: ['shortcut', 'global', 'worktree'], diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index db83ac8d..fa35f749 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -15,7 +15,7 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-activation' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { LinkedWorktreeItem } from './LinkedWorktreeItem' import { isGitRepoKind } from '../../../../shared/repo-kind' import type { Repo, Worktree } from '../../../../shared/types' @@ -27,9 +27,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const repos = useAppStore((s) => s.repos) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) - const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) - const setActiveRepo = useAppStore((s) => s.setActiveRepo) - const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar) const openModal = useAppStore((s) => s.openModal) const setActiveView = useAppStore((s) => s.setActiveView) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) @@ -184,17 +181,10 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const handleOpenWorktree = useCallback( (worktree: Worktree) => { - setActiveRepo(repoId) - // Why: if the dialog was opened from the settings view, we must switch - // back to terminal — otherwise App.tsx keeps rendering Settings and the - // worktree appears stuck. AddWorktreeDialog does the same thing. - setActiveView('terminal') - setActiveWorktree(worktree.id) - ensureWorktreeHasInitialTerminal(useAppStore.getState(), worktree.id) - revealWorktreeInSidebar(worktree.id) + activateAndRevealWorktree(worktree.id) closeModal() }, - [repoId, setActiveRepo, setActiveView, setActiveWorktree, revealWorktreeInSidebar, closeModal] + [closeModal] ) const handleCreateWorktree = useCallback(() => { diff --git a/src/renderer/src/components/sidebar/AddWorktreeDialog.tsx b/src/renderer/src/components/sidebar/AddWorktreeDialog.tsx index c9decb04..39152bd9 100644 --- a/src/renderer/src/components/sidebar/AddWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/AddWorktreeDialog.tsx @@ -24,7 +24,7 @@ import { } from '@/components/ui/select' import RepoDotLabel from '@/components/repo/RepoDotLabel' import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' -import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-activation' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getSuggestedFishName, shouldApplySuggestedName } from './worktree-name-suggestions' @@ -39,16 +39,9 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() { const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const activeRepoId = useAppStore((s) => s.activeRepoId) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) - const setActiveRepo = useAppStore((s) => s.setActiveRepo) - const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) const setActiveView = useAppStore((s) => s.setActiveView) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const setSidebarOpen = useAppStore((s) => s.setSidebarOpen) - const searchQuery = useAppStore((s) => s.searchQuery) - const setSearchQuery = useAppStore((s) => s.setSearchQuery) - const filterRepoIds = useAppStore((s) => s.filterRepoIds) - const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) - const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar) const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen) const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) @@ -216,18 +209,14 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() { } : undefined - setActiveRepo(repoId) - setActiveView('terminal') + activateAndRevealWorktree(wt.id, { + setup: result.setup, + issueCommand + }) + // Why: dialog-specific extras that remain after calling the shared + // helper — opening the sidebar and right sidebar are create-flow + // concerns, not general activation behavior. setSidebarOpen(true) - if (searchQuery) { - setSearchQuery('') - } - if (filterRepoIds.length > 0 && !filterRepoIds.includes(repoId)) { - setFilterRepoIds([]) - } - setActiveWorktree(wt.id) - ensureWorktreeHasInitialTerminal(useAppStore.getState(), wt.id, result.setup, issueCommand) - revealWorktreeInSidebar(wt.id) if (settings?.rightSidebarOpenByDefault) { setRightSidebarTab('explorer') setRightSidebarOpen(true) @@ -246,15 +235,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() { comment, createWorktree, updateWorktreeMeta, - setActiveRepo, - setActiveView, setSidebarOpen, - searchQuery, - setSearchQuery, - filterRepoIds, - setFilterRepoIds, - setActiveWorktree, - revealWorktreeInSidebar, setRightSidebarOpen, setRightSidebarTab, settings?.rightSidebarOpenByDefault, diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index 9dee5678..4ed88d04 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -1,4 +1,5 @@ import { detectAgentStatusFromTitle } from '@/lib/agent-status' +import { branchName } from '@/lib/git-utils' import type { Worktree, Repo, TerminalTab } from '../../../../shared/types' type SortBy = 'name' | 'recent' | 'repo' @@ -10,17 +11,13 @@ export type RecentSortOverride = { hasRecentPRSignal: boolean } -function branchDisplayName(branch: string): string { - return branch.replace(/^refs\/heads\//, '') -} - export function hasRecentPRSignal( worktree: Worktree, repoMap: Map, prCache: Record | null ): boolean { const repo = repoMap.get(worktree.repoId) - const branch = branchDisplayName(worktree.branch) + const branch = branchName(worktree.branch) if (!repo || !branch) { return worktree.linkedPR !== null } @@ -160,6 +157,37 @@ export function buildWorktreeComparator( } } +/** + * Sort worktrees by recent-work signals, handling the cold-start / warm + * distinction in one place. On cold start (no live PTYs yet), falls back to + * persisted `sortOrder` descending with alphabetical `displayName` fallback. + * Once any PTY is alive, uses the full smart-score comparator. + * + * Both the palette and `getVisibleWorktreeIds()` import this to avoid + * duplicating the cold/warm branching logic. + */ +export function sortWorktreesRecent( + worktrees: Worktree[], + tabsByWorktree: Record, + repoMap: Map, + prCache: Record | null +): Worktree[] { + const hasAnyLivePty = Object.values(tabsByWorktree) + .flat() + .some((t) => t.ptyId) + + if (!hasAnyLivePty) { + // Cold start: use persisted sortOrder snapshot + return [...worktrees].sort( + (a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName) + ) + } + + return [...worktrees].sort( + buildWorktreeComparator('recent', tabsByWorktree, repoMap, prCache, Date.now()) + ) +} + /** * Compute a recent-work score for a worktree. * Higher score = higher in the list. diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 40236036..0c929b6b 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -1,7 +1,7 @@ import type { Worktree, Repo, TerminalTab } from '../../../../shared/types' import type { AppState } from '@/store/types' import { matchesSearch } from './worktree-list-groups' -import { buildWorktreeComparator } from './smart-sort' +import { buildWorktreeComparator, sortWorktreesRecent } from './smart-sort' import { useAppStore } from '@/store' /** @@ -121,28 +121,12 @@ export function getVisibleWorktreeIds(): string[] { let sortedIds: string[] if (state.sortBy === 'recent') { - const hasAnyLivePty = Object.values(state.tabsByWorktree) - .flat() - .some((t) => t.ptyId) - - if (!hasAnyLivePty) { - // Cold start: use persisted sortOrder snapshot - const sorted = [...allWorktrees].sort( - (a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName) - ) - sortedIds = sorted.map((w) => w.id) - } else { - const sorted = [...allWorktrees].sort( - buildWorktreeComparator( - state.sortBy, - state.tabsByWorktree, - repoMap, - state.prCache, - Date.now() - ) - ) - sortedIds = sorted.map((w) => w.id) - } + sortedIds = sortWorktreesRecent( + allWorktrees, + state.tabsByWorktree, + repoMap, + state.prCache + ).map((w) => w.id) } else { const sorted = [...allWorktrees].sort( buildWorktreeComparator( diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index c3f99e7a..3d3aec42 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -1,10 +1,9 @@ import { CircleCheckBig, CircleDot, CircleX, FolderGit2, GitPullRequest } from 'lucide-react' import type React from 'react' import type { Repo, Worktree } from '../../../../shared/types' +import { branchName } from '@/lib/git-utils' -export function branchName(branch: string): string { - return branch.replace(/^refs\/heads\//, '') -} +export { branchName } export type GroupHeaderRow = { type: 'header' diff --git a/src/renderer/src/components/ui/command.tsx b/src/renderer/src/components/ui/command.tsx new file mode 100644 index 00000000..978a9f75 --- /dev/null +++ b/src/renderer/src/components/ui/command.tsx @@ -0,0 +1,155 @@ +'use client' + +import * as React from 'react' +import { Command as CommandPrimitive } from 'cmdk' +import { SearchIcon } from 'lucide-react' +import { Dialog as DialogPrimitive } from 'radix-ui' + +import { cn } from '@/lib/utils' + +function Command({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + children, + title = 'Command Palette', + description = 'Search for a command to run...', + shouldFilter, + onCloseAutoFocus, + ...props +}: React.ComponentProps & { + title?: string + description?: string + shouldFilter?: boolean + onCloseAutoFocus?: (e: Event) => void +}) { + return ( + + + + + {title} + + {description} + + + {children} + + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator +} diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index d8bf1d65..856ddd47 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1,7 +1,8 @@ import { useEffect } from 'react' import { useAppStore } from '../store' import { applyUIZoom } from '@/lib/ui-zoom' -import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-activation' +import { activateAndRevealWorktree, ensureWorktreeHasInitialTerminal } from '@/lib/worktree-activation' +import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom' import type { UpdateStatus } from '../../../shared/types' import { createUpdateToastController } from './update-toast-controller' @@ -79,6 +80,39 @@ export function useIpcEvents(): void { }) ) + unsubs.push( + window.api.ui.onToggleWorktreePalette(() => { + const store = useAppStore.getState() + if (store.activeModal === 'worktree-palette') { + store.closeModal() + return + } + store.openModal('worktree-palette') + }) + ) + + unsubs.push( + window.api.ui.onOpenQuickOpen(() => { + const store = useAppStore.getState() + if (store.activeView !== 'settings' && store.activeWorktreeId !== null) { + store.openModal('quick-open') + } + }) + ) + + unsubs.push( + window.api.ui.onJumpToWorktreeIndex((index) => { + const store = useAppStore.getState() + if (store.activeView === 'settings') { + return + } + const visibleIds = getVisibleWorktreeIds() + if (index < visibleIds.length) { + activateAndRevealWorktree(visibleIds[index]) + } + }) + ) + unsubs.push( window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup }) => { void (async () => { diff --git a/src/renderer/src/lib/git-utils.ts b/src/renderer/src/lib/git-utils.ts new file mode 100644 index 00000000..5e0eb175 --- /dev/null +++ b/src/renderer/src/lib/git-utils.ts @@ -0,0 +1,4 @@ +/** Strip the `refs/heads/` prefix from a branch ref to get the display name. */ +export function branchName(branch: string): string { + return branch.replace(/^refs\/heads\//, '') +} diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index e8aeb881..cf4a0ebe 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -1,5 +1,7 @@ import type { WorktreeSetupLaunch } from '../../../shared/types' import { buildSetupRunnerCommand } from './setup-runner' +import { useAppStore } from '@/store' +import { findWorktreeById } from '@/store/slices/worktree-helpers' type WorktreeActivationStore = { tabsByWorktree: Record @@ -15,6 +17,68 @@ type WorktreeActivationStore = { ) => void } +/** + * Shared activation sequence used by the worktree palette, AddRepoDialog, + * and AddWorktreeDialog. Covers: cross-repo `activeRepoId` switch, + * `activeView` from settings, `setActiveWorktree`, initial terminal + * creation, sidebar filter clearing, and sidebar reveal. + * + * The caller only passes `worktreeId`; the helper derives `repoId` + * internally via `findWorktreeById`. Returns early without side effects + * if the worktree is not found (e.g. deleted between palette open and select). + */ +export function activateAndRevealWorktree( + worktreeId: string, + opts?: { + setup?: WorktreeSetupLaunch + issueCommand?: { command: string; env?: Record } + } +): boolean { + const state = useAppStore.getState() + const wt = findWorktreeById(state.worktreesByRepo, worktreeId) + if (!wt) { + return false + } + + // 1. Set activeRepoId if crossing repos + if (wt.repoId !== state.activeRepoId) { + state.setActiveRepo(wt.repoId) + } + + // 2. Switch activeView from settings to terminal + if (state.activeView === 'settings') { + state.setActiveView('terminal') + } + + // 3. Core activation: sets activeWorktreeId, restores per-worktree state, + // clears unread, bumps dead PTY generations, triggers GitHub refresh + state.setActiveWorktree(worktreeId) + + // 4. Ensure a focusable surface exists for externally-created worktrees + ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + worktreeId, + opts?.setup, + opts?.issueCommand + ) + + // 5. Clear sidebar filters that would hide the target worktree + // Why: revealWorktreeInSidebar relies on the worktree card being rendered + // in the sidebar. If sidebar filters exclude the target, the card is never + // rendered and the reveal silently no-ops. + if (state.searchQuery) { + state.setSearchQuery('') + } + if (state.filterRepoIds.length > 0 && !state.filterRepoIds.includes(wt.repoId)) { + state.setFilterRepoIds([]) + } + + // 6. Reveal in sidebar + state.revealWorktreeInSidebar(worktreeId) + + return true +} + export function ensureWorktreeHasInitialTerminal( store: WorktreeActivationStore, worktreeId: string, diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 1e1db6f8..98469cc7 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -247,10 +247,6 @@ export type EditorSlice = { reveal: { filePath: string; line: number; column: number; matchLength: number } | null ) => void - // Quick open (Cmd+P) - quickOpenVisible: boolean - setQuickOpenVisible: (visible: boolean) => void - // Session hydration — restore editor files from persisted workspace session hydrateEditorSession: (session: WorkspaceSessionState) => void } @@ -1270,10 +1266,6 @@ export const createEditorSlice: StateCreator = (s pendingEditorReveal: null, setPendingEditorReveal: (reveal) => set({ pendingEditorReveal: reveal }), - // Quick open - quickOpenVisible: false, - setQuickOpenVisible: (visible) => set({ quickOpenVisible: visible }), - // Why: only edit-mode files are restored — diffs and conflict views depend on // transient git state that may have changed between sessions. Restoring them // would show stale data or fail to load entirely. diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 2eb90662..99c20dcd 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -37,6 +37,8 @@ export type UISlice = { | 'confirm-non-git-folder' | 'confirm-remove-folder' | 'add-repo' + | 'quick-open' + | 'worktree-palette' modalData: Record openModal: (modal: UISlice['activeModal'], data?: Record) => void closeModal: () => void