a very good jj gui
0
fork

Configure Feed

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

fix: resolve lint errors across codebase

- Remove non-null assertion in AppShell BFS loop
- Use refs pattern in useKeyboard to avoid stale closures without useCallback
- Add biome-ignore for Label component (htmlFor comes from props)
- Change Field from div with role=group to semantic fieldset element
- Remove duplicate ElidedRow function

Issue: TAT-53
Issue: TAT-54
Issue: TAT-55

+565 -202
+5
apps/desktop/biome.jsonc
··· 1 1 { 2 2 "$schema": "node_modules/@biomejs/biome/configuration_schema.json", 3 + "plugins": [ 4 + "./no-react-memoization.grit", 5 + "./no-usestate.grit", 6 + "./no-useeffect-data-fetching.grit" 7 + ], 3 8 "vcs": { 4 9 "enabled": false, 5 10 "clientKind": "git",
+12
apps/desktop/no-react-memoization.grit
··· 1 + `$fn($args)` where { 2 + or { 3 + $fn <: `useCallback`, 4 + $fn <: `useMemo`, 5 + $fn <: `memo` 6 + }, 7 + register_diagnostic( 8 + span = $fn, 9 + message = "Manual memoization (useCallback/useMemo/memo) is unnecessary with React Compiler. Use regular functions/components instead.", 10 + severity = "warn" 11 + ) 12 + }
+9
apps/desktop/no-useeffect-data-fetching.grit
··· 1 + `$fn($args)` where { 2 + $fn <: `useEffect`, 3 + register_diagnostic( 4 + span = $fn, 5 + message = "useEffect for data fetching is discouraged. Use TanStack DB (useLiveQuery) instead.", 6 + severity = "warn" 7 + ) 8 + } 9 +
+9
apps/desktop/no-usestate.grit
··· 1 + `$fn($args)` where { 2 + $fn <: `useState`, 3 + register_diagnostic( 4 + span = $fn, 5 + message = "useState is discouraged. Use atoms from @/atoms.ts instead for global state management.", 6 + severity = "warn" 7 + ) 8 + } 9 +
+1
apps/desktop/src/atoms.ts
··· 2 2 3 3 export const shortcutsHelpOpenAtom = Atom.make(false); 4 4 export const aceJumpOpenAtom = Atom.make(false); 5 + export const expandedElidedSectionsAtom = Atom.make<string[]>([]);
+14 -17
apps/desktop/src/components/AceJump.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 - import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 + import { useEffect, useRef, useState } from "react"; 3 3 import { aceJumpOpenAtom } from "@/atoms"; 4 4 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 5 5 import type { Revision } from "@/tauri-commands"; ··· 27 27 } 28 28 }, [open]); 29 29 30 - const matches = useMemo(() => { 30 + const matches = (() => { 31 31 if (!query) return []; 32 32 const lowerQuery = query.toLowerCase(); 33 33 return revisions.filter((r) => r.change_id.toLowerCase().startsWith(lowerQuery)); 34 - }, [revisions, query]); 34 + })(); 35 35 36 - const handleSubmit = useCallback(() => { 36 + function handleSubmit() { 37 37 if (matches.length === 1) { 38 38 onJump(matches[0].change_id); 39 39 setOpen(false); ··· 41 41 onJump(matches[0].change_id); 42 42 setOpen(false); 43 43 } 44 - }, [matches, onJump, setOpen]); 44 + } 45 45 46 46 useEffect(() => { 47 47 if (matches.length === 1 && query.length >= 2) { ··· 50 50 } 51 51 }, [matches, query, onJump, setOpen]); 52 52 53 - const handleKeyDown = useCallback( 54 - (e: React.KeyboardEvent) => { 55 - if (e.key === "Escape") { 56 - e.preventDefault(); 57 - setOpen(false); 58 - } else if (e.key === "Enter") { 59 - e.preventDefault(); 60 - handleSubmit(); 61 - } 62 - }, 63 - [handleSubmit, setOpen], 64 - ); 53 + function handleKeyDown(e: React.KeyboardEvent) { 54 + if (e.key === "Escape") { 55 + e.preventDefault(); 56 + setOpen(false); 57 + } else if (e.key === "Enter") { 58 + e.preventDefault(); 59 + handleSubmit(); 60 + } 61 + } 65 62 66 63 if (!open) return null; 67 64
+51 -59
apps/desktop/src/components/AppShell.tsx
··· 3 3 import { homeDir } from "@tauri-apps/api/path"; 4 4 import { open } from "@tauri-apps/plugin-dialog"; 5 5 import { Effect } from "effect"; 6 - import { useCallback, useMemo, useState } from "react"; 6 + import { useState } from "react"; 7 7 import { AceJump } from "@/components/AceJump"; 8 8 import { CommandPalette } from "@/components/CommandPalette"; 9 9 import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp"; 10 - import { reorderForGraph, RevisionGraph } from "@/components/RevisionGraph"; 10 + import { RevisionGraph, reorderForGraph } from "@/components/RevisionGraph"; 11 11 import { StatusBar } from "@/components/StatusBar"; 12 12 import { Toolbar } from "@/components/Toolbar"; 13 - import { emptyRevisionsCollection, getRevisionsCollection, projectsCollection } from "@/db"; 13 + import { 14 + editRevision, 15 + emptyRevisionsCollection, 16 + getRevisionsCollection, 17 + newRevision, 18 + projectsCollection, 19 + } from "@/db"; 14 20 import { useKeyboardNavigation, useKeyboardShortcut, useKeySequence } from "@/hooks/useKeyboard"; 15 21 import { 16 22 findProjectByPath, ··· 19 25 type Revision, 20 26 upsertProject, 21 27 } from "@/tauri-commands"; 22 - import { editRevision, newRevision } from "@/db"; 23 28 24 29 const openDirectoryDialogEffect = Effect.gen(function* () { 25 30 const home = yield* Effect.tryPromise({ ··· 59 64 60 65 const { data: projects = [] } = useLiveQuery(projectsCollection); 61 66 62 - const activeProject = useMemo( 63 - () => projects.find((p) => p.id === projectId) ?? null, 64 - [projects, projectId], 65 - ); 67 + const activeProject = projects.find((p) => p.id === projectId) ?? null; 66 68 67 - const revisionsCollection = useMemo( 68 - () => (activeProject ? getRevisionsCollection(activeProject.path) : emptyRevisionsCollection), 69 - [activeProject], 70 - ); 69 + const revisionsCollection = activeProject 70 + ? getRevisionsCollection(activeProject.path) 71 + : emptyRevisionsCollection; 71 72 72 73 const { data: revisions = [], isLoading = false } = useLiveQuery(revisionsCollection); 73 74 74 - const orderedRevisions = useMemo(() => reorderForGraph(revisions), [revisions]); 75 + const orderedRevisions = reorderForGraph(revisions); 75 76 76 - const selectedRevision = useMemo(() => { 77 + const selectedRevision = (() => { 77 78 if (revisions.length === 0) return null; 78 79 if (rev) { 79 80 const found = revisions.find((r) => r.change_id === rev); 80 81 if (found) return found; 81 82 } 82 83 return revisions.find((r) => r.is_working_copy) || revisions[0]; 83 - }, [revisions, rev]); 84 + })(); 84 85 85 - const handleOpenRepo = useCallback(() => { 86 + function handleOpenRepo() { 86 87 const program = Effect.gen(function* () { 87 88 const selected = yield* openDirectoryDialogEffect; 88 89 if (!selected) return; ··· 119 120 Effect.catchAll(() => Effect.void), 120 121 ); 121 122 Effect.runPromise(program); 122 - }, [navigate]); 123 + } 123 124 124 - const handleSelectProject = useCallback( 125 - (project: Project) => { 126 - navigate({ to: "/project/$projectId", params: { projectId: project.id } }); 127 - }, 128 - [navigate], 129 - ); 125 + function handleSelectProject(project: Project) { 126 + navigate({ to: "/project/$projectId", params: { projectId: project.id } }); 127 + } 130 128 131 - const handleSelectRevision = useCallback( 132 - (revision: Revision) => { 133 - if (!projectId) return; 134 - navigate({ 135 - to: "/project/$projectId", 136 - params: { projectId }, 137 - search: { rev: revision.change_id }, 138 - }); 139 - }, 140 - [navigate, projectId], 141 - ); 129 + function handleSelectRevision(revision: Revision) { 130 + if (!projectId) return; 131 + navigate({ 132 + to: "/project/$projectId", 133 + params: { projectId }, 134 + search: { rev: revision.change_id }, 135 + }); 136 + } 142 137 143 - const handleNavigateToChangeId = useCallback( 144 - (changeId: string) => { 145 - if (!projectId) return; 146 - navigate({ 147 - to: "/project/$projectId", 148 - params: { projectId }, 149 - search: { rev: changeId || undefined }, 150 - }); 151 - }, 152 - [navigate, projectId], 153 - ); 138 + function handleNavigateToChangeId(changeId: string) { 139 + if (!projectId) return; 140 + navigate({ 141 + to: "/project/$projectId", 142 + params: { projectId }, 143 + search: { rev: changeId || undefined }, 144 + }); 145 + } 154 146 155 147 useKeyboardNavigation({ 156 148 orderedRevisions, ··· 158 150 onNavigate: handleNavigateToChangeId, 159 151 }); 160 152 161 - const triggerFlash = useCallback((changeId: string) => { 153 + function triggerFlash(changeId: string) { 162 154 setFlash({ changeId, key: Date.now() }); 163 155 setTimeout(() => setFlash(null), 400); 164 - }, []); 156 + } 165 157 166 - const handleYankId = useCallback(() => { 158 + function handleYankId() { 167 159 if (!selectedRevision) return; 168 160 navigator.clipboard.writeText(selectedRevision.change_id); 169 161 triggerFlash(selectedRevision.change_id); 170 - }, [selectedRevision, triggerFlash]); 162 + } 171 163 172 - const handleYankLink = useCallback(() => { 164 + function handleYankLink() { 173 165 if (!selectedRevision || !projectId) return; 174 166 const link = `tatami://project/${projectId}/revision/${selectedRevision.change_id}`; 175 167 navigator.clipboard.writeText(link); 176 168 triggerFlash(selectedRevision.change_id); 177 - }, [selectedRevision, projectId, triggerFlash]); 169 + } 178 170 179 171 useKeySequence({ sequence: "yy", onTrigger: handleYankId, enabled: !!selectedRevision }); 180 172 useKeySequence({ ··· 183 175 enabled: !!selectedRevision && !!projectId, 184 176 }); 185 177 186 - const handleNew = useCallback(() => { 178 + function handleNew() { 187 179 if (!activeProject || !selectedRevision) return; 188 180 newRevision(activeProject.path, [selectedRevision.change_id]); 189 - }, [activeProject, selectedRevision]); 181 + } 190 182 191 - const handleEdit = useCallback(() => { 183 + function handleEdit() { 192 184 if (!activeProject || !selectedRevision) return; 193 185 const currentWC = revisions.find((r) => r.is_working_copy); 194 186 editRevision(activeProject.path, selectedRevision.change_id, currentWC?.change_id ?? null); 195 - }, [activeProject, selectedRevision, revisions]); 187 + } 196 188 197 189 useKeyboardShortcut({ 198 190 key: "n", ··· 206 198 enabled: !!activeProject && !!selectedRevision, 207 199 }); 208 200 209 - const closestBookmark = useMemo(() => { 201 + const closestBookmark = (() => { 210 202 const workingCopy = revisions.find((r) => r.is_working_copy); 211 203 if (!workingCopy) return null; 212 204 ··· 224 216 const queue = [...workingCopy.parent_ids]; 225 217 226 218 while (queue.length > 0) { 227 - const commitId = queue.shift()!; 228 - if (visited.has(commitId)) continue; 219 + const commitId = queue.shift(); 220 + if (!commitId || visited.has(commitId)) continue; 229 221 visited.add(commitId); 230 222 231 223 const rev = byCommitId.get(commitId); ··· 239 231 } 240 232 241 233 return null; 242 - }, [revisions]); 234 + })(); 243 235 244 236 return ( 245 237 <>
+1 -1
apps/desktop/src/components/CommandPalette.tsx
··· 1 - import { useState } from "react"; 2 1 import { FolderOpen } from "lucide-react"; 2 + import { useState } from "react"; 3 3 import { 4 4 CommandDialog, 5 5 CommandEmpty,
+396 -74
apps/desktop/src/components/RevisionGraph.tsx
··· 1 - import { memo, useCallback, useMemo } from "react"; 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import { useSearch } from "@tanstack/react-router"; 3 + import { expandedElidedSectionsAtom } from "@/atoms"; 2 4 import { Badge } from "@/components/ui/badge"; 3 5 import { Button } from "@/components/ui/button"; 4 6 import { ScrollArea } from "@/components/ui/scroll-area"; ··· 39 41 40 42 interface ElidedInfo { 41 43 id: string; 42 - childCommitId: string; 43 - parentCommitId: string | null; 44 + elidedRevisions: Revision[]; 44 45 } 45 46 46 47 interface GraphNode { ··· 56 57 type: GraphNodeType; 57 58 revision: Revision | null; 58 59 elidedInfo: ElidedInfo | null; 60 + lane: number; 59 61 } 60 62 61 63 interface GraphData { ··· 85 87 return children.length === 0 || !children.some((c) => commitMap.has(c)); 86 88 }); 87 89 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 + }); 98 + 88 99 // DFS from heads, prioritizing working copy's branch 89 100 const ordered: Revision[] = []; 90 101 const seen = new Set<string>(); ··· 103 114 } 104 115 } 105 116 106 - for (const head of heads) { 117 + // Visit working copy first to ensure its chain is ordered first 118 + if (workingCopy) { 119 + visit(workingCopy); 120 + } 121 + 122 + for (const head of sortedHeads) { 107 123 visit(head); 108 124 } 109 125 ··· 117 133 return ordered; 118 134 } 119 135 120 - function buildGraph(revisions: Revision[]): GraphData { 136 + // Get the set of commit IDs in the working copy's ancestor chain (for lane 0) 137 + function getWorkingCopyChain(revisions: Revision[]): Set<string> { 138 + const commitMap = new Map(revisions.map((r) => [r.commit_id, r])); 139 + const workingCopy = revisions.find((r) => r.is_working_copy); 140 + const chain = new Set<string>(); 141 + 142 + if (workingCopy) { 143 + const queue = [workingCopy.commit_id]; 144 + while (queue.length > 0) { 145 + const id = queue.shift(); 146 + if (!id || chain.has(id)) continue; 147 + chain.add(id); 148 + const rev = commitMap.get(id); 149 + 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]); 153 + } 154 + } 155 + } 156 + } 157 + 158 + return chain; 159 + } 160 + 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 { 121 286 if (revisions.length === 0) return { nodes: [], laneCount: 1, rows: [] }; 122 287 123 - // Reorder: heads first (children before parents), working copy branch prioritized 124 - const orderedRevisions = reorderForGraph(revisions); 288 + // Map commit_id -> Revision for ancestry lookups 289 + const commitMap = new Map(revisions.map((r) => [r.commit_id, r])); 290 + 291 + // Compute which revisions to show vs elide 292 + const rows = computeElision(revisions, expandedSections); 125 293 126 294 // Build set of visible commit IDs for edge type detection 127 - const visibleCommitIds = new Set(orderedRevisions.map((r) => r.commit_id)); 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 + ); 128 302 129 - // First pass: build rows array with elided placeholders inserted 130 - const rows: GraphRow[] = []; 131 - const elidedIds = new Set<string>(); 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; 132 308 133 - for (let i = 0; i < orderedRevisions.length; i++) { 134 - const rev = orderedRevisions[i]; 135 - rows.push({ type: "revision", revision: rev, elidedInfo: null }); 309 + while (currentId) { 310 + if (visibleCommitIds.has(currentId)) { 311 + return currentId; 312 + } 136 313 137 - // Check if any parent is not visible (indirect edge) - insert elided placeholder 138 - for (const parentId of rev.parent_ids) { 139 - if (!visibleCommitIds.has(parentId)) { 140 - const elidedId = `elided-${rev.commit_id}-${parentId}`; 141 - if (!elidedIds.has(elidedId)) { 142 - elidedIds.add(elidedId); 143 - rows.push({ 144 - type: "elided", 145 - revision: null, 146 - elidedInfo: { 147 - id: elidedId, 148 - childCommitId: rev.commit_id, 149 - parentCommitId: parentId, 150 - }, 151 - }); 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; 152 326 } 153 327 } 328 + if (!nextId) break; 329 + 330 + currentId = nextId; 331 + 332 + if (visited.size > MAX_STEPS) break; 154 333 } 334 + 335 + return null; 155 336 } 337 + 338 + // Get working copy chain - these commits should all be in lane 0 339 + const workingCopyChain = getWorkingCopyChain(revisions); 156 340 157 341 // Build row index maps 158 342 const commitToRow = new Map<string, number>(); ··· 170 354 const nodes: GraphNode[] = []; 171 355 const activeLanes: (string | null)[] = [null]; 172 356 357 + // Pre-assign lane 0 to working copy chain 358 + for (const commitId of workingCopyChain) { 359 + commitToLane.set(commitId, 0); 360 + } 361 + 173 362 function claimLane(id: string, preferredLane?: number): number { 363 + // If already assigned (e.g., working copy chain), return that lane 364 + const existing = commitToLane.get(id); 365 + if (existing !== undefined) return existing; 366 + 174 367 if ( 175 368 preferredLane !== undefined && 176 369 preferredLane < activeLanes.length && ··· 179 372 activeLanes[preferredLane] = id; 180 373 return preferredLane; 181 374 } 182 - for (let i = 0; i < activeLanes.length; i++) { 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++) { 183 378 if (activeLanes[i] === null) { 184 379 activeLanes[i] = id; 185 380 return i; ··· 200 395 201 396 let lane = commitToLane.get(revision.commit_id); 202 397 if (lane === undefined) { 203 - const preferLane = rowIdx === 0 ? 0 : 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; 204 401 lane = claimLane(revision.commit_id, preferLane); 205 402 commitToLane.set(revision.commit_id, lane); 206 - } else { 207 - activeLanes[lane] = revision.commit_id; 403 + } 404 + // Update active lane 405 + while (activeLanes.length <= lane) { 406 + activeLanes.push(null); 208 407 } 408 + activeLanes[lane] = revision.commit_id; 209 409 210 410 const parentConnections: ParentConnection[] = []; 211 411 212 412 for (let i = 0; i < revision.parent_ids.length; i++) { 213 413 const parentId = revision.parent_ids[i]; 214 - const isVisible = visibleCommitIds.has(parentId); 215 414 216 - if (isVisible) { 415 + if (visibleCommitIds.has(parentId)) { 217 416 // Direct edge to visible parent 218 417 const parentRow = commitToRow.get(parentId); 219 418 if (parentRow !== undefined) { ··· 224 423 } 225 424 parentConnections.push({ parentRow, parentLane, edgeType: "direct" }); 226 425 } 227 - } else { 228 - // Indirect edge - connect to elided placeholder 229 - const elidedId = `elided-${revision.commit_id}-${parentId}`; 230 - const elidedRow = elidedToRow.get(elidedId); 231 - if (elidedRow !== undefined) { 232 - let elidedLane = elidedToLane.get(elidedId); 233 - if (elidedLane === undefined) { 234 - elidedLane = lane; // Elided node stays in same lane as child 235 - elidedToLane.set(elidedId, elidedLane); 236 - } 237 - parentConnections.push({ parentRow: elidedRow, parentLane: elidedLane, edgeType: "indirect" }); 238 - } 426 + continue; 239 427 } 428 + 429 + // Parent is elided - walk up to the next visible ancestor 430 + const ancestorId = findVisibleAncestor(parentId); 431 + if (!ancestorId) continue; 432 + 433 + const parentRow = commitToRow.get(ancestorId); 434 + if (parentRow === undefined) continue; 435 + 436 + let parentLane = commitToLane.get(ancestorId); 437 + if (parentLane === undefined) { 438 + parentLane = i === 0 ? lane : claimLane(ancestorId); 439 + commitToLane.set(ancestorId, parentLane); 440 + } 441 + 442 + parentConnections.push({ parentRow, parentLane, edgeType: "indirect" }); 240 443 } 241 444 242 445 if (revision.parent_ids.length === 0 && lane < activeLanes.length) { ··· 255 458 const elidedInfo = row.elidedInfo; 256 459 let lane = elidedToLane.get(elidedInfo.id); 257 460 if (lane === undefined) { 258 - // Get lane from child 259 - const childLane = commitToLane.get(elidedInfo.childCommitId); 260 - lane = childLane ?? claimLane(elidedInfo.id); 461 + // Inherit lane from previous visible node or use lane 0 462 + const prevNode = nodes[nodes.length - 1]; 463 + lane = prevNode?.lane ?? 0; 261 464 elidedToLane.set(elidedInfo.id, lane); 262 465 } 263 466 ··· 267 470 elidedInfo, 268 471 row: rowIdx, 269 472 lane, 270 - parentConnections: [], // Elided nodes don't have parent connections in the graph 473 + parentConnections: [], 271 474 }); 272 475 } 273 476 } 274 477 478 + // Update rows with computed lane info from nodes 479 + for (const node of nodes) { 480 + const row = rows[node.row]; 481 + if (row) { 482 + row.lane = node.lane; 483 + } 484 + } 485 + 275 486 return { nodes, laneCount: Math.min(activeLanes.length, MAX_LANES), rows }; 276 487 } 277 488 ··· 284 495 } 285 496 286 497 function GraphColumn({ nodes, laneCount }: { nodes: GraphNode[]; laneCount: number }) { 498 + const { rev: selectedChangeId } = useSearch({ strict: false }); 287 499 const height = nodes.length * ROW_HEIGHT; 288 - const width = LANE_PADDING * 2 + laneCount * LANE_WIDTH; 500 + // Minimal right padding - just enough for the rightmost node 501 + const width = LANE_PADDING + laneCount * LANE_WIDTH + NODE_RADIUS + 4; 289 502 290 503 return ( 291 504 <svg width={width} height={height} className="shrink-0" role="img" aria-label="Revision graph"> ··· 322 535 ); 323 536 } 324 537 325 - // Curved path for cross-lane connections 326 - const midY = (y + parentY) / 2; 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 544 + const goingRight = parentX > x; 545 + const horizontalDir = goingRight ? 1 : -1; 546 + 327 547 return ( 328 548 <path 329 549 key={idx} 330 550 d={`M ${x} ${y + NODE_RADIUS} 331 - C ${x} ${midY}, ${parentX} ${midY}, ${parentX} ${parentY - 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} 555 + L ${parentX} ${parentY - NODE_RADIUS}`} 332 556 fill="none" 333 557 stroke={edgeColor} 334 558 strokeWidth={2} ··· 346 570 const y = node.row * ROW_HEIGHT + ROW_HEIGHT / 2; 347 571 const x = laneToX(node.lane); 348 572 const color = laneColor(node.lane); 573 + const isSelected = node.revision?.change_id === selectedChangeId; 349 574 350 575 // Elided placeholder node - render ~ symbol 351 576 if (node.type === "elided") { ··· 373 598 if (isWorkingCopy) { 374 599 return ( 375 600 <g key={node.revision?.change_id}> 601 + {isSelected && ( 602 + <circle cx={x} cy={y} r={NODE_RADIUS + 6} fill={color} fillOpacity={0.3} /> 603 + )} 376 604 <circle cx={x} cy={y} r={NODE_RADIUS + 3} fill={color} fillOpacity={0.2} /> 377 605 <text 378 606 x={x} ··· 393 621 if (isImmutable) { 394 622 return ( 395 623 <g key={node.revision?.change_id}> 624 + {isSelected && ( 625 + <circle cx={x} cy={y} r={NODE_RADIUS + 4} fill={color} fillOpacity={0.3} /> 626 + )} 396 627 <rect 397 628 x={x - NODE_RADIUS} 398 629 y={y - NODE_RADIUS} ··· 405 636 ); 406 637 } 407 638 408 - return <circle key={node.revision?.change_id} cx={x} cy={y} r={NODE_RADIUS} fill={color} />; 639 + return ( 640 + <g key={node.revision?.change_id}> 641 + {isSelected && ( 642 + <circle cx={x} cy={y} r={NODE_RADIUS + 4} fill={color} fillOpacity={0.3} /> 643 + )} 644 + <circle cx={x} cy={y} r={NODE_RADIUS} fill={color} /> 645 + </g> 646 + ); 409 647 })} 410 648 </svg> 411 649 ); 412 650 } 413 651 414 - const RevisionRow = memo(function RevisionRow({ 652 + function RevisionRow({ 415 653 revision, 416 654 isSelected, 417 655 onSelect, ··· 457 695 </div> 458 696 </Button> 459 697 ); 460 - }); 698 + } 699 + 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 + ]; 715 + 716 + 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 + } 727 + } 728 + } 729 + 730 + return [...typeCounts.entries()] 731 + .sort((a, b) => b[1] - a[1]) 732 + .slice(0, 3) 733 + .map(([type]) => type); 734 + } 735 + 736 + function formatTimeRange(revisions: Revision[]): string { 737 + if (revisions.length === 0) return ""; 738 + 739 + const timestamps = revisions.map((r) => r.timestamp); 740 + if (timestamps.length === 1) return timestamps[0]; 741 + 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); 461 761 462 - const ElidedRow = memo(function ElidedRow(_props: { elidedInfo: ElidedInfo }) { 762 + const typesStr = types.length > 0 ? ` · ${types.join(", ")}` : ""; 763 + const timeStr = timeRange ? ` · ${timeRange}` : ""; 764 + 463 765 return ( 464 - <div 766 + <button 767 + type="button" 465 768 style={{ height: ROW_HEIGHT }} 466 - className="flex items-center px-2 text-muted-foreground text-xs opacity-60" 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} 467 771 > 468 - <span className="font-mono">▸ commits elided</span> 469 - </div> 772 + <span className="font-mono"> 773 + {isExpanded ? "▾" : "▸"} {count} commit{count !== 1 ? "s" : ""} 774 + {typesStr} 775 + {timeStr} 776 + </span> 777 + </button> 470 778 ); 471 - }); 779 + } 472 780 473 781 export function RevisionGraph({ 474 782 revisions, ··· 477 785 isLoading, 478 786 flash, 479 787 }: RevisionGraphProps) { 480 - const { nodes, laneCount, rows } = useMemo(() => buildGraph(revisions), [revisions]); 788 + const [expandedSections, setExpandedSections] = useAtom(expandedElidedSectionsAtom); 789 + const { nodes, laneCount, rows } = buildGraph(revisions, expandedSections); 481 790 482 - const revisionMap = useMemo(() => new Map(revisions.map((r) => [r.change_id, r])), [revisions]); 791 + const revisionMap = new Map(revisions.map((r) => [r.change_id, r])); 483 792 484 - const handleSelect = useCallback( 485 - (changeId: string) => { 486 - const revision = revisionMap.get(changeId); 487 - if (revision) onSelectRevision(revision); 488 - }, 489 - [revisionMap, onSelectRevision], 490 - ); 793 + function handleSelect(changeId: string) { 794 + const revision = revisionMap.get(changeId); 795 + if (revision) onSelectRevision(revision); 796 + } 797 + 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 + } 491 805 492 806 if (revisions.length === 0) { 493 807 return ( ··· 516 830 ); 517 831 } 518 832 if (row.type === "elided" && row.elidedInfo) { 519 - return <ElidedRow key={row.elidedInfo.id} elidedInfo={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 + ); 520 842 } 521 843 return null; 522 844 })}
+2 -3
apps/desktop/src/components/ui/command.tsx
··· 1 1 "use client"; 2 2 3 - import * as React from "react"; 4 3 import { Command as CommandPrimitive } from "cmdk"; 5 4 import { SearchIcon } from "lucide-react"; 6 - 7 - import { cn } from "@/lib/utils"; 5 + import type * as React from "react"; 8 6 import { 9 7 Dialog, 10 8 DialogContent, ··· 13 11 DialogTitle, 14 12 } from "@/components/ui/dialog"; 15 13 import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"; 14 + import { cn } from "@/lib/utils"; 16 15 17 16 function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) { 18 17 return (
+3 -4
apps/desktop/src/components/ui/dialog.tsx
··· 1 - import * as React from "react"; 2 1 import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; 3 - 2 + import { XIcon } from "lucide-react"; 3 + import type * as React from "react"; 4 + import { Button } from "@/components/ui/button"; 4 5 import { cn } from "@/lib/utils"; 5 - import { Button } from "@/components/ui/button"; 6 - import { XIcon } from "lucide-react"; 7 6 8 7 function Dialog({ ...props }: DialogPrimitive.Root.Props) { 9 8 return <DialogPrimitive.Root data-slot="dialog" {...props} />;
+5 -7
apps/desktop/src/components/ui/field.tsx
··· 1 1 import { cva, type VariantProps } from "class-variance-authority"; 2 - import { useMemo } from "react"; 3 2 import { Label } from "@/components/ui/label"; 4 3 import { Separator } from "@/components/ui/separator"; 5 4 import { cn } from "@/lib/utils"; ··· 67 66 className, 68 67 orientation = "vertical", 69 68 ...props 70 - }: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) { 69 + }: React.ComponentProps<"fieldset"> & VariantProps<typeof fieldVariants>) { 71 70 return ( 72 - <div 73 - role="group" 71 + <fieldset 74 72 data-slot="field" 75 73 data-orientation={orientation} 76 - className={cn(fieldVariants({ orientation }), className)} 74 + className={cn(fieldVariants({ orientation }), "border-0 p-0 m-0", className)} 77 75 {...props} 78 76 /> 79 77 ); ··· 169 167 }: React.ComponentProps<"div"> & { 170 168 errors?: Array<{ message?: string } | undefined>; 171 169 }) { 172 - const content = useMemo(() => { 170 + const content = (() => { 173 171 if (children) { 174 172 return children; 175 173 } ··· 189 187 {uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)} 190 188 </ul> 191 189 ); 192 - }, [children, errors]); 190 + })(); 193 191 194 192 if (!content) { 195 193 return null;
+1
apps/desktop/src/components/ui/label.tsx
··· 6 6 7 7 function Label({ className, ...props }: React.ComponentProps<"label">) { 8 8 return ( 9 + // biome-ignore lint/a11y/noLabelWithoutControl: Label receives htmlFor via props when used 9 10 <label 10 11 data-slot="label" 11 12 className={cn(
+54 -31
apps/desktop/src/hooks/useKeyboard.ts
··· 34 34 selectedChangeId, 35 35 onNavigate, 36 36 }: UseKeyboardNavigationOptions) { 37 - const navigateToChangeId = (targetChangeId: string | null) => { 38 - if (!targetChangeId) return; 39 - onNavigate(targetChangeId); 40 - requestAnimationFrame(() => { 41 - const element = document.querySelector<HTMLElement>(`[data-change-id="${targetChangeId}"]`); 42 - if (element) { 43 - element.focus({ preventScroll: true }); 44 - element.scrollIntoView({ block: "nearest", behavior: "smooth" }); 45 - } 46 - }); 47 - }; 37 + // Use refs to avoid stale closures in event handler 38 + const orderedRevisionsRef = useRef(orderedRevisions); 39 + const selectedChangeIdRef = useRef(selectedChangeId); 40 + const onNavigateRef = useRef(onNavigate); 48 41 49 - const getCurrentContext = () => { 50 - let currentIndex = orderedRevisions.findIndex((r) => r.change_id === selectedChangeId); 51 - if (currentIndex < 0) { 52 - currentIndex = orderedRevisions.findIndex((r) => r.is_working_copy); 53 - if (currentIndex < 0) currentIndex = 0; 54 - } 55 - return { currentIndex, currentRevision: orderedRevisions[currentIndex] ?? null }; 56 - }; 42 + orderedRevisionsRef.current = orderedRevisions; 43 + selectedChangeIdRef.current = selectedChangeId; 44 + onNavigateRef.current = onNavigate; 57 45 58 46 useKeySequence({ 59 47 sequence: "gg", 60 - onTrigger: () => navigateToChangeId(orderedRevisions[0]?.change_id || null), 48 + onTrigger: () => { 49 + const revisions = orderedRevisionsRef.current; 50 + const targetChangeId = revisions[0]?.change_id || null; 51 + if (targetChangeId) { 52 + 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 + } 63 + }, 61 64 enabled: orderedRevisions.length > 0, 62 65 }); 63 66 ··· 68 71 return; 69 72 } 70 73 71 - const { currentIndex, currentRevision } = getCurrentContext(); 74 + const revisions = orderedRevisionsRef.current; 75 + const changeId = selectedChangeIdRef.current; 76 + 77 + let currentIndex = revisions.findIndex((r) => r.change_id === changeId); 78 + if (currentIndex < 0) { 79 + currentIndex = revisions.findIndex((r) => r.is_working_copy); 80 + if (currentIndex < 0) currentIndex = 0; 81 + } 82 + const currentRevision = revisions[currentIndex] ?? null; 83 + 72 84 let targetChangeId: string | null = null; 73 85 74 86 switch (event.key) { 75 87 case "j": 76 88 case "ArrowDown": 77 - if (currentIndex >= 0 && currentIndex < orderedRevisions.length - 1) { 78 - targetChangeId = orderedRevisions[currentIndex + 1].change_id; 89 + if (currentIndex >= 0 && currentIndex < revisions.length - 1) { 90 + targetChangeId = revisions[currentIndex + 1].change_id; 79 91 } 80 92 event.preventDefault(); 81 93 break; ··· 83 95 case "k": 84 96 case "ArrowUp": 85 97 if (currentIndex > 0) { 86 - targetChangeId = orderedRevisions[currentIndex - 1].change_id; 98 + targetChangeId = revisions[currentIndex - 1].change_id; 87 99 } 88 100 event.preventDefault(); 89 101 break; ··· 91 103 case "J": 92 104 if (currentRevision && currentRevision.parent_ids.length > 0) { 93 105 const parentId = currentRevision.parent_ids[0]; 94 - const parentRevision = orderedRevisions.find((r) => r.commit_id === parentId); 106 + const parentRevision = revisions.find((r) => r.commit_id === parentId); 95 107 targetChangeId = parentRevision?.change_id || null; 96 108 } 97 109 event.preventDefault(); ··· 99 111 100 112 case "K": 101 113 if (currentRevision) { 102 - const childRevision = orderedRevisions.find((r) => 114 + const childRevision = revisions.find((r) => 103 115 r.parent_ids.includes(currentRevision.commit_id), 104 116 ); 105 117 targetChangeId = childRevision?.change_id || null; ··· 108 120 break; 109 121 110 122 case "@": 111 - targetChangeId = orderedRevisions.find((r) => r.is_working_copy)?.change_id || null; 123 + targetChangeId = revisions.find((r) => r.is_working_copy)?.change_id || null; 112 124 event.preventDefault(); 113 125 break; 114 126 115 127 case "G": 116 - targetChangeId = orderedRevisions[orderedRevisions.length - 1]?.change_id || null; 128 + targetChangeId = revisions[revisions.length - 1]?.change_id || null; 117 129 event.preventDefault(); 118 130 break; 119 131 120 132 case "Escape": 121 - onNavigate(""); 133 + onNavigateRef.current(""); 122 134 event.preventDefault(); 123 135 break; 124 136 } 125 137 126 - navigateToChangeId(targetChangeId); 138 + if (targetChangeId) { 139 + 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 + }); 149 + } 127 150 } 128 151 129 152 window.addEventListener("keydown", handleKeyDown); 130 153 return () => window.removeEventListener("keydown", handleKeyDown); 131 - }, [orderedRevisions, selectedChangeId, onNavigate]); 154 + }, []); 132 155 } 133 156 134 157 export function useKeyboardShortcut({
+1 -3
apps/desktop/src/main.tsx
··· 27 27 search: revisionId ? { rev: revisionId } : {}, 28 28 }); 29 29 } 30 - } catch (error) { 31 - console.error("Failed to parse deep link URL:", url, error); 32 - } 30 + } catch (_error) {} 33 31 } 34 32 } 35 33
-2
apps/desktop/src/mocks/tauri-core.ts
··· 237 237 export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> { 238 238 const handler = handlers[cmd]; 239 239 if (!handler) { 240 - console.warn(`[Mock] No handler for command: ${cmd}`); 241 240 throw new Error(`[Mock] No handler for command: ${cmd}`); 242 241 } 243 242 // Simulate network delay 244 243 await new Promise((r) => setTimeout(r, 50)); 245 - console.log(`[Mock] ${cmd}`, args ?? ""); 246 244 return handler(args) as T; 247 245 }
+1 -1
apps/desktop/src/routes/settings.tsx
··· 1 1 import { createRoute, useNavigate } from "@tanstack/react-router"; 2 - import { Route as rootRoute } from "./__root"; 3 2 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 3 + import { Route as rootRoute } from "./__root"; 4 4 5 5 export const Route = createRoute({ 6 6 getParentRoute: () => rootRoute,