a very good jj gui
0
fork

Configure Feed

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

Remove view-mode toggle, AppHeader, and StatusBar; collapse to split layout

The view-mode switcher offered overview vs. split, but the diff panel is
core to the desktop client, so the overview-only branch was dead weight.
Drop the mode concept entirely (atom, type, persisted layout field on
both TS and Rust sides, navigation hook view-mode coupling, and the
1/2 keyboard shortcuts) and always render the split layout.

Move the workspace switcher, revision search, and sync into the
revisions sidebar so the AppHeader can go away. The macOS title bar
handles window dragging now that the in-app drag region is gone.

Drop the StatusBar — its theme toggle now lives as Light/Dark/System
entries under the new Appearance group in the command palette. The
shortcut help button is redundant since the ? key already opens the
help dialog. Closest-bookmark BFS and the working-copy status query
were only feeding the StatusBar, so they go too.

Empty state keeps just the workspace switcher above the EmptyState card.

+184 -373
-6
apps/desktop/src-tauri/src/storage.rs
··· 23 23 pub active_project_id: Option<String>, 24 24 pub selected_change_id: Option<String>, 25 25 pub sidebar_width: i32, 26 - pub view_mode: i32, 27 26 } 28 27 29 28 impl AppLayout { 30 29 fn normalized(mut self) -> Self { 31 30 self.sidebar_width = self.sidebar_width.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); 32 - self.view_mode = if self.view_mode == 2 { 2 } else { 1 }; 33 31 self 34 32 } 35 33 } ··· 40 38 active_project_id: None, 41 39 selected_change_id: None, 42 40 sidebar_width: DEFAULT_SIDEBAR_WIDTH, 43 - view_mode: 1, 44 41 } 45 42 } 46 43 } ··· 205 202 } 206 203 if updates.sidebar_width != 0 { 207 204 layout.sidebar_width = updates.sidebar_width.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); 208 - } 209 - if updates.view_mode != 0 { 210 - layout.view_mode = if updates.view_mode == 2 { 2 } else { 1 }; 211 205 } 212 206 213 207 let value = serde_json::to_string(&*layout)?;
-3
apps/desktop/src/atoms.ts
··· 6 6 // AceJump mode: when active, shows jump hints on visible revision change IDs 7 7 // Stores the typed query prefix (empty string = initial state showing first letters) 8 8 export const aceJumpQueryAtom = Atom.make<string | null>(null); 9 - // View mode: 1 = overview (only revisions), 2 = split (revisions + diff panel) 10 - export type ViewMode = 1 | 2; 11 - export const viewModeAtom = Atom.make<ViewMode>(1); 12 9 // Tracks which revision stacks are expanded (by stack ID) 13 10 export const expandedStacksAtom = Atom.make(new Set<string>()); 14 11 // Tracks which stack is currently hovered (for coordinated edge highlighting)
-107
apps/desktop/src/components/AppHeader.tsx
··· 1 - import { Columns2Icon, FolderOpenIcon, ListIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; 2 - import { Button } from "@/components/ui/button"; 3 - import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; 4 - 5 - interface AppHeaderProps { 6 - projectName: string | null; 7 - onOpenProject: () => void; 8 - onSync: () => void; 9 - onOpenSearch: () => void; 10 - viewMode: 1 | 2; 11 - onChangeViewMode: (mode: 1 | 2) => void; 12 - isSyncing?: boolean; 13 - } 14 - 15 - export function AppHeader({ 16 - projectName, 17 - onOpenProject, 18 - onSync, 19 - onOpenSearch, 20 - viewMode, 21 - onChangeViewMode, 22 - isSyncing = false, 23 - }: AppHeaderProps) { 24 - return ( 25 - <header 26 - className="h-10 flex items-center justify-between px-3 border-b border-border bg-background shrink-0" 27 - data-tauri-drag-region 28 - > 29 - {/* Left: Project/Repository + view mode */} 30 - <div className="flex items-center gap-1.5 min-w-0"> 31 - <Button 32 - variant="ghost" 33 - size="sm" 34 - className="h-7 px-2 gap-1.5 text-sm font-medium" 35 - onClick={onOpenProject} 36 - > 37 - <FolderOpenIcon className="size-4" /> 38 - <span className="truncate max-w-[200px]">{projectName ?? "Open Repository"}</span> 39 - </Button> 40 - <div className="relative flex items-center rounded-md border border-border/70 bg-muted/60 p-0.5 shadow-inner"> 41 - <div 42 - className={`absolute top-0.5 h-6 w-6 rounded-sm bg-background shadow-sm transition-transform duration-200 ${ 43 - viewMode === 1 ? "translate-x-0" : "translate-x-6" 44 - }`} 45 - /> 46 - <button 47 - type="button" 48 - className="relative z-10 inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none" 49 - onClick={() => onChangeViewMode(1)} 50 - title="Overview mode (1)" 51 - aria-label="Overview mode" 52 - disabled={!projectName} 53 - > 54 - <ListIcon className={`size-3.5 ${viewMode === 1 ? "text-foreground" : ""}`} /> 55 - </button> 56 - <button 57 - type="button" 58 - className="relative z-10 inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none" 59 - onClick={() => onChangeViewMode(2)} 60 - title="Split mode (2)" 61 - aria-label="Split mode" 62 - disabled={!projectName} 63 - > 64 - <Columns2Icon className={`size-3.5 ${viewMode === 2 ? "text-foreground" : ""}`} /> 65 - </button> 66 - </div> 67 - </div> 68 - 69 - {/* Right: sync + revision search */} 70 - <div className="flex items-center gap-1 ml-auto"> 71 - <Tooltip> 72 - <TooltipTrigger 73 - render={ 74 - <Button 75 - variant="ghost" 76 - size="icon-sm" 77 - className="h-7 w-7" 78 - onClick={onSync} 79 - disabled={isSyncing || !projectName} 80 - aria-label="Sync repository" 81 - > 82 - <RefreshCwIcon className={`size-4 ${isSyncing ? "animate-spin" : ""}`} /> 83 - </Button> 84 - } 85 - /> 86 - <TooltipContent side="bottom">Sync repository</TooltipContent> 87 - </Tooltip> 88 - <Tooltip> 89 - <TooltipTrigger 90 - render={ 91 - <Button 92 - variant="ghost" 93 - size="icon-sm" 94 - className="h-7 w-7 text-muted-foreground" 95 - onClick={onOpenSearch} 96 - aria-label="Search revisions" 97 - > 98 - <SearchIcon className="size-4" /> 99 - </Button> 100 - } 101 - /> 102 - <TooltipContent side="bottom">Search revisions (/)</TooltipContent> 103 - </Tooltip> 104 - </div> 105 - </header> 106 - ); 107 - }
+114 -167
apps/desktop/src/components/AppShell.tsx
··· 2 2 import { useLiveQuery } from "@tanstack/react-db"; 3 3 import { useQuery } from "@tanstack/react-query"; 4 4 import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; 5 + import { FolderOpenIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; 5 6 import { Profiler, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; 6 7 import { Route as ProjectRoute } from "@/routes/project.$projectId"; 7 8 import { ··· 9 10 expandedStacksAtom, 10 11 searchOpenAtom, 11 12 shortcutsHelpOpenAtom, 12 - viewModeAtom, 13 13 } from "@/atoms"; 14 14 15 15 const NARROW_BREAKPOINT = 768; ··· 24 24 return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, Math.round(width))); 25 25 } 26 26 27 - function normalizeViewMode(viewMode: number | null | undefined): 1 | 2 { 28 - return viewMode === 2 ? 2 : 1; 29 - } 30 - 31 27 function subscribeToMediaQuery(callback: () => void) { 32 28 const mediaQuery = window.matchMedia(`(max-width: ${NARROW_BREAKPOINT}px)`); 33 29 mediaQuery.addEventListener("change", callback); ··· 43 39 } 44 40 45 41 import { Search } from "@/components/Search"; 46 - import { AppHeader } from "@/components/AppHeader"; 47 42 import { CommandPalette } from "@/components/CommandPalette"; 48 43 import { PrerenderedDiffPanel } from "@/components/DiffPanel"; 49 44 import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp"; ··· 51 46 import { ProjectPicker } from "@/components/ProjectPicker"; 52 47 import { RevisionGraph, type RevisionGraphHandle } from "@/components/RevisionGraph"; 53 48 import { detectStacks, reorderForGraph } from "@/components/revision-graph-utils"; 54 - import { StatusBar } from "@/components/StatusBar"; 49 + import { Button } from "@/components/ui/button"; 55 50 import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; 51 + import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; 56 52 import { EmptyState } from "@/components/EmptyState"; 57 53 58 54 import { ··· 78 74 import { useSelectedRevision } from "@/hooks/useSelectedRevision"; 79 75 import { 80 76 getLayout, 81 - getStatus, 82 77 updateLayout, 83 78 type AppLayout, 84 79 type Repository, ··· 128 123 onOpenChange={setProjectPickerOpen} 129 124 /> 130 125 <div className="flex flex-col h-screen overflow-hidden"> 131 - <AppHeader 132 - projectName={null} 133 - onOpenProject={() => setProjectPickerOpen(true)} 134 - onSync={() => {}} 135 - onOpenSearch={() => {}} 136 - viewMode={1} 137 - onChangeViewMode={() => {}} 138 - /> 139 - <div className="flex-1 min-h-0 flex items-center justify-center"> 140 - <EmptyState 141 - onOpenRepo={handleAddRepository} 142 - onOpenShortcutsHelp={() => setShortcutsOpen(true)} 143 - /> 126 + <div className="flex-1 min-h-0 flex flex-col"> 127 + <div className="px-2 py-2 shrink-0"> 128 + <Button 129 + variant="ghost" 130 + size="sm" 131 + className="h-7 px-2 gap-1.5 text-sm font-medium" 132 + onClick={() => setProjectPickerOpen(true)} 133 + > 134 + <FolderOpenIcon className="size-4" /> 135 + <span className="truncate">Open Repository</span> 136 + </Button> 137 + </div> 138 + <div className="flex-1 flex items-center justify-center min-h-0"> 139 + <EmptyState 140 + onOpenRepo={handleAddRepository} 141 + onOpenShortcutsHelp={() => setShortcutsOpen(true)} 142 + /> 143 + </div> 144 144 </div> 145 - <StatusBar branch={null} isConnected={false} hasConflict={false} /> 146 145 </div> 147 146 </> 148 147 ); ··· 154 153 const { projectId } = useParams({ from: ProjectRoute.fullPath }); 155 154 const rev = useSearch({ from: ProjectRoute.fullPath, select: (s) => s.rev }); 156 155 const [flash, setFlash] = useState<{ changeId: string; key: number } | null>(null); 157 - const [viewMode, setViewMode] = useAtom(viewModeAtom); 158 156 const [, setSearchOpen] = useAtom(searchOpenAtom); 159 157 const [pendingAbandon, setPendingAbandon] = useState<Revision | null>(null); 160 158 const [editingChangeId, setEditingChangeId] = useState<string | null>(null); ··· 205 203 staleTime: Number.POSITIVE_INFINITY, 206 204 }); 207 205 208 - const { data: workingCopyStatus } = useQuery({ 209 - queryKey: ["status", activeProject?.path], 210 - queryFn: () => getStatus(activeProject?.path ?? ""), 211 - enabled: !!activeProject?.path, 212 - retry: false, 213 - }); 214 - 215 206 useAppTitle(activeProject ? `Tatami - ${activeProject.path}` : "Tatami"); 216 207 217 208 const revisionsCollection = activeProject ··· 275 266 useEffect(() => { 276 267 if (!persistedLayout) return; 277 268 278 - setViewMode(normalizeViewMode(persistedLayout.view_mode)); 279 269 setSidebarWidth(clampSidebarWidth(persistedLayout.sidebar_width)); 280 270 setSplitLayoutSeed((seed) => seed + 1); 281 271 layoutHydratedRef.current = true; 282 - }, [persistedLayout, setViewMode]); 272 + }, [persistedLayout]); 283 273 284 274 useEffect(() => { 285 275 if (!layoutHydratedRef.current) return; ··· 322 312 active_project_id: projectId, 323 313 selected_change_id: selectedRevisionKey, 324 314 sidebar_width: clampSidebarWidth(sidebarWidth), 325 - view_mode: viewMode, 326 315 }; 327 316 328 317 persistLayoutTimerRef.current = setTimeout(() => { ··· 334 323 clearTimeout(persistLayoutTimerRef.current); 335 324 } 336 325 }; 337 - }, [projectId, selectedRevisionKey, sidebarWidth, viewMode]); 326 + }, [projectId, selectedRevisionKey, sidebarWidth]); 338 327 339 328 // ast-grep-ignore: no-useeffect-state-sync 340 329 useEffect(() => { ··· 636 625 enabled: !!pendingAbandon, 637 626 }); 638 627 639 - // View mode shortcuts: 1 = overview, 2 = split 640 - useKeyboardShortcut({ 641 - key: "1", 642 - onPress: () => setViewMode(1), 643 - }); 644 - 645 - useKeyboardShortcut({ 646 - key: "2", 647 - onPress: () => setViewMode(2), 648 - }); 649 - 650 - const closestBookmark = (() => { 651 - const workingCopy = revisions.find((r) => r.is_working_copy); 652 - if (!workingCopy) return null; 653 - 654 - if (workingCopy.bookmarks.length > 0) { 655 - return workingCopy.bookmarks[0].name; 656 - } 657 - 658 - // BFS to find closest ancestor with bookmarks 659 - const byCommitId = new Map<string, Revision>(); 660 - for (const rev of revisions) { 661 - byCommitId.set(rev.commit_id, rev); 662 - } 663 - 664 - const visited = new Set<string>(); 665 - const queue = workingCopy.parent_edges.map((e) => e.parent_id); 666 - 667 - while (queue.length > 0) { 668 - const commitId = queue.shift(); 669 - if (!commitId || visited.has(commitId)) continue; 670 - visited.add(commitId); 671 - 672 - const rev = byCommitId.get(commitId); 673 - if (!rev) continue; 674 - 675 - if (rev.bookmarks.length > 0) { 676 - return rev.bookmarks[0].name; 677 - } 678 - 679 - queue.push(...rev.parent_edges.map((e) => e.parent_id)); 680 - } 681 - 682 - return null; 683 - })(); 684 - 685 628 async function handleSync() { 686 629 if (!activeProject || isSyncing) return; 687 630 setIsSyncing(true); ··· 741 684 }} 742 685 /> 743 686 <div className="flex flex-col h-screen overflow-hidden"> 744 - <AppHeader 745 - projectName={activeProject?.name ?? null} 746 - onOpenProject={() => setProjectPickerOpen(true)} 747 - onSync={handleSync} 748 - onOpenSearch={handleOpenSearch} 749 - viewMode={viewMode} 750 - onChangeViewMode={setViewMode} 751 - isSyncing={isSyncing} 752 - /> 753 687 <div className="flex-1 min-h-0"> 754 - {viewMode === 1 ? ( 755 - // Overview mode: only revision list 756 - <section 757 - ref={revisionsPanelRef} 758 - tabIndex={-1} 759 - className="h-full relative outline-none" 760 - aria-label="Revision list" 688 + <ResizablePanelGroup 689 + key={`${isNarrowScreen ? "narrow" : "wide"}-${splitLayoutSeed}`} 690 + id="app-shell-layout" 691 + orientation={isNarrowScreen ? "vertical" : "horizontal"} 692 + onLayoutChange={isNarrowScreen ? undefined : handleMainSplitLayout} 693 + > 694 + <ResizablePanel 695 + id="app-shell-revisions" 696 + defaultSize={isNarrowScreen ? "40%" : `${sidebarWidth}%`} 697 + minSize={isNarrowScreen ? "20%" : `${MIN_SIDEBAR_WIDTH}%`} 698 + maxSize={isNarrowScreen ? "60%" : `${MAX_SIDEBAR_WIDTH}%`} 761 699 > 762 - <Profiler id="RevisionGraph" onRender={onRenderCallback}> 763 - <RevisionGraph 764 - ref={revisionGraphRef} 765 - revisions={revisions} 766 - selectedRevision={selectedRevision} 767 - onSelectRevision={handleSelectRevision} 768 - isLoading={isLoading} 769 - errorMessage={revisionsErrorMessage} 770 - onRetry={handleRetryRevisions} 771 - flash={flash} 772 - repoPath={activeProject?.path ?? null} 773 - pendingAbandon={pendingAbandon} 774 - editingChangeId={editingChangeId} 775 - onDescribe={handleDescribe} 776 - onCancelDescribe={handleCancelDescribe} 777 - rebaseSourceChangeId={rebaseSourceRevision?.change_id ?? null} 778 - onPickRebaseDestination={handlePickRebaseDestination} 779 - diffPanelRef={diffPanelRef} 780 - /> 781 - </Profiler> 782 - </section> 783 - ) : ( 784 - // Split mode: revision list + diff panel (vertical on narrow screens) 785 - <ResizablePanelGroup 786 - key={`${isNarrowScreen ? "narrow" : "wide"}-${splitLayoutSeed}`} 787 - id="app-shell-layout" 788 - orientation={isNarrowScreen ? "vertical" : "horizontal"} 789 - onLayoutChange={isNarrowScreen ? undefined : handleMainSplitLayout} 790 - > 791 - <ResizablePanel 792 - id="app-shell-revisions" 793 - defaultSize={isNarrowScreen ? "40%" : `${sidebarWidth}%`} 794 - minSize={isNarrowScreen ? "20%" : `${MIN_SIDEBAR_WIDTH}%`} 795 - maxSize={isNarrowScreen ? "60%" : `${MAX_SIDEBAR_WIDTH}%`} 700 + <section 701 + ref={revisionsPanelRef} 702 + tabIndex={-1} 703 + className="h-full flex flex-col outline-none" 704 + aria-label="Revision list" 796 705 > 797 - <section 798 - ref={revisionsPanelRef} 799 - tabIndex={-1} 800 - className="h-full relative outline-none" 801 - aria-label="Revision list" 802 - > 706 + <div className="px-2 py-2 shrink-0 flex items-center gap-1"> 707 + <Button 708 + variant="ghost" 709 + size="sm" 710 + className="flex-1 min-w-0 justify-start h-7 px-2 gap-1.5 text-sm font-medium" 711 + onClick={() => setProjectPickerOpen(true)} 712 + > 713 + <FolderOpenIcon className="size-4" /> 714 + <span className="truncate"> 715 + {activeProject?.name ?? "Open Repository"} 716 + </span> 717 + </Button> 718 + <Tooltip> 719 + <TooltipTrigger 720 + render={ 721 + <Button 722 + variant="ghost" 723 + size="icon-sm" 724 + className="h-7 w-7 text-muted-foreground" 725 + onClick={handleOpenSearch} 726 + aria-label="Search revisions" 727 + > 728 + <SearchIcon className="size-4" /> 729 + </Button> 730 + } 731 + /> 732 + <TooltipContent side="bottom">Search revisions (/)</TooltipContent> 733 + </Tooltip> 734 + <Tooltip> 735 + <TooltipTrigger 736 + render={ 737 + <Button 738 + variant="ghost" 739 + size="icon-sm" 740 + className="h-7 w-7 text-muted-foreground" 741 + onClick={handleSync} 742 + disabled={isSyncing || !activeProject} 743 + aria-label="Sync repository" 744 + > 745 + <RefreshCwIcon 746 + className={`size-4 ${isSyncing ? "animate-spin" : ""}`} 747 + /> 748 + </Button> 749 + } 750 + /> 751 + <TooltipContent side="bottom">Sync repository</TooltipContent> 752 + </Tooltip> 753 + </div> 754 + <div className="flex-1 min-h-0 relative"> 803 755 <Profiler id="RevisionGraph" onRender={onRenderCallback}> 804 756 <RevisionGraph 805 757 ref={revisionGraphRef} ··· 820 772 diffPanelRef={diffPanelRef} 821 773 /> 822 774 </Profiler> 823 - </section> 824 - </ResizablePanel> 825 - <ResizableHandle 826 - withHandle 827 - orientation={isNarrowScreen ? "vertical" : "horizontal"} 828 - /> 829 - <ResizablePanel 830 - id="app-shell-diff" 831 - defaultSize={isNarrowScreen ? "60%" : `${100 - sidebarWidth}%`} 832 - minSize="30%" 833 - > 834 - <aside className="h-full" aria-label="Diff viewer"> 835 - <Profiler id="DiffPanel" onRender={onRenderCallback}> 836 - <PrerenderedDiffPanel 837 - ref={diffPanelRef} 838 - repoPath={activeProject?.path ?? null} 839 - revisions={orderedRevisions} 840 - selectedChangeId={debouncedChangeId} 841 - revisionsPanelRef={revisionsPanelRef} 842 - onDescribe={handleDescribe} 843 - /> 844 - </Profiler> 845 - </aside> 846 - </ResizablePanel> 847 - </ResizablePanelGroup> 848 - )} 775 + </div> 776 + </section> 777 + </ResizablePanel> 778 + <ResizableHandle 779 + withHandle 780 + orientation={isNarrowScreen ? "vertical" : "horizontal"} 781 + /> 782 + <ResizablePanel 783 + id="app-shell-diff" 784 + defaultSize={isNarrowScreen ? "60%" : `${100 - sidebarWidth}%`} 785 + minSize="30%" 786 + > 787 + <aside className="h-full" aria-label="Diff viewer"> 788 + <Profiler id="DiffPanel" onRender={onRenderCallback}> 789 + <PrerenderedDiffPanel 790 + ref={diffPanelRef} 791 + repoPath={activeProject?.path ?? null} 792 + revisions={orderedRevisions} 793 + selectedChangeId={debouncedChangeId} 794 + revisionsPanelRef={revisionsPanelRef} 795 + onDescribe={handleDescribe} 796 + /> 797 + </Profiler> 798 + </aside> 799 + </ResizablePanel> 800 + </ResizablePanelGroup> 849 801 </div> 850 - <StatusBar 851 - branch={closestBookmark} 852 - isConnected={!!activeProject} 853 - hasConflict={workingCopyStatus?.has_conflict ?? false} 854 - /> 855 802 </div> 856 803 </> 857 804 );
+69 -3
apps/desktop/src/components/CommandPalette.tsx
··· 1 - import { Folder, History, Settings, SlidersHorizontal, type LucideIcon } from "lucide-react"; 1 + import { 2 + Folder, 3 + History, 4 + Laptop, 5 + Moon, 6 + Settings, 7 + SlidersHorizontal, 8 + Sun, 9 + type LucideIcon, 10 + } from "lucide-react"; 2 11 import { useMemo, useState } from "react"; 3 12 import { 4 13 CommandDialog, ··· 10 19 CommandShortcut, 11 20 } from "@/components/ui/command"; 12 21 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 22 + import { useTheme } from "@/hooks/useTheme"; 13 23 14 24 interface CommandPaletteProps { 15 25 onOpenRepo: () => void; ··· 27 37 onSelect: () => void; 28 38 disabled?: boolean; 29 39 shortcut?: string; 30 - group: "repository" | "tools" | "app"; 40 + group: "repository" | "tools" | "appearance" | "app"; 31 41 } 32 42 33 43 export function CommandPalette({ ··· 38 48 onOpenOperationsLog, 39 49 }: CommandPaletteProps) { 40 50 const [open, setOpen] = useState(false); 51 + const { theme, setTheme } = useTheme(); 41 52 42 53 useKeyboardShortcut({ 43 54 key: "k", ··· 91 102 }); 92 103 } 93 104 105 + baseActions.push( 106 + { 107 + id: "theme-light", 108 + label: "Theme: Light", 109 + keywords: ["theme", "appearance", "light", "color"], 110 + icon: Sun, 111 + onSelect: () => setTheme("light"), 112 + disabled: theme === "light", 113 + group: "appearance", 114 + }, 115 + { 116 + id: "theme-dark", 117 + label: "Theme: Dark", 118 + keywords: ["theme", "appearance", "dark", "color"], 119 + icon: Moon, 120 + onSelect: () => setTheme("dark"), 121 + disabled: theme === "dark", 122 + group: "appearance", 123 + }, 124 + { 125 + id: "theme-system", 126 + label: "Theme: System", 127 + keywords: ["theme", "appearance", "system", "auto", "color"], 128 + icon: Laptop, 129 + onSelect: () => setTheme("system"), 130 + disabled: theme === "system", 131 + group: "appearance", 132 + }, 133 + ); 134 + 94 135 return baseActions; 95 - }, [canOpenOperationsLog, onOpenOperationsLog, onOpenProjects, onOpenRepo, onOpenSettings]); 136 + }, [ 137 + canOpenOperationsLog, 138 + onOpenOperationsLog, 139 + onOpenProjects, 140 + onOpenRepo, 141 + onOpenSettings, 142 + setTheme, 143 + theme, 144 + ]); 96 145 97 146 const repoActions = actions.filter((a) => a.group === "repository"); 98 147 const toolsActions = actions.filter((a) => a.group === "tools"); 148 + const appearanceActions = actions.filter((a) => a.group === "appearance"); 99 149 const appActions = actions.filter((a) => a.group === "app"); 100 150 101 151 return ( ··· 122 172 {toolsActions.length > 0 && ( 123 173 <CommandGroup heading="Tools"> 124 174 {toolsActions.map((action) => ( 175 + <CommandItem 176 + key={action.id} 177 + onSelect={() => select(action.onSelect)} 178 + keywords={action.keywords} 179 + disabled={action.disabled} 180 + > 181 + <action.icon className="mr-2 h-4 w-4" /> 182 + <span>{action.label}</span> 183 + {action.shortcut && <CommandShortcut>{action.shortcut}</CommandShortcut>} 184 + </CommandItem> 185 + ))} 186 + </CommandGroup> 187 + )} 188 + {appearanceActions.length > 0 && ( 189 + <CommandGroup heading="Appearance"> 190 + {appearanceActions.map((action) => ( 125 191 <CommandItem 126 192 key={action.id} 127 193 onSelect={() => select(action.onSelect)}
-69
apps/desktop/src/components/StatusBar.tsx
··· 1 - import { useAtom } from "@effect-atom/atom-react"; 2 - import { Circle, Laptop, Moon, Sun } from "lucide-react"; 3 - import { shortcutsHelpOpenAtom } from "@/atoms"; 4 - import { Button } from "@/components/ui/button"; 5 - import { Separator } from "@/components/ui/separator"; 6 - import { useTheme } from "@/hooks/useTheme"; 7 - 8 - interface StatusBarProps { 9 - branch: string | null; 10 - isConnected: boolean; 11 - hasConflict: boolean; 12 - } 13 - 14 - export function StatusBar({ branch, isConnected, hasConflict }: StatusBarProps) { 15 - const { theme, cycleTheme } = useTheme(); 16 - const ThemeIcon = theme === "system" ? Laptop : theme === "dark" ? Moon : Sun; 17 - const [, setShortcutsOpen] = useAtom(shortcutsHelpOpenAtom); 18 - 19 - return ( 20 - <div className="flex items-center h-8 px-2 border-t border-border bg-card text-xs text-muted-foreground"> 21 - <Button 22 - variant="ghost" 23 - size="icon-xs" 24 - onClick={cycleTheme} 25 - className="h-6 w-6" 26 - aria-label="Toggle theme" 27 - > 28 - <ThemeIcon className="h-3.5 w-3.5" /> 29 - </Button> 30 - <Separator orientation="vertical" className="h-4 mx-1" /> 31 - <Button 32 - variant="ghost" 33 - size="icon-xs" 34 - onClick={() => setShortcutsOpen(true)} 35 - className="h-6 w-6 text-muted-foreground/70 hover:text-foreground font-mono text-[10px] font-bold" 36 - aria-label="Keyboard shortcuts help" 37 - > 38 - ? 39 - </Button> 40 - <div className="flex items-center gap-3 ml-auto"> 41 - {branch && ( 42 - <> 43 - <div className="flex items-center gap-1.5"> 44 - <span className="font-medium">Closest bookmark:</span> 45 - <span>{branch}</span> 46 - </div> 47 - <Separator orientation="vertical" className="h-4" /> 48 - </> 49 - )} 50 - 51 - {hasConflict && ( 52 - <> 53 - <div className="flex items-center gap-1.5 text-destructive"> 54 - <Circle className="h-2 w-2 fill-current" /> 55 - <span>Conflicts</span> 56 - </div> 57 - <Separator orientation="vertical" className="h-4" /> 58 - </> 59 - )} 60 - <div className="flex items-center gap-1.5"> 61 - <Circle 62 - className={`h-2 w-2 fill-current ${isConnected ? "text-green-500" : "text-red-500"}`} 63 - /> 64 - <span>{isConnected ? "Connected" : "Disconnected"}</span> 65 - </div> 66 - </div> 67 - </div> 68 - ); 69 - }
+1 -12
apps/desktop/src/hooks/useRevisionGraphNavigation.ts
··· 1 - import { useAtom } from "@effect-atom/atom-react"; 2 1 import { useNavigate, useSearch } from "@tanstack/react-router"; 3 2 import type { RefObject } from "react"; 4 3 import { useRef } from "react"; 5 4 import { Route } from "@/routes/project.$projectId"; 6 - import { viewModeAtom } from "@/atoms"; 7 5 import { traceLog } from "@/lib/trace"; 8 6 import type { RevisionStack } from "@/components/revision-graph-utils"; 9 7 import { getRevisionKey } from "@/db"; ··· 79 77 }: UseRevisionGraphNavigationParams) { 80 78 const navigate = useNavigate({ from: Route.fullPath }); 81 79 const search = useSearch({ from: Route.fullPath }); 82 - const [viewMode, setViewMode] = useAtom(viewModeAtom); 83 80 84 81 // Read focused stack and selection from URL params 85 82 const focusedStackId = useSearch({ from: Route.fullPath, select: (s) => s.stack ?? null }); ··· 479 476 enabled: enabled && hasFocus, 480 477 }); 481 478 482 - // l / ArrowRight: switch to split mode and focus diff panel 479 + // l / ArrowRight: focus diff panel 483 480 useKeyboardShortcut({ 484 481 key: "l", 485 482 modifiers: {}, 486 483 onPress: () => { 487 484 if (!selectedRevision) return; 488 - // Always switch to split mode and focus diff panel 489 - if (viewMode === 1) { 490 - setViewMode(2); 491 - } 492 485 diffPanelRef.current?.focus(); 493 486 }, 494 487 enabled: enabled && hasFocus, ··· 499 492 modifiers: {}, 500 493 onPress: () => { 501 494 if (!selectedRevision) return; 502 - // Always switch to split mode and focus diff panel 503 - if (viewMode === 1) { 504 - setViewMode(2); 505 - } 506 495 diffPanelRef.current?.focus(); 507 496 }, 508 497 enabled: enabled && hasFocus,
-5
apps/desktop/src/mocks/setup.ts
··· 84 84 active_project_id: string | null; 85 85 selected_change_id: string | null; 86 86 sidebar_width: number; 87 - view_mode: 1 | 2; 88 87 }; 89 88 90 89 let mockProjects: Repository[] = [ ··· 108 107 active_project_id: mockProjects[0]?.id ?? null, 109 108 selected_change_id: null, 110 109 sidebar_width: 25, 111 - view_mode: 1, 112 110 }; 113 111 114 112 // Complex mock revision graph representing realistic development workflow ··· 952 950 } 953 951 if (typeof updates.sidebar_width === "number" && updates.sidebar_width !== 0) { 954 952 mockLayout.sidebar_width = Math.max(15, Math.min(70, Math.round(updates.sidebar_width))); 955 - } 956 - if (typeof updates.view_mode === "number") { 957 - mockLayout.view_mode = updates.view_mode === 2 ? 2 : 1; 958 953 } 959 954 return undefined; 960 955 },
-1
apps/desktop/src/tauri-commands.ts
··· 115 115 active_project_id: string | null; 116 116 selected_change_id: string | null; 117 117 sidebar_width: number; 118 - view_mode: 1 | 2; 119 118 } 120 119 121 120 export async function getLayout(): Promise<AppLayout> {