diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d818fc64972..a519a46713f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -143,6 +143,7 @@ "@types/express": "^5.0.5", "@types/pidusage": "^2.0.5", "@vercel/blob": "^2.0.0", + "@vscode/ripgrep": "^1.15.9", "@xterm/addon-clipboard": "0.3.0-beta.195", "@xterm/addon-fit": "0.12.0-beta.195", "@xterm/addon-image": "0.10.0-beta.195", @@ -192,6 +193,7 @@ "highlight.js": "^11.11.1", "html-to-image": "^1.11.13", "http-proxy": "^1.18.1", + "https-proxy-agent": "^7.0.2", "idb-keyval": "^6.2.2", "jose": "^6.1.3", "js-yaml": "^4.1.1", @@ -213,6 +215,7 @@ "posthog-js": "1.310.1", "posthog-node": "^5.24.7", "prebuild-install": "^7.1.1", + "proxy-from-env": "^1.1.0", "pyright": "^1.1.408", "react": "19.2.0", "react-dnd": "^16.0.1", @@ -254,6 +257,7 @@ "vscode-languageserver-types": "3.17.3", "ws": "^8.18.0", "yaml-language-server": "^1.21.0", + "yauzl": "^2.9.2", "zod": "^4.3.5", "zustand": "^5.0.8" }, diff --git a/apps/desktop/runtime-dependencies.ts b/apps/desktop/runtime-dependencies.ts index 8b02f1f71ec..7410c8a4377 100644 --- a/apps/desktop/runtime-dependencies.ts +++ b/apps/desktop/runtime-dependencies.ts @@ -79,6 +79,15 @@ const externalizedRuntimeModules: ExternalizedRuntimeModule[] = [ ], asarUnpackGlobs: ["**/node_modules/@libsql/**/*"], }, + { + // Ships the ripgrep binary with the app so VSCode-style .gitignore-aware + // Quick Open / Files tab search works for every user regardless of + // whether they have rg on their system PATH. + specifier: "@vscode/ripgrep", + materialize: ["@vscode/ripgrep"], + packagedCopies: [copyWholeModule("@vscode/ripgrep")], + asarUnpackGlobs: ["**/node_modules/@vscode/ripgrep/**/*"], + }, ]; const packagedSupportModules = [ diff --git a/apps/desktop/src/lib/trpc/routers/filesystem/index.ts b/apps/desktop/src/lib/trpc/routers/filesystem/index.ts index f07a51e6b05..fa319bf02d9 100644 --- a/apps/desktop/src/lib/trpc/routers/filesystem/index.ts +++ b/apps/desktop/src/lib/trpc/routers/filesystem/index.ts @@ -328,6 +328,9 @@ export const createFilesystemRouter = () => { includePattern: z.string().optional(), excludePattern: z.string().optional(), limit: z.number().optional(), + openFilePaths: z.array(z.string()).optional(), + recentFilePaths: z.array(z.string()).optional(), + scopeId: z.string().optional(), }), ) .query(async ({ input }) => { @@ -344,6 +347,25 @@ export const createFilesystemRouter = () => { includePattern: input.includePattern, excludePattern: input.excludePattern, limit: input.limit, + openFilePaths: input.openFilePaths, + recentFilePaths: input.recentFilePaths, + scopeId: input.scopeId, + }); + }); + }), + + warmupSearchIndex: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + includeHidden: z.boolean().optional(), + }), + ) + .mutation(async ({ input }) => { + return await withFilesystemErrorBoundary(async () => { + const service = getServiceForWorkspace(input.workspaceId); + return await service.warmupSearchIndex({ + includeHidden: input.includeHidden, }); }); }), diff --git a/apps/desktop/src/lib/trpc/routers/workspace-fs-service.ts b/apps/desktop/src/lib/trpc/routers/workspace-fs-service.ts index 75b22d6fd7c..2d223735c73 100644 --- a/apps/desktop/src/lib/trpc/routers/workspace-fs-service.ts +++ b/apps/desktop/src/lib/trpc/routers/workspace-fs-service.ts @@ -1,4 +1,6 @@ +import { execFile } from "node:child_process"; import path from "node:path"; +import { promisify } from "node:util"; import { createFsHostService, type FsHostService, @@ -7,13 +9,28 @@ import { type WorkspaceFsPathError, } from "@superset/workspace-fs/host"; import { TRPCError } from "@trpc/server"; +import { rgPath as bundledRgPath } from "@vscode/ripgrep"; import { shell } from "electron"; import { getWorkspace } from "./workspaces/utils/db-helpers"; -import { execWithShellEnv } from "./workspaces/utils/shell-env"; import { getWorkspacePath } from "./workspaces/utils/worktree"; +const execFileAsync = promisify(execFile); + const filesystemWatcherManager = new FsWatcherManager(); +// electron-builder packs node_modules into app.asar, but native binaries can't +// execute from inside asar. We unpack @vscode/ripgrep via `asarUnpack` in +// electron-builder.ts, and at runtime we rewrite the path from the asar view +// to the asar.unpacked view so `execFile` can invoke it. +const rgExecutablePath = bundledRgPath.includes( + `${path.sep}app.asar${path.sep}`, +) + ? bundledRgPath.replace( + `${path.sep}app.asar${path.sep}`, + `${path.sep}app.asar.unpacked${path.sep}`, + ) + : bundledRgPath; + const sharedHostServiceOptions = { trashItem: async (absolutePath: string) => { await shell.trashItem(absolutePath); @@ -22,7 +39,10 @@ const sharedHostServiceOptions = { args: string[], options: { cwd: string; maxBuffer: number; signal?: AbortSignal }, ) => { - const result = await execWithShellEnv("rg", args, { + // Shipping our own ripgrep (via @vscode/ripgrep) means users don't + // have to `brew install ripgrep` to get .gitignore-aware search. + // Matches VSCode's approach. + const result = await execFileAsync(rgExecutablePath, args, { cwd: options.cwd, maxBuffer: options.maxBuffer, windowsHide: true, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/hooks/useWorkspaceFileSearch/useWorkspaceFileSearch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/hooks/useWorkspaceFileSearch/useWorkspaceFileSearch.ts index 9c42d9353bb..f876c8edce6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/hooks/useWorkspaceFileSearch/useWorkspaceFileSearch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/hooks/useWorkspaceFileSearch/useWorkspaceFileSearch.ts @@ -6,12 +6,20 @@ interface UseWorkspaceFileSearchParams { workspaceId: string; searchTerm: string; limit?: number; + includePattern?: string; + excludePattern?: string; + openFilePaths?: string[]; + recentFilePaths?: string[]; } export function useWorkspaceFileSearch({ workspaceId, searchTerm, limit = SEARCH_RESULT_LIMIT, + includePattern = "", + excludePattern = "", + openFilePaths, + recentFilePaths, }: UseWorkspaceFileSearchParams) { const trimmedQuery = searchTerm.trim(); const debouncedQuery = useDebouncedValue(trimmedQuery, 150); @@ -24,6 +32,10 @@ export function useWorkspaceFileSearch({ workspaceId, query: debouncedQuery, limit, + includePattern, + excludePattern, + openFilePaths, + recentFilePaths, }, { enabled: debouncedQuery.length > 0, 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 5ba45045658..3a3d5e48380 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 @@ -489,12 +489,23 @@ function WorkspaceContent({ [store], ); + const openFilePathsList = useMemo( + () => Array.from(openFilePaths), + [openFilePaths], + ); + const recentFilePathsList = useMemo( + () => recentFiles.map((file) => file.absolutePath), + [recentFiles], + ); + // FORK NOTE: fork uses the richer `useCommandPalette` (supports filters, // cross-workspace open, scope switching) instead of upstream's simple // boolean-state palette. const commandPalette = useCommandPalette({ workspaceId, navigate, + openFilePaths: openFilePathsList, + recentFilePaths: recentFilePathsList, onSelectFile: ({ close, filePath, targetWorkspaceId }) => { close(); if (targetWorkspaceId !== workspaceId) { diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts index c33cd59420a..b50f87e1f9a 100644 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts @@ -23,6 +23,16 @@ interface UseCommandPaletteParams { close: () => void; navigate: UseNavigateResult; }) => void; + /** + * Absolute paths currently open in the editor. Forwarded to `searchFiles` + * so VSCode-style Quick Open boosts them above unrelated hits. + */ + openFilePaths?: readonly string[]; + /** + * Absolute paths ordered most-recent-first. Forwarded to `searchFiles` for + * MRU boosting and tiebreaking. + */ + recentFilePaths?: readonly string[]; } export function useCommandPalette({ @@ -30,6 +40,8 @@ export function useCommandPalette({ navigate, enabled = true, onSelectFile, + openFilePaths, + recentFilePaths, }: UseCommandPaletteParams) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); @@ -64,6 +76,20 @@ export function useCommandPalette({ }, ); + // Kick off a background index build whenever Cmd+P is opened. The backend + // deduplicates concurrent builds via its in-flight Promise cache and the + // TTL (30s), so firing this on every open is cheap and keeps results + // instant even after the cache expires between uses. + const warmupMutation = + electronTrpc.filesystem.warmupSearchIndex.useMutation(); + const warmupMutate = warmupMutation.mutate; + useEffect(() => { + if (!open || !workspaceId) { + return; + } + warmupMutate({ workspaceId }); + }, [open, workspaceId, warmupMutate]); + // Build roots array for multi-workspace search const roots = useMemo(() => { if (scope !== "global" || !allGrouped) return []; @@ -103,6 +129,32 @@ export function useCommandPalette({ return result; }, [scope, allGrouped]); + // Stabilize identity of the MRU/open arrays across renders. Joining into a + // single sentinel string is cheap and means React Query only re-fetches + // when the actual set of paths changes, not on every parent render. + const openFilePathsKey = useMemo( + () => (openFilePaths ? openFilePaths.join("\u0000") : ""), + [openFilePaths], + ); + const recentFilePathsKey = useMemo( + () => (recentFilePaths ? recentFilePaths.join("\u0000") : ""), + [recentFilePaths], + ); + const openFilePathsList = useMemo( + () => + openFilePathsKey.length > 0 + ? openFilePathsKey.split("\u0000") + : undefined, + [openFilePathsKey], + ); + const recentFilePathsList = useMemo( + () => + recentFilePathsKey.length > 0 + ? recentFilePathsKey.split("\u0000") + : undefined, + [recentFilePathsKey], + ); + // Single-workspace search (existing behavior) const singleSearch = useFileSearch({ workspaceId: open && scope === "workspace" ? workspaceId : undefined, @@ -110,9 +162,14 @@ export function useCommandPalette({ includePattern, excludePattern, limit: SEARCH_LIMIT, + openFilePaths: openFilePathsList, + recentFilePaths: recentFilePathsList, + scopeId: "quick-open", }); - // Multi-workspace search + // Multi-workspace search. Note that MRU/open boosts aren't forwarded here + // because the recency lists are scoped to the current workspace; applying + // them across other workspaces would mis-rank unrelated paths. const debouncedQuery = useDebouncedValue(query.trim(), 150); const multiSearchQueries = electronTrpc.useQueries((t) => open && scope === "global" && roots.length > 0 && debouncedQuery.length > 0 @@ -123,6 +180,7 @@ export function useCommandPalette({ includePattern, excludePattern, limit: SEARCH_LIMIT, + scopeId: "quick-open-global", }), ) : [], diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/FileTreeToolbar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/FileTreeToolbar.tsx index 48b2685000e..e1904852e17 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/FileTreeToolbar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/FileTreeToolbar.tsx @@ -1,7 +1,7 @@ import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; import { LuChevronsDownUp, LuFilePlus, @@ -11,7 +11,6 @@ import { LuX, } from "react-icons/lu"; import { useFileExplorerStore } from "renderer/stores/file-explorer"; -import { SEARCH_DEBOUNCE_MS } from "../../constants"; interface FileTreeToolbarProps { searchTerm: string; @@ -33,48 +32,18 @@ export function FileTreeToolbar({ isRefreshing = false, }: FileTreeToolbarProps) { const { showFileTooltips, toggleFileTooltips } = useFileExplorerStore(); - const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm); - const debounceTimeoutRef = useRef | null>(null); - - useEffect(() => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - debounceTimeoutRef.current = null; - } - setLocalSearchTerm(searchTerm); - }, [searchTerm]); - - useEffect(() => { - return () => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } - }; - }, []); + // Debounce lives entirely in `useFileSearch` so the input stays responsive + // and we avoid the two-layer debounce chain that previously delayed renders + // unpredictably depending on stale closures. const handleSearchChange = useCallback( (e: React.ChangeEvent) => { - const value = e.target.value; - setLocalSearchTerm(value); - - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } - - debounceTimeoutRef.current = setTimeout(() => { - onSearchChange(value); - debounceTimeoutRef.current = null; - }, SEARCH_DEBOUNCE_MS); + onSearchChange(e.target.value); }, [onSearchChange], ); const handleClearSearch = useCallback(() => { - setLocalSearchTerm(""); - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - debounceTimeoutRef.current = null; - } onSearchChange(""); }, [onSearchChange]); @@ -84,11 +53,11 @@ export function FileTreeToolbar({ - {localSearchTerm && ( + {searchTerm && (