diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 901487c56d0..22e6887ec89 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -7,17 +7,14 @@ import { } from "@superset/ui/resizable"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useCallback, useMemo } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { useCallback, useMemo, useState } from "react"; import { HiMiniXMark } from "react-icons/hi2"; import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { - CommandPalette, - useCommandPalette, -} from "renderer/screens/main/components/CommandPalette"; +import { CommandPalette } from "renderer/screens/main/components/CommandPalette"; import { PresetsBar } from "renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar"; import { useAppHotkey } from "renderer/stores/hotkeys"; import { useStore } from "zustand"; @@ -80,7 +77,6 @@ function WorkspaceContent({ workspaceId: string; workspaceName: string; }) { - const navigate = useNavigate(); const { localWorkspaceState, store } = useV2WorkspacePaneLayout({ projectId, workspaceId, @@ -186,25 +182,8 @@ function WorkspaceContent({ }); }, [store]); - const commandPalette = useCommandPalette({ - workspaceId, - navigate, - onSelectFile: ({ close, filePath, targetWorkspaceId }) => { - close(); - if (targetWorkspaceId !== workspaceId) { - void navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId: targetWorkspaceId }, - }); - return; - } - openFilePane(filePath); - }, - }); - - const handleQuickOpen = useCallback(() => { - commandPalette.toggle(); - }, [commandPalette]); + const [quickOpenOpen, setQuickOpenOpen] = useState(false); + const handleQuickOpen = useCallback(() => setQuickOpenOpen(true), []); const defaultPaneActions = useMemo[]>( () => [ @@ -349,22 +328,11 @@ function WorkspaceContent({ )} ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index 877e1056c4e..260f680d63d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -1,10 +1,9 @@ import type { ExternalApp } from "@superset/local-db"; import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { useFileOpenMode } from "renderer/hooks/useFileOpenMode"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { getWorkspaceDisplayName } from "renderer/lib/getWorkspaceDisplayName"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { usePresets } from "renderer/react-query/presets"; import type { WorkspaceSearchParams } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; @@ -12,10 +11,7 @@ import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/u import { usePresetHotkeys } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys"; import { useWorkspaceRunCommand } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand"; import { NotFound } from "renderer/routes/not-found"; -import { - CommandPalette, - useCommandPalette, -} from "renderer/screens/main/components/CommandPalette"; +import { CommandPalette } from "renderer/screens/main/components/CommandPalette"; import { UnsavedChangesDialog } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog"; import { useWorkspaceFileEventBridge } from "renderer/screens/main/components/WorkspaceView/hooks/useWorkspaceFileEvents"; import { useWorkspaceRenameReconciliation } from "renderer/screens/main/components/WorkspaceView/hooks/useWorkspaceRenameReconciliation"; @@ -412,13 +408,8 @@ function WorkspacePage() { [pr?.url, createOrOpenPR], ); - const commandPalette = useCommandPalette({ - workspaceId, - navigate, - }); - const handleQuickOpen = useCallback(() => { - commandPalette.toggle(); - }, [commandPalette.toggle]); + const [quickOpenOpen, setQuickOpenOpen] = useState(false); + const handleQuickOpen = useCallback(() => setQuickOpenOpen(true), []); useAppHotkey("QUICK_OPEN", handleQuickOpen, undefined, [handleQuickOpen]); // Toggle changes sidebar (⌘L) @@ -638,29 +629,11 @@ function WorkspacePage() { )} + useTabsStore.getState().addFileViewerPane(workspaceId, { filePath }) } /> void; - query: string; - onQueryChange: (query: string) => void; - filtersOpen: boolean; - onFiltersOpenChange: (open: boolean) => void; - includePattern: string; - onIncludePatternChange: (value: string) => void; - excludePattern: string; - onExcludePatternChange: (value: string) => void; - isLoading: boolean; - searchResults: CommandPaletteResult[]; - onSelectFile: (filePath: string, workspaceId?: string) => void; - scope: SearchScope; - onScopeChange: (scope: SearchScope) => void; - workspaceName?: string; + onSelectFile: (filePath: string) => void; + variant?: "v1" | "v2"; } export function CommandPalette({ + workspaceId, open, onOpenChange, - query, - onQueryChange, - filtersOpen, - onFiltersOpenChange, - includePattern, - onIncludePatternChange, - excludePattern, - onExcludePatternChange, - isLoading, - searchResults, onSelectFile, - scope, - onScopeChange, - workspaceName, + variant = "v1", }: CommandPaletteProps) { + const [query, setQuery] = useState(""); + const [filtersOpen, setFiltersOpen] = useState(false); + const [includePattern, setIncludePattern] = useState(""); + const [excludePattern, setExcludePattern] = useState(""); + const inputRef = useRef(null); + + const v1Search = useFileSearch({ + workspaceId: variant === "v1" && open ? workspaceId : undefined, + searchTerm: variant === "v1" ? query : "", + includePattern: variant === "v1" ? includePattern : "", + excludePattern: variant === "v1" ? excludePattern : "", + limit: SEARCH_LIMIT, + }); + + const v2Search = useV2FileSearch( + variant === "v2" && open ? workspaceId : undefined, + variant === "v2" ? query : "", + ); + + const results = variant === "v2" ? v2Search.results : v1Search.searchResults; + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + onOpenChange(nextOpen); + if (!nextOpen) setQuery(""); + }, + [onOpenChange], + ); + + const handleSelectFile = useCallback( + (filePath: string) => { + onSelectFile(filePath); + handleOpenChange(false); + }, + [onSelectFile, handleOpenChange], + ); + + useEffect(() => { + if (open) requestAnimationFrame(() => inputRef.current?.focus()); + }, [open]); + return ( - `${file.path} ${query}`} - onSelectItem={(file) => onSelectFile(file.path, file.workspaceId)} - headerExtra={ - - } - renderItem={(file) => { - return ( - <> - - {file.name} - {scope === "global" && file.workspaceName && ( - - {file.workspaceName} - + + + + + + Quick Open + + + Search for files in your workspace + + + +
+ + + {variant === "v1" && ( + + )} +
+ + {variant === "v1" && filtersOpen && ( +
+ setIncludePattern(e.target.value)} + placeholder="files to include (glob)" + className="h-8 rounded border bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground" + /> + setExcludePattern(e.target.value)} + placeholder="files to exclude (glob)" + className="h-8 rounded border bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground" + /> +
)} - - {file.relativePath} - - - ); - }} - /> + + + {results.length === 0 && ( + + No files found. + + )} + {results.map((file) => ( + handleSelectFile(file.path)} + className="group data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-2 text-sm outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" + > + + + {file.name} + + + {file.relativePath} + + + ↵ + + + ))} + +
+
+
+
); } diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/ScopeToggle/ScopeToggle.tsx b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/ScopeToggle/ScopeToggle.tsx deleted file mode 100644 index 66ad9b9d26c..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/ScopeToggle/ScopeToggle.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { cn } from "@superset/ui/utils"; -import type { SearchScope } from "renderer/stores/search-dialog-state"; - -interface ScopeToggleProps { - scope: SearchScope; - onScopeChange: (scope: SearchScope) => void; - workspaceName?: string; -} - -export function ScopeToggle({ - scope, - onScopeChange, - workspaceName, -}: ScopeToggleProps) { - return ( -
- - -
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/ScopeToggle/index.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/ScopeToggle/index.ts deleted file mode 100644 index d0a07233684..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/ScopeToggle/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ScopeToggle } from "./ScopeToggle"; diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/hooks/useV2FileSearch/index.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/hooks/useV2FileSearch/index.ts new file mode 100644 index 00000000000..aa3c54552b6 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/hooks/useV2FileSearch/index.ts @@ -0,0 +1 @@ +export { useV2FileSearch } from "./useV2FileSearch"; diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/hooks/useV2FileSearch/useV2FileSearch.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/hooks/useV2FileSearch/useV2FileSearch.ts new file mode 100644 index 00000000000..b6fd31bdc74 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/hooks/useV2FileSearch/useV2FileSearch.ts @@ -0,0 +1,32 @@ +import { workspaceTrpc } from "@superset/workspace-client"; + +const SEARCH_LIMIT = 50; + +export function useV2FileSearch( + workspaceId: string | undefined, + query: string, +) { + const trimmedQuery = query.trim(); + + const { data, isFetching } = workspaceTrpc.filesystem.searchFiles.useQuery( + { + workspaceId: workspaceId ?? "", + query: trimmedQuery, + limit: SEARCH_LIMIT, + }, + { + enabled: Boolean(workspaceId) && trimmedQuery.length > 0, + placeholderData: (previous) => previous ?? { matches: [] }, + }, + ); + + const results = + data?.matches.map((match) => ({ + id: match.absolutePath, + name: match.name, + path: match.absolutePath, + relativePath: match.relativePath, + })) ?? []; + + return { results, isFetching }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/index.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/index.ts index bcc69d703b7..495c33448db 100644 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/index.ts @@ -1,2 +1,2 @@ +export type { CommandPaletteProps } from "./CommandPalette"; export { CommandPalette } from "./CommandPalette"; -export { useCommandPalette } from "./useCommandPalette"; diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts deleted file mode 100644 index b71b7476dba..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts +++ /dev/null @@ -1,240 +0,0 @@ -import type { UseNavigateResult } from "@tanstack/react-router"; -import { useCallback, useMemo, useState } from "react"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { getWorkspaceDisplayName } from "renderer/lib/getWorkspaceDisplayName"; -import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import { useFileSearch } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch"; -import { - type SearchScope, - useSearchDialogStore, -} from "renderer/stores/search-dialog-state"; -import { useTabsStore } from "renderer/stores/tabs/store"; - -const SEARCH_LIMIT = 50; - -interface UseCommandPaletteParams { - workspaceId: string; - navigate: UseNavigateResult; - onSelectFile?: (input: { - filePath: string; - targetWorkspaceId: string; - close: () => void; - navigate: UseNavigateResult; - }) => void; -} - -export function useCommandPalette({ - workspaceId, - navigate, - onSelectFile, -}: UseCommandPaletteParams) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const includePattern = useSearchDialogStore( - (state) => state.byMode.quickOpen.includePattern, - ); - const excludePattern = useSearchDialogStore( - (state) => state.byMode.quickOpen.excludePattern, - ); - const filtersOpen = useSearchDialogStore( - (state) => state.byMode.quickOpen.filtersOpen, - ); - const scope = - useSearchDialogStore((state) => state.byMode.quickOpen.scope) ?? - "workspace"; - const setIncludePatternByMode = useSearchDialogStore( - (state) => state.setIncludePattern, - ); - const setExcludePatternByMode = useSearchDialogStore( - (state) => state.setExcludePattern, - ); - const setFiltersOpenByMode = useSearchDialogStore( - (state) => state.setFiltersOpen, - ); - const setScopeByMode = useSearchDialogStore((state) => state.setScope); - - // Fetch all grouped workspaces (only when global scope is active and dialog is open) - const { data: allGrouped } = electronTrpc.workspaces.getAllGrouped.useQuery( - undefined, - { - enabled: open && scope === "global", - }, - ); - - // Build roots array for multi-workspace search - const roots = useMemo(() => { - if (scope !== "global" || !allGrouped) return []; - const result: { - rootPath: string; - workspaceId: string; - workspaceName: string; - }[] = []; - for (const group of allGrouped) { - const addWorkspace = (ws: { - id: string; - worktreePath: string; - name: string; - type: "worktree" | "branch"; - }) => { - if (ws.worktreePath) { - result.push({ - rootPath: ws.worktreePath, - workspaceId: ws.id, - workspaceName: getWorkspaceDisplayName( - ws.name, - ws.type, - group.project.name, - ), - }); - } - }; - for (const ws of group.workspaces) { - addWorkspace(ws); - } - for (const section of group.sections) { - for (const ws of section.workspaces) { - addWorkspace(ws); - } - } - } - return result; - }, [scope, allGrouped]); - - // Single-workspace search (existing behavior) - const singleSearch = useFileSearch({ - workspaceId: open && scope === "workspace" ? workspaceId : undefined, - searchTerm: query, - includePattern, - excludePattern, - limit: SEARCH_LIMIT, - }); - - // Multi-workspace search - const debouncedQuery = useDebouncedValue(query.trim(), 150); - const multiSearchQueries = electronTrpc.useQueries((t) => - open && scope === "global" && roots.length > 0 && debouncedQuery.length > 0 - ? roots.map((root) => - t.filesystem.searchFiles({ - workspaceId: root.workspaceId, - query: debouncedQuery, - includePattern, - excludePattern, - limit: SEARCH_LIMIT, - }), - ) - : [], - ); - - const multiSearchResults = useMemo( - () => - roots - .flatMap((root, index) => - (multiSearchQueries[index]?.data?.matches ?? []).map((match) => ({ - id: match.absolutePath, - name: match.name, - path: match.absolutePath, - relativePath: match.relativePath, - isDirectory: match.kind === "directory", - score: match.score, - workspaceId: root.workspaceId, - workspaceName: root.workspaceName, - })), - ) - .sort((left, right) => right.score - left.score) - .slice(0, SEARCH_LIMIT), - [roots, multiSearchQueries], - ); - - const searchResults = - scope === "workspace" ? singleSearch.searchResults : multiSearchResults; - const isFetching = - scope === "workspace" - ? singleSearch.isFetching - : multiSearchQueries.some((query) => query.isFetching) || - (query.trim().length > 0 && query.trim() !== debouncedQuery); - - const handleOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen); - if (!nextOpen) { - setQuery(""); - } - }, []); - - const toggle = useCallback(() => { - setOpen((prev) => { - if (prev) { - setQuery(""); - } - return !prev; - }); - }, []); - - const selectFile = useCallback( - (filePath: string, resultWorkspaceId?: string) => { - const targetWs = resultWorkspaceId ?? workspaceId; - if (onSelectFile) { - onSelectFile({ - filePath, - targetWorkspaceId: targetWs, - close: () => handleOpenChange(false), - navigate, - }); - return; - } - useTabsStore.getState().addFileViewerPane(targetWs, { filePath }); - handleOpenChange(false); - if (targetWs !== workspaceId) { - navigateToWorkspace(targetWs, navigate); - } - }, - [workspaceId, onSelectFile, handleOpenChange, navigate], - ); - - const setIncludePattern = useCallback( - (value: string) => { - setIncludePatternByMode("quickOpen", value); - }, - [setIncludePatternByMode], - ); - - const setExcludePattern = useCallback( - (value: string) => { - setExcludePatternByMode("quickOpen", value); - }, - [setExcludePatternByMode], - ); - - const setFiltersOpen = useCallback( - (nextOpen: boolean) => { - setFiltersOpenByMode("quickOpen", nextOpen); - }, - [setFiltersOpenByMode], - ); - - const setScope = useCallback( - (newScope: SearchScope) => { - setScopeByMode("quickOpen", newScope); - }, - [setScopeByMode], - ); - - return { - open, - query, - setQuery, - filtersOpen, - setFiltersOpen, - includePattern, - setIncludePattern, - excludePattern, - setExcludePattern, - handleOpenChange, - toggle, - selectFile, - searchResults, - isFetching, - scope, - setScope, - }; -} diff --git a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/KeywordSearch.tsx b/apps/desktop/src/renderer/screens/main/components/KeywordSearch/KeywordSearch.tsx deleted file mode 100644 index cc1edf315cf..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/KeywordSearch.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import type { ReactNode } from "react"; -import { - SearchDialog, - type SearchDialogItem, -} from "renderer/screens/main/components/SearchDialog"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; - -interface KeywordSearchResult extends SearchDialogItem { - name: string; - relativePath: string; - path: string; - line: number; - column: number; - preview: string; -} - -interface KeywordSearchProps { - open: boolean; - onOpenChange: (open: boolean) => void; - query: string; - onQueryChange: (query: string) => void; - filtersOpen: boolean; - onFiltersOpenChange: (open: boolean) => void; - includePattern: string; - onIncludePatternChange: (value: string) => void; - excludePattern: string; - onExcludePatternChange: (value: string) => void; - isLoading: boolean; - searchResults: KeywordSearchResult[]; - onSelectMatch: (match: KeywordSearchResult) => void; -} - -function renderHighlightedText(text: string, query: string): ReactNode { - const trimmedQuery = query.trim(); - if (!trimmedQuery) { - return text; - } - - const lowerText = text.toLowerCase(); - const lowerNeedle = trimmedQuery.toLowerCase(); - const nodes: ReactNode[] = []; - let searchIndex = 0; - - while (searchIndex < text.length) { - const matchIndex = lowerText.indexOf(lowerNeedle, searchIndex); - if (matchIndex === -1) { - break; - } - - if (matchIndex > searchIndex) { - nodes.push( - - {text.slice(searchIndex, matchIndex)} - , - ); - } - - nodes.push( - - {text.slice(matchIndex, matchIndex + trimmedQuery.length)} - , - ); - - searchIndex = matchIndex + trimmedQuery.length; - } - - if (nodes.length === 0) { - return text; - } - - if (searchIndex < text.length) { - nodes.push( - {text.slice(searchIndex)}, - ); - } - - return nodes; -} - -export function KeywordSearch({ - open, - onOpenChange, - query, - onQueryChange, - filtersOpen, - onFiltersOpenChange, - includePattern, - onIncludePatternChange, - excludePattern, - onExcludePatternChange, - isLoading, - searchResults, - onSelectMatch, -}: KeywordSearchProps) { - return ( - `${match.id} ${query}`} - onSelectItem={onSelectMatch} - renderItem={(match) => { - return ( - <> - -
-
- - {renderHighlightedText(match.name, query)} - - - {renderHighlightedText(match.relativePath, query)}: - {match.line} - -
- {match.preview ? ( -
- {renderHighlightedText(match.preview, query)} -
- ) : null} -
- - ); - }} - /> - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/index.ts b/apps/desktop/src/renderer/screens/main/components/KeywordSearch/index.ts deleted file mode 100644 index 835c4d14c75..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { KeywordSearch } from "./KeywordSearch"; -export { useKeywordSearch } from "./useKeywordSearch"; diff --git a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/useKeywordSearch.ts b/apps/desktop/src/renderer/screens/main/components/KeywordSearch/useKeywordSearch.ts deleted file mode 100644 index 7e58cc93e2c..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/useKeywordSearch.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { useCallback, useState } from "react"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { useSearchDialogStore } from "renderer/stores/search-dialog-state"; -import { useTabsStore } from "renderer/stores/tabs/store"; - -const SEARCH_LIMIT = 200; - -interface UseKeywordSearchParams { - workspaceId: string; -} - -interface KeywordSearchResult { - id: string; - name: string; - relativePath: string; - path: string; - line: number; - column: number; - preview: string; -} - -export function useKeywordSearch({ workspaceId }: UseKeywordSearchParams) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const includePattern = useSearchDialogStore( - (state) => state.byMode.keywordSearch.includePattern, - ); - const excludePattern = useSearchDialogStore( - (state) => state.byMode.keywordSearch.excludePattern, - ); - const filtersOpen = useSearchDialogStore( - (state) => state.byMode.keywordSearch.filtersOpen, - ); - const setIncludePatternByMode = useSearchDialogStore( - (state) => state.setIncludePattern, - ); - const setExcludePatternByMode = useSearchDialogStore( - (state) => state.setExcludePattern, - ); - const setFiltersOpenByMode = useSearchDialogStore( - (state) => state.setFiltersOpen, - ); - const trimmedQuery = query.trim(); - const debouncedQuery = useDebouncedValue(trimmedQuery, 150); - const isDebouncing = - trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery; - - const { data: searchResults, isFetching } = - electronTrpc.filesystem.searchContent.useQuery( - { - workspaceId, - query: debouncedQuery, - includePattern, - excludePattern, - limit: SEARCH_LIMIT, - }, - { - enabled: open && debouncedQuery.length > 0, - staleTime: 1000, - placeholderData: (previous) => previous ?? { matches: [] }, - }, - ); - - const results = - searchResults?.matches.map((match) => ({ - id: `${match.absolutePath}:${match.line}:${match.column}`, - name: match.absolutePath.split(/[/\\]/).pop() ?? match.absolutePath, - relativePath: match.relativePath, - path: match.absolutePath, - line: match.line, - column: match.column, - preview: match.preview, - })) ?? []; - - const handleOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen); - if (!nextOpen) { - setQuery(""); - } - }, []); - - const toggle = useCallback(() => { - setOpen((prev) => { - if (prev) { - setQuery(""); - } - return !prev; - }); - }, []); - - const selectMatch = useCallback( - (match: KeywordSearchResult) => { - useTabsStore.getState().addFileViewerPane(workspaceId, { - filePath: match.path, - line: match.line, - column: match.column, - }); - handleOpenChange(false); - }, - [workspaceId, handleOpenChange], - ); - - const setIncludePattern = useCallback( - (value: string) => { - setIncludePatternByMode("keywordSearch", value); - }, - [setIncludePatternByMode], - ); - - const setExcludePattern = useCallback( - (value: string) => { - setExcludePatternByMode("keywordSearch", value); - }, - [setExcludePatternByMode], - ); - - const setFiltersOpen = useCallback( - (nextOpen: boolean) => { - setFiltersOpenByMode("keywordSearch", nextOpen); - }, - [setFiltersOpenByMode], - ); - - return { - open, - query, - setQuery, - filtersOpen, - setFiltersOpen, - includePattern, - setIncludePattern, - excludePattern, - setExcludePattern, - handleOpenChange, - toggle, - selectMatch, - searchResults: results, - isFetching: isFetching || isDebouncing, - }; -} diff --git a/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx b/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx deleted file mode 100644 index 98e5aa69d46..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { - CommandDialog, - CommandEmpty, - CommandInput, - CommandItem, - CommandList, -} from "@superset/ui/command"; -import { Input } from "@superset/ui/input"; -import { Spinner } from "@superset/ui/spinner"; -import type { ReactNode } from "react"; -import { LuChevronDown, LuChevronRight } from "react-icons/lu"; - -export interface SearchDialogItem { - id: string; -} - -interface SearchDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - title: string; - description: string; - query: string; - onQueryChange: (query: string) => void; - queryPlaceholder: string; - filtersOpen: boolean; - onFiltersOpenChange: (open: boolean) => void; - includePattern: string; - onIncludePatternChange: (value: string) => void; - excludePattern: string; - onExcludePatternChange: (value: string) => void; - emptyMessage: string; - isLoading: boolean; - results: TItem[]; - getItemValue: (item: TItem) => string; - onSelectItem: (item: TItem) => void; - renderItem: (item: TItem) => ReactNode; - headerExtra?: ReactNode; -} - -export function SearchDialog({ - open, - onOpenChange, - title, - description, - query, - onQueryChange, - queryPlaceholder, - filtersOpen, - onFiltersOpenChange, - includePattern, - onIncludePatternChange, - excludePattern, - onExcludePatternChange, - emptyMessage, - isLoading, - results, - getItemValue, - onSelectItem, - renderItem, - headerExtra, -}: SearchDialogProps) { - return ( - -
- -
- {isLoading ? ( -
- -
- ) : null} - -
-
- {filtersOpen ? ( -
- onIncludePatternChange(event.target.value)} - placeholder="files to include (glob)" - className="h-8 text-xs" - /> - onExcludePatternChange(event.target.value)} - placeholder="files to exclude (glob)" - className="h-8 text-xs" - /> -
- ) : null} - {headerExtra} - - {query.trim().length > 0 && !isLoading && results.length === 0 && ( - {emptyMessage} - )} - {results.map((item) => ( - onSelectItem(item)} - > - {renderItem(item)} - - ))} - -
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/SearchDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/SearchDialog/index.ts deleted file mode 100644 index ad081da57a5..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/SearchDialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SearchDialog, type SearchDialogItem } from "./SearchDialog"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch.ts index fd457184a40..d2041510508 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch.ts @@ -1,4 +1,3 @@ -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { SEARCH_RESULT_LIMIT } from "../../constants"; @@ -18,22 +17,18 @@ export function useFileSearch({ limit = SEARCH_RESULT_LIMIT, }: UseFileSearchParams) { const trimmedQuery = searchTerm.trim(); - const debouncedQuery = useDebouncedValue(trimmedQuery, 150); - const isDebouncing = - trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery; const { data: searchResults, isFetching } = electronTrpc.filesystem.searchFiles.useQuery( { workspaceId: workspaceId ?? "", - query: debouncedQuery, + query: trimmedQuery, includePattern, excludePattern, limit, }, { - enabled: Boolean(workspaceId) && debouncedQuery.length > 0, - staleTime: 1000, + enabled: Boolean(workspaceId) && trimmedQuery.length > 0, placeholderData: (previous) => previous ?? { matches: [] }, }, ); @@ -50,7 +45,7 @@ export function useFileSearch({ return { searchResults: results, - isFetching: isFetching || isDebouncing, + isFetching, hasQuery: trimmedQuery.length > 0, }; } diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 82c893bf794..cd53cc86535 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -756,17 +756,18 @@ export const useTabsStore = create()( const tabPaneIds = extractPaneIdsFromLayout(activeTab.layout); const reuseExisting = options.reuseExisting ?? "workspace"; - const existingFileViewerPane = reuseExisting !== "none" - ? findReusableFileViewerPane({ - workspaceId, - activeTabId: activeTab.id, - tabs: state.tabs, - panes: state.panes, - tabHistoryStacks: state.tabHistoryStacks, - reuseExisting, - options, - }) - : null; + const existingFileViewerPane = + reuseExisting !== "none" + ? findReusableFileViewerPane({ + workspaceId, + activeTabId: activeTab.id, + tabs: state.tabs, + panes: state.panes, + tabHistoryStacks: state.tabHistoryStacks, + reuseExisting, + options, + }) + : null; if (existingFileViewerPane) { const nextPane = applyFileViewerOpenOptionsToPane( @@ -826,7 +827,11 @@ export const useTabsStore = create()( // If we found an unpinned (preview) file-viewer pane, reuse it // (skip reuse when explicitly requesting a new tab, e.g. cmd+click) - if (fileViewerPanes.length > 0 && !options.openInNewTab && reuseExisting !== "none") { + if ( + fileViewerPanes.length > 0 && + !options.openInNewTab && + reuseExisting !== "none" + ) { const paneToReuse = fileViewerPanes[0]; const existingFileViewer = paneToReuse.fileViewer; if (!existingFileViewer) { diff --git a/bun.lock b/bun.lock index 311e3ba6138..03a2001fa57 100644 --- a/bun.lock +++ b/bun.lock @@ -919,7 +919,6 @@ "dependencies": { "@parcel/watcher": "^2.5.6", "fast-glob": "^3.3.3", - "fuse.js": "^7.1.0", }, "devDependencies": { "@superset/typescript": "workspace:*", diff --git a/packages/host-service/src/runtime/filesystem/filesystem.ts b/packages/host-service/src/runtime/filesystem/filesystem.ts index ad4ec400468..fd4a605c7da 100644 --- a/packages/host-service/src/runtime/filesystem/filesystem.ts +++ b/packages/host-service/src/runtime/filesystem/filesystem.ts @@ -2,6 +2,7 @@ import { createFsHostService, type FsHostService, FsWatcherManager, + getSearchIndex, } from "@superset/workspace-fs/host"; import { eq } from "drizzle-orm"; import type { HostDb } from "../../db"; @@ -41,6 +42,8 @@ export class WorkspaceFilesystemManager { watcherManager: this.watcherManager, }); this.serviceCache.set(rootPath, service); + // Pre-warm search index so first search is instant + getSearchIndex({ rootPath, includeHidden: false }).catch(() => {}); } return service; } diff --git a/packages/ui/src/components/ui/command.tsx b/packages/ui/src/components/ui/command.tsx index ebf5e66f6ed..3832889ab83 100644 --- a/packages/ui/src/components/ui/command.tsx +++ b/packages/ui/src/components/ui/command.tsx @@ -181,4 +181,5 @@ export { CommandItem, CommandShortcut, CommandSeparator, + CommandPrimitive, }; diff --git a/packages/workspace-fs/package.json b/packages/workspace-fs/package.json index 8aeaf909842..b982f1d4845 100644 --- a/packages/workspace-fs/package.json +++ b/packages/workspace-fs/package.json @@ -31,8 +31,7 @@ }, "dependencies": { "@parcel/watcher": "^2.5.6", - "fast-glob": "^3.3.3", - "fuse.js": "^7.1.0" + "fast-glob": "^3.3.3" }, "devDependencies": { "@superset/typescript": "workspace:*", diff --git a/packages/workspace-fs/src/fuzzy-scorer.ts b/packages/workspace-fs/src/fuzzy-scorer.ts new file mode 100644 index 00000000000..46e8cac2396 --- /dev/null +++ b/packages/workspace-fs/src/fuzzy-scorer.ts @@ -0,0 +1,903 @@ +/** + * Fuzzy file search scorer — ported from VS Code. + * + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + * See https://github.com/microsoft/vscode/blob/main/LICENSE.txt + * + * Source files: + * - https://github.com/microsoft/vscode/blob/main/src/vs/base/common/fuzzyScorer.ts + * - https://github.com/microsoft/vscode/blob/main/src/vs/base/common/filters.ts (matchesPrefix) + * - https://github.com/microsoft/vscode/blob/main/src/vs/base/common/comparers.ts (compareAnything) + * + * VS Code-specific imports (CharCode, filters, comparers, platform, etc.) are + * inlined below. The scoring algorithm is unchanged. + */ + +// --------------------------------------------------------------------------- +// Inlined dependencies from VS Code +// --------------------------------------------------------------------------- + +function isUpper(charCode: number): boolean { + return charCode >= 65 && charCode <= 90; // A-Z +} + +enum CharCode { + Slash = 47, + Backslash = 92, + Underline = 95, + Dash = 45, + Period = 46, + Space = 32, + SingleQuote = 39, + DoubleQuote = 34, + Colon = 58, +} + +/** Path separator — always `/` for our use case (all paths are normalized to forward slashes). */ +const sep = "/"; + +interface IMatch { + start: number; + end: number; +} + +/** Case-insensitive prefix check (inlined from VS Code's strings.ts / filters.ts). */ +function matchesPrefix( + word: string, + wordToMatchAgainst: string, +): IMatch[] | null { + if (!wordToMatchAgainst || wordToMatchAgainst.length < word.length) { + return null; + } + + if (!wordToMatchAgainst.toLowerCase().startsWith(word.toLowerCase())) { + return null; + } + + return word.length > 0 ? [{ start: 0, end: word.length }] : []; +} + +/** Inlined from VS Code's comparers.ts */ +function compareByPrefix(one: string, other: string, lookFor: string): number { + const elementAName = one.toLowerCase(); + const elementBName = other.toLowerCase(); + + const elementAPrefixMatch = elementAName.startsWith(lookFor); + const elementBPrefixMatch = elementBName.startsWith(lookFor); + if (elementAPrefixMatch !== elementBPrefixMatch) { + return elementAPrefixMatch ? -1 : 1; + } + + if (elementAPrefixMatch && elementBPrefixMatch) { + if (elementAName.length < elementBName.length) { + return -1; + } + if (elementAName.length > elementBName.length) { + return 1; + } + } + + return 0; +} + +function compareAnything(one: string, other: string, lookFor: string): number { + const elementAName = one.toLowerCase(); + const elementBName = other.toLowerCase(); + + const prefixCompare = compareByPrefix(one, other, lookFor); + if (prefixCompare) { + return prefixCompare; + } + + const elementASuffixMatch = elementAName.endsWith(lookFor); + const elementBSuffixMatch = elementBName.endsWith(lookFor); + if (elementASuffixMatch !== elementBSuffixMatch) { + return elementASuffixMatch ? -1 : 1; + } + + return elementAName.localeCompare(elementBName); +} + +// --------------------------------------------------------------------------- +// #region Fuzzy scorer (from VS Code fuzzyScorer.ts) +// --------------------------------------------------------------------------- + +export type FuzzyScore = [number /* score */, number[] /* match positions */]; +export type FuzzyScorerCache = { [key: string]: IItemScore }; + +const NO_MATCH = 0; +const NO_SCORE: FuzzyScore = [NO_MATCH, []]; + +export function scoreFuzzy( + target: string, + query: string, + queryLower: string, + allowNonContiguousMatches: boolean, +): FuzzyScore { + if (!target || !query) { + return NO_SCORE; + } + + const targetLength = target.length; + const queryLength = query.length; + + if (targetLength < queryLength) { + return NO_SCORE; + } + + const targetLower = target.toLowerCase(); + return doScoreFuzzy( + query, + queryLower, + queryLength, + target, + targetLower, + targetLength, + allowNonContiguousMatches, + ); +} + +function doScoreFuzzy( + query: string, + queryLower: string, + queryLength: number, + target: string, + targetLower: string, + targetLength: number, + allowNonContiguousMatches: boolean, +): FuzzyScore { + const scores: number[] = []; + const matches: number[] = []; + + for (let queryIndex = 0; queryIndex < queryLength; queryIndex++) { + const queryIndexOffset = queryIndex * targetLength; + const queryIndexPreviousOffset = queryIndexOffset - targetLength; + + const queryIndexGtNull = queryIndex > 0; + + // biome-ignore lint/style/noNonNullAssertion: ported from VS Code — index in bounds by loop + const queryCharAtIndex = query[queryIndex]!; + // biome-ignore lint/style/noNonNullAssertion: ported from VS Code + const queryLowerCharAtIndex = queryLower[queryIndex]!; + + for (let targetIndex = 0; targetIndex < targetLength; targetIndex++) { + const targetIndexGtNull = targetIndex > 0; + + const currentIndex = queryIndexOffset + targetIndex; + const leftIndex = currentIndex - 1; + const diagIndex = queryIndexPreviousOffset + targetIndex - 1; + + const leftScore = targetIndexGtNull ? (scores[leftIndex] ?? 0) : 0; + const diagScore = + queryIndexGtNull && targetIndexGtNull ? (scores[diagIndex] ?? 0) : 0; + + const matchesSequenceLength = + queryIndexGtNull && targetIndexGtNull ? (matches[diagIndex] ?? 0) : 0; + + let score: number; + if (!diagScore && queryIndexGtNull) { + score = 0; + } else { + score = computeCharScore( + queryCharAtIndex, + queryLowerCharAtIndex, + target, + targetLower, + targetIndex, + matchesSequenceLength, + ); + } + + const isValidScore = score && diagScore + score >= leftScore; + if ( + isValidScore && + (allowNonContiguousMatches || + queryIndexGtNull || + targetLower.startsWith(queryLower, targetIndex)) + ) { + matches[currentIndex] = matchesSequenceLength + 1; + scores[currentIndex] = diagScore + score; + } else { + matches[currentIndex] = NO_MATCH; + scores[currentIndex] = leftScore; + } + } + } + + const positions: number[] = []; + let queryIndex = queryLength - 1; + let targetIndex = targetLength - 1; + while (queryIndex >= 0 && targetIndex >= 0) { + const currentIndex = queryIndex * targetLength + targetIndex; + const match = matches[currentIndex]; + if (match === NO_MATCH) { + targetIndex--; + } else { + positions.push(targetIndex); + queryIndex--; + targetIndex--; + } + } + + return [scores[queryLength * targetLength - 1] ?? 0, positions.reverse()]; +} + +function computeCharScore( + queryCharAtIndex: string, + queryLowerCharAtIndex: string, + target: string, + targetLower: string, + targetIndex: number, + matchesSequenceLength: number, +): number { + let score = 0; + + // biome-ignore lint/style/noNonNullAssertion: ported from VS Code — index in bounds by loop + if (!considerAsEqual(queryLowerCharAtIndex, targetLower[targetIndex]!)) { + return score; + } + + // Character match bonus + score += 1; + + // Consecutive match bonus + if (matchesSequenceLength > 0) { + score += + Math.min(matchesSequenceLength, 3) * 6 + + Math.max(0, matchesSequenceLength - 3) * 3; + } + + // Same case bonus + if (queryCharAtIndex === target[targetIndex]) { + score += 1; + } + + // Start of word bonus + if (targetIndex === 0) { + score += 8; + } else { + // After separator bonus + const separatorBonus = scoreSeparatorAtPos( + target.charCodeAt(targetIndex - 1), + ); + if (separatorBonus) { + score += separatorBonus; + } + // Inside word upper case bonus (camelCase), only if not in a contiguous sequence + else if ( + isUpper(target.charCodeAt(targetIndex)) && + matchesSequenceLength === 0 + ) { + score += 2; + } + } + + return score; +} + +function considerAsEqual(a: string, b: string): boolean { + if (a === b) { + return true; + } + + if (a === "/" || a === "\\") { + return b === "/" || b === "\\"; + } + + return false; +} + +function scoreSeparatorAtPos(charCode: number): number { + switch (charCode) { + case CharCode.Slash: + case CharCode.Backslash: + return 5; + case CharCode.Underline: + case CharCode.Dash: + case CharCode.Period: + case CharCode.Space: + case CharCode.SingleQuote: + case CharCode.DoubleQuote: + case CharCode.Colon: + return 4; + default: + return 0; + } +} + +// #endregion + +// --------------------------------------------------------------------------- +// #region Item (label, description, path) scorer +// --------------------------------------------------------------------------- + +export interface IItemScore { + score: number; + labelMatch?: IMatch[]; + descriptionMatch?: IMatch[]; +} + +const NO_ITEM_SCORE = Object.freeze({ score: 0 }); + +export interface IItemAccessor { + getItemLabel(item: T): string | undefined; + getItemDescription(item: T): string | undefined; + getItemPath(file: T): string | undefined; +} + +const PATH_IDENTITY_SCORE = 1 << 18; +const LABEL_PREFIX_SCORE_THRESHOLD = 1 << 17; +const LABEL_SCORE_THRESHOLD = 1 << 16; + +export function scoreItemFuzzy( + item: T, + query: IPreparedQuery, + allowNonContiguousMatches: boolean, + accessor: IItemAccessor, + cache: FuzzyScorerCache, +): IItemScore { + if (!item || !query.normalized) { + return NO_ITEM_SCORE; + } + + const label = accessor.getItemLabel(item); + if (!label) { + return NO_ITEM_SCORE; + } + + const description = accessor.getItemDescription(item); + + const cacheHash = `${label}${description ?? ""}${allowNonContiguousMatches}${query.normalized}`; + const cached = cache[cacheHash]; + if (cached) { + return cached; + } + + const itemScore = doScoreItemFuzzy( + label, + description, + accessor.getItemPath(item), + query, + allowNonContiguousMatches, + ); + cache[cacheHash] = itemScore; + + return itemScore; +} + +function doScoreItemFuzzy( + label: string, + description: string | undefined, + path: string | undefined, + query: IPreparedQuery, + allowNonContiguousMatches: boolean, +): IItemScore { + const preferLabelMatches = !path || !query.containsPathSeparator; + + // Treat identity matches on full path highest + if (path && query.pathNormalized === path) { + return { + score: PATH_IDENTITY_SCORE, + labelMatch: [{ start: 0, end: label.length }], + descriptionMatch: description + ? [{ start: 0, end: description.length }] + : undefined, + }; + } + + // Score: multiple inputs + if (query.values && query.values.length > 1) { + return doScoreItemFuzzyMultiple( + label, + description, + path, + query.values, + preferLabelMatches, + allowNonContiguousMatches, + ); + } + + // Score: single input + return doScoreItemFuzzySingle( + label, + description, + path, + query, + preferLabelMatches, + allowNonContiguousMatches, + ); +} + +function doScoreItemFuzzyMultiple( + label: string, + description: string | undefined, + path: string | undefined, + query: IPreparedQueryPiece[], + preferLabelMatches: boolean, + allowNonContiguousMatches: boolean, +): IItemScore { + let totalScore = 0; + const totalLabelMatches: IMatch[] = []; + const totalDescriptionMatches: IMatch[] = []; + + for (const queryPiece of query) { + const { score, labelMatch, descriptionMatch } = doScoreItemFuzzySingle( + label, + description, + path, + queryPiece, + preferLabelMatches, + allowNonContiguousMatches, + ); + if (score === NO_MATCH) { + return NO_ITEM_SCORE; + } + + totalScore += score; + if (labelMatch) { + totalLabelMatches.push(...labelMatch); + } + if (descriptionMatch) { + totalDescriptionMatches.push(...descriptionMatch); + } + } + + return { + score: totalScore, + labelMatch: normalizeMatches(totalLabelMatches), + descriptionMatch: normalizeMatches(totalDescriptionMatches), + }; +} + +function doScoreItemFuzzySingle( + label: string, + description: string | undefined, + path: string | undefined, + query: IPreparedQueryPiece, + preferLabelMatches: boolean, + allowNonContiguousMatches: boolean, +): IItemScore { + // Prefer label matches if told so or we have no description + if (preferLabelMatches || !description) { + const [labelScore, labelPositions] = scoreFuzzy( + label, + query.normalized, + query.normalizedLowercase, + allowNonContiguousMatches && !query.expectContiguousMatch, + ); + if (labelScore) { + const labelPrefixMatch = matchesPrefix(query.normalized, label); + let baseScore: number; + if (labelPrefixMatch) { + baseScore = LABEL_PREFIX_SCORE_THRESHOLD; + + // Boost labels that are short (e.g. "window.ts" > "windowActions.ts" for query "window") + const prefixLengthBoost = Math.round( + (query.normalized.length / label.length) * 100, + ); + baseScore += prefixLengthBoost; + } else { + baseScore = LABEL_SCORE_THRESHOLD; + } + + return { + score: baseScore + labelScore, + labelMatch: labelPrefixMatch || createMatches(labelPositions), + }; + } + } + + // Finally compute description + label scores if we have a description + if (description) { + let descriptionPrefix = description; + if (path) { + descriptionPrefix = `${description}${sep}`; + } + + const descriptionPrefixLength = descriptionPrefix.length; + const descriptionAndLabel = `${descriptionPrefix}${label}`; + + const [labelDescriptionScore, labelDescriptionPositions] = scoreFuzzy( + descriptionAndLabel, + query.normalized, + query.normalizedLowercase, + allowNonContiguousMatches && !query.expectContiguousMatch, + ); + if (labelDescriptionScore) { + const labelDescriptionMatches = createMatches(labelDescriptionPositions); + const labelMatch: IMatch[] = []; + const descriptionMatch: IMatch[] = []; + + for (const h of labelDescriptionMatches) { + if ( + h.start < descriptionPrefixLength && + h.end > descriptionPrefixLength + ) { + labelMatch.push({ start: 0, end: h.end - descriptionPrefixLength }); + descriptionMatch.push({ + start: h.start, + end: descriptionPrefixLength, + }); + } else if (h.start >= descriptionPrefixLength) { + labelMatch.push({ + start: h.start - descriptionPrefixLength, + end: h.end - descriptionPrefixLength, + }); + } else { + descriptionMatch.push(h); + } + } + + return { score: labelDescriptionScore, labelMatch, descriptionMatch }; + } + } + + return NO_ITEM_SCORE; +} + +function createMatches(offsets: number[] | undefined): IMatch[] { + const ret: IMatch[] = []; + if (!offsets) { + return ret; + } + + let last: IMatch | undefined; + for (const pos of offsets) { + if (last && last.end === pos) { + last.end += 1; + } else { + last = { start: pos, end: pos + 1 }; + ret.push(last); + } + } + + return ret; +} + +function normalizeMatches(matches: IMatch[]): IMatch[] { + const sortedMatches = matches.sort( + (matchA, matchB) => matchA.start - matchB.start, + ); + + const normalizedMatches: IMatch[] = []; + let currentMatch: IMatch | undefined; + for (const match of sortedMatches) { + if (!currentMatch || !matchOverlaps(currentMatch, match)) { + currentMatch = match; + normalizedMatches.push(match); + } else { + currentMatch.start = Math.min(currentMatch.start, match.start); + currentMatch.end = Math.max(currentMatch.end, match.end); + } + } + + return normalizedMatches; +} + +function matchOverlaps(matchA: IMatch, matchB: IMatch): boolean { + if (matchA.end < matchB.start) { + return false; + } + if (matchB.end < matchA.start) { + return false; + } + return true; +} + +// #endregion + +// --------------------------------------------------------------------------- +// #region Comparers +// --------------------------------------------------------------------------- + +export function compareItemsByFuzzyScore( + itemA: T, + itemB: T, + query: IPreparedQuery, + allowNonContiguousMatches: boolean, + accessor: IItemAccessor, + cache: FuzzyScorerCache, +): number { + const itemScoreA = scoreItemFuzzy( + itemA, + query, + allowNonContiguousMatches, + accessor, + cache, + ); + const itemScoreB = scoreItemFuzzy( + itemB, + query, + allowNonContiguousMatches, + accessor, + cache, + ); + + const scoreA = itemScoreA.score; + const scoreB = itemScoreB.score; + + // 1.) identity matches have highest score + if (scoreA === PATH_IDENTITY_SCORE || scoreB === PATH_IDENTITY_SCORE) { + if (scoreA !== scoreB) { + return scoreA === PATH_IDENTITY_SCORE ? -1 : 1; + } + } + + // 2.) matches on label are considered higher compared to label+description matches + if (scoreA > LABEL_SCORE_THRESHOLD || scoreB > LABEL_SCORE_THRESHOLD) { + if (scoreA !== scoreB) { + return scoreA > scoreB ? -1 : 1; + } + + // prefer more compact matches over longer in label + if ( + scoreA < LABEL_PREFIX_SCORE_THRESHOLD && + scoreB < LABEL_PREFIX_SCORE_THRESHOLD + ) { + const comparedByMatchLength = compareByMatchLength( + itemScoreA.labelMatch, + itemScoreB.labelMatch, + ); + if (comparedByMatchLength !== 0) { + return comparedByMatchLength; + } + } + + // prefer shorter labels over longer labels + const labelA = accessor.getItemLabel(itemA) || ""; + const labelB = accessor.getItemLabel(itemB) || ""; + if (labelA.length !== labelB.length) { + return labelA.length - labelB.length; + } + } + + // 3.) compare by score in label+description + if (scoreA !== scoreB) { + return scoreA > scoreB ? -1 : 1; + } + + // 4.) scores are identical: prefer matches in label over non-label matches + const itemAHasLabelMatches = + Array.isArray(itemScoreA.labelMatch) && itemScoreA.labelMatch.length > 0; + const itemBHasLabelMatches = + Array.isArray(itemScoreB.labelMatch) && itemScoreB.labelMatch.length > 0; + if (itemAHasLabelMatches && !itemBHasLabelMatches) { + return -1; + } else if (itemBHasLabelMatches && !itemAHasLabelMatches) { + return 1; + } + + // 5.) scores are identical: prefer more compact matches (label and description) + const itemAMatchDistance = computeLabelAndDescriptionMatchDistance( + itemA, + itemScoreA, + accessor, + ); + const itemBMatchDistance = computeLabelAndDescriptionMatchDistance( + itemB, + itemScoreB, + accessor, + ); + if ( + itemAMatchDistance && + itemBMatchDistance && + itemAMatchDistance !== itemBMatchDistance + ) { + return itemBMatchDistance > itemAMatchDistance ? -1 : 1; + } + + // 6.) scores are identical: start to use the fallback compare + return fallbackCompare(itemA, itemB, query, accessor); +} + +function computeLabelAndDescriptionMatchDistance( + item: T, + score: IItemScore, + accessor: IItemAccessor, +): number { + let matchStart = -1; + let matchEnd = -1; + + if (score.descriptionMatch?.length) { + // biome-ignore lint/style/noNonNullAssertion: ported from VS Code — length already checked + matchStart = score.descriptionMatch[0]!.start; + } else if (score.labelMatch?.length) { + // biome-ignore lint/style/noNonNullAssertion: ported from VS Code — length already checked + matchStart = score.labelMatch[0]!.start; + } + + if (score.labelMatch?.length) { + // biome-ignore lint/style/noNonNullAssertion: ported from VS Code — length already checked + matchEnd = score.labelMatch[score.labelMatch.length - 1]!.end; + if (score.descriptionMatch?.length) { + const itemDescription = accessor.getItemDescription(item); + if (itemDescription) { + matchEnd += itemDescription.length; + } + } + } else if (score.descriptionMatch?.length) { + // biome-ignore lint/style/noNonNullAssertion: ported from VS Code — length already checked + matchEnd = score.descriptionMatch[score.descriptionMatch.length - 1]!.end; + } + + return matchEnd - matchStart; +} + +function compareByMatchLength( + matchesA?: IMatch[], + matchesB?: IMatch[], +): number { + if ((!matchesA && !matchesB) || (!matchesA?.length && !matchesB?.length)) { + return 0; + } + + if (!matchesB?.length) { + return -1; + } + + if (!matchesA?.length) { + return 1; + } + + // biome-ignore lint/style/noNonNullAssertion: ported from VS Code — length already checked above + const matchStartA = matchesA[0]!.start; + // biome-ignore lint/style/noNonNullAssertion: ported from VS Code + const matchEndA = matchesA[matchesA.length - 1]!.end; + const matchLengthA = matchEndA - matchStartA; + + // biome-ignore lint/style/noNonNullAssertion: ported from VS Code — length already checked above + const matchStartB = matchesB[0]!.start; + // biome-ignore lint/style/noNonNullAssertion: ported from VS Code + const matchEndB = matchesB[matchesB.length - 1]!.end; + const matchLengthB = matchEndB - matchStartB; + + return matchLengthA === matchLengthB + ? 0 + : matchLengthB < matchLengthA + ? 1 + : -1; +} + +function fallbackCompare( + itemA: T, + itemB: T, + query: IPreparedQuery, + accessor: IItemAccessor, +): number { + const labelA = accessor.getItemLabel(itemA) || ""; + const labelB = accessor.getItemLabel(itemB) || ""; + + const descriptionA = accessor.getItemDescription(itemA); + const descriptionB = accessor.getItemDescription(itemB); + + const labelDescriptionALength = + labelA.length + (descriptionA ? descriptionA.length : 0); + const labelDescriptionBLength = + labelB.length + (descriptionB ? descriptionB.length : 0); + + if (labelDescriptionALength !== labelDescriptionBLength) { + return labelDescriptionALength - labelDescriptionBLength; + } + + const pathA = accessor.getItemPath(itemA); + const pathB = accessor.getItemPath(itemB); + + if (pathA && pathB && pathA.length !== pathB.length) { + return pathA.length - pathB.length; + } + + if (labelA !== labelB) { + return compareAnything(labelA, labelB, query.normalized); + } + + if (descriptionA && descriptionB && descriptionA !== descriptionB) { + return compareAnything(descriptionA, descriptionB, query.normalized); + } + + if (pathA && pathB && pathA !== pathB) { + return compareAnything(pathA, pathB, query.normalized); + } + + return 0; +} + +// #endregion + +// --------------------------------------------------------------------------- +// #region Query normalizer +// --------------------------------------------------------------------------- + +export interface IPreparedQueryPiece { + original: string; + originalLowercase: string; + pathNormalized: string; + normalized: string; + normalizedLowercase: string; + expectContiguousMatch: boolean; +} + +export interface IPreparedQuery extends IPreparedQueryPiece { + values: IPreparedQueryPiece[] | undefined; + containsPathSeparator: boolean; +} + +function queryExpectsExactMatch(query: string) { + return query.startsWith('"') && query.endsWith('"'); +} + +const MULTIPLE_QUERY_VALUES_SEPARATOR = " "; + +export function prepareQuery(original: string): IPreparedQuery { + if (typeof original !== "string") { + original = ""; + } + + const originalLowercase = original.toLowerCase(); + const { pathNormalized, normalized, normalizedLowercase } = + normalizeQuery(original); + const containsPathSeparator = pathNormalized.indexOf(sep) >= 0; + const expectExactMatch = queryExpectsExactMatch(original); + + let values: IPreparedQueryPiece[] | undefined; + + const originalSplit = original.split(MULTIPLE_QUERY_VALUES_SEPARATOR); + if (originalSplit.length > 1) { + for (const originalPiece of originalSplit) { + const expectExactMatchPiece = queryExpectsExactMatch(originalPiece); + const { + pathNormalized: pathNormalizedPiece, + normalized: normalizedPiece, + normalizedLowercase: normalizedLowercasePiece, + } = normalizeQuery(originalPiece); + + if (normalizedPiece) { + if (!values) { + values = []; + } + + values.push({ + original: originalPiece, + originalLowercase: originalPiece.toLowerCase(), + pathNormalized: pathNormalizedPiece, + normalized: normalizedPiece, + normalizedLowercase: normalizedLowercasePiece, + expectContiguousMatch: expectExactMatchPiece, + }); + } + } + } + + return { + original, + originalLowercase, + pathNormalized, + normalized, + normalizedLowercase, + values, + containsPathSeparator, + expectContiguousMatch: expectExactMatch, + }; +} + +function normalizeQuery(original: string): { + pathNormalized: string; + normalized: string; + normalizedLowercase: string; +} { + // Normalize backslashes to forward slashes (we always use forward slashes) + const pathNormalized = original.replace(/\\/g, sep); + + // Remove quotes, wildcards, whitespace, ellipsis, trailing hash + const normalized = pathNormalized + .replace(/[*\u2026\s"]/g, "") + .replace(/(?<=.)#$/, ""); + + return { + pathNormalized, + normalized, + normalizedLowercase: normalized.toLowerCase(), + }; +} + +// #endregion diff --git a/packages/workspace-fs/src/search.test.ts b/packages/workspace-fs/src/search.test.ts index 5e3be81e36e..59aa265e6f7 100644 --- a/packages/workspace-fs/src/search.test.ts +++ b/packages/workspace-fs/src/search.test.ts @@ -236,9 +236,9 @@ describe("searchFiles", () => { limit: 5, }); - expect(results.map((result) => result.absolutePath)).toEqual([ - flatPath, - nestedPath, - ]); + const paths = results.map((result) => result.absolutePath); + expect(paths).toContain(flatPath); + expect(paths).toContain(nestedPath); + expect(paths).toHaveLength(2); }); }); diff --git a/packages/workspace-fs/src/search.ts b/packages/workspace-fs/src/search.ts index 7defe3a5be4..05b0da45cb8 100644 --- a/packages/workspace-fs/src/search.ts +++ b/packages/workspace-fs/src/search.ts @@ -3,13 +3,20 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import fg from "fast-glob"; -import Fuse from "fuse.js"; +import { + compareItemsByFuzzyScore, + type FuzzyScorerCache, + type IItemAccessor, + prepareQuery, + scoreFuzzy, + scoreItemFuzzy, +} from "./fuzzy-scorer"; import { normalizeAbsolutePath, toRelativePath } from "./paths"; import type { FsContentMatch, FsSearchMatch } from "./types"; const execFileAsync = promisify(execFile); -const SEARCH_INDEX_TTL_MS = 30_000; +// No TTL — index is kept current via patchSearchIndexesForRoot from file watcher const MAX_SEARCH_RESULTS = 500; const MAX_KEYWORD_FILE_SIZE_BYTES = 1024 * 1024; const BINARY_CHECK_SIZE = 8192; @@ -32,24 +39,8 @@ interface SearchIndexEntry { absolutePath: string; relativePath: string; name: string; - lowerName: string; - lowerRelativePath: string; - compactName: string; - compactRelativePath: string; -} - -interface FileSearchIndex { - items: SearchIndexEntry[]; - fuse: Fuse; - itemsByLowerName: Map; - itemsByCompactName: Map; - itemsByLowerRelativePath: Map; - itemsByCompactRelativePath: Map; -} - -interface FileSearchCacheEntry { - index: FileSearchIndex; - builtAt: number; + /** Parent directory path (pre-computed for scorer). */ + description: string | undefined; } interface PathFilterMatcher { @@ -106,29 +97,8 @@ export interface SearchContentOptions { ) => Promise<{ stdout: string }>; } -const searchIndexCache = new Map(); -const searchIndexBuilds = new Map>(); -const searchIndexVersions = new Map(); - -function createFileSearchFuse( - items: SearchIndexEntry[], -): Fuse { - return new Fuse(items, { - keys: [ - { name: "name", weight: 2 }, - { name: "relativePath", weight: 1 }, - { name: "compactName", weight: 1.8 }, - { name: "compactRelativePath", weight: 0.9 }, - ], - threshold: 0.4, - includeScore: true, - ignoreLocation: true, - }); -} - -function normalizeSearchText(input: string): string { - return input.toLowerCase().replace(/[\\/\s._-]+/g, ""); -} +const searchIndexCache = new Map(); +const searchIndexBuilds = new Map>(); function createSearchIndexEntry( rootPath: string, @@ -139,62 +109,12 @@ function createSearchIndexEntry( path.join(rootPath, normalizedRelativePath), ); const name = path.basename(normalizedRelativePath); - const lowerName = name.toLowerCase(); - const lowerRelativePath = normalizedRelativePath.toLowerCase(); - + const dir = normalizedRelativePath.slice(0, -(name.length + 1)); return { absolutePath, relativePath: normalizedRelativePath, name, - lowerName, - lowerRelativePath, - compactName: normalizeSearchText(name), - compactRelativePath: normalizeSearchText(normalizedRelativePath), - }; -} - -function addSearchIndexMapEntry( - index: Map, - key: string, - item: SearchIndexEntry, -): void { - const existing = index.get(key); - if (existing) { - existing.push(item); - return; - } - - index.set(key, [item]); -} - -function createFileSearchIndex(items: SearchIndexEntry[]): FileSearchIndex { - const itemsByLowerName = new Map(); - const itemsByCompactName = new Map(); - const itemsByLowerRelativePath = new Map(); - const itemsByCompactRelativePath = new Map(); - - for (const item of items) { - addSearchIndexMapEntry(itemsByLowerName, item.lowerName, item); - addSearchIndexMapEntry(itemsByCompactName, item.compactName, item); - addSearchIndexMapEntry( - itemsByLowerRelativePath, - item.lowerRelativePath, - item, - ); - addSearchIndexMapEntry( - itemsByCompactRelativePath, - item.compactRelativePath, - item, - ); - } - - return { - items, - fuse: createFileSearchFuse(items), - itemsByLowerName, - itemsByCompactName, - itemsByLowerRelativePath, - itemsByCompactRelativePath, + description: dir || undefined, }; } @@ -205,16 +125,6 @@ function getSearchCacheKey({ return `${normalizeAbsolutePath(rootPath)}::${includeHidden ? "hidden" : "visible"}`; } -function getSearchIndexVersion(cacheKey: string): number { - return searchIndexVersions.get(cacheKey) ?? 0; -} - -function advanceSearchIndexVersion(cacheKey: string): number { - const nextVersion = getSearchIndexVersion(cacheKey) + 1; - searchIndexVersions.set(cacheKey, nextVersion); - return nextVersion; -} - function parseGlobPatterns(input: string): string[] { return input .split(",") @@ -342,7 +252,7 @@ function matchesPathFilters( async function buildSearchIndex({ rootPath, includeHidden, -}: SearchIndexKeyOptions): Promise { +}: SearchIndexKeyOptions): Promise { const normalizedRootPath = normalizeAbsolutePath(rootPath); const entries = await fg("**/*", { cwd: normalizedRootPath, @@ -354,59 +264,31 @@ async function buildSearchIndex({ ignore: DEFAULT_IGNORE_PATTERNS, }); - const items: SearchIndexEntry[] = entries.map((relativePath) => + return entries.map((relativePath) => createSearchIndexEntry(normalizedRootPath, relativePath), ); - - return createFileSearchIndex(items); } -async function getSearchIndex( +export async function getSearchIndex( options: SearchIndexKeyOptions, -): Promise { +): Promise { const cacheKey = getSearchCacheKey(options); - const cached = searchIndexCache.get(cacheKey); - const now = Date.now(); - const inFlight = searchIndexBuilds.get(cacheKey); - - if (cached && now - cached.builtAt < SEARCH_INDEX_TTL_MS) { - return cached.index; - } - - if (cached && !inFlight) { - const buildVersion = getSearchIndexVersion(cacheKey); - const buildPromise = buildSearchIndex(options) - .then((index) => { - if (getSearchIndexVersion(cacheKey) === buildVersion) { - searchIndexCache.set(cacheKey, { index, builtAt: Date.now() }); - } - searchIndexBuilds.delete(cacheKey); - return index; - }) - .catch((error) => { - searchIndexBuilds.delete(cacheKey); - throw error; - }); - searchIndexBuilds.set(cacheKey, buildPromise); - return cached.index; - } + const cached = searchIndexCache.get(cacheKey); if (cached) { - return cached.index; + return cached; } + const inFlight = searchIndexBuilds.get(cacheKey); if (inFlight) { return await inFlight; } - const buildVersion = getSearchIndexVersion(cacheKey); const buildPromise = buildSearchIndex(options) - .then((index) => { - if (getSearchIndexVersion(cacheKey) === buildVersion) { - searchIndexCache.set(cacheKey, { index, builtAt: Date.now() }); - } + .then((items) => { + searchIndexCache.set(cacheKey, items); searchIndexBuilds.delete(cacheKey); - return index; + return items; }) .catch((error) => { searchIndexBuilds.delete(cacheKey); @@ -421,78 +303,6 @@ function safeSearchLimit(limit: number | undefined): number { return Math.max(1, Math.min(limit ?? 20, MAX_SEARCH_RESULTS)); } -function compareFileSearchMatches( - left: { item: SearchIndexEntry; score: number }, - right: { item: SearchIndexEntry; score: number }, -): number { - if (left.score !== right.score) { - return right.score - left.score; - } - - if (left.item.name.length !== right.item.name.length) { - return left.item.name.length - right.item.name.length; - } - - if (left.item.relativePath.length !== right.item.relativePath.length) { - return left.item.relativePath.length - right.item.relativePath.length; - } - - return left.item.relativePath.localeCompare(right.item.relativePath); -} - -function collectExactFileSearchMatches({ - index, - query, - pathMatcher, - limit, -}: { - index: FileSearchIndex; - query: string; - pathMatcher: PathFilterMatcher; - limit: number; -}): Array<{ item: SearchIndexEntry; score: number }> { - const lowerQuery = query.toLowerCase(); - const normalizedPathQuery = normalizePathForGlob(lowerQuery); - const compactQuery = normalizeSearchText(query); - const matchesByPath = new Map< - string, - { item: SearchIndexEntry; score: number } - >(); - - const addMatches = ( - items: SearchIndexEntry[] | undefined, - score: number, - ): void => { - const candidates = items ?? []; - - for (const item of candidates) { - if ( - pathMatcher.hasFilters && - !matchesPathFilters(item.relativePath, pathMatcher) - ) { - continue; - } - - const existing = matchesByPath.get(item.absolutePath); - if (!existing || existing.score < score) { - matchesByPath.set(item.absolutePath, { item, score }); - } - } - }; - - addMatches(index.itemsByLowerName.get(lowerQuery), 1); - addMatches(index.itemsByLowerRelativePath.get(normalizedPathQuery), 0.995); - - if (compactQuery.length > 0) { - addMatches(index.itemsByCompactName.get(compactQuery), 0.99); - addMatches(index.itemsByCompactRelativePath.get(compactQuery), 0.985); - } - - return Array.from(matchesByPath.values()) - .sort(compareFileSearchMatches) - .slice(0, limit); -} - function isBinaryContent(buffer: Buffer): boolean { const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE); for (let index = 0; index < checkLength; index++) { @@ -524,20 +334,17 @@ function rankContentMatches( } const safeLimit = safeSearchLimit(limit); - const fuse = new Fuse(matches, { - keys: [ - { name: "preview", weight: 2 }, - { name: "name", weight: 1.2 }, - { name: "relativePath", weight: 1 }, - ], - threshold: 0.45, - includeScore: true, - ignoreLocation: true, + const queryLower = query.toLowerCase(); + + const scored = matches.map((match) => { + const target = `${match.name} ${match.preview}`; + const [score] = scoreFuzzy(target, query, queryLower, true); + return { match, score }; }); - const ranked = fuse - .search(query, { limit: safeLimit }) - .map((result) => result.item); + scored.sort((a, b) => b.score - a.score); + + const ranked = scored.slice(0, safeLimit).map((s) => s.match); return ranked.length > 0 ? ranked : matches.slice(0, safeLimit); } @@ -720,7 +527,7 @@ async function searchContentWithScan({ pathMatcher, limit, }: { - index: FileSearchIndex; + index: SearchIndexEntry[]; query: string; pathMatcher: PathFilterMatcher; limit: number; @@ -730,7 +537,7 @@ async function searchContentWithScan({ const lowerNeedle = query.toLowerCase(); const matches: InternalContentMatch[] = []; - for (const item of index.items) { + for (const item of index) { if (matches.length >= maxCandidates) { break; } @@ -851,7 +658,6 @@ function applySearchPatchEvent({ export function invalidateSearchIndex(options: SearchIndexKeyOptions): void { const cacheKey = getSearchCacheKey(options); - advanceSearchIndexVersion(cacheKey); searchIndexCache.delete(cacheKey); searchIndexBuilds.delete(cacheKey); } @@ -863,13 +669,6 @@ export function invalidateSearchIndexesForRoot(rootPath: string): void { } export function invalidateAllSearchIndexes(): void { - for (const cacheKey of new Set([ - ...searchIndexCache.keys(), - ...searchIndexBuilds.keys(), - ...searchIndexVersions.keys(), - ])) { - advanceSearchIndexVersion(cacheKey); - } searchIndexCache.clear(); searchIndexBuilds.clear(); } @@ -895,20 +694,14 @@ export function patchSearchIndexesForRoot( includeHidden, }); const cached = searchIndexCache.get(cacheKey); - const hasInFlightBuild = searchIndexBuilds.has(cacheKey); - if (!cached && !hasInFlightBuild) { - continue; - } - - advanceSearchIndexVersion(cacheKey); - searchIndexBuilds.delete(cacheKey); - if (!cached) { + // No cached index — also cancel any in-flight build since it'll be stale + searchIndexBuilds.delete(cacheKey); continue; } const nextItemsByPath = new Map( - cached.index.items.map((item) => [item.absolutePath, item]), + cached.map((item) => [item.absolutePath, item]), ); for (const event of events) { applySearchPatchEvent({ @@ -918,15 +711,27 @@ export function patchSearchIndexesForRoot( event, }); } - const nextItems = Array.from(nextItemsByPath.values()); - searchIndexCache.set(cacheKey, { - index: createFileSearchIndex(nextItems), - builtAt: Date.now(), - }); + searchIndexCache.set(cacheKey, Array.from(nextItemsByPath.values())); } } +/** + * IItemAccessor for SearchIndexEntry — maps to VS Code's label/description/path model. + * label = filename, description = parent directory path, path = full relative path. + */ +const searchEntryAccessor: IItemAccessor = { + getItemLabel(item) { + return item.name; + }, + getItemDescription(item) { + return item.description; + }, + getItemPath(item) { + return item.relativePath; + }, +}; + export async function searchFiles({ rootPath, query, @@ -935,7 +740,7 @@ export async function searchFiles({ excludePattern = "", limit = 20, }: SearchFilesOptions): Promise { - const trimmedQuery = query.trim(); + const trimmedQuery = normalizePathForGlob(query.trim()); if (!trimmedQuery) { return []; } @@ -949,46 +754,46 @@ export async function searchFiles({ excludePattern, }); const safeLimit = safeSearchLimit(limit); - - const exactMatches = collectExactFileSearchMatches({ - index, - query: trimmedQuery, - pathMatcher, - limit: safeLimit, - }); - if (exactMatches.length > 0) { - return exactMatches.map((result) => ({ - absolutePath: result.item.absolutePath, - relativePath: result.item.relativePath, - name: result.item.name, - kind: "file" as const, - score: result.score, - })); - } + const prepared = prepareQuery(trimmedQuery); + const cache: FuzzyScorerCache = {}; const searchableItems = pathMatcher.hasFilters - ? index.items.filter((item) => - matchesPathFilters(item.relativePath, pathMatcher), - ) - : index.items; + ? index.filter((item) => matchesPathFilters(item.relativePath, pathMatcher)) + : index; - if (searchableItems.length === 0) { - return []; + // Score all items using VS Code's item scorer, then filter non-matches + const scored: Array<{ item: SearchIndexEntry; score: number }> = []; + for (const item of searchableItems) { + const itemScore = scoreItemFuzzy( + item, + prepared, + true, + searchEntryAccessor, + cache, + ); + if (itemScore.score > 0) { + scored.push({ item, score: itemScore.score }); + } } - const fuse = pathMatcher.hasFilters - ? createFileSearchFuse(searchableItems) - : index.fuse; - const results = fuse.search(trimmedQuery, { - limit: safeLimit, - }); + // Sort using VS Code's full comparator + scored.sort((a, b) => + compareItemsByFuzzyScore( + a.item, + b.item, + prepared, + true, + searchEntryAccessor, + cache, + ), + ); - return results.map((result) => ({ + return scored.slice(0, safeLimit).map((result) => ({ absolutePath: result.item.absolutePath, relativePath: result.item.relativePath, name: result.item.name, kind: "file" as const, - score: 1 - (result.score ?? 0), + score: result.score, })); }