a very good jj gui
0
fork

Configure Feed

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

Sprint 2: add operations log dialog with per-operation undo

+250 -27
+14
apps/desktop/src/components/AppShell.tsx
··· 27 27 import { CommandPalette } from "@/components/CommandPalette"; 28 28 import { PrerenderedDiffPanel } from "@/components/DiffPanel"; 29 29 import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp"; 30 + import { OperationsLog } from "@/components/OperationsLog"; 30 31 import { ProjectPicker } from "@/components/ProjectPicker"; 31 32 import { RevisionGraph, type RevisionGraphHandle } from "@/components/RevisionGraph"; 32 33 import { detectStacks, reorderForGraph } from "@/components/revision-graph-utils"; ··· 125 126 const [rebaseSourceKey, setRebaseSourceKey] = useState<string | null>(null); 126 127 const [projectPickerOpen, setProjectPickerOpen] = useState(false); 127 128 const [isSyncing, setIsSyncing] = useState(false); 129 + const [operationsLogOpen, setOperationsLogOpen] = useState(false); 128 130 const revisionGraphRef = useRef<RevisionGraphHandle>(null); 129 131 const revisionsPanelRef = useRef<HTMLDivElement>(null); 130 132 const diffPanelRef = useRef<HTMLDivElement>(null); ··· 553 555 setSearchOpen(true); 554 556 } 555 557 558 + function handleOpenOperationsLog() { 559 + if (!activeProject) return; 560 + setOperationsLogOpen(true); 561 + } 562 + 556 563 return ( 557 564 <> 558 565 <ProjectPicker ··· 565 572 onOpenRepo={handleAddRepository} 566 573 onOpenProjects={() => navigate({ to: "/repositories" })} 567 574 onOpenSettings={() => navigate({ to: "/settings" })} 575 + canOpenOperationsLog={!!activeProject} 576 + onOpenOperationsLog={handleOpenOperationsLog} 577 + /> 578 + <OperationsLog 579 + repoPath={activeProject?.path ?? null} 580 + open={operationsLogOpen} 581 + onOpenChange={setOperationsLogOpen} 568 582 /> 569 583 <KeyboardShortcutsHelp /> 570 584 <Search
+67 -26
apps/desktop/src/components/CommandPalette.tsx
··· 1 - import { Folder, Settings, SlidersHorizontal } from "lucide-react"; 2 - import { useState } from "react"; 1 + import { Folder, History, Settings, SlidersHorizontal, type LucideIcon } from "lucide-react"; 2 + import { useMemo, useState } from "react"; 3 3 import { 4 4 CommandDialog, 5 5 CommandEmpty, ··· 14 14 onOpenRepo: () => void; 15 15 onOpenProjects: () => void; 16 16 onOpenSettings: () => void; 17 + canOpenOperationsLog?: boolean; 18 + onOpenOperationsLog?: () => void; 19 + } 20 + 21 + interface PaletteAction { 22 + id: string; 23 + label: string; 24 + keywords: string[]; 25 + icon: LucideIcon; 26 + onSelect: () => void; 27 + disabled?: boolean; 17 28 } 18 29 19 30 export function CommandPalette({ 20 31 onOpenRepo, 21 32 onOpenProjects, 22 33 onOpenSettings, 34 + canOpenOperationsLog = false, 35 + onOpenOperationsLog, 23 36 }: CommandPaletteProps) { 24 37 const [open, setOpen] = useState(false); 25 38 26 39 useKeyboardShortcut({ 27 40 key: "k", 28 41 modifiers: { meta: true, ctrl: true }, 29 - onPress: () => setOpen((open) => !open), 42 + onPress: () => setOpen((prevOpen) => !prevOpen), 30 43 }); 31 44 32 - const handleOpenRepo = () => { 33 - onOpenRepo(); 45 + function select(handler: () => void) { 46 + handler(); 34 47 setOpen(false); 35 - }; 48 + } 49 + 50 + const actions = useMemo<PaletteAction[]>(() => { 51 + const baseActions: PaletteAction[] = [ 52 + { 53 + id: "open-repo", 54 + label: "Add a repository...", 55 + keywords: ["add", "repository", "open", "folder"], 56 + icon: Folder, 57 + onSelect: onOpenRepo, 58 + }, 59 + { 60 + id: "open-projects", 61 + label: "Manage repositories...", 62 + keywords: ["manage", "repositories", "projects"], 63 + icon: Settings, 64 + onSelect: onOpenProjects, 65 + }, 66 + { 67 + id: "open-settings", 68 + label: "Settings", 69 + keywords: ["settings", "preferences", "config"], 70 + icon: SlidersHorizontal, 71 + onSelect: onOpenSettings, 72 + }, 73 + ]; 36 74 37 - const handleOpenProjects = () => { 38 - onOpenProjects(); 39 - setOpen(false); 40 - }; 75 + if (onOpenOperationsLog) { 76 + baseActions.push({ 77 + id: "open-operations-log", 78 + label: "Open operations log", 79 + keywords: ["operations", "log", "history", "undo"], 80 + icon: History, 81 + onSelect: onOpenOperationsLog, 82 + disabled: !canOpenOperationsLog, 83 + }); 84 + } 41 85 42 - const handleOpenSettings = () => { 43 - onOpenSettings(); 44 - setOpen(false); 45 - }; 86 + return baseActions; 87 + }, [canOpenOperationsLog, onOpenOperationsLog, onOpenProjects, onOpenRepo, onOpenSettings]); 46 88 47 89 return ( 48 90 <CommandDialog open={open} onOpenChange={setOpen}> ··· 50 92 <CommandList> 51 93 <CommandEmpty>No actions found.</CommandEmpty> 52 94 <CommandGroup heading="Actions"> 53 - <CommandItem onSelect={handleOpenRepo}> 54 - <Folder className="mr-2 h-4 w-4" /> 55 - <span>Add a repository...</span> 56 - </CommandItem> 57 - <CommandItem onSelect={handleOpenProjects}> 58 - <Settings className="mr-2 h-4 w-4" /> 59 - <span>Manage repositories...</span> 60 - </CommandItem> 61 - <CommandItem onSelect={handleOpenSettings}> 62 - <SlidersHorizontal className="mr-2 h-4 w-4" /> 63 - <span>Settings</span> 64 - </CommandItem> 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 + ))} 65 106 </CommandGroup> 66 107 </CommandList> 67 108 </CommandDialog>
+168
apps/desktop/src/components/OperationsLog.tsx
··· 1 + import { RefreshCw } from "lucide-react"; 2 + import { useMemo, useState } from "react"; 3 + import { invalidateRepositoryQueries, queryClient } from "@/db"; 4 + import { getOperations, type Operation, undoOperation } from "@/tauri-commands"; 5 + import { Button } from "@/components/ui/button"; 6 + import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 7 + import { toast } from "@/components/ui/sonner"; 8 + import { useQuery } from "@tanstack/react-query"; 9 + 10 + interface OperationsLogProps { 11 + repoPath: string | null; 12 + open: boolean; 13 + onOpenChange: (open: boolean) => void; 14 + } 15 + 16 + function formatTimestamp(timestamp: string): string { 17 + const parsed = new Date(timestamp); 18 + if (Number.isNaN(parsed.getTime())) { 19 + return timestamp; 20 + } 21 + return parsed.toLocaleString(); 22 + } 23 + 24 + function getUndoHint(errorMessage: string): string { 25 + const lower = errorMessage.toLowerCase(); 26 + 27 + if (lower.includes("root") || lower.includes("initial")) { 28 + return "The initial/root operation cannot be undone."; 29 + } 30 + 31 + if ( 32 + lower.includes("descendant") || 33 + lower.includes("head") || 34 + lower.includes("newer") || 35 + lower.includes("conflict") 36 + ) { 37 + return "Undo a newer operation first, then retry."; 38 + } 39 + 40 + return "Check the operation order and try again."; 41 + } 42 + 43 + function operationTimestampMillis(operation: Operation): number { 44 + const millis = new Date(operation.timestamp).getTime(); 45 + return Number.isNaN(millis) ? 0 : millis; 46 + } 47 + 48 + export function OperationsLog({ repoPath, open, onOpenChange }: OperationsLogProps) { 49 + const [undoingOperationId, setUndoingOperationId] = useState<string | null>(null); 50 + 51 + const { 52 + data: operations = [], 53 + isLoading, 54 + error, 55 + isFetching, 56 + refetch, 57 + } = useQuery({ 58 + queryKey: ["operations", repoPath], 59 + queryFn: () => getOperations(repoPath ?? "", 50), 60 + enabled: open && !!repoPath, 61 + retry: false, 62 + }); 63 + 64 + const sortedOperations = useMemo(() => { 65 + const copy = [...operations]; 66 + copy.sort((left, right) => operationTimestampMillis(right) - operationTimestampMillis(left)); 67 + return copy; 68 + }, [operations]); 69 + 70 + async function handleUndo(operation: Operation) { 71 + if (!repoPath || undoingOperationId) return; 72 + 73 + setUndoingOperationId(operation.id); 74 + try { 75 + await undoOperation(repoPath, operation.id); 76 + await Promise.all([ 77 + invalidateRepositoryQueries(repoPath), 78 + queryClient.invalidateQueries({ queryKey: ["operations", repoPath] }), 79 + ]); 80 + toast.success(`Undid operation ${operation.id.slice(0, 8)}`); 81 + void refetch(); 82 + } catch (undoError) { 83 + const message = undoError instanceof Error ? undoError.message : String(undoError); 84 + const hint = getUndoHint(message); 85 + toast.error("Failed to undo operation", { 86 + description: `${message} ${hint}`, 87 + duration: Number.POSITIVE_INFINITY, 88 + }); 89 + } finally { 90 + setUndoingOperationId(null); 91 + } 92 + } 93 + 94 + return ( 95 + <Dialog open={open} onOpenChange={onOpenChange}> 96 + <DialogContent className="sm:max-w-3xl"> 97 + <DialogHeader> 98 + <div className="flex items-center justify-between gap-3"> 99 + <DialogTitle>Operations log</DialogTitle> 100 + <Button 101 + variant="ghost" 102 + size="xs" 103 + onClick={() => { 104 + void refetch(); 105 + }} 106 + disabled={!repoPath || isFetching} 107 + > 108 + <RefreshCw className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`} /> 109 + Refresh 110 + </Button> 111 + </div> 112 + </DialogHeader> 113 + <div className="max-h-[420px] overflow-y-auto rounded-sm border border-border"> 114 + {!repoPath ? ( 115 + <p className="p-3 text-xs text-muted-foreground">Select a repository first.</p> 116 + ) : isLoading ? ( 117 + <p className="p-3 text-xs text-muted-foreground">Loading operations...</p> 118 + ) : error ? ( 119 + <p className="p-3 text-xs text-destructive"> 120 + {error instanceof Error 121 + ? error.message 122 + : `Failed to load operations: ${String(error)}`} 123 + </p> 124 + ) : sortedOperations.length === 0 ? ( 125 + <p className="p-3 text-xs text-muted-foreground">No operations found.</p> 126 + ) : ( 127 + <ul className="divide-y divide-border"> 128 + {sortedOperations.map((operation) => { 129 + const isUndoingThis = undoingOperationId === operation.id; 130 + const isUndoingAny = undoingOperationId !== null; 131 + return ( 132 + <li key={operation.id} className="p-3"> 133 + <div className="flex items-start justify-between gap-3"> 134 + <div className="min-w-0"> 135 + <p className="text-xs font-medium leading-5"> 136 + {operation.description || "(no description)"} 137 + </p> 138 + <p className="mt-1 text-[11px] text-muted-foreground"> 139 + {formatTimestamp(operation.timestamp)} • {operation.user}@ 140 + {operation.hostname} 141 + </p> 142 + </div> 143 + <div className="flex items-center gap-2 shrink-0"> 144 + <code className="text-[10px] text-muted-foreground"> 145 + {operation.id.slice(0, 8)} 146 + </code> 147 + <Button 148 + variant="outline" 149 + size="xs" 150 + onClick={() => { 151 + void handleUndo(operation); 152 + }} 153 + disabled={isUndoingAny} 154 + > 155 + {isUndoingThis ? "Undoing..." : "Undo"} 156 + </Button> 157 + </div> 158 + </div> 159 + </li> 160 + ); 161 + })} 162 + </ul> 163 + )} 164 + </div> 165 + </DialogContent> 166 + </Dialog> 167 + ); 168 + }
+1 -1
apps/desktop/src/db.ts
··· 165 165 ); 166 166 } 167 167 168 - async function invalidateRepositoryQueries(repoPath: string): Promise<void> { 168 + export async function invalidateRepositoryQueries(repoPath: string): Promise<void> { 169 169 await queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 170 170 await queryClient.invalidateQueries({ queryKey: ["revision-changes", repoPath] }); 171 171 await queryClient.invalidateQueries({ queryKey: ["revision-diff", repoPath] });