a very good jj gui
0
fork

Configure Feed

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

feat(ui): add revset preset selector with stack view support

+171 -30
+2 -2
apps/desktop/src-tauri/src/lib.rs
··· 49 49 } 50 50 51 51 #[tauri::command] 52 - async fn get_revisions(repo_path: String, limit: usize, revset: Option<String>) -> Result<Vec<Revision>, String> { 52 + async fn get_revisions(repo_path: String, limit: usize, revset: Option<String>, preset: Option<String>) -> Result<Vec<Revision>, String> { 53 53 let path = Path::new(&repo_path); 54 - repo::log::fetch_log(path, limit, revset.as_deref()).map_err(|e| format!("Failed to fetch log: {}", e)) 54 + repo::log::fetch_log(path, limit, revset.as_deref(), preset.as_deref()).map_err(|e| format!("Failed to fetch log: {}", e)) 55 55 } 56 56 57 57 #[tauri::command]
+21 -10
apps/desktop/src-tauri/src/storage.rs
··· 10 10 pub path: String, 11 11 pub name: String, 12 12 pub last_opened_at: i64, 13 + pub revset_preset: Option<String>, 13 14 } 14 15 15 16 #[derive(Debug, Clone, Serialize, Deserialize)] ··· 51 52 id TEXT PRIMARY KEY, 52 53 path TEXT NOT NULL UNIQUE, 53 54 name TEXT NOT NULL, 54 - last_opened_at INTEGER NOT NULL 55 + last_opened_at INTEGER NOT NULL, 56 + revset_preset TEXT 55 57 ) 56 58 "#, 57 59 ) 58 60 .execute(&pool) 59 61 .await?; 60 62 63 + // Migration: add revset_preset column if it doesn't exist 64 + let _ = sqlx::query("ALTER TABLE projects ADD COLUMN revset_preset TEXT") 65 + .execute(&pool) 66 + .await; 67 + 61 68 sqlx::query( 62 69 r#" 63 70 CREATE TABLE IF NOT EXISTS layout ( ··· 87 94 } 88 95 89 96 pub async fn get_projects(&self) -> anyhow::Result<Vec<Project>> { 90 - let rows: Vec<(String, String, String, i64)> = sqlx::query_as( 91 - "SELECT id, path, name, last_opened_at FROM projects ORDER BY last_opened_at DESC", 97 + let rows: Vec<(String, String, String, i64, Option<String>)> = sqlx::query_as( 98 + "SELECT id, path, name, last_opened_at, revset_preset FROM projects ORDER BY last_opened_at DESC", 92 99 ) 93 100 .fetch_all(&self.pool) 94 101 .await?; 95 102 96 103 Ok(rows 97 104 .into_iter() 98 - .map(|(id, path, name, last_opened_at)| Project { 105 + .map(|(id, path, name, last_opened_at, revset_preset)| Project { 99 106 id, 100 107 path, 101 108 name, 102 109 last_opened_at, 110 + revset_preset, 103 111 }) 104 112 .collect()) 105 113 } ··· 107 115 pub async fn upsert_project(&self, project: &Project) -> anyhow::Result<()> { 108 116 sqlx::query( 109 117 r#" 110 - INSERT INTO projects (id, path, name, last_opened_at) 111 - VALUES (?, ?, ?, ?) 118 + INSERT INTO projects (id, path, name, last_opened_at, revset_preset) 119 + VALUES (?, ?, ?, ?, ?) 112 120 ON CONFLICT(id) DO UPDATE SET 113 121 path = excluded.path, 114 122 name = excluded.name, 115 - last_opened_at = excluded.last_opened_at 123 + last_opened_at = excluded.last_opened_at, 124 + revset_preset = excluded.revset_preset 116 125 "#, 117 126 ) 118 127 .bind(&project.id) 119 128 .bind(&project.path) 120 129 .bind(&project.name) 121 130 .bind(project.last_opened_at) 131 + .bind(&project.revset_preset) 122 132 .execute(&self.pool) 123 133 .await?; 124 134 ··· 126 136 } 127 137 128 138 pub async fn find_project_by_path(&self, path: &str) -> anyhow::Result<Option<Project>> { 129 - let row: Option<(String, String, String, i64)> = 130 - sqlx::query_as("SELECT id, path, name, last_opened_at FROM projects WHERE path = ?") 139 + let row: Option<(String, String, String, i64, Option<String>)> = 140 + sqlx::query_as("SELECT id, path, name, last_opened_at, revset_preset FROM projects WHERE path = ?") 131 141 .bind(path) 132 142 .fetch_optional(&self.pool) 133 143 .await?; 134 144 135 - Ok(row.map(|(id, path, name, last_opened_at)| Project { 145 + Ok(row.map(|(id, path, name, last_opened_at, revset_preset)| Project { 136 146 id, 137 147 path, 138 148 name, 139 149 last_opened_at, 150 + revset_preset, 140 151 })) 141 152 } 142 153
+2 -1
apps/desktop/src/atoms.ts
··· 2 2 3 3 export const shortcutsHelpOpenAtom = Atom.make(false); 4 4 export const aceJumpOpenAtom = Atom.make(false); 5 - export const expandedElidedSectionsAtom = Atom.make<string[]>([]); 5 + // When set, shows only the stack (ancestors) from this change_id down to trunk 6 + export const stackViewChangeIdAtom = Atom.make<string | null>(null);
+52 -2
apps/desktop/src/components/AppShell.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 1 2 import { useLiveQuery } from "@tanstack/react-db"; 2 3 import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; 3 4 import { homeDir } from "@tauri-apps/api/path"; 4 5 import { open } from "@tauri-apps/plugin-dialog"; 5 6 import { Effect } from "effect"; 6 7 import { useState } from "react"; 8 + import { stackViewChangeIdAtom } from "@/atoms"; 7 9 import { AceJump } from "@/components/AceJump"; 8 10 import { CommandPalette } from "@/components/CommandPalette"; 9 11 import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp"; ··· 55 57 const { projectId } = useParams({ strict: false }); 56 58 const { rev } = useSearch({ strict: false }); 57 59 const [flash, setFlash] = useState<{ changeId: string; key: number } | null>(null); 60 + const [stackViewChangeId, setStackViewChangeId] = useAtom(stackViewChangeIdAtom); 58 61 59 62 useKeyboardShortcut({ 60 63 key: ",", ··· 66 69 67 70 const activeProject = projects.find((p) => p.id === projectId) ?? null; 68 71 72 + // Build the stack revset: the branch from merge-base to the selected commit 73 + // (::X ~ ::trunk()) gives ancestors of X that are NOT ancestors of trunk (the branch) 74 + // roots(...)- gives the parent of the first branch commit (the merge base) 75 + // (X & ::trunk()) handles the case where X is already an ancestor of trunk (just show X) 76 + const stackRevset = stackViewChangeId 77 + ? `(::${stackViewChangeId} ~ ::trunk()) | roots(::${stackViewChangeId} ~ ::trunk())- | (${stackViewChangeId} & ::trunk())` 78 + : undefined; 79 + 69 80 const revisionsCollection = activeProject 70 - ? getRevisionsCollection(activeProject.path) 81 + ? getRevisionsCollection( 82 + activeProject.path, 83 + activeProject.revset_preset ?? undefined, 84 + stackRevset, 85 + ) 71 86 : emptyRevisionsCollection; 72 87 73 88 const { data: revisions = [], isLoading = false } = useLiveQuery(revisionsCollection); ··· 104 119 path: repoPath, 105 120 name, 106 121 last_opened_at: Date.now(), 122 + revset_preset: null, 107 123 }; 108 124 109 125 yield* Effect.tryPromise({ ··· 123 139 } 124 140 125 141 function handleSelectProject(project: Project) { 142 + setStackViewChangeId(null); // Clear stack view when switching projects 126 143 navigate({ to: "/project/$projectId", params: { projectId: project.id } }); 127 144 } 128 145 ··· 186 203 editRevision(activeProject.path, selectedRevision.change_id, currentWC?.change_id ?? null); 187 204 } 188 205 206 + function handlePresetChange(preset: string | null) { 207 + if (!activeProject || !preset) return; 208 + const updatedProject: Project = { 209 + ...activeProject, 210 + revset_preset: preset, 211 + last_opened_at: Date.now(), 212 + }; 213 + upsertProject(updatedProject); 214 + projectsCollection.utils.writeUpsert([updatedProject]); 215 + } 216 + 189 217 useKeyboardShortcut({ 190 218 key: "n", 191 219 onPress: handleNew, ··· 195 223 useKeyboardShortcut({ 196 224 key: "e", 197 225 onPress: handleEdit, 226 + enabled: !!activeProject && !!selectedRevision, 227 + }); 228 + 229 + // Toggle stack view: show only ancestors from selected revision to trunk 230 + function handleToggleStackView() { 231 + if (!selectedRevision) return; 232 + if (stackViewChangeId) { 233 + // Turn off stack view 234 + setStackViewChangeId(null); 235 + } else { 236 + // Turn on stack view anchored to selected revision 237 + setStackViewChangeId(selectedRevision.change_id); 238 + } 239 + } 240 + 241 + useKeyboardShortcut({ 242 + key: "s", 243 + onPress: handleToggleStackView, 198 244 enabled: !!activeProject && !!selectedRevision, 199 245 }); 200 246 ··· 243 289 <KeyboardShortcutsHelp /> 244 290 <AceJump revisions={orderedRevisions} onJump={handleNavigateToChangeId} /> 245 291 <div className="flex flex-col h-screen overflow-hidden"> 246 - <Toolbar repoPath={activeProject?.path ?? null} /> 292 + <Toolbar 293 + repoPath={activeProject?.path ?? null} 294 + currentPreset={activeProject?.revset_preset ?? "active"} 295 + onPresetChange={handlePresetChange} 296 + /> 247 297 <section className="flex-1 min-h-0" aria-label="Revision list"> 248 298 <RevisionGraph 249 299 revisions={revisions}
+1
apps/desktop/src/components/KeyboardShortcutsHelp.tsx
··· 23 23 items: [ 24 24 { keys: ["n"], description: "New revision on selected" }, 25 25 { keys: ["e"], description: "Edit selected revision" }, 26 + { keys: ["s"], description: "Toggle stack view" }, 26 27 ], 27 28 }, 28 29 {
+68 -3
apps/desktop/src/components/Toolbar.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import { useRef } from "react"; 3 + import { stackViewChangeIdAtom } from "@/atoms"; 4 + import { Badge } from "@/components/ui/badge"; 5 + import { 6 + Select, 7 + SelectContent, 8 + SelectItem, 9 + SelectTrigger, 10 + SelectValue, 11 + } from "@/components/ui/select"; 12 + import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 13 + 14 + const PRESET_REVSETS: Record<string, string> = { 15 + // Current stack: trunk to working copy 16 + stack: "trunk()..@ | trunk()", 17 + // Stack + my other mutable work + recent context 18 + active: 19 + "trunk()..@ | trunk() | (mine() & mutable() & ~::@) | (heads(mutable()) & ~::@) | ancestors(immutable_heads().., 2)", 20 + // Everything (debug/power user) 21 + full_history: "ancestors(visible_heads())", 22 + }; 23 + 1 24 interface ToolbarProps { 2 25 repoPath: string | null; 26 + currentPreset?: string; 27 + onPresetChange?: (preset: string) => void; 3 28 } 4 29 5 - export function Toolbar({ repoPath }: ToolbarProps) { 30 + export function Toolbar({ repoPath, currentPreset = "stack", onPresetChange }: ToolbarProps) { 31 + const [stackViewChangeId, setStackViewChangeId] = useAtom(stackViewChangeIdAtom); 32 + const revsetExpression = PRESET_REVSETS[currentPreset] ?? currentPreset; 33 + const selectTriggerRef = useRef<HTMLButtonElement>(null); 34 + 35 + useKeyboardShortcut({ 36 + key: "L", 37 + onPress: () => selectTriggerRef.current?.click(), 38 + enabled: !!repoPath && !!onPresetChange, 39 + }); 40 + 6 41 return ( 7 - <div className="flex items-center h-12 px-2 border-b border-border bg-card"> 8 - <div className="flex-1 text-sm text-muted-foreground truncate"> 42 + <div className="flex items-center h-10 px-2 border-b border-border bg-card gap-3"> 43 + <div className="text-xs text-muted-foreground truncate shrink-0"> 9 44 {repoPath || "No repository selected"} 10 45 </div> 46 + {repoPath && onPresetChange && ( 47 + <> 48 + <div className="h-4 w-px bg-border" /> 49 + {stackViewChangeId ? ( 50 + <Badge 51 + variant="secondary" 52 + className="text-xs cursor-pointer hover:bg-destructive/20" 53 + onClick={() => setStackViewChangeId(null)} 54 + > 55 + Stack: {stackViewChangeId.slice(0, 8)} ✕ 56 + </Badge> 57 + ) : ( 58 + <Select value={currentPreset} onValueChange={(v) => v && onPresetChange(v)}> 59 + <SelectTrigger ref={selectTriggerRef} size="sm" className="w-[110px] h-6 text-xs"> 60 + <SelectValue /> 61 + </SelectTrigger> 62 + <SelectContent> 63 + <SelectItem value="stack">Stack</SelectItem> 64 + <SelectItem value="active">Active</SelectItem> 65 + <SelectItem value="full_history">Full history</SelectItem> 66 + </SelectContent> 67 + </Select> 68 + )} 69 + <code className="text-[10px] text-muted-foreground/60 font-mono truncate"> 70 + {stackViewChangeId 71 + ? `::${stackViewChangeId.slice(0, 8)} ~ ::trunk()` 72 + : revsetExpression} 73 + </code> 74 + </> 75 + )} 11 76 </div> 12 77 ); 13 78 }
+8 -3
apps/desktop/src/components/ui/select.tsx
··· 27 27 ); 28 28 } 29 29 30 + type SelectTriggerProps = SelectPrimitive.Trigger.Props & { 31 + size?: "sm" | "default"; 32 + ref?: React.Ref<HTMLButtonElement>; 33 + }; 34 + 30 35 function SelectTrigger({ 31 36 className, 32 37 size = "default", 33 38 children, 39 + ref, 34 40 ...props 35 - }: SelectPrimitive.Trigger.Props & { 36 - size?: "sm" | "default"; 37 - }) { 41 + }: SelectTriggerProps) { 38 42 return ( 39 43 <SelectPrimitive.Trigger 44 + ref={ref} 40 45 data-slot="select-trigger" 41 46 data-size={size} 42 47 className={cn(
+15 -8
apps/desktop/src/db.ts
··· 28 28 const revisionCollections = new Map<string, ReturnType<typeof createRevisionsCollection>>(); 29 29 const revisionWatchers = new Map<string, { unlisten: () => void; refCount: number }>(); 30 30 31 - function createRevisionsCollection(repoPath: string) { 31 + function createRevisionsCollection(repoPath: string, preset?: string, customRevset?: string) { 32 + const limit = preset === "full_history" ? 10000 : 100; 32 33 const collection = createCollection({ 33 34 ...queryCollectionOptions({ 34 35 queryClient, 35 - queryKey: ["revisions", repoPath], 36 - queryFn: () => getRevisions(repoPath, 100), 36 + queryKey: ["revisions", repoPath, preset, customRevset], 37 + queryFn: () => getRevisions(repoPath, limit, customRevset, customRevset ? undefined : preset), 37 38 getKey: (revision: Revision) => revision.change_id, 38 39 }), 39 40 }); ··· 49 50 await watchRepository(repoPath); 50 51 const unlisten = await listen<string>("repo-changed", async (event) => { 51 52 if (event.payload === repoPath) { 52 - const revisions = await getRevisions(repoPath, 100); 53 + const revisions = await getRevisions( 54 + repoPath, 55 + limit, 56 + customRevset, 57 + customRevset ? undefined : preset, 58 + ); 53 59 const newIds = new Set(revisions.map((r) => r.change_id)); 54 60 for (const key of collection.state.keys()) { 55 61 if (!newIds.has(key)) { ··· 68 74 return collection; 69 75 } 70 76 71 - export function getRevisionsCollection(repoPath: string) { 72 - let collection = revisionCollections.get(repoPath); 77 + export function getRevisionsCollection(repoPath: string, preset?: string, customRevset?: string) { 78 + const cacheKey = `${repoPath}:${preset ?? "active"}:${customRevset ?? ""}`; 79 + let collection = revisionCollections.get(cacheKey); 73 80 if (!collection) { 74 - collection = createRevisionsCollection(repoPath); 75 - revisionCollections.set(repoPath, collection); 81 + collection = createRevisionsCollection(repoPath, preset, customRevset); 82 + revisionCollections.set(cacheKey, collection); 76 83 } 77 84 return collection; 78 85 }
+2 -1
apps/desktop/src/tauri-commands.ts
··· 20 20 repoPath: string, 21 21 limit: number, 22 22 revset?: string, 23 + preset?: string, 23 24 ): Promise<Revision[]> { 24 - return invoke<Revision[]>("get_revisions", { repoPath, limit, revset }); 25 + return invoke<Revision[]>("get_revisions", { repoPath, limit, revset, preset }); 25 26 } 26 27 27 28 export async function getStatus(repoPath: string): Promise<WorkingCopyStatus> {