a very good jj gui
0
fork

Configure Feed

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

feat: add split view focus management with arrow key navigation

Add focusPanelAtom to track which panel has focus in split view mode.
- l/ArrowRight in revision graph moves focus to diff panel
- h/ArrowLeft in diff panel moves focus back to revisions
- j/k navigation in diff panel cycles through changed files
- Visual ring indicator shows which panel has focus

Also adds hoveredStackIdAtom for coordinated stack edge highlighting.

+126 -30
+6
apps/desktop/src/atoms.ts
··· 8 8 // View mode: 1 = overview (only revisions), 2 = split (revisions + diff panel) 9 9 export type ViewMode = 1 | 2; 10 10 export const viewModeAtom = Atom.make<ViewMode>(1); 11 + // Panel focus tracking for split view (viewMode=2) 12 + // "revisions" = left panel (revision graph), "diff" = right panel (diff viewer) 13 + export type FocusPanel = "revisions" | "diff"; 14 + export const focusPanelAtom = Atom.make<FocusPanel>("revisions"); 11 15 // Tracks which revision stacks are expanded (by stack ID) 12 16 export const expandedStacksAtom = Atom.make(new Set<string>()); 17 + // Tracks which stack is currently hovered (for coordinated edge highlighting) 18 + export const hoveredStackIdAtom = Atom.make<string | null>(null); 13 19 14 20 // Diff panel state 15 21 export type DiffStyle = "unified" | "split";
+4 -5
apps/desktop/src/components/ChangedFilesList.tsx
··· 64 64 return ( 65 65 <div 66 66 className={cn( 67 - "flex items-center gap-2 w-full px-3 py-1.5 text-left transition-colors cursor-pointer group", 68 - "hover:bg-muted/50", 69 - isFocused && "bg-muted text-foreground", 67 + "flex items-center gap-2 px-3 py-1.5 text-left transition-colors cursor-pointer group", 68 + isFocused ? "bg-muted text-foreground" : "hover:bg-muted/50", 70 69 )} 71 70 data-focused={isFocused || undefined} 72 71 data-checked={isChecked || undefined} ··· 167 166 const selectedCount = selectedFiles?.size ?? 0; 168 167 169 168 return ( 170 - <div className="flex flex-col"> 169 + <div> 171 170 <div className="px-3 py-2 border-b border-border flex items-center justify-between"> 172 171 <span className="text-xs font-semibold text-muted-foreground"> 173 172 {filesCount} {fileWord} changed ··· 176 175 <span className="text-xs text-primary font-medium">{selectedCount} selected</span> 177 176 )} 178 177 </div> 179 - <div className="flex flex-col"> 178 + <div> 180 179 {files.map((file) => ( 181 180 <FileListItem 182 181 key={file.path}
+116 -25
apps/desktop/src/components/DiffPanel.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { PatchDiff } from "@pierre/diffs/react"; 3 3 import { useLiveQuery } from "@tanstack/react-db"; 4 - import { useSearch } from "@tanstack/react-router"; 4 + import { useNavigate, useSearch } from "@tanstack/react-router"; 5 5 import { 6 6 ChevronDownIcon, 7 7 ChevronRightIcon, ··· 16 16 diffStyleAtom, 17 17 expandedDiffFilesAtom, 18 18 fileDiffStyleOverridesAtom, 19 + focusPanelAtom, 19 20 } from "@/atoms"; 21 + import { ChangedFilesList } from "@/components/ChangedFilesList"; 20 22 import { Button } from "@/components/ui/button"; 21 23 import { Separator } from "@/components/ui/separator"; 22 - import { emptyDiffCollection, getRevisionDiffCollection } from "@/db"; 24 + import { 25 + emptyChangesCollection, 26 + emptyDiffCollection, 27 + getRevisionChangesCollection, 28 + getRevisionDiffCollection, 29 + } from "@/db"; 30 + import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 23 31 import type { Revision } from "@/tauri-commands"; 24 32 25 33 interface DiffPanelProps { ··· 117 125 data-file-path={filePath} 118 126 > 119 127 <div 120 - className={`flex items-center gap-2 px-2 py-1.5 border-b ${ 121 - isSelected ? "bg-accent border-accent-foreground" : "bg-muted border-border" 128 + className={`flex items-center gap-2 px-2 py-1.5 border-b cursor-pointer transition-colors ${ 129 + isSelected 130 + ? "bg-accent border-accent-foreground" 131 + : "bg-muted border-border hover:bg-accent/50" 122 132 }`} 133 + onClick={handleToggleCollapse} 123 134 > 124 - {/* Collapse toggle button - covers left side */} 125 - <button 126 - type="button" 127 - onClick={handleToggleCollapse} 128 - className="flex items-center gap-2 flex-1 min-w-0 hover:bg-accent/50 -m-1.5 -ml-2 p-1.5 pl-2 rounded-l transition-colors" 129 - > 130 - <span className="text-muted-foreground shrink-0"> 131 - {isCollapsed ? ( 132 - <ChevronRightIcon className="size-4" /> 133 - ) : ( 134 - <ChevronDownIcon className="size-4" /> 135 - )} 136 - </span> 137 - <code className="font-mono text-xs text-foreground text-left flex-1 truncate"> 138 - {filePath} 139 - </code> 140 - </button> 135 + {/* Collapse toggle - left side */} 136 + <span className="text-muted-foreground shrink-0"> 137 + {isCollapsed ? ( 138 + <ChevronRightIcon className="size-4" /> 139 + ) : ( 140 + <ChevronDownIcon className="size-4" /> 141 + )} 142 + </span> 143 + <code className="font-mono text-xs text-foreground text-left flex-1 truncate min-w-0"> 144 + {filePath} 145 + </code> 141 146 142 147 {/* Per-file diff style toggle buttons */} 143 - <div className="flex items-center gap-0.5"> 148 + <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}> 144 149 <Button 145 150 variant={effectiveDiffStyle === "unified" ? "secondary" : "ghost"} 146 151 size="icon-xs" ··· 223 228 } 224 229 225 230 export function DiffPanel({ repoPath, changeId, revision }: DiffPanelProps) { 226 - const { file: selectedFilePath } = useSearch({ strict: false }); 231 + const navigate = useNavigate(); 232 + const search = useSearch({ strict: false }); 233 + const { file: selectedFilePath } = search; 227 234 const fileRefsMap = useRef<Map<string, React.RefObject<HTMLDivElement | null>>>(new Map()); 228 235 const [expandedFiles, setExpandedFiles] = useAtom(expandedDiffFilesAtom); 229 236 const [, setStyleOverrides] = useAtom(fileDiffStyleOverridesAtom); 230 237 const lastChangeIdRef = useRef<string | null>(null); 238 + const [focusPanel, setFocusPanel] = useAtom(focusPanelAtom); 239 + 240 + // Fetch changed files for the file list 241 + const changesCollection = 242 + repoPath && changeId 243 + ? getRevisionChangesCollection(repoPath, changeId) 244 + : emptyChangesCollection; 245 + const { data: changedFiles = [], isLoading: filesLoading } = useLiveQuery(changesCollection); 246 + 247 + // j/k navigation when diff panel has focus 248 + useKeyboardShortcut({ 249 + key: "j", 250 + modifiers: {}, 251 + onPress: () => { 252 + if (changedFiles.length === 0) return; 253 + const filePaths = changedFiles.map((f) => f.path); 254 + const currentIndex = selectedFilePath ? filePaths.indexOf(selectedFilePath) : -1; 255 + const nextIndex = currentIndex + 1; 256 + if (nextIndex < filePaths.length) { 257 + // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 258 + navigate({ search: { ...search, file: filePaths[nextIndex] } as any }); 259 + } else if (currentIndex === -1) { 260 + // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 261 + navigate({ search: { ...search, file: filePaths[0] } as any }); 262 + } 263 + }, 264 + enabled: focusPanel === "diff", 265 + }); 266 + 267 + useKeyboardShortcut({ 268 + key: "k", 269 + modifiers: {}, 270 + onPress: () => { 271 + if (changedFiles.length === 0) return; 272 + const filePaths = changedFiles.map((f) => f.path); 273 + const currentIndex = selectedFilePath ? filePaths.indexOf(selectedFilePath) : -1; 274 + if (currentIndex > 0) { 275 + // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 276 + navigate({ search: { ...search, file: filePaths[currentIndex - 1] } as any }); 277 + } else if (currentIndex === -1) { 278 + // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 279 + navigate({ search: { ...search, file: filePaths[filePaths.length - 1] } as any }); 280 + } 281 + }, 282 + enabled: focusPanel === "diff", 283 + }); 284 + 285 + // Handle h/ArrowLeft to move focus back to revisions panel 286 + useKeyboardShortcut({ 287 + key: "h", 288 + modifiers: {}, 289 + onPress: () => { 290 + if (focusPanel === "diff") { 291 + setFocusPanel("revisions"); 292 + } 293 + }, 294 + enabled: focusPanel === "diff", 295 + }); 296 + 297 + useKeyboardShortcut({ 298 + key: "ArrowLeft", 299 + modifiers: {}, 300 + onPress: () => { 301 + if (focusPanel === "diff") { 302 + setFocusPanel("revisions"); 303 + } 304 + }, 305 + enabled: focusPanel === "diff", 306 + }); 231 307 232 308 // Always fetch all diffs 233 309 const diffCollection = ··· 281 357 } 282 358 } 283 359 360 + function handleSelectFile(filePath: string) { 361 + // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 362 + navigate({ search: { ...search, file: filePath } as any }); 363 + } 364 + 284 365 // Scroll to selected file when it changes 285 366 useEffect(() => { 286 367 if (selectedFilePath && fileRefsMap.current.has(selectedFilePath)) { ··· 319 400 } 320 401 321 402 return ( 322 - <div className="h-full overflow-auto bg-background"> 403 + <div 404 + className={`h-full overflow-auto bg-background ${focusPanel === "diff" ? "ring-2 ring-inset ring-accent" : ""}`} 405 + > 323 406 {revision && ( 324 - <div className="p-4 pb-0"> 407 + <div className="p-4 pb-0 space-y-3"> 325 408 <RevisionHeader revision={revision} /> 409 + <div className="border border-border rounded-lg overflow-hidden bg-background"> 410 + <ChangedFilesList 411 + files={changedFiles} 412 + selectedFile={selectedFilePath ?? null} 413 + onSelectFile={handleSelectFile} 414 + isLoading={filesLoading} 415 + /> 416 + </div> 326 417 </div> 327 418 )} 328 419 <div className="p-4 space-y-4">