diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 8b1ee8fd01d..d04a5b73c59 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -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(() => { @@ -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') } } diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts new file mode 100644 index 00000000000..66eee150fb9 --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts new file mode 100644 index 00000000000..522edbb6294 --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts @@ -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 + prCache: AppState['prCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null + settings: AppState['settings'] +} + +export function buildWorktreeChecksReviewIndex({ + worktrees, + repoMap, + prCache, + hostedReviewCache, + settings +}: WorktreeChecksReviewIndexArgs): Map { + const reviews = new Map() + 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 +} diff --git a/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts new file mode 100644 index 00000000000..2e9254e86bf --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts @@ -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 = { + prCache: {}, + issueCache: {}, + hostedReviewCache: {} + } + const filledState: Pick = { + 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 + + 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) + }) +}) diff --git a/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.ts b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.ts new file mode 100644 index 00000000000..1a1c234f12d --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.ts @@ -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, + 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 + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 40492be7b71..d6d8725c2d8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "Comment", "issue": "Issue", + "mr": "MR", "port": "Port", "pr": "PR" } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f909b7687b9..a2bbc411665 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "Comentario", "issue": "Issue", + "mr": "MR", "port": "Puerto", "pr": "PR" } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 33bc07fbfd1..8ce317e4ae4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "コメント", "issue": "Issue", + "mr": "MR", "port": "ポート", "pr": "PR" } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 67187a4b21c..37326de084a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "댓글", "issue": "이슈", + "mr": "MR", "port": "포트", "pr": "PR" } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index dbf29920404..88c13a84eb6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "评论", "issue": "议题", + "mr": "MR", "port": "端口", "pr": "PR" } diff --git a/src/renderer/src/lib/worktree-palette-review-match.ts b/src/renderer/src/lib/worktree-palette-review-match.ts new file mode 100644 index 00000000000..9945722a366 --- /dev/null +++ b/src/renderer/src/lib/worktree-palette-review-match.ts @@ -0,0 +1,47 @@ +import type { HostedReviewInfo } from '../../../shared/hosted-review' + +type SearchableReview = Pick +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 } + } +} diff --git a/src/renderer/src/lib/worktree-palette-search.test.ts b/src/renderer/src/lib/worktree-palette-search.test.ts index ff63dd75ba5..674ec97f22b 100644 --- a/src/renderer/src/lib/worktree-palette-search.test.ts +++ b/src/renderer/src/lib/worktree-palette-search.test.ts @@ -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 { 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({ diff --git a/src/renderer/src/lib/worktree-palette-search.ts b/src/renderer/src/lib/worktree-palette-search.ts index e7a5933050b..d05d41a97f9 100644 --- a/src/renderer/src/lib/worktree-palette-search.ts +++ b/src/renderer/src/lib/worktree-palette-search.ts @@ -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, prCache: Record | null, issueCache: Record | null, - workspacePortsByWorktreeId?: Map + workspacePortsByWorktreeId?: Map, + checksReviewByWorktreeId?: ReadonlyMap ): 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) {