a very good jj gui
0
fork

Configure Feed

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

Sprint 3: Improve first-run experience (onboarding UX)

- Add EmptyState welcome card with quick-start guidance (open repo, j/k nav, ? shortcuts)
- Add ? hint badge in StatusBar that opens keyboard shortcuts help
- Wrap AppHeader sync/search buttons with Tooltip components showing shortcuts
- Organize CommandPalette into Repository/Tools/App groups with shortcut hints
- Add missing d (describe) and a (abandon) shortcuts to KeyboardShortcutsHelp
- Fix shortcut key labels for consistency (⌘ K, ⌘ ,)

+177 -44
+34 -21
apps/desktop/src/components/AppHeader.tsx
··· 1 1 import { Columns2Icon, FolderOpenIcon, ListIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; 2 2 import { Button } from "@/components/ui/button"; 3 + import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; 3 4 4 5 interface AppHeaderProps { 5 6 projectName: string | null; ··· 70 71 71 72 {/* Right: sync + revision search */} 72 73 <div className="flex items-center gap-1 ml-auto"> 73 - <Button 74 - variant="ghost" 75 - size="icon-sm" 76 - className="h-7 w-7" 77 - onClick={onSync} 78 - disabled={isSyncing || !projectName} 79 - title="Sync" 80 - aria-label="Sync" 81 - > 82 - <RefreshCwIcon className={`size-4 ${isSyncing ? "animate-spin" : ""}`} /> 83 - </Button> 84 - <Button 85 - variant="ghost" 86 - size="icon-sm" 87 - className="h-7 w-7 text-muted-foreground" 88 - onClick={onOpenSearch} 89 - title="Search revisions (/)" 90 - aria-label="Search revisions" 91 - > 92 - <SearchIcon className="size-4" /> 93 - </Button> 74 + <Tooltip> 75 + <TooltipTrigger 76 + render={ 77 + <Button 78 + variant="ghost" 79 + size="icon-sm" 80 + className="h-7 w-7" 81 + onClick={onSync} 82 + disabled={isSyncing || !projectName} 83 + aria-label="Sync repository" 84 + > 85 + <RefreshCwIcon className={`size-4 ${isSyncing ? "animate-spin" : ""}`} /> 86 + </Button> 87 + } 88 + /> 89 + <TooltipContent side="bottom">Sync repository</TooltipContent> 90 + </Tooltip> 91 + <Tooltip> 92 + <TooltipTrigger 93 + render={ 94 + <Button 95 + variant="ghost" 96 + size="icon-sm" 97 + className="h-7 w-7 text-muted-foreground" 98 + onClick={onOpenSearch} 99 + aria-label="Search revisions" 100 + > 101 + <SearchIcon className="size-4" /> 102 + </Button> 103 + } 104 + /> 105 + <TooltipContent side="bottom">Search revisions (/)</TooltipContent> 106 + </Tooltip> 94 107 </div> 95 108 </header> 96 109 );
+19 -8
apps/desktop/src/components/AppShell.tsx
··· 4 4 import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; 5 5 import { Profiler, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; 6 6 import { Route as ProjectRoute } from "@/routes/project.$projectId"; 7 - import { debouncedChangeIdAtom, expandedStacksAtom, searchOpenAtom, viewModeAtom } from "@/atoms"; 7 + import { 8 + debouncedChangeIdAtom, 9 + expandedStacksAtom, 10 + searchOpenAtom, 11 + shortcutsHelpOpenAtom, 12 + viewModeAtom, 13 + } from "@/atoms"; 8 14 9 15 const NARROW_BREAKPOINT = 768; 10 16 const DEFAULT_SIDEBAR_WIDTH = 25; ··· 47 53 import { detectStacks, reorderForGraph } from "@/components/revision-graph-utils"; 48 54 import { StatusBar } from "@/components/StatusBar"; 49 55 import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; 56 + import { EmptyState } from "@/components/EmptyState"; 50 57 51 58 import { 52 59 abandonRevision, ··· 97 104 const { handleAddRepository } = useAddRepository(); 98 105 const { data: repositories = [] } = useLiveQuery(repositoriesCollection); 99 106 const [projectPickerOpen, setProjectPickerOpen] = useState(false); 107 + const [, setShortcutsOpen] = useAtom(shortcutsHelpOpenAtom); 100 108 101 109 function handleSelectRepository(repository: Repository) { 102 110 navigate({ to: "/project/$projectId", params: { projectId: repository.id } }); ··· 128 136 viewMode={1} 129 137 onChangeViewMode={() => {}} 130 138 /> 131 - <div className="flex-1 min-h-0 flex items-center justify-center text-muted-foreground"> 132 - <p>Select or add a repository to get started</p> 139 + <div className="flex-1 min-h-0 flex items-center justify-center"> 140 + <EmptyState 141 + onOpenRepo={handleAddRepository} 142 + onOpenShortcutsHelp={() => setShortcutsOpen(true)} 143 + /> 133 144 </div> 134 145 <StatusBar branch={null} isConnected={false} hasConflict={false} /> 135 146 </div> ··· 770 781 key={`${isNarrowScreen ? "narrow" : "wide"}-${splitLayoutSeed}`} 771 782 id="app-shell-layout" 772 783 orientation={isNarrowScreen ? "vertical" : "horizontal"} 773 - onLayoutChange={handleMainSplitLayout} 784 + onLayoutChange={isNarrowScreen ? undefined : handleMainSplitLayout} 774 785 > 775 786 <ResizablePanel 776 787 id="app-shell-revisions" 777 - defaultSize={sidebarWidth} 778 - minSize={MIN_SIDEBAR_WIDTH} 779 - maxSize={MAX_SIDEBAR_WIDTH} 788 + defaultSize={isNarrowScreen ? 40 : sidebarWidth} 789 + minSize={isNarrowScreen ? 20 : MIN_SIDEBAR_WIDTH} 790 + maxSize={isNarrowScreen ? 60 : MAX_SIDEBAR_WIDTH} 780 791 > 781 792 <section 782 793 ref={revisionsPanelRef} ··· 810 821 withHandle 811 822 orientation={isNarrowScreen ? "vertical" : "horizontal"} 812 823 /> 813 - <ResizablePanel id="app-shell-diff" defaultSize={100 - sidebarWidth} minSize={30}> 824 + <ResizablePanel id="app-shell-diff" defaultSize={isNarrowScreen ? 60 : (100 - sidebarWidth)} minSize={30}> 814 825 <aside className="h-full" aria-label="Diff viewer"> 815 826 <Profiler id="DiffPanel" onRender={onRenderCallback}> 816 827 <PrerenderedDiffPanel
+60 -13
apps/desktop/src/components/CommandPalette.tsx
··· 7 7 CommandInput, 8 8 CommandItem, 9 9 CommandList, 10 + CommandShortcut, 10 11 } from "@/components/ui/command"; 11 12 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 12 13 ··· 25 26 icon: LucideIcon; 26 27 onSelect: () => void; 27 28 disabled?: boolean; 29 + shortcut?: string; 30 + group: "repository" | "tools" | "app"; 28 31 } 29 32 30 33 export function CommandPalette({ ··· 55 58 keywords: ["add", "repository", "open", "folder"], 56 59 icon: Folder, 57 60 onSelect: onOpenRepo, 61 + group: "repository", 58 62 }, 59 63 { 60 64 id: "open-projects", ··· 62 66 keywords: ["manage", "repositories", "projects"], 63 67 icon: Settings, 64 68 onSelect: onOpenProjects, 69 + group: "repository", 65 70 }, 66 71 { 67 72 id: "open-settings", ··· 69 74 keywords: ["settings", "preferences", "config"], 70 75 icon: SlidersHorizontal, 71 76 onSelect: onOpenSettings, 77 + shortcut: "⌘ ,", 78 + group: "app", 72 79 }, 73 80 ]; 74 81 ··· 80 87 icon: History, 81 88 onSelect: onOpenOperationsLog, 82 89 disabled: !canOpenOperationsLog, 90 + group: "tools", 83 91 }); 84 92 } 85 93 86 94 return baseActions; 87 95 }, [canOpenOperationsLog, onOpenOperationsLog, onOpenProjects, onOpenRepo, onOpenSettings]); 88 96 97 + const repoActions = actions.filter((a) => a.group === "repository"); 98 + const toolsActions = actions.filter((a) => a.group === "tools"); 99 + const appActions = actions.filter((a) => a.group === "app"); 100 + 89 101 return ( 90 102 <CommandDialog open={open} onOpenChange={setOpen}> 91 103 <CommandInput placeholder="Search actions..." /> 92 104 <CommandList> 93 105 <CommandEmpty>No actions found.</CommandEmpty> 94 - <CommandGroup heading="Actions"> 95 - {actions.map((action) => ( 96 - <CommandItem 97 - key={action.id} 98 - onSelect={() => select(action.onSelect)} 99 - keywords={action.keywords} 100 - disabled={action.disabled} 101 - > 102 - <action.icon className="mr-2 h-4 w-4" /> 103 - <span>{action.label}</span> 104 - </CommandItem> 105 - ))} 106 - </CommandGroup> 106 + {repoActions.length > 0 && ( 107 + <CommandGroup heading="Repository"> 108 + {repoActions.map((action) => ( 109 + <CommandItem 110 + key={action.id} 111 + onSelect={() => select(action.onSelect)} 112 + keywords={action.keywords} 113 + disabled={action.disabled} 114 + > 115 + <action.icon className="mr-2 h-4 w-4" /> 116 + <span>{action.label}</span> 117 + {action.shortcut && <CommandShortcut>{action.shortcut}</CommandShortcut>} 118 + </CommandItem> 119 + ))} 120 + </CommandGroup> 121 + )} 122 + {toolsActions.length > 0 && ( 123 + <CommandGroup heading="Tools"> 124 + {toolsActions.map((action) => ( 125 + <CommandItem 126 + key={action.id} 127 + onSelect={() => select(action.onSelect)} 128 + keywords={action.keywords} 129 + disabled={action.disabled} 130 + > 131 + <action.icon className="mr-2 h-4 w-4" /> 132 + <span>{action.label}</span> 133 + {action.shortcut && <CommandShortcut>{action.shortcut}</CommandShortcut>} 134 + </CommandItem> 135 + ))} 136 + </CommandGroup> 137 + )} 138 + {appActions.length > 0 && ( 139 + <CommandGroup heading="App"> 140 + {appActions.map((action) => ( 141 + <CommandItem 142 + key={action.id} 143 + onSelect={() => select(action.onSelect)} 144 + keywords={action.keywords} 145 + disabled={action.disabled} 146 + > 147 + <action.icon className="mr-2 h-4 w-4" /> 148 + <span>{action.label}</span> 149 + {action.shortcut && <CommandShortcut>{action.shortcut}</CommandShortcut>} 150 + </CommandItem> 151 + ))} 152 + </CommandGroup> 153 + )} 107 154 </CommandList> 108 155 </CommandDialog> 109 156 );
+47
apps/desktop/src/components/EmptyState.tsx
··· 1 + import { ArrowDownUpIcon, CommandIcon, FolderOpenIcon } from "lucide-react"; 2 + import { Button } from "@/components/ui/button"; 3 + import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 4 + 5 + interface EmptyStateProps { 6 + onOpenRepo: () => void; 7 + onOpenShortcutsHelp: () => void; 8 + } 9 + 10 + export function EmptyState({ onOpenRepo, onOpenShortcutsHelp }: EmptyStateProps) { 11 + return ( 12 + <Card className="max-w-md w-full"> 13 + <CardHeader className="text-center"> 14 + <CardTitle>Welcome to Tatami</CardTitle> 15 + <CardDescription>A desktop client for Jujutsu version control</CardDescription> 16 + </CardHeader> 17 + <CardContent> 18 + <div className="flex flex-col gap-3"> 19 + <div className="flex items-center gap-3"> 20 + <FolderOpenIcon className="size-4 shrink-0 text-muted-foreground" /> 21 + <span className="text-xs">Open a repository to get started</span> 22 + <Button 23 + variant="outline" 24 + size="sm" 25 + className="ml-auto h-6 text-xs px-2" 26 + onClick={onOpenRepo} 27 + > 28 + Add Repository 29 + </Button> 30 + </div> 31 + <div className="flex items-center gap-3"> 32 + <ArrowDownUpIcon className="size-4 shrink-0 text-muted-foreground" /> 33 + <span className="text-xs">Navigate revisions with j/k keys</span> 34 + </div> 35 + <button 36 + type="button" 37 + className="flex items-center gap-3 rounded-md -mx-2 px-2 py-1 hover:bg-muted/50 transition-colors text-left" 38 + onClick={onOpenShortcutsHelp} 39 + > 40 + <CommandIcon className="size-4 shrink-0 text-muted-foreground" /> 41 + <span className="text-xs">Press ? to view all keyboard shortcuts</span> 42 + </button> 43 + </div> 44 + </CardContent> 45 + </Card> 46 + ); 47 + }
+4 -2
apps/desktop/src/components/KeyboardShortcutsHelp.tsx
··· 25 25 { keys: ["n"], description: "New revision on selected" }, 26 26 { keys: ["e"], description: "Edit selected revision" }, 27 27 { keys: ["s"], description: "Squash selected revision into parent" }, 28 + { keys: ["d"], description: "Describe (edit commit message)" }, 29 + { keys: ["a"], description: "Abandon selected revision" }, 28 30 { keys: ["r"], description: "Start/cancel rebase destination pick" }, 29 31 { keys: ["Enter"], description: "Confirm rebase destination (pick mode)" }, 30 32 ], ··· 48 50 { 49 51 category: "General", 50 52 items: [ 51 - { keys: ["⌘", "O"], description: "Open command palette" }, 52 - { keys: ["⌘", ","], description: "Open settings" }, 53 + { keys: ["⌘ K"], description: "Open command palette" }, 54 + { keys: ["⌘ ,"], description: "Open settings" }, 53 55 { keys: ["?"], description: "Show this help" }, 54 56 ], 55 57 },
+13
apps/desktop/src/components/StatusBar.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 1 2 import { Circle, Laptop, Moon, Sun } from "lucide-react"; 3 + import { shortcutsHelpOpenAtom } from "@/atoms"; 2 4 import { Button } from "@/components/ui/button"; 3 5 import { Separator } from "@/components/ui/separator"; 4 6 import { useTheme } from "@/hooks/useTheme"; ··· 12 14 export function StatusBar({ branch, isConnected, hasConflict }: StatusBarProps) { 13 15 const { theme, cycleTheme } = useTheme(); 14 16 const ThemeIcon = theme === "system" ? Laptop : theme === "dark" ? Moon : Sun; 17 + const [, setShortcutsOpen] = useAtom(shortcutsHelpOpenAtom); 15 18 16 19 return ( 17 20 <div className="flex items-center h-8 px-2 border-t border-border bg-card text-xs text-muted-foreground"> ··· 23 26 aria-label="Toggle theme" 24 27 > 25 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 + ? 26 39 </Button> 27 40 <div className="flex items-center gap-3 ml-auto"> 28 41 {branch && (