a very good jj gui
0
fork

Configure Feed

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

refactor: misc improvements to AceJump, ChangedFilesList, KeyboardShortcutsHelp, and vite config

- AceJump: use useDeferredValue for filtering debounce, simplify state reset
- ChangedFilesList: convert FileRow to use button element for better a11y
- KeyboardShortcutsHelp: increase dialog width, improve layout
- vite.config: add agentation plugin

+66 -39
+37 -25
apps/desktop/src/components/AceJump.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import type React from "react"; 3 - import { useRef, useState, useEffect, useMemo, useCallback } from "react"; 3 + import { useRef, useState, useEffect, useMemo, useCallback, useDeferredValue } from "react"; 4 4 import { aceJumpOpenAtom } from "@/atoms"; 5 5 import { 6 6 CommandDialog, ··· 62 62 return false; 63 63 } 64 64 65 + // Initial revset result state 66 + const initialRevsetResult = { 67 + changeIds: [] as string[], 68 + error: null as string | null, 69 + loading: false, 70 + label: null as string | null, 71 + }; 72 + 65 73 export function AceJump({ revisions, repoPath, onJump }: AceJumpProps) { 66 - const [open, setOpen] = useAtom(aceJumpOpenAtom); 74 + const [open, setOpenRaw] = useAtom(aceJumpOpenAtom); 67 75 const [search, setSearch] = useState(""); 68 - const [revsetResult, setRevsetResult] = useState<{ 69 - changeIds: string[]; 70 - error: string | null; 71 - loading: boolean; 72 - label: string | null; 73 - }>({ changeIds: [], error: null, loading: false, label: null }); 76 + // Use React's useDeferredValue for filtering debounce - no useEffect needed 77 + const debouncedSearch = useDeferredValue(search); 78 + 79 + const [revsetResult, setRevsetResult] = useState(initialRevsetResult); 80 + 81 + // Wrap setOpen to reset state when opening 82 + const setOpen = useCallback( 83 + (nextOpen: boolean) => { 84 + if (nextOpen) { 85 + // Reset state when opening - no useEffect needed 86 + setSearch(""); 87 + setRevsetResult(initialRevsetResult); 88 + } 89 + setOpenRaw(nextOpen); 90 + }, 91 + [setOpenRaw], 92 + ); 74 93 75 94 useKeyboardShortcut({ 76 95 key: "/", 77 96 onPress: () => setOpen(true), 78 97 enabled: !open, 79 98 }); 80 - 81 - // Reset search when dialog opens 82 - useEffect(() => { 83 - if (open) { 84 - setSearch(""); 85 - setRevsetResult({ changeIds: [], error: null, loading: false, label: null }); 86 - } 87 - }, [open]); 88 99 89 100 // Stable ref for callback 90 101 const onJumpRef = useRef(onJump); ··· 132 143 [repoPath], 133 144 ); 134 145 135 - // Debounce the revset resolution 146 + // Debounce the revset resolution (async API call - acceptable use of useEffect) 136 147 useEffect(() => { 137 148 const timeout = setTimeout(() => { 138 149 resolveRevsetDebounced(search); ··· 149 160 150 161 // Determine if we're in revset mode 151 162 const isRevsetMode = 152 - isRevsetExpression(search) && 163 + isRevsetExpression(debouncedSearch) && 153 164 (revsetResult.loading || revsetResult.changeIds.length > 0 || revsetResult.error); 154 165 const revsetChangeIdSet = useMemo( 155 166 () => new Set(revsetResult.changeIds), ··· 163 174 if (isRevsetMode && revsetChangeIdSet.has(revision.change_id)) { 164 175 return "revset"; 165 176 } 166 - if (!search || isRevsetMode) return null; 167 - const lowerSearch = search.toLowerCase(); 177 + if (!debouncedSearch || isRevsetMode) return null; 178 + const lowerSearch = debouncedSearch.toLowerCase(); 168 179 169 180 if (revision.change_id.toLowerCase().startsWith(lowerSearch)) return "changeId"; 170 181 if (revision.bookmarks.some((b) => b.toLowerCase().includes(lowerSearch))) return "bookmark"; ··· 173 184 } 174 185 175 186 function getMatchingBookmark(revision: Revision): string | null { 176 - if (!search || isRevsetMode) return null; 177 - const lowerSearch = search.toLowerCase(); 187 + if (!debouncedSearch || isRevsetMode) return null; 188 + const lowerSearch = debouncedSearch.toLowerCase(); 178 189 return revision.bookmarks.find((b) => b.toLowerCase().includes(lowerSearch)) ?? null; 179 190 } 180 191 181 192 // Custom filter function that ranks by match type 193 + // Note: cmdk passes the current search query, we must use it for correct sorting 182 194 function customFilter(value: string, searchQuery: string): number { 183 195 if (!searchQuery) return 1; // Show all when no search 184 196 185 197 const revision = revisionByChangeId.get(value); 186 198 if (!revision) return 0; 187 199 188 - // Revset match - highest priority 200 + // Revset match - highest priority (use debouncedSearch for revset mode check) 189 201 if (isRevsetMode) { 190 202 return revsetChangeIdSet.has(value) ? 1.0 : 0; 191 203 } ··· 289 301 {revision.bookmarks.length > 0 && ( 290 302 <span className="text-xs text-primary font-medium shrink-0"> 291 303 {matchType === "bookmark" && matchingBookmark ? ( 292 - <HighlightMatch text={matchingBookmark} query={search} /> 304 + <HighlightMatch text={matchingBookmark} query={debouncedSearch} /> 293 305 ) : ( 294 306 revision.bookmarks[0] 295 307 )} ··· 302 314 )} 303 315 <span className="text-xs text-muted-foreground truncate flex-1"> 304 316 {matchType === "description" ? ( 305 - <HighlightMatch text={firstLine} query={search} /> 317 + <HighlightMatch text={firstLine} query={debouncedSearch} /> 306 318 ) : ( 307 319 firstLine 308 320 )}
+4 -10
apps/desktop/src/components/ChangedFilesList.tsx
··· 62 62 showSelection?: boolean; 63 63 }) { 64 64 return ( 65 - <div 65 + <button 66 + type="button" 66 67 className={cn( 67 - "flex items-center gap-2 px-3 py-1.5 text-left transition-colors cursor-pointer group", 68 + "flex items-center gap-2 px-3 py-1.5 text-left transition-colors cursor-pointer group w-full", 68 69 isFocused ? "bg-muted text-foreground" : "hover:bg-muted/50", 69 70 )} 70 71 data-focused={isFocused || undefined} 71 72 data-checked={isChecked || undefined} 72 73 data-file-path={file.path} 73 74 onClick={onClick} 74 - onKeyDown={(e) => { 75 - if (e.key === "Enter" || e.key === " ") { 76 - onClick(); 77 - } 78 - }} 79 - role="button" 80 - tabIndex={0} 81 75 > 82 76 {showSelection && ( 83 77 <button ··· 107 101 > 108 102 {file.path} 109 103 </span> 110 - </div> 104 + </button> 111 105 ); 112 106 } 113 107
+7 -4
apps/desktop/src/components/KeyboardShortcutsHelp.tsx
··· 70 70 71 71 return ( 72 72 <Dialog open={open} onOpenChange={setOpen}> 73 - <DialogContent className="sm:max-w-md"> 73 + <DialogContent className="sm:max-w-2xl"> 74 74 <DialogHeader> 75 75 <DialogTitle>Keyboard Shortcuts</DialogTitle> 76 76 </DialogHeader> 77 - <div className="space-y-4"> 77 + <div className="grid grid-cols-2 gap-x-8 gap-y-4"> 78 78 {shortcuts.map((section) => ( 79 79 <div key={section.category}> 80 80 <h3 className="text-xs font-medium text-muted-foreground mb-2">{section.category}</h3> 81 81 <div className="space-y-1.5"> 82 82 {section.items.map((shortcut) => ( 83 - <div key={shortcut.description} className="flex items-center justify-between"> 83 + <div 84 + key={shortcut.description} 85 + className="flex items-center justify-between gap-4" 86 + > 84 87 <span className="text-xs">{shortcut.description}</span> 85 - <div className="flex items-center gap-1"> 88 + <div className="flex items-center gap-1 shrink-0"> 86 89 {shortcut.keys.map((key, i) => ( 87 90 <Kbd key={i}>{key}</Kbd> 88 91 ))}
+16
apps/desktop/src/main.tsx
··· 66 66 onOpenUrl(handleDeepLinks); 67 67 } 68 68 69 + async function setupMenuEvents(): Promise<void> { 70 + if (!IS_TAURI) return; 71 + 72 + const { listen } = await import("@tauri-apps/api/event"); 73 + 74 + // Handle "open-project" menu action from Rust 75 + listen<string>("open-project", (event) => { 76 + const projectId = event.payload; 77 + router.navigate({ 78 + to: "/project/$projectId", 79 + params: { projectId }, 80 + }); 81 + }); 82 + } 83 + 69 84 declare module "@tanstack/react-router" { 70 85 interface Register { 71 86 router: typeof router; ··· 75 90 async function bootstrap(): Promise<void> { 76 91 await setupMocks(); 77 92 await setupDeepLinks(); 93 + await setupMenuEvents(); 78 94 79 95 initializeTheme(); 80 96
+2
apps/desktop/vite.config.ts
··· 2 2 import { defineConfig } from "vite"; 3 3 import react from "@vitejs/plugin-react"; 4 4 import tailwindcss from "@tailwindcss/vite"; 5 + import agentation from "vite-plugin-agentation"; 5 6 6 7 const host = process.env.TAURI_DEV_HOST; 7 8 ··· 15 16 }, 16 17 }), 17 18 tailwindcss(), 19 + agentation() 18 20 ], 19 21 resolve: { 20 22 alias: {