-
Notifications
You must be signed in to change notification settings - Fork 5.8k
Render changed files as expandable tree with aggregated diff stats #172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,11 +63,17 @@ import { | |
| MAX_THREAD_TERMINAL_COUNT, | ||
| type ChatMessage, | ||
| type Thread, | ||
| type TurnDiffFileChange, | ||
| type TurnDiffSummary, | ||
| } from "../types"; | ||
| import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; | ||
| import { useTheme } from "../hooks/useTheme"; | ||
| import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | ||
| import { | ||
| buildTurnDiffTree, | ||
| summarizeTurnDiffStats, | ||
| type TurnDiffTreeNode, | ||
| } from "../lib/turnDiffTree"; | ||
| import BranchToolbar from "./BranchToolbar"; | ||
| import GitActionsControl from "./GitActionsControl"; | ||
| import { | ||
|
|
@@ -333,6 +339,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { | |
| pathValue: string; | ||
| kind: "file" | "directory"; | ||
| theme: "light" | "dark"; | ||
| className?: string; | ||
| }) { | ||
| const [failedIconUrl, setFailedIconUrl] = useState<string | null>(null); | ||
| const iconUrl = useMemo( | ||
|
|
@@ -343,9 +350,9 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { | |
|
|
||
| if (failed) { | ||
| return props.kind === "directory" ? ( | ||
| <FolderIcon className="size-4 text-muted-foreground/80" /> | ||
| <FolderIcon className={cn("size-4 text-muted-foreground/80", props.className)} /> | ||
| ) : ( | ||
| <FileIcon className="size-4 text-muted-foreground/80" /> | ||
| <FileIcon className={cn("size-4 text-muted-foreground/80", props.className)} /> | ||
| ); | ||
| } | ||
|
|
||
|
|
@@ -354,7 +361,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { | |
| src={iconUrl} | ||
| alt="" | ||
| aria-hidden="true" | ||
| className="size-4 shrink-0" | ||
| className={cn("size-4 shrink-0", props.className)} | ||
| loading="lazy" | ||
| onError={() => setFailedIconUrl(iconUrl)} | ||
| /> | ||
|
|
@@ -2547,6 +2554,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | |
| isRevertingCheckpoint={isRevertingCheckpoint} | ||
| onImageExpand={onExpandTimelineImage} | ||
| markdownCwd={gitCwd ?? undefined} | ||
| resolvedTheme={resolvedTheme} | ||
| /> | ||
| </div> | ||
|
|
||
|
|
@@ -3126,6 +3134,156 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri | |
| ); | ||
| }); | ||
|
|
||
| function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { | ||
| return stat.additions > 0 || stat.deletions > 0; | ||
| } | ||
|
|
||
| const DiffStatLabel = memo(function DiffStatLabel(props: { | ||
| additions: number; | ||
| deletions: number; | ||
| showParentheses?: boolean; | ||
| }) { | ||
| const { additions, deletions, showParentheses = false } = props; | ||
| return ( | ||
| <> | ||
| {showParentheses && <span className="text-muted-foreground/70">(</span>} | ||
| <span className="text-success">+{additions}</span> | ||
| <span className="mx-0.5 text-muted-foreground/70">/</span> | ||
| <span className="text-destructive">-{deletions}</span> | ||
| {showParentheses && <span className="text-muted-foreground/70">)</span>} | ||
| </> | ||
| ); | ||
| }); | ||
|
|
||
| function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] { | ||
| const paths: string[] = []; | ||
| for (const node of nodes) { | ||
| if (node.kind !== "directory") continue; | ||
| paths.push(node.path); | ||
| paths.push(...collectDirectoryPaths(node.children)); | ||
| } | ||
| return paths; | ||
| } | ||
|
|
||
| function buildDirectoryExpansionState( | ||
| directoryPaths: ReadonlyArray<string>, | ||
| expanded: boolean, | ||
| ): Record<string, boolean> { | ||
| const expandedState: Record<string, boolean> = {}; | ||
| for (const directoryPath of directoryPaths) { | ||
| expandedState[directoryPath] = expanded; | ||
| } | ||
| return expandedState; | ||
| } | ||
|
|
||
| const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ||
| turnId: TurnId; | ||
| files: ReadonlyArray<TurnDiffFileChange>; | ||
| allDirectoriesExpanded: boolean; | ||
| resolvedTheme: "light" | "dark"; | ||
| onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | ||
| }) { | ||
| const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; | ||
| const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); | ||
| const directoryPathsKey = useMemo(() => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes]); | ||
| const allDirectoryExpansionState = useMemo( | ||
| () => | ||
| buildDirectoryExpansionState( | ||
| directoryPathsKey ? directoryPathsKey.split("\u0000") : [], | ||
| allDirectoriesExpanded, | ||
| ), | ||
| [allDirectoriesExpanded, directoryPathsKey], | ||
| ); | ||
| const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>( | ||
| () => buildDirectoryExpansionState((directoryPathsKey ? directoryPathsKey.split("\u0000") : []), true), | ||
| ); | ||
| useEffect(() => { | ||
| setExpandedDirectories(allDirectoryExpansionState); | ||
| }, [allDirectoryExpansionState]); | ||
|
|
||
| const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { | ||
| setExpandedDirectories((current) => ({ | ||
| ...current, | ||
| [pathValue]: !(current[pathValue] ?? fallbackExpanded), | ||
| })); | ||
| }, []); | ||
|
|
||
| const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { | ||
| const leftPadding = 8 + depth * 14; | ||
| if (node.kind === "directory") { | ||
| const isExpanded = expandedDirectories[node.path] ?? depth === 0; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| return ( | ||
| <div key={`dir:${node.path}`}> | ||
| <button | ||
| type="button" | ||
| className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" | ||
| style={{ paddingLeft: `${leftPadding}px` }} | ||
| onClick={() => toggleDirectory(node.path, depth === 0)} | ||
| > | ||
| <ChevronRightIcon | ||
| aria-hidden="true" | ||
| className={cn( | ||
| "size-3.5 shrink-0 text-muted-foreground/70 transition-transform group-hover:text-foreground/80", | ||
| isExpanded && "rotate-90", | ||
| )} | ||
| /> | ||
| {isExpanded ? ( | ||
| <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/75" /> | ||
| ) : ( | ||
| <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" /> | ||
| )} | ||
| <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90"> | ||
| {node.name} | ||
| </span> | ||
| {hasNonZeroStat(node.stat) && ( | ||
| <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> | ||
| <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> | ||
| </span> | ||
| )} | ||
| </button> | ||
| {isExpanded && ( | ||
| <div className="space-y-0.5"> | ||
| {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))} | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <button | ||
| key={`file:${node.path}`} | ||
| type="button" | ||
| className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" | ||
| style={{ paddingLeft: `${leftPadding}px` }} | ||
| onClick={() => onOpenTurnDiff(turnId, node.path)} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inconsistent path format passed to diff handlerMedium Severity When a tree file node is clicked, Additional Locations (1) |
||
| > | ||
| <span aria-hidden="true" className="size-3.5 shrink-0" /> | ||
| <VscodeEntryIcon | ||
| pathValue={node.path} | ||
| kind="file" | ||
| theme={resolvedTheme} | ||
| className="size-3.5 text-muted-foreground/70" | ||
| /> | ||
| <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90"> | ||
| {node.name} | ||
| </span> | ||
| {node.stat && ( | ||
| <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> | ||
| <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> | ||
| </span> | ||
| )} | ||
| </button> | ||
| ); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="space-y-0.5"> | ||
| {treeNodes.map((node) => renderTreeNode(node, 0))} | ||
| </div> | ||
| ); | ||
| }); | ||
|
|
||
| interface MessagesTimelineProps { | ||
| hasMessages: boolean; | ||
| isWorking: boolean; | ||
|
|
@@ -3145,6 +3303,7 @@ interface MessagesTimelineProps { | |
| isRevertingCheckpoint: boolean; | ||
| onImageExpand: (preview: ExpandedImagePreview) => void; | ||
| markdownCwd: string | undefined; | ||
| resolvedTheme: "light" | "dark"; | ||
| } | ||
|
|
||
| type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; | ||
|
|
@@ -3199,6 +3358,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ | |
| isRevertingCheckpoint, | ||
| onImageExpand, | ||
| markdownCwd, | ||
| resolvedTheme, | ||
| }: MessagesTimelineProps) { | ||
| const rows = useMemo<TimelineRow[]>(() => { | ||
| const nextRows: TimelineRow[] = []; | ||
|
|
@@ -3339,6 +3499,15 @@ const MessagesTimeline = memo(function MessagesTimeline({ | |
|
|
||
| const virtualRows = rowVirtualizer.getVirtualItems(); | ||
| const nonVirtualizedRows = rows.slice(virtualizedRowCount); | ||
| const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< | ||
| Record<string, boolean> | ||
| >({}); | ||
| const onToggleAllDirectories = useCallback((turnId: TurnId) => { | ||
| setAllDirectoriesExpandedByTurnId((current) => ({ | ||
| ...current, | ||
| [turnId]: !(current[turnId] ?? true), | ||
| })); | ||
| }, []); | ||
|
|
||
| const renderRowContent = (row: TimelineRow) => ( | ||
| <div className="pb-4"> | ||
|
|
@@ -3507,81 +3676,54 @@ const MessagesTimeline = memo(function MessagesTimeline({ | |
| if (!turnSummary) return null; | ||
| const checkpointFiles = turnSummary.files; | ||
| if (checkpointFiles.length === 0) return null; | ||
| const summaryStat = checkpointFiles.reduce( | ||
| (acc, file) => { | ||
| if ( | ||
| typeof file.additions !== "number" || | ||
| typeof file.deletions !== "number" | ||
| ) { | ||
| return acc; | ||
| } | ||
| return { | ||
| additions: acc.additions + file.additions, | ||
| deletions: acc.deletions + file.deletions, | ||
| }; | ||
| }, | ||
| { additions: 0, deletions: 0 }, | ||
| ); | ||
| const summaryStat = summarizeTurnDiffStats(checkpointFiles); | ||
| const changedFileCountLabel = String(checkpointFiles.length); | ||
| const allDirectoriesExpanded = | ||
| allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; | ||
| return ( | ||
| <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> | ||
| <div className="mb-1.5 flex items-center justify-between gap-2"> | ||
| <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | ||
| <span>Changed files ({changedFileCountLabel})</span> | ||
| {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( | ||
| {hasNonZeroStat(summaryStat) && ( | ||
| <> | ||
| <span className="mx-1">•</span> | ||
| <span className="text-success">+{summaryStat.additions}</span> | ||
| <span className="mx-0.5 text-muted-foreground/70">/</span> | ||
| <span className="text-destructive">-{summaryStat.deletions}</span> | ||
| <DiffStatLabel | ||
| additions={summaryStat.additions} | ||
| deletions={summaryStat.deletions} | ||
| /> | ||
| </> | ||
| )} | ||
| </p> | ||
| <Button | ||
| type="button" | ||
| size="xs" | ||
| variant="outline" | ||
| onClick={() => | ||
| onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path) | ||
| } | ||
| > | ||
| View diff | ||
| </Button> | ||
| </div> | ||
| <div className="flex flex-wrap gap-1.5"> | ||
| {checkpointFiles.map((file) => ( | ||
| <button | ||
| key={`${turnSummary.turnId}:${file.path}`} | ||
| <div className="flex items-center gap-1.5"> | ||
| <Button | ||
| type="button" | ||
| className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90" | ||
| onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} | ||
| size="xs" | ||
| variant="outline" | ||
| onClick={() => onToggleAllDirectories(turnSummary.turnId)} | ||
| > | ||
| {(() => { | ||
| const stat = | ||
| typeof file.additions === "number" && | ||
| typeof file.deletions === "number" | ||
| ? { | ||
| additions: file.additions, | ||
| deletions: file.deletions, | ||
| } | ||
| : null; | ||
| if (!stat) { | ||
| return file.path; | ||
| } | ||
| return ( | ||
| <> | ||
| <span>{file.path}</span> | ||
| <span className="ml-1 text-muted-foreground/70">(</span> | ||
| <span className="text-success">+{stat.additions}</span> | ||
| <span className="mx-0.5 text-muted-foreground/70">/</span> | ||
| <span className="text-destructive">-{stat.deletions}</span> | ||
| <span className="text-muted-foreground/70">)</span> | ||
| </> | ||
| ); | ||
| })()} | ||
| </button> | ||
| ))} | ||
| {allDirectoriesExpanded ? "Collapse all" : "Expand all"} | ||
| </Button> | ||
| <Button | ||
| type="button" | ||
| size="xs" | ||
| variant="outline" | ||
| onClick={() => | ||
| onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path) | ||
| } | ||
| > | ||
| View diff | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| <ChangedFilesTree | ||
| key={`changed-files-tree:${turnSummary.turnId}`} | ||
| turnId={turnSummary.turnId} | ||
| files={checkpointFiles} | ||
| allDirectoriesExpanded={allDirectoriesExpanded} | ||
| resolvedTheme={resolvedTheme} | ||
| onOpenTurnDiff={onOpenTurnDiff} | ||
| /> | ||
| </div> | ||
| ); | ||
| })()} | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused
showParenthesesprop is dead codeLow Severity
The
showParenthesesprop onDiffStatLabelis defined with a default offalse, but no caller ever passestrue. This appears to be leftover scaffolding from the old inline-chip layout that rendered parentheses around per-file stats — a format that no longer exists after this refactor.