a very good jj gui
0
fork

Configure Feed

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

+520 -270
+60
apps/desktop/src/components/AppHeader.tsx
··· 1 + import { FolderOpenIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; 2 + import { Button } from "@/components/ui/button"; 3 + 4 + interface AppHeaderProps { 5 + projectName: string | null; 6 + onOpenProject: () => void; 7 + onSync: () => void; 8 + onOpenSearch: () => void; 9 + isSyncing?: boolean; 10 + } 11 + 12 + export function AppHeader({ 13 + projectName, 14 + onOpenProject, 15 + onSync, 16 + onOpenSearch, 17 + isSyncing = false, 18 + }: AppHeaderProps) { 19 + return ( 20 + <header 21 + className="h-10 flex items-center justify-between px-3 border-b border-border bg-background shrink-0" 22 + data-tauri-drag-region 23 + > 24 + {/* Left: Project/Repository */} 25 + <Button 26 + variant="ghost" 27 + size="sm" 28 + className="h-7 px-2 gap-1.5 text-sm font-medium" 29 + onClick={onOpenProject} 30 + > 31 + <FolderOpenIcon className="size-4" /> 32 + <span className="truncate max-w-[200px]">{projectName ?? "Open Repository"}</span> 33 + </Button> 34 + 35 + {/* Center: Revision search */} 36 + <Button 37 + variant="ghost" 38 + size="sm" 39 + className="h-7 px-2 gap-1.5 text-sm text-muted-foreground" 40 + onClick={onOpenSearch} 41 + > 42 + <SearchIcon className="size-4" /> 43 + <span>Search revisions...</span> 44 + <kbd className="ml-2 text-xs bg-muted px-1.5 py-0.5 rounded">/</kbd> 45 + </Button> 46 + 47 + {/* Right: Sync */} 48 + <Button 49 + variant="ghost" 50 + size="sm" 51 + className="h-7 px-2 gap-1.5 text-sm" 52 + onClick={onSync} 53 + disabled={isSyncing || !projectName} 54 + > 55 + <RefreshCwIcon className={`size-4 ${isSyncing ? "animate-spin" : ""}`} /> 56 + <span>Sync</span> 57 + </Button> 58 + </header> 59 + ); 60 + }
+43 -2
apps/desktop/src/components/AppShell.tsx
··· 22 22 } 23 23 24 24 import { AceJump } from "@/components/AceJump"; 25 + import { AppHeader } from "@/components/AppHeader"; 25 26 import { CommandPalette } from "@/components/CommandPalette"; 26 27 import { PrerenderedDiffPanel } from "@/components/DiffPanel"; 27 28 import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp"; ··· 64 65 const navigate = useNavigate(); 65 66 const { handleAddRepository } = useAddRepository(); 66 67 const { data: repositories = [] } = useLiveQuery(repositoriesCollection); 68 + const [projectPickerOpen, setProjectPickerOpen] = useState(false); 67 69 68 70 function handleSelectRepository(repository: Repository) { 69 71 navigate({ to: "/project/$projectId", params: { projectId: repository.id } }); ··· 80 82 onOpenSettings={() => navigate({ to: "/settings" })} 81 83 /> 82 84 <KeyboardShortcutsHelp /> 83 - <ProjectPicker repositories={repositories} onSelectRepository={handleSelectRepository} /> 85 + <ProjectPicker 86 + repositories={repositories} 87 + onSelectRepository={handleSelectRepository} 88 + open={projectPickerOpen} 89 + onOpenChange={setProjectPickerOpen} 90 + /> 84 91 <div className="flex flex-col h-screen overflow-hidden"> 92 + <AppHeader 93 + projectName={null} 94 + onOpenProject={() => setProjectPickerOpen(true)} 95 + onSync={() => {}} 96 + onOpenSearch={() => {}} 97 + /> 85 98 <div className="flex-1 min-h-0 flex items-center justify-center text-muted-foreground"> 86 99 <p>Select or add a repository to get started</p> 87 100 </div> ··· 103 116 const [flash, setFlash] = useState<{ changeId: string; key: number } | null>(null); 104 117 const [viewMode, setViewMode] = useAtom(viewModeAtom); 105 118 const [pendingAbandon, setPendingAbandon] = useState<Revision | null>(null); 119 + const [projectPickerOpen, setProjectPickerOpen] = useState(false); 120 + const [isSyncing, setIsSyncing] = useState(false); 106 121 const revisionGraphRef = useRef<RevisionGraphHandle>(null); 107 122 const isNarrowScreen = useIsNarrowScreen(); 108 123 const { handleAddRepository } = useAddRepository(); ··· 405 420 return null; 406 421 })(); 407 422 423 + function handleSync() { 424 + if (!activeProject || isSyncing) return; 425 + setIsSyncing(true); 426 + // TODO: Implement jj git fetch 427 + // For now, just simulate a sync delay 428 + setTimeout(() => setIsSyncing(false), 1000); 429 + } 430 + 431 + function handleOpenSearch() { 432 + // Focus the AceJump / revision search 433 + // The "/" key already triggers this via AceJump 434 + window.dispatchEvent(new KeyboardEvent("keydown", { key: "/" })); 435 + } 436 + 408 437 return ( 409 438 <> 410 - <ProjectPicker repositories={repositories} onSelectRepository={handleSelectRepository} /> 439 + <ProjectPicker 440 + repositories={repositories} 441 + onSelectRepository={handleSelectRepository} 442 + open={projectPickerOpen} 443 + onOpenChange={setProjectPickerOpen} 444 + /> 411 445 <CommandPalette 412 446 onOpenRepo={handleAddRepository} 413 447 onOpenProjects={() => navigate({ to: "/repositories" })} ··· 426 460 }} 427 461 /> 428 462 <div className="flex flex-col h-screen overflow-hidden"> 463 + <AppHeader 464 + projectName={activeProject?.name ?? null} 465 + onOpenProject={() => setProjectPickerOpen(true)} 466 + onSync={handleSync} 467 + onOpenSearch={handleOpenSearch} 468 + isSyncing={isSyncing} 469 + /> 429 470 <div className="flex-1 min-h-0"> 430 471 {viewMode === 1 ? ( 431 472 // Overview mode: only revision list
+11 -4
apps/desktop/src/components/ChangedFilesList.tsx
··· 53 53 onClick, 54 54 onToggleSelection, 55 55 showSelection, 56 + isOdd, 56 57 }: { 57 58 file: ChangedFile; 58 59 isFocused: boolean; ··· 60 61 onClick: () => void; 61 62 onToggleSelection?: () => void; 62 63 showSelection?: boolean; 64 + isOdd: boolean; 63 65 }) { 64 66 return ( 65 67 <button 66 68 type="button" 67 69 className={cn( 68 - "flex items-center gap-2 px-3 py-1.5 text-left transition-colors cursor-pointer group w-full", 69 - isFocused ? "bg-muted text-foreground" : "hover:bg-muted/50", 70 + "flex items-center gap-2 px-3 py-1.5 text-left w-full", 71 + isFocused 72 + ? "bg-accent/40 text-foreground" 73 + : isOdd 74 + ? "bg-muted/30 text-muted-foreground" 75 + : "text-muted-foreground", 70 76 )} 71 77 data-focused={isFocused || undefined} 72 78 data-checked={isChecked || undefined} ··· 95 101 <span 96 102 className={cn( 97 103 "font-mono text-xs truncate flex-1", 98 - isFocused ? "text-foreground" : "text-muted-foreground group-hover:text-foreground", 104 + isFocused ? "text-foreground" : "text-muted-foreground", 99 105 )} 100 106 title={file.path} 101 107 > ··· 170 176 )} 171 177 </div> 172 178 <div> 173 - {files.map((file) => ( 179 + {files.map((file, index) => ( 174 180 <FileListItem 175 181 key={file.path} 176 182 file={file} ··· 181 187 onToggleFileSelection ? () => onToggleFileSelection(file.path) : undefined 182 188 } 183 189 showSelection={showSelection} 190 + isOdd={index % 2 === 1} 184 191 /> 185 192 ))} 186 193 </div>
+93 -27
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 { PatchDiff } from "@pierre/diffs/react"; 3 4 import { Columns2Icon, RowsIcon } from "lucide-react"; 4 5 import { useEffect, useMemo, useRef, useState } from "react"; 5 6 import { type DiffStyle, type DiffViewState, diffStyleAtom, diffViewStateAtom } from "@/atoms"; 6 - import { FileList, RevisionHeader, SingleFileDiff } from "@/components/diff"; 7 + import { FileList, RevisionHeader } from "@/components/diff"; 7 8 import { Button } from "@/components/ui/button"; 8 9 import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; 10 + import { ScrollArea } from "@/components/ui/scroll-area"; 9 11 import { 10 12 emptyChangesCollection, 11 13 emptyDiffCollection, ··· 99 101 } 100 102 101 103 /** 104 + * Multi-file diff viewer - shows multiple diffs in a scrollable container 105 + */ 106 + function MultiFileDiff({ 107 + patches, 108 + diffViewState, 109 + globalDiffStyle, 110 + }: { 111 + patches: Array<{ path: string; patch: string }>; 112 + diffViewState: DiffViewState; 113 + globalDiffStyle: DiffStyle; 114 + }) { 115 + if (patches.length === 0) { 116 + return ( 117 + <div className="flex items-center justify-center h-full text-muted-foreground text-sm"> 118 + Select a file to view its diff 119 + </div> 120 + ); 121 + } 122 + 123 + return ( 124 + <ScrollArea className="h-full w-full"> 125 + <div className="divide-y divide-border"> 126 + {patches.map(({ path, patch }) => { 127 + const effectiveStyle = diffViewState.styleOverrides.get(path) ?? globalDiffStyle; 128 + return ( 129 + <div key={path} className="min-h-0"> 130 + {!patch.trim() ? ( 131 + <div className="px-4 py-8 text-center text-muted-foreground text-sm"> 132 + No changes in {path} 133 + </div> 134 + ) : ( 135 + <PatchDiff 136 + patch={patch} 137 + options={{ hunkSeparators: "line-info", diffStyle: effectiveStyle }} 138 + /> 139 + )} 140 + </div> 141 + ); 142 + })} 143 + </div> 144 + </ScrollArea> 145 + ); 146 + } 147 + 148 + /** 102 149 * Get the current diff view state, resetting if the changeId has changed. 103 150 * This is a pure derivation - no useEffect needed for state sync. 104 151 */ ··· 119 166 const scrollContainerRef = useRef<HTMLDivElement>(null); 120 167 const [globalDiffStyle] = useAtom(diffStyleAtom); 121 168 const [diffViewState, setDiffViewState] = useAtom(diffViewStateAtom); 122 - const [selectedFile, setSelectedFile] = useState<string | null>(null); 169 + const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()); 123 170 const prevChangeIdRef = useRef<string | null>(null); 124 171 125 - // Get effective diff style for selected file 126 - const effectiveDiffStyle = selectedFile 127 - ? (diffViewState.styleOverrides.get(selectedFile) ?? globalDiffStyle) 172 + // Get first selected file for style override display 173 + const firstSelectedFile = selectedFiles.size > 0 ? [...selectedFiles][0] : null; 174 + 175 + // Get effective diff style for first selected file 176 + const effectiveDiffStyle = firstSelectedFile 177 + ? (diffViewState.styleOverrides.get(firstSelectedFile) ?? globalDiffStyle) 128 178 : globalDiffStyle; 129 179 130 180 function handleSetLocalStyle(style: DiffStyle) { 131 - if (!selectedFile) return; 181 + if (selectedFiles.size === 0) return; 132 182 setDiffViewState((prev) => { 133 183 const next = new Map(prev.styleOverrides); 134 - next.set(selectedFile, style); 184 + // Apply style to all selected files 185 + for (const file of selectedFiles) { 186 + next.set(file, style); 187 + } 135 188 return { ...prev, styleOverrides: next }; 136 189 }); 137 190 } 138 191 139 - // Reset selected file when changeId changes 192 + // Reset selected files when changeId changes 140 193 if (prevChangeIdRef.current !== changeId) { 141 194 prevChangeIdRef.current = changeId; 142 - if (selectedFile !== null) { 143 - setSelectedFile(null); 195 + if (selectedFiles.size > 0) { 196 + setSelectedFiles(new Set()); 144 197 } 145 198 } 146 199 ··· 195 248 196 249 // Auto-select first file when files load and none selected 197 250 useEffect(() => { 198 - if (changedFiles.length > 0 && !selectedFile) { 199 - setSelectedFile(changedFiles[0].path); 251 + if (changedFiles.length > 0 && selectedFiles.size === 0) { 252 + setSelectedFiles(new Set([changedFiles[0].path])); 200 253 } 201 - }, [changedFiles, selectedFile]); 254 + }, [changedFiles, selectedFiles.size]); 202 255 203 - // Get patch for selected file 204 - const selectedPatch = selectedFile ? (patchMap.get(selectedFile) ?? null) : null; 256 + // Get patches for selected files (in order) 257 + const selectedPatches = useMemo(() => { 258 + const patches: Array<{ path: string; patch: string }> = []; 259 + // Maintain file order from changedFiles 260 + for (const file of changedFiles) { 261 + if (selectedFiles.has(file.path)) { 262 + const patch = patchMap.get(file.path); 263 + if (patch) { 264 + patches.push({ path: file.path, patch }); 265 + } 266 + } 267 + } 268 + return patches; 269 + }, [changedFiles, selectedFiles, patchMap]); 205 270 206 271 if (!repoPath || !changeId) { 207 272 return ( ··· 234 299 > 235 300 {/* Revision header */} 236 301 {revision && ( 237 - <div className="px-4 pt-4 pb-2 shrink-0"> 302 + <div className="px-4 pt-2 pb-2 shrink-0"> 238 303 <RevisionHeader revision={revision} /> 239 304 </div> 240 305 )} 241 306 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"> 307 + {/* Toolbar */} 308 + <div className="flex items-center justify-end px-3 py-2 border-b border-border bg-background shrink-0 min-w-0"> 309 + <div className="flex items-center gap-0.5 shrink-0"> 248 310 <Button 249 311 variant={effectiveDiffStyle === "unified" ? "secondary" : "ghost"} 250 312 size="icon-xs" 251 313 onClick={() => handleSetLocalStyle("unified")} 252 314 title="Unified diff view" 253 315 className="h-6 w-6" 254 - disabled={!selectedFile} 316 + disabled={selectedFiles.size === 0} 255 317 > 256 318 <RowsIcon className="size-3" /> 257 319 </Button> ··· 261 323 onClick={() => handleSetLocalStyle("split")} 262 324 title="Split diff view" 263 325 className="h-6 w-6" 264 - disabled={!selectedFile} 326 + disabled={selectedFiles.size === 0} 265 327 > 266 328 <Columns2Icon className="size-3" /> 267 329 </Button> ··· 280 342 <div className="h-full w-full min-w-0"> 281 343 <FileList 282 344 files={changedFiles} 283 - selectedFile={selectedFile} 284 - onSelectFile={setSelectedFile} 345 + selectedFiles={selectedFiles} 346 + onSelectFiles={setSelectedFiles} 285 347 totalAdditions={totalAdditions} 286 348 totalDeletions={totalDeletions} 287 349 /> ··· 293 355 {/* Diff content panel */} 294 356 <ResizablePanel id="diff-content" defaultSize="70%"> 295 357 <div className="h-full w-full min-w-0"> 296 - <SingleFileDiff patch={selectedPatch} filePath={selectedFile} /> 358 + <MultiFileDiff 359 + patches={selectedPatches} 360 + diffViewState={diffViewState} 361 + globalDiffStyle={globalDiffStyle} 362 + /> 297 363 </div> 298 364 </ResizablePanel> 299 365 </ResizablePanelGroup>
+153 -41
apps/desktop/src/components/diff/FileList.tsx
··· 23 23 24 24 interface FileListProps { 25 25 files: ChangedFile[]; 26 - selectedFile: string | null; 27 - onSelectFile: (filePath: string) => void; 26 + selectedFiles: Set<string>; 27 + onSelectFiles: (filePaths: Set<string>) => void; 28 28 totalAdditions: number; 29 29 totalDeletions: number; 30 30 } ··· 140 140 interface TreeNodeComponentProps { 141 141 node: TreeNode; 142 142 depth: number; 143 - selectedFile: string | null; 144 - onSelectFile: (filePath: string) => void; 143 + selectedFiles: Set<string>; 144 + onSelectFile: (filePath: string, modifiers: { shift: boolean; meta: boolean }) => void; 145 + onSelectFolder: (folderPath: string) => void; 145 146 expandedDirs: Set<string>; 146 147 toggleDir: (path: string) => void; 147 148 itemRefs: React.RefObject<Map<string, HTMLButtonElement>>; 148 149 } 149 150 151 + // Collect all file paths under a tree node 152 + function collectFilePaths(node: TreeNode): string[] { 153 + if (!node.isDirectory) { 154 + return node.path ? [node.path] : []; 155 + } 156 + const paths: string[] = []; 157 + for (const child of node.children.values()) { 158 + paths.push(...collectFilePaths(child)); 159 + } 160 + return paths; 161 + } 162 + 150 163 function TreeNodeComponent({ 151 164 node, 152 165 depth, 153 - selectedFile, 166 + selectedFiles, 154 167 onSelectFile, 168 + onSelectFolder, 155 169 expandedDirs, 156 170 toggleDir, 157 171 itemRefs, ··· 164 178 <div> 165 179 <button 166 180 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 - )} 181 + onClick={(e) => { 182 + if (e.metaKey || e.ctrlKey) { 183 + // Cmd/Ctrl+click selects all files in folder 184 + onSelectFolder(node.path); 185 + } else { 186 + toggleDir(node.path); 187 + } 188 + }} 189 + className="w-full flex items-center gap-1.5 px-3 py-1 text-left text-sm text-muted-foreground" 172 190 style={{ paddingLeft: `${depth * 12 + 12}px` }} 173 191 > 174 192 {isExpanded ? ( ··· 177 195 <ChevronRightIcon className="size-3 shrink-0" /> 178 196 )} 179 197 {isExpanded ? ( 180 - <FolderOpenIcon className="size-4 shrink-0 text-amber-500" /> 198 + <FolderOpenIcon className="size-4 shrink-0 text-muted-foreground" /> 181 199 ) : ( 182 - <FolderIcon className="size-4 shrink-0 text-amber-500" /> 200 + <FolderIcon className="size-4 shrink-0 text-muted-foreground" /> 183 201 )} 184 202 <span className="truncate">{node.name}</span> 185 203 </button> ··· 190 208 key={child.path} 191 209 node={child} 192 210 depth={depth + 1} 193 - selectedFile={selectedFile} 211 + selectedFiles={selectedFiles} 194 212 onSelectFile={onSelectFile} 213 + onSelectFolder={onSelectFolder} 195 214 expandedDirs={expandedDirs} 196 215 toggleDir={toggleDir} 197 216 itemRefs={itemRefs} ··· 204 223 } 205 224 206 225 // File node 207 - const isSelected = node.path === selectedFile; 226 + const isSelected = selectedFiles.has(node.path); 227 + // Add extra padding to align with folder text (chevron width + gap) 228 + const fileIndent = depth * 12 + 12 + 18; 208 229 return ( 209 230 <button 210 231 key={node.path} ··· 213 234 else itemRefs.current?.delete(node.path); 214 235 }} 215 236 type="button" 216 - onClick={() => onSelectFile(node.path)} 237 + onClick={(e) => onSelectFile(node.path, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey })} 217 238 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", 239 + "w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm", 240 + isSelected ? "bg-accent/40 text-foreground" : "text-muted-foreground", 221 241 )} 222 - style={{ paddingLeft: `${depth * 12 + 12}px` }} 242 + style={{ paddingLeft: `${fileIndent}px` }} 223 243 > 224 244 {node.file && getFileStatusIcon(node.file.status)} 225 245 <span className="truncate font-medium">{node.name}</span> ··· 229 249 230 250 export function FileList({ 231 251 files, 232 - selectedFile, 233 - onSelectFile, 252 + selectedFiles, 253 + onSelectFiles, 234 254 totalAdditions, 235 255 totalDeletions, 236 256 }: FileListProps) { 237 - const [focusPanel] = useAtom(focusPanelAtom); 257 + const [focusPanel, setFocusPanel] = useAtom(focusPanelAtom); 238 258 const hasFocus = focusPanel === "diff"; 239 259 const listRef = useRef<HTMLDivElement>(null); 240 260 const itemRefs = useRef<Map<string, HTMLButtonElement>>(new Map()); 241 261 const [filterQuery, setFilterQuery] = useState(""); 242 262 const [viewMode, setViewMode] = useState<"flat" | "tree">("tree"); 243 263 const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set()); 264 + const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null); 244 265 245 266 // Filter files by search query 246 267 const filteredFiles = useMemo(() => { ··· 284 305 }); 285 306 }, []); 286 307 287 - const selectedIndex = selectedFile ? filteredFiles.findIndex((f) => f.path === selectedFile) : -1; 308 + // Handle file selection with modifiers 309 + const handleSelectFile = useCallback( 310 + (filePath: string, modifiers: { shift: boolean; meta: boolean }) => { 311 + const clickedIndex = filteredFiles.findIndex((f) => f.path === filePath); 312 + 313 + // Set focus to diff panel when clicking on a file 314 + setFocusPanel("diff"); 315 + 316 + if (modifiers.meta) { 317 + // Cmd/Ctrl+click: toggle selection 318 + const newSelected = new Set(selectedFiles); 319 + if (newSelected.has(filePath)) { 320 + newSelected.delete(filePath); 321 + } else { 322 + newSelected.add(filePath); 323 + } 324 + onSelectFiles(newSelected); 325 + setLastClickedIndex(clickedIndex); 326 + } else if (modifiers.shift && lastClickedIndex !== null) { 327 + // Shift+click: range selection 328 + const start = Math.min(lastClickedIndex, clickedIndex); 329 + const end = Math.max(lastClickedIndex, clickedIndex); 330 + const newSelected = new Set(selectedFiles); 331 + for (let i = start; i <= end; i++) { 332 + newSelected.add(filteredFiles[i].path); 333 + } 334 + onSelectFiles(newSelected); 335 + } else { 336 + // Normal click: single selection 337 + onSelectFiles(new Set([filePath])); 338 + setLastClickedIndex(clickedIndex); 339 + } 340 + }, 341 + [filteredFiles, selectedFiles, onSelectFiles, lastClickedIndex, setFocusPanel], 342 + ); 343 + 344 + // Handle folder selection (select all files in folder) 345 + const handleSelectFolder = useCallback( 346 + (folderPath: string) => { 347 + // Find the tree node for this folder 348 + const findNode = (node: TreeNode, path: string): TreeNode | null => { 349 + if (node.path === path) return node; 350 + for (const child of node.children.values()) { 351 + const found = findNode(child, path); 352 + if (found) return found; 353 + } 354 + return null; 355 + }; 356 + 357 + const folderNode = findNode(tree, folderPath); 358 + if (!folderNode) return; 359 + 360 + const folderFiles = collectFilePaths(folderNode); 361 + const allSelected = folderFiles.every((f) => selectedFiles.has(f)); 362 + 363 + const newSelected = new Set(selectedFiles); 364 + if (allSelected) { 365 + // Deselect all files in folder 366 + for (const f of folderFiles) { 367 + newSelected.delete(f); 368 + } 369 + } else { 370 + // Select all files in folder 371 + for (const f of folderFiles) { 372 + newSelected.add(f); 373 + } 374 + } 375 + onSelectFiles(newSelected); 376 + }, 377 + [tree, selectedFiles, onSelectFiles], 378 + ); 379 + 380 + // Get first selected file index for navigation 381 + const firstSelectedPath = selectedFiles.size > 0 ? [...selectedFiles][0] : null; 382 + const selectedIndex = firstSelectedPath 383 + ? filteredFiles.findIndex((f) => f.path === firstSelectedPath) 384 + : -1; 288 385 289 386 // Navigate to next file 290 387 const navigateDown = useCallback(() => { 291 388 if (filteredFiles.length === 0) return; 292 389 const nextIndex = selectedIndex < filteredFiles.length - 1 ? selectedIndex + 1 : selectedIndex; 293 390 const nextFile = filteredFiles[nextIndex]; 294 - if (nextFile) onSelectFile(nextFile.path); 295 - }, [filteredFiles, selectedIndex, onSelectFile]); 391 + if (nextFile) { 392 + onSelectFiles(new Set([nextFile.path])); 393 + setLastClickedIndex(nextIndex); 394 + } 395 + }, [filteredFiles, selectedIndex, onSelectFiles]); 296 396 297 397 // Navigate to previous file 298 398 const navigateUp = useCallback(() => { 299 399 if (filteredFiles.length === 0) return; 300 400 const prevIndex = selectedIndex > 0 ? selectedIndex - 1 : 0; 301 401 const prevFile = filteredFiles[prevIndex]; 302 - if (prevFile) onSelectFile(prevFile.path); 303 - }, [filteredFiles, selectedIndex, onSelectFile]); 402 + if (prevFile) { 403 + onSelectFiles(new Set([prevFile.path])); 404 + setLastClickedIndex(prevIndex); 405 + } 406 + }, [filteredFiles, selectedIndex, onSelectFiles]); 304 407 305 408 // Keyboard navigation when diff panel is focused 306 409 useKeyboardShortcut({ ··· 327 430 enabled: hasFocus, 328 431 }); 329 432 330 - // Scroll selected item into view 433 + // Scroll first selected item into view 331 434 useEffect(() => { 332 - if (selectedFile) { 333 - const item = itemRefs.current.get(selectedFile); 435 + if (firstSelectedPath) { 436 + const item = itemRefs.current.get(firstSelectedPath); 334 437 item?.scrollIntoView({ block: "nearest", behavior: "instant" }); 335 438 } 336 - }, [selectedFile]); 439 + }, [firstSelectedPath]); 337 440 338 441 return ( 339 442 <div className="flex flex-col h-full w-full overflow-hidden"> ··· 375 478 placeholder="Filter files..." 376 479 value={filterQuery} 377 480 onChange={(e) => setFilterQuery(e.target.value)} 378 - className="h-7 pl-7 text-xs" 481 + className="h-7 pl-7 text-xs rounded-md" 379 482 /> 380 483 </div> 381 484 </div> 382 485 383 486 <ScrollArea className="flex-1"> 384 - <div ref={listRef} className="py-1"> 487 + <div ref={listRef}> 385 488 {viewMode === "flat" 386 489 ? // Flat list view 387 - filteredFiles.map((file) => { 388 - const isSelected = file.path === selectedFile; 490 + filteredFiles.map((file, index) => { 491 + const isSelected = selectedFiles.has(file.path); 389 492 const fileName = getFileName(file.path); 390 493 const directory = getFileDirectory(file.path); 391 494 ··· 397 500 else itemRefs.current.delete(file.path); 398 501 }} 399 502 type="button" 400 - onClick={() => onSelectFile(file.path)} 503 + onClick={(e) => 504 + handleSelectFile(file.path, { 505 + shift: e.shiftKey, 506 + meta: e.metaKey || e.ctrlKey, 507 + }) 508 + } 401 509 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", 510 + "w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm", 511 + isSelected 512 + ? "bg-accent/40 text-foreground" 513 + : index % 2 === 1 514 + ? "bg-muted/30 text-muted-foreground" 515 + : "text-muted-foreground", 405 516 )} 406 517 > 407 518 {getFileStatusIcon(file.status)} ··· 420 531 key={child.path} 421 532 node={child} 422 533 depth={0} 423 - selectedFile={selectedFile} 424 - onSelectFile={onSelectFile} 534 + selectedFiles={selectedFiles} 535 + onSelectFile={handleSelectFile} 536 + onSelectFolder={handleSelectFolder} 425 537 expandedDirs={expandedDirs} 426 538 toggleDir={toggleDir} 427 539 itemRefs={itemRefs}
+7 -17
apps/desktop/src/components/diff/RevisionHeader.tsx
··· 1 1 import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; 2 2 import { useState } from "react"; 3 3 import type { Revision } from "@/tauri-commands"; 4 - import { cn } from "@/lib/utils"; 5 4 6 5 interface RevisionHeaderProps { 7 6 revision: Revision; ··· 18 17 const hasBody = body.length > 0; 19 18 20 19 return ( 21 - <div className="border border-border rounded-lg bg-card"> 20 + <div> 22 21 <div className="px-3 py-2 font-mono text-xs space-y-1.5"> 23 22 <div className="flex gap-4"> 24 23 <div> ··· 38 37 </div> 39 38 {title && ( 40 39 <div className="mt-2 pt-2 border-t border-border"> 41 - <div className="flex items-start justify-between gap-2"> 42 - <span className="text-sm font-semibold text-foreground font-sans">{title}</span> 40 + <div className="flex items-start gap-1"> 43 41 {hasBody && ( 44 42 <button 45 43 type="button" 46 44 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 - )} 45 + className="text-muted-foreground hover:text-foreground shrink-0 transition-colors mt-0.5" 51 46 > 52 47 {isExpanded ? ( 53 - <> 54 - <ChevronDownIcon className="size-3" /> 55 - <span>collapse</span> 56 - </> 48 + <ChevronDownIcon className="size-4" /> 57 49 ) : ( 58 - <> 59 - <ChevronRightIcon className="size-3" /> 60 - <span>expand</span> 61 - </> 50 + <ChevronRightIcon className="size-4" /> 62 51 )} 63 52 </button> 64 53 )} 54 + <span className="text-sm font-semibold text-foreground font-sans">{title}</span> 65 55 </div> 66 56 {hasBody && isExpanded && ( 67 - <pre className="text-xs text-muted-foreground whitespace-pre-wrap font-sans mt-2"> 57 + <pre className="text-xs text-muted-foreground whitespace-pre-wrap font-sans mt-2 ml-5"> 68 58 {body} 69 59 </pre> 70 60 )}
+100 -96
apps/desktop/src/components/revision-graph/RevisionRow.tsx
··· 80 80 }); 81 81 } 82 82 83 - // Constants matching edge layer calculations 84 - const TOP_PADDING = 16; 85 - const CONTENT_MIN_HEIGHT = 56; 86 83 const nodeSize = revision.is_working_copy ? NODE_RADIUS * 2 + 14 : NODE_RADIUS * 2 + 8; 87 84 88 85 return ( 89 - <div style={{ height: isExpanded ? "auto" : ROW_HEIGHT }} className="flex flex-col relative"> 86 + // biome-ignore lint/a11y/useSemanticElements: Complex styling requires div 87 + <div 88 + ref={(el) => { 89 + // Focus management: when this row is focused and rendered, focus the DOM element 90 + if (isFocused && el && document.activeElement !== el) { 91 + el.focus({ preventScroll: true }); 92 + } 93 + }} 94 + role="button" 95 + tabIndex={0} 96 + style={{ height: isExpanded ? "auto" : ROW_HEIGHT }} 97 + className={`flex relative select-none outline-none ${ 98 + revision.is_immutable ? "opacity-60" : "" 99 + } ${isDimmed ? "opacity-40" : ""}`} 100 + data-selected={isSelected || undefined} 101 + data-checked={isChecked || undefined} 102 + data-expanded={isExpanded || undefined} 103 + data-change-id={revision.change_id} 104 + onClick={(e) => { 105 + // Prevent text selection on shift+click 106 + if (e.shiftKey) { 107 + e.preventDefault(); 108 + window.getSelection()?.removeAllRanges(); 109 + } 110 + // Set focus to revisions panel when clicking 111 + setFocusPanel("revisions"); 112 + onSelect(revision.change_id, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 113 + }} 114 + onKeyDown={(e) => { 115 + if (e.key === "Enter" || e.key === " ") { 116 + onSelect(revision.change_id, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 117 + } 118 + }} 119 + > 90 120 {/* Graph node - absolutely positioned to align with edge layer */} 91 121 <div 92 122 className="absolute z-20 flex items-center justify-center" 93 123 style={{ 94 124 left: nodeOffset - nodeSize / 2, 95 - top: TOP_PADDING + CONTENT_MIN_HEIGHT / 2 - nodeSize / 2, 125 + top: ROW_HEIGHT / 2 - nodeSize / 2, 96 126 }} 97 127 > 98 128 <GraphNode revision={revision} lane={lane} isSelected={isSelected} color={color} /> 99 129 </div> 100 - <div className="flex items-start min-h-[56px] pt-4"> 101 - {/* Spacer for graph area */} 102 - <div className="shrink-0" style={{ width: nodeAreaWidth }} /> 103 - {/* biome-ignore lint/a11y/useSemanticElements: Complex styling requires div */} 104 - <div 105 - role="button" 106 - tabIndex={0} 107 - className={`relative flex-1 mr-2 min-w-0 overflow-hidden rounded my-2 mx-1 select-none border ${ 108 - isFocused || isChecked 109 - ? "bg-accent/40 border-accent/60 hover:bg-accent/50" 110 - : "bg-card hover:bg-muted border-border" 111 - } text-card-foreground shadow-sm hover:shadow hover:cursor-pointer ${ 112 - revision.is_immutable ? "opacity-60" : "" 113 - } ${isDimmed ? "opacity-40" : ""}`} 114 - data-focused={isFocused || undefined} 115 - data-selected={isSelected || undefined} 116 - data-checked={isChecked || undefined} 117 - data-expanded={isExpanded || undefined} 118 - data-change-id={revision.change_id} 119 - onClick={(e) => { 120 - // Prevent text selection on shift+click 121 - if (e.shiftKey) { 122 - e.preventDefault(); 123 - window.getSelection()?.removeAllRanges(); 124 - } 125 - onSelect(revision.change_id, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 126 - }} 127 - onKeyDown={(e) => { 128 - if (e.key === "Enter" || e.key === " ") { 129 - onSelect(revision.change_id, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 130 - } 131 - }} 132 - > 133 - <div className={`px-3 py-2 min-w-0 ${isPendingAbandon ? "blur-sm" : ""}`}> 134 - <div className="flex items-center gap-2 flex-nowrap min-w-0"> 135 - <code 136 - className={`text-xs font-mono rounded px-0.5 shrink-0 ${ 137 - isFlashing ? "bg-primary/40 animate-pulse" : "" 138 - } text-muted-foreground`} 139 - > 140 - {jumpModeActive && jumpHint ? ( 141 - <> 142 - {/* Already matched portion */} 143 - {jumpQuery && ( 144 - <span className="bg-primary/30 text-primary font-semibold"> 145 - {revision.change_id_short.slice(0, jumpQuery.length)} 146 - </span> 147 - )} 148 - {/* Next character to type (the hint) */} 149 - <span className="bg-primary text-primary-foreground font-semibold rounded-sm"> 150 - {revision.change_id_short[jumpQuery.length]} 130 + {/* Spacer for graph area */} 131 + <div className="shrink-0" style={{ width: nodeAreaWidth + 8 }} /> 132 + {/* Content area with visual styling - full row height */} 133 + <div 134 + className={`relative flex-1 mr-2 min-w-0 overflow-hidden text-card-foreground flex flex-col justify-center py-1 border-b ${ 135 + isChecked || isFocused ? "bg-accent/40 rounded-md border-transparent" : "border-border/30" 136 + }`} 137 + > 138 + <div className={`px-3 py-1.5 min-w-0 ${isPendingAbandon ? "blur-sm" : ""}`}> 139 + <div className="flex items-center gap-2 flex-nowrap min-w-0"> 140 + <code 141 + className={`text-xs font-mono rounded px-0.5 shrink-0 ${ 142 + isFlashing ? "bg-primary/40 animate-pulse" : "" 143 + } text-muted-foreground`} 144 + > 145 + {jumpModeActive && jumpHint ? ( 146 + <> 147 + {/* Already matched portion */} 148 + {jumpQuery && ( 149 + <span className="bg-primary/30 text-primary font-semibold"> 150 + {revision.change_id_short.slice(0, jumpQuery.length)} 151 151 </span> 152 - {/* Rest of the ID */} 153 - <span>{revision.change_id_short.slice(jumpQuery.length + 1)}</span> 154 - </> 155 - ) : ( 156 - revision.change_id_short 157 - )} 158 - </code> 159 - {revision.bookmarks.length > 0 && ( 160 - <span 161 - className="text-xs text-primary font-medium truncate min-w-0 whitespace-nowrap" 162 - title={revision.bookmarks.join(", ")} 163 - > 164 - {revision.bookmarks.join(", ")} 165 - </span> 152 + )} 153 + {/* Next character to type (the hint) */} 154 + <span className="bg-primary text-primary-foreground font-semibold rounded-sm"> 155 + {revision.change_id_short[jumpQuery.length]} 156 + </span> 157 + {/* Rest of the ID */} 158 + <span>{revision.change_id_short.slice(jumpQuery.length + 1)}</span> 159 + </> 160 + ) : ( 161 + revision.change_id_short 166 162 )} 167 - <span className="text-xs text-muted-foreground truncate min-w-0 shrink-0"> 168 - {revision.author.split("@")[0]} · {revision.timestamp} 163 + </code> 164 + {revision.bookmarks.length > 0 && ( 165 + <span 166 + className="text-xs text-primary font-medium truncate min-w-0 whitespace-nowrap" 167 + title={revision.bookmarks.join(", ")} 168 + > 169 + {revision.bookmarks.join(", ")} 169 170 </span> 170 - </div> 171 - <div className={`text-sm mt-1 ${isExpanded ? "" : "truncate"}`}>{firstLine}</div> 171 + )} 172 + <span className="text-xs text-muted-foreground truncate min-w-0 shrink-0"> 173 + {revision.author.split("@")[0]} · {revision.timestamp} 174 + </span> 172 175 </div> 173 - {isExpanded && ( 174 - <div className={`px-3 pb-3 pt-0 space-y-3 ${isPendingAbandon ? "blur-sm" : ""}`}> 175 - <pre className="text-xs text-muted-foreground whitespace-pre-wrap break-words font-mono bg-muted/40 border border-border/60 rounded p-2"> 176 - {fullDescription} 177 - </pre> 178 - <div className="border border-border rounded-lg overflow-hidden bg-background"> 179 - <ChangedFilesList 180 - files={changedFilesQuery.data ?? []} 181 - selectedFile={selectedFile} 182 - onSelectFile={handleSelectFile} 183 - isLoading={changedFilesQuery.isLoading} 184 - /> 185 - </div> 176 + <div className={`text-sm mt-1 ${isExpanded ? "" : "truncate"}`}>{firstLine}</div> 177 + </div> 178 + {isExpanded && ( 179 + <div className={`px-3 pb-3 pt-0 space-y-3 ${isPendingAbandon ? "blur-sm" : ""}`}> 180 + <pre className="text-xs text-muted-foreground whitespace-pre-wrap break-words font-mono bg-muted/40 border border-border/60 rounded p-2"> 181 + {fullDescription} 182 + </pre> 183 + <div className="border border-border rounded-lg overflow-hidden bg-background"> 184 + <ChangedFilesList 185 + files={changedFilesQuery.data ?? []} 186 + selectedFile={selectedFile} 187 + onSelectFile={handleSelectFile} 188 + isLoading={changedFilesQuery.isLoading} 189 + /> 186 190 </div> 187 - )} 188 - {isPendingAbandon && ( 189 - <div className="absolute inset-0 flex items-center justify-center bg-destructive/10 rounded"> 190 - <div className="text-sm font-medium text-destructive-foreground bg-destructive/90 px-3 py-1.5 rounded"> 191 - Abandon this revision? <kbd className="ml-1 px-1 bg-background/20 rounded">Y</kbd> /{" "} 192 - <kbd className="px-1 bg-background/20 rounded">N</kbd> 193 - </div> 191 + </div> 192 + )} 193 + {isPendingAbandon && ( 194 + <div className="absolute inset-0 flex items-center justify-center bg-destructive/10 rounded"> 195 + <div className="text-sm font-medium text-destructive-foreground bg-destructive/90 px-3 py-1.5 rounded"> 196 + Abandon this revision? <kbd className="ml-1 px-1 bg-background/20 rounded">Y</kbd> /{" "} 197 + <kbd className="px-1 bg-background/20 rounded">N</kbd> 194 198 </div> 195 - )} 196 - </div> 199 + </div> 200 + )} 197 201 </div> 198 202 </div> 199 203 );
+41 -68
apps/desktop/src/components/revision-graph/index.tsx
··· 1000 1000 const wcIndex = workingCopy ? changeIdToIndex.get(workingCopy.change_id) : undefined; 1001 1001 1002 1002 // Calculate edge layer dimensions and row center positions 1003 - const TOP_PADDING = 16; // Matches pt-4 on RevisionRow 1004 - const CONTENT_MIN_HEIGHT = 56; // Matches min-h-[56px] on RevisionRow content 1005 1003 const getRowStart = (row: number) => rowOffsets.get(row) ?? row * ROW_HEIGHT; 1006 - const getRowCenter = (row: number) => getRowStart(row) + TOP_PADDING + CONTENT_MIN_HEIGHT / 2; 1004 + const getRowCenter = (row: number) => getRowStart(row) + ROW_HEIGHT / 2; 1007 1005 const graphWidth = LANE_PADDING + laneCount * LANE_WIDTH + NODE_RADIUS + 2; 1008 1006 1009 1007 return ( 1010 1008 <div 1011 1009 ref={parentRef} 1012 - className="h-full overflow-auto ascii-bg" 1010 + className="h-full overflow-auto ascii-bg pt-4" 1013 1011 style={{ overflowAnchor: "none" }} 1014 1012 > 1015 1013 <div ··· 1046 1044 const { stack, lane } = displayRow; 1047 1045 const nodeAreaWidth = LANE_PADDING + (lane + 1) * LANE_WIDTH; 1048 1046 const count = stack.intermediateChangeIds.length; 1049 - // Show up to 3 stacked card layers 1050 - const layers = Math.min(count, 3); 1051 1047 1052 1048 // Check if this stack is related to the selected revision (for dimming) 1053 1049 const isStackRelated = stack.changeIds.some((id) => relatedRevisions.has(id)); ··· 1065 1061 height: ROW_HEIGHT, 1066 1062 }} 1067 1063 > 1068 - <div className="flex flex-col relative" style={{ height: ROW_HEIGHT }}> 1069 - <div className="flex items-start min-h-[56px] pt-4"> 1070 - {/* Spacer for graph area */} 1071 - <div className="shrink-0" style={{ width: nodeAreaWidth }} /> 1072 - <button 1073 - type="button" 1074 - onClick={() => handleToggleStack(stack.id)} 1075 - className={`relative flex-1 mr-2 min-w-0 my-2 mx-1 cursor-pointer group ${isStackDimmed ? "opacity-40" : ""}`} 1076 - style={{ height: 40 }} 1077 - data-focused={isStackFocused || undefined} 1078 - data-stack-id={stack.id} 1079 - > 1080 - {/* Stacked card layers */} 1081 - {Array.from({ length: layers }).map((_, i) => { 1082 - const layerIndex = layers - 1 - i; // Render back layers first 1083 - const offset = layerIndex * 4; 1084 - const isTopLayer = layerIndex === 0; 1085 - const scale = 1 - layerIndex * 0.02; 1086 - 1087 - return ( 1088 - <div 1089 - key={layerIndex} 1090 - className={`absolute left-0 right-0 rounded border shadow-sm group-hover:border-muted-foreground/50 ${ 1091 - isStackFocused && isTopLayer 1092 - ? "bg-accent/40 border-accent/60" 1093 - : "bg-card border-border" 1094 - } text-card-foreground`} 1095 - style={{ 1096 - top: 0, 1097 - height: 40, 1098 - transform: `translateY(${offset}px) scaleX(${scale})`, 1099 - transformOrigin: "top center", 1100 - opacity: 1 - layerIndex * 0.2, 1101 - zIndex: layers - layerIndex, 1102 - }} 1103 - /> 1104 - ); 1105 - })} 1106 - {/* Content overlay on top card */} 1107 - <div 1108 - className="absolute inset-0 flex items-center justify-center gap-2 rounded" 1109 - style={{ zIndex: layers + 1, height: 40 }} 1064 + <div className="flex relative" style={{ height: ROW_HEIGHT }}> 1065 + {/* Spacer for graph area */} 1066 + <div className="shrink-0" style={{ width: nodeAreaWidth + 8 }} /> 1067 + <button 1068 + type="button" 1069 + onClick={() => handleToggleStack(stack.id)} 1070 + className={`relative flex-1 mr-2 min-w-0 py-1 flex items-center justify-center outline-none border-b ${ 1071 + isStackFocused 1072 + ? "bg-accent/40 rounded-md border-transparent" 1073 + : "border-border/30" 1074 + } ${isStackDimmed ? "opacity-40" : ""}`} 1075 + ref={(el) => { 1076 + // Programmatically focus when stack becomes focused 1077 + if (isStackFocused && el && document.activeElement !== el) { 1078 + el.focus({ preventScroll: true }); 1079 + } 1080 + }} 1081 + data-stack-id={stack.id} 1082 + > 1083 + {/* Content */} 1084 + <div className="flex items-center justify-center gap-2"> 1085 + <svg 1086 + className="w-3.5 h-3.5 text-muted-foreground" 1087 + fill="none" 1088 + viewBox="0 0 24 24" 1089 + stroke="currentColor" 1090 + aria-hidden="true" 1110 1091 > 1111 - <svg 1112 - className="w-3.5 h-3.5 text-muted-foreground" 1113 - fill="none" 1114 - viewBox="0 0 24 24" 1115 - stroke="currentColor" 1116 - aria-hidden="true" 1117 - > 1118 - <path 1119 - strokeLinecap="round" 1120 - strokeLinejoin="round" 1121 - strokeWidth={2} 1122 - d="M19 9l-7 7-7-7" 1123 - /> 1124 - </svg> 1125 - <span className="text-xs text-muted-foreground group-hover:text-foreground"> 1126 - {count} hidden revision{count !== 1 ? "s" : ""} 1127 - </span> 1128 - </div> 1129 - </button> 1130 - </div> 1092 + <path 1093 + strokeLinecap="round" 1094 + strokeLinejoin="round" 1095 + strokeWidth={2} 1096 + d="M19 9l-7 7-7-7" 1097 + /> 1098 + </svg> 1099 + <span className="text-xs text-muted-foreground"> 1100 + {count} hidden revision{count !== 1 ? "s" : ""} 1101 + </span> 1102 + </div> 1103 + </button> 1131 1104 </div> 1132 1105 </div> 1133 1106 );
+1 -1
apps/desktop/src/components/ui/resizable.tsx
··· 34 34 "focus-visible:ring-ring relative flex items-center justify-center shrink-0 transition-colors focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden", 35 35 isVertical 36 36 ? "h-2 w-full bg-border/50 hover:bg-border cursor-row-resize" 37 - : "w-px h-full bg-transparent hover:bg-border/50 cursor-col-resize", 37 + : "w-px h-full bg-border/50 hover:bg-border cursor-col-resize", 38 38 className, 39 39 )} 40 40 {...props}
+11 -14
apps/desktop/src/styles/index.css
··· 210 210 } 211 211 } 212 212 213 - /* ASCII pattern background for revision graph */ 213 + /* Background for revision graph (dotted pattern removed for Mac-native look) */ 214 214 .ascii-bg { 215 215 background-color: var(--background); 216 - background-image: 217 - radial-gradient( 218 - circle, 219 - color-mix(in oklch, var(--foreground) 12%, transparent) 1px, 220 - transparent 1px 221 - ), 222 - radial-gradient( 223 - circle, 224 - color-mix(in oklch, var(--foreground) 8%, transparent) 1px, 225 - transparent 1px 226 - ); 227 - background-size: 40px 40px; 228 - background-position: 2px 2px, 22px 22px; 229 216 } 230 217 231 218 ··· 233 220 line.stack-edge.stack-edge-hovered { 234 221 stroke-width: 3; 235 222 stroke-opacity: 1; 223 + } 224 + 225 + /* @pierre/diffs theme integration */ 226 + [data-diffs-header], 227 + [data-diffs], 228 + [data-error-wrapper] { 229 + --diffs-light-bg: var(--background); 230 + --diffs-dark-bg: var(--background); 231 + --diffs-light: var(--foreground); 232 + --diffs-dark: var(--foreground); 236 233 } 237 234 238 235 /* Sticky header for @pierre/diffs file diffs */