a very good jj gui
0
fork

Configure Feed

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

style: add 3D revision rows with ASCII background texture

+498 -126
+1 -3
.claude/settings.json
··· 1 1 { 2 + "enabledPlugins": {}, 2 3 "extraKnownMarketplaces": { 3 4 "fiberplane-claude-code-plugins": { 4 5 "source": { ··· 6 7 "repo": "fiberplane/claude-code-plugins" 7 8 } 8 9 } 9 - }, 10 - "enabledPlugins": { 11 - "fp@fiberplane-claude-code-plugins": true 12 10 } 13 11 }
+1 -4
.fp/.gitignore
··· 1 - issues/ 2 - comments/ 3 - activity.jsonl 4 - workspace.toml 1 + snapshots/
+1
.fp/config.toml
··· 1 1 # FP CLI Configuration 2 2 3 + project_id = "proj_01KEEPF3D0PK78G737MFXHGEKM" 3 4 prefix = "TAT"
+1
apps/desktop/package.json
··· 24 24 "@tanstack/react-db": "^0.1.59", 25 25 "@tanstack/react-query": "^5.90.12", 26 26 "@tanstack/react-router": "^1.141.6", 27 + "@tanstack/react-virtual": "^3.13.16", 27 28 "@tauri-apps/api": "^2.1.1", 28 29 "@tauri-apps/plugin-deep-link": "~2", 29 30 "@tauri-apps/plugin-dialog": "^2.4.2",
+30 -25
apps/desktop/src/components/AppShell.tsx
··· 4 4 import { homeDir } from "@tauri-apps/api/path"; 5 5 import { open } from "@tauri-apps/plugin-dialog"; 6 6 import { Effect } from "effect"; 7 - import { useState } from "react"; 7 + import { useRef, useState } from "react"; 8 8 import { stackViewChangeIdAtom } from "@/atoms"; 9 9 import { AceJump } from "@/components/AceJump"; 10 10 import { CommandPalette } from "@/components/CommandPalette"; 11 11 import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp"; 12 - import { RevisionGraph, reorderForGraph } from "@/components/RevisionGraph"; 12 + import { 13 + RevisionGraph, 14 + type RevisionGraphHandle, 15 + reorderForGraph, 16 + } from "@/components/RevisionGraph"; 17 + import { StackIndicator } from "@/components/StackIndicator"; 13 18 import { StatusBar } from "@/components/StatusBar"; 14 - import { Toolbar } from "@/components/Toolbar"; 19 + 15 20 import { 16 21 editRevision, 17 22 emptyRevisionsCollection, ··· 58 63 const { rev } = useSearch({ strict: false }); 59 64 const [flash, setFlash] = useState<{ changeId: string; key: number } | null>(null); 60 65 const [stackViewChangeId, setStackViewChangeId] = useAtom(stackViewChangeIdAtom); 66 + const revisionGraphRef = useRef<RevisionGraphHandle>(null); 61 67 62 68 useKeyboardShortcut({ 63 69 key: ",", ··· 69 75 70 76 const activeProject = projects.find((p) => p.id === projectId) ?? null; 71 77 72 - // Build the stack revset: the branch from merge-base to the selected commit 73 - // (::X ~ ::trunk()) gives ancestors of X that are NOT ancestors of trunk (the branch) 78 + // Build the stack revset: the full branch containing the selected commit 79 + // (::X ~ ::trunk()) gives ancestors of X that are NOT ancestors of trunk (the branch below X) 80 + // X:: gives descendants of X (the branch above X) 74 81 // roots(...)- gives the parent of the first branch commit (the merge base) 75 82 // (X & ::trunk()) handles the case where X is already an ancestor of trunk (just show X) 76 83 const stackRevset = stackViewChangeId 77 - ? `(::${stackViewChangeId} ~ ::trunk()) | roots(::${stackViewChangeId} ~ ::trunk())- | (${stackViewChangeId} & ::trunk())` 84 + ? `(::${stackViewChangeId} ~ ::trunk()) | (${stackViewChangeId}:: ~ ::trunk()) | roots(::${stackViewChangeId} ~ ::trunk())- | (${stackViewChangeId} & ::trunk())` 78 85 : undefined; 79 86 80 87 const revisionsCollection = activeProject 81 88 ? getRevisionsCollection( 82 89 activeProject.path, 83 - activeProject.revset_preset ?? undefined, 90 + activeProject.revset_preset ?? "full_history", 84 91 stackRevset, 85 92 ) 86 93 : emptyRevisionsCollection; ··· 165 172 orderedRevisions, 166 173 selectedChangeId: rev ?? null, 167 174 onNavigate: handleNavigateToChangeId, 175 + scrollToChangeId: (changeId) => revisionGraphRef.current?.scrollToChangeId(changeId), 168 176 }); 169 177 170 178 function triggerFlash(changeId: string) { ··· 203 211 editRevision(activeProject.path, selectedRevision.change_id, currentWC?.change_id ?? null); 204 212 } 205 213 206 - function handlePresetChange(preset: string | null) { 207 - if (!activeProject || !preset) return; 208 - const updatedProject: Project = { 209 - ...activeProject, 210 - revset_preset: preset, 211 - last_opened_at: Date.now(), 212 - }; 213 - upsertProject(updatedProject); 214 - projectsCollection.utils.writeUpsert([updatedProject]); 215 - } 216 - 217 214 useKeyboardShortcut({ 218 215 key: "n", 219 216 onPress: handleNew, ··· 287 284 onOpenRepo={handleOpenRepo} 288 285 /> 289 286 <KeyboardShortcutsHelp /> 290 - <AceJump revisions={orderedRevisions} onJump={handleNavigateToChangeId} /> 287 + <AceJump 288 + revisions={orderedRevisions} 289 + onJump={(changeId) => { 290 + handleNavigateToChangeId(changeId); 291 + revisionGraphRef.current?.scrollToChangeId(changeId, { align: "center", smooth: true }); 292 + }} 293 + /> 291 294 <div className="flex flex-col h-screen overflow-hidden"> 292 - <Toolbar 293 - repoPath={activeProject?.path ?? null} 294 - currentPreset={activeProject?.revset_preset ?? "active"} 295 - onPresetChange={handlePresetChange} 296 - /> 297 - <section className="flex-1 min-h-0" aria-label="Revision list"> 295 + <section className="flex-1 min-h-0 relative" aria-label="Revision list"> 296 + <StackIndicator 297 + onDismiss={() => { 298 + // Clear selection - default logic will pick working copy 299 + handleNavigateToChangeId(""); 300 + }} 301 + /> 298 302 <RevisionGraph 303 + ref={revisionGraphRef} 299 304 revisions={revisions} 300 305 selectedRevision={selectedRevision} 301 306 onSelectRevision={handleSelectRevision}
+2 -2
apps/desktop/src/components/KeyboardShortcutsHelp.tsx
··· 9 9 items: [ 10 10 { keys: ["j", "↓"], description: "Move down" }, 11 11 { keys: ["k", "↑"], description: "Move up" }, 12 - { keys: ["J"], description: "Jump to parent revision" }, 13 - { keys: ["K"], description: "Jump to child revision" }, 12 + { keys: ["J / -"], description: "Jump to parent revision" }, 13 + { keys: ["K / + / ="], description: "Jump to child revision" }, 14 14 { keys: ["@"], description: "Jump to working copy" }, 15 15 { keys: ["f"], description: "Jump to revision by ID prefix" }, 16 16 { keys: ["g g"], description: "Jump to first revision" },
+344 -55
apps/desktop/src/components/RevisionGraph.tsx
··· 1 1 import { useSearch } from "@tanstack/react-router"; 2 + import { useVirtualizer } from "@tanstack/react-virtual"; 3 + import { forwardRef, useImperativeHandle, useRef, useState, useEffect } from "react"; 2 4 import { Badge } from "@/components/ui/badge"; 3 5 import { Button } from "@/components/ui/button"; 4 - import { ScrollArea } from "@/components/ui/scroll-area"; 6 + import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 5 7 import type { Revision } from "@/tauri-commands"; 6 8 9 + // Debug overlay - toggle with Ctrl+Shift+D 10 + const DEBUG_OVERLAY_DEFAULT = false; 11 + 12 + function DebugOverlay({ 13 + enabled, 14 + scrollRef, 15 + selectedIndex, 16 + visibleStartRow, 17 + visibleEndRow, 18 + totalRows, 19 + }: { 20 + enabled: boolean; 21 + scrollRef: React.RefObject<HTMLDivElement | null>; 22 + selectedIndex: number | undefined; 23 + visibleStartRow: number; 24 + visibleEndRow: number; 25 + totalRows: number; 26 + }) { 27 + // Force re-render on scroll/resize/focus 28 + const [, forceUpdate] = useState(0); 29 + 30 + useEffect(() => { 31 + if (!enabled) return; 32 + const el = scrollRef.current; 33 + if (!el) return; 34 + 35 + const update = () => forceUpdate((n) => n + 1); 36 + el.addEventListener("scroll", update); 37 + window.addEventListener("resize", update); 38 + document.addEventListener("focusin", update); 39 + return () => { 40 + el.removeEventListener("scroll", update); 41 + window.removeEventListener("resize", update); 42 + document.removeEventListener("focusin", update); 43 + }; 44 + }, [scrollRef, enabled]); 45 + 46 + if (!enabled) return null; 47 + 48 + const el = scrollRef.current; 49 + const scrollTop = el?.scrollTop ?? 0; 50 + const clientHeight = el?.clientHeight ?? 0; 51 + const scrollHeight = el?.scrollHeight ?? 0; 52 + 53 + const selectedItemTop = selectedIndex !== undefined ? selectedIndex * ROW_HEIGHT : 0; 54 + const selectedItemBottom = selectedItemTop + ROW_HEIGHT; 55 + const distanceFromTop = selectedItemTop - scrollTop; 56 + const distanceFromBottom = scrollTop + clientHeight - selectedItemBottom; 57 + const isInViewport = distanceFromTop >= 0 && distanceFromBottom >= 0; 58 + 59 + const active = document.activeElement; 60 + const activeElement = active 61 + ? `${active.tagName}${active.className ? `.${active.className.split(" ")[0]}` : ""}` 62 + : "none"; 63 + 64 + const info = { 65 + scrollTop, 66 + clientHeight, 67 + scrollHeight, 68 + viewportEnd: scrollTop + clientHeight, 69 + selectedIndex, 70 + itemTop: selectedItemTop, 71 + itemBottom: selectedItemBottom, 72 + distFromTop: distanceFromTop, 73 + distFromBottom: distanceFromBottom, 74 + isInViewport, 75 + virtualRange: `${visibleStartRow}-${visibleEndRow}`, 76 + totalRows, 77 + ROW_HEIGHT, 78 + activeElement, 79 + }; 80 + 81 + return ( 82 + <div 83 + 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" 84 + onClick={() => navigator.clipboard.writeText(JSON.stringify(info, null, 2))} 85 + title="Click to copy" 86 + > 87 + <div className="font-bold text-green-300 mb-2"> 88 + Debug Info <span className="text-green-600">(click to copy)</span> 89 + </div> 90 + <div className="space-y-1"> 91 + <div>scrollTop: {scrollTop.toFixed(0)}</div> 92 + <div>clientHeight: {clientHeight.toFixed(0)}</div> 93 + <div>scrollHeight: {scrollHeight}</div> 94 + <div>viewportEnd: {(scrollTop + clientHeight).toFixed(0)}</div> 95 + <div className="border-t border-green-800 my-2" /> 96 + <div>selectedIndex: {selectedIndex ?? "none"}</div> 97 + <div>itemTop: {selectedItemTop}</div> 98 + <div>itemBottom: {selectedItemBottom}</div> 99 + <div className="border-t border-green-800 my-2" /> 100 + <div>distFromTop: {distanceFromTop.toFixed(0)}</div> 101 + <div>distFromBottom: {distanceFromBottom.toFixed(0)}</div> 102 + <div className={isInViewport ? "text-green-400" : "text-red-400"}> 103 + inViewport: {isInViewport ? "YES" : "NO"} 104 + </div> 105 + <div className="border-t border-green-800 my-2" /> 106 + <div> 107 + virtualRange: {visibleStartRow}-{visibleEndRow} 108 + </div> 109 + <div>totalRows: {totalRows}</div> 110 + <div>ROW_HEIGHT: {ROW_HEIGHT}</div> 111 + <div className="border-t border-green-800 my-2" /> 112 + <div className="truncate" title={activeElement}> 113 + focus: {activeElement} 114 + </div> 115 + </div> 116 + </div> 117 + ); 118 + } 119 + 120 + export interface RevisionGraphHandle { 121 + scrollToChangeId: ( 122 + changeId: string, 123 + options?: { align?: "auto" | "center"; smooth?: boolean }, 124 + ) => void; 125 + } 126 + 7 127 interface RevisionGraphProps { 8 128 revisions: Revision[]; 9 129 selectedRevision: Revision | null; ··· 379 499 return LANE_COLORS[lane % LANE_COLORS.length]; 380 500 } 381 501 382 - function GraphColumn({ nodes, laneCount }: { nodes: GraphNode[]; laneCount: number }) { 502 + interface GraphColumnProps { 503 + nodes: GraphNode[]; 504 + laneCount: number; 505 + visibleStartRow: number; 506 + visibleEndRow: number; 507 + totalHeight: number; 508 + } 509 + 510 + function GraphColumn({ 511 + nodes, 512 + laneCount, 513 + visibleStartRow, 514 + visibleEndRow, 515 + totalHeight, 516 + }: GraphColumnProps) { 383 517 const { rev: selectedChangeId } = useSearch({ strict: false }); 384 - const height = nodes.length * ROW_HEIGHT; 385 518 // Minimal right padding - tight fit for the rightmost node 386 519 const width = LANE_PADDING + laneCount * LANE_WIDTH + NODE_RADIUS + 2; 387 520 521 + // Add overscan for edges that might span across viewport boundary 522 + const overscan = 5; 523 + const startRow = Math.max(0, visibleStartRow - overscan); 524 + const endRow = Math.min(nodes.length - 1, visibleEndRow + overscan); 525 + 526 + // Filter nodes that are in the visible range 527 + const visibleNodes = nodes.filter((node) => node.row >= startRow && node.row <= endRow); 528 + 529 + // Also include nodes whose edges pass through the visible area 530 + const nodesWithVisibleEdges = nodes.filter((node) => { 531 + if (node.row >= startRow && node.row <= endRow) return false; // Already included 532 + return node.parentConnections.some((conn) => { 533 + const minRow = Math.min(node.row, conn.parentRow); 534 + const maxRow = Math.max(node.row, conn.parentRow); 535 + return maxRow >= startRow && minRow <= endRow; 536 + }); 537 + }); 538 + 539 + const allVisibleNodes = [...visibleNodes, ...nodesWithVisibleEdges]; 540 + 388 541 return ( 389 - <svg width={width} height={height} className="shrink-0" role="img" aria-label="Revision graph"> 542 + <svg 543 + width={width} 544 + height={totalHeight} 545 + className="shrink-0 absolute top-0 left-0 pointer-events-none" 546 + role="img" 547 + aria-label="Revision graph" 548 + > 390 549 <title>Revision graph</title> 391 550 {/* Edges */} 392 - {nodes.map((node) => { 551 + {allVisibleNodes.map((node) => { 393 552 const y = node.row * ROW_HEIGHT + ROW_HEIGHT / 2; 394 553 const x = laneToX(node.lane); 395 554 const color = laneColor(node.lane); ··· 469 628 ); 470 629 })} 471 630 472 - {/* Nodes */} 473 - {nodes.map((node) => { 631 + {/* Nodes - only render visible ones */} 632 + {visibleNodes.map((node) => { 474 633 const y = node.row * ROW_HEIGHT + ROW_HEIGHT / 2; 475 634 const x = laneToX(node.lane); 476 635 const color = laneColor(node.lane); ··· 555 714 <div style={{ height: ROW_HEIGHT }} className="flex items-center"> 556 715 <div style={{ width: indent }} className="shrink-0" /> 557 716 <div 558 - className={`flex-1 mr-2 revision-row-3d transition-opacity duration-150 ${revision.is_immutable ? "opacity-60" : ""} ${isDimmed ? "opacity-40" : ""}`} 717 + className={`flex-1 mr-2 bg-background rounded my-0.5 mx-1 shadow-sm hover:shadow dark:bg-[oklch(0.18_0.005_49)] dark:shadow-[0_1px_3px_rgba(0,0,0,0.3),inset_0_1px_0_rgba(255,255,255,0.03)] dark:hover:shadow-[0_3px_6px_rgba(0,0,0,0.4),inset_0_1px_0_rgba(255,255,255,0.05)] transition-opacity duration-150 ${revision.is_immutable ? "opacity-60" : ""} ${isDimmed ? "opacity-40" : ""}`} 559 718 > 560 719 <Button 561 720 variant="ghost" 562 721 onClick={() => onSelect(revision.change_id)} 563 722 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 ${ 723 + className={`w-full h-full justify-start text-left px-3 py-2 rounded ring-0 outline-none focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:outline-none hover:bg-transparent ${ 565 724 isSelected ? "bg-accent/50 text-accent-foreground" : "" 566 725 }`} 567 726 > ··· 653 812 return related; 654 813 } 655 814 656 - export function RevisionGraph({ 657 - revisions, 658 - selectedRevision, 659 - onSelectRevision, 660 - isLoading, 661 - flash, 662 - }: RevisionGraphProps) { 663 - const { nodes, laneCount, rows } = buildGraph(revisions); 815 + export const RevisionGraph = forwardRef<RevisionGraphHandle, RevisionGraphProps>( 816 + function RevisionGraph({ revisions, selectedRevision, onSelectRevision, isLoading, flash }, ref) { 817 + const parentRef = useRef<HTMLDivElement>(null); 818 + const { nodes, laneCount, rows } = buildGraph(revisions); 819 + 820 + const revisionMap = new Map(revisions.map((r) => [r.change_id, r])); 821 + const relatedRevisions = getRelatedRevisions(revisions, selectedRevision?.change_id ?? null); 822 + 823 + // Build change_id -> row index map for scrolling 824 + const changeIdToIndex = new Map<string, number>(); 825 + for (let i = 0; i < rows.length; i++) { 826 + changeIdToIndex.set(rows[i].revision.change_id, i); 827 + } 828 + 829 + const [debugEnabled, setDebugEnabled] = useState(DEBUG_OVERLAY_DEFAULT); 830 + const debugEnabledRef = useRef(debugEnabled); 831 + debugEnabledRef.current = debugEnabled; 832 + 833 + // Toggle debug overlay with Ctrl+Shift+D 834 + useKeyboardShortcut({ 835 + key: "D", 836 + modifiers: { ctrl: true, shift: true }, 837 + onPress: () => setDebugEnabled((prev) => !prev), 838 + }); 839 + 840 + const rowVirtualizer = useVirtualizer({ 841 + count: rows.length, 842 + getScrollElement: () => parentRef.current, 843 + estimateSize: () => ROW_HEIGHT, 844 + overscan: 10, 845 + debug: debugEnabled, 846 + }); 664 847 665 - const revisionMap = new Map(revisions.map((r) => [r.change_id, r])); 666 - const relatedRevisions = getRelatedRevisions(revisions, selectedRevision?.change_id ?? null); 848 + // Expose scrollToChangeId method via ref 849 + useImperativeHandle(ref, () => ({ 850 + scrollToChangeId: ( 851 + changeId: string, 852 + options?: { align?: "auto" | "center"; smooth?: boolean }, 853 + ) => { 854 + const debug = debugEnabledRef.current; 855 + const index = changeIdToIndex.get(changeId); 856 + if (index === undefined) { 857 + if (debug) console.log("[scroll] changeId not found:", changeId); 858 + return; 859 + } 860 + 861 + const scrollElement = parentRef.current; 862 + if (!scrollElement) { 863 + if (debug) console.log("[scroll] scrollElement is null"); 864 + return; 865 + } 866 + 867 + const scrollTop = scrollElement.scrollTop; 868 + const viewportHeight = scrollElement.clientHeight; 869 + const scrollHeight = scrollElement.scrollHeight; 870 + const itemTop = index * ROW_HEIGHT; 871 + const itemBottom = itemTop + ROW_HEIGHT; 872 + 873 + if (debug) { 874 + console.log("[scroll] called:", { 875 + index, 876 + options, 877 + scrollTop, 878 + viewportHeight, 879 + scrollHeight, 880 + itemTop, 881 + itemBottom, 882 + }); 883 + } 884 + 885 + // For jump commands (smooth/center), always scroll 886 + if (options?.smooth || options?.align === "center") { 887 + if (debug) console.log("[scroll] using scrollToIndex (jump)"); 888 + rowVirtualizer.scrollToIndex(index, { 889 + align: "center", 890 + behavior: "smooth", 891 + }); 892 + return; 893 + } 894 + 895 + // For step navigation, manually scroll only if item is outside viewport 896 + const isAboveViewport = itemTop < scrollTop; 897 + const isBelowViewport = itemBottom > scrollTop + viewportHeight; 898 + 899 + if (debug) { 900 + console.log("[scroll] visibility:", { isAboveViewport, isBelowViewport }); 901 + } 902 + 903 + if (isAboveViewport) { 904 + const newScrollTop = itemTop; 905 + if (debug) console.log("[scroll] scrolling UP to:", newScrollTop); 906 + scrollElement.scrollTop = newScrollTop; 907 + } else if (isBelowViewport) { 908 + const newScrollTop = itemBottom - viewportHeight; 909 + if (debug) console.log("[scroll] scrolling DOWN to:", newScrollTop); 910 + scrollElement.scrollTop = newScrollTop; 911 + } else { 912 + if (debug) console.log("[scroll] item already visible, no scroll needed"); 913 + } 914 + }, 915 + })); 916 + 917 + function handleSelect(changeId: string) { 918 + const revision = revisionMap.get(changeId); 919 + if (revision) onSelectRevision(revision); 920 + } 921 + 922 + if (revisions.length === 0) { 923 + return ( 924 + <div className="flex items-center justify-center h-full bg-background text-muted-foreground text-sm"> 925 + {isLoading ? "Loading revisions..." : "Select a project to view revisions"} 926 + </div> 927 + ); 928 + } 929 + 930 + const virtualItems = rowVirtualizer.getVirtualItems(); 931 + const visibleStartRow = virtualItems[0]?.index ?? 0; 932 + const visibleEndRow = virtualItems[virtualItems.length - 1]?.index ?? 0; 933 + const totalHeight = rowVirtualizer.getTotalSize(); 667 934 668 - function handleSelect(changeId: string) { 669 - const revision = revisionMap.get(changeId); 670 - if (revision) onSelectRevision(revision); 671 - } 935 + const selectedIndex = selectedRevision ? changeIdToIndex.get(selectedRevision.change_id) : undefined; 672 936 673 - if (revisions.length === 0) { 674 937 return ( 675 - <div className="flex items-center justify-center h-full bg-background text-muted-foreground text-sm"> 676 - {isLoading ? "Loading revisions..." : "Select a project to view revisions"} 677 - </div> 678 - ); 679 - } 938 + <div ref={parentRef} className="h-full overflow-auto ascii-bg" style={{ overflowAnchor: "none" }}> 939 + <div 940 + className="relative" 941 + style={{ 942 + height: `${totalHeight}px`, 943 + width: "100%", 944 + }} 945 + > 946 + {/* Graph column - positioned absolutely, scrolls with content */} 947 + <GraphColumn 948 + nodes={nodes} 949 + laneCount={laneCount} 950 + visibleStartRow={visibleStartRow} 951 + visibleEndRow={visibleEndRow} 952 + totalHeight={totalHeight} 953 + /> 680 954 681 - return ( 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} /> 955 + {/* Virtualized rows */} 956 + <div className="relative z-10"> 957 + {virtualItems.map((virtualRow) => { 958 + const row = rows[virtualRow.index]; 959 + const isFlashing = flash?.changeId === row.revision.change_id; 960 + const isDimmed = 961 + selectedRevision !== null && !relatedRevisions.has(row.revision.change_id); 962 + return ( 963 + <div 964 + key={row.revision.change_id} 965 + className="absolute left-0 w-full" 966 + style={{ 967 + height: `${virtualRow.size}px`, 968 + transform: `translateY(${virtualRow.start}px)`, 969 + }} 970 + > 971 + <RevisionRow 972 + revision={row.revision} 973 + maxLaneOnRow={row.maxLaneOnRow} 974 + isSelected={selectedRevision?.change_id === row.revision.change_id} 975 + onSelect={handleSelect} 976 + isFlashing={isFlashing} 977 + isDimmed={isDimmed} 978 + /> 979 + </div> 980 + ); 981 + })} 982 + </div> 686 983 </div> 687 - <div className="relative z-10"> 688 - {rows.map((row) => { 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 - ); 703 - })} 704 - </div> 984 + 985 + {/* Debug overlay - toggle with Ctrl+Shift+D */} 986 + <DebugOverlay 987 + enabled={debugEnabled} 988 + scrollRef={parentRef} 989 + selectedIndex={selectedIndex} 990 + visibleStartRow={visibleStartRow} 991 + visibleEndRow={visibleEndRow} 992 + totalRows={rows.length} 993 + /> 705 994 </div> 706 - </ScrollArea> 707 - ); 708 - } 995 + ); 996 + }, 997 + );
+39
apps/desktop/src/components/StackIndicator.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import { X } from "lucide-react"; 3 + import { stackViewChangeIdAtom } from "@/atoms"; 4 + import { Badge } from "@/components/ui/badge"; 5 + import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 6 + 7 + interface StackIndicatorProps { 8 + onDismiss?: () => void; 9 + } 10 + 11 + export function StackIndicator({ onDismiss }: StackIndicatorProps) { 12 + const [stackViewChangeId, setStackViewChangeId] = useAtom(stackViewChangeIdAtom); 13 + 14 + function handleDismiss() { 15 + setStackViewChangeId(null); 16 + onDismiss?.(); 17 + } 18 + 19 + useKeyboardShortcut({ 20 + key: "Escape", 21 + onPress: handleDismiss, 22 + enabled: !!stackViewChangeId, 23 + }); 24 + 25 + if (!stackViewChangeId) return null; 26 + 27 + return ( 28 + <div className="absolute top-2 left-2 z-20"> 29 + <Badge 30 + variant="secondary" 31 + className="text-xs cursor-pointer hover:bg-destructive/20 gap-1 pr-1" 32 + onClick={handleDismiss} 33 + > 34 + <span className="font-mono">{stackViewChangeId.slice(0, 8)}</span> 35 + <X className="h-3 w-3" /> 36 + </Badge> 37 + </div> 38 + ); 39 + }
+1 -1
apps/desktop/src/db.ts
··· 75 75 } 76 76 77 77 export function getRevisionsCollection(repoPath: string, preset?: string, customRevset?: string) { 78 - const cacheKey = `${repoPath}:${preset ?? "active"}:${customRevset ?? ""}`; 78 + const cacheKey = `${repoPath}:${preset ?? "full_history"}:${customRevset ?? ""}`; 79 79 let collection = revisionCollections.get(cacheKey); 80 80 if (!collection) { 81 81 collection = createRevisionsCollection(repoPath, preset, customRevset);
+58 -35
apps/desktop/src/hooks/useKeyboard.ts
··· 1 1 import { useEffect, useRef } from "react"; 2 2 import type { Revision } from "@/tauri-commands"; 3 3 4 + interface ScrollOptions { 5 + align?: "auto" | "center"; 6 + smooth?: boolean; 7 + } 8 + 4 9 interface UseKeyboardNavigationOptions { 5 10 orderedRevisions: Revision[]; 6 11 selectedChangeId: string | null; 7 12 onNavigate: (changeId: string) => void; 13 + scrollToChangeId?: (changeId: string, options?: ScrollOptions) => void; 8 14 } 9 15 10 16 interface UseKeyboardShortcutOptions { ··· 33 39 orderedRevisions, 34 40 selectedChangeId, 35 41 onNavigate, 42 + scrollToChangeId, 36 43 }: UseKeyboardNavigationOptions) { 37 44 // Use refs to avoid stale closures in event handler 38 45 const orderedRevisionsRef = useRef(orderedRevisions); 39 46 const selectedChangeIdRef = useRef(selectedChangeId); 40 47 const onNavigateRef = useRef(onNavigate); 48 + const scrollToChangeIdRef = useRef(scrollToChangeId); 41 49 42 50 orderedRevisionsRef.current = orderedRevisions; 43 51 selectedChangeIdRef.current = selectedChangeId; 44 52 onNavigateRef.current = onNavigate; 53 + scrollToChangeIdRef.current = scrollToChangeId; 45 54 46 55 useKeySequence({ 47 56 sequence: "gg", ··· 50 59 const targetChangeId = revisions[0]?.change_id || null; 51 60 if (targetChangeId) { 52 61 onNavigateRef.current(targetChangeId); 53 - requestAnimationFrame(() => { 54 - const element = document.querySelector<HTMLElement>( 55 - `[data-change-id="${targetChangeId}"]`, 56 - ); 57 - if (element) { 58 - element.focus({ preventScroll: true }); 59 - element.scrollIntoView({ block: "nearest", behavior: "smooth" }); 60 - } 61 - }); 62 + scrollToChangeIdRef.current?.(targetChangeId, { align: "center", smooth: true }); 62 63 } 63 64 }, 64 65 enabled: orderedRevisions.length > 0, ··· 82 83 const currentRevision = revisions[currentIndex] ?? null; 83 84 84 85 let targetChangeId: string | null = null; 86 + // "jump" = always scroll to center, "step" = scroll only if needed, "none" = no explicit scroll 87 + let scrollMode: "jump" | "step" | "none" = "none"; 85 88 86 - switch (event.key) { 87 - case "j": 88 - case "ArrowDown": 89 + // Check for - and + using code (more reliable across keyboard layouts) 90 + const isMinusKey = 91 + event.key === "-" || event.code === "Minus" || event.code === "NumpadSubtract"; 92 + const isPlusKey = 93 + event.key === "+" || 94 + event.key === "=" || 95 + event.code === "Equal" || 96 + event.code === "NumpadAdd"; 97 + 98 + switch (true) { 99 + case event.key === "j" || event.key === "ArrowDown": 89 100 if (currentIndex >= 0 && currentIndex < revisions.length - 1) { 90 101 targetChangeId = revisions[currentIndex + 1].change_id; 102 + scrollMode = "step"; 91 103 } 92 104 event.preventDefault(); 93 105 break; 94 106 95 - case "k": 96 - case "ArrowUp": 107 + case event.key === "k" || event.key === "ArrowUp": 97 108 if (currentIndex > 0) { 98 109 targetChangeId = revisions[currentIndex - 1].change_id; 110 + scrollMode = "step"; 99 111 } 100 112 event.preventDefault(); 101 113 break; 102 114 103 - case "J": 104 - if (currentRevision && currentRevision.parent_ids.length > 0) { 105 - const parentId = currentRevision.parent_ids[0]; 106 - const parentRevision = revisions.find((r) => r.commit_id === parentId); 107 - targetChangeId = parentRevision?.change_id || null; 115 + case event.key === "J" || isMinusKey: 116 + if (currentRevision) { 117 + // Navigate to parent revision 118 + const parentId = 119 + currentRevision.parent_ids[0] || currentRevision.parent_edges[0]?.parent_id; 120 + if (parentId) { 121 + const parentRevision = revisions.find((r) => r.commit_id === parentId); 122 + if (parentRevision) { 123 + targetChangeId = parentRevision.change_id; 124 + scrollMode = "step"; 125 + } 126 + } 108 127 } 109 128 event.preventDefault(); 110 129 break; 111 130 112 - case "K": 131 + case event.key === "K" || isPlusKey: 113 132 if (currentRevision) { 114 - const childRevision = revisions.find((r) => 115 - r.parent_ids.includes(currentRevision.commit_id), 133 + // Find child by checking if any revision has current as parent 134 + const childRevision = revisions.find( 135 + (r) => 136 + r.parent_ids.includes(currentRevision.commit_id) || 137 + r.parent_edges.some((e) => e.parent_id === currentRevision.commit_id), 116 138 ); 117 - targetChangeId = childRevision?.change_id || null; 139 + if (childRevision) { 140 + targetChangeId = childRevision.change_id; 141 + scrollMode = "step"; 142 + } 118 143 } 119 144 event.preventDefault(); 120 145 break; 121 146 122 - case "@": 147 + case event.key === "@": 123 148 targetChangeId = revisions.find((r) => r.is_working_copy)?.change_id || null; 149 + scrollMode = "jump"; 124 150 event.preventDefault(); 125 151 break; 126 152 127 - case "G": 153 + case event.key === "G": 128 154 targetChangeId = revisions[revisions.length - 1]?.change_id || null; 155 + scrollMode = "jump"; 129 156 event.preventDefault(); 130 157 break; 131 158 132 - case "Escape": 159 + case event.key === "Escape": 133 160 onNavigateRef.current(""); 134 161 event.preventDefault(); 135 162 break; ··· 137 164 138 165 if (targetChangeId) { 139 166 onNavigateRef.current(targetChangeId); 140 - requestAnimationFrame(() => { 141 - const element = document.querySelector<HTMLElement>( 142 - `[data-change-id="${targetChangeId}"]`, 143 - ); 144 - if (element) { 145 - element.focus({ preventScroll: true }); 146 - element.scrollIntoView({ block: "nearest", behavior: "smooth" }); 147 - } 148 - }); 167 + if (scrollMode === "jump") { 168 + scrollToChangeIdRef.current?.(targetChangeId, { align: "center", smooth: true }); 169 + } else if (scrollMode === "step") { 170 + scrollToChangeIdRef.current?.(targetChangeId, { align: "auto", smooth: false }); 171 + } 149 172 } 150 173 } 151 174
+5 -1
apps/desktop/src/main.tsx
··· 8 8 import { routeTree } from "./routeTree.gen"; 9 9 import "./styles/index.css"; 10 10 11 - const router = createRouter({ routeTree }); 11 + const router = createRouter({ 12 + routeTree, 13 + defaultPreloadStaleTime: 0, 14 + scrollRestoration: false, 15 + }); 12 16 13 17 function handleDeepLinks(urls: string[]) { 14 18 for (const url of urls) {
+10
apps/desktop/src/styles/index.css
··· 104 104 } 105 105 } 106 106 107 + /* ASCII pattern background for revision graph */ 108 + .ascii-bg { 109 + background-color: var(--background); 110 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Ctext x='2' y='12' font-family='monospace' font-size='11' fill='%23666666' opacity='0.15'%3E·%3C/text%3E%3Ctext x='22' y='12' font-family='monospace' font-size='11' fill='%23666666' opacity='0.12'%3E·%3C/text%3E%3Ctext x='12' y='28' font-family='monospace' font-size='11' fill='%23666666' opacity='0.13'%3E·%3C/text%3E%3Ctext x='32' y='28' font-family='monospace' font-size='11' fill='%23666666' opacity='0.1'%3E·%3C/text%3E%3C/svg%3E"); 111 + } 112 + 113 + .dark .ascii-bg { 114 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Ctext x='2' y='12' font-family='monospace' font-size='11' fill='%23ffffff' opacity='0.08'%3E·%3C/text%3E%3Ctext x='22' y='12' font-family='monospace' font-size='11' fill='%23ffffff' opacity='0.06'%3E·%3C/text%3E%3Ctext x='12' y='28' font-family='monospace' font-size='11' fill='%23ffffff' opacity='0.07'%3E·%3C/text%3E%3Ctext x='32' y='28' font-family='monospace' font-size='11' fill='%23ffffff' opacity='0.05'%3E·%3C/text%3E%3C/svg%3E"); 115 + } 116 +
+5
bun.lock
··· 24 24 "@tanstack/react-db": "^0.1.59", 25 25 "@tanstack/react-query": "^5.90.12", 26 26 "@tanstack/react-router": "^1.141.6", 27 + "@tanstack/react-virtual": "^3.13.16", 27 28 "@tauri-apps/api": "^2.1.1", 28 29 "@tauri-apps/plugin-deep-link": "~2", 29 30 "@tauri-apps/plugin-dialog": "^2.4.2", ··· 411 412 412 413 "@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="], 413 414 415 + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.16", "", { "dependencies": { "@tanstack/virtual-core": "3.13.16" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y4xLKvLu6UZWiGdNcgk3yYlzCznYIV0m8dSyUzr3eAC0dHLos5V74qhUHxutYddFGgGU8sWLkp6H5c2RCrsrXw=="], 416 + 414 417 "@tanstack/router-core": ["@tanstack/router-core@1.141.6", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.0", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-AqH61axLq2xFaM+B0veGQ4OOzMzr2Ih+qXzBmGRy5e0wMJkr1efPZXLF0K7nEjF++bmL/excew2Br6v9xrZ/5g=="], 415 418 416 419 "@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], 420 + 421 + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.16", "", {}, "sha512-njazUC8mDkrxWmyZmn/3eXrDcP8Msb3chSr4q6a65RmwdSbMlMCdnOphv6/8mLO7O3Fuza5s4M4DclmvAO5w0w=="], 417 422 418 423 "@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="], 419 424