a very good jj gui
0
fork

Configure Feed

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

refactor: extract DiffPanel subcomponents into diff/ directory

+322 -343
+74 -343
apps/desktop/src/components/DiffPanel.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 - import { PatchDiff } from "@pierre/diffs/react"; 3 2 import { useLiveQuery } from "@tanstack/react-db"; 4 - import { useNavigate, useSearch } from "@tanstack/react-router"; 5 - import { 6 - ChevronDownIcon, 7 - ChevronRightIcon, 8 - ChevronsDownUpIcon, 9 - ChevronsUpDownIcon, 10 - Columns2Icon, 11 - RowsIcon, 12 - } from "lucide-react"; 3 + import { useSearch } from "@tanstack/react-router"; 4 + import { Route } from "@/routes/project.$projectId"; 13 5 import { useEffect, useRef } from "react"; 14 - import { 15 - type DiffStyle, 16 - diffStyleAtom, 17 - expandedDiffFilesAtom, 18 - fileDiffStyleOverridesAtom, 19 - focusPanelAtom, 20 - } from "@/atoms"; 21 - import { ChangedFilesList } from "@/components/ChangedFilesList"; 22 - import { Button } from "@/components/ui/button"; 23 - import { Separator } from "@/components/ui/separator"; 24 - import { 25 - emptyChangesCollection, 26 - emptyDiffCollection, 27 - getRevisionChangesCollection, 28 - getRevisionDiffCollection, 29 - } from "@/db"; 30 - import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 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"; 11 + import { useDiffPanelKeyboard } from "@/hooks/useDiffPanelKeyboard"; 31 12 import type { Revision } from "@/tauri-commands"; 32 13 33 14 interface DiffPanelProps { ··· 36 17 revision: Revision | null; 37 18 } 38 19 39 - function RevisionHeader({ revision }: { revision: Revision }) { 40 - const commitIdShort = revision.commit_id.substring(0, 12); 41 - 42 - return ( 43 - <div className="border border-border rounded-lg bg-card"> 44 - <div className="px-3 py-2 font-mono text-xs space-y-1.5"> 45 - <div className="flex gap-4"> 46 - <div> 47 - <span className="text-muted-foreground">Change ID:</span>{" "} 48 - <span className="text-foreground font-semibold">{revision.change_id_short}</span> 49 - </div> 50 - <div> 51 - <span className="text-muted-foreground">Commit ID:</span>{" "} 52 - <span className="text-foreground">{commitIdShort}</span> 53 - </div> 54 - </div> 55 - <div> 56 - <span className="text-muted-foreground">Author:</span>{" "} 57 - <span className="text-foreground">{revision.author}</span> 58 - <span className="text-muted-foreground ml-4">at</span>{" "} 59 - <span className="text-foreground">{revision.timestamp}</span> 60 - </div> 61 - {revision.description && ( 62 - <div className="mt-2 pt-2 border-t border-border"> 63 - <pre className="text-xs text-foreground whitespace-pre-wrap font-sans"> 64 - {revision.description} 65 - </pre> 66 - </div> 67 - )} 68 - </div> 69 - </div> 70 - ); 20 + interface PrerenderedDiffPanelProps { 21 + repoPath: string | null; 22 + revisions: Revision[]; 23 + selectedChangeId: string | null; 71 24 } 72 25 26 + /** 27 + * Extract file path from a unified diff patch. 28 + */ 73 29 function extractFilePath(patch: string): string { 74 30 const match = patch.match(/^\+\+\+ b\/(.+)$/m); 75 31 return match ? match[1] : "unknown"; 76 32 } 77 33 78 - function FileDiffSection({ 79 - patch, 80 - isSelected = false, 81 - fileRef, 82 - }: { 83 - patch: string; 84 - isSelected?: boolean; 85 - fileRef?: React.RefObject<HTMLDivElement | null>; 86 - }) { 87 - const [globalDiffStyle] = useAtom(diffStyleAtom); 88 - const [expandedFiles, setExpandedFiles] = useAtom(expandedDiffFilesAtom); 89 - const [styleOverrides, setStyleOverrides] = useAtom(fileDiffStyleOverridesAtom); 90 - 91 - const filePath = extractFilePath(patch); 92 - const isExpanded = expandedFiles?.has(filePath) ?? false; 93 - // Auto-expand when selected 94 - const isCollapsed = isSelected ? false : !isExpanded; 95 - // Use local override if set, otherwise use global 96 - const effectiveDiffStyle = styleOverrides.get(filePath) ?? globalDiffStyle; 97 - 98 - function handleToggleCollapse() { 99 - setExpandedFiles((prev) => { 100 - const next = new Set(prev ?? []); 101 - if (isCollapsed) { 102 - next.add(filePath); 103 - } else { 104 - next.delete(filePath); 105 - } 106 - return next; 107 - }); 108 - } 109 - 110 - function handleSetLocalStyle(style: DiffStyle) { 111 - setStyleOverrides((prev) => { 112 - const next = new Map(prev); 113 - next.set(filePath, style); 114 - return next; 115 - }); 116 - } 117 - 118 - return ( 119 - <div 120 - ref={fileRef} 121 - className={`border rounded-lg overflow-hidden ${ 122 - isSelected ? "border-accent-foreground border-2" : "border-border" 123 - }`} 124 - data-selected={isSelected || undefined} 125 - data-file-path={filePath} 126 - > 127 - <div 128 - className={`flex items-center gap-2 px-2 py-1.5 border-b cursor-pointer transition-colors ${ 129 - isSelected 130 - ? "bg-accent border-accent-foreground" 131 - : "bg-muted border-border hover:bg-accent/50" 132 - }`} 133 - onClick={handleToggleCollapse} 134 - > 135 - {/* Collapse toggle - left side */} 136 - <span className="text-muted-foreground shrink-0"> 137 - {isCollapsed ? ( 138 - <ChevronRightIcon className="size-4" /> 139 - ) : ( 140 - <ChevronDownIcon className="size-4" /> 141 - )} 142 - </span> 143 - <code className="font-mono text-xs text-foreground text-left flex-1 truncate min-w-0"> 144 - {filePath} 145 - </code> 146 - 147 - {/* Per-file diff style toggle buttons */} 148 - <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}> 149 - <Button 150 - variant={effectiveDiffStyle === "unified" ? "secondary" : "ghost"} 151 - size="icon-xs" 152 - onClick={() => handleSetLocalStyle("unified")} 153 - title="Unified diff" 154 - className="h-6 w-6" 155 - > 156 - <RowsIcon className="size-3" /> 157 - </Button> 158 - <Button 159 - variant={effectiveDiffStyle === "split" ? "secondary" : "ghost"} 160 - size="icon-xs" 161 - onClick={() => handleSetLocalStyle("split")} 162 - title="Split diff" 163 - className="h-6 w-6" 164 - > 165 - <Columns2Icon className="size-3" /> 166 - </Button> 167 - </div> 168 - </div> 169 - {!isCollapsed && ( 170 - <div> 171 - {!patch.trim() ? ( 172 - <div className="px-4 py-8 text-center text-muted-foreground text-sm"> 173 - No changes in this file 174 - </div> 175 - ) : ( 176 - <PatchDiff 177 - patch={patch} 178 - options={{ hunkSeparators: "line-info", diffStyle: effectiveDiffStyle }} 179 - /> 180 - )} 181 - </div> 182 - )} 183 - </div> 184 - ); 185 - } 186 - 34 + /** 35 + * Split a multi-file unified diff into individual file diffs. 36 + */ 187 37 function splitMultiFileDiff(unifiedDiff: string): string[] { 188 38 if (!unifiedDiff.trim()) { 189 39 return []; ··· 209 59 return fileDiffs; 210 60 } 211 61 212 - interface PrerenderedDiffPanelProps { 213 - repoPath: string | null; 214 - revisions: Revision[]; 215 - selectedChangeId: string | null; 216 - } 217 - 218 62 export function PrerenderedDiffPanel({ 219 63 repoPath, 220 64 revisions, ··· 227 71 return <DiffPanel repoPath={repoPath} changeId={selectedChangeId} revision={selectedRevision} />; 228 72 } 229 73 74 + /** 75 + * Get the current diff view state, resetting if the changeId has changed. 76 + * This is a pure derivation - no useEffect needed for state sync. 77 + */ 78 + function getDiffViewState( 79 + currentState: DiffViewState, 80 + changeId: string | null, 81 + firstFilePath: string | null, 82 + ): DiffViewState { 83 + // If changeId matches, return current state as-is 84 + if (currentState.forChangeId === changeId) { 85 + return currentState; 86 + } 87 + // ChangeId changed - return reset state 88 + return { 89 + forChangeId: changeId, 90 + expandedFiles: firstFilePath ? new Set([firstFilePath]) : new Set(), 91 + styleOverrides: new Map(), 92 + }; 93 + } 94 + 230 95 export function DiffPanel({ repoPath, changeId, revision }: DiffPanelProps) { 231 - const navigate = useNavigate(); 232 - const search = useSearch({ strict: false }); 96 + const search = useSearch({ from: Route.fullPath }); 233 97 const { file: selectedFilePath } = search; 234 98 const fileRefsMap = useRef<Map<string, React.RefObject<HTMLDivElement | null>>>(new Map()); 235 - const [expandedFiles, setExpandedFiles] = useAtom(expandedDiffFilesAtom); 236 - const [, setStyleOverrides] = useAtom(fileDiffStyleOverridesAtom); 237 - const lastChangeIdRef = useRef<string | null>(null); 238 - const [focusPanel, setFocusPanel] = useAtom(focusPanelAtom); 239 - 240 - // Fetch changed files for the file list 241 - const changesCollection = 242 - repoPath && changeId 243 - ? getRevisionChangesCollection(repoPath, changeId) 244 - : emptyChangesCollection; 245 - const { data: changedFiles = [], isLoading: filesLoading } = useLiveQuery(changesCollection); 246 - 247 - // j/k navigation when diff panel has focus 248 - useKeyboardShortcut({ 249 - key: "j", 250 - modifiers: {}, 251 - onPress: () => { 252 - if (changedFiles.length === 0) return; 253 - const filePaths = changedFiles.map((f) => f.path); 254 - const currentIndex = selectedFilePath ? filePaths.indexOf(selectedFilePath) : -1; 255 - const nextIndex = currentIndex + 1; 256 - if (nextIndex < filePaths.length) { 257 - // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 258 - navigate({ search: { ...search, file: filePaths[nextIndex] } as any }); 259 - } else if (currentIndex === -1) { 260 - // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 261 - navigate({ search: { ...search, file: filePaths[0] } as any }); 262 - } 263 - }, 264 - enabled: focusPanel === "diff", 265 - }); 266 - 267 - useKeyboardShortcut({ 268 - key: "k", 269 - modifiers: {}, 270 - onPress: () => { 271 - if (changedFiles.length === 0) return; 272 - const filePaths = changedFiles.map((f) => f.path); 273 - const currentIndex = selectedFilePath ? filePaths.indexOf(selectedFilePath) : -1; 274 - if (currentIndex > 0) { 275 - // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 276 - navigate({ search: { ...search, file: filePaths[currentIndex - 1] } as any }); 277 - } else if (currentIndex === -1) { 278 - // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 279 - navigate({ search: { ...search, file: filePaths[filePaths.length - 1] } as any }); 280 - } 281 - }, 282 - enabled: focusPanel === "diff", 283 - }); 284 - 285 - // Handle h/ArrowLeft to move focus back to revisions panel 286 - useKeyboardShortcut({ 287 - key: "h", 288 - modifiers: {}, 289 - onPress: () => { 290 - if (focusPanel === "diff") { 291 - setFocusPanel("revisions"); 292 - } 293 - }, 294 - enabled: focusPanel === "diff", 295 - }); 99 + const [diffViewState, setDiffViewState] = useAtom(diffViewStateAtom); 100 + const scrollContainerRef = useRef<HTMLDivElement>(null); 296 101 297 - useKeyboardShortcut({ 298 - key: "ArrowLeft", 299 - modifiers: {}, 300 - onPress: () => { 301 - if (focusPanel === "diff") { 302 - setFocusPanel("revisions"); 303 - } 304 - }, 305 - enabled: focusPanel === "diff", 306 - }); 102 + // Keyboard navigation 103 + useDiffPanelKeyboard({ scrollContainerRef }); 307 104 308 105 // Always fetch all diffs 309 106 const diffCollection = ··· 314 111 const fileDiffs = splitMultiFileDiff(revisionDiff); 315 112 const filePaths = fileDiffs.map(extractFilePath); 316 113 317 - // Reset state when revision changes 318 114 const firstFilePath = filePaths[0] ?? null; 319 - useEffect(() => { 320 - if (changeId !== lastChangeIdRef.current) { 321 - lastChangeIdRef.current = changeId; 322 - // Reset to first file expanded 323 - if (firstFilePath) { 324 - setExpandedFiles(new Set([firstFilePath])); 325 - } else { 326 - setExpandedFiles(new Set()); 327 - } 328 - // Clear per-file style overrides 329 - setStyleOverrides(new Map()); 330 - } 331 - }, [changeId, firstFilePath, setExpandedFiles, setStyleOverrides]); 332 115 333 - // Initialize expanded files on first load 334 - useEffect(() => { 335 - if (expandedFiles === null && firstFilePath) { 336 - setExpandedFiles(new Set([firstFilePath])); 337 - } 338 - }, [expandedFiles, firstFilePath, setExpandedFiles]); 116 + // Derive the effective state - resets automatically when changeId changes 117 + const effectiveState = getDiffViewState(diffViewState, changeId, firstFilePath); 118 + 119 + // Sync atom if state was reset (only writes when needed) 120 + if (effectiveState !== diffViewState) { 121 + setDiffViewState(effectiveState); 122 + } 123 + 124 + const { expandedFiles } = effectiveState; 339 125 340 126 // Get or create ref for each file 341 127 const getFileRef = (filePath: string): React.RefObject<HTMLDivElement | null> => { ··· 347 133 }; 348 134 349 135 // Toggle all folds 350 - const allExpanded = filePaths.length > 0 && filePaths.every((p) => expandedFiles?.has(p)); 136 + const allExpanded = filePaths.length > 0 && filePaths.every((p) => expandedFiles.has(p)); 351 137 352 138 function handleToggleAllFolds() { 353 - if (allExpanded) { 354 - setExpandedFiles(new Set()); 355 - } else { 356 - setExpandedFiles(new Set(filePaths)); 357 - } 358 - } 359 - 360 - function handleSelectFile(filePath: string) { 361 - // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 362 - navigate({ search: { ...search, file: filePath } as any }); 139 + setDiffViewState((prev) => ({ 140 + ...prev, 141 + expandedFiles: allExpanded ? new Set() : new Set(filePaths), 142 + })); 363 143 } 364 144 365 145 // Scroll to selected file when it changes 366 146 useEffect(() => { 367 - if (selectedFilePath && fileRefsMap.current.has(selectedFilePath)) { 147 + if (!selectedFilePath || fileDiffs.length === 0) return; 148 + 149 + // Use requestAnimationFrame to ensure DOM is updated before scrolling 150 + requestAnimationFrame(() => { 368 151 const ref = fileRefsMap.current.get(selectedFilePath); 369 152 if (ref?.current) { 370 153 ref.current.scrollIntoView({ 371 - behavior: "smooth", 154 + behavior: "instant", 372 155 block: "start", 373 156 }); 374 157 } 375 - } 376 - }, [selectedFilePath]); 158 + }); 159 + }, [selectedFilePath, fileDiffs.length]); 377 160 378 161 if (!repoPath || !changeId) { 379 162 return ( ··· 400 183 } 401 184 402 185 return ( 403 - <div 404 - className={`h-full overflow-auto bg-background ${focusPanel === "diff" ? "ring-2 ring-inset ring-accent" : ""}`} 405 - > 186 + <div ref={scrollContainerRef} className="h-full overflow-auto bg-background outline-none"> 406 187 {revision && ( 407 - <div className="p-4 pb-0 space-y-3"> 188 + <div className="px-4 pt-6 pb-2"> 408 189 <RevisionHeader revision={revision} /> 409 - <div className="border border-border rounded-lg overflow-hidden bg-background"> 410 - <ChangedFilesList 411 - files={changedFiles} 412 - selectedFile={selectedFilePath ?? null} 413 - onSelectFile={handleSelectFile} 414 - isLoading={filesLoading} 415 - /> 416 - </div> 417 190 </div> 418 191 )} 192 + <DiffToolbar 193 + fileCount={fileDiffs.length} 194 + allExpanded={allExpanded} 195 + onToggleAllFolds={handleToggleAllFolds} 196 + /> 197 + {/* File diffs */} 419 198 <div className="p-4 space-y-4"> 420 - <div className="flex items-center h-8 px-2 text-xs text-muted-foreground sticky top-0 z-10 bg-background -mt-2 pt-2"> 421 - <span className="font-medium"> 422 - {fileDiffs.length} {fileDiffs.length === 1 ? "file" : "files"} 423 - </span> 424 - <Separator orientation="vertical" className="h-4 mx-3" /> 425 - <Button 426 - variant="ghost" 427 - size="icon-xs" 428 - onClick={handleToggleAllFolds} 429 - title={allExpanded ? "Collapse all files" : "Expand all files"} 430 - className="h-6 w-6" 431 - > 432 - {allExpanded ? ( 433 - <ChevronsDownUpIcon className="size-3.5" /> 434 - ) : ( 435 - <ChevronsUpDownIcon className="size-3.5" /> 436 - )} 437 - </Button> 438 - <div className="flex items-center gap-0.5 ml-auto"> 439 - <DiffStyleToggle /> 440 - </div> 441 - </div> 442 199 {fileDiffs.map((patch) => { 443 200 const filePath = extractFilePath(patch); 444 201 const fileRef = getFileRef(filePath); ··· 448 205 <FileDiffSection 449 206 key={filePath} 450 207 patch={patch} 208 + filePath={filePath} 451 209 isSelected={isSelected} 452 210 fileRef={fileRef} 453 211 /> ··· 457 215 </div> 458 216 ); 459 217 } 460 - 461 - function DiffStyleToggle() { 462 - const [globalDiffStyle, setGlobalDiffStyle] = useAtom(diffStyleAtom); 463 - 464 - return ( 465 - <> 466 - <Button 467 - variant={globalDiffStyle === "unified" ? "secondary" : "ghost"} 468 - size="icon-xs" 469 - onClick={() => setGlobalDiffStyle("unified")} 470 - title="Unified diff view" 471 - className="h-6 w-6" 472 - > 473 - <RowsIcon className="size-3" /> 474 - </Button> 475 - <Button 476 - variant={globalDiffStyle === "split" ? "secondary" : "ghost"} 477 - size="icon-xs" 478 - onClick={() => setGlobalDiffStyle("split")} 479 - title="Split diff view" 480 - className="h-6 w-6" 481 - > 482 - <Columns2Icon className="size-3" /> 483 - </Button> 484 - </> 485 - ); 486 - }
+84
apps/desktop/src/components/diff/DiffToolbar.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import { 3 + ChevronsDownUpIcon, 4 + ChevronsUpDownIcon, 5 + Columns2Icon, 6 + RowsIcon, 7 + SearchIcon, 8 + } from "lucide-react"; 9 + import { diffStyleAtom } from "@/atoms"; 10 + import { Button } from "@/components/ui/button"; 11 + import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; 12 + 13 + interface DiffToolbarProps { 14 + fileCount: number; 15 + allExpanded: boolean; 16 + onToggleAllFolds: () => void; 17 + } 18 + 19 + export function DiffToolbar({ fileCount, allExpanded, onToggleAllFolds }: DiffToolbarProps) { 20 + return ( 21 + <div className="sticky top-0 z-10 bg-background border-b border-border"> 22 + <div className="flex items-center gap-2 px-4 py-2"> 23 + <span className="text-xs text-muted-foreground"> 24 + {fileCount} {fileCount === 1 ? "file" : "files"} 25 + </span> 26 + 27 + {/* Search/file selector input */} 28 + <InputGroup className="bg-input/30 border-input/30 h-8 border-none shadow-none! *:data-[slot=input-group-addon]:pl-2! flex-1 rounded-md"> 29 + <InputGroupInput 30 + placeholder="Search files..." 31 + className="w-full text-xs outline-hidden disabled:cursor-not-allowed disabled:opacity-50" 32 + /> 33 + <InputGroupAddon> 34 + <SearchIcon className="size-4 shrink-0 opacity-50" /> 35 + </InputGroupAddon> 36 + </InputGroup> 37 + 38 + <div className="flex items-center gap-0.5"> 39 + <Button 40 + variant="ghost" 41 + size="icon-xs" 42 + onClick={onToggleAllFolds} 43 + title={allExpanded ? "Collapse all files" : "Expand all files"} 44 + className="h-6 w-6" 45 + > 46 + {allExpanded ? ( 47 + <ChevronsDownUpIcon className="size-3.5" /> 48 + ) : ( 49 + <ChevronsUpDownIcon className="size-3.5" /> 50 + )} 51 + </Button> 52 + <DiffStyleToggle /> 53 + </div> 54 + </div> 55 + </div> 56 + ); 57 + } 58 + 59 + function DiffStyleToggle() { 60 + const [globalDiffStyle, setGlobalDiffStyle] = useAtom(diffStyleAtom); 61 + 62 + return ( 63 + <> 64 + <Button 65 + variant={globalDiffStyle === "unified" ? "secondary" : "ghost"} 66 + size="icon-xs" 67 + onClick={() => setGlobalDiffStyle("unified")} 68 + title="Unified diff view" 69 + className="h-6 w-6" 70 + > 71 + <RowsIcon className="size-3" /> 72 + </Button> 73 + <Button 74 + variant={globalDiffStyle === "split" ? "secondary" : "ghost"} 75 + size="icon-xs" 76 + onClick={() => setGlobalDiffStyle("split")} 77 + title="Split diff view" 78 + className="h-6 w-6" 79 + > 80 + <Columns2Icon className="size-3" /> 81 + </Button> 82 + </> 83 + ); 84 + }
+122
apps/desktop/src/components/diff/FileDiffSection.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import { PatchDiff } from "@pierre/diffs/react"; 3 + import { ChevronDownIcon, ChevronRightIcon, Columns2Icon, RowsIcon } from "lucide-react"; 4 + import { type DiffStyle, diffStyleAtom, diffViewStateAtom } from "@/atoms"; 5 + import { Button } from "@/components/ui/button"; 6 + 7 + interface FileDiffSectionProps { 8 + patch: string; 9 + filePath: string; 10 + isSelected?: boolean; 11 + fileRef?: React.RefObject<HTMLDivElement | null>; 12 + } 13 + 14 + export function FileDiffSection({ 15 + patch, 16 + filePath, 17 + isSelected = false, 18 + fileRef, 19 + }: FileDiffSectionProps) { 20 + const [globalDiffStyle] = useAtom(diffStyleAtom); 21 + const [diffViewState, setDiffViewState] = useAtom(diffViewStateAtom); 22 + 23 + const isExpanded = diffViewState.expandedFiles.has(filePath); 24 + const isCollapsed = !isExpanded; 25 + // Use local override if set, otherwise use global 26 + const effectiveDiffStyle = diffViewState.styleOverrides.get(filePath) ?? globalDiffStyle; 27 + 28 + function handleToggleCollapse() { 29 + setDiffViewState((prev) => { 30 + const next = new Set(prev.expandedFiles); 31 + if (isCollapsed) { 32 + next.add(filePath); 33 + } else { 34 + next.delete(filePath); 35 + } 36 + return { ...prev, expandedFiles: next }; 37 + }); 38 + } 39 + 40 + function handleSetLocalStyle(style: DiffStyle) { 41 + setDiffViewState((prev) => { 42 + const next = new Map(prev.styleOverrides); 43 + next.set(filePath, style); 44 + return { ...prev, styleOverrides: next }; 45 + }); 46 + } 47 + 48 + return ( 49 + <div 50 + ref={fileRef} 51 + className={`border rounded-lg overflow-hidden ${ 52 + isSelected ? "border-accent-foreground border-2" : "border-border" 53 + }`} 54 + data-selected={isSelected || undefined} 55 + data-file-path={filePath} 56 + > 57 + <button 58 + type="button" 59 + className={`flex items-center gap-2 px-2 py-1.5 border-b cursor-pointer transition-colors w-full text-left ${ 60 + isSelected 61 + ? "bg-accent/30 border-accent-foreground" 62 + : "bg-muted/30 border-border hover:bg-accent/50" 63 + }`} 64 + onClick={handleToggleCollapse} 65 + > 66 + {/* Collapse toggle - left side */} 67 + <span className="text-muted-foreground shrink-0"> 68 + {isCollapsed ? ( 69 + <ChevronRightIcon className="size-4" /> 70 + ) : ( 71 + <ChevronDownIcon className="size-4" /> 72 + )} 73 + </span> 74 + <code className="font-mono text-xs text-foreground text-left flex-1 truncate min-w-0"> 75 + {filePath} 76 + </code> 77 + 78 + {/* Per-file diff style toggle buttons */} 79 + <span className="flex items-center gap-0.5"> 80 + <Button 81 + variant={effectiveDiffStyle === "unified" ? "secondary" : "ghost"} 82 + size="icon-xs" 83 + onClick={(e) => { 84 + e.stopPropagation(); 85 + handleSetLocalStyle("unified"); 86 + }} 87 + title="Unified diff" 88 + className="h-6 w-6" 89 + > 90 + <RowsIcon className="size-3" /> 91 + </Button> 92 + <Button 93 + variant={effectiveDiffStyle === "split" ? "secondary" : "ghost"} 94 + size="icon-xs" 95 + onClick={(e) => { 96 + e.stopPropagation(); 97 + handleSetLocalStyle("split"); 98 + }} 99 + title="Split diff" 100 + className="h-6 w-6" 101 + > 102 + <Columns2Icon className="size-3" /> 103 + </Button> 104 + </span> 105 + </button> 106 + {!isCollapsed && ( 107 + <div> 108 + {!patch.trim() ? ( 109 + <div className="px-4 py-8 text-center text-muted-foreground text-sm"> 110 + No changes in this file 111 + </div> 112 + ) : ( 113 + <PatchDiff 114 + patch={patch} 115 + options={{ hunkSeparators: "line-info", diffStyle: effectiveDiffStyle }} 116 + /> 117 + )} 118 + </div> 119 + )} 120 + </div> 121 + ); 122 + }
+39
apps/desktop/src/components/diff/RevisionHeader.tsx
··· 1 + import type { Revision } from "@/tauri-commands"; 2 + 3 + interface RevisionHeaderProps { 4 + revision: Revision; 5 + } 6 + 7 + export function RevisionHeader({ revision }: RevisionHeaderProps) { 8 + const commitIdShort = revision.commit_id.substring(0, 12); 9 + 10 + return ( 11 + <div className="border border-border rounded-lg bg-card"> 12 + <div className="px-3 py-2 font-mono text-xs space-y-1.5"> 13 + <div className="flex gap-4"> 14 + <div> 15 + <span className="text-muted-foreground">Change ID:</span>{" "} 16 + <span className="text-foreground font-semibold">{revision.change_id_short}</span> 17 + </div> 18 + <div> 19 + <span className="text-muted-foreground">Commit ID:</span>{" "} 20 + <span className="text-foreground">{commitIdShort}</span> 21 + </div> 22 + </div> 23 + <div> 24 + <span className="text-muted-foreground">Author:</span>{" "} 25 + <span className="text-foreground">{revision.author}</span> 26 + <span className="text-muted-foreground ml-4">at</span>{" "} 27 + <span className="text-foreground">{revision.timestamp}</span> 28 + </div> 29 + {revision.description && ( 30 + <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> 34 + </div> 35 + )} 36 + </div> 37 + </div> 38 + ); 39 + }
+3
apps/desktop/src/components/diff/index.ts
··· 1 + export { RevisionHeader } from "./RevisionHeader"; 2 + export { FileDiffSection } from "./FileDiffSection"; 3 + export { DiffToolbar } from "./DiffToolbar";