mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat: add browser tab search to Cmd+J jump palette (#675)
This commit is contained in:
parent
97f3cd5199
commit
f5b716a760
8 changed files with 1716 additions and 173 deletions
480
docs/cmd-j-scoped-jump-palette-plan.md
Normal file
480
docs/cmd-j-scoped-jump-palette-plan.md
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
# Design Document: Scoped Cmd+J Jump Palette for Worktrees and Browser Tabs
|
||||
|
||||
**Status:** Draft
|
||||
**Date:** 2026-04-15
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Extend Orca's existing `Cmd+J` / `Ctrl+Shift+J` worktree palette into a single app-wide jump surface with explicit scopes. The first release keeps the palette centered on two jobs:
|
||||
|
||||
- jump to a worktree
|
||||
- jump to an already-open browser page across any worktree
|
||||
|
||||
The palette opens into a lightweight scope switcher with three modes:
|
||||
|
||||
- `All`
|
||||
- `Worktrees`
|
||||
- `Browser Tabs`
|
||||
|
||||
Users can press `Tab` / `Shift+Tab` to cycle scopes without leaving the keyboard. The default contract on open remains `Worktrees`, not `All`, so existing users still land in the familiar worktree-first flow before opting into broader search. Search results stay intentionally narrow: browser-only discovery is supported because the user explicitly needs to find already-open pages across worktrees, but the palette does not become a generic "everything" bucket in v1.
|
||||
|
||||
## 1.1 Phase 0.5 Direction Lock
|
||||
|
||||
Phase 0.5 locked three product decisions for v1:
|
||||
|
||||
- `Cmd+J` still opens into `Worktrees` by default.
|
||||
- Browser discovery indexes live open `BrowserPage`s, not just browser workspace containers.
|
||||
- Browser ordering uses a simple context-first heuristic instead of true last-focused recency state.
|
||||
|
||||
Why these decisions were chosen:
|
||||
|
||||
- `Cmd+J` already has a strong worktree-switching contract in Orca. Making `All` the default would silently turn a familiar command into a mixed-ranking surface and force existing users to re-learn the first screen they see.
|
||||
- The remembered object in the browser case is the page itself. Searching only browser workspace shells would miss the page-level titles and URLs users actually recall when they say "I know I already have this open somewhere."
|
||||
- True global browser recency would require new focus-tracking state whose only initial consumer is palette ranking. A simple heuristic is easier to ship, easier to explain, and good enough for the first version of this discovery workflow.
|
||||
|
||||
## 2. Problem
|
||||
|
||||
Orca already supports multiple concurrent worktrees and persistent in-app browser tabs. Users can jump between worktrees with the existing `Cmd+J` palette, but they cannot quickly answer a more specific question:
|
||||
|
||||
> "I know I already have this page open somewhere. Which worktree is it in, and how do I get back to it?"
|
||||
|
||||
The current worktree palette is container-first. That works when the user remembers the worktree identity, but it breaks down when the remembered thing is a page title, host, or URL path.
|
||||
|
||||
This is a discovery problem, not a recency problem. A cycle UI is poor at it because:
|
||||
|
||||
- the user often does not know which worktree owns the target tab
|
||||
- multiple browser tabs can share similar titles
|
||||
- cycling scales badly once many worktrees have open tabs
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- Preserve `Cmd+J` / `Ctrl+Shift+J` as Orca's single global jump entry point.
|
||||
- Let users search open browser pages across all worktrees.
|
||||
- Keep worktree search fast and familiar for existing users.
|
||||
- Make scope switching explicit and keyboard-first.
|
||||
- Avoid overcommitting Orca to a generic "search all open items" model before the product is ready to support terminals, editors, and commands consistently.
|
||||
- Preserve the existing expectation that `Cmd+J` opens as a worktree-first jump flow.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- Searching terminal scrollback or editor contents.
|
||||
- Adding a persistent sidebar or browser-tab manager panel.
|
||||
- Replacing local `Ctrl+Tab` behavior or introducing a new cycle UI.
|
||||
- Expanding `Cmd+J` into files, terminals, editor tabs, or commands in this change.
|
||||
|
||||
## 5. UX
|
||||
|
||||
### 5.1 Entry Point
|
||||
|
||||
Keep the existing shortcut:
|
||||
|
||||
- macOS: `Cmd+J`
|
||||
- Windows/Linux: `Ctrl+Shift+J`
|
||||
|
||||
This shortcut already means "global jump" inside Orca and is already forwarded correctly even when an embedded browser guest owns focus. Reusing it preserves muscle memory and avoids proliferating navigation surfaces.
|
||||
|
||||
### 5.2 Scope Model
|
||||
|
||||
The palette header contains three explicit scope chips:
|
||||
|
||||
- `All`
|
||||
- `Worktrees`
|
||||
- `Browser Tabs`
|
||||
|
||||
Keyboard behavior:
|
||||
|
||||
- `Tab`: next scope
|
||||
- `Shift+Tab`: previous scope
|
||||
- `Up` / `Down`: move selection within results
|
||||
- `Enter`: activate selected result
|
||||
- `Esc`: close palette
|
||||
|
||||
Default scope on open: `Worktrees`.
|
||||
|
||||
Why this shape:
|
||||
|
||||
- one entry point is easier to remember than separate dialogs
|
||||
- explicit scopes prevent a mixed list from becoming noisy
|
||||
- `Tab` matches the mental model of "move across modes" without conflicting with list navigation
|
||||
- opening in `Worktrees` preserves today's default behavior and makes `All` an explicit expansion, not a silent contract change
|
||||
|
||||
### 5.3 Scope Semantics
|
||||
|
||||
#### `All`
|
||||
|
||||
Merged result list of:
|
||||
|
||||
- open browser pages across all worktrees
|
||||
- worktrees across all repos
|
||||
|
||||
Ranking rules:
|
||||
|
||||
- strong browser title matches rank above weak worktree metadata matches
|
||||
- host/url matches get boosted when the query resembles a domain, URL, or path fragment
|
||||
- exact worktree name matches still beat weak browser matches
|
||||
- current worktree/current browser page receive a small context boost when otherwise tied
|
||||
- browser results use the same heuristic ordering as `Browser Tabs`; v1 does not add hidden last-focused browser recency state just for this merged scope
|
||||
|
||||
`All` is meant to feel smart, not exhaustive.
|
||||
|
||||
#### `Worktrees`
|
||||
|
||||
Equivalent to today's worktree palette behavior:
|
||||
|
||||
- same global search semantics for worktree metadata
|
||||
- same recent-first ordering for the default empty-query state
|
||||
- same selection and activation behavior
|
||||
|
||||
#### `Browser Tabs`
|
||||
|
||||
Shows only open browser pages across all worktrees. The user-facing scope label stays `Browser Tabs`, but each row maps to a live `BrowserPage`, not a browser workspace shell.
|
||||
|
||||
Empty query ordering:
|
||||
|
||||
1. current browser page, if any
|
||||
2. other open browser pages in the current worktree
|
||||
3. browser pages in other worktrees, grouped by the existing worktree ordering and then sorted by title, falling back to URL
|
||||
|
||||
This mode is the direct answer to "just show me all open browsers."
|
||||
|
||||
Why this ordering:
|
||||
|
||||
- it pulls the user's current context to the top without inventing new global recency state
|
||||
- it stays stable enough that users can learn where results tend to land
|
||||
- it keeps the implementation honest about what Orca already knows today versus what would require new focus-history plumbing
|
||||
|
||||
### 5.4 Result Rows
|
||||
|
||||
#### Browser tab row
|
||||
|
||||
Primary text:
|
||||
|
||||
- current page title, falling back to formatted URL when the title is blank or useless
|
||||
|
||||
Secondary text:
|
||||
|
||||
- host + trimmed path
|
||||
|
||||
Context chips on the right:
|
||||
|
||||
- repo name
|
||||
- worktree display name
|
||||
|
||||
Optional badges:
|
||||
|
||||
- `Current Tab`
|
||||
- `Current Worktree`
|
||||
|
||||
Why this is required:
|
||||
|
||||
- browser tab titles are often duplicated (`localhost`, `Settings`, `Dashboard`)
|
||||
- users need immediate disambiguation without opening the result
|
||||
- worktree context is the whole point of the feature
|
||||
- each row represents the actual page the user remembers, while worktree and repo chips explain where that page lives
|
||||
|
||||
#### Worktree row
|
||||
|
||||
Keep the existing row structure:
|
||||
|
||||
- worktree display name
|
||||
- branch
|
||||
- optional supporting text for comment / PR / issue
|
||||
- repo badge
|
||||
|
||||
This avoids making existing users relearn the palette.
|
||||
|
||||
### 5.5 Empty States
|
||||
|
||||
`All`
|
||||
|
||||
- If no worktrees and no browser tabs exist: `No worktrees or open browser tabs`
|
||||
- If query yields no results: `No matches in worktrees or browser tabs`
|
||||
|
||||
`Worktrees`
|
||||
|
||||
- Preserve existing copy
|
||||
|
||||
`Browser Tabs`
|
||||
|
||||
- No open browser tabs: `No open browser tabs`
|
||||
- No query matches: `No browser tabs match your search`
|
||||
|
||||
### 5.6 Activation Behavior
|
||||
|
||||
Selecting a worktree result:
|
||||
|
||||
- preserve current `activateAndRevealWorktree(worktreeId)` behavior
|
||||
|
||||
Selecting a browser result:
|
||||
|
||||
1. activate and reveal the owning worktree
|
||||
2. focus the target browser workspace tab
|
||||
3. select the target `BrowserPage` inside that workspace
|
||||
4. set `activeTabType` to `browser`
|
||||
5. close the palette
|
||||
6. restore focus into the browser surface, not the terminal/editor fallback
|
||||
|
||||
Why this ordering matters:
|
||||
|
||||
- browser pages are subordinate to worktree activation in Orca's model
|
||||
- worktree-first activation restores the right workspace state and sidebar visibility
|
||||
- selecting a browser result should feel like "take me there directly," not "switch worktree and make me pick again"
|
||||
|
||||
## 6. Data Model and Search Inputs
|
||||
|
||||
### 6.1 Worktree Results
|
||||
|
||||
Use the existing search surface:
|
||||
|
||||
- `displayName`
|
||||
- branch
|
||||
- repo name
|
||||
- comment
|
||||
- linked PR number/title
|
||||
- linked issue number/title
|
||||
|
||||
### 6.2 Browser Tab Results
|
||||
|
||||
Search only currently open browser pages, not history and not just browser workspace shells.
|
||||
|
||||
For each browser result, index:
|
||||
|
||||
- page title
|
||||
- page URL
|
||||
- formatted host/path
|
||||
- owning browser workspace label, if available
|
||||
- owning worktree display name
|
||||
- owning repo display name
|
||||
|
||||
Each open `BrowserPage` contributes its own result row. Browser workspaces still matter for ownership and activation, but they are context, not the searchable unit.
|
||||
|
||||
This is intentionally limited to live open pages because Orca still is not an app-wide browsing history system. The goal is to help users recover something they already have open, not to introduce a second browsing history feature through the palette.
|
||||
|
||||
### 6.3 Why Not Terminal Tabs Yet
|
||||
|
||||
Terminal tabs are deliberately out of scope for text-first search in this change.
|
||||
|
||||
Reasons:
|
||||
|
||||
- terminal tab titles are less stable and less descriptive than browser titles
|
||||
- the meaningful part of a terminal session often lives in scrollback, not tab metadata
|
||||
- adding terminal tabs only because browser tabs are added would create a low-signal mixed palette
|
||||
|
||||
This design keeps the palette honest: it supports browser-page search because that metadata makes the target genuinely searchable. If Orca later wants an "all open items" palette, that should be a deliberate follow-up with result quality standards for each item type.
|
||||
|
||||
## 7. Architecture
|
||||
|
||||
### 7.1 Existing Pieces Reused
|
||||
|
||||
- `WorktreeJumpPalette.tsx` remains the base surface and interaction shell.
|
||||
- Existing worktree search logic remains intact for the `Worktrees` scope.
|
||||
- Browser search input comes from the live open `BrowserPage`s already held by renderer browser state.
|
||||
- Browser activation continues to use the existing browser-workspace activation pathway after worktree activation, then selects the matching page inside that workspace.
|
||||
- Main-process shortcut forwarding remains unchanged.
|
||||
|
||||
### 7.2 New Search Model
|
||||
|
||||
Add a palette view-model layer that produces typed results:
|
||||
|
||||
```ts
|
||||
type JumpPaletteScope = 'all' | 'worktrees' | 'browser-tabs'
|
||||
|
||||
type JumpPaletteResult =
|
||||
| { type: 'worktree'; worktreeId: string; score: number; ... }
|
||||
| {
|
||||
type: 'browser-page'
|
||||
worktreeId: string
|
||||
browserTabId: string
|
||||
browserPageId: string
|
||||
score: number
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The existing worktree search helper remains responsible for worktree scoring. A new browser-page search helper handles browser result scoring and formatting. The palette shell merges and sorts results only in `All`.
|
||||
|
||||
Why split the search helpers:
|
||||
|
||||
- worktree matching logic is already non-trivial and should not be regressed
|
||||
- browser result ranking has different signals than worktree ranking
|
||||
- typed results keep selection and rendering explicit instead of relying on ad hoc ID prefixes
|
||||
- page-level browser hits need both workspace ownership and page identity for activation
|
||||
|
||||
### 7.3 Focus and Close Semantics
|
||||
|
||||
The existing palette already manages focus restoration carefully. That logic should be extended, not replaced.
|
||||
|
||||
New rule:
|
||||
|
||||
- if the selected result is a browser page, the post-close focus path targets the active browser surface
|
||||
- otherwise preserve today's terminal/editor focus restoration behavior
|
||||
|
||||
### 7.4 System Context
|
||||
|
||||
```text
|
||||
+------------------+ +-----------------------+
|
||||
| Main Process | | Renderer Store |
|
||||
| shortcut forward | -----> | activeModal |
|
||||
| Cmd+J / Ctrl+... | | worktreesByRepo |
|
||||
+------------------+ | browser state |
|
||||
| activeWorktreeId |
|
||||
+-----------+-----------+
|
||||
|
|
||||
v
|
||||
+------------------------+
|
||||
| Cmd+J Jump Palette |
|
||||
| scopes + search + list |
|
||||
+-----+-------------+----+
|
||||
| |
|
||||
worktree hit | | browser-page hit
|
||||
v v
|
||||
+------------------+ +---------------------+
|
||||
| activate/reveal | | activate/reveal |
|
||||
| target worktree | | target worktree |
|
||||
+------------------+ +----------+----------+
|
||||
|
|
||||
v
|
||||
+----------------------+
|
||||
| activate browser |
|
||||
| workspace + page |
|
||||
| focus browser pane |
|
||||
+----------------------+
|
||||
```
|
||||
|
||||
### 7.5 Data Flows
|
||||
|
||||
#### Happy path: browser-page search and jump
|
||||
|
||||
```text
|
||||
Cmd+J -> palette opens in Worktrees -> user switches to Browser Tabs ->
|
||||
query matches browser page ->
|
||||
user presses Enter -> activateAndRevealWorktree(worktreeId) ->
|
||||
activate target browser workspace and page -> palette closes -> browser surface focused
|
||||
```
|
||||
|
||||
#### Nil path: user opens palette with no browser pages
|
||||
|
||||
```text
|
||||
Cmd+J -> palette opens -> Browser Tabs scope selected ->
|
||||
search model sees zero browser pages -> empty state shown -> no side effects
|
||||
```
|
||||
|
||||
#### Empty path: query yields no browser or worktree matches
|
||||
|
||||
```text
|
||||
query typed -> search returns [] -> scope-specific empty state rendered ->
|
||||
selection cleared or pinned to no result -> Enter does nothing
|
||||
```
|
||||
|
||||
#### Upstream error path: selected browser page disappears before activation
|
||||
|
||||
```text
|
||||
user selects browser result -> store lookup fails because page/worktree closed ->
|
||||
show toast error -> keep palette open if possible, otherwise close safely without switching
|
||||
```
|
||||
|
||||
## 8. Alternatives Considered
|
||||
|
||||
### 8.1 Dedicated browser-tab-only dialog
|
||||
|
||||
Pros:
|
||||
|
||||
- clearer mental model for the browser-specific job
|
||||
- no mixed-result ranking complexity
|
||||
|
||||
Cons:
|
||||
|
||||
- adds another shortcut and another navigation surface
|
||||
- weakens `Cmd+J` as the single place to jump around Orca
|
||||
|
||||
Decision: rejected for now. The scoped palette gives the same utility with less surface area.
|
||||
|
||||
### 8.2 Browser tabs only inside `Cmd+J`, no scopes
|
||||
|
||||
Pros:
|
||||
|
||||
- least new UI chrome
|
||||
|
||||
Cons:
|
||||
|
||||
- mixed results become harder to reason about
|
||||
- users cannot quickly answer "just show me browser tabs"
|
||||
|
||||
Decision: rejected. Explicit scopes are worth the small extra header chrome.
|
||||
|
||||
### 8.3 Expand immediately to terminals, files, and commands
|
||||
|
||||
Pros:
|
||||
|
||||
- one "go to anything" story
|
||||
|
||||
Cons:
|
||||
|
||||
- scope explosion
|
||||
- result quality is uneven across item types
|
||||
- much higher design and implementation complexity
|
||||
|
||||
Decision: rejected for v1. Start with the two jobs the user clearly asked for.
|
||||
|
||||
### 8.4 Make `All` the default scope
|
||||
|
||||
Pros:
|
||||
|
||||
- makes browser discovery visible immediately
|
||||
- creates a more obviously "global" first impression
|
||||
|
||||
Cons:
|
||||
|
||||
- breaks the current worktree-first contract of `Cmd+J`
|
||||
- makes the first screen depend on mixed ranking logic instead of today's predictable worktree list
|
||||
|
||||
Decision: rejected for v1. `All` remains available, but opening in `Worktrees` preserves muscle memory and keeps the expansion explicit.
|
||||
|
||||
### 8.5 Search browser workspaces instead of live pages
|
||||
|
||||
Pros:
|
||||
|
||||
- simpler indexing model
|
||||
- reuses the existing browser workspace abstraction directly
|
||||
|
||||
Cons:
|
||||
|
||||
- misses the page titles and URLs users actually remember
|
||||
- treats the container as the search target even when the user wants a specific page inside it
|
||||
|
||||
Decision: rejected. The palette should index the page the user is trying to recover, then use workspace and worktree context to explain where it lives.
|
||||
|
||||
### 8.6 Add true browser recency tracking for v1 ranking
|
||||
|
||||
Pros:
|
||||
|
||||
- could produce sharper empty-query ordering over time
|
||||
|
||||
Cons:
|
||||
|
||||
- requires new state and focus bookkeeping for a thin initial payoff
|
||||
- introduces ranking behavior that is harder to explain and debug
|
||||
|
||||
Decision: rejected for v1. Start with a deterministic context-first heuristic and revisit true recency only if usage shows the heuristic is insufficient.
|
||||
|
||||
## 9. Rollout
|
||||
|
||||
### Phase 1
|
||||
|
||||
- Add scoped header to the existing `Cmd+J` palette
|
||||
- Keep `Worktrees` as the default scope on open
|
||||
- Preserve worktree-only behavior under the `Worktrees` scope
|
||||
- Add browser-page search and activation
|
||||
- Search live open `BrowserPage`s rather than browser workspace shells
|
||||
- Add `All` merged ranking
|
||||
|
||||
### Phase 2 (optional follow-up)
|
||||
|
||||
- Evaluate whether users need additional scopes such as editor tabs or commands
|
||||
- Only add a new scope if it has a clear, high-signal search model
|
||||
|
||||
## 10. Open Questions
|
||||
|
||||
- Whether a later iteration should remember a last-used non-default scope without changing the default-open `Worktrees` contract
|
||||
- Whether browser-tab results should expose close actions from the palette in a later pass
|
||||
- Whether `All` should group results visually by type or keep one flat ranked list
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
/* oxlint-disable max-lines */
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Globe, Plus } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
CommandDialog,
|
||||
|
|
@ -9,7 +10,6 @@ import {
|
|||
CommandEmpty,
|
||||
CommandItem
|
||||
} from '@/components/ui/command'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { branchName } from '@/lib/git-utils'
|
||||
import { sortWorktreesRecent } from '@/components/sidebar/smart-sort'
|
||||
import StatusIndicator from '@/components/sidebar/StatusIndicator'
|
||||
|
|
@ -17,11 +17,48 @@ import { cn } from '@/lib/utils'
|
|||
import { getWorktreeStatus, getWorktreeStatusLabel } from '@/lib/worktree-status'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import { searchWorktrees, type MatchRange } from '@/lib/worktree-palette-search'
|
||||
import type { Worktree } from '../../../shared/types'
|
||||
import {
|
||||
searchWorktrees,
|
||||
type MatchRange,
|
||||
type PaletteSearchResult
|
||||
} from '@/lib/worktree-palette-search'
|
||||
import {
|
||||
isBlankBrowserUrl,
|
||||
searchBrowserPages,
|
||||
type BrowserPaletteSearchResult,
|
||||
type SearchableBrowserPage
|
||||
} from '@/lib/browser-palette-search'
|
||||
import {
|
||||
ORCA_BROWSER_FOCUS_REQUEST_EVENT,
|
||||
queueBrowserFocusRequest
|
||||
} from '@/components/browser-pane/browser-focus'
|
||||
import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types'
|
||||
import { isGitRepoKind } from '../../../shared/repo-kind'
|
||||
|
||||
// ─── Highlight helper ───────────────────────────────────────────────
|
||||
type PaletteScope = 'worktrees' | 'browser-tabs'
|
||||
|
||||
type WorktreePaletteItem = {
|
||||
id: string
|
||||
type: 'worktree'
|
||||
match: PaletteSearchResult
|
||||
worktree: Worktree
|
||||
}
|
||||
|
||||
type BrowserPaletteItem = {
|
||||
id: string
|
||||
type: 'browser-page'
|
||||
result: BrowserPaletteSearchResult
|
||||
}
|
||||
|
||||
type PaletteItem = WorktreePaletteItem | BrowserPaletteItem
|
||||
|
||||
type BrowserSelection = {
|
||||
worktree: Worktree
|
||||
workspace: BrowserWorkspace
|
||||
page: BrowserPage
|
||||
}
|
||||
|
||||
const SCOPE_ORDER: PaletteScope[] = ['worktrees', 'browser-tabs']
|
||||
|
||||
function HighlightedText({
|
||||
text,
|
||||
|
|
@ -62,7 +99,34 @@ function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Eleme
|
|||
)
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────
|
||||
function nextScope(scope: PaletteScope, direction: 1 | -1): PaletteScope {
|
||||
const index = SCOPE_ORDER.indexOf(scope)
|
||||
const nextIndex = (index + direction + SCOPE_ORDER.length) % SCOPE_ORDER.length
|
||||
return SCOPE_ORDER[nextIndex]
|
||||
}
|
||||
|
||||
function findBrowserSelection(
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
worktreeId: string
|
||||
): BrowserSelection | null {
|
||||
const state = useAppStore.getState()
|
||||
const page = (state.browserPagesByWorkspace[workspaceId] ?? []).find((p) => p.id === pageId)
|
||||
if (!page) {
|
||||
return null
|
||||
}
|
||||
const workspace = (state.browserTabsByWorktree[worktreeId] ?? []).find(
|
||||
(w) => w.id === workspaceId
|
||||
)
|
||||
if (!workspace) {
|
||||
return null
|
||||
}
|
||||
const worktree = findWorktreeById(state.worktreesByRepo, worktreeId)
|
||||
if (!worktree) {
|
||||
return null
|
||||
}
|
||||
return { page, workspace, worktree }
|
||||
}
|
||||
|
||||
export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
const visible = useAppStore((s) => s.activeModal === 'worktree-palette')
|
||||
|
|
@ -74,22 +138,25 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
const prCache = useAppStore((s) => s.prCache)
|
||||
const issueCache = useAppStore((s) => s.issueCache)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const activeTabType = useAppStore((s) => s.activeTabType)
|
||||
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
|
||||
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
|
||||
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const [selectedWorktreeId, setSelectedWorktreeId] = useState('')
|
||||
const [scope, setScope] = useState<PaletteScope>('worktrees')
|
||||
const [selectedItemId, setSelectedItemId] = useState('')
|
||||
const previousWorktreeIdRef = useRef<string | null>(null)
|
||||
const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal'>('terminal')
|
||||
const previousBrowserPageIdRef = useRef<string | null>(null)
|
||||
const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview')
|
||||
const wasVisibleRef = useRef(false)
|
||||
const skipRestoreFocusRef = useRef(false)
|
||||
const prevQueryRef = useRef('')
|
||||
const prevScopeRef = useRef<PaletteScope>('worktrees')
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Why: debounce the search query so the result list doesn't reshuffle on
|
||||
// every keystroke while the user is still typing. The input stays responsive
|
||||
// (controlled by `query`), but the heavier search + re-render is gated by
|
||||
// `debouncedQuery`. 150ms is fast enough to feel instant on a pause, slow
|
||||
// enough to skip intermediate keystrokes.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebouncedQuery(query), 150)
|
||||
return () => clearTimeout(id)
|
||||
|
|
@ -98,7 +165,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
const repoMap = useMemo(() => new Map(repos.map((r) => [r.id, r])), [repos])
|
||||
const canCreateWorktree = useMemo(() => repos.some((repo) => isGitRepoKind(repo)), [repos])
|
||||
|
||||
// All non-archived worktrees sorted by recent signals
|
||||
const sortedWorktrees = useMemo(() => {
|
||||
const all: Worktree[] = Object.values(worktreesByRepo)
|
||||
.flat()
|
||||
|
|
@ -106,89 +172,193 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
return sortWorktreesRecent(all, tabsByWorktree, repoMap, prCache)
|
||||
}, [worktreesByRepo, tabsByWorktree, repoMap, prCache])
|
||||
|
||||
// Search results
|
||||
const matches = useMemo(
|
||||
const browserSortedWorktrees = useMemo(() => {
|
||||
const all: Worktree[] = Object.values(worktreesByRepo).flat()
|
||||
// Why: browser-tab search is explicitly cross-worktree, so it must keep
|
||||
// indexing live browser pages even when their owning worktree is archived.
|
||||
return sortWorktreesRecent(all, tabsByWorktree, repoMap, prCache)
|
||||
}, [worktreesByRepo, tabsByWorktree, repoMap, prCache])
|
||||
|
||||
// Why: browser rows need worktree lookups for repo badge colors, and browser
|
||||
// search intentionally includes archived worktrees. This map must cover all
|
||||
// worktrees, not just the non-archived sortedWorktrees used for the Worktrees scope.
|
||||
const worktreeMap = useMemo(() => {
|
||||
const map = new Map<string, Worktree>()
|
||||
for (const worktree of browserSortedWorktrees) {
|
||||
map.set(worktree.id, worktree)
|
||||
}
|
||||
return map
|
||||
}, [browserSortedWorktrees])
|
||||
|
||||
const worktreeOrder = useMemo(
|
||||
() => new Map(browserSortedWorktrees.map((worktree, index) => [worktree.id, index])),
|
||||
[browserSortedWorktrees]
|
||||
)
|
||||
|
||||
const worktreeMatches = useMemo(
|
||||
() => searchWorktrees(sortedWorktrees, debouncedQuery.trim(), repoMap, prCache, issueCache),
|
||||
[sortedWorktrees, debouncedQuery, repoMap, prCache, issueCache]
|
||||
)
|
||||
const createWorktreeName = debouncedQuery.trim()
|
||||
// Why: only surface the create-worktree action when the query yields no matches,
|
||||
// so it doesn't clutter the list when existing worktrees already satisfy the search.
|
||||
const showCreateAction =
|
||||
canCreateWorktree && createWorktreeName.length > 0 && matches.length === 0
|
||||
|
||||
// Build a map of worktreeId -> Worktree for quick lookup
|
||||
const worktreeMap = useMemo(() => {
|
||||
const map = new Map<string, Worktree>()
|
||||
for (const w of sortedWorktrees) {
|
||||
map.set(w.id, w)
|
||||
const browserPageEntries = useMemo<SearchableBrowserPage[]>(() => {
|
||||
const entries: SearchableBrowserPage[] = []
|
||||
for (const worktree of browserSortedWorktrees) {
|
||||
const repoName = repoMap.get(worktree.repoId)?.displayName ?? ''
|
||||
const worktreeSortIndex = worktreeOrder.get(worktree.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const workspaces = browserTabsByWorktree[worktree.id] ?? []
|
||||
for (const workspace of workspaces) {
|
||||
const pages = browserPagesByWorkspace[workspace.id] ?? []
|
||||
for (const page of pages) {
|
||||
entries.push({
|
||||
page,
|
||||
workspace,
|
||||
worktree,
|
||||
repoName,
|
||||
worktreeSortIndex,
|
||||
isCurrentPage:
|
||||
workspace.id === activeBrowserTabId && workspace.activePageId === page.id,
|
||||
isCurrentWorktree: activeWorktreeId === worktree.id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [sortedWorktrees])
|
||||
return entries
|
||||
}, [
|
||||
activeBrowserTabId,
|
||||
activeWorktreeId,
|
||||
browserPagesByWorkspace,
|
||||
browserTabsByWorktree,
|
||||
browserSortedWorktrees,
|
||||
repoMap,
|
||||
worktreeOrder
|
||||
])
|
||||
|
||||
const browserMatches = useMemo(
|
||||
() => searchBrowserPages(browserPageEntries, debouncedQuery.trim()),
|
||||
[browserPageEntries, debouncedQuery]
|
||||
)
|
||||
|
||||
const worktreeItems = useMemo<WorktreePaletteItem[]>(
|
||||
() =>
|
||||
worktreeMatches
|
||||
.map((match) => {
|
||||
const worktree = worktreeMap.get(match.worktreeId)
|
||||
if (!worktree) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: `worktree:${worktree.id}`,
|
||||
type: 'worktree' as const,
|
||||
match,
|
||||
worktree
|
||||
}
|
||||
})
|
||||
.filter((item): item is WorktreePaletteItem => item !== null),
|
||||
[worktreeMap, worktreeMatches]
|
||||
)
|
||||
|
||||
const browserItems = useMemo<BrowserPaletteItem[]>(
|
||||
() =>
|
||||
browserMatches.map((result) => ({
|
||||
id: `browser-page:${result.pageId}`,
|
||||
type: 'browser-page' as const,
|
||||
result
|
||||
})),
|
||||
[browserMatches]
|
||||
)
|
||||
|
||||
const visibleItems = useMemo<PaletteItem[]>(() => {
|
||||
if (scope === 'browser-tabs') {
|
||||
return browserItems
|
||||
}
|
||||
return worktreeItems
|
||||
}, [browserItems, scope, worktreeItems])
|
||||
|
||||
const createWorktreeName = debouncedQuery.trim()
|
||||
const showCreateAction =
|
||||
scope === 'worktrees' &&
|
||||
canCreateWorktree &&
|
||||
createWorktreeName.length > 0 &&
|
||||
worktreeItems.length === 0
|
||||
|
||||
// Loading state: repos exist but worktreesByRepo is still empty
|
||||
const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0
|
||||
const hasWorktrees = sortedWorktrees.length > 0
|
||||
const hasAnyWorktrees = sortedWorktrees.length > 0
|
||||
const hasAnyBrowserPages = browserPageEntries.length > 0
|
||||
const hasQuery = debouncedQuery.trim().length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && !wasVisibleRef.current) {
|
||||
// Why: this dialog opens from external store state, so session reset must
|
||||
// follow the controlled `visible` flag instead of relying on Radix open callbacks.
|
||||
// Why: the palette now supports multiple scopes, but Cmd+J still has a
|
||||
// worktree-first contract. Reset to that scope on every open so browser
|
||||
// exploration remains opt-in rather than sticky across sessions.
|
||||
previousWorktreeIdRef.current = activeWorktreeId
|
||||
previousActiveTabTypeRef.current = activeTabType
|
||||
previousBrowserPageIdRef.current =
|
||||
activeWorktreeId && activeTabType === 'browser'
|
||||
? ((browserTabsByWorktree[activeWorktreeId] ?? []).find(
|
||||
(workspace) => workspace.id === activeBrowserTabId
|
||||
)?.activePageId ?? null)
|
||||
: null
|
||||
// Why: capture which browser surface had focus *before* Radix Dialog
|
||||
// steals it. By onOpenAutoFocus time, document.activeElement has already
|
||||
// moved to the dialog content, so address-bar detection must happen here.
|
||||
previousBrowserFocusTargetRef.current =
|
||||
activeTabType === 'browser' &&
|
||||
document.activeElement instanceof HTMLElement &&
|
||||
document.activeElement.closest('[data-orca-browser-address-bar="true"]')
|
||||
? 'address-bar'
|
||||
: 'webview'
|
||||
skipRestoreFocusRef.current = false
|
||||
prevQueryRef.current = ''
|
||||
prevScopeRef.current = 'worktrees'
|
||||
setScope('worktrees')
|
||||
setQuery('')
|
||||
setDebouncedQuery('')
|
||||
setSelectedWorktreeId('')
|
||||
setSelectedItemId('')
|
||||
}
|
||||
|
||||
wasVisibleRef.current = visible
|
||||
}, [visible, activeWorktreeId])
|
||||
}, [activeBrowserTabId, activeTabType, activeWorktreeId, browserTabsByWorktree, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
const queryChanged = debouncedQuery !== prevQueryRef.current
|
||||
const scopeChanged = scope !== prevScopeRef.current
|
||||
prevQueryRef.current = debouncedQuery
|
||||
prevScopeRef.current = scope
|
||||
|
||||
const firstSelectableId = showCreateAction ? '__create_worktree__' : null
|
||||
|
||||
// Why: when the search query changes, the results reorder to reflect new
|
||||
// relevance ranking. Always snap the selection to the top result so the
|
||||
// user sees the best match highlighted, and scroll the list to the top so
|
||||
// the selected item is visible without the user having to scroll up.
|
||||
if (queryChanged) {
|
||||
if (matches.length > 0) {
|
||||
setSelectedWorktreeId(matches[0].worktreeId)
|
||||
if (queryChanged || scopeChanged) {
|
||||
if (visibleItems.length > 0) {
|
||||
setSelectedItemId(visibleItems[0].id)
|
||||
} else {
|
||||
setSelectedWorktreeId(firstSelectableId ?? '')
|
||||
setSelectedItemId(firstSelectableId ?? '')
|
||||
}
|
||||
listRef.current?.scrollTo(0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
setSelectedWorktreeId(firstSelectableId ?? '')
|
||||
if (visibleItems.length === 0) {
|
||||
setSelectedItemId(firstSelectableId ?? '')
|
||||
return
|
||||
}
|
||||
if (selectedWorktreeId === '__create_worktree__' && showCreateAction) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!matches.some((match) => match.worktreeId === selectedWorktreeId) &&
|
||||
selectedWorktreeId !== firstSelectableId
|
||||
) {
|
||||
// Why: the palette keeps live recent ordering while open. Control cmdk's
|
||||
// selected value by worktree ID so background re-sorts keep the same
|
||||
// logical worktree selected instead of drifting to a new visual index.
|
||||
setSelectedWorktreeId(firstSelectableId ?? matches[0].worktreeId)
|
||||
}
|
||||
}, [visible, matches, selectedWorktreeId, showCreateAction, debouncedQuery])
|
||||
|
||||
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.
|
||||
if (selectedItemId === '__create_worktree__' && showCreateAction) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!visibleItems.some((item) => item.id === selectedItemId) &&
|
||||
selectedItemId !== firstSelectableId
|
||||
) {
|
||||
setSelectedItemId(firstSelectableId ?? visibleItems[0].id)
|
||||
}
|
||||
}, [debouncedQuery, scope, selectedItemId, showCreateAction, visible, visibleItems])
|
||||
|
||||
const focusFallbackSurface = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const xterm = document.querySelector('.xterm-helper-textarea') as HTMLElement | null
|
||||
|
|
@ -196,7 +366,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
xterm.focus()
|
||||
return
|
||||
}
|
||||
// Fallback: try Monaco editor
|
||||
const monaco = document.querySelector('.monaco-editor textarea') as HTMLElement | null
|
||||
if (monaco) {
|
||||
monaco.focus()
|
||||
|
|
@ -205,6 +374,18 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
})
|
||||
}, [])
|
||||
|
||||
const requestBrowserFocus = useCallback(
|
||||
(detail: { pageId: string; target: 'webview' | 'address-bar' }) => {
|
||||
queueBrowserFocusRequest(detail)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ORCA_BROWSER_FOCUS_REQUEST_EVENT, {
|
||||
detail
|
||||
})
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
|
|
@ -212,96 +393,204 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
}
|
||||
|
||||
closeModal()
|
||||
if (previousWorktreeIdRef.current && !skipRestoreFocusRef.current) {
|
||||
focusActiveSurface()
|
||||
if (skipRestoreFocusRef.current) {
|
||||
return
|
||||
}
|
||||
if (previousActiveTabTypeRef.current === 'browser' && previousBrowserPageIdRef.current) {
|
||||
// Why: dismissing Cmd+J from a browser surface should return focus to
|
||||
// that page, not fall through to the generic terminal/editor fallback.
|
||||
requestBrowserFocus({
|
||||
pageId: previousBrowserPageIdRef.current,
|
||||
target: previousBrowserFocusTargetRef.current
|
||||
})
|
||||
return
|
||||
}
|
||||
if (previousWorktreeIdRef.current) {
|
||||
focusFallbackSurface()
|
||||
}
|
||||
},
|
||||
[closeModal, focusActiveSurface]
|
||||
[closeModal, focusFallbackSurface, requestBrowserFocus]
|
||||
)
|
||||
|
||||
const handleSelect = useCallback(
|
||||
const handleSelectWorktree = useCallback(
|
||||
(worktreeId: string) => {
|
||||
const state = useAppStore.getState()
|
||||
const wt = findWorktreeById(state.worktreesByRepo, worktreeId)
|
||||
if (!wt) {
|
||||
const worktree = findWorktreeById(useAppStore.getState().worktreesByRepo, worktreeId)
|
||||
if (!worktree) {
|
||||
toast.error('Worktree no longer exists')
|
||||
return
|
||||
}
|
||||
activateAndRevealWorktree(worktreeId)
|
||||
skipRestoreFocusRef.current = true
|
||||
closeModal()
|
||||
setSelectedWorktreeId('')
|
||||
focusActiveSurface()
|
||||
setSelectedItemId('')
|
||||
focusFallbackSurface()
|
||||
},
|
||||
[closeModal, focusActiveSurface]
|
||||
[closeModal, focusFallbackSurface]
|
||||
)
|
||||
|
||||
const handleSelectBrowserPage = useCallback(
|
||||
(result: BrowserPaletteSearchResult) => {
|
||||
const { pageId, workspaceId, worktreeId } = result
|
||||
const selection = findBrowserSelection(pageId, workspaceId, worktreeId)
|
||||
if (!selection) {
|
||||
toast.error('Browser page no longer exists')
|
||||
return
|
||||
}
|
||||
// Why: capture the workspace and page info before activateAndRevealWorktree
|
||||
// mutates store state. Store cascades during worktree activation can remap
|
||||
// browser workspace state, making a second findBrowserSelection unreliable.
|
||||
const { worktree, workspace, page } = selection
|
||||
const activated = activateAndRevealWorktree(worktree.id)
|
||||
if (!activated) {
|
||||
toast.error('Worktree no longer exists')
|
||||
return
|
||||
}
|
||||
|
||||
const state = useAppStore.getState()
|
||||
state.setActiveBrowserTab(workspace.id)
|
||||
state.setActiveBrowserPage(workspace.id, pageId)
|
||||
skipRestoreFocusRef.current = true
|
||||
closeModal()
|
||||
setSelectedItemId('')
|
||||
requestBrowserFocus({
|
||||
pageId,
|
||||
target: isBlankBrowserUrl(page.url) ? 'address-bar' : 'webview'
|
||||
})
|
||||
},
|
||||
[closeModal, requestBrowserFocus]
|
||||
)
|
||||
|
||||
const handleSelectItem = useCallback(
|
||||
(item: PaletteItem) => {
|
||||
if (item.type === 'worktree') {
|
||||
handleSelectWorktree(item.worktree.id)
|
||||
} else {
|
||||
handleSelectBrowserPage(item.result)
|
||||
}
|
||||
},
|
||||
[handleSelectBrowserPage, handleSelectWorktree]
|
||||
)
|
||||
|
||||
const handleCreateWorktree = useCallback(() => {
|
||||
// Why: when Cmd+J hands off to the create dialog, that new modal owns focus.
|
||||
// Re-running the palette's terminal/editor focus restore races the dialog's
|
||||
// autofocus and can pull keyboard input away from the name field.
|
||||
skipRestoreFocusRef.current = true
|
||||
closeModal()
|
||||
// Why: we open create-worktree in a microtask so Radix Dialog fully unmounts
|
||||
// before the next modal mounts, avoiding stacked-dialog focus conflicts.
|
||||
queueMicrotask(() =>
|
||||
openModal('create-worktree', createWorktreeName ? { prefilledName: createWorktreeName } : {})
|
||||
)
|
||||
}, [closeModal, createWorktreeName, openModal])
|
||||
|
||||
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 worktreeResultCount = matches.length
|
||||
const actionCount = showCreateAction ? 1 : 0
|
||||
const handleOpenAutoFocus = useCallback((_event: Event) => {
|
||||
// No-op: address-bar detection is handled in the visible effect before
|
||||
// Radix steals focus. This callback exists only to satisfy the prop API.
|
||||
}, [])
|
||||
|
||||
const handleInputKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'Tab') {
|
||||
return
|
||||
}
|
||||
// Why: the scope chips are part of the palette's search model, not the
|
||||
// browser's focus ring. Cycling them with Tab keeps the input focused and
|
||||
// avoids turning scope changes into a pointer-only affordance.
|
||||
event.preventDefault()
|
||||
setScope((current) => nextScope(current, event.shiftKey ? -1 : 1))
|
||||
}, [])
|
||||
|
||||
const title = scope === 'browser-tabs' ? 'Open Browser Tab' : 'Open Worktree'
|
||||
const description =
|
||||
scope === 'browser-tabs'
|
||||
? 'Search open browser pages across all worktrees'
|
||||
: 'Search across all worktrees by name, branch, comment, PR, or issue'
|
||||
const placeholder =
|
||||
scope === 'browser-tabs' ? 'Search open browser tabs...' : 'Jump to worktree...'
|
||||
|
||||
const resultCount = visibleItems.length
|
||||
const emptyState = (() => {
|
||||
if (scope === 'browser-tabs') {
|
||||
return hasAnyBrowserPages && hasQuery
|
||||
? {
|
||||
title: 'No browser tabs match your search',
|
||||
subtitle: 'Try a page title, URL, worktree name, or repo name.'
|
||||
}
|
||||
: {
|
||||
title: 'No open browser tabs',
|
||||
subtitle: 'Open a page in Orca and it will show up here.'
|
||||
}
|
||||
}
|
||||
return hasAnyWorktrees && hasQuery
|
||||
? {
|
||||
title: 'No worktrees match your search',
|
||||
subtitle: 'Try a name, branch, repo, comment, PR, or issue.'
|
||||
}
|
||||
: {
|
||||
title: 'No active worktrees',
|
||||
subtitle: 'Create one to get started, then jump back here any time.'
|
||||
}
|
||||
})()
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={visible}
|
||||
onOpenChange={handleOpenChange}
|
||||
shouldFilter={false}
|
||||
onOpenAutoFocus={handleOpenAutoFocus}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
title="Open Worktree"
|
||||
description="Search across all worktrees by name, branch, comment, PR, or issue"
|
||||
title={title}
|
||||
description={description}
|
||||
overlayClassName="bg-black/55 backdrop-blur-[2px]"
|
||||
contentClassName="top-[13%] w-[736px] max-w-[94vw] overflow-hidden rounded-xl border border-border/70 bg-background/96 shadow-[0_26px_84px_rgba(0,0,0,0.32)] backdrop-blur-xl"
|
||||
commandProps={{
|
||||
loop: true,
|
||||
value: selectedWorktreeId,
|
||||
onValueChange: setSelectedWorktreeId,
|
||||
value: selectedItemId,
|
||||
onValueChange: setSelectedItemId,
|
||||
className: 'bg-transparent'
|
||||
}}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder="Jump to worktree..."
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
wrapperClassName="mx-3 mt-3 rounded-lg border border-border/55 bg-muted/28 px-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
|
||||
iconClassName="mr-2.5 h-4 w-4 text-muted-foreground/60"
|
||||
className="h-12 text-[14px] placeholder:text-muted-foreground/75"
|
||||
/>
|
||||
<CommandList ref={listRef} className="max-h-[min(460px,62vh)] px-2.5 pb-2.5 pt-1.5">
|
||||
<div role="tablist" className="mx-3 mt-2 flex items-center gap-1.5 px-0.5">
|
||||
{SCOPE_ORDER.map((candidate) => {
|
||||
const active = candidate === scope
|
||||
const label = candidate === 'worktrees' ? 'Worktrees' : 'Browser Tabs'
|
||||
return (
|
||||
<button
|
||||
key={candidate}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => setScope(candidate)}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors',
|
||||
active
|
||||
? 'border-border bg-accent/80 text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<CommandList ref={listRef} className="max-h-[min(460px,62vh)] px-2.5 pb-2.5 pt-2">
|
||||
{isLoading ? (
|
||||
<PaletteState
|
||||
title="Loading worktrees"
|
||||
subtitle="Gathering your recent worktrees and activity state."
|
||||
title="Loading jump targets"
|
||||
subtitle="Gathering your recent worktrees and open browser pages."
|
||||
/>
|
||||
) : !hasWorktrees && !showCreateAction ? (
|
||||
) : visibleItems.length === 0 && !showCreateAction ? (
|
||||
<CommandEmpty className="py-0">
|
||||
<PaletteState
|
||||
title="No active worktrees"
|
||||
subtitle="Create one to get started, then jump back here any time."
|
||||
/>
|
||||
</CommandEmpty>
|
||||
) : matches.length === 0 && !showCreateAction ? (
|
||||
<CommandEmpty className="py-0">
|
||||
<PaletteState
|
||||
title="No worktrees match your search"
|
||||
subtitle="Try a name, branch, repo, comment, PR, or issue."
|
||||
/>
|
||||
<PaletteState title={emptyState.title} subtitle={emptyState.subtitle} />
|
||||
</CommandEmpty>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -321,107 +610,183 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
</div>
|
||||
</CommandItem>
|
||||
)}
|
||||
{matches.length === 0 && query.trim() && (
|
||||
<div className="px-2 pb-2 pt-1">
|
||||
<PaletteState
|
||||
title="No worktrees match your search"
|
||||
subtitle="Try a name, branch, repo, comment, PR, or issue."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{matches.map((match) => {
|
||||
const w = worktreeMap.get(match.worktreeId)
|
||||
if (!w) {
|
||||
return null
|
||||
{visibleItems.map((item) => {
|
||||
if (item.type === 'worktree') {
|
||||
const worktree = item.worktree
|
||||
const repo = repoMap.get(worktree.repoId)
|
||||
const repoName = repo?.displayName ?? ''
|
||||
const branch = branchName(worktree.branch)
|
||||
const status = getWorktreeStatus(
|
||||
tabsByWorktree[worktree.id] ?? [],
|
||||
browserTabsByWorktree[worktree.id] ?? []
|
||||
)
|
||||
const statusLabel = getWorktreeStatusLabel(status)
|
||||
const isCurrentWorktree = activeWorktreeId === worktree.id
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
onSelect={() => handleSelectItem(item)}
|
||||
data-current={isCurrentWorktree ? 'true' : undefined}
|
||||
className={cn(
|
||||
'group mx-0.5 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]',
|
||||
'data-[selected=true]:border-border data-[selected=true]:bg-neutral-100 data-[selected=true]:text-foreground dark:data-[selected=true]:bg-neutral-800'
|
||||
)}
|
||||
>
|
||||
<div className="flex w-4 shrink-0 items-center justify-center self-start pt-0.5">
|
||||
<StatusIndicator status={status} aria-hidden="true" />
|
||||
<span className="sr-only">{statusLabel}</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{item.match.displayNameRange ? (
|
||||
<HighlightedText
|
||||
text={worktree.displayName}
|
||||
matchRange={item.match.displayNameRange}
|
||||
/>
|
||||
) : (
|
||||
worktree.displayName
|
||||
)}
|
||||
</span>
|
||||
{isCurrentWorktree && (
|
||||
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
{worktree.isMainWorktree && (
|
||||
<span className="shrink-0 self-center rounded border border-muted-foreground/30 bg-muted-foreground/5 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground">
|
||||
primary
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground/45">·</span>
|
||||
<span className="truncate text-[12px] font-medium text-muted-foreground/92">
|
||||
{item.match.branchRange ? (
|
||||
<HighlightedText
|
||||
text={branch}
|
||||
matchRange={item.match.branchRange}
|
||||
/>
|
||||
) : (
|
||||
branch
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{item.match.supportingText && (
|
||||
<div className="mt-1.5 flex min-w-0 items-start gap-2 text-[12px] leading-5 text-muted-foreground/88">
|
||||
<span className="shrink-0 rounded-full border border-border/45 bg-background/45 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.12em] text-muted-foreground/75">
|
||||
{item.match.supportingText.label}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
<HighlightedText
|
||||
text={item.match.supportingText.text}
|
||||
matchRange={item.match.supportingText.matchRange}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
{repoName && (
|
||||
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-1.5 shrink-0 rounded-full"
|
||||
style={
|
||||
repo?.badgeColor
|
||||
? { backgroundColor: repo.badgeColor }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{item.match.repoRange ? (
|
||||
<HighlightedText
|
||||
text={repoName}
|
||||
matchRange={item.match.repoRange}
|
||||
/>
|
||||
) : (
|
||||
repoName
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
const repo = repoMap.get(w.repoId)
|
||||
const repoName = repo?.displayName ?? ''
|
||||
const branch = branchName(w.branch)
|
||||
const status = getWorktreeStatus(
|
||||
tabsByWorktree[w.id] ?? [],
|
||||
browserTabsByWorktree[w.id] ?? []
|
||||
)
|
||||
const statusLabel = getWorktreeStatusLabel(status)
|
||||
const isCurrentWorktree = activeWorktreeId === w.id
|
||||
|
||||
const result = item.result
|
||||
const browserWorktree = worktreeMap.get(result.worktreeId)
|
||||
const browserRepo = browserWorktree ? repoMap.get(browserWorktree.repoId) : undefined
|
||||
const browserRepoName = browserRepo?.displayName ?? result.repoName
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={w.id}
|
||||
value={w.id}
|
||||
onSelect={() => handleSelect(w.id)}
|
||||
data-current={isCurrentWorktree ? 'true' : undefined}
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
onSelect={() => handleSelectItem(item)}
|
||||
className={cn(
|
||||
'group mx-0.5 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]',
|
||||
'data-[selected=true]:border-border data-[selected=true]:bg-neutral-100 data-[selected=true]:text-foreground dark:data-[selected=true]:bg-neutral-800'
|
||||
)}
|
||||
>
|
||||
<div className="flex w-4 shrink-0 items-center justify-center self-start pt-0.5">
|
||||
<StatusIndicator status={status} aria-hidden="true" />
|
||||
<span className="sr-only">{statusLabel}</span>
|
||||
<div className="flex w-4 shrink-0 items-center justify-center self-start pt-0.5 text-muted-foreground/85">
|
||||
<Globe className="size-3.5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{match.displayNameRange ? (
|
||||
<HighlightedText
|
||||
text={w.displayName}
|
||||
matchRange={match.displayNameRange}
|
||||
/>
|
||||
) : (
|
||||
w.displayName
|
||||
)}
|
||||
<span className="max-w-[40%] shrink-0 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
<HighlightedText text={result.title} matchRange={result.titleRange} />
|
||||
</span>
|
||||
{isCurrentWorktree && (
|
||||
{result.isCurrentPage && (
|
||||
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
|
||||
Current
|
||||
Current Tab
|
||||
</span>
|
||||
)}
|
||||
{w.isMainWorktree && (
|
||||
<span className="shrink-0 self-center rounded border border-muted-foreground/30 bg-muted-foreground/5 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground">
|
||||
primary
|
||||
{!result.isCurrentPage && result.isCurrentWorktree && (
|
||||
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
|
||||
Current Worktree
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground/45">·</span>
|
||||
<span className="truncate text-[12px] font-medium text-muted-foreground/92">
|
||||
{match.branchRange ? (
|
||||
<HighlightedText text={branch} matchRange={match.branchRange} />
|
||||
) : (
|
||||
branch
|
||||
)}
|
||||
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
|
||||
<HighlightedText
|
||||
text={result.secondaryText}
|
||||
matchRange={result.secondaryRange}
|
||||
/>
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground/45">·</span>
|
||||
<span className="shrink-0 text-[12px] font-medium text-muted-foreground/92">
|
||||
<HighlightedText
|
||||
text={result.worktreeName}
|
||||
matchRange={result.worktreeRange}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{match.supportingText && (
|
||||
<div className="mt-1.5 flex min-w-0 items-start gap-2 text-[12px] leading-5 text-muted-foreground/88">
|
||||
<span className="shrink-0 rounded-full border border-border/45 bg-background/45 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.12em] text-muted-foreground/75">
|
||||
{match.supportingText.label}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
<HighlightedText
|
||||
text={match.supportingText.text}
|
||||
matchRange={match.supportingText.matchRange}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
{repoName && (
|
||||
{browserRepoName && (
|
||||
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-1.5 shrink-0 rounded-full"
|
||||
style={
|
||||
repo?.badgeColor ? { backgroundColor: repo.badgeColor } : undefined
|
||||
browserRepo?.badgeColor
|
||||
? { backgroundColor: browserRepo.badgeColor }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{match.repoRange ? (
|
||||
<HighlightedText text={repoName} matchRange={match.repoRange} />
|
||||
) : (
|
||||
repoName
|
||||
)}
|
||||
<HighlightedText
|
||||
text={browserRepoName}
|
||||
matchRange={result.repoRange}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -438,17 +803,18 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
<div className="flex items-center gap-2">
|
||||
<FooterKey>Enter</FooterKey>
|
||||
<span>Open</span>
|
||||
<FooterKey>Tab</FooterKey>
|
||||
<span>Switch</span>
|
||||
<FooterKey>Esc</FooterKey>
|
||||
<span>Close</span>
|
||||
<FooterKey>↑↓</FooterKey>
|
||||
<span>Move</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Accessibility: announce result count changes */}
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{query.trim()
|
||||
? `${worktreeResultCount} worktrees found${actionCount ? ', create new worktree action available' : ''}`
|
||||
: `${worktreeResultCount} worktrees available${actionCount ? ', create new worktree action available' : ''}`}
|
||||
{debouncedQuery.trim()
|
||||
? `${resultCount} results found in ${scope === 'worktrees' ? 'worktrees' : 'browser tabs'}${showCreateAction ? ', create new worktree action available' : ''}`
|
||||
: `${resultCount} ${scope === 'worktrees' ? 'worktrees' : 'browser tabs'} available${showCreateAction ? ', create new worktree action available' : ''}`}
|
||||
</div>
|
||||
</CommandDialog>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ import { useGrabMode } from './useGrabMode'
|
|||
import { formatGrabPayloadAsText } from './GrabConfirmationSheet'
|
||||
import { isEditableKeyboardTarget } from './browser-keyboard'
|
||||
import BrowserFind from './BrowserFind'
|
||||
import {
|
||||
consumeBrowserFocusRequest,
|
||||
ORCA_BROWSER_FOCUS_REQUEST_EVENT,
|
||||
type BrowserFocusRequestDetail
|
||||
} from './browser-focus'
|
||||
import {
|
||||
formatByteCount,
|
||||
formatDownloadFinishedNotice,
|
||||
|
|
@ -312,7 +317,8 @@ export default function BrowserPane({
|
|||
}): React.JSX.Element {
|
||||
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
|
||||
const browserPages = browserPagesByWorkspace[browserTab.id] ?? EMPTY_BROWSER_PAGES
|
||||
const activeBrowserPage = browserPages[0] ?? null
|
||||
const activeBrowserPage =
|
||||
browserPages.find((page) => page.id === browserTab.activePageId) ?? browserPages[0] ?? null
|
||||
const updateBrowserPageState = useAppStore((s) => s.updateBrowserPageState)
|
||||
const setBrowserPageUrl = useAppStore((s) => s.setBrowserPageUrl)
|
||||
|
||||
|
|
@ -718,6 +724,70 @@ function BrowserPagePane({
|
|||
})
|
||||
}, [focusAddressBarNow, isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
const focusTarget = consumeBrowserFocusRequest(browserTab.id)
|
||||
if (!focusTarget) {
|
||||
return
|
||||
}
|
||||
keepAddressBarFocusRef.current = focusTarget === 'address-bar'
|
||||
let cancelled = false
|
||||
let frameId = 0
|
||||
let attempts = 0
|
||||
const runFocus = (): void => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
const didFocus = focusTarget === 'address-bar' ? focusAddressBarNow() : focusWebviewNow()
|
||||
attempts += 1
|
||||
if (!didFocus && attempts < 6) {
|
||||
frameId = window.requestAnimationFrame(runFocus)
|
||||
}
|
||||
}
|
||||
// Why: jump-palette browser focus can be queued before the target page
|
||||
// pane mounts. Persisting the request outside React lets the active page
|
||||
// claim it once mounted instead of depending on a transient event race.
|
||||
frameId = window.requestAnimationFrame(runFocus)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.cancelAnimationFrame(frameId)
|
||||
}
|
||||
}, [browserTab.id, focusAddressBarNow, focusWebviewNow, isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
const handleBrowserFocusRequest = (event: Event): void => {
|
||||
const detail = (event as CustomEvent<BrowserFocusRequestDetail>).detail
|
||||
if (!detail || detail.pageId !== browserTab.id) {
|
||||
return
|
||||
}
|
||||
const focusTarget = consumeBrowserFocusRequest(browserTab.id)
|
||||
if (!focusTarget) {
|
||||
return
|
||||
}
|
||||
if (focusTarget === 'address-bar') {
|
||||
// Why: palette-triggered address-bar focus has to survive the same
|
||||
// follow-up browser load events as the existing blank-tab path.
|
||||
keepAddressBarFocusRef.current = true
|
||||
focusAddressBarNow()
|
||||
return
|
||||
}
|
||||
keepAddressBarFocusRef.current = false
|
||||
focusWebviewNow()
|
||||
}
|
||||
// Why: queued focus lets a page claim a request after mount, but palette
|
||||
// re-selecting an already-active page never remounts. Listening for the
|
||||
// matching event lets the active pane consume the durable request
|
||||
// immediately without regressing the mount/activation path above.
|
||||
window.addEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, handleBrowserFocusRequest)
|
||||
return () =>
|
||||
window.removeEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, handleBrowserFocusRequest)
|
||||
}, [browserTab.id, focusAddressBarNow, focusWebviewNow, isActive])
|
||||
|
||||
// Cmd/Ctrl+F — find in page (renderer path: focus on browser chrome)
|
||||
// Why: unlike grab-mode shortcuts (bare C/S) which skip editable targets,
|
||||
// Cmd+F is a modified chord that should always open find — even from the
|
||||
|
|
@ -1765,6 +1835,7 @@ function BrowserPagePane({
|
|||
ref={addressBarInputRef}
|
||||
value={addressBarValue}
|
||||
onChange={(event) => setAddressBarValue(event.target.value)}
|
||||
data-orca-browser-address-bar="true"
|
||||
className="h-auto border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { consumeBrowserFocusRequest, queueBrowserFocusRequest } from './browser-focus'
|
||||
|
||||
describe('browser-focus', () => {
|
||||
it('queues and consumes one browser focus request per page id', () => {
|
||||
queueBrowserFocusRequest({ pageId: 'page-1', target: 'webview' })
|
||||
|
||||
expect(consumeBrowserFocusRequest('page-1')).toBe('webview')
|
||||
expect(consumeBrowserFocusRequest('page-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('overwrites older requests for the same page id', () => {
|
||||
queueBrowserFocusRequest({ pageId: 'page-2', target: 'webview' })
|
||||
queueBrowserFocusRequest({ pageId: 'page-2', target: 'address-bar' })
|
||||
|
||||
expect(consumeBrowserFocusRequest('page-2')).toBe('address-bar')
|
||||
})
|
||||
|
||||
it('returns null for a page id that was never queued', () => {
|
||||
expect(consumeBrowserFocusRequest('nonexistent-page')).toBeNull()
|
||||
})
|
||||
})
|
||||
23
src/renderer/src/components/browser-pane/browser-focus.ts
Normal file
23
src/renderer/src/components/browser-pane/browser-focus.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export type BrowserFocusTarget = 'webview' | 'address-bar'
|
||||
|
||||
export type BrowserFocusRequestDetail = {
|
||||
pageId: string
|
||||
target: BrowserFocusTarget
|
||||
}
|
||||
|
||||
export const ORCA_BROWSER_FOCUS_REQUEST_EVENT = 'orca:browser-focus-request'
|
||||
|
||||
const pendingBrowserFocusByPageId = new Map<string, BrowserFocusTarget>()
|
||||
|
||||
export function queueBrowserFocusRequest(detail: BrowserFocusRequestDetail): void {
|
||||
pendingBrowserFocusByPageId.set(detail.pageId, detail.target)
|
||||
}
|
||||
|
||||
export function consumeBrowserFocusRequest(pageId: string): BrowserFocusTarget | null {
|
||||
const pending = pendingBrowserFocusByPageId.get(pageId) ?? null
|
||||
if (!pending) {
|
||||
return null
|
||||
}
|
||||
pendingBrowserFocusByPageId.delete(pageId)
|
||||
return pending
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ function CommandDialog({
|
|||
title = 'Command Palette',
|
||||
description = 'Search for a command to run...',
|
||||
shouldFilter,
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
contentClassName,
|
||||
overlayClassName,
|
||||
|
|
@ -34,6 +35,7 @@ function CommandDialog({
|
|||
title?: string
|
||||
description?: string
|
||||
shouldFilter?: boolean
|
||||
onOpenAutoFocus?: (e: Event) => void
|
||||
onCloseAutoFocus?: (e: Event) => void
|
||||
contentClassName?: string
|
||||
overlayClassName?: string
|
||||
|
|
@ -55,6 +57,7 @@ function CommandDialog({
|
|||
'fixed top-[20%] left-[50%] z-50 w-[660px] max-w-[90vw] translate-x-[-50%] rounded-lg border border-border bg-popover shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
contentClassName
|
||||
)}
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">{title}</DialogPrimitive.Title>
|
||||
|
|
|
|||
308
src/renderer/src/lib/browser-palette-search.test.ts
Normal file
308
src/renderer/src/lib/browser-palette-search.test.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types'
|
||||
import {
|
||||
searchBrowserPages,
|
||||
formatBrowserPaletteUrl,
|
||||
isBlankBrowserUrl
|
||||
} from './browser-palette-search'
|
||||
|
||||
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/tmp/wt-1',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/browser-search',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'Palette Worktree',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorkspace(overrides: Partial<BrowserWorkspace> = {}): BrowserWorkspace {
|
||||
return {
|
||||
id: 'browser-workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
activePageId: 'page-1',
|
||||
pageIds: ['page-1'],
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makePage(overrides: Partial<BrowserPage> = {}): BrowserPage {
|
||||
return {
|
||||
id: 'page-1',
|
||||
workspaceId: 'browser-workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
url: 'https://example.com/docs',
|
||||
title: 'Project Docs',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('browser-palette-search', () => {
|
||||
it('formats browser urls without protocol for palette display', () => {
|
||||
expect(formatBrowserPaletteUrl('https://example.com/docs?q=1#hash')).toBe(
|
||||
'example.com/docs?q=1#hash'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps empty-query ordering deterministic and context-first', () => {
|
||||
const results = searchBrowserPages(
|
||||
[
|
||||
{
|
||||
page: makePage({ id: 'page-current', title: 'Current Page' }),
|
||||
workspace: makeWorkspace({ id: 'ws-current', activePageId: 'page-current' }),
|
||||
worktree: makeWorktree({ id: 'wt-current', displayName: 'Current WT' }),
|
||||
repoName: 'repo/current',
|
||||
worktreeSortIndex: 1,
|
||||
isCurrentPage: true,
|
||||
isCurrentWorktree: true
|
||||
},
|
||||
{
|
||||
page: makePage({
|
||||
id: 'page-sibling',
|
||||
workspaceId: 'ws-sibling',
|
||||
worktreeId: 'wt-current',
|
||||
title: 'Sibling Page',
|
||||
url: 'https://example.com/sibling'
|
||||
}),
|
||||
workspace: makeWorkspace({
|
||||
id: 'ws-sibling',
|
||||
worktreeId: 'wt-current',
|
||||
activePageId: 'page-sibling'
|
||||
}),
|
||||
worktree: makeWorktree({ id: 'wt-current', displayName: 'Current WT' }),
|
||||
repoName: 'repo/current',
|
||||
worktreeSortIndex: 1,
|
||||
isCurrentPage: false,
|
||||
isCurrentWorktree: true
|
||||
},
|
||||
{
|
||||
page: makePage({
|
||||
id: 'page-other',
|
||||
workspaceId: 'ws-other',
|
||||
worktreeId: 'wt-other',
|
||||
title: 'Other Page',
|
||||
url: 'https://example.com/other'
|
||||
}),
|
||||
workspace: makeWorkspace({
|
||||
id: 'ws-other',
|
||||
worktreeId: 'wt-other',
|
||||
activePageId: 'page-other'
|
||||
}),
|
||||
worktree: makeWorktree({ id: 'wt-other', displayName: 'Other WT', repoId: 'repo-2' }),
|
||||
repoName: 'repo/other',
|
||||
worktreeSortIndex: 2,
|
||||
isCurrentPage: false,
|
||||
isCurrentWorktree: false
|
||||
}
|
||||
],
|
||||
''
|
||||
)
|
||||
|
||||
expect(results.map((result) => result.pageId)).toEqual([
|
||||
'page-current',
|
||||
'page-sibling',
|
||||
'page-other'
|
||||
])
|
||||
})
|
||||
|
||||
it('searches against page titles before worktree metadata', () => {
|
||||
const results = searchBrowserPages(
|
||||
[
|
||||
{
|
||||
page: makePage({ id: 'page-1', title: 'Design Spec' }),
|
||||
workspace: makeWorkspace({ id: 'ws-1' }),
|
||||
worktree: makeWorktree({ id: 'wt-1', displayName: 'Unrelated' }),
|
||||
repoName: 'repo/one',
|
||||
worktreeSortIndex: 1,
|
||||
isCurrentPage: false,
|
||||
isCurrentWorktree: false
|
||||
},
|
||||
{
|
||||
page: makePage({
|
||||
id: 'page-2',
|
||||
workspaceId: 'ws-2',
|
||||
worktreeId: 'wt-2',
|
||||
title: 'Home',
|
||||
url: 'https://example.com/home'
|
||||
}),
|
||||
workspace: makeWorkspace({ id: 'ws-2', worktreeId: 'wt-2', activePageId: 'page-2' }),
|
||||
worktree: makeWorktree({ id: 'wt-2', repoId: 'repo-2', displayName: 'Design Review' }),
|
||||
repoName: 'repo/two',
|
||||
worktreeSortIndex: 2,
|
||||
isCurrentPage: false,
|
||||
isCurrentWorktree: false
|
||||
}
|
||||
],
|
||||
'design'
|
||||
)
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].pageId).toBe('page-1')
|
||||
expect(results[0].titleRange).toEqual({ start: 0, end: 6 })
|
||||
expect(results[1].worktreeRange).toEqual({ start: 0, end: 6 })
|
||||
})
|
||||
|
||||
it('matches against formatted URLs when title does not match', () => {
|
||||
const results = searchBrowserPages(
|
||||
[
|
||||
{
|
||||
page: makePage({
|
||||
id: 'page-1',
|
||||
title: 'Dashboard',
|
||||
url: 'https://app.example.com/settings'
|
||||
}),
|
||||
workspace: makeWorkspace({ id: 'ws-1' }),
|
||||
worktree: makeWorktree({ id: 'wt-1' }),
|
||||
repoName: 'repo/one',
|
||||
worktreeSortIndex: 1,
|
||||
isCurrentPage: false,
|
||||
isCurrentWorktree: false
|
||||
}
|
||||
],
|
||||
'settings'
|
||||
)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].secondaryRange).toEqual({ start: 16, end: 24 })
|
||||
expect(results[0].titleRange).toBeNull()
|
||||
})
|
||||
|
||||
it('matches against raw URL when formatted URL does not match', () => {
|
||||
const results = searchBrowserPages(
|
||||
[
|
||||
{
|
||||
page: makePage({ id: 'page-1', title: 'Docs', url: 'https://docs.example.com/' }),
|
||||
workspace: makeWorkspace({ id: 'ws-1' }),
|
||||
worktree: makeWorktree({ id: 'wt-1' }),
|
||||
repoName: 'repo/one',
|
||||
worktreeSortIndex: 1,
|
||||
isCurrentPage: false,
|
||||
isCurrentWorktree: false
|
||||
}
|
||||
],
|
||||
'https'
|
||||
)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].secondaryRange).toEqual({ start: 0, end: 5 })
|
||||
})
|
||||
|
||||
it('returns empty array when query matches nothing', () => {
|
||||
const results = searchBrowserPages(
|
||||
[
|
||||
{
|
||||
page: makePage({ id: 'page-1', title: 'Dashboard', url: 'https://example.com' }),
|
||||
workspace: makeWorkspace({ id: 'ws-1' }),
|
||||
worktree: makeWorktree({ id: 'wt-1', displayName: 'Feature' }),
|
||||
repoName: 'myrepo',
|
||||
worktreeSortIndex: 1,
|
||||
isCurrentPage: false,
|
||||
isCurrentWorktree: false
|
||||
}
|
||||
],
|
||||
'zzzznonexistent'
|
||||
)
|
||||
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('formats blank URLs as New Tab', () => {
|
||||
expect(formatBrowserPaletteUrl('about:blank')).toBe('New Tab')
|
||||
expect(formatBrowserPaletteUrl('data:text/html,')).toBe('New Tab')
|
||||
})
|
||||
|
||||
it('identifies blank browser URLs', () => {
|
||||
expect(isBlankBrowserUrl('about:blank')).toBe(true)
|
||||
expect(isBlankBrowserUrl('data:text/html,')).toBe(true)
|
||||
expect(isBlankBrowserUrl('https://example.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('boosts current page and current worktree in scored results', () => {
|
||||
const entries = [
|
||||
{
|
||||
page: makePage({ id: 'page-other', title: 'React Docs', url: 'https://react.dev' }),
|
||||
workspace: makeWorkspace({
|
||||
id: 'ws-other',
|
||||
worktreeId: 'wt-other',
|
||||
activePageId: 'page-other'
|
||||
}),
|
||||
worktree: makeWorktree({ id: 'wt-other', displayName: 'Other' }),
|
||||
repoName: 'repo',
|
||||
worktreeSortIndex: 1,
|
||||
isCurrentPage: false,
|
||||
isCurrentWorktree: false
|
||||
},
|
||||
{
|
||||
page: makePage({
|
||||
id: 'page-current',
|
||||
workspaceId: 'ws-current',
|
||||
worktreeId: 'wt-current',
|
||||
title: 'React Native Docs',
|
||||
url: 'https://reactnative.dev'
|
||||
}),
|
||||
workspace: makeWorkspace({
|
||||
id: 'ws-current',
|
||||
worktreeId: 'wt-current',
|
||||
activePageId: 'page-current'
|
||||
}),
|
||||
worktree: makeWorktree({ id: 'wt-current', displayName: 'Current' }),
|
||||
repoName: 'repo',
|
||||
worktreeSortIndex: 1,
|
||||
isCurrentPage: true,
|
||||
isCurrentWorktree: true
|
||||
}
|
||||
]
|
||||
|
||||
const results = searchBrowserPages(entries, 'react')
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].pageId).toBe('page-current')
|
||||
expect(results[1].pageId).toBe('page-other')
|
||||
})
|
||||
|
||||
it('matches the visible workspace label in browser search', () => {
|
||||
const results = searchBrowserPages(
|
||||
[
|
||||
{
|
||||
page: makePage({ id: 'page-1', title: 'Docs' }),
|
||||
workspace: makeWorkspace({ id: 'ws-1', label: 'Browser 7' }),
|
||||
worktree: makeWorktree({ id: 'wt-1', displayName: 'Palette Worktree' }),
|
||||
repoName: 'repo/one',
|
||||
worktreeSortIndex: 1,
|
||||
isCurrentPage: false,
|
||||
isCurrentWorktree: false
|
||||
}
|
||||
],
|
||||
'browser 7'
|
||||
)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].workspaceRange).toEqual({ start: 0, end: 9 })
|
||||
})
|
||||
})
|
||||
270
src/renderer/src/lib/browser-palette-search.ts
Normal file
270
src/renderer/src/lib/browser-palette-search.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants'
|
||||
import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types'
|
||||
import type { MatchRange } from './worktree-palette-search'
|
||||
|
||||
export type SearchableBrowserPage = {
|
||||
page: BrowserPage
|
||||
workspace: BrowserWorkspace
|
||||
worktree: Worktree
|
||||
repoName: string
|
||||
worktreeSortIndex: number
|
||||
isCurrentPage: boolean
|
||||
isCurrentWorktree: boolean
|
||||
}
|
||||
|
||||
export type BrowserPaletteSearchResult = {
|
||||
pageId: string
|
||||
workspaceId: string
|
||||
worktreeId: string
|
||||
title: string
|
||||
secondaryText: string
|
||||
workspaceLabel: string | null
|
||||
repoName: string
|
||||
worktreeName: string
|
||||
workspaceRange: MatchRange | null
|
||||
titleRange: MatchRange | null
|
||||
secondaryRange: MatchRange | null
|
||||
repoRange: MatchRange | null
|
||||
worktreeRange: MatchRange | null
|
||||
isCurrentPage: boolean
|
||||
isCurrentWorktree: boolean
|
||||
score: number
|
||||
}
|
||||
|
||||
function compareText(a: string, b: string): number {
|
||||
return a.localeCompare(b, undefined, { sensitivity: 'base' })
|
||||
}
|
||||
|
||||
export function isBlankBrowserUrl(url: string): boolean {
|
||||
return url === 'about:blank' || url === ORCA_BROWSER_BLANK_URL
|
||||
}
|
||||
|
||||
export function formatBrowserPaletteUrl(url: string): string {
|
||||
if (isBlankBrowserUrl(url)) {
|
||||
return 'New Tab'
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return `${parsed.host}${parsed.pathname === '/' ? '' : parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
function findRange(text: string, query: string): MatchRange | null {
|
||||
if (!query) {
|
||||
return null
|
||||
}
|
||||
const start = text.toLowerCase().indexOf(query)
|
||||
if (start === -1) {
|
||||
return null
|
||||
}
|
||||
return { start, end: start + query.length }
|
||||
}
|
||||
|
||||
function compareEmptyQueryResults(
|
||||
a: BrowserPaletteSearchResult,
|
||||
b: BrowserPaletteSearchResult
|
||||
): number {
|
||||
if (a.isCurrentPage !== b.isCurrentPage) {
|
||||
return a.isCurrentPage ? -1 : 1
|
||||
}
|
||||
if (a.isCurrentWorktree !== b.isCurrentWorktree) {
|
||||
return a.isCurrentWorktree ? -1 : 1
|
||||
}
|
||||
if (a.score !== b.score) {
|
||||
return a.score - b.score
|
||||
}
|
||||
const secondaryCmp = compareText(a.secondaryText, b.secondaryText)
|
||||
if (secondaryCmp !== 0) {
|
||||
return secondaryCmp
|
||||
}
|
||||
return compareText(a.title, b.title)
|
||||
}
|
||||
|
||||
function scoreBrowserPageMatch({
|
||||
fieldWeight,
|
||||
matchIndex,
|
||||
entry
|
||||
}: {
|
||||
fieldWeight: number
|
||||
matchIndex: number
|
||||
entry: SearchableBrowserPage
|
||||
}): number {
|
||||
let score = fieldWeight + matchIndex + entry.worktreeSortIndex * 100
|
||||
if (entry.isCurrentPage) {
|
||||
score -= 40
|
||||
} else if (entry.isCurrentWorktree) {
|
||||
score -= 10
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
export function searchBrowserPages(
|
||||
entries: SearchableBrowserPage[],
|
||||
query: string
|
||||
): BrowserPaletteSearchResult[] {
|
||||
const trimmedQuery = query.trim().toLowerCase()
|
||||
const results: BrowserPaletteSearchResult[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const formattedUrl = formatBrowserPaletteUrl(entry.page.url)
|
||||
const title = entry.page.title || formattedUrl
|
||||
const fallbackSecondaryText = formattedUrl
|
||||
const baseResult = {
|
||||
pageId: entry.page.id,
|
||||
workspaceId: entry.workspace.id,
|
||||
worktreeId: entry.worktree.id,
|
||||
title,
|
||||
workspaceLabel: entry.workspace.label ?? null,
|
||||
repoName: entry.repoName,
|
||||
worktreeName: entry.worktree.displayName,
|
||||
isCurrentPage: entry.isCurrentPage,
|
||||
isCurrentWorktree: entry.isCurrentWorktree
|
||||
}
|
||||
|
||||
if (!trimmedQuery) {
|
||||
results.push({
|
||||
...baseResult,
|
||||
secondaryText: fallbackSecondaryText,
|
||||
workspaceRange: null,
|
||||
titleRange: null,
|
||||
secondaryRange: null,
|
||||
repoRange: null,
|
||||
worktreeRange: null,
|
||||
// Why: empty-query browser ordering is intentionally deterministic and
|
||||
// context-first. The palette should not invent hidden browser recency
|
||||
// semantics until Orca explicitly tracks them in state.
|
||||
score: entry.isCurrentPage
|
||||
? -2
|
||||
: entry.isCurrentWorktree
|
||||
? -1
|
||||
: entry.worktreeSortIndex * 100
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const titleRange = findRange(title, trimmedQuery)
|
||||
if (titleRange) {
|
||||
results.push({
|
||||
...baseResult,
|
||||
secondaryText: fallbackSecondaryText,
|
||||
workspaceRange: null,
|
||||
titleRange,
|
||||
secondaryRange: null,
|
||||
repoRange: null,
|
||||
worktreeRange: null,
|
||||
score: scoreBrowserPageMatch({
|
||||
fieldWeight: 0,
|
||||
matchIndex: titleRange.start,
|
||||
entry
|
||||
})
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const formattedUrlRange = findRange(formattedUrl, trimmedQuery)
|
||||
if (formattedUrlRange) {
|
||||
results.push({
|
||||
...baseResult,
|
||||
secondaryText: formattedUrl,
|
||||
workspaceRange: null,
|
||||
titleRange: null,
|
||||
secondaryRange: formattedUrlRange,
|
||||
repoRange: null,
|
||||
worktreeRange: null,
|
||||
score: scoreBrowserPageMatch({
|
||||
fieldWeight: 20,
|
||||
matchIndex: formattedUrlRange.start,
|
||||
entry
|
||||
})
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const rawUrlRange = findRange(entry.page.url, trimmedQuery)
|
||||
if (rawUrlRange) {
|
||||
results.push({
|
||||
...baseResult,
|
||||
secondaryText: entry.page.url,
|
||||
workspaceRange: null,
|
||||
titleRange: null,
|
||||
secondaryRange: rawUrlRange,
|
||||
repoRange: null,
|
||||
worktreeRange: null,
|
||||
score: scoreBrowserPageMatch({
|
||||
fieldWeight: 24,
|
||||
matchIndex: rawUrlRange.start,
|
||||
entry
|
||||
})
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const workspaceRange = findRange(entry.workspace.label ?? '', trimmedQuery)
|
||||
if (workspaceRange) {
|
||||
results.push({
|
||||
...baseResult,
|
||||
secondaryText: fallbackSecondaryText,
|
||||
workspaceRange,
|
||||
titleRange: null,
|
||||
secondaryRange: null,
|
||||
repoRange: null,
|
||||
worktreeRange: null,
|
||||
score: scoreBrowserPageMatch({
|
||||
fieldWeight: 32,
|
||||
matchIndex: workspaceRange.start,
|
||||
entry
|
||||
})
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const worktreeRange = findRange(entry.worktree.displayName, trimmedQuery)
|
||||
if (worktreeRange) {
|
||||
results.push({
|
||||
...baseResult,
|
||||
secondaryText: fallbackSecondaryText,
|
||||
workspaceRange: null,
|
||||
titleRange: null,
|
||||
secondaryRange: null,
|
||||
repoRange: null,
|
||||
worktreeRange,
|
||||
score: scoreBrowserPageMatch({
|
||||
fieldWeight: 40,
|
||||
matchIndex: worktreeRange.start,
|
||||
entry
|
||||
})
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const repoRange = findRange(entry.repoName, trimmedQuery)
|
||||
if (repoRange) {
|
||||
results.push({
|
||||
...baseResult,
|
||||
secondaryText: fallbackSecondaryText,
|
||||
workspaceRange: null,
|
||||
titleRange: null,
|
||||
secondaryRange: null,
|
||||
repoRange,
|
||||
worktreeRange: null,
|
||||
score: scoreBrowserPageMatch({
|
||||
fieldWeight: 60,
|
||||
matchIndex: repoRange.start,
|
||||
entry
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => {
|
||||
if (!trimmedQuery) {
|
||||
return compareEmptyQueryResults(a, b)
|
||||
}
|
||||
if (a.score !== b.score) {
|
||||
return a.score - b.score
|
||||
}
|
||||
return compareEmptyQueryResults(a, b)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue