diff --git a/src-tauri/src/commands/editor/format.rs b/src-tauri/src/commands/editor/format.rs index 7c9ed5343..60d54b408 100644 --- a/src-tauri/src/commands/editor/format.rs +++ b/src-tauri/src/commands/editor/format.rs @@ -133,8 +133,10 @@ async fn format_with_generic( let formatted = if output_method == "stdout" { String::from_utf8_lossy(&output.stdout).to_string() } else { - // For file output, read the file (TODO: implement file-based formatting) - content.to_string() + // For file output, read the formatted file + let file_path = + file_path.ok_or("file_path required for file output method")?; + std::fs::read_to_string(file_path).unwrap_or_else(|_| content.to_string()) }; Ok(FormatResponse { diff --git a/src/features/ai/components/chat/ai-chat.tsx b/src/features/ai/components/chat/ai-chat.tsx index 9af1b1888..c841bf49d 100644 --- a/src/features/ai/components/chat/ai-chat.tsx +++ b/src/features/ai/components/chat/ai-chat.tsx @@ -28,7 +28,7 @@ import { getChatCompletionStream, isAcpAgent } from "@/features/ai/services/ai-c import { useAIChatStore } from "@/features/ai/stores/ai-chat.store"; import type { AcpEvent } from "@/features/ai/types/acp.types"; import type { ContextInfo } from "@/features/ai/types/ai-context.types"; -import type { AIChatProps, Message } from "@/features/ai/types/ai-chat.types"; +import type { AIChatProps, Message, ImageContent } from "@/features/ai/types/ai-chat.types"; import type { ChatAcpEvent } from "@/features/ai/types/chat-ui.types"; import { getFallbackAgentSessionTitle, @@ -395,6 +395,7 @@ const AIChat = memo(function AIChat({ const processMessage = async ( messageContent: string, + images: ImageContent[] = [], options: { editedUserMessageId?: string } = {}, ) => { const store = useAIChatStore.getState(); @@ -439,12 +440,14 @@ const AIChat = memo(function AIChat({ ...existingMessages[editedUserMessageIndex], content: trimmedMessageContent, timestamp: new Date(), + images: images.length > 0 ? images : existingMessages[editedUserMessageIndex].images, } : { id: createMessageId(), content: trimmedMessageContent, role: "user", timestamp: new Date(), + images: images.length > 0 ? images : undefined, }; const assistantMessageId = createMessageId(); @@ -964,7 +967,7 @@ details: ${errorDetails || mainError} }, [isSurfaceTyping, surfaceStreamingMessageId]); const sendMessage = useCallback( - async (messageContent: string) => { + async (messageContent: string, images?: ImageContent[]) => { const isAcp = isAcpAgent(currentAgentId); // For ACP agents, we don't need an API key. if (!messageContent.trim() || (!isAcp && !chatState.hasApiKey)) return; @@ -975,14 +978,14 @@ details: ${errorDetails || mainError} return; } - await processMessage(messageContent); + await processMessage(messageContent, images); }, [chatState.hasApiKey, currentAgentId, isSurfaceTyping, surfaceStreamingMessageId], ); const handleSendMessage = useCallback( - async (messageContent: string) => { - await sendMessage(messageContent); + async (messageContent: string, images?: ImageContent[]) => { + await sendMessage(messageContent, images); }, [sendMessage], ); @@ -992,7 +995,7 @@ details: ${errorDetails || mainError} return; } - await processMessage(content, { editedUserMessageId: messageId }); + await processMessage(content, [], { editedUserMessageId: messageId }); }; useEffect(() => { diff --git a/src/features/ai/components/input/chat-input-bar.tsx b/src/features/ai/components/input/chat-input-bar.tsx index d3f1cf70e..14efb1168 100644 --- a/src/features/ai/components/input/chat-input-bar.tsx +++ b/src/features/ai/components/input/chat-input-bar.tsx @@ -25,7 +25,7 @@ import { import type { InlineDropdownPosition, PastedImage } from "@/features/ai/types/chat-composer.types"; import type { AIChatSkill } from "@/features/ai/types/skills.types"; import type { SlashCommand } from "@/features/ai/types/acp.types"; -import type { AIChatInputBarProps } from "@/features/ai/types/ai-chat.types"; +import type { AIChatInputBarProps, ImageContent } from "@/features/ai/types/ai-chat.types"; import type { FileEntry } from "@/features/file-system/types/app.types"; import { getProviderById } from "@/features/ai/types/providers.types"; import { openSidebarResourceBuffer } from "@/features/sidebar/utils/open-sidebar-resource"; @@ -182,7 +182,13 @@ const AIChatInputBar = memo(function AIChatInputBar({ const setSelectedFilesPaths = onSetSelectedFilesPaths; const showMention = useCallback( (position: InlineDropdownPosition, search: string, startIndex: number) => { - setMentionState({ active: true, position, search, startIndex, selectedIndex: 0 }); + setMentionState({ + active: true, + position, + search, + startIndex, + selectedIndex: 0, + }); }, [], ); @@ -196,7 +202,12 @@ const AIChatInputBar = memo(function AIChatInputBar({ setMentionState((current) => ({ ...current, selectedIndex })); }, []); const showSlashCommands = useCallback((position: InlineDropdownPosition, search: string) => { - setSlashCommandState({ active: true, position, search, selectedIndex: 0 }); + setSlashCommandState({ + active: true, + position, + search, + selectedIndex: 0, + }); }, []); const hideSlashCommands = useCallback(() => { setSlashCommandState((current) => ({ ...current, active: false })); @@ -924,8 +935,14 @@ const AIChatInputBar = memo(function AIChatInputBar({ inputRef.current.innerHTML = ""; } - // Send the captured message (TODO: include images in message) - await onSendMessage(currentInput); + // Convert pasted images to ImageContent format + const images: ImageContent[] = currentImages.map((img) => ({ + data: img.dataUrl.split(",")[1] || img.dataUrl, + mediaType: img.dataUrl.split(";")[0]?.split(":")[1] || "image/png", + })); + + // Send the captured message with images + await onSendMessage(currentInput, images); }; const focusInput = useCallback(() => inputRef.current?.focus(), []); diff --git a/src/features/ai/types/ai-chat.types.ts b/src/features/ai/types/ai-chat.types.ts index 45b6ced54..a15706af4 100644 --- a/src/features/ai/types/ai-chat.types.ts +++ b/src/features/ai/types/ai-chat.types.ts @@ -25,7 +25,7 @@ export interface ToolCall { isComplete?: boolean; } -interface ImageContent { +export interface ImageContent { data: string; mediaType: string; } @@ -109,6 +109,6 @@ export interface AIChatInputBarProps { presentation?: "default" | "initial"; autoFocus?: boolean; onAgentChange?: (agentId: AgentType) => void; - onSendMessage: (message: string) => Promise; + onSendMessage: (message: string, images?: ImageContent[]) => Promise; onStopStreaming: () => void; } diff --git a/src/features/editor/lib/wasm-parser/loader.ts b/src/features/editor/lib/wasm-parser/loader.ts index fba44f2da..641b114ed 100644 --- a/src/features/editor/lib/wasm-parser/loader.ts +++ b/src/features/editor/lib/wasm-parser/loader.ts @@ -10,6 +10,30 @@ import { indexedDBParserCache } from "./cache-indexeddb"; import { fetchHighlightQuery } from "./extension-assets"; import type { LoadedParser, ParserConfig } from "../../types/wasm-parser/wasm-parser.types"; +async function computeSha256(bytes: Uint8Array): Promise { + const hashBuffer = await crypto.subtle.digest("SHA-256", bytes); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +interface ParserManifest { + version?: string; + name?: string; + [key: string]: unknown; +} + +async function fetchManifestVersion(wasmPath: string): Promise { + try { + const manifestUrl = wasmPath.replace(/\.wasm$/, ".json"); + const response = await fetch(manifestUrl); + if (!response.ok) return null; + const manifest = (await response.json()) as ParserManifest; + return manifest.version ?? null; + } catch { + return null; + } +} + export function getTreeSitterRuntimeAssetPath(scriptName: string): string { if (scriptName === "web-tree-sitter.wasm") { return treeSitterRuntimeWasmUrl; @@ -608,13 +632,15 @@ class WasmParserLoader { // Cache for future use try { + const version = (await fetchManifestVersion(wasmPath)) || "1.0.0"; + const checksum = await computeSha256(wasmBytes as Uint8Array); await indexedDBParserCache.set({ languageId, wasmBlob: new Blob([wasmBytes as BlobPart]), // Legacy compatibility wasmData: wasmBytes.buffer as ArrayBuffer, // Preferred: ArrayBuffer highlightQuery: queryText || "", - version: "1.0.0", // TODO: Get version from manifest - checksum: "", // TODO: Calculate checksum + version, + checksum, downloadedAt: Date.now(), lastUsedAt: Date.now(), size: wasmBytes.byteLength, @@ -664,13 +690,15 @@ class WasmParserLoader { // Cache local parsers to IndexedDB for future use try { + const version = (await fetchManifestVersion(wasmPath)) || "1.0.0"; + const checksum = await computeSha256(wasmBytes as Uint8Array); await indexedDBParserCache.set({ languageId, wasmBlob: new Blob([wasmBytes as BlobPart]), wasmData: wasmBytes.buffer as ArrayBuffer, highlightQuery: queryText || "", - version: "1.0.0", - checksum: "", + version, + checksum, downloadedAt: Date.now(), lastUsedAt: Date.now(), size: wasmBytes.byteLength, diff --git a/src/features/quick-open/tests/file-filtering.test.ts b/src/features/quick-open/tests/file-filtering.test.ts new file mode 100644 index 000000000..8fd7370e2 --- /dev/null +++ b/src/features/quick-open/tests/file-filtering.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { RecentFile } from "@/features/file-system/types/recent-files.types"; +import { filterQuickOpenRecentFiles, shouldIgnoreFile } from "../utils/file-filtering"; + +function makeRecentFile(path: string, overrides: Partial = {}): RecentFile { + return { + path, + name: path.split("/").pop() ?? path, + lastAccessed: "2026-01-01T00:00:00.000Z", + accessCount: 1, + frecencyScore: 1, + workspacePath: "/workspace", + ...overrides, + }; +} + +describe("shouldIgnoreFile", () => { + it("ignores files inside dependency and build directories", () => { + expect(shouldIgnoreFile("/workspace/node_modules/lib/index.js")).toBe(true); + expect(shouldIgnoreFile("/workspace/dist/bundle.min.js")).toBe(true); + expect(shouldIgnoreFile("src/target/debug/app.rs")).toBe(true); + }); + + it("ignores lockfiles and OS metadata files", () => { + expect(shouldIgnoreFile("/workspace/package-lock.json")).toBe(true); + expect(shouldIgnoreFile("/workspace/Cargo.lock")).toBe(true); + expect(shouldIgnoreFile("/workspace/.DS_Store")).toBe(true); + }); + + it("keeps regular source files", () => { + expect(shouldIgnoreFile("/workspace/src/main.ts")).toBe(false); + expect(shouldIgnoreFile("/workspace/README.md")).toBe(false); + }); +}); + +describe("filterQuickOpenRecentFiles", () => { + const indexedPaths = new Set(["/workspace/src/a.ts", "/workspace/src/b.ts"]); + + it("drops recent files from other workspaces", () => { + const filtered = filterQuickOpenRecentFiles( + [ + makeRecentFile("/workspace/src/a.ts"), + makeRecentFile("/other/src/c.ts", { workspacePath: "/other" }), + ], + "/workspace", + indexedPaths, + true, + ); + + expect(filtered.map((file) => file.path)).toEqual(["/workspace/src/a.ts"]); + }); + + it("keeps files that exist in the workspace index", () => { + const filtered = filterQuickOpenRecentFiles( + [makeRecentFile("/workspace/src/a.ts"), makeRecentFile("/workspace/src/deleted.ts")], + "/workspace", + indexedPaths, + true, + ); + + expect(filtered.map((file) => file.path)).toEqual(["/workspace/src/a.ts"]); + }); + + it("keeps external and unindexed files while the file tree has not loaded", () => { + const filtered = filterQuickOpenRecentFiles( + [ + makeRecentFile("/outside/d.ts", { external: true }), + makeRecentFile("/workspace/src/unindexed.ts"), + ], + "/workspace", + indexedPaths, + false, + ); + + expect(filtered).toHaveLength(2); + }); + + it("keeps indexed files when no root folder is open", () => { + const filtered = filterQuickOpenRecentFiles( + [ + makeRecentFile("/anywhere/x.ts", { workspacePath: null }), + makeRecentFile("/workspace/src/a.ts"), + ], + null, + indexedPaths, + true, + ); + + // Without a root folder everything belongs to the workspace, + // but loaded indexes still gate which files exist. + expect(filtered.map((file) => file.path)).toEqual(["/workspace/src/a.ts"]); + }); +}); diff --git a/src/features/quick-open/tests/fuzzy-search.test.ts b/src/features/quick-open/tests/fuzzy-search.test.ts new file mode 100644 index 000000000..f672ce666 --- /dev/null +++ b/src/features/quick-open/tests/fuzzy-search.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vite-plus/test"; +import { fuzzyScore } from "../utils/fuzzy-search"; + +describe("fuzzyScore", () => { + it("scores exact matches highest, ignoring case", () => { + expect(fuzzyScore("Button", "button")).toBe(1000); + expect(fuzzyScore("README.md", "readme.md")).toBe(1000); + }); + + it("scores prefix matches above substring matches", () => { + const prefix = fuzzyScore("use-file-search", "use-"); + const substring = fuzzyScore("hooks/use-file-search.ts", "file"); + + expect(prefix).toBe(800); + expect(substring).toBe(600); + }); + + it("returns 0 for empty queries and short queries without substring matches", () => { + expect(fuzzyScore("anything", "")).toBe(0); + // Queries of two characters or less only match substrings + expect(fuzzyScore("abcdef", "xb")).toBe(0); + }); + + it("returns 0 when the text does not contain the query characters in order", () => { + expect(fuzzyScore("settings-dialog", "dialog-settings")).toBe(0); + expect(fuzzyScore("abc", "xyz")).toBe(0); + }); + + it("scores subsequence fuzzy matches positively and rewards consecutive runs", () => { + const spreadOut = fuzzyScore("use-file-search", "usr"); + const consecutive = fuzzyScore("user-profile", "usr"); + + expect(spreadOut).toBeGreaterThan(0); + expect(consecutive).toBeGreaterThan(spreadOut); + }); + + it("rejects sparse subsequences that fall below the density threshold", () => { + // Matching every character would need too many gaps across the text, + // so the scorer returns 0 to avoid garbage results. + expect(fuzzyScore("a-very-long-component-name-in-a-deep-folder", "avldnmt")).toBe(0); + }); +}); diff --git a/src/features/tabs/tests/path-shortener.test.ts b/src/features/tabs/tests/path-shortener.test.ts new file mode 100644 index 000000000..fd541ff34 --- /dev/null +++ b/src/features/tabs/tests/path-shortener.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { EditorContent } from "@/features/panes/types/pane-content.types"; +import { calculateDisplayNames } from "../utils/path-shortener"; + +function makeEditorBuffer(id: string, path: string, isVirtual = false): EditorContent { + return { + id, + type: "editor", + path, + name: path.split("/").pop() ?? path, + content: "", + savedContent: "", + isDirty: false, + isVirtual, + isPinned: false, + isPreview: false, + isActive: false, + language: "typescript", + tokens: [], + }; +} + +describe("calculateDisplayNames", () => { + it("shows the bare filename when it is unique across buffers", () => { + const names = calculateDisplayNames( + [ + makeEditorBuffer("a", "/workspace/src/app.ts"), + makeEditorBuffer("b", "/workspace/lib/util.ts"), + ], + "/workspace", + ); + + expect(names.get("a")).toBe("app.ts"); + expect(names.get("b")).toBe("util.ts"); + }); + + it("disambiguates duplicate filenames with their parent directory", () => { + const names = calculateDisplayNames( + [ + makeEditorBuffer("a", "/workspace/client/index.ts"), + makeEditorBuffer("b", "/workspace/server/index.ts"), + ], + "/workspace", + ); + + expect(names.get("a")).toBe("../client/index.ts"); + expect(names.get("b")).toBe("../server/index.ts"); + }); + + it("walks further up the path until duplicates become distinct", () => { + const names = calculateDisplayNames( + [ + makeEditorBuffer("a", "/workspace/packages/web/src/index.ts"), + makeEditorBuffer("b", "/workspace/packages/api/src/index.ts"), + makeEditorBuffer("c", "/workspace/docs/src/index.ts"), + ], + "/workspace", + ); + + // One parent segment is not enough (two "src" folders), two are. + expect(names.get("a")).toBe("../web/src/index.ts"); + expect(names.get("b")).toBe("../api/src/index.ts"); + expect(names.get("c")).toBe("../docs/src/index.ts"); + }); + + it("falls back to the full relative path for identical paths", () => { + const names = calculateDisplayNames( + [ + makeEditorBuffer("a", "/workspace/dup/index.ts"), + makeEditorBuffer("b", "/workspace/dup/index.ts"), + ], + "/workspace", + ); + + expect(names.get("a")).toBe("../workspace/dup/index.ts"); + expect(names.get("b")).toBe("../workspace/dup/index.ts"); + }); + + it("uses the buffer name for virtual buffers", () => { + const names = calculateDisplayNames( + [makeEditorBuffer("term", "", true), makeEditorBuffer("file", "/workspace/a.ts")], + "/workspace", + ); + + expect(names.has("term")).toBe(true); + expect(names.get("file")).toBe("a.ts"); + }); +}); diff --git a/src/features/tabs/utils/path-shortener.ts b/src/features/tabs/utils/path-shortener.ts index f04bcb9f1..c441f157a 100644 --- a/src/features/tabs/utils/path-shortener.ts +++ b/src/features/tabs/utils/path-shortener.ts @@ -8,8 +8,10 @@ function getPathSegments(filePath: string): string[] { // Normalize path separators to forward slash const normalized = filePath.replace(/\\/g, "/"); const parts = normalized.split("/"); + // Drop empty segments caused by absolute-path leading slashes + const nonEmptyParts = parts.filter((part) => part.length > 0); // Return all parts except the last one (filename) - return parts.slice(0, -1); + return nonEmptyParts.slice(0, -1); } /**