a very good jj gui
0
fork

Configure Feed

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

feat(graph): improve lane assignment with trunk detection and related commit dimming

+388 -529
+388 -529
apps/desktop/src/components/RevisionGraph.tsx
··· 1 - import { useAtom } from "@effect-atom/atom-react"; 2 1 import { useSearch } from "@tanstack/react-router"; 3 - import { expandedElidedSectionsAtom } from "@/atoms"; 4 2 import { Badge } from "@/components/ui/badge"; 5 3 import { Button } from "@/components/ui/button"; 6 4 import { ScrollArea } from "@/components/ui/scroll-area"; ··· 15 13 } 16 14 17 15 const ROW_HEIGHT = 56; 18 - const LANE_WIDTH = 24; 19 - const LANE_PADDING = 12; 16 + const LANE_WIDTH = 20; 17 + const LANE_PADDING = 8; 20 18 const NODE_RADIUS = 5; 21 - const MAX_LANES = 6; 19 + const MAX_LANES = 3; 22 20 23 21 const LANE_COLORS = [ 24 22 "hsl(45 100% 55%)", // yellow (main branch) ··· 29 27 "hsl(340 85% 60%)", // pink 30 28 ]; 31 29 32 - type GraphEdgeType = "direct" | "indirect"; 30 + type GraphEdgeType = "direct" | "indirect" | "missing"; 33 31 34 32 interface ParentConnection { 35 33 parentRow: number; 36 34 parentLane: number; 37 35 edgeType: GraphEdgeType; 38 - } 39 - 40 - type GraphNodeType = "revision" | "elided"; 41 - 42 - interface ElidedInfo { 43 - id: string; 44 - elidedRevisions: Revision[]; 36 + isDeemphasized?: boolean; 37 + isMissingStub?: boolean; 45 38 } 46 39 47 40 interface GraphNode { 48 - type: GraphNodeType; 49 - revision: Revision | null; 50 - elidedInfo: ElidedInfo | null; 41 + revision: Revision; 51 42 row: number; 52 43 lane: number; 53 44 parentConnections: ParentConnection[]; 54 45 } 55 46 56 47 interface GraphRow { 57 - type: GraphNodeType; 58 - revision: Revision | null; 59 - elidedInfo: ElidedInfo | null; 48 + revision: Revision; 60 49 lane: number; 50 + maxLaneOnRow: number; // Rightmost lane occupied by any graph element (node or edge) on this row 61 51 } 62 52 63 53 interface GraphData { ··· 69 59 export function reorderForGraph(revisions: Revision[]): Revision[] { 70 60 if (revisions.length === 0) return []; 71 61 72 - // Build parent->children map 73 - const childrenMap = new Map<string, string[]>(); 74 62 const commitMap = new Map(revisions.map((r) => [r.commit_id, r])); 63 + const commitIds = new Set(revisions.map((r) => r.commit_id)); 75 64 65 + // Build parent/children maps (only for edges within our revset) 66 + const childrenMap = new Map<string, string[]>(); 67 + const parentMap = new Map<string, string[]>(); 76 68 for (const rev of revisions) { 77 - for (const parentId of rev.parent_ids) { 78 - const children = childrenMap.get(parentId) ?? []; 69 + const parents: string[] = []; 70 + for (const edge of rev.parent_edges) { 71 + if (edge.edge_type === "missing") continue; 72 + if (!commitIds.has(edge.parent_id)) continue; 73 + parents.push(edge.parent_id); 74 + const children = childrenMap.get(edge.parent_id) ?? []; 79 75 children.push(rev.commit_id); 80 - childrenMap.set(parentId, children); 76 + childrenMap.set(edge.parent_id, children); 81 77 } 78 + parentMap.set(rev.commit_id, parents); 82 79 } 83 80 84 - // Find heads (commits with no children in our set) 85 - const heads = revisions.filter((r) => { 86 - const children = childrenMap.get(r.commit_id) ?? []; 87 - return children.length === 0 || !children.some((c) => commitMap.has(c)); 88 - }); 81 + // Priority score for a revision (lower = higher priority) 82 + function getPriority(rev: Revision): number { 83 + if (rev.is_working_copy) return 0; 84 + if (rev.is_mine && !rev.is_immutable) return 1; 85 + if (rev.bookmarks.length > 0 && !rev.is_immutable) return 2; 86 + if (!rev.is_immutable) return 3; 87 + if (rev.bookmarks.length > 0) return 4; 88 + return 5; 89 + } 90 + 91 + // Find heads and sort by priority 92 + const heads = revisions 93 + .filter((r) => { 94 + const children = childrenMap.get(r.commit_id) ?? []; 95 + return children.filter((c) => commitIds.has(c)).length === 0; 96 + }) 97 + .sort((a, b) => getPriority(a) - getPriority(b)); 98 + 99 + // Track which commits have been output 100 + const output = new Set<string>(); 101 + // Track remaining children count for each commit 102 + const remainingChildren = new Map<string, number>(); 103 + for (const rev of revisions) { 104 + const children = childrenMap.get(rev.commit_id) ?? []; 105 + const childCount = children.filter((c) => commitIds.has(c)).length; 106 + remainingChildren.set(rev.commit_id, childCount); 107 + } 89 108 90 - // Prioritize working copy - it should be visited first 91 - const workingCopy = revisions.find((r) => r.is_working_copy); 92 - const sortedHeads = [...heads].sort((a, b) => { 93 - if (a.is_working_copy) return -1; 94 - if (b.is_working_copy) return 1; 95 - // If working copy is an ancestor of a head, prioritize that head 96 - return 0; 97 - }); 109 + const result: Revision[] = []; 98 110 99 - // DFS from heads, prioritizing working copy's branch 100 - const ordered: Revision[] = []; 101 - const seen = new Set<string>(); 111 + // Process each head's branch using DFS 112 + // This ensures we exhaust a branch before moving to the next 113 + function processBranch(startId: string) { 114 + const stack = [startId]; 102 115 103 - function visit(rev: Revision) { 104 - if (seen.has(rev.commit_id)) return; 105 - seen.add(rev.commit_id); 106 - ordered.push(rev); 116 + while (stack.length > 0) { 117 + const id = stack[stack.length - 1]; // Peek 107 118 108 - // Visit parents (first parent first for main line) 109 - for (const parentId of rev.parent_ids) { 110 - const parent = commitMap.get(parentId); 111 - if (parent) { 112 - visit(parent); 119 + if (output.has(id)) { 120 + stack.pop(); 121 + continue; 113 122 } 114 - } 115 - } 116 123 117 - // Visit working copy first to ensure its chain is ordered first 118 - if (workingCopy) { 119 - visit(workingCopy); 120 - } 124 + const remaining = remainingChildren.get(id) ?? 0; 125 + if (remaining > 0) { 126 + // This commit still has unprocessed children 127 + // They must be from other branches - we'll come back to this commit 128 + stack.pop(); 129 + continue; 130 + } 121 131 122 - for (const head of sortedHeads) { 123 - visit(head); 132 + // All children processed, we can output this commit 133 + output.add(id); 134 + const rev = commitMap.get(id); 135 + if (rev) result.push(rev); 136 + stack.pop(); 137 + 138 + // Decrease remaining children count for parents and add to stack 139 + const parents = parentMap.get(id) ?? []; 140 + for (const parentId of parents) { 141 + if (!output.has(parentId)) { 142 + const newRemaining = (remainingChildren.get(parentId) ?? 1) - 1; 143 + remainingChildren.set(parentId, newRemaining); 144 + // Add parent to stack to continue DFS 145 + stack.push(parentId); 146 + } 147 + } 148 + } 124 149 } 125 150 126 - // Add any remaining (shouldn't happen, but safety) 127 - for (const rev of revisions) { 128 - if (!seen.has(rev.commit_id)) { 129 - ordered.push(rev); 151 + // Process heads in priority order 152 + for (const head of heads) { 153 + if (!output.has(head.commit_id)) { 154 + processBranch(head.commit_id); 130 155 } 131 156 } 132 157 133 - return ordered; 158 + return result; 134 159 } 135 160 136 161 // Get the set of commit IDs in the working copy's ancestor chain (for lane 0) ··· 147 172 chain.add(id); 148 173 const rev = commitMap.get(id); 149 174 if (rev) { 150 - // Follow first parent only for the main chain 151 - if (rev.parent_ids.length > 0 && commitMap.has(rev.parent_ids[0])) { 152 - queue.push(rev.parent_ids[0]); 175 + // Follow first non-missing parent edge for the main chain 176 + const firstEdge = rev.parent_edges.find((e) => e.edge_type !== "missing"); 177 + if (firstEdge && commitMap.has(firstEdge.parent_id)) { 178 + queue.push(firstEdge.parent_id); 153 179 } 154 180 } 155 181 } ··· 158 184 return chain; 159 185 } 160 186 161 - function computeElision(revisions: Revision[], expandedSections: string[]): GraphRow[] { 162 - if (revisions.length === 0) return []; 163 - 164 - const commitMap = new Map(revisions.map((r) => [r.commit_id, r])); 165 - const childrenMap = new Map<string, string[]>(); 166 - for (const rev of revisions) { 167 - for (const parentId of rev.parent_ids) { 168 - const children = childrenMap.get(parentId) ?? []; 169 - children.push(rev.commit_id); 170 - childrenMap.set(parentId, children); 171 - } 172 - } 173 - 174 - // Find trunk head (first immutable commit with a trunk bookmark) 175 - const trunkHead = revisions.find( 176 - (r) => r.is_immutable && r.bookmarks.some((b) => ["main", "master", "trunk"].includes(b)), 177 - ); 178 - 179 - // Find working copy and its ancestors (the main work branch) 180 - const workingCopy = revisions.find((r) => r.is_working_copy); 181 - const workBranchIds = new Set<string>(); 182 - if (workingCopy) { 183 - const queue = [workingCopy.commit_id]; 184 - while (queue.length > 0) { 185 - const id = queue.shift(); 186 - if (!id) continue; 187 - if (workBranchIds.has(id)) continue; 188 - workBranchIds.add(id); 189 - const rev = commitMap.get(id); 190 - if (rev) { 191 - for (const parentId of rev.parent_ids) { 192 - if (commitMap.has(parentId)) { 193 - queue.push(parentId); 194 - } 195 - } 196 - } 197 - } 198 - } 199 - 200 - // Identify immutable backbone (commits from trunk head to root) 201 - const immutableBackbone = new Set<string>(); 202 - if (trunkHead) { 203 - const queue = [trunkHead.commit_id]; 204 - while (queue.length > 0) { 205 - const id = queue.shift(); 206 - if (!id) continue; 207 - if (immutableBackbone.has(id)) continue; 208 - const rev = commitMap.get(id); 209 - if (rev?.is_immutable) { 210 - immutableBackbone.add(id); 211 - for (const parentId of rev.parent_ids) { 212 - if (commitMap.has(parentId)) { 213 - queue.push(parentId); 214 - } 215 - } 216 - } 217 - } 218 - } 219 - 220 - // Determine which revisions to show vs elide 221 - // Show: working copy branch, trunk head, heads of other branches, merge points 222 - const visibleIds = new Set<string>(); 223 - 224 - for (const rev of revisions) { 225 - const isHead = 226 - (childrenMap.get(rev.commit_id)?.length ?? 0) === 0 || 227 - !childrenMap.get(rev.commit_id)?.some((c) => commitMap.has(c)); 228 - const isMerge = rev.parent_ids.length > 1; 229 - const isBranchPoint = 230 - (childrenMap.get(rev.commit_id)?.filter((c) => commitMap.has(c))?.length ?? 0) > 1; 231 - const isTrunkHead = rev.commit_id === trunkHead?.commit_id; 232 - const isOnWorkBranch = workBranchIds.has(rev.commit_id); 233 - 234 - if ( 235 - rev.is_working_copy || 236 - isHead || 237 - isMerge || 238 - isBranchPoint || 239 - isTrunkHead || 240 - isOnWorkBranch || 241 - rev.bookmarks.length > 0 242 - ) { 243 - visibleIds.add(rev.commit_id); 244 - } 245 - } 246 - 247 - // Group consecutive elided immutable commits 248 - const rows: GraphRow[] = []; 249 - const orderedRevisions = reorderForGraph(revisions); 250 - let currentElidedGroup: Revision[] = []; 251 - let elidedGroupParentId: string | null = null; 252 - 253 - function flushElidedGroup(elidedId: string) { 254 - if (currentElidedGroup.length === 0) return; 255 - const isExpanded = expandedSections.includes(elidedId); 256 - rows.push({ 257 - type: "elided", 258 - revision: null, 259 - elidedInfo: { id: elidedId, elidedRevisions: [...currentElidedGroup] }, 260 - lane: 0, 261 - }); 262 - if (isExpanded) { 263 - for (const elidedRev of currentElidedGroup) { 264 - rows.push({ type: "revision", revision: elidedRev, elidedInfo: null, lane: 0 }); 265 - } 266 - } 267 - currentElidedGroup = []; 268 - } 269 - 270 - for (const rev of orderedRevisions) { 271 - if (visibleIds.has(rev.commit_id)) { 272 - flushElidedGroup(`elided-${elidedGroupParentId ?? "root"}`); 273 - rows.push({ type: "revision", revision: rev, elidedInfo: null, lane: 0 }); 274 - elidedGroupParentId = rev.commit_id; 275 - } else { 276 - currentElidedGroup.push(rev); 277 - } 278 - } 279 - 280 - flushElidedGroup(`elided-${elidedGroupParentId ?? "root"}-final`); 281 - 282 - return rows; 283 - } 284 - 285 - function buildGraph(revisions: Revision[], expandedSections: string[]): GraphData { 187 + function buildGraph(revisions: Revision[]): GraphData { 286 188 if (revisions.length === 0) return { nodes: [], laneCount: 1, rows: [] }; 287 189 288 190 // Map commit_id -> Revision for ancestry lookups 289 191 const commitMap = new Map(revisions.map((r) => [r.commit_id, r])); 290 192 291 - // Compute which revisions to show vs elide 292 - const rows = computeElision(revisions, expandedSections); 293 - 294 - // Build set of visible commit IDs for edge type detection 295 - const visibleCommitIds = new Set( 296 - rows 297 - .filter( 298 - (r): r is GraphRow & { revision: Revision } => r.type === "revision" && r.revision !== null, 299 - ) 300 - .map((r) => r.revision.commit_id), 301 - ); 302 - 303 - // Walk up the ancestry from a (possibly elided) parent to the next visible ancestor 304 - function findVisibleAncestor(startId: string): string | null { 305 - let currentId: string | undefined = startId; 306 - const visited = new Set<string>(); 307 - const MAX_STEPS = 1000; 308 - 309 - while (currentId) { 310 - if (visibleCommitIds.has(currentId)) { 311 - return currentId; 312 - } 313 - 314 - if (visited.has(currentId)) break; 315 - visited.add(currentId); 316 - 317 - const rev = commitMap.get(currentId); 318 - if (!rev || rev.parent_ids.length === 0) break; 319 - 320 - // Prefer first parent, fall back to any parent present in this revision set 321 - let nextId: string | undefined; 322 - for (const pid of rev.parent_ids) { 323 - if (commitMap.has(pid)) { 324 - nextId = pid; 325 - break; 326 - } 327 - } 328 - if (!nextId) break; 329 - 330 - currentId = nextId; 331 - 332 - if (visited.size > MAX_STEPS) break; 333 - } 334 - 335 - return null; 336 - } 193 + // Create rows for all revisions (no elision) 194 + const orderedRevisions = reorderForGraph(revisions); 195 + const rows: GraphRow[] = orderedRevisions.map((rev) => ({ 196 + revision: rev, 197 + lane: 0, 198 + maxLaneOnRow: 0, 199 + })); 337 200 338 201 // Get working copy chain - these commits should all be in lane 0 339 202 const workingCopyChain = getWorkingCopyChain(revisions); 340 203 341 - // Build row index maps 204 + // Check if we have trunk commits or working copy (determines if lane 0 is reserved) 205 + const hasLane0Commits = workingCopyChain.size > 0 || revisions.some((r) => r.is_trunk); 206 + 207 + // Build row index map 342 208 const commitToRow = new Map<string, number>(); 343 - const elidedToRow = new Map<string, number>(); 344 209 rows.forEach((row, idx) => { 345 - if (row.type === "revision" && row.revision) { 346 - commitToRow.set(row.revision.commit_id, idx); 347 - } else if (row.type === "elided" && row.elidedInfo) { 348 - elidedToRow.set(row.elidedInfo.id, idx); 349 - } 210 + commitToRow.set(row.revision.commit_id, idx); 350 211 }); 351 212 352 213 const commitToLane = new Map<string, number>(); 353 - const elidedToLane = new Map<string, number>(); 354 214 const nodes: GraphNode[] = []; 355 - const activeLanes: (string | null)[] = [null]; 356 - 357 - // Pre-assign lane 0 to working copy chain 358 - for (const commitId of workingCopyChain) { 359 - commitToLane.set(commitId, 0); 360 - } 215 + // Start at lane 1 if lane 0 is reserved for trunk/working copy 216 + let nextLane = hasLane0Commits ? 1 : 0; 361 217 362 218 function claimLane(id: string, preferredLane?: number): number { 363 219 // If already assigned (e.g., working copy chain), return that lane 364 220 const existing = commitToLane.get(id); 365 221 if (existing !== undefined) return existing; 366 222 367 - if ( 368 - preferredLane !== undefined && 369 - preferredLane < activeLanes.length && 370 - activeLanes[preferredLane] === null 371 - ) { 372 - activeLanes[preferredLane] = id; 373 - return preferredLane; 223 + if (preferredLane !== undefined) { 224 + return Math.min(preferredLane, MAX_LANES - 1); 374 225 } 375 - // For non-working-copy commits, start from lane 1 376 - const startLane = workingCopyChain.size > 0 ? 1 : 0; 377 - for (let i = startLane; i < activeLanes.length; i++) { 378 - if (activeLanes[i] === null) { 379 - activeLanes[i] = id; 380 - return i; 381 - } 382 - } 383 - if (activeLanes.length < MAX_LANES) { 384 - activeLanes.push(id); 385 - return activeLanes.length - 1; 386 - } 387 - return MAX_LANES - 1; 226 + 227 + // Assign next available lane 228 + const lane = Math.min(nextLane, MAX_LANES - 1); 229 + nextLane = Math.min(nextLane + 1, MAX_LANES); 230 + return lane; 388 231 } 389 232 390 233 for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { 391 234 const row = rows[rowIdx]; 235 + const revision = row.revision; 392 236 393 - if (row.type === "revision" && row.revision) { 394 - const revision = row.revision; 395 - 396 - let lane = commitToLane.get(revision.commit_id); 397 - if (lane === undefined) { 398 - // Working copy chain gets lane 0, others get lanes 1+ 399 - const isOnWorkingCopyChain = workingCopyChain.has(revision.commit_id); 400 - const preferLane = isOnWorkingCopyChain ? 0 : undefined; 401 - lane = claimLane(revision.commit_id, preferLane); 402 - commitToLane.set(revision.commit_id, lane); 403 - } 404 - // Update active lane 405 - while (activeLanes.length <= lane) { 406 - activeLanes.push(null); 237 + let lane = commitToLane.get(revision.commit_id); 238 + if (lane === undefined) { 239 + // Trunk commits and working copy chain always go on lane 0 240 + const isOnWorkingCopyChain = workingCopyChain.has(revision.commit_id); 241 + if (revision.is_trunk || isOnWorkingCopyChain) { 242 + lane = 0; 243 + } else { 244 + lane = claimLane(revision.commit_id); 407 245 } 408 - activeLanes[lane] = revision.commit_id; 246 + commitToLane.set(revision.commit_id, lane); 247 + } 248 + 249 + const parentConnections: ParentConnection[] = []; 409 250 410 - const parentConnections: ParentConnection[] = []; 251 + // Detect "main merges into branch" scenario 252 + const isMerge = revision.parent_edges.length > 1; 253 + const isMutableCommit = !revision.is_immutable; 411 254 412 - for (let i = 0; i < revision.parent_ids.length; i++) { 413 - const parentId = revision.parent_ids[i]; 255 + // Use parent_edges from backend which contains edge type info 256 + for (let i = 0; i < revision.parent_edges.length; i++) { 257 + const parentEdge = revision.parent_edges[i]; 258 + const parentId = parentEdge.parent_id; 259 + const edgeType = parentEdge.edge_type; 414 260 415 - if (visibleCommitIds.has(parentId)) { 416 - // Direct edge to visible parent 417 - const parentRow = commitToRow.get(parentId); 418 - if (parentRow !== undefined) { 419 - let parentLane = commitToLane.get(parentId); 420 - if (parentLane === undefined) { 421 - parentLane = i === 0 ? lane : claimLane(parentId); 422 - commitToLane.set(parentId, parentLane); 423 - } 424 - parentConnections.push({ parentRow, parentLane, edgeType: "direct" }); 425 - } 426 - continue; 427 - } 261 + // Handle missing edges (parents outside our revset) 262 + if (edgeType === "missing") { 263 + // Draw a short stub to indicate ancestry exists outside current view 264 + parentConnections.push({ 265 + parentRow: rowIdx + 1, // Just one row down for the stub 266 + parentLane: lane, 267 + edgeType: "missing", 268 + isMissingStub: true, 269 + }); 270 + continue; 271 + } 428 272 429 - // Parent is elided - walk up to the next visible ancestor 430 - const ancestorId = findVisibleAncestor(parentId); 431 - if (!ancestorId) continue; 273 + const parentRow = commitToRow.get(parentId); 274 + if (parentRow === undefined) continue; 432 275 433 - const parentRow = commitToRow.get(ancestorId); 434 - if (parentRow === undefined) continue; 276 + let parentLane = commitToLane.get(parentId); 277 + if (parentLane === undefined) { 278 + // Check if parent is a trunk commit or on working copy chain 279 + const parentRev = commitMap.get(parentId); 280 + const parentIsTrunk = parentRev?.is_trunk ?? false; 281 + const parentOnWorkingCopyChain = workingCopyChain.has(parentId); 435 282 436 - let parentLane = commitToLane.get(ancestorId); 437 - if (parentLane === undefined) { 438 - parentLane = i === 0 ? lane : claimLane(ancestorId); 439 - commitToLane.set(ancestorId, parentLane); 283 + if (parentIsTrunk || parentOnWorkingCopyChain) { 284 + // Trunk commits and working copy chain always go on lane 0 285 + parentLane = 0; 286 + } else { 287 + // First parent inherits our lane; other parents get their own 288 + parentLane = i === 0 ? lane : claimLane(parentId); 440 289 } 441 - 442 - parentConnections.push({ parentRow, parentLane, edgeType: "indirect" }); 290 + commitToLane.set(parentId, parentLane); 443 291 } 444 292 445 - if (revision.parent_ids.length === 0 && lane < activeLanes.length) { 446 - activeLanes[lane] = null; 447 - } 293 + // Check if parent is immutable (look up the actual parent revision) 294 + const parentRev = commitMap.get(parentId); 295 + const parentIsImmutable = parentRev?.is_immutable ?? false; 448 296 449 - nodes.push({ 450 - type: "revision", 451 - revision, 452 - elidedInfo: null, 453 - row: rowIdx, 454 - lane, 455 - parentConnections, 456 - }); 457 - } else if (row.type === "elided" && row.elidedInfo) { 458 - const elidedInfo = row.elidedInfo; 459 - let lane = elidedToLane.get(elidedInfo.id); 460 - if (lane === undefined) { 461 - // Inherit lane from previous visible node or use lane 0 462 - const prevNode = nodes[nodes.length - 1]; 463 - lane = prevNode?.lane ?? 0; 464 - elidedToLane.set(elidedInfo.id, lane); 465 - } 297 + // De-emphasize if: merge commit, mutable commit, immutable parent 298 + // IMPORTANT: Don't de-emphasize first parent (i === 0) - that's the mainline 299 + const isDeemphasized = isMerge && isMutableCommit && parentIsImmutable && i > 0; 300 + 301 + parentConnections.push({ parentRow, parentLane, edgeType, isDeemphasized }); 302 + } 466 303 467 - nodes.push({ 468 - type: "elided", 469 - revision: null, 470 - elidedInfo, 471 - row: rowIdx, 472 - lane, 473 - parentConnections: [], 304 + // Fallback: if node has parents but no connections drawn, add a stub 305 + // This handles cases where all edges were filtered/missing 306 + if (parentConnections.length === 0 && revision.parent_ids.length > 0) { 307 + parentConnections.push({ 308 + parentRow: rowIdx + 1, 309 + parentLane: lane, 310 + edgeType: "missing", 311 + isMissingStub: true, 474 312 }); 475 313 } 314 + 315 + nodes.push({ 316 + revision, 317 + row: rowIdx, 318 + lane, 319 + parentConnections, 320 + }); 476 321 } 477 322 478 323 // Update rows with computed lane info from nodes 324 + let maxLaneUsed = 0; 479 325 for (const node of nodes) { 480 326 const row = rows[node.row]; 481 327 if (row) { 482 328 row.lane = node.lane; 329 + row.maxLaneOnRow = node.lane; // Initialize with node's lane 483 330 } 331 + maxLaneUsed = Math.max(maxLaneUsed, node.lane); 484 332 } 485 333 486 - return { nodes, laneCount: Math.min(activeLanes.length, MAX_LANES), rows }; 334 + // Calculate maxLaneOnRow by analyzing which edges pass through each row 335 + // With horizontal-first edges: horizontal segment at node's row, vertical segment to parent 336 + for (const node of nodes) { 337 + for (const conn of node.parentConnections) { 338 + const nodeRow = node.row; 339 + const parentRow = conn.parentRow; 340 + const nodeLane = node.lane; 341 + const parentLane = conn.parentLane; 342 + 343 + // Cross-lane edge: horizontal segment at node's row uses both lanes 344 + if (nodeLane !== parentLane) { 345 + const row = rows[nodeRow]; 346 + if (row) { 347 + row.maxLaneOnRow = Math.max(row.maxLaneOnRow, nodeLane, parentLane); 348 + } 349 + } 350 + 351 + // Vertical segment passes through all rows between node and parent 352 + const minRow = Math.min(nodeRow, parentRow); 353 + const maxRow = Math.max(nodeRow, parentRow); 354 + for (let r = minRow + 1; r < maxRow; r++) { 355 + const row = rows[r]; 356 + if (row) { 357 + row.maxLaneOnRow = Math.max(row.maxLaneOnRow, parentLane); 358 + } 359 + } 360 + } 361 + } 362 + 363 + // Ensure global consistency - propagate lane usage through connected sections 364 + // This handles cases where disconnected branches exist 365 + const globalMaxLane = maxLaneUsed; 366 + for (const row of rows) { 367 + // Ensure every row accounts for at least its own node's lane 368 + row.maxLaneOnRow = Math.max(row.maxLaneOnRow, row.lane); 369 + } 370 + 371 + return { nodes, laneCount: globalMaxLane + 1, rows }; 487 372 } 488 373 489 374 function laneToX(lane: number): number { ··· 497 382 function GraphColumn({ nodes, laneCount }: { nodes: GraphNode[]; laneCount: number }) { 498 383 const { rev: selectedChangeId } = useSearch({ strict: false }); 499 384 const height = nodes.length * ROW_HEIGHT; 500 - // Minimal right padding - just enough for the rightmost node 501 - const width = LANE_PADDING + laneCount * LANE_WIDTH + NODE_RADIUS + 4; 385 + // Minimal right padding - tight fit for the rightmost node 386 + const width = LANE_PADDING + laneCount * LANE_WIDTH + NODE_RADIUS + 2; 502 387 503 388 return ( 504 389 <svg width={width} height={height} className="shrink-0" role="img" aria-label="Revision graph"> 505 390 <title>Revision graph</title> 506 391 {/* Edges */} 507 392 {nodes.map((node) => { 508 - if (node.type === "elided") return null; 509 - 510 393 const y = node.row * ROW_HEIGHT + ROW_HEIGHT / 2; 511 394 const x = laneToX(node.lane); 512 395 const color = laneColor(node.lane); 513 - const nodeKey = node.revision?.change_id ?? node.elidedInfo?.id ?? `node-${node.row}`; 396 + const nodeKey = node.revision.change_id; 514 397 515 398 return ( 516 399 <g key={`edges-${nodeKey}`}> 517 400 {node.parentConnections.map((conn, idx) => { 401 + const edgeColor = laneColor(conn.parentLane); 402 + 403 + // Style based on edge type from backend 404 + const isDashed = conn.edgeType === "indirect"; 405 + const isMissing = conn.edgeType === "missing"; 406 + 407 + // Apply de-emphasis styling for "main merges into branch" edges 408 + const strokeWidth = conn.isDeemphasized ? 1 : 2; 409 + const strokeOpacity = conn.isDeemphasized ? 0.4 : isMissing ? 0.3 : 0.8; 410 + const strokeColor = conn.isDeemphasized ? "#888" : edgeColor; 411 + 412 + // Missing stub: short dashed vertical line indicating parent outside view 413 + if (conn.isMissingStub) { 414 + const stubLength = ROW_HEIGHT * 0.4; 415 + return ( 416 + <line 417 + key={idx} 418 + x1={x} 419 + y1={y + NODE_RADIUS} 420 + x2={x} 421 + y2={y + NODE_RADIUS + stubLength} 422 + stroke={color} 423 + strokeWidth={1.5} 424 + strokeOpacity={0.4} 425 + strokeDasharray="3 3" 426 + /> 427 + ); 428 + } 429 + 518 430 const parentY = conn.parentRow * ROW_HEIGHT + ROW_HEIGHT / 2; 519 431 const parentX = laneToX(conn.parentLane); 520 - const edgeColor = laneColor(conn.parentLane); 521 - const isDashed = conn.edgeType === "indirect"; 522 432 523 433 if (node.lane === conn.parentLane) { 524 434 return ( ··· 528 438 y1={y + NODE_RADIUS} 529 439 x2={parentX} 530 440 y2={parentY - NODE_RADIUS} 531 - stroke={color} 532 - strokeWidth={2} 441 + stroke={conn.isDeemphasized ? strokeColor : color} 442 + strokeWidth={strokeWidth} 443 + strokeOpacity={strokeOpacity} 533 444 strokeDasharray={isDashed ? "4 4" : undefined} 534 445 /> 535 446 ); 536 447 } 537 448 538 - // Squared path for cross-lane connections (like jj CLI) 539 - // Go down one row, then horizontal, then down to parent 540 - const cornerRadius = 6; 541 - const turnY = y + ROW_HEIGHT; // Turn point one row below current node 542 - 543 - // Path: down from node, curve corner, horizontal, curve corner, down to parent 449 + // Cross-lane connections: horizontal from child, curve down into parent's lane 544 450 const goingRight = parentX > x; 545 - const horizontalDir = goingRight ? 1 : -1; 451 + const arcRadius = 10; 546 452 547 453 return ( 548 454 <path 549 455 key={idx} 550 456 d={`M ${x} ${y + NODE_RADIUS} 551 - L ${x} ${turnY - cornerRadius} 552 - Q ${x} ${turnY}, ${x + cornerRadius * horizontalDir} ${turnY} 553 - L ${parentX - cornerRadius * horizontalDir} ${turnY} 554 - Q ${parentX} ${turnY}, ${parentX} ${turnY + cornerRadius} 457 + L ${parentX - arcRadius * (goingRight ? 1 : -1)} ${y + NODE_RADIUS} 458 + Q ${parentX} ${y + NODE_RADIUS} ${parentX} ${y + NODE_RADIUS + arcRadius} 555 459 L ${parentX} ${parentY - NODE_RADIUS}`} 556 460 fill="none" 557 - stroke={edgeColor} 558 - strokeWidth={2} 559 - strokeOpacity={0.8} 461 + stroke={strokeColor} 462 + strokeWidth={strokeWidth} 463 + strokeOpacity={strokeOpacity} 560 464 strokeDasharray={isDashed ? "4 4" : undefined} 561 465 /> 562 466 ); ··· 570 474 const y = node.row * ROW_HEIGHT + ROW_HEIGHT / 2; 571 475 const x = laneToX(node.lane); 572 476 const color = laneColor(node.lane); 573 - const isSelected = node.revision?.change_id === selectedChangeId; 574 - 575 - // Elided placeholder node - render ~ symbol 576 - if (node.type === "elided") { 577 - return ( 578 - <g key={node.elidedInfo?.id ?? `elided-${node.row}`}> 579 - <text 580 - x={x} 581 - y={y} 582 - textAnchor="middle" 583 - dominantBaseline="central" 584 - fill={color} 585 - fontWeight="bold" 586 - fontSize="14" 587 - opacity={0.7} 588 - > 589 - ~ 590 - </text> 591 - </g> 592 - ); 593 - } 594 - 595 - const isWorkingCopy = node.revision?.is_working_copy ?? false; 596 - const isImmutable = node.revision?.is_immutable ?? false; 477 + const isSelected = node.revision.change_id === selectedChangeId; 478 + const isWorkingCopy = node.revision.is_working_copy; 479 + const isImmutable = node.revision.is_immutable; 597 480 598 481 if (isWorkingCopy) { 599 482 return ( 600 - <g key={node.revision?.change_id}> 483 + <g key={node.revision.change_id}> 601 484 {isSelected && ( 602 485 <circle cx={x} cy={y} r={NODE_RADIUS + 6} fill={color} fillOpacity={0.3} /> 603 486 )} ··· 620 503 // Immutable commits get a diamond shape (◆) 621 504 if (isImmutable) { 622 505 return ( 623 - <g key={node.revision?.change_id}> 506 + <g key={node.revision.change_id}> 624 507 {isSelected && ( 625 508 <circle cx={x} cy={y} r={NODE_RADIUS + 4} fill={color} fillOpacity={0.3} /> 626 509 )} ··· 637 520 } 638 521 639 522 return ( 640 - <g key={node.revision?.change_id}> 523 + <g key={node.revision.change_id}> 641 524 {isSelected && ( 642 525 <circle cx={x} cy={y} r={NODE_RADIUS + 4} fill={color} fillOpacity={0.3} /> 643 526 )} ··· 651 534 652 535 function RevisionRow({ 653 536 revision, 537 + maxLaneOnRow, 654 538 isSelected, 655 539 onSelect, 656 540 isFlashing, 541 + isDimmed, 657 542 }: { 658 543 revision: Revision; 544 + maxLaneOnRow: number; 659 545 isSelected: boolean; 660 546 onSelect: (changeId: string) => void; 661 547 isFlashing: boolean; 548 + isDimmed: boolean; 662 549 }) { 663 550 const description = revision.description.split("\n")[0] || "(no description)"; 551 + // Indent row to position text after the rightmost graph element on this row 552 + const indent = LANE_PADDING + (maxLaneOnRow + 1) * LANE_WIDTH + NODE_RADIUS + 4; 664 553 665 554 return ( 666 - <Button 667 - variant="ghost" 668 - onClick={() => onSelect(revision.change_id)} 669 - data-change-id={revision.change_id} 670 - style={{ height: ROW_HEIGHT }} 671 - className={`w-full justify-start text-left px-2 animate-in fade-in slide-in-from-left-1 duration-150 rounded-sm focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:ring-0 focus-visible:ring-offset-0 ${ 672 - isSelected ? "bg-accent text-accent-foreground" : "" 673 - } ${revision.is_immutable ? "opacity-60" : ""}`} 674 - > 675 - <div className="flex-1 min-w-0"> 676 - <div className="flex items-center gap-2 flex-wrap"> 677 - <code 678 - className={`text-xs font-mono text-muted-foreground rounded px-0.5 ${ 679 - isFlashing ? "bg-green-500/50 animate-pulse" : "" 680 - }`} 681 - > 682 - {revision.change_id_short} 683 - </code> 684 - {revision.bookmarks.length > 0 && 685 - revision.bookmarks.map((bookmark) => ( 686 - <Badge key={bookmark} variant="secondary" className="text-xs px-1 py-0"> 687 - {bookmark} 688 - </Badge> 689 - ))} 690 - <span className="text-xs text-muted-foreground"> 691 - {revision.author.split("@")[0]} · {revision.timestamp} 692 - </span> 693 - </div> 694 - <div className="text-sm truncate">{description}</div> 555 + <div style={{ height: ROW_HEIGHT }} className="flex items-center"> 556 + <div style={{ width: indent }} className="shrink-0" /> 557 + <div 558 + className={`flex-1 mr-2 revision-row-3d transition-opacity duration-150 ${revision.is_immutable ? "opacity-60" : ""} ${isDimmed ? "opacity-40" : ""}`} 559 + > 560 + <Button 561 + variant="ghost" 562 + onClick={() => onSelect(revision.change_id)} 563 + data-change-id={revision.change_id} 564 + className={`w-full h-full justify-start text-left px-3 py-2 animate-in fade-in slide-in-from-left-1 duration-150 rounded focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 hover:bg-transparent ${ 565 + isSelected ? "bg-accent/50 text-accent-foreground" : "" 566 + }`} 567 + > 568 + <div className="flex-1 min-w-0"> 569 + <div className="flex items-center gap-2 flex-wrap"> 570 + <code 571 + className={`text-xs font-mono text-muted-foreground rounded px-0.5 ${ 572 + isFlashing ? "bg-green-500/50 animate-pulse" : "" 573 + }`} 574 + > 575 + {revision.change_id_short} 576 + </code> 577 + {revision.bookmarks.length > 0 && 578 + revision.bookmarks.map((bookmark) => ( 579 + <Badge key={bookmark} variant="secondary" className="text-xs px-1 py-0"> 580 + {bookmark} 581 + </Badge> 582 + ))} 583 + <span className="text-xs text-muted-foreground"> 584 + {revision.author.split("@")[0]} · {revision.timestamp} 585 + </span> 586 + </div> 587 + <div className="text-sm truncate">{description}</div> 588 + </div> 589 + </Button> 695 590 </div> 696 - </Button> 591 + </div> 697 592 ); 698 593 } 699 594 700 - function extractCommitTypes(revisions: Revision[]): string[] { 701 - const typeCounts = new Map<string, number>(); 702 - const conventionalTypes = [ 703 - "feat", 704 - "fix", 705 - "docs", 706 - "style", 707 - "refactor", 708 - "perf", 709 - "test", 710 - "build", 711 - "ci", 712 - "chore", 713 - "revert", 714 - ]; 595 + // Compute related revisions (ancestors + descendants) of a selected revision 596 + function getRelatedRevisions(revisions: Revision[], selectedChangeId: string | null): Set<string> { 597 + if (!selectedChangeId) return new Set(); 598 + 599 + const related = new Set<string>(); 600 + const commitIdToChangeId = new Map<string, string>(); 601 + const changeIdToCommitId = new Map<string, string>(); 602 + const childrenMap = new Map<string, string[]>(); // commit_id -> child commit_ids 603 + const parentMap = new Map<string, string[]>(); // commit_id -> parent commit_ids 715 604 605 + // Build maps 716 606 for (const rev of revisions) { 717 - const firstLine = rev.description.split("\n")[0]; 718 - const colonIdx = firstLine.indexOf(":"); 719 - if (colonIdx > 0) { 720 - let typeStr = firstLine.slice(0, colonIdx); 721 - const parenIdx = typeStr.indexOf("("); 722 - if (parenIdx > 0) typeStr = typeStr.slice(0, parenIdx); 723 - typeStr = typeStr.trim().toLowerCase(); 724 - if (conventionalTypes.includes(typeStr)) { 725 - typeCounts.set(typeStr, (typeCounts.get(typeStr) ?? 0) + 1); 726 - } 607 + commitIdToChangeId.set(rev.commit_id, rev.change_id); 608 + changeIdToCommitId.set(rev.change_id, rev.commit_id); 609 + const parents: string[] = []; 610 + for (const edge of rev.parent_edges) { 611 + if (edge.edge_type === "missing") continue; 612 + parents.push(edge.parent_id); 613 + const children = childrenMap.get(edge.parent_id) ?? []; 614 + children.push(rev.commit_id); 615 + childrenMap.set(edge.parent_id, children); 727 616 } 617 + parentMap.set(rev.commit_id, parents); 728 618 } 729 619 730 - return [...typeCounts.entries()] 731 - .sort((a, b) => b[1] - a[1]) 732 - .slice(0, 3) 733 - .map(([type]) => type); 734 - } 620 + const selectedCommitId = changeIdToCommitId.get(selectedChangeId); 621 + if (!selectedCommitId) return new Set(); 735 622 736 - function formatTimeRange(revisions: Revision[]): string { 737 - if (revisions.length === 0) return ""; 623 + // BFS to find ancestors 624 + const ancestorQueue = [selectedCommitId]; 625 + const visited = new Set<string>(); 626 + while (ancestorQueue.length > 0) { 627 + const id = ancestorQueue.shift()!; 628 + if (visited.has(id)) continue; 629 + visited.add(id); 630 + const changeId = commitIdToChangeId.get(id); 631 + if (changeId) related.add(changeId); 632 + const parents = parentMap.get(id) ?? []; 633 + for (const parentId of parents) { 634 + ancestorQueue.push(parentId); 635 + } 636 + } 738 637 739 - const timestamps = revisions.map((r) => r.timestamp); 740 - if (timestamps.length === 1) return timestamps[0]; 638 + // BFS to find descendants 639 + const descendantQueue = [selectedCommitId]; 640 + visited.clear(); 641 + while (descendantQueue.length > 0) { 642 + const id = descendantQueue.shift()!; 643 + if (visited.has(id)) continue; 644 + visited.add(id); 645 + const changeId = commitIdToChangeId.get(id); 646 + if (changeId) related.add(changeId); 647 + const children = childrenMap.get(id) ?? []; 648 + for (const childId of children) { 649 + descendantQueue.push(childId); 650 + } 651 + } 741 652 742 - // Just show the range of relative times 743 - const first = timestamps[0]; 744 - const last = timestamps[timestamps.length - 1]; 745 - if (first === last) return first; 746 - return `${first} - ${last}`; 747 - } 748 - 749 - function ElidedRow({ 750 - elidedInfo, 751 - isExpanded, 752 - onToggle, 753 - }: { 754 - elidedInfo: ElidedInfo; 755 - isExpanded: boolean; 756 - onToggle: () => void; 757 - }) { 758 - const count = elidedInfo.elidedRevisions.length; 759 - const types = extractCommitTypes(elidedInfo.elidedRevisions); 760 - const timeRange = formatTimeRange(elidedInfo.elidedRevisions); 761 - 762 - const typesStr = types.length > 0 ? ` · ${types.join(", ")}` : ""; 763 - const timeStr = timeRange ? ` · ${timeRange}` : ""; 764 - 765 - return ( 766 - <button 767 - type="button" 768 - style={{ height: ROW_HEIGHT }} 769 - className="flex items-center px-2 text-muted-foreground text-xs opacity-60 cursor-pointer hover:opacity-80 w-full text-left bg-transparent border-none" 770 - onClick={onToggle} 771 - > 772 - <span className="font-mono"> 773 - {isExpanded ? "▾" : "▸"} {count} commit{count !== 1 ? "s" : ""} 774 - {typesStr} 775 - {timeStr} 776 - </span> 777 - </button> 778 - ); 653 + return related; 779 654 } 780 655 781 656 export function RevisionGraph({ ··· 785 660 isLoading, 786 661 flash, 787 662 }: RevisionGraphProps) { 788 - const [expandedSections, setExpandedSections] = useAtom(expandedElidedSectionsAtom); 789 - const { nodes, laneCount, rows } = buildGraph(revisions, expandedSections); 663 + const { nodes, laneCount, rows } = buildGraph(revisions); 790 664 791 665 const revisionMap = new Map(revisions.map((r) => [r.change_id, r])); 666 + const relatedRevisions = getRelatedRevisions(revisions, selectedRevision?.change_id ?? null); 792 667 793 668 function handleSelect(changeId: string) { 794 669 const revision = revisionMap.get(changeId); 795 670 if (revision) onSelectRevision(revision); 796 671 } 797 672 798 - function toggleExpanded(elidedId: string) { 799 - if (expandedSections.includes(elidedId)) { 800 - setExpandedSections(expandedSections.filter((id) => id !== elidedId)); 801 - } else { 802 - setExpandedSections([...expandedSections, elidedId]); 803 - } 804 - } 805 - 806 673 if (revisions.length === 0) { 807 674 return ( 808 675 <div className="flex items-center justify-center h-full bg-background text-muted-foreground text-sm"> ··· 812 679 } 813 680 814 681 return ( 815 - <ScrollArea className="h-full bg-background"> 816 - <div className="flex"> 817 - <GraphColumn nodes={nodes} laneCount={laneCount} /> 818 - <div className="flex-1 min-w-0"> 682 + <ScrollArea className="h-full ascii-bg"> 683 + <div className="relative py-1"> 684 + <div className="absolute top-0 left-0 z-0 pointer-events-none"> 685 + <GraphColumn nodes={nodes} laneCount={laneCount} /> 686 + </div> 687 + <div className="relative z-10"> 819 688 {rows.map((row) => { 820 - if (row.type === "revision" && row.revision) { 821 - const isFlashing = flash?.changeId === row.revision.change_id; 822 - return ( 823 - <RevisionRow 824 - key={row.revision.change_id} 825 - revision={row.revision} 826 - isSelected={selectedRevision?.change_id === row.revision.change_id} 827 - onSelect={handleSelect} 828 - isFlashing={isFlashing} 829 - /> 830 - ); 831 - } 832 - if (row.type === "elided" && row.elidedInfo) { 833 - const isExpanded = expandedSections.includes(row.elidedInfo.id); 834 - return ( 835 - <ElidedRow 836 - key={row.elidedInfo.id} 837 - elidedInfo={row.elidedInfo} 838 - isExpanded={isExpanded} 839 - onToggle={() => row.elidedInfo && toggleExpanded(row.elidedInfo.id)} 840 - /> 841 - ); 842 - } 843 - return null; 689 + const isFlashing = flash?.changeId === row.revision.change_id; 690 + const isDimmed = 691 + selectedRevision !== null && !relatedRevisions.has(row.revision.change_id); 692 + return ( 693 + <RevisionRow 694 + key={row.revision.change_id} 695 + revision={row.revision} 696 + maxLaneOnRow={row.maxLaneOnRow} 697 + isSelected={selectedRevision?.change_id === row.revision.change_id} 698 + onSelect={handleSelect} 699 + isFlashing={isFlashing} 700 + isDimmed={isDimmed} 701 + /> 702 + ); 844 703 })} 845 704 </div> 846 705 </div>