a very good jj gui
0
fork

Configure Feed

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

feat: extract reusable hooks for keyboard navigation and repository management

+751
+85
apps/desktop/src/hooks/useAddRepository.ts
··· 1 + import { useNavigate } from "@tanstack/react-router"; 2 + import { homeDir } from "@tauri-apps/api/path"; 3 + import { open } from "@tauri-apps/plugin-dialog"; 4 + import { Effect } from "effect"; 5 + import { useState } from "react"; 6 + import { addRepository, repositoriesCollection } from "@/db"; 7 + import { findRepository, findRepositoryByPath, type Repository } from "@/tauri-commands"; 8 + 9 + const openDirectoryDialogEffect = Effect.gen(function* () { 10 + const home = yield* Effect.tryPromise({ 11 + try: () => homeDir(), 12 + catch: (error) => new Error(`Failed to get home directory: ${error}`), 13 + }); 14 + 15 + return yield* Effect.tryPromise({ 16 + try: () => 17 + open({ 18 + directory: true, 19 + multiple: false, 20 + defaultPath: home, 21 + title: "Select Repository", 22 + }), 23 + catch: (error) => new Error(`Failed to open directory dialog: ${error}`), 24 + }); 25 + }); 26 + 27 + const findRepositoryEffect = (startPath: string) => 28 + Effect.tryPromise({ 29 + try: () => findRepository(startPath), 30 + catch: (error) => new Error(`Failed to find repository: ${error}`), 31 + }); 32 + 33 + /** 34 + * Hook for adding a new repository via directory picker dialog. 35 + * Returns a handler that opens the dialog and navigates to the project. 36 + */ 37 + export function useAddRepository() { 38 + const navigate = useNavigate(); 39 + const [isAdding, setIsAdding] = useState(false); 40 + 41 + function handleAddRepository() { 42 + if (isAdding) return; 43 + setIsAdding(true); 44 + 45 + const program = Effect.gen(function* () { 46 + const selected = yield* openDirectoryDialogEffect; 47 + if (!selected) return; 48 + 49 + const repoPath = yield* findRepositoryEffect(selected); 50 + if (!repoPath) return; 51 + 52 + const existingRepository = yield* Effect.tryPromise({ 53 + try: () => findRepositoryByPath(repoPath), 54 + catch: () => null, 55 + }); 56 + 57 + const repositoryId = existingRepository?.id ?? crypto.randomUUID(); 58 + const name = repoPath.split("/").pop() ?? repoPath; 59 + 60 + const repository: Repository = { 61 + id: repositoryId, 62 + path: repoPath, 63 + name, 64 + last_opened_at: Date.now(), 65 + revset_preset: null, 66 + }; 67 + 68 + yield* Effect.tryPromise({ 69 + try: () => addRepository(repositoriesCollection, repository), 70 + catch: (error) => new Error(`Failed to save repository: ${error}`), 71 + }); 72 + 73 + yield* Effect.sync(() => { 74 + navigate({ to: "/project/$projectId", params: { projectId: repositoryId } }); 75 + }); 76 + }).pipe( 77 + Effect.tapError((error) => Effect.logError("handleAddRepository failed", error)), 78 + Effect.catchAll(() => Effect.void), 79 + ); 80 + 81 + Effect.runPromise(program).finally(() => setIsAdding(false)); 82 + } 83 + 84 + return { handleAddRepository, isAdding }; 85 + }
+14
apps/desktop/src/hooks/useAppTitle.ts
··· 1 + import { getCurrentWindow } from "@tauri-apps/api/window"; 2 + import { useEffect } from "react"; 3 + 4 + /** 5 + * Syncs the document title and Tauri window title. 6 + * Updates both whenever the title changes. 7 + */ 8 + export function useAppTitle(title: string) { 9 + useEffect(() => { 10 + document.title = title; 11 + const windowHandle = getCurrentWindow(); 12 + windowHandle.setTitle(title).catch(() => undefined); 13 + }, [title]); 14 + }
+73
apps/desktop/src/hooks/useDiffPanelKeyboard.ts
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import type { RefObject } from "react"; 3 + import { focusPanelAtom } from "@/atoms"; 4 + import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 5 + 6 + const SCROLL_AMOUNT = 100; 7 + 8 + interface UseDiffPanelKeyboardOptions { 9 + scrollContainerRef: RefObject<HTMLDivElement | null>; 10 + enabled?: boolean; 11 + } 12 + 13 + /** 14 + * Hook for diff panel keyboard navigation. 15 + * - j/k/ArrowDown/ArrowUp: scroll the diff panel 16 + * - h/ArrowLeft: move focus back to revisions panel 17 + */ 18 + export function useDiffPanelKeyboard({ 19 + scrollContainerRef, 20 + enabled = true, 21 + }: UseDiffPanelKeyboardOptions) { 22 + const [focusPanel, setFocusPanel] = useAtom(focusPanelAtom); 23 + const hasDiffFocus = focusPanel === "diff"; 24 + const isEnabled = enabled && hasDiffFocus; 25 + 26 + // j/k/arrows scroll the diff panel 27 + useKeyboardShortcut({ 28 + key: "j", 29 + modifiers: {}, 30 + onPress: () => 31 + scrollContainerRef.current?.scrollBy({ top: SCROLL_AMOUNT, behavior: "instant" }), 32 + enabled: isEnabled, 33 + }); 34 + 35 + useKeyboardShortcut({ 36 + key: "ArrowDown", 37 + modifiers: {}, 38 + onPress: () => 39 + scrollContainerRef.current?.scrollBy({ top: SCROLL_AMOUNT, behavior: "instant" }), 40 + enabled: isEnabled, 41 + }); 42 + 43 + useKeyboardShortcut({ 44 + key: "k", 45 + modifiers: {}, 46 + onPress: () => 47 + scrollContainerRef.current?.scrollBy({ top: -SCROLL_AMOUNT, behavior: "instant" }), 48 + enabled: isEnabled, 49 + }); 50 + 51 + useKeyboardShortcut({ 52 + key: "ArrowUp", 53 + modifiers: {}, 54 + onPress: () => 55 + scrollContainerRef.current?.scrollBy({ top: -SCROLL_AMOUNT, behavior: "instant" }), 56 + enabled: isEnabled, 57 + }); 58 + 59 + // h/ArrowLeft to move focus back to revisions panel 60 + useKeyboardShortcut({ 61 + key: "h", 62 + modifiers: {}, 63 + onPress: () => setFocusPanel("revisions"), 64 + enabled: isEnabled, 65 + }); 66 + 67 + useKeyboardShortcut({ 68 + key: "ArrowLeft", 69 + modifiers: {}, 70 + onPress: () => setFocusPanel("revisions"), 71 + enabled: isEnabled, 72 + }); 73 + }
+579
apps/desktop/src/hooks/useRevisionGraphNavigation.ts
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import { useNavigate, useSearch } from "@tanstack/react-router"; 3 + import { useRef } from "react"; 4 + import { Route } from "@/routes/project.$projectId"; 5 + import { focusPanelAtom, viewModeAtom } from "@/atoms"; 6 + import type { RevisionStack } from "@/components/revision-graph-utils"; 7 + import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 8 + import type { Revision } from "@/tauri-commands"; 9 + 10 + // Display row can be either a revision row or a collapsed stack row 11 + // These match the types used in RevisionGraph component 12 + interface GraphRow { 13 + revision: Revision; 14 + lane: number; 15 + maxLaneOnRow: number; 16 + } 17 + 18 + interface RevisionDisplayRow { 19 + type: "revision"; 20 + row: GraphRow; 21 + } 22 + 23 + interface CollapsedStackDisplayRow { 24 + type: "collapsed-stack"; 25 + stack: RevisionStack; 26 + lane: number; 27 + } 28 + 29 + type DisplayRow = RevisionDisplayRow | CollapsedStackDisplayRow; 30 + 31 + interface UseRevisionGraphNavigationParams { 32 + /** All revisions in display order */ 33 + revisions: Revision[]; 34 + /** Display rows (revisions + collapsed stacks) */ 35 + displayRows: DisplayRow[]; 36 + /** Map of change_id -> display row index */ 37 + changeIdToIndex: Map<string, number>; 38 + /** Currently selected revision */ 39 + selectedRevision: Revision | null; 40 + /** Whether navigation is enabled (disable during jump mode) */ 41 + enabled: boolean; 42 + /** Called to scroll a display index into view */ 43 + scrollToIndex: (index: number) => void; 44 + /** Handler for expanding/collapsing stacks */ 45 + onToggleStack: (stackId: string) => void; 46 + /** Check if inline expanded for h/l behavior */ 47 + isSelectedExpanded?: boolean; 48 + } 49 + 50 + /** 51 + * Hook that handles all keyboard navigation for the revision graph. 52 + * 53 + * Handles: 54 + * - j/k navigation (move up/down through display rows) 55 + * - J/K navigation (jump between related revisions on working copy chain) 56 + * - Arrow key navigation 57 + * - g/G (go to top/bottom) 58 + * - Home/End 59 + * - Shift+navigation for range selection 60 + * - h/l for expand/collapse 61 + * - Space/Enter for stack toggle / revision check 62 + * - Escape to clear selection 63 + */ 64 + export function useRevisionGraphNavigation({ 65 + revisions, 66 + displayRows, 67 + changeIdToIndex, 68 + selectedRevision, 69 + enabled, 70 + scrollToIndex, 71 + onToggleStack, 72 + isSelectedExpanded = false, 73 + }: UseRevisionGraphNavigationParams) { 74 + const navigate = useNavigate({ from: Route.fullPath }); 75 + const search = useSearch({ from: Route.fullPath }); 76 + const [viewMode] = useAtom(viewModeAtom); 77 + const [focusPanel, setFocusPanel] = useAtom(focusPanelAtom); 78 + 79 + // Read focused stack and selection from URL params 80 + const focusedStackId = useSearch({ from: Route.fullPath, select: (s) => s.stack ?? null }); 81 + const selectedParam = useSearch({ from: Route.fullPath, select: (s) => s.selected ?? "" }); 82 + const selectionAnchor = useSearch({ 83 + from: Route.fullPath, 84 + select: (s) => s.selectionAnchor ?? null, 85 + }); 86 + 87 + // Parse selected revisions from URL param 88 + const selectedRevisions = selectedParam 89 + ? new Set(selectedParam.split(",").filter(Boolean)) 90 + : new Set<string>(); 91 + const hasSelection = selectedRevisions.size > 0; 92 + 93 + // Diff panel has keyboard focus 94 + const diffPanelHasFocus = focusPanel === "diff"; 95 + 96 + // Build commit map for parent/child navigation 97 + const commitMapRef = useRef<Map<string, Revision>>(new Map()); 98 + commitMapRef.current = new Map(revisions.map((r) => [r.commit_id, r])); 99 + 100 + // Get working copy chain for J/K navigation 101 + const workingCopyChainRef = useRef<Set<string>>(new Set()); 102 + workingCopyChainRef.current = getWorkingCopyChain(revisions); 103 + 104 + // ---------------------------------------------------------- 105 + // Helper functions 106 + // ---------------------------------------------------------- 107 + 108 + // Get current focused index in displayRows 109 + const getCurrentDisplayIndex = (): number => { 110 + if (focusedStackId) { 111 + return displayRows.findIndex( 112 + (row) => row.type === "collapsed-stack" && row.stack.id === focusedStackId, 113 + ); 114 + } 115 + if (selectedRevision) { 116 + return displayRows.findIndex( 117 + (row) => 118 + row.type === "revision" && row.row.revision.change_id === selectedRevision.change_id, 119 + ); 120 + } 121 + return -1; 122 + }; 123 + 124 + // Navigate to a display row (revision or collapsed stack) 125 + // Clears selection and anchor (regular navigation without shift) 126 + const navigateToDisplayRow = (index: number) => { 127 + const displayRow = displayRows[index]; 128 + if (!displayRow) return; 129 + 130 + if (displayRow.type === "revision") { 131 + navigate({ 132 + search: { 133 + ...search, 134 + stack: undefined, 135 + rev: displayRow.row.revision.change_id, 136 + selected: undefined, 137 + selectionAnchor: undefined, 138 + }, 139 + replace: true, 140 + }); 141 + } else if (displayRow.type === "collapsed-stack") { 142 + navigate({ 143 + search: { 144 + ...search, 145 + stack: displayRow.stack.id, 146 + rev: undefined, 147 + selected: undefined, 148 + selectionAnchor: undefined, 149 + }, 150 + replace: true, 151 + }); 152 + } 153 + 154 + scrollToIndex(index); 155 + }; 156 + 157 + // Extend selection in a direction (macOS-style anchor-based selection) 158 + const extendSelection = (direction: "down" | "up") => { 159 + if (!selectedRevision) return; 160 + const currentIndex = changeIdToIndex.get(selectedRevision.change_id); 161 + if (currentIndex === undefined) return; 162 + 163 + const step = direction === "down" ? 1 : -1; 164 + const limit = direction === "down" ? displayRows.length : -1; 165 + 166 + // Find the next revision in the given direction 167 + let targetChangeId: string | null = null; 168 + let targetIndex: number | null = null; 169 + for (let i = currentIndex + step; direction === "down" ? i < limit : i > limit; i += step) { 170 + const displayRow = displayRows[i]; 171 + if (displayRow.type === "revision") { 172 + targetChangeId = displayRow.row.revision.change_id; 173 + targetIndex = i; 174 + break; 175 + } 176 + } 177 + 178 + if (!targetChangeId || targetIndex === null) return; 179 + 180 + // Determine anchor: use existing anchor or set it to current position 181 + const anchorChangeId = selectionAnchor ?? selectedRevision.change_id; 182 + const anchorIndex = changeIdToIndex.get(anchorChangeId); 183 + if (anchorIndex === undefined) return; 184 + 185 + // Select all revisions between anchor and target (inclusive) 186 + const startIndex = Math.min(anchorIndex, targetIndex); 187 + const endIndex = Math.max(anchorIndex, targetIndex); 188 + const newSelection = new Set<string>(); 189 + for (let i = startIndex; i <= endIndex; i++) { 190 + const displayRow = displayRows[i]; 191 + if (displayRow.type === "revision") { 192 + newSelection.add(displayRow.row.revision.change_id); 193 + } 194 + } 195 + 196 + const selected = [...newSelection].join(","); 197 + navigate({ 198 + search: { 199 + ...search, 200 + selected, 201 + selectionAnchor: anchorChangeId, 202 + rev: targetChangeId, 203 + stack: undefined, 204 + }, 205 + replace: true, 206 + }); 207 + 208 + scrollToIndex(targetIndex); 209 + }; 210 + 211 + // Navigate in related revisions (working copy chain) - for J/K 212 + const navigateRelated = (direction: "down" | "up") => { 213 + if (!selectedRevision) return; 214 + 215 + const chain = workingCopyChainRef.current; 216 + 217 + // Find revisions on the working copy chain 218 + const chainRevisions = displayRows 219 + .filter((row): row is RevisionDisplayRow => row.type === "revision") 220 + .filter((row) => chain.has(row.row.revision.commit_id)); 221 + 222 + if (chainRevisions.length === 0) return; 223 + 224 + // Find current position in chain 225 + const currentChainIndex = chainRevisions.findIndex( 226 + (row) => row.row.revision.change_id === selectedRevision.change_id, 227 + ); 228 + 229 + let targetRevision: Revision | null = null; 230 + 231 + if (currentChainIndex === -1) { 232 + // Not on chain - jump to nearest chain revision 233 + const currentDisplayIndex = getCurrentDisplayIndex(); 234 + if (direction === "down") { 235 + // Find first chain revision below 236 + for (let i = currentDisplayIndex + 1; i < displayRows.length; i++) { 237 + const displayRow = displayRows[i]; 238 + if (displayRow.type === "revision" && chain.has(displayRow.row.revision.commit_id)) { 239 + targetRevision = displayRow.row.revision; 240 + break; 241 + } 242 + } 243 + } else { 244 + // Find first chain revision above 245 + for (let i = currentDisplayIndex - 1; i >= 0; i--) { 246 + const displayRow = displayRows[i]; 247 + if (displayRow.type === "revision" && chain.has(displayRow.row.revision.commit_id)) { 248 + targetRevision = displayRow.row.revision; 249 + break; 250 + } 251 + } 252 + } 253 + } else { 254 + // On chain - move to next/prev in chain 255 + if (direction === "down" && currentChainIndex < chainRevisions.length - 1) { 256 + targetRevision = chainRevisions[currentChainIndex + 1].row.revision; 257 + } else if (direction === "up" && currentChainIndex > 0) { 258 + targetRevision = chainRevisions[currentChainIndex - 1].row.revision; 259 + } 260 + } 261 + 262 + if (targetRevision) { 263 + const targetIndex = changeIdToIndex.get(targetRevision.change_id); 264 + if (targetIndex !== undefined) { 265 + navigateToDisplayRow(targetIndex); 266 + } 267 + } 268 + }; 269 + 270 + // Toggle revision check state 271 + const toggleRevisionCheck = (changeId: string) => { 272 + const next = new Set(selectedRevisions); 273 + if (next.has(changeId)) { 274 + next.delete(changeId); 275 + } else { 276 + next.add(changeId); 277 + } 278 + const selected = next.size > 0 ? [...next].join(",") : undefined; 279 + navigate({ 280 + search: { ...search, selected }, 281 + replace: true, 282 + }); 283 + }; 284 + 285 + // ---------------------------------------------------------- 286 + // Keyboard shortcuts 287 + // ---------------------------------------------------------- 288 + 289 + // Clear selection with Escape 290 + useKeyboardShortcut({ 291 + key: "Escape", 292 + modifiers: {}, 293 + onPress: () => { 294 + navigate({ 295 + search: { 296 + ...search, 297 + selected: undefined, 298 + selectionAnchor: undefined, 299 + stack: undefined, 300 + }, 301 + replace: true, 302 + }); 303 + }, 304 + enabled: (hasSelection || !!focusedStackId) && enabled, 305 + }); 306 + 307 + // j / ArrowDown: navigate to next display row 308 + useKeyboardShortcut({ 309 + key: "j", 310 + modifiers: {}, 311 + onPress: () => { 312 + const currentIndex = getCurrentDisplayIndex(); 313 + if (currentIndex < 0) { 314 + if (displayRows.length > 0) navigateToDisplayRow(0); 315 + } else if (currentIndex < displayRows.length - 1) { 316 + navigateToDisplayRow(currentIndex + 1); 317 + } 318 + }, 319 + enabled: enabled && !diffPanelHasFocus, 320 + }); 321 + 322 + useKeyboardShortcut({ 323 + key: "ArrowDown", 324 + modifiers: {}, 325 + onPress: () => { 326 + const currentIndex = getCurrentDisplayIndex(); 327 + if (currentIndex < 0) { 328 + if (displayRows.length > 0) navigateToDisplayRow(0); 329 + } else if (currentIndex < displayRows.length - 1) { 330 + navigateToDisplayRow(currentIndex + 1); 331 + } 332 + }, 333 + enabled: enabled && !diffPanelHasFocus, 334 + }); 335 + 336 + // k / ArrowUp: navigate to previous display row 337 + useKeyboardShortcut({ 338 + key: "k", 339 + modifiers: {}, 340 + onPress: () => { 341 + const currentIndex = getCurrentDisplayIndex(); 342 + if (currentIndex > 0) { 343 + navigateToDisplayRow(currentIndex - 1); 344 + } 345 + }, 346 + enabled: enabled && !diffPanelHasFocus, 347 + }); 348 + 349 + useKeyboardShortcut({ 350 + key: "ArrowUp", 351 + modifiers: {}, 352 + onPress: () => { 353 + const currentIndex = getCurrentDisplayIndex(); 354 + if (currentIndex > 0) { 355 + navigateToDisplayRow(currentIndex - 1); 356 + } 357 + }, 358 + enabled: enabled && !diffPanelHasFocus, 359 + }); 360 + 361 + // J: navigate down in working copy chain 362 + useKeyboardShortcut({ 363 + key: "J", 364 + modifiers: { shift: true }, 365 + onPress: () => navigateRelated("down"), 366 + enabled: enabled && !diffPanelHasFocus && !!selectedRevision, 367 + }); 368 + 369 + // K: navigate up in working copy chain 370 + useKeyboardShortcut({ 371 + key: "K", 372 + modifiers: { shift: true }, 373 + onPress: () => navigateRelated("up"), 374 + enabled: enabled && !diffPanelHasFocus && !!selectedRevision, 375 + }); 376 + 377 + // Shift+j: extend selection downward 378 + useKeyboardShortcut({ 379 + key: "j", 380 + modifiers: { shift: true }, 381 + onPress: () => extendSelection("down"), 382 + enabled: enabled && !diffPanelHasFocus && !!selectedRevision, 383 + }); 384 + 385 + // Shift+k: extend selection upward 386 + useKeyboardShortcut({ 387 + key: "k", 388 + modifiers: { shift: true }, 389 + onPress: () => extendSelection("up"), 390 + enabled: enabled && !diffPanelHasFocus && !!selectedRevision, 391 + }); 392 + 393 + // Shift+ArrowDown: extend selection downward 394 + useKeyboardShortcut({ 395 + key: "ArrowDown", 396 + modifiers: { shift: true }, 397 + onPress: () => extendSelection("down"), 398 + enabled: enabled && !diffPanelHasFocus && !!selectedRevision, 399 + }); 400 + 401 + // Shift+ArrowUp: extend selection upward 402 + useKeyboardShortcut({ 403 + key: "ArrowUp", 404 + modifiers: { shift: true }, 405 + onPress: () => extendSelection("up"), 406 + enabled: enabled && !diffPanelHasFocus && !!selectedRevision, 407 + }); 408 + 409 + // G: go to bottom 410 + useKeyboardShortcut({ 411 + key: "G", 412 + modifiers: { shift: true }, 413 + onPress: () => { 414 + if (displayRows.length > 0) { 415 + // Find the last revision row 416 + for (let i = displayRows.length - 1; i >= 0; i--) { 417 + if (displayRows[i].type === "revision") { 418 + navigateToDisplayRow(i); 419 + break; 420 + } 421 + } 422 + } 423 + }, 424 + enabled: enabled && !diffPanelHasFocus, 425 + }); 426 + 427 + // Home: go to top 428 + useKeyboardShortcut({ 429 + key: "Home", 430 + modifiers: {}, 431 + onPress: () => { 432 + if (displayRows.length > 0) { 433 + navigateToDisplayRow(0); 434 + } 435 + }, 436 + enabled: enabled && !diffPanelHasFocus, 437 + }); 438 + 439 + // End: go to bottom 440 + useKeyboardShortcut({ 441 + key: "End", 442 + modifiers: {}, 443 + onPress: () => { 444 + if (displayRows.length > 0) { 445 + for (let i = displayRows.length - 1; i >= 0; i--) { 446 + if (displayRows[i].type === "revision") { 447 + navigateToDisplayRow(i); 448 + break; 449 + } 450 + } 451 + } 452 + }, 453 + enabled: enabled && !diffPanelHasFocus, 454 + }); 455 + 456 + // l / ArrowRight: expand revision (overview) or focus diff panel (split) 457 + useKeyboardShortcut({ 458 + key: "l", 459 + modifiers: {}, 460 + onPress: () => { 461 + if (viewMode === 2) { 462 + setFocusPanel("diff"); 463 + return; 464 + } 465 + if (!selectedRevision) return; 466 + if (isSelectedExpanded) return; 467 + navigate({ 468 + search: { ...search, expanded: true }, 469 + }); 470 + }, 471 + enabled: enabled, 472 + }); 473 + 474 + useKeyboardShortcut({ 475 + key: "ArrowRight", 476 + modifiers: {}, 477 + onPress: () => { 478 + if (viewMode === 2) { 479 + setFocusPanel("diff"); 480 + return; 481 + } 482 + if (!selectedRevision) return; 483 + if (isSelectedExpanded) return; 484 + navigate({ 485 + search: { ...search, expanded: true }, 486 + }); 487 + }, 488 + enabled: enabled, 489 + }); 490 + 491 + // h / ArrowLeft: collapse revision (overview) 492 + useKeyboardShortcut({ 493 + key: "h", 494 + modifiers: {}, 495 + onPress: () => { 496 + if (viewMode === 2) { 497 + // In split mode, h in revision panel does nothing 498 + return; 499 + } 500 + if (!selectedRevision) return; 501 + if (!isSelectedExpanded) return; 502 + navigate({ 503 + search: { ...search, expanded: undefined }, 504 + }); 505 + }, 506 + enabled: enabled, 507 + }); 508 + 509 + useKeyboardShortcut({ 510 + key: "ArrowLeft", 511 + modifiers: {}, 512 + onPress: () => { 513 + if (viewMode === 2) { 514 + return; 515 + } 516 + if (!selectedRevision) return; 517 + if (!isSelectedExpanded) return; 518 + navigate({ 519 + search: { ...search, expanded: undefined }, 520 + }); 521 + }, 522 + enabled: enabled, 523 + }); 524 + 525 + // Space: toggle check or expand stack 526 + useKeyboardShortcut({ 527 + key: " ", 528 + modifiers: {}, 529 + onPress: () => { 530 + if (focusedStackId) { 531 + onToggleStack(focusedStackId); 532 + } else if (selectedRevision) { 533 + toggleRevisionCheck(selectedRevision.change_id); 534 + } 535 + }, 536 + enabled: enabled, 537 + }); 538 + 539 + // Enter: expand focused stack 540 + useKeyboardShortcut({ 541 + key: "Enter", 542 + modifiers: {}, 543 + onPress: () => { 544 + if (focusedStackId) { 545 + onToggleStack(focusedStackId); 546 + } 547 + }, 548 + enabled: enabled && !!focusedStackId, 549 + }); 550 + } 551 + 552 + /** 553 + * Get the set of commit IDs in the working copy's ancestor chain. 554 + * Used for J/K navigation to jump between related revisions. 555 + */ 556 + function getWorkingCopyChain(revisions: Revision[]): Set<string> { 557 + const commitMap = new Map(revisions.map((r) => [r.commit_id, r])); 558 + const workingCopy = revisions.find((r) => r.is_working_copy); 559 + const chain = new Set<string>(); 560 + 561 + if (workingCopy) { 562 + const queue = [workingCopy.commit_id]; 563 + while (queue.length > 0) { 564 + const id = queue.shift(); 565 + if (!id || chain.has(id)) continue; 566 + chain.add(id); 567 + const rev = commitMap.get(id); 568 + if (rev) { 569 + // Follow first non-missing parent edge for the main chain 570 + const firstEdge = rev.parent_edges.find((e) => e.edge_type !== "missing"); 571 + if (firstEdge && commitMap.has(firstEdge.parent_id)) { 572 + queue.push(firstEdge.parent_id); 573 + } 574 + } 575 + } 576 + } 577 + 578 + return chain; 579 + }