a very good jj gui
0
fork

Configure Feed

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

+840 -412
+1
Cargo.lock
··· 5409 5409 version = "0.1.0" 5410 5410 dependencies = [ 5411 5411 "anyhow", 5412 + "base64 0.22.1", 5412 5413 "chrono", 5413 5414 "futures", 5414 5415 "hex",
apps/desktop/app-icon.png

This is a binary file and will not be displayed.

+1
apps/desktop/src-tauri/Cargo.toml
··· 35 35 tauri-plugin-deep-link = "2" 36 36 chrono = "0.4.42" 37 37 uuid = { version = "1", features = ["v4"] } 38 + base64 = "0.22" 38 39 39 40 [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 40 41 tauri-plugin-shell = "2.0"
+34
apps/desktop/src-tauri/src/lib.rs
··· 356 356 repo::log::resolve_revset(path, &revset).map_err(|e| format!("Failed to resolve revset: {}", e)) 357 357 } 358 358 359 + #[derive(Serialize)] 360 + struct FileContentResult { 361 + base64: String, 362 + size: usize, 363 + } 364 + 365 + /// Get file content as base64 for a specific revision version (current or parent). 366 + /// Used for displaying binary files like images in the diff view. 367 + #[tauri::command] 368 + async fn get_file_content_base64( 369 + repo_path: String, 370 + change_id: String, 371 + file_path: String, 372 + version: String, 373 + ) -> Result<FileContentResult, String> { 374 + use base64::{Engine as _, engine::general_purpose::STANDARD}; 375 + 376 + let path = Path::new(&repo_path); 377 + let jj = JjRepo::open(path).map_err(|e| e.to_string())?; 378 + let commit = jj.get_commit(&change_id).map_err(|e| e.to_string())?; 379 + 380 + let content = match version.as_str() { 381 + "current" => jj.get_file_content(&commit, &file_path).unwrap_or_default(), 382 + "parent" => jj.get_parent_file_content(&commit, &file_path).unwrap_or_default(), 383 + _ => return Err("Invalid version: use 'current' or 'parent'".to_string()), 384 + }; 385 + 386 + Ok(FileContentResult { 387 + base64: STANDARD.encode(&content), 388 + size: content.len(), 389 + }) 390 + } 391 + 359 392 /// Handle "Open Project" menu action: show folder picker, find jj repo, save project, emit event 360 393 fn handle_open_project(app_handle: &AppHandle) { 361 394 let handle = app_handle.clone(); ··· 512 545 get_revision_changes, 513 546 get_commit_recency, 514 547 resolve_revset, 548 + get_file_content_base64, 515 549 get_projects, 516 550 upsert_project, 517 551 find_project_by_path,
+75 -5
apps/desktop/src-tauri/src/repo/log.rs
··· 15 15 use super::jj::JjRepo; 16 16 17 17 #[derive(Clone, Debug, serde::Serialize)] 18 + pub struct BookmarkInfo { 19 + pub name: String, 20 + pub is_tracked: bool, 21 + pub remote: Option<String>, 22 + pub is_ahead: bool, 23 + pub is_behind: bool, 24 + pub is_conflicted: bool, 25 + } 26 + 27 + #[derive(Clone, Debug, serde::Serialize)] 18 28 pub struct ParentEdge { 19 29 pub parent_id: String, 20 30 pub edge_type: String, ··· 36 46 pub is_trunk: bool, 37 47 pub is_divergent: bool, 38 48 pub divergent_index: Option<usize>, 39 - pub bookmarks: Vec<String>, 49 + pub has_conflict: bool, 50 + pub bookmarks: Vec<BookmarkInfo>, 40 51 } 41 52 42 53 pub fn fetch_log(repo_path: &Path, limit: usize, revset: Option<&str>, preset: Option<&str>) -> Result<Vec<Revision>> { ··· 236 247 237 248 let is_trunk = trunk_ancestor_ids.contains(&commit_id); 238 249 250 + let has_conflict = commit.has_conflict(); 251 + 239 252 revisions.push(Revision { 240 253 commit_id: hex::encode(&commit_id.to_bytes()[..6]), 241 254 change_id: full_change_id, ··· 251 264 is_trunk, 252 265 is_divergent, 253 266 divergent_index, 267 + has_conflict, 254 268 bookmarks, 255 269 }); 256 270 } ··· 305 319 } 306 320 } 307 321 308 - fn get_bookmarks_for_commit(repo: &dyn Repo, commit_id: &CommitId) -> Vec<String> { 322 + fn get_bookmarks_for_commit(repo: &dyn Repo, commit_id: &CommitId) -> Vec<BookmarkInfo> { 323 + use jj_lib::str_util::StringMatcher; 324 + 309 325 let view = repo.view(); 310 326 let mut bookmarks = Vec::new(); 311 327 312 - for (name, target) in view.local_bookmarks() { 313 - if target.added_ids().any(|id| id == commit_id) { 314 - bookmarks.push(name.as_str().to_string()); 328 + for (name, local_target) in view.local_bookmarks() { 329 + if !local_target.added_ids().any(|id| id == commit_id) { 330 + continue; 331 + } 332 + 333 + let name_str = name.as_str().to_string(); 334 + 335 + // Check all remotes for tracking status 336 + let mut found_tracked_remote = false; 337 + let mut tracking_remote: Option<String> = None; 338 + let mut is_ahead = false; 339 + let mut is_behind = false; 340 + let mut is_conflicted = false; 341 + 342 + // Iterate over all remotes to find tracked refs for this bookmark 343 + let all_remotes = StringMatcher::all(); 344 + for (remote_name, _remote_view) in view.remote_views_matching(&all_remotes) { 345 + let symbol = name.to_remote_symbol(remote_name); 346 + let remote_ref = view.get_remote_bookmark(symbol); 347 + 348 + if remote_ref.is_tracked() { 349 + found_tracked_remote = true; 350 + tracking_remote = Some(remote_name.as_str().to_string()); 351 + 352 + let remote_target = remote_ref.tracked_target(); 353 + 354 + // Check for conflicts (diverged bookmark) 355 + if local_target.has_conflict() || remote_target.has_conflict() { 356 + is_conflicted = true; 357 + } 358 + 359 + // Compare local and remote targets to determine ahead/behind 360 + // If targets are equal, neither ahead nor behind 361 + // If local has commits remote doesn't have -> ahead 362 + // If remote has commits local doesn't have -> behind 363 + if local_target != remote_target { 364 + // Check if local has commits not in remote (ahead) 365 + let local_ids: std::collections::HashSet<_> = local_target.added_ids().collect(); 366 + let remote_ids: std::collections::HashSet<_> = remote_target.added_ids().collect(); 367 + 368 + // Local is ahead if it has commits that remote doesn't 369 + is_ahead = local_ids.iter().any(|id| !remote_ids.contains(id)); 370 + // Local is behind if remote has commits that local doesn't 371 + is_behind = remote_ids.iter().any(|id| !local_ids.contains(id)); 372 + } 373 + 374 + break; // Use first tracked remote found 375 + } 315 376 } 377 + 378 + bookmarks.push(BookmarkInfo { 379 + name: name_str, 380 + is_tracked: found_tracked_remote, 381 + remote: tracking_remote, 382 + is_ahead, 383 + is_behind, 384 + is_conflicted, 385 + }); 316 386 } 317 387 318 388 bookmarks
+8 -6
apps/desktop/src/components/AceJump.tsx
··· 10 10 CommandItem, 11 11 CommandList, 12 12 } from "@/components/ui/command"; 13 + import { getRevisionKey } from "@/db"; 13 14 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 14 15 import { resolveRevset, type Revision } from "@/tauri-commands"; 15 16 ··· 178 179 const lowerSearch = debouncedSearch.toLowerCase(); 179 180 180 181 if (revision.change_id.toLowerCase().startsWith(lowerSearch)) return "changeId"; 181 - if (revision.bookmarks.some((b) => b.toLowerCase().includes(lowerSearch))) return "bookmark"; 182 + if (revision.bookmarks.some((b) => b.name.toLowerCase().includes(lowerSearch))) 183 + return "bookmark"; 182 184 if (revision.description.toLowerCase().includes(lowerSearch)) return "description"; 183 185 return null; 184 186 } ··· 186 188 function getMatchingBookmark(revision: Revision): string | null { 187 189 if (!debouncedSearch || isRevsetMode) return null; 188 190 const lowerSearch = debouncedSearch.toLowerCase(); 189 - return revision.bookmarks.find((b) => b.toLowerCase().includes(lowerSearch)) ?? null; 191 + return revision.bookmarks.find((b) => b.name.toLowerCase().includes(lowerSearch))?.name ?? null; 190 192 } 191 193 192 194 // Custom filter function that ranks by match type ··· 210 212 } 211 213 212 214 // Bookmark match - medium priority 213 - if (revision.bookmarks.some((b) => b.toLowerCase().includes(lowerSearch))) { 215 + if (revision.bookmarks.some((b) => b.name.toLowerCase().includes(lowerSearch))) { 214 216 return 0.7; 215 217 } 216 218 ··· 275 277 276 278 return ( 277 279 <CommandItem 278 - key={revision.change_id} 280 + key={getRevisionKey(revision)} 279 281 value={revision.change_id} 280 282 onSelect={() => jumpTo(revision.change_id)} 281 283 keywords={[ 282 284 revision.change_id, 283 285 revision.change_id_short, 284 - ...revision.bookmarks, 286 + ...revision.bookmarks.map((b) => b.name), 285 287 revision.description, 286 288 ]} 287 289 className="flex items-center gap-3 py-2.5" ··· 303 305 {matchType === "bookmark" && matchingBookmark ? ( 304 306 <HighlightMatch text={matchingBookmark} query={debouncedSearch} /> 305 307 ) : ( 306 - revision.bookmarks[0] 308 + revision.bookmarks[0].name 307 309 )} 308 310 {revision.bookmarks.length > 1 && ( 309 311 <span className="text-muted-foreground ml-1">
+15 -80
apps/desktop/src/components/AppShell.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { useLiveQuery } from "@tanstack/react-db"; 3 3 import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; 4 - import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; 4 + import { useMemo, useRef, useState, useSyncExternalStore } from "react"; 5 5 import { Route as ProjectRoute } from "@/routes/project.$projectId"; 6 6 import { expandedStacksAtom, viewModeAtom } from "@/atoms"; 7 7 ··· 35 35 import { 36 36 abandonRevision, 37 37 editRevision, 38 - emptyChangesCollection, 39 38 emptyCommitRecencyCollection, 40 39 emptyRevisionsCollection, 41 40 getCommitRecencyCollection, 42 - getRevisionChangesCollection, 41 + getRevisionKey, 43 42 getRevisionsCollection, 44 43 newRevision, 45 44 repositoriesCollection, ··· 109 108 const navigate = useNavigate({ from: ProjectRoute.fullPath }); 110 109 const { projectId } = useParams({ from: ProjectRoute.fullPath }); 111 110 const rev = useSearch({ from: ProjectRoute.fullPath, select: (s) => s.rev }); 112 - const expanded = useSearch({ from: ProjectRoute.fullPath, select: (s) => s.expanded }); 113 - const file = useSearch({ from: ProjectRoute.fullPath, select: (s) => s.file }); 114 - // Get full search object for navigation (only re-renders when expanded/file/rev change, which we need anyway) 115 - const search = useSearch({ from: ProjectRoute.fullPath }); 116 111 const [flash, setFlash] = useState<{ changeId: string; key: number } | null>(null); 117 112 const [viewMode, setViewMode] = useAtom(viewModeAtom); 118 113 const [pendingAbandon, setPendingAbandon] = useState<Revision | null>(null); ··· 175 170 const selectedRevision = (() => { 176 171 if (revisions.length === 0) return null; 177 172 if (rev) { 178 - const found = revisions.find((r) => r.change_id === rev); 173 + // Match using revision key to handle divergent revisions (e.g., "tpuq/0") 174 + const found = revisions.find((r) => getRevisionKey(r) === rev); 179 175 if (found) return found; 180 176 } 181 177 return revisions.find((r) => r.is_working_copy) || revisions[0]; ··· 190 186 navigate({ 191 187 to: "/project/$projectId", 192 188 params: { projectId }, 193 - search: { rev: revision.change_id }, 189 + search: { rev: getRevisionKey(revision) }, 194 190 }); 195 191 } 196 192 ··· 320 316 onPress: () => setViewMode(2), 321 317 }); 322 318 323 - // Get changed files collection for selected revision (TanStack Query handles fetching) 324 - const changesCollection = 325 - expanded && activeProject?.path && selectedRevision?.change_id 326 - ? getRevisionChangesCollection(activeProject.path, selectedRevision.change_id) 327 - : emptyChangesCollection; 328 - const { data: changedFiles = [] } = useLiveQuery(changesCollection); 329 - 330 - // File navigation when revision is expanded - uses capture phase to run before revision navigation 331 - useEffect(() => { 332 - if (!expanded || changedFiles.length === 0) return; 333 - 334 - function handleFileNavigation(event: KeyboardEvent) { 335 - const activeElement = document.activeElement; 336 - if (activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA") { 337 - return; 338 - } 339 - 340 - // Only handle j/k keys for file navigation when revision is expanded 341 - if (event.key !== "j" && event.key !== "k") { 342 - return; 343 - } 344 - 345 - const currentFile = file; 346 - const filePaths = changedFiles.map((f) => f.path); 347 - 348 - if (event.key === "j") { 349 - event.preventDefault(); 350 - event.stopImmediatePropagation(); 351 - const currentIndex = currentFile ? filePaths.indexOf(currentFile) : -1; 352 - const nextIndex = currentIndex + 1; 353 - 354 - if (nextIndex < filePaths.length) { 355 - navigate({ 356 - search: { ...search, file: filePaths[nextIndex], expanded: true }, 357 - }); 358 - } else if (currentIndex === -1 && filePaths.length > 0) { 359 - navigate({ 360 - search: { ...search, file: filePaths[0], expanded: true }, 361 - }); 362 - } 363 - } else if (event.key === "k") { 364 - event.preventDefault(); 365 - event.stopImmediatePropagation(); 366 - const currentIndex = currentFile ? filePaths.indexOf(currentFile) : -1; 367 - 368 - if (currentIndex > 0) { 369 - const prevIndex = currentIndex - 1; 370 - navigate({ 371 - search: { ...search, file: filePaths[prevIndex], expanded: true }, 372 - }); 373 - } else if (currentIndex === -1 && filePaths.length > 0) { 374 - navigate({ 375 - search: { 376 - ...search, 377 - file: filePaths[filePaths.length - 1], 378 - expanded: true, 379 - }, 380 - }); 381 - } 382 - } 383 - } 384 - 385 - // Use capture phase to intercept before revision navigation handler 386 - window.addEventListener("keydown", handleFileNavigation, true); 387 - return () => window.removeEventListener("keydown", handleFileNavigation, true); 388 - }, [expanded, file, search, changedFiles, navigate]); 389 - 390 319 const closestBookmark = (() => { 391 320 const workingCopy = revisions.find((r) => r.is_working_copy); 392 321 if (!workingCopy) return null; 393 322 394 323 if (workingCopy.bookmarks.length > 0) { 395 - return workingCopy.bookmarks[0]; 324 + return workingCopy.bookmarks[0].name; 396 325 } 397 326 398 327 // BFS to find closest ancestor with bookmarks ··· 413 342 if (!rev) continue; 414 343 415 344 if (rev.bookmarks.length > 0) { 416 - return rev.bookmarks[0]; 345 + return rev.bookmarks[0].name; 417 346 } 418 347 419 348 queue.push(...rev.parent_ids); ··· 472 401 <div className="flex-1 min-h-0"> 473 402 {viewMode === 1 ? ( 474 403 // Overview mode: only revision list 475 - <section ref={revisionsPanelRef} className="h-full relative" aria-label="Revision list"> 404 + <section 405 + ref={revisionsPanelRef} 406 + tabIndex={-1} 407 + className="h-full relative outline-none" 408 + aria-label="Revision list" 409 + > 476 410 <RevisionGraph 477 411 ref={revisionGraphRef} 478 412 revisions={revisions} ··· 491 425 <ResizablePanel defaultSize={isNarrowScreen ? 40 : 25} minSize={15}> 492 426 <section 493 427 ref={revisionsPanelRef} 494 - className="h-full relative" 428 + tabIndex={-1} 429 + className="h-full relative outline-none" 495 430 aria-label="Revision list" 496 431 > 497 432 <RevisionGraph
+16 -44
apps/desktop/src/components/ChangedFilesList.tsx
··· 140 140 showSelection = false, 141 141 }: ChangedFilesListProps) { 142 142 if (isLoading) { 143 - return ( 144 - <div className="flex flex-col"> 145 - <div className="px-3 py-2 border-b border-border"> 146 - <Skeleton className="h-4 w-24" /> 147 - </div> 148 - <LoadingSkeleton /> 149 - </div> 150 - ); 143 + return <LoadingSkeleton />; 151 144 } 152 145 153 146 if (files.length === 0) { 154 - return ( 155 - <div className="flex flex-col"> 156 - <div className="px-3 py-2 border-b border-border"> 157 - <span className="text-xs font-semibold text-muted-foreground">0 files changed</span> 158 - </div> 159 - <EmptyState /> 160 - </div> 161 - ); 147 + return <EmptyState />; 162 148 } 163 149 164 - const filesCount = files.length; 165 - const fileWord = filesCount === 1 ? "file" : "files"; 166 - const selectedCount = selectedFiles?.size ?? 0; 167 - 168 150 return ( 169 151 <div> 170 - <div className="px-3 py-2 border-b border-border flex items-center justify-between"> 171 - <span className="text-xs font-semibold text-muted-foreground"> 172 - {filesCount} {fileWord} changed 173 - </span> 174 - {showSelection && selectedCount > 0 && ( 175 - <span className="text-xs text-primary font-medium">{selectedCount} selected</span> 176 - )} 177 - </div> 178 - <div> 179 - {files.map((file, index) => ( 180 - <FileListItem 181 - key={file.path} 182 - file={file} 183 - isFocused={selectedFile === file.path} 184 - isChecked={selectedFiles?.has(file.path) ?? false} 185 - onClick={() => onSelectFile(file.path)} 186 - onToggleSelection={ 187 - onToggleFileSelection ? () => onToggleFileSelection(file.path) : undefined 188 - } 189 - showSelection={showSelection} 190 - isOdd={index % 2 === 1} 191 - /> 192 - ))} 193 - </div> 152 + {files.map((file, index) => ( 153 + <FileListItem 154 + key={file.path} 155 + file={file} 156 + isFocused={selectedFile === file.path} 157 + isChecked={selectedFiles?.has(file.path) ?? false} 158 + onClick={() => onSelectFile(file.path)} 159 + onToggleSelection={ 160 + onToggleFileSelection ? () => onToggleFileSelection(file.path) : undefined 161 + } 162 + showSelection={showSelection} 163 + isOdd={index % 2 === 1} 164 + /> 165 + ))} 194 166 </div> 195 167 ); 196 168 }
+48 -11
apps/desktop/src/components/DiffPanel.tsx
··· 2 2 import { useLiveQuery } from "@tanstack/react-db"; 3 3 import { PatchDiff } from "@pierre/diffs/react"; 4 4 import { Columns2Icon, RowsIcon } from "lucide-react"; 5 - import type { RefObject } from "react"; 5 + import type { FocusEvent, RefObject } from "react"; 6 6 import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; 7 7 import { type DiffStyle, type DiffViewState, diffStyleAtom, diffViewStateAtom } from "@/atoms"; 8 8 import { FileList, RevisionHeader } from "@/components/diff"; 9 + import { ImageDiff } from "@/components/diff/ImageDiff"; 9 10 import { Button } from "@/components/ui/button"; 10 11 import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; 11 12 import { ScrollArea } from "@/components/ui/scroll-area"; ··· 16 17 getRevisionDiffCollection, 17 18 } from "@/db"; 18 19 import { useDiffPanelKeyboard } from "@/hooks/useDiffPanelKeyboard"; 19 - import { useFocusWithin } from "@/hooks/useFocusWithin"; 20 + import type { ChangedFileStatus } from "@/schemas"; 20 21 import type { Revision } from "@/tauri-commands"; 22 + import { isImageFile } from "@/utils/file-types"; 21 23 22 24 interface DiffPanelProps { 23 25 repoPath: string | null; ··· 117 119 patches, 118 120 diffViewState, 119 121 globalDiffStyle, 122 + repoPath, 123 + changeId, 120 124 }: { 121 - patches: Array<{ path: string; patch: string }>; 125 + patches: Array<{ path: string; patch: string; status: ChangedFileStatus }>; 122 126 diffViewState: DiffViewState; 123 127 globalDiffStyle: DiffStyle; 128 + repoPath: string; 129 + changeId: string; 124 130 }) { 125 131 if (patches.length === 0) { 126 132 return ( ··· 133 139 return ( 134 140 <ScrollArea className="h-full w-full"> 135 141 <div className="divide-y divide-border"> 136 - {patches.map(({ path, patch }) => { 142 + {patches.map(({ path, patch, status }) => { 143 + // Check if this is an image file 144 + if (isImageFile(path)) { 145 + return ( 146 + <div key={path} className="min-h-0"> 147 + <ImageDiff 148 + repoPath={repoPath} 149 + changeId={changeId} 150 + filePath={path} 151 + status={status} 152 + /> 153 + </div> 154 + ); 155 + } 156 + 137 157 const effectiveStyle = diffViewState.styleOverrides.get(path) ?? globalDiffStyle; 138 158 return ( 139 159 <div key={path} className="min-h-0"> ··· 182 202 const [diffViewState, setDiffViewState] = useAtom(diffViewStateAtom); 183 203 const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()); 184 204 const prevChangeIdRef = useRef<string | null>(null); 205 + const [hasFocus, setHasFocus] = useState(false); 185 206 186 - // Use native focus tracking 187 - const hasFocus = useFocusWithin(containerRef); 207 + // Handler for blur events - only unfocus if focus moves outside container 208 + const handleBlur = (e: FocusEvent<HTMLDivElement>) => { 209 + if (!e.currentTarget.contains(e.relatedTarget as Node)) { 210 + setHasFocus(false); 211 + } 212 + }; 188 213 189 214 // Merge refs if external ref is provided 190 215 const setRefs = (el: HTMLDivElement | null) => { ··· 282 307 283 308 // Get patches for selected files (in order) 284 309 const selectedPatches = useMemo(() => { 285 - const patches: Array<{ path: string; patch: string }> = []; 310 + const patches: Array<{ path: string; patch: string; status: ChangedFileStatus }> = []; 286 311 // Maintain file order from changedFiles 287 312 for (const file of changedFiles) { 288 313 if (selectedFiles.has(file.path)) { 289 - const patch = patchMap.get(file.path); 290 - if (patch) { 291 - patches.push({ path: file.path, patch }); 292 - } 314 + const patch = patchMap.get(file.path) ?? ""; 315 + patches.push({ path: file.path, patch, status: file.status as ChangedFileStatus }); 293 316 } 294 317 } 295 318 return patches; ··· 297 320 298 321 if (!repoPath || !changeId) { 299 322 return ( 323 + // biome-ignore lint/a11y/noStaticElementInteractions: Focus tracking for keyboard navigation 300 324 <div 301 325 ref={setRefs} 302 326 tabIndex={-1} 327 + onFocus={() => setHasFocus(true)} 328 + onBlur={handleBlur} 303 329 className="flex items-center justify-center h-full text-muted-foreground text-sm outline-none" 304 330 > 305 331 Select a revision to view diffs ··· 309 335 310 336 if (isLoading) { 311 337 return ( 338 + // biome-ignore lint/a11y/noStaticElementInteractions: Focus tracking for keyboard navigation 312 339 <div 313 340 ref={setRefs} 314 341 tabIndex={-1} 342 + onFocus={() => setHasFocus(true)} 343 + onBlur={handleBlur} 315 344 className="flex items-center justify-center h-full text-muted-foreground text-sm outline-none" 316 345 > 317 346 Loading diffs... ··· 321 350 322 351 if (changedFiles.length === 0) { 323 352 return ( 353 + // biome-ignore lint/a11y/noStaticElementInteractions: Focus tracking for keyboard navigation 324 354 <div 325 355 ref={setRefs} 326 356 tabIndex={-1} 357 + onFocus={() => setHasFocus(true)} 358 + onBlur={handleBlur} 327 359 className="flex items-center justify-center h-full text-muted-foreground text-sm outline-none" 328 360 > 329 361 No changes in this revision ··· 332 364 } 333 365 334 366 return ( 367 + // biome-ignore lint/a11y/noStaticElementInteractions: Focus tracking for keyboard navigation 335 368 <div 336 369 ref={setRefs} 337 370 tabIndex={-1} 371 + onFocus={() => setHasFocus(true)} 372 + onBlur={handleBlur} 338 373 className="h-full w-full flex flex-col bg-background outline-none overflow-hidden" 339 374 > 340 375 {/* Revision header */} ··· 400 435 patches={selectedPatches} 401 436 diffViewState={diffViewState} 402 437 globalDiffStyle={globalDiffStyle} 438 + repoPath={repoPath} 439 + changeId={changeId} 403 440 /> 404 441 </div> 405 442 </ResizablePanel>
+149 -21
apps/desktop/src/components/diff/FileList.tsx
··· 136 136 }); 137 137 } 138 138 139 + // Get files in visual tree order (matches how tree is rendered) 140 + function getFilesInTreeOrder(node: TreeNode): ChangedFile[] { 141 + const result: ChangedFile[] = []; 142 + 143 + function traverse(n: TreeNode) { 144 + const sortedChildren = getSortedChildren(n); 145 + for (const child of sortedChildren) { 146 + if (child.file) { 147 + result.push(child.file); 148 + } 149 + if (child.isDirectory) { 150 + traverse(child); 151 + } 152 + } 153 + } 154 + 155 + traverse(node); 156 + return result; 157 + } 158 + 139 159 interface TreeNodeComponentProps { 140 160 node: TreeNode; 141 161 depth: number; ··· 145 165 expandedDirs: Set<string>; 146 166 toggleDir: (path: string) => void; 147 167 itemRefs: React.RefObject<Map<string, HTMLButtonElement>>; 168 + hasFocus: boolean; 148 169 } 149 170 150 171 // Collect all file paths under a tree node ··· 168 189 expandedDirs, 169 190 toggleDir, 170 191 itemRefs, 192 + hasFocus, 171 193 }: TreeNodeComponentProps) { 172 194 const isExpanded = expandedDirs.has(node.path); 173 195 const sortedChildren = getSortedChildren(node); ··· 213 235 expandedDirs={expandedDirs} 214 236 toggleDir={toggleDir} 215 237 itemRefs={itemRefs} 238 + hasFocus={hasFocus} 216 239 /> 217 240 ))} 218 241 </div> ··· 236 259 onClick={(e) => onSelectFile(node.path, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey })} 237 260 className={cn( 238 261 "w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm", 239 - isSelected ? "bg-accent/40 text-foreground" : "text-muted-foreground", 262 + isSelected 263 + ? hasFocus 264 + ? "bg-accent/40 text-foreground" 265 + : "bg-muted text-foreground" 266 + : "text-muted-foreground", 240 267 )} 241 268 style={{ paddingLeft: `${fileIndent}px` }} 242 269 > ··· 260 287 const [viewMode, setViewMode] = useState<"flat" | "tree">("tree"); 261 288 const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set()); 262 289 const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null); 290 + const [selectionAnchor, setSelectionAnchor] = useState<number | null>(null); 263 291 264 292 // Filter files by search query 265 293 const filteredFiles = useMemo(() => { ··· 273 301 const rawTree = buildTree(filteredFiles); 274 302 return collapseSingleChildDirs(rawTree); 275 303 }, [filteredFiles]); 304 + 305 + // Get files in visual tree order for navigation 306 + const sortedFiles = useMemo(() => { 307 + return getFilesInTreeOrder(tree); 308 + }, [tree]); 276 309 277 310 // Auto-expand all directories when switching to tree view or when filter changes 278 311 useEffect(() => { ··· 372 405 [tree, selectedFiles, onSelectFiles], 373 406 ); 374 407 375 - // Get first selected file index for navigation 408 + // Get first selected file path for scrolling 376 409 const firstSelectedPath = selectedFiles.size > 0 ? [...selectedFiles][0] : null; 377 - const selectedIndex = firstSelectedPath 378 - ? filteredFiles.findIndex((f) => f.path === firstSelectedPath) 379 - : -1; 410 + 411 + // The focused index is where the cursor is (for keyboard navigation) 412 + const focusedIndex = 413 + lastClickedIndex ?? 414 + (selectedFiles.size > 0 ? sortedFiles.findIndex((f) => selectedFiles.has(f.path)) : -1); 380 415 381 416 // Navigate to next file 382 417 const navigateDown = useCallback(() => { 383 - if (filteredFiles.length === 0) return; 384 - const nextIndex = selectedIndex < filteredFiles.length - 1 ? selectedIndex + 1 : selectedIndex; 385 - const nextFile = filteredFiles[nextIndex]; 386 - if (nextFile) { 387 - onSelectFiles(new Set([nextFile.path])); 418 + if (sortedFiles.length === 0) return; 419 + const nextIndex = focusedIndex < sortedFiles.length - 1 ? focusedIndex + 1 : focusedIndex; 420 + if (nextIndex !== focusedIndex) { 421 + onSelectFiles(new Set([sortedFiles[nextIndex].path])); 388 422 setLastClickedIndex(nextIndex); 423 + setSelectionAnchor(null); // Reset anchor on single select 389 424 } 390 - }, [filteredFiles, selectedIndex, onSelectFiles]); 425 + }, [sortedFiles, focusedIndex, onSelectFiles]); 391 426 392 427 // Navigate to previous file 393 428 const navigateUp = useCallback(() => { 394 - if (filteredFiles.length === 0) return; 395 - const prevIndex = selectedIndex > 0 ? selectedIndex - 1 : 0; 396 - const prevFile = filteredFiles[prevIndex]; 397 - if (prevFile) { 398 - onSelectFiles(new Set([prevFile.path])); 429 + if (sortedFiles.length === 0) return; 430 + const prevIndex = focusedIndex > 0 ? focusedIndex - 1 : 0; 431 + if (prevIndex !== focusedIndex) { 432 + onSelectFiles(new Set([sortedFiles[prevIndex].path])); 399 433 setLastClickedIndex(prevIndex); 434 + setSelectionAnchor(null); // Reset anchor on single select 400 435 } 401 - }, [filteredFiles, selectedIndex, onSelectFiles]); 436 + }, [sortedFiles, focusedIndex, onSelectFiles]); 437 + 438 + // Extend selection downward 439 + const extendSelectionDown = useCallback(() => { 440 + if (sortedFiles.length === 0) return; 441 + 442 + const currentFocus = focusedIndex >= 0 ? focusedIndex : 0; 443 + const nextFocus = currentFocus < sortedFiles.length - 1 ? currentFocus + 1 : currentFocus; 444 + 445 + if (nextFocus === currentFocus) return; 446 + 447 + // Set anchor on first shift-select (anchor stays fixed, focus moves) 448 + const anchor = selectionAnchor ?? currentFocus; 449 + if (selectionAnchor === null) { 450 + setSelectionAnchor(currentFocus); 451 + } 452 + 453 + // Select all files between anchor and new focus (inclusive) 454 + const startIdx = Math.min(anchor, nextFocus); 455 + const endIdx = Math.max(anchor, nextFocus); 456 + const newSelection = new Set<string>(); 457 + for (let i = startIdx; i <= endIdx; i++) { 458 + newSelection.add(sortedFiles[i].path); 459 + } 460 + 461 + onSelectFiles(newSelection); 462 + setLastClickedIndex(nextFocus); 463 + }, [sortedFiles, focusedIndex, selectionAnchor, onSelectFiles]); 464 + 465 + // Extend selection upward 466 + const extendSelectionUp = useCallback(() => { 467 + if (sortedFiles.length === 0) return; 468 + 469 + const currentFocus = focusedIndex >= 0 ? focusedIndex : 0; 470 + const nextFocus = currentFocus > 0 ? currentFocus - 1 : 0; 471 + 472 + if (nextFocus === currentFocus) return; 473 + 474 + // Set anchor on first shift-select 475 + const anchor = selectionAnchor ?? currentFocus; 476 + if (selectionAnchor === null) { 477 + setSelectionAnchor(currentFocus); 478 + } 479 + 480 + // Select all files between anchor and new focus (inclusive) 481 + const startIdx = Math.min(anchor, nextFocus); 482 + const endIdx = Math.max(anchor, nextFocus); 483 + const newSelection = new Set<string>(); 484 + for (let i = startIdx; i <= endIdx; i++) { 485 + newSelection.add(sortedFiles[i].path); 486 + } 487 + 488 + onSelectFiles(newSelection); 489 + setLastClickedIndex(nextFocus); 490 + }, [sortedFiles, focusedIndex, selectionAnchor, onSelectFiles]); 402 491 403 492 // Keyboard navigation when diff panel is focused 404 493 useKeyboardShortcut({ ··· 425 514 enabled: hasFocus, 426 515 }); 427 516 517 + // Shift+J: extend selection downward 518 + useKeyboardShortcut({ 519 + key: "J", 520 + modifiers: { shift: true }, 521 + onPress: extendSelectionDown, 522 + enabled: hasFocus, 523 + }); 524 + 525 + // Shift+K: extend selection upward 526 + useKeyboardShortcut({ 527 + key: "K", 528 + modifiers: { shift: true }, 529 + onPress: extendSelectionUp, 530 + enabled: hasFocus, 531 + }); 532 + 533 + // Shift+ArrowDown: extend selection downward 534 + useKeyboardShortcut({ 535 + key: "ArrowDown", 536 + modifiers: { shift: true }, 537 + onPress: extendSelectionDown, 538 + enabled: hasFocus, 539 + }); 540 + 541 + // Shift+ArrowUp: extend selection upward 542 + useKeyboardShortcut({ 543 + key: "ArrowUp", 544 + modifiers: { shift: true }, 545 + onPress: extendSelectionUp, 546 + enabled: hasFocus, 547 + }); 548 + 428 549 // Scroll first selected item into view 429 550 useEffect(() => { 430 551 if (firstSelectedPath) { ··· 434 555 }, [firstSelectedPath]); 435 556 436 557 return ( 437 - <div className="flex flex-col h-full w-full overflow-hidden"> 558 + <div 559 + ref={listRef} 560 + tabIndex={-1} 561 + className="flex flex-col h-full w-full overflow-hidden outline-none" 562 + > 438 563 {/* Summary header */} 439 564 <div className="border-b border-border px-3 py-2 text-xs text-muted-foreground shrink-0"> 440 565 <div className="flex items-center justify-between"> ··· 478 603 </div> 479 604 </div> 480 605 481 - <ScrollArea className="flex-1"> 482 - <div ref={listRef}> 606 + <ScrollArea className="flex-1 min-h-0"> 607 + <div> 483 608 {viewMode === "flat" 484 609 ? // Flat list view 485 610 filteredFiles.map((file, index) => { ··· 504 629 className={cn( 505 630 "w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm", 506 631 isSelected 507 - ? "bg-accent/40 text-foreground" 632 + ? hasFocus 633 + ? "bg-accent/40 text-foreground" 634 + : "bg-muted text-foreground" 508 635 : index % 2 === 1 509 636 ? "bg-muted/30 text-muted-foreground" 510 637 : "text-muted-foreground", ··· 532 659 expandedDirs={expandedDirs} 533 660 toggleDir={toggleDir} 534 661 itemRefs={itemRefs} 662 + hasFocus={hasFocus} 535 663 /> 536 664 ))} 537 665 </div>
+102
apps/desktop/src/components/diff/ImageDiff.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import type { ChangedFileStatus } from "@/schemas"; 3 + import { getFileContentBase64 } from "@/tauri-commands"; 4 + import { getMimeType } from "@/utils/file-types"; 5 + 6 + interface ImageDiffProps { 7 + repoPath: string; 8 + changeId: string; 9 + filePath: string; 10 + status: ChangedFileStatus; 11 + } 12 + 13 + export function ImageDiff({ repoPath, changeId, filePath, status }: ImageDiffProps) { 14 + const [currentSrc, setCurrentSrc] = useState<string | null>(null); 15 + const [parentSrc, setParentSrc] = useState<string | null>(null); 16 + const [loading, setLoading] = useState(true); 17 + const [error, setError] = useState<string | null>(null); 18 + 19 + useEffect(() => { 20 + async function loadImages() { 21 + setLoading(true); 22 + setError(null); 23 + const mimeType = getMimeType(filePath); 24 + 25 + try { 26 + if (status !== "deleted") { 27 + const result = await getFileContentBase64(repoPath, changeId, filePath, "current"); 28 + setCurrentSrc(`data:${mimeType};base64,${result.base64}`); 29 + } 30 + if (status !== "added") { 31 + const result = await getFileContentBase64(repoPath, changeId, filePath, "parent"); 32 + setParentSrc(`data:${mimeType};base64,${result.base64}`); 33 + } 34 + } catch (e) { 35 + setError(e instanceof Error ? e.message : "Failed to load image"); 36 + } 37 + setLoading(false); 38 + } 39 + loadImages(); 40 + }, [repoPath, changeId, filePath, status]); 41 + 42 + if (loading) { 43 + return <div className="p-4 text-muted-foreground">Loading image...</div>; 44 + } 45 + 46 + if (error) { 47 + return <div className="p-4 text-destructive">Error: {error}</div>; 48 + } 49 + 50 + if (status === "added" && currentSrc) { 51 + return ( 52 + <div className="p-4"> 53 + <div className="inline-block border-2 border-green-500 rounded overflow-hidden"> 54 + <img src={currentSrc} alt={filePath} className="max-w-full max-h-96 object-contain" /> 55 + </div> 56 + <div className="text-xs text-green-600 mt-2">Added</div> 57 + </div> 58 + ); 59 + } 60 + 61 + if (status === "deleted" && parentSrc) { 62 + return ( 63 + <div className="p-4"> 64 + <div className="inline-block border-2 border-red-500 rounded overflow-hidden opacity-50"> 65 + <img src={parentSrc} alt={filePath} className="max-w-full max-h-96 object-contain" /> 66 + </div> 67 + <div className="text-xs text-red-600 mt-2">Deleted</div> 68 + </div> 69 + ); 70 + } 71 + 72 + // Modified: side-by-side (only render if both images are loaded) 73 + if (parentSrc && currentSrc) { 74 + return ( 75 + <div className="p-4 flex gap-4"> 76 + <div className="flex-1"> 77 + <div className="text-xs text-muted-foreground mb-2">Before</div> 78 + <div className="inline-block border border-red-500/50 rounded overflow-hidden"> 79 + <img 80 + src={parentSrc} 81 + alt={`${filePath} (before)`} 82 + className="max-w-full max-h-80 object-contain" 83 + /> 84 + </div> 85 + </div> 86 + <div className="flex-1"> 87 + <div className="text-xs text-muted-foreground mb-2">After</div> 88 + <div className="inline-block border border-green-500/50 rounded overflow-hidden"> 89 + <img 90 + src={currentSrc} 91 + alt={`${filePath} (after)`} 92 + className="max-w-full max-h-80 object-contain" 93 + /> 94 + </div> 95 + </div> 96 + </div> 97 + ); 98 + } 99 + 100 + // Fallback for unexpected state 101 + return <div className="p-4 text-muted-foreground">Unable to load image</div>; 102 + }
+30 -7
apps/desktop/src/components/revision-graph/BookmarkTag.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { draggingBookmarkAtom } from "@/atoms"; 3 + import type { BookmarkInfo } from "@/schemas"; 3 4 4 5 interface BookmarkTagProps { 5 - bookmark: string; 6 + bookmark: BookmarkInfo; 6 7 changeId: string; 7 8 } 8 9 ··· 15 16 16 17 // Derive isDragging from global state instead of local useState 17 18 const isDragging = 18 - draggingBookmark?.bookmark === bookmark && 19 - draggingBookmark?.fromChangeId === changeId; 19 + draggingBookmark?.bookmark === bookmark.name && draggingBookmark?.fromChangeId === changeId; 20 + 21 + // Determine status indicator and tooltip 22 + let statusIndicator: React.ReactNode = null; 23 + let statusDescription = ""; 24 + 25 + if (bookmark.is_conflicted) { 26 + statusIndicator = <span className="text-destructive ml-0.5">↕</span>; 27 + statusDescription = " (diverged - local and remote have conflicting changes)"; 28 + } else if (bookmark.is_ahead && bookmark.is_behind) { 29 + statusIndicator = <span className="text-destructive ml-0.5">↕</span>; 30 + statusDescription = " (diverged - ahead and behind remote)"; 31 + } else if (bookmark.is_ahead) { 32 + statusIndicator = <span className="text-yellow-500 ml-0.5">↑</span>; 33 + statusDescription = " (ahead of remote)"; 34 + } else if (bookmark.is_behind) { 35 + statusIndicator = <span className="text-blue-500 ml-0.5">↓</span>; 36 + statusDescription = " (behind remote)"; 37 + } 38 + 39 + const remoteInfo = bookmark.remote ? ` tracking ${bookmark.remote}` : ""; 40 + const trackingInfo = bookmark.is_tracked ? remoteInfo : " (untracked)"; 41 + const tooltip = `Drag to move "${bookmark.name}" to another revision${trackingInfo}${statusDescription}`; 20 42 21 43 return ( 22 44 // biome-ignore lint/a11y/noStaticElementInteractions: Draggable element needs drag handlers 23 45 <span 24 46 draggable 25 47 onDragStart={(e) => { 26 - const dragData = JSON.stringify({ bookmark, changeId }); 48 + const dragData = JSON.stringify({ bookmark: bookmark.name, changeId }); 27 49 e.dataTransfer.setData("text/plain", dragData); 28 50 e.dataTransfer.setData("application/x-bookmark", dragData); 29 51 e.dataTransfer.effectAllowed = "move"; 30 52 31 53 // Delay state update to avoid re-render cancelling drag 32 54 requestAnimationFrame(() => { 33 - setDraggingBookmark({ bookmark, fromChangeId: changeId }); 55 + setDraggingBookmark({ bookmark: bookmark.name, fromChangeId: changeId }); 34 56 }); 35 57 }} 36 58 onDragEnd={() => { ··· 39 61 className={`text-xs text-primary font-medium whitespace-nowrap cursor-grab active:cursor-grabbing px-1.5 py-0.5 rounded-sm hover:bg-primary/10 transition-opacity ${ 40 62 isDragging ? "opacity-50" : "" 41 63 }`} 42 - title={`Drag to move "${bookmark}" to another revision`} 64 + title={tooltip} 43 65 > 44 - {bookmark} 66 + {bookmark.name} 67 + {statusIndicator} 45 68 </span> 46 69 ); 47 70 }
+33
apps/desktop/src/components/revision-graph/GraphNode.tsx
··· 46 46 fillOpacity={0.3} 47 47 /> 48 48 )} 49 + {revision.has_conflict && ( 50 + <circle 51 + cx={(size + 8) / 2} 52 + cy={(size + 8) / 2} 53 + r={NODE_RADIUS + 3} 54 + stroke="var(--destructive)" 55 + strokeWidth={2} 56 + strokeDasharray="3 2" 57 + fill="none" 58 + /> 59 + )} 49 60 <circle 50 61 cx={(size + 8) / 2} 51 62 cy={(size + 8) / 2} ··· 90 101 fillOpacity={0.3} 91 102 /> 92 103 )} 104 + {revision.has_conflict && ( 105 + <circle 106 + cx={(size + 8) / 2} 107 + cy={(size + 8) / 2} 108 + r={NODE_RADIUS + 3} 109 + stroke="var(--destructive)" 110 + strokeWidth={2} 111 + strokeDasharray="3 2" 112 + fill="none" 113 + /> 114 + )} 93 115 <rect 94 116 x={(size + 8) / 2 - NODE_RADIUS} 95 117 y={(size + 8) / 2 - NODE_RADIUS} ··· 121 143 r={selectedRingSize} 122 144 fill={color} 123 145 fillOpacity={0.3} 146 + /> 147 + )} 148 + {revision.has_conflict && ( 149 + <circle 150 + cx={(size + 8) / 2} 151 + cy={(size + 8) / 2} 152 + r={NODE_RADIUS + 3} 153 + stroke="var(--destructive)" 154 + strokeWidth={2} 155 + strokeDasharray="3 2" 156 + fill="none" 124 157 /> 125 158 )} 126 159 <circle cx={(size + 8) / 2} cy={(size + 8) / 2} r={NODE_RADIUS} fill={color} />
+25 -60
apps/desktop/src/components/revision-graph/RevisionRow.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 - import { useLiveQuery } from "@tanstack/react-db"; 3 - import { useNavigate, useSearch } from "@tanstack/react-router"; 4 2 import { useRef, useState } from "react"; 5 - import { Route } from "@/routes/project.$projectId"; 6 - import { draggingBookmarkAtom, viewModeAtom } from "@/atoms"; 7 - import { ChangedFilesList } from "@/components/ChangedFilesList"; 8 - import { emptyChangesCollection, getRevisionChangesCollection } from "@/db"; 3 + import { draggingBookmarkAtom } from "@/atoms"; 4 + import { getRevisionKey } from "@/db"; 9 5 import type { Revision } from "@/tauri-commands"; 10 6 import { BookmarkTag } from "./BookmarkTag"; 11 7 import { ROW_HEIGHT, LANE_PADDING, LANE_WIDTH, NODE_RADIUS, laneToX, laneColor } from "./constants"; ··· 20 16 onSelect: (changeId: string, modifiers: { shift: boolean; meta: boolean }) => void; 21 17 isFlashing: boolean; 22 18 isDimmed: boolean; 23 - isExpanded: boolean; 24 19 isFocused: boolean; 25 - repoPath: string | null; 26 20 isPendingAbandon: boolean; 27 21 jumpModeActive: boolean; 28 22 jumpQuery: string; 29 23 jumpHint: string | null; 30 24 onMoveBookmark?: (bookmark: string, fromChangeId: string, toChangeId: string) => void; 25 + hasFocus: boolean; 31 26 } 32 27 33 28 /** ··· 43 38 onSelect, 44 39 isFlashing, 45 40 isDimmed, 46 - isExpanded, 47 41 isFocused, 48 - repoPath, 49 42 isPendingAbandon, 50 43 jumpModeActive, 51 44 jumpQuery, 52 45 jumpHint, 53 46 onMoveBookmark, 47 + hasFocus, 54 48 }: RevisionRowProps) { 55 49 const firstLine = revision.description.split("\n")[0] || "(no description)"; 56 - const fullDescription = revision.description || "(no description)"; 57 50 const [isDragOver, setIsDragOver] = useState(false); 58 51 const [showDropPlaceholder, setShowDropPlaceholder] = useState(false); 59 52 const dragOverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); ··· 65 58 const nodeOffset = laneToX(lane); 66 59 const color = laneColor(lane); 67 60 68 - const selectedFile = useSearch({ from: Route.fullPath, select: (s) => s.file ?? null }); 69 - const search = useSearch({ from: Route.fullPath }); 70 - const navigate = useNavigate({ from: Route.fullPath }); 71 - const [viewMode, setViewMode] = useAtom(viewModeAtom); 72 - 73 - const changedFilesCollection = 74 - isExpanded && repoPath 75 - ? getRevisionChangesCollection(repoPath, revision.change_id) 76 - : emptyChangesCollection; 77 - const changedFilesQuery = useLiveQuery(changedFilesCollection); 78 - 79 - function handleSelectFile(filePath: string) { 80 - // If in overview mode, switch to split mode 81 - if (viewMode === 1) { 82 - setViewMode(2); 83 - } 84 - // Clear expanded state and navigate to file 85 - navigate({ 86 - search: { ...search, file: filePath, expanded: undefined }, 87 - }); 88 - } 89 - 90 61 const nodeSize = revision.is_working_copy ? NODE_RADIUS * 2 + 14 : NODE_RADIUS * 2 + 8; 91 62 92 63 return ( ··· 100 71 }} 101 72 role="button" 102 73 tabIndex={0} 103 - style={{ height: isExpanded ? "auto" : ROW_HEIGHT }} 74 + style={{ height: ROW_HEIGHT }} 104 75 className={`flex relative select-none outline-none ${ 105 76 revision.is_immutable ? "opacity-60" : "" 106 77 } ${isDimmed ? "opacity-40" : ""}`} 107 78 data-selected={isSelected || undefined} 108 79 data-checked={isChecked || undefined} 109 - data-expanded={isExpanded || undefined} 110 80 data-change-id={revision.change_id} 111 81 onClick={(e) => { 112 82 // Prevent text selection on shift+click ··· 114 84 e.preventDefault(); 115 85 window.getSelection()?.removeAllRanges(); 116 86 } 117 - onSelect(revision.change_id, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 87 + onSelect(getRevisionKey(revision), { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 118 88 }} 119 89 onKeyDown={(e) => { 120 90 if (e.key === "Enter" || e.key === " ") { 121 - onSelect(revision.change_id, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 91 + onSelect(getRevisionKey(revision), { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 122 92 } 123 93 }} 124 94 onDragEnter={(e) => { ··· 196 166 isDragOver 197 167 ? "bg-primary/20 border-primary/50 rounded-md" 198 168 : isChecked || isFocused 199 - ? "bg-accent/40 rounded-md border-transparent" 169 + ? hasFocus 170 + ? "bg-accent/40 rounded-md border-transparent" 171 + : "bg-muted rounded-md border-transparent" 200 172 : "border-border/30" 201 173 }`} 202 174 > ··· 226 198 ) : ( 227 199 revision.change_id_short 228 200 )} 201 + {revision.has_conflict && <span className="ml-1 text-destructive">⚠</span>} 229 202 </code> 230 203 {/* Bookmarks - middle column */} 231 204 <div className="flex items-center gap-1 min-w-0 overflow-hidden"> 232 205 {revision.bookmarks.map((bookmark) => ( 233 - <BookmarkTag key={bookmark} bookmark={bookmark} changeId={revision.change_id} /> 206 + <BookmarkTag 207 + key={bookmark.name} 208 + bookmark={bookmark} 209 + changeId={revision.change_id} 210 + /> 234 211 ))} 235 - {showDropPlaceholder && draggingBookmark && draggingBookmark.fromChangeId !== revision.change_id && ( 236 - <span className="text-xs text-primary/60 font-medium whitespace-nowrap px-1 rounded-sm border border-dashed border-primary/40 bg-primary/5 pointer-events-none"> 237 - {draggingBookmark.bookmark} 238 - </span> 239 - )} 212 + {showDropPlaceholder && 213 + draggingBookmark && 214 + draggingBookmark.fromChangeId !== revision.change_id && ( 215 + <span className="text-xs text-primary/60 font-medium whitespace-nowrap px-1 rounded-sm border border-dashed border-primary/40 bg-primary/5 pointer-events-none"> 216 + {draggingBookmark.bookmark} 217 + </span> 218 + )} 240 219 </div> 241 220 <span className="text-xs text-muted-foreground truncate whitespace-nowrap"> 242 221 {revision.author.split("@")[0]} · {revision.timestamp} 243 222 </span> 244 223 </div> 245 - <div className={`text-sm mt-1 ${isExpanded ? "" : "truncate"}`}>{firstLine}</div> 224 + <div className="text-sm mt-1 truncate">{firstLine}</div> 246 225 </div> 247 - {isExpanded && ( 248 - <div className={`px-3 pb-3 pt-0 space-y-3 ${isPendingAbandon ? "blur-sm" : ""}`}> 249 - <pre className="text-xs text-muted-foreground whitespace-pre-wrap break-words font-mono bg-muted/40 border border-border/60 rounded p-2"> 250 - {fullDescription} 251 - </pre> 252 - <div className="border border-border rounded-lg overflow-hidden bg-background"> 253 - <ChangedFilesList 254 - files={changedFilesQuery.data ?? []} 255 - selectedFile={selectedFile} 256 - onSelectFile={handleSelectFile} 257 - isLoading={changedFilesQuery.isLoading} 258 - /> 259 - </div> 260 - </div> 261 - )} 226 + 262 227 {isPendingAbandon && ( 263 228 <div className="absolute inset-0 flex items-center justify-center bg-destructive/10 rounded"> 264 229 <div className="text-sm font-medium text-destructive-foreground bg-destructive/90 px-3 py-1.5 rounded">
+42 -48
apps/desktop/src/components/revision-graph/index.tsx
··· 9 9 expandedStacksAtom, 10 10 hoveredStackIdAtom, 11 11 inlineJumpQueryAtom, 12 - viewModeAtom, 13 12 } from "@/atoms"; 14 13 import { 15 14 reorderForGraph, ··· 17 16 computeRevisionAncestry, 18 17 type RevisionStack, 19 18 } from "@/components/revision-graph-utils"; 20 - import { prefetchRevisionDiffs } from "@/db"; 19 + import { getRevisionKey, prefetchRevisionDiffs } from "@/db"; 21 20 import { useFocusWithin } from "@/hooks/useFocusWithin"; 22 21 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 23 22 import { useRevisionGraphNavigation } from "@/hooks/useRevisionGraphNavigation"; ··· 391 390 rows: allRows, 392 391 edgeBindings, 393 392 } = useMemo(() => buildGraph(revisions), [revisions]); 394 - const expanded = useSearch({ from: Route.fullPath, select: (s) => s.expanded }); 395 393 const search = useSearch({ from: Route.fullPath }); 396 394 const navigate = useNavigate({ from: Route.fullPath }); 397 395 const [inlineJumpQuery, setInlineJumpQuery] = useAtom(inlineJumpQueryAtom); 398 396 const inlineJumpMode = inlineJumpQuery !== null; 399 - const [viewMode] = useAtom(viewModeAtom); 400 397 401 398 // Detect collapsible stacks 402 399 const stacks = useMemo(() => detectStacks(revisions), [revisions]); ··· 433 430 }); 434 431 } 435 432 436 - // Toggle a revision's checked state 437 - function toggleRevisionCheck(changeId: string) { 433 + // Toggle a revision's checked state (uses revision key) 434 + function toggleRevisionCheck(revisionKey: string) { 438 435 const next = new Set(selectedRevisions); 439 - if (next.has(changeId)) { 440 - next.delete(changeId); 436 + if (next.has(revisionKey)) { 437 + next.delete(revisionKey); 441 438 } else { 442 - next.add(changeId); 439 + next.add(revisionKey); 443 440 } 444 441 setSelectedRevisions(next); 445 442 } ··· 552 549 } 553 550 } 554 551 555 - // Maps for lookups - by change_id for UI, by commit_id for graph edges 556 - const revisionMapByChangeId = new Map(revisions.map((r) => [r.change_id, r])); 552 + // Maps for lookups - by revision key for UI, by commit_id for graph edges 553 + const revisionMapByKey = new Map(revisions.map((r) => [getRevisionKey(r), r])); 557 554 const revisionMapByCommitId = new Map(revisions.map((r) => [r.commit_id, r])); 558 555 559 556 // Compute related revisions for dimming logic ··· 570 567 return getRelatedRevisions(revisions, selectedRevision?.change_id ?? null); 571 568 }, [revisions, focusedStack, selectedRevision?.change_id]); 572 569 573 - // Build change_id -> displayRow index map for scrolling and edge positioning 570 + // Build revision key -> displayRow index map for scrolling and edge positioning 574 571 // IMPORTANT: Use displayRows indices (not rows) to match virtualizer positioning 572 + // Uses getRevisionKey() to handle divergent revisions (same change_id, different divergent_index) 575 573 const changeIdToIndex = new Map<string, number>(); 576 574 const commitToRowIndex = new Map<string, number>(); 577 575 for (let i = 0; i < displayRows.length; i++) { 578 576 const displayRow = displayRows[i]; 579 577 if (displayRow.type === "revision") { 580 - changeIdToIndex.set(displayRow.row.revision.change_id, i); 578 + changeIdToIndex.set(getRevisionKey(displayRow.row.revision), i); 581 579 commitToRowIndex.set(displayRow.row.revision.commit_id, i); 582 580 } 583 581 } ··· 680 678 // Ref to hold scroll function - only scrolls if item is outside visible range 681 679 const scrollToIndexIfNeededRef = useRef<((index: number) => void) | null>(null); 682 680 683 - // Determine if selected revision is expanded based on URL search params 684 - // Only allow inline expansion in overview mode (viewMode=1) 685 - const isSelectedExpanded = viewMode === 1 && expanded === true && !!selectedRevision; 686 - 687 - // Keyboard navigation (j/k/J/K/arrows/g/G/Home/End/h/l/Space/Enter/Escape) 681 + // Keyboard navigation (j/k/J/K/arrows/g/G/Home/End/l/Space/Enter/Escape) 688 682 useRevisionGraphNavigation({ 689 683 revisions, 690 684 displayRows, ··· 693 687 enabled: !inlineJumpMode, 694 688 scrollToIndex: (index) => scrollToIndexIfNeededRef.current?.(index), 695 689 onToggleStack: handleToggleStack, 696 - isSelectedExpanded, 697 690 hasFocus, 698 691 diffPanelRef, 699 692 }); ··· 741 734 if (displayRow.type === "collapsed-stack") { 742 735 return COLLAPSED_STACK_HEIGHT; 743 736 } 744 - const row = displayRow.row; 745 - const isExpanded = 746 - isSelectedExpanded && row.revision.change_id === selectedRevision?.change_id; 747 - return isExpanded ? ROW_HEIGHT * 3 : ROW_HEIGHT; 737 + return ROW_HEIGHT; 748 738 }, 749 739 overscan: 10, 750 740 debug: debugEnabled, ··· 794 784 }, 795 785 })); 796 786 797 - function handleSelect(changeId: string, modifiers: { shift: boolean; meta: boolean }) { 798 - const revision = revisionMapByChangeId.get(changeId); 787 + function handleSelect(revisionKey: string, modifiers: { shift: boolean; meta: boolean }) { 788 + const revision = revisionMapByKey.get(revisionKey); 799 789 if (!revision) return; 800 790 801 791 // Cmd/Ctrl+click: toggle selection 802 792 if (modifiers.meta) { 803 - toggleRevisionCheck(changeId); 793 + toggleRevisionCheck(revisionKey); 804 794 return; 805 795 } 806 796 807 797 // Shift+click: range select from focused to clicked 808 798 if (modifiers.shift && selectedRevision) { 809 - const focusedIndex = changeIdToIndex.get(selectedRevision.change_id); 810 - const clickedIndex = changeIdToIndex.get(changeId); 799 + const focusedIndex = changeIdToIndex.get(getRevisionKey(selectedRevision)); 800 + const clickedIndex = changeIdToIndex.get(revisionKey); 811 801 if (focusedIndex !== undefined && clickedIndex !== undefined) { 812 802 const startIdx = Math.min(focusedIndex, clickedIndex); 813 803 const endIdx = Math.max(focusedIndex, clickedIndex); ··· 815 805 for (let i = startIdx; i <= endIdx; i++) { 816 806 const displayRow = displayRows[i]; 817 807 if (displayRow.type === "revision") { 818 - newSelection.add(displayRow.row.revision.change_id); 808 + newSelection.add(getRevisionKey(displayRow.row.revision)); 819 809 } 820 810 } 821 811 // Update selection in URL ··· 835 825 selected: undefined, 836 826 selectionAnchor: undefined, 837 827 stack: undefined, 838 - rev: changeId, 828 + rev: revisionKey, 839 829 }, 840 830 replace: true, 841 831 }); ··· 973 963 if (matches.length === 1) { 974 964 // Single match - jump directly 975 965 setInlineJumpQuery(null); 976 - const revision = revisionMapByChangeId.get(matches[0].changeId); 966 + // Look up revision by change_id (matchingRevisions stores change_id) 967 + const revision = revisions.find((r) => r.change_id === matches[0].changeId); 977 968 if (revision) { 978 969 onSelectRevision(revision); 979 970 } ··· 995 986 996 987 window.addEventListener("keydown", handleJumpKey); 997 988 return () => window.removeEventListener("keydown", handleJumpKey); 998 - }, [ 999 - inlineJumpMode, 1000 - inlineJumpQuery, 1001 - setInlineJumpQuery, 1002 - revisionMapByChangeId, 1003 - onSelectRevision, 1004 - ]); 989 + }, [inlineJumpMode, inlineJumpQuery, setInlineJumpQuery, revisions, onSelectRevision]); 1005 990 1006 991 if (revisions.length === 0) { 1007 992 return ( ··· 1016 1001 } 1017 1002 1018 1003 const selectedIndex = selectedRevision 1019 - ? changeIdToIndex.get(selectedRevision.change_id) 1004 + ? changeIdToIndex.get(getRevisionKey(selectedRevision)) 1020 1005 : undefined; 1021 1006 1022 1007 const workingCopy = revisions.find((r) => r.is_working_copy); 1023 - const wcIndex = workingCopy ? changeIdToIndex.get(workingCopy.change_id) : undefined; 1008 + const wcIndex = workingCopy ? changeIdToIndex.get(getRevisionKey(workingCopy)) : undefined; 1024 1009 1025 1010 // Calculate edge layer dimensions and row center positions 1026 - const getRowStart = (row: number) => rowOffsets.get(row) ?? row * ROW_HEIGHT; 1027 - const getRowCenter = (row: number) => getRowStart(row) + ROW_HEIGHT / 2; 1011 + const getRowStart = (row: number) => { 1012 + const offset = rowOffsets.get(row) ?? row * ROW_HEIGHT; 1013 + return offset; 1014 + }; 1015 + const getRowCenter = (row: number) => { 1016 + const displayRow = displayRows[row]; 1017 + const height = displayRow?.type === "collapsed-stack" ? COLLAPSED_STACK_HEIGHT : ROW_HEIGHT; 1018 + const start = getRowStart(row); 1019 + const center = start + height / 2; 1020 + return center; 1021 + }; 1028 1022 const graphWidth = LANE_PADDING + laneCount * LANE_WIDTH + NODE_RADIUS + 2; 1029 1023 1030 1024 return ( ··· 1152 1146 !relatedRevisions.has(row.revision.change_id); 1153 1147 // Only show focus if no stack is focused 1154 1148 const isFocused = 1155 - !focusedStackId && selectedRevision?.change_id === row.revision.change_id; 1149 + !focusedStackId && 1150 + !!selectedRevision && 1151 + getRevisionKey(selectedRevision) === getRevisionKey(row.revision); 1156 1152 const isSelected = isFocused; 1157 - const isExpanded = isSelectedExpanded && isFocused; 1158 1153 1159 1154 return ( 1160 1155 <div 1161 - key={row.revision.change_id} 1156 + key={getRevisionKey(row.revision)} 1162 1157 ref={rowVirtualizer.measureElement} 1163 1158 data-index={virtualRow.index} 1164 1159 className="absolute left-0 w-full" ··· 1171 1166 lane={lane} 1172 1167 maxLaneOnRow={row.maxLaneOnRow} 1173 1168 isSelected={isSelected} 1174 - isChecked={selectedRevisions.has(row.revision.change_id)} 1169 + isChecked={selectedRevisions.has(getRevisionKey(row.revision))} 1175 1170 isFocused={isFocused} 1176 1171 onSelect={handleSelect} 1177 1172 isFlashing={isFlashing} 1178 1173 isDimmed={isDimmed} 1179 - isExpanded={isExpanded} 1180 - repoPath={repoPath} 1181 1174 isPendingAbandon={pendingAbandon?.change_id === row.revision.change_id} 1182 1175 jumpModeActive={inlineJumpMode} 1183 1176 jumpQuery={inlineJumpQuery ?? ""} 1184 1177 jumpHint={jumpHintsMap.get(row.revision.change_id) ?? null} 1178 + hasFocus={hasFocus} 1185 1179 /> 1186 1180 </div> 1187 1181 );
+1 -1
apps/desktop/src/components/ui/scroll-area.tsx
··· 8 8 return ( 9 9 <ScrollAreaPrimitive.Root 10 10 data-slot="scroll-area" 11 - className={cn("relative", className)} 11 + className={cn("relative h-full", className)} 12 12 {...props} 13 13 > 14 14 <ScrollAreaPrimitive.Viewport
+3 -6
apps/desktop/src/components/ui/sonner.tsx
··· 16 16 "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground group-[.toast]:text-xs group-[.toast]:font-medium group-[.toast]:rounded-md", 17 17 cancelButton: 18 18 "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground group-[.toast]:text-xs group-[.toast]:rounded-md", 19 - success: 20 - "group-[.toaster]:border-primary/30 [&_[data-icon]]:text-primary", 21 - error: 22 - "group-[.toaster]:border-destructive/40 [&_[data-icon]]:text-destructive", 23 - warning: 24 - "group-[.toaster]:border-chart-3/40 [&_[data-icon]]:text-chart-3", 19 + success: "group-[.toaster]:border-primary/30 [&_[data-icon]]:text-primary", 20 + error: "group-[.toaster]:border-destructive/40 [&_[data-icon]]:text-destructive", 21 + warning: "group-[.toaster]:border-chart-3/40 [&_[data-icon]]:text-chart-3", 25 22 info: "group-[.toaster]:border-primary/30 [&_[data-icon]]:text-primary", 26 23 }, 27 24 }}
+2 -1
apps/desktop/src/db.ts
··· 219 219 // ============================================================================ 220 220 221 221 // Key function that handles divergent changes (same change_id, different commits) 222 - function getRevisionKey(revision: Revision): string { 222 + export function getRevisionKey(revision: Revision): string { 223 223 if (revision.divergent_index != null) { 224 224 return `${revision.change_id}/${revision.divergent_index}`; 225 225 } ··· 331 331 is_trunk: false, 332 332 is_divergent: false, 333 333 divergent_index: null, 334 + has_conflict: false, 334 335 bookmarks: [], 335 336 }; 336 337
+12 -2
apps/desktop/src/hooks/useDiffPanelKeyboard.ts
··· 60 60 useKeyboardShortcut({ 61 61 key: "h", 62 62 modifiers: {}, 63 - onPress: () => revisionsPanelRef.current?.focus(), 63 + onPress: () => { 64 + // Find the focusable element inside the revisions panel section 65 + // The RevisionGraph has its own container with tabIndex={-1} 66 + const section = revisionsPanelRef.current; 67 + const target = section?.querySelector('[tabindex="-1"]') as HTMLElement | null; 68 + target?.focus(); 69 + }, 64 70 enabled: isEnabled, 65 71 }); 66 72 67 73 useKeyboardShortcut({ 68 74 key: "ArrowLeft", 69 75 modifiers: {}, 70 - onPress: () => revisionsPanelRef.current?.focus(), 76 + onPress: () => { 77 + const section = revisionsPanelRef.current; 78 + const target = section?.querySelector('[tabindex="-1"]') as HTMLElement | null; 79 + target?.focus(); 80 + }, 71 81 enabled: isEnabled, 72 82 }); 73 83 }
+29 -21
apps/desktop/src/hooks/useKeyboard.ts
··· 1 1 import { useEffect, useRef } from "react"; 2 + import { getRevisionKey } from "@/db"; 2 3 import type { Revision } from "@/tauri-commands"; 3 4 4 5 interface ScrollOptions { ··· 61 62 sequence: "gg", 62 63 onTrigger: () => { 63 64 const revisions = orderedRevisionsRef.current; 64 - const targetChangeId = revisions[0]?.change_id || null; 65 - if (targetChangeId) { 66 - onNavigateRef.current(targetChangeId); 67 - scrollToChangeIdRef.current?.(targetChangeId, { align: "center", smooth: true }); 65 + const targetRev = revisions[0]; 66 + if (targetRev) { 67 + const targetKey = getRevisionKey(targetRev); 68 + onNavigateRef.current(targetKey); 69 + scrollToChangeIdRef.current?.(targetKey, { align: "center", smooth: true }); 68 70 } 69 71 }, 70 72 enabled: orderedRevisions.length > 0, ··· 78 80 } 79 81 80 82 const revisions = orderedRevisionsRef.current; 81 - const changeId = selectedChangeIdRef.current; 83 + const revisionKey = selectedChangeIdRef.current; 82 84 83 - let currentIndex = revisions.findIndex((r) => r.change_id === changeId); 85 + // Find current index using revision key (handles divergent revisions) 86 + let currentIndex = revisions.findIndex((r) => getRevisionKey(r) === revisionKey); 84 87 if (currentIndex < 0) { 85 88 currentIndex = revisions.findIndex((r) => r.is_working_copy); 86 89 if (currentIndex < 0) currentIndex = 0; 87 90 } 88 91 const currentRevision = revisions[currentIndex] ?? null; 89 92 90 - let targetChangeId: string | null = null; 93 + let targetRevisionKey: string | null = null; 91 94 // "jump" = always scroll to center, "step" = scroll only if needed, "none" = no explicit scroll 92 95 let scrollMode: "jump" | "step" | "none" = "none"; 93 96 ··· 103 106 switch (true) { 104 107 case (event.key === "j" || event.key === "ArrowDown") && !disableBasicNavigationRef.current: 105 108 if (currentIndex >= 0 && currentIndex < revisions.length - 1) { 106 - targetChangeId = revisions[currentIndex + 1].change_id; 109 + targetRevisionKey = getRevisionKey(revisions[currentIndex + 1]); 107 110 scrollMode = "step"; 108 111 } 109 112 event.preventDefault(); ··· 111 114 112 115 case (event.key === "k" || event.key === "ArrowUp") && !disableBasicNavigationRef.current: 113 116 if (currentIndex > 0) { 114 - targetChangeId = revisions[currentIndex - 1].change_id; 117 + targetRevisionKey = getRevisionKey(revisions[currentIndex - 1]); 115 118 scrollMode = "step"; 116 119 } 117 120 event.preventDefault(); ··· 125 128 if (parentId) { 126 129 const parentRevision = revisions.find((r) => r.commit_id === parentId); 127 130 if (parentRevision) { 128 - targetChangeId = parentRevision.change_id; 131 + targetRevisionKey = getRevisionKey(parentRevision); 129 132 scrollMode = "step"; 130 133 } 131 134 } ··· 142 145 r.parent_edges.some((e) => e.parent_id === currentRevision.commit_id), 143 146 ); 144 147 if (childRevision) { 145 - targetChangeId = childRevision.change_id; 148 + targetRevisionKey = getRevisionKey(childRevision); 146 149 scrollMode = "step"; 147 150 } 148 151 } 149 152 event.preventDefault(); 150 153 break; 151 154 152 - case event.key === "@": 153 - targetChangeId = revisions.find((r) => r.is_working_copy)?.change_id || null; 155 + case event.key === "@": { 156 + const wcRevision = revisions.find((r) => r.is_working_copy); 157 + targetRevisionKey = wcRevision ? getRevisionKey(wcRevision) : null; 154 158 scrollMode = "jump"; 155 159 event.preventDefault(); 156 160 break; 161 + } 157 162 158 - case event.key === "G": 159 - targetChangeId = revisions[revisions.length - 1]?.change_id || null; 163 + case event.key === "G": { 164 + const lastRevision = revisions[revisions.length - 1]; 165 + targetRevisionKey = lastRevision ? getRevisionKey(lastRevision) : null; 160 166 scrollMode = "jump"; 161 167 event.preventDefault(); 162 168 break; 169 + } 163 170 164 171 case event.key === "Escape": 165 172 onNavigateRef.current(""); ··· 167 174 break; 168 175 } 169 176 170 - if (targetChangeId) { 171 - onNavigateRef.current(targetChangeId); 177 + if (targetRevisionKey) { 178 + onNavigateRef.current(targetRevisionKey); 172 179 if (scrollMode === "jump") { 173 - scrollToChangeIdRef.current?.(targetChangeId, { align: "center", smooth: true }); 180 + scrollToChangeIdRef.current?.(targetRevisionKey, { align: "center", smooth: true }); 174 181 } else if (scrollMode === "step") { 175 - scrollToChangeIdRef.current?.(targetChangeId, { align: "auto", smooth: false }); 182 + scrollToChangeIdRef.current?.(targetRevisionKey, { align: "auto", smooth: false }); 176 183 } 177 184 } 178 185 } ··· 221 228 } 222 229 } 223 230 224 - const altMatch = modifiers.alt === undefined || event.altKey === modifiers.alt; 225 - const shiftMatch = modifiers.shift === undefined || event.shiftKey === modifiers.shift; 231 + const altMatch = modifiers.alt === undefined ? !event.altKey : event.altKey === modifiers.alt; 232 + const shiftMatch = 233 + modifiers.shift === undefined ? !event.shiftKey : event.shiftKey === modifiers.shift; 226 234 227 235 if (metaCtrlMatch && altMatch && shiftMatch) { 228 236 event.preventDefault();
+59 -84
apps/desktop/src/hooks/useRevisionGraphNavigation.ts
··· 5 5 import { Route } from "@/routes/project.$projectId"; 6 6 import { viewModeAtom } from "@/atoms"; 7 7 import type { RevisionStack } from "@/components/revision-graph-utils"; 8 + import { getRevisionKey } from "@/db"; 8 9 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 9 10 import type { Revision } from "@/tauri-commands"; 10 11 ··· 44 45 scrollToIndex: (index: number) => void; 45 46 /** Handler for expanding/collapsing stacks */ 46 47 onToggleStack: (stackId: string) => void; 47 - /** Check if inline expanded for h/l behavior */ 48 - isSelectedExpanded?: boolean; 49 48 /** Whether the revisions panel has focus */ 50 49 hasFocus: boolean; 51 50 /** Ref to the diff panel for focus transfer */ ··· 74 73 enabled, 75 74 scrollToIndex, 76 75 onToggleStack, 77 - isSelectedExpanded = false, 78 76 hasFocus, 79 77 diffPanelRef, 80 78 }: UseRevisionGraphNavigationParams) { 81 79 const navigate = useNavigate({ from: Route.fullPath }); 82 80 const search = useSearch({ from: Route.fullPath }); 83 - const [viewMode] = useAtom(viewModeAtom); 81 + const [viewMode, setViewMode] = useAtom(viewModeAtom); 84 82 85 83 // Read focused stack and selection from URL params 86 84 const focusedStackId = useSearch({ from: Route.fullPath, select: (s) => s.stack ?? null }); ··· 116 114 ); 117 115 } 118 116 if (selectedRevision) { 117 + const selectedKey = getRevisionKey(selectedRevision); 119 118 return displayRows.findIndex( 120 - (row) => 121 - row.type === "revision" && row.row.revision.change_id === selectedRevision.change_id, 119 + (row) => row.type === "revision" && getRevisionKey(row.row.revision) === selectedKey, 122 120 ); 123 121 } 124 122 return -1; ··· 135 133 search: { 136 134 ...search, 137 135 stack: undefined, 138 - rev: displayRow.row.revision.change_id, 136 + rev: getRevisionKey(displayRow.row.revision), 139 137 selected: undefined, 140 138 selectionAnchor: undefined, 141 139 }, ··· 159 157 160 158 // Extend selection in a direction (macOS-style anchor-based selection) 161 159 const extendSelection = (direction: "down" | "up") => { 162 - if (!selectedRevision) return; 163 - const currentIndex = changeIdToIndex.get(selectedRevision.change_id); 164 - if (currentIndex === undefined) return; 160 + if (!selectedRevision) { 161 + return; 162 + } 163 + 164 + const currentIndex = changeIdToIndex.get(getRevisionKey(selectedRevision)); 165 + 166 + if (currentIndex === undefined) { 167 + return; 168 + } 165 169 166 170 const step = direction === "down" ? 1 : -1; 167 171 const limit = direction === "down" ? displayRows.length : -1; 168 172 169 173 // Find the next revision in the given direction 170 - let targetChangeId: string | null = null; 174 + let targetRevisionKey: string | null = null; 171 175 let targetIndex: number | null = null; 172 176 for (let i = currentIndex + step; direction === "down" ? i < limit : i > limit; i += step) { 173 177 const displayRow = displayRows[i]; 174 178 if (displayRow.type === "revision") { 175 - targetChangeId = displayRow.row.revision.change_id; 179 + targetRevisionKey = getRevisionKey(displayRow.row.revision); 176 180 targetIndex = i; 177 181 break; 178 182 } 179 183 } 180 184 181 - if (!targetChangeId || targetIndex === null) return; 185 + if (!targetRevisionKey || targetIndex === null) { 186 + return; 187 + } 182 188 183 189 // Determine anchor: use existing anchor or set it to current position 184 - const anchorChangeId = selectionAnchor ?? selectedRevision.change_id; 185 - const anchorIndex = changeIdToIndex.get(anchorChangeId); 186 - if (anchorIndex === undefined) return; 190 + const anchorKey = selectionAnchor ?? getRevisionKey(selectedRevision); 191 + const anchorIndex = changeIdToIndex.get(anchorKey); 192 + 193 + if (anchorIndex === undefined) { 194 + return; 195 + } 187 196 188 197 // Select all revisions between anchor and target (inclusive) 189 198 const startIndex = Math.min(anchorIndex, targetIndex); ··· 192 201 for (let i = startIndex; i <= endIndex; i++) { 193 202 const displayRow = displayRows[i]; 194 203 if (displayRow.type === "revision") { 195 - newSelection.add(displayRow.row.revision.change_id); 204 + newSelection.add(getRevisionKey(displayRow.row.revision)); 196 205 } 197 206 } 198 207 199 208 const selected = [...newSelection].join(","); 209 + 200 210 navigate({ 201 211 search: { 202 212 ...search, 203 213 selected, 204 - selectionAnchor: anchorChangeId, 205 - rev: targetChangeId, 214 + selectionAnchor: anchorKey, 215 + rev: targetRevisionKey, 206 216 stack: undefined, 207 217 }, 208 218 replace: true, ··· 225 235 if (chainRevisions.length === 0) return; 226 236 227 237 // Find current position in chain 238 + const selectedKey = getRevisionKey(selectedRevision); 228 239 const currentChainIndex = chainRevisions.findIndex( 229 - (row) => row.row.revision.change_id === selectedRevision.change_id, 240 + (row) => getRevisionKey(row.row.revision) === selectedKey, 230 241 ); 231 242 232 243 let targetRevision: Revision | null = null; ··· 263 274 } 264 275 265 276 if (targetRevision) { 266 - const targetIndex = changeIdToIndex.get(targetRevision.change_id); 277 + const targetIndex = changeIdToIndex.get(getRevisionKey(targetRevision)); 267 278 if (targetIndex !== undefined) { 268 279 navigateToDisplayRow(targetIndex); 269 280 } ··· 361 372 enabled: enabled && hasFocus, 362 373 }); 363 374 364 - // J: navigate down in working copy chain 375 + // Ctrl+J: navigate down in working copy chain 365 376 useKeyboardShortcut({ 366 - key: "J", 367 - modifiers: { shift: true }, 377 + key: "j", 378 + modifiers: { ctrl: true }, 368 379 onPress: () => navigateRelated("down"), 369 380 enabled: enabled && hasFocus && !!selectedRevision, 370 381 }); 371 382 372 - // K: navigate up in working copy chain 383 + // Ctrl+K: navigate up in working copy chain 373 384 useKeyboardShortcut({ 374 - key: "K", 375 - modifiers: { shift: true }, 385 + key: "k", 386 + modifiers: { ctrl: true }, 376 387 onPress: () => navigateRelated("up"), 377 388 enabled: enabled && hasFocus && !!selectedRevision, 378 389 }); 379 390 380 - // Shift+j: extend selection downward 391 + // Shift+J: extend selection downward 381 392 useKeyboardShortcut({ 382 - key: "j", 393 + key: "J", 383 394 modifiers: { shift: true }, 384 - onPress: () => extendSelection("down"), 395 + onPress: () => { 396 + extendSelection("down"); 397 + }, 385 398 enabled: enabled && hasFocus && !!selectedRevision, 386 399 }); 387 400 388 - // Shift+k: extend selection upward 401 + // Shift+K: extend selection upward 389 402 useKeyboardShortcut({ 390 - key: "k", 403 + key: "K", 391 404 modifiers: { shift: true }, 392 - onPress: () => extendSelection("up"), 405 + onPress: () => { 406 + extendSelection("up"); 407 + }, 393 408 enabled: enabled && hasFocus && !!selectedRevision, 394 409 }); 395 410 ··· 456 471 enabled: enabled && hasFocus, 457 472 }); 458 473 459 - // l / ArrowRight: expand revision (overview) or focus diff panel (split) 474 + // l / ArrowRight: switch to split mode and focus diff panel 460 475 useKeyboardShortcut({ 461 476 key: "l", 462 477 modifiers: {}, 463 478 onPress: () => { 464 - if (viewMode === 2) { 465 - diffPanelRef.current?.focus(); 466 - return; 467 - } 468 479 if (!selectedRevision) return; 469 - if (isSelectedExpanded) return; 470 - navigate({ 471 - search: { ...search, expanded: true }, 472 - }); 480 + // Always switch to split mode and focus diff panel 481 + if (viewMode === 1) { 482 + setViewMode(2); 483 + } 484 + diffPanelRef.current?.focus(); 473 485 }, 474 486 enabled: enabled && hasFocus, 475 487 }); ··· 478 490 key: "ArrowRight", 479 491 modifiers: {}, 480 492 onPress: () => { 481 - if (viewMode === 2) { 482 - diffPanelRef.current?.focus(); 483 - return; 484 - } 485 493 if (!selectedRevision) return; 486 - if (isSelectedExpanded) return; 487 - navigate({ 488 - search: { ...search, expanded: true }, 489 - }); 490 - }, 491 - enabled: enabled && hasFocus, 492 - }); 493 - 494 - // h / ArrowLeft: collapse revision (overview) 495 - useKeyboardShortcut({ 496 - key: "h", 497 - modifiers: {}, 498 - onPress: () => { 499 - if (viewMode === 2) { 500 - // In split mode, h in revision panel does nothing 501 - return; 494 + // Always switch to split mode and focus diff panel 495 + if (viewMode === 1) { 496 + setViewMode(2); 502 497 } 503 - if (!selectedRevision) return; 504 - if (!isSelectedExpanded) return; 505 - navigate({ 506 - search: { ...search, expanded: undefined }, 507 - }); 508 - }, 509 - enabled: enabled && hasFocus, 510 - }); 511 - 512 - useKeyboardShortcut({ 513 - key: "ArrowLeft", 514 - modifiers: {}, 515 - onPress: () => { 516 - if (viewMode === 2) { 517 - return; 518 - } 519 - if (!selectedRevision) return; 520 - if (!isSelectedExpanded) return; 521 - navigate({ 522 - search: { ...search, expanded: undefined }, 523 - }); 498 + diffPanelRef.current?.focus(); 524 499 }, 525 500 enabled: enabled && hasFocus, 526 501 }); ··· 533 508 if (focusedStackId) { 534 509 onToggleStack(focusedStackId); 535 510 } else if (selectedRevision) { 536 - toggleRevisionCheck(selectedRevision.change_id); 511 + toggleRevisionCheck(getRevisionKey(selectedRevision)); 537 512 } 538 513 }, 539 514 enabled: enabled,
+82 -11
apps/desktop/src/mocks/setup.ts
··· 2 2 // NOTE: tauri-stub.ts must be imported before this to set up __TAURI_INTERNALS__ 3 3 4 4 import { IS_TAURI } from "@/tauri-stub"; 5 - import type { Revision, WorkingCopyStatus, Repository, ChangedFile } from "@/schemas"; 5 + import type { Revision, WorkingCopyStatus, Repository, ChangedFile, BookmarkInfo } from "@/schemas"; 6 + 7 + // Create a mock BookmarkInfo for testing purposes 8 + function mockBookmark(name: string, options?: Partial<Omit<BookmarkInfo, "name">>): BookmarkInfo { 9 + return { 10 + name, 11 + is_tracked: options?.is_tracked ?? true, 12 + remote: options?.remote ?? "origin", 13 + is_ahead: options?.is_ahead ?? false, 14 + is_behind: options?.is_behind ?? false, 15 + is_conflicted: options?.is_conflicted ?? false, 16 + }; 17 + } 6 18 7 19 // Generate random jj-style change ID (12 characters, k-z only) 8 20 function generateChangeId(): string { ··· 92 104 is_trunk: false, 93 105 is_divergent: false, 94 106 divergent_index: null, 107 + has_conflict: false, 95 108 bookmarks: [], 96 109 }, 97 110 // Main trunk commits ··· 109 122 is_trunk: true, 110 123 is_divergent: false, 111 124 divergent_index: null, 125 + has_conflict: false, 112 126 bookmarks: [], 113 127 }, 114 128 { ··· 125 139 is_trunk: true, 126 140 is_divergent: false, 127 141 divergent_index: null, 142 + has_conflict: false, 128 143 bookmarks: [], 129 144 }, 130 145 { ··· 141 156 is_trunk: true, 142 157 is_divergent: false, 143 158 divergent_index: null, 159 + has_conflict: false, 144 160 bookmarks: [], 145 161 }, 146 162 // Feature branch A: Authentication (branches from main003, 4 commits - UNMERGED) ··· 158 174 is_trunk: false, 159 175 is_divergent: false, 160 176 divergent_index: null, 161 - bookmarks: ["feature/auth"], 177 + has_conflict: false, 178 + bookmarks: [mockBookmark("feature/auth")], 162 179 }, 163 180 { 164 181 commit_id: "auth0020000000", ··· 174 191 is_trunk: false, 175 192 is_divergent: false, 176 193 divergent_index: null, 194 + has_conflict: false, 177 195 bookmarks: [], 178 196 }, 179 197 { ··· 190 208 is_trunk: false, 191 209 is_divergent: false, 192 210 divergent_index: null, 211 + has_conflict: false, 193 212 bookmarks: [], 194 213 }, 195 214 { ··· 206 225 is_trunk: false, 207 226 is_divergent: false, 208 227 divergent_index: null, 228 + has_conflict: false, 209 229 bookmarks: [], 210 230 }, 211 231 // Feature branch B: Dark mode (branches from main003, 5 commits - UNMERGED) ··· 223 243 is_trunk: false, 224 244 is_divergent: false, 225 245 divergent_index: null, 226 - bookmarks: ["feature/dark-mode"], 246 + has_conflict: false, 247 + bookmarks: [mockBookmark("feature/dark-mode", { is_ahead: true })], 227 248 }, 228 249 { 229 250 commit_id: "dark0020000000", ··· 239 260 is_trunk: false, 240 261 is_divergent: false, 241 262 divergent_index: null, 263 + has_conflict: false, 242 264 bookmarks: [], 243 265 }, 244 266 { ··· 255 277 is_trunk: false, 256 278 is_divergent: false, 257 279 divergent_index: null, 280 + has_conflict: false, 258 281 bookmarks: [], 259 282 }, 260 283 { ··· 271 294 is_trunk: false, 272 295 is_divergent: false, 273 296 divergent_index: null, 297 + has_conflict: false, 274 298 bookmarks: [], 275 299 }, 276 300 { ··· 287 311 is_trunk: false, 288 312 is_divergent: false, 289 313 divergent_index: null, 314 + has_conflict: false, 290 315 bookmarks: [], 291 316 }, 292 317 // Main trunk continues ··· 304 329 is_trunk: true, 305 330 is_divergent: false, 306 331 divergent_index: null, 332 + has_conflict: false, 307 333 bookmarks: [], 308 334 }, 309 335 { ··· 320 346 is_trunk: true, 321 347 is_divergent: false, 322 348 divergent_index: null, 349 + has_conflict: false, 323 350 bookmarks: [], 324 351 }, 325 352 // Feature branch C: Performance (branches from main005, 4 commits - UNMERGED) ··· 337 364 is_trunk: false, 338 365 is_divergent: false, 339 366 divergent_index: null, 340 - bookmarks: ["feature/performance"], 367 + has_conflict: false, 368 + bookmarks: [mockBookmark("feature/performance", { is_behind: true })], 341 369 }, 342 370 { 343 371 commit_id: "perf0020000000", ··· 353 381 is_trunk: false, 354 382 is_divergent: false, 355 383 divergent_index: null, 384 + has_conflict: false, 356 385 bookmarks: [], 357 386 }, 358 387 { ··· 369 398 is_trunk: false, 370 399 is_divergent: false, 371 400 divergent_index: null, 401 + has_conflict: false, 372 402 bookmarks: [], 373 403 }, 374 404 { ··· 385 415 is_trunk: false, 386 416 is_divergent: false, 387 417 divergent_index: null, 418 + has_conflict: false, 388 419 bookmarks: [], 389 420 }, 390 421 // Main trunk continues ··· 402 433 is_trunk: true, 403 434 is_divergent: false, 404 435 divergent_index: null, 436 + has_conflict: false, 405 437 bookmarks: [], 406 438 }, 407 439 { ··· 418 450 is_trunk: true, 419 451 is_divergent: false, 420 452 divergent_index: null, 453 + has_conflict: false, 421 454 bookmarks: [], 422 455 }, 423 456 // Feature branch D: API improvements (branches from main007, 3 commits - UNMERGED) ··· 435 468 is_trunk: false, 436 469 is_divergent: false, 437 470 divergent_index: null, 438 - bookmarks: ["feature/api"], 471 + has_conflict: false, 472 + bookmarks: [mockBookmark("feature/api")], 439 473 }, 440 474 { 441 475 commit_id: "api0020000000", ··· 451 485 is_trunk: false, 452 486 is_divergent: false, 453 487 divergent_index: null, 488 + has_conflict: false, 454 489 bookmarks: [], 455 490 }, 456 491 { ··· 467 502 is_trunk: false, 468 503 is_divergent: false, 469 504 divergent_index: null, 505 + has_conflict: false, 470 506 bookmarks: [], 471 507 }, 472 508 // Feature branch E: Testing (branches from main007, 5 commits - UNMERGED) ··· 484 520 is_trunk: false, 485 521 is_divergent: false, 486 522 divergent_index: null, 487 - bookmarks: ["feature/testing"], 523 + has_conflict: false, 524 + bookmarks: [mockBookmark("feature/testing", { is_conflicted: true })], 488 525 }, 489 526 { 490 527 commit_id: "test0020000000", ··· 500 537 is_trunk: false, 501 538 is_divergent: false, 502 539 divergent_index: null, 540 + has_conflict: false, 503 541 bookmarks: [], 504 542 }, 505 543 { ··· 516 554 is_trunk: false, 517 555 is_divergent: false, 518 556 divergent_index: null, 557 + has_conflict: false, 519 558 bookmarks: [], 520 559 }, 521 560 { ··· 532 571 is_trunk: false, 533 572 is_divergent: false, 534 573 divergent_index: null, 574 + has_conflict: false, 535 575 bookmarks: [], 536 576 }, 537 577 { ··· 548 588 is_trunk: false, 549 589 is_divergent: false, 550 590 divergent_index: null, 591 + has_conflict: false, 551 592 bookmarks: [], 552 593 }, 553 594 // Main trunk continues ··· 565 606 is_trunk: true, 566 607 is_divergent: false, 567 608 divergent_index: null, 609 + has_conflict: false, 568 610 bookmarks: [], 569 611 }, 570 612 // Feature branch F: UI improvements (branches from main008, 4 commits - UNMERGED) ··· 582 624 is_trunk: false, 583 625 is_divergent: false, 584 626 divergent_index: null, 585 - bookmarks: ["feature/ui"], 627 + has_conflict: false, 628 + bookmarks: [mockBookmark("feature/ui", { is_tracked: false, remote: null })], 586 629 }, 587 630 { 588 631 commit_id: "ui0020000000", ··· 598 641 is_trunk: false, 599 642 is_divergent: false, 600 643 divergent_index: null, 644 + has_conflict: false, 601 645 bookmarks: [], 602 646 }, 603 647 { ··· 614 658 is_trunk: false, 615 659 is_divergent: false, 616 660 divergent_index: null, 661 + has_conflict: false, 617 662 bookmarks: [], 618 663 }, 619 664 { ··· 630 675 is_trunk: false, 631 676 is_divergent: false, 632 677 divergent_index: null, 678 + has_conflict: false, 633 679 bookmarks: [], 634 680 }, 635 681 // Feature branch G: Security (branches from main008, 3 commits - UNMERGED) ··· 647 693 is_trunk: false, 648 694 is_divergent: false, 649 695 divergent_index: null, 650 - bookmarks: ["feature/security"], 696 + has_conflict: false, 697 + bookmarks: [mockBookmark("feature/security")], 651 698 }, 652 699 { 653 700 commit_id: "sec0020000000", ··· 663 710 is_trunk: false, 664 711 is_divergent: false, 665 712 divergent_index: null, 713 + has_conflict: false, 666 714 bookmarks: [], 667 715 }, 668 716 { ··· 679 727 is_trunk: false, 680 728 is_divergent: false, 681 729 divergent_index: null, 730 + has_conflict: false, 682 731 bookmarks: [], 683 732 }, 684 733 // Feature branch H: Documentation (branches from main008, 4 commits - UNMERGED) ··· 696 745 is_trunk: false, 697 746 is_divergent: false, 698 747 divergent_index: null, 699 - bookmarks: ["feature/docs"], 748 + has_conflict: false, 749 + bookmarks: [mockBookmark("feature/docs")], 700 750 }, 701 751 { 702 752 commit_id: "doc0020000000", ··· 712 762 is_trunk: false, 713 763 is_divergent: false, 714 764 divergent_index: null, 765 + has_conflict: false, 715 766 bookmarks: [], 716 767 }, 717 768 { ··· 728 779 is_trunk: false, 729 780 is_divergent: false, 730 781 divergent_index: null, 782 + has_conflict: false, 731 783 bookmarks: [], 732 784 }, 733 785 { ··· 744 796 is_trunk: false, 745 797 is_divergent: false, 746 798 divergent_index: null, 799 + has_conflict: false, 747 800 bookmarks: [], 748 801 }, 749 802 // Feature branch I: Monitoring (branches from main003, older branch - 3 commits - UNMERGED) ··· 761 814 is_trunk: false, 762 815 is_divergent: false, 763 816 divergent_index: null, 764 - bookmarks: ["feature/monitoring"], 817 + has_conflict: false, 818 + bookmarks: [mockBookmark("feature/monitoring")], 765 819 }, 766 820 { 767 821 commit_id: "mon0020000000", ··· 777 831 is_trunk: false, 778 832 is_divergent: false, 779 833 divergent_index: null, 834 + has_conflict: false, 780 835 bookmarks: [], 781 836 }, 782 837 { ··· 793 848 is_trunk: false, 794 849 is_divergent: false, 795 850 divergent_index: null, 851 + has_conflict: false, 796 852 bookmarks: [], 797 853 }, 798 854 // Main trunk continues ··· 810 866 is_trunk: true, 811 867 is_divergent: false, 812 868 divergent_index: null, 869 + has_conflict: false, 813 870 bookmarks: [], 814 871 }, 815 872 // Current working copy (on main009) - only "main" bookmark exists here ··· 827 884 is_trunk: true, 828 885 is_divergent: false, 829 886 divergent_index: null, 830 - bookmarks: ["main"], // Only "main" bookmark 887 + has_conflict: false, 888 + bookmarks: [mockBookmark("main")], // Only "main" bookmark 831 889 }, 832 890 // Current working copy (on main010) 833 891 { ··· 844 902 is_trunk: false, 845 903 is_divergent: false, 846 904 divergent_index: null, 905 + has_conflict: false, 847 906 bookmarks: [], 848 907 }, 849 908 ]; ··· 855 914 const mockChangedFiles: ChangedFile[] = [ 856 915 { path: "src/main.rs", status: "modified" }, 857 916 { path: "README.md", status: "added" }, 917 + { path: "src/components/ui/buttons/PrimaryButton.tsx", status: "modified" }, 918 + { path: "src/components/ui/buttons/SecondaryButton.tsx", status: "modified" }, 919 + { path: "src/features/auth/hooks/useAuth.ts", status: "added" }, 920 + { path: "src/features/auth/hooks/useSession.ts", status: "added" }, 921 + { path: "src/features/auth/components/LoginForm.tsx", status: "modified" }, 922 + { path: "packages/core/lib/utils/formatters/date.ts", status: "deleted" }, 923 + { path: "packages/core/lib/utils/formatters/number.ts", status: "modified" }, 924 + { path: "packages/core/lib/utils/validators/email.ts", status: "added" }, 925 + { path: "tests/unit/auth/login.test.ts", status: "added" }, 926 + { path: "tests/integration/api/users.test.ts", status: "modified" }, 858 927 ]; 859 928 860 929 type MockHandler = (args: Record<string, unknown>) => unknown; ··· 958 1027 is_trunk: false, 959 1028 is_divergent: false, 960 1029 divergent_index: null, 1030 + has_conflict: false, 961 1031 bookmarks: [], 962 1032 }; 963 1033 ··· 1027 1097 is_trunk: false, 1028 1098 is_divergent: false, 1029 1099 divergent_index: null, 1100 + has_conflict: false, 1030 1101 bookmarks: [], 1031 1102 }; 1032 1103
-2
apps/desktop/src/routes/project.$projectId.tsx
··· 8 8 export type ProjectSearchParams = { 9 9 rev?: string; 10 10 file?: string; 11 - expanded?: boolean; 12 11 stack?: string; // Focused collapsed stack id 13 12 selected?: string; // Comma-separated list of selected revision changeIds 14 13 selectionAnchor?: string; // changeId where shift-selection started ··· 21 20 return { 22 21 rev: typeof search.rev === "string" ? search.rev : undefined, 23 22 file: typeof search.file === "string" ? search.file : undefined, 24 - expanded: search.expanded === true || search.expanded === "true", 25 23 stack: typeof search.stack === "string" ? search.stack : undefined, 26 24 selected: typeof search.selected === "string" ? search.selected : undefined, 27 25 selectionAnchor:
+12 -1
apps/desktop/src/schemas.ts
··· 9 9 }); 10 10 export type ParentEdge = typeof ParentEdge.Type; 11 11 12 + export const BookmarkInfo = Schema.Struct({ 13 + name: Schema.String, 14 + is_tracked: Schema.Boolean, 15 + remote: Schema.NullOr(Schema.String), 16 + is_ahead: Schema.Boolean, 17 + is_behind: Schema.Boolean, 18 + is_conflicted: Schema.Boolean, 19 + }); 20 + export type BookmarkInfo = typeof BookmarkInfo.Type; 21 + 12 22 export const Revision = Schema.Struct({ 13 23 commit_id: Schema.String, 14 24 change_id: Schema.String, ··· 24 34 is_trunk: Schema.Boolean, 25 35 is_divergent: Schema.Boolean, 26 36 divergent_index: Schema.NullOr(Schema.Number), 27 - bookmarks: Schema.Array(Schema.String), 37 + has_conflict: Schema.Boolean, 38 + bookmarks: Schema.Array(BookmarkInfo), 28 39 }); 29 40 export type Revision = typeof Revision.Type; 30 41
+26 -1
apps/desktop/src/tauri-commands.ts
··· 84 84 parentChangeIds: string[], 85 85 changeId?: string, 86 86 ): Promise<MutationResult> { 87 - return invoke<MutationResult>("jj_new", { repoPath, parentChangeIds, changeId: changeId ?? null }); 87 + return invoke<MutationResult>("jj_new", { 88 + repoPath, 89 + parentChangeIds, 90 + changeId: changeId ?? null, 91 + }); 88 92 } 89 93 90 94 export async function jjEdit(repoPath: string, changeId: string): Promise<MutationResult> { ··· 134 138 export async function resolveRevset(repoPath: string, revset: string): Promise<RevsetResult> { 135 139 return invoke<RevsetResult>("resolve_revset", { repoPath, revset }); 136 140 } 141 + 142 + /** Result of fetching file content as base64 */ 143 + export interface FileContentResult { 144 + base64: string; 145 + size: number; 146 + } 147 + 148 + /** Get file content as base64 for displaying binary files like images */ 149 + export async function getFileContentBase64( 150 + repoPath: string, 151 + changeId: string, 152 + filePath: string, 153 + version: "current" | "parent", 154 + ): Promise<FileContentResult> { 155 + return invoke<FileContentResult>("get_file_content_base64", { 156 + repoPath, 157 + changeId, 158 + filePath, 159 + version, 160 + }); 161 + }
+35
apps/desktop/src/utils/file-types.ts
··· 1 + const IMAGE_EXTENSIONS = new Set([ 2 + "png", 3 + "jpg", 4 + "jpeg", 5 + "gif", 6 + "svg", 7 + "webp", 8 + "ico", 9 + "bmp", 10 + "tiff", 11 + "avif", 12 + ]); 13 + 14 + const MIME_TYPES: Record<string, string> = { 15 + png: "image/png", 16 + jpg: "image/jpeg", 17 + jpeg: "image/jpeg", 18 + gif: "image/gif", 19 + svg: "image/svg+xml", 20 + webp: "image/webp", 21 + ico: "image/x-icon", 22 + bmp: "image/bmp", 23 + tiff: "image/tiff", 24 + avif: "image/avif", 25 + }; 26 + 27 + export function isImageFile(path: string): boolean { 28 + const ext = path.split(".").pop()?.toLowerCase() ?? ""; 29 + return IMAGE_EXTENSIONS.has(ext); 30 + } 31 + 32 + export function getMimeType(path: string): string { 33 + const ext = path.split(".").pop()?.toLowerCase() ?? ""; 34 + return MIME_TYPES[ext] ?? "application/octet-stream"; 35 + }