mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat: GitHub PR/issue drawer on new-workspace page (#744)
This commit is contained in:
parent
480c19f5f0
commit
7df0d9686c
8 changed files with 1217 additions and 3 deletions
366
src/main/github/work-item-details.ts
Normal file
366
src/main/github/work-item-details.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
/* eslint-disable max-lines -- Why: the PR/Issue details service groups the
|
||||
body/comments/files/checks fetch paths alongside the file-contents resolver
|
||||
so the drawer's rate-limit and caching strategy lives in one place. */
|
||||
import type {
|
||||
GitHubPRFile,
|
||||
GitHubPRFileContents,
|
||||
GitHubWorkItem,
|
||||
GitHubWorkItemDetails,
|
||||
PRCheckDetail,
|
||||
PRComment
|
||||
} from '../../shared/types'
|
||||
import { ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
|
||||
import { getWorkItem, getPRChecks, getPRComments } from './client'
|
||||
|
||||
// Why: a PR "changed file" listing returned by the REST endpoint is paginated
|
||||
// at 100 per page; we cap at a reasonable total so a massive PR cannot starve
|
||||
// the gh semaphore while we fetch file listings.
|
||||
const MAX_PR_FILES = 300
|
||||
|
||||
type RESTPRFile = {
|
||||
filename: string
|
||||
previous_filename?: string
|
||||
status: string
|
||||
additions: number
|
||||
deletions: number
|
||||
changes: number
|
||||
/** Raw patch text when available; absent for binary files or patches over GitHub's size cap. */
|
||||
patch?: string
|
||||
}
|
||||
|
||||
function mapFileStatus(raw: string): GitHubPRFile['status'] {
|
||||
switch (raw) {
|
||||
case 'added':
|
||||
return 'added'
|
||||
case 'removed':
|
||||
return 'removed'
|
||||
case 'modified':
|
||||
return 'modified'
|
||||
case 'renamed':
|
||||
return 'renamed'
|
||||
case 'copied':
|
||||
return 'copied'
|
||||
case 'changed':
|
||||
return 'changed'
|
||||
case 'unchanged':
|
||||
return 'unchanged'
|
||||
default:
|
||||
return 'modified'
|
||||
}
|
||||
}
|
||||
|
||||
// Why: GitHub's REST file listing does not explicitly flag binary files, but it
|
||||
// omits the `patch` field for them. When a file has changes but no patch, we
|
||||
// treat it as binary so the drawer's diff tab can show a placeholder instead of
|
||||
// attempting to fetch contents that would render as noise in a text diff viewer.
|
||||
function isBinaryHint(file: RESTPRFile): boolean {
|
||||
if (file.status === 'removed' || file.status === 'added') {
|
||||
// A newly added or removed file with zero patch text but non-zero changes
|
||||
// is almost always binary (images, lockfiles over the size cap, etc.).
|
||||
return file.patch === undefined && file.changes > 0
|
||||
}
|
||||
return file.patch === undefined && file.changes > 0
|
||||
}
|
||||
|
||||
async function getPRHeadBaseSha(
|
||||
repoPath: string,
|
||||
prNumber: number
|
||||
): Promise<{ headSha: string; baseSha: string } | null> {
|
||||
const ownerRepo = await getOwnerRepo(repoPath)
|
||||
try {
|
||||
if (ownerRepo) {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
const data = JSON.parse(stdout) as {
|
||||
head?: { sha?: string }
|
||||
base?: { sha?: string }
|
||||
}
|
||||
if (data.head?.sha && data.base?.sha) {
|
||||
return { headSha: data.head.sha, baseSha: data.base.sha }
|
||||
}
|
||||
return null
|
||||
}
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['pr', 'view', String(prNumber), '--json', 'headRefOid,baseRefOid'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
const data = JSON.parse(stdout) as { headRefOid?: string; baseRefOid?: string }
|
||||
if (data.headRefOid && data.baseRefOid) {
|
||||
return { headSha: data.headRefOid, baseSha: data.baseRefOid }
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getPRFiles(repoPath: string, prNumber: number): Promise<GitHubPRFile[]> {
|
||||
const ownerRepo = await getOwnerRepo(repoPath)
|
||||
if (!ownerRepo) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'60s',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/files?per_page=100`
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
const data = JSON.parse(stdout) as RESTPRFile[]
|
||||
return data.slice(0, MAX_PR_FILES).map((file) => ({
|
||||
path: file.filename,
|
||||
oldPath: file.previous_filename,
|
||||
status: mapFileStatus(file.status),
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
isBinary: isBinaryHint(file)
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function getIssueBodyAndComments(
|
||||
repoPath: string,
|
||||
issueNumber: number
|
||||
): Promise<{ body: string; comments: PRComment[] }> {
|
||||
const ownerRepo = await getOwnerRepo(repoPath)
|
||||
try {
|
||||
if (ownerRepo) {
|
||||
const [issueResult, commentsResult] = await Promise.all([
|
||||
ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'60s',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}`
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
),
|
||||
ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'60s',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/comments?per_page=100`
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
])
|
||||
const issue = JSON.parse(issueResult.stdout) as { body?: string | null }
|
||||
type RESTComment = {
|
||||
id: number
|
||||
user: { login: string; avatar_url: string } | null
|
||||
body: string
|
||||
created_at: string
|
||||
html_url: string
|
||||
}
|
||||
const comments = (JSON.parse(commentsResult.stdout) as RESTComment[]).map(
|
||||
(c): PRComment => ({
|
||||
id: c.id,
|
||||
author: c.user?.login ?? 'ghost',
|
||||
authorAvatarUrl: c.user?.avatar_url ?? '',
|
||||
body: c.body ?? '',
|
||||
createdAt: c.created_at,
|
||||
url: c.html_url
|
||||
})
|
||||
)
|
||||
return { body: issue.body ?? '', comments }
|
||||
}
|
||||
// Fallback: non-GitHub remote
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['issue', 'view', String(issueNumber), '--json', 'body,comments'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
const data = JSON.parse(stdout) as {
|
||||
body?: string
|
||||
comments?: {
|
||||
author: { login: string }
|
||||
body: string
|
||||
createdAt: string
|
||||
url: string
|
||||
}[]
|
||||
}
|
||||
const comments = (data.comments ?? []).map(
|
||||
(c, i): PRComment => ({
|
||||
id: i,
|
||||
author: c.author?.login ?? 'ghost',
|
||||
authorAvatarUrl: '',
|
||||
body: c.body ?? '',
|
||||
createdAt: c.createdAt,
|
||||
url: c.url ?? ''
|
||||
})
|
||||
)
|
||||
return { body: data.body ?? '', comments }
|
||||
} catch {
|
||||
return { body: '', comments: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async function getPRBody(repoPath: string, prNumber: number): Promise<string> {
|
||||
const ownerRepo = await getOwnerRepo(repoPath)
|
||||
try {
|
||||
if (ownerRepo) {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
const data = JSON.parse(stdout) as { body?: string | null }
|
||||
return data.body ?? ''
|
||||
}
|
||||
const { stdout } = await ghExecFileAsync(['pr', 'view', String(prNumber), '--json', 'body'], {
|
||||
cwd: repoPath
|
||||
})
|
||||
const data = JSON.parse(stdout) as { body?: string }
|
||||
return data.body ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkItemDetails(
|
||||
repoPath: string,
|
||||
number: number
|
||||
): Promise<GitHubWorkItemDetails | null> {
|
||||
// Why: getWorkItem already handles acquire/release. We call it first (outside
|
||||
// our semaphore) so the known-cheap lookup doesn't compete with the richer
|
||||
// detail fetches that follow.
|
||||
const item: GitHubWorkItem | null = await getWorkItem(repoPath, number)
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
if (item.type === 'issue') {
|
||||
const { body, comments } = await getIssueBodyAndComments(repoPath, item.number)
|
||||
return { item, body, comments }
|
||||
}
|
||||
|
||||
// PR: fetch body + comments + checks + files + head/base SHAs in parallel.
|
||||
const [body, comments, shas, files] = await Promise.all([
|
||||
getPRBody(repoPath, item.number),
|
||||
getPRComments(repoPath, item.number),
|
||||
getPRHeadBaseSha(repoPath, item.number),
|
||||
getPRFiles(repoPath, item.number)
|
||||
])
|
||||
|
||||
const checks: PRCheckDetail[] = shas?.headSha
|
||||
? await getPRChecks(repoPath, item.number, shas.headSha)
|
||||
: await getPRChecks(repoPath, item.number)
|
||||
|
||||
return {
|
||||
item,
|
||||
body,
|
||||
comments,
|
||||
headSha: shas?.headSha,
|
||||
baseSha: shas?.baseSha,
|
||||
checks,
|
||||
files
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: base64-decoded contents at specific commits are needed to feed Orca's
|
||||
// Monaco-based DiffViewer (which expects original/modified text, not unified
|
||||
// diff patches). Fetching via gh api --cache keeps rate-limit usage bounded
|
||||
// during rapid file-expand clicks in the drawer.
|
||||
async function fetchContentAtRef(args: {
|
||||
repoPath: string
|
||||
owner: string
|
||||
repo: string
|
||||
path: string
|
||||
ref: string
|
||||
}): Promise<{ content: string; isBinary: boolean }> {
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'300s',
|
||||
'-H',
|
||||
'Accept: application/vnd.github.raw',
|
||||
`repos/${args.owner}/${args.repo}/contents/${encodeURI(args.path)}?ref=${encodeURIComponent(args.ref)}`
|
||||
],
|
||||
{ cwd: args.repoPath }
|
||||
)
|
||||
// Raw content response: Electron's execFile returns string in utf-8. If the
|
||||
// file is binary, the string will contain replacement characters — we treat
|
||||
// anything with a NUL byte in the first 2KB as binary and skip rendering.
|
||||
const sample = stdout.slice(0, 2048)
|
||||
if (sample.includes('\u0000')) {
|
||||
return { content: '', isBinary: true }
|
||||
}
|
||||
return { content: stdout, isBinary: false }
|
||||
} catch {
|
||||
return { content: '', isBinary: false }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPRFileContents(args: {
|
||||
repoPath: string
|
||||
prNumber: number
|
||||
path: string
|
||||
oldPath?: string
|
||||
status: GitHubPRFile['status']
|
||||
headSha: string
|
||||
baseSha: string
|
||||
}): Promise<GitHubPRFileContents> {
|
||||
const ownerRepo = await getOwnerRepo(args.repoPath)
|
||||
if (!ownerRepo) {
|
||||
return {
|
||||
original: '',
|
||||
modified: '',
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false
|
||||
}
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
// Why: for added files there's no original content at the base ref; for
|
||||
// removed files there's no modified content at the head ref. Skipping the
|
||||
// redundant fetches keeps latency down and avoids spurious 404 warnings.
|
||||
const needsOriginal = args.status !== 'added'
|
||||
const needsModified = args.status !== 'removed'
|
||||
const originalRef = args.baseSha
|
||||
const originalPath = args.oldPath ?? args.path
|
||||
|
||||
const [original, modified] = await Promise.all([
|
||||
needsOriginal
|
||||
? fetchContentAtRef({
|
||||
repoPath: args.repoPath,
|
||||
owner: ownerRepo.owner,
|
||||
repo: ownerRepo.repo,
|
||||
path: originalPath,
|
||||
ref: originalRef
|
||||
})
|
||||
: Promise.resolve({ content: '', isBinary: false }),
|
||||
needsModified
|
||||
? fetchContentAtRef({
|
||||
repoPath: args.repoPath,
|
||||
owner: ownerRepo.owner,
|
||||
repo: ownerRepo.repo,
|
||||
path: args.path,
|
||||
ref: args.headSha
|
||||
})
|
||||
: Promise.resolve({ content: '', isBinary: false })
|
||||
])
|
||||
|
||||
return {
|
||||
original: original.content,
|
||||
modified: modified.content,
|
||||
originalIsBinary: original.isBinary,
|
||||
modifiedIsBinary: modified.isBinary
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ import {
|
|||
checkOrcaStarred,
|
||||
starOrca
|
||||
} from '../github/client'
|
||||
import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details'
|
||||
import type { GitHubPRFile } from '../../shared/types'
|
||||
|
||||
// Why: returns the full Repo object instead of just the path string so that
|
||||
// callers have access to repo.id for stat tracking and other context.
|
||||
|
|
@ -72,6 +74,38 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
|||
return getWorkItem(repo.path, args.number)
|
||||
})
|
||||
|
||||
ipcMain.handle('gh:workItemDetails', (_event, args: { repoPath: string; number: number }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return getWorkItemDetails(repo.path, args.number)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'gh:prFileContents',
|
||||
(
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
prNumber: number
|
||||
path: string
|
||||
oldPath?: string
|
||||
status: GitHubPRFile['status']
|
||||
headSha: string
|
||||
baseSha: string
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return getPRFileContents({
|
||||
repoPath: repo.path,
|
||||
prNumber: args.prNumber,
|
||||
path: args.path,
|
||||
oldPath: args.oldPath,
|
||||
status: args.status,
|
||||
headSha: args.headSha,
|
||||
baseSha: args.baseSha
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:repoSlug', (_event, args: { repoPath: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return getRepoSlug(repo.path)
|
||||
|
|
|
|||
16
src/preload/api-types.d.ts
vendored
16
src/preload/api-types.d.ts
vendored
|
|
@ -15,7 +15,10 @@ import type {
|
|||
GitConflictOperation,
|
||||
GitDiffResult,
|
||||
GitStatusEntry,
|
||||
GitHubPRFile,
|
||||
GitHubPRFileContents,
|
||||
GitHubWorkItem,
|
||||
GitHubWorkItemDetails,
|
||||
GitHubViewer,
|
||||
IssueInfo,
|
||||
NotificationDispatchRequest,
|
||||
|
|
@ -278,6 +281,19 @@ export type PreloadApi = {
|
|||
prForBranch: (args: { repoPath: string; branch: string }) => Promise<PRInfo | null>
|
||||
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
|
||||
workItem: (args: { repoPath: string; number: number }) => Promise<GitHubWorkItem | null>
|
||||
workItemDetails: (args: {
|
||||
repoPath: string
|
||||
number: number
|
||||
}) => Promise<GitHubWorkItemDetails | null>
|
||||
prFileContents: (args: {
|
||||
repoPath: string
|
||||
prNumber: number
|
||||
path: string
|
||||
oldPath?: string
|
||||
status: GitHubPRFile['status']
|
||||
headSha: string
|
||||
baseSha: string
|
||||
}) => Promise<GitHubPRFileContents>
|
||||
listIssues: (args: { repoPath: string; limit?: number }) => Promise<IssueInfo[]>
|
||||
listWorkItems: (args: {
|
||||
repoPath: string
|
||||
|
|
|
|||
21
src/preload/index.d.ts
vendored
21
src/preload/index.d.ts
vendored
|
|
@ -1,7 +1,10 @@
|
|||
import type { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import type {
|
||||
CreateWorktreeResult,
|
||||
GitHubPRFile,
|
||||
GitHubPRFileContents,
|
||||
GitHubWorkItem,
|
||||
GitHubWorkItemDetails,
|
||||
GitHubViewer,
|
||||
CreateWorktreeArgs,
|
||||
OpenCodeStatusEvent
|
||||
|
|
@ -11,7 +14,10 @@ import type { PreloadApi } from './api-types'
|
|||
|
||||
type ReposApi = {
|
||||
list: () => Promise<Repo[]>
|
||||
add: (args: { path: string; kind?: 'git' | 'folder' }) => Promise<{ repo: Repo } | { error: string }>
|
||||
add: (args: {
|
||||
path: string
|
||||
kind?: 'git' | 'folder'
|
||||
}) => Promise<{ repo: Repo } | { error: string }>
|
||||
addRemote: (args: {
|
||||
connectionId: string
|
||||
remotePath: string
|
||||
|
|
@ -71,6 +77,19 @@ type GhApi = {
|
|||
prForBranch: (args: { repoPath: string; branch: string }) => Promise<PRInfo | null>
|
||||
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
|
||||
workItem: (args: { repoPath: string; number: number }) => Promise<GitHubWorkItem | null>
|
||||
workItemDetails: (args: {
|
||||
repoPath: string
|
||||
number: number
|
||||
}) => Promise<GitHubWorkItemDetails | null>
|
||||
prFileContents: (args: {
|
||||
repoPath: string
|
||||
prNumber: number
|
||||
path: string
|
||||
oldPath?: string
|
||||
status: GitHubPRFile['status']
|
||||
headSha: string
|
||||
baseSha: string
|
||||
}) => Promise<GitHubPRFileContents>
|
||||
listIssues: (args: { repoPath: string; limit?: number }) => Promise<IssueInfo[]>
|
||||
listWorkItems: (args: {
|
||||
repoPath: string
|
||||
|
|
|
|||
|
|
@ -312,6 +312,19 @@ const api = {
|
|||
workItem: (args: { repoPath: string; number: number }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gh:workItem', args),
|
||||
|
||||
workItemDetails: (args: { repoPath: string; number: number }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gh:workItemDetails', args),
|
||||
|
||||
prFileContents: (args: {
|
||||
repoPath: string
|
||||
prNumber: number
|
||||
path: string
|
||||
oldPath?: string
|
||||
status: string
|
||||
headSha: string
|
||||
baseSha: string
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gh:prFileContents', args),
|
||||
|
||||
listIssues: (args: { repoPath: string; limit?: number }): Promise<unknown[]> =>
|
||||
ipcRenderer.invoke('gh:listIssues', args),
|
||||
|
||||
|
|
|
|||
721
src/renderer/src/components/GitHubItemDrawer.tsx
Normal file
721
src/renderer/src/components/GitHubItemDrawer.tsx
Normal file
|
|
@ -0,0 +1,721 @@
|
|||
/* eslint-disable max-lines -- Why: the GH drawer keeps its header, conversation, files, and checks tabs co-located so the read-only PR/Issue surface stays in one place while this view evolves. */
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleDashed,
|
||||
CircleDot,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
LoaderCircle,
|
||||
MessageSquare,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import { useSidebarResize } from '@/hooks/useSidebarResize'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-helpers'
|
||||
import type {
|
||||
GitHubPRFile,
|
||||
GitHubPRFileContents,
|
||||
GitHubWorkItem,
|
||||
GitHubWorkItemDetails,
|
||||
PRComment
|
||||
} from '../../../shared/types'
|
||||
|
||||
// Why: the editor's DiffViewer loads Monaco, which is heavy and should not be
|
||||
// pulled into the drawer's bundle until the user actually opens the Files tab.
|
||||
const DiffViewer = lazy(() => import('@/components/editor/DiffViewer'))
|
||||
|
||||
const DRAWER_MIN_WIDTH = 420
|
||||
const DRAWER_MAX_WIDTH = 920
|
||||
const DRAWER_DEFAULT_WIDTH = 560
|
||||
|
||||
type DrawerTab = 'conversation' | 'files' | 'checks'
|
||||
|
||||
type GitHubItemDrawerProps = {
|
||||
workItem: GitHubWorkItem | null
|
||||
repoPath: string | null
|
||||
/** Called when the user clicks the primary CTA — same semantics as today's row-click → composer modal. */
|
||||
onUse: (item: GitHubWorkItem) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function formatRelativeTime(input: string): string {
|
||||
const date = new Date(input)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'recently'
|
||||
}
|
||||
const diffMs = date.getTime() - Date.now()
|
||||
const diffMinutes = Math.round(diffMs / 60_000)
|
||||
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
if (Math.abs(diffMinutes) < 60) {
|
||||
return formatter.format(diffMinutes, 'minute')
|
||||
}
|
||||
const diffHours = Math.round(diffMinutes / 60)
|
||||
if (Math.abs(diffHours) < 24) {
|
||||
return formatter.format(diffHours, 'hour')
|
||||
}
|
||||
const diffDays = Math.round(diffHours / 24)
|
||||
return formatter.format(diffDays, 'day')
|
||||
}
|
||||
|
||||
function getStateLabel(item: GitHubWorkItem): string {
|
||||
if (item.type === 'pr') {
|
||||
if (item.state === 'merged') {
|
||||
return 'Merged'
|
||||
}
|
||||
if (item.state === 'draft') {
|
||||
return 'Draft'
|
||||
}
|
||||
if (item.state === 'closed') {
|
||||
return 'Closed'
|
||||
}
|
||||
return 'Open'
|
||||
}
|
||||
return item.state === 'closed' ? 'Closed' : 'Open'
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
function fileStatusTone(status: GitHubPRFile['status']): string {
|
||||
switch (status) {
|
||||
case 'added':
|
||||
return 'text-emerald-500'
|
||||
case 'removed':
|
||||
return 'text-rose-500'
|
||||
case 'renamed':
|
||||
case 'copied':
|
||||
return 'text-sky-500'
|
||||
default:
|
||||
return 'text-amber-500'
|
||||
}
|
||||
}
|
||||
|
||||
function fileStatusLabel(status: GitHubPRFile['status']): string {
|
||||
switch (status) {
|
||||
case 'added':
|
||||
return 'A'
|
||||
case 'removed':
|
||||
return 'D'
|
||||
case 'renamed':
|
||||
return 'R'
|
||||
case 'copied':
|
||||
return 'C'
|
||||
case 'unchanged':
|
||||
return '·'
|
||||
default:
|
||||
return 'M'
|
||||
}
|
||||
}
|
||||
|
||||
type FileRowProps = {
|
||||
file: GitHubPRFile
|
||||
repoPath: string
|
||||
prNumber: number
|
||||
headSha: string | undefined
|
||||
baseSha: string | undefined
|
||||
}
|
||||
|
||||
function PRFileRow({
|
||||
file,
|
||||
repoPath,
|
||||
prNumber,
|
||||
headSha,
|
||||
baseSha
|
||||
}: FileRowProps): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [contents, setContents] = useState<GitHubPRFileContents | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const canLoadDiff = Boolean(headSha && baseSha) && !file.isBinary
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setExpanded((prev) => {
|
||||
const next = !prev
|
||||
if (next && !contents && !loading && canLoadDiff && headSha && baseSha) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
window.api.gh
|
||||
.prFileContents({
|
||||
repoPath,
|
||||
prNumber,
|
||||
path: file.path,
|
||||
oldPath: file.oldPath,
|
||||
status: file.status,
|
||||
headSha,
|
||||
baseSha
|
||||
})
|
||||
.then((result) => {
|
||||
setContents(result)
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load diff')
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [
|
||||
baseSha,
|
||||
canLoadDiff,
|
||||
contents,
|
||||
file.oldPath,
|
||||
file.path,
|
||||
file.status,
|
||||
headSha,
|
||||
loading,
|
||||
prNumber,
|
||||
repoPath
|
||||
])
|
||||
|
||||
const language = useMemo(() => detectLanguage(file.path), [file.path])
|
||||
const modelKey = `gh-drawer:pr:${prNumber}:${file.path}`
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left transition hover:bg-muted/40"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-5 shrink-0 items-center justify-center rounded border border-border/60 font-mono text-[10px]',
|
||||
fileStatusTone(file.status)
|
||||
)}
|
||||
aria-label={file.status}
|
||||
>
|
||||
{fileStatusLabel(file.status)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[12px] text-foreground">
|
||||
{file.oldPath && file.oldPath !== file.path ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">{file.oldPath}</span>
|
||||
<span className="mx-1 text-muted-foreground">→</span>
|
||||
{file.path}
|
||||
</>
|
||||
) : (
|
||||
file.path
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
||||
<span className="text-emerald-500">+{file.additions}</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="text-rose-500">−{file.deletions}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
// Why: DiffViewer's inner layout uses flex-1/min-h-0, so this wrapper
|
||||
// must be a flex column with a fixed height for Monaco to size itself
|
||||
// correctly. A plain block div collapses flex-1 to 0 and renders empty.
|
||||
<div className="flex h-[420px] flex-col border-t border-border/40 bg-background">
|
||||
{!canLoadDiff ? (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-[12px] text-muted-foreground">
|
||||
{file.isBinary
|
||||
? 'Binary file — diff not shown.'
|
||||
: 'Diff unavailable (missing commit SHAs).'}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-[12px] text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : contents ? (
|
||||
contents.originalIsBinary || contents.modifiedIsBinary ? (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-[12px] text-muted-foreground">
|
||||
Binary file — diff not shown.
|
||||
</div>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DiffViewer
|
||||
modelKey={modelKey}
|
||||
originalContent={contents.original}
|
||||
modifiedContent={contents.modified}
|
||||
language={language}
|
||||
filePath={file.path}
|
||||
relativePath={file.path}
|
||||
sideBySide={false}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationTab({
|
||||
item,
|
||||
body,
|
||||
comments,
|
||||
loading
|
||||
}: {
|
||||
item: GitHubWorkItem
|
||||
body: string
|
||||
comments: PRComment[]
|
||||
loading: boolean
|
||||
}): React.JSX.Element {
|
||||
const authorLabel = item.author ?? 'unknown'
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-4 py-4">
|
||||
<div className="rounded-lg border border-border/50 bg-background/40">
|
||||
<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>· {formatRelativeTime(item.updatedAt)}</span>
|
||||
</div>
|
||||
<div className="px-3 py-3 text-[14px] leading-relaxed text-foreground">
|
||||
{body.trim() ? (
|
||||
<CommentMarkdown content={body} className="text-[14px] leading-relaxed" />
|
||||
) : (
|
||||
<span className="italic text-muted-foreground">No description provided.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<MessageSquare className="size-4 text-muted-foreground" />
|
||||
<span className="text-[13px] font-medium text-foreground">Comments</span>
|
||||
{comments.length > 0 && (
|
||||
<span className="text-[12px] text-muted-foreground">{comments.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && comments.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">No comments yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="rounded-lg border border-border/40 bg-background/30">
|
||||
<div className="flex 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="text-[13px] font-semibold text-foreground">{comment.author}</span>
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
· {formatRelativeTime(comment.createdAt)}
|
||||
</span>
|
||||
{comment.path && (
|
||||
<span className="font-mono text-[11px] text-muted-foreground/70">
|
||||
{comment.path.split('/').pop()}
|
||||
{comment.line ? `:L${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">
|
||||
resolved
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
{comment.url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.api.shell.openUrl(comment.url)}
|
||||
className="text-muted-foreground/60 hover:text-foreground"
|
||||
aria-label="Open comment on GitHub"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<CommentMarkdown content={comment.body} className="text-[13px] leading-relaxed" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChecksTab({
|
||||
checks,
|
||||
loading
|
||||
}: {
|
||||
checks: GitHubWorkItemDetails['checks']
|
||||
loading: boolean
|
||||
}): React.JSX.Element {
|
||||
const list = checks ?? []
|
||||
if (loading && list.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-10 text-center text-[12px] text-muted-foreground">
|
||||
No checks configured.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="px-2 py-2">
|
||||
{list.map((check) => {
|
||||
const conclusion = check.conclusion ?? 'pending'
|
||||
const Icon = CHECK_ICON[conclusion] ?? CircleDashed
|
||||
const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground'
|
||||
return (
|
||||
<button
|
||||
key={check.name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (check.url) {
|
||||
window.api.shell.openUrl(check.url)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition',
|
||||
check.url ? 'hover:bg-muted/40' : ''
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn('size-3.5 shrink-0', color, conclusion === 'pending' && 'animate-spin')}
|
||||
/>
|
||||
<span className="flex-1 truncate text-[12px] text-foreground">{check.name}</span>
|
||||
{check.url && <ExternalLink className="size-3 shrink-0 text-muted-foreground/40" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GitHubItemDrawer({
|
||||
workItem,
|
||||
repoPath,
|
||||
onUse,
|
||||
onClose
|
||||
}: GitHubItemDrawerProps): React.JSX.Element | null {
|
||||
const [width, setWidth] = useState(DRAWER_DEFAULT_WIDTH)
|
||||
const [tab, setTab] = useState<DrawerTab>('conversation')
|
||||
const [details, setDetails] = useState<GitHubWorkItemDetails | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { containerRef, isResizing, onResizeStart } = useSidebarResize<HTMLDivElement>({
|
||||
isOpen: workItem !== null,
|
||||
width,
|
||||
minWidth: DRAWER_MIN_WIDTH,
|
||||
maxWidth: DRAWER_MAX_WIDTH,
|
||||
deltaSign: -1,
|
||||
setWidth
|
||||
})
|
||||
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!workItem || !repoPath) {
|
||||
setDetails(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
// Why: if the user clicks through several rows quickly, discard stale
|
||||
// responses by tagging each request with a monotonic id and only applying
|
||||
// results whose id matches the latest one.
|
||||
requestIdRef.current += 1
|
||||
const requestId = requestIdRef.current
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setDetails(null)
|
||||
setTab('conversation')
|
||||
|
||||
window.api.gh
|
||||
.workItemDetails({ repoPath, number: workItem.number })
|
||||
.then((result) => {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return
|
||||
}
|
||||
setDetails(result)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Failed to load details')
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
}, [repoPath, workItem])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workItem) {
|
||||
return
|
||||
}
|
||||
const handler = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onClose, workItem])
|
||||
|
||||
if (!workItem) {
|
||||
return null
|
||||
}
|
||||
|
||||
const Icon = workItem.type === 'pr' ? GitPullRequest : CircleDot
|
||||
const body = details?.body ?? ''
|
||||
const comments = details?.comments ?? []
|
||||
const files = details?.files ?? []
|
||||
const checks = details?.checks ?? []
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width: `${width}px` }}
|
||||
className={cn(
|
||||
'relative flex h-full shrink-0 flex-col border-l border-border/60 bg-card shadow-xl',
|
||||
isResizing && 'select-none'
|
||||
)}
|
||||
>
|
||||
{/* Left-edge resize handle */}
|
||||
<div
|
||||
onMouseDown={onResizeStart}
|
||||
className="absolute left-0 top-0 z-10 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary/40"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize drawer"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex-none border-b border-border/60 px-4 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className="mt-1 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full border px-2 py-0.5 text-[11px] font-medium',
|
||||
getStateTone(workItem)
|
||||
)}
|
||||
>
|
||||
{getStateLabel(workItem)}
|
||||
</span>
|
||||
<span className="font-mono text-[12px] text-muted-foreground">
|
||||
#{workItem.number}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-1 text-[15px] font-semibold leading-tight text-foreground">
|
||||
{workItem.title}
|
||||
</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span>{workItem.author ?? 'unknown'}</span>
|
||||
<span>· {formatRelativeTime(workItem.updatedAt)}</span>
|
||||
{workItem.branchName && (
|
||||
<span className="font-mono text-[10px] text-muted-foreground/80">
|
||||
· {workItem.branchName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{workItem.labels.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{workItem.labels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="rounded-full border border-border/50 bg-background/60 px-2 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => window.api.shell.openUrl(workItem.url)}
|
||||
aria-label="Open on GitHub"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Open on GitHub
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={onClose}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Close · Esc
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs + body */}
|
||||
<div className="min-h-0 flex-1">
|
||||
{error ? (
|
||||
<div className="px-4 py-6 text-[12px] text-destructive">{error}</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value as DrawerTab)}
|
||||
className="flex h-full min-h-0 flex-col gap-0"
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="mx-4 mt-2 justify-start gap-3 border-b border-border/60"
|
||||
>
|
||||
<TabsTrigger value="conversation" className="px-2">
|
||||
<MessageSquare className="size-3.5" />
|
||||
Conversation
|
||||
</TabsTrigger>
|
||||
{workItem.type === 'pr' && (
|
||||
<>
|
||||
<TabsTrigger value="files" className="px-2">
|
||||
<FileText className="size-3.5" />
|
||||
Files
|
||||
{files.length > 0 && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">{files.length}</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="checks" className="px-2">
|
||||
Checks
|
||||
{checks.length > 0 && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">
|
||||
{checks.length}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
|
||||
<TabsContent value="conversation" className="mt-0">
|
||||
<ConversationTab
|
||||
item={workItem}
|
||||
body={body}
|
||||
comments={comments}
|
||||
loading={loading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{workItem.type === 'pr' && (
|
||||
<TabsContent value="files" className="mt-0">
|
||||
{loading && files.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-[12px] text-muted-foreground">
|
||||
No files changed.
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{files.map((file) => (
|
||||
<PRFileRow
|
||||
key={file.path}
|
||||
file={file}
|
||||
repoPath={repoPath ?? ''}
|
||||
prNumber={workItem.number}
|
||||
headSha={details?.headSha}
|
||||
baseSha={details?.baseSha}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{workItem.type === 'pr' && (
|
||||
<TabsContent value="checks" className="mt-0">
|
||||
<ChecksTab checks={checks} loading={loading} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex-none border-t border-border/60 bg-background/40 px-4 py-3">
|
||||
<Button
|
||||
onClick={() => onUse(workItem)}
|
||||
className="w-full justify-center gap-2"
|
||||
aria-label={`Start workspace from ${workItem.type === 'pr' ? 'PR' : 'issue'}`}
|
||||
>
|
||||
{`Start workspace from ${workItem.type === 'pr' ? 'PR' : 'issue'}`}
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import RepoCombobox from '@/components/repo/RepoCombobox'
|
||||
import NewWorkspaceComposerCard from '@/components/NewWorkspaceComposerCard'
|
||||
import GitHubItemDrawer from '@/components/GitHubItemDrawer'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LightRays } from '@/components/ui/light-rays'
|
||||
import { useComposerState } from '@/hooks/useComposerState'
|
||||
|
|
@ -165,6 +166,10 @@ export default function NewWorkspacePage(): React.JSX.Element {
|
|||
const [tasksError, setTasksError] = useState<string | null>(null)
|
||||
const [taskRefreshNonce, setTaskRefreshNonce] = useState(0)
|
||||
const [workItems, setWorkItems] = useState<GitHubWorkItem[]>([])
|
||||
// Why: clicking a GitHub row opens this drawer for a read-only preview.
|
||||
// The composer modal is only opened by the drawer's "Use" button, which
|
||||
// calls the same handleSelectWorkItem as the old direct row-click flow.
|
||||
const [drawerWorkItem, setDrawerWorkItem] = useState<GitHubWorkItem | null>(null)
|
||||
|
||||
const defaultTaskViewPreset = settings?.defaultTaskViewPreset ?? 'all'
|
||||
|
||||
|
|
@ -407,7 +412,9 @@ export default function NewWorkspacePage(): React.JSX.Element {
|
|||
)}
|
||||
|
||||
<div className="relative z-10 flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex-none flex items-center justify-end px-5 py-3 md:px-8 md:py-4">
|
||||
{/* Why: the Esc/discard button is left-aligned to avoid colliding with the
|
||||
right-docked GitHub drawer and app sidebar, which also live on the right edge. */}
|
||||
<div className="flex-none flex items-center justify-start px-5 py-3 md:px-8 md:py-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -603,7 +610,7 @@ export default function NewWorkspacePage(): React.JSX.Element {
|
|||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectWorkItem(item)}
|
||||
onClick={() => setDrawerWorkItem(item)}
|
||||
className="grid w-full gap-4 px-4 py-4 text-left transition hover:bg-muted/40 lg:grid-cols-[96px_minmax(0,1.8fr)_minmax(140px,1fr)_150px_120px_90px]"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
|
|
@ -709,6 +716,16 @@ export default function NewWorkspacePage(): React.JSX.Element {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GitHubItemDrawer
|
||||
workItem={drawerWorkItem}
|
||||
repoPath={selectedRepo?.path ?? null}
|
||||
onUse={(item) => {
|
||||
setDrawerWorkItem(null)
|
||||
handleSelectWorkItem(item)
|
||||
}}
|
||||
onClose={() => setDrawerWorkItem(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -376,6 +376,34 @@ export type GitHubWorkItem = {
|
|||
baseRefName?: string
|
||||
}
|
||||
|
||||
export type GitHubPRFile = {
|
||||
path: string
|
||||
oldPath?: string
|
||||
status: 'added' | 'modified' | 'removed' | 'renamed' | 'copied' | 'changed' | 'unchanged'
|
||||
additions: number
|
||||
deletions: number
|
||||
/** GitHub marks files above its diff size limit as binary-like; we skip content fetches for these. */
|
||||
isBinary: boolean
|
||||
}
|
||||
|
||||
export type GitHubPRFileContents = {
|
||||
original: string
|
||||
modified: string
|
||||
originalIsBinary: boolean
|
||||
modifiedIsBinary: boolean
|
||||
}
|
||||
|
||||
export type GitHubWorkItemDetails = {
|
||||
item: GitHubWorkItem
|
||||
body: string
|
||||
comments: PRComment[]
|
||||
/** Only set for PRs. Head/base SHAs used by the Files tab to fetch per-file content. */
|
||||
headSha?: string
|
||||
baseSha?: string
|
||||
checks?: PRCheckDetail[]
|
||||
files?: GitHubPRFile[]
|
||||
}
|
||||
|
||||
// ─── Hooks (orca.yaml) ──────────────────────────────────────────────
|
||||
export type OrcaHooks = {
|
||||
scripts: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue