a very good jj gui
0
fork

Configure Feed

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

refactor(desktop): replace focusPanelAtom with native browser focus APIs

- Add useFocusWithin hook using focusin/focusout events
- Remove focusPanelAtom from atoms.ts
- Update useDiffPanelKeyboard to use hasFocus prop and call .focus() for panel switching
- Update useRevisionGraphNavigation to use hasFocus prop and call .focus() for panel switching
- Update FileList to accept hasFocus prop instead of reading from atom
- Update RevisionRow to remove manual setFocusPanel calls
- Update DiffPanel and RevisionGraph to use useFocusWithin with tabIndex={-1}
- Update AppShell to create and pass panel refs for cross-panel focus transfer

This simplifies focus management by relying on native browser focus instead of manual state tracking.

+617 -213
+284
apps/desktop/dev/vite-plugin-console-forward.ts
··· 1 + import { createLogger } from "vite"; 2 + import type { Plugin } from "vite"; 3 + import type { IncomingMessage, ServerResponse } from "node:http"; 4 + 5 + interface LogEntry { 6 + level: string; 7 + message: string; 8 + timestamp: Date; 9 + url?: string; 10 + userAgent?: string; 11 + stacks?: string[]; 12 + extra?: any; 13 + } 14 + 15 + interface ClientLogRequest { 16 + logs: LogEntry[]; 17 + } 18 + 19 + export interface ConsoleForwardOptions { 20 + /** 21 + * Whether to enable console forwarding (default: true in dev mode) 22 + */ 23 + enabled?: boolean; 24 + /** 25 + * API endpoint path (default: '/api/debug/client-logs') 26 + */ 27 + endpoint?: string; 28 + /** 29 + * Console levels to forward (default: ['log', 'warn', 'error', 'info', 'debug']) 30 + */ 31 + levels?: ("log" | "warn" | "error" | "info" | "debug")[]; 32 + } 33 + 34 + const logger = createLogger("info", { 35 + prefix: "[browser]", 36 + }); 37 + 38 + export function consoleForwardPlugin( 39 + options: ConsoleForwardOptions = {}, 40 + ): Plugin { 41 + const { 42 + enabled = true, 43 + endpoint = "/api/debug/client-logs", 44 + levels = ["log", "warn", "error", "info", "debug"], 45 + } = options; 46 + 47 + const virtualModuleId = "virtual:console-forward"; 48 + const resolvedVirtualModuleId = "\0" + virtualModuleId; 49 + 50 + return { 51 + name: "console-forward", 52 + 53 + resolveId(id) { 54 + if (id === virtualModuleId) { 55 + return resolvedVirtualModuleId; 56 + } 57 + }, 58 + 59 + transformIndexHtml: { 60 + order: "pre", 61 + handler(html) { 62 + if (!enabled) { 63 + return html; 64 + } 65 + 66 + // Check if the virtual module is already imported 67 + if (html.includes("virtual:console-forward")) { 68 + return html; 69 + } 70 + 71 + // Inject the import script in the head section 72 + return html.replace( 73 + /<head[^>]*>/i, 74 + (match) => 75 + `${match}\n <script type="module">import "virtual:console-forward";</script>`, 76 + ); 77 + }, 78 + }, 79 + 80 + load(id) { 81 + if (id === resolvedVirtualModuleId) { 82 + if (!enabled) { 83 + return "export default {};"; 84 + } 85 + 86 + // Create the console forwarding code 87 + return ` 88 + // Console forwarding module 89 + const originalMethods = { 90 + log: console.log.bind(console), 91 + warn: console.warn.bind(console), 92 + error: console.error.bind(console), 93 + info: console.info.bind(console), 94 + debug: console.debug.bind(console), 95 + }; 96 + 97 + const logBuffer = []; 98 + let flushTimeout = null; 99 + const FLUSH_DELAY = 100; 100 + const MAX_BUFFER_SIZE = 50; 101 + 102 + function createLogEntry(level, args) { 103 + const stacks = []; 104 + const extra = []; 105 + 106 + const message = args.map((arg) => { 107 + if (arg === undefined) return "undefined"; 108 + if (typeof arg === "string") return arg; 109 + if (arg instanceof Error || typeof arg.stack === "string") { 110 + let stringifiedError = arg.toString(); 111 + if (arg.stack) { 112 + let stack = arg.stack.toString(); 113 + if (stack.startsWith(stringifiedError)) { 114 + stack = stack.slice(stringifiedError.length).trimStart(); 115 + } 116 + if (stack) { 117 + stacks.push(stack); 118 + } 119 + } 120 + return stringifiedError; 121 + } 122 + if (typeof arg === "object" && arg !== null) { 123 + try { 124 + extra.push(JSON.parse(JSON.stringify(arg))); 125 + } catch { 126 + extra.push(String(arg)); 127 + } 128 + return "[extra#" + extra.length + "]"; 129 + } 130 + return String(arg); 131 + }).join(" "); 132 + 133 + return { 134 + level, 135 + message, 136 + timestamp: new Date(), 137 + url: window.location.href, 138 + userAgent: navigator.userAgent, 139 + stacks, 140 + extra, 141 + }; 142 + } 143 + 144 + async function sendLogs(logs) { 145 + try { 146 + await fetch("${endpoint}", { 147 + method: "POST", 148 + headers: { "Content-Type": "application/json" }, 149 + body: JSON.stringify({ logs }), 150 + }); 151 + } catch (error) { 152 + // Fail silently in production 153 + } 154 + } 155 + 156 + function flushLogs() { 157 + if (logBuffer.length === 0) return; 158 + const logsToSend = [...logBuffer]; 159 + logBuffer.length = 0; 160 + sendLogs(logsToSend); 161 + if (flushTimeout) { 162 + clearTimeout(flushTimeout); 163 + flushTimeout = null; 164 + } 165 + } 166 + 167 + function addToBuffer(entry) { 168 + logBuffer.push(entry); 169 + if (logBuffer.length >= MAX_BUFFER_SIZE) { 170 + flushLogs(); 171 + return; 172 + } 173 + if (!flushTimeout) { 174 + flushTimeout = setTimeout(flushLogs, FLUSH_DELAY); 175 + } 176 + } 177 + 178 + // Patch console methods 179 + ${levels 180 + .map( 181 + (level) => ` 182 + console.${level} = function(...args) { 183 + originalMethods.${level}(...args); 184 + const entry = createLogEntry("${level}", args); 185 + addToBuffer(entry); 186 + };`, 187 + ) 188 + .join("")} 189 + 190 + // Cleanup handlers 191 + window.addEventListener("beforeunload", flushLogs); 192 + setInterval(flushLogs, 10000); 193 + 194 + export default { flushLogs }; 195 + `; 196 + } 197 + }, 198 + configureServer(server) { 199 + // Add API endpoint to handle forwarded console logs 200 + server.middlewares.use(endpoint, (req, res, next) => { 201 + const request = req as IncomingMessage & { method?: string }; 202 + if (request.method !== "POST") { 203 + return next(); 204 + } 205 + 206 + let body = ""; 207 + request.setEncoding!("utf8"); 208 + 209 + request.on("data", (chunk: string) => { 210 + body += chunk; 211 + }); 212 + 213 + request.on("end", () => { 214 + try { 215 + const { logs }: ClientLogRequest = JSON.parse(body); 216 + 217 + // Forward each log to the Vite dev server console using Vite's logger 218 + logs.forEach((log) => { 219 + const location = log.url ? ` (${log.url})` : ""; 220 + let message = `[${log.level}] ${log.message}${location}`; 221 + 222 + // Add stack traces if available 223 + if (log.stacks && log.stacks.length > 0) { 224 + message += 225 + "\n" + 226 + log.stacks 227 + .map((stack) => 228 + stack 229 + .split("\n") 230 + .map((line) => ` ${line}`) 231 + .join("\n"), 232 + ) 233 + .join("\n"); 234 + } 235 + 236 + // Add extra data if available 237 + if (log.extra && log.extra.length > 0) { 238 + message += 239 + "\n Extra data: " + 240 + JSON.stringify(log.extra, null, 2) 241 + .split("\n") 242 + .map((line) => ` ${line}`) 243 + .join("\n"); 244 + } 245 + 246 + // Use Vite's logger for consistent formatting 247 + const logOptions = { timestamp: true }; 248 + switch (log.level) { 249 + case "error": 250 + const error = 251 + log.stacks && log.stacks.length > 0 252 + ? new Error(log.stacks.join("\n")) 253 + : null; 254 + logger.error(message, { ...logOptions, error }); 255 + break; 256 + case "warn": 257 + logger.warn(message, logOptions); 258 + break; 259 + case "info": 260 + logger.info(message, logOptions); 261 + break; 262 + case "debug": 263 + logger.info(message, logOptions); 264 + break; 265 + default: 266 + logger.info(message, logOptions); 267 + } 268 + }); 269 + 270 + res.writeHead(200, { "Content-Type": "application/json" }); 271 + res.end(JSON.stringify({ success: true })); 272 + } catch (error) { 273 + server.config.logger.error("Error processing client logs:", { 274 + timestamp: true, 275 + error: error as Error, 276 + }); 277 + res.writeHead(400, { "Content-Type": "application/json" }); 278 + res.end(JSON.stringify({ error: "Invalid JSON" })); 279 + } 280 + }); 281 + }); 282 + }, 283 + }; 284 + }
+12 -2
apps/desktop/src/components/AppShell.tsx
··· 119 119 const [projectPickerOpen, setProjectPickerOpen] = useState(false); 120 120 const [isSyncing, setIsSyncing] = useState(false); 121 121 const revisionGraphRef = useRef<RevisionGraphHandle>(null); 122 + const revisionsPanelRef = useRef<HTMLDivElement>(null); 123 + const diffPanelRef = useRef<HTMLDivElement>(null); 122 124 const isNarrowScreen = useIsNarrowScreen(); 123 125 const { handleAddRepository } = useAddRepository(); 124 126 ··· 470 472 <div className="flex-1 min-h-0"> 471 473 {viewMode === 1 ? ( 472 474 // Overview mode: only revision list 473 - <section className="h-full relative" aria-label="Revision list"> 475 + <section ref={revisionsPanelRef} className="h-full relative" aria-label="Revision list"> 474 476 <RevisionGraph 475 477 ref={revisionGraphRef} 476 478 revisions={revisions} ··· 480 482 flash={flash} 481 483 repoPath={activeProject?.path ?? null} 482 484 pendingAbandon={pendingAbandon} 485 + diffPanelRef={diffPanelRef} 483 486 /> 484 487 </section> 485 488 ) : ( 486 489 // Split mode: revision list + diff panel (vertical on narrow screens) 487 490 <ResizablePanelGroup orientation={isNarrowScreen ? "vertical" : "horizontal"}> 488 491 <ResizablePanel defaultSize={isNarrowScreen ? 40 : 25} minSize={15}> 489 - <section className="h-full relative" aria-label="Revision list"> 492 + <section 493 + ref={revisionsPanelRef} 494 + className="h-full relative" 495 + aria-label="Revision list" 496 + > 490 497 <RevisionGraph 491 498 ref={revisionGraphRef} 492 499 revisions={revisions} ··· 496 503 flash={flash} 497 504 repoPath={activeProject?.path ?? null} 498 505 pendingAbandon={pendingAbandon} 506 + diffPanelRef={diffPanelRef} 499 507 /> 500 508 </section> 501 509 </ResizablePanel> ··· 503 511 <ResizablePanel defaultSize={isNarrowScreen ? 60 : 75} minSize={30}> 504 512 <aside className="h-full" aria-label="Diff viewer"> 505 513 <PrerenderedDiffPanel 514 + ref={diffPanelRef} 506 515 repoPath={activeProject?.path ?? null} 507 516 revisions={orderedRevisions} 508 517 selectedChangeId={selectedRevision?.change_id ?? null} 518 + revisionsPanelRef={revisionsPanelRef} 509 519 /> 510 520 </aside> 511 521 </ResizablePanel>
+60 -19
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 { useEffect, useMemo, useRef, useState } from "react"; 5 + import type { RefObject } from "react"; 6 + import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; 6 7 import { type DiffStyle, type DiffViewState, diffStyleAtom, diffViewStateAtom } from "@/atoms"; 7 8 import { FileList, RevisionHeader } from "@/components/diff"; 8 9 import { Button } from "@/components/ui/button"; ··· 15 16 getRevisionDiffCollection, 16 17 } from "@/db"; 17 18 import { useDiffPanelKeyboard } from "@/hooks/useDiffPanelKeyboard"; 19 + import { useFocusWithin } from "@/hooks/useFocusWithin"; 18 20 import type { Revision } from "@/tauri-commands"; 19 21 20 22 interface DiffPanelProps { 21 23 repoPath: string | null; 22 24 changeId: string | null; 23 25 revision: Revision | null; 26 + revisionsPanelRef: RefObject<HTMLElement | null>; 24 27 } 25 28 26 29 interface PrerenderedDiffPanelProps { 27 30 repoPath: string | null; 28 31 revisions: Revision[]; 29 32 selectedChangeId: string | null; 33 + revisionsPanelRef: RefObject<HTMLElement | null>; 30 34 } 31 35 32 36 /** ··· 88 92 return { additions, deletions }; 89 93 } 90 94 91 - export function PrerenderedDiffPanel({ 92 - repoPath, 93 - revisions, 94 - selectedChangeId, 95 - }: PrerenderedDiffPanelProps) { 96 - const selectedRevision = selectedChangeId 97 - ? (revisions.find((r) => r.change_id === selectedChangeId) ?? null) 98 - : null; 95 + export const PrerenderedDiffPanel = forwardRef<HTMLDivElement, PrerenderedDiffPanelProps>( 96 + function PrerenderedDiffPanel({ repoPath, revisions, selectedChangeId, revisionsPanelRef }, ref) { 97 + const selectedRevision = selectedChangeId 98 + ? (revisions.find((r) => r.change_id === selectedChangeId) ?? null) 99 + : null; 99 100 100 - return <DiffPanel repoPath={repoPath} changeId={selectedChangeId} revision={selectedRevision} />; 101 - } 101 + return ( 102 + <DiffPanel 103 + ref={ref} 104 + repoPath={repoPath} 105 + changeId={selectedChangeId} 106 + revision={selectedRevision} 107 + revisionsPanelRef={revisionsPanelRef} 108 + /> 109 + ); 110 + }, 111 + ); 102 112 103 113 /** 104 114 * Multi-file diff viewer - shows multiple diffs in a scrollable container ··· 162 172 }; 163 173 } 164 174 165 - export function DiffPanel({ repoPath, changeId, revision }: DiffPanelProps) { 175 + export const DiffPanel = forwardRef<HTMLDivElement, DiffPanelProps>(function DiffPanel( 176 + { repoPath, changeId, revision, revisionsPanelRef }, 177 + ref, 178 + ) { 179 + const containerRef = useRef<HTMLDivElement>(null); 166 180 const scrollContainerRef = useRef<HTMLDivElement>(null); 167 181 const [globalDiffStyle] = useAtom(diffStyleAtom); 168 182 const [diffViewState, setDiffViewState] = useAtom(diffViewStateAtom); 169 183 const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()); 170 184 const prevChangeIdRef = useRef<string | null>(null); 171 185 186 + // Use native focus tracking 187 + const hasFocus = useFocusWithin(containerRef); 188 + 189 + // Merge refs if external ref is provided 190 + const setRefs = (el: HTMLDivElement | null) => { 191 + (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el; 192 + if (typeof ref === "function") { 193 + ref(el); 194 + } else if (ref) { 195 + ref.current = el; 196 + } 197 + }; 198 + 172 199 // Get first selected file for style override display 173 200 const firstSelectedFile = selectedFiles.size > 0 ? [...selectedFiles][0] : null; 174 201 ··· 198 225 } 199 226 200 227 // Keyboard navigation 201 - useDiffPanelKeyboard({ scrollContainerRef }); 228 + useDiffPanelKeyboard({ scrollContainerRef, revisionsPanelRef, hasFocus }); 202 229 203 230 // Fetch file changes (for the file list with status) 204 231 const changesCollection = ··· 270 297 271 298 if (!repoPath || !changeId) { 272 299 return ( 273 - <div className="flex items-center justify-center h-full text-muted-foreground text-sm"> 300 + <div 301 + ref={setRefs} 302 + tabIndex={-1} 303 + className="flex items-center justify-center h-full text-muted-foreground text-sm outline-none" 304 + > 274 305 Select a revision to view diffs 275 306 </div> 276 307 ); ··· 278 309 279 310 if (isLoading) { 280 311 return ( 281 - <div className="flex items-center justify-center h-full text-muted-foreground text-sm"> 312 + <div 313 + ref={setRefs} 314 + tabIndex={-1} 315 + className="flex items-center justify-center h-full text-muted-foreground text-sm outline-none" 316 + > 282 317 Loading diffs... 283 318 </div> 284 319 ); ··· 286 321 287 322 if (changedFiles.length === 0) { 288 323 return ( 289 - <div className="flex items-center justify-center h-full text-muted-foreground text-sm"> 324 + <div 325 + ref={setRefs} 326 + tabIndex={-1} 327 + className="flex items-center justify-center h-full text-muted-foreground text-sm outline-none" 328 + > 290 329 No changes in this revision 291 330 </div> 292 331 ); ··· 294 333 295 334 return ( 296 335 <div 297 - ref={scrollContainerRef} 336 + ref={setRefs} 337 + tabIndex={-1} 298 338 className="h-full w-full flex flex-col bg-background outline-none overflow-hidden" 299 339 > 300 340 {/* Revision header */} ··· 331 371 </div> 332 372 333 373 {/* Two-column layout wrapper */} 334 - <div className="relative flex-1 min-h-0 min-w-0"> 374 + <div ref={scrollContainerRef} className="relative flex-1 min-h-0 min-w-0 overflow-auto"> 335 375 <ResizablePanelGroup 336 376 id="diff-panel-layout" 337 377 orientation="horizontal" ··· 346 386 onSelectFiles={setSelectedFiles} 347 387 totalAdditions={totalAdditions} 348 388 totalDeletions={totalDeletions} 389 + hasFocus={hasFocus} 349 390 /> 350 391 </div> 351 392 </ResizablePanel> ··· 366 407 </div> 367 408 </div> 368 409 ); 369 - } 410 + });
+3 -8
apps/desktop/src/components/diff/FileList.tsx
··· 1 - import { useAtom } from "@effect-atom/atom-react"; 2 1 import { 3 2 ChevronDownIcon, 4 3 ChevronRightIcon, ··· 13 12 SearchIcon, 14 13 } from "lucide-react"; 15 14 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 16 - import { focusPanelAtom } from "@/atoms"; 17 15 import { Button } from "@/components/ui/button"; 18 16 import { Input } from "@/components/ui/input"; 19 17 import { ScrollArea } from "@/components/ui/scroll-area"; ··· 27 25 onSelectFiles: (filePaths: Set<string>) => void; 28 26 totalAdditions: number; 29 27 totalDeletions: number; 28 + hasFocus: boolean; 30 29 } 31 30 32 31 function getFileStatusIcon(status: ChangedFileStatus) { ··· 253 252 onSelectFiles, 254 253 totalAdditions, 255 254 totalDeletions, 255 + hasFocus, 256 256 }: FileListProps) { 257 - const [focusPanel, setFocusPanel] = useAtom(focusPanelAtom); 258 - const hasFocus = focusPanel === "diff"; 259 257 const listRef = useRef<HTMLDivElement>(null); 260 258 const itemRefs = useRef<Map<string, HTMLButtonElement>>(new Map()); 261 259 const [filterQuery, setFilterQuery] = useState(""); ··· 310 308 (filePath: string, modifiers: { shift: boolean; meta: boolean }) => { 311 309 const clickedIndex = filteredFiles.findIndex((f) => f.path === filePath); 312 310 313 - // Set focus to diff panel when clicking on a file 314 - setFocusPanel("diff"); 315 - 316 311 if (modifiers.meta) { 317 312 // Cmd/Ctrl+click: toggle selection 318 313 const newSelected = new Set(selectedFiles); ··· 338 333 setLastClickedIndex(clickedIndex); 339 334 } 340 335 }, 341 - [filteredFiles, selectedFiles, onSelectFiles, lastClickedIndex, setFocusPanel], 336 + [filteredFiles, selectedFiles, onSelectFiles, lastClickedIndex], 342 337 ); 343 338 344 339 // Handle folder selection (select all files in folder)
+1 -1
apps/desktop/src/components/diff/RevisionHeader.tsx
··· 36 36 <span className="text-foreground">{revision.timestamp}</span> 37 37 </div> 38 38 {title && ( 39 - <div className="mt-2 pt-2 border-t border-border"> 39 + <div className="mt-2 pt-2"> 40 40 <div className="flex items-start gap-1"> 41 41 {hasBody && ( 42 42 <button
+1 -1
apps/desktop/src/components/revision-graph/GraphEdge.tsx
··· 95 95 96 96 // For collapsed stacks, draw a dotted line with clickable area 97 97 if (isCollapsedStack) { 98 - const collapsedLabel = `${collapsedCount ?? 0} hidden revision${(collapsedCount ?? 0) !== 1 ? "s" : ""} - click to expand`; 98 + const collapsedLabel = `${collapsedCount ?? 0} elided revision${(collapsedCount ?? 0) !== 1 ? "s" : ""} - click to expand`; 99 99 100 100 return ( 101 101 // biome-ignore lint/a11y/useSemanticElements: Cannot use button inside SVG
+2 -6
apps/desktop/src/components/revision-graph/RevisionRow.tsx
··· 2 2 import { useLiveQuery } from "@tanstack/react-db"; 3 3 import { useNavigate, useSearch } from "@tanstack/react-router"; 4 4 import { Route } from "@/routes/project.$projectId"; 5 - import { focusPanelAtom, viewModeAtom } from "@/atoms"; 5 + import { draggingBookmarkAtom, viewModeAtom } from "@/atoms"; 6 6 import { ChangedFilesList } from "@/components/ChangedFilesList"; 7 7 import { emptyChangesCollection, getRevisionChangesCollection } from "@/db"; 8 8 import type { Revision } from "@/tauri-commands"; ··· 60 60 const search = useSearch({ from: Route.fullPath }); 61 61 const navigate = useNavigate({ from: Route.fullPath }); 62 62 const [viewMode, setViewMode] = useAtom(viewModeAtom); 63 - const [, setFocusPanel] = useAtom(focusPanelAtom); 64 63 65 64 const changedFilesCollection = 66 65 isExpanded && repoPath ··· 69 68 const changedFilesQuery = useLiveQuery(changedFilesCollection); 70 69 71 70 function handleSelectFile(filePath: string) { 72 - // If in overview mode, switch to split mode and focus diff panel 71 + // If in overview mode, switch to split mode 73 72 if (viewMode === 1) { 74 73 setViewMode(2); 75 - setFocusPanel("diff"); 76 74 } 77 75 // Clear expanded state and navigate to file 78 76 navigate({ ··· 107 105 e.preventDefault(); 108 106 window.getSelection()?.removeAllRanges(); 109 107 } 110 - // Set focus to revisions panel when clicking 111 - setFocusPanel("revisions"); 112 108 onSelect(revision.change_id, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 113 109 }} 114 110 onKeyDown={(e) => {
+181 -143
apps/desktop/src/components/revision-graph/index.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { useNavigate, useSearch } from "@tanstack/react-router"; 3 3 import { useVirtualizer } from "@tanstack/react-virtual"; 4 + import type { RefObject } from "react"; 4 5 import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react"; 5 6 import { Route } from "@/routes/project.$projectId"; 6 7 import { ··· 17 18 type RevisionStack, 18 19 } from "@/components/revision-graph-utils"; 19 20 import { prefetchRevisionDiffs } from "@/db"; 21 + import { useFocusWithin } from "@/hooks/useFocusWithin"; 20 22 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 21 23 import { useRevisionGraphNavigation } from "@/hooks/useRevisionGraphNavigation"; 22 24 import type { Revision } from "@/tauri-commands"; ··· 54 56 flash?: { changeId: string; key: number } | null; 55 57 repoPath: string | null; 56 58 pendingAbandon?: Revision | null; 59 + diffPanelRef: RefObject<HTMLElement | null>; 57 60 } 58 61 59 62 // Get the set of commit IDs in the working copy's ancestor chain (for lane 0) ··· 365 368 366 369 export const RevisionGraph = forwardRef<RevisionGraphHandle, RevisionGraphProps>( 367 370 function RevisionGraph( 368 - { revisions, selectedRevision, onSelectRevision, isLoading, flash, repoPath, pendingAbandon }, 371 + { 372 + revisions, 373 + selectedRevision, 374 + onSelectRevision, 375 + isLoading, 376 + flash, 377 + repoPath, 378 + pendingAbandon, 379 + diffPanelRef, 380 + }, 369 381 ref, 370 382 ) { 371 383 const parentRef = useRef<HTMLDivElement>(null); 384 + const containerRef = useRef<HTMLDivElement>(null); 385 + 386 + // Use native focus tracking 387 + const hasFocus = useFocusWithin(containerRef); 372 388 const { 373 389 nodes, 374 390 laneCount, ··· 678 694 scrollToIndex: (index) => scrollToIndexIfNeededRef.current?.(index), 679 695 onToggleStack: handleToggleStack, 680 696 isSelectedExpanded, 697 + hasFocus, 698 + diffPanelRef, 681 699 }); 682 700 683 701 // Toggle debug overlay with Ctrl+Shift+D ··· 713 731 enabled: inlineJumpMode, 714 732 }); 715 733 734 + const COLLAPSED_STACK_HEIGHT = 32; 735 + 716 736 const rowVirtualizer = useVirtualizer({ 717 737 count: displayRows.length, 718 738 getScrollElement: () => parentRef.current, 719 739 estimateSize: (index: number) => { 720 740 const displayRow = displayRows[index]; 721 741 if (displayRow.type === "collapsed-stack") { 722 - // Same height as a regular revision row 723 - return ROW_HEIGHT; 742 + return COLLAPSED_STACK_HEIGHT; 724 743 } 725 744 const row = displayRow.row; 726 745 const isExpanded = ··· 986 1005 987 1006 if (revisions.length === 0) { 988 1007 return ( 989 - <div className="flex items-center justify-center h-full bg-background text-muted-foreground text-sm"> 1008 + <div 1009 + ref={containerRef} 1010 + tabIndex={-1} 1011 + className="flex items-center justify-center h-full bg-background text-muted-foreground text-sm outline-none" 1012 + > 990 1013 {isLoading ? "Loading revisions..." : "Select a project to view revisions"} 991 1014 </div> 992 1015 ); ··· 1005 1028 const graphWidth = LANE_PADDING + laneCount * LANE_WIDTH + NODE_RADIUS + 2; 1006 1029 1007 1030 return ( 1008 - <div 1009 - ref={parentRef} 1010 - className="h-full overflow-auto ascii-bg pt-4" 1011 - style={{ overflowAnchor: "none" }} 1012 - > 1031 + <div ref={containerRef} tabIndex={-1} className="h-full outline-none"> 1013 1032 <div 1014 - className="relative" 1015 - style={{ 1016 - height: `${totalHeight}px`, 1017 - width: "100%", 1018 - }} 1033 + ref={parentRef} 1034 + className="h-full overflow-auto ascii-bg pt-4" 1035 + style={{ overflowAnchor: "none" }} 1019 1036 > 1020 - {/* Edge layer - semantic edge components positioned absolutely */} 1021 - {/* Key includes expandedStacks to force remount when stack state changes */} 1022 - <EdgeLayer 1023 - key={`edges-${[...expandedStacks].sort().join(",")}`} 1024 - bindings={filteredEdgeBindings} 1025 - commitToRow={commitToRowIndex} 1026 - revisionMap={revisionMapByCommitId} 1027 - getRowCenter={getRowCenter} 1028 - totalHeight={totalHeight} 1029 - width={graphWidth} 1030 - visibleStartRow={visibleStartRow} 1031 - visibleEndRow={visibleEndRow} 1032 - stackById={stackById} 1033 - changeIdToCommitId={changeIdToCommitId} 1034 - onToggleStack={handleToggleStack} 1035 - /> 1037 + <div 1038 + className="relative" 1039 + style={{ 1040 + height: `${totalHeight}px`, 1041 + width: "100%", 1042 + }} 1043 + > 1044 + {/* Edge layer - semantic edge components positioned absolutely */} 1045 + {/* Key includes expandedStacks to force remount when stack state changes */} 1046 + <EdgeLayer 1047 + key={`edges-${[...expandedStacks].sort().join(",")}`} 1048 + bindings={filteredEdgeBindings} 1049 + commitToRow={commitToRowIndex} 1050 + revisionMap={revisionMapByCommitId} 1051 + getRowCenter={getRowCenter} 1052 + totalHeight={totalHeight} 1053 + width={graphWidth} 1054 + visibleStartRow={visibleStartRow} 1055 + visibleEndRow={visibleEndRow} 1056 + stackById={stackById} 1057 + changeIdToCommitId={changeIdToCommitId} 1058 + onToggleStack={handleToggleStack} 1059 + /> 1036 1060 1037 - {/* Virtualized rows with inline graph nodes */} 1038 - <div className="relative z-10"> 1039 - {virtualItems.map((virtualRow) => { 1040 - const displayRow = displayRows[virtualRow.index]; 1061 + {/* Virtualized rows with inline graph nodes */} 1062 + <div className="relative z-10"> 1063 + {virtualItems.map((virtualRow) => { 1064 + const displayRow = displayRows[virtualRow.index]; 1041 1065 1042 - // Collapsed stack row - styled as stacked cards 1043 - if (displayRow.type === "collapsed-stack") { 1044 - const { stack, lane } = displayRow; 1045 - const nodeAreaWidth = LANE_PADDING + (lane + 1) * LANE_WIDTH; 1046 - const count = stack.intermediateChangeIds.length; 1066 + // Collapsed stack row - styled as stacked cards 1067 + if (displayRow.type === "collapsed-stack") { 1068 + const { stack, lane } = displayRow; 1069 + const nodeAreaWidth = LANE_PADDING + (lane + 1) * LANE_WIDTH; 1070 + const count = stack.intermediateChangeIds.length; 1047 1071 1048 - // Check if this stack is related to the selected revision (for dimming) 1049 - const isStackRelated = stack.changeIds.some((id) => relatedRevisions.has(id)); 1050 - const isStackDimmed = selectedRevision !== null && !isStackRelated; 1051 - const isStackFocused = focusedStackId === stack.id; 1072 + // Check if this stack is related to the selected revision (for dimming) 1073 + const isStackRelated = stack.changeIds.some((id) => relatedRevisions.has(id)); 1074 + const isStackDimmed = selectedRevision !== null && !isStackRelated; 1075 + const isStackFocused = focusedStackId === stack.id; 1076 + 1077 + return ( 1078 + <div 1079 + key={`collapsed-${stack.id}`} 1080 + ref={rowVirtualizer.measureElement} 1081 + data-index={virtualRow.index} 1082 + className="absolute left-0 w-full" 1083 + style={{ 1084 + transform: `translateY(${virtualRow.start}px)`, 1085 + height: COLLAPSED_STACK_HEIGHT, 1086 + }} 1087 + > 1088 + <div className="flex relative" style={{ height: COLLAPSED_STACK_HEIGHT }}> 1089 + {/* Spacer for graph area */} 1090 + <div className="shrink-0" style={{ width: nodeAreaWidth + 8 }} /> 1091 + <button 1092 + type="button" 1093 + onClick={() => { 1094 + // Focus the stack (similar to keyboard navigation) 1095 + navigate({ 1096 + search: { 1097 + ...search, 1098 + stack: stack.id, 1099 + rev: undefined, 1100 + selected: undefined, 1101 + selectionAnchor: undefined, 1102 + }, 1103 + replace: true, 1104 + }); 1105 + }} 1106 + onDoubleClick={() => handleToggleStack(stack.id)} 1107 + className={`relative flex-1 mr-2 min-w-0 py-1 flex items-center justify-center outline-none border-b ${ 1108 + isStackFocused 1109 + ? "bg-accent/40 rounded-md border-transparent" 1110 + : "border-border/30" 1111 + } ${isStackDimmed ? "opacity-40" : ""}`} 1112 + ref={(el) => { 1113 + // Programmatically focus when stack becomes focused 1114 + if (isStackFocused && el && document.activeElement !== el) { 1115 + el.focus({ preventScroll: true }); 1116 + } 1117 + }} 1118 + data-stack-id={stack.id} 1119 + > 1120 + {/* Content */} 1121 + <div className="flex items-center justify-center gap-2"> 1122 + <svg 1123 + className="w-3.5 h-3.5 text-muted-foreground" 1124 + fill="none" 1125 + viewBox="0 0 24 24" 1126 + stroke="currentColor" 1127 + aria-hidden="true" 1128 + > 1129 + <path 1130 + strokeLinecap="round" 1131 + strokeLinejoin="round" 1132 + strokeWidth={2} 1133 + d="M7 10l5-5 5 5M7 14l5 5 5-5" 1134 + /> 1135 + </svg> 1136 + <span className="text-xs text-muted-foreground"> 1137 + {count} elided revision{count !== 1 ? "s" : ""} 1138 + </span> 1139 + </div> 1140 + </button> 1141 + </div> 1142 + </div> 1143 + ); 1144 + } 1145 + 1146 + // Regular revision row 1147 + const { row } = displayRow; 1148 + const lane = changeIdToLane.get(row.revision.change_id) ?? 0; 1149 + const isFlashing = flash?.changeId === row.revision.change_id; 1150 + const isDimmed = 1151 + (selectedRevision !== null || focusedStackId !== null) && 1152 + !relatedRevisions.has(row.revision.change_id); 1153 + // Only show focus if no stack is focused 1154 + const isFocused = 1155 + !focusedStackId && selectedRevision?.change_id === row.revision.change_id; 1156 + const isSelected = isFocused; 1157 + const isExpanded = isSelectedExpanded && isFocused; 1052 1158 1053 1159 return ( 1054 1160 <div 1055 - key={`collapsed-${stack.id}`} 1161 + key={row.revision.change_id} 1056 1162 ref={rowVirtualizer.measureElement} 1057 1163 data-index={virtualRow.index} 1058 1164 className="absolute left-0 w-full" 1059 1165 style={{ 1060 1166 transform: `translateY(${virtualRow.start}px)`, 1061 - height: ROW_HEIGHT, 1062 1167 }} 1063 1168 > 1064 - <div className="flex relative" style={{ height: ROW_HEIGHT }}> 1065 - {/* Spacer for graph area */} 1066 - <div className="shrink-0" style={{ width: nodeAreaWidth + 8 }} /> 1067 - <button 1068 - type="button" 1069 - onClick={() => handleToggleStack(stack.id)} 1070 - className={`relative flex-1 mr-2 min-w-0 py-1 flex items-center justify-center outline-none border-b ${ 1071 - isStackFocused 1072 - ? "bg-accent/40 rounded-md border-transparent" 1073 - : "border-border/30" 1074 - } ${isStackDimmed ? "opacity-40" : ""}`} 1075 - ref={(el) => { 1076 - // Programmatically focus when stack becomes focused 1077 - if (isStackFocused && el && document.activeElement !== el) { 1078 - el.focus({ preventScroll: true }); 1079 - } 1080 - }} 1081 - data-stack-id={stack.id} 1082 - > 1083 - {/* Content */} 1084 - <div className="flex items-center justify-center gap-2"> 1085 - <svg 1086 - className="w-3.5 h-3.5 text-muted-foreground" 1087 - fill="none" 1088 - viewBox="0 0 24 24" 1089 - stroke="currentColor" 1090 - aria-hidden="true" 1091 - > 1092 - <path 1093 - strokeLinecap="round" 1094 - strokeLinejoin="round" 1095 - strokeWidth={2} 1096 - d="M19 9l-7 7-7-7" 1097 - /> 1098 - </svg> 1099 - <span className="text-xs text-muted-foreground"> 1100 - {count} hidden revision{count !== 1 ? "s" : ""} 1101 - </span> 1102 - </div> 1103 - </button> 1104 - </div> 1169 + <RevisionRow 1170 + revision={row.revision} 1171 + lane={lane} 1172 + maxLaneOnRow={row.maxLaneOnRow} 1173 + isSelected={isSelected} 1174 + isChecked={selectedRevisions.has(row.revision.change_id)} 1175 + isFocused={isFocused} 1176 + onSelect={handleSelect} 1177 + isFlashing={isFlashing} 1178 + isDimmed={isDimmed} 1179 + isExpanded={isExpanded} 1180 + repoPath={repoPath} 1181 + isPendingAbandon={pendingAbandon?.change_id === row.revision.change_id} 1182 + jumpModeActive={inlineJumpMode} 1183 + jumpQuery={inlineJumpQuery ?? ""} 1184 + jumpHint={jumpHintsMap.get(row.revision.change_id) ?? null} 1185 + /> 1105 1186 </div> 1106 1187 ); 1107 - } 1188 + })} 1189 + </div> 1190 + </div> 1108 1191 1109 - // Regular revision row 1110 - const { row } = displayRow; 1111 - const lane = changeIdToLane.get(row.revision.change_id) ?? 0; 1112 - const isFlashing = flash?.changeId === row.revision.change_id; 1113 - const isDimmed = 1114 - (selectedRevision !== null || focusedStackId !== null) && 1115 - !relatedRevisions.has(row.revision.change_id); 1116 - // Only show focus if no stack is focused 1117 - const isFocused = 1118 - !focusedStackId && selectedRevision?.change_id === row.revision.change_id; 1119 - const isSelected = isFocused; 1120 - const isExpanded = isSelectedExpanded && isFocused; 1121 - 1122 - return ( 1123 - <div 1124 - key={row.revision.change_id} 1125 - ref={rowVirtualizer.measureElement} 1126 - data-index={virtualRow.index} 1127 - className="absolute left-0 w-full" 1128 - style={{ 1129 - transform: `translateY(${virtualRow.start}px)`, 1130 - }} 1131 - > 1132 - <RevisionRow 1133 - revision={row.revision} 1134 - lane={lane} 1135 - maxLaneOnRow={row.maxLaneOnRow} 1136 - isSelected={isSelected} 1137 - isChecked={selectedRevisions.has(row.revision.change_id)} 1138 - isFocused={isFocused} 1139 - onSelect={handleSelect} 1140 - isFlashing={isFlashing} 1141 - isDimmed={isDimmed} 1142 - isExpanded={isExpanded} 1143 - repoPath={repoPath} 1144 - isPendingAbandon={pendingAbandon?.change_id === row.revision.change_id} 1145 - jumpModeActive={inlineJumpMode} 1146 - jumpQuery={inlineJumpQuery ?? ""} 1147 - jumpHint={jumpHintsMap.get(row.revision.change_id) ?? null} 1148 - /> 1149 - </div> 1150 - ); 1151 - })} 1152 - </div> 1192 + {/* Debug overlay - toggle with Ctrl+Shift+D */} 1193 + <DebugOverlay 1194 + scrollRef={parentRef} 1195 + selectedIndex={selectedIndex} 1196 + visibleStartRow={visibleStartRow} 1197 + visibleEndRow={visibleEndRow} 1198 + totalRows={rows.length} 1199 + wcIndex={wcIndex} 1200 + selectedChangeId={selectedRevision?.change_id} 1201 + wcChangeId={workingCopy?.change_id} 1202 + /> 1153 1203 </div> 1154 - 1155 - {/* Debug overlay - toggle with Ctrl+Shift+D */} 1156 - <DebugOverlay 1157 - scrollRef={parentRef} 1158 - selectedIndex={selectedIndex} 1159 - visibleStartRow={visibleStartRow} 1160 - visibleEndRow={visibleEndRow} 1161 - totalRows={rows.length} 1162 - wcIndex={wcIndex} 1163 - selectedChangeId={selectedRevision?.change_id} 1164 - wcChangeId={workingCopy?.change_id} 1165 - /> 1166 1204 </div> 1167 1205 ); 1168 1206 },
+1 -1
apps/desktop/src/components/revision-graph/types.ts
··· 19 19 isMissingStub?: boolean; 20 20 /** If set, this edge represents a collapsed stack and clicking it should expand */ 21 21 collapsedStackId?: string; 22 - /** Number of hidden revisions in the collapsed stack */ 22 + /** Number of elided revisions in the collapsed stack */ 23 23 collapsedCount?: number; 24 24 /** If set, this edge is part of an expanded stack and clicking it should collapse */ 25 25 expandedStackId?: string;
+7 -7
apps/desktop/src/hooks/useDiffPanelKeyboard.ts
··· 1 - import { useAtom } from "@effect-atom/atom-react"; 2 1 import type { RefObject } from "react"; 3 - import { focusPanelAtom } from "@/atoms"; 4 2 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 5 3 6 4 const SCROLL_AMOUNT = 100; 7 5 8 6 interface UseDiffPanelKeyboardOptions { 9 7 scrollContainerRef: RefObject<HTMLDivElement | null>; 8 + revisionsPanelRef: RefObject<HTMLElement | null>; 9 + hasFocus: boolean; 10 10 enabled?: boolean; 11 11 } 12 12 ··· 17 17 */ 18 18 export function useDiffPanelKeyboard({ 19 19 scrollContainerRef, 20 + revisionsPanelRef, 21 + hasFocus, 20 22 enabled = true, 21 23 }: UseDiffPanelKeyboardOptions) { 22 - const [focusPanel, setFocusPanel] = useAtom(focusPanelAtom); 23 - const hasDiffFocus = focusPanel === "diff"; 24 - const isEnabled = enabled && hasDiffFocus; 24 + const isEnabled = enabled && hasFocus; 25 25 26 26 // j/k/arrows scroll the diff panel 27 27 useKeyboardShortcut({ ··· 60 60 useKeyboardShortcut({ 61 61 key: "h", 62 62 modifiers: {}, 63 - onPress: () => setFocusPanel("revisions"), 63 + onPress: () => revisionsPanelRef.current?.focus(), 64 64 enabled: isEnabled, 65 65 }); 66 66 67 67 useKeyboardShortcut({ 68 68 key: "ArrowLeft", 69 69 modifiers: {}, 70 - onPress: () => setFocusPanel("revisions"), 70 + onPress: () => revisionsPanelRef.current?.focus(), 71 71 enabled: isEnabled, 72 72 }); 73 73 }
+35
apps/desktop/src/hooks/useFocusWithin.ts
··· 1 + import { useEffect, useState, type RefObject } from "react"; 2 + 3 + /** 4 + * Returns true when focus is within the container element. 5 + * Uses focusin/focusout events for reliable tracking. 6 + */ 7 + export function useFocusWithin(containerRef: RefObject<HTMLElement | null>): boolean { 8 + const [hasFocus, setHasFocus] = useState(false); 9 + 10 + useEffect(() => { 11 + const container = containerRef.current; 12 + if (!container) return; 13 + 14 + const handleFocusIn = () => setHasFocus(true); 15 + const handleFocusOut = (e: FocusEvent) => { 16 + const relatedTarget = e.relatedTarget as Node | null; 17 + if (!relatedTarget || !container.contains(relatedTarget)) { 18 + setHasFocus(false); 19 + } 20 + }; 21 + 22 + container.addEventListener("focusin", handleFocusIn); 23 + container.addEventListener("focusout", handleFocusOut); 24 + setHasFocus(container.contains(document.activeElement)); 25 + 26 + return () => { 27 + container.removeEventListener("focusin", handleFocusIn); 28 + container.removeEventListener("focusout", handleFocusOut); 29 + }; 30 + // containerRef is a stable ref object - we intentionally depend on its .current value 31 + // which is captured at effect time 32 + }, [containerRef]); 33 + 34 + return hasFocus; 35 + }
+27 -24
apps/desktop/src/hooks/useRevisionGraphNavigation.ts
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { useNavigate, useSearch } from "@tanstack/react-router"; 3 + import type { RefObject } from "react"; 3 4 import { useRef } from "react"; 4 5 import { Route } from "@/routes/project.$projectId"; 5 - import { focusPanelAtom, viewModeAtom } from "@/atoms"; 6 + import { viewModeAtom } from "@/atoms"; 6 7 import type { RevisionStack } from "@/components/revision-graph-utils"; 7 8 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 8 9 import type { Revision } from "@/tauri-commands"; ··· 45 46 onToggleStack: (stackId: string) => void; 46 47 /** Check if inline expanded for h/l behavior */ 47 48 isSelectedExpanded?: boolean; 49 + /** Whether the revisions panel has focus */ 50 + hasFocus: boolean; 51 + /** Ref to the diff panel for focus transfer */ 52 + diffPanelRef: RefObject<HTMLElement | null>; 48 53 } 49 54 50 55 /** ··· 70 75 scrollToIndex, 71 76 onToggleStack, 72 77 isSelectedExpanded = false, 78 + hasFocus, 79 + diffPanelRef, 73 80 }: UseRevisionGraphNavigationParams) { 74 81 const navigate = useNavigate({ from: Route.fullPath }); 75 82 const search = useSearch({ from: Route.fullPath }); 76 83 const [viewMode] = useAtom(viewModeAtom); 77 - const [focusPanel, setFocusPanel] = useAtom(focusPanelAtom); 78 84 79 85 // Read focused stack and selection from URL params 80 86 const focusedStackId = useSearch({ from: Route.fullPath, select: (s) => s.stack ?? null }); ··· 89 95 ? new Set(selectedParam.split(",").filter(Boolean)) 90 96 : new Set<string>(); 91 97 const hasSelection = selectedRevisions.size > 0; 92 - 93 - // Diff panel has keyboard focus 94 - const diffPanelHasFocus = focusPanel === "diff"; 95 98 96 99 // Build commit map for parent/child navigation 97 100 const commitMapRef = useRef<Map<string, Revision>>(new Map()); ··· 316 319 navigateToDisplayRow(currentIndex + 1); 317 320 } 318 321 }, 319 - enabled: enabled && !diffPanelHasFocus, 322 + enabled: enabled && hasFocus, 320 323 }); 321 324 322 325 useKeyboardShortcut({ ··· 330 333 navigateToDisplayRow(currentIndex + 1); 331 334 } 332 335 }, 333 - enabled: enabled && !diffPanelHasFocus, 336 + enabled: enabled && hasFocus, 334 337 }); 335 338 336 339 // k / ArrowUp: navigate to previous display row ··· 343 346 navigateToDisplayRow(currentIndex - 1); 344 347 } 345 348 }, 346 - enabled: enabled && !diffPanelHasFocus, 349 + enabled: enabled && hasFocus, 347 350 }); 348 351 349 352 useKeyboardShortcut({ ··· 355 358 navigateToDisplayRow(currentIndex - 1); 356 359 } 357 360 }, 358 - enabled: enabled && !diffPanelHasFocus, 361 + enabled: enabled && hasFocus, 359 362 }); 360 363 361 364 // J: navigate down in working copy chain ··· 363 366 key: "J", 364 367 modifiers: { shift: true }, 365 368 onPress: () => navigateRelated("down"), 366 - enabled: enabled && !diffPanelHasFocus && !!selectedRevision, 369 + enabled: enabled && hasFocus && !!selectedRevision, 367 370 }); 368 371 369 372 // K: navigate up in working copy chain ··· 371 374 key: "K", 372 375 modifiers: { shift: true }, 373 376 onPress: () => navigateRelated("up"), 374 - enabled: enabled && !diffPanelHasFocus && !!selectedRevision, 377 + enabled: enabled && hasFocus && !!selectedRevision, 375 378 }); 376 379 377 380 // Shift+j: extend selection downward ··· 379 382 key: "j", 380 383 modifiers: { shift: true }, 381 384 onPress: () => extendSelection("down"), 382 - enabled: enabled && !diffPanelHasFocus && !!selectedRevision, 385 + enabled: enabled && hasFocus && !!selectedRevision, 383 386 }); 384 387 385 388 // Shift+k: extend selection upward ··· 387 390 key: "k", 388 391 modifiers: { shift: true }, 389 392 onPress: () => extendSelection("up"), 390 - enabled: enabled && !diffPanelHasFocus && !!selectedRevision, 393 + enabled: enabled && hasFocus && !!selectedRevision, 391 394 }); 392 395 393 396 // Shift+ArrowDown: extend selection downward ··· 395 398 key: "ArrowDown", 396 399 modifiers: { shift: true }, 397 400 onPress: () => extendSelection("down"), 398 - enabled: enabled && !diffPanelHasFocus && !!selectedRevision, 401 + enabled: enabled && hasFocus && !!selectedRevision, 399 402 }); 400 403 401 404 // Shift+ArrowUp: extend selection upward ··· 403 406 key: "ArrowUp", 404 407 modifiers: { shift: true }, 405 408 onPress: () => extendSelection("up"), 406 - enabled: enabled && !diffPanelHasFocus && !!selectedRevision, 409 + enabled: enabled && hasFocus && !!selectedRevision, 407 410 }); 408 411 409 412 // G: go to bottom ··· 421 424 } 422 425 } 423 426 }, 424 - enabled: enabled && !diffPanelHasFocus, 427 + enabled: enabled && hasFocus, 425 428 }); 426 429 427 430 // Home: go to top ··· 433 436 navigateToDisplayRow(0); 434 437 } 435 438 }, 436 - enabled: enabled && !diffPanelHasFocus, 439 + enabled: enabled && hasFocus, 437 440 }); 438 441 439 442 // End: go to bottom ··· 450 453 } 451 454 } 452 455 }, 453 - enabled: enabled && !diffPanelHasFocus, 456 + enabled: enabled && hasFocus, 454 457 }); 455 458 456 459 // l / ArrowRight: expand revision (overview) or focus diff panel (split) ··· 459 462 modifiers: {}, 460 463 onPress: () => { 461 464 if (viewMode === 2) { 462 - setFocusPanel("diff"); 465 + diffPanelRef.current?.focus(); 463 466 return; 464 467 } 465 468 if (!selectedRevision) return; ··· 468 471 search: { ...search, expanded: true }, 469 472 }); 470 473 }, 471 - enabled: enabled, 474 + enabled: enabled && hasFocus, 472 475 }); 473 476 474 477 useKeyboardShortcut({ ··· 476 479 modifiers: {}, 477 480 onPress: () => { 478 481 if (viewMode === 2) { 479 - setFocusPanel("diff"); 482 + diffPanelRef.current?.focus(); 480 483 return; 481 484 } 482 485 if (!selectedRevision) return; ··· 485 488 search: { ...search, expanded: true }, 486 489 }); 487 490 }, 488 - enabled: enabled, 491 + enabled: enabled && hasFocus, 489 492 }); 490 493 491 494 // h / ArrowLeft: collapse revision (overview) ··· 503 506 search: { ...search, expanded: undefined }, 504 507 }); 505 508 }, 506 - enabled: enabled, 509 + enabled: enabled && hasFocus, 507 510 }); 508 511 509 512 useKeyboardShortcut({ ··· 519 522 search: { ...search, expanded: undefined }, 520 523 }); 521 524 }, 522 - enabled: enabled, 525 + enabled: enabled && hasFocus, 523 526 }); 524 527 525 528 // Space: toggle check or expand stack
+3 -1
apps/desktop/vite.config.ts
··· 3 3 import react from "@vitejs/plugin-react"; 4 4 import tailwindcss from "@tailwindcss/vite"; 5 5 import agentation from "vite-plugin-agentation"; 6 + import { consoleForwardPlugin } from "./dev/vite-plugin-console-forward"; 6 7 7 8 const host = process.env.TAURI_DEV_HOST; 8 9 ··· 16 17 }, 17 18 }), 18 19 tailwindcss(), 19 - agentation() 20 + agentation(), 21 + consoleForwardPlugin() 20 22 ], 21 23 resolve: { 22 24 alias: {