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. */
22import React , { Suspense , lazy , useCallback , useEffect , useMemo , useRef , useState } from 'react'
33import {
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
3640import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
3741import { detectLanguage } from '@/lib/language-detect'
3842import { cn } from '@/lib/utils'
43+ import { buildDiffTree , type DiffTreeNode } from '@/components/pr-diff-tree'
3944import { CHECK_COLOR , CHECK_ICON } from '@/components/right-sidebar/checks-helpers'
4045import {
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.
320449const 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