feat(renderer): search worktrees by review metadata

This commit is contained in:
Brennan Benson
2026-07-13 01:22:52 -07:00
parent 0b65d725c9
commit acd87fd7b0
13 changed files with 508 additions and 37 deletions
@@ -104,6 +104,8 @@ import {
getCmdJQuickActions,
CREATE_WORKSPACE_QUICK_ACTION_ID
} from '@/components/cmd-j/quick-actions'
import { buildWorktreeChecksReviewIndex } from '@/components/cmd-j/worktree-checks-review-index'
import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs'
import {
getComposerEligibleRepos,
resolveComposerGitRepoId
@@ -382,8 +384,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const agentStatusEpoch = useAppStore((s) =>
visible || statusInputsLingering ? s.agentStatusEpoch : 0
)
const prCache = useAppStore((s) => s.prCache)
const issueCache = useAppStore((s) => s.issueCache)
const { prCache, issueCache, hostedReviewCache } = useAppStore(
useShallow((s) => selectWorktreePaletteCacheInputs(s, visible || statusInputsLingering))
)
const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeTabType = useAppStore((s) => s.activeTabType)
@@ -622,6 +625,18 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
[browserSortedWorktrees]
)
const checksReviewByWorktreeId = useMemo(
() =>
buildWorktreeChecksReviewIndex({
worktrees: allWorktrees,
repoMap,
prCache,
hostedReviewCache,
settings
}),
[allWorktrees, hostedReviewCache, prCache, repoMap, settings]
)
const worktreeMatches = useMemo(
() =>
searchWorktrees(
@@ -630,9 +645,18 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
repoMap,
prCache,
issueCache,
getWorkspacePortsByWorktreeId(workspacePortScan)
getWorkspacePortsByWorktreeId(workspacePortScan),
checksReviewByWorktreeId
),
[sortedWorktrees, deferredQuery, repoMap, prCache, issueCache, workspacePortScan]
[
sortedWorktrees,
deferredQuery,
repoMap,
prCache,
issueCache,
workspacePortScan,
checksReviewByWorktreeId
]
)
const browserPageEntries = useMemo<SearchableBrowserPage[]>(() => {
@@ -2364,5 +2388,7 @@ function getPaletteSupportingTextLabel(
return translate('worktreeJumpPalette.matchLabel.port', 'Port')
case 'pr':
return translate('worktreeJumpPalette.matchLabel.pr', 'PR')
case 'mr':
return translate('worktreeJumpPalette.matchLabel.mr', 'MR')
}
}
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { PRInfo, Repo, Worktree } from '../../../../shared/types'
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import { buildWorktreeChecksReviewIndex } from './worktree-checks-review-index'
const repo: Repo = {
id: 'repo-1',
path: '/remote/orca',
displayName: 'orca',
badgeColor: '#000000',
addedAt: 0,
executionHostId: 'ssh:staging'
}
const worktree: Worktree = {
id: 'worktree-1',
repoId: repo.id,
path: '/remote/orca-worktrees/search',
head: 'abc123',
branch: 'refs/heads/feature/search',
isBare: false,
isMainWorktree: false,
displayName: 'Search reviews',
comment: '',
linkedIssue: null,
linkedPR: 42,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
function makePR(): PRInfo {
return {
number: 42,
title: 'Search worktrees by their pull requests',
state: 'open',
url: 'https://github.com/acme/orca/pull/42',
checksStatus: 'success',
updatedAt: '2026-07-12T00:00:00Z',
mergeable: 'MERGEABLE'
}
}
function makeGitLabReview(): HostedReviewInfo {
return {
provider: 'gitlab',
number: 17,
title: 'Search worktrees by merge request',
state: 'open',
url: 'https://gitlab.com/acme/orca/-/merge_requests/17',
status: 'pending',
updatedAt: '2026-07-12T00:00:00Z',
mergeable: 'UNKNOWN'
}
}
describe('buildWorktreeChecksReviewIndex', () => {
it('reads the same host-scoped GitHub PR cache entry as Checks', () => {
const key = getGitHubPRCacheKey(
repo.path,
repo.id,
'feature/search',
null,
repo.connectionId,
repo.executionHostId,
true
)
const reviews = buildWorktreeChecksReviewIndex({
worktrees: [worktree],
repoMap: new Map([[repo.id, repo]]),
prCache: { [key]: { data: makePR(), fetchedAt: 1 } },
hostedReviewCache: {},
settings: null
})
expect(reviews.get(worktree.id)).toMatchObject({
provider: 'github',
number: 42,
title: 'Search worktrees by their pull requests'
})
})
it('uses the GitLab review selected by Checks instead of stale GitHub metadata', () => {
const gitLabWorktree = { ...worktree, linkedGitLabMR: 17 }
const prKey = getGitHubPRCacheKey(
repo.path,
repo.id,
'feature/search',
null,
repo.connectionId,
repo.executionHostId,
true
)
const reviewKey = getHostedReviewCacheKey(
repo.path,
'feature/search',
null,
repo.id,
repo.connectionId,
repo.executionHostId,
true
)
const gitLabReview = makeGitLabReview()
const reviews = buildWorktreeChecksReviewIndex({
worktrees: [gitLabWorktree],
repoMap: new Map([[repo.id, repo]]),
prCache: { [prKey]: { data: makePR(), fetchedAt: 1 } },
hostedReviewCache: { [reviewKey]: { data: gitLabReview, fetchedAt: 1 } },
settings: null
})
expect(reviews.get(worktree.id)).toBe(gitLabReview)
})
})
@@ -0,0 +1,69 @@
import { branchName } from '@/lib/git-utils'
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import type { AppState } from '@/store/types'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { Repo, Worktree } from '../../../../shared/types'
import { selectChecksPanelReview } from '../right-sidebar/checks-panel-review'
type WorktreeChecksReviewIndexArgs = {
worktrees: readonly Worktree[]
repoMap: ReadonlyMap<string, Repo>
prCache: AppState['prCache'] | null
hostedReviewCache: AppState['hostedReviewCache'] | null
settings: AppState['settings']
}
export function buildWorktreeChecksReviewIndex({
worktrees,
repoMap,
prCache,
hostedReviewCache,
settings
}: WorktreeChecksReviewIndexArgs): Map<string, HostedReviewInfo> {
const reviews = new Map<string, HostedReviewInfo>()
if (!prCache || !hostedReviewCache) {
return reviews
}
for (const worktree of worktrees) {
const repo = repoMap.get(worktree.repoId)
if (!repo) {
continue
}
const branch = branchName(worktree.branch)
const prKey = getGitHubPRCacheKey(
repo.path,
repo.id,
branch,
settings,
repo.connectionId,
repo.executionHostId,
true
)
const hostedReviewKey = getHostedReviewCacheKey(
repo.path,
branch,
settings,
repo.id,
repo.connectionId,
repo.executionHostId,
true
)
// Why: Cmd+J should expose exactly the review metadata Checks has already
// resolved, without starting another provider lookup from the search path.
const review = selectChecksPanelReview({
hostedReview: hostedReviewCache[hostedReviewKey]?.data,
pr: prCache[prKey]?.data,
linkedGitLabMR: worktree.linkedGitLabMR ?? null,
linkedBitbucketPR: worktree.linkedBitbucketPR ?? null,
linkedAzureDevOpsPR: worktree.linkedAzureDevOpsPR ?? null,
linkedGiteaPR: worktree.linkedGiteaPR ?? null
})
if (review) {
reviews.set(worktree.id, review)
}
}
return reviews
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import type { AppState } from '@/store/types'
import {
EMPTY_WORKTREE_PALETTE_CACHE_INPUTS,
selectWorktreePaletteCacheInputs
} from './worktree-palette-cache-inputs'
describe('selectWorktreePaletteCacheInputs', () => {
it('stays referentially stable across review cache writes while the palette is closed', () => {
const emptyState: Pick<AppState, 'prCache' | 'issueCache' | 'hostedReviewCache'> = {
prCache: {},
issueCache: {},
hostedReviewCache: {}
}
const filledState: Pick<AppState, 'prCache' | 'issueCache' | 'hostedReviewCache'> = {
prCache: { pr: { data: null, fetchedAt: 1 } },
issueCache: { issue: { data: null, fetchedAt: 1 } },
hostedReviewCache: { review: { data: null, fetchedAt: 1 } }
}
const before = selectWorktreePaletteCacheInputs(emptyState, false)
const after = selectWorktreePaletteCacheInputs(filledState, false)
expect(before).toBe(EMPTY_WORKTREE_PALETTE_CACHE_INPUTS)
expect(after).toBe(before)
})
it('returns live cache identities while the palette is visible', () => {
const state = {
prCache: {},
issueCache: {},
hostedReviewCache: {}
} as Pick<AppState, 'prCache' | 'issueCache' | 'hostedReviewCache'>
const selected = selectWorktreePaletteCacheInputs(state, true)
expect(selected).toEqual(state)
expect(selected.prCache).toBe(state.prCache)
expect(selected.issueCache).toBe(state.issueCache)
expect(selected.hostedReviewCache).toBe(state.hostedReviewCache)
})
})
@@ -0,0 +1,29 @@
import type { AppState } from '@/store/types'
export type WorktreePaletteCacheInputs = {
prCache: AppState['prCache'] | null
issueCache: AppState['issueCache'] | null
hostedReviewCache: AppState['hostedReviewCache'] | null
}
export const EMPTY_WORKTREE_PALETTE_CACHE_INPUTS: WorktreePaletteCacheInputs = Object.freeze({
prCache: null,
issueCache: null,
hostedReviewCache: null
})
export function selectWorktreePaletteCacheInputs(
state: Pick<AppState, 'prCache' | 'issueCache' | 'hostedReviewCache'>,
active: boolean
): WorktreePaletteCacheInputs {
// Why: the palette stays mounted while closed; cache replacement from Checks
// must not rerender it when no search results are visible.
if (!active) {
return EMPTY_WORKTREE_PALETTE_CACHE_INPUTS
}
return {
prCache: state.prCache,
issueCache: state.issueCache,
hostedReviewCache: state.hostedReviewCache
}
}
+1
View File
@@ -73,6 +73,7 @@
"matchLabel": {
"comment": "Comment",
"issue": "Issue",
"mr": "MR",
"port": "Port",
"pr": "PR"
}
+1
View File
@@ -73,6 +73,7 @@
"matchLabel": {
"comment": "Comentario",
"issue": "Issue",
"mr": "MR",
"port": "Puerto",
"pr": "PR"
}
+1
View File
@@ -73,6 +73,7 @@
"matchLabel": {
"comment": "コメント",
"issue": "Issue",
"mr": "MR",
"port": "ポート",
"pr": "PR"
}
+1
View File
@@ -73,6 +73,7 @@
"matchLabel": {
"comment": "댓글",
"issue": "이슈",
"mr": "MR",
"port": "포트",
"pr": "PR"
}
+1
View File
@@ -73,6 +73,7 @@
"matchLabel": {
"comment": "评论",
"issue": "议题",
"mr": "MR",
"port": "端口",
"pr": "PR"
}
@@ -0,0 +1,47 @@
import type { HostedReviewInfo } from '../../../shared/hosted-review'
type SearchableReview = Pick<HostedReviewInfo, 'number' | 'title' | 'provider'>
type WorktreePaletteReviewMatch = {
labelKind: 'pr' | 'mr'
text: string
matchRange: { start: number; end: number }
}
export function matchWorktreePaletteReview(
review: SearchableReview,
query: string,
numericQuery: string
): WorktreePaletteReviewMatch | null {
const isMergeRequest = review.provider === 'gitlab'
const numberPrefix = isMergeRequest ? 'MR !' : 'PR #'
const hasPullRequestSigil = query.startsWith('#')
const hasMergeRequestSigil = query.startsWith('!')
const sigilMatchesProvider =
(!hasPullRequestSigil && !hasMergeRequestSigil) ||
(hasPullRequestSigil && !isMergeRequest) ||
(hasMergeRequestSigil && isMergeRequest)
const reviewNumericQuery = hasMergeRequestSigil ? query.slice(1) : numericQuery
const reviewNumberIndex = sigilMatchesProvider
? String(review.number).indexOf(reviewNumericQuery)
: -1
if (reviewNumericQuery && reviewNumberIndex !== -1) {
return {
labelKind: isMergeRequest ? 'mr' : 'pr',
text: `${numberPrefix}${review.number}`,
matchRange: {
start: numberPrefix.length + reviewNumberIndex,
end: numberPrefix.length + reviewNumberIndex + reviewNumericQuery.length
}
}
}
const titleIndex = review.title.toLowerCase().indexOf(query)
if (titleIndex === -1) {
return null
}
return {
labelKind: isMergeRequest ? 'mr' : 'pr',
text: review.title,
matchRange: { start: titleIndex, end: titleIndex + query.length }
}
}
@@ -5,6 +5,7 @@ import {
isWorktreePaletteQueryTooLarge
} from './worktree-palette-query-bounds'
import type { Repo, Worktree } from '../../../shared/types'
import type { HostedReviewInfo } from '../../../shared/hosted-review'
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
@@ -172,6 +173,147 @@ describe('worktree-palette-search', () => {
})
})
it('matches the GitLab review title and number already selected by Checks', () => {
const review: HostedReviewInfo = {
provider: 'gitlab',
number: 17,
title: 'Reuse checks tab review metadata',
state: 'open',
url: 'https://gitlab.com/acme/orca/-/merge_requests/17',
status: 'success',
updatedAt: '2026-07-12T00:00:00Z',
mergeable: 'MERGEABLE'
}
const reviews = new Map([['wt-1', review]])
const titleResults = searchWorktrees(
[makeWorktree()],
'checks tab',
repoMap,
null,
null,
undefined,
reviews
)
const numberResults = searchWorktrees(
[makeWorktree()],
'!17',
repoMap,
null,
null,
undefined,
reviews
)
expect(titleResults[0].supportingText).toEqual({
labelKind: 'mr',
text: review.title,
matchRange: { start: 6, end: 16 }
})
expect(numberResults[0].supportingText).toEqual({
labelKind: 'mr',
text: 'MR !17',
matchRange: { start: 4, end: 6 }
})
})
it('does not search stale GitHub cache metadata when Checks selected another review', () => {
const review: HostedReviewInfo = {
provider: 'gitlab',
number: 17,
title: 'Current merge request',
state: 'open',
url: 'https://gitlab.com/acme/orca/-/merge_requests/17',
status: 'success',
updatedAt: '2026-07-12T00:00:00Z',
mergeable: 'MERGEABLE'
}
expect(
searchWorktrees(
[makeWorktree({ branch: 'refs/heads/feature/palette-refresh' })],
'stale github title',
repoMap,
{
'/repo/orca::feature/palette-refresh': {
data: { number: 99, title: 'Stale GitHub title' }
}
},
null,
undefined,
new Map([['wt-1', review]])
)
).toEqual([])
expect(
searchWorktrees(
[makeWorktree({ linkedPR: 99 })],
'#99',
repoMap,
null,
null,
undefined,
new Map([['wt-1', review]])
)
).toEqual([])
})
it('scopes PR and MR number sigils to their providers', () => {
const gitHubReview: HostedReviewInfo = {
provider: 'github',
number: 42,
title: 'GitHub pull request',
state: 'open',
url: 'https://github.com/acme/orca/pull/42',
status: 'success',
updatedAt: '2026-07-12T00:00:00Z',
mergeable: 'MERGEABLE'
}
const gitLabReview: HostedReviewInfo = {
provider: 'gitlab',
number: 17,
title: 'GitLab merge request',
state: 'open',
url: 'https://gitlab.com/acme/orca/-/merge_requests/17',
status: 'success',
updatedAt: '2026-07-12T00:00:00Z',
mergeable: 'MERGEABLE'
}
expect(
searchWorktrees(
[makeWorktree()],
'!42',
repoMap,
null,
null,
undefined,
new Map([['wt-1', gitHubReview]])
)
).toEqual([])
expect(
searchWorktrees(
[makeWorktree()],
'#17',
repoMap,
null,
null,
undefined,
new Map([['wt-1', gitLabReview]])
)
).toEqual([])
expect(
searchWorktrees(
[makeWorktree({ linkedIssue: 42 })],
'!42',
repoMap,
null,
null,
new Map([['wt-1', [{ port: 42 }]]])
)
).toEqual([])
})
it('preserves input order when query matches a repo name', () => {
const worktrees = [
makeWorktree({
+23 -33
View File
@@ -1,8 +1,10 @@
import { branchName } from '@/lib/git-utils'
import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github'
import type { HostedReviewInfo } from '../../../shared/hosted-review'
import type { Repo, Worktree } from '../../../shared/types'
import { extractWorktreePaletteCommentSnippet } from './worktree-palette-comment-snippet'
import { isWorktreePaletteQueryTooLarge } from './worktree-palette-query-bounds'
import { matchWorktreePaletteReview } from './worktree-palette-review-match'
export type MatchRange = { start: number; end: number }
@@ -16,7 +18,7 @@ export type PaletteMatchedField =
| 'port'
export type PaletteSupportingText = {
labelKind: 'comment' | 'pr' | 'issue' | 'port'
labelKind: 'comment' | 'pr' | 'mr' | 'issue' | 'port'
text: string
matchRange: MatchRange | null
}
@@ -69,7 +71,8 @@ export function searchWorktrees(
repoMap: Map<string, Repo>,
prCache: Record<string, PRCacheEntry> | null,
issueCache: Record<string, IssueCacheEntry> | null,
workspacePortsByWorktreeId?: Map<string, { port: number; processName?: string }[]>
workspacePortsByWorktreeId?: Map<string, { port: number; processName?: string }[]>,
checksReviewByWorktreeId?: ReadonlyMap<string, HostedReviewInfo>
): PaletteSearchResult[] {
if (isWorktreePaletteQueryTooLarge(query)) {
return []
@@ -199,42 +202,29 @@ export function searchWorktrees(
}
const repo = repoMap.get(worktree.repoId)
const checksReview = checksReviewByWorktreeId?.get(worktree.id)
if (checksReview) {
const supportingText = matchWorktreePaletteReview(checksReview, q, numericQuery)
if (supportingText) {
results.push(makeResult(worktree.id, 'pr', { supportingText }))
continue
}
}
const prKey = repo ? `${repo.path}::${branch}` : ''
const pr = prKey && prCache ? prCache[prKey]?.data : undefined
const pr = !checksReview && prKey && prCache ? prCache[prKey]?.data : undefined
if (pr) {
const prText = `PR #${pr.number}`
const prNumberIndex = String(pr.number).indexOf(numericQuery)
if (prNumberIndex !== -1) {
results.push(
makeResult(worktree.id, 'pr', {
supportingText: {
labelKind: 'pr',
text: prText,
matchRange: {
start: 'PR #'.length + prNumberIndex,
end: 'PR #'.length + prNumberIndex + numericQuery.length
}
}
})
)
const supportingText = matchWorktreePaletteReview(
{ ...pr, provider: 'github' },
q,
numericQuery
)
if (supportingText) {
results.push(makeResult(worktree.id, 'pr', { supportingText }))
continue
}
const prTitleIndex = pr.title.toLowerCase().indexOf(q)
if (prTitleIndex !== -1) {
results.push(
makeResult(worktree.id, 'pr', {
supportingText: {
labelKind: 'pr',
text: pr.title,
matchRange: { start: prTitleIndex, end: prTitleIndex + q.length }
}
})
)
continue
}
} else if (worktree.linkedPR != null) {
} else if (!checksReview && worktree.linkedPR != null) {
const prText = `PR #${worktree.linkedPR}`
const prNumberIndex = String(worktree.linkedPR).indexOf(numericQuery)
if (prNumberIndex !== -1) {