* fix(files): refresh open file content after external edits Previously, opening a file in the Files view and then editing it externally (e.g. via CLI or another editor) would show stale content. Even closing and reopening the file returned cached content — a full page reload was required. Root causes: 1. The in-memory readFile cache used path-only hits, with no metadata validation. External edits were invisible until the cache was evicted. 2. No polling mechanism existed to detect external changes to the open file. Fix: - Add mtimeMs to statFile across all runtimes (web, VS Code, desktop). - Cache layer (RuntimeAPIProvider): validate cache hits against current stat metadata (mtimeMs + size). On miss, use stat→read→stat to avoid TOCTOU. - UI layer (FilesView): poll the open file every 2s; on detected change, set loadedFilePath=null to trigger the existing load effect once (no double reload). Skip polling when tab is hidden or editor has unsaved changes. - After save, refresh the stat ref so the next poll doesn't see a spurious change from the save itself. Addresses review feedback from PR #827 (double reload + TOCTOU). * fix(files): address P2 review findings - readFreshFile retry now uses stat→read→stat to maintain TOCTOU protection during the retry path (not just the initial read). - Replace isDirty in polling effect deps with isDirtyRef to avoid unnecessary interval teardown/restart on every edit/save cycle.
499 lines
18 KiB
TypeScript
499 lines
18 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 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>;
|
|
};
|
|
|
|
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 deps.execGit(['check-ignore', '--', ...pathsToCheck], 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 results: Array<{
|
|
command: string;
|
|
success: boolean;
|
|
exitCode?: number;
|
|
stdout?: string;
|
|
stderr?: string;
|
|
error?: string;
|
|
}> = [];
|
|
|
|
for (const cmd of commands) {
|
|
if (typeof cmd !== 'string' || !cmd.trim()) {
|
|
results.push({ command: cmd, success: false, error: 'Invalid command' });
|
|
continue;
|
|
}
|
|
try {
|
|
const { stdout, stderr } = await execAsync(`${shell} ${shellFlag} "${cmd.replace(/"/g, '\\"')}"`, {
|
|
cwd: resolvedCwd,
|
|
env: augmentedEnv,
|
|
timeout: 300000,
|
|
});
|
|
results.push({
|
|
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 };
|
|
results.push({
|
|
command: cmd,
|
|
success: false,
|
|
exitCode: typeof err.code === 'number' ? err.code : 1,
|
|
stdout: (err.stdout || '').trim(),
|
|
stderr: (err.stderr || '').trim(),
|
|
error: err.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|