a very good jj gui
0
fork

Configure Feed

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

lineage

+395 -171
+20 -1
apps/desktop/src-tauri/src/lib.rs
··· 4 4 5 5 use repo::diff; 6 6 use repo::jj::{JjRepo, MutationResult, Operation}; 7 - use repo::log::{Revision, RevsetResult}; 7 + use repo::log::{LineageResult, Revision, RevsetResult}; 8 8 use repo::status::WorkingCopyStatus; 9 9 use serde::Serialize; 10 10 use std::path::{Path, PathBuf}; ··· 559 559 repo::log::resolve_revset(path, &revset).map_err(|e| format!("Failed to resolve revset: {}", e)) 560 560 } 561 561 562 + /// Get lineage (ancestors and descendants) for multiple revisions in a batch. 563 + #[tauri::command] 564 + async fn get_lineage_batch( 565 + repo_path: String, 566 + change_ids: Vec<String>, 567 + ) -> Result<Vec<LineageResult>, String> { 568 + let path = Path::new(&repo_path); 569 + 570 + let results: Vec<LineageResult> = change_ids 571 + .iter() 572 + .filter_map(|change_id| { 573 + repo::log::get_lineage(path, change_id).ok() 574 + }) 575 + .collect(); 576 + 577 + Ok(results) 578 + } 579 + 562 580 #[derive(Serialize)] 563 581 struct FileContentResult { 564 582 base64: String, ··· 750 768 get_changes_batch, 751 769 get_commit_recency, 752 770 resolve_revset, 771 + get_lineage_batch, 753 772 get_file_content_base64, 754 773 get_projects, 755 774 upsert_project,
+104 -9
apps/desktop/src-tauri/src/repo/log.rs
··· 35 35 pub commit_id: String, 36 36 pub change_id: String, 37 37 pub change_id_short: String, 38 - pub parent_ids: Vec<String>, 39 38 pub parent_edges: Vec<ParentEdge>, 39 + pub children_ids: Vec<String>, 40 40 pub description: String, 41 41 pub author: String, 42 42 pub timestamp: String, ··· 200 200 201 201 let bookmarks = get_bookmarks_for_commit(repo.as_ref(), &commit_id); 202 202 203 - // Keep parent_ids for backward compatibility 204 - let parent_ids: Vec<String> = commit 205 - .parent_ids() 206 - .iter() 207 - .map(|id| hex::encode(&id.to_bytes()[..6])) 208 - .collect(); 209 - 210 203 // Build parent_edges from graph edges with type information 211 204 let parent_edges: Vec<ParentEdge> = edges 212 205 .iter() ··· 253 246 commit_id: hex::encode(&commit_id.to_bytes()[..6]), 254 247 change_id: full_change_id, 255 248 change_id_short, 256 - parent_ids, 257 249 parent_edges, 250 + children_ids: Vec::new(), // Populated in second pass 258 251 description, 259 252 author: author_name, 260 253 timestamp, ··· 269 262 }); 270 263 } 271 264 265 + // Second pass: compute children_ids from parent_edges 266 + // Build a map of parent_id -> list of child commit_ids 267 + let mut children_map: HashMap<String, Vec<String>> = HashMap::new(); 268 + for revision in &revisions { 269 + for edge in &revision.parent_edges { 270 + children_map 271 + .entry(edge.parent_id.clone()) 272 + .or_default() 273 + .push(revision.commit_id.clone()); 274 + } 275 + } 276 + 277 + // Populate children_ids for each revision 278 + for revision in &mut revisions { 279 + if let Some(children) = children_map.remove(&revision.commit_id) { 280 + revision.children_ids = children; 281 + } 282 + } 283 + 272 284 Ok(revisions) 273 285 } 274 286 ··· 395 407 pub error: Option<String>, 396 408 } 397 409 410 + /// Result of computing lineage for a revision 411 + #[derive(Clone, Debug, serde::Serialize)] 412 + pub struct LineageResult { 413 + pub change_id: String, 414 + pub related_ids: Vec<String>, 415 + } 416 + 398 417 /// Resolve a revset expression and return matching change IDs 399 418 pub fn resolve_revset(repo_path: &Path, revset_str: &str) -> Result<RevsetResult> { 400 419 let jj_repo = JjRepo::open(repo_path)?; ··· 498 517 error: None, 499 518 }) 500 519 } 520 + 521 + /// Compute lineage (ancestors and descendants) for a revision 522 + pub fn get_lineage(repo_path: &Path, change_id: &str) -> Result<LineageResult> { 523 + let jj_repo = JjRepo::open(repo_path)?; 524 + let repo = jj_repo.repo_loader().load_at_head()?; 525 + 526 + // Set up aliases (same as resolve_revset) 527 + let mut aliases_map = RevsetAliasesMap::new(); 528 + let user_email = jj_repo.user_settings().user_email(); 529 + 530 + aliases_map.insert( 531 + "trunk()", 532 + r#"latest( 533 + remote_bookmarks(exact:"main", exact:"origin") | 534 + remote_bookmarks(exact:"master", exact:"origin") | 535 + remote_bookmarks(exact:"trunk", exact:"origin") | 536 + root() 537 + )"#, 538 + ).ok(); 539 + 540 + aliases_map.insert("builtin_immutable_heads()", "present(trunk()) | tags() | untracked_remote_bookmarks()").ok(); 541 + aliases_map.insert("immutable_heads()", "builtin_immutable_heads()").ok(); 542 + 543 + let mine_revset = format!(r#"author_email(exact-i:"{}")"#, user_email); 544 + aliases_map.insert("mine()", &mine_revset).ok(); 545 + 546 + let path_converter = RepoPathUiConverter::Fs { 547 + cwd: repo_path.to_path_buf(), 548 + base: repo_path.to_path_buf(), 549 + }; 550 + let workspace_name = jj_repo.workspace_name(); 551 + let workspace_ctx = RevsetWorkspaceContext { 552 + path_converter: &path_converter, 553 + workspace_name, 554 + }; 555 + 556 + let context = RevsetParseContext { 557 + aliases_map: &aliases_map, 558 + local_variables: HashMap::new(), 559 + user_email: jj_repo.user_settings().user_email(), 560 + date_pattern_context: chrono::Utc::now().fixed_offset().into(), 561 + default_ignored_remote: Some(git::REMOTE_NAME_FOR_LOCAL_GIT_REPO), 562 + extensions: &RevsetExtensions::default(), 563 + workspace: Some(workspace_ctx), 564 + }; 565 + 566 + // Build revset: ancestors(X) | descendants(X) 567 + let revset_str = format!("ancestors({}) | descendants({})", change_id, change_id); 568 + 569 + let mut diagnostics = RevsetDiagnostics::new(); 570 + let expression = parse(&mut diagnostics, &revset_str, &context) 571 + .context("Failed to parse lineage revset")?; 572 + 573 + let symbol_resolver = SymbolResolver::new(repo.as_ref(), &([] as [&Box<dyn SymbolResolverExtension>; 0])); 574 + let resolved = expression.resolve_user_expression(repo.as_ref(), &symbol_resolver) 575 + .context("Failed to resolve lineage revset")?; 576 + 577 + let revset = resolved.evaluate(repo.as_ref()) 578 + .context("Failed to evaluate lineage revset")?; 579 + 580 + // Collect related change IDs (excluding the input change_id itself) 581 + let mut related_ids = Vec::new(); 582 + for commit_id_result in revset.iter() { 583 + let commit_id = commit_id_result?; 584 + let commit = repo.store().get_commit(&commit_id)?; 585 + let related_change_id = format_change_id(commit.change_id()); 586 + if related_change_id != change_id { 587 + related_ids.push(related_change_id); 588 + } 589 + } 590 + 591 + Ok(LineageResult { 592 + change_id: change_id.to_string(), 593 + related_ids, 594 + }) 595 + }
+2 -2
apps/desktop/src/components/AppShell.tsx
··· 332 332 } 333 333 334 334 const visited = new Set<string>(); 335 - const queue = [...workingCopy.parent_ids]; 335 + const queue = workingCopy.parent_edges.map((e) => e.parent_id); 336 336 337 337 while (queue.length > 0) { 338 338 const commitId = queue.shift(); ··· 346 346 return rev.bookmarks[0].name; 347 347 } 348 348 349 - queue.push(...rev.parent_ids); 349 + queue.push(...rev.parent_edges.map((e) => e.parent_id)); 350 350 } 351 351 352 352 return null;
+69 -85
apps/desktop/src/components/revision-graph/index.tsx
··· 26 26 } from "@/components/revision-graph-utils"; 27 27 import { getRevisionKey } from "@/db"; 28 28 import { useFocusWithin } from "@/hooks/useFocusWithin"; 29 - import { usePrefetch } from "@/hooks/useRevisionData"; 29 + import { useLineage, usePrefetch } from "@/hooks/useRevisionData"; 30 30 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 31 31 import { useRevisionGraphNavigation } from "@/hooks/useRevisionGraphNavigation"; 32 32 import type { Revision } from "@/tauri-commands"; ··· 188 188 // Handle missing edges (parents outside our revset) 189 189 // Only show stub if we have original parents but no visible parents 190 190 const hasMissingParents = revision.parent_edges.some((e) => e.edge_type === "missing"); 191 - const hasParentsOutsideView = revision.parent_ids.length > visibleParents.length; 191 + const hasParentsOutsideView = revision.parent_edges.length > visibleParents.length; 192 192 193 193 if ((hasMissingParents || hasParentsOutsideView) && parentConnections.length === 0) { 194 194 // All parents are outside the view - show a stub ··· 316 316 317 317 traceEnd(traceId, { rowCount: rows.length, edgeCount: edgeBindings.length }); 318 318 return { nodes, laneCount: globalMaxLane + 1, rows, edgeBindings }; 319 - } 320 - 321 - // Compute related revisions (ancestors + descendants) of a selected revision 322 - function getRelatedRevisions(revisions: Revision[], selectedChangeId: string | null): Set<string> { 323 - const traceId = traceStart("get-related-revisions", { 324 - selectedChangeId, 325 - revisionCount: revisions.length, 326 - }); 327 - 328 - if (!selectedChangeId) { 329 - traceEnd(traceId, { relatedCount: 0 }); 330 - return new Set(); 331 - } 332 - 333 - const related = new Set<string>(); 334 - const commitIdToChangeId = new Map<string, string>(); 335 - const changeIdToCommitId = new Map<string, string>(); 336 - const childrenMap = new Map<string, string[]>(); // commit_id -> child commit_ids 337 - const parentMap = new Map<string, string[]>(); // commit_id -> parent commit_ids 338 - 339 - // Build maps 340 - for (const rev of revisions) { 341 - commitIdToChangeId.set(rev.commit_id, rev.change_id); 342 - changeIdToCommitId.set(rev.change_id, rev.commit_id); 343 - const parents: string[] = []; 344 - for (const edge of rev.parent_edges) { 345 - if (edge.edge_type === "missing") continue; 346 - parents.push(edge.parent_id); 347 - const children = childrenMap.get(edge.parent_id) ?? []; 348 - children.push(rev.commit_id); 349 - childrenMap.set(edge.parent_id, children); 350 - } 351 - parentMap.set(rev.commit_id, parents); 352 - } 353 - 354 - const selectedCommitId = changeIdToCommitId.get(selectedChangeId); 355 - if (!selectedCommitId) { 356 - traceEnd(traceId, { relatedCount: 0 }); 357 - return new Set(); 358 - } 359 - 360 - // BFS to find ancestors 361 - const ancestorQueue = [selectedCommitId]; 362 - const visited = new Set<string>(); 363 - while (ancestorQueue.length > 0) { 364 - const id = ancestorQueue.shift(); 365 - if (!id || visited.has(id)) continue; 366 - visited.add(id); 367 - const changeId = commitIdToChangeId.get(id); 368 - if (changeId) related.add(changeId); 369 - const parents = parentMap.get(id) ?? []; 370 - for (const parentId of parents) { 371 - ancestorQueue.push(parentId); 372 - } 373 - } 374 - 375 - // BFS to find descendants 376 - const descendantQueue = [selectedCommitId]; 377 - visited.clear(); 378 - while (descendantQueue.length > 0) { 379 - const id = descendantQueue.shift(); 380 - if (!id || visited.has(id)) continue; 381 - visited.add(id); 382 - const changeId = commitIdToChangeId.get(id); 383 - if (changeId) related.add(changeId); 384 - const children = childrenMap.get(id) ?? []; 385 - for (const childId of children) { 386 - descendantQueue.push(childId); 387 - } 388 - } 389 - 390 - traceEnd(traceId, { relatedCount: related.size }); 391 - return related; 392 319 } 393 320 394 321 export const RevisionGraph = forwardRef<RevisionGraphHandle, RevisionGraphProps>( ··· 436 363 const stacks = useMemo(() => detectStacks(stableRevisions), [stableRevisions]); 437 364 438 365 // Setup prefetch hooks for visible revision data 439 - const { prefetchDiffs, prefetchChanges } = usePrefetch(repoPath ?? ""); 366 + const { prefetchDiffs, prefetchChanges, prefetchLineage } = usePrefetch(repoPath ?? ""); 440 367 441 368 // Track which stacks are expanded (empty = all collapsed by default) 442 369 const [expandedStacks, setExpandedStacks] = useAtom(expandedStacksAtom); ··· 592 519 // Defer the selected ID so dimming computation doesn't block selection highlight 593 520 const deferredSelectedChangeId = useDeferredValue(selectedRevision?.change_id ?? null); 594 521 522 + // Resolve stack for lineage queries 523 + const focusedStack = focusedStackId ? stackById.get(focusedStackId) : null; 524 + 525 + // Get lineage from backend - call hooks unconditionally with null when not needed 526 + const topLineageChangeId = focusedStack ? focusedStack.topChangeId : null; 527 + const bottomLineageChangeId = focusedStack ? focusedStack.bottomChangeId : null; 528 + const primaryLineageChangeId = focusedStack ? null : deferredSelectedChangeId; 529 + 530 + const { lineage: topLineage, isLoaded: topLoaded } = useLineage( 531 + repoPath ?? "", 532 + topLineageChangeId, 533 + ); 534 + const { lineage: bottomLineage, isLoaded: bottomLoaded } = useLineage( 535 + repoPath ?? "", 536 + bottomLineageChangeId, 537 + ); 538 + const { lineage: primaryLineage, isLoaded: primaryLoaded } = useLineage( 539 + repoPath ?? "", 540 + primaryLineageChangeId, 541 + ); 542 + 543 + // Don't dim while lineage is loading - prevents flicker 544 + const lineageIsLoading = focusedStack 545 + ? !topLoaded || !bottomLoaded 546 + : deferredSelectedChangeId && !primaryLoaded; 547 + 595 548 // Compute related revisions for dimming logic 596 - // Use focusedStackId (string) as dependency instead of focusedStack (object) for stability 549 + // Intersect with visible revisions for display 550 + // When loading, return null to disable dimming entirely 597 551 const relatedRevisions = useMemo(() => { 598 - const focusedStack = focusedStackId ? stackById.get(focusedStackId) : null; 552 + // Don't dim while loading 553 + if (lineageIsLoading) return null; 554 + 555 + const visibleIds = new Set(stableRevisions.map((r) => r.change_id)); 556 + 599 557 if (focusedStack) { 600 558 // When stack is focused, highlight the stack endpoints and their ancestors/descendants 601 - const topRelated = getRelatedRevisions(stableRevisions, focusedStack.topChangeId); 602 - const bottomRelated = getRelatedRevisions(stableRevisions, focusedStack.bottomChangeId); 603 - // Union of both sets 604 - return new Set([...topRelated, ...bottomRelated]); 559 + const combined = new Set([...topLineage, ...bottomLineage]); 560 + const result = new Set([...combined].filter((id) => visibleIds.has(id))); 561 + // Always include the focused stack endpoints 562 + if (focusedStack.topChangeId) result.add(focusedStack.topChangeId); 563 + if (focusedStack.bottomChangeId) result.add(focusedStack.bottomChangeId); 564 + return result; 605 565 } 606 - return getRelatedRevisions(stableRevisions, deferredSelectedChangeId); 607 - }, [stableRevisions, stackById, focusedStackId, deferredSelectedChangeId]); 566 + 567 + if (!deferredSelectedChangeId) return null; 568 + const result = new Set([...primaryLineage].filter((id) => visibleIds.has(id))); 569 + // Always include the selected revision itself 570 + result.add(deferredSelectedChangeId); 571 + return result; 572 + }, [ 573 + stableRevisions, 574 + focusedStack, 575 + topLineage, 576 + bottomLineage, 577 + primaryLineage, 578 + deferredSelectedChangeId, 579 + lineageIsLoading, 580 + ]); 608 581 609 582 // Build revision key -> displayRow index map for scrolling and edge positioning 610 583 // IMPORTANT: Use displayRows indices (not rows) to match virtualizer positioning ··· 943 916 const uniqueIds = [...new Set(changeIds)].slice(0, 50); 944 917 prefetchDiffs(uniqueIds); 945 918 prefetchChanges(uniqueIds); 919 + 920 + // Prefetch lineage for selected revision (for dimming related revisions) 921 + if (selectedRevision) { 922 + prefetchLineage([selectedRevision.change_id]); 923 + } 946 924 }, 200); 947 925 948 926 // Cleanup on unmount or when deps change ··· 959 937 selectedRevision, 960 938 prefetchDiffs, 961 939 prefetchChanges, 940 + prefetchLineage, 962 941 ]); 963 942 964 943 // Compute jump hints for visible rows based on change ID prefix matching ··· 1164 1143 const count = stack.intermediateChangeIds.length; 1165 1144 1166 1145 // Check if this stack is related to the selected revision (for dimming) 1167 - const isStackRelated = stack.changeIds.some((id) => relatedRevisions.has(id)); 1146 + // Don't dim if relatedRevisions is null (still loading) 1147 + const isStackRelated = 1148 + relatedRevisions === null || 1149 + stack.changeIds.some((id) => relatedRevisions.has(id)); 1168 1150 const isStackDimmed = selectedRevision !== null && !isStackRelated; 1169 1151 const isStackFocused = focusedStackId === stack.id; 1170 1152 ··· 1240 1222 const { row } = displayRow; 1241 1223 const lane = changeIdToLane.get(row.revision.change_id) ?? 0; 1242 1224 const isFlashing = flash?.changeId === row.revision.change_id; 1225 + // Don't dim if relatedRevisions is null (still loading) 1243 1226 const isDimmed = 1227 + relatedRevisions !== null && 1244 1228 (selectedRevision !== null || focusedStackId !== null) && 1245 1229 !relatedRevisions.has(row.revision.change_id); 1246 1230 // Only show focus if no stack is focused
+34 -1
apps/desktop/src/db.ts
··· 138 138 await queryClient.invalidateQueries({ queryKey: ["revision-changes", repoPath] }); 139 139 await queryClient.invalidateQueries({ queryKey: ["revision-diff", repoPath] }); 140 140 await queryClient.invalidateQueries({ queryKey: ["commit-recency", repoPath] }); 141 + await queryClient.invalidateQueries({ queryKey: ["lineage"] }); 141 142 } 142 143 }); 143 144 ··· 326 327 commit_id: `pending-${preAllocatedChangeId}`, // Temporary, will be replaced 327 328 change_id: preAllocatedChangeId, 328 329 change_id_short: preAllocatedChangeId.slice(0, 8), // Approximate short ID 329 - parent_ids: [parentRevision.commit_id], 330 330 parent_edges: [{ parent_id: parentRevision.commit_id, edge_type: "direct" as const }], 331 + children_ids: [], 331 332 description: "", 332 333 author: parentRevision.author, // Inherit from parent 333 334 timestamp: new Date().toISOString(), ··· 656 657 }); 657 658 658 659 export type ChangesCollection = typeof changesCollection; 660 + 661 + // ============================================================================ 662 + // Unified Lineage Collection (single collection for all revision lineage data) 663 + // ============================================================================ 664 + 665 + /** 666 + * Unified lineage record - stores related revision IDs keyed by repoPath:changeId. 667 + * Used for highlighting related revisions in the graph. 668 + */ 669 + export interface LineageRecord { 670 + repoPath: string; 671 + changeId: string; 672 + relatedIds: string[]; 673 + } 674 + 675 + function getLineageRecordKey(l: LineageRecord): string { 676 + return `${l.repoPath}:${l.changeId}`; 677 + } 678 + 679 + const lineageQueryKey = ["lineage"] as const; 680 + 681 + export const lineageCollection = createCollection({ 682 + ...queryCollectionOptions({ 683 + queryClient, 684 + queryKey: lineageQueryKey, 685 + queryFn: async () => [] as LineageRecord[], 686 + getKey: getLineageRecordKey, 687 + }), 688 + startSync: true, 689 + }); 690 + 691 + export type LineageCollection = typeof lineageCollection;
+9 -11
apps/desktop/src/hooks/useKeyboard.ts
··· 123 123 case isMinusKey: 124 124 if (currentRevision) { 125 125 // Navigate to parent revision 126 - const parentId = 127 - currentRevision.parent_ids[0] || currentRevision.parent_edges[0]?.parent_id; 126 + const parentId = currentRevision.parent_edges[0]?.parent_id; 128 127 if (parentId) { 129 128 const parentRevision = revisions.find((r) => r.commit_id === parentId); 130 129 if (parentRevision) { ··· 138 137 139 138 case isPlusKey: 140 139 if (currentRevision) { 141 - // Find child by checking if any revision has current as parent 142 - const childRevision = revisions.find( 143 - (r) => 144 - r.parent_ids.includes(currentRevision.commit_id) || 145 - r.parent_edges.some((e) => e.parent_id === currentRevision.commit_id), 146 - ); 147 - if (childRevision) { 148 - targetRevisionKey = getRevisionKey(childRevision); 149 - scrollMode = "step"; 140 + // Navigate to child revision using children_ids 141 + const childId = currentRevision.children_ids[0]; 142 + if (childId) { 143 + const childRevision = revisions.find((r) => r.commit_id === childId); 144 + if (childRevision) { 145 + targetRevisionKey = getRevisionKey(childRevision); 146 + scrollMode = "step"; 147 + } 150 148 } 151 149 } 152 150 event.preventDefault();
+107 -1
apps/desktop/src/hooks/useRevisionData.ts
··· 7 7 8 8 import { useLiveQuery } from "@tanstack/react-db"; 9 9 import { useMemo, useRef } from "react"; 10 - import { type ChangeRecord, changesCollection, type DiffRecord, diffsCollection } from "@/db"; 10 + import { 11 + type ChangeRecord, 12 + changesCollection, 13 + type DiffRecord, 14 + diffsCollection, 15 + type LineageRecord, 16 + lineageCollection, 17 + } from "@/db"; 11 18 import { type BatchLoader, createBatchLoader } from "@/lib/batch-loader"; 12 19 import { traceLog } from "@/lib/trace"; 13 20 import { 14 21 getChangesBatchEffect, 15 22 getDiffsBatchEffect, 23 + getLineageBatchEffect, 24 + type LineageResult, 16 25 type RevisionChanges, 17 26 type RevisionDiff, 18 27 } from "@/tauri-commands"; ··· 111 120 }); 112 121 } 113 122 123 + /** 124 + * Creates a BatchLoader for revision lineage. 125 + * The loader batches IPC calls and syncs results to the unified lineageCollection. 126 + */ 127 + function createLineageLoader(repoPath: string): BatchLoader { 128 + return createBatchLoader<LineageResult>({ 129 + debounceMs: 50, 130 + maxBatchSize: 20, 131 + fetchBatch: (ids) => getLineageBatchEffect(repoPath, ids), 132 + syncToCollection: (results) => { 133 + // Wait for collection to be ready before writing 134 + if (lineageCollection.status !== "ready") return; 135 + const records: LineageRecord[] = results.map((r) => ({ 136 + repoPath, 137 + changeId: r.change_id, 138 + relatedIds: r.related_ids, 139 + })); 140 + lineageCollection.utils.writeUpsert(records); 141 + 142 + // LRU eviction: keep only last 50 lineage records per repo 143 + const MAX_LINEAGE = 50; 144 + const allRecords = Array.from(lineageCollection.state.values()).filter( 145 + (r) => r.repoPath === repoPath, 146 + ); 147 + if (allRecords.length > MAX_LINEAGE) { 148 + // Remove oldest entries (first ones in the Map iteration order) 149 + const toRemove = allRecords.slice(0, allRecords.length - MAX_LINEAGE); 150 + for (const record of toRemove) { 151 + const key = `${record.repoPath}:${record.changeId}`; 152 + lineageCollection.state.delete(key); 153 + } 154 + } 155 + }, 156 + isLoaded: (id) => { 157 + if (lineageCollection.status !== "ready") return false; 158 + const key = `${repoPath}:${id}`; 159 + return lineageCollection.state.has(key); 160 + }, 161 + }); 162 + } 163 + 114 164 // ============================================================================ 115 165 // Loader Instance Cache 116 166 // ============================================================================ ··· 118 168 // Cache loaders per repoPath to avoid creating multiple instances 119 169 const diffLoaders = new Map<string, BatchLoader>(); 120 170 const changesLoaders = new Map<string, BatchLoader>(); 171 + const lineageLoaders = new Map<string, BatchLoader>(); 121 172 122 173 /** 123 174 * Clean up loaders for repos we're no longer viewing. ··· 134 185 changesLoaders.delete(path); 135 186 } 136 187 } 188 + for (const [path] of lineageLoaders) { 189 + if (path !== currentRepoPath) { 190 + lineageLoaders.delete(path); 191 + } 192 + } 137 193 } 138 194 139 195 function getDiffLoader(repoPath: string): BatchLoader { ··· 150 206 if (!loader) { 151 207 loader = createChangesLoader(repoPath); 152 208 changesLoaders.set(repoPath, loader); 209 + } 210 + return loader; 211 + } 212 + 213 + function getLineageLoader(repoPath: string): BatchLoader { 214 + let loader = lineageLoaders.get(repoPath); 215 + if (!loader) { 216 + loader = createLineageLoader(repoPath); 217 + lineageLoaders.set(repoPath, loader); 153 218 } 154 219 return loader; 155 220 } ··· 186 251 } 187 252 188 253 /** 254 + * Read lineage (related revision IDs) for a revision from local DB. 255 + * Returns { lineage, isLoaded } to allow callers to handle loading state. 256 + */ 257 + export function useLineage( 258 + repoPath: string, 259 + changeId: string | null, 260 + ): { lineage: Set<string>; isLoaded: boolean } { 261 + const { data: allLineage = [] } = useLiveQuery(lineageCollection); 262 + 263 + return useMemo(() => { 264 + if (!changeId) { 265 + return { lineage: new Set<string>(), isLoaded: true }; 266 + } 267 + 268 + const record = allLineage.find((l) => l.repoPath === repoPath && l.changeId === changeId); 269 + 270 + if (record) { 271 + return { lineage: new Set(record.relatedIds), isLoaded: true }; 272 + } 273 + 274 + return { lineage: new Set<string>(), isLoaded: false }; 275 + }, [allLineage, repoPath, changeId]); 276 + } 277 + 278 + /** 189 279 * Prefetch hook for components that need to load data ahead of user interaction. 190 280 * Returns functions to queue prefetch requests and flush them immediately if needed. 191 281 */ 192 282 export function usePrefetch(repoPath: string): { 193 283 prefetchDiffs: (ids: string[]) => void; 194 284 prefetchChanges: (ids: string[]) => void; 285 + prefetchLineage: (ids: string[]) => void; 195 286 flushDiffs: () => Promise<void>; 196 287 flushChanges: () => Promise<void>; 288 + flushLineage: () => Promise<void>; 197 289 } { 198 290 // Use refs to avoid recreating loaders on each render 199 291 const diffLoaderRef = useRef<BatchLoader | null>(null); 200 292 const changesLoaderRef = useRef<BatchLoader | null>(null); 293 + const lineageLoaderRef = useRef<BatchLoader | null>(null); 201 294 const currentRepoPathRef = useRef<string>(repoPath); 202 295 203 296 // Reset loaders if repoPath changes ··· 205 298 currentRepoPathRef.current = repoPath; 206 299 diffLoaderRef.current = null; 207 300 changesLoaderRef.current = null; 301 + lineageLoaderRef.current = null; 208 302 // Clean up loaders for other repos to prevent memory leaks 209 303 cleanupLoadersExcept(repoPath); 210 304 } ··· 224 318 return changesLoaderRef.current; 225 319 } 226 320 321 + function getLineageLoaderInstance(): BatchLoader { 322 + if (!lineageLoaderRef.current) { 323 + lineageLoaderRef.current = getLineageLoader(repoPath); 324 + } 325 + return lineageLoaderRef.current; 326 + } 327 + 227 328 return { 228 329 prefetchDiffs: (ids: string[]) => { 229 330 traceLog("prefetch-diffs", { count: ids.length, ids }); ··· 233 334 traceLog("prefetch-changes", { count: ids.length, ids }); 234 335 getChangesLoaderInstance().queueMany(ids); 235 336 }, 337 + prefetchLineage: (ids: string[]) => { 338 + traceLog("prefetch-lineage", { count: ids.length, ids }); 339 + getLineageLoaderInstance().queueMany(ids); 340 + }, 236 341 flushDiffs: () => getDiffLoaderInstance().flushPromise(), 237 342 flushChanges: () => getChangesLoaderInstance().flushPromise(), 343 + flushLineage: () => getLineageLoaderInstance().flushPromise(), 238 344 }; 239 345 }, [repoPath]); 240 346 }
+27 -60
apps/desktop/src/mocks/setup.ts
··· 26 26 return result; 27 27 } 28 28 29 - // Calculate shortest unique prefix for each change ID 30 - function calculateShortIds(revisionsRaw: Omit<Revision, "change_id_short">[]): Revision[] { 29 + // Calculate shortest unique prefix for each change ID and compute children_ids 30 + function calculateShortIds( 31 + revisionsRaw: Omit<Revision, "change_id_short" | "children_ids">[], 32 + ): Revision[] { 31 33 const changeIds = revisionsRaw.map((r) => r.change_id); 34 + 35 + // Build children_ids map from parent_edges 36 + const childrenMap = new Map<string, string[]>(); 37 + for (const revision of revisionsRaw) { 38 + for (const edge of revision.parent_edges) { 39 + const children = childrenMap.get(edge.parent_id) ?? []; 40 + children.push(revision.commit_id); 41 + childrenMap.set(edge.parent_id, children); 42 + } 43 + } 44 + 32 45 const result: Revision[] = []; 33 46 34 47 for (let i = 0; i < changeIds.length; i++) { ··· 60 73 result.push({ 61 74 ...revision, 62 75 change_id_short: changeIdShort, 76 + children_ids: childrenMap.get(revision.commit_id) ?? [], 63 77 }); 64 78 } 65 79 ··· 88 102 // Change IDs are generated randomly (jj-style: 12 chars, k-z only) 89 103 // Short IDs are calculated as minimum unique prefixes 90 104 // Only one "main" bookmark exists on the latest main commit 91 - const mockRevisionsRaw: Omit<Revision, "change_id_short">[] = [ 105 + const mockRevisionsRaw: Omit<Revision, "change_id_short" | "children_ids">[] = [ 92 106 // Root commit 93 107 { 94 108 commit_id: "root0000000000", 95 109 change_id: generateChangeId(), 96 - parent_ids: [], 97 110 parent_edges: [], 98 111 description: "chore: initial commit", 99 112 author: "alice@example.com", ··· 111 124 { 112 125 commit_id: "main0010000000", 113 126 change_id: generateChangeId(), 114 - parent_ids: ["root0000000000"], 115 127 parent_edges: [{ parent_id: "root0000000000", edge_type: "direct" }], 116 128 description: "feat: initial project setup with build config", 117 129 author: "alice@example.com", ··· 128 140 { 129 141 commit_id: "main0020000000", 130 142 change_id: generateChangeId(), 131 - parent_ids: ["main0010000000"], 132 143 parent_edges: [{ parent_id: "main0010000000", edge_type: "direct" }], 133 144 description: "feat: add basic routing infrastructure", 134 145 author: "alice@example.com", ··· 145 156 { 146 157 commit_id: "main0030000000", 147 158 change_id: generateChangeId(), 148 - parent_ids: ["main0020000000"], 149 159 parent_edges: [{ parent_id: "main0020000000", edge_type: "direct" }], 150 160 description: "feat: implement core data models", 151 161 author: "bob@example.com", ··· 163 173 { 164 174 commit_id: "auth0010000000", 165 175 change_id: generateChangeId(), 166 - parent_ids: ["main0030000000"], 167 176 parent_edges: [{ parent_id: "main0030000000", edge_type: "direct" }], 168 177 description: "feat: add authentication service skeleton", 169 178 author: "alice@example.com", ··· 180 189 { 181 190 commit_id: "auth0020000000", 182 191 change_id: generateChangeId(), 183 - parent_ids: ["auth0010000000"], 184 192 parent_edges: [{ parent_id: "auth0010000000", edge_type: "direct" }], 185 193 description: "feat: implement login form component", 186 194 author: "alice@example.com", ··· 197 205 { 198 206 commit_id: "auth0030000000", 199 207 change_id: generateChangeId(), 200 - parent_ids: ["auth0020000000"], 201 208 parent_edges: [{ parent_id: "auth0020000000", edge_type: "direct" }], 202 209 description: "feat: add JWT token handling", 203 210 author: "alice@example.com", ··· 214 221 { 215 222 commit_id: "auth0040000000", 216 223 change_id: generateChangeId(), 217 - parent_ids: ["auth0030000000"], 218 224 parent_edges: [{ parent_id: "auth0030000000", edge_type: "direct" }], 219 225 description: "feat: add password reset flow", 220 226 author: "alice@example.com", ··· 232 238 { 233 239 commit_id: "dark0010000000", 234 240 change_id: generateChangeId(), 235 - parent_ids: ["main0030000000"], 236 241 parent_edges: [{ parent_id: "main0030000000", edge_type: "direct" }], 237 242 description: "feat: add theme provider infrastructure", 238 243 author: "charlie@example.com", ··· 249 254 { 250 255 commit_id: "dark0020000000", 251 256 change_id: generateChangeId(), 252 - parent_ids: ["dark0010000000"], 253 257 parent_edges: [{ parent_id: "dark0010000000", edge_type: "direct" }], 254 258 description: "feat: implement dark mode toggle component", 255 259 author: "charlie@example.com", ··· 266 270 { 267 271 commit_id: "dark0030000000", 268 272 change_id: generateChangeId(), 269 - parent_ids: ["dark0020000000"], 270 273 parent_edges: [{ parent_id: "dark0020000000", edge_type: "direct" }], 271 274 description: "feat: add dark mode styles for all components", 272 275 author: "charlie@example.com", ··· 283 286 { 284 287 commit_id: "dark0040000000", 285 288 change_id: generateChangeId(), 286 - parent_ids: ["dark0030000000"], 287 289 parent_edges: [{ parent_id: "dark0030000000", edge_type: "direct" }], 288 290 description: "feat: add system theme detection", 289 291 author: "charlie@example.com", ··· 300 302 { 301 303 commit_id: "dark0050000000", 302 304 change_id: generateChangeId(), 303 - parent_ids: ["dark0040000000"], 304 305 parent_edges: [{ parent_id: "dark0040000000", edge_type: "direct" }], 305 306 description: "fix: improve contrast ratios for accessibility", 306 307 author: "charlie@example.com", ··· 318 319 { 319 320 commit_id: "main0040000000", 320 321 change_id: generateChangeId(), 321 - parent_ids: ["main0030000000"], 322 322 parent_edges: [{ parent_id: "main0030000000", edge_type: "direct" }], 323 323 description: "fix: resolve race condition in data fetching", 324 324 author: "bob@example.com", ··· 335 335 { 336 336 commit_id: "main0050000000", 337 337 change_id: generateChangeId(), 338 - parent_ids: ["main0040000000"], 339 338 parent_edges: [{ parent_id: "main0040000000", edge_type: "direct" }], 340 339 description: "refactor: extract shared utilities into separate module", 341 340 author: "bob@example.com", ··· 353 352 { 354 353 commit_id: "perf0010000000", 355 354 change_id: generateChangeId(), 356 - parent_ids: ["main0050000000"], 357 355 parent_edges: [{ parent_id: "main0050000000", edge_type: "direct" }], 358 356 description: "perf: optimize database queries", 359 357 author: "david@example.com", ··· 370 368 { 371 369 commit_id: "perf0020000000", 372 370 change_id: generateChangeId(), 373 - parent_ids: ["perf0010000000"], 374 371 parent_edges: [{ parent_id: "perf0010000000", edge_type: "direct" }], 375 372 description: "perf: add query result caching layer", 376 373 author: "david@example.com", ··· 387 384 { 388 385 commit_id: "perf0030000000", 389 386 change_id: generateChangeId(), 390 - parent_ids: ["perf0020000000"], 391 387 parent_edges: [{ parent_id: "perf0020000000", edge_type: "direct" }], 392 388 description: "perf: implement lazy loading for large datasets", 393 389 author: "david@example.com", ··· 404 400 { 405 401 commit_id: "perf0040000000", 406 402 change_id: generateChangeId(), 407 - parent_ids: ["perf0030000000"], 408 403 parent_edges: [{ parent_id: "perf0030000000", edge_type: "direct" }], 409 404 description: "perf: add connection pooling for database", 410 405 author: "david@example.com", ··· 422 417 { 423 418 commit_id: "main0060000000", 424 419 change_id: generateChangeId(), 425 - parent_ids: ["main0050000000"], 426 420 parent_edges: [{ parent_id: "main0050000000", edge_type: "direct" }], 427 421 description: "docs: update API documentation", 428 422 author: "alice@example.com", ··· 439 433 { 440 434 commit_id: "main0070000000", 441 435 change_id: generateChangeId(), 442 - parent_ids: ["main0060000000"], 443 436 parent_edges: [{ parent_id: "main0060000000", edge_type: "direct" }], 444 437 description: "chore: update dependencies", 445 438 author: "bob@example.com", ··· 457 450 { 458 451 commit_id: "api0010000000", 459 452 change_id: generateChangeId(), 460 - parent_ids: ["main0070000000"], 461 453 parent_edges: [{ parent_id: "main0070000000", edge_type: "direct" }], 462 454 description: "feat: add REST API endpoint for user management", 463 455 author: "eve@example.com", ··· 474 466 { 475 467 commit_id: "api0020000000", 476 468 change_id: generateChangeId(), 477 - parent_ids: ["api0010000000"], 478 469 parent_edges: [{ parent_id: "api0010000000", edge_type: "direct" }], 479 470 description: "feat: add request validation middleware", 480 471 author: "eve@example.com", ··· 491 482 { 492 483 commit_id: "api0030000000", 493 484 change_id: generateChangeId(), 494 - parent_ids: ["api0020000000"], 495 485 parent_edges: [{ parent_id: "api0020000000", edge_type: "direct" }], 496 486 description: "feat: add pagination support for list endpoints", 497 487 author: "eve@example.com", ··· 509 499 { 510 500 commit_id: "test0010000000", 511 501 change_id: generateChangeId(), 512 - parent_ids: ["main0070000000"], 513 502 parent_edges: [{ parent_id: "main0070000000", edge_type: "direct" }], 514 503 description: "test: add unit tests for core utilities", 515 504 author: "frank@example.com", ··· 526 515 { 527 516 commit_id: "test0020000000", 528 517 change_id: generateChangeId(), 529 - parent_ids: ["test0010000000"], 530 518 parent_edges: [{ parent_id: "test0010000000", edge_type: "direct" }], 531 519 description: "test: add integration tests for API endpoints", 532 520 author: "frank@example.com", ··· 543 531 { 544 532 commit_id: "test0030000000", 545 533 change_id: generateChangeId(), 546 - parent_ids: ["test0020000000"], 547 534 parent_edges: [{ parent_id: "test0020000000", edge_type: "direct" }], 548 535 description: "test: add end-to-end tests for critical flows", 549 536 author: "frank@example.com", ··· 560 547 { 561 548 commit_id: "test0040000000", 562 549 change_id: generateChangeId(), 563 - parent_ids: ["test0030000000"], 564 550 parent_edges: [{ parent_id: "test0030000000", edge_type: "direct" }], 565 551 description: "test: add performance benchmarks", 566 552 author: "frank@example.com", ··· 577 563 { 578 564 commit_id: "test0050000000", 579 565 change_id: generateChangeId(), 580 - parent_ids: ["test0040000000"], 581 566 parent_edges: [{ parent_id: "test0040000000", edge_type: "direct" }], 582 567 description: "test: add test coverage reporting", 583 568 author: "frank@example.com", ··· 595 580 { 596 581 commit_id: "main0080000000", 597 582 change_id: generateChangeId(), 598 - parent_ids: ["main0070000000"], 599 583 parent_edges: [{ parent_id: "main0070000000", edge_type: "direct" }], 600 584 description: "fix: handle edge case in form validation", 601 585 author: "henry@example.com", ··· 613 597 { 614 598 commit_id: "ui0010000000", 615 599 change_id: generateChangeId(), 616 - parent_ids: ["main0080000000"], 617 600 parent_edges: [{ parent_id: "main0080000000", edge_type: "direct" }], 618 601 description: "feat: redesign navigation component", 619 602 author: "grace@example.com", ··· 630 613 { 631 614 commit_id: "ui0020000000", 632 615 change_id: generateChangeId(), 633 - parent_ids: ["ui0010000000"], 634 616 parent_edges: [{ parent_id: "ui0010000000", edge_type: "direct" }], 635 617 description: "feat: add responsive layout breakpoints", 636 618 author: "grace@example.com", ··· 647 629 { 648 630 commit_id: "ui0030000000", 649 631 change_id: generateChangeId(), 650 - parent_ids: ["ui0020000000"], 651 632 parent_edges: [{ parent_id: "ui0020000000", edge_type: "direct" }], 652 633 description: "feat: add loading states and skeletons", 653 634 author: "grace@example.com", ··· 664 645 { 665 646 commit_id: "ui0040000000", 666 647 change_id: generateChangeId(), 667 - parent_ids: ["ui0030000000"], 668 648 parent_edges: [{ parent_id: "ui0030000000", edge_type: "direct" }], 669 649 description: "feat: improve accessibility with ARIA labels", 670 650 author: "grace@example.com", ··· 682 662 { 683 663 commit_id: "sec0010000000", 684 664 change_id: generateChangeId(), 685 - parent_ids: ["main0080000000"], 686 665 parent_edges: [{ parent_id: "main0080000000", edge_type: "direct" }], 687 666 description: "security: add input sanitization", 688 667 author: "iris@example.com", ··· 699 678 { 700 679 commit_id: "sec0020000000", 701 680 change_id: generateChangeId(), 702 - parent_ids: ["sec0010000000"], 703 681 parent_edges: [{ parent_id: "sec0010000000", edge_type: "direct" }], 704 682 description: "security: implement rate limiting", 705 683 author: "iris@example.com", ··· 716 694 { 717 695 commit_id: "sec0030000000", 718 696 change_id: generateChangeId(), 719 - parent_ids: ["sec0020000000"], 720 697 parent_edges: [{ parent_id: "sec0020000000", edge_type: "direct" }], 721 698 description: "security: add CSRF protection", 722 699 author: "iris@example.com", ··· 734 711 { 735 712 commit_id: "doc0010000000", 736 713 change_id: generateChangeId(), 737 - parent_ids: ["main0080000000"], 738 714 parent_edges: [{ parent_id: "main0080000000", edge_type: "direct" }], 739 715 description: "docs: add architecture decision records", 740 716 author: "lisa@example.com", ··· 751 727 { 752 728 commit_id: "doc0020000000", 753 729 change_id: generateChangeId(), 754 - parent_ids: ["doc0010000000"], 755 730 parent_edges: [{ parent_id: "doc0010000000", edge_type: "direct" }], 756 731 description: "docs: add API reference documentation", 757 732 author: "lisa@example.com", ··· 768 743 { 769 744 commit_id: "doc0030000000", 770 745 change_id: generateChangeId(), 771 - parent_ids: ["doc0020000000"], 772 746 parent_edges: [{ parent_id: "doc0020000000", edge_type: "direct" }], 773 747 description: "docs: add deployment guide", 774 748 author: "lisa@example.com", ··· 785 759 { 786 760 commit_id: "doc0040000000", 787 761 change_id: generateChangeId(), 788 - parent_ids: ["doc0030000000"], 789 762 parent_edges: [{ parent_id: "doc0030000000", edge_type: "direct" }], 790 763 description: "docs: add troubleshooting section", 791 764 author: "lisa@example.com", ··· 803 776 { 804 777 commit_id: "mon0010000000", 805 778 change_id: generateChangeId(), 806 - parent_ids: ["main0030000000"], 807 779 parent_edges: [{ parent_id: "main0030000000", edge_type: "direct" }], 808 780 description: "feat: add application monitoring setup", 809 781 author: "jack@example.com", ··· 820 792 { 821 793 commit_id: "mon0020000000", 822 794 change_id: generateChangeId(), 823 - parent_ids: ["mon0010000000"], 824 795 parent_edges: [{ parent_id: "mon0010000000", edge_type: "direct" }], 825 796 description: "feat: add error tracking integration", 826 797 author: "jack@example.com", ··· 837 808 { 838 809 commit_id: "mon0030000000", 839 810 change_id: generateChangeId(), 840 - parent_ids: ["mon0020000000"], 841 811 parent_edges: [{ parent_id: "mon0020000000", edge_type: "direct" }], 842 812 description: "feat: add performance metrics dashboard", 843 813 author: "jack@example.com", ··· 855 825 { 856 826 commit_id: "main0090000000", 857 827 change_id: generateChangeId(), 858 - parent_ids: ["main0080000000"], 859 828 parent_edges: [{ parent_id: "main0080000000", edge_type: "direct" }], 860 829 description: "fix: resolve memory leak in event handlers", 861 830 author: "henry@example.com", ··· 873 842 { 874 843 commit_id: "main0100000000", 875 844 change_id: generateChangeId(), 876 - parent_ids: ["main0090000000"], 877 845 parent_edges: [{ parent_id: "main0090000000", edge_type: "direct" }], 878 846 description: "chore: prepare for release", 879 847 author: "alice@example.com", ··· 891 859 { 892 860 commit_id: "wc00100000000", 893 861 change_id: generateChangeId(), 894 - parent_ids: ["main0100000000"], 895 862 parent_edges: [{ parent_id: "main0100000000", edge_type: "direct" }], 896 863 description: "", 897 864 author: "alice@example.com", ··· 1034 1001 // Use provided change ID or generate new one 1035 1002 const newChangeId = providedChangeId ?? generateChangeId(); 1036 1003 const newCommitId = `new${Date.now().toString(16).slice(-10)}`; 1037 - const newRevision: Omit<Revision, "change_id_short"> = { 1004 + const newRevision: Omit<Revision, "change_id_short" | "children_ids"> = { 1038 1005 commit_id: newCommitId, 1039 1006 change_id: newChangeId, 1040 - parent_ids: parentCommitIds, 1041 1007 parent_edges: parentCommitIds.map((id) => ({ parent_id: id, edge_type: "direct" as const })), 1042 1008 description: "", 1043 1009 author: "alice@example.com", ··· 1054 1020 1055 1021 // Recalculate short IDs with new revision included 1056 1022 const allRevisionsRaw = [ 1057 - ...mockRevisions.map(({ change_id_short: _, ...r }) => r), 1023 + ...mockRevisions.map(({ change_id_short: _, children_ids: __, ...r }) => r), 1058 1024 newRevision, 1059 1025 ]; 1060 1026 mockRevisions = calculateShortIds(allRevisionsRaw); ··· 1095 1061 if (revision.is_working_copy) { 1096 1062 // Abandoning WC creates a new WC on the parent 1097 1063 // Clear WC flag and create a new working copy 1098 - const parentCommitId = revision.parent_ids[0]; 1064 + const parentCommitId = revision.parent_edges[0]?.parent_id; 1099 1065 // Remove the abandoned revision 1100 1066 mockRevisions = mockRevisions.filter((_, i) => i !== revisionIndex); 1101 1067 1102 1068 // Create new working copy on parent 1103 1069 const newChangeId = generateChangeId(); 1104 1070 const newCommitId = `wc${Date.now().toString(16).slice(-10)}`; 1105 - const newRevision: Omit<Revision, "change_id_short"> = { 1071 + const newRevision: Omit<Revision, "change_id_short" | "children_ids"> = { 1106 1072 commit_id: newCommitId, 1107 1073 change_id: newChangeId, 1108 - parent_ids: parentCommitId ? [parentCommitId] : [], 1109 1074 parent_edges: parentCommitId 1110 1075 ? [{ parent_id: parentCommitId, edge_type: "direct" as const }] 1111 1076 : [], ··· 1124 1089 1125 1090 // Recalculate short IDs 1126 1091 const allRevisionsRaw = [ 1127 - ...mockRevisions.map(({ change_id_short: _, ...r }) => r), 1092 + ...mockRevisions.map(({ change_id_short: _, children_ids: __, ...r }) => r), 1128 1093 newRevision, 1129 1094 ]; 1130 1095 mockRevisions = calculateShortIds(allRevisionsRaw); ··· 1132 1097 // Just remove the revision 1133 1098 mockRevisions = mockRevisions.filter((_, i) => i !== revisionIndex); 1134 1099 // Recalculate short IDs after removal 1135 - const allRevisionsRaw = mockRevisions.map(({ change_id_short: _, ...r }) => r); 1100 + const allRevisionsRaw = mockRevisions.map( 1101 + ({ change_id_short: _, children_ids: __, ...r }) => r, 1102 + ); 1136 1103 mockRevisions = calculateShortIds(allRevisionsRaw); 1137 1104 } 1138 1105 ··· 1148 1115 } 1149 1116 if (revset === "@-") { 1150 1117 const wc = mockRevisions.find((r) => r.is_working_copy); 1151 - if (wc && wc.parent_ids.length > 0) { 1118 + if (wc && wc.parent_edges.length > 0) { 1152 1119 // Find parent by commit_id 1153 - const parent = mockRevisions.find((r) => r.commit_id === wc.parent_ids[0]); 1120 + const parent = mockRevisions.find((r) => r.commit_id === wc.parent_edges[0].parent_id); 1154 1121 return { change_ids: parent ? [parent.change_id] : [], error: null }; 1155 1122 } 1156 1123 return { change_ids: [], error: null };
+1 -1
apps/desktop/src/schemas.ts
··· 23 23 commit_id: Schema.String, 24 24 change_id: Schema.String, 25 25 change_id_short: Schema.String, 26 - parent_ids: Schema.Array(Schema.String), 27 26 parent_edges: Schema.Array(ParentEdge), 27 + children_ids: Schema.Array(Schema.String), 28 28 description: Schema.String, 29 29 author: Schema.String, 30 30 timestamp: Schema.String,
+22
apps/desktop/src/tauri-commands.ts
··· 185 185 return invoke<RevsetResult>("resolve_revset", { repoPath, revset }); 186 186 } 187 187 188 + /** Result of computing lineage for a revision */ 189 + export interface LineageResult { 190 + change_id: string; 191 + related_ids: string[]; 192 + } 193 + 194 + /** Fetch lineage for multiple revisions in a single IPC call */ 195 + export function getLineageBatchEffect( 196 + repoPath: string, 197 + changeIds: string[], 198 + ): Effect.Effect<LineageResult[], Error> { 199 + return Effect.tryPromise({ 200 + try: async () => { 201 + const spanId = traceStart("ipc-get-lineage-batch", { count: changeIds.length }); 202 + const result = await invoke<LineageResult[]>("get_lineage_batch", { repoPath, changeIds }); 203 + traceEnd(spanId, { count: result.length }); 204 + return result; 205 + }, 206 + catch: (error) => new Error(`Failed to fetch lineage batch: ${error}`), 207 + }); 208 + } 209 + 188 210 /** Result of fetching file content as base64 */ 189 211 export interface FileContentResult { 190 212 base64: string;