a very good jj gui
0
fork

Configure Feed

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

fix: use useDeferredValue + shouldFilter=false for search debounce

cmdk doesn't support controlled search state, so to debounce filtering:
1. Disable cmdk's built-in filter with shouldFilter={false}
2. Use useDeferredValue to defer the search value
3. Do our own filtering/sorting in useMemo with the deferred value

This allows the input to remain responsive while filtering is deferred.

+60 -94
+60 -94
apps/desktop/src/components/Search.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { useQuery } from "@tanstack/react-query"; 3 3 import type React from "react"; 4 - import { useRef, useState, useMemo, useCallback, useEffect } from "react"; 4 + import { useRef, useState, useMemo, useCallback, useDeferredValue } from "react"; 5 5 import { searchOpenAtom } from "@/atoms"; 6 6 import { 7 7 CommandDialog, ··· 67 67 export function Search({ revisions, repoPath, onJump }: SearchProps) { 68 68 const [open, setOpenRaw] = useAtom(searchOpenAtom); 69 69 const [search, setSearch] = useState(""); 70 - // Debounce search input to prevent lag during typing 71 - const [debouncedSearch, setDebouncedSearch] = useState(""); 72 - const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); 73 - 74 - useEffect(() => { 75 - // Clear any pending debounce 76 - if (debounceTimerRef.current) { 77 - clearTimeout(debounceTimerRef.current); 78 - } 79 - 80 - // Update after 150ms of no changes 81 - debounceTimerRef.current = setTimeout(() => { 82 - setDebouncedSearch(search); 83 - }, 150); 84 - 85 - return () => { 86 - if (debounceTimerRef.current) { 87 - clearTimeout(debounceTimerRef.current); 88 - } 89 - }; 90 - }, [search]); 70 + // Defer filtering to allow input to remain responsive during rapid typing 71 + const deferredSearch = useDeferredValue(search); 91 72 92 73 // Wrap setOpen to reset state when opening 93 74 const setOpen = useCallback( ··· 119 100 } 120 101 121 102 // Determine if current search is a revset expression 122 - const isRevset = isRevsetExpression(debouncedSearch); 103 + const isRevset = isRevsetExpression(deferredSearch); 123 104 124 105 // Use TanStack Query for revset resolution (async data fetching) 125 106 const { ··· 127 108 isLoading: revsetLoading, 128 109 error: revsetError, 129 110 } = useQuery({ 130 - queryKey: ["revset", repoPath, debouncedSearch], 111 + queryKey: ["revset", repoPath, deferredSearch], 131 112 queryFn: async () => { 132 113 if (!repoPath) throw new Error("No repo path"); 133 - const result = await resolveRevset(repoPath, debouncedSearch.trim()); 114 + const result = await resolveRevset(repoPath, deferredSearch.trim()); 134 115 return result; 135 116 }, 136 - enabled: !!repoPath && isRevset && debouncedSearch.trim().length > 0, 117 + enabled: !!repoPath && isRevset && deferredSearch.trim().length > 0, 137 118 staleTime: 30 * 1000, // 30 seconds 138 119 retry: false, 139 120 }); ··· 144 125 changeIds: revsetData?.change_ids ?? [], 145 126 error: revsetData?.error ?? (revsetError ? String(revsetError) : null), 146 127 loading: revsetLoading, 147 - label: isRevset ? debouncedSearch : null, 128 + label: isRevset ? deferredSearch : null, 148 129 }), 149 - [revsetData, revsetError, revsetLoading, isRevset, debouncedSearch], 150 - ); 151 - 152 - // Build lookup maps 153 - const revisionByChangeId = useMemo( 154 - () => new Map(revisions.map((r) => [r.change_id, r])), 155 - [revisions], 130 + [revsetData, revsetError, revsetLoading, isRevset, deferredSearch], 156 131 ); 157 132 158 133 // Determine if we're in revset mode 159 134 const isRevsetMode = 160 - isRevsetExpression(debouncedSearch) && 135 + isRevsetExpression(deferredSearch) && 161 136 (revsetResult.loading || revsetResult.changeIds.length > 0 || revsetResult.error); 162 137 const revsetChangeIdSet = useMemo( 163 138 () => new Set(revsetResult.changeIds), 164 139 [revsetResult.changeIds], 165 140 ); 166 141 167 - // Determine what matched for each revision 168 - function getMatchType( 169 - revision: Revision, 170 - ): "revset" | "changeId" | "bookmark" | "description" | null { 171 - if (isRevsetMode && revsetChangeIdSet.has(revision.change_id)) { 172 - return "revset"; 173 - } 174 - if (!debouncedSearch || isRevsetMode) return null; 175 - const lowerSearch = debouncedSearch.toLowerCase(); 176 - 177 - if (revision.change_id.toLowerCase().startsWith(lowerSearch)) return "changeId"; 178 - if (revision.bookmarks.some((b) => b.name.toLowerCase().includes(lowerSearch))) 179 - return "bookmark"; 180 - if (revision.description.toLowerCase().includes(lowerSearch)) return "description"; 181 - return null; 182 - } 183 - 184 - function getMatchingBookmark(revision: Revision): string | null { 185 - if (!debouncedSearch || isRevsetMode) return null; 186 - const lowerSearch = debouncedSearch.toLowerCase(); 187 - return revision.bookmarks.find((b) => b.name.toLowerCase().includes(lowerSearch))?.name ?? null; 188 - } 189 - 190 - // Custom filter function that ranks by match type 191 - // Note: cmdk passes the current search query, we must use it for correct sorting 192 - function customFilter(value: string, searchQuery: string): number { 193 - if (!searchQuery) return 1; // Show all when no search 194 - 195 - const revision = revisionByChangeId.get(value); 196 - if (!revision) return 0; 142 + // Filter and sort revisions ourselves using deferred search (allows input to stay responsive) 143 + // We disable cmdk's built-in filtering entirely to avoid sync filtering on every keystroke 144 + const { filteredRevisions, getMatchType, getMatchingBookmark } = useMemo(() => { 145 + // Helper to determine match type for a revision 146 + function matchType( 147 + revision: Revision, 148 + ): "revset" | "changeId" | "bookmark" | "description" | null { 149 + if (isRevsetMode && revsetChangeIdSet.has(revision.change_id)) { 150 + return "revset"; 151 + } 152 + if (!deferredSearch || isRevsetMode) return null; 153 + const lowerSearch = deferredSearch.toLowerCase(); 197 154 198 - // Revset match - highest priority (use debouncedSearch for revset mode check) 199 - if (isRevsetMode) { 200 - return revsetChangeIdSet.has(value) ? 1.0 : 0; 155 + if (revision.change_id.toLowerCase().startsWith(lowerSearch)) return "changeId"; 156 + if (revision.bookmarks.some((b) => b.name.toLowerCase().includes(lowerSearch))) 157 + return "bookmark"; 158 + if (revision.description.toLowerCase().includes(lowerSearch)) return "description"; 159 + return null; 201 160 } 202 161 203 - const lowerSearch = searchQuery.toLowerCase(); 204 - 205 - // Change ID match - highest priority 206 - if (revision.change_id.toLowerCase().startsWith(lowerSearch)) { 207 - return 1.0; 162 + function matchingBookmark(revision: Revision): string | null { 163 + if (!deferredSearch || isRevsetMode) return null; 164 + const lowerSearch = deferredSearch.toLowerCase(); 165 + return ( 166 + revision.bookmarks.find((b) => b.name.toLowerCase().includes(lowerSearch))?.name ?? null 167 + ); 208 168 } 209 169 210 - // Bookmark match - medium priority 211 - if (revision.bookmarks.some((b) => b.name.toLowerCase().includes(lowerSearch))) { 212 - return 0.7; 170 + // Filter 171 + let filtered: Revision[]; 172 + if (isRevsetMode) { 173 + filtered = revisions.filter((r) => revsetChangeIdSet.has(r.change_id)); 174 + } else if (!deferredSearch) { 175 + filtered = revisions; 176 + } else { 177 + filtered = revisions.filter((r) => matchType(r) !== null); 213 178 } 214 179 215 - // Description match - lower priority 216 - if (revision.description.toLowerCase().includes(lowerSearch)) { 217 - return 0.4; 180 + // Sort by match priority (changeId > bookmark > description) 181 + if (deferredSearch && !isRevsetMode) { 182 + const priority: Record<string, number> = { changeId: 0, bookmark: 1, description: 2 }; 183 + filtered.sort((a, b) => { 184 + const aType = matchType(a); 185 + const bType = matchType(b); 186 + const aPriority = aType ? priority[aType] ?? 3 : 3; 187 + const bPriority = bType ? priority[bType] ?? 3 : 3; 188 + return aPriority - bPriority; 189 + }); 218 190 } 219 191 220 - return 0; 221 - } 222 - 223 - // When in revset mode, we need to disable cmdk's text-based filter 224 - // because revset expressions like "@" don't match any text 225 - const shouldFilter = !isRevsetMode; 226 - 227 - // Filter revisions when in revset mode (manually, since cmdk filter is disabled) 228 - const filteredRevisions = isRevsetMode 229 - ? revisions.filter((r) => revsetChangeIdSet.has(r.change_id)) 230 - : revisions; 192 + return { 193 + filteredRevisions: filtered, 194 + getMatchType: matchType, 195 + getMatchingBookmark: matchingBookmark, 196 + }; 197 + }, [revisions, deferredSearch, isRevsetMode, revsetChangeIdSet]); 231 198 232 199 return ( 233 200 <CommandDialog ··· 236 203 title="Jump to revision" 237 204 description="Search by change ID, bookmark, message, or use jj revset syntax" 238 205 className="max-w-3xl rounded-xl" 239 - filter={shouldFilter ? customFilter : undefined} 240 - shouldFilter={shouldFilter} 206 + shouldFilter={false} 241 207 > 242 208 <CommandInput 243 209 placeholder="Search or use revset (@, @-, trunk(), mine())..." ··· 299 265 {revision.bookmarks.length > 0 && ( 300 266 <span className="text-xs text-primary font-medium shrink-0"> 301 267 {matchType === "bookmark" && matchingBookmark ? ( 302 - <HighlightMatch text={matchingBookmark} query={debouncedSearch} /> 268 + <HighlightMatch text={matchingBookmark} query={deferredSearch} /> 303 269 ) : ( 304 270 revision.bookmarks[0].name 305 271 )} ··· 312 278 )} 313 279 <span className="text-xs text-muted-foreground truncate flex-1"> 314 280 {matchType === "description" ? ( 315 - <HighlightMatch text={firstLine} query={debouncedSearch} /> 281 + <HighlightMatch text={firstLine} query={deferredSearch} /> 316 282 ) : ( 317 283 firstLine 318 284 )}