a very good jj gui
0
fork

Configure Feed

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

Sprint 2: wire persisted layout and session restoration

+209 -7
+21 -2
apps/desktop/src-tauri/src/storage.rs
··· 13 13 pub revset_preset: Option<String>, 14 14 } 15 15 16 + const DEFAULT_SIDEBAR_WIDTH: i32 = 25; 17 + const MIN_SIDEBAR_WIDTH: i32 = 15; 18 + const MAX_SIDEBAR_WIDTH: i32 = 70; 19 + 16 20 #[derive(Debug, Clone, Serialize, Deserialize)] 21 + #[serde(default)] 17 22 pub struct AppLayout { 18 23 pub active_project_id: Option<String>, 19 24 pub selected_change_id: Option<String>, 20 25 pub sidebar_width: i32, 26 + pub view_mode: i32, 27 + } 28 + 29 + impl AppLayout { 30 + fn normalized(mut self) -> Self { 31 + 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 + self 34 + } 21 35 } 22 36 23 37 impl Default for AppLayout { ··· 25 39 Self { 26 40 active_project_id: None, 27 41 selected_change_id: None, 28 - sidebar_width: 25, 42 + sidebar_width: DEFAULT_SIDEBAR_WIDTH, 43 + view_mode: 1, 29 44 } 30 45 } 31 46 } ··· 91 106 .ok()?; 92 107 93 108 row.and_then(|(value,)| serde_json::from_str(&value).ok()) 109 + .map(AppLayout::normalized) 94 110 } 95 111 96 112 pub async fn get_projects(&self) -> anyhow::Result<Vec<Project>> { ··· 188 204 layout.selected_change_id = updates.selected_change_id; 189 205 } 190 206 if updates.sidebar_width != 0 { 191 - layout.sidebar_width = updates.sidebar_width; 207 + 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 }; 192 211 } 193 212 194 213 let value = serde_json::to_string(&*layout)?;
+117 -4
apps/desktop/src/components/AppShell.tsx
··· 7 7 import { debouncedChangeIdAtom, expandedStacksAtom, searchOpenAtom, viewModeAtom } from "@/atoms"; 8 8 9 9 const NARROW_BREAKPOINT = 768; 10 + const DEFAULT_SIDEBAR_WIDTH = 25; 11 + const MIN_SIDEBAR_WIDTH = 15; 12 + const MAX_SIDEBAR_WIDTH = 70; 13 + 14 + function clampSidebarWidth(width: number | null | undefined): number { 15 + if (typeof width !== "number" || Number.isNaN(width)) { 16 + return DEFAULT_SIDEBAR_WIDTH; 17 + } 18 + return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, Math.round(width))); 19 + } 20 + 21 + function normalizeViewMode(viewMode: number | null | undefined): 1 | 2 { 22 + return viewMode === 2 ? 2 : 1; 23 + } 10 24 11 25 function subscribeToMediaQuery(callback: () => void) { 12 26 const mediaQuery = window.matchMedia(`(max-width: ${NARROW_BREAKPOINT}px)`); ··· 53 67 import { useAppTitle } from "@/hooks/useAppTitle"; 54 68 import { useKeyboardNavigation, useKeyboardShortcut, useKeySequence } from "@/hooks/useKeyboard"; 55 69 import { useSelectedRevision } from "@/hooks/useSelectedRevision"; 56 - import { getStatus, type Repository, type Revision } from "@/tauri-commands"; 70 + import { 71 + getLayout, 72 + getStatus, 73 + updateLayout, 74 + type AppLayout, 75 + type Repository, 76 + type Revision, 77 + } from "@/tauri-commands"; 57 78 import { onRenderCallback } from "@/lib/trace"; 58 79 59 80 // Wrapper component that handles the case when no project is selected ··· 127 148 const [projectPickerOpen, setProjectPickerOpen] = useState(false); 128 149 const [isSyncing, setIsSyncing] = useState(false); 129 150 const [operationsLogOpen, setOperationsLogOpen] = useState(false); 151 + const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH); 152 + const [splitLayoutSeed, setSplitLayoutSeed] = useState(0); 130 153 const revisionGraphRef = useRef<RevisionGraphHandle>(null); 131 154 const revisionsPanelRef = useRef<HTMLDivElement>(null); 132 155 const diffPanelRef = useRef<HTMLDivElement>(null); 156 + const layoutHydratedRef = useRef(false); 157 + const selectionRestoredForProjectRef = useRef<string | null>(null); 158 + const persistLayoutTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); 133 159 const isNarrowScreen = useIsNarrowScreen(); 134 160 const { handleAddRepository } = useAddRepository(); 135 161 ··· 143 169 144 170 const activeProject = repositories.find((p) => p.id === projectId) ?? null; 145 171 172 + const { data: persistedLayout } = useQuery({ 173 + queryKey: ["app-layout"], 174 + queryFn: getLayout, 175 + staleTime: Number.POSITIVE_INFINITY, 176 + }); 177 + 146 178 const { data: workingCopyStatus } = useQuery({ 147 179 queryKey: ["status", activeProject?.path], 148 180 queryFn: () => getStatus(activeProject?.path ?? ""), ··· 195 227 }, [revisions, orderedRevisions, expandedStacks]); 196 228 197 229 const selectedRevision = useSelectedRevision(revisions, rev); 230 + const selectedRevisionKey = selectedRevision ? getRevisionKey(selectedRevision) : null; 198 231 const rebaseSourceRevision = rebaseSourceKey 199 232 ? (revisions.find((r) => getRevisionKey(r) === rebaseSourceKey) ?? null) 200 233 : null; ··· 209 242 }; 210 243 211 244 useEffect(() => { 245 + if (!persistedLayout) return; 246 + 247 + setViewMode(normalizeViewMode(persistedLayout.view_mode)); 248 + setSidebarWidth(clampSidebarWidth(persistedLayout.sidebar_width)); 249 + setSplitLayoutSeed((seed) => seed + 1); 250 + layoutHydratedRef.current = true; 251 + }, [persistedLayout, setViewMode]); 252 + 253 + useEffect(() => { 254 + if (!layoutHydratedRef.current) return; 255 + if (selectionRestoredForProjectRef.current === projectId) return; 256 + if (isLoading) return; 257 + 258 + selectionRestoredForProjectRef.current = projectId; 259 + 260 + if (rev) return; 261 + if (!persistedLayout) return; 262 + if (persistedLayout.active_project_id !== projectId) return; 263 + 264 + const persistedSelection = persistedLayout.selected_change_id; 265 + if (!persistedSelection) return; 266 + 267 + const selectionExists = revisions.some((revision) => { 268 + const revisionKey = getRevisionKey(revision); 269 + return revisionKey === persistedSelection || revision.change_id === persistedSelection; 270 + }); 271 + if (!selectionExists) return; 272 + 273 + navigate({ 274 + to: "/project/$projectId", 275 + params: { projectId }, 276 + search: { rev: persistedSelection }, 277 + replace: true, 278 + }); 279 + }, [isLoading, navigate, persistedLayout, projectId, rev, revisions]); 280 + 281 + useEffect(() => { 282 + if (!layoutHydratedRef.current) return; 283 + if (!projectId) return; 284 + 285 + if (persistLayoutTimerRef.current) { 286 + clearTimeout(persistLayoutTimerRef.current); 287 + } 288 + 289 + const layoutUpdate: AppLayout = { 290 + active_project_id: projectId, 291 + selected_change_id: selectedRevisionKey, 292 + sidebar_width: clampSidebarWidth(sidebarWidth), 293 + view_mode: viewMode, 294 + }; 295 + 296 + persistLayoutTimerRef.current = setTimeout(() => { 297 + void updateLayout(layoutUpdate).catch(() => {}); 298 + }, 250); 299 + 300 + return () => { 301 + if (persistLayoutTimerRef.current) { 302 + clearTimeout(persistLayoutTimerRef.current); 303 + } 304 + }; 305 + }, [projectId, selectedRevisionKey, sidebarWidth, viewMode]); 306 + 307 + useEffect(() => { 212 308 if (!editingChangeId) return; 213 309 if (!selectedRevision || getRevisionKey(selectedRevision) !== editingChangeId) { 214 310 setEditingChangeId(null); ··· 560 656 setOperationsLogOpen(true); 561 657 } 562 658 659 + function handleMainSplitLayout(layout: Record<string, number>) { 660 + const nextSidebarRaw = layout["app-shell-revisions"]; 661 + if (typeof nextSidebarRaw !== "number") return; 662 + const nextSidebarWidth = clampSidebarWidth(nextSidebarRaw); 663 + setSidebarWidth((current) => (current === nextSidebarWidth ? current : nextSidebarWidth)); 664 + } 665 + 563 666 return ( 564 667 <> 565 668 <ProjectPicker ··· 634 737 </section> 635 738 ) : ( 636 739 // Split mode: revision list + diff panel (vertical on narrow screens) 637 - <ResizablePanelGroup orientation={isNarrowScreen ? "vertical" : "horizontal"}> 638 - <ResizablePanel defaultSize={isNarrowScreen ? 40 : 25} minSize={15}> 740 + <ResizablePanelGroup 741 + key={`${isNarrowScreen ? "narrow" : "wide"}-${splitLayoutSeed}`} 742 + id="app-shell-layout" 743 + orientation={isNarrowScreen ? "vertical" : "horizontal"} 744 + onLayoutChange={handleMainSplitLayout} 745 + > 746 + <ResizablePanel 747 + id="app-shell-revisions" 748 + defaultSize={sidebarWidth} 749 + minSize={MIN_SIDEBAR_WIDTH} 750 + maxSize={MAX_SIDEBAR_WIDTH} 751 + > 639 752 <section 640 753 ref={revisionsPanelRef} 641 754 tabIndex={-1} ··· 668 781 withHandle 669 782 orientation={isNarrowScreen ? "vertical" : "horizontal"} 670 783 /> 671 - <ResizablePanel defaultSize={isNarrowScreen ? 60 : 75} minSize={30}> 784 + <ResizablePanel id="app-shell-diff" defaultSize={100 - sidebarWidth} minSize={30}> 672 785 <aside className="h-full" aria-label="Diff viewer"> 673 786 <Profiler id="DiffPanel" onRender={onRenderCallback}> 674 787 <PrerenderedDiffPanel
+36
apps/desktop/src/mocks/setup.ts
··· 80 80 return result; 81 81 } 82 82 83 + type MockAppLayout = { 84 + active_project_id: string | null; 85 + selected_change_id: string | null; 86 + sidebar_width: number; 87 + view_mode: 1 | 2; 88 + }; 89 + 83 90 let mockProjects: Repository[] = [ 84 91 { 85 92 id: "mock-1", ··· 96 103 revset_preset: null, 97 104 }, 98 105 ]; 106 + 107 + let mockLayout: MockAppLayout = { 108 + active_project_id: mockProjects[0]?.id ?? null, 109 + selected_change_id: null, 110 + sidebar_width: 25, 111 + view_mode: 1, 112 + }; 99 113 100 114 // Complex mock revision graph representing realistic development workflow 101 115 // Structure: Multiple unmerged feature branches with 3+ commits, diverse branching patterns ··· 920 934 remove_project: (args) => { 921 935 const projectId = args.projectId as string; 922 936 mockProjects = mockProjects.filter((p) => p.id !== projectId); 937 + if (mockLayout.active_project_id === projectId) { 938 + mockLayout.active_project_id = null; 939 + mockLayout.selected_change_id = null; 940 + } 941 + return undefined; 942 + }, 943 + get_layout: () => mockLayout, 944 + update_layout: (args) => { 945 + const updates = (args.layout ?? {}) as Partial<MockAppLayout>; 946 + const hasActiveProjectUpdate = typeof updates.active_project_id === "string"; 947 + if (hasActiveProjectUpdate) { 948 + mockLayout.active_project_id = updates.active_project_id ?? null; 949 + } 950 + if (updates.selected_change_id !== undefined || hasActiveProjectUpdate) { 951 + mockLayout.selected_change_id = updates.selected_change_id ?? null; 952 + } 953 + if (typeof updates.sidebar_width === "number" && updates.sidebar_width !== 0) { 954 + 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 + } 923 959 return undefined; 924 960 }, 925 961 find_project_by_path: (args) => {
+20 -1
apps/desktop/src/routes/index.tsx
··· 1 1 import { useLiveQuery } from "@tanstack/react-db"; 2 + import { useQuery } from "@tanstack/react-query"; 2 3 import { createRoute, Navigate } from "@tanstack/react-router"; 3 4 import { AppShell } from "@/components/AppShell"; 4 5 import { repositoriesCollection } from "@/db"; 6 + import { getLayout } from "@/tauri-commands"; 5 7 import { Route as rootRoute } from "./__root"; 6 8 7 9 export const Route = createRoute({ ··· 12 14 13 15 function IndexComponent() { 14 16 const { data: repositories = [] } = useLiveQuery(repositoriesCollection); 17 + const { data: layout, isPending: isLayoutPending } = useQuery({ 18 + queryKey: ["app-layout"], 19 + queryFn: getLayout, 20 + staleTime: Number.POSITIVE_INFINITY, 21 + enabled: repositories.length > 0, 22 + }); 15 23 16 24 if (repositories.length > 0) { 25 + if (isLayoutPending) { 26 + return null; 27 + } 28 + 17 29 // Sort by last_opened_at descending to get most recently opened repository 18 30 const sortedRepositories = [...repositories].sort( 19 31 (a, b) => (b.last_opened_at ?? 0) - (a.last_opened_at ?? 0), 20 32 ); 21 - return <Navigate to="/project/$projectId" params={{ projectId: sortedRepositories[0].id }} />; 33 + 34 + const persistedRepository = layout?.active_project_id 35 + ? repositories.find((repository) => repository.id === layout.active_project_id) 36 + : null; 37 + 38 + const targetRepository = persistedRepository ?? sortedRepositories[0]; 39 + 40 + return <Navigate to="/project/$projectId" params={{ projectId: targetRepository.id }} />; 22 41 } 23 42 24 43 return <AppShell />;
+15
apps/desktop/src/tauri-commands.ts
··· 111 111 return invoke("remove_project", { projectId: repositoryId }); 112 112 } 113 113 114 + export interface AppLayout { 115 + active_project_id: string | null; 116 + selected_change_id: string | null; 117 + sidebar_width: number; 118 + view_mode: 1 | 2; 119 + } 120 + 121 + export async function getLayout(): Promise<AppLayout> { 122 + return invoke<AppLayout>("get_layout"); 123 + } 124 + 125 + export async function updateLayout(layout: AppLayout): Promise<void> { 126 + return invoke("update_layout", { layout }); 127 + } 128 + 114 129 export async function watchRepository(repoPath: string): Promise<void> { 115 130 return invoke("watch_repository", { repoPath }); 116 131 }