Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src-tauri/src/commands/editor/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 9 additions & 6 deletions src/features/ai/components/chat/ai-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -395,6 +395,7 @@ const AIChat = memo(function AIChat({

const processMessage = async (
messageContent: string,
images: ImageContent[] = [],
options: { editedUserMessageId?: string } = {},
) => {
const store = useAIChatStore.getState();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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],
);
Expand All @@ -992,7 +995,7 @@ details: ${errorDetails || mainError}
return;
}

await processMessage(content, { editedUserMessageId: messageId });
await processMessage(content, [], { editedUserMessageId: messageId });
};

useEffect(() => {
Expand Down
27 changes: 22 additions & 5 deletions src/features/ai/components/input/chat-input-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});
},
[],
);
Expand All @@ -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 }));
Expand Down Expand Up @@ -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(), []);
Expand Down
4 changes: 2 additions & 2 deletions src/features/ai/types/ai-chat.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export interface ToolCall {
isComplete?: boolean;
}

interface ImageContent {
export interface ImageContent {
data: string;
mediaType: string;
}
Expand Down Expand Up @@ -109,6 +109,6 @@ export interface AIChatInputBarProps {
presentation?: "default" | "initial";
autoFocus?: boolean;
onAgentChange?: (agentId: AgentType) => void;
onSendMessage: (message: string) => Promise<void>;
onSendMessage: (message: string, images?: ImageContent[]) => Promise<void>;
onStopStreaming: () => void;
}
36 changes: 32 additions & 4 deletions src/features/editor/lib/wasm-parser/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArrayBuffer>): Promise<string> {
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<string | null> {
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;
Expand Down Expand Up @@ -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<ArrayBuffer>);
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,
Expand Down Expand Up @@ -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<ArrayBuffer>);
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,
Expand Down
93 changes: 93 additions & 0 deletions src/features/quick-open/tests/file-filtering.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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"]);
});
});
42 changes: 42 additions & 0 deletions src/features/quick-open/tests/fuzzy-search.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading