a very good jj gui
0
fork

Configure Feed

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

feat: add inline jump mode for revision navigation

+1871 -488
+265 -140
apps/desktop/src/components/AceJump.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 - import { useEffect, useRef, useState } from "react"; 2 + import type React from "react"; 3 + import { useRef, useState, useEffect, useMemo, useCallback } from "react"; 3 4 import { aceJumpOpenAtom } from "@/atoms"; 5 + import { 6 + CommandDialog, 7 + CommandEmpty, 8 + CommandGroup, 9 + CommandInput, 10 + CommandItem, 11 + CommandList, 12 + } from "@/components/ui/command"; 4 13 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 5 - import type { Revision } from "@/tauri-commands"; 14 + import { resolveRevset, type Revision } from "@/tauri-commands"; 6 15 7 16 interface AceJumpProps { 8 17 revisions: Revision[]; 18 + repoPath: string | null; 9 19 onJump: (changeId: string) => void; 10 20 } 11 21 12 - export function AceJump({ revisions, onJump }: AceJumpProps) { 22 + // Highlight matching text in a string 23 + function HighlightMatch({ text, query }: { text: string; query: string }): React.ReactElement { 24 + if (!query) return <>{text}</>; 25 + 26 + const lowerText = text.toLowerCase(); 27 + const lowerQuery = query.toLowerCase(); 28 + const index = lowerText.indexOf(lowerQuery); 29 + 30 + if (index === -1) return <>{text}</>; 31 + 32 + const before = text.slice(0, index); 33 + const match = text.slice(index, index + query.length); 34 + const after = text.slice(index + query.length); 35 + 36 + return ( 37 + <> 38 + {before} 39 + <span className="bg-primary/30 text-primary font-semibold">{match}</span> 40 + {after} 41 + </> 42 + ); 43 + } 44 + 45 + // Check if a string looks like a revset expression 46 + function isRevsetExpression(query: string): boolean { 47 + const trimmed = query.trim(); 48 + if (!trimmed) return false; 49 + 50 + // Revset patterns: @, @-, @--, id-, id+, or any jj revset syntax 51 + // We'll be more liberal and consider anything with special chars as potential revset 52 + if (trimmed === "@") return true; 53 + if (/^@-+$/.test(trimmed)) return true; // @-, @--, etc. 54 + if (/^[a-z0-9]+-$/i.test(trimmed)) return true; // id- 55 + if (/^[a-z0-9]+\+$/i.test(trimmed)) return true; // id+ 56 + if (trimmed.includes("(")) return true; // function calls like trunk(), mine() 57 + if (trimmed.includes("|")) return true; // union 58 + if (trimmed.includes("&")) return true; // intersection 59 + if (trimmed.includes("::")) return true; // ancestors 60 + if (trimmed.includes("..")) return true; // range 61 + 62 + return false; 63 + } 64 + 65 + export function AceJump({ revisions, repoPath, onJump }: AceJumpProps) { 13 66 const [open, setOpen] = useAtom(aceJumpOpenAtom); 14 - const [query, setQuery] = useState(""); 15 - const [selectedIndex, setSelectedIndex] = useState(0); 16 - const inputRef = useRef<HTMLInputElement>(null); 67 + const [search, setSearch] = useState(""); 68 + const [revsetResult, setRevsetResult] = useState<{ 69 + changeIds: string[]; 70 + error: string | null; 71 + loading: boolean; 72 + label: string | null; 73 + }>({ changeIds: [], error: null, loading: false, label: null }); 17 74 18 75 useKeyboardShortcut({ 19 - key: "f", 76 + key: "/", 20 77 onPress: () => setOpen(true), 21 78 enabled: !open, 22 79 }); 23 80 81 + // Reset search when dialog opens 24 82 useEffect(() => { 25 83 if (open) { 26 - setQuery(""); 27 - setSelectedIndex(0); 28 - requestAnimationFrame(() => inputRef.current?.focus()); 84 + setSearch(""); 85 + setRevsetResult({ changeIds: [], error: null, loading: false, label: null }); 29 86 } 30 87 }, [open]); 31 88 32 - const matches = (() => { 33 - if (!query) return []; // Don't show list until user starts typing 34 - const lowerQuery = query.toLowerCase(); 35 - return revisions.filter((r) => r.change_id.toLowerCase().startsWith(lowerQuery)).slice(0, 10); 36 - })(); 37 - 38 - function close() { 39 - if (document.activeElement instanceof HTMLElement) { 40 - document.activeElement.blur(); 41 - } 42 - setOpen(false); 43 - } 44 - 45 - // Stable refs for callbacks to avoid effect re-running 89 + // Stable ref for callback 46 90 const onJumpRef = useRef(onJump); 47 91 onJumpRef.current = onJump; 48 92 49 93 function jumpTo(changeId: string) { 50 - // Blur active element synchronously before closing 51 - if (document.activeElement instanceof HTMLElement) { 52 - document.activeElement.blur(); 53 - } 54 94 setOpen(false); 55 - // Defer the actual jump to next frame to ensure focus is fully released 56 95 requestAnimationFrame(() => { 57 96 onJumpRef.current(changeId); 58 97 }); 59 98 } 60 99 61 - // Auto-jump when single match and query is at least 2 characters 62 - const singleMatchChangeId = matches.length === 1 ? matches[0].change_id : null; 100 + // Debounced revset resolution 101 + const resolveRevsetDebounced = useCallback( 102 + async (query: string) => { 103 + if (!repoPath || !query.trim()) { 104 + setRevsetResult({ changeIds: [], error: null, loading: false, label: null }); 105 + return; 106 + } 107 + 108 + if (!isRevsetExpression(query)) { 109 + setRevsetResult({ changeIds: [], error: null, loading: false, label: null }); 110 + return; 111 + } 112 + 113 + setRevsetResult((prev) => ({ ...prev, loading: true, label: query })); 114 + 115 + try { 116 + const result = await resolveRevset(repoPath, query.trim()); 117 + setRevsetResult({ 118 + changeIds: result.change_ids, 119 + error: result.error, 120 + loading: false, 121 + label: query, 122 + }); 123 + } catch (err) { 124 + setRevsetResult({ 125 + changeIds: [], 126 + error: String(err), 127 + loading: false, 128 + label: query, 129 + }); 130 + } 131 + }, 132 + [repoPath], 133 + ); 134 + 135 + // Debounce the revset resolution 63 136 useEffect(() => { 64 - if (singleMatchChangeId && query.length >= 2) { 65 - jumpTo(singleMatchChangeId); 137 + const timeout = setTimeout(() => { 138 + resolveRevsetDebounced(search); 139 + }, 150); // 150ms debounce 140 + 141 + return () => clearTimeout(timeout); 142 + }, [search, resolveRevsetDebounced]); 143 + 144 + // Build lookup maps 145 + const revisionByChangeId = useMemo( 146 + () => new Map(revisions.map((r) => [r.change_id, r])), 147 + [revisions], 148 + ); 149 + 150 + // Determine if we're in revset mode 151 + const isRevsetMode = isRevsetExpression(search) && (revsetResult.loading || revsetResult.changeIds.length > 0 || revsetResult.error); 152 + const revsetChangeIdSet = useMemo( 153 + () => new Set(revsetResult.changeIds), 154 + [revsetResult.changeIds], 155 + ); 156 + 157 + // Determine what matched for each revision 158 + function getMatchType(revision: Revision): "revset" | "changeId" | "bookmark" | "description" | null { 159 + if (isRevsetMode && revsetChangeIdSet.has(revision.change_id)) { 160 + return "revset"; 66 161 } 67 - }, [singleMatchChangeId, query.length]); 162 + if (!search || isRevsetMode) return null; 163 + const lowerSearch = search.toLowerCase(); 164 + 165 + if (revision.change_id.toLowerCase().startsWith(lowerSearch)) return "changeId"; 166 + if (revision.bookmarks.some((b) => b.toLowerCase().includes(lowerSearch))) return "bookmark"; 167 + if (revision.description.toLowerCase().includes(lowerSearch)) return "description"; 168 + return null; 169 + } 68 170 69 - function handleKeyDown(e: React.KeyboardEvent) { 70 - if (e.key === "Escape") { 71 - e.preventDefault(); 72 - close(); 73 - } else if (e.key === "Enter") { 74 - e.preventDefault(); 75 - if (matches.length > 0) { 76 - jumpTo(matches[selectedIndex]?.change_id ?? matches[0].change_id); 77 - } 78 - } else if (e.key === "ArrowDown" || (e.key === "j" && e.ctrlKey)) { 79 - e.preventDefault(); 80 - setSelectedIndex((i) => Math.min(i + 1, matches.length - 1)); 81 - } else if (e.key === "ArrowUp" || (e.key === "k" && e.ctrlKey)) { 82 - e.preventDefault(); 83 - setSelectedIndex((i) => Math.max(i - 1, 0)); 171 + function getMatchingBookmark(revision: Revision): string | null { 172 + if (!search || isRevsetMode) return null; 173 + const lowerSearch = search.toLowerCase(); 174 + return revision.bookmarks.find((b) => b.toLowerCase().includes(lowerSearch)) ?? null; 175 + } 176 + 177 + // Custom filter function that ranks by match type 178 + function customFilter(value: string, searchQuery: string): number { 179 + if (!searchQuery) return 1; // Show all when no search 180 + 181 + const revision = revisionByChangeId.get(value); 182 + if (!revision) return 0; 183 + 184 + // Revset match - highest priority 185 + if (isRevsetMode) { 186 + return revsetChangeIdSet.has(value) ? 1.0 : 0; 187 + } 188 + 189 + const lowerSearch = searchQuery.toLowerCase(); 190 + 191 + // Change ID match - highest priority 192 + if (revision.change_id.toLowerCase().startsWith(lowerSearch)) { 193 + return 1.0; 194 + } 195 + 196 + // Bookmark match - medium priority 197 + if (revision.bookmarks.some((b) => b.toLowerCase().includes(lowerSearch))) { 198 + return 0.7; 199 + } 200 + 201 + // Description match - lower priority 202 + if (revision.description.toLowerCase().includes(lowerSearch)) { 203 + return 0.4; 84 204 } 205 + 206 + return 0; 85 207 } 86 208 87 - if (!open) return null; 209 + // When in revset mode, we need to disable cmdk's text-based filter 210 + // because revset expressions like "@" don't match any text 211 + const shouldFilter = !isRevsetMode; 212 + 213 + // Filter revisions when in revset mode (manually, since cmdk filter is disabled) 214 + const filteredRevisions = isRevsetMode 215 + ? revisions.filter((r) => revsetChangeIdSet.has(r.change_id)) 216 + : revisions; 88 217 89 218 return ( 90 - // biome-ignore lint/a11y/noStaticElementInteractions: Backdrop for modal dialog 91 - // biome-ignore lint/a11y/useKeyWithClickEvents: Keyboard handled by input, backdrop is click-only 92 - <div 93 - className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]" 94 - onClick={(e) => { 95 - if (e.target === e.currentTarget) close(); 96 - }} 219 + <CommandDialog 220 + open={open} 221 + onOpenChange={setOpen} 222 + title="Jump to revision" 223 + description="Search by change ID, bookmark, message, or use jj revset syntax" 224 + className="max-w-3xl rounded-xl" 225 + filter={shouldFilter ? customFilter : undefined} 226 + shouldFilter={shouldFilter} 97 227 > 98 - <div className="bg-background border border-border rounded-lg shadow-xl w-[400px] overflow-hidden"> 99 - <div className={`p-3 ${query ? "border-b border-border" : ""}`}> 100 - <div className="flex items-center gap-2 mb-2"> 101 - <span className="text-xs text-muted-foreground font-medium">Jump to revision</span> 102 - <span className="text-xs text-muted-foreground/60">(type change ID prefix)</span> 103 - </div> 104 - <input 105 - ref={inputRef} 106 - type="text" 107 - value={query} 108 - onChange={(e) => { 109 - setQuery(e.target.value); 110 - setSelectedIndex(0); 111 - }} 112 - onKeyDown={handleKeyDown} 113 - placeholder="e.g. kzm, abc..." 114 - className="w-full px-3 py-2 text-sm bg-muted border border-border rounded-md font-mono focus:outline-none focus:ring-2 focus:ring-ring" 115 - autoComplete="off" 116 - spellCheck={false} 117 - /> 118 - </div> 119 - 120 - {query && ( 121 - <div className="max-h-[300px] overflow-y-auto"> 122 - {matches.length === 0 ? ( 123 - <div className="px-3 py-6 text-center text-muted-foreground text-sm"> 124 - No revisions match "{query}" 125 - </div> 126 - ) : ( 127 - <div className="py-1"> 128 - {matches.map((revision, index) => { 129 - const isSelected = index === selectedIndex; 130 - // Use short ID but ensure we show at least as much as the user typed 131 - const displayLength = Math.max(revision.change_id_short.length, query.length); 132 - const displayId = revision.change_id.slice(0, displayLength); 133 - const matchedPart = displayId.slice(0, query.length); 134 - const restPart = displayId.slice(query.length); 135 - 136 - return ( 137 - <button 138 - type="button" 139 - key={revision.change_id} 140 - onClick={() => jumpTo(revision.change_id)} 141 - onMouseEnter={() => setSelectedIndex(index)} 142 - className={`w-full px-3 py-2 text-left flex items-center gap-3 transition-colors ${ 143 - isSelected ? "bg-accent" : "hover:bg-muted/50" 144 - }`} 145 - > 146 - <code className="font-mono text-sm shrink-0"> 147 - <span className="text-foreground font-semibold">{matchedPart}</span> 148 - <span className="text-muted-foreground">{restPart}</span> 149 - </code> 150 - <span className="text-sm text-muted-foreground truncate flex-1"> 151 - {revision.description?.split("\n")[0] || ( 152 - <span className="italic">no description</span> 153 - )} 154 - </span> 155 - {revision.is_working_copy && ( 156 - <span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 shrink-0"> 157 - @ 158 - </span> 159 - )} 160 - </button> 161 - ); 162 - })} 163 - </div> 164 - )} 228 + <CommandInput 229 + placeholder="Search or use revset (@, @-, trunk(), mine())..." 230 + value={search} 231 + onValueChange={setSearch} 232 + /> 233 + <CommandList className="max-h-[450px]"> 234 + <CommandEmpty> 235 + {revsetResult.loading ? ( 236 + <span className="text-muted-foreground">Resolving revset...</span> 237 + ) : revsetResult.error ? ( 238 + <span className="text-destructive">{revsetResult.error}</span> 239 + ) : isRevsetMode && revsetResult.changeIds.length === 0 ? ( 240 + <span>No revisions match revset: {revsetResult.label}</span> 241 + ) : ( 242 + "No revisions found." 243 + )} 244 + </CommandEmpty> 245 + {isRevsetMode && !revsetResult.loading && !revsetResult.error && revsetResult.changeIds.length > 0 && ( 246 + <div className="px-3 py-2 text-xs text-muted-foreground border-b border-border"> 247 + revset: {revsetResult.label} ({revsetResult.changeIds.length} match{revsetResult.changeIds.length !== 1 ? "es" : ""}) 165 248 </div> 166 249 )} 167 - 168 - {query && matches.length > 0 && ( 169 - <div className="px-3 py-2 border-t border-border bg-muted/30 text-xs text-muted-foreground flex gap-4"> 170 - <span> 171 - <kbd className="px-1 py-0.5 bg-muted rounded text-[10px]">↑↓</kbd> navigate 172 - </span> 173 - <span> 174 - <kbd className="px-1 py-0.5 bg-muted rounded text-[10px]">Enter</kbd> jump 175 - </span> 176 - <span> 177 - <kbd className="px-1 py-0.5 bg-muted rounded text-[10px]">Esc</kbd> close 178 - </span> 179 - </div> 180 - )} 181 - </div> 182 - </div> 250 + <CommandGroup> 251 + {filteredRevisions.map((revision) => { 252 + const firstLine = revision.description?.split("\n")[0] || "(no description)"; 253 + const matchType = getMatchType(revision); 254 + const matchingBookmark = getMatchingBookmark(revision); 255 + 256 + return ( 257 + <CommandItem 258 + key={revision.change_id} 259 + value={revision.change_id} 260 + onSelect={() => jumpTo(revision.change_id)} 261 + keywords={[ 262 + revision.change_id, 263 + revision.change_id_short, 264 + ...revision.bookmarks, 265 + revision.description, 266 + ]} 267 + className="flex items-center gap-3 py-2.5" 268 + > 269 + <code className="font-mono text-xs shrink-0 min-w-[3ch]"> 270 + {matchType === "changeId" || matchType === "revset" ? ( 271 + <span className="text-primary font-semibold">{revision.change_id_short}</span> 272 + ) : ( 273 + <span className="text-muted-foreground">{revision.change_id_short}</span> 274 + )} 275 + </code> 276 + {revision.is_working_copy && ( 277 + <span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 shrink-0"> 278 + @ 279 + </span> 280 + )} 281 + {revision.bookmarks.length > 0 && ( 282 + <span className="text-xs text-primary font-medium shrink-0"> 283 + {matchType === "bookmark" && matchingBookmark ? ( 284 + <HighlightMatch text={matchingBookmark} query={search} /> 285 + ) : ( 286 + revision.bookmarks[0] 287 + )} 288 + {revision.bookmarks.length > 1 && ( 289 + <span className="text-muted-foreground ml-1"> 290 + +{revision.bookmarks.length - 1} 291 + </span> 292 + )} 293 + </span> 294 + )} 295 + <span className="text-xs text-muted-foreground truncate flex-1"> 296 + {matchType === "description" ? ( 297 + <HighlightMatch text={firstLine} query={search} /> 298 + ) : ( 299 + firstLine 300 + )} 301 + </span> 302 + </CommandItem> 303 + ); 304 + })} 305 + </CommandGroup> 306 + </CommandList> 307 + </CommandDialog> 183 308 ); 184 309 }
+1226 -332
apps/desktop/src/components/RevisionGraph.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 1 2 import { useQuery } from "@tanstack/react-query"; 2 3 import { useNavigate, useSearch } from "@tanstack/react-router"; 3 4 import { useVirtualizer } from "@tanstack/react-virtual"; 4 - import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; 5 + import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; 6 + import { expandedStacksAtom, inlineJumpQueryAtom } from "@/atoms"; 5 7 import { ChangedFilesList } from "@/components/ChangedFilesList"; 6 - import { reorderForGraph } from "@/components/revision-graph-utils"; 7 - import { Badge } from "@/components/ui/badge"; 8 + import { reorderForGraph, detectStacks, computeRevisionAncestry, type RevisionStack } from "@/components/revision-graph-utils"; 9 + import { prefetchRevisionDiffs } from "@/db"; 8 10 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 9 11 import { getRevisionChanges, type Revision } from "@/tauri-commands"; 12 + 10 13 11 14 // Debug overlay - toggle with Ctrl+Shift+D 12 15 const DEBUG_OVERLAY_DEFAULT = false; ··· 18 21 visibleStartRow, 19 22 visibleEndRow, 20 23 totalRows, 24 + wcIndex, 25 + selectedChangeId, 26 + wcChangeId, 21 27 }: { 22 28 enabled: boolean; 23 29 scrollRef: React.RefObject<HTMLDivElement | null>; ··· 25 31 visibleStartRow: number; 26 32 visibleEndRow: number; 27 33 totalRows: number; 34 + wcIndex: number | undefined; 35 + selectedChangeId: string | undefined; 36 + wcChangeId: string | undefined; 28 37 }) { 29 38 // Force re-render on scroll/resize/focus 30 39 const [, forceUpdate] = useState(0); 31 40 41 + const prevScrollTop = useRef<number>(0); 42 + 32 43 useEffect(() => { 33 44 if (!enabled) return; 34 45 const el = scrollRef.current; 35 46 if (!el) return; 36 47 37 - const update = () => forceUpdate((n) => n + 1); 48 + const update = () => { 49 + const newScrollTop = el.scrollTop; 50 + if (Math.abs(newScrollTop - prevScrollTop.current) > 100) { 51 + console.log("[scroll] jump detected:", { 52 + from: prevScrollTop.current.toFixed(0), 53 + to: newScrollTop.toFixed(0), 54 + delta: (newScrollTop - prevScrollTop.current).toFixed(0), 55 + }); 56 + } 57 + prevScrollTop.current = newScrollTop; 58 + forceUpdate((n) => n + 1); 59 + }; 38 60 el.addEventListener("scroll", update); 39 61 window.addEventListener("resize", update); 40 62 document.addEventListener("focusin", update); ··· 69 91 scrollHeight, 70 92 viewportEnd: scrollTop + clientHeight, 71 93 selectedIndex, 94 + wcIndex, 72 95 itemTop: selectedItemTop, 73 96 itemBottom: selectedItemBottom, 74 97 distFromTop: distanceFromTop, ··· 78 101 totalRows, 79 102 ROW_HEIGHT, 80 103 activeElement, 104 + selected: selectedChangeId?.slice(0, 4), 105 + wc: wcChangeId?.slice(0, 4), 81 106 }; 82 107 83 108 return ( ··· 96 121 <div>viewportEnd: {(scrollTop + clientHeight).toFixed(0)}</div> 97 122 <div className="border-t border-green-800 my-2" /> 98 123 <div>selectedIndex: {selectedIndex ?? "none"}</div> 124 + <div>wcIndex: {wcIndex ?? "none"}</div> 125 + <div>selected: {selectedChangeId?.slice(0, 4) ?? "none"}</div> 126 + <div>wc: {wcChangeId?.slice(0, 4) ?? "none"}</div> 127 + <div className="border-t border-green-800 my-2" /> 99 128 <div>itemTop: {selectedItemTop}</div> 100 129 <div>itemBottom: {selectedItemBottom}</div> 101 - <div className="border-t border-green-800 my-2" /> 102 130 <div>distFromTop: {distanceFromTop.toFixed(0)}</div> 103 131 <div>distFromBottom: {distanceFromBottom.toFixed(0)}</div> 104 132 <div className={isInViewport ? "text-green-400" : "text-red-400"}> ··· 133 161 isLoading: boolean; 134 162 flash?: { changeId: string; key: number } | null; 135 163 repoPath: string | null; 164 + pendingAbandon?: Revision | null; 136 165 } 137 166 138 167 const ROW_HEIGHT = 64; 168 + const COLLAPSED_INDICATOR_HEIGHT = 32; 139 169 const LANE_WIDTH = 20; 140 170 const LANE_PADDING = 8; 141 171 const NODE_RADIUS = 5; 142 - const MAX_LANES = 3; 172 + const MAX_LANES = 2; 143 173 144 174 const LANE_COLORS = [ 145 175 "var(--chart-1)", ··· 152 182 153 183 type GraphEdgeType = "direct" | "indirect" | "missing"; 154 184 185 + // ============================================================================ 186 + // Semantic Graph Components (tldraw-inspired architecture) 187 + // ============================================================================ 188 + 189 + // Represents a semantic binding between two revisions (like tldraw's shape bindings) 190 + interface EdgeBinding { 191 + id: string; 192 + sourceRevisionId: string; 193 + targetRevisionId: string; 194 + sourceLane: number; 195 + targetLane: number; 196 + edgeType: GraphEdgeType; 197 + isDeemphasized?: boolean; 198 + isMissingStub?: boolean; 199 + /** If set, this edge represents a collapsed stack and clicking it should expand */ 200 + collapsedStackId?: string; 201 + /** Number of hidden revisions in the collapsed stack */ 202 + collapsedCount?: number; 203 + /** If set, this edge is part of an expanded stack and clicking it should collapse */ 204 + expandedStackId?: string; 205 + } 206 + 207 + interface GraphNodeProps { 208 + revision: Revision; 209 + lane: number; 210 + isSelected: boolean; 211 + color: string; 212 + } 213 + 214 + // GraphNode - Semantic node component rendered inline with each row 215 + // Uses inline SVG for proper accessibility (avoids role="img" on divs) 216 + function GraphNode({ revision, lane, isSelected, color }: GraphNodeProps) { 217 + const isWorkingCopy = revision.is_working_copy; 218 + const isImmutable = revision.is_immutable; 219 + 220 + const size = isWorkingCopy ? NODE_RADIUS * 2 + 6 : NODE_RADIUS * 2; 221 + const selectedRingSize = isWorkingCopy ? NODE_RADIUS + 6 : NODE_RADIUS + 4; 222 + 223 + // Working copy: @ symbol with glow 224 + if (isWorkingCopy) { 225 + return ( 226 + <svg 227 + width={size + 8} 228 + height={size + 8} 229 + viewBox={`0 0 ${size + 8} ${size + 8}`} 230 + className="shrink-0" 231 + aria-label={`Working copy revision ${revision.change_id_short}`} 232 + data-revision-id={revision.change_id} 233 + data-lane={lane} 234 + > 235 + <title>Working copy: {revision.change_id_short}</title> 236 + {isSelected && ( 237 + <circle 238 + cx={(size + 8) / 2} 239 + cy={(size + 8) / 2} 240 + r={selectedRingSize} 241 + fill={color} 242 + fillOpacity={0.3} 243 + /> 244 + )} 245 + <circle 246 + cx={(size + 8) / 2} 247 + cy={(size + 8) / 2} 248 + r={NODE_RADIUS + 3} 249 + fill={color} 250 + fillOpacity={0.2} 251 + /> 252 + <text 253 + x={(size + 8) / 2} 254 + y={(size + 8) / 2} 255 + textAnchor="middle" 256 + dominantBaseline="central" 257 + fill={color} 258 + fontWeight="bold" 259 + fontSize="12" 260 + > 261 + @ 262 + </text> 263 + </svg> 264 + ); 265 + } 266 + 267 + // Immutable: diamond shape 268 + if (isImmutable) { 269 + return ( 270 + <svg 271 + width={size + 8} 272 + height={size + 8} 273 + viewBox={`0 0 ${size + 8} ${size + 8}`} 274 + className="shrink-0" 275 + aria-label={`Immutable revision ${revision.change_id_short}`} 276 + data-revision-id={revision.change_id} 277 + data-lane={lane} 278 + > 279 + <title>Immutable: {revision.change_id_short}</title> 280 + {isSelected && ( 281 + <circle 282 + cx={(size + 8) / 2} 283 + cy={(size + 8) / 2} 284 + r={selectedRingSize} 285 + fill={color} 286 + fillOpacity={0.3} 287 + /> 288 + )} 289 + <rect 290 + x={(size + 8) / 2 - NODE_RADIUS} 291 + y={(size + 8) / 2 - NODE_RADIUS} 292 + width={NODE_RADIUS * 2} 293 + height={NODE_RADIUS * 2} 294 + fill={color} 295 + transform={`rotate(45 ${(size + 8) / 2} ${(size + 8) / 2})`} 296 + /> 297 + </svg> 298 + ); 299 + } 300 + 301 + // Regular mutable: circle 302 + return ( 303 + <svg 304 + width={size + 8} 305 + height={size + 8} 306 + viewBox={`0 0 ${size + 8} ${size + 8}`} 307 + className="shrink-0" 308 + aria-label={`Revision ${revision.change_id_short}`} 309 + data-revision-id={revision.change_id} 310 + data-lane={lane} 311 + > 312 + <title>Revision: {revision.change_id_short}</title> 313 + {isSelected && ( 314 + <circle 315 + cx={(size + 8) / 2} 316 + cy={(size + 8) / 2} 317 + r={selectedRingSize} 318 + fill={color} 319 + fillOpacity={0.3} 320 + /> 321 + )} 322 + <circle 323 + cx={(size + 8) / 2} 324 + cy={(size + 8) / 2} 325 + r={NODE_RADIUS} 326 + fill={color} 327 + /> 328 + </svg> 329 + ); 330 + } 331 + 332 + interface GraphEdgeProps { 333 + binding: EdgeBinding; 334 + sourceY: number; 335 + targetY: number; 336 + sourceRevision: Revision; 337 + targetRevision: Revision | null; 338 + stackTopY?: number; 339 + stackBottomY?: number; 340 + onToggleStack?: (stackId: string) => void; 341 + } 342 + 343 + // GraphEdge - Semantic edge component with source/target revision bindings 344 + function GraphEdge({ binding, sourceY, targetY, sourceRevision, targetRevision, stackTopY, stackBottomY, onToggleStack }: GraphEdgeProps) { 345 + const { sourceLane, targetLane, edgeType, isDeemphasized, isMissingStub, collapsedStackId, collapsedCount, expandedStackId } = binding; 346 + 347 + const sourceX = laneToX(sourceLane); 348 + const targetX = laneToX(targetLane); 349 + const sourceColor = laneColor(sourceLane); 350 + const targetColor = laneColor(targetLane); 351 + 352 + // Style based on edge type 353 + const isDashed = edgeType === "indirect"; 354 + const isMissing = edgeType === "missing"; 355 + const strokeWidth = isDeemphasized ? 1 : 2; 356 + const strokeOpacity = isDeemphasized ? 0.4 : isMissing ? 0.3 : 0.8; 357 + const strokeColor = isDeemphasized ? "var(--muted-foreground)" : targetColor; 358 + 359 + // Accessibility label describing the connection 360 + const ariaLabel = isMissingStub 361 + ? `${sourceRevision.change_id_short} has parent outside current view` 362 + : targetRevision 363 + ? `${sourceRevision.change_id_short} → ${targetRevision.change_id_short}${edgeType === "indirect" ? " (indirect)" : ""}` 364 + : `Edge from ${sourceRevision.change_id_short}`; 365 + 366 + // Missing stub: short dashed line indicating parent outside view 367 + if (isMissingStub) { 368 + const stubLength = ROW_HEIGHT * 0.4; 369 + return ( 370 + <g aria-label={ariaLabel} style={{ pointerEvents: "none" }}> 371 + <title>{ariaLabel}</title> 372 + <line 373 + x1={sourceX} 374 + y1={sourceY + NODE_RADIUS} 375 + x2={sourceX} 376 + y2={sourceY + NODE_RADIUS + stubLength} 377 + stroke={sourceColor} 378 + strokeWidth={1.5} 379 + strokeOpacity={0.4} 380 + strokeDasharray="3 3" 381 + data-edge-type="missing-stub" 382 + data-source-revision={sourceRevision.change_id} 383 + /> 384 + </g> 385 + ); 386 + } 387 + 388 + // Same lane: straight vertical line (or dotted for collapsed stacks) 389 + if (sourceLane === targetLane) { 390 + const isCollapsedStack = !!collapsedStackId; 391 + const y1 = sourceY + NODE_RADIUS; 392 + const y2 = targetY - NODE_RADIUS; 393 + 394 + // For collapsed stacks, draw a dotted line with clickable area 395 + if (isCollapsedStack) { 396 + const collapsedLabel = `${collapsedCount ?? 0} hidden revision${(collapsedCount ?? 0) !== 1 ? "s" : ""} - click to expand`; 397 + 398 + return ( 399 + <g 400 + aria-label={collapsedLabel} 401 + className="cursor-pointer group" 402 + style={{ pointerEvents: "auto" }} 403 + onClick={() => onToggleStack?.(collapsedStackId)} 404 + > 405 + <title>{collapsedLabel}</title> 406 + {/* Invisible wider hitbox for easier clicking */} 407 + <line 408 + x1={sourceX} 409 + y1={y1} 410 + x2={sourceX} 411 + y2={y2} 412 + stroke="transparent" 413 + strokeWidth={16} 414 + /> 415 + {/* Visible dotted line */} 416 + <line 417 + x1={sourceX} 418 + y1={y1} 419 + x2={sourceX} 420 + y2={y2} 421 + stroke={sourceColor} 422 + strokeWidth={strokeWidth} 423 + strokeOpacity={0.7} 424 + strokeDasharray="3 6" 425 + strokeLinecap="round" 426 + className="group-hover:[stroke-width:3] group-hover:[stroke-opacity:1] transition-[stroke-width,stroke-opacity] duration-150" 427 + data-edge-type="collapsed-stack" 428 + data-stack-id={collapsedStackId} 429 + data-source-revision={sourceRevision.change_id} 430 + data-target-revision={targetRevision?.change_id} 431 + /> 432 + </g> 433 + ); 434 + } 435 + 436 + // For expanded stacks, make the edge clickable to collapse 437 + if (expandedStackId) { 438 + const expandedLabel = `Click to collapse stack`; 439 + // Use full stack bounds if available, otherwise fall back to node bounds 440 + const hitboxY1 = stackTopY !== undefined ? stackTopY : y1; 441 + const hitboxY2 = stackBottomY !== undefined ? stackBottomY : y2; 442 + return ( 443 + <g 444 + aria-label={expandedLabel} 445 + className="cursor-pointer stack-group" 446 + data-stack-id={expandedStackId} 447 + style={{ pointerEvents: "auto" }} 448 + onClick={() => onToggleStack?.(expandedStackId)} 449 + > 450 + <title>{expandedLabel}</title> 451 + {/* Invisible wider hitbox covering full stack height */} 452 + <line 453 + x1={sourceX} 454 + y1={hitboxY1} 455 + x2={targetX} 456 + y2={hitboxY2} 457 + stroke="transparent" 458 + strokeWidth={16} 459 + /> 460 + <line 461 + x1={sourceX} 462 + y1={y1} 463 + x2={targetX} 464 + y2={y2} 465 + stroke={isDeemphasized ? strokeColor : sourceColor} 466 + strokeWidth={strokeWidth} 467 + strokeOpacity={strokeOpacity} 468 + strokeDasharray={isDashed ? "4 4" : undefined} 469 + className="stack-edge transition-[stroke-width,stroke-opacity] duration-150" 470 + data-edge-type={edgeType} 471 + data-stack-id={expandedStackId} 472 + data-source-revision={sourceRevision.change_id} 473 + data-target-revision={targetRevision?.change_id} 474 + /> 475 + </g> 476 + ); 477 + } 478 + 479 + return ( 480 + <g aria-label={ariaLabel} style={{ pointerEvents: "none" }}> 481 + <title>{ariaLabel}</title> 482 + <line 483 + x1={sourceX} 484 + y1={y1} 485 + x2={targetX} 486 + y2={y2} 487 + stroke={isDeemphasized ? strokeColor : sourceColor} 488 + strokeWidth={strokeWidth} 489 + strokeOpacity={strokeOpacity} 490 + strokeDasharray={isDashed ? "4 4" : undefined} 491 + data-edge-type={edgeType} 492 + data-source-revision={sourceRevision.change_id} 493 + data-target-revision={targetRevision?.change_id} 494 + /> 495 + </g> 496 + ); 497 + } 498 + 499 + // Cross-lane: horizontal from source, curve down into target's lane 500 + const goingRight = targetX > sourceX; 501 + const arcRadius = 10; 502 + 503 + return ( 504 + <g aria-label={ariaLabel} style={{ pointerEvents: "none" }}> 505 + <title>{ariaLabel}</title> 506 + <path 507 + d={`M ${sourceX} ${sourceY + NODE_RADIUS} 508 + L ${targetX - arcRadius * (goingRight ? 1 : -1)} ${sourceY + NODE_RADIUS} 509 + Q ${targetX} ${sourceY + NODE_RADIUS} ${targetX} ${sourceY + NODE_RADIUS + arcRadius} 510 + L ${targetX} ${targetY - NODE_RADIUS}`} 511 + fill="none" 512 + stroke={strokeColor} 513 + strokeWidth={strokeWidth} 514 + strokeOpacity={strokeOpacity} 515 + strokeDasharray={isDashed ? "4 4" : undefined} 516 + data-edge-type={edgeType} 517 + data-source-revision={sourceRevision.change_id} 518 + data-target-revision={targetRevision?.change_id} 519 + /> 520 + </g> 521 + ); 522 + } 523 + 524 + interface EdgeLayerProps { 525 + bindings: EdgeBinding[]; 526 + revisionMap: Map<string, Revision>; 527 + getRowCenter: (row: number) => number; 528 + commitToRow: Map<string, number>; 529 + totalHeight: number; 530 + width: number; 531 + visibleStartRow: number; 532 + visibleEndRow: number; 533 + stackById: Map<string, RevisionStack>; 534 + changeIdToCommitId: Map<string, string>; 535 + onToggleStack?: (stackId: string) => void; 536 + } 537 + 538 + // EdgeLayer - Renders all semantic edge components 539 + function EdgeLayer({ 540 + bindings, 541 + revisionMap, 542 + getRowCenter, 543 + commitToRow, 544 + totalHeight, 545 + width, 546 + visibleStartRow, 547 + visibleEndRow, 548 + stackById, 549 + changeIdToCommitId, 550 + onToggleStack, 551 + }: EdgeLayerProps) { 552 + const svgRef = useRef<SVGSVGElement>(null); 553 + 554 + // Add overscan for edges that might span across viewport boundary 555 + // Use larger overscan to handle collapsed stack edges that span many rows 556 + const overscan = 15; 557 + const startRow = Math.max(0, visibleStartRow - overscan); 558 + const endRow = visibleEndRow + overscan; 559 + 560 + // Filter bindings to those visible in the viewport 561 + const visibleBindings = bindings.filter((binding) => { 562 + const sourceRow = commitToRow.get(binding.sourceRevisionId); 563 + const targetRow = commitToRow.get(binding.targetRevisionId); 564 + if (sourceRow === undefined) return false; 565 + 566 + // For missing stubs, just check if source is near visible range 567 + if (binding.isMissingStub) { 568 + return sourceRow >= startRow && sourceRow <= endRow; 569 + } 570 + 571 + if (targetRow === undefined) return false; 572 + 573 + // Check if edge passes through visible area 574 + const minRow = Math.min(sourceRow, targetRow); 575 + const maxRow = Math.max(sourceRow, targetRow); 576 + return maxRow >= startRow && minRow <= endRow; 577 + }); 578 + 579 + // Handle hover for stack edges - make all edges in the same stack respond together 580 + // Use event delegation on the SVG to handle dynamically added groups 581 + useEffect(() => { 582 + const svg = svgRef.current; 583 + if (!svg) return; 584 + 585 + let hoveredStackId: string | null = null; 586 + 587 + const handleMouseOver = (e: Event) => { 588 + const target = e.target as HTMLElement; 589 + // Check if the event originated from a stack group 590 + const group = target.closest('g.stack-group[data-stack-id]') as HTMLElement; 591 + if (!group) return; 592 + 593 + const stackId = group.getAttribute('data-stack-id'); 594 + if (!stackId || stackId === hoveredStackId) return; 595 + 596 + hoveredStackId = stackId; 597 + // Find all edges with the same stack-id and add hover class 598 + const edges = svg.querySelectorAll(`line.stack-edge[data-stack-id="${stackId}"]`); 599 + edges.forEach((edge) => { 600 + edge.classList.add('stack-edge-hovered'); 601 + }); 602 + }; 603 + 604 + const handleMouseOut = (e: Event) => { 605 + const target = e.target as HTMLElement; 606 + const relatedTarget = (e as MouseEvent).relatedTarget as HTMLElement; 607 + 608 + // Check if we're leaving a stack group 609 + const group = target.closest('g.stack-group[data-stack-id]') as HTMLElement; 610 + if (!group) return; 611 + 612 + // Check if we're moving to another element within the same stack group 613 + if (relatedTarget && group.contains(relatedTarget)) return; 614 + 615 + const stackId = group.getAttribute('data-stack-id'); 616 + if (!stackId || stackId !== hoveredStackId) return; 617 + 618 + hoveredStackId = null; 619 + // Remove hover class from all edges with the same stack-id 620 + const edges = svg.querySelectorAll(`line.stack-edge[data-stack-id="${stackId}"]`); 621 + edges.forEach((edge) => { 622 + edge.classList.remove('stack-edge-hovered'); 623 + }); 624 + }; 625 + 626 + // Use event delegation - attach listeners to the SVG element 627 + // mouseover/mouseout bubble, unlike mouseenter/mouseleave 628 + svg.addEventListener('mouseover', handleMouseOver, true); 629 + svg.addEventListener('mouseout', handleMouseOut, true); 630 + 631 + return () => { 632 + svg.removeEventListener('mouseover', handleMouseOver, true); 633 + svg.removeEventListener('mouseout', handleMouseOut, true); 634 + }; 635 + }, []); 636 + 637 + return ( 638 + <svg 639 + ref={svgRef} 640 + width={width} 641 + height={totalHeight} 642 + className="shrink-0 absolute top-0 left-0 z-20" 643 + role="img" 644 + aria-label="Revision connections" 645 + > 646 + <title>Revision graph edges</title> 647 + {visibleBindings.map((binding) => { 648 + const sourceRow = commitToRow.get(binding.sourceRevisionId); 649 + const targetRow = binding.isMissingStub 650 + ? (sourceRow !== undefined ? sourceRow + 1 : undefined) 651 + : commitToRow.get(binding.targetRevisionId); 652 + 653 + if (sourceRow === undefined) return null; 654 + 655 + const sourceRevision = revisionMap.get(binding.sourceRevisionId); 656 + const targetRevision = binding.targetRevisionId 657 + ? revisionMap.get(binding.targetRevisionId) ?? null 658 + : null; 659 + 660 + if (!sourceRevision) return null; 661 + 662 + // For expanded stacks, calculate full stack bounds 663 + let stackTopY: number | undefined; 664 + let stackBottomY: number | undefined; 665 + if (binding.expandedStackId) { 666 + const stack = stackById.get(binding.expandedStackId); 667 + if (stack) { 668 + const topCommitId = changeIdToCommitId.get(stack.topChangeId); 669 + const bottomCommitId = changeIdToCommitId.get(stack.bottomChangeId); 670 + const topRow = topCommitId ? commitToRow.get(topCommitId) : undefined; 671 + const bottomRow = bottomCommitId ? commitToRow.get(bottomCommitId) : undefined; 672 + if (topRow !== undefined && bottomRow !== undefined) { 673 + stackTopY = getRowCenter(topRow) - NODE_RADIUS; 674 + stackBottomY = getRowCenter(bottomRow) + NODE_RADIUS; 675 + } 676 + } 677 + } 678 + 679 + return ( 680 + <GraphEdge 681 + key={binding.id} 682 + binding={binding} 683 + sourceY={getRowCenter(sourceRow)} 684 + targetY={targetRow !== undefined ? getRowCenter(targetRow) : getRowCenter(sourceRow) + ROW_HEIGHT} 685 + sourceRevision={sourceRevision} 686 + targetRevision={targetRevision} 687 + stackTopY={stackTopY} 688 + stackBottomY={stackBottomY} 689 + onToggleStack={onToggleStack} 690 + /> 691 + ); 692 + })} 693 + </svg> 694 + ); 695 + } 696 + 697 + // ============================================================================ 698 + // End of Semantic Graph Components 699 + // ============================================================================ 700 + 155 701 interface ParentConnection { 156 702 parentRow: number; 157 703 parentLane: number; ··· 177 723 nodes: GraphNode[]; 178 724 laneCount: number; 179 725 rows: GraphRow[]; 726 + edgeBindings: EdgeBinding[]; 180 727 } 181 728 182 729 // Get the set of commit IDs in the working copy's ancestor chain (for lane 0) ··· 206 753 } 207 754 208 755 function buildGraph(revisions: Revision[]): GraphData { 209 - if (revisions.length === 0) return { nodes: [], laneCount: 1, rows: [] }; 756 + if (revisions.length === 0) return { nodes: [], laneCount: 1, rows: [], edgeBindings: [] }; 210 757 211 758 // Map commit_id -> Revision for ancestry lookups 212 759 const commitMap = new Map(revisions.map((r) => [r.commit_id, r])); 213 760 761 + // Compute ancestry relationships within the visible revset 762 + // This determines which revisions are actually related and should be connected 763 + const ancestry = computeRevisionAncestry(revisions); 764 + 214 765 // Create rows for all revisions (no elision) 215 766 const orderedRevisions = reorderForGraph(revisions); 216 767 const rows: GraphRow[] = orderedRevisions.map((rev) => ({ ··· 222 773 // Get working copy chain - these commits should all be in lane 0 223 774 const workingCopyChain = getWorkingCopyChain(revisions); 224 775 225 - // Check if we have trunk commits or working copy (determines if lane 0 is reserved) 226 - const hasLane0Commits = workingCopyChain.size > 0 || revisions.some((r) => r.is_trunk); 227 - 228 776 // Build row index map 229 777 const commitToRow = new Map<string, number>(); 230 778 rows.forEach((row, idx) => { ··· 233 781 234 782 const commitToLane = new Map<string, number>(); 235 783 const nodes: GraphNode[] = []; 236 - // Start at lane 1 if lane 0 is reserved for trunk/working copy 237 - let nextLane = hasLane0Commits ? 1 : 0; 238 784 239 - function claimLane(id: string, preferredLane?: number): number { 240 - // If already assigned (e.g., working copy chain), return that lane 241 - const existing = commitToLane.get(id); 242 - if (existing !== undefined) return existing; 243 - 244 - if (preferredLane !== undefined) { 245 - return Math.min(preferredLane, MAX_LANES - 1); 785 + // Simple 2-lane system: 786 + // Lane 0: trunk commits and working copy chain 787 + // Lane 1: everything else (all feature branches) 788 + for (const rev of orderedRevisions) { 789 + const isOnWorkingCopyChain = workingCopyChain.has(rev.commit_id); 790 + if (rev.is_trunk || isOnWorkingCopyChain) { 791 + commitToLane.set(rev.commit_id, 0); 792 + } else { 793 + commitToLane.set(rev.commit_id, 1); 246 794 } 247 - 248 - // Assign next available lane 249 - const lane = Math.min(nextLane, MAX_LANES - 1); 250 - nextLane = Math.min(nextLane + 1, MAX_LANES); 251 - return lane; 252 795 } 253 796 797 + // Second pass: build nodes with parent connections 254 798 for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { 255 799 const row = rows[rowIdx]; 256 800 const revision = row.revision; 801 + const lane = commitToLane.get(revision.commit_id) ?? 0; 257 802 258 - let lane = commitToLane.get(revision.commit_id); 259 - if (lane === undefined) { 260 - // Trunk commits and working copy chain always go on lane 0 261 - const isOnWorkingCopyChain = workingCopyChain.has(revision.commit_id); 262 - if (revision.is_trunk || isOnWorkingCopyChain) { 263 - lane = 0; 264 - } else { 265 - lane = claimLane(revision.commit_id); 266 - } 267 - commitToLane.set(revision.commit_id, lane); 268 - } 803 + const parentConnections: ParentConnection[] = []; 804 + 805 + // Use ancestry.parents which only includes parents within the visible revset 806 + // This ensures we only draw edges to actual ancestors, not to unrelated revisions 807 + const visibleParents = ancestry.parents.get(revision.commit_id) ?? []; 269 808 270 - const parentConnections: ParentConnection[] = []; 809 + // Also check original parent_edges for edge type info and missing edges 810 + const parentEdgeMap = new Map(revision.parent_edges.map((e) => [e.parent_id, e])); 271 811 272 812 // Detect "main merges into branch" scenario 273 - const isMerge = revision.parent_edges.length > 1; 813 + const isMerge = visibleParents.length > 1; 274 814 const isMutableCommit = !revision.is_immutable; 275 815 276 - // Use parent_edges from backend which contains edge type info 277 - for (let i = 0; i < revision.parent_edges.length; i++) { 278 - const parentEdge = revision.parent_edges[i]; 279 - const parentId = parentEdge.parent_id; 280 - const edgeType = parentEdge.edge_type; 281 - 282 - // Handle missing edges (parents outside our revset) 283 - if (edgeType === "missing") { 284 - // Draw a short stub to indicate ancestry exists outside current view 285 - parentConnections.push({ 286 - parentRow: rowIdx + 1, // Just one row down for the stub 287 - parentLane: lane, 288 - edgeType: "missing", 289 - isMissingStub: true, 290 - }); 291 - continue; 292 - } 816 + // Process visible parents (ancestors within our revset) 817 + for (let i = 0; i < visibleParents.length; i++) { 818 + const parentId = visibleParents[i]; 819 + const parentEdge = parentEdgeMap.get(parentId); 820 + const edgeType = parentEdge?.edge_type ?? "direct"; 293 821 294 822 const parentRow = commitToRow.get(parentId); 295 823 if (parentRow === undefined) continue; 296 824 297 825 let parentLane = commitToLane.get(parentId); 298 826 if (parentLane === undefined) { 299 - // Check if parent is a trunk commit or on working copy chain 300 - const parentRev = commitMap.get(parentId); 301 - const parentIsTrunk = parentRev?.is_trunk ?? false; 302 - const parentOnWorkingCopyChain = workingCopyChain.has(parentId); 303 - 304 - if (parentIsTrunk || parentOnWorkingCopyChain) { 305 - // Trunk commits and working copy chain always go on lane 0 306 - parentLane = 0; 307 - } else { 308 - // First parent inherits our lane; other parents get their own 309 - parentLane = i === 0 ? lane : claimLane(parentId); 310 - } 827 + // This shouldn't happen after first pass, but handle gracefully 828 + parentLane = lane; 311 829 commitToLane.set(parentId, parentLane); 312 830 } 313 831 ··· 322 840 parentConnections.push({ parentRow, parentLane, edgeType, isDeemphasized }); 323 841 } 324 842 325 - // Fallback: if node has parents but no connections drawn, add a stub 326 - // This handles cases where all edges were filtered/missing 327 - if (parentConnections.length === 0 && revision.parent_ids.length > 0) { 843 + // Handle missing edges (parents outside our revset) 844 + // Only show stub if we have original parents but no visible parents 845 + const hasMissingParents = revision.parent_edges.some((e) => e.edge_type === "missing"); 846 + const hasParentsOutsideView = revision.parent_ids.length > visibleParents.length; 847 + 848 + if ((hasMissingParents || hasParentsOutsideView) && parentConnections.length === 0) { 849 + // All parents are outside the view - show a stub 850 + parentConnections.push({ 851 + parentRow: rowIdx + 1, // Just one row down for the stub 852 + parentLane: lane, 853 + edgeType: "missing", 854 + isMissingStub: true, 855 + }); 856 + } else if (hasMissingParents && parentConnections.length > 0) { 857 + // Some parents are visible, some are missing - add stub for missing ones 328 858 parentConnections.push({ 329 859 parentRow: rowIdx + 1, 330 860 parentLane: lane, ··· 352 882 maxLaneUsed = Math.max(maxLaneUsed, node.lane); 353 883 } 354 884 355 - // Calculate maxLaneOnRow by analyzing which edges pass through each row 356 - // With horizontal-first edges: horizontal segment at node's row, vertical segment to parent 885 + // Calculate maxLaneOnRow using sweep line algorithm O(n log n) instead of O(n³) 886 + // Collect edge spans as events for efficient processing 887 + type SpanEvent = { row: number; isStart: boolean; lane: number }; 888 + const events: SpanEvent[] = []; 889 + 357 890 for (const node of nodes) { 358 891 for (const conn of node.parentConnections) { 359 892 const nodeRow = node.row; ··· 369 902 } 370 903 } 371 904 372 - // Vertical segment passes through all rows between node and parent 905 + // Vertical segment: create start/end events instead of iterating rows 373 906 const minRow = Math.min(nodeRow, parentRow); 374 907 const maxRow = Math.max(nodeRow, parentRow); 375 - for (let r = minRow + 1; r < maxRow; r++) { 376 - const row = rows[r]; 377 - if (row) { 378 - row.maxLaneOnRow = Math.max(row.maxLaneOnRow, parentLane); 379 - } 908 + if (maxRow > minRow + 1) { 909 + // Edge spans rows [minRow+1, maxRow-1] inclusive 910 + events.push({ row: minRow + 1, isStart: true, lane: parentLane }); 911 + events.push({ row: maxRow, isStart: false, lane: parentLane }); 912 + } 913 + } 914 + } 915 + 916 + // Sort events: by row, with starts before ends at same row 917 + events.sort((a, b) => a.row - b.row || (a.isStart ? -1 : 1)); 918 + 919 + // Sweep through rows, tracking active lane counts 920 + const laneCounts = new Array(MAX_LANES).fill(0); 921 + let eventIdx = 0; 922 + 923 + for (let r = 0; r < rows.length; r++) { 924 + // Process all events at this row 925 + while (eventIdx < events.length && events[eventIdx].row === r) { 926 + const { isStart, lane } = events[eventIdx]; 927 + laneCounts[lane] += isStart ? 1 : -1; 928 + eventIdx++; 929 + } 930 + 931 + // Find max active lane for this row (check from highest lane down) 932 + for (let lane = MAX_LANES - 1; lane >= 0; lane--) { 933 + if (laneCounts[lane] > 0) { 934 + rows[r].maxLaneOnRow = Math.max(rows[r].maxLaneOnRow, lane); 935 + break; 380 936 } 381 937 } 382 938 } ··· 389 945 row.maxLaneOnRow = Math.max(row.maxLaneOnRow, row.lane); 390 946 } 391 947 392 - return { nodes, laneCount: globalMaxLane + 1, rows }; 948 + // Generate semantic edge bindings from nodes' parent connections 949 + const edgeBindings: EdgeBinding[] = []; 950 + let edgeCounter = 0; 951 + 952 + for (const node of nodes) { 953 + for (const conn of node.parentConnections) { 954 + // For missing stubs, use commit_id of source and empty target 955 + const targetCommitId = conn.isMissingStub 956 + ? "" 957 + : rows[conn.parentRow]?.revision.commit_id ?? ""; 958 + 959 + edgeBindings.push({ 960 + id: `edge-${node.revision.commit_id}-${edgeCounter++}`, 961 + sourceRevisionId: node.revision.commit_id, 962 + targetRevisionId: targetCommitId, 963 + sourceLane: node.lane, 964 + targetLane: conn.parentLane, 965 + edgeType: conn.edgeType, 966 + isDeemphasized: conn.isDeemphasized, 967 + isMissingStub: conn.isMissingStub, 968 + }); 969 + } 970 + } 971 + 972 + return { nodes, laneCount: globalMaxLane + 1, rows, edgeBindings }; 393 973 } 394 974 395 975 function laneToX(lane: number): number { ··· 400 980 return LANE_COLORS[lane % LANE_COLORS.length]; 401 981 } 402 982 403 - interface GraphColumnProps { 404 - nodes: GraphNode[]; 405 - laneCount: number; 406 - visibleStartRow: number; 407 - visibleEndRow: number; 408 - totalHeight: number; 409 - rowOffsets: Map<number, number>; 410 - } 411 - 412 - function GraphColumn({ 413 - nodes, 414 - laneCount, 415 - visibleStartRow, 416 - visibleEndRow, 417 - totalHeight, 418 - rowOffsets, 419 - }: GraphColumnProps) { 420 - const getRowStart = (row: number) => rowOffsets.get(row) ?? row * ROW_HEIGHT; 421 - const getRowCenter = (row: number) => getRowStart(row) + ROW_HEIGHT / 2; 422 - 423 - const { rev: selectedChangeId } = useSearch({ strict: false }); 424 - // Minimal right padding - tight fit for the rightmost node 425 - const width = LANE_PADDING + laneCount * LANE_WIDTH + NODE_RADIUS + 2; 426 - 427 - // Add overscan for edges that might span across viewport boundary 428 - const overscan = 5; 429 - const startRow = Math.max(0, visibleStartRow - overscan); 430 - const endRow = Math.min(nodes.length - 1, visibleEndRow + overscan); 431 - 432 - // Filter nodes that are in the visible range 433 - const visibleNodes = nodes.filter((node) => node.row >= startRow && node.row <= endRow); 434 - 435 - // Also include nodes whose edges pass through the visible area 436 - const nodesWithVisibleEdges = nodes.filter((node) => { 437 - if (node.row >= startRow && node.row <= endRow) return false; // Already included 438 - return node.parentConnections.some((conn) => { 439 - const minRow = Math.min(node.row, conn.parentRow); 440 - const maxRow = Math.max(node.row, conn.parentRow); 441 - return maxRow >= startRow && minRow <= endRow; 442 - }); 443 - }); 444 - 445 - const allVisibleNodes = [...visibleNodes, ...nodesWithVisibleEdges]; 446 - 447 - return ( 448 - <svg 449 - width={width} 450 - height={totalHeight} 451 - className="shrink-0 absolute top-0 left-0 pointer-events-none" 452 - role="img" 453 - aria-label="Revision graph" 454 - > 455 - <title>Revision graph</title> 456 - {/* Edges */} 457 - {allVisibleNodes.map((node) => { 458 - const y = getRowCenter(node.row); 459 - const x = laneToX(node.lane); 460 - const color = laneColor(node.lane); 461 - const nodeKey = node.revision.change_id; 462 - 463 - return ( 464 - <g key={`edges-${nodeKey}`}> 465 - {node.parentConnections.map((conn, idx) => { 466 - const edgeColor = laneColor(conn.parentLane); 467 - 468 - // Style based on edge type from backend 469 - const isDashed = conn.edgeType === "indirect"; 470 - const isMissing = conn.edgeType === "missing"; 471 - 472 - // Apply de-emphasis styling for "main merges into branch" edges 473 - const strokeWidth = conn.isDeemphasized ? 1 : 2; 474 - const strokeOpacity = conn.isDeemphasized ? 0.4 : isMissing ? 0.3 : 0.8; 475 - const strokeColor = conn.isDeemphasized ? "var(--muted-foreground)" : edgeColor; 476 - 477 - // Missing stub: short dashed vertical line indicating parent outside view 478 - if (conn.isMissingStub) { 479 - const stubLength = ROW_HEIGHT * 0.4; 480 - return ( 481 - <line 482 - key={idx} 483 - x1={x} 484 - y1={y + NODE_RADIUS} 485 - x2={x} 486 - y2={y + NODE_RADIUS + stubLength} 487 - stroke={color} 488 - strokeWidth={1.5} 489 - strokeOpacity={0.4} 490 - strokeDasharray="3 3" 491 - /> 492 - ); 493 - } 494 - 495 - const parentY = getRowCenter(conn.parentRow); 496 - const parentX = laneToX(conn.parentLane); 497 - 498 - if (node.lane === conn.parentLane) { 499 - return ( 500 - <line 501 - key={idx} 502 - x1={x} 503 - y1={y + NODE_RADIUS} 504 - x2={parentX} 505 - y2={parentY - NODE_RADIUS} 506 - stroke={conn.isDeemphasized ? strokeColor : color} 507 - strokeWidth={strokeWidth} 508 - strokeOpacity={strokeOpacity} 509 - strokeDasharray={isDashed ? "4 4" : undefined} 510 - /> 511 - ); 512 - } 513 - 514 - // Cross-lane connections: horizontal from child, curve down into parent's lane 515 - const goingRight = parentX > x; 516 - const arcRadius = 10; 517 - 518 - return ( 519 - <path 520 - key={idx} 521 - d={`M ${x} ${y + NODE_RADIUS} 522 - L ${parentX - arcRadius * (goingRight ? 1 : -1)} ${y + NODE_RADIUS} 523 - Q ${parentX} ${y + NODE_RADIUS} ${parentX} ${y + NODE_RADIUS + arcRadius} 524 - L ${parentX} ${parentY - NODE_RADIUS}`} 525 - fill="none" 526 - stroke={strokeColor} 527 - strokeWidth={strokeWidth} 528 - strokeOpacity={strokeOpacity} 529 - strokeDasharray={isDashed ? "4 4" : undefined} 530 - /> 531 - ); 532 - })} 533 - </g> 534 - ); 535 - })} 536 - 537 - {/* Nodes - only render visible ones */} 538 - {visibleNodes.map((node) => { 539 - const y = getRowCenter(node.row); 540 - const x = laneToX(node.lane); 541 - const color = laneColor(node.lane); 542 - const isSelected = node.revision.change_id === selectedChangeId; 543 - const isWorkingCopy = node.revision.is_working_copy; 544 - const isImmutable = node.revision.is_immutable; 545 - 546 - if (isWorkingCopy) { 547 - return ( 548 - <g key={node.revision.change_id}> 549 - {isSelected && ( 550 - <circle cx={x} cy={y} r={NODE_RADIUS + 6} fill={color} fillOpacity={0.3} /> 551 - )} 552 - <circle cx={x} cy={y} r={NODE_RADIUS + 3} fill={color} fillOpacity={0.2} /> 553 - <text 554 - x={x} 555 - y={y} 556 - textAnchor="middle" 557 - dominantBaseline="central" 558 - fill={color} 559 - fontWeight="bold" 560 - fontSize="12" 561 - > 562 - @ 563 - </text> 564 - </g> 565 - ); 566 - } 567 - 568 - // Immutable commits get a diamond shape (◆) 569 - if (isImmutable) { 570 - return ( 571 - <g key={node.revision.change_id}> 572 - {isSelected && ( 573 - <circle cx={x} cy={y} r={NODE_RADIUS + 4} fill={color} fillOpacity={0.3} /> 574 - )} 575 - <rect 576 - x={x - NODE_RADIUS} 577 - y={y - NODE_RADIUS} 578 - width={NODE_RADIUS * 2} 579 - height={NODE_RADIUS * 2} 580 - fill={color} 581 - transform={`rotate(45 ${x} ${y})`} 582 - /> 583 - </g> 584 - ); 585 - } 586 - 587 - return ( 588 - <g key={node.revision.change_id}> 589 - {isSelected && ( 590 - <circle cx={x} cy={y} r={NODE_RADIUS + 4} fill={color} fillOpacity={0.3} /> 591 - )} 592 - <circle cx={x} cy={y} r={NODE_RADIUS} fill={color} /> 593 - </g> 594 - ); 595 - })} 596 - </svg> 597 - ); 598 - } 599 - 600 983 function RevisionRow({ 601 984 revision, 985 + lane, 602 986 maxLaneOnRow, 603 987 isSelected, 604 988 onSelect, ··· 607 991 isExpanded, 608 992 isFocused, 609 993 repoPath, 994 + isPendingAbandon, 995 + jumpHint, 996 + jumpModeActive, 997 + jumpQuery, 610 998 }: { 611 999 revision: Revision; 1000 + lane: number; 612 1001 maxLaneOnRow: number; 613 1002 isSelected: boolean; 614 1003 onSelect: (changeId: string) => void; ··· 617 1006 isExpanded: boolean; 618 1007 isFocused: boolean; 619 1008 repoPath: string | null; 1009 + isPendingAbandon: boolean; 1010 + jumpHint: string | null; 1011 + jumpModeActive: boolean; 1012 + jumpQuery: string; 620 1013 }) { 621 1014 const firstLine = revision.description.split("\n")[0] || "(no description)"; 622 1015 const fullDescription = revision.description || "(no description)"; 623 - const indent = LANE_PADDING + (maxLaneOnRow + 1) * LANE_WIDTH + NODE_RADIUS + 4; 1016 + 1017 + // Calculate the node position area - leaves space for graph edges on the left 1018 + const nodeAreaWidth = LANE_PADDING + (maxLaneOnRow + 1) * LANE_WIDTH; 1019 + const nodeOffset = laneToX(lane); 1020 + const color = laneColor(lane); 624 1021 1022 + const selectedFile = useSearch({ strict: false, select: (s) => s.file ?? null }); 625 1023 const search = useSearch({ strict: false }); 626 1024 const navigate = useNavigate(); 627 - const selectedFile = search.file ?? null; 628 1025 629 1026 const changedFilesQuery = useQuery({ 630 1027 queryKey: ["revision-changes", repoPath, revision.change_id], ··· 641 1038 }); 642 1039 } 643 1040 1041 + // Constants matching edge layer calculations 1042 + const TOP_PADDING = 16; 1043 + const CONTENT_MIN_HEIGHT = 56; 1044 + const nodeSize = revision.is_working_copy ? NODE_RADIUS * 2 + 14 : NODE_RADIUS * 2 + 8; 1045 + 644 1046 return ( 645 - <div style={{ height: isExpanded ? "auto" : ROW_HEIGHT }} className="flex flex-col"> 646 - <div className="flex items-start min-h-[56px]"> 647 - <div style={{ width: indent }} className="shrink-0" /> 1047 + <div style={{ height: isExpanded ? "auto" : ROW_HEIGHT }} className="flex flex-col relative"> 1048 + {/* Graph node - absolutely positioned to align with edge layer */} 1049 + <div 1050 + className="absolute z-20 flex items-center justify-center" 1051 + style={{ 1052 + left: nodeOffset - nodeSize / 2, 1053 + top: TOP_PADDING + CONTENT_MIN_HEIGHT / 2 - nodeSize / 2, 1054 + }} 1055 + > 1056 + <GraphNode 1057 + revision={revision} 1058 + lane={lane} 1059 + isSelected={isSelected} 1060 + color={color} 1061 + /> 1062 + </div> 1063 + <div className="flex items-start min-h-[56px] pt-4"> 1064 + {/* Spacer for graph area */} 1065 + <div className="shrink-0" style={{ width: nodeAreaWidth }} /> 648 1066 <div 649 - className={`flex-1 mr-2 min-w-0 overflow-hidden rounded my-2 mx-1 border border-border bg-card text-card-foreground shadow-sm transition-colors duration-150 hover:shadow hover:bg-accent/20 hover:cursor-pointer ${ 1067 + className={`relative flex-1 mr-2 min-w-0 overflow-hidden rounded my-2 mx-1 ${ 1068 + isFocused ? "" : "border border-border" 1069 + } bg-card text-card-foreground shadow-sm transition-colors duration-150 hover:shadow hover:bg-accent/20 hover:cursor-pointer ${ 650 1070 revision.is_immutable ? "opacity-60" : "" 651 - } ${isDimmed ? "opacity-40" : ""} ${isSelected ? "bg-accent/30 border-ring/60" : ""} ${ 1071 + } ${isDimmed ? "opacity-40" : ""} ${isSelected ? "bg-accent/30" : ""} ${ 652 1072 isFocused ? "ring-2 ring-ring/80 ring-offset-2 ring-offset-background" : "" 653 1073 }`} 654 1074 onClick={() => onSelect(revision.change_id)} 655 1075 > 656 - <div className="px-3 py-2 min-w-0"> 1076 + <div className={`px-3 py-2 min-w-0 ${isPendingAbandon ? "blur-sm" : ""}`}> 657 1077 <div className="flex items-center gap-2 flex-nowrap min-w-0"> 658 1078 <code 659 - className={`text-xs font-mono text-muted-foreground rounded px-0.5 ${ 1079 + className={`text-xs font-mono rounded px-0.5 shrink-0 ${ 660 1080 isFlashing ? "bg-primary/40 animate-pulse" : "" 661 - }`} 1081 + } text-muted-foreground`} 662 1082 > 663 - {revision.change_id_short} 1083 + {jumpModeActive && jumpHint ? ( 1084 + <> 1085 + {/* Already matched portion */} 1086 + {jumpQuery && ( 1087 + <span className="bg-primary/30 text-primary font-semibold"> 1088 + {revision.change_id_short.slice(0, jumpQuery.length)} 1089 + </span> 1090 + )} 1091 + {/* Next character to type (the hint) */} 1092 + <span className="bg-primary text-primary-foreground font-semibold rounded-sm"> 1093 + {revision.change_id_short[jumpQuery.length]} 1094 + </span> 1095 + {/* Rest of the ID */} 1096 + <span> 1097 + {revision.change_id_short.slice(jumpQuery.length + 1)} 1098 + </span> 1099 + </> 1100 + ) : ( 1101 + revision.change_id_short 1102 + )} 664 1103 </code> 665 - {revision.bookmarks.length > 0 && 666 - revision.bookmarks.map((bookmark) => ( 667 - <Badge key={bookmark} variant="secondary" className="text-xs px-1 py-0"> 668 - {bookmark} 669 - </Badge> 670 - ))} 671 - <span className="text-xs text-muted-foreground truncate min-w-0"> 1104 + {revision.bookmarks.length > 0 && ( 1105 + <span 1106 + className="text-xs text-primary font-medium truncate min-w-0 whitespace-nowrap" 1107 + title={revision.bookmarks.join(", ")} 1108 + > 1109 + {revision.bookmarks.join(", ")} 1110 + </span> 1111 + )} 1112 + <span className="text-xs text-muted-foreground truncate min-w-0 shrink-0"> 672 1113 {revision.author.split("@")[0]} · {revision.timestamp} 673 1114 </span> 674 1115 </div> 675 1116 <div className={`text-sm mt-1 ${isExpanded ? "" : "truncate"}`}>{firstLine}</div> 676 1117 </div> 677 1118 {isExpanded && ( 678 - <div className="px-3 pb-3 pt-0 space-y-3"> 1119 + <div className={`px-3 pb-3 pt-0 space-y-3 ${isPendingAbandon ? "blur-sm" : ""}`}> 679 1120 <pre className="text-xs text-muted-foreground whitespace-pre-wrap break-words font-mono bg-muted/40 border border-border/60 rounded p-2"> 680 1121 {fullDescription} 681 1122 </pre> ··· 686 1127 onSelectFile={handleSelectFile} 687 1128 isLoading={changedFilesQuery.isLoading} 688 1129 /> 1130 + </div> 1131 + </div> 1132 + )} 1133 + {isPendingAbandon && ( 1134 + <div className="absolute inset-0 flex items-center justify-center bg-destructive/10 rounded"> 1135 + <div className="text-sm font-medium text-destructive-foreground bg-destructive/90 px-3 py-1.5 rounded"> 1136 + Abandon this revision? <kbd className="ml-1 px-1 bg-background/20 rounded">Y</kbd> / <kbd className="px-1 bg-background/20 rounded">N</kbd> 689 1137 </div> 690 1138 </div> 691 1139 )} ··· 758 1206 759 1207 export const RevisionGraph = forwardRef<RevisionGraphHandle, RevisionGraphProps>( 760 1208 function RevisionGraph( 761 - { revisions, selectedRevision, onSelectRevision, isLoading, flash, repoPath }, 1209 + { revisions, selectedRevision, onSelectRevision, isLoading, flash, repoPath, pendingAbandon }, 762 1210 ref, 763 1211 ) { 764 1212 const parentRef = useRef<HTMLDivElement>(null); 765 - const { nodes, laneCount, rows } = buildGraph(revisions); 1213 + const { nodes, laneCount, rows: allRows, edgeBindings } = useMemo( 1214 + () => buildGraph(revisions), 1215 + [revisions], 1216 + ); 1217 + const expanded = useSearch({ strict: false, select: (s) => s.expanded }); 766 1218 const search = useSearch({ strict: false }); 767 1219 const navigate = useNavigate(); 1220 + const [inlineJumpQuery, setInlineJumpQuery] = useAtom(inlineJumpQueryAtom); 1221 + const inlineJumpMode = inlineJumpQuery !== null; 768 1222 769 - const revisionMap = new Map(revisions.map((r) => [r.change_id, r])); 1223 + // Detect collapsible stacks 1224 + const stacks = useMemo(() => detectStacks(revisions), [revisions]); 1225 + 1226 + // Prefetch diffs for all revisions in background 1227 + // This eagerly creates TanStack DB collections which trigger async fetches 1228 + useMemo(() => { 1229 + if (repoPath && revisions.length > 0) { 1230 + const changeIds = revisions.map((r) => r.change_id); 1231 + prefetchRevisionDiffs(repoPath, changeIds); 1232 + } 1233 + }, [repoPath, revisions]); 1234 + 1235 + // Track which stacks are expanded (empty = all collapsed by default) 1236 + const [expandedStacks, setExpandedStacks] = useAtom(expandedStacksAtom); 1237 + 1238 + // Build lookup maps for stacks 1239 + const { stackByChangeId, stackById, intermediateChangeIds } = useMemo(() => { 1240 + const byChangeId = new Map<string, RevisionStack>(); 1241 + const byId = new Map<string, RevisionStack>(); 1242 + const intermediates = new Set<string>(); 1243 + 1244 + for (const stack of stacks) { 1245 + byId.set(stack.id, stack); 1246 + for (const changeId of stack.changeIds) { 1247 + byChangeId.set(changeId, stack); 1248 + } 1249 + for (const changeId of stack.intermediateChangeIds) { 1250 + intermediates.add(changeId); 1251 + } 1252 + } 1253 + return { stackByChangeId: byChangeId, stackById: byId, intermediateChangeIds: intermediates }; 1254 + }, [stacks]); 1255 + 1256 + // Build node lane lookup by change_id (needed for display row construction) 1257 + const changeIdToLane = useMemo(() => { 1258 + const map = new Map<string, number>(); 1259 + for (const node of nodes) { 1260 + map.set(node.revision.change_id, node.lane); 1261 + } 1262 + return map; 1263 + }, [nodes]); 1264 + 1265 + // Display row can be either a revision row or a collapsed stack spacer 1266 + type DisplayRow = 1267 + | { type: "revision"; row: GraphRow } 1268 + | { type: "collapsed-spacer"; stack: RevisionStack; lane: number }; 1269 + 1270 + // Filter rows to hide collapsed intermediate revisions and add spacers 1271 + const displayRows = useMemo(() => { 1272 + const result: DisplayRow[] = []; 1273 + 1274 + for (const row of allRows) { 1275 + const changeId = row.revision.change_id; 1276 + const stack = stackByChangeId.get(changeId); 1277 + 1278 + if (stack && intermediateChangeIds.has(changeId)) { 1279 + // This is an intermediate revision in a stack 1280 + if (expandedStacks.has(stack.id)) { 1281 + // Stack is expanded - show the revision 1282 + result.push({ type: "revision", row }); 1283 + } 1284 + // If collapsed, skip this row (don't add it) 1285 + } else { 1286 + // Not an intermediate or not in a stack - always show 1287 + result.push({ type: "revision", row }); 1288 + 1289 + // If this is the top of a collapsed stack, insert a spacer after it 1290 + if (stack && changeId === stack.topChangeId && !expandedStacks.has(stack.id)) { 1291 + const lane = changeIdToLane.get(changeId) ?? 0; 1292 + result.push({ type: "collapsed-spacer", stack, lane }); 1293 + } 1294 + } 1295 + } 1296 + 1297 + return result; 1298 + }, [allRows, stackByChangeId, intermediateChangeIds, expandedStacks, changeIdToLane]); 1299 + 1300 + // Extract just revision rows for edge positioning and other logic 1301 + const rows = useMemo( 1302 + () => 1303 + displayRows 1304 + .filter((d): d is { type: "revision"; row: GraphRow } => d.type === "revision") 1305 + .map((d) => d.row), 1306 + [displayRows], 1307 + ); 1308 + 1309 + 1310 + // Toggle stack expansion 1311 + function toggleStackExpansion(stackId: string) { 1312 + setExpandedStacks((prev) => { 1313 + const next = new Set(prev); 1314 + if (next.has(stackId)) { 1315 + next.delete(stackId); 1316 + } else { 1317 + next.add(stackId); 1318 + } 1319 + return next; 1320 + }); 1321 + } 1322 + 1323 + // Maps for lookups - by change_id for UI, by commit_id for graph edges 1324 + const revisionMapByChangeId = new Map(revisions.map((r) => [r.change_id, r])); 1325 + const revisionMapByCommitId = new Map(revisions.map((r) => [r.commit_id, r])); 770 1326 const relatedRevisions = getRelatedRevisions(revisions, selectedRevision?.change_id ?? null); 771 1327 772 - // Build change_id -> row index map for scrolling 1328 + // Build change_id -> displayRow index map for scrolling and edge positioning 1329 + // IMPORTANT: Use displayRows indices (not rows) to match virtualizer positioning 773 1330 const changeIdToIndex = new Map<string, number>(); 774 - for (let i = 0; i < rows.length; i++) { 775 - changeIdToIndex.set(rows[i].revision.change_id, i); 1331 + const commitToRowIndex = new Map<string, number>(); 1332 + for (let i = 0; i < displayRows.length; i++) { 1333 + const displayRow = displayRows[i]; 1334 + if (displayRow.type === "revision") { 1335 + changeIdToIndex.set(displayRow.row.revision.change_id, i); 1336 + commitToRowIndex.set(displayRow.row.revision.commit_id, i); 1337 + } 1338 + } 1339 + 1340 + // Create a mapping of change_id -> commit_id for edge remapping 1341 + const changeIdToCommitId = new Map<string, string>(); 1342 + for (const rev of revisions) { 1343 + changeIdToCommitId.set(rev.change_id, rev.commit_id); 776 1344 } 777 1345 1346 + // Filter edge bindings to handle collapsed/expanded stacks 1347 + // When a stack is collapsed, edges from/to intermediates should be remapped 1348 + // When a stack is expanded, edges within it should be clickable to collapse 1349 + const filteredEdgeBindings = useMemo(() => { 1350 + // Build mapping: hidden commit_id -> { visible commit_id, stack info } 1351 + const hiddenToVisible = new Map<string, { targetCommitId: string; stack: RevisionStack }>(); 1352 + // Build mapping: top commit_id -> stack (for marking edges as collapsed) 1353 + const topCommitToStack = new Map<string, RevisionStack>(); 1354 + // Build mapping: commit_id -> stack (for edges within expanded stacks) 1355 + const commitToExpandedStack = new Map<string, RevisionStack>(); 1356 + 1357 + for (const stack of stacks) { 1358 + if (!expandedStacks.has(stack.id)) { 1359 + // Stack is collapsed - map all intermediates to bottom revision 1360 + const bottomCommitId = changeIdToCommitId.get(stack.bottomChangeId); 1361 + const topCommitId = changeIdToCommitId.get(stack.topChangeId); 1362 + 1363 + if (bottomCommitId && topCommitId) { 1364 + topCommitToStack.set(topCommitId, stack); 1365 + 1366 + for (const intermediateChangeId of stack.intermediateChangeIds) { 1367 + const intermediateCommitId = changeIdToCommitId.get(intermediateChangeId); 1368 + if (intermediateCommitId) { 1369 + hiddenToVisible.set(intermediateCommitId, { targetCommitId: bottomCommitId, stack }); 1370 + } 1371 + } 1372 + } 1373 + } else { 1374 + // Stack is expanded - mark all commits in the stack for clickable edges 1375 + for (const changeId of stack.changeIds) { 1376 + const commitId = changeIdToCommitId.get(changeId); 1377 + if (commitId) { 1378 + commitToExpandedStack.set(commitId, stack); 1379 + } 1380 + } 1381 + } 1382 + } 1383 + 1384 + // Remap edge bindings 1385 + const remapped: EdgeBinding[] = []; 1386 + const seen = new Set<string>(); // Deduplicate edges 1387 + 1388 + for (const binding of edgeBindings) { 1389 + let targetId = binding.targetRevisionId; 1390 + let collapsedStackId: string | undefined; 1391 + let collapsedCount: number | undefined; 1392 + let expandedStackId: string | undefined; 1393 + 1394 + // Check if this edge originates from a collapsed stack top 1395 + const stackFromTop = topCommitToStack.get(binding.sourceRevisionId); 1396 + if (stackFromTop && hiddenToVisible.has(targetId)) { 1397 + // This is the edge from top to first intermediate - remap to bottom 1398 + const info = hiddenToVisible.get(targetId)!; 1399 + targetId = info.targetCommitId; 1400 + collapsedStackId = info.stack.id; 1401 + collapsedCount = info.stack.intermediateChangeIds.length; 1402 + } else if (hiddenToVisible.has(targetId)) { 1403 + // Remap target if it's a hidden intermediate 1404 + targetId = hiddenToVisible.get(targetId)!.targetCommitId; 1405 + } else { 1406 + // Check if both source and target are in the same expanded stack 1407 + const sourceStack = commitToExpandedStack.get(binding.sourceRevisionId); 1408 + const targetStack = commitToExpandedStack.get(targetId); 1409 + if (sourceStack && targetStack && sourceStack.id === targetStack.id) { 1410 + expandedStackId = sourceStack.id; 1411 + } 1412 + } 1413 + 1414 + // Skip edges where source is a hidden intermediate 1415 + if (hiddenToVisible.has(binding.sourceRevisionId)) { 1416 + continue; 1417 + } 1418 + 1419 + // Deduplicate 1420 + const key = `${binding.sourceRevisionId}->${targetId}`; 1421 + if (seen.has(key)) continue; 1422 + seen.add(key); 1423 + 1424 + remapped.push({ 1425 + ...binding, 1426 + targetRevisionId: targetId, 1427 + collapsedStackId, 1428 + collapsedCount, 1429 + expandedStackId, 1430 + }); 1431 + } 1432 + 1433 + return remapped; 1434 + }, [edgeBindings, stacks, expandedStacks, changeIdToCommitId]); 1435 + 778 1436 const [debugEnabled, setDebugEnabled] = useState(DEBUG_OVERLAY_DEFAULT); 779 1437 const debugEnabledRef = useRef(debugEnabled); 780 1438 debugEnabledRef.current = debugEnabled; 781 1439 782 1440 // Determine if selected revision is expanded based on URL search params 783 - const isSelectedExpanded = search.expanded === true && !!selectedRevision; 1441 + const isSelectedExpanded = expanded === true && !!selectedRevision; 784 1442 785 1443 // Toggle debug overlay with Ctrl+Shift+D 786 1444 useKeyboardShortcut({ ··· 817 1475 if (!isSelectedExpanded) return; // Do nothing if already collapsed 818 1476 819 1477 // Collapse the revision by removing expanded from URL 820 - const { expanded, ...restSearch } = search; 1478 + const { expanded: _expanded, ...restSearch } = search; 821 1479 navigate({ 822 1480 search: restSearch as any, 823 1481 }); 824 1482 }, 825 1483 }); 826 1484 1485 + // Track if we just activated jump mode to ignore the same 'f' keypress 1486 + const justActivatedRef = useRef(false); 1487 + 1488 + // Activate inline jump mode with 'f' key 1489 + useKeyboardShortcut({ 1490 + key: "f", 1491 + modifiers: {}, 1492 + onPress: () => { 1493 + justActivatedRef.current = true; 1494 + setInlineJumpQuery(""); 1495 + // Clear the flag after a short delay (same event loop tick protection) 1496 + requestAnimationFrame(() => { 1497 + justActivatedRef.current = false; 1498 + }); 1499 + }, 1500 + enabled: !inlineJumpMode, 1501 + }); 1502 + 1503 + // Cancel inline jump mode with Escape 1504 + useKeyboardShortcut({ 1505 + key: "Escape", 1506 + modifiers: {}, 1507 + onPress: () => setInlineJumpQuery(null), 1508 + enabled: inlineJumpMode, 1509 + }); 1510 + 827 1511 const rowVirtualizer = useVirtualizer({ 828 - count: rows.length, 1512 + count: displayRows.length, 829 1513 getScrollElement: () => parentRef.current, 830 1514 estimateSize: (index: number) => { 831 - const row = rows[index]; 1515 + const displayRow = displayRows[index]; 1516 + if (displayRow.type === "collapsed-spacer") { 1517 + // Fixed height spacer for collapsed stacks 1518 + return COLLAPSED_INDICATOR_HEIGHT; 1519 + } 1520 + const row = displayRow.row; 832 1521 const isExpanded = 833 1522 isSelectedExpanded && row.revision.change_id === selectedRevision?.change_id; 834 1523 return isExpanded ? ROW_HEIGHT * 3 : ROW_HEIGHT; ··· 907 1596 })); 908 1597 909 1598 function handleSelect(changeId: string) { 910 - const revision = revisionMap.get(changeId); 1599 + const revision = revisionMapByChangeId.get(changeId); 911 1600 if (revision) onSelectRevision(revision); 912 - } 913 - 914 - if (revisions.length === 0) { 915 - return ( 916 - <div className="flex items-center justify-center h-full bg-background text-muted-foreground text-sm"> 917 - {isLoading ? "Loading revisions..." : "Select a project to view revisions"} 918 - </div> 919 - ); 920 1601 } 921 1602 922 1603 const virtualItems = rowVirtualizer.getVirtualItems(); ··· 928 1609 rowOffsets.set(item.index, item.start); 929 1610 } 930 1611 1612 + // Compute jump hints for visible rows based on change ID prefix matching 1613 + const jumpHintsMap = new Map<string, string>(); 1614 + const matchingRevisions: Array<{ changeId: string; shortId: string }> = []; 1615 + 1616 + if (inlineJumpMode && revisions.length > 0) { 1617 + const query = inlineJumpQuery ?? ""; 1618 + 1619 + // First, collect all visible revisions that match the current query 1620 + for (const item of virtualItems) { 1621 + const row = rows[item.index]; 1622 + if (row) { 1623 + const shortId = row.revision.change_id_short.toLowerCase(); 1624 + if (shortId.startsWith(query.toLowerCase())) { 1625 + matchingRevisions.push({ 1626 + changeId: row.revision.change_id, 1627 + shortId: row.revision.change_id_short, 1628 + }); 1629 + } 1630 + } 1631 + } 1632 + 1633 + // Assign hints based on the next character in the change ID 1634 + if (query === "") { 1635 + // Initial state: show first letter of each change ID 1636 + for (const { changeId, shortId } of matchingRevisions) { 1637 + jumpHintsMap.set(changeId, shortId[0].toLowerCase()); 1638 + } 1639 + } else { 1640 + // After typing: show the next letter to type, or secondary hints if needed 1641 + const nextCharIndex = query.length; 1642 + const nextChars = new Map<string, Array<{ changeId: string; shortId: string }>>(); 1643 + 1644 + // Group by next character 1645 + for (const rev of matchingRevisions) { 1646 + const nextChar = rev.shortId[nextCharIndex]?.toLowerCase() ?? ""; 1647 + if (nextChar) { 1648 + const group = nextChars.get(nextChar) ?? []; 1649 + group.push(rev); 1650 + nextChars.set(nextChar, group); 1651 + } 1652 + } 1653 + 1654 + // Assign hints 1655 + for (const { changeId, shortId } of matchingRevisions) { 1656 + const nextChar = shortId[nextCharIndex]?.toLowerCase() ?? ""; 1657 + if (nextChar) { 1658 + jumpHintsMap.set(changeId, nextChar); 1659 + } 1660 + } 1661 + } 1662 + } 1663 + 1664 + // Store matching revisions in a ref for use in the effect 1665 + const matchingRevisionsRef = useRef(matchingRevisions); 1666 + matchingRevisionsRef.current = matchingRevisions; 1667 + 1668 + // Handle jump hint letter key presses 1669 + useEffect(() => { 1670 + if (!inlineJumpMode) return; 1671 + 1672 + function handleJumpKey(event: KeyboardEvent) { 1673 + const activeElement = document.activeElement; 1674 + if (activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA") { 1675 + return; 1676 + } 1677 + 1678 + const key = event.key.toLowerCase(); 1679 + 1680 + // Ignore the activation key 'f' if we just activated (prevents same event capture) 1681 + if (key === "f" && justActivatedRef.current) { 1682 + return; 1683 + } 1684 + 1685 + // Handle backspace to remove last character 1686 + if (event.key === "Backspace") { 1687 + event.preventDefault(); 1688 + const currentQuery = inlineJumpQuery ?? ""; 1689 + if (currentQuery.length > 0) { 1690 + setInlineJumpQuery(currentQuery.slice(0, -1)); 1691 + } else { 1692 + setInlineJumpQuery(null); // Cancel if already empty 1693 + } 1694 + return; 1695 + } 1696 + 1697 + // Only accept alphanumeric characters for the query 1698 + if (/^[a-z0-9]$/i.test(key)) { 1699 + event.preventDefault(); 1700 + const newQuery = (inlineJumpQuery ?? "") + key; 1701 + 1702 + // Find matching revisions with the new query 1703 + const matches = matchingRevisionsRef.current.filter(({ shortId }) => 1704 + shortId.toLowerCase().startsWith(newQuery.toLowerCase()), 1705 + ); 1706 + 1707 + if (matches.length === 1) { 1708 + // Single match - jump directly 1709 + setInlineJumpQuery(null); 1710 + const revision = revisionMapByChangeId.get(matches[0].changeId); 1711 + if (revision) { 1712 + onSelectRevision(revision); 1713 + } 1714 + } else if (matches.length === 0) { 1715 + // No matches - cancel 1716 + setInlineJumpQuery(null); 1717 + } else { 1718 + // Multiple matches - update query to filter 1719 + setInlineJumpQuery(newQuery); 1720 + } 1721 + return; 1722 + } 1723 + 1724 + // Any other non-modifier key cancels jump mode 1725 + if (!["Shift", "Control", "Alt", "Meta", "CapsLock"].includes(event.key)) { 1726 + setInlineJumpQuery(null); 1727 + } 1728 + } 1729 + 1730 + window.addEventListener("keydown", handleJumpKey); 1731 + return () => window.removeEventListener("keydown", handleJumpKey); 1732 + }, [inlineJumpMode, inlineJumpQuery, setInlineJumpQuery, revisionMapByChangeId, onSelectRevision]); 1733 + 1734 + if (revisions.length === 0) { 1735 + return ( 1736 + <div className="flex items-center justify-center h-full bg-background text-muted-foreground text-sm"> 1737 + {isLoading ? "Loading revisions..." : "Select a project to view revisions"} 1738 + </div> 1739 + ); 1740 + } 1741 + 931 1742 const selectedIndex = selectedRevision 932 1743 ? changeIdToIndex.get(selectedRevision.change_id) 933 1744 : undefined; 934 1745 1746 + const workingCopy = revisions.find((r) => r.is_working_copy); 1747 + const wcIndex = workingCopy ? changeIdToIndex.get(workingCopy.change_id) : undefined; 1748 + 1749 + // Calculate edge layer dimensions and row center positions 1750 + const TOP_PADDING = 16; // Matches pt-4 on RevisionRow 1751 + const CONTENT_MIN_HEIGHT = 56; // Matches min-h-[56px] on RevisionRow content 1752 + const getRowStart = (row: number) => rowOffsets.get(row) ?? row * ROW_HEIGHT; 1753 + const getRowCenter = (row: number) => getRowStart(row) + TOP_PADDING + CONTENT_MIN_HEIGHT / 2; 1754 + const graphWidth = LANE_PADDING + laneCount * LANE_WIDTH + NODE_RADIUS + 2; 1755 + 935 1756 return ( 936 1757 <div 937 1758 ref={parentRef} ··· 945 1766 width: "100%", 946 1767 }} 947 1768 > 948 - {/* Graph column - positioned absolutely, scrolls with content */} 949 - <GraphColumn 950 - nodes={nodes} 951 - laneCount={laneCount} 1769 + {/* Edge layer - semantic edge components positioned absolutely */} 1770 + <EdgeLayer 1771 + bindings={filteredEdgeBindings} 1772 + revisionMap={revisionMapByCommitId} 1773 + getRowCenter={getRowCenter} 1774 + commitToRow={commitToRowIndex} 1775 + totalHeight={totalHeight} 1776 + width={graphWidth} 952 1777 visibleStartRow={visibleStartRow} 953 1778 visibleEndRow={visibleEndRow} 954 - totalHeight={totalHeight} 955 - rowOffsets={rowOffsets} 1779 + stackById={stackById} 1780 + changeIdToCommitId={changeIdToCommitId} 1781 + onToggleStack={toggleStackExpansion} 956 1782 /> 957 1783 958 - {/* Virtualized rows */} 1784 + {/* Virtualized rows with inline graph nodes */} 959 1785 <div className="relative z-10"> 960 1786 {virtualItems.map((virtualRow) => { 961 - const row = rows[virtualRow.index]; 1787 + const displayRow = displayRows[virtualRow.index]; 1788 + 1789 + // Collapsed stack spacer row - button positioned at edge midpoint 1790 + if (displayRow.type === "collapsed-spacer") { 1791 + const { stack, lane } = displayRow; 1792 + // Get the row indices for top and bottom of this stack 1793 + const topRowIdx = changeIdToIndex.get(stack.topChangeId); 1794 + const bottomRowIdx = changeIdToIndex.get(stack.bottomChangeId); 1795 + // Calculate button position at midpoint of dotted edge 1796 + const topCenter = topRowIdx !== undefined ? getRowCenter(topRowIdx) : virtualRow.start; 1797 + const bottomCenter = bottomRowIdx !== undefined ? getRowCenter(bottomRowIdx) : virtualRow.start + COLLAPSED_INDICATOR_HEIGHT; 1798 + const edgeMidY = (topCenter + bottomCenter) / 2; 1799 + // Position button relative to spacer row start 1800 + const buttonOffsetY = edgeMidY - virtualRow.start - 12; // 12 = half button height 1801 + 1802 + return ( 1803 + <div 1804 + key={`spacer-${stack.id}`} 1805 + ref={rowVirtualizer.measureElement} 1806 + data-index={virtualRow.index} 1807 + className="absolute left-0 w-full pointer-events-none" 1808 + style={{ 1809 + transform: `translateY(${virtualRow.start}px)`, 1810 + height: COLLAPSED_INDICATOR_HEIGHT, 1811 + }} 1812 + > 1813 + <button 1814 + type="button" 1815 + onClick={() => toggleStackExpansion(stack.id)} 1816 + className="absolute flex items-center gap-1.5 px-3 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 transition-colors pointer-events-auto rounded" 1817 + style={{ 1818 + top: buttonOffsetY + 4, 1819 + left: (lane + 1) * LANE_WIDTH + 16, 1820 + backgroundColor: "transparent", 1821 + }} 1822 + > 1823 + <svg 1824 + className="w-3 h-3" 1825 + fill="none" 1826 + viewBox="0 0 24 24" 1827 + stroke="currentColor" 1828 + > 1829 + <path 1830 + strokeLinecap="round" 1831 + strokeLinejoin="round" 1832 + strokeWidth={2} 1833 + d="M19 9l-7 7-7-7" 1834 + /> 1835 + </svg> 1836 + <span> 1837 + {stack.intermediateChangeIds.length} hidden revision 1838 + {stack.intermediateChangeIds.length !== 1 ? "s" : ""} 1839 + </span> 1840 + </button> 1841 + </div> 1842 + ); 1843 + } 1844 + 1845 + // Regular revision row 1846 + const row = displayRow.row; 1847 + const lane = changeIdToLane.get(row.revision.change_id) ?? 0; 962 1848 const isFlashing = flash?.changeId === row.revision.change_id; 963 1849 const isDimmed = 964 1850 selectedRevision !== null && !relatedRevisions.has(row.revision.change_id); ··· 976 1862 transform: `translateY(${virtualRow.start}px)`, 977 1863 }} 978 1864 > 979 - <RevisionRow 980 - revision={row.revision} 981 - maxLaneOnRow={row.maxLaneOnRow} 982 - isSelected={isSelected} 983 - isFocused={isFocused} 984 - onSelect={handleSelect} 985 - isFlashing={isFlashing} 986 - isDimmed={isDimmed} 987 - isExpanded={isExpanded} 988 - repoPath={repoPath} 989 - /> 1865 + <RevisionRow 1866 + revision={row.revision} 1867 + lane={lane} 1868 + maxLaneOnRow={row.maxLaneOnRow} 1869 + isSelected={isSelected} 1870 + isFocused={isFocused} 1871 + onSelect={handleSelect} 1872 + isFlashing={isFlashing} 1873 + isDimmed={isDimmed} 1874 + isExpanded={isExpanded} 1875 + repoPath={repoPath} 1876 + isPendingAbandon={pendingAbandon?.change_id === row.revision.change_id} 1877 + jumpHint={jumpHintsMap.get(row.revision.change_id) ?? null} 1878 + jumpModeActive={inlineJumpMode} 1879 + jumpQuery={inlineJumpQuery ?? ""} 1880 + /> 990 1881 </div> 991 1882 ); 992 1883 })} ··· 1001 1892 visibleStartRow={visibleStartRow} 1002 1893 visibleEndRow={visibleEndRow} 1003 1894 totalRows={rows.length} 1895 + wcIndex={wcIndex} 1896 + selectedChangeId={selectedRevision?.change_id} 1897 + wcChangeId={workingCopy?.change_id} 1004 1898 /> 1005 1899 </div> 1006 1900 );
+380 -16
apps/desktop/src/components/revision-graph-utils.ts
··· 1 1 import type { Revision } from "@/tauri-commands"; 2 2 3 - export function reorderForGraph(revisions: Revision[]): Revision[] { 3 + /** Recency map: commit_id (hex) -> timestamp_millis when last WC */ 4 + export type CommitRecency = Record<string, number>; 5 + 6 + /** 7 + * Ancestry information for a revision within the visible revset. 8 + * Used to determine graph edges and lane allocation. 9 + */ 10 + export interface RevisionAncestry { 11 + /** commit_id -> Set of ancestor commit_ids within the visible revset */ 12 + ancestors: Map<string, Set<string>>; 13 + /** commit_id -> Set of descendant commit_ids within the visible revset */ 14 + descendants: Map<string, Set<string>>; 15 + /** commit_id -> direct parent commit_ids within the visible revset */ 16 + parents: Map<string, string[]>; 17 + /** commit_id -> direct child commit_ids within the visible revset */ 18 + children: Map<string, string[]>; 19 + } 20 + 21 + /** 22 + * Computes ancestor/descendant relationships for all revisions within the visible revset. 23 + * This is used to determine which revisions are actually related and should be connected by edges. 24 + */ 25 + export function computeRevisionAncestry(revisions: Revision[]): RevisionAncestry { 26 + if (revisions.length === 0) { 27 + return { 28 + ancestors: new Map(), 29 + descendants: new Map(), 30 + parents: new Map(), 31 + children: new Map(), 32 + }; 33 + } 34 + 35 + const commitIds = new Set(revisions.map((r) => r.commit_id)); 36 + 37 + // Build direct parent/child relationships (only within visible revset) 38 + const parents = new Map<string, string[]>(); 39 + const children = new Map<string, string[]>(); 40 + 41 + for (const rev of revisions) { 42 + const visibleParents: string[] = []; 43 + for (const edge of rev.parent_edges) { 44 + // Only consider non-missing edges where parent is in visible set 45 + if (edge.edge_type === "missing") continue; 46 + if (!commitIds.has(edge.parent_id)) continue; 47 + visibleParents.push(edge.parent_id); 48 + 49 + // Build children map 50 + const parentChildren = children.get(edge.parent_id) ?? []; 51 + parentChildren.push(rev.commit_id); 52 + children.set(edge.parent_id, parentChildren); 53 + } 54 + parents.set(rev.commit_id, visibleParents); 55 + } 56 + 57 + // Ensure all commits have entries even if they have no children/parents 58 + for (const rev of revisions) { 59 + if (!children.has(rev.commit_id)) { 60 + children.set(rev.commit_id, []); 61 + } 62 + } 63 + 64 + // Compute transitive ancestors for each commit using BFS 65 + const ancestors = new Map<string, Set<string>>(); 66 + for (const rev of revisions) { 67 + const ancestorSet = new Set<string>(); 68 + const queue = [...(parents.get(rev.commit_id) ?? [])]; 69 + 70 + while (queue.length > 0) { 71 + const parentId = queue.shift()!; 72 + if (ancestorSet.has(parentId)) continue; 73 + ancestorSet.add(parentId); 74 + queue.push(...(parents.get(parentId) ?? [])); 75 + } 76 + 77 + ancestors.set(rev.commit_id, ancestorSet); 78 + } 79 + 80 + // Compute transitive descendants for each commit using BFS 81 + const descendants = new Map<string, Set<string>>(); 82 + for (const rev of revisions) { 83 + const descendantSet = new Set<string>(); 84 + const queue = [...(children.get(rev.commit_id) ?? [])]; 85 + 86 + while (queue.length > 0) { 87 + const childId = queue.shift()!; 88 + if (descendantSet.has(childId)) continue; 89 + descendantSet.add(childId); 90 + queue.push(...(children.get(childId) ?? [])); 91 + } 92 + 93 + descendants.set(rev.commit_id, descendantSet); 94 + } 95 + 96 + return { ancestors, descendants, parents, children }; 97 + } 98 + 99 + /** 100 + * Checks if two revisions are related (one is ancestor/descendant of the other). 101 + */ 102 + export function areRevisionsRelated( 103 + commitIdA: string, 104 + commitIdB: string, 105 + ancestry: RevisionAncestry, 106 + ): boolean { 107 + if (commitIdA === commitIdB) return true; 108 + const ancestorsA = ancestry.ancestors.get(commitIdA); 109 + const ancestorsB = ancestry.ancestors.get(commitIdB); 110 + if (ancestorsA?.has(commitIdB)) return true; 111 + if (ancestorsB?.has(commitIdA)) return true; 112 + return false; 113 + } 114 + 115 + /** 116 + * Groups revisions into connected components based on ancestry relationships. 117 + * Each component contains revisions that are related (share ancestor/descendant relationships). 118 + */ 119 + export function groupIntoConnectedComponents( 120 + revisions: Revision[], 121 + ancestry: RevisionAncestry, 122 + ): Map<string, string[]> { 123 + const components = new Map<string, string[]>(); // componentId -> commit_ids 124 + const commitToComponent = new Map<string, string>(); 125 + 126 + for (const rev of revisions) { 127 + if (commitToComponent.has(rev.commit_id)) continue; 128 + 129 + // Start a new component with this revision as the root 130 + const componentId = rev.commit_id; 131 + const componentMembers: string[] = []; 132 + const queue = [rev.commit_id]; 133 + 134 + while (queue.length > 0) { 135 + const commitId = queue.shift()!; 136 + if (commitToComponent.has(commitId)) continue; 137 + 138 + commitToComponent.set(commitId, componentId); 139 + componentMembers.push(commitId); 140 + 141 + // Add all ancestors and descendants to the component 142 + const ancestorSet = ancestry.ancestors.get(commitId) ?? new Set(); 143 + const descendantSet = ancestry.descendants.get(commitId) ?? new Set(); 144 + 145 + for (const ancestorId of ancestorSet) { 146 + if (!commitToComponent.has(ancestorId)) { 147 + queue.push(ancestorId); 148 + } 149 + } 150 + for (const descendantId of descendantSet) { 151 + if (!commitToComponent.has(descendantId)) { 152 + queue.push(descendantId); 153 + } 154 + } 155 + } 156 + 157 + components.set(componentId, componentMembers); 158 + } 159 + 160 + return components; 161 + } 162 + 163 + /** 164 + * A linear stack of revisions that can be collapsed. 165 + * Stacks are detected when revisions form a linear chain without branches. 166 + */ 167 + export interface RevisionStack { 168 + /** Unique ID for this stack (based on top revision's change_id) */ 169 + id: string; 170 + /** All change_ids in this stack, from top (newest) to bottom (oldest) */ 171 + changeIds: string[]; 172 + /** The top revision of the stack (most recent) */ 173 + topChangeId: string; 174 + /** The bottom revision of the stack (oldest, often has a bookmark) */ 175 + bottomChangeId: string; 176 + /** Intermediate revisions that can be hidden when collapsed */ 177 + intermediateChangeIds: string[]; 178 + } 179 + 180 + /** 181 + * Detects linear stacks in the revision graph. 182 + * A stack is a linear sequence where: 183 + * - Each revision has exactly one "linear" parent in the sequence (a parent with only 1 child) 184 + * - Merge commits ("merge main into branch") are allowed as intermediates - they have multiple 185 + * parents but only one forms the linear chain (the branch parent has 1 child, main has many) 186 + * - Top revision can have any children (or none) 187 + * - Bottom revision can have any parents 188 + * - Minimum 3 revisions to form a collapsible stack (so we have at least 1 hidden) 189 + */ 190 + export function detectStacks(revisions: Revision[]): RevisionStack[] { 191 + if (revisions.length < 3) return []; 192 + 193 + const commitIds = new Set(revisions.map((r) => r.commit_id)); 194 + const changeIdByCommitId = new Map(revisions.map((r) => [r.commit_id, r.change_id])); 195 + const revisionByChangeId = new Map(revisions.map((r) => [r.change_id, r])); 196 + 197 + // Build parent/children maps (only for edges within our revset) 198 + const childrenMap = new Map<string, string[]>(); // commit_id -> child commit_ids 199 + const parentMap = new Map<string, string[]>(); // commit_id -> parent commit_ids 200 + 201 + for (const rev of revisions) { 202 + const parents: string[] = []; 203 + for (const edge of rev.parent_edges) { 204 + if (edge.edge_type === "missing") continue; 205 + if (!commitIds.has(edge.parent_id)) continue; 206 + parents.push(edge.parent_id); 207 + const children = childrenMap.get(edge.parent_id) ?? []; 208 + children.push(rev.commit_id); 209 + childrenMap.set(edge.parent_id, children); 210 + } 211 + parentMap.set(rev.commit_id, parents); 212 + } 213 + 214 + // Check if a revision should NOT be collapsed (needs to stay visible) 215 + function shouldRemainVisible(rev: Revision): boolean { 216 + if (rev.is_working_copy) return true; 217 + if (rev.is_trunk) return true; 218 + if (rev.bookmarks.length > 0) return true; 219 + if (rev.is_divergent) return true; 220 + return false; 221 + } 222 + 223 + const stacks: RevisionStack[] = []; 224 + const usedInStack = new Set<string>(); 225 + 226 + // Walk through revisions and find stack starts 227 + for (const rev of revisions) { 228 + if (usedInStack.has(rev.change_id)) continue; 229 + if (rev.is_immutable) continue; // Don't collapse immutable commits 230 + 231 + const commitId = rev.commit_id; 232 + 233 + // A stack starts at a revision that: 234 + // - Is not immutable 235 + // - Has at least one parent in our view that forms a linear chain 236 + // (i.e., that parent has exactly 1 child - this revision) 237 + // This handles merge commits: we follow the "linear" parent 238 + const parents = parentMap.get(commitId) ?? []; 239 + if (parents.length === 0) continue; 240 + 241 + // Find the linear parent (has exactly 1 child - this revision) 242 + const linearParents = parents.filter((parentId) => { 243 + const parentChildren = childrenMap.get(parentId) ?? []; 244 + return parentChildren.length === 1; 245 + }); 246 + // Need exactly one linear parent to start a chain 247 + if (linearParents.length !== 1) continue; 248 + 249 + // Walk down the chain to find all linear descendants 250 + const chain: string[] = [rev.change_id]; 251 + let current = rev; 252 + 253 + while (true) { 254 + const currentParents = parentMap.get(current.commit_id) ?? []; 255 + if (currentParents.length === 0) break; 256 + 257 + // Find the "linear" parent - the one that has exactly 1 child (current) 258 + // This handles merge commits: the branch parent has 1 child, while 259 + // the "main being merged in" parent typically has multiple children 260 + let linearParentId: string | null = null; 261 + for (const parentId of currentParents) { 262 + const parentChildren = childrenMap.get(parentId) ?? []; 263 + if (parentChildren.length === 1) { 264 + if (linearParentId !== null) { 265 + // Multiple parents with single child - ambiguous, stop 266 + linearParentId = null; 267 + break; 268 + } 269 + linearParentId = parentId; 270 + } 271 + } 272 + 273 + if (!linearParentId) break; 274 + 275 + const parentChangeId = changeIdByCommitId.get(linearParentId); 276 + if (!parentChangeId) break; 277 + 278 + const parentRev = revisionByChangeId.get(parentChangeId); 279 + if (!parentRev) break; 280 + 281 + // Stop if parent is immutable 282 + if (parentRev.is_immutable) break; 283 + 284 + chain.push(parentChangeId); 285 + current = parentRev; 286 + 287 + // If parent should remain visible and we have enough in chain, stop 288 + if (shouldRemainVisible(parentRev) && chain.length >= 2) break; 289 + } 290 + 291 + // Only create stack if we have at least 3 revisions (1+ intermediate) 292 + if (chain.length >= 3) { 293 + const topChangeId = chain[0]; 294 + const bottomChangeId = chain[chain.length - 1]; 295 + const intermediateChangeIds = chain.slice(1, -1); 296 + 297 + stacks.push({ 298 + id: topChangeId, 299 + changeIds: chain, 300 + topChangeId, 301 + bottomChangeId, 302 + intermediateChangeIds, 303 + }); 304 + 305 + for (const changeId of chain) { 306 + usedInStack.add(changeId); 307 + } 308 + } 309 + } 310 + 311 + return stacks; 312 + } 313 + 314 + export function reorderForGraph( 315 + revisions: Revision[], 316 + recency?: CommitRecency, 317 + ): Revision[] { 4 318 if (revisions.length === 0) return []; 5 319 6 320 const commitMap = new Map(revisions.map((r) => [r.commit_id, r])); ··· 22 336 parentMap.set(rev.commit_id, parents); 23 337 } 24 338 25 - // Priority score for a revision (lower = higher priority) 26 - function getPriority(rev: Revision): number { 27 - if (rev.is_working_copy) return 0; 28 - if (rev.is_mine && !rev.is_immutable) return 1; 29 - if (rev.bookmarks.length > 0 && !rev.is_immutable) return 2; 30 - if (!rev.is_immutable) return 3; 31 - if (rev.bookmarks.length > 0) return 4; 32 - return 5; 339 + // Find the head that contains the working copy in its ancestry 340 + const workingCopy = revisions.find((r) => r.is_working_copy); 341 + const wcAncestorHeadId = (() => { 342 + if (!workingCopy) return null; 343 + // Walk up from WC to find which head contains it 344 + const visited = new Set<string>(); 345 + const queue = [workingCopy.commit_id]; 346 + while (queue.length > 0) { 347 + const id = queue.shift()!; 348 + if (visited.has(id)) continue; 349 + visited.add(id); 350 + const children = childrenMap.get(id) ?? []; 351 + const validChildren = children.filter((c) => commitIds.has(c)); 352 + if (validChildren.length === 0) { 353 + // This is a head 354 + return id; 355 + } 356 + queue.push(...validChildren); 357 + } 358 + return null; 359 + })(); 360 + 361 + // Get max recency for a branch (walk down from head to find most recent touch) 362 + function getBranchRecency(headCommitId: string): number { 363 + if (!recency) return 0; 364 + let maxRecency = recency[headCommitId] ?? 0; 365 + // Walk ancestors to find any that were touched more recently 366 + const visited = new Set<string>(); 367 + const queue = [headCommitId]; 368 + while (queue.length > 0) { 369 + const id = queue.shift()!; 370 + if (visited.has(id)) continue; 371 + visited.add(id); 372 + const ts = recency[id] ?? 0; 373 + if (ts > maxRecency) maxRecency = ts; 374 + const parents = parentMap.get(id) ?? []; 375 + queue.push(...parents); 376 + } 377 + return maxRecency; 33 378 } 34 379 35 - // Find heads and sort by priority 36 - const heads = revisions 37 - .filter((r) => { 38 - const children = childrenMap.get(r.commit_id) ?? []; 39 - return children.filter((c) => commitIds.has(c)).length === 0; 40 - }) 41 - .sort((a, b) => getPriority(a) - getPriority(b)); 380 + // Find heads 381 + const heads = revisions.filter((r) => { 382 + const children = childrenMap.get(r.commit_id) ?? []; 383 + return children.filter((c) => commitIds.has(c)).length === 0; 384 + }); 385 + 386 + // Sort heads: WC's branch first, then by recency (most recent first), then stable tiebreaker 387 + heads.sort((a, b) => { 388 + // WC's branch always first 389 + const aIsWcBranch = a.commit_id === wcAncestorHeadId; 390 + const bIsWcBranch = b.commit_id === wcAncestorHeadId; 391 + if (aIsWcBranch && !bIsWcBranch) return -1; 392 + if (!aIsWcBranch && bIsWcBranch) return 1; 393 + 394 + // Sort by recency (higher timestamp = more recent = should come first) 395 + if (recency) { 396 + const aRecency = getBranchRecency(a.commit_id); 397 + const bRecency = getBranchRecency(b.commit_id); 398 + if (aRecency !== bRecency) { 399 + return bRecency - aRecency; // Descending (most recent first) 400 + } 401 + } 402 + 403 + // Stable tiebreaker: use change_id 404 + return a.change_id.localeCompare(b.change_id); 405 + }); 42 406 43 407 // Track which commits have been output 44 408 const output = new Set<string>();