a very good jj gui
0
fork

Configure Feed

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

refactor: cleanup AppShell by removing debug code and stack view feature

+92 -201
+19 -4
apps/desktop/src/atoms.ts
··· 17 17 // Tracks which stack is currently hovered (for coordinated edge highlighting) 18 18 export const hoveredStackIdAtom = Atom.make<string | null>(null); 19 19 20 + // DEBUG STATE 21 + /** Debug overlay visibility (Ctrl+Shift+D) */ 22 + export const debugOverlayEnabledAtom = Atom.make(false); 23 + 20 24 // Diff panel state 21 25 export type DiffStyle = "unified" | "split"; 22 26 export const diffStyleAtom = Atom.make<DiffStyle>("unified"); 23 - // Tracks expanded files in diff panel (null = not initialized, will default to first file) 24 - export const expandedDiffFilesAtom = Atom.make<Set<string> | null>(null); 25 - // Per-file diff style overrides (file path -> style) 26 - export const fileDiffStyleOverridesAtom = Atom.make<Map<string, DiffStyle>>(new Map()); 27 + 28 + // Unified diff view state that auto-resets when changeId changes 29 + export type DiffViewState = { 30 + forChangeId: string | null; 31 + expandedFiles: Set<string>; 32 + styleOverrides: Map<string, DiffStyle>; 33 + }; 34 + 35 + const initialDiffViewState: DiffViewState = { 36 + forChangeId: null, 37 + expandedFiles: new Set<string>(), 38 + styleOverrides: new Map<string, DiffStyle>(), 39 + }; 40 + 41 + export const diffViewStateAtom = Atom.make(initialDiffViewState);
+73 -197
apps/desktop/src/components/AppShell.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { useLiveQuery } from "@tanstack/react-db"; 3 3 import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; 4 - import { homeDir } from "@tauri-apps/api/path"; 5 - import { getCurrentWindow } from "@tauri-apps/api/window"; 6 - import { open } from "@tauri-apps/plugin-dialog"; 7 - import { Effect } from "effect"; 8 4 import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; 9 - import { expandedStacksAtom, stackViewChangeIdAtom, viewModeAtom } from "@/atoms"; 5 + import { Route as ProjectRoute } from "@/routes/project.$projectId"; 6 + import { expandedStacksAtom, viewModeAtom } from "@/atoms"; 10 7 11 8 const NARROW_BREAKPOINT = 768; 12 9 ··· 31 28 import { ProjectPicker } from "@/components/ProjectPicker"; 32 29 import { RevisionGraph, type RevisionGraphHandle } from "@/components/RevisionGraph"; 33 30 import { detectStacks, reorderForGraph } from "@/components/revision-graph-utils"; 34 - import { StackIndicator } from "@/components/StackIndicator"; 35 31 import { StatusBar } from "@/components/StatusBar"; 36 32 import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; 37 33 38 34 import { 39 35 abandonRevision, 40 - addRepository, 41 36 editRevision, 42 37 emptyChangesCollection, 43 38 emptyCommitRecencyCollection, ··· 48 43 newRevision, 49 44 repositoriesCollection, 50 45 } from "@/db"; 46 + import { useAddRepository } from "@/hooks/useAddRepository"; 47 + import { useAppTitle } from "@/hooks/useAppTitle"; 51 48 import { useKeyboardNavigation, useKeyboardShortcut, useKeySequence } from "@/hooks/useKeyboard"; 52 - import { 53 - findRepository, 54 - findRepositoryByPath, 55 - type Repository, 56 - type Revision, 57 - } from "@/tauri-commands"; 49 + import type { Repository, Revision } from "@/tauri-commands"; 58 50 59 - const openDirectoryDialogEffect = Effect.gen(function* () { 60 - const home = yield* Effect.tryPromise({ 61 - try: () => homeDir(), 62 - catch: (error) => new Error(`Failed to get home directory: ${error}`), 63 - }); 51 + // Wrapper component that handles the case when no project is selected 52 + export function AppShell() { 53 + const { projectId } = useParams({ strict: false }); 64 54 65 - return yield* Effect.tryPromise({ 66 - try: () => 67 - open({ 68 - directory: true, 69 - multiple: false, 70 - defaultPath: home, 71 - title: "Select Repository", 72 - }), 73 - catch: (error) => new Error(`Failed to open directory dialog: ${error}`), 74 - }); 75 - }); 55 + if (!projectId) { 56 + return <AppShellEmpty />; 57 + } 76 58 77 - const findRepositoryEffect = (startPath: string) => 78 - Effect.tryPromise({ 79 - try: () => findRepository(startPath), 80 - catch: (error) => new Error(`Failed to find repository: ${error}`), 81 - }); 59 + return <AppShellWithProject />; 60 + } 82 61 83 - export function AppShell() { 62 + // Empty state when no project is selected (rendered from root "/" route) 63 + function AppShellEmpty() { 84 64 const navigate = useNavigate(); 85 - const { projectId } = useParams({ strict: false }); 86 - const rev = useSearch({ strict: false, select: (s) => s.rev }); 87 - const expanded = useSearch({ strict: false, select: (s) => s.expanded }); 88 - const file = useSearch({ strict: false, select: (s) => s.file }); 65 + const { handleAddRepository } = useAddRepository(); 66 + const { data: repositories = [] } = useLiveQuery(repositoriesCollection); 67 + 68 + function handleSelectRepository(repository: Repository) { 69 + navigate({ to: "/project/$projectId", params: { projectId: repository.id } }); 70 + } 71 + 72 + useAppTitle("Tatami"); 73 + 74 + return ( 75 + <> 76 + <AceJump revisions={[]} repoPath={null} onJump={() => {}} /> 77 + <CommandPalette 78 + onOpenRepo={handleAddRepository} 79 + onOpenProjects={() => navigate({ to: "/repositories" })} 80 + onOpenSettings={() => navigate({ to: "/settings" })} 81 + /> 82 + <KeyboardShortcutsHelp /> 83 + <ProjectPicker repositories={repositories} onSelectRepository={handleSelectRepository} /> 84 + <div className="flex flex-col h-screen overflow-hidden"> 85 + <div className="flex-1 min-h-0 flex items-center justify-center text-muted-foreground"> 86 + <p>Select or add a repository to get started</p> 87 + </div> 88 + <StatusBar branch={null} isConnected={false} /> 89 + </div> 90 + </> 91 + ); 92 + } 93 + 94 + // Full app shell when a project is selected (rendered from "/project/$projectId" route) 95 + function AppShellWithProject() { 96 + const navigate = useNavigate({ from: ProjectRoute.fullPath }); 97 + const { projectId } = useParams({ from: ProjectRoute.fullPath }); 98 + const rev = useSearch({ from: ProjectRoute.fullPath, select: (s) => s.rev }); 99 + const expanded = useSearch({ from: ProjectRoute.fullPath, select: (s) => s.expanded }); 100 + const file = useSearch({ from: ProjectRoute.fullPath, select: (s) => s.file }); 89 101 // Get full search object for navigation (only re-renders when expanded/file/rev change, which we need anyway) 90 - const search = useSearch({ strict: false }); 102 + const search = useSearch({ from: ProjectRoute.fullPath }); 91 103 const [flash, setFlash] = useState<{ changeId: string; key: number } | null>(null); 92 - const [stackViewChangeId, setStackViewChangeId] = useAtom(stackViewChangeIdAtom); 93 104 const [viewMode, setViewMode] = useAtom(viewModeAtom); 94 105 const [pendingAbandon, setPendingAbandon] = useState<Revision | null>(null); 95 106 const revisionGraphRef = useRef<RevisionGraphHandle>(null); 96 107 const isNarrowScreen = useIsNarrowScreen(); 108 + const { handleAddRepository } = useAddRepository(); 97 109 98 110 useKeyboardShortcut({ 99 111 key: ",", ··· 104 116 const { data: repositories = [] } = useLiveQuery(repositoriesCollection); 105 117 106 118 const activeProject = repositories.find((p) => p.id === projectId) ?? null; 107 - const titleLabel = activeProject ? `Tatami - ${activeProject.path}` : "Tatami"; 108 119 109 - // Build the stack revset: the full branch containing the selected commit 110 - // (::X ~ ::trunk()) gives ancestors of X that are NOT ancestors of trunk (the branch below X) 111 - // X:: gives descendants of X (the branch above X) 112 - // roots(...)- gives the parent of the first branch commit (the merge base) 113 - // (X & ::trunk()) handles the case where X is already an ancestor of trunk (just show X) 114 - const stackRevset = stackViewChangeId 115 - ? `(::${stackViewChangeId} ~ ::trunk()) | (${stackViewChangeId}:: ~ ::trunk()) | roots(::${stackViewChangeId} ~ ::trunk())- | (${stackViewChangeId} & ::trunk())` 116 - : undefined; 120 + useAppTitle(activeProject ? `Tatami - ${activeProject.path}` : "Tatami"); 117 121 118 122 const revisionsCollection = activeProject 119 - ? getRevisionsCollection( 120 - activeProject.path, 121 - activeProject.revset_preset ?? "full_history", 122 - stackRevset, 123 - ) 123 + ? getRevisionsCollection(activeProject.path, activeProject.revset_preset ?? "full_history") 124 124 : emptyRevisionsCollection; 125 125 126 126 const { data: revisions = [], isLoading = false } = useLiveQuery(revisionsCollection); ··· 155 155 return orderedRevisions.filter((r) => !hiddenChangeIds.has(r.change_id)); 156 156 }, [revisions, orderedRevisions, expandedStacks]); 157 157 158 - // Debug: log when revisions change to track reordering 159 - const workingCopy = revisions.find((r) => r.is_working_copy); 160 - const prevOrderRef = useRef<string[]>([]); 161 - useEffect(() => { 162 - const currentOrder = orderedRevisions.map((r) => r.change_id); 163 - const prevOrder = prevOrderRef.current; 164 - 165 - // Find which revisions changed position 166 - const changes: string[] = []; 167 - for (let i = 0; i < Math.min(currentOrder.length, prevOrder.length); i++) { 168 - if (currentOrder[i] !== prevOrder[i]) { 169 - changes.push( 170 - `[${i}] ${prevOrder[i]?.slice(0, 4) ?? "?"} → ${currentOrder[i]?.slice(0, 4) ?? "?"}`, 171 - ); 172 - if (changes.length >= 10) break; 173 - } 174 - } 175 - 176 - if (changes.length > 0 || prevOrder.length !== currentOrder.length) { 177 - console.log("[reorder] changes detected:", { 178 - prevLength: prevOrder.length, 179 - newLength: currentOrder.length, 180 - wcBefore: prevOrder.findIndex( 181 - (id) => revisions.find((r) => r.change_id === id)?.is_working_copy, 182 - ), 183 - wcAfter: currentOrder.findIndex((id) => id === workingCopy?.change_id), 184 - firstChanges: changes, 185 - first10: currentOrder.slice(0, 10).map((id) => id.slice(0, 4)), 186 - }); 187 - } 188 - 189 - prevOrderRef.current = currentOrder; 190 - }, [orderedRevisions, workingCopy?.change_id, revisions]); 191 - 192 - useEffect(() => { 193 - document.title = titleLabel; 194 - const windowHandle = getCurrentWindow(); 195 - windowHandle.setTitle(titleLabel).catch(() => undefined); 196 - }, [titleLabel]); 197 - 198 158 const selectedRevision = (() => { 199 159 if (revisions.length === 0) return null; 200 160 if (rev) { ··· 204 164 return revisions.find((r) => r.is_working_copy) || revisions[0]; 205 165 })(); 206 166 207 - function handleOpenRepo() { 208 - const program = Effect.gen(function* () { 209 - const selected = yield* openDirectoryDialogEffect; 210 - if (!selected) return; 211 - 212 - const repoPath = yield* findRepositoryEffect(selected); 213 - if (!repoPath) return; 214 - 215 - const existingRepository = yield* Effect.tryPromise({ 216 - try: () => findRepositoryByPath(repoPath), 217 - catch: () => null, 218 - }); 219 - 220 - const repositoryId = existingRepository?.id ?? crypto.randomUUID(); 221 - const name = repoPath.split("/").pop() ?? repoPath; 222 - 223 - const repository: Repository = { 224 - id: repositoryId, 225 - path: repoPath, 226 - name, 227 - last_opened_at: Date.now(), 228 - revset_preset: null, 229 - }; 230 - 231 - yield* Effect.tryPromise({ 232 - try: () => addRepository(repositoriesCollection, repository), 233 - catch: (error) => new Error(`Failed to save repository: ${error}`), 234 - }); 235 - 236 - yield* Effect.sync(() => { 237 - navigate({ to: "/project/$projectId", params: { projectId: repositoryId } }); 238 - }); 239 - }).pipe( 240 - Effect.tapError((error) => Effect.logError("handleOpenRepo failed", error)), 241 - Effect.catchAll(() => Effect.void), 242 - ); 243 - Effect.runPromise(program); 244 - } 245 - 246 167 function handleSelectRepository(repository: Repository) { 247 - setStackViewChangeId(null); // Clear stack view when switching repositories 248 168 navigate({ to: "/project/$projectId", params: { projectId: repository.id } }); 249 169 } 250 170 ··· 301 221 302 222 function handleNew() { 303 223 if (!activeProject || !selectedRevision) return; 304 - newRevision(activeProject.path, [selectedRevision.change_id]); 224 + const currentWC = revisions.find((r) => r.is_working_copy); 225 + newRevision( 226 + revisionsCollection, 227 + activeProject.path, 228 + [selectedRevision.change_id], 229 + selectedRevision, 230 + currentWC ?? null, 231 + ); 305 232 } 306 233 307 234 function handleEdit() { 308 235 if (!activeProject || !selectedRevision) return; 309 236 const currentWC = revisions.find((r) => r.is_working_copy); 310 - 311 - // Debug: log indices before edit 312 - const currentWCIndex = orderedRevisions.findIndex((r) => r.change_id === currentWC?.change_id); 313 - const targetIndex = orderedRevisions.findIndex( 314 - (r) => r.change_id === selectedRevision.change_id, 315 - ); 316 - console.log("[edit] before:", { 317 - currentWC: currentWC?.change_id_short, 318 - currentWCIndex, 319 - target: selectedRevision.change_id_short, 320 - targetIndex, 321 - totalRevisions: orderedRevisions.length, 322 - }); 323 - 324 237 editRevision(revisionsCollection, activeProject.path, selectedRevision, currentWC ?? null); 325 238 } 326 239 ··· 346 259 347 260 function confirmAbandon() { 348 261 if (!activeProject || !pendingAbandon) return; 349 - const preset = activeProject.revset_preset ?? "full_history"; 350 - const limit = preset === "full_history" ? 10000 : 100; 351 - abandonRevision( 352 - revisionsCollection, 353 - activeProject.path, 354 - pendingAbandon, 355 - limit, 356 - stackRevset, 357 - preset, 358 - ); 262 + abandonRevision(revisionsCollection, activeProject.path, pendingAbandon); 359 263 setPendingAbandon(null); 360 264 } 361 265 ··· 388 292 enabled: !!pendingAbandon, 389 293 }); 390 294 391 - // Toggle stack view: show only ancestors from selected revision to trunk 392 - function handleToggleStackView() { 393 - if (!selectedRevision) return; 394 - if (stackViewChangeId) { 395 - // Turn off stack view 396 - setStackViewChangeId(null); 397 - } else { 398 - // Turn on stack view anchored to selected revision 399 - setStackViewChangeId(selectedRevision.change_id); 400 - } 401 - } 402 - 403 - useKeyboardShortcut({ 404 - key: "s", 405 - onPress: handleToggleStackView, 406 - enabled: !!activeProject && !!selectedRevision, 407 - }); 408 - 409 295 // View mode shortcuts: 1 = overview, 2 = split 410 296 useKeyboardShortcut({ 411 297 key: "1", ··· 450 336 451 337 if (nextIndex < filePaths.length) { 452 338 navigate({ 453 - // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 454 - search: { ...search, file: filePaths[nextIndex], expanded: true } as any, 339 + search: { ...search, file: filePaths[nextIndex], expanded: true }, 455 340 }); 456 341 } else if (currentIndex === -1 && filePaths.length > 0) { 457 342 navigate({ 458 - // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 459 - search: { ...search, file: filePaths[0], expanded: true } as any, 343 + search: { ...search, file: filePaths[0], expanded: true }, 460 344 }); 461 345 } 462 346 } else if (event.key === "k") { ··· 467 351 if (currentIndex > 0) { 468 352 const prevIndex = currentIndex - 1; 469 353 navigate({ 470 - // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 471 - search: { ...search, file: filePaths[prevIndex], expanded: true } as any, 354 + search: { ...search, file: filePaths[prevIndex], expanded: true }, 472 355 }); 473 356 } else if (currentIndex === -1 && filePaths.length > 0) { 474 357 navigate({ 475 - // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 476 - search: { ...search, file: filePaths[filePaths.length - 1], expanded: true } as any, 358 + search: { 359 + ...search, 360 + file: filePaths[filePaths.length - 1], 361 + expanded: true, 362 + }, 477 363 }); 478 364 } 479 365 } ··· 523 409 <> 524 410 <ProjectPicker repositories={repositories} onSelectRepository={handleSelectRepository} /> 525 411 <CommandPalette 526 - onOpenRepo={handleOpenRepo} 412 + onOpenRepo={handleAddRepository} 527 413 onOpenProjects={() => navigate({ to: "/repositories" })} 528 414 onOpenSettings={() => navigate({ to: "/settings" })} 529 415 /> ··· 544 430 {viewMode === 1 ? ( 545 431 // Overview mode: only revision list 546 432 <section className="h-full relative" aria-label="Revision list"> 547 - <StackIndicator 548 - onDismiss={() => { 549 - handleNavigateToChangeId(""); 550 - }} 551 - /> 552 433 <RevisionGraph 553 434 ref={revisionGraphRef} 554 435 revisions={revisions} ··· 565 446 <ResizablePanelGroup orientation={isNarrowScreen ? "vertical" : "horizontal"}> 566 447 <ResizablePanel defaultSize={isNarrowScreen ? 40 : 33} minSize={20}> 567 448 <section className="h-full relative" aria-label="Revision list"> 568 - <StackIndicator 569 - onDismiss={() => { 570 - handleNavigateToChangeId(""); 571 - }} 572 - /> 573 449 <RevisionGraph 574 450 ref={revisionGraphRef} 575 451 revisions={revisions}