630 lines
23 KiB
TypeScript
630 lines
23 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
import type { BridgeResponse } from './bridge';
|
|
|
|
type BridgeMessageInput = {
|
|
id: string;
|
|
type: string;
|
|
payload?: unknown;
|
|
};
|
|
|
|
type FsAttachment = {
|
|
name: string;
|
|
mimeType: string;
|
|
size: number;
|
|
dataUrl: string;
|
|
};
|
|
|
|
type SkippedAttachment = {
|
|
name: string;
|
|
reason: string;
|
|
};
|
|
|
|
type DroppedReferenceParse =
|
|
| { uri: vscode.Uri }
|
|
| { skipped: SkippedAttachment };
|
|
|
|
type ReadUriAsAttachmentResult =
|
|
| { file: FsAttachment }
|
|
| { skipped: SkippedAttachment };
|
|
|
|
type DirectoryEntry = {
|
|
name: string;
|
|
path: string;
|
|
isDirectory: boolean;
|
|
};
|
|
|
|
type FsExecCommandResult = {
|
|
command: string;
|
|
success: boolean;
|
|
exitCode?: number;
|
|
stdout?: string;
|
|
stderr?: string;
|
|
error?: string;
|
|
};
|
|
|
|
const createGitReadCacheTtlMs = () => {
|
|
const raw = Number(process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS);
|
|
if (Number.isFinite(raw) && raw >= 0) return raw;
|
|
return 30 * 1000;
|
|
};
|
|
|
|
const createGitCheckIgnoreTimeoutMs = () => {
|
|
const raw = Number(process.env.OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS);
|
|
if (Number.isFinite(raw) && raw >= 0) return raw;
|
|
return 2500;
|
|
};
|
|
|
|
const normalizeCommand = (command: unknown): string =>
|
|
typeof command === 'string' ? command.trim().replace(/\s+/g, ' ') : '';
|
|
|
|
const isCacheableGitReadCommand = (command: string): boolean => {
|
|
const normalized = normalizeCommand(command);
|
|
return /^git rev-parse(?: --(?:absolute-git-dir|git-common-dir|show-toplevel)){1,3}$/.test(normalized);
|
|
};
|
|
|
|
const GIT_READ_CACHE_TTL_MS = createGitReadCacheTtlMs();
|
|
const GIT_CHECK_IGNORE_TIMEOUT_MS = createGitCheckIgnoreTimeoutMs();
|
|
const GIT_READ_CACHE_MAX_ENTRIES = 500;
|
|
const GIT_READ_CACHE_MAX_BYTES = 1024 * 1024;
|
|
const gitReadCache = new Map<string, { result: FsExecCommandResult; at: number }>();
|
|
const inFlightGitReadCache = new Map<string, Promise<FsExecCommandResult>>();
|
|
|
|
const gitReadEntryBytes = (key: string, result: FsExecCommandResult): number =>
|
|
key.length + (result.stdout?.length ?? 0) + (result.stderr?.length ?? 0);
|
|
|
|
const setGitReadCacheEntry = (key: string, result: FsExecCommandResult): void => {
|
|
gitReadCache.delete(key);
|
|
gitReadCache.set(key, { result, at: Date.now() });
|
|
|
|
let totalBytes = 0;
|
|
for (const [entryKey, entry] of gitReadCache) {
|
|
totalBytes += gitReadEntryBytes(entryKey, entry.result);
|
|
}
|
|
|
|
while (
|
|
gitReadCache.size > GIT_READ_CACHE_MAX_ENTRIES ||
|
|
(totalBytes > GIT_READ_CACHE_MAX_BYTES && gitReadCache.size > 1)
|
|
) {
|
|
const oldest = gitReadCache.entries().next().value;
|
|
if (!oldest) {
|
|
break;
|
|
}
|
|
totalBytes -= gitReadEntryBytes(oldest[0], oldest[1].result);
|
|
gitReadCache.delete(oldest[0]);
|
|
}
|
|
};
|
|
|
|
export const clearGitReadCacheForTests = (): void => {
|
|
gitReadCache.clear();
|
|
inFlightGitReadCache.clear();
|
|
};
|
|
|
|
type FsDeps = {
|
|
resolveUserPath: (value: string, baseDirectory: string) => string;
|
|
listDirectoryEntries: (directoryPath: string) => Promise<DirectoryEntry[]>;
|
|
normalizeFsPath: (value: string) => string;
|
|
execGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
|
searchDirectory: (
|
|
directory: string,
|
|
query: string,
|
|
limit: number | undefined,
|
|
includeHidden: boolean,
|
|
respectGitignore: boolean,
|
|
) => Promise<Array<{ name: string; path: string; relativePath: string; extension: string | undefined }>>;
|
|
resolveFileReadPath: (inputPath: string) => Promise<
|
|
| { ok: true; resolvedPath: string }
|
|
| { ok: false; status: number; error: string }
|
|
>;
|
|
parseDroppedFileReference: (rawReference: string) => DroppedReferenceParse;
|
|
readUriAsAttachment: (uri: vscode.Uri, name: string) => Promise<ReadUriAsAttachmentResult>;
|
|
};
|
|
|
|
const runGitCheckIgnore = async (
|
|
execGit: FsDeps['execGit'],
|
|
args: string[],
|
|
cwd: string,
|
|
): Promise<{ stdout: string; stderr: string; exitCode: number } | null> => {
|
|
if (GIT_CHECK_IGNORE_TIMEOUT_MS <= 0) {
|
|
return execGit(args, cwd);
|
|
}
|
|
|
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
execGit(args, cwd),
|
|
new Promise<null>((resolve) => {
|
|
timeout = setTimeout(() => resolve(null), GIT_CHECK_IGNORE_TIMEOUT_MS);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
};
|
|
|
|
export async function handleFsBridgeMessage(
|
|
message: BridgeMessageInput,
|
|
deps: FsDeps,
|
|
): Promise<BridgeResponse | null> {
|
|
const { id, type, payload } = message;
|
|
|
|
switch (type) {
|
|
case 'files:list': {
|
|
const { path: dirPath } = payload as { path: string };
|
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
|
const resolvedPath = deps.resolveUserPath(dirPath, workspaceRoot);
|
|
const uri = vscode.Uri.file(resolvedPath);
|
|
const entries = await vscode.workspace.fs.readDirectory(uri);
|
|
const result = entries.map(([name, fileType]) => ({
|
|
name,
|
|
path: vscode.Uri.joinPath(uri, name).fsPath,
|
|
isDirectory: fileType === vscode.FileType.Directory,
|
|
}));
|
|
return { id, type, success: true, data: { directory: deps.normalizeFsPath(resolvedPath), entries: result } };
|
|
}
|
|
|
|
case 'files:search': {
|
|
const { query, maxResults = 50 } = payload as { query: string; maxResults?: number };
|
|
const pattern = `**/*${query}*`;
|
|
const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**', maxResults);
|
|
const results = files.map((file) => ({
|
|
path: file.fsPath,
|
|
}));
|
|
return { id, type, success: true, data: results };
|
|
}
|
|
|
|
case 'workspace:folder': {
|
|
const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
|
return { id, type, success: true, data: { folder } };
|
|
}
|
|
|
|
case 'config:get': {
|
|
const { key } = payload as { key: string };
|
|
const config = vscode.workspace.getConfiguration('openchamber');
|
|
const value = config.get(key);
|
|
return { id, type, success: true, data: { value } };
|
|
}
|
|
|
|
case 'api:fs:list': {
|
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
|
const { path: targetPath, respectGitignore } = (payload || {}) as { path?: string; respectGitignore?: boolean };
|
|
const target = targetPath || workspaceRoot;
|
|
const resolvedPath = deps.resolveUserPath(target, workspaceRoot) || workspaceRoot;
|
|
|
|
const entries = await deps.listDirectoryEntries(resolvedPath);
|
|
const normalized = deps.normalizeFsPath(resolvedPath);
|
|
|
|
if (!respectGitignore) {
|
|
return { id, type, success: true, data: { entries, directory: normalized, path: normalized } };
|
|
}
|
|
|
|
const pathsToCheck = entries.map((entry) => entry.name).filter(Boolean);
|
|
if (pathsToCheck.length === 0) {
|
|
return { id, type, success: true, data: { entries, directory: normalized, path: normalized } };
|
|
}
|
|
|
|
try {
|
|
const result = await runGitCheckIgnore(deps.execGit, ['check-ignore', '--', ...pathsToCheck], normalized);
|
|
if (!result) {
|
|
return { id, type, success: true, data: { entries, directory: normalized, path: normalized } };
|
|
}
|
|
const ignoredNames = new Set(
|
|
result.stdout
|
|
.split('\n')
|
|
.map((name) => name.trim())
|
|
.filter(Boolean)
|
|
);
|
|
|
|
const filteredEntries = entries.filter((entry) => !ignoredNames.has(entry.name));
|
|
return { id, type, success: true, data: { entries: filteredEntries, directory: normalized, path: normalized } };
|
|
} catch {
|
|
return { id, type, success: true, data: { entries, directory: normalized, path: normalized } };
|
|
}
|
|
}
|
|
|
|
case 'api:fs:search': {
|
|
const { directory = '', query = '', limit, includeHidden, respectGitignore } = (payload || {}) as {
|
|
directory?: string;
|
|
query?: string;
|
|
limit?: number;
|
|
includeHidden?: boolean;
|
|
respectGitignore?: boolean;
|
|
};
|
|
const files = await deps.searchDirectory(directory, query, limit, Boolean(includeHidden), respectGitignore !== false);
|
|
return { id, type, success: true, data: { files } };
|
|
}
|
|
|
|
case 'api:fs:mkdir': {
|
|
const target = (payload as { path: string })?.path;
|
|
if (!target) {
|
|
return { id, type, success: false, error: 'Path is required' };
|
|
}
|
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
|
const resolvedPath = deps.resolveUserPath(target, workspaceRoot);
|
|
await vscode.workspace.fs.createDirectory(vscode.Uri.file(resolvedPath));
|
|
return { id, type, success: true, data: { success: true, path: deps.normalizeFsPath(resolvedPath) } };
|
|
}
|
|
|
|
case 'api:fs/home': {
|
|
return { id, type, success: true, data: { home: deps.normalizeFsPath(os.homedir()) } };
|
|
}
|
|
|
|
case 'api:fs:read': {
|
|
const target = (payload as { path: string })?.path;
|
|
if (!target) {
|
|
return { id, type, success: false, error: 'Path is required' };
|
|
}
|
|
|
|
const resolution = await deps.resolveFileReadPath(target);
|
|
if (!resolution.ok) {
|
|
return { id, type, success: false, error: resolution.error };
|
|
}
|
|
|
|
try {
|
|
const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8');
|
|
return { id, type, success: true, data: { content, path: deps.normalizeFsPath(resolution.resolvedPath) } };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Failed to read file';
|
|
return { id, type, success: false, error: message };
|
|
}
|
|
}
|
|
|
|
case 'api:fs:stat': {
|
|
const target = (payload as { path: string })?.path;
|
|
if (!target) {
|
|
return { id, type, success: false, error: 'Path is required' };
|
|
}
|
|
|
|
const resolution = await deps.resolveFileReadPath(target);
|
|
if (!resolution.ok) {
|
|
return { id, type, success: false, error: resolution.error };
|
|
}
|
|
|
|
try {
|
|
const stats = await fs.promises.stat(resolution.resolvedPath);
|
|
if (!stats.isFile()) {
|
|
return { id, type, success: false, error: 'Specified path is not a file' };
|
|
}
|
|
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: {
|
|
path: deps.normalizeFsPath(resolution.resolvedPath),
|
|
isFile: true,
|
|
size: stats.size,
|
|
mtimeMs: stats.mtimeMs,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Failed to stat file';
|
|
return { id, type, success: false, error: message };
|
|
}
|
|
}
|
|
|
|
case 'api:fs:write': {
|
|
const { path: targetPath, content } = (payload as { path: string; content: string }) || {};
|
|
if (!targetPath) {
|
|
return { id, type, success: false, error: 'Path is required' };
|
|
}
|
|
if (typeof content !== 'string') {
|
|
return { id, type, success: false, error: 'Content is required' };
|
|
}
|
|
try {
|
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
|
const resolvedPath = deps.resolveUserPath(targetPath, workspaceRoot);
|
|
const uri = vscode.Uri.file(resolvedPath);
|
|
const parentUri = vscode.Uri.file(path.dirname(resolvedPath));
|
|
try {
|
|
await vscode.workspace.fs.createDirectory(parentUri);
|
|
} catch {
|
|
// Directory may already exist
|
|
}
|
|
await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8'));
|
|
return { id, type, success: true, data: { success: true, path: deps.normalizeFsPath(resolvedPath) } };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Failed to write file';
|
|
return { id, type, success: false, error: message };
|
|
}
|
|
}
|
|
|
|
case 'api:fs:delete': {
|
|
const targetPath = (payload as { path: string })?.path;
|
|
if (!targetPath) {
|
|
return { id, type, success: false, error: 'Path is required' };
|
|
}
|
|
try {
|
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
|
const resolvedPath = deps.resolveUserPath(targetPath, workspaceRoot);
|
|
const uri = vscode.Uri.file(resolvedPath);
|
|
await vscode.workspace.fs.delete(uri, { recursive: true, useTrash: false });
|
|
return { id, type, success: true, data: { success: true } };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Failed to delete file';
|
|
return { id, type, success: false, error: message };
|
|
}
|
|
}
|
|
|
|
case 'api:fs:rename': {
|
|
const { oldPath, newPath } = (payload as { oldPath: string; newPath: string }) || {};
|
|
if (!oldPath) {
|
|
return { id, type, success: false, error: 'oldPath is required' };
|
|
}
|
|
if (!newPath) {
|
|
return { id, type, success: false, error: 'newPath is required' };
|
|
}
|
|
try {
|
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
|
const resolvedOld = deps.resolveUserPath(oldPath, workspaceRoot);
|
|
const resolvedNew = deps.resolveUserPath(newPath, workspaceRoot);
|
|
const oldUri = vscode.Uri.file(resolvedOld);
|
|
const newUri = vscode.Uri.file(resolvedNew);
|
|
await vscode.workspace.fs.rename(oldUri, newUri, { overwrite: false });
|
|
return { id, type, success: true, data: { success: true, path: deps.normalizeFsPath(resolvedNew) } };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Failed to rename file';
|
|
return { id, type, success: false, error: message };
|
|
}
|
|
}
|
|
|
|
case 'api:fs:exec': {
|
|
const { commands, cwd } = (payload as { commands: string[]; cwd: string }) || {};
|
|
if (!Array.isArray(commands) || commands.length === 0) {
|
|
return { id, type, success: false, error: 'Commands array is required' };
|
|
}
|
|
if (!cwd) {
|
|
return { id, type, success: false, error: 'Working directory (cwd) is required' };
|
|
}
|
|
try {
|
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
|
const resolvedCwd = deps.resolveUserPath(cwd, workspaceRoot);
|
|
const { exec } = await import('child_process');
|
|
const { promisify } = await import('util');
|
|
const execAsync = promisify(exec);
|
|
const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
|
|
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
|
|
|
|
const augmentedEnv = {
|
|
...process.env,
|
|
PATH: process.env.PATH,
|
|
};
|
|
|
|
const runCommand = async (cmd: string): Promise<FsExecCommandResult> => {
|
|
try {
|
|
const { stdout, stderr } = await execAsync(`${shell} ${shellFlag} "${cmd.replace(/"/g, '\\"')}"`, {
|
|
cwd: resolvedCwd,
|
|
env: augmentedEnv,
|
|
windowsHide: true,
|
|
timeout: 300000,
|
|
});
|
|
return {
|
|
command: cmd,
|
|
success: true,
|
|
exitCode: 0,
|
|
stdout: (stdout || '').trim(),
|
|
stderr: (stderr || '').trim(),
|
|
};
|
|
} catch (execError) {
|
|
const err = execError as { code?: number; stdout?: string; stderr?: string; message?: string };
|
|
return {
|
|
command: cmd,
|
|
success: false,
|
|
exitCode: typeof err.code === 'number' ? err.code : 1,
|
|
stdout: (err.stdout || '').trim(),
|
|
stderr: (err.stderr || '').trim(),
|
|
error: err.message,
|
|
};
|
|
}
|
|
};
|
|
|
|
const runCommandWithGitReadCache = async (cmd: string): Promise<FsExecCommandResult> => {
|
|
const cacheable = GIT_READ_CACHE_TTL_MS > 0 && isCacheableGitReadCommand(cmd);
|
|
const cacheKey = cacheable ? `${resolvedCwd}\0${normalizeCommand(cmd)}` : null;
|
|
|
|
if (cacheKey) {
|
|
const cached = gitReadCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.at < GIT_READ_CACHE_TTL_MS) {
|
|
gitReadCache.delete(cacheKey);
|
|
gitReadCache.set(cacheKey, cached);
|
|
return { ...cached.result, command: cmd };
|
|
}
|
|
if (cached) {
|
|
gitReadCache.delete(cacheKey);
|
|
}
|
|
|
|
const inFlight = inFlightGitReadCache.get(cacheKey);
|
|
if (inFlight) {
|
|
const result = await inFlight;
|
|
return { ...result, command: cmd };
|
|
}
|
|
}
|
|
|
|
const runPromise = runCommand(cmd).then((result) => {
|
|
if (cacheKey && result.success) {
|
|
setGitReadCacheEntry(cacheKey, result);
|
|
}
|
|
return result;
|
|
}).finally(() => {
|
|
if (cacheKey && inFlightGitReadCache.get(cacheKey) === runPromise) {
|
|
inFlightGitReadCache.delete(cacheKey);
|
|
}
|
|
});
|
|
|
|
if (cacheKey) {
|
|
inFlightGitReadCache.set(cacheKey, runPromise);
|
|
}
|
|
|
|
return runPromise;
|
|
};
|
|
|
|
const results: FsExecCommandResult[] = [];
|
|
|
|
for (const cmd of commands) {
|
|
if (typeof cmd !== 'string' || !cmd.trim()) {
|
|
results.push({ command: cmd, success: false, error: 'Invalid command' });
|
|
continue;
|
|
}
|
|
results.push(await runCommandWithGitReadCache(cmd));
|
|
}
|
|
|
|
const allSucceeded = results.every((r) => r.success);
|
|
return { id, type, success: true, data: { success: allSucceeded, results } };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Failed to execute commands';
|
|
return { id, type, success: false, error: message };
|
|
}
|
|
}
|
|
|
|
case 'api:files/pick': {
|
|
const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false;
|
|
const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri;
|
|
|
|
const picks = await vscode.window.showOpenDialog({
|
|
canSelectFiles: true,
|
|
canSelectFolders: false,
|
|
canSelectMany: allowMany,
|
|
defaultUri,
|
|
openLabel: 'Attach',
|
|
});
|
|
|
|
if (!picks || picks.length === 0) {
|
|
return { id, type, success: true, data: { files: [], skipped: [] } };
|
|
}
|
|
|
|
const files: FsAttachment[] = [];
|
|
const skipped: SkippedAttachment[] = [];
|
|
for (const uri of picks) {
|
|
const result = await deps.readUriAsAttachment(uri, path.basename(uri.fsPath || uri.path || uri.toString()));
|
|
if ('file' in result) {
|
|
files.push(result.file);
|
|
} else {
|
|
skipped.push(result.skipped);
|
|
}
|
|
}
|
|
|
|
return { id, type, success: true, data: { files, skipped } };
|
|
}
|
|
|
|
case 'api:files/drop': {
|
|
const references = (payload as { uris?: string[] })?.uris;
|
|
const sourceUris = Array.isArray(references) ? references.filter((entry) => typeof entry === 'string') : [];
|
|
if (sourceUris.length === 0) {
|
|
return { id, type, success: true, data: { files: [], skipped: [] } };
|
|
}
|
|
|
|
const files: FsAttachment[] = [];
|
|
const skipped: SkippedAttachment[] = [];
|
|
const dedupedUris = Array.from(new Set(sourceUris.map((entry) => entry.trim()).filter(Boolean)));
|
|
|
|
for (const rawUri of dedupedUris) {
|
|
const parsed = deps.parseDroppedFileReference(rawUri);
|
|
if ('skipped' in parsed) {
|
|
skipped.push(parsed.skipped);
|
|
continue;
|
|
}
|
|
|
|
const uri = parsed.uri;
|
|
const name = path.basename(uri.fsPath || uri.path || rawUri);
|
|
|
|
const result = await deps.readUriAsAttachment(uri, name);
|
|
if ('file' in result) {
|
|
files.push(result.file);
|
|
} else {
|
|
skipped.push(result.skipped);
|
|
}
|
|
}
|
|
|
|
return { id, type, success: true, data: { files, skipped } };
|
|
}
|
|
|
|
case 'api:files/save-image': {
|
|
const rawFileName = (payload as { fileName?: unknown })?.fileName;
|
|
const rawDataUrl = (payload as { dataUrl?: unknown })?.dataUrl;
|
|
const dataUrl = typeof rawDataUrl === 'string' ? rawDataUrl.trim() : '';
|
|
if (!dataUrl.startsWith('data:image/')) {
|
|
return { id, type, success: false, error: 'Invalid image payload' };
|
|
}
|
|
|
|
const defaultFileName = typeof rawFileName === 'string' && rawFileName.trim().length > 0
|
|
? rawFileName.trim()
|
|
: `message-${Date.now()}.png`;
|
|
|
|
const saveUri = await vscode.window.showSaveDialog({
|
|
saveLabel: 'Save image',
|
|
defaultUri: vscode.workspace.workspaceFolders?.[0]
|
|
? vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, defaultFileName)
|
|
: undefined,
|
|
filters: { Images: ['png'] },
|
|
});
|
|
|
|
if (!saveUri) {
|
|
return { id, type, success: true, data: { saved: false, canceled: true } };
|
|
}
|
|
|
|
const commaIndex = dataUrl.indexOf(',');
|
|
if (commaIndex === -1) {
|
|
return { id, type, success: false, error: 'Invalid image data URL' };
|
|
}
|
|
|
|
const base64 = dataUrl.slice(commaIndex + 1);
|
|
const bytes = Buffer.from(base64, 'base64');
|
|
await vscode.workspace.fs.writeFile(saveUri, bytes);
|
|
|
|
return { id, type, success: true, data: { saved: true, path: saveUri.fsPath || saveUri.toString() } };
|
|
}
|
|
|
|
case 'api:files/save-markdown': {
|
|
const rawFileName = (payload as { fileName?: unknown })?.fileName;
|
|
const rawContent = (payload as { content?: unknown })?.content;
|
|
const content = typeof rawContent === 'string' ? rawContent : '';
|
|
if (!content) {
|
|
return { id, type, success: false, error: 'Invalid markdown payload' };
|
|
}
|
|
|
|
const defaultFileName = typeof rawFileName === 'string' && rawFileName.trim().length > 0
|
|
? rawFileName.trim()
|
|
: `session-${Date.now()}.md`;
|
|
|
|
const saveUri = await vscode.window.showSaveDialog({
|
|
saveLabel: 'Export session',
|
|
defaultUri: vscode.workspace.workspaceFolders?.[0]
|
|
? vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, defaultFileName)
|
|
: undefined,
|
|
filters: { Markdown: ['md'] },
|
|
});
|
|
|
|
if (!saveUri) {
|
|
return { id, type, success: true, data: { saved: false, canceled: true } };
|
|
}
|
|
|
|
await vscode.workspace.fs.writeFile(saveUri, Buffer.from(content, 'utf8'));
|
|
|
|
return { id, type, success: true, data: { saved: true, path: saveUri.fsPath || saveUri.toString() } };
|
|
}
|
|
|
|
case 'api:fs:reveal': {
|
|
const targetPath = (payload as { path?: unknown })?.path;
|
|
const value = typeof targetPath === 'string' ? targetPath.trim() : '';
|
|
if (!value) {
|
|
return { id, type, success: false, error: 'Path is required' };
|
|
}
|
|
|
|
try {
|
|
const uri = value.includes('://') ? vscode.Uri.parse(value) : vscode.Uri.file(value);
|
|
await vscode.commands.executeCommand('revealFileInOS', uri);
|
|
return { id, type, success: true, data: { success: true } };
|
|
} catch {
|
|
return { id, type, success: false, error: 'Failed to reveal path' };
|
|
}
|
|
}
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|