a very good jj gui
0
fork

Configure Feed

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

refactor: extract RevisionGraph subcomponents into revision-graph/ directory

+2163 -2342
+27 -2342
apps/desktop/src/components/RevisionGraph.tsx
··· 1 - import { useAtom } from "@effect-atom/atom-react"; 2 - import { useLiveQuery } from "@tanstack/react-db"; 3 - import { useNavigate, useSearch } from "@tanstack/react-router"; 4 - import { useVirtualizer } from "@tanstack/react-virtual"; 5 - import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; 6 - import { expandedStacksAtom, focusPanelAtom, hoveredStackIdAtom, inlineJumpQueryAtom, viewModeAtom } from "@/atoms"; 7 - import { ChangedFilesList } from "@/components/ChangedFilesList"; 8 - import { 9 - reorderForGraph, 10 - detectStacks, 11 - computeRevisionAncestry, 12 - type RevisionStack, 13 - } from "@/components/revision-graph-utils"; 14 - import { emptyChangesCollection, getRevisionChangesCollection, prefetchRevisionDiffs } from "@/db"; 15 - import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 16 - import type { Revision } from "@/tauri-commands"; 1 + /** 2 + * RevisionGraph - Re-export from modular components 3 + * 4 + * The component has been refactored into smaller modules in ./revision-graph/ 5 + * This file maintains backwards compatibility for existing imports. 6 + */ 7 + export { 8 + RevisionGraph, 9 + type RevisionGraphHandle, 10 + } from "./revision-graph"; 17 11 18 - // Debug overlay - toggle with Ctrl+Shift+D 19 - const DEBUG_OVERLAY_DEFAULT = false; 12 + // Re-export types and constants for consumers that may need them 13 + export type { 14 + EdgeBinding, 15 + GraphNode, 16 + GraphRow, 17 + GraphData, 18 + GraphEdgeType, 19 + } from "./revision-graph"; 20 20 21 - function DebugOverlay({ 22 - enabled, 23 - scrollRef, 24 - selectedIndex, 25 - visibleStartRow, 26 - visibleEndRow, 27 - totalRows, 28 - wcIndex, 29 - selectedChangeId, 30 - wcChangeId, 31 - }: { 32 - enabled: boolean; 33 - scrollRef: React.RefObject<HTMLDivElement | null>; 34 - selectedIndex: number | undefined; 35 - visibleStartRow: number; 36 - visibleEndRow: number; 37 - totalRows: number; 38 - wcIndex: number | undefined; 39 - selectedChangeId: string | undefined; 40 - wcChangeId: string | undefined; 41 - }) { 42 - // Force re-render on scroll/resize/focus 43 - const [, forceUpdate] = useState(0); 44 - 45 - const prevScrollTop = useRef<number>(0); 46 - 47 - useEffect(() => { 48 - if (!enabled) return; 49 - const el = scrollRef.current; 50 - if (!el) return; 51 - 52 - const update = () => { 53 - const newScrollTop = el.scrollTop; 54 - if (Math.abs(newScrollTop - prevScrollTop.current) > 100) { 55 - console.log("[scroll] jump detected:", { 56 - from: prevScrollTop.current.toFixed(0), 57 - to: newScrollTop.toFixed(0), 58 - delta: (newScrollTop - prevScrollTop.current).toFixed(0), 59 - }); 60 - } 61 - prevScrollTop.current = newScrollTop; 62 - forceUpdate((n) => n + 1); 63 - }; 64 - el.addEventListener("scroll", update); 65 - window.addEventListener("resize", update); 66 - document.addEventListener("focusin", update); 67 - return () => { 68 - el.removeEventListener("scroll", update); 69 - window.removeEventListener("resize", update); 70 - document.removeEventListener("focusin", update); 71 - }; 72 - }, [scrollRef, enabled]); 73 - 74 - if (!enabled) return null; 75 - 76 - const el = scrollRef.current; 77 - const scrollTop = el?.scrollTop ?? 0; 78 - const clientHeight = el?.clientHeight ?? 0; 79 - const scrollHeight = el?.scrollHeight ?? 0; 80 - 81 - const selectedItemTop = selectedIndex !== undefined ? selectedIndex * ROW_HEIGHT : 0; 82 - const selectedItemBottom = selectedItemTop + ROW_HEIGHT; 83 - const distanceFromTop = selectedItemTop - scrollTop; 84 - const distanceFromBottom = scrollTop + clientHeight - selectedItemBottom; 85 - const isInViewport = distanceFromTop >= 0 && distanceFromBottom >= 0; 86 - 87 - const active = document.activeElement; 88 - const activeElement = active 89 - ? `${active.tagName}${active.className ? `.${active.className.split(" ")[0]}` : ""}` 90 - : "none"; 91 - 92 - const info = { 93 - scrollTop, 94 - clientHeight, 95 - scrollHeight, 96 - viewportEnd: scrollTop + clientHeight, 97 - selectedIndex, 98 - wcIndex, 99 - itemTop: selectedItemTop, 100 - itemBottom: selectedItemBottom, 101 - distFromTop: distanceFromTop, 102 - distFromBottom: distanceFromBottom, 103 - isInViewport, 104 - virtualRange: `${visibleStartRow}-${visibleEndRow}`, 105 - totalRows, 106 - ROW_HEIGHT, 107 - activeElement, 108 - selected: selectedChangeId?.slice(0, 4), 109 - wc: wcChangeId?.slice(0, 4), 110 - }; 111 - 112 - return ( 113 - <div 114 - className="fixed bottom-12 right-4 z-50 bg-black/90 text-green-400 font-mono text-xs p-3 rounded-lg shadow-lg max-w-xs cursor-pointer hover:bg-black/95 active:scale-95 transition-transform" 115 - onClick={() => navigator.clipboard.writeText(JSON.stringify(info, null, 2))} 116 - title="Click to copy" 117 - > 118 - <div className="font-bold text-green-300 mb-2"> 119 - Debug Info <span className="text-green-600">(click to copy)</span> 120 - </div> 121 - <div className="space-y-1"> 122 - <div>scrollTop: {scrollTop.toFixed(0)}</div> 123 - <div>clientHeight: {clientHeight.toFixed(0)}</div> 124 - <div>scrollHeight: {scrollHeight}</div> 125 - <div>viewportEnd: {(scrollTop + clientHeight).toFixed(0)}</div> 126 - <div className="border-t border-green-800 my-2" /> 127 - <div>selectedIndex: {selectedIndex ?? "none"}</div> 128 - <div>wcIndex: {wcIndex ?? "none"}</div> 129 - <div>selected: {selectedChangeId?.slice(0, 4) ?? "none"}</div> 130 - <div>wc: {wcChangeId?.slice(0, 4) ?? "none"}</div> 131 - <div className="border-t border-green-800 my-2" /> 132 - <div>itemTop: {selectedItemTop}</div> 133 - <div>itemBottom: {selectedItemBottom}</div> 134 - <div>distFromTop: {distanceFromTop.toFixed(0)}</div> 135 - <div>distFromBottom: {distanceFromBottom.toFixed(0)}</div> 136 - <div className={isInViewport ? "text-green-400" : "text-red-400"}> 137 - inViewport: {isInViewport ? "YES" : "NO"} 138 - </div> 139 - <div className="border-t border-green-800 my-2" /> 140 - <div> 141 - virtualRange: {visibleStartRow}-{visibleEndRow} 142 - </div> 143 - <div>totalRows: {totalRows}</div> 144 - <div>ROW_HEIGHT: {ROW_HEIGHT}</div> 145 - <div className="border-t border-green-800 my-2" /> 146 - <div className="truncate" title={activeElement}> 147 - focus: {activeElement} 148 - </div> 149 - </div> 150 - </div> 151 - ); 152 - } 153 - 154 - export interface RevisionGraphHandle { 155 - scrollToChangeId: ( 156 - changeId: string, 157 - options?: { align?: "auto" | "center"; smooth?: boolean }, 158 - ) => void; 159 - } 160 - 161 - interface RevisionGraphProps { 162 - revisions: Revision[]; 163 - selectedRevision: Revision | null; 164 - onSelectRevision: (revision: Revision) => void; 165 - isLoading: boolean; 166 - flash?: { changeId: string; key: number } | null; 167 - repoPath: string | null; 168 - pendingAbandon?: Revision | null; 169 - } 170 - 171 - const ROW_HEIGHT = 64; 172 - const LANE_WIDTH = 20; 173 - const LANE_PADDING = 8; 174 - const NODE_RADIUS = 5; 175 - const MAX_LANES = 2; 176 - 177 - const LANE_COLORS = [ 178 - "var(--chart-1)", 179 - "var(--chart-2)", 180 - "var(--chart-3)", 181 - "var(--chart-4)", 182 - "var(--chart-5)", 183 - "var(--primary)", 184 - ]; 185 - 186 - type GraphEdgeType = "direct" | "indirect" | "missing"; 187 - 188 - // ============================================================================ 189 - // Semantic Graph Components (tldraw-inspired architecture) 190 - // ============================================================================ 191 - 192 - // Represents a semantic binding between two revisions (like tldraw's shape bindings) 193 - interface EdgeBinding { 194 - id: string; 195 - sourceRevisionId: string; 196 - targetRevisionId: string; 197 - sourceLane: number; 198 - targetLane: number; 199 - edgeType: GraphEdgeType; 200 - isDeemphasized?: boolean; 201 - isMissingStub?: boolean; 202 - /** If set, this edge represents a collapsed stack and clicking it should expand */ 203 - collapsedStackId?: string; 204 - /** Number of hidden revisions in the collapsed stack */ 205 - collapsedCount?: number; 206 - /** If set, this edge is part of an expanded stack and clicking it should collapse */ 207 - expandedStackId?: string; 208 - } 209 - 210 - interface GraphNodeProps { 211 - revision: Revision; 212 - lane: number; 213 - isSelected: boolean; 214 - color: string; 215 - } 216 - 217 - // GraphNode - Semantic node component rendered inline with each row 218 - // Uses inline SVG for proper accessibility (avoids role="img" on divs) 219 - function GraphNode({ revision, lane, isSelected, color }: GraphNodeProps) { 220 - const isWorkingCopy = revision.is_working_copy; 221 - const isImmutable = revision.is_immutable; 222 - 223 - const size = isWorkingCopy ? NODE_RADIUS * 2 + 6 : NODE_RADIUS * 2; 224 - const selectedRingSize = isWorkingCopy ? NODE_RADIUS + 6 : NODE_RADIUS + 4; 225 - 226 - // Working copy: @ symbol with glow 227 - if (isWorkingCopy) { 228 - return ( 229 - <svg 230 - width={size + 8} 231 - height={size + 8} 232 - viewBox={`0 0 ${size + 8} ${size + 8}`} 233 - className="shrink-0" 234 - aria-label={`Working copy revision ${revision.change_id_short}`} 235 - data-revision-id={revision.change_id} 236 - data-lane={lane} 237 - > 238 - <title>Working copy: {revision.change_id_short}</title> 239 - {isSelected && ( 240 - <circle 241 - cx={(size + 8) / 2} 242 - cy={(size + 8) / 2} 243 - r={selectedRingSize} 244 - fill={color} 245 - fillOpacity={0.3} 246 - /> 247 - )} 248 - <circle 249 - cx={(size + 8) / 2} 250 - cy={(size + 8) / 2} 251 - r={NODE_RADIUS + 3} 252 - fill={color} 253 - fillOpacity={0.2} 254 - /> 255 - <text 256 - x={(size + 8) / 2} 257 - y={(size + 8) / 2} 258 - textAnchor="middle" 259 - dominantBaseline="central" 260 - fill={color} 261 - fontWeight="bold" 262 - fontSize="12" 263 - > 264 - @ 265 - </text> 266 - </svg> 267 - ); 268 - } 269 - 270 - // Immutable: diamond shape 271 - if (isImmutable) { 272 - return ( 273 - <svg 274 - width={size + 8} 275 - height={size + 8} 276 - viewBox={`0 0 ${size + 8} ${size + 8}`} 277 - className="shrink-0" 278 - aria-label={`Immutable revision ${revision.change_id_short}`} 279 - data-revision-id={revision.change_id} 280 - data-lane={lane} 281 - > 282 - <title>Immutable: {revision.change_id_short}</title> 283 - {isSelected && ( 284 - <circle 285 - cx={(size + 8) / 2} 286 - cy={(size + 8) / 2} 287 - r={selectedRingSize} 288 - fill={color} 289 - fillOpacity={0.3} 290 - /> 291 - )} 292 - <rect 293 - x={(size + 8) / 2 - NODE_RADIUS} 294 - y={(size + 8) / 2 - NODE_RADIUS} 295 - width={NODE_RADIUS * 2} 296 - height={NODE_RADIUS * 2} 297 - fill={color} 298 - transform={`rotate(45 ${(size + 8) / 2} ${(size + 8) / 2})`} 299 - /> 300 - </svg> 301 - ); 302 - } 303 - 304 - // Regular mutable: circle 305 - return ( 306 - <svg 307 - width={size + 8} 308 - height={size + 8} 309 - viewBox={`0 0 ${size + 8} ${size + 8}`} 310 - className="shrink-0" 311 - aria-label={`Revision ${revision.change_id_short}`} 312 - data-revision-id={revision.change_id} 313 - data-lane={lane} 314 - > 315 - <title>Revision: {revision.change_id_short}</title> 316 - {isSelected && ( 317 - <circle 318 - cx={(size + 8) / 2} 319 - cy={(size + 8) / 2} 320 - r={selectedRingSize} 321 - fill={color} 322 - fillOpacity={0.3} 323 - /> 324 - )} 325 - <circle cx={(size + 8) / 2} cy={(size + 8) / 2} r={NODE_RADIUS} fill={color} /> 326 - </svg> 327 - ); 328 - } 329 - 330 - interface GraphEdgeProps { 331 - binding: EdgeBinding; 332 - sourceY: number; 333 - targetY: number; 334 - sourceRevision: Revision; 335 - targetRevision: Revision | null; 336 - stackTopY?: number; 337 - stackBottomY?: number; 338 - hoveredStackId: string | null; 339 - onHoverStack: (stackId: string | null) => void; 340 - onToggleStack?: (stackId: string) => void; 341 - } 342 - 343 - // GraphEdge - Semantic edge component with source/target revision bindings 344 - function GraphEdge({ 345 - binding, 346 - sourceY, 347 - targetY, 348 - sourceRevision, 349 - targetRevision, 350 - stackTopY, 351 - stackBottomY, 352 - hoveredStackId, 353 - onHoverStack, 354 - onToggleStack, 355 - }: GraphEdgeProps) { 356 - const { 357 - sourceLane, 358 - targetLane, 359 - edgeType, 360 - isDeemphasized, 361 - isMissingStub, 362 - collapsedStackId, 363 - collapsedCount, 364 - expandedStackId, 365 - } = binding; 366 - 367 - // Check if this edge's stack is currently hovered 368 - const isStackHovered = expandedStackId !== undefined && hoveredStackId === expandedStackId; 369 - 370 - const sourceX = laneToX(sourceLane); 371 - const targetX = laneToX(targetLane); 372 - const sourceColor = laneColor(sourceLane); 373 - const targetColor = laneColor(targetLane); 374 - 375 - // Style based on edge type 376 - const isDashed = edgeType === "indirect"; 377 - const isMissing = edgeType === "missing"; 378 - const strokeWidth = isDeemphasized ? 1 : 2; 379 - const strokeOpacity = isDeemphasized ? 0.4 : isMissing ? 0.3 : 0.8; 380 - const strokeColor = isDeemphasized ? "var(--muted-foreground)" : targetColor; 381 - 382 - // Accessibility label describing the connection 383 - const ariaLabel = isMissingStub 384 - ? `${sourceRevision.change_id_short} has parent outside current view` 385 - : targetRevision 386 - ? `${sourceRevision.change_id_short} → ${targetRevision.change_id_short}${edgeType === "indirect" ? " (indirect)" : ""}` 387 - : `Edge from ${sourceRevision.change_id_short}`; 388 - 389 - // Missing stub: short dashed line indicating parent outside view 390 - if (isMissingStub) { 391 - const stubLength = ROW_HEIGHT * 0.4; 392 - return ( 393 - <g aria-label={ariaLabel} style={{ pointerEvents: "none" }}> 394 - <title>{ariaLabel}</title> 395 - <line 396 - x1={sourceX} 397 - y1={sourceY + NODE_RADIUS} 398 - x2={sourceX} 399 - y2={sourceY + NODE_RADIUS + stubLength} 400 - stroke={sourceColor} 401 - strokeWidth={1.5} 402 - strokeOpacity={0.4} 403 - strokeDasharray="3 3" 404 - data-edge-type="missing-stub" 405 - data-source-revision={sourceRevision.change_id} 406 - /> 407 - </g> 408 - ); 409 - } 410 - 411 - // Same lane: straight vertical line (or dotted for collapsed stacks) 412 - if (sourceLane === targetLane) { 413 - const isCollapsedStack = !!collapsedStackId; 414 - const y1 = sourceY + NODE_RADIUS; 415 - const y2 = targetY - NODE_RADIUS; 416 - 417 - // For collapsed stacks, draw a dotted line with clickable area 418 - if (isCollapsedStack) { 419 - const collapsedLabel = `${collapsedCount ?? 0} hidden revision${(collapsedCount ?? 0) !== 1 ? "s" : ""} - click to expand`; 420 - 421 - return ( 422 - <g 423 - aria-label={collapsedLabel} 424 - className="cursor-pointer group" 425 - style={{ pointerEvents: "auto" }} 426 - onClick={() => onToggleStack?.(collapsedStackId)} 427 - > 428 - <title>{collapsedLabel}</title> 429 - {/* Invisible wider hitbox for easier clicking */} 430 - <line x1={sourceX} y1={y1} x2={sourceX} y2={y2} stroke="transparent" strokeWidth={16} /> 431 - {/* Visible dotted line */} 432 - <line 433 - x1={sourceX} 434 - y1={y1} 435 - x2={sourceX} 436 - y2={y2} 437 - stroke={sourceColor} 438 - strokeWidth={strokeWidth} 439 - strokeOpacity={0.7} 440 - strokeDasharray="3 6" 441 - strokeLinecap="round" 442 - className="group-hover:[stroke-width:3] group-hover:[stroke-opacity:1] transition-[stroke-width,stroke-opacity] duration-150" 443 - data-edge-type="collapsed-stack" 444 - data-stack-id={collapsedStackId} 445 - data-source-revision={sourceRevision.change_id} 446 - data-target-revision={targetRevision?.change_id} 447 - /> 448 - </g> 449 - ); 450 - } 451 - 452 - // For expanded stacks, make the edge clickable to collapse 453 - if (expandedStackId) { 454 - const expandedLabel = `Click to collapse stack`; 455 - // Use full stack bounds if available, otherwise fall back to node bounds 456 - const hitboxY1 = stackTopY !== undefined ? stackTopY : y1; 457 - const hitboxY2 = stackBottomY !== undefined ? stackBottomY : y2; 458 - 459 - // Apply hover styling reactively based on atom state 460 - const hoverStrokeWidth = isStackHovered ? 3 : strokeWidth; 461 - const hoverStrokeOpacity = isStackHovered ? 1 : strokeOpacity; 462 - 463 - return ( 464 - <g 465 - aria-label={expandedLabel} 466 - className="cursor-pointer" 467 - style={{ pointerEvents: "auto" }} 468 - onClick={() => onToggleStack?.(expandedStackId)} 469 - onMouseEnter={() => onHoverStack(expandedStackId)} 470 - onMouseLeave={() => onHoverStack(null)} 471 - > 472 - <title>{expandedLabel}</title> 473 - {/* Invisible wider hitbox covering full stack height */} 474 - <line 475 - x1={sourceX} 476 - y1={hitboxY1} 477 - x2={targetX} 478 - y2={hitboxY2} 479 - stroke="transparent" 480 - strokeWidth={16} 481 - /> 482 - <line 483 - x1={sourceX} 484 - y1={y1} 485 - x2={targetX} 486 - y2={y2} 487 - stroke={isDeemphasized ? strokeColor : sourceColor} 488 - strokeWidth={hoverStrokeWidth} 489 - strokeOpacity={hoverStrokeOpacity} 490 - strokeDasharray={isDashed ? "4 4" : undefined} 491 - className="transition-[stroke-width,stroke-opacity] duration-150" 492 - data-edge-type={edgeType} 493 - data-source-revision={sourceRevision.change_id} 494 - data-target-revision={targetRevision?.change_id} 495 - /> 496 - </g> 497 - ); 498 - } 499 - 500 - return ( 501 - <g aria-label={ariaLabel} style={{ pointerEvents: "none" }}> 502 - <title>{ariaLabel}</title> 503 - <line 504 - x1={sourceX} 505 - y1={y1} 506 - x2={targetX} 507 - y2={y2} 508 - stroke={isDeemphasized ? strokeColor : sourceColor} 509 - strokeWidth={strokeWidth} 510 - strokeOpacity={strokeOpacity} 511 - strokeDasharray={isDashed ? "4 4" : undefined} 512 - data-edge-type={edgeType} 513 - data-source-revision={sourceRevision.change_id} 514 - data-target-revision={targetRevision?.change_id} 515 - /> 516 - </g> 517 - ); 518 - } 519 - 520 - // Cross-lane: horizontal from source, curve down into target's lane 521 - const goingRight = targetX > sourceX; 522 - const arcRadius = 10; 523 - 524 - return ( 525 - <g aria-label={ariaLabel} style={{ pointerEvents: "none" }}> 526 - <title>{ariaLabel}</title> 527 - <path 528 - d={`M ${sourceX} ${sourceY + NODE_RADIUS} 529 - L ${targetX - arcRadius * (goingRight ? 1 : -1)} ${sourceY + NODE_RADIUS} 530 - Q ${targetX} ${sourceY + NODE_RADIUS} ${targetX} ${sourceY + NODE_RADIUS + arcRadius} 531 - L ${targetX} ${targetY - NODE_RADIUS}`} 532 - fill="none" 533 - stroke={strokeColor} 534 - strokeWidth={strokeWidth} 535 - strokeOpacity={strokeOpacity} 536 - strokeDasharray={isDashed ? "4 4" : undefined} 537 - data-edge-type={edgeType} 538 - data-source-revision={sourceRevision.change_id} 539 - data-target-revision={targetRevision?.change_id} 540 - /> 541 - </g> 542 - ); 543 - } 544 - 545 - interface EdgeLayerProps { 546 - bindings: EdgeBinding[]; 547 - revisionMap: Map<string, Revision>; 548 - getRowCenter: (row: number) => number; 549 - commitToRow: Map<string, number>; 550 - totalHeight: number; 551 - width: number; 552 - visibleStartRow: number; 553 - visibleEndRow: number; 554 - stackById: Map<string, RevisionStack>; 555 - changeIdToCommitId: Map<string, string>; 556 - onToggleStack?: (stackId: string) => void; 557 - } 558 - 559 - // EdgeLayer - Renders all semantic edge components 560 - function EdgeLayer({ 561 - bindings, 562 - revisionMap, 563 - getRowCenter, 564 - commitToRow, 565 - totalHeight, 566 - width, 567 - visibleStartRow, 568 - visibleEndRow, 569 - stackById, 570 - changeIdToCommitId, 571 - onToggleStack, 572 - }: EdgeLayerProps) { 573 - // Use atom for hover state - automatically syncs with stack toggling and view mode changes 574 - const [hoveredStackId, setHoveredStackId] = useAtom(hoveredStackIdAtom); 575 - 576 - // Add overscan for edges that might span across viewport boundary 577 - // Use larger overscan to handle collapsed stack edges that span many rows 578 - const overscan = 15; 579 - const startRow = Math.max(0, visibleStartRow - overscan); 580 - const endRow = visibleEndRow + overscan; 581 - 582 - // Filter bindings to those visible in the viewport 583 - const visibleBindings = bindings.filter((binding) => { 584 - const sourceRow = commitToRow.get(binding.sourceRevisionId); 585 - const targetRow = commitToRow.get(binding.targetRevisionId); 586 - if (sourceRow === undefined) return false; 587 - 588 - // For missing stubs, just check if source is near visible range 589 - if (binding.isMissingStub) { 590 - return sourceRow >= startRow && sourceRow <= endRow; 591 - } 592 - 593 - if (targetRow === undefined) return false; 594 - 595 - // Check if edge passes through visible area 596 - const minRow = Math.min(sourceRow, targetRow); 597 - const maxRow = Math.max(sourceRow, targetRow); 598 - return maxRow >= startRow && minRow <= endRow; 599 - }); 600 - 601 - return ( 602 - <svg 603 - width={width} 604 - height={totalHeight} 605 - className="shrink-0 absolute top-0 left-0 z-20" 606 - role="img" 607 - aria-label="Revision connections" 608 - > 609 - <title>Revision graph edges</title> 610 - {visibleBindings.map((binding) => { 611 - const sourceRow = commitToRow.get(binding.sourceRevisionId); 612 - const targetRow = binding.isMissingStub 613 - ? sourceRow !== undefined 614 - ? sourceRow + 1 615 - : undefined 616 - : commitToRow.get(binding.targetRevisionId); 617 - 618 - if (sourceRow === undefined) return null; 619 - 620 - const sourceRevision = revisionMap.get(binding.sourceRevisionId); 621 - const targetRevision = binding.targetRevisionId 622 - ? (revisionMap.get(binding.targetRevisionId) ?? null) 623 - : null; 624 - 625 - if (!sourceRevision) return null; 626 - 627 - // For expanded stacks, calculate full stack bounds 628 - let stackTopY: number | undefined; 629 - let stackBottomY: number | undefined; 630 - if (binding.expandedStackId) { 631 - const stack = stackById.get(binding.expandedStackId); 632 - if (stack) { 633 - const topCommitId = changeIdToCommitId.get(stack.topChangeId); 634 - const bottomCommitId = changeIdToCommitId.get(stack.bottomChangeId); 635 - const topRow = topCommitId ? commitToRow.get(topCommitId) : undefined; 636 - const bottomRow = bottomCommitId ? commitToRow.get(bottomCommitId) : undefined; 637 - if (topRow !== undefined && bottomRow !== undefined) { 638 - stackTopY = getRowCenter(topRow) - NODE_RADIUS; 639 - stackBottomY = getRowCenter(bottomRow) + NODE_RADIUS; 640 - } 641 - } 642 - } 643 - 644 - // Use a key that captures the edge's structural identity: 645 - // source, target (which changes when collapsed), and stack state 646 - const edgeKey = `${binding.sourceRevisionId}->${binding.targetRevisionId}:${binding.collapsedStackId ?? binding.expandedStackId ?? "none"}`; 647 - 648 - return ( 649 - <GraphEdge 650 - key={edgeKey} 651 - binding={binding} 652 - sourceY={getRowCenter(sourceRow)} 653 - targetY={ 654 - targetRow !== undefined 655 - ? getRowCenter(targetRow) 656 - : getRowCenter(sourceRow) + ROW_HEIGHT 657 - } 658 - sourceRevision={sourceRevision} 659 - targetRevision={targetRevision} 660 - stackTopY={stackTopY} 661 - stackBottomY={stackBottomY} 662 - hoveredStackId={hoveredStackId} 663 - onHoverStack={setHoveredStackId} 664 - onToggleStack={onToggleStack} 665 - /> 666 - ); 667 - })} 668 - </svg> 669 - ); 670 - } 671 - 672 - // ============================================================================ 673 - // End of Semantic Graph Components 674 - // ============================================================================ 675 - 676 - interface ParentConnection { 677 - parentRow: number; 678 - parentLane: number; 679 - edgeType: GraphEdgeType; 680 - isDeemphasized?: boolean; 681 - isMissingStub?: boolean; 682 - } 683 - 684 - interface GraphNode { 685 - revision: Revision; 686 - row: number; 687 - lane: number; 688 - parentConnections: ParentConnection[]; 689 - } 690 - 691 - interface GraphRow { 692 - revision: Revision; 693 - lane: number; 694 - maxLaneOnRow: number; // Rightmost lane occupied by any graph element (node or edge) on this row 695 - } 696 - 697 - interface GraphData { 698 - nodes: GraphNode[]; 699 - laneCount: number; 700 - rows: GraphRow[]; 701 - edgeBindings: EdgeBinding[]; 702 - } 703 - 704 - // Get the set of commit IDs in the working copy's ancestor chain (for lane 0) 705 - function getWorkingCopyChain(revisions: Revision[]): Set<string> { 706 - const commitMap = new Map(revisions.map((r) => [r.commit_id, r])); 707 - const workingCopy = revisions.find((r) => r.is_working_copy); 708 - const chain = new Set<string>(); 709 - 710 - if (workingCopy) { 711 - const queue = [workingCopy.commit_id]; 712 - while (queue.length > 0) { 713 - const id = queue.shift(); 714 - if (!id || chain.has(id)) continue; 715 - chain.add(id); 716 - const rev = commitMap.get(id); 717 - if (rev) { 718 - // Follow first non-missing parent edge for the main chain 719 - const firstEdge = rev.parent_edges.find((e) => e.edge_type !== "missing"); 720 - if (firstEdge && commitMap.has(firstEdge.parent_id)) { 721 - queue.push(firstEdge.parent_id); 722 - } 723 - } 724 - } 725 - } 726 - 727 - return chain; 728 - } 729 - 730 - function buildGraph(revisions: Revision[]): GraphData { 731 - if (revisions.length === 0) return { nodes: [], laneCount: 1, rows: [], edgeBindings: [] }; 732 - 733 - // Map commit_id -> Revision for ancestry lookups 734 - const commitMap = new Map(revisions.map((r) => [r.commit_id, r])); 735 - 736 - // Compute ancestry relationships within the visible revset 737 - // This determines which revisions are actually related and should be connected 738 - const ancestry = computeRevisionAncestry(revisions); 739 - 740 - // Create rows for all revisions (no elision) 741 - const orderedRevisions = reorderForGraph(revisions); 742 - const rows: GraphRow[] = orderedRevisions.map((rev) => ({ 743 - revision: rev, 744 - lane: 0, 745 - maxLaneOnRow: 0, 746 - })); 747 - 748 - // Get working copy chain - these commits should all be in lane 0 749 - const workingCopyChain = getWorkingCopyChain(revisions); 750 - 751 - // Build row index map 752 - const commitToRow = new Map<string, number>(); 753 - rows.forEach((row, idx) => { 754 - commitToRow.set(row.revision.commit_id, idx); 755 - }); 756 - 757 - const commitToLane = new Map<string, number>(); 758 - const nodes: GraphNode[] = []; 759 - 760 - // Simple 2-lane system: 761 - // Lane 0: trunk commits and working copy chain 762 - // Lane 1: everything else (all feature branches) 763 - for (const rev of orderedRevisions) { 764 - const isOnWorkingCopyChain = workingCopyChain.has(rev.commit_id); 765 - if (rev.is_trunk || isOnWorkingCopyChain) { 766 - commitToLane.set(rev.commit_id, 0); 767 - } else { 768 - commitToLane.set(rev.commit_id, 1); 769 - } 770 - } 771 - 772 - // Second pass: build nodes with parent connections 773 - for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { 774 - const row = rows[rowIdx]; 775 - const revision = row.revision; 776 - const lane = commitToLane.get(revision.commit_id) ?? 0; 777 - 778 - const parentConnections: ParentConnection[] = []; 779 - 780 - // Use ancestry.parents which only includes parents within the visible revset 781 - // This ensures we only draw edges to actual ancestors, not to unrelated revisions 782 - const visibleParents = ancestry.parents.get(revision.commit_id) ?? []; 783 - 784 - // Also check original parent_edges for edge type info and missing edges 785 - const parentEdgeMap = new Map(revision.parent_edges.map((e) => [e.parent_id, e])); 786 - 787 - // Detect "main merges into branch" scenario 788 - const isMerge = visibleParents.length > 1; 789 - const isMutableCommit = !revision.is_immutable; 790 - 791 - // Process visible parents (ancestors within our revset) 792 - for (let i = 0; i < visibleParents.length; i++) { 793 - const parentId = visibleParents[i]; 794 - const parentEdge = parentEdgeMap.get(parentId); 795 - const edgeType = parentEdge?.edge_type ?? "direct"; 796 - 797 - const parentRow = commitToRow.get(parentId); 798 - if (parentRow === undefined) continue; 799 - 800 - let parentLane = commitToLane.get(parentId); 801 - if (parentLane === undefined) { 802 - // This shouldn't happen after first pass, but handle gracefully 803 - parentLane = lane; 804 - commitToLane.set(parentId, parentLane); 805 - } 806 - 807 - // Check if parent is immutable (look up the actual parent revision) 808 - const parentRev = commitMap.get(parentId); 809 - const parentIsImmutable = parentRev?.is_immutable ?? false; 810 - 811 - // De-emphasize if: merge commit, mutable commit, immutable parent 812 - // IMPORTANT: Don't de-emphasize first parent (i === 0) - that's the mainline 813 - const isDeemphasized = isMerge && isMutableCommit && parentIsImmutable && i > 0; 814 - 815 - parentConnections.push({ parentRow, parentLane, edgeType, isDeemphasized }); 816 - } 817 - 818 - // Handle missing edges (parents outside our revset) 819 - // Only show stub if we have original parents but no visible parents 820 - const hasMissingParents = revision.parent_edges.some((e) => e.edge_type === "missing"); 821 - const hasParentsOutsideView = revision.parent_ids.length > visibleParents.length; 822 - 823 - if ((hasMissingParents || hasParentsOutsideView) && parentConnections.length === 0) { 824 - // All parents are outside the view - show a stub 825 - parentConnections.push({ 826 - parentRow: rowIdx + 1, // Just one row down for the stub 827 - parentLane: lane, 828 - edgeType: "missing", 829 - isMissingStub: true, 830 - }); 831 - } else if (hasMissingParents && parentConnections.length > 0) { 832 - // Some parents are visible, some are missing - add stub for missing ones 833 - parentConnections.push({ 834 - parentRow: rowIdx + 1, 835 - parentLane: lane, 836 - edgeType: "missing", 837 - isMissingStub: true, 838 - }); 839 - } 840 - 841 - nodes.push({ 842 - revision, 843 - row: rowIdx, 844 - lane, 845 - parentConnections, 846 - }); 847 - } 848 - 849 - // Update rows with computed lane info from nodes 850 - let maxLaneUsed = 0; 851 - for (const node of nodes) { 852 - const row = rows[node.row]; 853 - if (row) { 854 - row.lane = node.lane; 855 - row.maxLaneOnRow = node.lane; // Initialize with node's lane 856 - } 857 - maxLaneUsed = Math.max(maxLaneUsed, node.lane); 858 - } 859 - 860 - // Calculate maxLaneOnRow using sweep line algorithm O(n log n) instead of O(n³) 861 - // Collect edge spans as events for efficient processing 862 - type SpanEvent = { row: number; isStart: boolean; lane: number }; 863 - const events: SpanEvent[] = []; 864 - 865 - for (const node of nodes) { 866 - for (const conn of node.parentConnections) { 867 - const nodeRow = node.row; 868 - const parentRow = conn.parentRow; 869 - const nodeLane = node.lane; 870 - const parentLane = conn.parentLane; 871 - 872 - // Cross-lane edge: horizontal segment at node's row uses both lanes 873 - if (nodeLane !== parentLane) { 874 - const row = rows[nodeRow]; 875 - if (row) { 876 - row.maxLaneOnRow = Math.max(row.maxLaneOnRow, nodeLane, parentLane); 877 - } 878 - } 879 - 880 - // Vertical segment: create start/end events instead of iterating rows 881 - const minRow = Math.min(nodeRow, parentRow); 882 - const maxRow = Math.max(nodeRow, parentRow); 883 - if (maxRow > minRow + 1) { 884 - // Edge spans rows [minRow+1, maxRow-1] inclusive 885 - events.push({ row: minRow + 1, isStart: true, lane: parentLane }); 886 - events.push({ row: maxRow, isStart: false, lane: parentLane }); 887 - } 888 - } 889 - } 890 - 891 - // Sort events: by row, with starts before ends at same row 892 - events.sort((a, b) => a.row - b.row || (a.isStart ? -1 : 1)); 893 - 894 - // Sweep through rows, tracking active lane counts 895 - const laneCounts = new Array(MAX_LANES).fill(0); 896 - let eventIdx = 0; 897 - 898 - for (let r = 0; r < rows.length; r++) { 899 - // Process all events at this row 900 - while (eventIdx < events.length && events[eventIdx].row === r) { 901 - const { isStart, lane } = events[eventIdx]; 902 - laneCounts[lane] += isStart ? 1 : -1; 903 - eventIdx++; 904 - } 905 - 906 - // Find max active lane for this row (check from highest lane down) 907 - for (let lane = MAX_LANES - 1; lane >= 0; lane--) { 908 - if (laneCounts[lane] > 0) { 909 - rows[r].maxLaneOnRow = Math.max(rows[r].maxLaneOnRow, lane); 910 - break; 911 - } 912 - } 913 - } 914 - 915 - // Ensure global consistency - propagate lane usage through connected sections 916 - // This handles cases where disconnected branches exist 917 - const globalMaxLane = maxLaneUsed; 918 - for (const row of rows) { 919 - // Ensure every row accounts for at least its own node's lane 920 - row.maxLaneOnRow = Math.max(row.maxLaneOnRow, row.lane); 921 - } 922 - 923 - // Generate semantic edge bindings from nodes' parent connections 924 - const edgeBindings: EdgeBinding[] = []; 925 - let edgeCounter = 0; 926 - 927 - for (const node of nodes) { 928 - for (const conn of node.parentConnections) { 929 - // For missing stubs, use commit_id of source and empty target 930 - const targetCommitId = conn.isMissingStub 931 - ? "" 932 - : (rows[conn.parentRow]?.revision.commit_id ?? ""); 933 - 934 - edgeBindings.push({ 935 - id: `edge-${node.revision.commit_id}-${edgeCounter++}`, 936 - sourceRevisionId: node.revision.commit_id, 937 - targetRevisionId: targetCommitId, 938 - sourceLane: node.lane, 939 - targetLane: conn.parentLane, 940 - edgeType: conn.edgeType, 941 - isDeemphasized: conn.isDeemphasized, 942 - isMissingStub: conn.isMissingStub, 943 - }); 944 - } 945 - } 946 - 947 - return { nodes, laneCount: globalMaxLane + 1, rows, edgeBindings }; 948 - } 949 - 950 - function laneToX(lane: number): number { 951 - return LANE_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2; 952 - } 953 - 954 - function laneColor(lane: number): string { 955 - return LANE_COLORS[lane % LANE_COLORS.length]; 956 - } 957 - 958 - function RevisionRow({ 959 - revision, 960 - lane, 961 - maxLaneOnRow, 962 - isSelected, 963 - isChecked, 964 - onSelect, 965 - isFlashing, 966 - isDimmed, 967 - isExpanded, 968 - isFocused, 969 - repoPath, 970 - isPendingAbandon, 971 - jumpHint, 972 - jumpModeActive, 973 - jumpQuery, 974 - }: { 975 - revision: Revision; 976 - lane: number; 977 - maxLaneOnRow: number; 978 - isSelected: boolean; 979 - isChecked: boolean; 980 - onSelect: (changeId: string, modifiers: { shift: boolean; meta: boolean }) => void; 981 - isFlashing: boolean; 982 - isDimmed: boolean; 983 - isExpanded: boolean; 984 - isFocused: boolean; 985 - repoPath: string | null; 986 - isPendingAbandon: boolean; 987 - jumpHint: string | null; 988 - jumpModeActive: boolean; 989 - jumpQuery: string; 990 - }) { 991 - const firstLine = revision.description.split("\n")[0] || "(no description)"; 992 - const fullDescription = revision.description || "(no description)"; 993 - 994 - // Calculate the node position area - leaves space for graph edges on the left 995 - const nodeAreaWidth = LANE_PADDING + (maxLaneOnRow + 1) * LANE_WIDTH; 996 - const nodeOffset = laneToX(lane); 997 - const color = laneColor(lane); 998 - 999 - const selectedFile = useSearch({ strict: false, select: (s) => s.file ?? null }); 1000 - const search = useSearch({ strict: false }); 1001 - const navigate = useNavigate(); 1002 - 1003 - const changedFilesCollection = 1004 - isExpanded && repoPath 1005 - ? getRevisionChangesCollection(repoPath, revision.change_id) 1006 - : emptyChangesCollection; 1007 - const changedFilesQuery = useLiveQuery(changedFilesCollection); 1008 - 1009 - function handleSelectFile(filePath: string) { 1010 - navigate({ 1011 - // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 1012 - search: { ...search, file: filePath } as any, 1013 - }); 1014 - } 1015 - 1016 - // Constants matching edge layer calculations 1017 - const TOP_PADDING = 16; 1018 - const CONTENT_MIN_HEIGHT = 56; 1019 - const nodeSize = revision.is_working_copy ? NODE_RADIUS * 2 + 14 : NODE_RADIUS * 2 + 8; 1020 - 1021 - return ( 1022 - <div style={{ height: isExpanded ? "auto" : ROW_HEIGHT }} className="flex flex-col relative"> 1023 - {/* Graph node - absolutely positioned to align with edge layer */} 1024 - <div 1025 - className="absolute z-20 flex items-center justify-center" 1026 - style={{ 1027 - left: nodeOffset - nodeSize / 2, 1028 - top: TOP_PADDING + CONTENT_MIN_HEIGHT / 2 - nodeSize / 2, 1029 - }} 1030 - > 1031 - <GraphNode revision={revision} lane={lane} isSelected={isSelected} color={color} /> 1032 - </div> 1033 - <div className="flex items-start min-h-[56px] pt-4"> 1034 - {/* Spacer for graph area */} 1035 - <div className="shrink-0" style={{ width: nodeAreaWidth }} /> 1036 - <div 1037 - className={`relative flex-1 mr-2 min-w-0 overflow-hidden rounded my-2 mx-1 select-none border ${ 1038 - isFocused || isChecked 1039 - ? "bg-accent/40 border-accent/60 hover:bg-accent/50" 1040 - : "bg-card hover:bg-muted border-border" 1041 - } text-card-foreground shadow-sm hover:shadow hover:cursor-pointer ${ 1042 - revision.is_immutable ? "opacity-60" : "" 1043 - } ${isDimmed ? "opacity-40" : ""}`} 1044 - data-focused={isFocused || undefined} 1045 - data-selected={isSelected || undefined} 1046 - data-checked={isChecked || undefined} 1047 - data-expanded={isExpanded || undefined} 1048 - data-change-id={revision.change_id} 1049 - onClick={(e) => { 1050 - // Prevent text selection on shift+click 1051 - if (e.shiftKey) { 1052 - e.preventDefault(); 1053 - window.getSelection()?.removeAllRanges(); 1054 - } 1055 - onSelect(revision.change_id, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 1056 - }} 1057 - > 1058 - <div className={`px-3 py-2 min-w-0 ${isPendingAbandon ? "blur-sm" : ""}`}> 1059 - <div className="flex items-center gap-2 flex-nowrap min-w-0"> 1060 - <code 1061 - className={`text-xs font-mono rounded px-0.5 shrink-0 ${ 1062 - isFlashing ? "bg-primary/40 animate-pulse" : "" 1063 - } text-muted-foreground`} 1064 - > 1065 - {jumpModeActive && jumpHint ? ( 1066 - <> 1067 - {/* Already matched portion */} 1068 - {jumpQuery && ( 1069 - <span className="bg-primary/30 text-primary font-semibold"> 1070 - {revision.change_id_short.slice(0, jumpQuery.length)} 1071 - </span> 1072 - )} 1073 - {/* Next character to type (the hint) */} 1074 - <span className="bg-primary text-primary-foreground font-semibold rounded-sm"> 1075 - {revision.change_id_short[jumpQuery.length]} 1076 - </span> 1077 - {/* Rest of the ID */} 1078 - <span>{revision.change_id_short.slice(jumpQuery.length + 1)}</span> 1079 - </> 1080 - ) : ( 1081 - revision.change_id_short 1082 - )} 1083 - </code> 1084 - {revision.bookmarks.length > 0 && ( 1085 - <span 1086 - className="text-xs text-primary font-medium truncate min-w-0 whitespace-nowrap" 1087 - title={revision.bookmarks.join(", ")} 1088 - > 1089 - {revision.bookmarks.join(", ")} 1090 - </span> 1091 - )} 1092 - <span className="text-xs text-muted-foreground truncate min-w-0 shrink-0"> 1093 - {revision.author.split("@")[0]} · {revision.timestamp} 1094 - </span> 1095 - </div> 1096 - <div className={`text-sm mt-1 ${isExpanded ? "" : "truncate"}`}>{firstLine}</div> 1097 - </div> 1098 - {isExpanded && ( 1099 - <div className={`px-3 pb-3 pt-0 space-y-3 ${isPendingAbandon ? "blur-sm" : ""}`}> 1100 - <pre className="text-xs text-muted-foreground whitespace-pre-wrap break-words font-mono bg-muted/40 border border-border/60 rounded p-2"> 1101 - {fullDescription} 1102 - </pre> 1103 - <div className="border border-border rounded-lg overflow-hidden bg-background"> 1104 - <ChangedFilesList 1105 - files={changedFilesQuery.data ?? []} 1106 - selectedFile={selectedFile} 1107 - onSelectFile={handleSelectFile} 1108 - isLoading={changedFilesQuery.isLoading} 1109 - /> 1110 - </div> 1111 - </div> 1112 - )} 1113 - {isPendingAbandon && ( 1114 - <div className="absolute inset-0 flex items-center justify-center bg-destructive/10 rounded"> 1115 - <div className="text-sm font-medium text-destructive-foreground bg-destructive/90 px-3 py-1.5 rounded"> 1116 - Abandon this revision? <kbd className="ml-1 px-1 bg-background/20 rounded">Y</kbd> /{" "} 1117 - <kbd className="px-1 bg-background/20 rounded">N</kbd> 1118 - </div> 1119 - </div> 1120 - )} 1121 - </div> 1122 - </div> 1123 - </div> 1124 - ); 1125 - } 1126 - 1127 - // Compute related revisions (ancestors + descendants) of a selected revision 1128 - function getRelatedRevisions(revisions: Revision[], selectedChangeId: string | null): Set<string> { 1129 - if (!selectedChangeId) return new Set(); 1130 - 1131 - const related = new Set<string>(); 1132 - const commitIdToChangeId = new Map<string, string>(); 1133 - const changeIdToCommitId = new Map<string, string>(); 1134 - const childrenMap = new Map<string, string[]>(); // commit_id -> child commit_ids 1135 - const parentMap = new Map<string, string[]>(); // commit_id -> parent commit_ids 1136 - 1137 - // Build maps 1138 - for (const rev of revisions) { 1139 - commitIdToChangeId.set(rev.commit_id, rev.change_id); 1140 - changeIdToCommitId.set(rev.change_id, rev.commit_id); 1141 - const parents: string[] = []; 1142 - for (const edge of rev.parent_edges) { 1143 - if (edge.edge_type === "missing") continue; 1144 - parents.push(edge.parent_id); 1145 - const children = childrenMap.get(edge.parent_id) ?? []; 1146 - children.push(rev.commit_id); 1147 - childrenMap.set(edge.parent_id, children); 1148 - } 1149 - parentMap.set(rev.commit_id, parents); 1150 - } 1151 - 1152 - const selectedCommitId = changeIdToCommitId.get(selectedChangeId); 1153 - if (!selectedCommitId) return new Set(); 1154 - 1155 - // BFS to find ancestors 1156 - const ancestorQueue = [selectedCommitId]; 1157 - const visited = new Set<string>(); 1158 - while (ancestorQueue.length > 0) { 1159 - const id = ancestorQueue.shift()!; 1160 - if (visited.has(id)) continue; 1161 - visited.add(id); 1162 - const changeId = commitIdToChangeId.get(id); 1163 - if (changeId) related.add(changeId); 1164 - const parents = parentMap.get(id) ?? []; 1165 - for (const parentId of parents) { 1166 - ancestorQueue.push(parentId); 1167 - } 1168 - } 1169 - 1170 - // BFS to find descendants 1171 - const descendantQueue = [selectedCommitId]; 1172 - visited.clear(); 1173 - while (descendantQueue.length > 0) { 1174 - const id = descendantQueue.shift()!; 1175 - if (visited.has(id)) continue; 1176 - visited.add(id); 1177 - const changeId = commitIdToChangeId.get(id); 1178 - if (changeId) related.add(changeId); 1179 - const children = childrenMap.get(id) ?? []; 1180 - for (const childId of children) { 1181 - descendantQueue.push(childId); 1182 - } 1183 - } 1184 - 1185 - return related; 1186 - } 1187 - 1188 - export const RevisionGraph = forwardRef<RevisionGraphHandle, RevisionGraphProps>( 1189 - function RevisionGraph( 1190 - { revisions, selectedRevision, onSelectRevision, isLoading, flash, repoPath, pendingAbandon }, 1191 - ref, 1192 - ) { 1193 - const parentRef = useRef<HTMLDivElement>(null); 1194 - const { 1195 - nodes, 1196 - laneCount, 1197 - rows: allRows, 1198 - edgeBindings, 1199 - } = useMemo(() => buildGraph(revisions), [revisions]); 1200 - const expanded = useSearch({ strict: false, select: (s) => s.expanded }); 1201 - const search = useSearch({ strict: false }); 1202 - const navigate = useNavigate(); 1203 - const [inlineJumpQuery, setInlineJumpQuery] = useAtom(inlineJumpQueryAtom); 1204 - const inlineJumpMode = inlineJumpQuery !== null; 1205 - const [viewMode] = useAtom(viewModeAtom); 1206 - const [, setFocusPanel] = useAtom(focusPanelAtom); 1207 - 1208 - // Detect collapsible stacks 1209 - const stacks = useMemo(() => detectStacks(revisions), [revisions]); 1210 - 1211 - // Prefetch diffs for all revisions in background 1212 - // This eagerly creates TanStack DB collections which trigger async fetches 1213 - useMemo(() => { 1214 - if (repoPath && revisions.length > 0) { 1215 - const changeIds = revisions.map((r) => r.change_id); 1216 - prefetchRevisionDiffs(repoPath, changeIds); 1217 - } 1218 - }, [repoPath, revisions]); 1219 - 1220 - // Track which stacks are expanded (empty = all collapsed by default) 1221 - const [expandedStacks, setExpandedStacks] = useAtom(expandedStacksAtom); 1222 - // Track hovered stack for coordinated edge highlighting 1223 - const [, setHoveredStackId] = useAtom(hoveredStackIdAtom); 1224 - 1225 - // Read focused stack and selection from URL params 1226 - const focusedStackId = useSearch({ strict: false, select: (s) => s.stack ?? null }); 1227 - const selectedParam = useSearch({ strict: false, select: (s) => s.selected ?? "" }); 1228 - const selectionAnchor = useSearch({ strict: false, select: (s) => s.selectionAnchor ?? null }); 1229 - const selectedRevisions = useMemo(() => { 1230 - if (!selectedParam) return new Set<string>(); 1231 - return new Set(selectedParam.split(",").filter(Boolean)); 1232 - }, [selectedParam]); 1233 - const hasSelection = selectedRevisions.size > 0; 1234 - 1235 - // Update URL with new selection 1236 - function setSelectedRevisions(updater: Set<string> | ((prev: Set<string>) => Set<string>)) { 1237 - const newSelection = typeof updater === "function" ? updater(selectedRevisions) : updater; 1238 - const selected = newSelection.size > 0 ? [...newSelection].join(",") : undefined; 1239 - navigate({ 1240 - search: { ...search, selected } as any, 1241 - replace: true, 1242 - }); 1243 - } 1244 - 1245 - // Toggle a revision's checked state 1246 - function toggleRevisionCheck(changeId: string) { 1247 - const next = new Set(selectedRevisions); 1248 - if (next.has(changeId)) { 1249 - next.delete(changeId); 1250 - } else { 1251 - next.add(changeId); 1252 - } 1253 - setSelectedRevisions(next); 1254 - } 1255 - 1256 - // Clear selection when Escape is pressed (when not in jump mode) 1257 - useKeyboardShortcut({ 1258 - key: "Escape", 1259 - modifiers: {}, 1260 - onPress: () => { 1261 - navigate({ 1262 - search: { 1263 - ...search, 1264 - selected: undefined, 1265 - selectionAnchor: undefined, 1266 - stack: undefined, 1267 - } as any, 1268 - replace: true, 1269 - }); 1270 - }, 1271 - enabled: (hasSelection || !!focusedStackId) && !inlineJumpMode, 1272 - }); 1273 - 1274 - // Build lookup maps for stacks 1275 - const { stackByChangeId, stackById, intermediateChangeIds } = useMemo(() => { 1276 - const byChangeId = new Map<string, RevisionStack>(); 1277 - const byId = new Map<string, RevisionStack>(); 1278 - const intermediates = new Set<string>(); 1279 - 1280 - for (const stack of stacks) { 1281 - byId.set(stack.id, stack); 1282 - for (const changeId of stack.changeIds) { 1283 - byChangeId.set(changeId, stack); 1284 - } 1285 - for (const changeId of stack.intermediateChangeIds) { 1286 - intermediates.add(changeId); 1287 - } 1288 - } 1289 - return { stackByChangeId: byChangeId, stackById: byId, intermediateChangeIds: intermediates }; 1290 - }, [stacks]); 1291 - 1292 - // Build node lane lookup by change_id (needed for display row construction) 1293 - const changeIdToLane = useMemo(() => { 1294 - const map = new Map<string, number>(); 1295 - for (const node of nodes) { 1296 - map.set(node.revision.change_id, node.lane); 1297 - } 1298 - return map; 1299 - }, [nodes]); 1300 - 1301 - // Display row can be either a revision row or a collapsed stack row 1302 - type DisplayRow = 1303 - | { type: "revision"; row: GraphRow } 1304 - | { type: "collapsed-stack"; stack: RevisionStack; lane: number }; 1305 - 1306 - // Filter rows to hide collapsed intermediate revisions and replace with a single collapsed stack row 1307 - const displayRows = useMemo(() => { 1308 - const result: DisplayRow[] = []; 1309 - 1310 - for (const row of allRows) { 1311 - const changeId = row.revision.change_id; 1312 - const stack = stackByChangeId.get(changeId); 1313 - 1314 - if (stack && intermediateChangeIds.has(changeId)) { 1315 - // This is an intermediate revision in a stack 1316 - if (expandedStacks.has(stack.id)) { 1317 - // Stack is expanded - show the revision 1318 - result.push({ type: "revision", row }); 1319 - } 1320 - // If collapsed, skip this row 1321 - } else { 1322 - // Not an intermediate or not in a stack - always show 1323 - result.push({ type: "revision", row }); 1324 - 1325 - // If this is the top of a collapsed stack, insert a collapsed stack row after it 1326 - if (stack && changeId === stack.topChangeId && !expandedStacks.has(stack.id)) { 1327 - const lane = changeIdToLane.get(changeId) ?? 0; 1328 - result.push({ type: "collapsed-stack", stack, lane }); 1329 - } 1330 - } 1331 - } 1332 - 1333 - return result; 1334 - }, [allRows, stackByChangeId, intermediateChangeIds, expandedStacks, changeIdToLane]); 1335 - 1336 - // Extract just revision rows for edge positioning and other logic 1337 - const rows = useMemo( 1338 - () => 1339 - displayRows 1340 - .filter((d): d is { type: "revision"; row: GraphRow } => d.type === "revision") 1341 - .map((d) => d.row), 1342 - [displayRows], 1343 - ); 1344 - 1345 - // Toggle stack expansion 1346 - function toggleStackExpansion(stackId: string) { 1347 - // Clear hover state since stack structure is changing 1348 - setHoveredStackId(null); 1349 - setExpandedStacks((prev) => { 1350 - const next = new Set(prev); 1351 - if (next.has(stackId)) { 1352 - next.delete(stackId); 1353 - } else { 1354 - next.add(stackId); 1355 - } 1356 - return next; 1357 - }); 1358 - } 1359 - 1360 - // Toggle stack expansion and focus the top of newly revealed revisions when expanding 1361 - function handleToggleStack(stackId: string) { 1362 - const stack = stackById.get(stackId); 1363 - const isCurrentlyExpanded = expandedStacks.has(stackId); 1364 - toggleStackExpansion(stackId); 1365 - 1366 - // If expanding (not currently expanded), focus the first intermediate revision 1367 - // (the top of the newly revealed revisions, not the already-visible top of the stack) 1368 - if (!isCurrentlyExpanded && stack && stack.intermediateChangeIds.length > 0) { 1369 - navigate({ 1370 - search: { 1371 - ...search, 1372 - stack: undefined, 1373 - rev: stack.intermediateChangeIds[0], 1374 - selected: undefined, 1375 - selectionAnchor: undefined, 1376 - } as any, 1377 - replace: true, 1378 - }); 1379 - } 1380 - } 1381 - 1382 - // Maps for lookups - by change_id for UI, by commit_id for graph edges 1383 - const revisionMapByChangeId = new Map(revisions.map((r) => [r.change_id, r])); 1384 - const revisionMapByCommitId = new Map(revisions.map((r) => [r.commit_id, r])); 1385 - 1386 - // Compute related revisions for dimming logic 1387 - // When a stack is focused, use the stack's top and bottom as the "selected" revisions 1388 - const focusedStack = focusedStackId ? stackById.get(focusedStackId) : null; 1389 - const relatedRevisions = useMemo(() => { 1390 - if (focusedStack) { 1391 - // When stack is focused, highlight the stack endpoints and their ancestors/descendants 1392 - const topRelated = getRelatedRevisions(revisions, focusedStack.topChangeId); 1393 - const bottomRelated = getRelatedRevisions(revisions, focusedStack.bottomChangeId); 1394 - // Union of both sets 1395 - return new Set([...topRelated, ...bottomRelated]); 1396 - } 1397 - return getRelatedRevisions(revisions, selectedRevision?.change_id ?? null); 1398 - }, [revisions, focusedStack, selectedRevision?.change_id]); 1399 - 1400 - // Build change_id -> displayRow index map for scrolling and edge positioning 1401 - // IMPORTANT: Use displayRows indices (not rows) to match virtualizer positioning 1402 - const changeIdToIndex = new Map<string, number>(); 1403 - const commitToRowIndex = new Map<string, number>(); 1404 - for (let i = 0; i < displayRows.length; i++) { 1405 - const displayRow = displayRows[i]; 1406 - if (displayRow.type === "revision") { 1407 - changeIdToIndex.set(displayRow.row.revision.change_id, i); 1408 - commitToRowIndex.set(displayRow.row.revision.commit_id, i); 1409 - } 1410 - } 1411 - 1412 - // Create a mapping of change_id -> commit_id for edge remapping 1413 - const changeIdToCommitId = useMemo(() => { 1414 - const map = new Map<string, string>(); 1415 - for (const rev of revisions) { 1416 - map.set(rev.change_id, rev.commit_id); 1417 - } 1418 - return map; 1419 - }, [revisions]); 1420 - 1421 - // Filter edge bindings to handle collapsed/expanded stacks 1422 - // When a stack is collapsed, edges from/to intermediates should be remapped 1423 - // When a stack is expanded, edges within it should be clickable to collapse 1424 - const filteredEdgeBindings = useMemo(() => { 1425 - // Maps for collapsed stacks: intermediate commits -> bottom commit 1426 - const hiddenToVisible = new Map<string, { targetCommitId: string; stack: RevisionStack }>(); 1427 - const topCommitToStack = new Map<string, RevisionStack>(); 1428 - // Map for expanded stacks: all commits in expanded stacks 1429 - const commitToExpandedStack = new Map<string, RevisionStack>(); 1430 - 1431 - for (const stack of stacks) { 1432 - const isExpanded = expandedStacks.has(stack.id); 1433 - 1434 - if (isExpanded) { 1435 - for (const changeId of stack.changeIds) { 1436 - const commitId = changeIdToCommitId.get(changeId); 1437 - if (commitId) commitToExpandedStack.set(commitId, stack); 1438 - } 1439 - } else { 1440 - const bottomCommitId = changeIdToCommitId.get(stack.bottomChangeId); 1441 - const topCommitId = changeIdToCommitId.get(stack.topChangeId); 1442 - if (!bottomCommitId || !topCommitId) continue; 1443 - 1444 - topCommitToStack.set(topCommitId, stack); 1445 - for (const intermediateChangeId of stack.intermediateChangeIds) { 1446 - const intermediateCommitId = changeIdToCommitId.get(intermediateChangeId); 1447 - if (intermediateCommitId) { 1448 - hiddenToVisible.set(intermediateCommitId, { targetCommitId: bottomCommitId, stack }); 1449 - } 1450 - } 1451 - } 1452 - } 1453 - 1454 - const remapped: EdgeBinding[] = []; 1455 - const seen = new Set<string>(); 1456 - 1457 - for (const binding of edgeBindings) { 1458 - const { sourceRevisionId, targetRevisionId } = binding; 1459 - const sourceExpandedStack = commitToExpandedStack.get(sourceRevisionId); 1460 - 1461 - // Skip hidden intermediates (unless in an expanded stack) 1462 - if (hiddenToVisible.has(sourceRevisionId) && !sourceExpandedStack) continue; 1463 - 1464 - let targetId = targetRevisionId; 1465 - let collapsedStackId: string | undefined; 1466 - let collapsedCount: number | undefined; 1467 - let expandedStackId: string | undefined; 1468 - 1469 - if (sourceExpandedStack) { 1470 - // Source is in expanded stack - check if edge is within same stack 1471 - const targetExpandedStack = commitToExpandedStack.get(targetRevisionId); 1472 - if (targetExpandedStack?.id === sourceExpandedStack.id) { 1473 - expandedStackId = sourceExpandedStack.id; 1474 - } 1475 - } else { 1476 - // Source not in expanded stack - apply collapsed stack remapping 1477 - const hiddenInfo = hiddenToVisible.get(targetId); 1478 - if (hiddenInfo) { 1479 - const isFromStackTop = topCommitToStack.has(sourceRevisionId); 1480 - targetId = hiddenInfo.targetCommitId; 1481 - if (isFromStackTop) { 1482 - collapsedStackId = hiddenInfo.stack.id; 1483 - collapsedCount = hiddenInfo.stack.intermediateChangeIds.length; 1484 - } 1485 - } 1486 - } 1487 - 1488 - // Deduplicate 1489 - const key = `${sourceRevisionId}->${targetId}`; 1490 - if (seen.has(key)) continue; 1491 - seen.add(key); 1492 - 1493 - remapped.push({ 1494 - ...binding, 1495 - targetRevisionId: targetId, 1496 - collapsedStackId, 1497 - collapsedCount, 1498 - expandedStackId, 1499 - }); 1500 - } 1501 - 1502 - return remapped; 1503 - }, [edgeBindings, stacks, expandedStacks, changeIdToCommitId]); 1504 - 1505 - const [debugEnabled, setDebugEnabled] = useState(DEBUG_OVERLAY_DEFAULT); 1506 - const debugEnabledRef = useRef(debugEnabled); 1507 - debugEnabledRef.current = debugEnabled; 1508 - 1509 - // Ref to hold scroll function - only scrolls if item is outside visible range 1510 - const scrollToIndexIfNeededRef = useRef<((index: number) => void) | null>(null); 1511 - 1512 - // Determine if selected revision is expanded based on URL search params 1513 - const isSelectedExpanded = expanded === true && !!selectedRevision; 1514 - 1515 - // Toggle debug overlay with Ctrl+Shift+D 1516 - useKeyboardShortcut({ 1517 - key: "D", 1518 - modifiers: { ctrl: true, shift: true }, 1519 - onPress: () => setDebugEnabled((prev) => !prev), 1520 - }); 1521 - 1522 - // Expand selected revision with 'l' or ArrowRight key 1523 - // In overview mode: expands revision inline 1524 - // In split mode: moves focus to diff panel 1525 - useKeyboardShortcut({ 1526 - key: "l", 1527 - modifiers: {}, 1528 - onPress: () => { 1529 - if (viewMode === 2) { 1530 - // Split mode: move focus to diff panel 1531 - setFocusPanel("diff"); 1532 - return; 1533 - } 1534 - // Overview mode: expand inline 1535 - if (!selectedRevision) return; 1536 - if (isSelectedExpanded) return; 1537 - navigate({ 1538 - search: { ...search, expanded: true } as any, 1539 - }); 1540 - }, 1541 - }); 1542 - 1543 - useKeyboardShortcut({ 1544 - key: "ArrowRight", 1545 - modifiers: {}, 1546 - onPress: () => { 1547 - if (viewMode === 2) { 1548 - setFocusPanel("diff"); 1549 - return; 1550 - } 1551 - if (!selectedRevision) return; 1552 - if (isSelectedExpanded) return; 1553 - navigate({ 1554 - search: { ...search, expanded: true } as any, 1555 - }); 1556 - }, 1557 - }); 1558 - 1559 - // Collapse selected revision with 'h' or ArrowLeft key 1560 - // In overview mode: collapses revision inline 1561 - // In split mode: moves focus to revision list 1562 - useKeyboardShortcut({ 1563 - key: "h", 1564 - modifiers: {}, 1565 - onPress: () => { 1566 - if (viewMode === 2) { 1567 - // Split mode: move focus back to revisions (but don't do anything if already there) 1568 - // Note: This handler runs in RevisionGraph, so focus is already here 1569 - return; 1570 - } 1571 - // Overview mode: collapse inline 1572 - if (!selectedRevision) return; 1573 - if (!isSelectedExpanded) return; 1574 - navigate({ 1575 - search: { ...search, expanded: undefined } as any, 1576 - }); 1577 - }, 1578 - }); 1579 - 1580 - useKeyboardShortcut({ 1581 - key: "ArrowLeft", 1582 - modifiers: {}, 1583 - onPress: () => { 1584 - if (viewMode === 2) { 1585 - return; 1586 - } 1587 - if (!selectedRevision) return; 1588 - if (!isSelectedExpanded) return; 1589 - navigate({ 1590 - search: { ...search, expanded: undefined } as any, 1591 - }); 1592 - }, 1593 - }); 1594 - 1595 - // Helper to extend selection in a direction (macOS-style anchor-based selection) 1596 - const extendSelection = (direction: "down" | "up") => { 1597 - if (!selectedRevision) return; 1598 - const currentIndex = changeIdToIndex.get(selectedRevision.change_id); 1599 - if (currentIndex === undefined) return; 1600 - 1601 - const step = direction === "down" ? 1 : -1; 1602 - const limit = direction === "down" ? displayRows.length : -1; 1603 - 1604 - // Find the next revision in the given direction 1605 - let targetChangeId: string | null = null; 1606 - let targetIndex: number | null = null; 1607 - for (let i = currentIndex + step; direction === "down" ? i < limit : i > limit; i += step) { 1608 - const row = displayRows[i]; 1609 - if (row.type === "revision") { 1610 - targetChangeId = row.row.revision.change_id; 1611 - targetIndex = i; 1612 - break; 1613 - } 1614 - } 1615 - 1616 - if (!targetChangeId || targetIndex === null) return; 1617 - 1618 - // Determine anchor: use existing anchor or set it to current position 1619 - const anchorChangeId = selectionAnchor ?? selectedRevision.change_id; 1620 - const anchorIndex = changeIdToIndex.get(anchorChangeId); 1621 - if (anchorIndex === undefined) return; 1622 - 1623 - // Select all revisions between anchor and target (inclusive) 1624 - const startIndex = Math.min(anchorIndex, targetIndex); 1625 - const endIndex = Math.max(anchorIndex, targetIndex); 1626 - const newSelection = new Set<string>(); 1627 - for (let i = startIndex; i <= endIndex; i++) { 1628 - const row = displayRows[i]; 1629 - if (row.type === "revision") { 1630 - newSelection.add(row.row.revision.change_id); 1631 - } 1632 - } 1633 - 1634 - const selected = [...newSelection].join(","); 1635 - // Update URL with new selection, anchor, and move focus 1636 - navigate({ 1637 - search: { 1638 - ...search, 1639 - selected, 1640 - selectionAnchor: anchorChangeId, 1641 - rev: targetChangeId, 1642 - stack: undefined, 1643 - } as any, 1644 - replace: true, 1645 - }); 1646 - 1647 - // Scroll to keep item visible 1648 - scrollToIndexIfNeededRef.current?.(targetIndex); 1649 - }; 1650 - 1651 - // Shift+j: extend selection downward 1652 - useKeyboardShortcut({ 1653 - key: "j", 1654 - modifiers: { shift: true }, 1655 - onPress: () => extendSelection("down"), 1656 - enabled: !!selectedRevision && !inlineJumpMode, 1657 - }); 1658 - 1659 - // Shift+k: extend selection upward 1660 - useKeyboardShortcut({ 1661 - key: "k", 1662 - modifiers: { shift: true }, 1663 - onPress: () => extendSelection("up"), 1664 - enabled: !!selectedRevision && !inlineJumpMode, 1665 - }); 1666 - 1667 - // Shift+ArrowDown: extend selection downward 1668 - useKeyboardShortcut({ 1669 - key: "ArrowDown", 1670 - modifiers: { shift: true }, 1671 - onPress: () => extendSelection("down"), 1672 - enabled: !!selectedRevision && !inlineJumpMode, 1673 - }); 1674 - 1675 - // Shift+ArrowUp: extend selection upward 1676 - useKeyboardShortcut({ 1677 - key: "ArrowUp", 1678 - modifiers: { shift: true }, 1679 - onPress: () => extendSelection("up"), 1680 - enabled: !!selectedRevision && !inlineJumpMode, 1681 - }); 1682 - 1683 - // Get current focused index in displayRows (either revision or collapsed stack) 1684 - const getCurrentDisplayIndex = (): number => { 1685 - if (focusedStackId) { 1686 - return displayRows.findIndex( 1687 - (row) => row.type === "collapsed-stack" && row.stack.id === focusedStackId, 1688 - ); 1689 - } 1690 - if (selectedRevision) { 1691 - return displayRows.findIndex( 1692 - (row) => 1693 - row.type === "revision" && row.row.revision.change_id === selectedRevision.change_id, 1694 - ); 1695 - } 1696 - return -1; 1697 - }; 1698 - 1699 - // Navigate to a display row (revision or collapsed stack) 1700 - // Clears selection and anchor (regular navigation without shift) 1701 - const navigateToDisplayRow = (index: number) => { 1702 - const row = displayRows[index]; 1703 - if (!row) return; 1704 - 1705 - if (row.type === "revision") { 1706 - // Clear stack focus, selection, anchor and set revision 1707 - navigate({ 1708 - search: { 1709 - ...search, 1710 - stack: undefined, 1711 - rev: row.row.revision.change_id, 1712 - selected: undefined, 1713 - selectionAnchor: undefined, 1714 - } as any, 1715 - replace: true, 1716 - }); 1717 - } else if (row.type === "collapsed-stack") { 1718 - // Set stack focus and clear rev, selection, anchor 1719 - navigate({ 1720 - search: { 1721 - ...search, 1722 - stack: row.stack.id, 1723 - rev: undefined, 1724 - selected: undefined, 1725 - selectionAnchor: undefined, 1726 - } as any, 1727 - replace: true, 1728 - }); 1729 - } 1730 - 1731 - // Scroll to keep item visible (only if outside viewport) 1732 - scrollToIndexIfNeededRef.current?.(index); 1733 - }; 1734 - 1735 - // j / ArrowDown: navigate to next display row 1736 - useKeyboardShortcut({ 1737 - key: "j", 1738 - modifiers: {}, 1739 - onPress: () => { 1740 - const currentIndex = getCurrentDisplayIndex(); 1741 - if (currentIndex < 0) { 1742 - // No current focus, start from first 1743 - if (displayRows.length > 0) navigateToDisplayRow(0); 1744 - } else if (currentIndex < displayRows.length - 1) { 1745 - navigateToDisplayRow(currentIndex + 1); 1746 - } 1747 - }, 1748 - enabled: !inlineJumpMode, 1749 - }); 1750 - 1751 - useKeyboardShortcut({ 1752 - key: "ArrowDown", 1753 - modifiers: {}, 1754 - onPress: () => { 1755 - const currentIndex = getCurrentDisplayIndex(); 1756 - if (currentIndex < 0) { 1757 - if (displayRows.length > 0) navigateToDisplayRow(0); 1758 - } else if (currentIndex < displayRows.length - 1) { 1759 - navigateToDisplayRow(currentIndex + 1); 1760 - } 1761 - }, 1762 - enabled: !inlineJumpMode, 1763 - }); 1764 - 1765 - // k / ArrowUp: navigate to previous display row 1766 - useKeyboardShortcut({ 1767 - key: "k", 1768 - modifiers: {}, 1769 - onPress: () => { 1770 - const currentIndex = getCurrentDisplayIndex(); 1771 - if (currentIndex > 0) { 1772 - navigateToDisplayRow(currentIndex - 1); 1773 - } 1774 - }, 1775 - enabled: !inlineJumpMode, 1776 - }); 1777 - 1778 - useKeyboardShortcut({ 1779 - key: "ArrowUp", 1780 - modifiers: {}, 1781 - onPress: () => { 1782 - const currentIndex = getCurrentDisplayIndex(); 1783 - if (currentIndex > 0) { 1784 - navigateToDisplayRow(currentIndex - 1); 1785 - } 1786 - }, 1787 - enabled: !inlineJumpMode, 1788 - }); 1789 - 1790 - // Space/Enter on collapsed stack: expand it and focus the top revision 1791 - useKeyboardShortcut({ 1792 - key: " ", 1793 - modifiers: {}, 1794 - onPress: () => { 1795 - if (focusedStackId) { 1796 - handleToggleStack(focusedStackId); 1797 - } else if (selectedRevision) { 1798 - toggleRevisionCheck(selectedRevision.change_id); 1799 - } 1800 - }, 1801 - enabled: !inlineJumpMode, 1802 - }); 1803 - 1804 - useKeyboardShortcut({ 1805 - key: "Enter", 1806 - modifiers: {}, 1807 - onPress: () => { 1808 - if (focusedStackId) { 1809 - handleToggleStack(focusedStackId); 1810 - } 1811 - }, 1812 - enabled: !!focusedStackId && !inlineJumpMode, 1813 - }); 1814 - 1815 - // Track if we just activated jump mode to ignore the same 'f' keypress 1816 - const justActivatedRef = useRef(false); 1817 - 1818 - // Activate inline jump mode with 'f' key 1819 - useKeyboardShortcut({ 1820 - key: "f", 1821 - modifiers: {}, 1822 - onPress: () => { 1823 - justActivatedRef.current = true; 1824 - setInlineJumpQuery(""); 1825 - // Clear the flag after a short delay (same event loop tick protection) 1826 - requestAnimationFrame(() => { 1827 - justActivatedRef.current = false; 1828 - }); 1829 - }, 1830 - enabled: !inlineJumpMode, 1831 - }); 1832 - 1833 - // Cancel inline jump mode with Escape 1834 - useKeyboardShortcut({ 1835 - key: "Escape", 1836 - modifiers: {}, 1837 - onPress: () => setInlineJumpQuery(null), 1838 - enabled: inlineJumpMode, 1839 - }); 1840 - 1841 - const rowVirtualizer = useVirtualizer({ 1842 - count: displayRows.length, 1843 - getScrollElement: () => parentRef.current, 1844 - estimateSize: (index: number) => { 1845 - const displayRow = displayRows[index]; 1846 - if (displayRow.type === "collapsed-stack") { 1847 - // Same height as a regular revision row 1848 - return ROW_HEIGHT; 1849 - } 1850 - const row = displayRow.row; 1851 - const isExpanded = 1852 - isSelectedExpanded && row.revision.change_id === selectedRevision?.change_id; 1853 - return isExpanded ? ROW_HEIGHT * 3 : ROW_HEIGHT; 1854 - }, 1855 - overscan: 10, 1856 - debug: debugEnabled, 1857 - }); 1858 - 1859 - // scrollToIndexIfNeededRef is updated below after virtualItems is computed 1860 - 1861 - // Expose scrollToChangeId method via ref 1862 - useImperativeHandle(ref, () => ({ 1863 - scrollToChangeId: ( 1864 - changeId: string, 1865 - options?: { align?: "auto" | "center"; smooth?: boolean }, 1866 - ) => { 1867 - const debug = debugEnabledRef.current; 1868 - const index = changeIdToIndex.get(changeId); 1869 - if (index === undefined) { 1870 - if (debug) console.log("[scroll] changeId not found:", changeId); 1871 - return; 1872 - } 1873 - 1874 - const scrollElement = parentRef.current; 1875 - if (!scrollElement) { 1876 - if (debug) console.log("[scroll] scrollElement is null"); 1877 - return; 1878 - } 1879 - 1880 - const scrollTop = scrollElement.scrollTop; 1881 - const viewportHeight = scrollElement.clientHeight; 1882 - const scrollHeight = scrollElement.scrollHeight; 1883 - const itemTop = index * ROW_HEIGHT; 1884 - const itemBottom = itemTop + ROW_HEIGHT; 1885 - 1886 - if (debug) { 1887 - console.log("[scroll] called:", { 1888 - index, 1889 - options, 1890 - scrollTop, 1891 - viewportHeight, 1892 - scrollHeight, 1893 - itemTop, 1894 - itemBottom, 1895 - }); 1896 - } 1897 - 1898 - // For jump commands (smooth/center), always scroll 1899 - if (options?.smooth || options?.align === "center") { 1900 - if (debug) console.log("[scroll] using scrollToIndex (jump)"); 1901 - rowVirtualizer.scrollToIndex(index, { 1902 - align: "center", 1903 - behavior: "smooth", 1904 - }); 1905 - return; 1906 - } 1907 - 1908 - // For step navigation, manually scroll only if item is outside viewport 1909 - const isAboveViewport = itemTop < scrollTop; 1910 - const isBelowViewport = itemBottom > scrollTop + viewportHeight; 1911 - 1912 - if (debug) { 1913 - console.log("[scroll] visibility:", { isAboveViewport, isBelowViewport }); 1914 - } 1915 - 1916 - if (isAboveViewport) { 1917 - const newScrollTop = itemTop; 1918 - if (debug) console.log("[scroll] scrolling UP to:", newScrollTop); 1919 - scrollElement.scrollTop = newScrollTop; 1920 - } else if (isBelowViewport) { 1921 - const newScrollTop = itemBottom - viewportHeight; 1922 - if (debug) console.log("[scroll] scrolling DOWN to:", newScrollTop); 1923 - scrollElement.scrollTop = newScrollTop; 1924 - } else { 1925 - if (debug) console.log("[scroll] item already visible, no scroll needed"); 1926 - } 1927 - }, 1928 - })); 1929 - 1930 - function handleSelect(changeId: string, modifiers: { shift: boolean; meta: boolean }) { 1931 - const revision = revisionMapByChangeId.get(changeId); 1932 - if (!revision) return; 1933 - 1934 - // Cmd/Ctrl+click: toggle selection 1935 - if (modifiers.meta) { 1936 - toggleRevisionCheck(changeId); 1937 - return; 1938 - } 1939 - 1940 - // Shift+click: range select from focused to clicked 1941 - if (modifiers.shift && selectedRevision) { 1942 - const focusedIndex = changeIdToIndex.get(selectedRevision.change_id); 1943 - const clickedIndex = changeIdToIndex.get(changeId); 1944 - if (focusedIndex !== undefined && clickedIndex !== undefined) { 1945 - const startIdx = Math.min(focusedIndex, clickedIndex); 1946 - const endIdx = Math.max(focusedIndex, clickedIndex); 1947 - const newSelection = new Set<string>(); 1948 - for (let i = startIdx; i <= endIdx; i++) { 1949 - const displayRow = displayRows[i]; 1950 - if (displayRow.type === "revision") { 1951 - newSelection.add(displayRow.row.revision.change_id); 1952 - } 1953 - } 1954 - // Update selection in URL 1955 - const selected = newSelection.size > 0 ? [...newSelection].join(",") : undefined; 1956 - navigate({ 1957 - search: { ...search, selected, stack: undefined } as any, 1958 - replace: true, 1959 - }); 1960 - } 1961 - return; 1962 - } 1963 - 1964 - // Plain click: focus revision (clear selection, anchor, and stack focus) 1965 - navigate({ 1966 - search: { 1967 - ...search, 1968 - selected: undefined, 1969 - selectionAnchor: undefined, 1970 - stack: undefined, 1971 - rev: changeId, 1972 - } as any, 1973 - replace: true, 1974 - }); 1975 - } 1976 - 1977 - const virtualItems = rowVirtualizer.getVirtualItems(); 1978 - const visibleStartRow = virtualItems[0]?.index ?? 0; 1979 - const visibleEndRow = virtualItems[virtualItems.length - 1]?.index ?? 0; 1980 - const totalHeight = rowVirtualizer.getTotalSize(); 1981 - 1982 - // Update scroll ref - compute actually visible range based on scroll position 1983 - scrollToIndexIfNeededRef.current = (index: number) => { 1984 - const scrollEl = parentRef.current; 1985 - if (!scrollEl) return; 1986 - 1987 - const scrollTop = scrollEl.scrollTop; 1988 - const clientHeight = scrollEl.clientHeight; 1989 - 1990 - // Calculate which rows are fully visible (not just rendered with overscan) 1991 - // Use ceil for start (first fully visible) and floor-1 for end (last fully visible) 1992 - const visibleStart = Math.ceil(scrollTop / ROW_HEIGHT); 1993 - const visibleEnd = Math.floor((scrollTop + clientHeight) / ROW_HEIGHT) - 1; 1994 - 1995 - const shouldScroll = index < visibleStart || index > visibleEnd; 1996 - 1997 - // Only scroll if the item is outside the fully visible range 1998 - if (shouldScroll) { 1999 - rowVirtualizer.scrollToIndex(index, { align: "auto" }); 2000 - } 2001 - }; 2002 - const rowOffsets = new Map<number, number>(); 2003 - for (const item of virtualItems) { 2004 - rowOffsets.set(item.index, item.start); 2005 - } 2006 - 2007 - // Compute jump hints for visible rows based on change ID prefix matching 2008 - const jumpHintsMap = new Map<string, string>(); 2009 - const matchingRevisions: Array<{ changeId: string; shortId: string }> = []; 2010 - 2011 - if (inlineJumpMode && revisions.length > 0) { 2012 - const query = inlineJumpQuery ?? ""; 2013 - 2014 - // First, collect all visible revisions that match the current query 2015 - for (const item of virtualItems) { 2016 - const row = rows[item.index]; 2017 - if (row) { 2018 - const shortId = row.revision.change_id_short.toLowerCase(); 2019 - if (shortId.startsWith(query.toLowerCase())) { 2020 - matchingRevisions.push({ 2021 - changeId: row.revision.change_id, 2022 - shortId: row.revision.change_id_short, 2023 - }); 2024 - } 2025 - } 2026 - } 2027 - 2028 - // Assign hints based on the next character in the change ID 2029 - if (query === "") { 2030 - // Initial state: show first letter of each change ID 2031 - for (const { changeId, shortId } of matchingRevisions) { 2032 - jumpHintsMap.set(changeId, shortId[0].toLowerCase()); 2033 - } 2034 - } else { 2035 - // After typing: show the next letter to type, or secondary hints if needed 2036 - const nextCharIndex = query.length; 2037 - const nextChars = new Map<string, Array<{ changeId: string; shortId: string }>>(); 2038 - 2039 - // Group by next character 2040 - for (const rev of matchingRevisions) { 2041 - const nextChar = rev.shortId[nextCharIndex]?.toLowerCase() ?? ""; 2042 - if (nextChar) { 2043 - const group = nextChars.get(nextChar) ?? []; 2044 - group.push(rev); 2045 - nextChars.set(nextChar, group); 2046 - } 2047 - } 2048 - 2049 - // Assign hints 2050 - for (const { changeId, shortId } of matchingRevisions) { 2051 - const nextChar = shortId[nextCharIndex]?.toLowerCase() ?? ""; 2052 - if (nextChar) { 2053 - jumpHintsMap.set(changeId, nextChar); 2054 - } 2055 - } 2056 - } 2057 - } 2058 - 2059 - // Store matching revisions in a ref for use in the effect 2060 - const matchingRevisionsRef = useRef(matchingRevisions); 2061 - matchingRevisionsRef.current = matchingRevisions; 2062 - 2063 - // Handle jump hint letter key presses 2064 - useEffect(() => { 2065 - if (!inlineJumpMode) return; 2066 - 2067 - function handleJumpKey(event: KeyboardEvent) { 2068 - const activeElement = document.activeElement; 2069 - if (activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA") { 2070 - return; 2071 - } 2072 - 2073 - const key = event.key.toLowerCase(); 2074 - 2075 - // Ignore the activation key 'f' if we just activated (prevents same event capture) 2076 - if (key === "f" && justActivatedRef.current) { 2077 - return; 2078 - } 2079 - 2080 - // Handle backspace to remove last character 2081 - if (event.key === "Backspace") { 2082 - event.preventDefault(); 2083 - const currentQuery = inlineJumpQuery ?? ""; 2084 - if (currentQuery.length > 0) { 2085 - setInlineJumpQuery(currentQuery.slice(0, -1)); 2086 - } else { 2087 - setInlineJumpQuery(null); // Cancel if already empty 2088 - } 2089 - return; 2090 - } 2091 - 2092 - // Only accept alphanumeric characters for the query 2093 - if (/^[a-z0-9]$/i.test(key)) { 2094 - event.preventDefault(); 2095 - const newQuery = (inlineJumpQuery ?? "") + key; 2096 - 2097 - // Find matching revisions with the new query 2098 - const matches = matchingRevisionsRef.current.filter(({ shortId }) => 2099 - shortId.toLowerCase().startsWith(newQuery.toLowerCase()), 2100 - ); 2101 - 2102 - if (matches.length === 1) { 2103 - // Single match - jump directly 2104 - setInlineJumpQuery(null); 2105 - const revision = revisionMapByChangeId.get(matches[0].changeId); 2106 - if (revision) { 2107 - onSelectRevision(revision); 2108 - } 2109 - } else if (matches.length === 0) { 2110 - // No matches - cancel 2111 - setInlineJumpQuery(null); 2112 - } else { 2113 - // Multiple matches - update query to filter 2114 - setInlineJumpQuery(newQuery); 2115 - } 2116 - return; 2117 - } 2118 - 2119 - // Any other non-modifier key cancels jump mode 2120 - if (!["Shift", "Control", "Alt", "Meta", "CapsLock"].includes(event.key)) { 2121 - setInlineJumpQuery(null); 2122 - } 2123 - } 2124 - 2125 - window.addEventListener("keydown", handleJumpKey); 2126 - return () => window.removeEventListener("keydown", handleJumpKey); 2127 - }, [ 2128 - inlineJumpMode, 2129 - inlineJumpQuery, 2130 - setInlineJumpQuery, 2131 - revisionMapByChangeId, 2132 - onSelectRevision, 2133 - ]); 2134 - 2135 - if (revisions.length === 0) { 2136 - return ( 2137 - <div className="flex items-center justify-center h-full bg-background text-muted-foreground text-sm"> 2138 - {isLoading ? "Loading revisions..." : "Select a project to view revisions"} 2139 - </div> 2140 - ); 2141 - } 2142 - 2143 - const selectedIndex = selectedRevision 2144 - ? changeIdToIndex.get(selectedRevision.change_id) 2145 - : undefined; 2146 - 2147 - const workingCopy = revisions.find((r) => r.is_working_copy); 2148 - const wcIndex = workingCopy ? changeIdToIndex.get(workingCopy.change_id) : undefined; 2149 - 2150 - // Calculate edge layer dimensions and row center positions 2151 - const TOP_PADDING = 16; // Matches pt-4 on RevisionRow 2152 - const CONTENT_MIN_HEIGHT = 56; // Matches min-h-[56px] on RevisionRow content 2153 - const getRowStart = (row: number) => rowOffsets.get(row) ?? row * ROW_HEIGHT; 2154 - const getRowCenter = (row: number) => getRowStart(row) + TOP_PADDING + CONTENT_MIN_HEIGHT / 2; 2155 - const graphWidth = LANE_PADDING + laneCount * LANE_WIDTH + NODE_RADIUS + 2; 2156 - 2157 - return ( 2158 - <div 2159 - ref={parentRef} 2160 - className="h-full overflow-auto ascii-bg" 2161 - style={{ overflowAnchor: "none" }} 2162 - > 2163 - <div 2164 - className="relative" 2165 - style={{ 2166 - height: `${totalHeight}px`, 2167 - width: "100%", 2168 - }} 2169 - > 2170 - {/* Edge layer - semantic edge components positioned absolutely */} 2171 - {/* Key includes expandedStacks to force remount when stack state changes */} 2172 - <EdgeLayer 2173 - key={`edges-${[...expandedStacks].sort().join(",")}`} 2174 - bindings={filteredEdgeBindings} 2175 - revisionMap={revisionMapByCommitId} 2176 - getRowCenter={getRowCenter} 2177 - commitToRow={commitToRowIndex} 2178 - totalHeight={totalHeight} 2179 - width={graphWidth} 2180 - visibleStartRow={visibleStartRow} 2181 - visibleEndRow={visibleEndRow} 2182 - stackById={stackById} 2183 - changeIdToCommitId={changeIdToCommitId} 2184 - onToggleStack={handleToggleStack} 2185 - /> 2186 - 2187 - {/* Virtualized rows with inline graph nodes */} 2188 - <div className="relative z-10"> 2189 - {virtualItems.map((virtualRow) => { 2190 - const displayRow = displayRows[virtualRow.index]; 2191 - 2192 - // Collapsed stack row - styled as stacked cards 2193 - if (displayRow.type === "collapsed-stack") { 2194 - const { stack, lane } = displayRow; 2195 - const nodeAreaWidth = LANE_PADDING + (lane + 1) * LANE_WIDTH; 2196 - const count = stack.intermediateChangeIds.length; 2197 - // Show up to 3 stacked card layers 2198 - const layers = Math.min(count, 3); 2199 - 2200 - // Check if this stack is related to the selected revision (for dimming) 2201 - const isStackRelated = stack.changeIds.some((id) => relatedRevisions.has(id)); 2202 - const isStackDimmed = selectedRevision !== null && !isStackRelated; 2203 - const isStackFocused = focusedStackId === stack.id; 2204 - 2205 - return ( 2206 - <div 2207 - key={`collapsed-${stack.id}`} 2208 - ref={rowVirtualizer.measureElement} 2209 - data-index={virtualRow.index} 2210 - className="absolute left-0 w-full" 2211 - style={{ 2212 - transform: `translateY(${virtualRow.start}px)`, 2213 - height: ROW_HEIGHT, 2214 - }} 2215 - > 2216 - <div className="flex flex-col relative" style={{ height: ROW_HEIGHT }}> 2217 - <div className="flex items-start min-h-[56px] pt-4"> 2218 - {/* Spacer for graph area */} 2219 - <div className="shrink-0" style={{ width: nodeAreaWidth }} /> 2220 - <button 2221 - type="button" 2222 - onClick={() => handleToggleStack(stack.id)} 2223 - className={`relative flex-1 mr-2 min-w-0 my-2 mx-1 cursor-pointer group ${isStackDimmed ? "opacity-40" : ""}`} 2224 - style={{ height: 40 }} 2225 - data-focused={isStackFocused || undefined} 2226 - data-stack-id={stack.id} 2227 - > 2228 - {/* Stacked card layers */} 2229 - {Array.from({ length: layers }).map((_, i) => { 2230 - const layerIndex = layers - 1 - i; // Render back layers first 2231 - const offset = layerIndex * 4; 2232 - const isTopLayer = layerIndex === 0; 2233 - const scale = 1 - layerIndex * 0.02; 2234 - 2235 - return ( 2236 - <div 2237 - key={layerIndex} 2238 - className={`absolute left-0 right-0 rounded border shadow-sm group-hover:border-muted-foreground/50 ${ 2239 - isStackFocused && isTopLayer 2240 - ? "bg-accent/40 border-accent/60" 2241 - : "bg-card border-border" 2242 - } text-card-foreground`} 2243 - style={{ 2244 - top: 0, 2245 - height: 40, 2246 - transform: `translateY(${offset}px) scaleX(${scale})`, 2247 - transformOrigin: "top center", 2248 - opacity: 1 - layerIndex * 0.2, 2249 - zIndex: layers - layerIndex, 2250 - }} 2251 - /> 2252 - ); 2253 - })} 2254 - {/* Content overlay on top card */} 2255 - <div 2256 - className="absolute inset-0 flex items-center justify-center gap-2 rounded" 2257 - style={{ zIndex: layers + 1, height: 40 }} 2258 - > 2259 - <svg 2260 - className="w-3.5 h-3.5 text-muted-foreground" 2261 - fill="none" 2262 - viewBox="0 0 24 24" 2263 - stroke="currentColor" 2264 - > 2265 - <path 2266 - strokeLinecap="round" 2267 - strokeLinejoin="round" 2268 - strokeWidth={2} 2269 - d="M19 9l-7 7-7-7" 2270 - /> 2271 - </svg> 2272 - <span className="text-xs text-muted-foreground group-hover:text-foreground"> 2273 - {count} hidden revision{count !== 1 ? "s" : ""} 2274 - </span> 2275 - </div> 2276 - </button> 2277 - </div> 2278 - </div> 2279 - </div> 2280 - ); 2281 - } 2282 - 2283 - // Regular revision row 2284 - const { row } = displayRow; 2285 - const lane = changeIdToLane.get(row.revision.change_id) ?? 0; 2286 - const isFlashing = flash?.changeId === row.revision.change_id; 2287 - const isDimmed = 2288 - (selectedRevision !== null || focusedStackId !== null) && 2289 - !relatedRevisions.has(row.revision.change_id); 2290 - // Only show focus if no stack is focused 2291 - const isFocused = 2292 - !focusedStackId && selectedRevision?.change_id === row.revision.change_id; 2293 - const isSelected = isFocused; 2294 - const isExpanded = isSelectedExpanded && isFocused; 2295 - 2296 - return ( 2297 - <div 2298 - key={row.revision.change_id} 2299 - ref={rowVirtualizer.measureElement} 2300 - data-index={virtualRow.index} 2301 - className="absolute left-0 w-full" 2302 - style={{ 2303 - transform: `translateY(${virtualRow.start}px)`, 2304 - }} 2305 - > 2306 - <RevisionRow 2307 - revision={row.revision} 2308 - lane={lane} 2309 - maxLaneOnRow={row.maxLaneOnRow} 2310 - isSelected={isSelected} 2311 - isChecked={selectedRevisions.has(row.revision.change_id)} 2312 - isFocused={isFocused} 2313 - onSelect={handleSelect} 2314 - isFlashing={isFlashing} 2315 - isDimmed={isDimmed} 2316 - isExpanded={isExpanded} 2317 - repoPath={repoPath} 2318 - isPendingAbandon={pendingAbandon?.change_id === row.revision.change_id} 2319 - jumpHint={jumpHintsMap.get(row.revision.change_id) ?? null} 2320 - jumpModeActive={inlineJumpMode} 2321 - jumpQuery={inlineJumpQuery ?? ""} 2322 - /> 2323 - </div> 2324 - ); 2325 - })} 2326 - </div> 2327 - </div> 2328 - 2329 - {/* Debug overlay - toggle with Ctrl+Shift+D */} 2330 - <DebugOverlay 2331 - enabled={debugEnabled} 2332 - scrollRef={parentRef} 2333 - selectedIndex={selectedIndex} 2334 - visibleStartRow={visibleStartRow} 2335 - visibleEndRow={visibleEndRow} 2336 - totalRows={rows.length} 2337 - wcIndex={wcIndex} 2338 - selectedChangeId={selectedRevision?.change_id} 2339 - wcChangeId={workingCopy?.change_id} 2340 - /> 2341 - </div> 2342 - ); 2343 - }, 2344 - ); 21 + export { 22 + ROW_HEIGHT, 23 + LANE_WIDTH, 24 + LANE_PADDING, 25 + NODE_RADIUS, 26 + MAX_LANES, 27 + laneToX, 28 + laneColor, 29 + } from "./revision-graph";
+137
apps/desktop/src/components/revision-graph/DebugOverlay.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import { useEffect, useRef, useState } from "react"; 3 + import { debugOverlayEnabledAtom } from "@/atoms"; 4 + import { ROW_HEIGHT } from "./constants"; 5 + 6 + interface DebugOverlayProps { 7 + scrollRef: React.RefObject<HTMLDivElement | null>; 8 + selectedIndex: number | undefined; 9 + visibleStartRow: number; 10 + visibleEndRow: number; 11 + totalRows: number; 12 + wcIndex: number | undefined; 13 + selectedChangeId: string | undefined; 14 + wcChangeId: string | undefined; 15 + } 16 + 17 + /** 18 + * Debug overlay for RevisionGraph - toggle with Ctrl+Shift+D 19 + * Displays scroll position, viewport info, and selection state 20 + */ 21 + export function DebugOverlay({ 22 + scrollRef, 23 + selectedIndex, 24 + visibleStartRow, 25 + visibleEndRow, 26 + totalRows, 27 + wcIndex, 28 + selectedChangeId, 29 + wcChangeId, 30 + }: DebugOverlayProps) { 31 + const [enabled] = useAtom(debugOverlayEnabledAtom); 32 + 33 + // Force re-render on scroll/resize/focus 34 + const [, forceUpdate] = useState(0); 35 + 36 + const prevScrollTop = useRef<number>(0); 37 + 38 + useEffect(() => { 39 + if (!enabled) return; 40 + const el = scrollRef.current; 41 + if (!el) return; 42 + 43 + const update = () => { 44 + const newScrollTop = el.scrollTop; 45 + prevScrollTop.current = newScrollTop; 46 + forceUpdate((n) => n + 1); 47 + }; 48 + el.addEventListener("scroll", update); 49 + window.addEventListener("resize", update); 50 + document.addEventListener("focusin", update); 51 + return () => { 52 + el.removeEventListener("scroll", update); 53 + window.removeEventListener("resize", update); 54 + document.removeEventListener("focusin", update); 55 + }; 56 + }, [scrollRef, enabled]); 57 + 58 + if (!enabled) return null; 59 + 60 + const el = scrollRef.current; 61 + const scrollTop = el?.scrollTop ?? 0; 62 + const clientHeight = el?.clientHeight ?? 0; 63 + const scrollHeight = el?.scrollHeight ?? 0; 64 + 65 + const selectedItemTop = selectedIndex !== undefined ? selectedIndex * ROW_HEIGHT : 0; 66 + const selectedItemBottom = selectedItemTop + ROW_HEIGHT; 67 + const distanceFromTop = selectedItemTop - scrollTop; 68 + const distanceFromBottom = scrollTop + clientHeight - selectedItemBottom; 69 + const isInViewport = distanceFromTop >= 0 && distanceFromBottom >= 0; 70 + 71 + const active = document.activeElement; 72 + const activeElement = active 73 + ? `${active.tagName}${active.className ? `.${active.className.split(" ")[0]}` : ""}` 74 + : "none"; 75 + 76 + const info = { 77 + scrollTop, 78 + clientHeight, 79 + scrollHeight, 80 + viewportEnd: scrollTop + clientHeight, 81 + selectedIndex, 82 + wcIndex, 83 + itemTop: selectedItemTop, 84 + itemBottom: selectedItemBottom, 85 + distFromTop: distanceFromTop, 86 + distFromBottom: distanceFromBottom, 87 + isInViewport, 88 + virtualRange: `${visibleStartRow}-${visibleEndRow}`, 89 + totalRows, 90 + ROW_HEIGHT, 91 + activeElement, 92 + selected: selectedChangeId?.slice(0, 4), 93 + wc: wcChangeId?.slice(0, 4), 94 + }; 95 + 96 + return ( 97 + <button 98 + type="button" 99 + className="fixed bottom-12 right-4 z-50 bg-black/90 text-green-400 font-mono text-xs p-3 rounded-lg shadow-lg max-w-xs cursor-pointer hover:bg-black/95 active:scale-95 transition-transform text-left" 100 + onClick={() => navigator.clipboard.writeText(JSON.stringify(info, null, 2))} 101 + title="Click to copy" 102 + > 103 + <div className="font-bold text-green-300 mb-2"> 104 + Debug Info <span className="text-green-600">(click to copy)</span> 105 + </div> 106 + <div className="space-y-1"> 107 + <div>scrollTop: {scrollTop.toFixed(0)}</div> 108 + <div>clientHeight: {clientHeight.toFixed(0)}</div> 109 + <div>scrollHeight: {scrollHeight}</div> 110 + <div>viewportEnd: {(scrollTop + clientHeight).toFixed(0)}</div> 111 + <div className="border-t border-green-800 my-2" /> 112 + <div>selectedIndex: {selectedIndex ?? "none"}</div> 113 + <div>wcIndex: {wcIndex ?? "none"}</div> 114 + <div>selected: {selectedChangeId?.slice(0, 4) ?? "none"}</div> 115 + <div>wc: {wcChangeId?.slice(0, 4) ?? "none"}</div> 116 + <div className="border-t border-green-800 my-2" /> 117 + <div>itemTop: {selectedItemTop}</div> 118 + <div>itemBottom: {selectedItemBottom}</div> 119 + <div>distFromTop: {distanceFromTop.toFixed(0)}</div> 120 + <div>distFromBottom: {distanceFromBottom.toFixed(0)}</div> 121 + <div className={isInViewport ? "text-green-400" : "text-red-400"}> 122 + inViewport: {isInViewport ? "YES" : "NO"} 123 + </div> 124 + <div className="border-t border-green-800 my-2" /> 125 + <div> 126 + virtualRange: {visibleStartRow}-{visibleEndRow} 127 + </div> 128 + <div>totalRows: {totalRows}</div> 129 + <div>ROW_HEIGHT: {ROW_HEIGHT}</div> 130 + <div className="border-t border-green-800 my-2" /> 131 + <div className="truncate" title={activeElement}> 132 + focus: {activeElement} 133 + </div> 134 + </div> 135 + </button> 136 + ); 137 + }
+135
apps/desktop/src/components/revision-graph/EdgeLayer.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import { hoveredStackIdAtom } from "@/atoms"; 3 + import type { Revision } from "@/tauri-commands"; 4 + import type { RevisionStack } from "@/components/revision-graph-utils"; 5 + import type { EdgeBinding } from "./types"; 6 + import { NODE_RADIUS } from "./constants"; 7 + import { GraphEdge } from "./GraphEdge"; 8 + 9 + interface EdgeLayerProps { 10 + bindings: EdgeBinding[]; 11 + commitToRow: Map<string, number>; 12 + revisionMap: Map<string, Revision>; 13 + getRowCenter: (row: number) => number; 14 + totalHeight: number; 15 + width: number; 16 + visibleStartRow: number; 17 + visibleEndRow: number; 18 + stackById: Map<string, RevisionStack>; 19 + changeIdToCommitId: Map<string, string>; 20 + onToggleStack?: (stackId: string) => void; 21 + } 22 + 23 + /** 24 + * EdgeLayer - Renders all semantic edge components as an SVG overlay 25 + * Handles visibility filtering for virtualization 26 + */ 27 + export function EdgeLayer({ 28 + bindings, 29 + commitToRow, 30 + revisionMap, 31 + getRowCenter, 32 + totalHeight, 33 + width, 34 + visibleStartRow, 35 + visibleEndRow, 36 + stackById, 37 + changeIdToCommitId, 38 + onToggleStack, 39 + }: EdgeLayerProps) { 40 + // Use atom for hover state - automatically syncs with stack toggling and view mode changes 41 + const [hoveredStackId, setHoveredStackId] = useAtom(hoveredStackIdAtom); 42 + 43 + // Add overscan for edges that might span across viewport boundary 44 + // Use larger overscan to handle collapsed stack edges that span many rows 45 + const overscan = 15; 46 + const startRow = Math.max(0, visibleStartRow - overscan); 47 + const endRow = visibleEndRow + overscan; 48 + 49 + // Filter bindings to those visible in the viewport 50 + const visibleBindings = bindings.filter((binding) => { 51 + const sourceRow = commitToRow.get(binding.sourceRevisionId); 52 + const targetRow = commitToRow.get(binding.targetRevisionId); 53 + if (sourceRow === undefined) return false; 54 + 55 + // For missing stubs, just check if source is near visible range 56 + if (binding.isMissingStub) { 57 + return sourceRow >= startRow && sourceRow <= endRow; 58 + } 59 + 60 + if (targetRow === undefined) return false; 61 + 62 + // Check if edge passes through visible area 63 + const minRow = Math.min(sourceRow, targetRow); 64 + const maxRow = Math.max(sourceRow, targetRow); 65 + return maxRow >= startRow && minRow <= endRow; 66 + }); 67 + 68 + return ( 69 + <svg 70 + width={width} 71 + height={totalHeight} 72 + className="shrink-0 absolute top-0 left-0 z-20" 73 + role="img" 74 + aria-label="Revision connections" 75 + > 76 + <title>Revision graph edges</title> 77 + {visibleBindings.map((binding) => { 78 + const sourceRow = commitToRow.get(binding.sourceRevisionId); 79 + const targetRow = binding.isMissingStub 80 + ? sourceRow !== undefined 81 + ? sourceRow + 1 82 + : undefined 83 + : commitToRow.get(binding.targetRevisionId); 84 + 85 + if (sourceRow === undefined) return null; 86 + 87 + const sourceRevision = revisionMap.get(binding.sourceRevisionId); 88 + const targetRevision = binding.targetRevisionId 89 + ? (revisionMap.get(binding.targetRevisionId) ?? null) 90 + : null; 91 + 92 + if (!sourceRevision) return null; 93 + 94 + // For expanded stacks, calculate full stack bounds 95 + let stackTopY: number | undefined; 96 + let stackBottomY: number | undefined; 97 + if (binding.expandedStackId) { 98 + const stack = stackById.get(binding.expandedStackId); 99 + if (stack) { 100 + const topCommitId = changeIdToCommitId.get(stack.topChangeId); 101 + const bottomCommitId = changeIdToCommitId.get(stack.bottomChangeId); 102 + const topRow = topCommitId ? commitToRow.get(topCommitId) : undefined; 103 + const bottomRow = bottomCommitId ? commitToRow.get(bottomCommitId) : undefined; 104 + if (topRow !== undefined && bottomRow !== undefined) { 105 + stackTopY = getRowCenter(topRow) - NODE_RADIUS; 106 + stackBottomY = getRowCenter(bottomRow) + NODE_RADIUS; 107 + } 108 + } 109 + } 110 + 111 + // Use a key that captures the edge's structural identity: 112 + // source, target (which changes when collapsed), and stack state 113 + const edgeKey = `${binding.sourceRevisionId}->${binding.targetRevisionId}:${binding.collapsedStackId ?? binding.expandedStackId ?? "none"}`; 114 + 115 + return ( 116 + <GraphEdge 117 + key={edgeKey} 118 + binding={binding} 119 + sourceY={getRowCenter(sourceRow)} 120 + targetY={ 121 + targetRow !== undefined ? getRowCenter(targetRow) : getRowCenter(sourceRow) + 64 // ROW_HEIGHT fallback 122 + } 123 + sourceRevision={sourceRevision} 124 + targetRevision={targetRevision} 125 + stackTopY={stackTopY} 126 + stackBottomY={stackBottomY} 127 + hoveredStackId={hoveredStackId} 128 + onHoverStack={setHoveredStackId} 129 + onToggleStack={onToggleStack} 130 + /> 131 + ); 132 + })} 133 + </svg> 134 + ); 135 + }
+238
apps/desktop/src/components/revision-graph/GraphEdge.tsx
··· 1 + import type { Revision } from "@/tauri-commands"; 2 + import { NODE_RADIUS, ROW_HEIGHT, laneToX, laneColor } from "./constants"; 3 + import type { EdgeBinding } from "./types"; 4 + 5 + interface GraphEdgeProps { 6 + binding: EdgeBinding; 7 + sourceY: number; 8 + targetY: number; 9 + sourceRevision: Revision; 10 + targetRevision: Revision | null; 11 + stackTopY?: number; 12 + stackBottomY?: number; 13 + hoveredStackId: string | null; 14 + onHoverStack: (stackId: string | null) => void; 15 + onToggleStack?: (stackId: string) => void; 16 + } 17 + 18 + /** 19 + * GraphEdge - Semantic edge component with source/target revision bindings 20 + * Handles different edge types: direct, indirect (dashed), missing stub 21 + * Supports collapsed/expanded stack interactions 22 + */ 23 + export function GraphEdge({ 24 + binding, 25 + sourceY, 26 + targetY, 27 + sourceRevision, 28 + targetRevision, 29 + stackTopY, 30 + stackBottomY, 31 + hoveredStackId, 32 + onHoverStack, 33 + onToggleStack, 34 + }: GraphEdgeProps) { 35 + const { 36 + sourceLane, 37 + targetLane, 38 + edgeType, 39 + isDeemphasized, 40 + isMissingStub, 41 + collapsedStackId, 42 + collapsedCount, 43 + expandedStackId, 44 + } = binding; 45 + 46 + // Check if this edge's stack is currently hovered 47 + const isStackHovered = expandedStackId !== undefined && hoveredStackId === expandedStackId; 48 + 49 + const sourceX = laneToX(sourceLane); 50 + const targetX = laneToX(targetLane); 51 + const sourceColor = laneColor(sourceLane); 52 + const targetColor = laneColor(targetLane); 53 + 54 + // Style based on edge type 55 + const isDashed = edgeType === "indirect"; 56 + const isMissing = edgeType === "missing"; 57 + const strokeWidth = isDeemphasized ? 1 : 2; 58 + const strokeOpacity = isDeemphasized ? 0.4 : isMissing ? 0.3 : 0.8; 59 + const strokeColor = isDeemphasized ? "var(--muted-foreground)" : targetColor; 60 + 61 + // Accessibility label describing the connection 62 + const ariaLabel = isMissingStub 63 + ? `${sourceRevision.change_id_short} has parent outside current view` 64 + : targetRevision 65 + ? `${sourceRevision.change_id_short} → ${targetRevision.change_id_short}${edgeType === "indirect" ? " (indirect)" : ""}` 66 + : `Edge from ${sourceRevision.change_id_short}`; 67 + 68 + // Missing stub: short dashed line indicating parent outside view 69 + if (isMissingStub) { 70 + const stubLength = ROW_HEIGHT * 0.4; 71 + return ( 72 + <g aria-label={ariaLabel} style={{ pointerEvents: "none" }}> 73 + <title>{ariaLabel}</title> 74 + <line 75 + x1={sourceX} 76 + y1={sourceY + NODE_RADIUS} 77 + x2={sourceX} 78 + y2={sourceY + NODE_RADIUS + stubLength} 79 + stroke={sourceColor} 80 + strokeWidth={1.5} 81 + strokeOpacity={0.4} 82 + strokeDasharray="3 3" 83 + data-edge-type="missing-stub" 84 + data-source-revision={sourceRevision.change_id} 85 + /> 86 + </g> 87 + ); 88 + } 89 + 90 + // Same lane: straight vertical line (or dotted for collapsed stacks) 91 + if (sourceLane === targetLane) { 92 + const isCollapsedStack = !!collapsedStackId; 93 + const y1 = sourceY + NODE_RADIUS; 94 + const y2 = targetY - NODE_RADIUS; 95 + 96 + // For collapsed stacks, draw a dotted line with clickable area 97 + if (isCollapsedStack) { 98 + const collapsedLabel = `${collapsedCount ?? 0} hidden revision${(collapsedCount ?? 0) !== 1 ? "s" : ""} - click to expand`; 99 + 100 + return ( 101 + // biome-ignore lint/a11y/useSemanticElements: Cannot use button inside SVG 102 + <g 103 + role="button" 104 + tabIndex={0} 105 + aria-label={collapsedLabel} 106 + className="cursor-pointer group" 107 + style={{ pointerEvents: "auto" }} 108 + onClick={() => onToggleStack?.(collapsedStackId)} 109 + onKeyDown={(e) => { 110 + if (e.key === "Enter" || e.key === " ") { 111 + onToggleStack?.(collapsedStackId); 112 + } 113 + }} 114 + > 115 + <title>{collapsedLabel}</title> 116 + {/* Invisible wider hitbox for easier clicking */} 117 + <line x1={sourceX} y1={y1} x2={sourceX} y2={y2} stroke="transparent" strokeWidth={16} /> 118 + {/* Visible dotted line */} 119 + <line 120 + x1={sourceX} 121 + y1={y1} 122 + x2={sourceX} 123 + y2={y2} 124 + stroke={sourceColor} 125 + strokeWidth={strokeWidth} 126 + strokeOpacity={0.7} 127 + strokeDasharray="3 6" 128 + strokeLinecap="round" 129 + className="group-hover:[stroke-width:3] group-hover:[stroke-opacity:1] transition-[stroke-width,stroke-opacity] duration-150" 130 + data-edge-type="collapsed-stack" 131 + data-stack-id={collapsedStackId} 132 + data-source-revision={sourceRevision.change_id} 133 + data-target-revision={targetRevision?.change_id} 134 + /> 135 + </g> 136 + ); 137 + } 138 + 139 + // For expanded stacks, make the edge clickable to collapse 140 + if (expandedStackId) { 141 + const expandedLabel = `Click to collapse stack`; 142 + // Use full stack bounds if available, otherwise fall back to node bounds 143 + const hitboxY1 = stackTopY !== undefined ? stackTopY : y1; 144 + const hitboxY2 = stackBottomY !== undefined ? stackBottomY : y2; 145 + 146 + // Apply hover styling reactively based on atom state 147 + const hoverStrokeWidth = isStackHovered ? 3 : strokeWidth; 148 + const hoverStrokeOpacity = isStackHovered ? 1 : strokeOpacity; 149 + 150 + return ( 151 + // biome-ignore lint/a11y/useSemanticElements: Cannot use button inside SVG 152 + <g 153 + role="button" 154 + tabIndex={0} 155 + aria-label={expandedLabel} 156 + className="cursor-pointer" 157 + style={{ pointerEvents: "auto" }} 158 + onClick={() => onToggleStack?.(expandedStackId)} 159 + onKeyDown={(e) => { 160 + if (e.key === "Enter" || e.key === " ") { 161 + onToggleStack?.(expandedStackId); 162 + } 163 + }} 164 + onMouseEnter={() => onHoverStack(expandedStackId)} 165 + onMouseLeave={() => onHoverStack(null)} 166 + > 167 + <title>{expandedLabel}</title> 168 + {/* Invisible wider hitbox covering full stack height */} 169 + <line 170 + x1={sourceX} 171 + y1={hitboxY1} 172 + x2={targetX} 173 + y2={hitboxY2} 174 + stroke="transparent" 175 + strokeWidth={16} 176 + /> 177 + <line 178 + x1={sourceX} 179 + y1={y1} 180 + x2={targetX} 181 + y2={y2} 182 + stroke={isDeemphasized ? strokeColor : sourceColor} 183 + strokeWidth={hoverStrokeWidth} 184 + strokeOpacity={hoverStrokeOpacity} 185 + strokeDasharray={isDashed ? "4 4" : undefined} 186 + className="transition-[stroke-width,stroke-opacity] duration-150" 187 + data-edge-type={edgeType} 188 + data-source-revision={sourceRevision.change_id} 189 + data-target-revision={targetRevision?.change_id} 190 + /> 191 + </g> 192 + ); 193 + } 194 + 195 + return ( 196 + <g aria-label={ariaLabel} style={{ pointerEvents: "none" }}> 197 + <title>{ariaLabel}</title> 198 + <line 199 + x1={sourceX} 200 + y1={y1} 201 + x2={targetX} 202 + y2={y2} 203 + stroke={isDeemphasized ? strokeColor : sourceColor} 204 + strokeWidth={strokeWidth} 205 + strokeOpacity={strokeOpacity} 206 + strokeDasharray={isDashed ? "4 4" : undefined} 207 + data-edge-type={edgeType} 208 + data-source-revision={sourceRevision.change_id} 209 + data-target-revision={targetRevision?.change_id} 210 + /> 211 + </g> 212 + ); 213 + } 214 + 215 + // Cross-lane: horizontal from source, curve down into target's lane 216 + const goingRight = targetX > sourceX; 217 + const arcRadius = 10; 218 + 219 + return ( 220 + <g aria-label={ariaLabel} style={{ pointerEvents: "none" }}> 221 + <title>{ariaLabel}</title> 222 + <path 223 + d={`M ${sourceX} ${sourceY + NODE_RADIUS} 224 + L ${targetX - arcRadius * (goingRight ? 1 : -1)} ${sourceY + NODE_RADIUS} 225 + Q ${targetX} ${sourceY + NODE_RADIUS} ${targetX} ${sourceY + NODE_RADIUS + arcRadius} 226 + L ${targetX} ${targetY - NODE_RADIUS}`} 227 + fill="none" 228 + stroke={strokeColor} 229 + strokeWidth={strokeWidth} 230 + strokeOpacity={strokeOpacity} 231 + strokeDasharray={isDashed ? "4 4" : undefined} 232 + data-edge-type={edgeType} 233 + data-source-revision={sourceRevision.change_id} 234 + data-target-revision={targetRevision?.change_id} 235 + /> 236 + </g> 237 + ); 238 + }
+129
apps/desktop/src/components/revision-graph/GraphNode.tsx
··· 1 + import type { Revision } from "@/tauri-commands"; 2 + import { NODE_RADIUS } from "./constants"; 3 + 4 + interface GraphNodeProps { 5 + revision: Revision; 6 + lane: number; 7 + isSelected: boolean; 8 + color: string; 9 + } 10 + 11 + /** 12 + * GraphNode - Semantic node component rendered inline with each row 13 + * Uses inline SVG for proper accessibility (avoids role="img" on divs) 14 + * 15 + * Three variants: 16 + * - Working copy: @ symbol with glow 17 + * - Immutable: diamond shape 18 + * - Regular mutable: circle 19 + */ 20 + export function GraphNode({ revision, lane, isSelected, color }: GraphNodeProps) { 21 + const isWorkingCopy = revision.is_working_copy; 22 + const isImmutable = revision.is_immutable; 23 + 24 + const size = isWorkingCopy ? NODE_RADIUS * 2 + 6 : NODE_RADIUS * 2; 25 + const selectedRingSize = isWorkingCopy ? NODE_RADIUS + 6 : NODE_RADIUS + 4; 26 + 27 + // Working copy: @ symbol with glow 28 + if (isWorkingCopy) { 29 + return ( 30 + <svg 31 + width={size + 8} 32 + height={size + 8} 33 + viewBox={`0 0 ${size + 8} ${size + 8}`} 34 + className="shrink-0" 35 + aria-label={`Working copy revision ${revision.change_id_short}`} 36 + data-revision-id={revision.change_id} 37 + data-lane={lane} 38 + > 39 + <title>Working copy: {revision.change_id_short}</title> 40 + {isSelected && ( 41 + <circle 42 + cx={(size + 8) / 2} 43 + cy={(size + 8) / 2} 44 + r={selectedRingSize} 45 + fill={color} 46 + fillOpacity={0.3} 47 + /> 48 + )} 49 + <circle 50 + cx={(size + 8) / 2} 51 + cy={(size + 8) / 2} 52 + r={NODE_RADIUS + 3} 53 + fill={color} 54 + fillOpacity={0.2} 55 + /> 56 + <text 57 + x={(size + 8) / 2} 58 + y={(size + 8) / 2} 59 + textAnchor="middle" 60 + dominantBaseline="central" 61 + fill={color} 62 + fontWeight="bold" 63 + fontSize="12" 64 + > 65 + @ 66 + </text> 67 + </svg> 68 + ); 69 + } 70 + 71 + // Immutable: diamond shape 72 + if (isImmutable) { 73 + return ( 74 + <svg 75 + width={size + 8} 76 + height={size + 8} 77 + viewBox={`0 0 ${size + 8} ${size + 8}`} 78 + className="shrink-0" 79 + aria-label={`Immutable revision ${revision.change_id_short}`} 80 + data-revision-id={revision.change_id} 81 + data-lane={lane} 82 + > 83 + <title>Immutable: {revision.change_id_short}</title> 84 + {isSelected && ( 85 + <circle 86 + cx={(size + 8) / 2} 87 + cy={(size + 8) / 2} 88 + r={selectedRingSize} 89 + fill={color} 90 + fillOpacity={0.3} 91 + /> 92 + )} 93 + <rect 94 + x={(size + 8) / 2 - NODE_RADIUS} 95 + y={(size + 8) / 2 - NODE_RADIUS} 96 + width={NODE_RADIUS * 2} 97 + height={NODE_RADIUS * 2} 98 + fill={color} 99 + transform={`rotate(45 ${(size + 8) / 2} ${(size + 8) / 2})`} 100 + /> 101 + </svg> 102 + ); 103 + } 104 + 105 + // Regular mutable: circle 106 + return ( 107 + <svg 108 + width={size + 8} 109 + height={size + 8} 110 + viewBox={`0 0 ${size + 8} ${size + 8}`} 111 + className="shrink-0" 112 + aria-label={`Revision ${revision.change_id_short}`} 113 + data-revision-id={revision.change_id} 114 + data-lane={lane} 115 + > 116 + <title>Revision: {revision.change_id_short}</title> 117 + {isSelected && ( 118 + <circle 119 + cx={(size + 8) / 2} 120 + cy={(size + 8) / 2} 121 + r={selectedRingSize} 122 + fill={color} 123 + fillOpacity={0.3} 124 + /> 125 + )} 126 + <circle cx={(size + 8) / 2} cy={(size + 8) / 2} r={NODE_RADIUS} fill={color} /> 127 + </svg> 128 + ); 129 + }
+200
apps/desktop/src/components/revision-graph/RevisionRow.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import { useLiveQuery } from "@tanstack/react-db"; 3 + import { useNavigate, useSearch } from "@tanstack/react-router"; 4 + import { Route } from "@/routes/project.$projectId"; 5 + import { focusPanelAtom, viewModeAtom } from "@/atoms"; 6 + import { ChangedFilesList } from "@/components/ChangedFilesList"; 7 + import { emptyChangesCollection, getRevisionChangesCollection } from "@/db"; 8 + import type { Revision } from "@/tauri-commands"; 9 + import { ROW_HEIGHT, LANE_PADDING, LANE_WIDTH, NODE_RADIUS, laneToX, laneColor } from "./constants"; 10 + import { GraphNode } from "./GraphNode"; 11 + 12 + interface RevisionRowProps { 13 + revision: Revision; 14 + lane: number; 15 + maxLaneOnRow: number; 16 + isSelected: boolean; 17 + isChecked: boolean; 18 + onSelect: (changeId: string, modifiers: { shift: boolean; meta: boolean }) => void; 19 + isFlashing: boolean; 20 + isDimmed: boolean; 21 + isExpanded: boolean; 22 + isFocused: boolean; 23 + repoPath: string | null; 24 + isPendingAbandon: boolean; 25 + jumpModeActive: boolean; 26 + jumpQuery: string; 27 + jumpHint: string | null; 28 + } 29 + 30 + /** 31 + * RevisionRow - Renders a single revision in the graph 32 + * Includes graph node, revision metadata, branches, and expandable file list 33 + */ 34 + export function RevisionRow({ 35 + revision, 36 + lane, 37 + maxLaneOnRow, 38 + isSelected, 39 + isChecked, 40 + onSelect, 41 + isFlashing, 42 + isDimmed, 43 + isExpanded, 44 + isFocused, 45 + repoPath, 46 + isPendingAbandon, 47 + jumpModeActive, 48 + jumpQuery, 49 + jumpHint, 50 + }: RevisionRowProps) { 51 + const firstLine = revision.description.split("\n")[0] || "(no description)"; 52 + const fullDescription = revision.description || "(no description)"; 53 + 54 + // Calculate the node position area - leaves space for graph edges on the left 55 + const nodeAreaWidth = LANE_PADDING + (maxLaneOnRow + 1) * LANE_WIDTH; 56 + const nodeOffset = laneToX(lane); 57 + const color = laneColor(lane); 58 + 59 + const selectedFile = useSearch({ from: Route.fullPath, select: (s) => s.file ?? null }); 60 + const search = useSearch({ from: Route.fullPath }); 61 + const navigate = useNavigate({ from: Route.fullPath }); 62 + const [viewMode, setViewMode] = useAtom(viewModeAtom); 63 + const [, setFocusPanel] = useAtom(focusPanelAtom); 64 + 65 + const changedFilesCollection = 66 + isExpanded && repoPath 67 + ? getRevisionChangesCollection(repoPath, revision.change_id) 68 + : emptyChangesCollection; 69 + const changedFilesQuery = useLiveQuery(changedFilesCollection); 70 + 71 + function handleSelectFile(filePath: string) { 72 + // If in overview mode, switch to split mode and focus diff panel 73 + if (viewMode === 1) { 74 + setViewMode(2); 75 + setFocusPanel("diff"); 76 + } 77 + // Clear expanded state and navigate to file 78 + navigate({ 79 + search: { ...search, file: filePath, expanded: undefined }, 80 + }); 81 + } 82 + 83 + // Constants matching edge layer calculations 84 + const TOP_PADDING = 16; 85 + const CONTENT_MIN_HEIGHT = 56; 86 + const nodeSize = revision.is_working_copy ? NODE_RADIUS * 2 + 14 : NODE_RADIUS * 2 + 8; 87 + 88 + return ( 89 + <div style={{ height: isExpanded ? "auto" : ROW_HEIGHT }} className="flex flex-col relative"> 90 + {/* Graph node - absolutely positioned to align with edge layer */} 91 + <div 92 + className="absolute z-20 flex items-center justify-center" 93 + style={{ 94 + left: nodeOffset - nodeSize / 2, 95 + top: TOP_PADDING + CONTENT_MIN_HEIGHT / 2 - nodeSize / 2, 96 + }} 97 + > 98 + <GraphNode revision={revision} lane={lane} isSelected={isSelected} color={color} /> 99 + </div> 100 + <div className="flex items-start min-h-[56px] pt-4"> 101 + {/* Spacer for graph area */} 102 + <div className="shrink-0" style={{ width: nodeAreaWidth }} /> 103 + {/* biome-ignore lint/a11y/useSemanticElements: Complex styling requires div */} 104 + <div 105 + role="button" 106 + tabIndex={0} 107 + className={`relative flex-1 mr-2 min-w-0 overflow-hidden rounded my-2 mx-1 select-none border ${ 108 + isFocused || isChecked 109 + ? "bg-accent/40 border-accent/60 hover:bg-accent/50" 110 + : "bg-card hover:bg-muted border-border" 111 + } text-card-foreground shadow-sm hover:shadow hover:cursor-pointer ${ 112 + revision.is_immutable ? "opacity-60" : "" 113 + } ${isDimmed ? "opacity-40" : ""}`} 114 + data-focused={isFocused || undefined} 115 + data-selected={isSelected || undefined} 116 + data-checked={isChecked || undefined} 117 + data-expanded={isExpanded || undefined} 118 + data-change-id={revision.change_id} 119 + onClick={(e) => { 120 + // Prevent text selection on shift+click 121 + if (e.shiftKey) { 122 + e.preventDefault(); 123 + window.getSelection()?.removeAllRanges(); 124 + } 125 + onSelect(revision.change_id, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 126 + }} 127 + onKeyDown={(e) => { 128 + if (e.key === "Enter" || e.key === " ") { 129 + onSelect(revision.change_id, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 130 + } 131 + }} 132 + > 133 + <div className={`px-3 py-2 min-w-0 ${isPendingAbandon ? "blur-sm" : ""}`}> 134 + <div className="flex items-center gap-2 flex-nowrap min-w-0"> 135 + <code 136 + className={`text-xs font-mono rounded px-0.5 shrink-0 ${ 137 + isFlashing ? "bg-primary/40 animate-pulse" : "" 138 + } text-muted-foreground`} 139 + > 140 + {jumpModeActive && jumpHint ? ( 141 + <> 142 + {/* Already matched portion */} 143 + {jumpQuery && ( 144 + <span className="bg-primary/30 text-primary font-semibold"> 145 + {revision.change_id_short.slice(0, jumpQuery.length)} 146 + </span> 147 + )} 148 + {/* Next character to type (the hint) */} 149 + <span className="bg-primary text-primary-foreground font-semibold rounded-sm"> 150 + {revision.change_id_short[jumpQuery.length]} 151 + </span> 152 + {/* Rest of the ID */} 153 + <span>{revision.change_id_short.slice(jumpQuery.length + 1)}</span> 154 + </> 155 + ) : ( 156 + revision.change_id_short 157 + )} 158 + </code> 159 + {revision.bookmarks.length > 0 && ( 160 + <span 161 + className="text-xs text-primary font-medium truncate min-w-0 whitespace-nowrap" 162 + title={revision.bookmarks.join(", ")} 163 + > 164 + {revision.bookmarks.join(", ")} 165 + </span> 166 + )} 167 + <span className="text-xs text-muted-foreground truncate min-w-0 shrink-0"> 168 + {revision.author.split("@")[0]} · {revision.timestamp} 169 + </span> 170 + </div> 171 + <div className={`text-sm mt-1 ${isExpanded ? "" : "truncate"}`}>{firstLine}</div> 172 + </div> 173 + {isExpanded && ( 174 + <div className={`px-3 pb-3 pt-0 space-y-3 ${isPendingAbandon ? "blur-sm" : ""}`}> 175 + <pre className="text-xs text-muted-foreground whitespace-pre-wrap break-words font-mono bg-muted/40 border border-border/60 rounded p-2"> 176 + {fullDescription} 177 + </pre> 178 + <div className="border border-border rounded-lg overflow-hidden bg-background"> 179 + <ChangedFilesList 180 + files={changedFilesQuery.data ?? []} 181 + selectedFile={selectedFile} 182 + onSelectFile={handleSelectFile} 183 + isLoading={changedFilesQuery.isLoading} 184 + /> 185 + </div> 186 + </div> 187 + )} 188 + {isPendingAbandon && ( 189 + <div className="absolute inset-0 flex items-center justify-center bg-destructive/10 rounded"> 190 + <div className="text-sm font-medium text-destructive-foreground bg-destructive/90 px-3 py-1.5 rounded"> 191 + Abandon this revision? <kbd className="ml-1 px-1 bg-background/20 rounded">Y</kbd> /{" "} 192 + <kbd className="px-1 bg-background/20 rounded">N</kbd> 193 + </div> 194 + </div> 195 + )} 196 + </div> 197 + </div> 198 + </div> 199 + ); 200 + }
+34
apps/desktop/src/components/revision-graph/constants.ts
··· 1 + /** 2 + * Graph layout constants 3 + */ 4 + export const ROW_HEIGHT = 64; 5 + export const LANE_WIDTH = 20; 6 + export const LANE_PADDING = 8; 7 + export const NODE_RADIUS = 5; 8 + export const MAX_LANES = 2; 9 + 10 + /** 11 + * Colors for lanes in the revision graph 12 + */ 13 + export const LANE_COLORS = [ 14 + "var(--chart-1)", 15 + "var(--chart-2)", 16 + "var(--chart-3)", 17 + "var(--chart-4)", 18 + "var(--chart-5)", 19 + "var(--primary)", 20 + ]; 21 + 22 + /** 23 + * Convert a lane index to X coordinate 24 + */ 25 + export function laneToX(lane: number): number { 26 + return LANE_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2; 27 + } 28 + 29 + /** 30 + * Get the color for a lane index 31 + */ 32 + export function laneColor(lane: number): string { 33 + return LANE_COLORS[lane % LANE_COLORS.length]; 34 + }
+1196
apps/desktop/src/components/revision-graph/index.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import { useNavigate, useSearch } from "@tanstack/react-router"; 3 + import { useVirtualizer } from "@tanstack/react-virtual"; 4 + import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react"; 5 + import { Route } from "@/routes/project.$projectId"; 6 + import { 7 + debugOverlayEnabledAtom, 8 + expandedStacksAtom, 9 + hoveredStackIdAtom, 10 + inlineJumpQueryAtom, 11 + viewModeAtom, 12 + } from "@/atoms"; 13 + import { 14 + reorderForGraph, 15 + detectStacks, 16 + computeRevisionAncestry, 17 + type RevisionStack, 18 + } from "@/components/revision-graph-utils"; 19 + import { prefetchRevisionDiffs } from "@/db"; 20 + import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 21 + import { useRevisionGraphNavigation } from "@/hooks/useRevisionGraphNavigation"; 22 + import type { Revision } from "@/tauri-commands"; 23 + 24 + import { DebugOverlay } from "./DebugOverlay"; 25 + import { EdgeLayer } from "./EdgeLayer"; 26 + import { RevisionRow } from "./RevisionRow"; 27 + import { ROW_HEIGHT, LANE_WIDTH, LANE_PADDING, NODE_RADIUS, MAX_LANES } from "./constants"; 28 + import type { EdgeBinding, GraphNode, GraphRow, GraphData, GraphEdgeType } from "./types"; 29 + 30 + // Re-export types and constants for consumers 31 + export type { EdgeBinding, GraphNode, GraphRow, GraphData, GraphEdgeType } from "./types"; 32 + export { 33 + ROW_HEIGHT, 34 + LANE_WIDTH, 35 + LANE_PADDING, 36 + NODE_RADIUS, 37 + MAX_LANES, 38 + laneToX, 39 + laneColor, 40 + } from "./constants"; 41 + 42 + export interface RevisionGraphHandle { 43 + scrollToChangeId: ( 44 + changeId: string, 45 + options?: { align?: "auto" | "center"; smooth?: boolean }, 46 + ) => void; 47 + } 48 + 49 + interface RevisionGraphProps { 50 + revisions: Revision[]; 51 + selectedRevision: Revision | null; 52 + onSelectRevision: (revision: Revision) => void; 53 + isLoading: boolean; 54 + flash?: { changeId: string; key: number } | null; 55 + repoPath: string | null; 56 + pendingAbandon?: Revision | null; 57 + } 58 + 59 + // Get the set of commit IDs in the working copy's ancestor chain (for lane 0) 60 + function getWorkingCopyChain(revisions: Revision[]): Set<string> { 61 + const commitMap = new Map(revisions.map((r) => [r.commit_id, r])); 62 + const workingCopy = revisions.find((r) => r.is_working_copy); 63 + const chain = new Set<string>(); 64 + 65 + if (workingCopy) { 66 + const queue = [workingCopy.commit_id]; 67 + while (queue.length > 0) { 68 + const id = queue.shift(); 69 + if (!id || chain.has(id)) continue; 70 + chain.add(id); 71 + const rev = commitMap.get(id); 72 + if (rev) { 73 + // Follow first non-missing parent edge for the main chain 74 + const firstEdge = rev.parent_edges.find((e) => e.edge_type !== "missing"); 75 + if (firstEdge && commitMap.has(firstEdge.parent_id)) { 76 + queue.push(firstEdge.parent_id); 77 + } 78 + } 79 + } 80 + } 81 + 82 + return chain; 83 + } 84 + 85 + function buildGraph(revisions: Revision[]): GraphData { 86 + if (revisions.length === 0) return { nodes: [], laneCount: 1, rows: [], edgeBindings: [] }; 87 + 88 + // Map commit_id -> Revision for ancestry lookups 89 + const commitMap = new Map(revisions.map((r) => [r.commit_id, r])); 90 + 91 + // Compute ancestry relationships within the visible revset 92 + // This determines which revisions are actually related and should be connected 93 + const ancestry = computeRevisionAncestry(revisions); 94 + 95 + // Create rows for all revisions (no elision) 96 + const orderedRevisions = reorderForGraph(revisions); 97 + const rows: GraphRow[] = orderedRevisions.map((rev) => ({ 98 + revision: rev, 99 + lane: 0, 100 + maxLaneOnRow: 0, 101 + })); 102 + 103 + // Get working copy chain - these commits should all be in lane 0 104 + const workingCopyChain = getWorkingCopyChain(revisions); 105 + 106 + // Build row index map 107 + const commitToRow = new Map<string, number>(); 108 + rows.forEach((row, idx) => { 109 + commitToRow.set(row.revision.commit_id, idx); 110 + }); 111 + 112 + const commitToLane = new Map<string, number>(); 113 + const nodes: GraphNode[] = []; 114 + 115 + // Simple 2-lane system: 116 + // Lane 0: trunk commits and working copy chain 117 + // Lane 1: everything else (all feature branches) 118 + for (const rev of orderedRevisions) { 119 + const isOnWorkingCopyChain = workingCopyChain.has(rev.commit_id); 120 + if (rev.is_trunk || isOnWorkingCopyChain) { 121 + commitToLane.set(rev.commit_id, 0); 122 + } else { 123 + commitToLane.set(rev.commit_id, 1); 124 + } 125 + } 126 + 127 + // Second pass: build nodes with parent connections 128 + for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { 129 + const row = rows[rowIdx]; 130 + const revision = row.revision; 131 + const lane = commitToLane.get(revision.commit_id) ?? 0; 132 + 133 + const parentConnections: GraphNode["parentConnections"] = []; 134 + 135 + // Use ancestry.parents which only includes parents within the visible revset 136 + // This ensures we only draw edges to actual ancestors, not to unrelated revisions 137 + const visibleParents = ancestry.parents.get(revision.commit_id) ?? []; 138 + 139 + // Also check original parent_edges for edge type info and missing edges 140 + const parentEdgeMap = new Map(revision.parent_edges.map((e) => [e.parent_id, e])); 141 + 142 + // Detect "main merges into branch" scenario 143 + const isMerge = visibleParents.length > 1; 144 + const isMutableCommit = !revision.is_immutable; 145 + 146 + // Process visible parents (ancestors within our revset) 147 + for (let i = 0; i < visibleParents.length; i++) { 148 + const parentId = visibleParents[i]; 149 + const parentEdge = parentEdgeMap.get(parentId); 150 + const edgeType: GraphEdgeType = parentEdge?.edge_type ?? "direct"; 151 + 152 + const parentRow = commitToRow.get(parentId); 153 + if (parentRow === undefined) continue; 154 + 155 + let parentLane = commitToLane.get(parentId); 156 + if (parentLane === undefined) { 157 + // This shouldn't happen after first pass, but handle gracefully 158 + parentLane = lane; 159 + commitToLane.set(parentId, parentLane); 160 + } 161 + 162 + // Check if parent is immutable (look up the actual parent revision) 163 + const parentRev = commitMap.get(parentId); 164 + const parentIsImmutable = parentRev?.is_immutable ?? false; 165 + 166 + // De-emphasize if: merge commit, mutable commit, immutable parent 167 + // IMPORTANT: Don't de-emphasize first parent (i === 0) - that's the mainline 168 + const isDeemphasized = isMerge && isMutableCommit && parentIsImmutable && i > 0; 169 + 170 + parentConnections.push({ parentRow, parentLane, edgeType, isDeemphasized }); 171 + } 172 + 173 + // Handle missing edges (parents outside our revset) 174 + // Only show stub if we have original parents but no visible parents 175 + const hasMissingParents = revision.parent_edges.some((e) => e.edge_type === "missing"); 176 + const hasParentsOutsideView = revision.parent_ids.length > visibleParents.length; 177 + 178 + if ((hasMissingParents || hasParentsOutsideView) && parentConnections.length === 0) { 179 + // All parents are outside the view - show a stub 180 + parentConnections.push({ 181 + parentRow: rowIdx + 1, // Just one row down for the stub 182 + parentLane: lane, 183 + edgeType: "missing", 184 + isMissingStub: true, 185 + }); 186 + } else if (hasMissingParents && parentConnections.length > 0) { 187 + // Some parents are visible, some are missing - add stub for missing ones 188 + parentConnections.push({ 189 + parentRow: rowIdx + 1, 190 + parentLane: lane, 191 + edgeType: "missing", 192 + isMissingStub: true, 193 + }); 194 + } 195 + 196 + nodes.push({ 197 + revision, 198 + row: rowIdx, 199 + lane, 200 + parentConnections, 201 + }); 202 + } 203 + 204 + // Update rows with computed lane info from nodes 205 + let maxLaneUsed = 0; 206 + for (const node of nodes) { 207 + const row = rows[node.row]; 208 + if (row) { 209 + row.lane = node.lane; 210 + row.maxLaneOnRow = node.lane; // Initialize with node's lane 211 + } 212 + maxLaneUsed = Math.max(maxLaneUsed, node.lane); 213 + } 214 + 215 + // Calculate maxLaneOnRow using sweep line algorithm O(n log n) instead of O(n³) 216 + // Collect edge spans as events for efficient processing 217 + type SpanEvent = { row: number; isStart: boolean; lane: number }; 218 + const events: SpanEvent[] = []; 219 + 220 + for (const node of nodes) { 221 + for (const conn of node.parentConnections) { 222 + const nodeRow = node.row; 223 + const parentRow = conn.parentRow; 224 + const nodeLane = node.lane; 225 + const parentLane = conn.parentLane; 226 + 227 + // Cross-lane edge: horizontal segment at node's row uses both lanes 228 + if (nodeLane !== parentLane) { 229 + const row = rows[nodeRow]; 230 + if (row) { 231 + row.maxLaneOnRow = Math.max(row.maxLaneOnRow, nodeLane, parentLane); 232 + } 233 + } 234 + 235 + // Vertical segment: create start/end events instead of iterating rows 236 + const minRow = Math.min(nodeRow, parentRow); 237 + const maxRow = Math.max(nodeRow, parentRow); 238 + if (maxRow > minRow + 1) { 239 + // Edge spans rows [minRow+1, maxRow-1] inclusive 240 + events.push({ row: minRow + 1, isStart: true, lane: parentLane }); 241 + events.push({ row: maxRow, isStart: false, lane: parentLane }); 242 + } 243 + } 244 + } 245 + 246 + // Sort events: by row, with starts before ends at same row 247 + events.sort((a, b) => a.row - b.row || (a.isStart ? -1 : 1)); 248 + 249 + // Sweep through rows, tracking active lane counts 250 + const laneCounts = new Array(MAX_LANES).fill(0); 251 + let eventIdx = 0; 252 + 253 + for (let r = 0; r < rows.length; r++) { 254 + // Process all events at this row 255 + while (eventIdx < events.length && events[eventIdx].row === r) { 256 + const { isStart, lane } = events[eventIdx]; 257 + laneCounts[lane] += isStart ? 1 : -1; 258 + eventIdx++; 259 + } 260 + 261 + // Find max active lane for this row (check from highest lane down) 262 + for (let lane = MAX_LANES - 1; lane >= 0; lane--) { 263 + if (laneCounts[lane] > 0) { 264 + rows[r].maxLaneOnRow = Math.max(rows[r].maxLaneOnRow, lane); 265 + break; 266 + } 267 + } 268 + } 269 + 270 + // Ensure global consistency - propagate lane usage through connected sections 271 + // This handles cases where disconnected branches exist 272 + const globalMaxLane = maxLaneUsed; 273 + for (const row of rows) { 274 + // Ensure every row accounts for at least its own node's lane 275 + row.maxLaneOnRow = Math.max(row.maxLaneOnRow, row.lane); 276 + } 277 + 278 + // Generate semantic edge bindings from nodes' parent connections 279 + const edgeBindings: EdgeBinding[] = []; 280 + let edgeCounter = 0; 281 + 282 + for (const node of nodes) { 283 + for (const conn of node.parentConnections) { 284 + // For missing stubs, use commit_id of source and empty target 285 + const targetCommitId = conn.isMissingStub 286 + ? "" 287 + : (rows[conn.parentRow]?.revision.commit_id ?? ""); 288 + 289 + edgeBindings.push({ 290 + id: `edge-${node.revision.commit_id}-${edgeCounter++}`, 291 + sourceRevisionId: node.revision.commit_id, 292 + targetRevisionId: targetCommitId, 293 + sourceLane: node.lane, 294 + targetLane: conn.parentLane, 295 + edgeType: conn.edgeType, 296 + isDeemphasized: conn.isDeemphasized, 297 + isMissingStub: conn.isMissingStub, 298 + }); 299 + } 300 + } 301 + 302 + return { nodes, laneCount: globalMaxLane + 1, rows, edgeBindings }; 303 + } 304 + 305 + // Compute related revisions (ancestors + descendants) of a selected revision 306 + function getRelatedRevisions(revisions: Revision[], selectedChangeId: string | null): Set<string> { 307 + if (!selectedChangeId) return new Set(); 308 + 309 + const related = new Set<string>(); 310 + const commitIdToChangeId = new Map<string, string>(); 311 + const changeIdToCommitId = new Map<string, string>(); 312 + const childrenMap = new Map<string, string[]>(); // commit_id -> child commit_ids 313 + const parentMap = new Map<string, string[]>(); // commit_id -> parent commit_ids 314 + 315 + // Build maps 316 + for (const rev of revisions) { 317 + commitIdToChangeId.set(rev.commit_id, rev.change_id); 318 + changeIdToCommitId.set(rev.change_id, rev.commit_id); 319 + const parents: string[] = []; 320 + for (const edge of rev.parent_edges) { 321 + if (edge.edge_type === "missing") continue; 322 + parents.push(edge.parent_id); 323 + const children = childrenMap.get(edge.parent_id) ?? []; 324 + children.push(rev.commit_id); 325 + childrenMap.set(edge.parent_id, children); 326 + } 327 + parentMap.set(rev.commit_id, parents); 328 + } 329 + 330 + const selectedCommitId = changeIdToCommitId.get(selectedChangeId); 331 + if (!selectedCommitId) return new Set(); 332 + 333 + // BFS to find ancestors 334 + const ancestorQueue = [selectedCommitId]; 335 + const visited = new Set<string>(); 336 + while (ancestorQueue.length > 0) { 337 + const id = ancestorQueue.shift(); 338 + if (!id || visited.has(id)) continue; 339 + visited.add(id); 340 + const changeId = commitIdToChangeId.get(id); 341 + if (changeId) related.add(changeId); 342 + const parents = parentMap.get(id) ?? []; 343 + for (const parentId of parents) { 344 + ancestorQueue.push(parentId); 345 + } 346 + } 347 + 348 + // BFS to find descendants 349 + const descendantQueue = [selectedCommitId]; 350 + visited.clear(); 351 + while (descendantQueue.length > 0) { 352 + const id = descendantQueue.shift(); 353 + if (!id || visited.has(id)) continue; 354 + visited.add(id); 355 + const changeId = commitIdToChangeId.get(id); 356 + if (changeId) related.add(changeId); 357 + const children = childrenMap.get(id) ?? []; 358 + for (const childId of children) { 359 + descendantQueue.push(childId); 360 + } 361 + } 362 + 363 + return related; 364 + } 365 + 366 + export const RevisionGraph = forwardRef<RevisionGraphHandle, RevisionGraphProps>( 367 + function RevisionGraph( 368 + { revisions, selectedRevision, onSelectRevision, isLoading, flash, repoPath, pendingAbandon }, 369 + ref, 370 + ) { 371 + const parentRef = useRef<HTMLDivElement>(null); 372 + const { 373 + nodes, 374 + laneCount, 375 + rows: allRows, 376 + edgeBindings, 377 + } = useMemo(() => buildGraph(revisions), [revisions]); 378 + const expanded = useSearch({ from: Route.fullPath, select: (s) => s.expanded }); 379 + const search = useSearch({ from: Route.fullPath }); 380 + const navigate = useNavigate({ from: Route.fullPath }); 381 + const [inlineJumpQuery, setInlineJumpQuery] = useAtom(inlineJumpQueryAtom); 382 + const inlineJumpMode = inlineJumpQuery !== null; 383 + const [viewMode] = useAtom(viewModeAtom); 384 + 385 + // Detect collapsible stacks 386 + const stacks = useMemo(() => detectStacks(revisions), [revisions]); 387 + 388 + // Prefetch diffs for all revisions in background 389 + // This eagerly creates TanStack DB collections which trigger async fetches 390 + useMemo(() => { 391 + if (repoPath && revisions.length > 0) { 392 + const changeIds = revisions.map((r) => r.change_id); 393 + prefetchRevisionDiffs(repoPath, changeIds); 394 + } 395 + }, [repoPath, revisions]); 396 + 397 + // Track which stacks are expanded (empty = all collapsed by default) 398 + const [expandedStacks, setExpandedStacks] = useAtom(expandedStacksAtom); 399 + // Track hovered stack for coordinated edge highlighting 400 + const [, setHoveredStackId] = useAtom(hoveredStackIdAtom); 401 + 402 + // Read focused stack and selection from URL params 403 + const focusedStackId = useSearch({ from: Route.fullPath, select: (s) => s.stack ?? null }); 404 + const selectedParam = useSearch({ from: Route.fullPath, select: (s) => s.selected ?? "" }); 405 + const selectedRevisions = useMemo(() => { 406 + if (!selectedParam) return new Set<string>(); 407 + return new Set(selectedParam.split(",").filter(Boolean)); 408 + }, [selectedParam]); 409 + 410 + // Update URL with new selection 411 + function setSelectedRevisions(updater: Set<string> | ((prev: Set<string>) => Set<string>)) { 412 + const newSelection = typeof updater === "function" ? updater(selectedRevisions) : updater; 413 + const selected = newSelection.size > 0 ? [...newSelection].join(",") : undefined; 414 + navigate({ 415 + search: { ...search, selected }, 416 + replace: true, 417 + }); 418 + } 419 + 420 + // Toggle a revision's checked state 421 + function toggleRevisionCheck(changeId: string) { 422 + const next = new Set(selectedRevisions); 423 + if (next.has(changeId)) { 424 + next.delete(changeId); 425 + } else { 426 + next.add(changeId); 427 + } 428 + setSelectedRevisions(next); 429 + } 430 + 431 + // Build lookup maps for stacks 432 + const { stackByChangeId, stackById, intermediateChangeIds } = useMemo(() => { 433 + const byChangeId = new Map<string, RevisionStack>(); 434 + const byId = new Map<string, RevisionStack>(); 435 + const intermediates = new Set<string>(); 436 + 437 + for (const stack of stacks) { 438 + byId.set(stack.id, stack); 439 + for (const changeId of stack.changeIds) { 440 + byChangeId.set(changeId, stack); 441 + } 442 + for (const changeId of stack.intermediateChangeIds) { 443 + intermediates.add(changeId); 444 + } 445 + } 446 + return { stackByChangeId: byChangeId, stackById: byId, intermediateChangeIds: intermediates }; 447 + }, [stacks]); 448 + 449 + // Build node lane lookup by change_id (needed for display row construction) 450 + const changeIdToLane = useMemo(() => { 451 + const map = new Map<string, number>(); 452 + for (const node of nodes) { 453 + map.set(node.revision.change_id, node.lane); 454 + } 455 + return map; 456 + }, [nodes]); 457 + 458 + // Display row can be either a revision row or a collapsed stack row 459 + type DisplayRow = 460 + | { type: "revision"; row: GraphRow } 461 + | { type: "collapsed-stack"; stack: RevisionStack; lane: number }; 462 + 463 + // Filter rows to hide collapsed intermediate revisions and replace with a single collapsed stack row 464 + const displayRows = useMemo(() => { 465 + const result: DisplayRow[] = []; 466 + 467 + for (const row of allRows) { 468 + const changeId = row.revision.change_id; 469 + const stack = stackByChangeId.get(changeId); 470 + 471 + if (stack && intermediateChangeIds.has(changeId)) { 472 + // This is an intermediate revision in a stack 473 + if (expandedStacks.has(stack.id)) { 474 + // Stack is expanded - show the revision 475 + result.push({ type: "revision", row }); 476 + } 477 + // If collapsed, skip this row 478 + } else { 479 + // Not an intermediate or not in a stack - always show 480 + result.push({ type: "revision", row }); 481 + 482 + // If this is the top of a collapsed stack, insert a collapsed stack row after it 483 + if (stack && changeId === stack.topChangeId && !expandedStacks.has(stack.id)) { 484 + const lane = changeIdToLane.get(changeId) ?? 0; 485 + result.push({ type: "collapsed-stack", stack, lane }); 486 + } 487 + } 488 + } 489 + 490 + return result; 491 + }, [allRows, stackByChangeId, intermediateChangeIds, expandedStacks, changeIdToLane]); 492 + 493 + // Extract just revision rows for edge positioning and other logic 494 + const rows = useMemo( 495 + () => 496 + displayRows 497 + .filter((d): d is { type: "revision"; row: GraphRow } => d.type === "revision") 498 + .map((d) => d.row), 499 + [displayRows], 500 + ); 501 + 502 + // Toggle stack expansion 503 + function toggleStackExpansion(stackId: string) { 504 + // Clear hover state since stack structure is changing 505 + setHoveredStackId(null); 506 + setExpandedStacks((prev) => { 507 + const next = new Set(prev); 508 + if (next.has(stackId)) { 509 + next.delete(stackId); 510 + } else { 511 + next.add(stackId); 512 + } 513 + return next; 514 + }); 515 + } 516 + 517 + // Toggle stack expansion and focus the top of newly revealed revisions when expanding 518 + function handleToggleStack(stackId: string) { 519 + const stack = stackById.get(stackId); 520 + const isCurrentlyExpanded = expandedStacks.has(stackId); 521 + toggleStackExpansion(stackId); 522 + 523 + // If expanding (not currently expanded), focus the first intermediate revision 524 + // (the top of the newly revealed revisions, not the already-visible top of the stack) 525 + if (!isCurrentlyExpanded && stack && stack.intermediateChangeIds.length > 0) { 526 + navigate({ 527 + search: { 528 + ...search, 529 + stack: undefined, 530 + rev: stack.intermediateChangeIds[0], 531 + selected: undefined, 532 + selectionAnchor: undefined, 533 + }, 534 + replace: true, 535 + }); 536 + } 537 + } 538 + 539 + // Maps for lookups - by change_id for UI, by commit_id for graph edges 540 + const revisionMapByChangeId = new Map(revisions.map((r) => [r.change_id, r])); 541 + const revisionMapByCommitId = new Map(revisions.map((r) => [r.commit_id, r])); 542 + 543 + // Compute related revisions for dimming logic 544 + // When a stack is focused, use the stack's top and bottom as the "selected" revisions 545 + const focusedStack = focusedStackId ? stackById.get(focusedStackId) : null; 546 + const relatedRevisions = useMemo(() => { 547 + if (focusedStack) { 548 + // When stack is focused, highlight the stack endpoints and their ancestors/descendants 549 + const topRelated = getRelatedRevisions(revisions, focusedStack.topChangeId); 550 + const bottomRelated = getRelatedRevisions(revisions, focusedStack.bottomChangeId); 551 + // Union of both sets 552 + return new Set([...topRelated, ...bottomRelated]); 553 + } 554 + return getRelatedRevisions(revisions, selectedRevision?.change_id ?? null); 555 + }, [revisions, focusedStack, selectedRevision?.change_id]); 556 + 557 + // Build change_id -> displayRow index map for scrolling and edge positioning 558 + // IMPORTANT: Use displayRows indices (not rows) to match virtualizer positioning 559 + const changeIdToIndex = new Map<string, number>(); 560 + const commitToRowIndex = new Map<string, number>(); 561 + for (let i = 0; i < displayRows.length; i++) { 562 + const displayRow = displayRows[i]; 563 + if (displayRow.type === "revision") { 564 + changeIdToIndex.set(displayRow.row.revision.change_id, i); 565 + commitToRowIndex.set(displayRow.row.revision.commit_id, i); 566 + } 567 + } 568 + 569 + // Create a mapping of change_id -> commit_id for edge remapping 570 + const changeIdToCommitId = useMemo(() => { 571 + const map = new Map<string, string>(); 572 + for (const rev of revisions) { 573 + map.set(rev.change_id, rev.commit_id); 574 + } 575 + return map; 576 + }, [revisions]); 577 + 578 + // Filter edge bindings to handle collapsed/expanded stacks 579 + // When a stack is collapsed, edges from/to intermediates should be remapped 580 + // When a stack is expanded, edges within it should be clickable to collapse 581 + const filteredEdgeBindings = useMemo(() => { 582 + // Maps for collapsed stacks: intermediate commits -> bottom commit 583 + const hiddenToVisible = new Map<string, { targetCommitId: string; stack: RevisionStack }>(); 584 + const topCommitToStack = new Map<string, RevisionStack>(); 585 + // Map for expanded stacks: all commits in expanded stacks 586 + const commitToExpandedStack = new Map<string, RevisionStack>(); 587 + 588 + for (const stack of stacks) { 589 + const isExpanded = expandedStacks.has(stack.id); 590 + 591 + if (isExpanded) { 592 + for (const changeId of stack.changeIds) { 593 + const commitId = changeIdToCommitId.get(changeId); 594 + if (commitId) commitToExpandedStack.set(commitId, stack); 595 + } 596 + } else { 597 + const bottomCommitId = changeIdToCommitId.get(stack.bottomChangeId); 598 + const topCommitId = changeIdToCommitId.get(stack.topChangeId); 599 + if (!bottomCommitId || !topCommitId) continue; 600 + 601 + topCommitToStack.set(topCommitId, stack); 602 + for (const intermediateChangeId of stack.intermediateChangeIds) { 603 + const intermediateCommitId = changeIdToCommitId.get(intermediateChangeId); 604 + if (intermediateCommitId) { 605 + hiddenToVisible.set(intermediateCommitId, { targetCommitId: bottomCommitId, stack }); 606 + } 607 + } 608 + } 609 + } 610 + 611 + const remapped: EdgeBinding[] = []; 612 + const seen = new Set<string>(); 613 + 614 + for (const binding of edgeBindings) { 615 + const { sourceRevisionId, targetRevisionId } = binding; 616 + const sourceExpandedStack = commitToExpandedStack.get(sourceRevisionId); 617 + 618 + // Skip hidden intermediates (unless in an expanded stack) 619 + if (hiddenToVisible.has(sourceRevisionId) && !sourceExpandedStack) continue; 620 + 621 + let targetId = targetRevisionId; 622 + let collapsedStackId: string | undefined; 623 + let collapsedCount: number | undefined; 624 + let expandedStackId: string | undefined; 625 + 626 + if (sourceExpandedStack) { 627 + // Source is in expanded stack - check if edge is within same stack 628 + const targetExpandedStack = commitToExpandedStack.get(targetRevisionId); 629 + if (targetExpandedStack?.id === sourceExpandedStack.id) { 630 + expandedStackId = sourceExpandedStack.id; 631 + } 632 + } else { 633 + // Source not in expanded stack - apply collapsed stack remapping 634 + const hiddenInfo = hiddenToVisible.get(targetId); 635 + if (hiddenInfo) { 636 + const isFromStackTop = topCommitToStack.has(sourceRevisionId); 637 + targetId = hiddenInfo.targetCommitId; 638 + if (isFromStackTop) { 639 + collapsedStackId = hiddenInfo.stack.id; 640 + collapsedCount = hiddenInfo.stack.intermediateChangeIds.length; 641 + } 642 + } 643 + } 644 + 645 + // Deduplicate 646 + const key = `${sourceRevisionId}->${targetId}`; 647 + if (seen.has(key)) continue; 648 + seen.add(key); 649 + 650 + remapped.push({ 651 + ...binding, 652 + targetRevisionId: targetId, 653 + collapsedStackId, 654 + collapsedCount, 655 + expandedStackId, 656 + }); 657 + } 658 + 659 + return remapped; 660 + }, [edgeBindings, stacks, expandedStacks, changeIdToCommitId]); 661 + 662 + const [debugEnabled, setDebugEnabled] = useAtom(debugOverlayEnabledAtom); 663 + 664 + // Ref to hold scroll function - only scrolls if item is outside visible range 665 + const scrollToIndexIfNeededRef = useRef<((index: number) => void) | null>(null); 666 + 667 + // Determine if selected revision is expanded based on URL search params 668 + // Only allow inline expansion in overview mode (viewMode=1) 669 + const isSelectedExpanded = viewMode === 1 && expanded === true && !!selectedRevision; 670 + 671 + // Keyboard navigation (j/k/J/K/arrows/g/G/Home/End/h/l/Space/Enter/Escape) 672 + useRevisionGraphNavigation({ 673 + revisions, 674 + displayRows, 675 + changeIdToIndex, 676 + selectedRevision, 677 + enabled: !inlineJumpMode, 678 + scrollToIndex: (index) => scrollToIndexIfNeededRef.current?.(index), 679 + onToggleStack: handleToggleStack, 680 + isSelectedExpanded, 681 + }); 682 + 683 + // Toggle debug overlay with Ctrl+Shift+D 684 + useKeyboardShortcut({ 685 + key: "D", 686 + modifiers: { ctrl: true, shift: true }, 687 + onPress: () => setDebugEnabled((prev) => !prev), 688 + }); 689 + 690 + // Track if we just activated jump mode to ignore the same 'f' keypress 691 + const justActivatedRef = useRef(false); 692 + 693 + // Activate inline jump mode with 'f' key 694 + useKeyboardShortcut({ 695 + key: "f", 696 + modifiers: {}, 697 + onPress: () => { 698 + justActivatedRef.current = true; 699 + setInlineJumpQuery(""); 700 + // Clear the flag after a short delay (same event loop tick protection) 701 + requestAnimationFrame(() => { 702 + justActivatedRef.current = false; 703 + }); 704 + }, 705 + enabled: !inlineJumpMode, 706 + }); 707 + 708 + // Cancel inline jump mode with Escape 709 + useKeyboardShortcut({ 710 + key: "Escape", 711 + modifiers: {}, 712 + onPress: () => setInlineJumpQuery(null), 713 + enabled: inlineJumpMode, 714 + }); 715 + 716 + const rowVirtualizer = useVirtualizer({ 717 + count: displayRows.length, 718 + getScrollElement: () => parentRef.current, 719 + estimateSize: (index: number) => { 720 + const displayRow = displayRows[index]; 721 + if (displayRow.type === "collapsed-stack") { 722 + // Same height as a regular revision row 723 + return ROW_HEIGHT; 724 + } 725 + const row = displayRow.row; 726 + const isExpanded = 727 + isSelectedExpanded && row.revision.change_id === selectedRevision?.change_id; 728 + return isExpanded ? ROW_HEIGHT * 3 : ROW_HEIGHT; 729 + }, 730 + overscan: 10, 731 + debug: debugEnabled, 732 + }); 733 + 734 + // scrollToIndexIfNeededRef is updated below after virtualItems is computed 735 + 736 + // Expose scrollToChangeId method via ref 737 + useImperativeHandle(ref, () => ({ 738 + scrollToChangeId: ( 739 + changeId: string, 740 + options?: { align?: "auto" | "center"; smooth?: boolean }, 741 + ) => { 742 + const index = changeIdToIndex.get(changeId); 743 + if (index === undefined) { 744 + return; 745 + } 746 + 747 + const scrollElement = parentRef.current; 748 + if (!scrollElement) { 749 + return; 750 + } 751 + 752 + const scrollTop = scrollElement.scrollTop; 753 + const viewportHeight = scrollElement.clientHeight; 754 + const itemTop = index * ROW_HEIGHT; 755 + const itemBottom = itemTop + ROW_HEIGHT; 756 + 757 + // For jump commands (smooth/center), always scroll 758 + if (options?.smooth || options?.align === "center") { 759 + rowVirtualizer.scrollToIndex(index, { 760 + align: "center", 761 + behavior: "smooth", 762 + }); 763 + return; 764 + } 765 + 766 + // For step navigation, manually scroll only if item is outside viewport 767 + const isAboveViewport = itemTop < scrollTop; 768 + const isBelowViewport = itemBottom > scrollTop + viewportHeight; 769 + 770 + if (isAboveViewport) { 771 + scrollElement.scrollTop = itemTop; 772 + } else if (isBelowViewport) { 773 + scrollElement.scrollTop = itemBottom - viewportHeight; 774 + } 775 + }, 776 + })); 777 + 778 + function handleSelect(changeId: string, modifiers: { shift: boolean; meta: boolean }) { 779 + const revision = revisionMapByChangeId.get(changeId); 780 + if (!revision) return; 781 + 782 + // Cmd/Ctrl+click: toggle selection 783 + if (modifiers.meta) { 784 + toggleRevisionCheck(changeId); 785 + return; 786 + } 787 + 788 + // Shift+click: range select from focused to clicked 789 + if (modifiers.shift && selectedRevision) { 790 + const focusedIndex = changeIdToIndex.get(selectedRevision.change_id); 791 + const clickedIndex = changeIdToIndex.get(changeId); 792 + if (focusedIndex !== undefined && clickedIndex !== undefined) { 793 + const startIdx = Math.min(focusedIndex, clickedIndex); 794 + const endIdx = Math.max(focusedIndex, clickedIndex); 795 + const newSelection = new Set<string>(); 796 + for (let i = startIdx; i <= endIdx; i++) { 797 + const displayRow = displayRows[i]; 798 + if (displayRow.type === "revision") { 799 + newSelection.add(displayRow.row.revision.change_id); 800 + } 801 + } 802 + // Update selection in URL 803 + const selected = newSelection.size > 0 ? [...newSelection].join(",") : undefined; 804 + navigate({ 805 + search: { ...search, selected, stack: undefined }, 806 + replace: true, 807 + }); 808 + } 809 + return; 810 + } 811 + 812 + // Plain click: focus revision (clear selection, anchor, and stack focus) 813 + navigate({ 814 + search: { 815 + ...search, 816 + selected: undefined, 817 + selectionAnchor: undefined, 818 + stack: undefined, 819 + rev: changeId, 820 + }, 821 + replace: true, 822 + }); 823 + } 824 + 825 + const virtualItems = rowVirtualizer.getVirtualItems(); 826 + const visibleStartRow = virtualItems[0]?.index ?? 0; 827 + const visibleEndRow = virtualItems[virtualItems.length - 1]?.index ?? 0; 828 + const totalHeight = rowVirtualizer.getTotalSize(); 829 + 830 + // Update scroll ref - compute actually visible range based on scroll position 831 + scrollToIndexIfNeededRef.current = (index: number) => { 832 + const scrollEl = parentRef.current; 833 + if (!scrollEl) return; 834 + 835 + const scrollTop = scrollEl.scrollTop; 836 + const clientHeight = scrollEl.clientHeight; 837 + 838 + // Calculate which rows are fully visible (not just rendered with overscan) 839 + // Use ceil for start (first fully visible) and floor-1 for end (last fully visible) 840 + const visibleStart = Math.ceil(scrollTop / ROW_HEIGHT); 841 + const visibleEnd = Math.floor((scrollTop + clientHeight) / ROW_HEIGHT) - 1; 842 + 843 + const shouldScroll = index < visibleStart || index > visibleEnd; 844 + 845 + // Only scroll if the item is outside the fully visible range 846 + if (shouldScroll) { 847 + rowVirtualizer.scrollToIndex(index, { align: "auto" }); 848 + } 849 + }; 850 + const rowOffsets = new Map<number, number>(); 851 + for (const item of virtualItems) { 852 + rowOffsets.set(item.index, item.start); 853 + } 854 + 855 + // Compute jump hints for visible rows based on change ID prefix matching 856 + const { jumpHintsMap, matchingRevisions } = useMemo(() => { 857 + const hints = new Map<string, string>(); 858 + const matches: Array<{ changeId: string; shortId: string }> = []; 859 + 860 + if (inlineJumpMode && revisions.length > 0) { 861 + const query = inlineJumpQuery ?? ""; 862 + 863 + // First, collect all visible revisions that match the current query 864 + for (const item of virtualItems) { 865 + const row = rows[item.index]; 866 + if (row) { 867 + const shortId = row.revision.change_id_short.toLowerCase(); 868 + if (shortId.startsWith(query.toLowerCase())) { 869 + matches.push({ 870 + changeId: row.revision.change_id, 871 + shortId: row.revision.change_id_short, 872 + }); 873 + } 874 + } 875 + } 876 + 877 + // Assign hints based on the next character in the change ID 878 + if (query === "") { 879 + // Initial state: show first letter of each change ID 880 + for (const { changeId, shortId } of matches) { 881 + hints.set(changeId, shortId[0].toLowerCase()); 882 + } 883 + } else { 884 + // After typing: show the next letter to type, or secondary hints if needed 885 + const nextCharIndex = query.length; 886 + const nextChars = new Map<string, Array<{ changeId: string; shortId: string }>>(); 887 + 888 + // Group by next character 889 + for (const rev of matches) { 890 + const nextChar = rev.shortId[nextCharIndex]?.toLowerCase() ?? ""; 891 + if (nextChar) { 892 + const group = nextChars.get(nextChar) ?? []; 893 + group.push(rev); 894 + nextChars.set(nextChar, group); 895 + } 896 + } 897 + 898 + // Assign hints 899 + for (const { changeId, shortId } of matches) { 900 + const nextChar = shortId[nextCharIndex]?.toLowerCase() ?? ""; 901 + if (nextChar) { 902 + hints.set(changeId, nextChar); 903 + } 904 + } 905 + } 906 + } 907 + 908 + return { jumpHintsMap: hints, matchingRevisions: matches }; 909 + }, [inlineJumpMode, inlineJumpQuery, revisions.length, virtualItems, rows]); 910 + 911 + // Store matching revisions in a ref for use in the effect 912 + const matchingRevisionsRef = useRef(matchingRevisions); 913 + matchingRevisionsRef.current = matchingRevisions; 914 + 915 + // Handle jump hint letter key presses 916 + useEffect(() => { 917 + if (!inlineJumpMode) return; 918 + 919 + function handleJumpKey(event: KeyboardEvent) { 920 + const activeElement = document.activeElement; 921 + if (activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA") { 922 + return; 923 + } 924 + 925 + const key = event.key.toLowerCase(); 926 + 927 + // Ignore the activation key 'f' if we just activated (prevents same event capture) 928 + if (key === "f" && justActivatedRef.current) { 929 + return; 930 + } 931 + 932 + // Handle backspace to remove last character 933 + if (event.key === "Backspace") { 934 + event.preventDefault(); 935 + const currentQuery = inlineJumpQuery ?? ""; 936 + if (currentQuery.length > 0) { 937 + setInlineJumpQuery(currentQuery.slice(0, -1)); 938 + } else { 939 + setInlineJumpQuery(null); // Cancel if already empty 940 + } 941 + return; 942 + } 943 + 944 + // Only accept alphanumeric characters for the query 945 + if (/^[a-z0-9]$/i.test(key)) { 946 + event.preventDefault(); 947 + const newQuery = (inlineJumpQuery ?? "") + key; 948 + 949 + // Find matching revisions with the new query 950 + const matches = matchingRevisionsRef.current.filter(({ shortId }) => 951 + shortId.toLowerCase().startsWith(newQuery.toLowerCase()), 952 + ); 953 + 954 + if (matches.length === 1) { 955 + // Single match - jump directly 956 + setInlineJumpQuery(null); 957 + const revision = revisionMapByChangeId.get(matches[0].changeId); 958 + if (revision) { 959 + onSelectRevision(revision); 960 + } 961 + } else if (matches.length === 0) { 962 + // No matches - cancel 963 + setInlineJumpQuery(null); 964 + } else { 965 + // Multiple matches - update query to filter 966 + setInlineJumpQuery(newQuery); 967 + } 968 + return; 969 + } 970 + 971 + // Any other non-modifier key cancels jump mode 972 + if (!["Shift", "Control", "Alt", "Meta", "CapsLock"].includes(event.key)) { 973 + setInlineJumpQuery(null); 974 + } 975 + } 976 + 977 + window.addEventListener("keydown", handleJumpKey); 978 + return () => window.removeEventListener("keydown", handleJumpKey); 979 + }, [ 980 + inlineJumpMode, 981 + inlineJumpQuery, 982 + setInlineJumpQuery, 983 + revisionMapByChangeId, 984 + onSelectRevision, 985 + ]); 986 + 987 + if (revisions.length === 0) { 988 + return ( 989 + <div className="flex items-center justify-center h-full bg-background text-muted-foreground text-sm"> 990 + {isLoading ? "Loading revisions..." : "Select a project to view revisions"} 991 + </div> 992 + ); 993 + } 994 + 995 + const selectedIndex = selectedRevision 996 + ? changeIdToIndex.get(selectedRevision.change_id) 997 + : undefined; 998 + 999 + const workingCopy = revisions.find((r) => r.is_working_copy); 1000 + const wcIndex = workingCopy ? changeIdToIndex.get(workingCopy.change_id) : undefined; 1001 + 1002 + // Calculate edge layer dimensions and row center positions 1003 + const TOP_PADDING = 16; // Matches pt-4 on RevisionRow 1004 + const CONTENT_MIN_HEIGHT = 56; // Matches min-h-[56px] on RevisionRow content 1005 + const getRowStart = (row: number) => rowOffsets.get(row) ?? row * ROW_HEIGHT; 1006 + const getRowCenter = (row: number) => getRowStart(row) + TOP_PADDING + CONTENT_MIN_HEIGHT / 2; 1007 + const graphWidth = LANE_PADDING + laneCount * LANE_WIDTH + NODE_RADIUS + 2; 1008 + 1009 + return ( 1010 + <div 1011 + ref={parentRef} 1012 + className="h-full overflow-auto ascii-bg" 1013 + style={{ overflowAnchor: "none" }} 1014 + > 1015 + <div 1016 + className="relative" 1017 + style={{ 1018 + height: `${totalHeight}px`, 1019 + width: "100%", 1020 + }} 1021 + > 1022 + {/* Edge layer - semantic edge components positioned absolutely */} 1023 + {/* Key includes expandedStacks to force remount when stack state changes */} 1024 + <EdgeLayer 1025 + key={`edges-${[...expandedStacks].sort().join(",")}`} 1026 + bindings={filteredEdgeBindings} 1027 + commitToRow={commitToRowIndex} 1028 + revisionMap={revisionMapByCommitId} 1029 + getRowCenter={getRowCenter} 1030 + totalHeight={totalHeight} 1031 + width={graphWidth} 1032 + visibleStartRow={visibleStartRow} 1033 + visibleEndRow={visibleEndRow} 1034 + stackById={stackById} 1035 + changeIdToCommitId={changeIdToCommitId} 1036 + onToggleStack={handleToggleStack} 1037 + /> 1038 + 1039 + {/* Virtualized rows with inline graph nodes */} 1040 + <div className="relative z-10"> 1041 + {virtualItems.map((virtualRow) => { 1042 + const displayRow = displayRows[virtualRow.index]; 1043 + 1044 + // Collapsed stack row - styled as stacked cards 1045 + if (displayRow.type === "collapsed-stack") { 1046 + const { stack, lane } = displayRow; 1047 + const nodeAreaWidth = LANE_PADDING + (lane + 1) * LANE_WIDTH; 1048 + const count = stack.intermediateChangeIds.length; 1049 + // Show up to 3 stacked card layers 1050 + const layers = Math.min(count, 3); 1051 + 1052 + // Check if this stack is related to the selected revision (for dimming) 1053 + const isStackRelated = stack.changeIds.some((id) => relatedRevisions.has(id)); 1054 + const isStackDimmed = selectedRevision !== null && !isStackRelated; 1055 + const isStackFocused = focusedStackId === stack.id; 1056 + 1057 + return ( 1058 + <div 1059 + key={`collapsed-${stack.id}`} 1060 + ref={rowVirtualizer.measureElement} 1061 + data-index={virtualRow.index} 1062 + className="absolute left-0 w-full" 1063 + style={{ 1064 + transform: `translateY(${virtualRow.start}px)`, 1065 + height: ROW_HEIGHT, 1066 + }} 1067 + > 1068 + <div className="flex flex-col relative" style={{ height: ROW_HEIGHT }}> 1069 + <div className="flex items-start min-h-[56px] pt-4"> 1070 + {/* Spacer for graph area */} 1071 + <div className="shrink-0" style={{ width: nodeAreaWidth }} /> 1072 + <button 1073 + type="button" 1074 + onClick={() => handleToggleStack(stack.id)} 1075 + className={`relative flex-1 mr-2 min-w-0 my-2 mx-1 cursor-pointer group ${isStackDimmed ? "opacity-40" : ""}`} 1076 + style={{ height: 40 }} 1077 + data-focused={isStackFocused || undefined} 1078 + data-stack-id={stack.id} 1079 + > 1080 + {/* Stacked card layers */} 1081 + {Array.from({ length: layers }).map((_, i) => { 1082 + const layerIndex = layers - 1 - i; // Render back layers first 1083 + const offset = layerIndex * 4; 1084 + const isTopLayer = layerIndex === 0; 1085 + const scale = 1 - layerIndex * 0.02; 1086 + 1087 + return ( 1088 + <div 1089 + key={layerIndex} 1090 + className={`absolute left-0 right-0 rounded border shadow-sm group-hover:border-muted-foreground/50 ${ 1091 + isStackFocused && isTopLayer 1092 + ? "bg-accent/40 border-accent/60" 1093 + : "bg-card border-border" 1094 + } text-card-foreground`} 1095 + style={{ 1096 + top: 0, 1097 + height: 40, 1098 + transform: `translateY(${offset}px) scaleX(${scale})`, 1099 + transformOrigin: "top center", 1100 + opacity: 1 - layerIndex * 0.2, 1101 + zIndex: layers - layerIndex, 1102 + }} 1103 + /> 1104 + ); 1105 + })} 1106 + {/* Content overlay on top card */} 1107 + <div 1108 + className="absolute inset-0 flex items-center justify-center gap-2 rounded" 1109 + style={{ zIndex: layers + 1, height: 40 }} 1110 + > 1111 + <svg 1112 + className="w-3.5 h-3.5 text-muted-foreground" 1113 + fill="none" 1114 + viewBox="0 0 24 24" 1115 + stroke="currentColor" 1116 + aria-hidden="true" 1117 + > 1118 + <path 1119 + strokeLinecap="round" 1120 + strokeLinejoin="round" 1121 + strokeWidth={2} 1122 + d="M19 9l-7 7-7-7" 1123 + /> 1124 + </svg> 1125 + <span className="text-xs text-muted-foreground group-hover:text-foreground"> 1126 + {count} hidden revision{count !== 1 ? "s" : ""} 1127 + </span> 1128 + </div> 1129 + </button> 1130 + </div> 1131 + </div> 1132 + </div> 1133 + ); 1134 + } 1135 + 1136 + // Regular revision row 1137 + const { row } = displayRow; 1138 + const lane = changeIdToLane.get(row.revision.change_id) ?? 0; 1139 + const isFlashing = flash?.changeId === row.revision.change_id; 1140 + const isDimmed = 1141 + (selectedRevision !== null || focusedStackId !== null) && 1142 + !relatedRevisions.has(row.revision.change_id); 1143 + // Only show focus if no stack is focused 1144 + const isFocused = 1145 + !focusedStackId && selectedRevision?.change_id === row.revision.change_id; 1146 + const isSelected = isFocused; 1147 + const isExpanded = isSelectedExpanded && isFocused; 1148 + 1149 + return ( 1150 + <div 1151 + key={row.revision.change_id} 1152 + ref={rowVirtualizer.measureElement} 1153 + data-index={virtualRow.index} 1154 + className="absolute left-0 w-full" 1155 + style={{ 1156 + transform: `translateY(${virtualRow.start}px)`, 1157 + }} 1158 + > 1159 + <RevisionRow 1160 + revision={row.revision} 1161 + lane={lane} 1162 + maxLaneOnRow={row.maxLaneOnRow} 1163 + isSelected={isSelected} 1164 + isChecked={selectedRevisions.has(row.revision.change_id)} 1165 + isFocused={isFocused} 1166 + onSelect={handleSelect} 1167 + isFlashing={isFlashing} 1168 + isDimmed={isDimmed} 1169 + isExpanded={isExpanded} 1170 + repoPath={repoPath} 1171 + isPendingAbandon={pendingAbandon?.change_id === row.revision.change_id} 1172 + jumpModeActive={inlineJumpMode} 1173 + jumpQuery={inlineJumpQuery ?? ""} 1174 + jumpHint={jumpHintsMap.get(row.revision.change_id) ?? null} 1175 + /> 1176 + </div> 1177 + ); 1178 + })} 1179 + </div> 1180 + </div> 1181 + 1182 + {/* Debug overlay - toggle with Ctrl+Shift+D */} 1183 + <DebugOverlay 1184 + scrollRef={parentRef} 1185 + selectedIndex={selectedIndex} 1186 + visibleStartRow={visibleStartRow} 1187 + visibleEndRow={visibleEndRow} 1188 + totalRows={rows.length} 1189 + wcIndex={wcIndex} 1190 + selectedChangeId={selectedRevision?.change_id} 1191 + wcChangeId={workingCopy?.change_id} 1192 + /> 1193 + </div> 1194 + ); 1195 + }, 1196 + );
+67
apps/desktop/src/components/revision-graph/types.ts
··· 1 + import type { Revision } from "@/tauri-commands"; 2 + 3 + /** 4 + * Type of connection between revisions 5 + */ 6 + export type GraphEdgeType = "direct" | "indirect" | "missing"; 7 + 8 + /** 9 + * Represents a semantic binding between two revisions (like tldraw's shape bindings) 10 + */ 11 + export interface EdgeBinding { 12 + id: string; 13 + sourceRevisionId: string; 14 + targetRevisionId: string; 15 + sourceLane: number; 16 + targetLane: number; 17 + edgeType: GraphEdgeType; 18 + isDeemphasized?: boolean; 19 + isMissingStub?: boolean; 20 + /** If set, this edge represents a collapsed stack and clicking it should expand */ 21 + collapsedStackId?: string; 22 + /** Number of hidden revisions in the collapsed stack */ 23 + collapsedCount?: number; 24 + /** If set, this edge is part of an expanded stack and clicking it should collapse */ 25 + expandedStackId?: string; 26 + } 27 + 28 + /** 29 + * Connection from a revision to one of its parents 30 + */ 31 + export interface ParentConnection { 32 + parentRow: number; 33 + parentLane: number; 34 + edgeType: GraphEdgeType; 35 + isDeemphasized?: boolean; 36 + isMissingStub?: boolean; 37 + } 38 + 39 + /** 40 + * A node in the revision graph 41 + */ 42 + export interface GraphNode { 43 + revision: Revision; 44 + row: number; 45 + lane: number; 46 + parentConnections: ParentConnection[]; 47 + } 48 + 49 + /** 50 + * A row in the revision graph 51 + */ 52 + export interface GraphRow { 53 + revision: Revision; 54 + lane: number; 55 + /** Rightmost lane occupied by any graph element (node or edge) on this row */ 56 + maxLaneOnRow: number; 57 + } 58 + 59 + /** 60 + * Complete graph data structure 61 + */ 62 + export interface GraphData { 63 + nodes: GraphNode[]; 64 + laneCount: number; 65 + rows: GraphRow[]; 66 + edgeBindings: EdgeBinding[]; 67 + }