Skip to content

Commit 8e5114a

Browse files
mrayushmehrotraorca-ideJinwoo-H
authored
feat: implement hierarchical tree view for PR diff file list fixes #1109 (#1278)
Co-authored-by: Orca <help@stably.ai> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
1 parent cb73b37 commit 8e5114a

3 files changed

Lines changed: 489 additions & 15 deletions

File tree

src/renderer/src/components/GitHubItemDialog.tsx

Lines changed: 241 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable max-lines -- Why: the GH item dialog 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. */
22
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
33
import {
4+
AlignJustify,
45
ArrowDown,
56
ArrowRight,
67
ArrowUp,
@@ -11,7 +12,10 @@ import {
1112
CircleDot,
1213
ExternalLink,
1314
FileText,
15+
Folder,
16+
FolderOpen,
1417
GitPullRequest,
18+
LayoutList,
1519
LoaderCircle,
1620
MessageSquare,
1721
MessageSquarePlus,
@@ -36,6 +40,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
3640
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
3741
import { detectLanguage } from '@/lib/language-detect'
3842
import { cn } from '@/lib/utils'
43+
import { buildDiffTree, type DiffTreeNode } from '@/components/pr-diff-tree'
3944
import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-helpers'
4045
import {
4146
filterPRCommentsByAudience,
@@ -315,6 +320,130 @@ type FileRowProps = {
315320
baseSha: string | undefined
316321
}
317322

323+
type DiffViewMode = 'flat' | 'tree'
324+
325+
// ─── Tree view components ────────────────────────────────────────────
326+
327+
type DiffTreeNodeProps = {
328+
node: DiffTreeNode
329+
depth: number
330+
repoPath: string
331+
prNumber: number
332+
headSha: string | undefined
333+
baseSha: string | undefined
334+
onCommentAdded: (comment: PRComment) => void
335+
}
336+
337+
function PRDiffTreeNode({
338+
node,
339+
depth,
340+
repoPath,
341+
prNumber,
342+
headSha,
343+
baseSha,
344+
onCommentAdded
345+
}: DiffTreeNodeProps): React.JSX.Element {
346+
const [open, setOpen] = useState(true)
347+
348+
if (node.kind === 'file') {
349+
return (
350+
<PRFileRow
351+
file={node.file}
352+
repoPath={repoPath}
353+
prNumber={prNumber}
354+
headSha={headSha}
355+
baseSha={baseSha}
356+
onCommentAdded={onCommentAdded}
357+
// Why: tree-view file rows are indented by a CSS left-padding proportional
358+
// to depth so the expand chevron of PRFileRow stays at position 0 while
359+
// the folder hierarchy is communicated purely through indentation.
360+
indentDepth={depth}
361+
label={node.name}
362+
/>
363+
)
364+
}
365+
366+
// Directory node
367+
return (
368+
<div role="treeitem" aria-expanded={open}>
369+
<button
370+
type="button"
371+
onClick={() => setOpen((v) => !v)}
372+
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-left transition hover:bg-muted/40"
373+
style={{ paddingLeft: `${12 + depth * 16}px` }}
374+
aria-label={`${open ? 'Collapse' : 'Expand'} folder ${node.name}`}
375+
>
376+
{open ? (
377+
<>
378+
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
379+
<FolderOpen className="size-3.5 shrink-0 text-amber-400" />
380+
</>
381+
) : (
382+
<>
383+
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
384+
<Folder className="size-3.5 shrink-0 text-amber-400" />
385+
</>
386+
)}
387+
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground">
388+
{node.name}
389+
</span>
390+
</button>
391+
{open && (
392+
<div role="group">
393+
{node.children.map((child) => (
394+
<PRDiffTreeNode
395+
key={child.kind === 'file' ? child.file.path : child.path}
396+
node={child}
397+
depth={depth + 1}
398+
repoPath={repoPath}
399+
prNumber={prNumber}
400+
headSha={headSha}
401+
baseSha={baseSha}
402+
onCommentAdded={onCommentAdded}
403+
/>
404+
))}
405+
</div>
406+
)}
407+
</div>
408+
)
409+
}
410+
411+
type PRDiffTreeViewProps = {
412+
files: GitHubPRFile[]
413+
repoPath: string
414+
prNumber: number
415+
headSha: string | undefined
416+
baseSha: string | undefined
417+
onCommentAdded: (comment: PRComment) => void
418+
}
419+
420+
function PRDiffTreeView({
421+
files,
422+
repoPath,
423+
prNumber,
424+
headSha,
425+
baseSha,
426+
onCommentAdded
427+
}: PRDiffTreeViewProps): React.JSX.Element {
428+
const tree = useMemo(() => buildDiffTree(files), [files])
429+
return (
430+
<div role="tree" aria-label="Changed files">
431+
{tree.map((node) => (
432+
<PRDiffTreeNode
433+
key={node.kind === 'file' ? node.file.path : node.path}
434+
node={node}
435+
depth={0}
436+
repoPath={repoPath}
437+
prNumber={prNumber}
438+
headSha={headSha}
439+
baseSha={baseSha}
440+
onCommentAdded={onCommentAdded}
441+
/>
442+
))}
443+
</div>
444+
)
445+
}
446+
318447
// Why: bounded LRU — opening many PRs with many files during a session
319448
// would otherwise grow this module-level map without bound until reload.
320449
const PR_FILE_CONTENT_CACHE_MAX = 64
@@ -396,8 +525,14 @@ function PRFileRow({
396525
prNumber,
397526
headSha,
398527
baseSha,
399-
onCommentAdded
400-
}: FileRowProps & { onCommentAdded: (comment: PRComment) => void }): React.JSX.Element {
528+
onCommentAdded,
529+
indentDepth = 0,
530+
label
531+
}: FileRowProps & {
532+
onCommentAdded: (comment: PRComment) => void
533+
indentDepth?: number
534+
label?: string
535+
}): React.JSX.Element {
401536
const [expanded, setExpanded] = useState(false)
402537
const [contents, setContents] = useState<GitHubPRFileContents | null>(null)
403538
const [loading, setLoading] = useState(false)
@@ -469,11 +604,12 @@ function PRFileRow({
469604
)
470605

471606
return (
472-
<div className="border-b border-border/50">
607+
<div className="border-b border-border/50" {...(label != null ? { role: 'treeitem' } : {})}>
473608
<button
474609
type="button"
475610
onClick={handleToggle}
476-
className="flex w-full items-center gap-2 px-3 py-2 text-left transition hover:bg-muted/40"
611+
className="flex w-full items-center gap-2 py-2 pr-3 text-left transition hover:bg-muted/40"
612+
style={{ paddingLeft: `${12 + indentDepth * 16}px` }}
477613
>
478614
{expanded ? (
479615
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
@@ -491,13 +627,44 @@ function PRFileRow({
491627
</span>
492628
<span className="min-w-0 flex-1 truncate font-mono text-[12px] text-foreground">
493629
{file.oldPath && file.oldPath !== file.path ? (
494-
<>
495-
<span className="text-muted-foreground">{file.oldPath}</span>
496-
<span className="mx-1 text-muted-foreground"></span>
497-
{file.path}
498-
</>
630+
label ? (
631+
// Why: in tree view we only have room for basenames, but still need to
632+
// communicate the rename so the user doesn't have to expand or switch
633+
// to flat view to discover what was renamed. When basenames match (i.e.
634+
// only the directory changed), we include the parent directory so the
635+
// display isn't a meaningless "foo.ts → foo.ts".
636+
(() => {
637+
const oldBase = file.oldPath!.split('/').pop() ?? file.oldPath!
638+
if (oldBase === label) {
639+
const oldParts = file.oldPath!.split('/')
640+
const newParts = file.path.split('/')
641+
const oldShort = oldParts.slice(-2).join('/')
642+
const newShort = newParts.slice(-2).join('/')
643+
return (
644+
<>
645+
<span className="text-muted-foreground">{oldShort}</span>
646+
<span className="mx-1 text-muted-foreground"></span>
647+
{newShort}
648+
</>
649+
)
650+
}
651+
return (
652+
<>
653+
<span className="text-muted-foreground">{oldBase}</span>
654+
<span className="mx-1 text-muted-foreground"></span>
655+
{label}
656+
</>
657+
)
658+
})()
659+
) : (
660+
<>
661+
<span className="text-muted-foreground">{file.oldPath}</span>
662+
<span className="mx-1 text-muted-foreground"></span>
663+
{file.path}
664+
</>
665+
)
499666
) : (
500-
file.path
667+
(label ?? file.path)
501668
)}
502669
</span>
503670
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
@@ -1906,6 +2073,7 @@ export default function GitHubItemDialog({
19062073
const [error, setError] = useState<string | null>(null)
19072074
const [localState, setLocalState] = useState<GitHubWorkItem['state']>(workItem?.state ?? 'open')
19082075
const [localLabels, setLocalLabels] = useState<string[]>(workItem?.labels ?? [])
2076+
const [diffViewMode, setDiffViewMode] = useState<DiffViewMode>('flat')
19092077
const workItemId = workItem?.id
19102078
const workItemState = workItem?.state
19112079
const workItemLabels = workItem?.labels
@@ -2202,17 +2370,75 @@ export default function GitHubItemDialog({
22022370
</div>
22032371
) : (
22042372
<div>
2205-
{files.map((file) => (
2206-
<PRFileRow
2207-
key={file.path}
2208-
file={file}
2373+
{/* Files-tab toolbar: view-mode toggle */}
2374+
<div className="flex items-center justify-end gap-1 border-b border-border/40 px-3 py-1.5">
2375+
<Tooltip>
2376+
<TooltipTrigger asChild>
2377+
<button
2378+
id="pr-files-flat-view"
2379+
type="button"
2380+
onClick={() => setDiffViewMode('flat')}
2381+
aria-label="Flat view"
2382+
aria-pressed={diffViewMode === 'flat'}
2383+
className={cn(
2384+
'flex size-6 items-center justify-center rounded transition hover:bg-muted',
2385+
diffViewMode === 'flat'
2386+
? 'bg-muted text-foreground'
2387+
: 'text-muted-foreground'
2388+
)}
2389+
>
2390+
<AlignJustify className="size-3.5" />
2391+
</button>
2392+
</TooltipTrigger>
2393+
<TooltipContent side="bottom" sideOffset={4}>
2394+
Flat view
2395+
</TooltipContent>
2396+
</Tooltip>
2397+
<Tooltip>
2398+
<TooltipTrigger asChild>
2399+
<button
2400+
id="pr-files-tree-view"
2401+
type="button"
2402+
onClick={() => setDiffViewMode('tree')}
2403+
aria-label="Tree view"
2404+
aria-pressed={diffViewMode === 'tree'}
2405+
className={cn(
2406+
'flex size-6 items-center justify-center rounded transition hover:bg-muted',
2407+
diffViewMode === 'tree'
2408+
? 'bg-muted text-foreground'
2409+
: 'text-muted-foreground'
2410+
)}
2411+
>
2412+
<LayoutList className="size-3.5" />
2413+
</button>
2414+
</TooltipTrigger>
2415+
<TooltipContent side="bottom" sideOffset={4}>
2416+
Tree view
2417+
</TooltipContent>
2418+
</Tooltip>
2419+
</div>
2420+
{diffViewMode === 'flat' ? (
2421+
files.map((file) => (
2422+
<PRFileRow
2423+
key={file.path}
2424+
file={file}
2425+
repoPath={repoPath ?? ''}
2426+
prNumber={workItem.number}
2427+
headSha={details?.headSha}
2428+
baseSha={details?.baseSha}
2429+
onCommentAdded={appendOptimisticComment}
2430+
/>
2431+
))
2432+
) : (
2433+
<PRDiffTreeView
2434+
files={files}
22092435
repoPath={repoPath ?? ''}
22102436
prNumber={workItem.number}
22112437
headSha={details?.headSha}
22122438
baseSha={details?.baseSha}
22132439
onCommentAdded={appendOptimisticComment}
22142440
/>
2215-
))}
2441+
)}
22162442
</div>
22172443
)}
22182444
</TabsContent>

0 commit comments

Comments
 (0)