feat(cmd-j): show Recent Worktrees section + repo/branch composite search (#1180)

- Surface top 3 worktrees under a RECENT WORKTREES header on empty query
  when there are ≥4 worktrees, with a WORKTREES header for the rest
- Support "repo/branch" composite queries in the palette search, with
  highlight ranges on both segments
- Add placeholder hint for the new composite query

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-04-27 11:32:40 -07:00
committed by GitHub
co-authored by Orca
parent 8cce94eb5a
commit 6978e6a4aa
3 changed files with 102 additions and 5 deletions
@@ -282,18 +282,50 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const bothSectionsPopulated = worktreeItems.length > 0 && browserItems.length > 0
const hasQuery = deferredQuery.trim().length > 0
const EMPTY_QUERY_BROWSER_PREVIEW = 3
const RECENT_COUNT = 3
const RECENT_MIN_WORKTREES = 4
// Rule A: Recent section renders only when the user hasn't typed and there
// are enough worktrees that "the rest" is non-empty. With ≤3 worktrees, a
// RECENT label would sit atop the entire list with nothing below it —
// chrome with no decision value. In that case we fall through to the
// existing showHeaders logic below.
const showRecentSection = !hasQuery && worktreeItems.length >= RECENT_MIN_WORKTREES
const visibleWorktreeItems = worktreeItems
const visibleBrowserItems =
!hasQuery && bothSectionsPopulated
? browserItems.slice(0, EMPTY_QUERY_BROWSER_PREVIEW)
: browserItems
const showHeaders = bothSectionsPopulated
if (visibleWorktreeItems.length > 0) {
if (showRecentSection) {
// Why: symmetric RECENT WORKTREES / WORKTREES headers make the two
// groups unambiguous. Users asked for an explicit "Worktrees" title on
// the lower group rather than a bare divider.
entries.push({
id: '__header_recent__',
type: 'section-header',
label: 'Recent Worktrees'
})
entries.push(...worktreeItems.slice(0, RECENT_COUNT))
entries.push({ id: '__header_all_worktrees__', type: 'section-header', label: 'Worktrees' })
entries.push(...worktreeItems.slice(RECENT_COUNT))
if (visibleBrowserItems.length > 0) {
entries.push({
id: '__header_browser__',
type: 'section-header',
label: 'Browser Tabs'
})
entries.push(...visibleBrowserItems)
}
return entries
}
if (worktreeItems.length > 0) {
if (showHeaders) {
entries.push({ id: '__header_worktrees__', type: 'section-header', label: 'Worktrees' })
}
entries.push(...visibleWorktreeItems)
entries.push(...worktreeItems)
}
if (visibleBrowserItems.length > 0) {
if (showHeaders) {
@@ -669,7 +701,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
}}
>
<CommandInput
placeholder="Jump to worktree or browser tab..."
placeholder={'Jump to worktree or browser tab\u2026 try "repo/branch"'}
value={query}
onValueChange={setQuery}
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)]"
@@ -693,7 +725,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
return (
<div
key={entry.id}
className="mx-0.5 mt-2 mb-0.5 px-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70 first:mt-0"
className="mx-0.5 mt-3 mb-1 px-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70"
>
{entry.label}
</div>
@@ -134,6 +134,43 @@ describe('worktree-palette-search', () => {
expect(results[2].worktreeId).toBe('wt-main')
})
it('supports "repo/branch" composite queries and highlights both segments', () => {
const worktrees = [
makeWorktree({
id: 'wt-main',
branch: 'refs/heads/main',
displayName: 'main'
}),
makeWorktree({
id: 'wt-feature',
branch: 'refs/heads/feature/foo',
displayName: 'feature foo'
})
]
const results = searchWorktrees(worktrees, 'orca/main', repoMap, null, null)
expect(results).toHaveLength(1)
expect(results[0].worktreeId).toBe('wt-main')
expect(results[0].matchedField).toBe('branch')
expect(results[0].repoRange).toEqual({ start: 9, end: 13 })
expect(results[0].branchRange).toEqual({ start: 0, end: 4 })
})
it('falls back to single-token matching when a composite query has no composite hits', () => {
const results = searchWorktrees(
[makeWorktree({ branch: 'refs/heads/feature/palette-refresh' })],
'feature/palette',
repoMap,
null,
null
)
expect(results).toHaveLength(1)
expect(results[0].matchedField).toBe('branch')
expect(results[0].branchRange).toEqual({ start: 0, end: 'feature/palette'.length })
})
it('matches issue numbers with a leading hash and returns issue render context', () => {
const results = searchWorktrees(
[makeWorktree({ linkedIssue: 304 })],
@@ -86,7 +86,35 @@ export function searchWorktrees(
const numericQuery = q.startsWith('#') ? q.slice(1) : q
const results: PaletteSearchResult[] = []
// Support "repo/branch" composite queries (e.g. "orca/main") so users can
// narrow by repo and branch in a single token. We split on the FIRST slash
// only — branch names themselves contain slashes (e.g. "feature/foo"), and
// we still want the right-hand side to match those in full.
const slashIndex = q.indexOf('/')
const composite =
slashIndex > 0 && slashIndex < q.length - 1
? { repoPart: q.slice(0, slashIndex), branchPart: q.slice(slashIndex + 1) }
: null
for (const worktree of worktrees) {
if (composite) {
const repoName = repoMap.get(worktree.repoId)?.displayName ?? ''
const branch = branchName(worktree.branch)
const repoIdx = repoName.toLowerCase().indexOf(composite.repoPart)
const branchIdx = branch.toLowerCase().indexOf(composite.branchPart)
if (repoIdx !== -1 && branchIdx !== -1) {
results.push(
makeResult(worktree.id, 'branch', {
repoRange: { start: repoIdx, end: repoIdx + composite.repoPart.length },
branchRange: { start: branchIdx, end: branchIdx + composite.branchPart.length }
})
)
continue
}
// Fall through to single-token matching so users who type a branch name
// that happens to contain a slash (e.g. "feature/foo") still get hits.
}
const nameIndex = worktree.displayName.toLowerCase().indexOf(q)
if (nameIndex !== -1) {
results.push(