import * as path from "node:path"; import * as vscode from "vscode"; import { Events, Methods } from "../../shared/bridge"; import type { FileChange, ProjectFile } from "../../shared/types"; import type { BaselineSession } from "../managers/baseline.manager"; import { isWorkspacePathContained, resolveWorkspacePath, type WorkspacePath, } from "../utils/workspace-path"; import type { Handler, HandlerContext } from "./types"; interface GetProjectFilesParams { query?: string; } interface PickMediaParams { maxCount?: number; includeVideo?: boolean } interface FilePathParams { filePath: string } interface OptionalFilePathParams { filePath?: string } interface PathsParams { paths: string[] } interface CheckFilesExistParams { paths: string[] } const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp"]; const VIDEO_EXTENSIONS = ["mp4", "webm", "mov"]; const IMAGE_MIME_TYPES: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp", ".ico": "image/x-icon", }; const FILE_SUGGEST_LIMIT = 20; const getProjectFiles: Handler = async (params, ctx) => { if (!ctx.workDirUri || !ctx.workDir) return []; const suggested = await suggestFiles(ctx, params?.query ?? ""); if (suggested !== undefined) return suggested; return ctx.fileManager.searchFiles(ctx.workDirUri, params?.query); }; async function suggestFiles(ctx: HandlerContext, query: string): Promise { try { const result = await ctx.harness.suggestFiles(ctx.requireWorkDir(), { query, limit: FILE_SUGGEST_LIMIT }); if (result === undefined) return undefined; return result.items.map((item) => ({ path: item.path, name: item.name, isDirectory: item.kind === "directory", matchPositions: [...item.matchPositions], })); } catch (error) { ctx.logError("File suggest failed", error); return []; } } const pickMedia: Handler = async (params) => { const maxCount = params.maxCount ?? 9; const includeVideo = params.includeVideo ?? true; const filters: Record = { Images: IMAGE_EXTENSIONS }; if (includeVideo) { filters["Videos"] = VIDEO_EXTENSIONS; filters["All Media"] = [...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS]; } const uris = await vscode.window.showOpenDialog({ canSelectMany: true, filters, title: "Select Media" }); if (!uris) return []; const results: string[] = []; for (const uri of uris.slice(0, maxCount)) { try { const extension = path.extname(uri.fsPath).toLowerCase().slice(1); const isVideo = VIDEO_EXTENSIONS.includes(extension); const stat = await vscode.workspace.fs.stat(uri); if (stat.size > (isVideo ? 20 : 10) * 1024 * 1024) continue; const bytes = await vscode.workspace.fs.readFile(uri); results.push(`data:${mediaMime(extension)};base64,${Buffer.from(bytes).toString("base64")}`); } catch { // Skip a file that disappears or cannot be read without failing the whole picker. } } return results; }; const openFile: Handler = async ({ filePath }, ctx) => { const resolved = await resolveExistingWorkspaceFile(ctx.requireWorkDirUri(), filePath); if (resolved === undefined) return { ok: false }; await vscode.commands.executeCommand("vscode.open", resolved.uri); return { ok: true }; }; const openFileDiff: Handler = async ({ filePath }, ctx) => { const sessionId = ctx.getSessionId(); const resolved = await resolveExistingWorkspaceFile(ctx.requireWorkDirUri(), filePath); if (!sessionId || resolved === undefined) return { ok: false }; const baselineUri = vscode.Uri.from({ scheme: "kimi-baseline", path: `/${resolved.relativePath}`, query: new URLSearchParams({ sessionId }).toString(), }); await vscode.commands.executeCommand( "vscode.diff", baselineUri, resolved.uri, `${path.basename(resolved.relativePath)} (changes from Kimi)`, ); return { ok: true }; }; const trackFiles: Handler = async ({ paths }, ctx) => { const session = requireBaselineSession(ctx); const workDirUri = ctx.requireWorkDirUri(); for (const filePath of paths) { const resolved = await resolveWorkspaceFile(workDirUri, filePath, true); if (resolved !== undefined) ctx.fileManager.trackFile(ctx.webviewId, resolved.uri.fsPath); } const changes = await ctx.baselineManager.getChanges(session); ctx.broadcast(Events.FileChangesUpdated, changes, ctx.webviewId); return changes; }; const clearTrackedFiles: Handler = async (_, ctx) => { ctx.fileManager.clearTracked(ctx.webviewId); ctx.broadcast(Events.FileChangesUpdated, [], ctx.webviewId); return { ok: true }; }; const revertFiles: Handler = async (params, ctx) => { const session = requireBaselineSession(ctx); if (params.filePath) { const resolved = await resolveWorkspaceFile(ctx.requireWorkDirUri(), params.filePath, true); if (resolved === undefined) return { ok: false }; await ctx.baselineManager.undo(session, resolved.relativePath); } else { await ctx.baselineManager.undoAll(session); ctx.fileManager.clearTracked(ctx.webviewId); } await ctx.fileManager.refreshChanges(ctx.webviewId); return { ok: true }; }; const keepChanges: Handler = async (params, ctx) => { const session = requireBaselineSession(ctx); if (params.filePath) { const resolved = await resolveWorkspaceFile(ctx.requireWorkDirUri(), params.filePath, true); if (resolved === undefined) return { ok: false }; await ctx.baselineManager.keep(session, resolved.relativePath); ctx.fileManager.getTracked(ctx.webviewId).delete(resolved.uri.fsPath); } else { await ctx.baselineManager.keepAll(session); ctx.fileManager.clearTracked(ctx.webviewId); } await ctx.fileManager.refreshChanges(ctx.webviewId); return { ok: true }; }; const checkFileExists: Handler = async ({ filePath }, ctx) => { if (!ctx.workDirUri) return false; return (await resolveExistingWorkspaceFile(ctx.workDirUri, filePath)) !== undefined; }; const checkFilesExist: Handler> = async ({ paths }, ctx) => { if (!ctx.workDirUri) return {}; return Object.fromEntries( await Promise.all( paths.map(async (filePath) => [ filePath, (await resolveExistingWorkspaceFile(ctx.workDirUri!, filePath)) !== undefined, ] as const), ), ); }; const getImageDataUri: Handler = async ({ filePath }, ctx) => { if (!ctx.workDirUri) return null; const resolved = await resolveExistingWorkspaceFile(ctx.workDirUri, decodeURIComponent(filePath)); if (resolved === undefined) return null; const mime = IMAGE_MIME_TYPES[path.extname(resolved.relativePath).toLowerCase()]; if (!mime) return null; try { // Same cap as the media picker: an oversized image inlined as a data URI // would live in the webview DOM (and its decoded bitmap) forever. const stat = await vscode.workspace.fs.stat(resolved.uri); if (stat.size > 10 * 1024 * 1024) return null; const bytes = await vscode.workspace.fs.readFile(resolved.uri); return `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`; } catch { return null; } }; export const fileHandlers: Record> = { [Methods.GetProjectFiles]: getProjectFiles, [Methods.PickMedia]: pickMedia, [Methods.OpenFile]: openFile, [Methods.OpenFileDiff]: openFileDiff, [Methods.TrackFiles]: trackFiles, [Methods.ClearTrackedFiles]: clearTrackedFiles, [Methods.RevertFiles]: revertFiles, [Methods.KeepChanges]: keepChanges, [Methods.CheckFileExists]: checkFileExists, [Methods.CheckFilesExist]: checkFilesExist, [Methods.GetImageDataUri]: getImageDataUri, }; function requireBaselineSession(ctx: Parameters[1]): BaselineSession { const session = ctx.fileManager.getSession(ctx.webviewId); if (session === null) throw new Error("No active session."); return session; } async function resolveWorkspaceFile( workDirUri: vscode.Uri, filePath: string, allowMissing = false, ): Promise { const resolved = resolveWorkspacePath(workDirUri, filePath); if (resolved === undefined || !(await isWorkspacePathContained(workDirUri, resolved.uri, { allowMissing }))) { return undefined; } return resolved; } async function resolveExistingWorkspaceFile( workDirUri: vscode.Uri, filePath: string, ): Promise { const resolved = await resolveWorkspaceFile(workDirUri, filePath); if (resolved === undefined) return undefined; try { await vscode.workspace.fs.stat(resolved.uri); return resolved; } catch { return undefined; } } function mediaMime(extension: string): string { return ({ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", } as Record)[extension] ?? "application/octet-stream"; }