a very good jj gui
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(diff): implement two-column layout with file list sidebar

Refactor DiffPanel to use a split-pane layout for better file navigation:

- Add FileList component with tree/flat view toggle and search filtering
- Add SingleFileDiff component for isolated file diff rendering
- Left panel shows changed files with status icons (added/modified/deleted)
- Right panel shows diff for selected file
- Tree view collapses single-child directories (e.g., apps/desktop/src)
- Keyboard navigation (j/k, arrows) in file list
- Per-file diff style toggle (unified/split) in toolbar
- Make RevisionHeader commit body collapsible
- Add sticky header styling for diff sections
- Auto-select first file when revision changes
- Display total additions/deletions summary

+697 -87
+2 -2
apps/desktop/src/components/AppShell.tsx
··· 444 444 ) : ( 445 445 // Split mode: revision list + diff panel (vertical on narrow screens) 446 446 <ResizablePanelGroup orientation={isNarrowScreen ? "vertical" : "horizontal"}> 447 - <ResizablePanel defaultSize={isNarrowScreen ? 40 : 33} minSize={20}> 447 + <ResizablePanel defaultSize={isNarrowScreen ? 40 : 25} minSize={15}> 448 448 <section className="h-full relative" aria-label="Revision list"> 449 449 <RevisionGraph 450 450 ref={revisionGraphRef} ··· 459 459 </section> 460 460 </ResizablePanel> 461 461 <ResizableHandle withHandle /> 462 - <ResizablePanel defaultSize={isNarrowScreen ? 60 : 67} minSize={30}> 462 + <ResizablePanel defaultSize={isNarrowScreen ? 60 : 75} minSize={30}> 463 463 <aside className="h-full" aria-label="Diff viewer"> 464 464 <PrerenderedDiffPanel 465 465 repoPath={activeProject?.path ?? null}
+167 -81
apps/desktop/src/components/DiffPanel.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { useLiveQuery } from "@tanstack/react-db"; 3 - import { useSearch } from "@tanstack/react-router"; 4 - import { Route } from "@/routes/project.$projectId"; 5 - import { useEffect, useRef } from "react"; 6 - import { type DiffViewState, diffViewStateAtom } from "@/atoms"; 7 - // Note: useEffect is kept for scroll-to-file behavior, which is acceptable 8 - // (DOM side effect, not state synchronization) 9 - import { DiffToolbar, FileDiffSection, RevisionHeader } from "@/components/diff"; 10 - import { emptyDiffCollection, getRevisionDiffCollection } from "@/db"; 3 + import { Columns2Icon, RowsIcon } from "lucide-react"; 4 + import { useEffect, useMemo, useRef, useState } from "react"; 5 + import { type DiffStyle, type DiffViewState, diffStyleAtom, diffViewStateAtom } from "@/atoms"; 6 + import { FileList, RevisionHeader, SingleFileDiff } from "@/components/diff"; 7 + import { Button } from "@/components/ui/button"; 8 + import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; 9 + import { 10 + emptyChangesCollection, 11 + emptyDiffCollection, 12 + getRevisionChangesCollection, 13 + getRevisionDiffCollection, 14 + } from "@/db"; 11 15 import { useDiffPanelKeyboard } from "@/hooks/useDiffPanelKeyboard"; 12 16 import type { Revision } from "@/tauri-commands"; 13 17 ··· 59 63 return fileDiffs; 60 64 } 61 65 66 + /** 67 + * Parse additions and deletions from a single patch. 68 + */ 69 + function parsePatchStats(patch: string): { additions: number; deletions: number } { 70 + let additions = 0; 71 + let deletions = 0; 72 + const lines = patch.split("\n"); 73 + 74 + for (const line of lines) { 75 + // Skip header lines 76 + if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("@@")) { 77 + continue; 78 + } 79 + if (line.startsWith("+") && !line.startsWith("++")) { 80 + additions++; 81 + } else if (line.startsWith("-") && !line.startsWith("--")) { 82 + deletions++; 83 + } 84 + } 85 + 86 + return { additions, deletions }; 87 + } 88 + 62 89 export function PrerenderedDiffPanel({ 63 90 repoPath, 64 91 revisions, ··· 75 102 * Get the current diff view state, resetting if the changeId has changed. 76 103 * This is a pure derivation - no useEffect needed for state sync. 77 104 */ 78 - function getDiffViewState( 79 - currentState: DiffViewState, 80 - changeId: string | null, 81 - firstFilePath: string | null, 82 - ): DiffViewState { 105 + function getDiffViewState(currentState: DiffViewState, changeId: string | null): DiffViewState { 83 106 // If changeId matches, return current state as-is 84 107 if (currentState.forChangeId === changeId) { 85 108 return currentState; ··· 87 110 // ChangeId changed - return reset state 88 111 return { 89 112 forChangeId: changeId, 90 - expandedFiles: firstFilePath ? new Set([firstFilePath]) : new Set(), 113 + expandedFiles: new Set(), 91 114 styleOverrides: new Map(), 92 115 }; 93 116 } 94 117 95 118 export function DiffPanel({ repoPath, changeId, revision }: DiffPanelProps) { 96 - const search = useSearch({ from: Route.fullPath }); 97 - const { file: selectedFilePath } = search; 98 - const fileRefsMap = useRef<Map<string, React.RefObject<HTMLDivElement | null>>>(new Map()); 119 + const scrollContainerRef = useRef<HTMLDivElement>(null); 120 + const [globalDiffStyle] = useAtom(diffStyleAtom); 99 121 const [diffViewState, setDiffViewState] = useAtom(diffViewStateAtom); 100 - const scrollContainerRef = useRef<HTMLDivElement>(null); 122 + const [selectedFile, setSelectedFile] = useState<string | null>(null); 123 + const prevChangeIdRef = useRef<string | null>(null); 124 + 125 + // Get effective diff style for selected file 126 + const effectiveDiffStyle = selectedFile 127 + ? (diffViewState.styleOverrides.get(selectedFile) ?? globalDiffStyle) 128 + : globalDiffStyle; 129 + 130 + function handleSetLocalStyle(style: DiffStyle) { 131 + if (!selectedFile) return; 132 + setDiffViewState((prev) => { 133 + const next = new Map(prev.styleOverrides); 134 + next.set(selectedFile, style); 135 + return { ...prev, styleOverrides: next }; 136 + }); 137 + } 138 + 139 + // Reset selected file when changeId changes 140 + if (prevChangeIdRef.current !== changeId) { 141 + prevChangeIdRef.current = changeId; 142 + if (selectedFile !== null) { 143 + setSelectedFile(null); 144 + } 145 + } 101 146 102 147 // Keyboard navigation 103 148 useDiffPanelKeyboard({ scrollContainerRef }); 104 149 105 - // Always fetch all diffs 150 + // Fetch file changes (for the file list with status) 151 + const changesCollection = 152 + repoPath && changeId 153 + ? getRevisionChangesCollection(repoPath, changeId) 154 + : emptyChangesCollection; 155 + const { data: changedFiles = [] } = useLiveQuery(changesCollection); 156 + 157 + // Fetch full diff (for the diff content) 106 158 const diffCollection = 107 159 repoPath && changeId ? getRevisionDiffCollection(repoPath, changeId) : emptyDiffCollection; 108 160 const { data: diffEntries = [], isLoading } = useLiveQuery(diffCollection); 109 161 const revisionDiff = diffEntries[0]?.content ?? ""; 110 162 111 - const fileDiffs = splitMultiFileDiff(revisionDiff); 112 - const filePaths = fileDiffs.map(extractFilePath); 163 + // Parse diff into individual file patches 164 + const fileDiffs = useMemo(() => splitMultiFileDiff(revisionDiff), [revisionDiff]); 113 165 114 - const firstFilePath = filePaths[0] ?? null; 166 + // Create a map from file path to patch content 167 + const patchMap = useMemo(() => { 168 + const map = new Map<string, string>(); 169 + for (const patch of fileDiffs) { 170 + const path = extractFilePath(patch); 171 + map.set(path, patch); 172 + } 173 + return map; 174 + }, [fileDiffs]); 175 + 176 + // Calculate total stats 177 + const { totalAdditions, totalDeletions } = useMemo(() => { 178 + let additions = 0; 179 + let deletions = 0; 180 + for (const patch of fileDiffs) { 181 + const stats = parsePatchStats(patch); 182 + additions += stats.additions; 183 + deletions += stats.deletions; 184 + } 185 + return { totalAdditions: additions, totalDeletions: deletions }; 186 + }, [fileDiffs]); 115 187 116 188 // Derive the effective state - resets automatically when changeId changes 117 - const effectiveState = getDiffViewState(diffViewState, changeId, firstFilePath); 189 + const effectiveState = getDiffViewState(diffViewState, changeId); 118 190 119 191 // Sync atom if state was reset (only writes when needed) 120 192 if (effectiveState !== diffViewState) { 121 193 setDiffViewState(effectiveState); 122 194 } 123 195 124 - const { expandedFiles } = effectiveState; 125 - 126 - // Get or create ref for each file 127 - const getFileRef = (filePath: string): React.RefObject<HTMLDivElement | null> => { 128 - if (!fileRefsMap.current.has(filePath)) { 129 - fileRefsMap.current.set(filePath, { current: null }); 196 + // Auto-select first file when files load and none selected 197 + useEffect(() => { 198 + if (changedFiles.length > 0 && !selectedFile) { 199 + setSelectedFile(changedFiles[0].path); 130 200 } 131 - // biome-ignore lint/style/noNonNullAssertion: Guaranteed to exist since we set it above 132 - return fileRefsMap.current.get(filePath)!; 133 - }; 134 - 135 - // Toggle all folds 136 - const allExpanded = filePaths.length > 0 && filePaths.every((p) => expandedFiles.has(p)); 201 + }, [changedFiles, selectedFile]); 137 202 138 - function handleToggleAllFolds() { 139 - setDiffViewState((prev) => ({ 140 - ...prev, 141 - expandedFiles: allExpanded ? new Set() : new Set(filePaths), 142 - })); 143 - } 144 - 145 - // Scroll to selected file when it changes 146 - useEffect(() => { 147 - if (!selectedFilePath || fileDiffs.length === 0) return; 148 - 149 - // Use requestAnimationFrame to ensure DOM is updated before scrolling 150 - requestAnimationFrame(() => { 151 - const ref = fileRefsMap.current.get(selectedFilePath); 152 - if (ref?.current) { 153 - ref.current.scrollIntoView({ 154 - behavior: "instant", 155 - block: "start", 156 - }); 157 - } 158 - }); 159 - }, [selectedFilePath, fileDiffs.length]); 203 + // Get patch for selected file 204 + const selectedPatch = selectedFile ? (patchMap.get(selectedFile) ?? null) : null; 160 205 161 206 if (!repoPath || !changeId) { 162 207 return ( ··· 174 219 ); 175 220 } 176 221 177 - if (fileDiffs.length === 0) { 222 + if (changedFiles.length === 0) { 178 223 return ( 179 224 <div className="flex items-center justify-center h-full text-muted-foreground text-sm"> 180 225 No changes in this revision ··· 183 228 } 184 229 185 230 return ( 186 - <div ref={scrollContainerRef} className="h-full overflow-auto bg-background outline-none"> 231 + <div 232 + ref={scrollContainerRef} 233 + className="h-full w-full flex flex-col bg-background outline-none overflow-hidden" 234 + > 235 + {/* Revision header */} 187 236 {revision && ( 188 - <div className="px-4 pt-6 pb-2"> 237 + <div className="px-4 pt-4 pb-2 shrink-0"> 189 238 <RevisionHeader revision={revision} /> 190 239 </div> 191 240 )} 192 - <DiffToolbar 193 - fileCount={fileDiffs.length} 194 - allExpanded={allExpanded} 195 - onToggleAllFolds={handleToggleAllFolds} 196 - /> 197 - {/* File diffs */} 198 - <div className="p-4 space-y-4"> 199 - {fileDiffs.map((patch) => { 200 - const filePath = extractFilePath(patch); 201 - const fileRef = getFileRef(filePath); 202 - const isSelected = selectedFilePath === filePath; 203 241 204 - return ( 205 - <FileDiffSection 206 - key={filePath} 207 - patch={patch} 208 - filePath={filePath} 209 - isSelected={isSelected} 210 - fileRef={fileRef} 211 - /> 212 - ); 213 - })} 242 + {/* Toolbar spanning both columns */} 243 + <div className="flex items-center justify-between px-3 py-2 border-b border-border bg-background shrink-0 min-w-0"> 244 + <code className="font-mono text-xs text-foreground truncate min-w-0"> 245 + {selectedFile ?? "No file selected"} 246 + </code> 247 + <div className="flex items-center gap-0.5 shrink-0 ml-2"> 248 + <Button 249 + variant={effectiveDiffStyle === "unified" ? "secondary" : "ghost"} 250 + size="icon-xs" 251 + onClick={() => handleSetLocalStyle("unified")} 252 + title="Unified diff view" 253 + className="h-6 w-6" 254 + disabled={!selectedFile} 255 + > 256 + <RowsIcon className="size-3" /> 257 + </Button> 258 + <Button 259 + variant={effectiveDiffStyle === "split" ? "secondary" : "ghost"} 260 + size="icon-xs" 261 + onClick={() => handleSetLocalStyle("split")} 262 + title="Split diff view" 263 + className="h-6 w-6" 264 + disabled={!selectedFile} 265 + > 266 + <Columns2Icon className="size-3" /> 267 + </Button> 268 + </div> 269 + </div> 270 + 271 + {/* Two-column layout wrapper */} 272 + <div className="relative flex-1 min-h-0 min-w-0"> 273 + <ResizablePanelGroup 274 + id="diff-panel-layout" 275 + orientation="horizontal" 276 + className="absolute inset-0" 277 + > 278 + {/* File list panel */} 279 + <ResizablePanel id="diff-file-list" defaultSize="30%" minSize="15%" maxSize="50%"> 280 + <div className="h-full w-full min-w-0"> 281 + <FileList 282 + files={changedFiles} 283 + selectedFile={selectedFile} 284 + onSelectFile={setSelectedFile} 285 + totalAdditions={totalAdditions} 286 + totalDeletions={totalDeletions} 287 + /> 288 + </div> 289 + </ResizablePanel> 290 + 291 + <ResizableHandle withHandle /> 292 + 293 + {/* Diff content panel */} 294 + <ResizablePanel id="diff-content" defaultSize="70%"> 295 + <div className="h-full w-full min-w-0"> 296 + <SingleFileDiff patch={selectedPatch} filePath={selectedFile} /> 297 + </div> 298 + </ResizablePanel> 299 + </ResizablePanelGroup> 214 300 </div> 215 301 </div> 216 302 );
+434
apps/desktop/src/components/diff/FileList.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import { 3 + ChevronDownIcon, 4 + ChevronRightIcon, 5 + FileIcon, 6 + FileMinus2Icon, 7 + FilePenIcon, 8 + FilePlus2Icon, 9 + FolderIcon, 10 + FolderOpenIcon, 11 + FolderTreeIcon, 12 + ListIcon, 13 + SearchIcon, 14 + } from "lucide-react"; 15 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 16 + import { focusPanelAtom } from "@/atoms"; 17 + import { Button } from "@/components/ui/button"; 18 + import { Input } from "@/components/ui/input"; 19 + import { ScrollArea } from "@/components/ui/scroll-area"; 20 + import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 21 + import { cn } from "@/lib/utils"; 22 + import type { ChangedFile, ChangedFileStatus } from "@/schemas"; 23 + 24 + interface FileListProps { 25 + files: ChangedFile[]; 26 + selectedFile: string | null; 27 + onSelectFile: (filePath: string) => void; 28 + totalAdditions: number; 29 + totalDeletions: number; 30 + } 31 + 32 + function getFileStatusIcon(status: ChangedFileStatus) { 33 + switch (status) { 34 + case "added": 35 + return <FilePlus2Icon className="size-4 text-green-500 shrink-0" />; 36 + case "deleted": 37 + return <FileMinus2Icon className="size-4 text-red-500 shrink-0" />; 38 + case "modified": 39 + return <FilePenIcon className="size-4 text-yellow-500 shrink-0" />; 40 + default: 41 + return <FileIcon className="size-4 text-muted-foreground shrink-0" />; 42 + } 43 + } 44 + 45 + function getFileName(filePath: string): string { 46 + return filePath.split("/").pop() ?? filePath; 47 + } 48 + 49 + function getFileDirectory(filePath: string): string { 50 + const parts = filePath.split("/"); 51 + if (parts.length <= 1) return ""; 52 + parts.pop(); 53 + return parts.join("/"); 54 + } 55 + 56 + // Tree node structure 57 + interface TreeNode { 58 + name: string; 59 + path: string; 60 + isDirectory: boolean; 61 + children: Map<string, TreeNode>; 62 + file?: ChangedFile; 63 + } 64 + 65 + function buildTree(files: ChangedFile[]): TreeNode { 66 + const root: TreeNode = { 67 + name: "", 68 + path: "", 69 + isDirectory: true, 70 + children: new Map(), 71 + }; 72 + 73 + for (const file of files) { 74 + const parts = file.path.split("/"); 75 + let current = root; 76 + 77 + for (let i = 0; i < parts.length; i++) { 78 + const part = parts[i]; 79 + const isLast = i === parts.length - 1; 80 + const pathSoFar = parts.slice(0, i + 1).join("/"); 81 + 82 + if (!current.children.has(part)) { 83 + current.children.set(part, { 84 + name: part, 85 + path: pathSoFar, 86 + isDirectory: !isLast, 87 + children: new Map(), 88 + file: isLast ? file : undefined, 89 + }); 90 + } 91 + 92 + const nextNode = current.children.get(part); 93 + if (nextNode) { 94 + current = nextNode; 95 + } 96 + } 97 + } 98 + 99 + return root; 100 + } 101 + 102 + // Flatten single-child directories (e.g., "apps/desktop/src" becomes one node) 103 + function collapseSingleChildDirs(node: TreeNode): TreeNode { 104 + // Process children first 105 + const processedChildren = new Map<string, TreeNode>(); 106 + 107 + for (const [key, child] of node.children) { 108 + const processed = collapseSingleChildDirs(child); 109 + processedChildren.set(key, processed); 110 + } 111 + 112 + node.children = processedChildren; 113 + 114 + // If this directory has exactly one child that is also a directory, merge them 115 + if (node.isDirectory && node.children.size === 1) { 116 + const [, onlyChild] = [...node.children.entries()][0]; 117 + if (onlyChild.isDirectory) { 118 + const mergedName = node.name ? `${node.name}/${onlyChild.name}` : onlyChild.name; 119 + return { 120 + ...onlyChild, 121 + name: mergedName, 122 + }; 123 + } 124 + } 125 + 126 + return node; 127 + } 128 + 129 + // Sort tree nodes: directories first, then alphabetically 130 + function getSortedChildren(node: TreeNode): TreeNode[] { 131 + const children = Array.from(node.children.values()); 132 + return children.sort((a, b) => { 133 + if (a.isDirectory !== b.isDirectory) { 134 + return a.isDirectory ? -1 : 1; 135 + } 136 + return a.name.localeCompare(b.name); 137 + }); 138 + } 139 + 140 + interface TreeNodeComponentProps { 141 + node: TreeNode; 142 + depth: number; 143 + selectedFile: string | null; 144 + onSelectFile: (filePath: string) => void; 145 + expandedDirs: Set<string>; 146 + toggleDir: (path: string) => void; 147 + itemRefs: React.RefObject<Map<string, HTMLButtonElement>>; 148 + } 149 + 150 + function TreeNodeComponent({ 151 + node, 152 + depth, 153 + selectedFile, 154 + onSelectFile, 155 + expandedDirs, 156 + toggleDir, 157 + itemRefs, 158 + }: TreeNodeComponentProps) { 159 + const isExpanded = expandedDirs.has(node.path); 160 + const sortedChildren = getSortedChildren(node); 161 + 162 + if (node.isDirectory) { 163 + return ( 164 + <div> 165 + <button 166 + type="button" 167 + onClick={() => toggleDir(node.path)} 168 + className={cn( 169 + "w-full flex items-center gap-1.5 px-3 py-1 text-left text-sm transition-colors", 170 + "hover:bg-accent/50 text-muted-foreground", 171 + )} 172 + style={{ paddingLeft: `${depth * 12 + 12}px` }} 173 + > 174 + {isExpanded ? ( 175 + <ChevronDownIcon className="size-3 shrink-0" /> 176 + ) : ( 177 + <ChevronRightIcon className="size-3 shrink-0" /> 178 + )} 179 + {isExpanded ? ( 180 + <FolderOpenIcon className="size-4 shrink-0 text-amber-500" /> 181 + ) : ( 182 + <FolderIcon className="size-4 shrink-0 text-amber-500" /> 183 + )} 184 + <span className="truncate">{node.name}</span> 185 + </button> 186 + {isExpanded && ( 187 + <div> 188 + {sortedChildren.map((child) => ( 189 + <TreeNodeComponent 190 + key={child.path} 191 + node={child} 192 + depth={depth + 1} 193 + selectedFile={selectedFile} 194 + onSelectFile={onSelectFile} 195 + expandedDirs={expandedDirs} 196 + toggleDir={toggleDir} 197 + itemRefs={itemRefs} 198 + /> 199 + ))} 200 + </div> 201 + )} 202 + </div> 203 + ); 204 + } 205 + 206 + // File node 207 + const isSelected = node.path === selectedFile; 208 + return ( 209 + <button 210 + key={node.path} 211 + ref={(el) => { 212 + if (el) itemRefs.current?.set(node.path, el); 213 + else itemRefs.current?.delete(node.path); 214 + }} 215 + type="button" 216 + onClick={() => onSelectFile(node.path)} 217 + className={cn( 218 + "w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors", 219 + "hover:bg-accent/50", 220 + isSelected && "bg-primary/10 text-foreground", 221 + )} 222 + style={{ paddingLeft: `${depth * 12 + 12}px` }} 223 + > 224 + {node.file && getFileStatusIcon(node.file.status)} 225 + <span className="truncate font-medium">{node.name}</span> 226 + </button> 227 + ); 228 + } 229 + 230 + export function FileList({ 231 + files, 232 + selectedFile, 233 + onSelectFile, 234 + totalAdditions, 235 + totalDeletions, 236 + }: FileListProps) { 237 + const [focusPanel] = useAtom(focusPanelAtom); 238 + const hasFocus = focusPanel === "diff"; 239 + const listRef = useRef<HTMLDivElement>(null); 240 + const itemRefs = useRef<Map<string, HTMLButtonElement>>(new Map()); 241 + const [filterQuery, setFilterQuery] = useState(""); 242 + const [viewMode, setViewMode] = useState<"flat" | "tree">("tree"); 243 + const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set()); 244 + 245 + // Filter files by search query 246 + const filteredFiles = useMemo(() => { 247 + if (!filterQuery.trim()) return files; 248 + const query = filterQuery.toLowerCase(); 249 + return files.filter((f) => f.path.toLowerCase().includes(query)); 250 + }, [files, filterQuery]); 251 + 252 + // Build tree from filtered files 253 + const tree = useMemo(() => { 254 + const rawTree = buildTree(filteredFiles); 255 + return collapseSingleChildDirs(rawTree); 256 + }, [filteredFiles]); 257 + 258 + // Auto-expand all directories when switching to tree view or when filter changes 259 + useEffect(() => { 260 + if (viewMode === "tree") { 261 + const allDirs = new Set<string>(); 262 + const collectDirs = (node: TreeNode) => { 263 + if (node.isDirectory && node.path) { 264 + allDirs.add(node.path); 265 + } 266 + for (const child of node.children.values()) { 267 + collectDirs(child); 268 + } 269 + }; 270 + collectDirs(tree); 271 + setExpandedDirs(allDirs); 272 + } 273 + }, [viewMode, tree]); 274 + 275 + const toggleDir = useCallback((path: string) => { 276 + setExpandedDirs((prev) => { 277 + const next = new Set(prev); 278 + if (next.has(path)) { 279 + next.delete(path); 280 + } else { 281 + next.add(path); 282 + } 283 + return next; 284 + }); 285 + }, []); 286 + 287 + const selectedIndex = selectedFile ? filteredFiles.findIndex((f) => f.path === selectedFile) : -1; 288 + 289 + // Navigate to next file 290 + const navigateDown = useCallback(() => { 291 + if (filteredFiles.length === 0) return; 292 + const nextIndex = selectedIndex < filteredFiles.length - 1 ? selectedIndex + 1 : selectedIndex; 293 + const nextFile = filteredFiles[nextIndex]; 294 + if (nextFile) onSelectFile(nextFile.path); 295 + }, [filteredFiles, selectedIndex, onSelectFile]); 296 + 297 + // Navigate to previous file 298 + const navigateUp = useCallback(() => { 299 + if (filteredFiles.length === 0) return; 300 + const prevIndex = selectedIndex > 0 ? selectedIndex - 1 : 0; 301 + const prevFile = filteredFiles[prevIndex]; 302 + if (prevFile) onSelectFile(prevFile.path); 303 + }, [filteredFiles, selectedIndex, onSelectFile]); 304 + 305 + // Keyboard navigation when diff panel is focused 306 + useKeyboardShortcut({ 307 + key: "j", 308 + onPress: navigateDown, 309 + enabled: hasFocus, 310 + }); 311 + 312 + useKeyboardShortcut({ 313 + key: "k", 314 + onPress: navigateUp, 315 + enabled: hasFocus, 316 + }); 317 + 318 + useKeyboardShortcut({ 319 + key: "ArrowDown", 320 + onPress: navigateDown, 321 + enabled: hasFocus, 322 + }); 323 + 324 + useKeyboardShortcut({ 325 + key: "ArrowUp", 326 + onPress: navigateUp, 327 + enabled: hasFocus, 328 + }); 329 + 330 + // Scroll selected item into view 331 + useEffect(() => { 332 + if (selectedFile) { 333 + const item = itemRefs.current.get(selectedFile); 334 + item?.scrollIntoView({ block: "nearest", behavior: "instant" }); 335 + } 336 + }, [selectedFile]); 337 + 338 + return ( 339 + <div className="flex flex-col h-full w-full overflow-hidden"> 340 + {/* Summary header */} 341 + <div className="border-b border-border px-3 py-2 text-xs text-muted-foreground shrink-0"> 342 + <div className="flex items-center justify-between"> 343 + <span> 344 + {files.length} {files.length === 1 ? "file" : "files"} changed 345 + </span> 346 + <div className="flex items-center gap-2"> 347 + <span> 348 + <span className="text-green-500">+{totalAdditions}</span> 349 + {" / "} 350 + <span className="text-red-500">-{totalDeletions}</span> 351 + </span> 352 + <Button 353 + variant="ghost" 354 + size="icon" 355 + className="size-6" 356 + onClick={() => setViewMode(viewMode === "flat" ? "tree" : "flat")} 357 + title={viewMode === "flat" ? "Switch to tree view" : "Switch to flat list"} 358 + > 359 + {viewMode === "flat" ? ( 360 + <FolderTreeIcon className="size-3.5" /> 361 + ) : ( 362 + <ListIcon className="size-3.5" /> 363 + )} 364 + </Button> 365 + </div> 366 + </div> 367 + </div> 368 + 369 + {/* Search filter */} 370 + <div className="px-2 py-2 border-b border-border shrink-0"> 371 + <div className="relative"> 372 + <SearchIcon className="absolute left-2 top-1/2 -translate-y-1/2 size-3 text-muted-foreground" /> 373 + <Input 374 + type="text" 375 + placeholder="Filter files..." 376 + value={filterQuery} 377 + onChange={(e) => setFilterQuery(e.target.value)} 378 + className="h-7 pl-7 text-xs" 379 + /> 380 + </div> 381 + </div> 382 + 383 + <ScrollArea className="flex-1"> 384 + <div ref={listRef} className="py-1"> 385 + {viewMode === "flat" 386 + ? // Flat list view 387 + filteredFiles.map((file) => { 388 + const isSelected = file.path === selectedFile; 389 + const fileName = getFileName(file.path); 390 + const directory = getFileDirectory(file.path); 391 + 392 + return ( 393 + <button 394 + key={file.path} 395 + ref={(el) => { 396 + if (el) itemRefs.current.set(file.path, el); 397 + else itemRefs.current.delete(file.path); 398 + }} 399 + type="button" 400 + onClick={() => onSelectFile(file.path)} 401 + className={cn( 402 + "w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors", 403 + "hover:bg-accent/50", 404 + isSelected && "bg-primary/10 text-foreground", 405 + )} 406 + > 407 + {getFileStatusIcon(file.status)} 408 + <span className="flex-1 min-w-0 truncate"> 409 + <span className="font-medium">{fileName}</span> 410 + {directory && ( 411 + <span className="text-muted-foreground ml-1 text-xs">{directory}</span> 412 + )} 413 + </span> 414 + </button> 415 + ); 416 + }) 417 + : // Tree view 418 + getSortedChildren(tree).map((child) => ( 419 + <TreeNodeComponent 420 + key={child.path} 421 + node={child} 422 + depth={0} 423 + selectedFile={selectedFile} 424 + onSelectFile={onSelectFile} 425 + expandedDirs={expandedDirs} 426 + toggleDir={toggleDir} 427 + itemRefs={itemRefs} 428 + /> 429 + ))} 430 + </div> 431 + </ScrollArea> 432 + </div> 433 + ); 434 + }
+41 -4
apps/desktop/src/components/diff/RevisionHeader.tsx
··· 1 + import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; 2 + import { useState } from "react"; 1 3 import type { Revision } from "@/tauri-commands"; 4 + import { cn } from "@/lib/utils"; 2 5 3 6 interface RevisionHeaderProps { 4 7 revision: Revision; ··· 6 9 7 10 export function RevisionHeader({ revision }: RevisionHeaderProps) { 8 11 const commitIdShort = revision.commit_id.substring(0, 12); 12 + const [isExpanded, setIsExpanded] = useState(false); 13 + 14 + // Split description into title (first line) and body (rest) 15 + const descriptionLines = revision.description?.split("\n") ?? []; 16 + const title = descriptionLines[0] ?? ""; 17 + const body = descriptionLines.slice(1).join("\n").trim(); 18 + const hasBody = body.length > 0; 9 19 10 20 return ( 11 21 <div className="border border-border rounded-lg bg-card"> ··· 26 36 <span className="text-muted-foreground ml-4">at</span>{" "} 27 37 <span className="text-foreground">{revision.timestamp}</span> 28 38 </div> 29 - {revision.description && ( 39 + {title && ( 30 40 <div className="mt-2 pt-2 border-t border-border"> 31 - <pre className="text-xs text-foreground whitespace-pre-wrap font-sans"> 32 - {revision.description} 33 - </pre> 41 + <div className="flex items-start justify-between gap-2"> 42 + <span className="text-sm font-semibold text-foreground font-sans">{title}</span> 43 + {hasBody && ( 44 + <button 45 + type="button" 46 + onClick={() => setIsExpanded(!isExpanded)} 47 + className={cn( 48 + "flex items-center gap-1 text-muted-foreground hover:text-foreground", 49 + "text-xs shrink-0 transition-colors", 50 + )} 51 + > 52 + {isExpanded ? ( 53 + <> 54 + <ChevronDownIcon className="size-3" /> 55 + <span>collapse</span> 56 + </> 57 + ) : ( 58 + <> 59 + <ChevronRightIcon className="size-3" /> 60 + <span>expand</span> 61 + </> 62 + )} 63 + </button> 64 + )} 65 + </div> 66 + {hasBody && isExpanded && ( 67 + <pre className="text-xs text-muted-foreground whitespace-pre-wrap font-sans mt-2"> 68 + {body} 69 + </pre> 70 + )} 34 71 </div> 35 72 )} 36 73 </div>
+43
apps/desktop/src/components/diff/SingleFileDiff.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import { PatchDiff } from "@pierre/diffs/react"; 3 + import { diffStyleAtom, diffViewStateAtom } from "@/atoms"; 4 + import { ScrollArea } from "@/components/ui/scroll-area"; 5 + 6 + interface SingleFileDiffProps { 7 + patch: string | null; 8 + filePath: string | null; 9 + } 10 + 11 + export function SingleFileDiff({ patch, filePath }: SingleFileDiffProps) { 12 + const [globalDiffStyle] = useAtom(diffStyleAtom); 13 + const [diffViewState] = useAtom(diffViewStateAtom); 14 + 15 + // Use local override if set, otherwise use global 16 + const effectiveDiffStyle = filePath 17 + ? (diffViewState.styleOverrides.get(filePath) ?? globalDiffStyle) 18 + : globalDiffStyle; 19 + 20 + // Empty state 21 + if (!filePath || patch === null) { 22 + return ( 23 + <div className="flex items-center justify-center h-full text-muted-foreground text-sm"> 24 + Select a file to view its diff 25 + </div> 26 + ); 27 + } 28 + 29 + return ( 30 + <ScrollArea className="h-full w-full"> 31 + {!patch.trim() ? ( 32 + <div className="px-4 py-8 text-center text-muted-foreground text-sm"> 33 + No changes in this file 34 + </div> 35 + ) : ( 36 + <PatchDiff 37 + patch={patch} 38 + options={{ hunkSeparators: "line-info", diffStyle: effectiveDiffStyle }} 39 + /> 40 + )} 41 + </ScrollArea> 42 + ); 43 + }
+2
apps/desktop/src/components/diff/index.ts
··· 1 1 export { RevisionHeader } from "./RevisionHeader"; 2 2 export { FileDiffSection } from "./FileDiffSection"; 3 3 export { DiffToolbar } from "./DiffToolbar"; 4 + export { FileList } from "./FileList"; 5 + export { SingleFileDiff } from "./SingleFileDiff";
+8
apps/desktop/src/styles/index.css
··· 234 234 stroke-width: 3; 235 235 stroke-opacity: 1; 236 236 } 237 + 238 + /* Sticky header for @pierre/diffs file diffs */ 239 + [data-diffs-header] { 240 + position: sticky; 241 + top: 0; 242 + z-index: 10; 243 + border-bottom: 1px solid var(--border); 244 + }