mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
refactor: split PullRequestPage.tsx under 400 lines
Move the 5888-line PR page into nested domain modules under src/renderer/src/components/pull-request-page/ and leave a thin public barrel. No intentional behavior change.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# Split PullRequestPage.tsx under 400 lines
|
||||
|
||||
Status: implemented. Source barrel is 4 lines. Largest dest is `files/combined-diff-viewer.tsx` (390).
|
||||
|
||||
Hard cap: `src/renderer/src/components/PullRequestPage.tsx` and every dest file `wc -l` <= 400.
|
||||
No `max-lines` disables. Zero intentional behavior change. Nested domain folders; file names do not repeat the folder name.
|
||||
|
||||
## Public barrel (source stays at original path)
|
||||
|
||||
`src/renderer/src/components/PullRequestPage.tsx` becomes imports + public API only:
|
||||
|
||||
- `export type { ItemDialogTab }`
|
||||
- `export type { PullRequestPageProjectOrigin }`
|
||||
- `export { invalidateWorkItemDetailsCacheForKey }`
|
||||
- `export { default }` from `page/surface.tsx`
|
||||
|
||||
## Dest tree
|
||||
|
||||
```
|
||||
src/renderer/src/components/pull-request-page/
|
||||
page-types.ts
|
||||
mentions/{query.ts,query.test.ts,options.ts,options.test.ts,textarea.tsx}
|
||||
presentation/{state-badge.tsx,state-badge.test.ts}
|
||||
reviewers/{request-actions.ts,picker-row.tsx,picker.tsx,requested-list.tsx,panel.tsx}
|
||||
cache/{work-item-details.ts,file-content.ts,files-diff-view.ts}
|
||||
files/{toolbar.tsx,section-state.ts,combined-diff-viewer.tsx}
|
||||
conversation/{comment-card.tsx,comment-group.tsx,description.tsx,tab.tsx}
|
||||
actions/{panel.tsx,merge-actions.ts}
|
||||
comments/{reply-form.tsx,composer.tsx}
|
||||
checks/{refresh.ts,rerun.ts,details-request.ts,fix-launch.ts,toolbar.tsx,row.tsx,fix-dialog.tsx,tab.tsx}
|
||||
edit/{issue-updates.ts,section.tsx}
|
||||
page/{use-details.ts,header.tsx,tabs-shell.tsx,surface.tsx}
|
||||
```
|
||||
|
||||
## Import DAG (no cycles)
|
||||
|
||||
`page-types` → mentions, presentation, reviewers, comments, checks, edit, actions, files, conversation, page
|
||||
`cache` → files, conversation, page
|
||||
`mentions` → comments, conversation
|
||||
`presentation` → edit
|
||||
`reviewers` / `actions` / `checks` / `comments` → conversation, page
|
||||
`edit` / `files` → page only
|
||||
|
||||
## Oversized cuts
|
||||
|
||||
| Symbol | Lines | Cut |
|
||||
|---|---|---|
|
||||
| PRReviewersPanel | ~700 | request-actions hook + picker-row + picker + requested-list + panel |
|
||||
| PRFilesCombinedDiffViewer | ~670 | files-diff-view cache + section-state hook + toolbar + viewer |
|
||||
| ConversationTab | ~651 | comment-card + comment-group + description + tab |
|
||||
| ChecksTab | ~1023 | refresh/rerun/details-request/fix-launch + toolbar + row + fix-dialog + tab |
|
||||
| GHEditSection | ~493 | issue-updates hook + section |
|
||||
| PullRequestPage | ~877 | use-details + header + tabs-shell + surface |
|
||||
| PRActionsPanel | ~355 + imports | merge-actions hook + panel |
|
||||
|
||||
## Host-boundary tests
|
||||
|
||||
Update `pull-request-page-host-boundary.test.ts` to read dest files (concatenate a domain when a former function is split). Keep the same string assertions.
|
||||
|
||||
Update `github/repro-8784-ghe-avatar-fallback.test.ts` to read `page/header.tsx` (GitHubUserAvatar + authorAvatarUrl).
|
||||
|
||||
## Characterization tests (uncovered pure symbols)
|
||||
|
||||
- `mentions/query.test.ts` — findMentionQuery
|
||||
- `mentions/options.test.ts` — buildMentionOptions
|
||||
- `presentation/state-badge.test.ts` — getStateTone
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/renderer/src/components/pull-request-page-host-boundary.test.ts \
|
||||
src/renderer/src/components/pull-request-page \
|
||||
src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts
|
||||
pnpm run typecheck:web
|
||||
pnpm run check:max-lines-ratchet
|
||||
# after removing the source max-lines disable:
|
||||
# prune config/max-lines-baseline.txt entry for PullRequestPage.tsx
|
||||
wc -l src/renderer/src/components/PullRequestPage.tsx \
|
||||
src/renderer/src/components/pull-request-page/**/*.{ts,tsx}
|
||||
```
|
||||
@@ -93,7 +93,6 @@ inline src/renderer/src/components/JiraIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearItemDrawer.tsx
|
||||
inline src/renderer/src/components/NewWorkspaceComposerCard.tsx
|
||||
inline src/renderer/src/components/PullRequestPage.tsx
|
||||
inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
inline src/renderer/src/components/UpdateCard.tsx
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ describe('issue #8784 GHE avatar fallback (regression)', () => {
|
||||
})
|
||||
|
||||
it('source routes PR author/reviewer avatars through GitHubUserAvatar + authorAvatarUrl', () => {
|
||||
const prPage = readFileSync(join(__dirname, '../PullRequestPage.tsx'), 'utf8')
|
||||
const prPage = readFileSync(join(__dirname, '../pull-request-page/page/header.tsx'), 'utf8')
|
||||
expect(prPage).toMatch(/GitHubUserAvatar/)
|
||||
expect(prPage).toMatch(/authorAvatarUrl/)
|
||||
// Why: author chip must not ignore API avatar_url and only pass login.
|
||||
|
||||
@@ -8,6 +8,10 @@ function componentSource(relativePath: string): string {
|
||||
return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8')
|
||||
}
|
||||
|
||||
function joinedSource(relativePaths: string[]): string {
|
||||
return relativePaths.map(componentSource).join('\n')
|
||||
}
|
||||
|
||||
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
|
||||
const start = source.indexOf(startPattern)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
@@ -16,14 +20,61 @@ function sourceBetween(source: string, startPattern: string, endPattern: string)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
const reviewersSource = joinedSource([
|
||||
'pull-request-page/reviewers/panel.tsx',
|
||||
'pull-request-page/reviewers/request-actions.ts',
|
||||
'pull-request-page/reviewers/picker.tsx',
|
||||
'pull-request-page/reviewers/requested-list.tsx'
|
||||
])
|
||||
|
||||
const editSource = joinedSource([
|
||||
'pull-request-page/edit/section.tsx',
|
||||
'pull-request-page/edit/issue-updates.ts'
|
||||
])
|
||||
|
||||
const actionsSource = joinedSource([
|
||||
'pull-request-page/actions/panel.tsx',
|
||||
'pull-request-page/actions/merge-actions.ts'
|
||||
])
|
||||
|
||||
const conversationSource = joinedSource([
|
||||
'pull-request-page/conversation/tab.tsx',
|
||||
'pull-request-page/conversation/reply.ts',
|
||||
'pull-request-page/conversation/comment-card.tsx'
|
||||
])
|
||||
|
||||
const detailsSource = joinedSource([
|
||||
'pull-request-page/page-types.ts',
|
||||
'pull-request-page/cache/work-item-details.ts',
|
||||
'pull-request-page/page/use-details.ts',
|
||||
'pull-request-page/page/surface.tsx'
|
||||
])
|
||||
|
||||
const filesSource = joinedSource([
|
||||
'pull-request-page/cache/file-content.ts',
|
||||
'pull-request-page/files/section-loader.ts',
|
||||
'pull-request-page/files/combined-diff-viewer.tsx',
|
||||
'pull-request-page/page/viewed-sync.ts'
|
||||
])
|
||||
|
||||
const commentsSource = joinedSource([
|
||||
'pull-request-page/comments/composer.tsx',
|
||||
'pull-request-page/conversation/reply.ts',
|
||||
'pull-request-page/files/line-comment.ts'
|
||||
])
|
||||
|
||||
const checksSource = joinedSource([
|
||||
'pull-request-page/checks/tab.tsx',
|
||||
'pull-request-page/checks/refresh.ts',
|
||||
'pull-request-page/checks/rerun.ts',
|
||||
'pull-request-page/checks/details-request.ts',
|
||||
'pull-request-page/checks/details.tsx',
|
||||
'pull-request-page/checks/row.tsx'
|
||||
])
|
||||
|
||||
describe('PullRequestPage host boundaries', () => {
|
||||
it('routes reviewer metadata and mutations through the PR repo owner host', () => {
|
||||
const source = componentSource('PullRequestPage.tsx')
|
||||
const section = sourceBetween(
|
||||
source,
|
||||
'function PRReviewersPanel',
|
||||
'const WORK_ITEM_DETAILS_CACHE_MAX'
|
||||
)
|
||||
const section = reviewersSource
|
||||
|
||||
expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)')
|
||||
expect(section).toContain('useRepoAssigneesBySlug(')
|
||||
@@ -33,22 +84,21 @@ describe('PullRequestPage host boundaries', () => {
|
||||
expect(section).toContain('getActiveRuntimeTarget(sourceSettings)')
|
||||
expect(section).toContain('sourceContext,')
|
||||
expect(section).toContain(
|
||||
'patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, {'
|
||||
'args.patchWorkItem(args.item.id, { reviewRequests: nextReviewRequests }, args.item.repoId, {'
|
||||
)
|
||||
expect(section).toContain(
|
||||
'const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId)'
|
||||
'const runtimeRepo = getGitHubRuntimeRepoId(args.sourceContext, args.item.repoId)'
|
||||
)
|
||||
expect(section).toContain("'github.requestPRReviewers'")
|
||||
expect(section).toContain("'github.removePRReviewers'")
|
||||
expect(section).toContain('resolvePullRequestRepo(item, projectOrigin)')
|
||||
expect(section.match(/prRepo: reviewRepo/g)).toHaveLength(4)
|
||||
expect(section.match(/prRepo: args\.reviewRepo/g)).toHaveLength(4)
|
||||
expect(section).toContain('notifyWorkItemDetailsMutation(')
|
||||
expect(section).toContain('{ local: false }')
|
||||
})
|
||||
|
||||
it('routes PR edit metadata through the same repo owner host as mutations', () => {
|
||||
const source = componentSource('PullRequestPage.tsx')
|
||||
const section = sourceBetween(source, 'function GHEditSection', 'function GHCommentComposer')
|
||||
const section = editSource
|
||||
|
||||
expect(section).toContain('getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)')
|
||||
expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)')
|
||||
@@ -61,77 +111,63 @@ describe('PullRequestPage host boundaries', () => {
|
||||
})
|
||||
|
||||
it('source-scopes full-page optimistic work item patches', () => {
|
||||
const source = componentSource('PullRequestPage.tsx')
|
||||
const prAssigneesSection = componentSource('github/PRAssigneesPanel.tsx')
|
||||
const prActionsSection = sourceBetween(
|
||||
source,
|
||||
'function PRActionsPanel',
|
||||
'function CommentReplyForm'
|
||||
)
|
||||
const issueEditSection = sourceBetween(
|
||||
source,
|
||||
'function GHEditSection',
|
||||
'function GHCommentComposer'
|
||||
)
|
||||
|
||||
expect(prAssigneesSection).toContain(
|
||||
'patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext })'
|
||||
)
|
||||
expect(prActionsSection).toContain(
|
||||
expect(actionsSource).toContain(
|
||||
'patchWorkItem(item.id, { state }, item.repoId, { sourceContext })'
|
||||
)
|
||||
expect(issueEditSection).toContain(
|
||||
'patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext })'
|
||||
expect(editSource).toContain(
|
||||
'args.patchWorkItem(args.item.id, { state: args.newState }, args.item.repoId, {'
|
||||
)
|
||||
expect(issueEditSection).toContain(
|
||||
'patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext })'
|
||||
expect(editSource).toContain(
|
||||
'args.patchWorkItem(args.item.id, { labels: newLabels }, args.item.repoId, {'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes PR mention metadata through the PR repo owner host', () => {
|
||||
const source = componentSource('PullRequestPage.tsx')
|
||||
const section = sourceBetween(source, 'function ConversationTab', 'const mentionOptions')
|
||||
const section = conversationSource
|
||||
|
||||
expect(section).toContain('getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)')
|
||||
expect(section).toContain('useRepoAssignees(repoPath, item.repoId, sourceSettings)')
|
||||
})
|
||||
|
||||
it('uses source-aware initial details routing and cache identity', () => {
|
||||
const source = componentSource('PullRequestPage.tsx')
|
||||
const propsSection = sourceBetween(
|
||||
source,
|
||||
'type PullRequestPageProps',
|
||||
'function findMentionQuery'
|
||||
)
|
||||
const propsSection = componentSource('pull-request-page/page-types.ts')
|
||||
const cacheKeySection = sourceBetween(
|
||||
source,
|
||||
'function getWorkItemDetailsCacheKey',
|
||||
'function touchWorkItemDetailsCache'
|
||||
componentSource('pull-request-page/cache/work-item-details.ts'),
|
||||
'export function getWorkItemDetailsCacheKey',
|
||||
'export function touchWorkItemDetailsCache'
|
||||
)
|
||||
const matchInvalidationSection = sourceBetween(
|
||||
source,
|
||||
'function invalidateWorkItemDetailsCacheByMatch',
|
||||
'function patchCachedPRFileViewedState'
|
||||
componentSource('pull-request-page/cache/work-item-details.ts'),
|
||||
'export function invalidateWorkItemDetailsCacheByMatch',
|
||||
'export function patchCachedPRFileViewedState'
|
||||
)
|
||||
|
||||
expect(propsSection).toContain('sourceContext?: TaskSourceContext | null')
|
||||
expect(source).toContain('lookupGitHubWorkItemDetailsForSource({')
|
||||
expect(source).toContain('sourceContext,')
|
||||
expect(detailsSource).toContain('lookupGitHubWorkItemDetailsForSource({')
|
||||
expect(detailsSource).toContain('sourceContext,')
|
||||
expect(cacheKeySection).toContain('sourceCacheScope')
|
||||
expect(source).toContain('getTaskSourceCacheScope(sourceContext)')
|
||||
expect(detailsSource).toContain('getTaskSourceCacheScope(sourceContext)')
|
||||
expect(matchInvalidationSection).toContain(
|
||||
'if (removed) {\n workItemDetailsCacheGeneration += 1'
|
||||
'if (removed) {\n workItemDetailsCacheGeneration.current += 1'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats null details as unavailable while preserving empty detail payloads', () => {
|
||||
const source = componentSource('PullRequestPage.tsx')
|
||||
const loadedSection = sourceBetween(
|
||||
source,
|
||||
componentSource('pull-request-page/page/use-details.ts'),
|
||||
'const loading = !!cachedEntry?.pending && !cachedEntry?.details',
|
||||
'// Why: if a cross-window mutation invalidates'
|
||||
)
|
||||
const resultSection = sourceBetween(source, 'inflight', '.catch((err) => {')
|
||||
const resultSection = sourceBetween(
|
||||
componentSource('pull-request-page/page/use-details.ts'),
|
||||
'inflight',
|
||||
'.catch((err) => {'
|
||||
)
|
||||
|
||||
expect(loadedSection).toContain('const detailsLoaded = Boolean(cachedEntry?.details)')
|
||||
expect(loadedSection).not.toContain('fetchedAt > 0')
|
||||
@@ -141,12 +177,11 @@ describe('PullRequestPage host boundaries', () => {
|
||||
})
|
||||
|
||||
it('routes file viewed mutations through the PR source context', () => {
|
||||
const source = componentSource('PullRequestPage.tsx')
|
||||
const helperSection = componentSource('github/github-work-item-comment-mutations.ts')
|
||||
const changeSection = sourceBetween(
|
||||
source,
|
||||
'const handlePRFileViewedChange = useCallback',
|
||||
'const ownerRepo = parseOwnerRepoFromItemUrl'
|
||||
componentSource('pull-request-page/page/viewed-sync.ts'),
|
||||
'export async function syncPullRequestFileViewed',
|
||||
'args.setPendingViewedPaths((prev) => {'
|
||||
)
|
||||
|
||||
expect(helperSection).toContain('getGitHubSourceRuntimeHost(args.sourceContext)')
|
||||
@@ -155,32 +190,16 @@ describe('PullRequestPage host boundaries', () => {
|
||||
expect(helperSection).toContain('sourceContext: args.sourceContext')
|
||||
expect(helperSection).toContain('{ local: false }')
|
||||
expect(changeSection).toContain('canUseDetailsRepoContext')
|
||||
expect(changeSection).toContain('repoPath: repoPath ??')
|
||||
expect(changeSection).toContain('sourceContext,')
|
||||
expect(changeSection).toContain('repoPath: args.repoPath ??')
|
||||
expect(changeSection).toContain('sourceContext: args.sourceContext')
|
||||
})
|
||||
|
||||
it('routes comment mutations through runtime source context when needed', () => {
|
||||
const source = componentSource('PullRequestPage.tsx')
|
||||
const helperSection = sourceBetween(
|
||||
componentSource('github/github-work-item-comment-mutations.ts'),
|
||||
'function addIssueCommentForRepo',
|
||||
'function setPRFileViewedForRepo'
|
||||
)
|
||||
const fileCommentSection = sourceBetween(
|
||||
source,
|
||||
'const handleAddLineComment = useCallback',
|
||||
'const renderViewedCheckbox = useCallback'
|
||||
)
|
||||
const conversationSection = sourceBetween(
|
||||
source,
|
||||
'const handleReply = useCallback',
|
||||
'const rightPanel ='
|
||||
)
|
||||
const composerSection = sourceBetween(
|
||||
source,
|
||||
'const handleSubmit = useCallback',
|
||||
'const canSubmitComment ='
|
||||
)
|
||||
|
||||
expect(helperSection).toContain('getGitHubSourceRuntimeHost(args.sourceContext)')
|
||||
expect(helperSection).toContain("'github.addIssueComment'")
|
||||
@@ -190,25 +209,28 @@ describe('PullRequestPage host boundaries', () => {
|
||||
expect(helperSection).toContain('sourceContext: args.sourceContext')
|
||||
expect(helperSection).toContain('notifyWorkItemDetailsMutation(')
|
||||
expect(helperSection).toContain('{ local: false }')
|
||||
expect(fileCommentSection).toContain('sourceContext,')
|
||||
expect(conversationSection).toContain('sourceContext,')
|
||||
expect(composerSection).toContain('sourceContext,')
|
||||
expect(filesSource).toContain('sourceContext,')
|
||||
expect(conversationSource).toContain('sourceContext,')
|
||||
expect(commentsSource).toContain('sourceContext,')
|
||||
})
|
||||
|
||||
it('routes PR file contents and runtime viewed invalidations through the PR source context', () => {
|
||||
const source = componentSource('PullRequestPage.tsx')
|
||||
const commentMutations = componentSource('github/github-work-item-comment-mutations.ts')
|
||||
const fileContentsSection = sourceBetween(
|
||||
source,
|
||||
'function loadPRFileContents',
|
||||
'type CachedPRFilesDiffViewState'
|
||||
componentSource('pull-request-page/cache/file-content.ts'),
|
||||
'export function loadPRFileContents',
|
||||
'touchPRFileContentCache(cacheKey, request)'
|
||||
)
|
||||
const fileContentsCacheKeySection = sourceBetween(
|
||||
source,
|
||||
'function getPRFileContentCacheKey',
|
||||
'function loadPRFileContents'
|
||||
componentSource('pull-request-page/cache/file-content.ts'),
|
||||
'export function getPRFileContentCacheKey',
|
||||
'export function loadPRFileContents'
|
||||
)
|
||||
const listenerSection = sourceBetween(
|
||||
componentSource('pull-request-page/cache/work-item-details.ts'),
|
||||
'let workItemMutatedUnsub',
|
||||
'if (import.meta !== undefined && import.meta.hot)'
|
||||
)
|
||||
const listenerSection = sourceBetween(source, 'let workItemMutatedUnsub', '// Why: bounded LRU')
|
||||
const commentContextSection = sourceBetween(
|
||||
componentSource('github/CommentCodeContext.tsx'),
|
||||
'function CommentCodeContext',
|
||||
@@ -235,65 +257,55 @@ describe('PullRequestPage host boundaries', () => {
|
||||
})
|
||||
|
||||
it('routes check actions through the PR source context', () => {
|
||||
const source = componentSource('PullRequestPage.tsx')
|
||||
const checksSection = sourceBetween(source, 'function ChecksTab', 'function MentionTextarea')
|
||||
|
||||
expect(checksSection).toContain('sourceContext?: TaskSourceContext | null')
|
||||
expect(checksSection).toContain('sourceContext,')
|
||||
expect(checksSection).toContain("'github.prChecks'")
|
||||
expect(checksSection).toContain("'github.rerunPRChecks'")
|
||||
expect(checksSection).toContain("'github.prCheckDetails'")
|
||||
expect(checksSection).toContain(
|
||||
'repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId)'
|
||||
expect(checksSource).toContain('sourceContext?: TaskSourceContext | null')
|
||||
expect(checksSource).toContain('sourceContext,')
|
||||
expect(checksSource).toContain("'github.prChecks'")
|
||||
expect(checksSource).toContain("'github.rerunPRChecks'")
|
||||
expect(checksSource).toContain("'github.prCheckDetails'")
|
||||
expect(checksSource).toContain(
|
||||
'repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId)'
|
||||
)
|
||||
expect(checksSection).toContain('window.api.gh.prChecks({')
|
||||
expect(checksSection).toContain('window.api.gh.rerunPRChecks({')
|
||||
expect(checksSection).toContain('prCheckDetails({')
|
||||
expect(checksSection).toMatch(
|
||||
/withGitHubCheckDetailsTimeout\(\(signal\) =>\s*runtimeHost\s*\?\s*callRuntimeRpc[\s\S]*:\s*window\.api\.gh\.prCheckDetails\(\{/
|
||||
expect(checksSource).toContain('window.api.gh.prChecks({')
|
||||
expect(checksSource).toContain('window.api.gh.rerunPRChecks({')
|
||||
expect(checksSource).toContain('prCheckDetails({')
|
||||
expect(checksSource).toMatch(
|
||||
/withGitHubCheckDetailsTimeout\(\(signal\) =>\s*args\.runtimeHost\s*\?\s*callRuntimeRpc[\s\S]*:\s*window\.api\.gh\.prCheckDetails\(\{/
|
||||
)
|
||||
expect(checksSection).toContain('{ timeoutMs: 30_000, signal }')
|
||||
expect(checksSource).toContain('{ timeoutMs: 30_000, signal }')
|
||||
})
|
||||
|
||||
it('makes failed check detail loads retryable and fences stale settlements', () => {
|
||||
const source = componentSource('PullRequestPage.tsx')
|
||||
const checksSection = sourceBetween(source, 'function ChecksTab', 'function MentionTextarea')
|
||||
|
||||
expect(checksSection).toContain('const requestId = ++nextCheckDetailsRequestIdRef.current')
|
||||
expect(checksSection).toContain('settleGitHubChecksTabDetails(current, key, requestId')
|
||||
expect(checksSection).toContain('createGitHubChecksTabState(checks, checkDetailsContextKey)')
|
||||
expect(checksSection).toContain('checksState,\n checks,\n checkDetailsContextKey')
|
||||
expect(checksSection).toContain('resetGitHubChecksTabForSource(current)')
|
||||
expect(checksSection).toContain(
|
||||
'committedChecksContextOwnerRef.current !== refreshContextOwner'
|
||||
expect(checksSource).toContain('const requestId = ++args.nextCheckDetailsRequestIdRef.current')
|
||||
expect(checksSource).toContain('settleGitHubChecksTabDetails(current, args.key, requestId')
|
||||
expect(checksSource).toContain('createGitHubChecksTabState(checks, checkDetailsContextKey)')
|
||||
expect(checksSource).toContain('checksState,\n checks,\n checkDetailsContextKey')
|
||||
expect(checksSource).toContain('resetGitHubChecksTabForSource(current)')
|
||||
expect(checksSource).toContain(
|
||||
'args.committedChecksContextOwnerRef.current !== refreshContextOwner'
|
||||
)
|
||||
expect(checksSection).toContain('activeChecksRefreshRequestIdRef.current !== refreshRequestId')
|
||||
expect(checksSection).toContain('current.contextOwner === refreshContextOwner')
|
||||
expect(checksSection).toContain(
|
||||
'const rerunContextOwner = committedChecksContextOwnerRef.current'
|
||||
expect(checksSource).toContain(
|
||||
'args.activeChecksRefreshRequestIdRef.current !== refreshRequestId'
|
||||
)
|
||||
expect(checksSection).toContain('committedChecksContextOwnerRef.current !== rerunContextOwner')
|
||||
expect(checksSection).toContain('await handleRefresh(rerunContextOwner)')
|
||||
expect(checksSection).toContain('!mountedRef.current ||')
|
||||
expect(checksSection).toContain(
|
||||
'onClick={() => requestCheckDetails(check, getCheckDetailsKey(check))}'
|
||||
expect(checksSource).toContain('current.contextOwner === refreshContextOwner')
|
||||
expect(checksSource).toContain(
|
||||
'const rerunContextOwner = args.committedChecksContextOwnerRef.current'
|
||||
)
|
||||
expect(checksSection).toContain('disabled={state.loading}')
|
||||
expect(checksSection).toContain('aria-busy={state.loading}')
|
||||
expect(checksSection).toContain("translate('githubChecks.retrying', 'Retrying…')")
|
||||
expect(checksSection).toContain(
|
||||
expect(checksSource).toContain(
|
||||
'args.committedChecksContextOwnerRef.current !== rerunContextOwner'
|
||||
)
|
||||
expect(checksSource).toContain('await args.handleRefresh(rerunContextOwner)')
|
||||
expect(checksSource).toContain('!args.mountedRef.current ||')
|
||||
expect(checksSource).toContain('onClick={() => onRetry(check, getCheckDetailsKey(check))}')
|
||||
expect(checksSource).toContain('disabled={state.loading}')
|
||||
expect(checksSource).toContain('aria-busy={state.loading}')
|
||||
expect(checksSource).toContain("translate('githubChecks.retrying', 'Retrying…')")
|
||||
expect(checksSource).toContain(
|
||||
"translate('auto.components.PullRequestPage.5df7c41d2a', 'Retry')"
|
||||
)
|
||||
})
|
||||
|
||||
it('routes edit metadata and mutations through the PR source context', () => {
|
||||
const source = componentSource('PullRequestPage.tsx')
|
||||
const editHelperSection = componentSource('github/github-work-item-edit-mutations.ts')
|
||||
const editSection = sourceBetween(
|
||||
source,
|
||||
'function GHEditSection',
|
||||
'function GHCommentComposer'
|
||||
)
|
||||
|
||||
expect(editHelperSection).toContain("'github.updateIssue'")
|
||||
expect(editHelperSection).toContain("'github.updatePRState'")
|
||||
@@ -311,34 +323,27 @@ describe('PullRequestPage host boundaries', () => {
|
||||
"repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? '')"
|
||||
)
|
||||
expect(editHelperSection).toContain('{ local: false }')
|
||||
expect(editSection).toContain('getTaskSourceRuntimeSettings(sourceContext)')
|
||||
expect(editSection).toContain('sourceContext,')
|
||||
expect(editSource).toContain('getTaskSourceRuntimeSettings(sourceContext)')
|
||||
expect(editSource).toContain('sourceContext,')
|
||||
})
|
||||
|
||||
it('routes merge actions through the repo owner host (#6957)', () => {
|
||||
const source = componentSource('PullRequestPage.tsx')
|
||||
const actionsSection = sourceBetween(
|
||||
source,
|
||||
'function PRActionsPanel',
|
||||
'function CommentReplyForm'
|
||||
)
|
||||
|
||||
expect(actionsSection).toContain(
|
||||
expect(actionsSource).toContain(
|
||||
'getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)'
|
||||
)
|
||||
expect(actionsSection).toContain('getActiveRuntimeTarget(sourceSettings)')
|
||||
expect(actionsSection).toContain(
|
||||
expect(actionsSource).toContain('getActiveRuntimeTarget(sourceSettings)')
|
||||
expect(actionsSource).toContain(
|
||||
'const canMergeWithRepoContext = !!repoPath || mergeTarget.kind ==='
|
||||
)
|
||||
expect(actionsSection).toContain("'github.mergePR'")
|
||||
expect(actionsSection).toContain("'github.setPRAutoMerge'")
|
||||
expect(actionsSection).toContain('const prRepo = resolvePullRequestRepo(item, projectOrigin)')
|
||||
expect(actionsSection).not.toContain('prRepo: item.prRepo ?? null')
|
||||
expect(actionsSection).toContain(
|
||||
'repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId)'
|
||||
expect(actionsSource).toContain("'github.mergePR'")
|
||||
expect(actionsSource).toContain("'github.setPRAutoMerge'")
|
||||
expect(actionsSource).toContain('const prRepo = resolvePullRequestRepo(item, projectOrigin)')
|
||||
expect(actionsSource).not.toContain('prRepo: item.prRepo ?? null')
|
||||
expect(actionsSource).toContain(
|
||||
'repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId)'
|
||||
)
|
||||
expect(actionsSection).toContain('sourceContext,')
|
||||
expect(actionsSection).toContain('notifyWorkItemDetailsMutation(')
|
||||
expect(actionsSection).toContain('{ local: false }')
|
||||
expect(actionsSource).toContain('sourceContext,')
|
||||
expect(actionsSource).toContain('notifyWorkItemDetailsMutation(')
|
||||
expect(actionsSource).toContain('{ local: false }')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { toast } from 'sonner'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
|
||||
import { notifyWorkItemDetailsMutation } from '@/components/github/github-work-item-comment-mutations'
|
||||
import { runPullRequestStateUpdate } from '@/components/github/github-work-item-edit-mutations'
|
||||
import { getGitHubRuntimeRepoId } from '@/lib/github-source-runtime-context'
|
||||
import { GITHUB_PR_MERGE_METHOD_LABELS } from '../../../../../shared/github/pull-request-merge-methods'
|
||||
import type { resolveGitHubPRMergeMethods } from '../../../../../shared/github/pull-request-merge-methods'
|
||||
import type {
|
||||
GitHubOwnerRepo,
|
||||
GitHubPRMergeMethod
|
||||
} from '../../../../../shared/github/pull-request-types'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type { PullRequestPageProjectOrigin } from '../page-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubPRMergeStatePresentation } from '@/components/github-pr-merge-state'
|
||||
|
||||
export async function changePullRequestState(args: {
|
||||
canMutateState: boolean
|
||||
statePending: boolean
|
||||
nextState: 'open' | 'closed'
|
||||
localState: GitHubWorkItem['state']
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
repoId: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
projectOrigin: PullRequestPageProjectOrigin | undefined
|
||||
prRepo: GitHubOwnerRepo | null
|
||||
confirm: (options: {
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
confirmVariant?: 'destructive' | 'default'
|
||||
}) => Promise<boolean>
|
||||
setStatePending: (value: boolean) => void
|
||||
applyStatePatch: (state: GitHubWorkItem['state']) => void
|
||||
onMutated: () => void
|
||||
}): Promise<void> {
|
||||
if (!args.canMutateState || args.statePending) {
|
||||
return
|
||||
}
|
||||
const label = args.nextState === 'closed' ? 'Close' : 'Reopen'
|
||||
const confirmed = await args.confirm({
|
||||
title: translate('auto.components.PullRequestPage.eec3706a6a', '{{value0}} PR #{{value1}}?', {
|
||||
value0: label,
|
||||
value1: args.item.number
|
||||
}),
|
||||
description:
|
||||
args.nextState === 'closed'
|
||||
? translate(
|
||||
'auto.components.PullRequestPage.5a65651096',
|
||||
'This will close the pull request on GitHub.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.PullRequestPage.3d77438c92',
|
||||
'This will reopen the pull request on GitHub.'
|
||||
),
|
||||
confirmLabel: label,
|
||||
confirmVariant: args.nextState === 'closed' ? 'destructive' : 'default'
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
const previousState = args.localState
|
||||
args.setStatePending(true)
|
||||
args.applyStatePatch(args.nextState)
|
||||
try {
|
||||
await runPullRequestStateUpdate({
|
||||
repoPath: args.repoPath,
|
||||
repoId: args.repoId,
|
||||
sourceContext: args.sourceContext,
|
||||
projectOrigin: args.projectOrigin,
|
||||
number: args.item.number,
|
||||
prRepo: args.prRepo,
|
||||
updates: { state: args.nextState }
|
||||
})
|
||||
toast.success(
|
||||
args.nextState === 'closed'
|
||||
? translate('auto.components.PullRequestPage.7aa3b5f706', 'Pull request closed')
|
||||
: translate('auto.components.PullRequestPage.710e47aa06', 'Pull request reopened')
|
||||
)
|
||||
args.onMutated()
|
||||
} catch (err) {
|
||||
args.applyStatePatch(previousState)
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate('auto.components.PullRequestPage.b8c6cbb8c4', 'Failed to {{value0}} PR', {
|
||||
value0: label.toLowerCase()
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
args.setStatePending(false)
|
||||
}
|
||||
}
|
||||
|
||||
export async function mergePullRequest(args: {
|
||||
mergeDisabled: boolean
|
||||
method: GitHubPRMergeMethod
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
repoId: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prRepo: GitHubOwnerRepo | null
|
||||
mergeTarget: RuntimeClientTarget
|
||||
confirm: (options: {
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
}) => Promise<boolean>
|
||||
setMergePending: (value: boolean) => void
|
||||
applyStatePatch: (state: GitHubWorkItem['state']) => void
|
||||
onMutated: () => void
|
||||
}): Promise<void> {
|
||||
if (args.mergeDisabled) {
|
||||
return
|
||||
}
|
||||
const label = GITHUB_PR_MERGE_METHOD_LABELS[args.method]
|
||||
const confirmed = await args.confirm({
|
||||
title: translate('auto.components.PullRequestPage.eec3706a6a', '{{value0}} PR #{{value1}}?', {
|
||||
value0: label,
|
||||
value1: args.item.number
|
||||
}),
|
||||
description: translate(
|
||||
'auto.components.PullRequestPage.a63b3c159c',
|
||||
'This will update the pull request on GitHub.'
|
||||
),
|
||||
confirmLabel: label
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
args.setMergePending(true)
|
||||
try {
|
||||
const result =
|
||||
args.mergeTarget.kind === 'environment'
|
||||
? await callRuntimeRpc<Awaited<ReturnType<typeof window.api.gh.mergePR>>>(
|
||||
args.mergeTarget,
|
||||
'github.mergePR',
|
||||
{
|
||||
repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId),
|
||||
prNumber: args.item.number,
|
||||
method: args.method,
|
||||
prRepo: args.prRepo
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: await window.api.gh.mergePR({
|
||||
repoPath: args.repoPath ?? '',
|
||||
repoId: args.repoId ?? undefined,
|
||||
sourceContext: args.sourceContext,
|
||||
prNumber: args.item.number,
|
||||
method: args.method,
|
||||
prRepo: args.prRepo
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
args.applyStatePatch('merged')
|
||||
if (args.mergeTarget.kind === 'environment') {
|
||||
notifyWorkItemDetailsMutation(
|
||||
{
|
||||
repoPath: args.repoPath ?? '',
|
||||
repoId: args.item.repoId,
|
||||
sourceContext: args.sourceContext,
|
||||
type: 'pr',
|
||||
number: args.item.number
|
||||
},
|
||||
{ local: false }
|
||||
)
|
||||
}
|
||||
toast.success(translate('auto.components.PullRequestPage.c57873d721', 'Pull request merged'))
|
||||
args.onMutated()
|
||||
} catch {
|
||||
toast.error(
|
||||
translate('auto.components.PullRequestPage.aae645d36d', 'Failed to merge pull request')
|
||||
)
|
||||
} finally {
|
||||
args.setMergePending(false)
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPullRequestAutoMerge(args: {
|
||||
canMergeWithRepoContext: boolean
|
||||
mergePresentation: GitHubPRMergeStatePresentation
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
repoId: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prRepo: GitHubOwnerRepo | null
|
||||
mergeTarget: RuntimeClientTarget
|
||||
mergeMethods: ReturnType<typeof resolveGitHubPRMergeMethods>
|
||||
setMergePending: (value: boolean) => void
|
||||
onMutated: () => void
|
||||
}): Promise<void> {
|
||||
if (!args.canMergeWithRepoContext || !args.mergePresentation.autoMergeAction) {
|
||||
return
|
||||
}
|
||||
const enabled = args.mergePresentation.autoMergeAction.kind === 'enable'
|
||||
args.setMergePending(true)
|
||||
try {
|
||||
const result =
|
||||
args.mergeTarget.kind === 'environment'
|
||||
? await callRuntimeRpc<Awaited<ReturnType<typeof window.api.gh.setPRAutoMerge>>>(
|
||||
args.mergeTarget,
|
||||
'github.setPRAutoMerge',
|
||||
{
|
||||
repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId),
|
||||
prNumber: args.item.number,
|
||||
enabled,
|
||||
method: enabled ? args.mergeMethods.defaultMethod : undefined,
|
||||
prRepo: args.prRepo
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: await window.api.gh.setPRAutoMerge({
|
||||
repoPath: args.repoPath ?? '',
|
||||
repoId: args.repoId ?? undefined,
|
||||
sourceContext: args.sourceContext,
|
||||
prNumber: args.item.number,
|
||||
enabled,
|
||||
method: enabled ? args.mergeMethods.defaultMethod : undefined,
|
||||
prRepo: args.prRepo
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
if (args.mergeTarget.kind === 'environment') {
|
||||
notifyWorkItemDetailsMutation(
|
||||
{
|
||||
repoPath: args.repoPath ?? '',
|
||||
repoId: args.item.repoId,
|
||||
sourceContext: args.sourceContext,
|
||||
type: 'pr',
|
||||
number: args.item.number
|
||||
},
|
||||
{ local: false }
|
||||
)
|
||||
}
|
||||
toast.success(
|
||||
enabled
|
||||
? translate('auto.components.PullRequestPage.5edbe7eefa', 'Auto-merge enabled')
|
||||
: translate('auto.components.PullRequestPage.0f5821b035', 'Auto-merge disabled')
|
||||
)
|
||||
args.onMutated()
|
||||
} catch {
|
||||
toast.error(
|
||||
enabled
|
||||
? translate('auto.components.PullRequestPage.d31f4b508c', 'Failed to enable auto-merge')
|
||||
: translate('auto.components.PullRequestPage.973ef2fac9', 'Failed to disable auto-merge')
|
||||
)
|
||||
} finally {
|
||||
args.setMergePending(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
ExternalLink,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
GitPullRequestClosed,
|
||||
LoaderCircle
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { getGitHubMutationRoutingSettings } from '@/lib/github-source-runtime-context'
|
||||
import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state'
|
||||
import { resolveGitHubPRMergeMethods } from '../../../../../shared/github/pull-request-merge-methods'
|
||||
import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type { PullRequestPageProjectOrigin } from '../page-types'
|
||||
import { WorkItemStateBadge } from '../presentation/state-badge'
|
||||
import { changePullRequestState, mergePullRequest, setPullRequestAutoMerge } from './merge-actions'
|
||||
|
||||
export function PRActionsPanel({
|
||||
item,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
localState,
|
||||
onStateChange,
|
||||
onMutated
|
||||
}: {
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
repoId: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
projectOrigin: PullRequestPageProjectOrigin | undefined
|
||||
localState: GitHubWorkItem['state']
|
||||
onStateChange: (state: GitHubWorkItem['state']) => void
|
||||
onMutated: () => void
|
||||
}): React.JSX.Element {
|
||||
const [statePending, setStatePending] = useState(false)
|
||||
const [mergePending, setMergePending] = useState(false)
|
||||
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
|
||||
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
|
||||
const confirm = useConfirmationDialog()
|
||||
const actionItem = { ...item, state: localState }
|
||||
const mergePresentation = presentGitHubPRMergeState(actionItem)
|
||||
const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings)
|
||||
const sourceSettings = useAppStore(
|
||||
useShallow((s) =>
|
||||
getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)
|
||||
)
|
||||
)
|
||||
const mergeTarget = getActiveRuntimeTarget(sourceSettings)
|
||||
const prRepo = resolvePullRequestRepo(item, projectOrigin)
|
||||
const canMutateWithRepoContext =
|
||||
!!repoPath || !!projectOrigin || mergeTarget.kind === 'environment'
|
||||
const canMutateState = localState !== 'merged' && canMutateWithRepoContext
|
||||
const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed'
|
||||
const canMergeWithRepoContext = !!repoPath || mergeTarget.kind === 'environment'
|
||||
const mergeDisabled =
|
||||
!canMergeWithRepoContext || mergePending || !mergePresentation.directMergeAvailable
|
||||
|
||||
const patchProjectRowIfNeeded = useCallback(
|
||||
(state: GitHubWorkItem['state']) => {
|
||||
if (!projectOrigin) {
|
||||
return
|
||||
}
|
||||
patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { state })
|
||||
},
|
||||
[patchProjectRowContent, projectOrigin]
|
||||
)
|
||||
|
||||
const applyStatePatch = useCallback(
|
||||
(state: GitHubWorkItem['state']) => {
|
||||
onStateChange(state)
|
||||
patchWorkItem(item.id, { state }, item.repoId, { sourceContext })
|
||||
patchProjectRowIfNeeded(state)
|
||||
},
|
||||
[item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, sourceContext]
|
||||
)
|
||||
|
||||
return (
|
||||
<aside className="rounded-lg border border-border/50 bg-card p-3 shadow-xs">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitPullRequest className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[13px] font-medium text-foreground">
|
||||
{translate('auto.components.PullRequestPage.1939d0f663', 'Pull request')}
|
||||
</span>
|
||||
</div>
|
||||
<WorkItemStateBadge item={actionItem} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<DropdownMenu modal={false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'w-full justify-center gap-2 bg-green-600 text-white hover:bg-green-700',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{mergePending ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<GitMerge className="size-3.5" />
|
||||
)}
|
||||
{mergePresentation.autoMergeAction?.label ??
|
||||
(mergePresentation.directMergeAvailable
|
||||
? mergeMethods.defaultLabel
|
||||
: mergePresentation.label)}
|
||||
<ChevronDown className="size-3 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{!canMergeWithRepoContext
|
||||
? translate(
|
||||
'auto.components.PullRequestPage.eca289e593',
|
||||
'Merge requires a registered local repo'
|
||||
)
|
||||
: mergePresentation.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" className="w-52">
|
||||
{mergePresentation.autoMergeAction && (
|
||||
<DropdownMenuItem
|
||||
disabled={!canMergeWithRepoContext || mergePending}
|
||||
onSelect={() =>
|
||||
void setPullRequestAutoMerge({
|
||||
canMergeWithRepoContext,
|
||||
mergePresentation,
|
||||
item,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
prRepo,
|
||||
mergeTarget,
|
||||
mergeMethods,
|
||||
setMergePending,
|
||||
onMutated
|
||||
})
|
||||
}
|
||||
>
|
||||
<GitMerge className="size-4" />
|
||||
{mergePresentation.autoMergeAction.label}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{mergePresentation.autoMergeAction && <DropdownMenuSeparator />}
|
||||
{mergeMethods.methods.map(({ method, label }) => (
|
||||
<DropdownMenuItem
|
||||
key={method}
|
||||
disabled={mergeDisabled}
|
||||
onSelect={() =>
|
||||
void mergePullRequest({
|
||||
mergeDisabled,
|
||||
method,
|
||||
item,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
prRepo,
|
||||
mergeTarget,
|
||||
confirm,
|
||||
setMergePending,
|
||||
applyStatePatch,
|
||||
onMutated
|
||||
})
|
||||
}
|
||||
>
|
||||
<GitMerge className="size-4" />
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(item.url)}>
|
||||
<ExternalLink className="size-4" />
|
||||
{translate('auto.components.PullRequestPage.7df8d5fc60', 'Open GitHub merge box')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant={nextState === 'closed' ? 'outline' : 'secondary'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'w-full justify-center gap-2',
|
||||
nextState === 'closed' &&
|
||||
'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50'
|
||||
)}
|
||||
disabled={!canMutateState || statePending}
|
||||
onClick={() =>
|
||||
void changePullRequestState({
|
||||
canMutateState,
|
||||
statePending,
|
||||
nextState,
|
||||
localState,
|
||||
item,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
prRepo,
|
||||
confirm,
|
||||
setStatePending,
|
||||
applyStatePatch,
|
||||
onMutated
|
||||
})
|
||||
}
|
||||
>
|
||||
{statePending ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : nextState === 'closed' ? (
|
||||
<GitPullRequestClosed className="size-3.5 text-destructive" />
|
||||
) : (
|
||||
<CircleDot className="size-3.5" />
|
||||
)}
|
||||
{nextState === 'closed'
|
||||
? translate('auto.components.PullRequestPage.96d013ed28', 'Close pull request')
|
||||
: translate('auto.components.PullRequestPage.9d5425918e', 'Reopen PR')}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type {
|
||||
GitHubOwnerRepo,
|
||||
GitHubPRFile,
|
||||
GitHubPRFileContents
|
||||
} from '../../../../../shared/github/pull-request-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import { getTaskSourceCacheScope } from '../../../../../shared/task-source-context'
|
||||
import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
getGitHubRuntimeRepoId,
|
||||
getGitHubSourceRuntimeHost
|
||||
} from '@/lib/github-source-runtime-context'
|
||||
import {
|
||||
PR_FILE_CONTENT_CACHE_MAX_BYTES,
|
||||
getRetainedPRFileContentsByteCount
|
||||
} from '@/components/github/pr-file-content-size'
|
||||
|
||||
// Why: bounded LRU so a session of opening many PR files can't grow this module map without bound.
|
||||
const PR_FILE_CONTENT_CACHE_MAX = 64
|
||||
type PRFileContentCacheEntry = {
|
||||
value: Promise<GitHubPRFileContents> | GitHubPRFileContents
|
||||
byteCount: number
|
||||
}
|
||||
const prFileContentCache = new Map<string, PRFileContentCacheEntry>()
|
||||
let prFileContentCacheBytes = 0
|
||||
|
||||
function touchPRFileContentCache(
|
||||
key: string,
|
||||
value: Promise<GitHubPRFileContents> | GitHubPRFileContents
|
||||
): void {
|
||||
const retainedByteCount = value instanceof Promise ? 0 : getRetainedPRFileContentsByteCount(value)
|
||||
if (retainedByteCount === null) {
|
||||
const existing = prFileContentCache.get(key)
|
||||
prFileContentCacheBytes -= existing?.byteCount ?? 0
|
||||
prFileContentCache.delete(key)
|
||||
return
|
||||
}
|
||||
|
||||
const existing = prFileContentCache.get(key)
|
||||
prFileContentCacheBytes -= existing?.byteCount ?? 0
|
||||
// Why: re-insert moves the key to MRU; Map insertion order makes the oldest key first when evicting.
|
||||
prFileContentCache.delete(key)
|
||||
const byteCount = retainedByteCount
|
||||
prFileContentCache.set(key, { value, byteCount })
|
||||
prFileContentCacheBytes += byteCount
|
||||
while (
|
||||
prFileContentCache.size > PR_FILE_CONTENT_CACHE_MAX ||
|
||||
prFileContentCacheBytes > PR_FILE_CONTENT_CACHE_MAX_BYTES
|
||||
) {
|
||||
const oldest = prFileContentCache.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
const evicted = prFileContentCache.get(oldest)
|
||||
prFileContentCacheBytes -= evicted?.byteCount ?? 0
|
||||
prFileContentCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
export function getPRFileContentCacheKey(args: {
|
||||
repoPath: string
|
||||
repoId: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
file: GitHubPRFile
|
||||
headSha: string
|
||||
baseSha: string
|
||||
}): string {
|
||||
const repositoryKey = args.repoId ? `repo:${args.repoId}` : `path:${args.repoPath}`
|
||||
const sourceKey =
|
||||
args.sourceContext?.provider === 'github'
|
||||
? `source:${getTaskSourceCacheScope(args.sourceContext)}`
|
||||
: 'source:local'
|
||||
return [
|
||||
repositoryKey,
|
||||
sourceKey,
|
||||
args.prNumber,
|
||||
args.prRepo ? githubRepoIdentityKey(args.prRepo) : '',
|
||||
args.file.path,
|
||||
args.file.oldPath ?? '',
|
||||
args.file.status,
|
||||
args.headSha,
|
||||
args.baseSha
|
||||
].join('\0')
|
||||
}
|
||||
|
||||
export function loadPRFileContents(args: {
|
||||
repoPath: string
|
||||
repoId: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
file: GitHubPRFile
|
||||
headSha: string
|
||||
baseSha: string
|
||||
}): Promise<GitHubPRFileContents> {
|
||||
const cacheKey = getPRFileContentCacheKey(args)
|
||||
const cached = prFileContentCache.get(cacheKey)
|
||||
if (cached) {
|
||||
touchPRFileContentCache(cacheKey, cached.value)
|
||||
return Promise.resolve(cached.value)
|
||||
}
|
||||
let request: Promise<GitHubPRFileContents>
|
||||
const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext)
|
||||
request = (
|
||||
runtimeHost
|
||||
? callRuntimeRpc<GitHubPRFileContents>(
|
||||
{ kind: 'environment', environmentId: runtimeHost.environmentId },
|
||||
'github.prFileContents',
|
||||
{
|
||||
repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId),
|
||||
prNumber: args.prNumber,
|
||||
prRepo: args.prRepo ?? null,
|
||||
path: args.file.path,
|
||||
oldPath: args.file.oldPath,
|
||||
status: args.file.status,
|
||||
headSha: args.headSha,
|
||||
baseSha: args.baseSha
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: window.api.gh.prFileContents({
|
||||
repoPath: args.repoPath,
|
||||
repoId: args.repoId,
|
||||
sourceContext: args.sourceContext,
|
||||
prNumber: args.prNumber,
|
||||
prRepo: args.prRepo ?? null,
|
||||
path: args.file.path,
|
||||
oldPath: args.file.oldPath,
|
||||
status: args.file.status,
|
||||
headSha: args.headSha,
|
||||
baseSha: args.baseSha
|
||||
})
|
||||
)
|
||||
.then((contents) => {
|
||||
if (prFileContentCache.get(cacheKey)?.value === request) {
|
||||
touchPRFileContentCache(cacheKey, contents)
|
||||
}
|
||||
return contents
|
||||
})
|
||||
.catch((err) => {
|
||||
const cachedRequest = prFileContentCache.get(cacheKey)
|
||||
if (cachedRequest?.value === request) {
|
||||
prFileContentCacheBytes -= cachedRequest.byteCount
|
||||
prFileContentCache.delete(cacheKey)
|
||||
}
|
||||
throw err
|
||||
})
|
||||
touchPRFileContentCache(cacheKey, request)
|
||||
return request
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DiffSection } from '@/components/editor/diff-section-types'
|
||||
|
||||
export type CachedPRFilesDiffViewState = {
|
||||
entrySignature: string
|
||||
sections: DiffSection[]
|
||||
sectionHeights: Record<number, number>
|
||||
loadedIndices: number[]
|
||||
scrollTop: number
|
||||
sideBySide: boolean
|
||||
fileTreeCollapsed: boolean
|
||||
activeTreeSectionKey: string | null
|
||||
}
|
||||
|
||||
export const prFilesDiffViewStateCache = new Map<string, CachedPRFilesDiffViewState>()
|
||||
export const prFilesDiffScrollTopCache = new Map<string, number>()
|
||||
@@ -0,0 +1,194 @@
|
||||
import type {
|
||||
GitHubAssignableUser,
|
||||
GitHubPRFileViewedState
|
||||
} from '../../../../../shared/github/pull-request-types'
|
||||
import type { GitHubWorkItemDetails } from '../../../../../shared/github/work-item-types'
|
||||
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
|
||||
import { onGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events'
|
||||
|
||||
// SWR cache: reopening a drawer paints cached data instantly while a background refetch reconciles. See docs/gh-work-item-drawer-cache.md.
|
||||
const WORK_ITEM_DETAILS_CACHE_MAX = 50
|
||||
export const WORK_ITEM_DETAILS_FRESH_MS = 30_000
|
||||
export const WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE = 'Unable to load details for this GitHub item.'
|
||||
export type WorkItemDetailsCacheEntry = {
|
||||
details: GitHubWorkItemDetails | null
|
||||
fetchedAt: number
|
||||
pending?: Promise<GitHubWorkItemDetails | null>
|
||||
error?: string
|
||||
}
|
||||
export const workItemDetailsCache = new Map<string, WorkItemDetailsCacheEntry>()
|
||||
|
||||
// Why: useSyncExternalStore snapshot stability relies on every cache write replacing the entry object identity (delete+set).
|
||||
const workItemDetailsCacheListeners = new Set<() => void>()
|
||||
export function subscribeWorkItemDetailsCache(listener: () => void): () => void {
|
||||
workItemDetailsCacheListeners.add(listener)
|
||||
return () => {
|
||||
workItemDetailsCacheListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
function notifyWorkItemDetailsCache(): void {
|
||||
for (const listener of workItemDetailsCacheListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkItemDetailsCacheKey(args: {
|
||||
repoPath: string
|
||||
repoId: string
|
||||
issueSourcePreference: string | undefined
|
||||
sourceCacheScope?: string | null
|
||||
type: 'issue' | 'pr'
|
||||
number: number
|
||||
}): string {
|
||||
// Why: `\0` separator avoids collisions between key fields that may contain `:` or `/`.
|
||||
const keyParts = args.sourceCacheScope
|
||||
? [args.repoId, args.sourceCacheScope, args.issueSourcePreference ?? 'auto', args.type]
|
||||
: [args.repoId, args.issueSourcePreference ?? 'auto', args.type]
|
||||
return [...keyParts, args.number].join('\0')
|
||||
}
|
||||
|
||||
export function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry): void {
|
||||
// Why: re-insert moves the key to MRU; Map insertion order keeps the oldest key first when evicting.
|
||||
workItemDetailsCache.delete(key)
|
||||
workItemDetailsCache.set(key, entry)
|
||||
while (workItemDetailsCache.size > WORK_ITEM_DETAILS_CACHE_MAX) {
|
||||
const oldest = workItemDetailsCache.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
workItemDetailsCache.delete(oldest)
|
||||
}
|
||||
notifyWorkItemDetailsCache()
|
||||
}
|
||||
|
||||
// Why: object (not a number export) so cross-module readers always see later bumps after the split.
|
||||
export const workItemDetailsCacheGeneration = { current: 0 }
|
||||
|
||||
// Exposed so mutation handlers can drop a stale entry after a local mutation (cross-window invalidation arrives via the `gh:workItemMutated` listener below).
|
||||
export function invalidateWorkItemDetailsCacheForKey(key: string): void {
|
||||
// Why: bump generation so an in-flight fetch launched before this invalidation won't write its stale result back.
|
||||
workItemDetailsCacheGeneration.current += 1
|
||||
const existed = workItemDetailsCache.delete(key)
|
||||
if (existed) {
|
||||
notifyWorkItemDetailsCache()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: without the exact key (cross-window events carry only repoPath+number+type), drop every entry matching that tuple regardless of source preference.
|
||||
export function invalidateWorkItemDetailsCacheByMatch(args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
type: 'issue' | 'pr'
|
||||
number: number
|
||||
}): void {
|
||||
const suffix = `\0${args.type}\0${args.number}`
|
||||
const prefix = `${args.repoId ?? args.repoPath}\0`
|
||||
let removed = false
|
||||
for (const key of Array.from(workItemDetailsCache.keys())) {
|
||||
if (key.startsWith(prefix) && key.endsWith(suffix)) {
|
||||
workItemDetailsCache.delete(key)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
if (removed) {
|
||||
workItemDetailsCacheGeneration.current += 1
|
||||
notifyWorkItemDetailsCache()
|
||||
}
|
||||
}
|
||||
|
||||
export function patchCachedPRFileViewedState(
|
||||
cacheKey: string,
|
||||
path: string,
|
||||
viewerViewedState: GitHubPRFileViewedState
|
||||
): GitHubPRFileViewedState | undefined {
|
||||
const prev = workItemDetailsCache.get(cacheKey)
|
||||
const files = prev?.details?.files
|
||||
if (!prev?.details || !files) {
|
||||
return undefined
|
||||
}
|
||||
let previousState: GitHubPRFileViewedState | undefined
|
||||
const nextFiles = files.map((file) => {
|
||||
if (file.path !== path) {
|
||||
return file
|
||||
}
|
||||
previousState = file.viewerViewedState ?? 'UNVIEWED'
|
||||
return { ...file, viewerViewedState }
|
||||
})
|
||||
if (previousState === undefined || previousState === viewerViewedState) {
|
||||
return previousState
|
||||
}
|
||||
touchWorkItemDetailsCache(cacheKey, {
|
||||
...prev,
|
||||
details: { ...prev.details, files: nextFiles },
|
||||
error: undefined
|
||||
})
|
||||
return previousState
|
||||
}
|
||||
|
||||
export function patchCachedPRChecks(cacheKey: string, checks: PRCheckDetail[]): void {
|
||||
const prev = workItemDetailsCache.get(cacheKey)
|
||||
if (!prev?.details) {
|
||||
return
|
||||
}
|
||||
touchWorkItemDetailsCache(cacheKey, {
|
||||
...prev,
|
||||
details: { ...prev.details, checks },
|
||||
fetchedAt: Date.now(),
|
||||
error: undefined
|
||||
})
|
||||
}
|
||||
|
||||
export function patchCachedPRReviewRequests(
|
||||
cacheKey: string,
|
||||
reviewRequests: GitHubAssignableUser[]
|
||||
): void {
|
||||
const prev = workItemDetailsCache.get(cacheKey)
|
||||
if (!prev?.details) {
|
||||
return
|
||||
}
|
||||
touchWorkItemDetailsCache(cacheKey, {
|
||||
...prev,
|
||||
details: {
|
||||
...prev.details,
|
||||
item: { ...prev.details.item, reviewRequests }
|
||||
},
|
||||
fetchedAt: Date.now(),
|
||||
error: undefined
|
||||
})
|
||||
}
|
||||
|
||||
export function patchCachedWorkItemBody(cacheKey: string, body: string): void {
|
||||
const prev = workItemDetailsCache.get(cacheKey)
|
||||
if (!prev?.details) {
|
||||
return
|
||||
}
|
||||
touchWorkItemDetailsCache(cacheKey, {
|
||||
...prev,
|
||||
details: { ...prev.details, body },
|
||||
fetchedAt: Date.now(),
|
||||
error: undefined
|
||||
})
|
||||
}
|
||||
|
||||
// Install once at module load (all dialogs share the cache); track the unsubscribe so Vite HMR doesn't accumulate listeners across reloads.
|
||||
let workItemMutatedUnsub: (() => void) | undefined
|
||||
let workItemDetailsCacheEventUnsub: (() => void) | undefined
|
||||
if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) {
|
||||
workItemMutatedUnsub = window.api.gh.onWorkItemMutated((payload) => {
|
||||
invalidateWorkItemDetailsCacheByMatch({
|
||||
repoPath: payload.repoPath,
|
||||
repoId: payload.repoId,
|
||||
type: payload.type,
|
||||
number: payload.number
|
||||
})
|
||||
})
|
||||
workItemDetailsCacheEventUnsub = onGitHubWorkItemDetailsCacheMutation((payload) => {
|
||||
invalidateWorkItemDetailsCacheByMatch(payload)
|
||||
})
|
||||
}
|
||||
if (import.meta !== undefined && import.meta.hot) {
|
||||
import.meta.hot.dispose(() => {
|
||||
workItemMutatedUnsub?.()
|
||||
workItemDetailsCacheEventUnsub?.()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout'
|
||||
import {
|
||||
beginGitHubChecksTabDetails,
|
||||
settleGitHubChecksTabDetails,
|
||||
type CheckDetailsLoadState,
|
||||
type GitHubChecksTabState
|
||||
} from '@/components/github-checks-tab-state'
|
||||
import { getGitHubRuntimeRepoId, type GitHubRuntimeHost } from '@/lib/github-source-runtime-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
|
||||
export function requestPullRequestCheckDetails(args: {
|
||||
canUseChecksRepoContext: boolean
|
||||
check: PRCheckDetail
|
||||
key: string
|
||||
nextCheckDetailsRequestIdRef: { current: number }
|
||||
mountedRef: { current: boolean }
|
||||
setChecksState: (updater: (current: GitHubChecksTabState) => GitHubChecksTabState) => void
|
||||
runtimeHost: GitHubRuntimeHost | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
repoId: string | null
|
||||
repoPath: string | null
|
||||
item: GitHubWorkItem
|
||||
prRepo: GitHubOwnerRepo | null
|
||||
}): void {
|
||||
if (
|
||||
!args.canUseChecksRepoContext ||
|
||||
(!args.check.checkRunId && !args.check.workflowRunId && !args.check.url)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const requestId = ++args.nextCheckDetailsRequestIdRef.current
|
||||
const commit = (next: Omit<CheckDetailsLoadState, 'requestId'>): void => {
|
||||
if (!args.mountedRef.current) {
|
||||
return
|
||||
}
|
||||
args.setChecksState((current) =>
|
||||
settleGitHubChecksTabDetails(current, args.key, requestId, next)
|
||||
)
|
||||
}
|
||||
args.setChecksState((current) => beginGitHubChecksTabDetails(current, args.key, requestId))
|
||||
const detailsRequest = withGitHubCheckDetailsTimeout((signal) =>
|
||||
args.runtimeHost
|
||||
? callRuntimeRpc<Awaited<ReturnType<typeof window.api.gh.prCheckDetails>>>(
|
||||
{ kind: 'environment', environmentId: args.runtimeHost.environmentId },
|
||||
'github.prCheckDetails',
|
||||
{
|
||||
repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId),
|
||||
checkRunId: args.check.checkRunId,
|
||||
workflowRunId: args.check.workflowRunId,
|
||||
checkName: args.check.name,
|
||||
url: args.check.url,
|
||||
prRepo: args.prRepo
|
||||
},
|
||||
{ timeoutMs: 30_000, signal }
|
||||
)
|
||||
: window.api.gh.prCheckDetails({
|
||||
repoPath: args.repoPath ?? '',
|
||||
repoId: args.repoId ?? undefined,
|
||||
sourceContext: args.sourceContext,
|
||||
checkRunId: args.check.checkRunId,
|
||||
workflowRunId: args.check.workflowRunId,
|
||||
checkName: args.check.name,
|
||||
url: args.check.url,
|
||||
prRepo: args.prRepo
|
||||
})
|
||||
)
|
||||
void detailsRequest
|
||||
.then((details) => {
|
||||
commit({
|
||||
loading: false,
|
||||
details,
|
||||
error: details
|
||||
? null
|
||||
: translate(
|
||||
'auto.components.PullRequestPage.6b1d5ee3e4',
|
||||
'No inline details are available for this check.'
|
||||
)
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
commit({
|
||||
loading: false,
|
||||
details: null,
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate(
|
||||
'auto.components.PullRequestPage.e04c027d98',
|
||||
'Failed to load check details.'
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import React from 'react'
|
||||
import { ExternalLink, LoaderCircle, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getCheckConclusion } from '@/components/pr-check-counts'
|
||||
import {
|
||||
formatCheckTimestamp,
|
||||
getCheckDetailsKey,
|
||||
getCheckStatusLabel
|
||||
} from '@/components/github/pr-check-presentation'
|
||||
import type { CheckDetailsLoadState } from '@/components/github-checks-tab-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
|
||||
|
||||
export function CheckDetailsPanel({
|
||||
check,
|
||||
state,
|
||||
onRetry
|
||||
}: {
|
||||
check: PRCheckDetail
|
||||
state: CheckDetailsLoadState | undefined
|
||||
onRetry: (check: PRCheckDetail, key: string) => void
|
||||
}): React.JSX.Element {
|
||||
const details = state?.details
|
||||
const openUrl = details?.detailsUrl ?? details?.url ?? check.url
|
||||
const startedAt = formatCheckTimestamp(details?.startedAt)
|
||||
const completedAt = formatCheckTimestamp(details?.completedAt)
|
||||
const detailsStatusCheck: PRCheckDetail = {
|
||||
...check,
|
||||
status: (details?.status as PRCheckDetail['status'] | undefined) ?? check.status,
|
||||
conclusion: (details?.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion
|
||||
}
|
||||
const hasOutput = Boolean(details?.title || details?.summary || details?.text)
|
||||
const hasAnnotations = (details?.annotations.length ?? 0) > 0
|
||||
const hasJobs = (details?.jobs.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<div className="mx-2 mb-2 mt-1 min-w-0 rounded-md border border-border/50 bg-muted/20 px-3 py-2">
|
||||
{state?.loading && !state.error ? (
|
||||
<div className="flex items-center gap-2 py-2 text-[12px] text-muted-foreground">
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
{translate('auto.components.PullRequestPage.d8e82b7f15', 'Loading check details…')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{translate('auto.components.PullRequestPage.662bc2998d', 'Status:')}{' '}
|
||||
{details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)}
|
||||
</span>
|
||||
{startedAt && (
|
||||
<span>
|
||||
{translate('auto.components.PullRequestPage.76551b1161', 'Started')} {startedAt}
|
||||
</span>
|
||||
)}
|
||||
{completedAt && (
|
||||
<span>
|
||||
{translate('auto.components.PullRequestPage.000f90afcf', 'Completed')} {completedAt}
|
||||
</span>
|
||||
)}
|
||||
{check.checkRunId && (
|
||||
<span className="font-mono">
|
||||
{translate('auto.components.PullRequestPage.f01bf79a79', 'check #')}
|
||||
{check.checkRunId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state?.error && (
|
||||
<div role="alert" className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="min-w-0 break-words text-[12px] text-destructive">{state.error}</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
disabled={state.loading}
|
||||
aria-busy={state.loading}
|
||||
onClick={() => onRetry(check, getCheckDetailsKey(check))}
|
||||
>
|
||||
<RefreshCw className={cn('size-3', state.loading && 'animate-spin')} />
|
||||
{state.loading
|
||||
? translate('githubChecks.retrying', 'Retrying…')
|
||||
: translate('auto.components.PullRequestPage.5df7c41d2a', 'Retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasOutput && (
|
||||
<div className="min-w-0 rounded-md border border-border/40 bg-background/70 px-2.5 py-2">
|
||||
{details?.title && (
|
||||
<div className="mb-1 text-[12px] font-medium text-foreground">{details.title}</div>
|
||||
)}
|
||||
{details?.summary && (
|
||||
<CommentMarkdown
|
||||
content={details.summary}
|
||||
variant="document"
|
||||
className="min-w-0 max-w-full overflow-hidden break-words text-[12px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
|
||||
/>
|
||||
)}
|
||||
{details?.text && (
|
||||
<CommentMarkdown
|
||||
content={details.text}
|
||||
variant="document"
|
||||
className="mt-2 min-w-0 max-w-full overflow-hidden break-words text-[12px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAnnotations && (
|
||||
<div className="min-w-0 rounded-md border border-border/40 bg-background/70">
|
||||
<div className="border-b border-border/40 px-2.5 py-1.5 text-[11px] font-medium text-foreground">
|
||||
{translate('auto.components.PullRequestPage.8432d17901', 'Annotations')}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{details!.annotations.map((annotation, index) => (
|
||||
<div
|
||||
key={`${annotation.path ?? 'annotation'}-${index}`}
|
||||
className={cn(
|
||||
'min-w-0 px-2.5 py-2 text-[12px]',
|
||||
index > 0 && 'border-t border-border/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{annotation.path ??
|
||||
translate('auto.components.PullRequestPage.35a0573f41', 'Annotation')}
|
||||
{annotation.startLine ? `:${annotation.startLine}` : ''}
|
||||
</span>
|
||||
{annotation.annotationLevel && (
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{annotation.annotationLevel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{annotation.title && (
|
||||
<div className="mt-1 text-[12px] font-medium text-foreground">
|
||||
{annotation.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 break-words text-[12px] text-foreground">
|
||||
{annotation.message}
|
||||
</div>
|
||||
{annotation.rawDetails && (
|
||||
<pre className="mt-1 whitespace-pre-wrap rounded bg-muted/40 p-2 font-mono text-[11px] text-muted-foreground">
|
||||
{annotation.rawDetails}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasJobs && (
|
||||
<div className="min-w-0 rounded-md border border-border/40 bg-background/70">
|
||||
<div className="border-b border-border/40 px-2.5 py-1.5 text-[11px] font-medium text-foreground">
|
||||
{translate('auto.components.PullRequestPage.7720c9c3f5', 'Jobs')}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{details!.jobs.map((job, index) => (
|
||||
<div
|
||||
key={`${job.name}-${index}`}
|
||||
className={cn('min-w-0 px-2.5 py-2', index > 0 && 'border-t border-border/30')}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-foreground">
|
||||
{job.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{job.conclusion ??
|
||||
job.status ??
|
||||
translate('auto.components.PullRequestPage.77d9388fb0', 'unknown')}
|
||||
</span>
|
||||
</div>
|
||||
{job.steps.length > 0 && (
|
||||
<div className="mt-1 grid gap-1">
|
||||
{job.steps.map((step) => (
|
||||
<div
|
||||
key={step.name}
|
||||
className="flex min-w-0 items-center gap-2 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{step.name}</span>
|
||||
<span className="shrink-0">{step.conclusion ?? step.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!state?.error && !hasOutput && !hasAnnotations && !hasJobs && (
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
{getCheckConclusion(check) === 'action_required'
|
||||
? translate(
|
||||
'auto.components.PullRequestPage.checkActionRequiredHint',
|
||||
'Needs a manual action on GitHub (e.g. approving the run) to unblock merging.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.PullRequestPage.1550675e5f',
|
||||
'No inline output is available for this check.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openUrl && (
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-7 gap-1 px-2 text-[11px]"
|
||||
onClick={() => window.api.shell.openUrl(openUrl)}
|
||||
>
|
||||
{translate('auto.components.PullRequestPage.1b14d0a69c', 'Open in GitHub')}
|
||||
<ExternalLink className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { useAppStore } from '@/store'
|
||||
import { SourceControlAgentActionDialog } from '@/components/right-sidebar/SourceControlAgentActionDialog'
|
||||
import { readSourceControlLaunchRecipeAgentId } from '@/lib/source-control-launch-agent-selection'
|
||||
import type { SourceControlActionRecipe } from '../../../../../shared/source-control-ai-actions'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import { saveFixChecksActionDefault, startFixChecksFromDialog } from './fix-launch'
|
||||
|
||||
export function ChecksFixDialog({
|
||||
fixChecksComposerPrompt,
|
||||
setFixChecksComposerPrompt,
|
||||
fixChecksRecipe,
|
||||
fixChecksLaunchPlatform,
|
||||
repoConnectionId,
|
||||
targetRepoId,
|
||||
item,
|
||||
updateSettings,
|
||||
updateRepo
|
||||
}: {
|
||||
fixChecksComposerPrompt: string | null
|
||||
setFixChecksComposerPrompt: (value: string | null) => void
|
||||
fixChecksRecipe: SourceControlActionRecipe
|
||||
fixChecksLaunchPlatform: NodeJS.Platform
|
||||
repoConnectionId: string | null
|
||||
targetRepoId: string | null
|
||||
item: GitHubWorkItem
|
||||
updateSettings: ReturnType<typeof useAppStore.getState>['updateSettings']
|
||||
updateRepo: ReturnType<typeof useAppStore.getState>['updateRepo']
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<SourceControlAgentActionDialog
|
||||
open={fixChecksComposerPrompt !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setFixChecksComposerPrompt(null)
|
||||
}
|
||||
}}
|
||||
actionId="fixChecks"
|
||||
title={translate('auto.components.PullRequestPage.a053bdd082', 'Fix Broken Checks With AI')}
|
||||
description={translate(
|
||||
'auto.components.PullRequestPage.ddfd42f460',
|
||||
'Review the prompt before starting an agent.'
|
||||
)}
|
||||
baseCommandInput={fixChecksComposerPrompt ?? ''}
|
||||
connectionId={repoConnectionId}
|
||||
repoId={targetRepoId}
|
||||
promptDelivery="submit-after-ready"
|
||||
launchPlatform={fixChecksLaunchPlatform}
|
||||
launchSource="task_page"
|
||||
savedAgentId={readSourceControlLaunchRecipeAgentId(fixChecksRecipe)}
|
||||
savedCommandInputTemplate={fixChecksRecipe.commandInputTemplate ?? null}
|
||||
savedAgentArgs={fixChecksRecipe.agentArgs ?? null}
|
||||
onSaveAgentDefault={(target, actionId, recipe) =>
|
||||
saveFixChecksActionDefault(target, actionId, recipe, updateSettings, updateRepo)
|
||||
}
|
||||
onLaunched={() => {
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.85e62c5266',
|
||||
'Started an AI agent for the broken checks.'
|
||||
)
|
||||
)
|
||||
}}
|
||||
onStart={({ agent, commandInput, agentArgs }) =>
|
||||
startFixChecksFromDialog({
|
||||
targetRepoId,
|
||||
item,
|
||||
agent,
|
||||
commandInput,
|
||||
agentArgs
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch'
|
||||
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
|
||||
import { buildFixBrokenChecksPrompt } from '@/components/pr-checks-fix-prompt'
|
||||
import {
|
||||
saveSourceControlActionRecipe,
|
||||
type SourceControlAiWriteTarget
|
||||
} from '../../../../../shared/source-control-ai-recipe-save'
|
||||
import type {
|
||||
SourceControlActionRecipe,
|
||||
SourceControlLaunchActionId
|
||||
} from '../../../../../shared/source-control-ai-actions'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
|
||||
|
||||
type StoreState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
export async function saveFixChecksActionDefault(
|
||||
target: SourceControlAiWriteTarget,
|
||||
actionId: SourceControlLaunchActionId,
|
||||
recipe: SourceControlActionRecipe,
|
||||
updateSettings: StoreState['updateSettings'],
|
||||
updateRepo: StoreState['updateRepo']
|
||||
): Promise<void> {
|
||||
const state = useAppStore.getState()
|
||||
const latestSettings = state.settings
|
||||
if (!latestSettings) {
|
||||
throw new Error('Settings are not loaded.')
|
||||
}
|
||||
const latestRepo =
|
||||
target.type === 'repo'
|
||||
? (state.repos.find((candidate) => candidate.id === target.repoId) ?? null)
|
||||
: null
|
||||
const result = saveSourceControlActionRecipe({
|
||||
target,
|
||||
settings: latestSettings,
|
||||
repo: latestRepo,
|
||||
actionId,
|
||||
recipe
|
||||
})
|
||||
if ('sourceControlAi' in result) {
|
||||
await updateSettings({ sourceControlAi: result.sourceControlAi })
|
||||
return
|
||||
}
|
||||
await updateRepo(result.target.repoId, result.update)
|
||||
}
|
||||
|
||||
export async function startFixChecksFromDialog(args: {
|
||||
targetRepoId: string | null
|
||||
item: GitHubWorkItem
|
||||
agent: Parameters<typeof launchWorkItemDirect>[0]['agentOverride']
|
||||
commandInput: string
|
||||
agentArgs: string
|
||||
}): Promise<boolean> {
|
||||
if (!args.targetRepoId) {
|
||||
return false
|
||||
}
|
||||
return await launchWorkItemDirect({
|
||||
item: { ...args.item, repoId: args.targetRepoId, pasteContent: args.commandInput },
|
||||
repoId: args.targetRepoId,
|
||||
launchSource: 'task_page',
|
||||
telemetrySource: 'sidebar',
|
||||
promptDelivery: 'submit-after-ready',
|
||||
agentOverride: args.agent,
|
||||
agentArgs: args.agentArgs,
|
||||
openModalFallback: () => {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.c4c02ea23e',
|
||||
'Unable to create a fix workspace automatically.'
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function fixBrokenPullRequestChecks(args: {
|
||||
targetRepoId: string | null
|
||||
fixingChecks: boolean
|
||||
failedChecks: PRCheckDetail[]
|
||||
item: GitHubWorkItem
|
||||
list: PRCheckDetail[]
|
||||
setFixingChecks: (value: boolean) => void
|
||||
setFixChecksComposerPrompt: (value: string | null) => void
|
||||
}): Promise<void> {
|
||||
if (!args.targetRepoId || args.fixingChecks) {
|
||||
return
|
||||
}
|
||||
if (args.failedChecks.length === 0) {
|
||||
toast.message(
|
||||
translate('auto.components.PullRequestPage.51c65c0265', 'No broken checks to fix.')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const basePrompt = buildFixBrokenChecksPrompt({
|
||||
reviewKind: 'PR',
|
||||
reviewNumber: args.item.number,
|
||||
reviewTitle: args.item.title,
|
||||
reviewUrl: args.item.url,
|
||||
checks: args.list
|
||||
})
|
||||
args.setFixingChecks(true)
|
||||
try {
|
||||
const started = await startFixChecksAgent({
|
||||
item: args.item,
|
||||
repoId: args.targetRepoId,
|
||||
basePrompt,
|
||||
launchSource: 'task_page',
|
||||
telemetrySource: 'sidebar',
|
||||
openModalFallback: () => {
|
||||
args.setFixChecksComposerPrompt(basePrompt)
|
||||
}
|
||||
})
|
||||
if (started) {
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.85e62c5266',
|
||||
'Started an AI agent for the broken checks.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error('Failed to start fix checks agent', err)
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.98583589c6',
|
||||
'Failed to start an AI agent for the broken checks: {{value0}}',
|
||||
{ value0: message }
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
args.setFixingChecks(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from 'react'
|
||||
import { CircleDashed, LoaderCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CHECK_COLOR } from '@/components/right-sidebar/checks-panel-content'
|
||||
import { getCheckCountChips, type getCheckCounts } from '@/components/pr-check-counts'
|
||||
import { getCheckDetailsKey } from '@/components/github/pr-check-presentation'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
|
||||
|
||||
export function ChecksTabLayouts({
|
||||
loading,
|
||||
listLength,
|
||||
variant,
|
||||
compactHeader,
|
||||
actions,
|
||||
SummaryIcon,
|
||||
summaryColor,
|
||||
summaryLabel,
|
||||
counts,
|
||||
sorted,
|
||||
renderCheckRow,
|
||||
fixChecksAgentDialog
|
||||
}: {
|
||||
loading: boolean
|
||||
listLength: number
|
||||
variant: 'compact' | 'page'
|
||||
compactHeader: React.JSX.Element
|
||||
actions: React.JSX.Element
|
||||
SummaryIcon: React.ComponentType<{ className?: string }>
|
||||
summaryColor: string
|
||||
summaryLabel: string
|
||||
counts: ReturnType<typeof getCheckCounts>
|
||||
sorted: PRCheckDetail[]
|
||||
renderCheckRow: (check: PRCheckDetail) => React.JSX.Element
|
||||
fixChecksAgentDialog: React.JSX.Element
|
||||
}): React.JSX.Element {
|
||||
if (loading && listLength === 0) {
|
||||
return (
|
||||
<>
|
||||
{variant === 'compact' ? compactHeader : null}
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (listLength === 0) {
|
||||
if (variant === 'page') {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<CircleDashed className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-[13px] font-medium text-foreground">
|
||||
{translate('auto.components.PullRequestPage.45877f5089', 'No checks found')}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.PullRequestPage.3912daf310',
|
||||
'This pull request has no reported checks yet.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{compactHeader}
|
||||
<div className="flex flex-col items-center justify-center gap-1 px-4 py-6 text-center">
|
||||
<CircleDashed className="size-4 text-muted-foreground/60" />
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
{translate('auto.components.PullRequestPage.a18d01cda3', 'No checks reported yet')}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (variant === 'page') {
|
||||
const countChips = getCheckCountChips(counts)
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<SummaryIcon
|
||||
className={cn(
|
||||
'size-4 shrink-0',
|
||||
summaryColor,
|
||||
counts.pending > 0 && counts.failing === 0 && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="truncate text-[13px] font-medium text-foreground">
|
||||
{summaryLabel}
|
||||
</span>
|
||||
{countChips.length > 1 && (
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
{countChips.map((chip, i) => (
|
||||
<React.Fragment key={chip.tone}>
|
||||
{i > 0 && <span className="opacity-40">·</span>}
|
||||
<span className={CHECK_COLOR[chip.tone]}>{chip.label}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-border/50 bg-card shadow-xs">
|
||||
{sorted.map((check, index) => (
|
||||
<div
|
||||
key={getCheckDetailsKey(check)}
|
||||
className={cn(index > 0 && 'border-t border-border/40')}
|
||||
>
|
||||
{renderCheckRow(check)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{fixChecksAgentDialog}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{compactHeader}
|
||||
<div className="max-h-[280px] overflow-y-auto p-1 scrollbar-sleek">
|
||||
{sorted.map(renderCheckRow)}
|
||||
</div>
|
||||
{fixChecksAgentDialog}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { toast } from 'sonner'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { getGitHubRuntimeRepoId, type GitHubRuntimeHost } from '@/lib/github-source-runtime-context'
|
||||
import {
|
||||
resetGitHubChecksTabForSource,
|
||||
updateGitHubChecksTabLocalChecks,
|
||||
type GitHubChecksTabState
|
||||
} from '@/components/github-checks-tab-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
|
||||
export async function refreshPullRequestChecks(args: {
|
||||
canUseChecksRepoContext: boolean
|
||||
expectedContextOwner?: object
|
||||
committedChecksContextOwnerRef: { current: object }
|
||||
nextChecksRefreshRequestIdRef: { current: number }
|
||||
activeChecksRefreshRequestIdRef: { current: number | null }
|
||||
setRefreshingOwner: (
|
||||
value:
|
||||
| { contextOwner: object; requestId: number }
|
||||
| null
|
||||
| ((
|
||||
current: { contextOwner: object; requestId: number } | null
|
||||
) => { contextOwner: object; requestId: number } | null)
|
||||
) => void
|
||||
setChecksState: (updater: (current: GitHubChecksTabState) => GitHubChecksTabState) => void
|
||||
runtimeHost: GitHubRuntimeHost | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
repoId: string | null
|
||||
repoPath: string | null
|
||||
item: GitHubWorkItem
|
||||
headSha: string | undefined
|
||||
prRepo: GitHubOwnerRepo | null
|
||||
mountedRef: { current: boolean }
|
||||
onChecksUpdated: (checks: PRCheckDetail[]) => void
|
||||
}): Promise<PRCheckDetail[] | null> {
|
||||
if (!args.canUseChecksRepoContext) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.c057f2fcb0',
|
||||
'Unable to refresh checks without a repository path.'
|
||||
)
|
||||
)
|
||||
return null
|
||||
}
|
||||
const refreshContextOwner =
|
||||
args.expectedContextOwner ?? args.committedChecksContextOwnerRef.current
|
||||
if (args.committedChecksContextOwnerRef.current !== refreshContextOwner) {
|
||||
return null
|
||||
}
|
||||
const refreshRequestId = ++args.nextChecksRefreshRequestIdRef.current
|
||||
args.activeChecksRefreshRequestIdRef.current = refreshRequestId
|
||||
args.setRefreshingOwner({ contextOwner: refreshContextOwner, requestId: refreshRequestId })
|
||||
try {
|
||||
const nextChecks = (await (args.runtimeHost
|
||||
? callRuntimeRpc<PRCheckDetail[]>(
|
||||
{ kind: 'environment', environmentId: args.runtimeHost.environmentId },
|
||||
'github.prChecks',
|
||||
{
|
||||
repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId),
|
||||
prNumber: args.item.number,
|
||||
headSha: args.headSha,
|
||||
prRepo: args.prRepo,
|
||||
noCache: true
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: window.api.gh.prChecks({
|
||||
repoPath: args.repoPath ?? '',
|
||||
repoId: args.repoId ?? undefined,
|
||||
sourceContext: args.sourceContext,
|
||||
prNumber: args.item.number,
|
||||
headSha: args.headSha,
|
||||
prRepo: args.prRepo,
|
||||
noCache: true
|
||||
}))) as PRCheckDetail[]
|
||||
if (
|
||||
!args.mountedRef.current ||
|
||||
args.committedChecksContextOwnerRef.current !== refreshContextOwner ||
|
||||
args.activeChecksRefreshRequestIdRef.current !== refreshRequestId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
args.setChecksState((current) =>
|
||||
current.contextOwner === refreshContextOwner
|
||||
? updateGitHubChecksTabLocalChecks(resetGitHubChecksTabForSource(current), nextChecks)
|
||||
: current
|
||||
)
|
||||
args.onChecksUpdated(nextChecks)
|
||||
return nextChecks
|
||||
} catch (err) {
|
||||
if (
|
||||
args.mountedRef.current &&
|
||||
args.committedChecksContextOwnerRef.current === refreshContextOwner &&
|
||||
args.activeChecksRefreshRequestIdRef.current === refreshRequestId
|
||||
) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate('auto.components.PullRequestPage.246b2c6456', 'Failed to refresh checks')
|
||||
)
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
if (args.activeChecksRefreshRequestIdRef.current === refreshRequestId) {
|
||||
args.activeChecksRefreshRequestIdRef.current = null
|
||||
}
|
||||
if (args.mountedRef.current) {
|
||||
args.setRefreshingOwner((current) =>
|
||||
current?.requestId === refreshRequestId ? null : current
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { toast } from 'sonner'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { getGitHubRuntimeRepoId, type GitHubRuntimeHost } from '@/lib/github-source-runtime-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
|
||||
export async function rerunPullRequestChecks(args: {
|
||||
canUseChecksRepoContext: boolean
|
||||
rerunning: boolean
|
||||
committedChecksContextOwnerRef: { current: object }
|
||||
setRerunningOwner: (value: object | null | ((current: object | null) => object | null)) => void
|
||||
runtimeHost: GitHubRuntimeHost | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
repoId: string | null
|
||||
repoPath: string | null
|
||||
item: GitHubWorkItem
|
||||
headSha: string | undefined
|
||||
prRepo: GitHubOwnerRepo | null
|
||||
failedOnly: boolean
|
||||
mountedRef: { current: boolean }
|
||||
handleRefresh: (expectedContextOwner?: object) => Promise<PRCheckDetail[] | null>
|
||||
}): Promise<void> {
|
||||
if (!args.canUseChecksRepoContext || args.rerunning) {
|
||||
return
|
||||
}
|
||||
const rerunContextOwner = args.committedChecksContextOwnerRef.current
|
||||
args.setRerunningOwner(rerunContextOwner)
|
||||
try {
|
||||
const result = args.runtimeHost
|
||||
? await callRuntimeRpc<Awaited<ReturnType<typeof window.api.gh.rerunPRChecks>>>(
|
||||
{ kind: 'environment', environmentId: args.runtimeHost.environmentId },
|
||||
'github.rerunPRChecks',
|
||||
{
|
||||
repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId),
|
||||
prNumber: args.item.number,
|
||||
headSha: args.headSha,
|
||||
failedOnly: args.failedOnly,
|
||||
prRepo: args.prRepo
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: await window.api.gh.rerunPRChecks({
|
||||
repoPath: args.repoPath ?? '',
|
||||
repoId: args.repoId ?? undefined,
|
||||
sourceContext: args.sourceContext,
|
||||
prNumber: args.item.number,
|
||||
headSha: args.headSha,
|
||||
failedOnly: args.failedOnly,
|
||||
prRepo: args.prRepo
|
||||
})
|
||||
if (
|
||||
!args.mountedRef.current ||
|
||||
args.committedChecksContextOwnerRef.current !== rerunContextOwner
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
toast.success(
|
||||
result.count === 1
|
||||
? translate('auto.components.PullRequestPage.5963a6a852', 'Check rerun requested')
|
||||
: translate('auto.components.PullRequestPage.18f2af42ac', 'Check reruns requested')
|
||||
)
|
||||
await args.handleRefresh(rerunContextOwner)
|
||||
} catch (err) {
|
||||
if (
|
||||
args.mountedRef.current &&
|
||||
args.committedChecksContextOwnerRef.current === rerunContextOwner
|
||||
) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate('auto.components.PullRequestPage.788a782bb0', 'Failed to rerun checks')
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (args.mountedRef.current) {
|
||||
args.setRerunningOwner((current) => (current === rerunContextOwner ? null : current))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react'
|
||||
import { ChevronDown, CircleDashed } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content'
|
||||
import { getCheckConclusion } from '@/components/pr-check-counts'
|
||||
import { getCheckDetailsKey, getCheckStatusLabel } from '@/components/github/pr-check-presentation'
|
||||
import type { CheckDetailsLoadState } from '@/components/github-checks-tab-state'
|
||||
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
|
||||
import { CheckDetailsPanel } from './details'
|
||||
|
||||
export function CheckRow({
|
||||
check,
|
||||
variant,
|
||||
expanded,
|
||||
detailsState,
|
||||
onToggle,
|
||||
onRetryDetails
|
||||
}: {
|
||||
check: PRCheckDetail
|
||||
variant: 'compact' | 'page'
|
||||
expanded: boolean
|
||||
detailsState: CheckDetailsLoadState | undefined
|
||||
onToggle: (check: PRCheckDetail) => void
|
||||
onRetryDetails: (check: PRCheckDetail, key: string) => void
|
||||
}): React.JSX.Element {
|
||||
const conclusion = getCheckConclusion(check)
|
||||
const Icon = CHECK_ICON[conclusion] ?? CircleDashed
|
||||
const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground'
|
||||
const statusLabel = getCheckStatusLabel(check)
|
||||
const key = getCheckDetailsKey(check)
|
||||
return (
|
||||
<div key={key} className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(check)}
|
||||
aria-expanded={expanded}
|
||||
className={cn(
|
||||
'flex w-full min-w-0 items-center gap-2 rounded-md text-left transition',
|
||||
variant === 'page' ? 'px-3 py-2.5 hover:bg-accent/60' : 'px-2 py-1.5 hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3 shrink-0 text-muted-foreground transition-transform',
|
||||
!expanded && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
<Icon
|
||||
className={cn('size-3.5 shrink-0', color, conclusion === 'pending' && 'animate-spin')}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-foreground">{check.name}</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">{statusLabel}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<CheckDetailsPanel check={check} state={detailsState} onRetry={onRetryDetails} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { CircleDashed } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content'
|
||||
import {
|
||||
createGitHubChecksTabState,
|
||||
resolveGitHubChecksTabState,
|
||||
toggleGitHubChecksTabExpandedKey
|
||||
} from '@/components/github-checks-tab-state'
|
||||
import { getCheckDetailsKey } from '@/components/github/pr-check-presentation'
|
||||
import { getCheckCounts, getChecksSummaryLabel } from '@/components/pr-check-counts'
|
||||
import { getBrokenChecks } from '@/components/pr-checks-fix-prompt'
|
||||
import { sortChecksBySeverity } from '../../../../../shared/pr-check-severity-order'
|
||||
import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key'
|
||||
import { getTaskSourceCacheScope } from '../../../../../shared/task-source-context'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity'
|
||||
import {
|
||||
canUseGitHubRepoContext,
|
||||
getGitHubSourceRuntimeHost
|
||||
} from '@/lib/github-source-runtime-context'
|
||||
import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
import { resolveSourceControlActionRecipe } from '../../../../../shared/source-control-ai'
|
||||
import type {
|
||||
GitHubWorkItem,
|
||||
GitHubWorkItemDetails
|
||||
} from '../../../../../shared/github/work-item-types'
|
||||
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
|
||||
import { refreshPullRequestChecks } from './refresh'
|
||||
import { rerunPullRequestChecks } from './rerun'
|
||||
import { requestPullRequestCheckDetails } from './details-request'
|
||||
import { fixBrokenPullRequestChecks } from './fix-launch'
|
||||
import { ChecksCompactHeader, buildChecksToolbar } from './toolbar'
|
||||
import { CheckRow } from './row'
|
||||
import { ChecksFixDialog } from './fix-dialog'
|
||||
import { ChecksTabLayouts } from './layouts'
|
||||
|
||||
export function ChecksTab({
|
||||
item,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
headSha,
|
||||
checks,
|
||||
loading,
|
||||
variant = 'compact',
|
||||
onChecksUpdated
|
||||
}: {
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
repoId: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
headSha: string | undefined
|
||||
checks: GitHubWorkItemDetails['checks']
|
||||
loading: boolean
|
||||
variant?: 'compact' | 'page'
|
||||
onChecksUpdated: (checks: PRCheckDetail[]) => void
|
||||
}): React.JSX.Element {
|
||||
const targetRepoId = repoId ?? item.repoId
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const updateRepo = useAppStore((s) => s.updateRepo)
|
||||
const repo = useAppStore((s) =>
|
||||
targetRepoId ? (s.repos.find((candidate) => candidate.id === targetRepoId) ?? null) : null
|
||||
)
|
||||
const [fixingChecks, setFixingChecks] = useState(false)
|
||||
const [fixChecksComposerPrompt, setFixChecksComposerPrompt] = useState<string | null>(null)
|
||||
const mountedRef = useMountedRef()
|
||||
const prRepo = useMemo(() => resolvePullRequestRepo(item), [item])
|
||||
const nextCheckDetailsRequestIdRef = useRef(0)
|
||||
const checkDetailsContextKey = [
|
||||
sourceContext ? getTaskSourceCacheScope(sourceContext) : 'local',
|
||||
repoId ?? item.repoId ?? '',
|
||||
repoPath ?? '',
|
||||
prRepo ? githubRepoIdentityKey(prRepo) : '',
|
||||
item.id,
|
||||
item.number,
|
||||
headSha ?? ''
|
||||
].join('\0')
|
||||
const [checksState, setChecksState] = useState(() =>
|
||||
createGitHubChecksTabState(checks, checkDetailsContextKey)
|
||||
)
|
||||
const resolvedChecksState = resolveGitHubChecksTabState(
|
||||
checksState,
|
||||
checks,
|
||||
checkDetailsContextKey
|
||||
)
|
||||
const committedChecksContextOwnerRef = useRef(resolvedChecksState.contextOwner)
|
||||
const nextChecksRefreshRequestIdRef = useRef(0)
|
||||
const activeChecksRefreshRequestIdRef = useRef<number | null>(null)
|
||||
const [refreshingOwner, setRefreshingOwner] = useState<{
|
||||
contextOwner: object
|
||||
requestId: number
|
||||
} | null>(null)
|
||||
const refreshing = refreshingOwner?.contextOwner === resolvedChecksState.contextOwner
|
||||
const [rerunningOwner, setRerunningOwner] = useState<object | null>(null)
|
||||
const rerunning = rerunningOwner === resolvedChecksState.contextOwner
|
||||
useLayoutEffect(() => {
|
||||
committedChecksContextOwnerRef.current = resolvedChecksState.contextOwner
|
||||
}, [resolvedChecksState.contextOwner])
|
||||
if (resolvedChecksState !== checksState) {
|
||||
// Why: reconcile before paint when a parent check refresh replaces the source list, so stale rows/details never show.
|
||||
setChecksState(resolvedChecksState)
|
||||
}
|
||||
const { localChecks, expandedCheckKey, detailsByCheckKey } = resolvedChecksState
|
||||
const list = useMemo(() => localChecks ?? checks ?? [], [checks, localChecks])
|
||||
const fixChecksRecipe = useMemo(
|
||||
() =>
|
||||
resolveSourceControlActionRecipe({
|
||||
settings,
|
||||
repo,
|
||||
actionId: 'fixChecks'
|
||||
}),
|
||||
[repo, settings]
|
||||
)
|
||||
const fixChecksLaunchPlatform = useMemo(
|
||||
() =>
|
||||
resolveSourceControlLaunchPlatform({
|
||||
connectionId: repo?.connectionId ?? null,
|
||||
worktreePath: repo?.path ?? null,
|
||||
projectRuntime: repo?.connectionId
|
||||
? undefined
|
||||
: getLocalRepoProjectExecutionRuntimeContext(
|
||||
useAppStore.getState(),
|
||||
repo?.id,
|
||||
CLIENT_PLATFORM
|
||||
)
|
||||
}),
|
||||
[repo?.connectionId, repo?.id, repo?.path]
|
||||
)
|
||||
const runtimeHost = getGitHubSourceRuntimeHost(sourceContext)
|
||||
const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext)
|
||||
const sorted = sortChecksBySeverity(list)
|
||||
const failedChecks = getBrokenChecks(list)
|
||||
const counts = getCheckCounts(list)
|
||||
const summaryLabel = getChecksSummaryLabel(list)
|
||||
// Why: keying the green tick off `list.length` painted an all-neutral PR green above the words
|
||||
// "0 of N checks passing"; nothing passed, so it reads unresolved like the checks pill does.
|
||||
const SummaryIcon =
|
||||
counts.failing > 0
|
||||
? CHECK_ICON.failure
|
||||
: counts.needsAction > 0
|
||||
? CHECK_ICON.action_required
|
||||
: counts.pending > 0
|
||||
? CHECK_ICON.pending
|
||||
: counts.passing > 0
|
||||
? CHECK_ICON.success
|
||||
: CircleDashed
|
||||
const summaryColor =
|
||||
counts.failing > 0
|
||||
? CHECK_COLOR.failure
|
||||
: counts.needsAction > 0
|
||||
? CHECK_COLOR.action_required
|
||||
: counts.pending > 0
|
||||
? CHECK_COLOR.pending
|
||||
: counts.passing > 0
|
||||
? CHECK_COLOR.success
|
||||
: 'text-muted-foreground'
|
||||
const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0)
|
||||
|
||||
const handleRefresh = useCallback(
|
||||
async (expectedContextOwner?: object) =>
|
||||
refreshPullRequestChecks({
|
||||
canUseChecksRepoContext,
|
||||
expectedContextOwner,
|
||||
committedChecksContextOwnerRef,
|
||||
nextChecksRefreshRequestIdRef,
|
||||
activeChecksRefreshRequestIdRef,
|
||||
setRefreshingOwner,
|
||||
setChecksState,
|
||||
runtimeHost,
|
||||
sourceContext,
|
||||
repoId,
|
||||
repoPath,
|
||||
item,
|
||||
headSha,
|
||||
prRepo,
|
||||
mountedRef,
|
||||
onChecksUpdated
|
||||
}),
|
||||
[
|
||||
canUseChecksRepoContext,
|
||||
headSha,
|
||||
item,
|
||||
mountedRef,
|
||||
onChecksUpdated,
|
||||
runtimeHost,
|
||||
prRepo,
|
||||
repoId,
|
||||
repoPath,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
const handleRerun = useCallback(
|
||||
async (failedOnly: boolean) =>
|
||||
rerunPullRequestChecks({
|
||||
canUseChecksRepoContext,
|
||||
rerunning,
|
||||
committedChecksContextOwnerRef,
|
||||
setRerunningOwner,
|
||||
runtimeHost,
|
||||
sourceContext,
|
||||
repoId,
|
||||
repoPath,
|
||||
item,
|
||||
headSha,
|
||||
prRepo,
|
||||
failedOnly,
|
||||
mountedRef,
|
||||
handleRefresh
|
||||
}),
|
||||
[
|
||||
canUseChecksRepoContext,
|
||||
handleRefresh,
|
||||
headSha,
|
||||
item,
|
||||
mountedRef,
|
||||
prRepo,
|
||||
runtimeHost,
|
||||
rerunning,
|
||||
repoId,
|
||||
repoPath,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
const requestCheckDetails = useCallback(
|
||||
(check: PRCheckDetail, key: string): void => {
|
||||
requestPullRequestCheckDetails({
|
||||
canUseChecksRepoContext,
|
||||
check,
|
||||
key,
|
||||
nextCheckDetailsRequestIdRef,
|
||||
mountedRef,
|
||||
setChecksState,
|
||||
runtimeHost,
|
||||
sourceContext,
|
||||
repoId,
|
||||
repoPath,
|
||||
item,
|
||||
prRepo
|
||||
})
|
||||
},
|
||||
[
|
||||
canUseChecksRepoContext,
|
||||
item,
|
||||
mountedRef,
|
||||
runtimeHost,
|
||||
prRepo,
|
||||
repoId,
|
||||
repoPath,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
const handleToggleCheckDetails = useCallback(
|
||||
(check: PRCheckDetail): void => {
|
||||
const key = getCheckDetailsKey(check)
|
||||
setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key))
|
||||
if (detailsByCheckKey[key]) {
|
||||
return
|
||||
}
|
||||
requestCheckDetails(check, key)
|
||||
},
|
||||
[detailsByCheckKey, requestCheckDetails]
|
||||
)
|
||||
|
||||
const { refreshAction, rerunAction, secondaryActions, actions } = buildChecksToolbar({
|
||||
variant,
|
||||
canUseChecksRepoContext,
|
||||
refreshing,
|
||||
rerunning,
|
||||
fixingChecks,
|
||||
canFixBrokenChecks,
|
||||
failedChecksCount: failedChecks.length,
|
||||
listLength: list.length,
|
||||
onRefresh: () => {
|
||||
void handleRefresh()
|
||||
},
|
||||
onFix: () => {
|
||||
void fixBrokenPullRequestChecks({
|
||||
targetRepoId,
|
||||
fixingChecks,
|
||||
failedChecks,
|
||||
item,
|
||||
list,
|
||||
setFixingChecks,
|
||||
setFixChecksComposerPrompt
|
||||
})
|
||||
},
|
||||
onRerun: (failedOnly) => {
|
||||
void handleRerun(failedOnly)
|
||||
}
|
||||
})
|
||||
|
||||
const compactHeader = (
|
||||
<ChecksCompactHeader
|
||||
SummaryIcon={SummaryIcon}
|
||||
summaryColor={summaryColor}
|
||||
summaryLabel={summaryLabel}
|
||||
counts={counts}
|
||||
listLength={list.length}
|
||||
refreshAction={refreshAction}
|
||||
rerunAction={rerunAction}
|
||||
secondaryActions={secondaryActions}
|
||||
/>
|
||||
)
|
||||
|
||||
const fixChecksAgentDialog = (
|
||||
<ChecksFixDialog
|
||||
fixChecksComposerPrompt={fixChecksComposerPrompt}
|
||||
setFixChecksComposerPrompt={setFixChecksComposerPrompt}
|
||||
fixChecksRecipe={fixChecksRecipe}
|
||||
fixChecksLaunchPlatform={fixChecksLaunchPlatform}
|
||||
repoConnectionId={repo?.connectionId ?? null}
|
||||
targetRepoId={targetRepoId}
|
||||
item={item}
|
||||
updateSettings={updateSettings}
|
||||
updateRepo={updateRepo}
|
||||
/>
|
||||
)
|
||||
|
||||
const renderCheckRow = (check: PRCheckDetail): React.JSX.Element => {
|
||||
const key = getCheckDetailsKey(check)
|
||||
return (
|
||||
<CheckRow
|
||||
key={key}
|
||||
check={check}
|
||||
variant={variant}
|
||||
expanded={expandedCheckKey === key}
|
||||
detailsState={detailsByCheckKey[key]}
|
||||
onToggle={handleToggleCheckDetails}
|
||||
onRetryDetails={requestCheckDetails}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChecksTabLayouts
|
||||
loading={loading}
|
||||
listLength={list.length}
|
||||
variant={variant}
|
||||
compactHeader={compactHeader}
|
||||
actions={actions}
|
||||
SummaryIcon={SummaryIcon}
|
||||
summaryColor={summaryColor}
|
||||
summaryLabel={summaryLabel}
|
||||
counts={counts}
|
||||
sorted={sorted}
|
||||
renderCheckRow={renderCheckRow}
|
||||
fixChecksAgentDialog={fixChecksAgentDialog}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import React from 'react'
|
||||
import { ChevronDown, LoaderCircle, RefreshCw, Wrench } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function buildChecksToolbar({
|
||||
variant,
|
||||
canUseChecksRepoContext,
|
||||
refreshing,
|
||||
rerunning,
|
||||
fixingChecks,
|
||||
canFixBrokenChecks,
|
||||
failedChecksCount,
|
||||
listLength,
|
||||
onRefresh,
|
||||
onFix,
|
||||
onRerun
|
||||
}: {
|
||||
variant: 'compact' | 'page'
|
||||
canUseChecksRepoContext: boolean
|
||||
refreshing: boolean
|
||||
rerunning: boolean
|
||||
fixingChecks: boolean
|
||||
canFixBrokenChecks: boolean
|
||||
failedChecksCount: number
|
||||
listLength: number
|
||||
onRefresh: () => void
|
||||
onFix: () => void
|
||||
onRerun: (failedOnly: boolean) => void
|
||||
}): {
|
||||
refreshAction: React.JSX.Element
|
||||
fixBrokenChecksAction: React.JSX.Element | null
|
||||
rerunAction: React.JSX.Element | null
|
||||
secondaryActions: React.JSX.Element | null
|
||||
actions: React.JSX.Element
|
||||
} {
|
||||
const refreshAction = (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-7 shrink-0"
|
||||
disabled={!canUseChecksRepoContext || refreshing}
|
||||
onClick={onRefresh}
|
||||
aria-label={translate('auto.components.PullRequestPage.5d0f42766d', 'Refresh checks')}
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate('auto.components.PullRequestPage.5d0f42766d', 'Refresh checks')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
const fixBrokenChecksAction =
|
||||
failedChecksCount > 0 || fixingChecks ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 gap-1 px-2 text-[11px]"
|
||||
disabled={!canFixBrokenChecks || fixingChecks}
|
||||
onClick={onFix}
|
||||
>
|
||||
{fixingChecks ? (
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Wrench className="size-3" />
|
||||
)}
|
||||
{variant === 'compact'
|
||||
? translate('auto.components.PullRequestPage.c808db1dd1', 'Fix checks')
|
||||
: translate('auto.components.PullRequestPage.a4541fd3db', 'Fix broken checks')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate(
|
||||
'auto.components.PullRequestPage.0fa8b8faec',
|
||||
'Start the default AI agent on these checks'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null
|
||||
const rerunAction =
|
||||
listLength > 0 || rerunning ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 gap-1 px-2 text-[11px]"
|
||||
disabled={!canUseChecksRepoContext || rerunning || listLength === 0}
|
||||
>
|
||||
{rerunning ? (
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
{translate('auto.components.PullRequestPage.522d9353e1', 'Rerun')}
|
||||
<ChevronDown className="size-3 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
disabled={failedChecksCount === 0 || rerunning}
|
||||
onSelect={() => onRerun(true)}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
{translate('auto.components.PullRequestPage.68605516dd', 'Rerun failed checks')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={rerunning} onSelect={() => onRerun(false)}>
|
||||
<RefreshCw className="size-4" />
|
||||
{translate('auto.components.PullRequestPage.54cddd1858', 'Rerun all checks')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null
|
||||
const secondaryActions =
|
||||
variant === 'compact' && !fixBrokenChecksAction ? null : fixBrokenChecksAction ||
|
||||
rerunAction ? (
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
|
||||
{fixBrokenChecksAction}
|
||||
{variant === 'page' ? rerunAction : null}
|
||||
</div>
|
||||
) : null
|
||||
const actions = (
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
|
||||
{refreshAction}
|
||||
{fixBrokenChecksAction}
|
||||
{rerunAction}
|
||||
</div>
|
||||
)
|
||||
return { refreshAction, fixBrokenChecksAction, rerunAction, secondaryActions, actions }
|
||||
}
|
||||
|
||||
export function ChecksCompactHeader({
|
||||
SummaryIcon,
|
||||
summaryColor,
|
||||
summaryLabel,
|
||||
counts,
|
||||
listLength,
|
||||
refreshAction,
|
||||
rerunAction,
|
||||
secondaryActions
|
||||
}: {
|
||||
SummaryIcon: React.ComponentType<{ className?: string }>
|
||||
summaryColor: string
|
||||
summaryLabel: string
|
||||
counts: { pending: number; failing: number }
|
||||
listLength: number
|
||||
refreshAction: React.JSX.Element
|
||||
rerunAction: React.JSX.Element | null
|
||||
secondaryActions: React.JSX.Element | null
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="border-b border-border/50 px-3 py-2">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<SummaryIcon
|
||||
className={cn(
|
||||
'mt-0.5 size-3.5 shrink-0',
|
||||
summaryColor,
|
||||
counts.pending > 0 && counts.failing === 0 && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] font-medium leading-5 text-foreground">
|
||||
{translate('auto.components.PullRequestPage.94d95cf1f7', 'Checks')}
|
||||
</div>
|
||||
{listLength > 0 && (
|
||||
<div className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{summaryLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{refreshAction}
|
||||
{listLength > 0 && (
|
||||
<div className="[&_button]:h-7 [&_button]:px-2 [&_button]:text-[11px]">
|
||||
{rerunAction}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{secondaryActions ? (
|
||||
<div className="mt-2 flex min-w-0 justify-end">{secondaryActions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { LoaderCircle, Send } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||
import {
|
||||
getCommentBodySubmitState,
|
||||
hasBoundedCommentBodyText
|
||||
} from '@/lib/comment-body-submit-state'
|
||||
import { addIssueCommentForRepo } from '@/components/github/github-work-item-comment-mutations'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
|
||||
import type { PRComment } from '../../../../../shared/github/comment-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type { MentionOption } from '../page-types'
|
||||
import { MentionTextarea } from '../mentions/textarea'
|
||||
|
||||
export function GHCommentComposer({
|
||||
className,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
issueNumber,
|
||||
itemType,
|
||||
prRepo,
|
||||
mentionOptions,
|
||||
onCommentAdded
|
||||
}: {
|
||||
className?: string
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
issueNumber: number
|
||||
itemType: 'issue' | 'pr'
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
mentionOptions: MentionOption[]
|
||||
onCommentAdded: (comment: PRComment) => void
|
||||
}): React.JSX.Element {
|
||||
const [body, setBody] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const mountedRef = useMountedRef()
|
||||
|
||||
const autoGrow = useCallback(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.max(80, Math.min(el.scrollHeight, 240))}px`
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const bodyState = getCommentBodySubmitState(body)
|
||||
if (bodyState.status === 'empty') {
|
||||
return
|
||||
}
|
||||
if (bodyState.status === 'too-large-leading-whitespace') {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.commentTooLarge',
|
||||
'Comment is too large to submit safely.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await addIssueCommentForRepo({
|
||||
repoPath,
|
||||
repoId: repoId ?? undefined,
|
||||
sourceContext,
|
||||
number: issueNumber,
|
||||
body: bodyState.body,
|
||||
type: itemType,
|
||||
prRepo
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (result.ok) {
|
||||
setBody('')
|
||||
requestAnimationFrame(autoGrow)
|
||||
// Why: use GitHub's returned comment so the optimistic row shows the real login/avatar without a reopen.
|
||||
onCommentAdded(result.comment)
|
||||
} else {
|
||||
toast.error(
|
||||
result.error ??
|
||||
translate('auto.components.PullRequestPage.1208347ac0', 'Failed to add comment')
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate('auto.components.PullRequestPage.1208347ac0', 'Failed to add comment')
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
autoGrow,
|
||||
body,
|
||||
mountedRef,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
issueNumber,
|
||||
itemType,
|
||||
prRepo,
|
||||
onCommentAdded
|
||||
])
|
||||
const canSubmitComment = hasBoundedCommentBodyText(body)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (isScreenSubmitShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<MentionTextarea
|
||||
textareaRef={textareaRef}
|
||||
value={body}
|
||||
onValueChange={(nextValue) => {
|
||||
setBody(nextValue)
|
||||
requestAnimationFrame(autoGrow)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={translate('auto.components.PullRequestPage.d2030fc8cd', 'Add a comment…')}
|
||||
rows={4}
|
||||
mentionOptions={mentionOptions}
|
||||
wrapperClassName="flex min-h-20 w-full items-stretch"
|
||||
className="scrollbar-sleek block h-20 max-h-[240px] min-h-20 w-full resize-none overflow-y-auto rounded-md border border-input bg-card px-3 py-2 pb-12 pr-12 text-[13px] leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmitComment || submitting}
|
||||
className="absolute bottom-3 right-3 shadow-sm"
|
||||
aria-label={translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')}
|
||||
>
|
||||
{submitting ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||
import {
|
||||
getCommentBodySubmitState,
|
||||
hasBoundedCommentBodyText
|
||||
} from '@/lib/comment-body-submit-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { MentionOption } from '../page-types'
|
||||
import { MentionTextarea } from '../mentions/textarea'
|
||||
|
||||
export function CommentReplyForm({
|
||||
className,
|
||||
placeholder,
|
||||
mentionOptions,
|
||||
onCancel,
|
||||
onSubmit
|
||||
}: {
|
||||
className?: string
|
||||
placeholder: string
|
||||
mentionOptions: MentionOption[]
|
||||
onCancel: () => void
|
||||
onSubmit: (body: string) => Promise<boolean>
|
||||
}): React.JSX.Element {
|
||||
const [body, setBody] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const mountedRef = useMountedRef()
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
const bodyState = getCommentBodySubmitState(body)
|
||||
if (bodyState.status === 'empty' || submitting) {
|
||||
return
|
||||
}
|
||||
if (bodyState.status === 'too-large-leading-whitespace') {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.commentTooLarge',
|
||||
'Comment is too large to submit safely.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const ok = await onSubmit(bodyState.body)
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (ok) {
|
||||
setBody('')
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
}, [body, mountedRef, onSubmit, submitting])
|
||||
const canSubmitReply = hasBoundedCommentBodyText(body)
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-md border border-border/50 bg-background/60 p-2', className)}>
|
||||
<MentionTextarea
|
||||
textareaRef={textareaRef}
|
||||
value={body}
|
||||
onValueChange={setBody}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (isScreenSubmitShortcut(e)) {
|
||||
e.preventDefault()
|
||||
void submit()
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={3}
|
||||
mentionOptions={mentionOptions}
|
||||
className="scrollbar-sleek min-h-20 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-[13px] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
{translate('auto.components.PullRequestPage.6591b1fa82', 'Cancel')}
|
||||
</Button>
|
||||
<Button size="sm" disabled={!canSubmitReply || submitting} onClick={() => void submit()}>
|
||||
{submitting
|
||||
? translate('auto.components.PullRequestPage.894cfd884b', 'Posting…')
|
||||
: translate('auto.components.PullRequestPage.f119e5f5ef', 'Reply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import React from 'react'
|
||||
import { ExternalLink, MessageSquarePlus } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
PR_COMMENT_OPEN_AUTHOR_CLASS,
|
||||
PR_COMMENT_RESOLVED_AUTHOR_CLASS,
|
||||
PR_COMMENT_RESOLVED_CONTAINER_CLASS
|
||||
} from '@/lib/pr-comment-resolution-classes'
|
||||
import { formatRelativeTime } from '@/components/github/work-item-state-presentation'
|
||||
import { CommentCodeContext } from '@/components/github/CommentCodeContext'
|
||||
import { CommentReactions } from '@/components/github/CommentReactions'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubOwnerRepo, GitHubPRFile } from '../../../../../shared/github/pull-request-types'
|
||||
import type { PRComment } from '../../../../../shared/github/comment-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type { MentionOption } from '../page-types'
|
||||
import { loadPRFileContents } from '../cache/file-content'
|
||||
import { CommentReplyForm } from '../comments/reply-form'
|
||||
|
||||
export function ConversationCommentCard({
|
||||
comment,
|
||||
isReply = false,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
prNumber,
|
||||
prRepo,
|
||||
files,
|
||||
headSha,
|
||||
baseSha,
|
||||
markdownGitHubRepo,
|
||||
mentionOptions,
|
||||
resolvedReplyingTo,
|
||||
onToggleReply,
|
||||
onSubmitReply
|
||||
}: {
|
||||
comment: PRComment
|
||||
isReply?: boolean
|
||||
repoPath: string | null
|
||||
repoId: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
prRepo: GitHubOwnerRepo | null
|
||||
files: GitHubPRFile[]
|
||||
headSha: string | undefined
|
||||
baseSha: string | undefined
|
||||
markdownGitHubRepo: { owner: string; repo: string; host?: string } | null
|
||||
mentionOptions: MentionOption[]
|
||||
resolvedReplyingTo: number | null
|
||||
onToggleReply: (commentId: number) => void
|
||||
onSubmitReply: (comment: PRComment, replyBody: string) => Promise<boolean>
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={cn(
|
||||
'min-w-0 overflow-hidden rounded-lg border border-border/40 bg-card shadow-xs',
|
||||
isReply && 'ml-6 max-w-[calc(100%-1.5rem)]',
|
||||
comment.isResolved && PR_COMMENT_RESOLVED_CONTAINER_CLASS
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-2">
|
||||
{comment.authorAvatarUrl ? (
|
||||
<img
|
||||
src={comment.authorAvatarUrl}
|
||||
alt={comment.author}
|
||||
className="size-5 shrink-0 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-5 shrink-0 rounded-full bg-muted" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate text-[13px] font-semibold',
|
||||
comment.isResolved ? PR_COMMENT_RESOLVED_AUTHOR_CLASS : PR_COMMENT_OPEN_AUTHOR_CLASS
|
||||
)}
|
||||
>
|
||||
{comment.author}
|
||||
</span>
|
||||
<span className="shrink-0 text-[12px] text-muted-foreground">
|
||||
· {formatRelativeTime(comment.createdAt)}
|
||||
</span>
|
||||
{comment.path && (
|
||||
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/70">
|
||||
{comment.path.split('/').pop()}
|
||||
{comment.line
|
||||
? translate('auto.components.PullRequestPage.34b9f7c264', ':L{{value0}}', {
|
||||
value0: comment.line
|
||||
})
|
||||
: ''}
|
||||
</span>
|
||||
)}
|
||||
{comment.isResolved && (
|
||||
<span className="rounded-full border border-border/60 bg-muted/40 px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{translate('auto.components.PullRequestPage.76b2a0ac5b', 'resolved')}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-7"
|
||||
onClick={() => onToggleReply(comment.id)}
|
||||
aria-label={translate(
|
||||
'auto.components.PullRequestPage.d6c6679de7',
|
||||
'Reply to comment'
|
||||
)}
|
||||
>
|
||||
<MessageSquarePlus className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{translate('auto.components.PullRequestPage.d6c6679de7', 'Reply to comment')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{comment.url && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-7"
|
||||
onClick={() => window.api.shell.openUrl(comment.url)}
|
||||
aria-label={translate(
|
||||
'auto.components.PullRequestPage.0ac19bb52e',
|
||||
'Open comment on GitHub'
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{translate('auto.components.PullRequestPage.0ac19bb52e', 'Open comment on GitHub')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 px-3 py-2">
|
||||
<CommentCodeContext
|
||||
comment={comment}
|
||||
repoPath={repoPath}
|
||||
repoId={repoId}
|
||||
sourceContext={sourceContext}
|
||||
prNumber={prNumber}
|
||||
prRepo={prRepo}
|
||||
files={files}
|
||||
headSha={headSha}
|
||||
baseSha={baseSha}
|
||||
loadPRFileContents={loadPRFileContents}
|
||||
/>
|
||||
<CommentMarkdown
|
||||
content={comment.body}
|
||||
variant="document"
|
||||
githubRepo={markdownGitHubRepo}
|
||||
className="min-w-0 max-w-full overflow-hidden break-words text-[13px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
|
||||
/>
|
||||
<CommentReactions reactions={comment.reactions} />
|
||||
{resolvedReplyingTo === comment.id && (
|
||||
<CommentReplyForm
|
||||
className="mt-3"
|
||||
placeholder={
|
||||
comment.path
|
||||
? translate(
|
||||
'auto.components.PullRequestPage.408e634fbb',
|
||||
'Reply in this review thread'
|
||||
)
|
||||
: translate('auto.components.PullRequestPage.31a7b202f2', 'Reply to @{{value0}}', {
|
||||
value0: comment.author
|
||||
})
|
||||
}
|
||||
mentionOptions={mentionOptions}
|
||||
onCancel={() => onToggleReply(comment.id)}
|
||||
onSubmit={(replyBody) => onSubmitReply(comment, replyBody)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger
|
||||
} from '@/components/ui/accordion'
|
||||
import {
|
||||
getPRCommentGroupCount,
|
||||
getPRCommentGroupId,
|
||||
getPRCommentGroupRoot,
|
||||
isResolvedPRCommentGroup,
|
||||
type PRCommentGroup
|
||||
} from '../../../../../shared/pr-comment-groups'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubOwnerRepo, GitHubPRFile } from '../../../../../shared/github/pull-request-types'
|
||||
import type { PRComment } from '../../../../../shared/github/comment-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type { MentionOption } from '../page-types'
|
||||
import { ConversationCommentCard } from './comment-card'
|
||||
|
||||
export function ConversationCommentGroup({
|
||||
group,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
prNumber,
|
||||
prRepo,
|
||||
files,
|
||||
headSha,
|
||||
baseSha,
|
||||
markdownGitHubRepo,
|
||||
mentionOptions,
|
||||
resolvedReplyingTo,
|
||||
onToggleReply,
|
||||
onSubmitReply
|
||||
}: {
|
||||
group: PRCommentGroup
|
||||
repoPath: string | null
|
||||
repoId: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
prRepo: GitHubOwnerRepo | null
|
||||
files: GitHubPRFile[]
|
||||
headSha: string | undefined
|
||||
baseSha: string | undefined
|
||||
markdownGitHubRepo: { owner: string; repo: string; host?: string } | null
|
||||
mentionOptions: MentionOption[]
|
||||
resolvedReplyingTo: number | null
|
||||
onToggleReply: (commentId: number) => void
|
||||
onSubmitReply: (comment: PRComment, replyBody: string) => Promise<boolean>
|
||||
}): React.JSX.Element {
|
||||
const cardProps = {
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
prNumber,
|
||||
prRepo,
|
||||
files,
|
||||
headSha,
|
||||
baseSha,
|
||||
markdownGitHubRepo,
|
||||
mentionOptions,
|
||||
resolvedReplyingTo,
|
||||
onToggleReply,
|
||||
onSubmitReply
|
||||
}
|
||||
const cards =
|
||||
group.kind === 'thread'
|
||||
? [
|
||||
<ConversationCommentCard key={group.root.id} comment={group.root} {...cardProps} />,
|
||||
...group.replies.map((reply) => (
|
||||
<ConversationCommentCard key={reply.id} comment={reply} isReply {...cardProps} />
|
||||
))
|
||||
]
|
||||
: [<ConversationCommentCard key={group.comment.id} comment={group.comment} {...cardProps} />]
|
||||
|
||||
if (!isResolvedPRCommentGroup(group)) {
|
||||
return (
|
||||
<div key={getPRCommentGroupId(group)} className="flex min-w-0 flex-col gap-3">
|
||||
{cards}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const root = getPRCommentGroupRoot(group)
|
||||
const count = getPRCommentGroupCount(group)
|
||||
return (
|
||||
<Accordion key={getPRCommentGroupId(group)} type="single" collapsible>
|
||||
<AccordionItem
|
||||
value={getPRCommentGroupId(group)}
|
||||
className="rounded-lg border border-border/40 bg-card"
|
||||
>
|
||||
<AccordionTrigger className="px-3 py-2 text-[13px] text-muted-foreground hover:bg-accent/30">
|
||||
<span className="min-w-0 truncate">
|
||||
{translate('auto.components.PullRequestPage.f4fe47c2bb', 'Resolved')}{' '}
|
||||
{group.kind === 'thread'
|
||||
? translate('auto.components.PullRequestPage.345b68254c', 'thread')
|
||||
: translate('auto.components.PullRequestPage.e01e34f5fa', 'comment')}{' '}
|
||||
{translate('auto.components.PullRequestPage.3c891789f6', 'by')} {root.author}
|
||||
{count > 1 ? ` (${count})` : ''}
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex min-w-0 flex-col gap-3 px-3 pb-3 pt-0">
|
||||
{cards}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from 'react'
|
||||
import { MessageSquare } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
getPRCommentAudienceEmptyLabel,
|
||||
getPrCommentAudienceFilters
|
||||
} from '@/lib/pr-comment-audience-labels'
|
||||
import { getPRCommentGroupId, type PRCommentGroup } from '../../../../../shared/pr-comment-groups'
|
||||
import type { PRCommentAudienceFilter } from '../../../../../shared/pr-comment-audience'
|
||||
import type { GitHubOwnerRepo, GitHubPRFile } from '../../../../../shared/github/pull-request-types'
|
||||
import type { PRComment } from '../../../../../shared/github/comment-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type { MentionOption } from '../page-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { ConversationCommentGroup } from './comment-group'
|
||||
|
||||
export function ConversationCommentsList({
|
||||
itemType,
|
||||
comments,
|
||||
visibleComments,
|
||||
visibleCommentGroups,
|
||||
commentFilter,
|
||||
commentCounts,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
prNumber,
|
||||
prRepo,
|
||||
files,
|
||||
headSha,
|
||||
baseSha,
|
||||
markdownGitHubRepo,
|
||||
mentionOptions,
|
||||
resolvedReplyingTo,
|
||||
onFilterChange,
|
||||
onToggleReply,
|
||||
onSubmitReply
|
||||
}: {
|
||||
itemType: 'issue' | 'pr'
|
||||
comments: PRComment[]
|
||||
visibleComments: PRComment[]
|
||||
visibleCommentGroups: PRCommentGroup[]
|
||||
commentFilter: PRCommentAudienceFilter
|
||||
commentCounts: Record<PRCommentAudienceFilter, number>
|
||||
repoPath: string | null
|
||||
repoId: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
prRepo: GitHubOwnerRepo | null
|
||||
files: GitHubPRFile[]
|
||||
headSha: string | undefined
|
||||
baseSha: string | undefined
|
||||
markdownGitHubRepo: { owner: string; repo: string; host?: string } | null
|
||||
mentionOptions: MentionOption[]
|
||||
resolvedReplyingTo: number | null
|
||||
onFilterChange: (value: PRCommentAudienceFilter) => void
|
||||
onToggleReply: (commentId: number) => void
|
||||
onSubmitReply: (comment: PRComment, replyBody: string) => Promise<boolean>
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<MessageSquare className="size-4 text-muted-foreground" />
|
||||
<span className="text-[13px] font-medium text-foreground">
|
||||
{translate('auto.components.PullRequestPage.3463d10a63', 'Comments')}
|
||||
</span>
|
||||
{comments.length > 0 && (
|
||||
<span className="rounded-full border border-border/50 bg-muted/30 px-1.5 py-0.5 text-[11px] tabular-nums text-muted-foreground">
|
||||
{comments.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{itemType === 'pr' && comments.length > 0 && (
|
||||
<div className="grid grid-cols-3 rounded-lg border border-border/50 bg-background p-0.5">
|
||||
{getPrCommentAudienceFilters().map((filter) => {
|
||||
const isActive = commentFilter === filter.value
|
||||
return (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 items-center justify-center gap-1 rounded-md px-2 text-[12px] font-medium text-muted-foreground transition-colors',
|
||||
isActive && 'bg-muted text-foreground'
|
||||
)}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => onFilterChange(filter.value)}
|
||||
>
|
||||
<span>{filter.label}</span>
|
||||
<span className="tabular-nums">{commentCounts[filter.value]}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comments.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-left text-[13px] text-muted-foreground">
|
||||
{translate('auto.components.PullRequestPage.d2d589556c', 'No comments yet.')}
|
||||
</div>
|
||||
) : visibleComments.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-center text-[13px] text-muted-foreground">
|
||||
{getPRCommentAudienceEmptyLabel(commentFilter)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{visibleCommentGroups.map((group) => (
|
||||
<ConversationCommentGroup
|
||||
key={getPRCommentGroupId(group)}
|
||||
group={group}
|
||||
repoPath={repoPath}
|
||||
repoId={repoId}
|
||||
sourceContext={sourceContext}
|
||||
prNumber={prNumber}
|
||||
prRepo={prRepo}
|
||||
files={files}
|
||||
headSha={headSha}
|
||||
baseSha={baseSha}
|
||||
markdownGitHubRepo={markdownGitHubRepo}
|
||||
mentionOptions={mentionOptions}
|
||||
resolvedReplyingTo={resolvedReplyingTo}
|
||||
onToggleReply={onToggleReply}
|
||||
onSubmitReply={onSubmitReply}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from 'react'
|
||||
import { Check, LoaderCircle, Pencil, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||
import { formatRelativeTime } from '@/components/github/work-item-state-presentation'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { MentionOption } from '../page-types'
|
||||
import { MentionTextarea } from '../mentions/textarea'
|
||||
|
||||
export function ConversationDescription({
|
||||
authorLabel,
|
||||
updatedAt,
|
||||
canEditBody,
|
||||
loading,
|
||||
detailsLoaded,
|
||||
bodyEditing,
|
||||
bodySaving,
|
||||
bodyChanged,
|
||||
body,
|
||||
resolvedBodyDraft,
|
||||
bodyTextareaRef,
|
||||
mentionOptions,
|
||||
markdownGitHubRepo,
|
||||
onCancelEdit,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onDraftChange
|
||||
}: {
|
||||
authorLabel: string
|
||||
updatedAt: string
|
||||
canEditBody: boolean
|
||||
loading: boolean
|
||||
detailsLoaded: boolean
|
||||
bodyEditing: boolean
|
||||
bodySaving: boolean
|
||||
bodyChanged: boolean
|
||||
body: string
|
||||
resolvedBodyDraft: string
|
||||
bodyTextareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
mentionOptions: MentionOption[]
|
||||
markdownGitHubRepo: { owner: string; repo: string; host?: string } | null
|
||||
onCancelEdit: () => void
|
||||
onStartEdit: () => void
|
||||
onSave: () => void
|
||||
onDraftChange: (value: string) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-card shadow-xs">
|
||||
<div className="flex items-center gap-2 border-b border-border/50 px-3 py-2 text-[12px] text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{authorLabel}</span>
|
||||
<span>
|
||||
{translate('auto.components.PullRequestPage.169a93b29a', 'updated')}{' '}
|
||||
{formatRelativeTime(updatedAt)}
|
||||
</span>
|
||||
{canEditBody && !loading && detailsLoaded ? (
|
||||
bodyEditing ? (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
disabled={bodySaving}
|
||||
onClick={onCancelEdit}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
{translate('auto.components.PullRequestPage.6591b1fa82', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
disabled={bodySaving || !bodyChanged}
|
||||
onClick={onSave}
|
||||
>
|
||||
{bodySaving ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
{translate('auto.components.PullRequestPage.4a337ac05f', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="ml-auto size-7"
|
||||
onClick={onStartEdit}
|
||||
aria-label={translate(
|
||||
'auto.components.PullRequestPage.da9aaa8bcf',
|
||||
'Edit description'
|
||||
)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{translate('auto.components.PullRequestPage.da9aaa8bcf', 'Edit description')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
<div className="px-4 py-4 text-[14px] leading-relaxed text-foreground">
|
||||
{loading && !detailsLoaded ? (
|
||||
<div className="flex items-center justify-center py-5">
|
||||
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : bodyEditing ? (
|
||||
<MentionTextarea
|
||||
textareaRef={bodyTextareaRef}
|
||||
value={resolvedBodyDraft}
|
||||
onValueChange={onDraftChange}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onCancelEdit()
|
||||
return
|
||||
}
|
||||
if (isScreenSubmitShortcut(event)) {
|
||||
event.preventDefault()
|
||||
onSave()
|
||||
}
|
||||
}}
|
||||
placeholder={translate('auto.components.PullRequestPage.778683ec84', 'Description')}
|
||||
rows={12}
|
||||
mentionOptions={mentionOptions}
|
||||
wrapperClassName="flex min-h-64 w-full items-stretch"
|
||||
className="scrollbar-sleek block min-h-64 w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-[13px] leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
) : body.trim() ? (
|
||||
<CommentMarkdown
|
||||
content={body}
|
||||
variant="document"
|
||||
githubRepo={markdownGitHubRepo}
|
||||
className="min-w-0 max-w-full overflow-hidden break-words text-[14px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
|
||||
/>
|
||||
) : (
|
||||
<span className="italic text-muted-foreground">
|
||||
{translate('auto.components.PullRequestPage.c8ea6c7c4c', 'No description provided.')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
attachPRReviewReplyParent,
|
||||
canPostPRReviewThreadReply
|
||||
} from '@/components/right-sidebar/pr-comments-ai-launch-ack'
|
||||
import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/pr-comment-fixing-reply-body'
|
||||
import {
|
||||
addIssueCommentForRepo,
|
||||
addPRReviewCommentReplyForRepo
|
||||
} from '@/components/github/github-work-item-comment-mutations'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import type { PRComment } from '../../../../../shared/github/comment-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
|
||||
export async function postConversationReply(args: {
|
||||
canUseRepoMutationContext: boolean
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prRepo: GitHubOwnerRepo | null
|
||||
comment: PRComment
|
||||
replyBody: string
|
||||
onCommentAdded: (comment: PRComment) => void
|
||||
onReplied: () => void
|
||||
}): Promise<boolean> {
|
||||
if (!args.canUseRepoMutationContext) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.6885c619e7',
|
||||
'Unable to reply without a repository path.'
|
||||
)
|
||||
)
|
||||
return false
|
||||
}
|
||||
// Why: nest under review threads (path/threadId/discussion_r); never post a
|
||||
// separate top-level conversation comment for those.
|
||||
const isReviewThreadReply = args.item.type === 'pr' && canPostPRReviewThreadReply(args.comment)
|
||||
const result = isReviewThreadReply
|
||||
? await addPRReviewCommentReplyForRepo({
|
||||
repoPath: args.repoPath ?? '',
|
||||
repoId: args.item.repoId,
|
||||
sourceContext: args.sourceContext,
|
||||
prNumber: args.item.number,
|
||||
prRepo: args.prRepo,
|
||||
commentId: args.comment.id,
|
||||
body: args.replyBody,
|
||||
threadId: args.comment.threadId,
|
||||
path: args.comment.path,
|
||||
line: args.comment.line
|
||||
})
|
||||
: await addIssueCommentForRepo({
|
||||
repoPath: args.repoPath ?? '',
|
||||
repoId: args.item.repoId,
|
||||
sourceContext: args.sourceContext,
|
||||
number: args.item.number,
|
||||
// Why: a GitHub App login carries a [bot] suffix that never resolves as a mention.
|
||||
body: buildPRCommentConversationReplyBody(args.comment.author, args.replyBody),
|
||||
type: args.item.type,
|
||||
prRepo: args.prRepo
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(
|
||||
result.error ||
|
||||
translate('auto.components.PullRequestPage.5821aab360', 'Failed to post reply.')
|
||||
)
|
||||
return false
|
||||
}
|
||||
args.onCommentAdded(
|
||||
isReviewThreadReply ? attachPRReviewReplyParent(result.comment, args.comment) : result.comment
|
||||
)
|
||||
args.onReplied()
|
||||
toast.success(translate('auto.components.PullRequestPage.11505c7a71', 'Reply posted.'))
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRepoAssignees } from '@/hooks/useIssueMetadata'
|
||||
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
|
||||
import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context'
|
||||
import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides'
|
||||
import {
|
||||
filterPRCommentsByAudience,
|
||||
getPRCommentAudienceCounts,
|
||||
type PRCommentAudienceFilter
|
||||
} from '../../../../../shared/pr-comment-audience'
|
||||
import { groupPRComments } from '../../../../../shared/pr-comment-groups'
|
||||
import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state'
|
||||
import { runWorkItemBodyUpdate } from '@/components/github/github-work-item-edit-mutations'
|
||||
import {
|
||||
parseOwnerRepoFromItemUrl,
|
||||
resolvePullRequestRepo
|
||||
} from '@/components/github/github-work-item-identity'
|
||||
import {
|
||||
resolveGitHubBodyDraft,
|
||||
shouldSyncGitHubBodyDraft
|
||||
} from '@/components/github-body-draft-state'
|
||||
import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type {
|
||||
GitHubAssignableUser,
|
||||
GitHubPRFile
|
||||
} from '../../../../../shared/github/pull-request-types'
|
||||
import type {
|
||||
GitHubWorkItem,
|
||||
GitHubWorkItemDetails
|
||||
} from '../../../../../shared/github/work-item-types'
|
||||
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
|
||||
import type { PRComment } from '../../../../../shared/github/comment-types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { PullRequestPageProjectOrigin } from '../page-types'
|
||||
import { buildMentionOptions } from '../mentions/options'
|
||||
import { PRActionsPanel } from '../actions/panel'
|
||||
import { PRAssigneesPanel } from '@/components/github/PRAssigneesPanel'
|
||||
import { PRReviewersPanel } from '../reviewers/panel'
|
||||
import { ChecksTab } from '../checks/tab'
|
||||
import { GHCommentComposer } from '../comments/composer'
|
||||
import { ConversationDescription } from './description'
|
||||
import { ConversationCommentsList } from './comments-list'
|
||||
import { postConversationReply } from './reply'
|
||||
|
||||
export function ConversationTab({
|
||||
item,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
body,
|
||||
comments,
|
||||
files,
|
||||
headSha,
|
||||
baseSha,
|
||||
loading,
|
||||
detailsLoaded,
|
||||
checks,
|
||||
participants: detailsParticipants,
|
||||
localState,
|
||||
onStateChange,
|
||||
projectOrigin,
|
||||
onMutated,
|
||||
onChecksUpdated,
|
||||
onBodyUpdated,
|
||||
onCommentAdded,
|
||||
onReviewersRequested
|
||||
}: {
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
repoId: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
body: string
|
||||
comments: PRComment[]
|
||||
files: GitHubPRFile[]
|
||||
headSha: string | undefined
|
||||
baseSha: string | undefined
|
||||
loading: boolean
|
||||
detailsLoaded: boolean
|
||||
checks: GitHubWorkItemDetails['checks']
|
||||
participants: GitHubAssignableUser[]
|
||||
localState: GitHubWorkItem['state']
|
||||
onStateChange: (state: GitHubWorkItem['state']) => void
|
||||
projectOrigin: PullRequestPageProjectOrigin | undefined
|
||||
onMutated: () => void
|
||||
onChecksUpdated: (checks: PRCheckDetail[]) => void
|
||||
onBodyUpdated: (body: string) => void
|
||||
onCommentAdded: (comment: PRComment) => void
|
||||
onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void
|
||||
}): React.JSX.Element {
|
||||
const authorLabel = item.author ?? 'unknown'
|
||||
const [replyingTo, setReplyingTo] = useState<number | null>(null)
|
||||
const [commentFilter, setCommentFilter] = useState<PRCommentAudienceFilter>('all')
|
||||
const [bodyDraft, setBodyDraft] = useState(body)
|
||||
const [bodyEditing, setBodyEditing] = useState(false)
|
||||
const [bodySaving, setBodySaving] = useState(false)
|
||||
const bodyTextareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const bodyTextareaFocusFrameRef = useRef<number | null>(null)
|
||||
const canUseRepoMutationContext = canUseGitHubRepoContext(repoPath, sourceContext)
|
||||
const repoOwnerSettings = useAppStore(
|
||||
useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null))
|
||||
)
|
||||
const sourceSettings = useMemo(
|
||||
() =>
|
||||
sourceContext?.provider === 'github'
|
||||
? ({
|
||||
...repoOwnerSettings,
|
||||
...getTaskSourceRuntimeSettings(sourceContext)
|
||||
} as typeof repoOwnerSettings)
|
||||
: repoOwnerSettings,
|
||||
[repoOwnerSettings, sourceContext]
|
||||
)
|
||||
const repoAssignees = useRepoAssignees(repoPath, item.repoId, sourceSettings)
|
||||
const botAuthorOverrides = usePRBotAuthorOverrides()
|
||||
const commentCounts = useMemo(
|
||||
() => getPRCommentAudienceCounts(comments, botAuthorOverrides),
|
||||
[botAuthorOverrides, comments]
|
||||
)
|
||||
const visibleComments = useMemo(
|
||||
() => filterPRCommentsByAudience(comments, commentFilter, botAuthorOverrides),
|
||||
[botAuthorOverrides, commentFilter, comments]
|
||||
)
|
||||
const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments])
|
||||
const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, visibleComments)
|
||||
const mentionOptions = useMemo(
|
||||
() =>
|
||||
buildMentionOptions({
|
||||
item,
|
||||
comments,
|
||||
participants: detailsParticipants,
|
||||
assignableUsers: repoAssignees.data
|
||||
}),
|
||||
[comments, detailsParticipants, item, repoAssignees.data]
|
||||
)
|
||||
|
||||
const cancelBodyTextareaFocusFrame = useCallback((): void => {
|
||||
if (bodyTextareaFocusFrameRef.current !== null) {
|
||||
cancelAnimationFrame(bodyTextareaFocusFrameRef.current)
|
||||
bodyTextareaFocusFrameRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (resolvedReplyingTo !== replyingTo) {
|
||||
// Why: clear before paint when filters/refetches hide the reply target, so a stale composer doesn't flash for the wrong comment set.
|
||||
setReplyingTo(resolvedReplyingTo)
|
||||
}
|
||||
|
||||
const resolvedBodyDraft = resolveGitHubBodyDraft(bodyDraft, body, bodyEditing)
|
||||
if (shouldSyncGitHubBodyDraft(bodyDraft, body, bodyEditing)) {
|
||||
// Why: reconcile before paint so a background body refresh while the editor is closed doesn't show a stale draft on reopen.
|
||||
setBodyDraft(resolvedBodyDraft)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyEditing) {
|
||||
cancelBodyTextareaFocusFrame()
|
||||
return cancelBodyTextareaFocusFrame
|
||||
}
|
||||
cancelBodyTextareaFocusFrame()
|
||||
bodyTextareaFocusFrameRef.current = requestAnimationFrame(() => {
|
||||
bodyTextareaFocusFrameRef.current = null
|
||||
bodyTextareaRef.current?.focus()
|
||||
})
|
||||
return cancelBodyTextareaFocusFrame
|
||||
}, [bodyEditing, cancelBodyTextareaFocusFrame])
|
||||
|
||||
const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url])
|
||||
const prRepo = useMemo(() => resolvePullRequestRepo(item, projectOrigin), [item, projectOrigin])
|
||||
const markdownGitHubRepo = useMemo(
|
||||
() =>
|
||||
projectOrigin
|
||||
? { owner: projectOrigin.owner, repo: projectOrigin.repo, host: projectOrigin.host }
|
||||
: bodySlug,
|
||||
[bodySlug, projectOrigin]
|
||||
)
|
||||
const canEditBody =
|
||||
item.type === 'pr'
|
||||
? Boolean(projectOrigin || bodySlug)
|
||||
: Boolean(projectOrigin || canUseRepoMutationContext)
|
||||
const bodyChanged = resolvedBodyDraft !== body
|
||||
|
||||
const handleSaveBody = useCallback(async (): Promise<void> => {
|
||||
if (bodySaving || !bodyChanged) {
|
||||
setBodyEditing(false)
|
||||
return
|
||||
}
|
||||
setBodySaving(true)
|
||||
try {
|
||||
await runWorkItemBodyUpdate({
|
||||
item,
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
body: resolvedBodyDraft,
|
||||
parsedSlug: bodySlug
|
||||
})
|
||||
onBodyUpdated(resolvedBodyDraft)
|
||||
setBodyEditing(false)
|
||||
toast.success(translate('auto.components.PullRequestPage.9b4190dc98', 'Description updated.'))
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate('auto.components.PullRequestPage.d94810f652', 'Failed to update description.')
|
||||
)
|
||||
} finally {
|
||||
setBodySaving(false)
|
||||
}
|
||||
}, [
|
||||
bodyChanged,
|
||||
resolvedBodyDraft,
|
||||
bodySaving,
|
||||
bodySlug,
|
||||
item,
|
||||
onBodyUpdated,
|
||||
projectOrigin,
|
||||
repoPath,
|
||||
sourceContext
|
||||
])
|
||||
|
||||
const handleReply = useCallback(
|
||||
async (comment: PRComment, replyBody: string): Promise<boolean> =>
|
||||
postConversationReply({
|
||||
canUseRepoMutationContext,
|
||||
item,
|
||||
repoPath,
|
||||
sourceContext,
|
||||
prRepo,
|
||||
comment,
|
||||
replyBody,
|
||||
onCommentAdded,
|
||||
onReplied: () => setReplyingTo(null)
|
||||
}),
|
||||
[canUseRepoMutationContext, item, onCommentAdded, prRepo, repoPath, sourceContext]
|
||||
)
|
||||
|
||||
const rightPanel =
|
||||
item.type === 'pr' ? (
|
||||
<div className="flex h-fit flex-col gap-5 xl:sticky xl:top-4">
|
||||
<PRActionsPanel
|
||||
item={item}
|
||||
repoPath={repoPath}
|
||||
repoId={item.repoId}
|
||||
sourceContext={sourceContext}
|
||||
projectOrigin={projectOrigin}
|
||||
localState={localState}
|
||||
onStateChange={onStateChange}
|
||||
onMutated={onMutated}
|
||||
/>
|
||||
<PRAssigneesPanel
|
||||
item={item}
|
||||
repoPath={repoPath}
|
||||
projectOrigin={projectOrigin}
|
||||
sourceContext={sourceContext}
|
||||
onMutated={onMutated}
|
||||
/>
|
||||
<PRReviewersPanel
|
||||
item={item}
|
||||
loading={loading}
|
||||
repoPath={repoPath}
|
||||
sourceContext={sourceContext}
|
||||
projectOrigin={projectOrigin}
|
||||
onReviewersRequested={onReviewersRequested}
|
||||
/>
|
||||
<aside className="overflow-hidden rounded-lg border border-border/50 bg-card shadow-xs">
|
||||
<ChecksTab
|
||||
item={item}
|
||||
repoPath={repoPath}
|
||||
repoId={item.repoId}
|
||||
sourceContext={sourceContext}
|
||||
headSha={headSha}
|
||||
checks={checks}
|
||||
loading={loading || !detailsLoaded}
|
||||
onChecksUpdated={onChecksUpdated}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-w-0 gap-5 px-4 py-4',
|
||||
// Why: on narrow windows the drawer is near full-width, so keep PR controls beside the conversation, not below long threads.
|
||||
item.type === 'pr' && 'grid-cols-[minmax(0,1fr)_300px]'
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<ConversationDescription
|
||||
authorLabel={authorLabel}
|
||||
updatedAt={item.updatedAt}
|
||||
canEditBody={canEditBody}
|
||||
loading={loading}
|
||||
detailsLoaded={detailsLoaded}
|
||||
bodyEditing={bodyEditing}
|
||||
bodySaving={bodySaving}
|
||||
bodyChanged={bodyChanged}
|
||||
body={body}
|
||||
resolvedBodyDraft={resolvedBodyDraft}
|
||||
bodyTextareaRef={bodyTextareaRef}
|
||||
mentionOptions={mentionOptions}
|
||||
markdownGitHubRepo={markdownGitHubRepo}
|
||||
onCancelEdit={() => {
|
||||
setBodyDraft(body)
|
||||
setBodyEditing(false)
|
||||
}}
|
||||
onStartEdit={() => {
|
||||
setBodyDraft(body)
|
||||
setBodyEditing(true)
|
||||
}}
|
||||
onSave={() => {
|
||||
void handleSaveBody()
|
||||
}}
|
||||
onDraftChange={setBodyDraft}
|
||||
/>
|
||||
|
||||
{detailsLoaded ? (
|
||||
<ConversationCommentsList
|
||||
itemType={item.type}
|
||||
comments={comments}
|
||||
visibleComments={visibleComments}
|
||||
visibleCommentGroups={visibleCommentGroups}
|
||||
commentFilter={commentFilter}
|
||||
commentCounts={commentCounts}
|
||||
repoPath={repoPath}
|
||||
repoId={item.repoId}
|
||||
sourceContext={sourceContext}
|
||||
prNumber={item.number}
|
||||
prRepo={prRepo}
|
||||
files={files}
|
||||
headSha={headSha}
|
||||
baseSha={baseSha}
|
||||
markdownGitHubRepo={markdownGitHubRepo}
|
||||
mentionOptions={mentionOptions}
|
||||
resolvedReplyingTo={resolvedReplyingTo}
|
||||
onFilterChange={setCommentFilter}
|
||||
onToggleReply={(commentId) =>
|
||||
setReplyingTo((current) => (current === commentId ? null : commentId))
|
||||
}
|
||||
onSubmitReply={handleReply}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{detailsLoaded && canUseRepoMutationContext && (
|
||||
<GHCommentComposer
|
||||
className="mt-1"
|
||||
repoPath={repoPath ?? ''}
|
||||
repoId={item.repoId}
|
||||
sourceContext={sourceContext}
|
||||
issueNumber={item.number}
|
||||
itemType={item.type}
|
||||
prRepo={prRepo}
|
||||
mentionOptions={mentionOptions}
|
||||
onCommentAdded={onCommentAdded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rightPanel}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { toast } from 'sonner'
|
||||
import { runIssueUpdate } from '@/components/github/github-work-item-edit-mutations'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type { PullRequestPageProjectOrigin } from '../page-types'
|
||||
|
||||
type IssueUpdateRun = (
|
||||
key: string,
|
||||
spec: {
|
||||
mutate: () => Promise<unknown>
|
||||
onOptimistic: () => void
|
||||
onRevert: () => void
|
||||
onSuccess: () => void
|
||||
onError: (err: string) => void
|
||||
}
|
||||
) => void
|
||||
|
||||
export function changeIssueState(args: {
|
||||
newState: 'open' | 'closed'
|
||||
localState: GitHubWorkItem['state']
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
projectOrigin: PullRequestPageProjectOrigin | undefined
|
||||
run: IssueUpdateRun
|
||||
onStateChange: (state: GitHubWorkItem['state']) => void
|
||||
patchWorkItem: (
|
||||
id: string,
|
||||
patch: Partial<GitHubWorkItem>,
|
||||
repoId?: string | null,
|
||||
options?: { sourceContext?: TaskSourceContext | null }
|
||||
) => void
|
||||
patchProjectRowIfNeeded: (patch: { state: GitHubWorkItem['state'] }) => void
|
||||
onMutated: () => void
|
||||
}): void {
|
||||
if (args.newState === args.localState) {
|
||||
return
|
||||
}
|
||||
const prevState = args.localState
|
||||
args.run('state', {
|
||||
mutate: () =>
|
||||
runIssueUpdate({
|
||||
repoId: args.item.repoId,
|
||||
repoPath: args.repoPath,
|
||||
sourceContext: args.sourceContext,
|
||||
projectOrigin: args.projectOrigin,
|
||||
number: args.item.number,
|
||||
updates: { state: args.newState }
|
||||
}),
|
||||
onOptimistic: () => {
|
||||
args.onStateChange(args.newState)
|
||||
args.patchWorkItem(args.item.id, { state: args.newState }, args.item.repoId, {
|
||||
sourceContext: args.sourceContext
|
||||
})
|
||||
args.patchProjectRowIfNeeded({ state: args.newState })
|
||||
},
|
||||
onRevert: () => {
|
||||
args.onStateChange(prevState)
|
||||
args.patchWorkItem(args.item.id, { state: prevState }, args.item.repoId, {
|
||||
sourceContext: args.sourceContext
|
||||
})
|
||||
args.patchProjectRowIfNeeded({ state: prevState })
|
||||
},
|
||||
onSuccess: () => {
|
||||
args.patchWorkItem(args.item.id, { state: args.newState }, args.item.repoId, {
|
||||
sourceContext: args.sourceContext
|
||||
})
|
||||
args.patchProjectRowIfNeeded({ state: args.newState })
|
||||
args.onMutated()
|
||||
},
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleIssueLabel(args: {
|
||||
label: string
|
||||
localLabels: string[]
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
projectOrigin: PullRequestPageProjectOrigin | undefined
|
||||
run: IssueUpdateRun
|
||||
onLabelsChange: (labels: string[]) => void
|
||||
patchWorkItem: (
|
||||
id: string,
|
||||
patch: Partial<GitHubWorkItem>,
|
||||
repoId?: string | null,
|
||||
options?: { sourceContext?: TaskSourceContext | null }
|
||||
) => void
|
||||
patchProjectRowIfNeeded: (patch: { labels: string[] }) => void
|
||||
onMutated: () => void
|
||||
}): void {
|
||||
const isAdding = !args.localLabels.includes(args.label)
|
||||
const prevLabels = args.localLabels
|
||||
const newLabels = isAdding
|
||||
? [...prevLabels, args.label]
|
||||
: prevLabels.filter((l) => l !== args.label)
|
||||
|
||||
if (isAdding) {
|
||||
args.run('labels', {
|
||||
mutate: () =>
|
||||
runIssueUpdate({
|
||||
repoId: args.item.repoId,
|
||||
repoPath: args.repoPath,
|
||||
sourceContext: args.sourceContext,
|
||||
projectOrigin: args.projectOrigin,
|
||||
number: args.item.number,
|
||||
updates: { addLabels: [args.label] }
|
||||
}),
|
||||
onOptimistic: () => {
|
||||
args.onLabelsChange(newLabels)
|
||||
args.patchWorkItem(args.item.id, { labels: newLabels }, args.item.repoId, {
|
||||
sourceContext: args.sourceContext
|
||||
})
|
||||
args.patchProjectRowIfNeeded({ labels: newLabels })
|
||||
},
|
||||
onSuccess: () => {
|
||||
args.onMutated()
|
||||
},
|
||||
onRevert: () => {
|
||||
args.onLabelsChange(prevLabels)
|
||||
args.patchWorkItem(args.item.id, { labels: prevLabels }, args.item.repoId, {
|
||||
sourceContext: args.sourceContext
|
||||
})
|
||||
args.patchProjectRowIfNeeded({ labels: prevLabels })
|
||||
},
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
args.run('labels', {
|
||||
mutate: () =>
|
||||
runIssueUpdate({
|
||||
repoId: args.item.repoId,
|
||||
repoPath: args.repoPath,
|
||||
sourceContext: args.sourceContext,
|
||||
projectOrigin: args.projectOrigin,
|
||||
number: args.item.number,
|
||||
updates: { removeLabels: [args.label] }
|
||||
}),
|
||||
onOptimistic: () => {
|
||||
args.onLabelsChange(newLabels)
|
||||
args.patchWorkItem(args.item.id, { labels: newLabels }, args.item.repoId, {
|
||||
sourceContext: args.sourceContext
|
||||
})
|
||||
args.patchProjectRowIfNeeded({ labels: newLabels })
|
||||
},
|
||||
onRevert: () => {
|
||||
args.onLabelsChange(prevLabels)
|
||||
args.patchWorkItem(args.item.id, { labels: prevLabels }, args.item.repoId, {
|
||||
sourceContext: args.sourceContext
|
||||
})
|
||||
args.patchProjectRowIfNeeded({ labels: prevLabels })
|
||||
},
|
||||
onSuccess: () => {
|
||||
args.onMutated()
|
||||
},
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleIssueAssignee(args: {
|
||||
login: string
|
||||
localAssignees: string[]
|
||||
assigneesItemKey: string
|
||||
editedAssigneesItemKeyRef: { current: string | null }
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
projectOrigin: PullRequestPageProjectOrigin | undefined
|
||||
run: IssueUpdateRun
|
||||
setLocalAssignees: (value: string[]) => void
|
||||
patchProjectRowIfNeeded: (patch: { assignees: string[] }) => void
|
||||
onMutated: () => void
|
||||
}): void {
|
||||
const isAssigned = args.localAssignees.includes(args.login)
|
||||
const prevAssignees = args.localAssignees
|
||||
const newAssignees = isAssigned
|
||||
? prevAssignees.filter((l) => l !== args.login)
|
||||
: [...prevAssignees, args.login]
|
||||
|
||||
// Why: scope the optimistic guard to this repo item so switching items doesn't suppress the next item's assignee sync.
|
||||
args.editedAssigneesItemKeyRef.current = args.assigneesItemKey
|
||||
if (isAssigned) {
|
||||
args.run('assignees', {
|
||||
mutate: () =>
|
||||
runIssueUpdate({
|
||||
repoId: args.item.repoId,
|
||||
repoPath: args.repoPath,
|
||||
sourceContext: args.sourceContext,
|
||||
projectOrigin: args.projectOrigin,
|
||||
number: args.item.number,
|
||||
updates: { removeAssignees: [args.login] }
|
||||
}),
|
||||
onOptimistic: () => {
|
||||
args.setLocalAssignees(newAssignees)
|
||||
args.patchProjectRowIfNeeded({ assignees: newAssignees })
|
||||
},
|
||||
onRevert: () => {
|
||||
args.setLocalAssignees(prevAssignees)
|
||||
args.patchProjectRowIfNeeded({ assignees: prevAssignees })
|
||||
},
|
||||
onSuccess: () => {
|
||||
args.onMutated()
|
||||
},
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
args.run('assignees', {
|
||||
mutate: () =>
|
||||
runIssueUpdate({
|
||||
repoId: args.item.repoId,
|
||||
repoPath: args.repoPath,
|
||||
sourceContext: args.sourceContext,
|
||||
projectOrigin: args.projectOrigin,
|
||||
number: args.item.number,
|
||||
updates: { addAssignees: [args.login] }
|
||||
}),
|
||||
onOptimistic: () => {
|
||||
args.setLocalAssignees(newAssignees)
|
||||
args.patchProjectRowIfNeeded({ assignees: newAssignees })
|
||||
},
|
||||
onSuccess: () => {
|
||||
args.onMutated()
|
||||
},
|
||||
onRevert: () => {
|
||||
args.setLocalAssignees(prevAssignees)
|
||||
args.patchProjectRowIfNeeded({ assignees: prevAssignees })
|
||||
},
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { ArrowRight, ChevronDown, CircleDashed, CircleDot, LoaderCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useImmediateMutation, useRepoAssignees, useRepoLabels } from '@/hooks/useIssueMetadata'
|
||||
import { useRepoAssigneesBySlug, useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata'
|
||||
import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
|
||||
import { getStateLabel } from '@/components/github/work-item-state-presentation'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { PullRequestPageProjectOrigin } from '../page-types'
|
||||
import { getStateTone } from '../presentation/state-badge'
|
||||
import { changeIssueState, toggleIssueAssignee, toggleIssueLabel } from './issue-updates'
|
||||
|
||||
export function GHEditSection({
|
||||
item,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
localState,
|
||||
localLabels,
|
||||
onStateChange,
|
||||
onLabelsChange,
|
||||
onMutated,
|
||||
assignees,
|
||||
onUse
|
||||
}: {
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
repoId: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
projectOrigin: PullRequestPageProjectOrigin | undefined
|
||||
localState: GitHubWorkItem['state']
|
||||
localLabels: string[]
|
||||
onStateChange: (state: GitHubWorkItem['state']) => void
|
||||
onLabelsChange: (labels: string[]) => void
|
||||
/** Called after a successful issue mutation so the parent can invalidate its details cache; otherwise a reopen within FRESH_MS paints pre-mutation data. */
|
||||
onMutated: () => void
|
||||
assignees: string[]
|
||||
onUse: (item: GitHubWorkItem) => void
|
||||
}): React.JSX.Element | null {
|
||||
const [labelPopoverOpen, setLabelPopoverOpen] = useState(false)
|
||||
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false)
|
||||
const [localAssignees, setLocalAssignees] = useState<string[]>(assignees)
|
||||
const editedAssigneesItemKeyRef = useRef<string | null>(null)
|
||||
const assigneesItemKey = `${item.repoId}\0${item.id}`
|
||||
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
|
||||
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
|
||||
const repoOwnerSettings = useAppStore(
|
||||
useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null))
|
||||
)
|
||||
const sourceSettings = useMemo(
|
||||
() =>
|
||||
sourceContext?.provider === 'github'
|
||||
? ({
|
||||
...repoOwnerSettings,
|
||||
...getTaskSourceRuntimeSettings(sourceContext)
|
||||
} as typeof repoOwnerSettings)
|
||||
: repoOwnerSettings,
|
||||
[repoOwnerSettings, sourceContext]
|
||||
)
|
||||
const { isPending, run } = useImmediateMutation()
|
||||
// Why: patchWorkItem only updates workItemsCache; Project-view rows also need projectViewCache patched or the table stays stale. See docs/design/github-project-view-tasks.md.
|
||||
const patchProjectRowIfNeeded = useCallback(
|
||||
(patch: Parameters<typeof patchProjectRowContent>[2]) => {
|
||||
if (!projectOrigin) {
|
||||
return
|
||||
}
|
||||
patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, patch)
|
||||
},
|
||||
[projectOrigin, patchProjectRowContent]
|
||||
)
|
||||
|
||||
// Why: with projectOrigin set, read labels/assignees from the row's repo (not workspace path) so popovers match where writes target.
|
||||
const slugOwner = projectOrigin?.owner ?? null
|
||||
const slugRepo = projectOrigin?.repo ?? null
|
||||
const repoLabelsByPath = useRepoLabels(
|
||||
projectOrigin ? null : repoPath,
|
||||
projectOrigin ? null : repoId,
|
||||
sourceSettings
|
||||
)
|
||||
const repoLabelsBySlug = useRepoLabelsBySlug(
|
||||
slugOwner,
|
||||
slugRepo,
|
||||
sourceSettings,
|
||||
projectOrigin?.host
|
||||
)
|
||||
const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath
|
||||
const repoAssigneesByPath = useRepoAssignees(
|
||||
projectOrigin ? null : repoPath,
|
||||
projectOrigin ? null : repoId,
|
||||
sourceSettings
|
||||
)
|
||||
const repoAssigneesBySlug = useRepoAssigneesBySlug(
|
||||
slugOwner,
|
||||
slugRepo,
|
||||
assignees,
|
||||
sourceSettings,
|
||||
projectOrigin?.host
|
||||
)
|
||||
const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath
|
||||
|
||||
// Why: sync local assignees on item change / detail resolve, but skip if the user made an optimistic edit so we don't clobber in-flight changes.
|
||||
useEffect(() => {
|
||||
if (editedAssigneesItemKeyRef.current === assigneesItemKey) {
|
||||
return
|
||||
}
|
||||
setLocalAssignees(assignees)
|
||||
}, [assigneesItemKey, assignees])
|
||||
|
||||
if (item.type === 'pr') {
|
||||
return null
|
||||
}
|
||||
|
||||
const checkIcon = (
|
||||
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M2 6l3 3 5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
|
||||
{/* State */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'group/status inline-flex items-center gap-0.5 rounded-full border px-2 py-0.5 text-[11px] font-medium transition hover:brightness-125 hover:ring-1 hover:ring-white/10',
|
||||
getStateTone({ ...item, state: localState })
|
||||
)}
|
||||
>
|
||||
{getStateLabel({ ...item, state: localState })}
|
||||
<ChevronDown className="size-2.5 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
changeIssueState({
|
||||
newState: 'open',
|
||||
localState,
|
||||
item,
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
run,
|
||||
onStateChange,
|
||||
patchWorkItem,
|
||||
patchProjectRowIfNeeded,
|
||||
onMutated
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
|
||||
localState === 'open' && 'bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<CircleDot className="size-3 text-emerald-500" />
|
||||
{translate('auto.components.PullRequestPage.7b8f6bf6d8', 'Open')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
changeIssueState({
|
||||
newState: 'closed',
|
||||
localState,
|
||||
item,
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
run,
|
||||
onStateChange,
|
||||
patchWorkItem,
|
||||
patchProjectRowIfNeeded,
|
||||
onMutated
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
|
||||
localState === 'closed' && 'bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<CircleDashed className="size-3 text-rose-500" />
|
||||
{translate('auto.components.PullRequestPage.b936cc51a4', 'Closed')}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Labels */}
|
||||
<Popover open={labelPopoverOpen} onOpenChange={setLabelPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending('labels') || repoLabels.loading}
|
||||
className="group/labels inline-flex items-center gap-1 rounded-full border border-border/30 bg-muted/20 px-2 py-0.5 text-[11px] transition hover:brightness-125 hover:ring-1 hover:ring-white/10 disabled:opacity-50"
|
||||
>
|
||||
{localLabels.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{translate('auto.components.PullRequestPage.bc215fea4d', '+ Label')}
|
||||
</span>
|
||||
) : (
|
||||
localLabels.map((name) => (
|
||||
<span key={name} className="text-[10px] text-muted-foreground">
|
||||
{name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
{isPending('labels') ? (
|
||||
<LoaderCircle className="size-3 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="size-2.5 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
|
||||
{repoLabels.error ? (
|
||||
<div className="px-2 py-3 text-center text-[12px] text-destructive">
|
||||
{repoLabels.error}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{repoLabels.data.map((label) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleIssueLabel({
|
||||
label,
|
||||
localLabels,
|
||||
item,
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
run,
|
||||
onLabelsChange,
|
||||
patchWorkItem,
|
||||
patchProjectRowIfNeeded,
|
||||
onMutated
|
||||
})
|
||||
}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-3.5 items-center justify-center rounded-sm border',
|
||||
localLabels.includes(label)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-input'
|
||||
)}
|
||||
>
|
||||
{localLabels.includes(label) && checkIcon}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Assignees */}
|
||||
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending('assignees') || repoAssignees.loading}
|
||||
className="group/assignees inline-flex items-center gap-1 rounded-full border border-border/30 bg-muted/20 px-2 py-0.5 text-[11px] transition hover:brightness-125 hover:ring-1 hover:ring-white/10 disabled:opacity-50"
|
||||
>
|
||||
{localAssignees.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{translate('auto.components.PullRequestPage.14c9fc70ed', '+ Assignee')}
|
||||
</span>
|
||||
) : (
|
||||
localAssignees.map((login) => (
|
||||
<span key={login} className="text-[10px] text-muted-foreground">
|
||||
{login}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
{isPending('assignees') ? (
|
||||
<LoaderCircle className="size-3 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="size-2.5 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
|
||||
{repoAssignees.error ? (
|
||||
<div className="px-2 py-3 text-center text-[12px] text-destructive">
|
||||
{repoAssignees.error}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{repoAssignees.data.map((user) => (
|
||||
<button
|
||||
key={user.login}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleIssueAssignee({
|
||||
login: user.login,
|
||||
localAssignees,
|
||||
assigneesItemKey,
|
||||
editedAssigneesItemKeyRef,
|
||||
item,
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
run,
|
||||
setLocalAssignees,
|
||||
patchProjectRowIfNeeded,
|
||||
onMutated
|
||||
})
|
||||
}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-3.5 items-center justify-center rounded-sm border',
|
||||
localAssignees.includes(user.login)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-input'
|
||||
)}
|
||||
>
|
||||
{localAssignees.includes(user.login) && checkIcon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{user.login}</span>
|
||||
{user.name && (
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{user.name}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onUse(item)}
|
||||
className="ml-auto gap-2"
|
||||
aria-label={translate(
|
||||
'auto.components.PullRequestPage.61452f2143',
|
||||
'Start workspace from issue'
|
||||
)}
|
||||
>
|
||||
{translate('auto.components.PullRequestPage.61452f2143', 'Start workspace from issue')}
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
import { useAppStore } from '@/store'
|
||||
import { DiffSectionItem } from '@/components/editor/DiffSectionItem'
|
||||
import {
|
||||
CombinedDiffFileTree,
|
||||
createCombinedDiffSectionIndexMap,
|
||||
handleCombinedDiffFileTreeNavigation
|
||||
} from '@/components/editor/CombinedDiffFileTree'
|
||||
import {
|
||||
getDiffSectionEstimatedHeight,
|
||||
isIntrinsicHeightImageDiff
|
||||
} from '@/components/editor/diff-section-layout'
|
||||
import type { DiffSection } from '@/components/editor/diff-section-types'
|
||||
import {
|
||||
getCombinedDiffBranchEntriesInTreeOrder,
|
||||
type CombinedDiffFileTreeEntry
|
||||
} from '@/components/editor/combined-diff-file-tree-model'
|
||||
import { PRViewedCheckbox } from '@/components/github/PRViewedCheckbox'
|
||||
import { isPRFileViewed } from '@/components/github/pr-file-content-size'
|
||||
import {
|
||||
PR_DIFF_OVERSCAN,
|
||||
getPRFileSectionKey,
|
||||
gitHubPRFileToBranchEntry,
|
||||
type PRFilesCombinedDiffViewerProps
|
||||
} from '@/components/github/pr-file-diff-mapping'
|
||||
import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key'
|
||||
import type { GitBranchChangeEntry } from '../../../../../shared/git-diff-compare-types'
|
||||
import { prFilesDiffScrollTopCache, prFilesDiffViewStateCache } from '../cache/files-diff-view'
|
||||
import { PRFilesDiffToolbar } from './toolbar'
|
||||
import { addPullRequestLineComment } from './line-comment'
|
||||
import { usePRFileSectionLoader } from './section-loader'
|
||||
import { usePRFilesDiffViewPersistence } from './view-restore'
|
||||
import { buildInlineReviewComments } from './inline-comments'
|
||||
|
||||
export function PRFilesCombinedDiffViewer({
|
||||
files,
|
||||
comments,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
prNumber,
|
||||
prRepo,
|
||||
prUrl,
|
||||
headSha,
|
||||
baseSha,
|
||||
pendingViewedPaths,
|
||||
onCommentAdded,
|
||||
onViewedChange
|
||||
}: PRFilesCombinedDiffViewerProps): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const isDark =
|
||||
settings?.theme === 'dark' ||
|
||||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const entriesCacheRef = useRef<{
|
||||
signature: string
|
||||
entries: GitBranchChangeEntry[]
|
||||
} | null>(null)
|
||||
const diffEntrySignature = useMemo(
|
||||
() =>
|
||||
JSON.stringify(
|
||||
files.map((file) => ({
|
||||
path: file.path,
|
||||
oldPath: file.oldPath ?? null,
|
||||
status: file.status,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
isBinary: file.isBinary
|
||||
}))
|
||||
),
|
||||
[files]
|
||||
)
|
||||
const entries = useMemo(() => {
|
||||
if (entriesCacheRef.current?.signature === diffEntrySignature) {
|
||||
return entriesCacheRef.current.entries
|
||||
}
|
||||
const nextEntries = getCombinedDiffBranchEntriesInTreeOrder(
|
||||
'commit',
|
||||
files.map(gitHubPRFileToBranchEntry)
|
||||
)
|
||||
entriesCacheRef.current = {
|
||||
signature: diffEntrySignature,
|
||||
entries: nextEntries
|
||||
}
|
||||
return nextEntries
|
||||
}, [diffEntrySignature, files])
|
||||
const fileByPath = useMemo(() => new Map(files.map((file) => [file.path, file])), [files])
|
||||
const inlineReviewComments = useMemo(
|
||||
() => buildInlineReviewComments(comments, repoId, prNumber),
|
||||
[comments, prNumber, repoId]
|
||||
)
|
||||
const entrySignature = useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
repoId,
|
||||
prNumber,
|
||||
prRepo: prRepo ? githubRepoIdentityKey(prRepo) : null,
|
||||
headSha: headSha ?? null,
|
||||
baseSha: baseSha ?? null,
|
||||
files: diffEntrySignature
|
||||
}),
|
||||
[baseSha, diffEntrySignature, headSha, prNumber, prRepo, repoId]
|
||||
)
|
||||
const viewStateKey = useMemo(
|
||||
() => [repoId || repoPath, prNumber, prRepo ? githubRepoIdentityKey(prRepo) : ''].join('\0'),
|
||||
[prNumber, prRepo, repoId, repoPath]
|
||||
)
|
||||
const [sections, setSections] = useState<DiffSection[]>([])
|
||||
const [sideBySide, setSideBySide] = useState(false)
|
||||
const [fileTreeCollapsed, setFileTreeCollapsed] = useState(false)
|
||||
const [sectionHeights, setSectionHeights] = useState<Record<number, number>>({})
|
||||
const [activeTreeSectionKey, setActiveTreeSectionKey] = useState<string | null>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const pendingRestoreScrollTopRef = useRef<number | null>(null)
|
||||
const loadedIndicesRef = useRef<Set<number>>(new Set())
|
||||
const loadingIndicesRef = useRef<Set<number>>(new Set())
|
||||
const sectionsRef = useRef<DiffSection[]>([])
|
||||
const generationRef = useRef(0)
|
||||
const modifiedEditorsRef = useRef<Map<number, monacoEditor.IStandaloneCodeEditor>>(new Map())
|
||||
const handleSectionSaveRef = useRef<(index: number) => Promise<void>>(async () => {})
|
||||
sectionsRef.current = sections
|
||||
|
||||
useEffect(() => {
|
||||
// Why: bump generation so stale async diff loads from the previous view can't patch the restored sections.
|
||||
generationRef.current += 1
|
||||
const cached = prFilesDiffViewStateCache.get(viewStateKey)
|
||||
if (cached && cached.entrySignature === entrySignature) {
|
||||
const restoredSections = cached.sections
|
||||
loadedIndicesRef.current = new Set(
|
||||
cached.loadedIndices.filter((index) => !restoredSections[index]?.loading)
|
||||
)
|
||||
loadingIndicesRef.current.clear()
|
||||
setSections(restoredSections)
|
||||
setSectionHeights(cached.sectionHeights)
|
||||
setSideBySide(cached.sideBySide)
|
||||
setFileTreeCollapsed(cached.fileTreeCollapsed)
|
||||
setActiveTreeSectionKey(cached.activeTreeSectionKey)
|
||||
pendingRestoreScrollTopRef.current =
|
||||
prFilesDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
|
||||
return
|
||||
}
|
||||
|
||||
loadedIndicesRef.current.clear()
|
||||
loadingIndicesRef.current.clear()
|
||||
pendingRestoreScrollTopRef.current = prFilesDiffScrollTopCache.get(viewStateKey) ?? null
|
||||
setSectionHeights({})
|
||||
setActiveTreeSectionKey(null)
|
||||
setSections(
|
||||
entries.map((entry) => ({
|
||||
key: getPRFileSectionKey(entry.path),
|
||||
path: entry.path,
|
||||
oldPath: entry.oldPath,
|
||||
status: entry.status,
|
||||
added: entry.added,
|
||||
removed: entry.removed,
|
||||
originalContent: '',
|
||||
modifiedContent: '',
|
||||
collapsed: false,
|
||||
loading: true,
|
||||
error: undefined,
|
||||
dirty: false,
|
||||
diffResult: null,
|
||||
largeDiffRenderLimit: null
|
||||
}))
|
||||
)
|
||||
}, [entries, entrySignature, viewStateKey])
|
||||
|
||||
const { loadSection, retrySection, toggleSection, setAllSectionsCollapsed } =
|
||||
usePRFileSectionLoader({
|
||||
sectionsRef,
|
||||
loadedIndicesRef,
|
||||
loadingIndicesRef,
|
||||
generationRef,
|
||||
fileByPath,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
prNumber,
|
||||
prRepo,
|
||||
headSha,
|
||||
baseSha,
|
||||
setSections,
|
||||
setSectionHeights
|
||||
})
|
||||
|
||||
const allSectionsCollapsed = sections.length > 0 && sections.every((section) => section.collapsed)
|
||||
const sectionIndexByKey = useMemo(() => createCombinedDiffSectionIndexMap(sections), [sections])
|
||||
const viewedSectionKeys = useMemo(
|
||||
() => new Set(files.filter(isPRFileViewed).map((file) => getPRFileSectionKey(file.path))),
|
||||
[files]
|
||||
)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: sections.length,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
estimateSize: (index) => {
|
||||
const section = sections[index]
|
||||
if (!section) {
|
||||
return 88
|
||||
}
|
||||
return getDiffSectionEstimatedHeight({
|
||||
collapsed: section.collapsed,
|
||||
measuredContentHeight: sectionHeights[index],
|
||||
originalContent: section.originalContent,
|
||||
modifiedContent: section.modifiedContent,
|
||||
changedLineCount:
|
||||
section.added === undefined && section.removed === undefined
|
||||
? undefined
|
||||
: (section.added ?? 0) + (section.removed ?? 0),
|
||||
useIntrinsicImageHeight: isIntrinsicHeightImageDiff(section.diffResult),
|
||||
isLargeDiffLimited: section.largeDiffRenderLimit?.limited === true,
|
||||
lineCounts: section.largeDiffRenderLimit?.lineCounts ?? undefined
|
||||
})
|
||||
},
|
||||
overscan: PR_DIFF_OVERSCAN,
|
||||
getItemKey: (index) => {
|
||||
const section = sections[index]
|
||||
return section
|
||||
? `${section.key}:${section.collapsed ? 'collapsed' : 'expanded'}:${entrySignature}`
|
||||
: `${index}:${entrySignature}`
|
||||
}
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
virtualizer.measure()
|
||||
}, [sideBySide, virtualizer])
|
||||
|
||||
usePRFilesDiffViewPersistence({
|
||||
sections,
|
||||
entriesLength: entries.length,
|
||||
viewStateKey,
|
||||
entrySignature,
|
||||
sectionHeights,
|
||||
sideBySide,
|
||||
fileTreeCollapsed,
|
||||
activeTreeSectionKey,
|
||||
loadedIndicesRef,
|
||||
scrollContainerRef,
|
||||
pendingRestoreScrollTopRef
|
||||
})
|
||||
|
||||
const handleTreeNavigate = useCallback(
|
||||
(entry: CombinedDiffFileTreeEntry) => {
|
||||
const navigatedIndex = handleCombinedDiffFileTreeNavigation({
|
||||
mode: 'commit',
|
||||
entry,
|
||||
sections: sectionsRef.current,
|
||||
sectionIndexByKey,
|
||||
toggleSection,
|
||||
scrollToIndex: (index) => virtualizer.scrollToIndex(index, { align: 'start' })
|
||||
})
|
||||
if (navigatedIndex !== null) {
|
||||
setActiveTreeSectionKey(sectionsRef.current[navigatedIndex]?.key ?? null)
|
||||
}
|
||||
},
|
||||
[sectionIndexByKey, toggleSection, virtualizer]
|
||||
)
|
||||
|
||||
const openFilesOnGitHub = useCallback(() => {
|
||||
void window.api.shell.openUrl(`${prUrl.replace(/\/$/, '')}/files`)
|
||||
}, [prUrl])
|
||||
|
||||
const handleAddLineComment = useCallback(
|
||||
async (
|
||||
section: DiffSection,
|
||||
{
|
||||
lineNumber,
|
||||
startLine,
|
||||
body
|
||||
}: {
|
||||
lineNumber: number
|
||||
startLine?: number
|
||||
body: string
|
||||
}
|
||||
) =>
|
||||
addPullRequestLineComment({
|
||||
headSha,
|
||||
section,
|
||||
lineNumber,
|
||||
startLine,
|
||||
body,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
prNumber,
|
||||
prRepo,
|
||||
onCommentAdded
|
||||
}),
|
||||
[headSha, onCommentAdded, prNumber, prRepo, repoId, repoPath, sourceContext]
|
||||
)
|
||||
|
||||
const renderViewedCheckbox = useCallback(
|
||||
(section: DiffSection) => {
|
||||
const file = fileByPath.get(section.path)
|
||||
if (!file) {
|
||||
return null
|
||||
}
|
||||
const viewed = isPRFileViewed(file)
|
||||
const pending = pendingViewedPaths.has(file.path)
|
||||
return (
|
||||
<PRViewedCheckbox
|
||||
checked={viewed}
|
||||
pending={pending}
|
||||
filePath={file.path}
|
||||
onToggle={() => {
|
||||
if (!pending) {
|
||||
void onViewedChange(file.path, !viewed)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[fileByPath, onViewedChange, pendingViewedPaths]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<PRFilesDiffToolbar
|
||||
files={files}
|
||||
fileTreeCollapsed={fileTreeCollapsed}
|
||||
allSectionsCollapsed={allSectionsCollapsed}
|
||||
sideBySide={sideBySide}
|
||||
onShowFileTree={() => setFileTreeCollapsed(false)}
|
||||
onToggleAllCollapsed={() => setAllSectionsCollapsed(!allSectionsCollapsed)}
|
||||
onToggleSideBySide={() => setSideBySide((prev) => !prev)}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<CombinedDiffFileTree
|
||||
mode="commit"
|
||||
worktreePath={repoPath}
|
||||
entries={entries}
|
||||
sectionIndexByKey={sectionIndexByKey}
|
||||
activeSectionKey={activeTreeSectionKey}
|
||||
viewedSectionKeys={viewedSectionKeys}
|
||||
collapsed={fileTreeCollapsed}
|
||||
onCollapsedChange={setFileTreeCollapsed}
|
||||
onNavigate={handleTreeNavigate}
|
||||
/>
|
||||
<div ref={scrollContainerRef} className="min-w-0 flex-1 overflow-auto scrollbar-editor">
|
||||
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const section = sections[virtualItem.index]
|
||||
if (!section) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{ top: `${virtualItem.start}px` }}
|
||||
>
|
||||
<DiffSectionItem
|
||||
section={section}
|
||||
index={virtualItem.index}
|
||||
isBranchMode={false}
|
||||
sideBySide={sideBySide}
|
||||
isDark={isDark}
|
||||
settings={settings}
|
||||
sectionHeight={sectionHeights[virtualItem.index]}
|
||||
worktreeId={`github-pr:${repoId}:${prNumber}`}
|
||||
inlineComments={inlineReviewComments}
|
||||
loadSection={loadSection}
|
||||
retrySection={retrySection}
|
||||
toggleSection={toggleSection}
|
||||
openSection={openFilesOnGitHub}
|
||||
openSectionTitle="Open files on GitHub"
|
||||
renderHeaderTrailingContent={renderViewedCheckbox}
|
||||
onAddLineComment={handleAddLineComment}
|
||||
addLineCommentLabel="Comment"
|
||||
addLineCommentPlaceholder="Add a review comment"
|
||||
getCommentableLineNumbers={(current) =>
|
||||
fileByPath.get(current.path)?.reviewCommentLineNumbers
|
||||
}
|
||||
setSectionHeights={setSectionHeights}
|
||||
setSections={setSections}
|
||||
modifiedEditorsRef={modifiedEditorsRef}
|
||||
handleSectionSaveRef={handleSectionSaveRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DecoratedDiffComment } from '@/components/diff-comments/useDiffCommentDecorator'
|
||||
import { formatRelativeTime } from '@/components/github/work-item-state-presentation'
|
||||
import type { PRComment } from '../../../../../shared/github/comment-types'
|
||||
|
||||
export function buildInlineReviewComments(
|
||||
comments: PRComment[],
|
||||
repoId: string,
|
||||
prNumber: number
|
||||
): DecoratedDiffComment[] {
|
||||
return comments.flatMap((comment): DecoratedDiffComment[] => {
|
||||
// Why: outdated threads keep originalLine for the sidebar, but rendering it inline can attach the comment to unrelated current code.
|
||||
if (comment.isOutdated || !comment.path || typeof comment.line !== 'number') {
|
||||
return []
|
||||
}
|
||||
const createdAtMs = new Date(comment.createdAt).getTime()
|
||||
return [
|
||||
{
|
||||
id: `github-pr-comment:${comment.id}`,
|
||||
worktreeId: `github-pr:${repoId}:${prNumber}`,
|
||||
filePath: comment.path,
|
||||
source: 'diff',
|
||||
startLine: comment.startLine,
|
||||
lineNumber: comment.line,
|
||||
body: comment.body,
|
||||
createdAt: Number.isFinite(createdAtMs) ? createdAtMs : Date.now(),
|
||||
side: 'modified',
|
||||
author: comment.author,
|
||||
authorAvatarUrl: comment.authorAvatarUrl,
|
||||
createdAtLabel: formatRelativeTime(comment.createdAt),
|
||||
url: comment.url,
|
||||
canDelete: false,
|
||||
canEdit: false
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { toast } from 'sonner'
|
||||
import { addPRReviewCommentForRepo } from '@/components/github/github-work-item-comment-mutations'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { DiffSection } from '@/components/editor/diff-section-types'
|
||||
import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
|
||||
import type { PRComment } from '../../../../../shared/github/comment-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
|
||||
export async function addPullRequestLineComment(args: {
|
||||
headSha: string | undefined
|
||||
section: DiffSection
|
||||
lineNumber: number
|
||||
startLine?: number
|
||||
body: string
|
||||
repoPath: string
|
||||
repoId: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
onCommentAdded: (comment: PRComment) => void
|
||||
}): Promise<boolean> {
|
||||
if (!args.headSha) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.d8c3ba91c4',
|
||||
'Unable to comment without the PR head SHA.'
|
||||
)
|
||||
)
|
||||
return false
|
||||
}
|
||||
const result = await addPRReviewCommentForRepo({
|
||||
repoPath: args.repoPath,
|
||||
repoId: args.repoId,
|
||||
sourceContext: args.sourceContext,
|
||||
prNumber: args.prNumber,
|
||||
prRepo: args.prRepo,
|
||||
commitId: args.headSha,
|
||||
path: args.section.path,
|
||||
line: args.lineNumber,
|
||||
startLine: args.startLine,
|
||||
body: args.body
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast.error(
|
||||
result.error ||
|
||||
translate('auto.components.PullRequestPage.19628e058d', 'Failed to add review comment.')
|
||||
)
|
||||
return false
|
||||
}
|
||||
args.onCommentAdded(result.comment)
|
||||
toast.success(translate('auto.components.PullRequestPage.eff839f438', 'Review comment added.'))
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { removeDiffSectionMeasuredHeight } from '@/components/editor/diff-section-height-cache'
|
||||
import {
|
||||
getPRFileContentsRenderLimit,
|
||||
getPRFileDiffResult
|
||||
} from '@/components/github/pr-file-diff-mapping'
|
||||
import {
|
||||
getStoredTextDiffContent,
|
||||
getStoredTextDiffResult
|
||||
} from '@/components/editor/large-diff-section-content'
|
||||
import type { DiffSection } from '@/components/editor/diff-section-types'
|
||||
import type { GitDiffResult } from '../../../../../shared/git-diff-compare-types'
|
||||
import type {
|
||||
GitHubOwnerRepo,
|
||||
GitHubPRFile,
|
||||
GitHubPRFileContents
|
||||
} from '../../../../../shared/github/pull-request-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import { loadPRFileContents } from '../cache/file-content'
|
||||
|
||||
export function usePRFileSectionLoader(args: {
|
||||
sectionsRef: MutableRefObject<DiffSection[]>
|
||||
loadedIndicesRef: MutableRefObject<Set<number>>
|
||||
loadingIndicesRef: MutableRefObject<Set<number>>
|
||||
generationRef: MutableRefObject<number>
|
||||
fileByPath: Map<string, GitHubPRFile>
|
||||
repoPath: string
|
||||
repoId: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
headSha: string | undefined
|
||||
baseSha: string | undefined
|
||||
setSections: Dispatch<SetStateAction<DiffSection[]>>
|
||||
setSectionHeights: Dispatch<SetStateAction<Record<number, number>>>
|
||||
}): {
|
||||
loadSection: (index: number) => void
|
||||
retrySection: (index: number) => void
|
||||
toggleSection: (index: number) => void
|
||||
setAllSectionsCollapsed: (collapsed: boolean) => void
|
||||
} {
|
||||
const loadSection = useCallback(
|
||||
(index: number) => {
|
||||
const section = args.sectionsRef.current[index]
|
||||
if (!section || section.collapsed) {
|
||||
return
|
||||
}
|
||||
if (args.loadedIndicesRef.current.has(index) || args.loadingIndicesRef.current.has(index)) {
|
||||
return
|
||||
}
|
||||
const file = args.fileByPath.get(section.path)
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
const generation = args.generationRef.current
|
||||
args.loadingIndicesRef.current.add(index)
|
||||
|
||||
const load = async (): Promise<{
|
||||
result: GitDiffResult
|
||||
resultContents?: GitHubPRFileContents
|
||||
error?: string
|
||||
}> => {
|
||||
if (file.isBinary) {
|
||||
return {
|
||||
result: {
|
||||
kind: 'binary',
|
||||
originalContent: '',
|
||||
modifiedContent: '',
|
||||
originalIsBinary: true,
|
||||
modifiedIsBinary: true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!args.headSha || !args.baseSha) {
|
||||
return {
|
||||
result: {
|
||||
kind: 'text',
|
||||
originalContent: '',
|
||||
modifiedContent: '',
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false
|
||||
},
|
||||
error: translate(
|
||||
'auto.components.PullRequestPage.74660bd80b',
|
||||
'Diff unavailable because the PR commit SHAs are missing.'
|
||||
)
|
||||
}
|
||||
}
|
||||
const contents = await loadPRFileContents({
|
||||
repoPath: args.repoPath,
|
||||
repoId: args.repoId,
|
||||
sourceContext: args.sourceContext,
|
||||
prNumber: args.prNumber,
|
||||
prRepo: args.prRepo,
|
||||
file,
|
||||
headSha: args.headSha,
|
||||
baseSha: args.baseSha
|
||||
})
|
||||
return { result: getPRFileDiffResult(contents), resultContents: contents }
|
||||
}
|
||||
|
||||
load()
|
||||
.catch((error) => ({
|
||||
result: {
|
||||
kind: 'text',
|
||||
originalContent: '',
|
||||
modifiedContent: '',
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false
|
||||
} as GitDiffResult,
|
||||
resultContents: undefined,
|
||||
error: error instanceof Error ? error.message : 'Failed to load diff.'
|
||||
}))
|
||||
.then(({ result, resultContents, error }) => {
|
||||
args.loadingIndicesRef.current.delete(index)
|
||||
if (args.generationRef.current !== generation) {
|
||||
return
|
||||
}
|
||||
const largeDiffRenderLimit =
|
||||
!error && result.kind === 'text' && resultContents
|
||||
? getPRFileContentsRenderLimit(resultContents)
|
||||
: null
|
||||
const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit)
|
||||
const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit)
|
||||
args.loadedIndicesRef.current.add(index)
|
||||
args.setSections((prev) =>
|
||||
prev.map((current, currentIndex) =>
|
||||
currentIndex === index
|
||||
? {
|
||||
...current,
|
||||
diffResult: storedResult,
|
||||
originalContent: storedContent.originalContent,
|
||||
modifiedContent: storedContent.modifiedContent,
|
||||
loading: false,
|
||||
error,
|
||||
largeDiffRenderLimit
|
||||
}
|
||||
: current
|
||||
)
|
||||
)
|
||||
})
|
||||
},
|
||||
[
|
||||
args.baseSha,
|
||||
args.fileByPath,
|
||||
args.headSha,
|
||||
args.prNumber,
|
||||
args.prRepo,
|
||||
args.repoId,
|
||||
args.repoPath,
|
||||
args.sourceContext,
|
||||
args.generationRef,
|
||||
args.loadedIndicesRef,
|
||||
args.loadingIndicesRef,
|
||||
args.sectionsRef,
|
||||
args.setSections
|
||||
]
|
||||
)
|
||||
|
||||
const retrySection = useCallback(
|
||||
(index: number) => {
|
||||
args.loadedIndicesRef.current.delete(index)
|
||||
args.loadingIndicesRef.current.delete(index)
|
||||
args.setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index))
|
||||
args.setSections((prev) =>
|
||||
prev.map((section, sectionIndex) =>
|
||||
sectionIndex === index
|
||||
? {
|
||||
...section,
|
||||
diffResult: null,
|
||||
originalContent: '',
|
||||
modifiedContent: '',
|
||||
loading: true,
|
||||
error: undefined,
|
||||
largeDiffRenderLimit: null
|
||||
}
|
||||
: section
|
||||
)
|
||||
)
|
||||
loadSection(index)
|
||||
},
|
||||
[
|
||||
args.loadedIndicesRef,
|
||||
args.loadingIndicesRef,
|
||||
args.setSectionHeights,
|
||||
args.setSections,
|
||||
loadSection
|
||||
]
|
||||
)
|
||||
|
||||
const toggleSection = useCallback(
|
||||
(index: number) => {
|
||||
const shouldLoadAfterExpand = args.sectionsRef.current[index]?.collapsed ?? false
|
||||
args.setSections((prev) =>
|
||||
prev.map((section, sectionIndex) =>
|
||||
sectionIndex === index ? { ...section, collapsed: !section.collapsed } : section
|
||||
)
|
||||
)
|
||||
if (shouldLoadAfterExpand) {
|
||||
window.requestAnimationFrame(() => loadSection(index))
|
||||
}
|
||||
},
|
||||
[args.sectionsRef, args.setSections, loadSection]
|
||||
)
|
||||
|
||||
const setAllSectionsCollapsed = useCallback(
|
||||
(collapsed: boolean) => {
|
||||
args.setSections((prev) => prev.map((section) => ({ ...section, collapsed })))
|
||||
if (!collapsed) {
|
||||
window.requestAnimationFrame(() => {
|
||||
args.sectionsRef.current.forEach((_, index) => loadSection(index))
|
||||
})
|
||||
}
|
||||
},
|
||||
[args.sectionsRef, args.setSections, loadSection]
|
||||
)
|
||||
|
||||
return { loadSection, retrySection, toggleSection, setAllSectionsCollapsed }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react'
|
||||
import { PanelLeftOpen } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { isPRFileViewed } from '@/components/github/pr-file-content-size'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubPRFile } from '../../../../../shared/github/pull-request-types'
|
||||
|
||||
export function PRFilesDiffToolbar({
|
||||
files,
|
||||
fileTreeCollapsed,
|
||||
allSectionsCollapsed,
|
||||
sideBySide,
|
||||
onShowFileTree,
|
||||
onToggleAllCollapsed,
|
||||
onToggleSideBySide
|
||||
}: {
|
||||
files: GitHubPRFile[]
|
||||
fileTreeCollapsed: boolean
|
||||
allSectionsCollapsed: boolean
|
||||
sideBySide: boolean
|
||||
onShowFileTree: () => void
|
||||
onToggleAllCollapsed: () => void
|
||||
onToggleSideBySide: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex shrink-0 items-center justify-between gap-3 border-b border-border bg-background px-3 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{fileTreeCollapsed && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.PullRequestPage.319cf2d54b',
|
||||
'Show file tree'
|
||||
)}
|
||||
onClick={onShowFileTree}
|
||||
>
|
||||
<PanelLeftOpen className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate('auto.components.PullRequestPage.319cf2d54b', 'Show file tree')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{files.filter(isPRFileViewed).length} / {files.length}{' '}
|
||||
{translate('auto.components.PullRequestPage.89e80af1c7', 'files viewed')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="w-20 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={onToggleAllCollapsed}
|
||||
>
|
||||
{allSectionsCollapsed
|
||||
? translate('auto.components.PullRequestPage.eb722a5a8c', 'Expand All')
|
||||
: translate('auto.components.PullRequestPage.dd94111c18', 'Collapse All')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-24 rounded border border-border px-2 py-0.5 text-center text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={onToggleSideBySide}
|
||||
>
|
||||
{sideBySide
|
||||
? translate('auto.components.PullRequestPage.e5f4a24f78', 'Inline')
|
||||
: translate('auto.components.PullRequestPage.1378d79e83', 'Side by Side')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useLayoutEffect, type MutableRefObject, type RefObject } from 'react'
|
||||
import { setWithLRU } from '@/lib/scroll-cache'
|
||||
import type { DiffSection } from '@/components/editor/diff-section-types'
|
||||
import { prFilesDiffScrollTopCache, prFilesDiffViewStateCache } from '../cache/files-diff-view'
|
||||
|
||||
export function usePRFilesDiffViewPersistence(args: {
|
||||
sections: DiffSection[]
|
||||
entriesLength: number
|
||||
viewStateKey: string
|
||||
entrySignature: string
|
||||
sectionHeights: Record<number, number>
|
||||
sideBySide: boolean
|
||||
fileTreeCollapsed: boolean
|
||||
activeTreeSectionKey: string | null
|
||||
loadedIndicesRef: MutableRefObject<Set<number>>
|
||||
scrollContainerRef: RefObject<HTMLDivElement | null>
|
||||
pendingRestoreScrollTopRef: MutableRefObject<number | null>
|
||||
}): void {
|
||||
useEffect(() => {
|
||||
if (args.sections.length === 0 && args.entriesLength > 0) {
|
||||
return
|
||||
}
|
||||
const preservedScrollTop =
|
||||
prFilesDiffScrollTopCache.get(args.viewStateKey) ??
|
||||
args.scrollContainerRef.current?.scrollTop ??
|
||||
0
|
||||
setWithLRU(prFilesDiffViewStateCache, args.viewStateKey, {
|
||||
entrySignature: args.entrySignature,
|
||||
sections: args.sections,
|
||||
sectionHeights: args.sectionHeights,
|
||||
loadedIndices: Array.from(args.loadedIndicesRef.current).filter(
|
||||
(index) => !args.sections[index]?.loading
|
||||
),
|
||||
scrollTop: preservedScrollTop,
|
||||
sideBySide: args.sideBySide,
|
||||
fileTreeCollapsed: args.fileTreeCollapsed,
|
||||
activeTreeSectionKey: args.activeTreeSectionKey
|
||||
})
|
||||
}, [
|
||||
args.activeTreeSectionKey,
|
||||
args.entriesLength,
|
||||
args.entrySignature,
|
||||
args.fileTreeCollapsed,
|
||||
args.loadedIndicesRef,
|
||||
args.scrollContainerRef,
|
||||
args.sectionHeights,
|
||||
args.sections,
|
||||
args.sideBySide,
|
||||
args.viewStateKey
|
||||
])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = args.scrollContainerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
const updateCachedScrollPosition = (): void => {
|
||||
const existing = prFilesDiffViewStateCache.get(args.viewStateKey)
|
||||
setWithLRU(prFilesDiffScrollTopCache, args.viewStateKey, container.scrollTop)
|
||||
if (!existing || existing.entrySignature !== args.entrySignature) {
|
||||
return
|
||||
}
|
||||
setWithLRU(prFilesDiffViewStateCache, args.viewStateKey, {
|
||||
...existing,
|
||||
scrollTop: container.scrollTop
|
||||
})
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', updateCachedScrollPosition)
|
||||
return () => {
|
||||
updateCachedScrollPosition()
|
||||
container.removeEventListener('scroll', updateCachedScrollPosition)
|
||||
}
|
||||
}, [args.entrySignature, args.scrollContainerRef, args.viewStateKey])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = args.scrollContainerRef.current
|
||||
const targetScrollTop = args.pendingRestoreScrollTopRef.current
|
||||
if (!container || targetScrollTop === null) {
|
||||
return
|
||||
}
|
||||
|
||||
let frameId = 0
|
||||
let attempts = 0
|
||||
const restoreScrollPosition = (): void => {
|
||||
const liveContainer = args.scrollContainerRef.current
|
||||
const liveTarget = args.pendingRestoreScrollTopRef.current
|
||||
if (!liveContainer || liveTarget === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxScrollTop = Math.max(0, liveContainer.scrollHeight - liveContainer.clientHeight)
|
||||
const nextScrollTop = Math.min(liveTarget, maxScrollTop)
|
||||
liveContainer.scrollTop = nextScrollTop
|
||||
setWithLRU(prFilesDiffScrollTopCache, args.viewStateKey, nextScrollTop)
|
||||
|
||||
if (Math.abs(liveContainer.scrollTop - liveTarget) <= 1 || maxScrollTop >= liveTarget) {
|
||||
args.pendingRestoreScrollTopRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
attempts += 1
|
||||
if (attempts < 30) {
|
||||
frameId = window.requestAnimationFrame(restoreScrollPosition)
|
||||
}
|
||||
}
|
||||
|
||||
restoreScrollPosition()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [
|
||||
args.pendingRestoreScrollTopRef,
|
||||
args.scrollContainerRef,
|
||||
args.sectionHeights,
|
||||
args.sections,
|
||||
args.viewStateKey
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types'
|
||||
import type { PRComment } from '../../../../../shared/github/comment-types'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import { buildMentionOptions } from './options'
|
||||
|
||||
const item = {
|
||||
author: 'octocat',
|
||||
type: 'pr'
|
||||
} as GitHubWorkItem
|
||||
|
||||
function comment(author: string, authorAvatarUrl?: string): PRComment {
|
||||
return { author, authorAvatarUrl } as PRComment
|
||||
}
|
||||
|
||||
function user(login: string, extras: Partial<GitHubAssignableUser> = {}): GitHubAssignableUser {
|
||||
return { login, name: extras.name ?? null, avatarUrl: extras.avatarUrl ?? '' }
|
||||
}
|
||||
|
||||
describe('buildMentionOptions', () => {
|
||||
it('includes the item author, commenters, participants, and assignable users', () => {
|
||||
const options = buildMentionOptions({
|
||||
item,
|
||||
comments: [comment('reviewer', 'https://avatars/reviewer')],
|
||||
participants: [user('helper', { name: 'Helper' })],
|
||||
assignableUsers: [user('teammate')]
|
||||
})
|
||||
|
||||
expect(options.map((option) => option.login)).toEqual([
|
||||
'octocat',
|
||||
'reviewer',
|
||||
'helper',
|
||||
'teammate'
|
||||
])
|
||||
expect(options[0]?.source).toBe('PR author')
|
||||
expect(options[1]?.avatarUrl).toBe('https://avatars/reviewer')
|
||||
expect(options[2]?.name).toBe('Helper')
|
||||
})
|
||||
|
||||
it('skips ghost and fills missing avatar/name on a later duplicate login', () => {
|
||||
const options = buildMentionOptions({
|
||||
item,
|
||||
comments: [comment('ghost'), comment('octocat', 'https://avatars/octocat')],
|
||||
participants: [user('octocat', { name: 'Mona' })],
|
||||
assignableUsers: []
|
||||
})
|
||||
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0]).toMatchObject({
|
||||
login: 'octocat',
|
||||
avatarUrl: 'https://avatars/octocat',
|
||||
name: 'Mona'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types'
|
||||
import type { PRComment } from '../../../../../shared/github/comment-types'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import type { MentionOption } from '../page-types'
|
||||
|
||||
export function buildMentionOptions({
|
||||
item,
|
||||
comments,
|
||||
participants,
|
||||
assignableUsers
|
||||
}: {
|
||||
item: GitHubWorkItem
|
||||
comments: PRComment[]
|
||||
participants: GitHubAssignableUser[]
|
||||
assignableUsers: GitHubAssignableUser[]
|
||||
}): MentionOption[] {
|
||||
const byLogin = new Map<string, MentionOption>()
|
||||
const add = (
|
||||
login: string | null | undefined,
|
||||
source: string,
|
||||
avatarUrl?: string,
|
||||
name?: string | null
|
||||
): void => {
|
||||
if (!login || login === 'ghost') {
|
||||
return
|
||||
}
|
||||
const key = login.toLowerCase()
|
||||
const existing = byLogin.get(key)
|
||||
if (existing) {
|
||||
if (!existing.avatarUrl && avatarUrl) {
|
||||
existing.avatarUrl = avatarUrl
|
||||
}
|
||||
if (!existing.name && name) {
|
||||
existing.name = name
|
||||
}
|
||||
return
|
||||
}
|
||||
byLogin.set(key, { login, source, avatarUrl, name })
|
||||
}
|
||||
|
||||
add(item.author, item.type === 'pr' ? 'PR author' : 'Issue author')
|
||||
for (const comment of comments) {
|
||||
add(comment.author, 'Commenter', comment.authorAvatarUrl)
|
||||
}
|
||||
for (const user of participants) {
|
||||
add(user.login, 'Participant', user.avatarUrl, user.name)
|
||||
}
|
||||
for (const user of assignableUsers) {
|
||||
add(user.login, 'Team member', user.avatarUrl, user.name)
|
||||
}
|
||||
|
||||
return Array.from(byLogin.values())
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { findMentionQuery } from './query'
|
||||
|
||||
describe('findMentionQuery', () => {
|
||||
it('returns the @query immediately before the caret', () => {
|
||||
expect(findMentionQuery('hi @octo', 8)).toEqual({ atIndex: 3, query: 'octo' })
|
||||
})
|
||||
|
||||
it('returns an empty query when the caret is right after @', () => {
|
||||
expect(findMentionQuery('hi @', 4)).toEqual({ atIndex: 3, query: '' })
|
||||
})
|
||||
|
||||
it('returns null when there is no mention token at the caret', () => {
|
||||
expect(findMentionQuery('hello world', 11)).toBeNull()
|
||||
expect(findMentionQuery('email@host', 10)).toBeNull()
|
||||
expect(findMentionQuery('hi @octo more', 13)).toBeNull()
|
||||
})
|
||||
|
||||
it('allows a mention after punctuation that starts a token', () => {
|
||||
expect(findMentionQuery('(@octo', 6)).toEqual({ atIndex: 1, query: 'octo' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MentionQuery } from '../page-types'
|
||||
|
||||
export function findMentionQuery(value: string, caret: number): MentionQuery | null {
|
||||
const beforeCaret = value.slice(0, caret)
|
||||
const match = /(^|[\s([{,])@([A-Za-z0-9-]*)$/.exec(beforeCaret)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const query = match[2] ?? ''
|
||||
return {
|
||||
atIndex: beforeCaret.length - query.length - 1,
|
||||
query
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { filterGitHubMentionOptions } from '@/components/github/github-mention-option-filter'
|
||||
import type { MentionOption, MentionQuery } from '../page-types'
|
||||
import { findMentionQuery } from './query'
|
||||
|
||||
export function MentionTextarea({
|
||||
value,
|
||||
onValueChange,
|
||||
onKeyDown,
|
||||
placeholder,
|
||||
rows,
|
||||
className,
|
||||
wrapperClassName,
|
||||
mentionOptions,
|
||||
textareaRef
|
||||
}: {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
placeholder: string
|
||||
rows: number
|
||||
className?: string
|
||||
wrapperClassName?: string
|
||||
mentionOptions: MentionOption[]
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
}): React.JSX.Element {
|
||||
const [mentionQuery, setMentionQuery] = useState<MentionQuery | null>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const suggestions = useMemo(
|
||||
() => (mentionQuery ? filterGitHubMentionOptions(mentionOptions, mentionQuery.query) : []),
|
||||
[mentionOptions, mentionQuery]
|
||||
)
|
||||
const showSuggestions = mentionQuery !== null && suggestions.length > 0
|
||||
|
||||
const syncMentionQuery = useCallback((textarea: HTMLTextAreaElement): void => {
|
||||
const nextQuery = findMentionQuery(textarea.value, textarea.selectionStart)
|
||||
setMentionQuery(nextQuery)
|
||||
setActiveIndex(0)
|
||||
}, [])
|
||||
|
||||
const insertMention = useCallback(
|
||||
(option: MentionOption): void => {
|
||||
const textarea = textareaRef.current
|
||||
const caret = textarea?.selectionStart ?? value.length
|
||||
const query = textarea ? findMentionQuery(value, caret) : mentionQuery
|
||||
if (!query) {
|
||||
return
|
||||
}
|
||||
const suffix = value[caret] && !/\s/.test(value[caret]) ? ' ' : ''
|
||||
const inserted = `@${option.login}${suffix}`
|
||||
const nextValue = `${value.slice(0, query.atIndex)}${inserted}${value.slice(caret)}`
|
||||
const nextCaret = query.atIndex + inserted.length
|
||||
onValueChange(nextValue)
|
||||
setMentionQuery(null)
|
||||
requestAnimationFrame(() => {
|
||||
textarea?.focus()
|
||||
textarea?.setSelectionRange(nextCaret, nextCaret)
|
||||
})
|
||||
},
|
||||
[mentionQuery, onValueChange, textareaRef, value]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('relative min-w-0 flex-1', wrapperClassName)}>
|
||||
{showSuggestions && (
|
||||
<div className="absolute right-0 bottom-[calc(100%+6px)] left-0 z-50 max-h-64 overflow-y-auto rounded-md border border-border/70 bg-popover p-1 text-popover-foreground shadow-lg scrollbar-sleek">
|
||||
{suggestions.map((option, index) => (
|
||||
<button
|
||||
key={option.login}
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
insertMention(option)
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px]',
|
||||
index === activeIndex && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{option.avatarUrl ? (
|
||||
<img src={option.avatarUrl} alt="" className="size-5 shrink-0 rounded-full" />
|
||||
) : (
|
||||
<div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
{option.login.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
<span className="shrink-0 font-medium">@{option.login}</span>
|
||||
{option.name && (
|
||||
<>
|
||||
<span className="shrink-0 text-muted-foreground">|</span>
|
||||
<span className="truncate text-muted-foreground">{option.name}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground">|</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">{option.source}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
onValueChange(event.target.value)
|
||||
syncMentionQuery(event.currentTarget)
|
||||
}}
|
||||
onClick={(event) => syncMentionQuery(event.currentTarget)}
|
||||
onKeyUp={(event) => {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) {
|
||||
syncMentionQuery(event.currentTarget)
|
||||
}
|
||||
}}
|
||||
onBlur={() => setMentionQuery(null)}
|
||||
onKeyDown={(event) => {
|
||||
if (showSuggestions) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setActiveIndex((current) => (current + 1) % suggestions.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setActiveIndex((current) => (current - 1 + suggestions.length) % suggestions.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
insertMention(suggestions[activeIndex] ?? suggestions[0])
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setMentionQuery(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
onKeyDown?.(event)
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={className}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { GitHubAssignableUser } from '../../../../shared/github/pull-request-types'
|
||||
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
|
||||
import type { TaskSourceContext } from '../../../../shared/task-source-context'
|
||||
import type {
|
||||
GitHubWorkItemProjectOrigin,
|
||||
ItemDialogTab
|
||||
} from '@/components/github/github-work-item-identity'
|
||||
|
||||
export type PullRequestPageProjectOrigin = GitHubWorkItemProjectOrigin
|
||||
|
||||
export type MentionOption = {
|
||||
login: string
|
||||
name?: string | null
|
||||
avatarUrl?: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export type MentionQuery = {
|
||||
atIndex: number
|
||||
query: string
|
||||
}
|
||||
|
||||
export type PullRequestPageProps = {
|
||||
workItem: GitHubWorkItem | null
|
||||
repoPath: string | null
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
initialTab?: ItemDialogTab
|
||||
backLabel?: string
|
||||
/** Called when the user clicks the primary CTA to start work from this item. */
|
||||
onUse: (item: GitHubWorkItem) => void
|
||||
onReviewRequestsChange?: (
|
||||
itemKey: { id: string; repoId: string },
|
||||
reviewRequests: GitHubAssignableUser[]
|
||||
) => void
|
||||
onClose: () => void
|
||||
/** Optional Project-origin context; when set, slug-addressed IPCs route writes to the row's repo instead of `repoPath` (both may be set — slug wins for writes). */
|
||||
projectOrigin?: PullRequestPageProjectOrigin
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FolderKanban,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ButtonGroup } from '@/components/ui/button-group'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { GitHubUserAvatar } from '@/components/github/github-user-avatar'
|
||||
import { parseOwnerRepoFromItemUrl } from '@/components/github/github-work-item-identity'
|
||||
import { formatRelativeTime, getStateLabel } from '@/components/github/work-item-state-presentation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
|
||||
export function PullRequestPageHeader({
|
||||
workItem,
|
||||
displayWorkItem,
|
||||
backLabel,
|
||||
onClose,
|
||||
linkCopied,
|
||||
setLinkCopyButtonRef,
|
||||
onCopyLink,
|
||||
attachedWorkspace,
|
||||
attachedWorkspaceLabel,
|
||||
localState,
|
||||
Icon,
|
||||
onOpenOrUsePR,
|
||||
onUseWorkItem
|
||||
}: {
|
||||
workItem: GitHubWorkItem
|
||||
displayWorkItem: GitHubWorkItem | null
|
||||
backLabel: string
|
||||
onClose: () => void
|
||||
linkCopied: boolean
|
||||
setLinkCopyButtonRef: (node: HTMLButtonElement | null) => void
|
||||
onCopyLink: () => void
|
||||
attachedWorkspace: unknown
|
||||
attachedWorkspaceLabel: string | null
|
||||
localState: GitHubWorkItem['state']
|
||||
Icon: React.ComponentType<{ className?: string }>
|
||||
onOpenOrUsePR: () => void
|
||||
onUseWorkItem: () => void
|
||||
}): React.JSX.Element {
|
||||
const ownerRepo = parseOwnerRepoFromItemUrl(workItem.url)
|
||||
const headBranch = workItem.branchName
|
||||
const baseBranch = workItem.baseRefName
|
||||
const stateBadgeTone =
|
||||
localState === 'merged'
|
||||
? 'bg-purple-600 text-white'
|
||||
: localState === 'draft'
|
||||
? 'bg-slate-500 text-white'
|
||||
: localState === 'closed'
|
||||
? 'bg-rose-600 text-white'
|
||||
: 'bg-emerald-600 text-white'
|
||||
const stateBadgeLabel = getStateLabel({ ...workItem, state: localState })
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Row 1: page header strip — breadcrumb-style row mirroring Primer canvas-subtle */}
|
||||
<div className="flex-none border-b border-border/60 bg-muted/30 px-6 py-2.5">
|
||||
<div className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="-ml-2 h-7 gap-1 px-2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={backLabel}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
{backLabel}
|
||||
</Button>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
{ownerRepo ? (
|
||||
<>
|
||||
<span className="truncate">
|
||||
<span className="text-muted-foreground">{ownerRepo.owner}</span>
|
||||
<span className="mx-1 text-muted-foreground/40">/</span>
|
||||
<span className="font-medium text-foreground">{ownerRepo.repo}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="font-mono text-muted-foreground">#{workItem.number}</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={setLinkCopyButtonRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onCopyLink}
|
||||
aria-label={translate(
|
||||
'auto.components.PullRequestPage.347034903a',
|
||||
'Copy GitHub link'
|
||||
)}
|
||||
>
|
||||
{linkCopied ? (
|
||||
<Check className="size-4 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{linkCopied
|
||||
? translate('auto.components.PullRequestPage.3b6886b2ee', 'Copied')
|
||||
: translate('auto.components.PullRequestPage.347034903a', 'Copy GitHub link')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => window.api.shell.openUrl(workItem.url)}
|
||||
aria-label={translate(
|
||||
'auto.components.PullRequestPage.8ecda455a0',
|
||||
'Open on GitHub'
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate('auto.components.PullRequestPage.8ecda455a0', 'Open on GitHub')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: PR title block — large weight-400 title + state row, mirrors Primer pr-title-block */}
|
||||
<div className="flex-none border-b border-border/60 px-6 py-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<h1 className="min-w-0 flex-1 text-[26px] font-medium leading-snug text-foreground">
|
||||
<span className="break-words">{workItem.title}</span>
|
||||
<span className="ml-2 align-baseline text-[20px] font-normal text-muted-foreground/70">
|
||||
#{workItem.number}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* Why: Orca's signature affordance — keep primary so it stands out against GitHub's familiar surface. */}
|
||||
<DropdownMenu modal={false}>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onOpenOrUsePR}
|
||||
className="w-[180px] justify-center gap-1.5 whitespace-nowrap"
|
||||
aria-label={
|
||||
attachedWorkspace
|
||||
? translate(
|
||||
'auto.components.PullRequestPage.a459866967',
|
||||
'Resume workspace attached to PR'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.PullRequestPage.25690a3855',
|
||||
'Start workspace from PR'
|
||||
)
|
||||
}
|
||||
>
|
||||
{attachedWorkspace
|
||||
? translate('auto.components.PullRequestPage.c9e7094a7b', 'Resume workspace')
|
||||
: translate('auto.components.PullRequestPage.71a3c0f9d2', 'Start workspace')}
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
aria-label={translate(
|
||||
'auto.components.PullRequestPage.57c13a5aa4',
|
||||
'More PR workspace actions'
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</ButtonGroup>
|
||||
<DropdownMenuContent align="end">
|
||||
{attachedWorkspace ? (
|
||||
<DropdownMenuItem onSelect={onUseWorkItem}>
|
||||
<Plus className="size-4" />
|
||||
{translate('auto.components.PullRequestPage.1a2570e18e', 'Start new workspace')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(workItem.url)}>
|
||||
<ExternalLink className="size-4" />
|
||||
{translate('auto.components.PullRequestPage.8ecda455a0', 'Open on GitHub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-2.5 gap-y-2 text-[13px] text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[12px] font-medium',
|
||||
stateBadgeTone
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{stateBadgeLabel}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{workItem.author ? (
|
||||
<GitHubUserAvatar
|
||||
login={workItem.author}
|
||||
avatarUrl={displayWorkItem?.authorAvatarUrl ?? workItem.authorAvatarUrl}
|
||||
className="size-5"
|
||||
/>
|
||||
) : null}
|
||||
<span className="font-semibold text-foreground">
|
||||
{workItem.author ??
|
||||
translate('auto.components.PullRequestPage.77d9388fb0', 'unknown')}
|
||||
</span>
|
||||
</span>
|
||||
{/* Why: base ← head scans faster than prose and matches how reviewers think about merge direction. */}
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
{baseBranch ? (
|
||||
<span className="rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-[12px] text-foreground">
|
||||
{baseBranch}
|
||||
</span>
|
||||
) : (
|
||||
<span className="italic">
|
||||
{translate('auto.components.PullRequestPage.c44b70352b', 'base branch')}
|
||||
</span>
|
||||
)}
|
||||
<ArrowLeft className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
{headBranch ? (
|
||||
<span className="rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-[12px] text-foreground">
|
||||
{headBranch}
|
||||
</span>
|
||||
) : (
|
||||
<span className="italic">
|
||||
{translate('auto.components.PullRequestPage.00b7b82329', 'head branch')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span className="text-muted-foreground/80">
|
||||
{translate('auto.components.PullRequestPage.dd5d9a4f17', 'updated {{value0}}', {
|
||||
value0: formatRelativeTime(workItem.updatedAt)
|
||||
})}
|
||||
</span>
|
||||
{attachedWorkspaceLabel ? (
|
||||
<>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<FolderKanban className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{attachedWorkspaceLabel}</span>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
CircleDot,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
GitPullRequestClosed,
|
||||
GitPullRequestDraft
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useAllWorktrees } from '@/store/selectors'
|
||||
import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context'
|
||||
import {
|
||||
findGithubPrWorkspaceAttachment,
|
||||
getGithubPrWorkspaceAttachmentLabel
|
||||
} from '@/lib/github-work-item-workspace-attachment'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import {
|
||||
clearGitHubLinkCopied,
|
||||
createGitHubLinkCopyState,
|
||||
markGitHubLinkCopied,
|
||||
resolveGitHubLinkCopyState
|
||||
} from '@/components/github-link-copy-state'
|
||||
import { normalizeItemDialogTab } from '@/components/github/github-work-item-identity'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import type { PullRequestPageProps } from '../page-types'
|
||||
import {
|
||||
invalidateWorkItemDetailsCacheByMatch,
|
||||
invalidateWorkItemDetailsCacheForKey,
|
||||
patchCachedPRChecks,
|
||||
patchCachedPRReviewRequests,
|
||||
patchCachedWorkItemBody
|
||||
} from '../cache/work-item-details'
|
||||
import { GHEditSection } from '../edit/section'
|
||||
import { usePullRequestDetails } from './use-details'
|
||||
import { PullRequestPageHeader } from './header'
|
||||
import { PullRequestPageTabs } from './tabs-shell'
|
||||
import { syncPullRequestFileViewed } from './viewed-sync'
|
||||
|
||||
export default function PullRequestPage({
|
||||
workItem,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
initialTab,
|
||||
backLabel = 'Pull requests',
|
||||
projectOrigin,
|
||||
onUse,
|
||||
onReviewRequestsChange,
|
||||
onClose
|
||||
}: PullRequestPageProps): React.JSX.Element {
|
||||
// Why: this component is page-only — the sheet variant lives in GitHubItemDialog.
|
||||
const workItemId = workItem?.id
|
||||
const [tab, setTab] = useState(() => normalizeItemDialogTab(workItem, initialTab))
|
||||
const [localState, setLocalState] = useState<GitHubWorkItem['state']>(workItem?.state ?? 'open')
|
||||
const [localLabels, setLocalLabels] = useState<string[]>(workItem?.labels ?? [])
|
||||
const [linkCopyState, setLinkCopyState] = useState(() => createGitHubLinkCopyState(workItemId))
|
||||
const resolvedLinkCopyState = resolveGitHubLinkCopyState(linkCopyState, workItemId)
|
||||
if (resolvedLinkCopyState !== linkCopyState) {
|
||||
// Why: reconcile before paint so switching items doesn't flash the previous item's copied indicator.
|
||||
setLinkCopyState(resolvedLinkCopyState)
|
||||
}
|
||||
const linkCopied = resolvedLinkCopyState.copied
|
||||
const workItemState = workItem?.state
|
||||
const workItemLabels = workItem?.labels
|
||||
const workItemType = workItem?.type
|
||||
const effectiveRepoId = repoId ?? workItem?.repoId ?? null
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const attachedWorkspace = useMemo(
|
||||
() =>
|
||||
workItem?.type === 'pr'
|
||||
? findGithubPrWorkspaceAttachment(allWorktrees, effectiveRepoId, workItem.number)
|
||||
: null,
|
||||
[allWorktrees, effectiveRepoId, workItem]
|
||||
)
|
||||
const attachedWorkspaceLabel = attachedWorkspace
|
||||
? getGithubPrWorkspaceAttachmentLabel(attachedWorkspace)
|
||||
: null
|
||||
|
||||
// Why: key must include issue source preference so origin/upstream toggles for the same issue number don't read back the wrong repo's details.
|
||||
const issueSourcePreference = useAppStore((s) => {
|
||||
if (!repoPath && !effectiveRepoId) {
|
||||
return undefined
|
||||
}
|
||||
return s.repos.find((r) => (effectiveRepoId ? r.id === effectiveRepoId : r.path === repoPath))
|
||||
?.issueSourcePreference
|
||||
})
|
||||
const canUseDetailsRepoContext = canUseGitHubRepoContext(repoPath, sourceContext)
|
||||
const { details, loading, error, detailsLoaded, detailsCacheKey, appendOptimisticComment } =
|
||||
usePullRequestDetails({
|
||||
workItem,
|
||||
repoPath,
|
||||
effectiveRepoId,
|
||||
sourceContext,
|
||||
issueSourcePreference,
|
||||
canUseDetailsRepoContext
|
||||
})
|
||||
|
||||
// Why: reset lifted edit state on item switch or when the same item gets an optimistic cache patch from the table.
|
||||
useEffect(() => {
|
||||
if (workItemState && workItemLabels) {
|
||||
setLocalState(workItemState)
|
||||
setLocalLabels(workItemLabels)
|
||||
}
|
||||
}, [workItemId, workItemState, workItemLabels])
|
||||
|
||||
useEffect(() => {
|
||||
const nextTab = workItemType === 'pr' ? (initialTab ?? 'conversation') : 'conversation'
|
||||
setTab(nextTab)
|
||||
}, [workItemId, workItemType, initialTab])
|
||||
|
||||
const handleUseWorkItem = useCallback((): void => {
|
||||
if (!workItem) {
|
||||
return
|
||||
}
|
||||
const targetRepoId = effectiveRepoId
|
||||
onUse(
|
||||
targetRepoId && targetRepoId !== workItem.repoId
|
||||
? { ...workItem, repoId: targetRepoId }
|
||||
: workItem
|
||||
)
|
||||
}, [effectiveRepoId, onUse, workItem])
|
||||
|
||||
const handleOpenOrUsePR = useCallback((): void => {
|
||||
if (!workItem) {
|
||||
return
|
||||
}
|
||||
const targetRepoId = effectiveRepoId
|
||||
const currentAttached = findGithubPrWorkspaceAttachment(
|
||||
useAppStore.getState().allWorktrees(),
|
||||
targetRepoId,
|
||||
workItem.number
|
||||
)
|
||||
if (!currentAttached) {
|
||||
handleUseWorkItem()
|
||||
return
|
||||
}
|
||||
|
||||
const result = activateAndRevealWorktree(currentAttached.id)
|
||||
if (result === false) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.61bfc81ada',
|
||||
'Unable to open the workspace attached to this pull request.'
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [effectiveRepoId, handleUseWorkItem, workItem])
|
||||
|
||||
// Why: Radix can leave `pointer-events: none` on <body> when opening right after another overlay closes, killing header clicks; poll a few frames to clear it.
|
||||
useEffect(() => {
|
||||
if (!workItem) {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
let count = 0
|
||||
let frameId: number | null = null
|
||||
const tick = (): void => {
|
||||
frameId = null
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (document.body.style.pointerEvents === 'none') {
|
||||
document.body.style.pointerEvents = ''
|
||||
}
|
||||
if (count++ < 5) {
|
||||
frameId = requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
tick()
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId)
|
||||
}
|
||||
}
|
||||
}, [workItem])
|
||||
|
||||
// Why: icon must track resolved state so a merged PR reads as merged, not as the open-PR glyph.
|
||||
const Icon =
|
||||
workItem?.type === 'pr'
|
||||
? localState === 'merged'
|
||||
? GitMerge
|
||||
: localState === 'closed'
|
||||
? GitPullRequestClosed
|
||||
: localState === 'draft'
|
||||
? GitPullRequestDraft
|
||||
: GitPullRequest
|
||||
: CircleDot
|
||||
const displayWorkItem = useMemo<GitHubWorkItem | null>(() => {
|
||||
if (!workItem) {
|
||||
return null
|
||||
}
|
||||
if (!details?.item) {
|
||||
return workItem
|
||||
}
|
||||
return { ...workItem, ...details.item, repoId: workItem.repoId }
|
||||
}, [details?.item, workItem])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workItem || details?.item.reviewRequests === undefined) {
|
||||
return
|
||||
}
|
||||
// Why: PR details can carry fresher reviewer metadata than the list row; push it back so the Tasks review chip isn't stale.
|
||||
onReviewRequestsChange?.(
|
||||
{ id: workItem.id, repoId: workItem.repoId },
|
||||
details.item.reviewRequests
|
||||
)
|
||||
}, [details?.item.reviewRequests, onReviewRequestsChange, workItem])
|
||||
|
||||
const body = details?.body ?? ''
|
||||
const comments = details?.comments ?? []
|
||||
const files = details?.files ?? []
|
||||
const filesUnavailable = details?.filesUnavailable ?? false
|
||||
const checks = details?.checks ?? []
|
||||
const [pendingViewedPaths, setPendingViewedPaths] = useState<Set<string>>(() => new Set())
|
||||
// Why: clipboard IPC can resolve after unmount; skip copied-state feedback rather than start a reset timer on a stale surface.
|
||||
const linkCopyMountedRef = useRef(false)
|
||||
const linkCopiedResetTimerRef = useRef<number | null>(null)
|
||||
const clearLinkCopiedResetTimer = useCallback((): void => {
|
||||
if (linkCopiedResetTimerRef.current === null) {
|
||||
return
|
||||
}
|
||||
window.clearTimeout(linkCopiedResetTimerRef.current)
|
||||
linkCopiedResetTimerRef.current = null
|
||||
}, [])
|
||||
const setLinkCopyButtonRef = useCallback(
|
||||
(node: HTMLButtonElement | null) => {
|
||||
linkCopyMountedRef.current = node !== null
|
||||
if (node === null) {
|
||||
// Why: clear the copied-state timer on ref detach instead of via a passive cleanup Effect.
|
||||
clearLinkCopiedResetTimer()
|
||||
}
|
||||
},
|
||||
[clearLinkCopiedResetTimer]
|
||||
)
|
||||
|
||||
const handleCopyWorkItemLink = useCallback(async (): Promise<void> => {
|
||||
if (!workItem) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: Electron clipboard IPC works even when browser clipboard APIs lose focus/activation in nested overlays.
|
||||
await window.api.ui.writeClipboardText(workItem.url)
|
||||
if (!linkCopyMountedRef.current) {
|
||||
return
|
||||
}
|
||||
clearLinkCopiedResetTimer()
|
||||
const copiedWorkItemId = workItem.id
|
||||
setLinkCopyState(markGitHubLinkCopied(copiedWorkItemId))
|
||||
linkCopiedResetTimerRef.current = window.setTimeout(() => {
|
||||
linkCopiedResetTimerRef.current = null
|
||||
setLinkCopyState((current) => clearGitHubLinkCopied(current, copiedWorkItemId))
|
||||
}, 1500)
|
||||
toast.success(translate('auto.components.PullRequestPage.992e799227', 'GitHub link copied'))
|
||||
} catch {
|
||||
toast.error(
|
||||
translate('auto.components.PullRequestPage.e0b15c793f', 'Failed to copy GitHub link')
|
||||
)
|
||||
}
|
||||
}, [clearLinkCopiedResetTimer, workItem])
|
||||
|
||||
const invalidateCurrentDetailsCache = useCallback((): void => {
|
||||
if (!workItem) {
|
||||
return
|
||||
}
|
||||
// Why: local repos invalidate all source-preference variants; runtime-only entries need their exact source-scoped key (no local path).
|
||||
if (repoPath) {
|
||||
invalidateWorkItemDetailsCacheByMatch({
|
||||
repoPath,
|
||||
repoId: effectiveRepoId ?? undefined,
|
||||
type: workItem.type,
|
||||
number: workItem.number
|
||||
})
|
||||
return
|
||||
}
|
||||
if (detailsCacheKey) {
|
||||
invalidateWorkItemDetailsCacheForKey(detailsCacheKey)
|
||||
}
|
||||
}, [detailsCacheKey, effectiveRepoId, repoPath, workItem])
|
||||
|
||||
const handlePRFileViewedChange = useCallback(
|
||||
async (path: string, viewed: boolean): Promise<boolean> =>
|
||||
syncPullRequestFileViewed({
|
||||
canUseDetailsRepoContext,
|
||||
details,
|
||||
workItem,
|
||||
path,
|
||||
viewed,
|
||||
detailsCacheKey,
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
setPendingViewedPaths
|
||||
}),
|
||||
[
|
||||
canUseDetailsRepoContext,
|
||||
details,
|
||||
detailsCacheKey,
|
||||
projectOrigin,
|
||||
repoPath,
|
||||
sourceContext,
|
||||
workItem
|
||||
]
|
||||
)
|
||||
|
||||
const content = workItem ? (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<PullRequestPageHeader
|
||||
workItem={workItem}
|
||||
displayWorkItem={displayWorkItem}
|
||||
backLabel={backLabel}
|
||||
onClose={onClose}
|
||||
linkCopied={linkCopied}
|
||||
setLinkCopyButtonRef={setLinkCopyButtonRef}
|
||||
onCopyLink={() => {
|
||||
void handleCopyWorkItemLink()
|
||||
}}
|
||||
attachedWorkspace={attachedWorkspace}
|
||||
attachedWorkspaceLabel={attachedWorkspaceLabel}
|
||||
localState={localState}
|
||||
Icon={Icon}
|
||||
onOpenOrUsePR={handleOpenOrUsePR}
|
||||
onUseWorkItem={handleUseWorkItem}
|
||||
/>
|
||||
|
||||
{(canUseDetailsRepoContext || projectOrigin) && (
|
||||
<GHEditSection
|
||||
item={workItem}
|
||||
repoPath={repoPath}
|
||||
repoId={effectiveRepoId}
|
||||
sourceContext={sourceContext}
|
||||
projectOrigin={projectOrigin}
|
||||
localState={localState}
|
||||
localLabels={localLabels}
|
||||
onStateChange={setLocalState}
|
||||
onLabelsChange={setLocalLabels}
|
||||
onMutated={invalidateCurrentDetailsCache}
|
||||
assignees={details?.assignees ?? []}
|
||||
onUse={onUse}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{error ? (
|
||||
<div className="px-4 py-6 text-[12px] text-destructive">{error}</div>
|
||||
) : (
|
||||
<PullRequestPageTabs
|
||||
tab={tab}
|
||||
onTabChange={setTab}
|
||||
workItem={workItem}
|
||||
displayWorkItem={displayWorkItem}
|
||||
repoPath={repoPath}
|
||||
effectiveRepoId={effectiveRepoId}
|
||||
sourceContext={sourceContext}
|
||||
projectOrigin={projectOrigin}
|
||||
body={body}
|
||||
comments={comments}
|
||||
files={files}
|
||||
filesUnavailable={filesUnavailable}
|
||||
checks={checks}
|
||||
loading={loading}
|
||||
detailsLoaded={detailsLoaded}
|
||||
details={details}
|
||||
localState={localState}
|
||||
setLocalState={setLocalState}
|
||||
detailsCacheKey={detailsCacheKey}
|
||||
pendingViewedPaths={pendingViewedPaths}
|
||||
invalidateCurrentDetailsCache={invalidateCurrentDetailsCache}
|
||||
appendOptimisticComment={appendOptimisticComment}
|
||||
handlePRFileViewedChange={handlePRFileViewedChange}
|
||||
onReviewRequestsChange={onReviewRequestsChange}
|
||||
patchCachedPRChecks={patchCachedPRChecks}
|
||||
patchCachedWorkItemBody={patchCachedWorkItemBody}
|
||||
patchCachedPRReviewRequests={patchCachedPRReviewRequests}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return <div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">{content}</div>
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import React from 'react'
|
||||
import { FileText, ListChecks, LoaderCircle, MessageSquare, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
resolvePullRequestRepo,
|
||||
type ItemDialogTab
|
||||
} from '@/components/github/github-work-item-identity'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type {
|
||||
GitHubAssignableUser,
|
||||
GitHubPRFile
|
||||
} from '../../../../../shared/github/pull-request-types'
|
||||
import type {
|
||||
GitHubWorkItem,
|
||||
GitHubWorkItemDetails
|
||||
} from '../../../../../shared/github/work-item-types'
|
||||
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
|
||||
import type { PRComment } from '../../../../../shared/github/comment-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type { PullRequestPageProjectOrigin } from '../page-types'
|
||||
import { ConversationTab } from '../conversation/tab'
|
||||
import { ChecksTab } from '../checks/tab'
|
||||
import { PRFilesCombinedDiffViewer } from '../files/combined-diff-viewer'
|
||||
|
||||
export function PullRequestPageTabs({
|
||||
tab,
|
||||
onTabChange,
|
||||
workItem,
|
||||
displayWorkItem,
|
||||
repoPath,
|
||||
effectiveRepoId,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
body,
|
||||
comments,
|
||||
files,
|
||||
filesUnavailable,
|
||||
checks,
|
||||
loading,
|
||||
detailsLoaded,
|
||||
details,
|
||||
localState,
|
||||
setLocalState,
|
||||
detailsCacheKey,
|
||||
pendingViewedPaths,
|
||||
invalidateCurrentDetailsCache,
|
||||
appendOptimisticComment,
|
||||
handlePRFileViewedChange,
|
||||
onReviewRequestsChange,
|
||||
patchCachedPRChecks,
|
||||
patchCachedWorkItemBody,
|
||||
patchCachedPRReviewRequests
|
||||
}: {
|
||||
tab: ItemDialogTab
|
||||
onTabChange: (value: ItemDialogTab) => void
|
||||
workItem: GitHubWorkItem
|
||||
displayWorkItem: GitHubWorkItem | null
|
||||
repoPath: string | null
|
||||
effectiveRepoId: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
projectOrigin: PullRequestPageProjectOrigin | undefined
|
||||
body: string
|
||||
comments: PRComment[]
|
||||
files: GitHubPRFile[]
|
||||
filesUnavailable: boolean
|
||||
checks: PRCheckDetail[]
|
||||
loading: boolean
|
||||
detailsLoaded: boolean
|
||||
details: GitHubWorkItemDetails | null
|
||||
localState: GitHubWorkItem['state']
|
||||
setLocalState: (state: GitHubWorkItem['state']) => void
|
||||
detailsCacheKey: string | null
|
||||
pendingViewedPaths: Set<string>
|
||||
invalidateCurrentDetailsCache: () => void
|
||||
appendOptimisticComment: (comment: PRComment) => void
|
||||
handlePRFileViewedChange: (path: string, viewed: boolean) => Promise<boolean>
|
||||
onReviewRequestsChange?: (
|
||||
itemKey: { id: string; repoId: string },
|
||||
reviewRequests: GitHubAssignableUser[]
|
||||
) => void
|
||||
patchCachedPRChecks: (cacheKey: string, checks: PRCheckDetail[]) => void
|
||||
patchCachedWorkItemBody: (cacheKey: string, body: string) => void
|
||||
patchCachedPRReviewRequests: (cacheKey: string, reviewRequests: GitHubAssignableUser[]) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => onTabChange(value as ItemDialogTab)}
|
||||
className="flex h-full min-h-0 flex-col gap-0"
|
||||
>
|
||||
{/* Why: the line variant already underlines the active tab via ::after; a second border would box the trigger. */}
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="mx-0 justify-start gap-2 border-b border-border/60 bg-transparent px-6"
|
||||
>
|
||||
<TabsTrigger value="conversation" className="px-3 py-2.5">
|
||||
<MessageSquare className="size-3.5" />
|
||||
{translate('auto.components.PullRequestPage.9e8d45700e', 'Conversation')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="checks" className="px-3 py-2.5">
|
||||
<ListChecks className="size-3.5" />
|
||||
{translate('auto.components.PullRequestPage.94d95cf1f7', 'Checks')}
|
||||
{checks.length > 0 && (
|
||||
<span className="ml-1 rounded-full bg-muted px-1.5 text-[10px] text-muted-foreground">
|
||||
{checks.length}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="files" className="px-3 py-2.5">
|
||||
<FileText className="size-3.5" />
|
||||
{translate('auto.components.PullRequestPage.4d18310d55', 'Files changed')}
|
||||
{files.length > 0 && (
|
||||
<span className="ml-1 rounded-full bg-muted px-1.5 text-[10px] text-muted-foreground">
|
||||
{files.length}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
|
||||
<TabsContent value="conversation" className="mt-0">
|
||||
<ConversationTab
|
||||
item={displayWorkItem ?? workItem}
|
||||
repoPath={repoPath}
|
||||
repoId={effectiveRepoId}
|
||||
sourceContext={sourceContext}
|
||||
body={body}
|
||||
comments={comments}
|
||||
files={files}
|
||||
headSha={details?.headSha}
|
||||
baseSha={details?.baseSha}
|
||||
loading={loading}
|
||||
detailsLoaded={detailsLoaded}
|
||||
checks={checks}
|
||||
participants={details?.participants ?? []}
|
||||
localState={localState}
|
||||
onStateChange={setLocalState}
|
||||
projectOrigin={projectOrigin}
|
||||
onMutated={invalidateCurrentDetailsCache}
|
||||
onChecksUpdated={(nextChecks) => {
|
||||
if (detailsCacheKey) {
|
||||
patchCachedPRChecks(detailsCacheKey, nextChecks)
|
||||
}
|
||||
}}
|
||||
onBodyUpdated={(nextBody) => {
|
||||
if (detailsCacheKey) {
|
||||
patchCachedWorkItemBody(detailsCacheKey, nextBody)
|
||||
}
|
||||
}}
|
||||
onCommentAdded={appendOptimisticComment}
|
||||
onReviewersRequested={(nextReviewRequests) => {
|
||||
if (detailsCacheKey) {
|
||||
patchCachedPRReviewRequests(detailsCacheKey, nextReviewRequests)
|
||||
}
|
||||
onReviewRequestsChange?.(
|
||||
{ id: workItem.id, repoId: workItem.repoId },
|
||||
nextReviewRequests
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="checks" className="mt-0">
|
||||
<ChecksTab
|
||||
item={workItem}
|
||||
repoPath={repoPath}
|
||||
repoId={effectiveRepoId}
|
||||
sourceContext={sourceContext}
|
||||
headSha={details?.headSha}
|
||||
checks={checks}
|
||||
loading={loading || !detailsLoaded}
|
||||
variant="page"
|
||||
onChecksUpdated={(nextChecks) => {
|
||||
if (detailsCacheKey) {
|
||||
patchCachedPRChecks(detailsCacheKey, nextChecks)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" className="mt-0 h-full min-h-0 overflow-hidden">
|
||||
{loading && files.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filesUnavailable && files.length === 0 ? (
|
||||
// Why: fetch failed (rate limit/auth/unresolved remote); offer retry instead of implying the PR is empty.
|
||||
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.PullRequestPage.filesUnavailable',
|
||||
"Couldn't load changed files."
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={invalidateCurrentDetailsCache}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{translate('auto.components.PullRequestPage.filesRetry', 'Retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-[12px] text-muted-foreground">
|
||||
{translate('auto.components.PullRequestPage.6ad2c1ab9c', 'No files changed.')}
|
||||
</div>
|
||||
) : (
|
||||
<PRFilesCombinedDiffViewer
|
||||
files={files}
|
||||
comments={comments}
|
||||
repoPath={repoPath ?? ''}
|
||||
repoId={effectiveRepoId ?? ''}
|
||||
sourceContext={sourceContext}
|
||||
prNumber={workItem.number}
|
||||
prRepo={resolvePullRequestRepo(workItem, projectOrigin)}
|
||||
prUrl={workItem.url}
|
||||
headSha={details?.headSha}
|
||||
baseSha={details?.baseSha}
|
||||
pendingViewedPaths={pendingViewedPaths}
|
||||
onCommentAdded={appendOptimisticComment}
|
||||
onViewedChange={handlePRFileViewedChange}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup'
|
||||
import { getTaskSourceCacheScope } from '../../../../../shared/task-source-context'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type {
|
||||
GitHubWorkItem,
|
||||
GitHubWorkItemDetails
|
||||
} from '../../../../../shared/github/work-item-types'
|
||||
import type { PRComment } from '../../../../../shared/github/comment-types'
|
||||
import {
|
||||
WORK_ITEM_DETAILS_FRESH_MS,
|
||||
WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE,
|
||||
getWorkItemDetailsCacheKey,
|
||||
subscribeWorkItemDetailsCache,
|
||||
touchWorkItemDetailsCache,
|
||||
workItemDetailsCache,
|
||||
workItemDetailsCacheGeneration
|
||||
} from '../cache/work-item-details'
|
||||
|
||||
export function usePullRequestDetails(args: {
|
||||
workItem: GitHubWorkItem | null
|
||||
repoPath: string | null
|
||||
effectiveRepoId: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
issueSourcePreference: string | undefined
|
||||
canUseDetailsRepoContext: boolean
|
||||
}): {
|
||||
details: GitHubWorkItemDetails | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
detailsLoaded: boolean
|
||||
detailsCacheKey: string | null
|
||||
appendOptimisticComment: (comment: PRComment) => void
|
||||
} {
|
||||
const {
|
||||
workItem,
|
||||
repoPath,
|
||||
effectiveRepoId,
|
||||
sourceContext,
|
||||
issueSourcePreference,
|
||||
canUseDetailsRepoContext
|
||||
} = args
|
||||
const detailsCacheKey = useMemo(() => {
|
||||
if (!workItem || !effectiveRepoId || !canUseDetailsRepoContext) {
|
||||
return null
|
||||
}
|
||||
return getWorkItemDetailsCacheKey({
|
||||
repoPath: repoPath ?? '',
|
||||
repoId: effectiveRepoId,
|
||||
issueSourcePreference,
|
||||
sourceCacheScope:
|
||||
sourceContext?.provider === 'github' ? getTaskSourceCacheScope(sourceContext) : null,
|
||||
type: workItem.type,
|
||||
number: workItem.number
|
||||
})
|
||||
}, [
|
||||
canUseDetailsRepoContext,
|
||||
repoPath,
|
||||
effectiveRepoId,
|
||||
sourceContext,
|
||||
workItem,
|
||||
issueSourcePreference
|
||||
])
|
||||
|
||||
// Why: hold optimistically-added comments so they merge into the fetch result instead of being overwritten.
|
||||
const optimisticCommentsRef = useRef<PRComment[]>([])
|
||||
// Why: track last fetched item to distinguish reopen from switch — reopen must preserve optimistic comments since gh's 60s cache omits the just-posted one.
|
||||
const prevItemIdRef = useRef<string | null>(null)
|
||||
|
||||
// Why: subscribe to the module cache so reopening a cached item paints synchronously; writes replace entry identity (delete+set), so Map.get is a stable snapshot.
|
||||
const cachedEntry = useSyncExternalStore(
|
||||
subscribeWorkItemDetailsCache,
|
||||
useCallback(
|
||||
() => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined),
|
||||
[detailsCacheKey]
|
||||
)
|
||||
)
|
||||
|
||||
// Why: bumped on cold open (no cached details) so the details memo re-runs and surfaces the optimistic comment via the loading shell; cache-notify handles the warm case.
|
||||
const [optimisticTick, setOptimisticTick] = useState(0)
|
||||
|
||||
// Why: merge optimistic comments into cached details; keyed off stable cachedEntry identity (not the per-render ref array) to avoid needless recompute.
|
||||
const details = useMemo<GitHubWorkItemDetails | null>(() => {
|
||||
const cachedDetails = cachedEntry?.details ?? null
|
||||
const opt = optimisticCommentsRef.current
|
||||
if (!cachedDetails) {
|
||||
// Why: on a cold open details may still be loading; surface optimistic comments via a minimal shell so a just-posted comment isn't held invisibly in the ref.
|
||||
if (opt.length > 0 && workItem) {
|
||||
return { item: workItem, body: '', comments: [...opt] }
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (opt.length === 0) {
|
||||
return cachedDetails
|
||||
}
|
||||
const ids = new Set(cachedDetails.comments.map((c) => c.id))
|
||||
const missing = opt.filter((c) => !ids.has(c.id))
|
||||
if (missing.length === 0) {
|
||||
return cachedDetails
|
||||
}
|
||||
return {
|
||||
...cachedDetails,
|
||||
comments: [...cachedDetails.comments, ...missing]
|
||||
}
|
||||
// Why: optimisticTick isn't read in the body but is the rerender signal for cold-open writes (memo reads a ref); removing it breaks the optimistic shell.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cachedEntry, workItem, optimisticTick])
|
||||
|
||||
const loading = !!cachedEntry?.pending && !cachedEntry?.details
|
||||
const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null
|
||||
const detailsLoaded = Boolean(cachedEntry?.details)
|
||||
|
||||
// Why: if a cross-window mutation invalidates the open drawer's entry (cachedEntry undefined, fetch deps unchanged), bump a tick so it refetches.
|
||||
const [refetchTick, setRefetchTick] = useState(0)
|
||||
useEffect(() => {
|
||||
if (workItem && detailsCacheKey && !cachedEntry) {
|
||||
setRefetchTick((n) => n + 1)
|
||||
}
|
||||
}, [workItem, detailsCacheKey, cachedEntry])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workItem || !effectiveRepoId || !detailsCacheKey || !canUseDetailsRepoContext) {
|
||||
return
|
||||
}
|
||||
// Why: only clear optimistic comments on a genuine item switch; on reopen gh's 60s cache omits the just-posted comment, so preserve the ref for re-merge.
|
||||
if (workItem.id !== prevItemIdRef.current) {
|
||||
optimisticCommentsRef.current = []
|
||||
}
|
||||
prevItemIdRef.current = workItem.id
|
||||
|
||||
const cached = workItemDetailsCache.get(detailsCacheKey)
|
||||
const now = Date.now()
|
||||
const hasFreshData = cached?.details && now - cached.fetchedAt <= WORK_ITEM_DETAILS_FRESH_MS
|
||||
|
||||
if (hasFreshData) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: dedupe concurrent opens on the same key so a rapid close→reopen shares one in-flight promise instead of racing two `gh` subprocesses.
|
||||
const inflight: Promise<GitHubWorkItemDetails | null> =
|
||||
cached?.pending ??
|
||||
lookupGitHubWorkItemDetailsForSource({
|
||||
repoPath: repoPath ?? '',
|
||||
repoId: effectiveRepoId,
|
||||
sourceContext,
|
||||
number: workItem.number,
|
||||
type: workItem.type
|
||||
})
|
||||
|
||||
// Why: snapshot the generation so a mid-flight invalidation (generation advance) blocks writing a stale result back.
|
||||
const launchedAtGeneration = workItemDetailsCacheGeneration.current
|
||||
|
||||
if (!cached?.pending) {
|
||||
touchWorkItemDetailsCache(detailsCacheKey, {
|
||||
details: cached?.details ?? null,
|
||||
fetchedAt: cached?.fetchedAt ?? 0,
|
||||
pending: inflight,
|
||||
error: cached?.error
|
||||
})
|
||||
}
|
||||
|
||||
inflight
|
||||
.then((result) => {
|
||||
const invalidatedMidFlight = workItemDetailsCacheGeneration.current !== launchedAtGeneration
|
||||
const prev = workItemDetailsCache.get(detailsCacheKey)
|
||||
if (invalidatedMidFlight && prev?.pending !== inflight) {
|
||||
// Why: entry was deliberately dropped (or later repopulated) — don't recreate or clobber it.
|
||||
return
|
||||
}
|
||||
// Why: null means unavailable/not found, not loaded empty content.
|
||||
if (result === null && prev?.details) {
|
||||
touchWorkItemDetailsCache(detailsCacheKey, {
|
||||
details: prev.details,
|
||||
fetchedAt: prev.fetchedAt,
|
||||
error: undefined
|
||||
})
|
||||
} else if (result === null) {
|
||||
touchWorkItemDetailsCache(detailsCacheKey, {
|
||||
details: null,
|
||||
fetchedAt: 0,
|
||||
error: WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE
|
||||
})
|
||||
} else {
|
||||
touchWorkItemDetailsCache(detailsCacheKey, {
|
||||
details: result,
|
||||
fetchedAt: Date.now(),
|
||||
error: undefined
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load details'
|
||||
const invalidatedMidFlight = workItemDetailsCacheGeneration.current !== launchedAtGeneration
|
||||
const prev = workItemDetailsCache.get(detailsCacheKey)
|
||||
if (invalidatedMidFlight && prev?.pending !== inflight) {
|
||||
return
|
||||
}
|
||||
// Why: stale-on-error — keep cached data, drop the pending promise so next open retries; surface the error only when nothing is cached.
|
||||
touchWorkItemDetailsCache(detailsCacheKey, {
|
||||
details: prev?.details ?? null,
|
||||
fetchedAt: prev?.fetchedAt ?? 0,
|
||||
error: message
|
||||
})
|
||||
})
|
||||
}, [
|
||||
canUseDetailsRepoContext,
|
||||
repoPath,
|
||||
effectiveRepoId,
|
||||
sourceContext,
|
||||
workItem,
|
||||
detailsCacheKey,
|
||||
refetchTick
|
||||
])
|
||||
|
||||
const appendOptimisticComment = useCallback(
|
||||
(comment: PRComment) => {
|
||||
// Why: skip refreshDetails() — gh api --cache 60s returns stale data that would overwrite the optimistic comment.
|
||||
optimisticCommentsRef.current.push(comment)
|
||||
// Why: write through the shared cache so subscribers re-render; fetchedAt=0 forces a background refresh next open for server-side fields.
|
||||
if (detailsCacheKey) {
|
||||
const prev = workItemDetailsCache.get(detailsCacheKey)
|
||||
if (prev?.details) {
|
||||
const ids = new Set(prev.details.comments.map((c) => c.id))
|
||||
if (!ids.has(comment.id)) {
|
||||
touchWorkItemDetailsCache(detailsCacheKey, {
|
||||
details: {
|
||||
...prev.details,
|
||||
comments: [...prev.details.comments, comment]
|
||||
},
|
||||
fetchedAt: 0,
|
||||
error: undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: cache empty (still loading) so no write/notify above; bump local state so the memo re-runs and surfaces the optimistic comment.
|
||||
setOptimisticTick((n) => n + 1)
|
||||
},
|
||||
[detailsCacheKey]
|
||||
)
|
||||
|
||||
return { details, loading, error, detailsLoaded, detailsCacheKey, appendOptimisticComment }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { toast } from 'sonner'
|
||||
import { setPRFileViewedForRepo } from '@/components/github/github-work-item-comment-mutations'
|
||||
import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubPRFileViewedState } from '../../../../../shared/github/pull-request-types'
|
||||
import type {
|
||||
GitHubWorkItem,
|
||||
GitHubWorkItemDetails
|
||||
} from '../../../../../shared/github/work-item-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type { PullRequestPageProjectOrigin } from '../page-types'
|
||||
import { patchCachedPRFileViewedState } from '../cache/work-item-details'
|
||||
|
||||
export async function syncPullRequestFileViewed(args: {
|
||||
canUseDetailsRepoContext: boolean
|
||||
details: GitHubWorkItemDetails | null
|
||||
workItem: GitHubWorkItem | null
|
||||
path: string
|
||||
viewed: boolean
|
||||
detailsCacheKey: string | null
|
||||
repoPath: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
projectOrigin: PullRequestPageProjectOrigin | undefined
|
||||
setPendingViewedPaths: (updater: (prev: Set<string>) => Set<string>) => void
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
!args.canUseDetailsRepoContext ||
|
||||
!args.details?.pullRequestId ||
|
||||
!args.workItem ||
|
||||
args.workItem.type !== 'pr'
|
||||
) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.996a1897d2',
|
||||
'Unable to sync viewed state for this pull request.'
|
||||
)
|
||||
)
|
||||
return false
|
||||
}
|
||||
args.setPendingViewedPaths((prev) => new Set(prev).add(args.path))
|
||||
const nextState: GitHubPRFileViewedState = args.viewed ? 'VIEWED' : 'UNVIEWED'
|
||||
const previousState = args.detailsCacheKey
|
||||
? patchCachedPRFileViewedState(args.detailsCacheKey, args.path, nextState)
|
||||
: undefined
|
||||
try {
|
||||
const ok = await setPRFileViewedForRepo({
|
||||
repoId: args.workItem.repoId,
|
||||
repoPath: args.repoPath ?? '',
|
||||
sourceContext: args.sourceContext,
|
||||
prNumber: args.workItem.number,
|
||||
prRepo: resolvePullRequestRepo(args.workItem, args.projectOrigin),
|
||||
pullRequestId: args.details.pullRequestId,
|
||||
path: args.path,
|
||||
viewed: args.viewed
|
||||
})
|
||||
if (!ok) {
|
||||
if (args.detailsCacheKey && previousState) {
|
||||
patchCachedPRFileViewedState(args.detailsCacheKey, args.path, previousState)
|
||||
}
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.5a01ca7253',
|
||||
'Failed to sync viewed state with GitHub.'
|
||||
)
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} finally {
|
||||
args.setPendingViewedPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(args.path)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import { getStateTone } from './state-badge'
|
||||
|
||||
function item(type: GitHubWorkItem['type'], state: GitHubWorkItem['state']): GitHubWorkItem {
|
||||
return { type, state } as GitHubWorkItem
|
||||
}
|
||||
|
||||
describe('getStateTone', () => {
|
||||
it('uses purple/slate/rose/emerald for pull request states', () => {
|
||||
expect(getStateTone(item('pr', 'merged'))).toContain('purple')
|
||||
expect(getStateTone(item('pr', 'draft'))).toContain('slate')
|
||||
expect(getStateTone(item('pr', 'closed'))).toContain('rose')
|
||||
expect(getStateTone(item('pr', 'open'))).toContain('emerald')
|
||||
})
|
||||
|
||||
it('uses rose for a closed issue and emerald otherwise', () => {
|
||||
expect(getStateTone(item('issue', 'closed'))).toContain('rose')
|
||||
expect(getStateTone(item('issue', 'open'))).toContain('emerald')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getStateLabel } from '@/components/github/work-item-state-presentation'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
|
||||
export function getStateTone(item: GitHubWorkItem): string {
|
||||
if (item.type === 'pr') {
|
||||
if (item.state === 'merged') {
|
||||
return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300'
|
||||
}
|
||||
if (item.state === 'draft') {
|
||||
return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300'
|
||||
}
|
||||
if (item.state === 'closed') {
|
||||
return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300'
|
||||
}
|
||||
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
|
||||
}
|
||||
if (item.state === 'closed') {
|
||||
return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300'
|
||||
}
|
||||
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
|
||||
}
|
||||
|
||||
export function WorkItemStateBadge({
|
||||
item,
|
||||
className
|
||||
}: {
|
||||
item: GitHubWorkItem
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-5 items-center rounded-full border px-2 text-[11px] font-medium',
|
||||
getStateTone(item),
|
||||
className
|
||||
)}
|
||||
>
|
||||
{getStateLabel(item)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRepoAssignees } from '@/hooks/useIssueMetadata'
|
||||
import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
|
||||
import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
|
||||
import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity'
|
||||
import { getGitHubPRReviewerRows } from '@/components/github-pr-reviewer-display'
|
||||
import { mergeReviewerSuggestions } from '@/components/github/work-item-state-presentation'
|
||||
import {
|
||||
filterGitHubPRReviewerCandidates,
|
||||
getGitHubPRReviewerQueryState
|
||||
} from '@/components/github/github-pr-reviewer-candidate-filter'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { PullRequestPageProjectOrigin } from '../page-types'
|
||||
import { createReviewerRequestActions } from './request-actions'
|
||||
import { ReviewerPicker } from './picker'
|
||||
import { ReviewerRequestedList } from './requested-list'
|
||||
|
||||
export function PRReviewersPanel({
|
||||
item,
|
||||
loading,
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
onReviewersRequested
|
||||
}: {
|
||||
item: GitHubWorkItem
|
||||
loading: boolean
|
||||
repoPath: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
projectOrigin?: PullRequestPageProjectOrigin
|
||||
onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [reviewerInput, setReviewerInput] = useState('')
|
||||
const [activeReviewerCursor, setActiveReviewerCursor] = useState({
|
||||
resetKey: '',
|
||||
index: 0
|
||||
})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [localReviewRequests, setLocalReviewRequests] = useState<GitHubAssignableUser[]>(
|
||||
() => item.reviewRequests ?? []
|
||||
)
|
||||
const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({
|
||||
itemId: item.id,
|
||||
repoId: item.repoId,
|
||||
reviewRequests: item.reviewRequests
|
||||
}))
|
||||
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
|
||||
const repoOwnerSettings = useAppStore(
|
||||
useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null))
|
||||
)
|
||||
const sourceSettings = useMemo(
|
||||
() =>
|
||||
sourceContext?.provider === 'github'
|
||||
? ({
|
||||
...repoOwnerSettings,
|
||||
...getTaskSourceRuntimeSettings(sourceContext)
|
||||
} as typeof repoOwnerSettings)
|
||||
: repoOwnerSettings,
|
||||
[repoOwnerSettings, sourceContext]
|
||||
)
|
||||
const reviewerInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const reviewerInputFocusFrameRef = useRef<number | null>(null)
|
||||
const reviewerPanelMountedRef = useRef(true)
|
||||
|
||||
const cancelReviewerInputFocusFrame = useCallback((): void => {
|
||||
if (reviewerInputFocusFrameRef.current !== null) {
|
||||
cancelAnimationFrame(reviewerInputFocusFrameRef.current)
|
||||
reviewerInputFocusFrameRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scheduleReviewerInputFocus = useCallback((): void => {
|
||||
if (!reviewerPanelMountedRef.current) {
|
||||
return
|
||||
}
|
||||
cancelReviewerInputFocusFrame()
|
||||
reviewerInputFocusFrameRef.current = requestAnimationFrame(() => {
|
||||
reviewerInputFocusFrameRef.current = null
|
||||
reviewerInputRef.current?.focus()
|
||||
})
|
||||
}, [cancelReviewerInputFocusFrame])
|
||||
|
||||
useEffect(() => {
|
||||
reviewerPanelMountedRef.current = true
|
||||
return () => {
|
||||
reviewerPanelMountedRef.current = false
|
||||
cancelReviewerInputFocusFrame()
|
||||
}
|
||||
}, [cancelReviewerInputFocusFrame])
|
||||
|
||||
// Why: clear stale optimistic review requests before paint on item switch/refetch (a passive Effect leaves one stale render).
|
||||
if (
|
||||
reviewRequestsSource.itemId !== item.id ||
|
||||
reviewRequestsSource.repoId !== item.repoId ||
|
||||
reviewRequestsSource.reviewRequests !== item.reviewRequests
|
||||
) {
|
||||
setReviewRequestsSource({
|
||||
itemId: item.id,
|
||||
repoId: item.repoId,
|
||||
reviewRequests: item.reviewRequests
|
||||
})
|
||||
setLocalReviewRequests(item.reviewRequests ?? [])
|
||||
}
|
||||
|
||||
const reviewerSeedUsers = useMemo<GitHubAssignableUser[]>(() => {
|
||||
const byLogin = new Map<string, GitHubAssignableUser>()
|
||||
const add = (user: GitHubAssignableUser): void => {
|
||||
if (!user.login) {
|
||||
return
|
||||
}
|
||||
byLogin.set(user.login.toLowerCase(), user)
|
||||
}
|
||||
for (const user of localReviewRequests) {
|
||||
add(user)
|
||||
}
|
||||
for (const review of item.latestReviews ?? []) {
|
||||
add({
|
||||
login: review.login,
|
||||
name: null,
|
||||
avatarUrl: review.avatarUrl ?? ''
|
||||
})
|
||||
}
|
||||
if (item.author) {
|
||||
add({ login: item.author, name: null, avatarUrl: '' })
|
||||
}
|
||||
return Array.from(byLogin.values())
|
||||
}, [item.author, item.latestReviews, localReviewRequests])
|
||||
|
||||
const reviewRepo = useMemo(
|
||||
() => resolvePullRequestRepo(item, projectOrigin),
|
||||
[item, projectOrigin]
|
||||
)
|
||||
const reviewerMetadataBySlug = useRepoAssigneesBySlug(
|
||||
open && reviewRepo ? reviewRepo.owner : null,
|
||||
open && reviewRepo ? reviewRepo.repo : null,
|
||||
reviewerSeedUsers.map((user) => user.login),
|
||||
sourceSettings,
|
||||
reviewRepo?.host
|
||||
)
|
||||
const reviewerMetadataByPath = useRepoAssignees(
|
||||
open && !reviewRepo ? repoPath : null,
|
||||
open && !reviewRepo ? item.repoId : null,
|
||||
sourceSettings
|
||||
)
|
||||
const reviewerMetadata = reviewRepo ? reviewerMetadataBySlug : reviewerMetadataByPath
|
||||
const displayItem = { ...item, reviewRequests: localReviewRequests }
|
||||
const reviewers = getGitHubPRReviewerRows(displayItem)
|
||||
const authorLogin = item.author?.toLowerCase() ?? null
|
||||
const reviewerCandidates = useMemo(
|
||||
() =>
|
||||
mergeReviewerSuggestions(reviewerMetadata.data, reviewerSeedUsers).filter(
|
||||
(user) => user.login.toLowerCase() !== authorLogin
|
||||
),
|
||||
[authorLogin, reviewerMetadata.data, reviewerSeedUsers]
|
||||
)
|
||||
const reviewerCandidatesByLogin = useMemo(
|
||||
() => new Map(reviewerCandidates.map((user) => [user.login.toLowerCase(), user])),
|
||||
[reviewerCandidates]
|
||||
)
|
||||
const selectedReviewerLogins = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
localReviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean)
|
||||
),
|
||||
[localReviewRequests]
|
||||
)
|
||||
const reviewerQueryState = useMemo(
|
||||
() => getGitHubPRReviewerQueryState(reviewerInput),
|
||||
[reviewerInput]
|
||||
)
|
||||
const reviewerQuery = reviewerQueryState.query
|
||||
const filteredReviewerCandidates = useMemo(
|
||||
() =>
|
||||
filterGitHubPRReviewerCandidates({
|
||||
candidates: reviewerCandidates,
|
||||
queryState: reviewerQueryState
|
||||
}),
|
||||
[reviewerCandidates, reviewerQueryState]
|
||||
)
|
||||
const suggestedReviewerRows = useMemo(
|
||||
() =>
|
||||
reviewerQuery.length === 0 && !reviewerQueryState.isTooLarge
|
||||
? reviewerSeedUsers
|
||||
.filter((user) => !selectedReviewerLogins.has(user.login.toLowerCase()))
|
||||
.filter((user) => user.login.toLowerCase() !== authorLogin)
|
||||
.map((user) => reviewerCandidatesByLogin.get(user.login.toLowerCase()) ?? user)
|
||||
.slice(0, 1)
|
||||
: [],
|
||||
[
|
||||
authorLogin,
|
||||
reviewerCandidatesByLogin,
|
||||
reviewerQuery.length,
|
||||
reviewerQueryState.isTooLarge,
|
||||
reviewerSeedUsers,
|
||||
selectedReviewerLogins
|
||||
]
|
||||
)
|
||||
const everyoneElseReviewerRows = useMemo(() => {
|
||||
const suggestedLogins = new Set(suggestedReviewerRows.map((user) => user.login.toLowerCase()))
|
||||
return filteredReviewerCandidates.filter(
|
||||
(user) => !suggestedLogins.has(user.login.toLowerCase())
|
||||
)
|
||||
}, [filteredReviewerCandidates, suggestedReviewerRows])
|
||||
const actionableReviewerRows = useMemo(
|
||||
() => [...suggestedReviewerRows, ...everyoneElseReviewerRows],
|
||||
[everyoneElseReviewerRows, suggestedReviewerRows]
|
||||
)
|
||||
|
||||
const reviewerCursorResetKey = `${reviewerQuery}\u0000${actionableReviewerRows.length}`
|
||||
if (activeReviewerCursor.resetKey !== reviewerCursorResetKey) {
|
||||
setActiveReviewerCursor({ resetKey: reviewerCursorResetKey, index: 0 })
|
||||
}
|
||||
const activeReviewerIndex =
|
||||
activeReviewerCursor.resetKey === reviewerCursorResetKey ? activeReviewerCursor.index : 0
|
||||
const setActiveReviewerIndex = useCallback(
|
||||
(nextIndex: number | ((current: number) => number)): void => {
|
||||
setActiveReviewerCursor((current) => {
|
||||
const currentIndex = current.resetKey === reviewerCursorResetKey ? current.index : 0
|
||||
return {
|
||||
resetKey: reviewerCursorResetKey,
|
||||
index: typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex
|
||||
}
|
||||
})
|
||||
},
|
||||
[reviewerCursorResetKey]
|
||||
)
|
||||
|
||||
const hasReviewerMetadata =
|
||||
item.reviewDecision !== undefined ||
|
||||
localReviewRequests.length > 0 ||
|
||||
item.reviewRequests !== undefined ||
|
||||
item.latestReviews !== undefined
|
||||
const canRequestReview =
|
||||
!!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment'
|
||||
|
||||
const { handleRequestReview, handleRemoveReviewers, requestReviewer } =
|
||||
createReviewerRequestActions({
|
||||
submitting,
|
||||
setSubmitting,
|
||||
reviewerInput,
|
||||
setReviewerInput,
|
||||
selectedReviewerLogins,
|
||||
localReviewRequests,
|
||||
setLocalReviewRequests,
|
||||
sourceSettings,
|
||||
repoPath,
|
||||
sourceContext,
|
||||
item,
|
||||
reviewRepo,
|
||||
reviewerPanelMountedRef,
|
||||
reviewerCandidates,
|
||||
patchWorkItem,
|
||||
onReviewersRequested,
|
||||
scheduleReviewerInputFocus
|
||||
})
|
||||
|
||||
const handleReviewerPickerOpenChange = (nextOpen: boolean): void => {
|
||||
setOpen(nextOpen)
|
||||
if (nextOpen) {
|
||||
scheduleReviewerInputFocus()
|
||||
return
|
||||
}
|
||||
setReviewerInput('')
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
<span>{translate('auto.components.PullRequestPage.00d3be6bcd', 'Reviewers')}</span>
|
||||
<ReviewerPicker
|
||||
open={open}
|
||||
submitting={submitting}
|
||||
canRequestReview={canRequestReview}
|
||||
reviewerInput={reviewerInput}
|
||||
reviewerInputRef={reviewerInputRef}
|
||||
reviewerMetadataLoading={reviewerMetadata.loading}
|
||||
reviewerMetadataError={reviewerMetadata.error}
|
||||
hasReviewerMetadata={hasReviewerMetadata}
|
||||
filteredReviewerCandidates={filteredReviewerCandidates}
|
||||
suggestedReviewerRows={suggestedReviewerRows}
|
||||
everyoneElseReviewerRows={everyoneElseReviewerRows}
|
||||
actionableReviewerRows={actionableReviewerRows}
|
||||
activeReviewerIndex={activeReviewerIndex}
|
||||
selectedReviewerLogins={selectedReviewerLogins}
|
||||
onOpenChange={handleReviewerPickerOpenChange}
|
||||
onInputChange={setReviewerInput}
|
||||
onActiveIndexChange={setActiveReviewerIndex}
|
||||
onEnter={() => {
|
||||
const activeReviewer = actionableReviewerRows[activeReviewerIndex]
|
||||
if (activeReviewer) {
|
||||
void requestReviewer(activeReviewer)
|
||||
return
|
||||
}
|
||||
void handleRequestReview()
|
||||
}}
|
||||
onRequestReviewer={(reviewer) => {
|
||||
void requestReviewer(reviewer)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ReviewerRequestedList
|
||||
reviewers={reviewers}
|
||||
loading={loading}
|
||||
hasReviewerMetadata={hasReviewerMetadata}
|
||||
selectedReviewerLogins={selectedReviewerLogins}
|
||||
submitting={submitting}
|
||||
canRequestReview={canRequestReview}
|
||||
onRemove={(login) => {
|
||||
void handleRemoveReviewers([login])
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types'
|
||||
|
||||
export function ReviewerPickerRow({
|
||||
reviewer,
|
||||
suggested,
|
||||
active,
|
||||
selected,
|
||||
activeIndex,
|
||||
onHover,
|
||||
onRequest
|
||||
}: {
|
||||
reviewer: GitHubAssignableUser
|
||||
suggested: boolean
|
||||
active: boolean
|
||||
selected: boolean
|
||||
activeIndex: number
|
||||
onHover: (index: number) => void
|
||||
onRequest: (reviewer: GitHubAssignableUser) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
key={`${suggested ? 'suggested' : 'reviewer'}:${reviewer.login}`}
|
||||
type="button"
|
||||
aria-label={
|
||||
selected
|
||||
? translate(
|
||||
'auto.components.PullRequestPage.36b514a457',
|
||||
'Unrequest reviewer {{value0}}',
|
||||
{ value0: reviewer.login }
|
||||
)
|
||||
: translate('auto.components.PullRequestPage.41d275d3ec', 'Request reviewer {{value0}}', {
|
||||
value0: reviewer.login
|
||||
})
|
||||
}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
'flex min-h-10 w-full items-center gap-2 border-b border-border/70 px-3 py-2 text-left text-[13px] outline-none last:border-b-0 hover:bg-accent/70 focus-visible:bg-accent focus-visible:text-accent-foreground',
|
||||
active && 'bg-accent text-accent-foreground',
|
||||
selected && 'font-medium'
|
||||
)}
|
||||
onMouseEnter={() => onHover(activeIndex)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
onFocus={() => onHover(activeIndex)}
|
||||
onClick={() => {
|
||||
onRequest(reviewer)
|
||||
}}
|
||||
>
|
||||
<span className="flex size-4 shrink-0 items-center justify-center text-foreground">
|
||||
{selected ? <Check className="size-3.5" /> : null}
|
||||
</span>
|
||||
{reviewer.avatarUrl ? (
|
||||
<img src={reviewer.avatarUrl} alt="" className="size-5 shrink-0 rounded-full" />
|
||||
) : (
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
{reviewer.login.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">
|
||||
<span className="font-semibold text-foreground">{reviewer.login}</span>
|
||||
{reviewer.name ? (
|
||||
<span className="ml-1 font-normal text-muted-foreground">{reviewer.name}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{suggested ? (
|
||||
<span className="block truncate text-[12px] leading-4 text-muted-foreground">
|
||||
{translate('auto.components.PullRequestPage.f4a4b3fd9f', 'Recently edited these files')}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React from 'react'
|
||||
import { LoaderCircle, Pencil } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types'
|
||||
import { ReviewerPickerRow } from './picker-row'
|
||||
|
||||
export function ReviewerPicker({
|
||||
open,
|
||||
submitting,
|
||||
canRequestReview,
|
||||
reviewerInput,
|
||||
reviewerInputRef,
|
||||
reviewerMetadataLoading,
|
||||
reviewerMetadataError,
|
||||
hasReviewerMetadata,
|
||||
filteredReviewerCandidates,
|
||||
suggestedReviewerRows,
|
||||
everyoneElseReviewerRows,
|
||||
actionableReviewerRows,
|
||||
activeReviewerIndex,
|
||||
selectedReviewerLogins,
|
||||
onOpenChange,
|
||||
onInputChange,
|
||||
onActiveIndexChange,
|
||||
onEnter,
|
||||
onRequestReviewer
|
||||
}: {
|
||||
open: boolean
|
||||
submitting: boolean
|
||||
canRequestReview: boolean
|
||||
reviewerInput: string
|
||||
reviewerInputRef: React.RefObject<HTMLInputElement | null>
|
||||
reviewerMetadataLoading: boolean
|
||||
reviewerMetadataError?: string | null
|
||||
hasReviewerMetadata: boolean
|
||||
filteredReviewerCandidates: GitHubAssignableUser[]
|
||||
suggestedReviewerRows: GitHubAssignableUser[]
|
||||
everyoneElseReviewerRows: GitHubAssignableUser[]
|
||||
actionableReviewerRows: GitHubAssignableUser[]
|
||||
activeReviewerIndex: number
|
||||
selectedReviewerLogins: Set<string>
|
||||
onOpenChange: (nextOpen: boolean) => void
|
||||
onInputChange: (value: string) => void
|
||||
onActiveIndexChange: (nextIndex: number | ((current: number) => number)) => void
|
||||
onEnter: () => void
|
||||
onRequestReviewer: (reviewer: GitHubAssignableUser) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting || !canRequestReview}
|
||||
aria-label={translate('auto.components.PullRequestPage.a04c137bb7', 'Reviewer')}
|
||||
className="rounded p-0.5 text-muted-foreground transition hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{submitting ? (
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Pencil className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="flex max-h-[420px] w-[330px] flex-col overflow-hidden rounded-md border-border/70 p-0"
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="border-b border-border/70 p-2">
|
||||
<Input
|
||||
ref={reviewerInputRef}
|
||||
value={reviewerInput}
|
||||
onChange={(event) => onInputChange(event.target.value)}
|
||||
disabled={submitting || !canRequestReview}
|
||||
placeholder={translate(
|
||||
'auto.components.PullRequestPage.3bde131f49',
|
||||
'Type or choose a user'
|
||||
)}
|
||||
aria-label={translate('auto.components.PullRequestPage.a04c137bb7', 'Reviewer')}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className="h-8 min-w-0 cursor-text rounded-md border-border/50 bg-background text-xs"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) {
|
||||
event.preventDefault()
|
||||
onActiveIndexChange((current) => (current + 1) % actionableReviewerRows.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) {
|
||||
event.preventDefault()
|
||||
onActiveIndexChange(
|
||||
(current) =>
|
||||
(current - 1 + actionableReviewerRows.length) % actionableReviewerRows.length
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onEnter()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onOpenChange(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
|
||||
{reviewerMetadataLoading ? (
|
||||
<div className="px-3 py-2 text-[13px] text-muted-foreground">
|
||||
{translate('auto.components.PullRequestPage.57750f4a8c', 'Loading...')}
|
||||
</div>
|
||||
) : filteredReviewerCandidates.length > 0 ? (
|
||||
<>
|
||||
{suggestedReviewerRows.length > 0 ? (
|
||||
<>
|
||||
<div className="border-b border-border/70 bg-muted/50 px-3 py-1.5 text-[12px] font-semibold text-foreground">
|
||||
{translate('auto.components.PullRequestPage.828f045847', 'Suggestions')}
|
||||
</div>
|
||||
{suggestedReviewerRows.map((reviewer, index) => (
|
||||
<ReviewerPickerRow
|
||||
key={`suggested:${reviewer.login}`}
|
||||
reviewer={reviewer}
|
||||
suggested
|
||||
active={actionableReviewerRows[activeReviewerIndex]?.login === reviewer.login}
|
||||
selected={selectedReviewerLogins.has(reviewer.login.toLowerCase())}
|
||||
activeIndex={index}
|
||||
onHover={onActiveIndexChange}
|
||||
onRequest={onRequestReviewer}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
<div className="border-b border-border/70 bg-muted/50 px-3 py-1.5 text-[12px] font-semibold text-foreground">
|
||||
{translate('auto.components.PullRequestPage.2760fa29a4', 'Everyone else')}
|
||||
</div>
|
||||
{everyoneElseReviewerRows.length > 0 ? (
|
||||
everyoneElseReviewerRows.map((reviewer, index) => (
|
||||
<ReviewerPickerRow
|
||||
key={`reviewer:${reviewer.login}`}
|
||||
reviewer={reviewer}
|
||||
suggested={false}
|
||||
active={actionableReviewerRows[activeReviewerIndex]?.login === reviewer.login}
|
||||
selected={selectedReviewerLogins.has(reviewer.login.toLowerCase())}
|
||||
activeIndex={suggestedReviewerRows.length + index}
|
||||
onHover={onActiveIndexChange}
|
||||
onRequest={onRequestReviewer}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-[13px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.PullRequestPage.5ad00c7a0e',
|
||||
'No matching reviewers.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-[13px] text-muted-foreground">
|
||||
{reviewerMetadataError ??
|
||||
(hasReviewerMetadata
|
||||
? translate(
|
||||
'auto.components.PullRequestPage.5ad00c7a0e',
|
||||
'No matching reviewers.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.PullRequestPage.56ec6eafb7',
|
||||
'Open the PR details to view current reviewers.'
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { toast } from 'sonner'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
normalizeGitHubReviewerLogins,
|
||||
parseGitHubReviewerInputLogins
|
||||
} from '@/components/github-pr-reviewer-display'
|
||||
import { notifyWorkItemDetailsMutation } from '@/components/github/github-work-item-comment-mutations'
|
||||
import { buildRequestedReviewUsers } from '@/components/github/work-item-state-presentation'
|
||||
import { getGitHubRuntimeRepoId } from '@/lib/github-source-runtime-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type {
|
||||
GitHubAssignableUser,
|
||||
GitHubOwnerRepo
|
||||
} from '../../../../../shared/github/pull-request-types'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type { GlobalSettings } from '../../../../../shared/global-settings-types'
|
||||
|
||||
type ReviewerRequestActionsArgs = {
|
||||
submitting: boolean
|
||||
setSubmitting: (value: boolean) => void
|
||||
reviewerInput: string
|
||||
setReviewerInput: (value: string) => void
|
||||
selectedReviewerLogins: Set<string>
|
||||
localReviewRequests: GitHubAssignableUser[]
|
||||
setLocalReviewRequests: (value: GitHubAssignableUser[]) => void
|
||||
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>
|
||||
repoPath: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
item: GitHubWorkItem
|
||||
reviewRepo: GitHubOwnerRepo | null
|
||||
reviewerPanelMountedRef: { current: boolean }
|
||||
reviewerCandidates: GitHubAssignableUser[]
|
||||
patchWorkItem: (
|
||||
id: string,
|
||||
patch: Partial<GitHubWorkItem>,
|
||||
repoId?: string | null,
|
||||
options?: { sourceContext?: TaskSourceContext | null }
|
||||
) => void
|
||||
onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void
|
||||
scheduleReviewerInputFocus: () => void
|
||||
}
|
||||
|
||||
export function createReviewerRequestActions(args: ReviewerRequestActionsArgs): {
|
||||
handleRequestReview: (requestedLogins?: string[]) => Promise<void>
|
||||
handleRemoveReviewers: (reviewersToRemove: string[]) => Promise<void>
|
||||
requestReviewer: (reviewer: GitHubAssignableUser) => Promise<void>
|
||||
} {
|
||||
const handleRequestReview = async (requestedLogins?: string[]): Promise<void> => {
|
||||
if (args.submitting) {
|
||||
return
|
||||
}
|
||||
const logins = normalizeGitHubReviewerLogins(
|
||||
requestedLogins ?? parseGitHubReviewerInputLogins(args.reviewerInput),
|
||||
args.selectedReviewerLogins
|
||||
)
|
||||
if (logins.length === 0) {
|
||||
toast.error(translate('auto.components.PullRequestPage.dace0d1a9f', 'Enter a reviewer'))
|
||||
return
|
||||
}
|
||||
if (args.localReviewRequests.length + logins.length > 15) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.8f369a6b6b',
|
||||
'You can request up to 15 reviewers'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
const target = getActiveRuntimeTarget(args.sourceSettings)
|
||||
if (target.kind !== 'environment' && !args.repoPath) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.1ae11c905c',
|
||||
'No repo context available for this pull request.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
args.setSubmitting(true)
|
||||
try {
|
||||
const runtimeRepo = getGitHubRuntimeRepoId(args.sourceContext, args.item.repoId)
|
||||
const result =
|
||||
target.kind === 'environment'
|
||||
? await callRuntimeRpc<{ ok: boolean; error?: string }>(
|
||||
target,
|
||||
'github.requestPRReviewers',
|
||||
{
|
||||
repo: runtimeRepo,
|
||||
prNumber: args.item.number,
|
||||
reviewers: logins,
|
||||
prRepo: args.reviewRepo
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: await window.api.gh.requestPRReviewers({
|
||||
repoPath: args.repoPath ?? '',
|
||||
repoId: args.item.repoId,
|
||||
sourceContext: args.sourceContext,
|
||||
prNumber: args.item.number,
|
||||
reviewers: logins,
|
||||
prRepo: args.reviewRepo
|
||||
})
|
||||
if (!args.reviewerPanelMountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (!result.ok) {
|
||||
toast.error(
|
||||
result.error ??
|
||||
translate('auto.components.PullRequestPage.2560588245', 'Failed to request reviewer')
|
||||
)
|
||||
return
|
||||
}
|
||||
const nextReviewRequests = buildRequestedReviewUsers(
|
||||
logins,
|
||||
args.reviewerCandidates,
|
||||
args.localReviewRequests
|
||||
)
|
||||
args.setLocalReviewRequests(nextReviewRequests)
|
||||
args.patchWorkItem(args.item.id, { reviewRequests: nextReviewRequests }, args.item.repoId, {
|
||||
sourceContext: args.sourceContext
|
||||
})
|
||||
args.onReviewersRequested(nextReviewRequests)
|
||||
if (target.kind === 'environment') {
|
||||
notifyWorkItemDetailsMutation(
|
||||
{
|
||||
repoPath: args.repoPath ?? '',
|
||||
repoId: args.item.repoId,
|
||||
sourceContext: args.sourceContext,
|
||||
type: 'pr',
|
||||
number: args.item.number
|
||||
},
|
||||
{ local: false }
|
||||
)
|
||||
}
|
||||
args.setReviewerInput('')
|
||||
toast.success(
|
||||
logins.length === 1
|
||||
? translate('auto.components.PullRequestPage.03282ff3b9', 'Reviewer requested')
|
||||
: translate('auto.components.PullRequestPage.102d3d177f', 'Reviewers requested')
|
||||
)
|
||||
} catch {
|
||||
if (args.reviewerPanelMountedRef.current) {
|
||||
toast.error(
|
||||
translate('auto.components.PullRequestPage.2560588245', 'Failed to request reviewer')
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (args.reviewerPanelMountedRef.current) {
|
||||
args.setSubmitting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveReviewers = async (reviewersToRemove: string[]): Promise<void> => {
|
||||
if (args.submitting) {
|
||||
return
|
||||
}
|
||||
const selected = new Set(
|
||||
args.localReviewRequests.map((reviewer) => reviewer.login.toLowerCase())
|
||||
)
|
||||
const logins = reviewersToRemove
|
||||
.map((reviewer) => reviewer.trim().replace(/^@/, ''))
|
||||
.filter((reviewer) => reviewer.length > 0 && selected.has(reviewer.toLowerCase()))
|
||||
if (logins.length === 0) {
|
||||
return
|
||||
}
|
||||
const target = getActiveRuntimeTarget(args.sourceSettings)
|
||||
if (target.kind !== 'environment' && !args.repoPath) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.PullRequestPage.1ae11c905c',
|
||||
'No repo context available for this pull request.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
args.setSubmitting(true)
|
||||
try {
|
||||
const runtimeRepo = getGitHubRuntimeRepoId(args.sourceContext, args.item.repoId)
|
||||
const result =
|
||||
target.kind === 'environment'
|
||||
? await callRuntimeRpc<{ ok: boolean; error?: string }>(
|
||||
target,
|
||||
'github.removePRReviewers',
|
||||
{
|
||||
repo: runtimeRepo,
|
||||
prNumber: args.item.number,
|
||||
reviewers: logins,
|
||||
prRepo: args.reviewRepo
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: await window.api.gh.removePRReviewers({
|
||||
repoPath: args.repoPath ?? '',
|
||||
repoId: args.item.repoId,
|
||||
sourceContext: args.sourceContext,
|
||||
prNumber: args.item.number,
|
||||
reviewers: logins,
|
||||
prRepo: args.reviewRepo
|
||||
})
|
||||
if (!args.reviewerPanelMountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (!result.ok) {
|
||||
toast.error(
|
||||
result.error ??
|
||||
translate('auto.components.PullRequestPage.c798fa0ec7', 'Failed to remove reviewer')
|
||||
)
|
||||
return
|
||||
}
|
||||
const removed = new Set(logins.map((login) => login.toLowerCase()))
|
||||
const nextReviewRequests = args.localReviewRequests.filter(
|
||||
(reviewer) => !removed.has(reviewer.login.toLowerCase())
|
||||
)
|
||||
args.setLocalReviewRequests(nextReviewRequests)
|
||||
args.patchWorkItem(args.item.id, { reviewRequests: nextReviewRequests }, args.item.repoId, {
|
||||
sourceContext: args.sourceContext
|
||||
})
|
||||
args.onReviewersRequested(nextReviewRequests)
|
||||
if (target.kind === 'environment') {
|
||||
notifyWorkItemDetailsMutation(
|
||||
{
|
||||
repoPath: args.repoPath ?? '',
|
||||
repoId: args.item.repoId,
|
||||
sourceContext: args.sourceContext,
|
||||
type: 'pr',
|
||||
number: args.item.number
|
||||
},
|
||||
{ local: false }
|
||||
)
|
||||
}
|
||||
args.setReviewerInput('')
|
||||
toast.success(
|
||||
logins.length === 1
|
||||
? translate('auto.components.PullRequestPage.2c1d93da43', 'Reviewer removed')
|
||||
: translate('auto.components.PullRequestPage.1e6d089420', 'Reviewers removed')
|
||||
)
|
||||
} catch {
|
||||
if (args.reviewerPanelMountedRef.current) {
|
||||
toast.error(
|
||||
translate('auto.components.PullRequestPage.c798fa0ec7', 'Failed to remove reviewer')
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (args.reviewerPanelMountedRef.current) {
|
||||
args.setSubmitting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestReviewer = async (reviewer: GitHubAssignableUser): Promise<void> => {
|
||||
await (args.selectedReviewerLogins.has(reviewer.login.toLowerCase())
|
||||
? handleRemoveReviewers([reviewer.login])
|
||||
: handleRequestReview([reviewer.login]))
|
||||
args.scheduleReviewerInputFocus()
|
||||
}
|
||||
|
||||
return { handleRequestReview, handleRemoveReviewers, requestReviewer }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react'
|
||||
import { LoaderCircle, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ReviewerAvatar } from '@/components/github/work-item-state-presentation'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { getGitHubPRReviewerRows } from '@/components/github-pr-reviewer-display'
|
||||
|
||||
export function ReviewerRequestedList({
|
||||
reviewers,
|
||||
loading,
|
||||
hasReviewerMetadata,
|
||||
selectedReviewerLogins,
|
||||
submitting,
|
||||
canRequestReview,
|
||||
onRemove
|
||||
}: {
|
||||
reviewers: ReturnType<typeof getGitHubPRReviewerRows>
|
||||
loading: boolean
|
||||
hasReviewerMetadata: boolean
|
||||
selectedReviewerLogins: Set<string>
|
||||
submitting: boolean
|
||||
canRequestReview: boolean
|
||||
onRemove: (login: string) => void
|
||||
}): React.JSX.Element {
|
||||
if (loading && !hasReviewerMetadata) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1 text-[12px] text-muted-foreground">
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
{translate('auto.components.PullRequestPage.acbd110867', 'Loading reviewers')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (reviewers.length === 0) {
|
||||
return (
|
||||
<div className="py-1 text-[12px] text-muted-foreground">
|
||||
{translate('auto.components.PullRequestPage.d10b6d5209', 'No reviewers requested.')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{reviewers.map((reviewer) => {
|
||||
const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase())
|
||||
return (
|
||||
<div key={reviewer.login} className="flex min-w-0 items-center gap-2">
|
||||
<ReviewerAvatar login={reviewer.login} avatarUrl={reviewer.avatarUrl} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">
|
||||
{reviewer.login}
|
||||
</div>
|
||||
{reviewer.name ? (
|
||||
<div className="truncate text-[11px] text-muted-foreground">{reviewer.name}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{reviewer.stateLabel}
|
||||
</span>
|
||||
{canRemoveReviewer ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
disabled={submitting || !canRequestReview}
|
||||
aria-label={translate(
|
||||
'auto.components.PullRequestPage.ae9a38fd4a',
|
||||
'Remove reviewer {{value0}}',
|
||||
{ value0: reviewer.login }
|
||||
)}
|
||||
onClick={() => {
|
||||
onRemove(reviewer.login)
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{translate('auto.components.PullRequestPage.7f964a365a', 'Remove reviewer')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user