diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts index d1c72efea3c..e9a481ca4cc 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts @@ -274,6 +274,7 @@ describe("detectLanguage", () => { test("detects web files", () => { expect(detectLanguage("index.html")).toBe("html"); + expect(detectLanguage("page.astro")).toBe("html"); expect(detectLanguage("styles.css")).toBe("css"); expect(detectLanguage("styles.scss")).toBe("scss"); }); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 9cfb1e3fa3c..e932fc634e2 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -90,6 +90,7 @@ app.on("child-process-gone", (_event, details) => { export async function MainWindow() { const savedWindowState = loadWindowState(); const initialBounds = getInitialWindowBounds(savedWindowState); + let persistedZoomLevel = savedWindowState?.zoomLevel; const isDev = env.NODE_ENV === "development"; const workspaceName = isDev ? getEnvWorkspaceName() : undefined; @@ -225,6 +226,7 @@ export async function MainWindow() { // Gated by `initialized` so the initial maximize() doesn't immediately // write isMaximized: true back to disk before the user touches the window. let initialized = false; + let hasCompletedFirstLoad = false; let saveTimeout: ReturnType | null = null; const debouncedSave = () => { if (!initialized || window.isDestroyed()) return; @@ -235,29 +237,43 @@ export async function MainWindow() { const bounds = isMaximized ? window.getNormalBounds() : window.getBounds(); + const zoomLevel = window.webContents.getZoomLevel(); saveWindowState({ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, isMaximized, - zoomLevel: window.webContents.getZoomLevel(), + zoomLevel, }); + persistedZoomLevel = zoomLevel; }, 500); }; window.on("move", debouncedSave); window.on("resize", debouncedSave); + window.webContents.on("zoom-changed", () => { + setTimeout(() => { + if (window.isDestroyed()) return; + persistedZoomLevel = window.webContents.getZoomLevel(); + debouncedSave(); + }, 0); + }); - window.webContents.once("did-finish-load", async () => { + window.webContents.on("did-finish-load", () => { console.log("[main-window] Renderer loaded successfully"); - if (initialBounds.isMaximized) { - window.maximize(); + + if (persistedZoomLevel !== undefined) { + window.webContents.setZoomLevel(persistedZoomLevel); } - if (savedWindowState?.zoomLevel !== undefined) { - window.webContents.setZoomLevel(savedWindowState.zoomLevel); + + if (!hasCompletedFirstLoad) { + if (initialBounds.isMaximized) { + window.maximize(); + } + window.show(); + initialized = true; + hasCompletedFirstLoad = true; } - window.show(); - initialized = true; }); window.webContents.on( @@ -295,6 +311,7 @@ export async function MainWindow() { isMaximized, zoomLevel, }); + persistedZoomLevel = zoomLevel; browserManager.unregisterAll(); server.close(); 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 ed7656ab114..877e1056c4e 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 @@ -16,10 +16,6 @@ import { CommandPalette, useCommandPalette, } from "renderer/screens/main/components/CommandPalette"; -import { - KeywordSearch, - useKeywordSearch, -} from "renderer/screens/main/components/KeywordSearch"; 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"; @@ -420,21 +416,10 @@ function WorkspacePage() { workspaceId, navigate, }); - const keywordSearch = useKeywordSearch({ - workspaceId, - }); const handleQuickOpen = useCallback(() => { - keywordSearch.handleOpenChange(false); commandPalette.toggle(); - }, [commandPalette.toggle, keywordSearch.handleOpenChange]); - const handleKeywordSearch = useCallback(() => { - commandPalette.handleOpenChange(false); - keywordSearch.toggle(); - }, [commandPalette.handleOpenChange, keywordSearch.toggle]); + }, [commandPalette.toggle]); useAppHotkey("QUICK_OPEN", handleQuickOpen, undefined, [handleQuickOpen]); - useAppHotkey("KEYWORD_SEARCH", handleKeywordSearch, undefined, [ - handleKeywordSearch, - ]); // Toggle changes sidebar (⌘L) useAppHotkey("TOGGLE_SIDEBAR", () => toggleSidebar(), undefined, [ @@ -678,21 +663,6 @@ function WorkspacePage() { : undefined } /> - { diff --git a/apps/desktop/src/shared/detect-language.ts b/apps/desktop/src/shared/detect-language.ts index 7f305cd2ba7..d4f42df7647 100644 --- a/apps/desktop/src/shared/detect-language.ts +++ b/apps/desktop/src/shared/detect-language.ts @@ -13,6 +13,7 @@ export function detectLanguage(filePath: string): string { // Web html: "html", htm: "html", + astro: "html", css: "css", scss: "scss", less: "less", diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts index 5543cf388b0..d64340fc69a 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -784,13 +784,6 @@ export const HOTKEYS = { category: "Navigation", description: "Search and open files in the current workspace", }), - KEYWORD_SEARCH: defineHotkey({ - keys: "meta+shift+f", - label: "Keyword Search", - category: "Navigation", - description: - "Search for keyword matches across files in the current workspace", - }), // Chat FIND_IN_CHAT: defineHotkey({ diff --git a/packages/workspace-fs/src/search.test.ts b/packages/workspace-fs/src/search.test.ts index 0e4c589d9e8..5e3be81e36e 100644 --- a/packages/workspace-fs/src/search.test.ts +++ b/packages/workspace-fs/src/search.test.ts @@ -130,4 +130,115 @@ describe("patchSearchIndexesForRoot", () => { hiddenPath, ); }); + + it("rebuilds search indexes after a directory rename", async () => { + const rootPath = await createTempRoot(); + const oldDirectoryPath = path.join(rootPath, "old-dir"); + const newDirectoryPath = path.join(rootPath, "new-dir"); + const oldFilePath = path.join(oldDirectoryPath, "target.ts"); + const newFilePath = path.join(newDirectoryPath, "target.ts"); + + await fs.mkdir(oldDirectoryPath, { recursive: true }); + await fs.writeFile(oldFilePath, "export const target = 1;\n"); + + await searchFiles({ + rootPath, + query: "old-dir/target.ts", + }); + + await fs.rename(oldDirectoryPath, newDirectoryPath); + + patchSearchIndexesForRoot(rootPath, [ + createPatchEvent({ + kind: "rename", + absolutePath: newDirectoryPath, + oldAbsolutePath: oldDirectoryPath, + isDirectory: true, + }), + ]); + + const oldPathResults = await searchFiles({ + rootPath, + query: "old-dir/target.ts", + }); + const newPathResults = await searchFiles({ + rootPath, + query: "new-dir/target.ts", + }); + + expect( + oldPathResults.some( + (result) => result.relativePath === "old-dir/target.ts", + ), + ).toEqual(false); + expect(newPathResults[0]?.absolutePath).toEqual(newFilePath); + expect(newPathResults[0]?.relativePath).toEqual("new-dir/target.ts"); + }); +}); + +describe("searchFiles", () => { + it("prioritizes exact filename matches ahead of fuzzy path matches", async () => { + const rootPath = await createTempRoot(); + const exactMatchPath = path.join(rootPath, "WorkspaceFiles.tsx"); + const fuzzyMatchPath = path.join(rootPath, "hooks", "useWorkspaceFiles.ts"); + + await fs.mkdir(path.dirname(fuzzyMatchPath), { recursive: true }); + await fs.writeFile(exactMatchPath, "export const exact = true;\n"); + await fs.writeFile(fuzzyMatchPath, "export const fuzzy = true;\n"); + + const results = await searchFiles({ + rootPath, + query: "WorkspaceFiles.tsx", + limit: 5, + }); + + expect(results[0]?.absolutePath).toEqual(exactMatchPath); + expect(results).toHaveLength(1); + + const fuzzyResults = await searchFiles({ + rootPath, + query: "useWorkspaceFiles", + limit: 5, + }); + + expect(fuzzyResults[0]?.absolutePath).toEqual(fuzzyMatchPath); + }); + + it("normalizes exact relative path queries before lookup", async () => { + const rootPath = await createTempRoot(); + const targetPath = path.join(rootPath, "src", "file.ts"); + + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, "export const value = true;\n"); + + const results = await searchFiles({ + rootPath, + query: "./src/file.ts", + limit: 5, + }); + + expect(results[0]?.absolutePath).toEqual(targetPath); + expect(results[0]?.relativePath).toEqual("src/file.ts"); + }); + + it("returns every compact path collision instead of dropping later entries", async () => { + const rootPath = await createTempRoot(); + const nestedPath = path.join(rootPath, "foo", "bar.ts"); + const flatPath = path.join(rootPath, "foo-bar.ts"); + + await fs.mkdir(path.dirname(nestedPath), { recursive: true }); + await fs.writeFile(nestedPath, "export const nested = true;\n"); + await fs.writeFile(flatPath, "export const flat = true;\n"); + + const results = await searchFiles({ + rootPath, + query: "foobarts", + limit: 5, + }); + + expect(results.map((result) => result.absolutePath)).toEqual([ + flatPath, + nestedPath, + ]); + }); }); diff --git a/packages/workspace-fs/src/search.ts b/packages/workspace-fs/src/search.ts index 82cc60b892e..7defe3a5be4 100644 --- a/packages/workspace-fs/src/search.ts +++ b/packages/workspace-fs/src/search.ts @@ -32,11 +32,19 @@ 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 { @@ -109,6 +117,8 @@ function createFileSearchFuse( keys: [ { name: "name", weight: 2 }, { name: "relativePath", weight: 1 }, + { name: "compactName", weight: 1.8 }, + { name: "compactRelativePath", weight: 0.9 }, ], threshold: 0.4, includeScore: true, @@ -116,6 +126,78 @@ function createFileSearchFuse( }); } +function normalizeSearchText(input: string): string { + return input.toLowerCase().replace(/[\\/\s._-]+/g, ""); +} + +function createSearchIndexEntry( + rootPath: string, + relativePath: string, +): SearchIndexEntry { + const normalizedRelativePath = normalizePathForGlob(relativePath); + const absolutePath = normalizeAbsolutePath( + path.join(rootPath, normalizedRelativePath), + ); + const name = path.basename(normalizedRelativePath); + const lowerName = name.toLowerCase(); + const lowerRelativePath = normalizedRelativePath.toLowerCase(); + + 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, + }; +} + function getSearchCacheKey({ rootPath, includeHidden, @@ -272,16 +354,11 @@ async function buildSearchIndex({ ignore: DEFAULT_IGNORE_PATTERNS, }); - const items: SearchIndexEntry[] = entries.map((relativePath) => ({ - absolutePath: path.join(normalizedRootPath, relativePath), - relativePath, - name: path.basename(relativePath), - })); + const items: SearchIndexEntry[] = entries.map((relativePath) => + createSearchIndexEntry(normalizedRootPath, relativePath), + ); - return { - items, - fuse: createFileSearchFuse(items), - }; + return createFileSearchIndex(items); } async function getSearchIndex( @@ -344,6 +421,78 @@ 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++) { @@ -678,11 +827,10 @@ function applySearchPatchEvent({ } const nextAbsolutePath = normalizeAbsolutePath(event.absolutePath); - itemsByPath.set(nextAbsolutePath, { - absolutePath: nextAbsolutePath, - relativePath: nextRelativePath, - name: path.basename(nextAbsolutePath), - }); + itemsByPath.set( + nextAbsolutePath, + createSearchIndexEntry(rootPath, nextRelativePath), + ); return; } @@ -698,11 +846,7 @@ function applySearchPatchEvent({ return; } - itemsByPath.set(absolutePath, { - absolutePath, - relativePath, - name: path.basename(absolutePath), - }); + itemsByPath.set(absolutePath, createSearchIndexEntry(rootPath, relativePath)); } export function invalidateSearchIndex(options: SearchIndexKeyOptions): void { @@ -738,6 +882,11 @@ export function patchSearchIndexesForRoot( return; } + if (events.some((event) => event.isDirectory)) { + invalidateSearchIndexesForRoot(rootPath); + return; + } + const normalizedRootPath = normalizeAbsolutePath(rootPath); for (const includeHidden of [true, false]) { @@ -772,10 +921,7 @@ export function patchSearchIndexesForRoot( const nextItems = Array.from(nextItemsByPath.values()); searchIndexCache.set(cacheKey, { - index: { - items: nextItems, - fuse: createFileSearchFuse(nextItems), - }, + index: createFileSearchIndex(nextItems), builtAt: Date.now(), }); } @@ -802,6 +948,24 @@ export async function searchFiles({ includePattern, 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 searchableItems = pathMatcher.hasFilters ? index.items.filter((item) => matchesPathFilters(item.relativePath, pathMatcher), @@ -816,7 +980,7 @@ export async function searchFiles({ ? createFileSearchFuse(searchableItems) : index.fuse; const results = fuse.search(trimmedQuery, { - limit: safeSearchLimit(limit), + limit: safeLimit, }); return results.map((result) => ({