Improve check error performance by off-loading to worker thread w/ incremental compilation (#575)

This commit is contained in:
Will Chen
2025-07-07 12:47:33 -07:00
committed by GitHub
parent 97b5c29f11
commit bc38f9b2d7
22 changed files with 695 additions and 396 deletions

View File

@@ -1,11 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
getDyadWriteTags,
getDyadRenameTags,
getDyadDeleteTags,
processFullResponseActions,
getDyadAddDependencyTags,
} from "../ipc/processors/response_processor";
getDyadDeleteTags,
} from "../ipc/utils/dyad_tag_parser";
import { processFullResponseActions } from "../ipc/processors/response_processor";
import {
removeDyadTags,
hasUnclosedDyadWrite,

View File

@@ -198,9 +198,9 @@ const PreviewHeader = ({
<DropdownMenuItem onClick={onClearSessionData}>
<Trash2 size={16} />
<div className="flex flex-col">
<span>Clear Preview Data</span>
<span>Clear Cache</span>
<span className="text-xs text-muted-foreground">
Clears cookies and local storage for the app preview
Clears cookies and local storage and other app cache
</span>
</div>
</DropdownMenuItem>

View File

@@ -40,7 +40,7 @@ import { Worker } from "worker_threads";
import { createFromTemplate } from "./createFromTemplate";
import { gitCommit } from "../utils/git_utils";
import { safeSend } from "../utils/safe_sender";
import { normalizePath } from "../processors/normalizePath";
import { normalizePath } from "../../../shared/normalizePath";
async function copyDir(
source: string,

View File

@@ -22,10 +22,7 @@ import { getDyadAppPath } from "../../paths/paths";
import { readSettings } from "../../main/settings";
import type { ChatResponseEnd, ChatStreamParams } from "../ipc_types";
import { extractCodebase, readFileWithCache } from "../../utils/codebase";
import {
getDyadAddDependencyTags,
processFullResponseActions,
} from "../processors/response_processor";
import { processFullResponseActions } from "../processors/response_processor";
import { streamTestResponse } from "./testing_chat_handlers";
import { getTestResponse } from "./testing_chat_handlers";
import { getModelClient, ModelClient } from "../utils/get_model_client";
@@ -51,7 +48,13 @@ import { safeSend } from "../utils/safe_sender";
import { cleanFullResponse } from "../utils/cleanFullResponse";
import { generateProblemReport } from "../processors/tsc";
import { createProblemFixPrompt } from "@/shared/problem_prompt";
import { AsyncVirtualFileSystem } from "@/utils/VirtualFilesystem";
import { AsyncVirtualFileSystem } from "../../../shared/VirtualFilesystem";
import {
getDyadAddDependencyTags,
getDyadWriteTags,
getDyadDeleteTags,
getDyadRenameTags,
} from "../utils/dyad_tag_parser";
import { fileExists } from "../utils/file_utils";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
@@ -708,7 +711,14 @@ ${problemReport.problems
readFile: (fileName: string) => readFileWithCache(fileName),
},
);
virtualFileSystem.applyResponseChanges(fullResponse);
const writeTags = getDyadWriteTags(fullResponse);
const renameTags = getDyadRenameTags(fullResponse);
const deletePaths = getDyadDeleteTags(fullResponse);
virtualFileSystem.applyResponseChanges({
deletePaths,
renameTags,
writeTags,
});
const { formattedOutput: codebaseInfo, files } =
await extractCodebase({

View File

@@ -9,16 +9,16 @@ import { messages, chats } from "../../db/schema";
import { desc, eq, and } from "drizzle-orm";
import path from "node:path"; // Import path for basename
// Import tag parsers
import { processFullResponseActions } from "../processors/response_processor";
import {
getDyadAddDependencyTags,
getDyadChatSummaryTag,
getDyadWriteTags,
getDyadRenameTags,
getDyadDeleteTags,
getDyadExecuteSqlTags,
getDyadRenameTags,
getDyadWriteTags,
getDyadAddDependencyTags,
getDyadChatSummaryTag,
getDyadCommandTags,
processFullResponseActions,
} from "../processors/response_processor";
} from "../utils/dyad_tag_parser";
import log from "electron-log";
import { isServerFunction } from "../../supabase_admin/supabase_utils";
import {

View File

@@ -1,4 +1,6 @@
import { ipcMain, session } from "electron";
import fs from "node:fs/promises";
import { getTypeScriptCachePath } from "@/paths/paths";
export const registerSessionHandlers = () => {
ipcMain.handle("clear-session-data", async (_event) => {
@@ -8,5 +10,12 @@ export const registerSessionHandlers = () => {
storages: ["cookies", "localstorage"],
});
console.info(`[IPC] All session data cleared for default session`);
// Clear custom cache data (like tsbuildinfo)
try {
await fs.rm(getTypeScriptCachePath(), { recursive: true, force: true });
} catch {
// Directory might not exist
}
});
};

View File

@@ -1,4 +1,6 @@
import { z } from "zod";
import type { ProblemReport, Problem } from "../../shared/tsc_types";
export type { ProblemReport, Problem };
export interface AppOutput {
type: "stdout" | "stderr" | "info" | "client-error";
@@ -246,15 +248,3 @@ export interface AppUpgrade {
manualUpgradeUrl: string;
isNeeded: boolean;
}
export interface Problem {
file: string;
line: number;
column: number;
message: string;
code: number;
}
export interface ProblemReport {
problems: Problem[];
}

View File

@@ -1,10 +0,0 @@
/**
* Normalize the path to use forward slashes instead of backslashes.
* This is important to prevent weird Git issues, particularly on Windows.
* @param path Source path.
* @returns Normalized path.
*/
export function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}

View File

@@ -15,149 +15,21 @@ import {
executeSupabaseSql,
} from "../../supabase_admin/supabase_management_client";
import { isServerFunction } from "../../supabase_admin/supabase_utils";
import { SqlQuery, UserSettings } from "../../lib/schemas";
import { UserSettings } from "../../lib/schemas";
import { gitCommit } from "../utils/git_utils";
import { readSettings } from "@/main/settings";
import { writeMigrationFile } from "../utils/file_utils";
import { normalizePath } from "./normalizePath";
import {
getDyadWriteTags,
getDyadRenameTags,
getDyadDeleteTags,
getDyadAddDependencyTags,
getDyadExecuteSqlTags,
} from "../utils/dyad_tag_parser";
const readFile = fs.promises.readFile;
const logger = log.scope("response_processor");
export function getDyadWriteTags(fullResponse: string): {
path: string;
content: string;
description?: string;
}[] {
const dyadWriteRegex = /<dyad-write([^>]*)>([\s\S]*?)<\/dyad-write>/gi;
const pathRegex = /path="([^"]+)"/;
const descriptionRegex = /description="([^"]+)"/;
let match;
const tags: { path: string; content: string; description?: string }[] = [];
while ((match = dyadWriteRegex.exec(fullResponse)) !== null) {
const attributesString = match[1];
let content = match[2].trim();
const pathMatch = pathRegex.exec(attributesString);
const descriptionMatch = descriptionRegex.exec(attributesString);
if (pathMatch && pathMatch[1]) {
const path = pathMatch[1];
const description = descriptionMatch?.[1];
const contentLines = content.split("\n");
if (contentLines[0]?.startsWith("```")) {
contentLines.shift();
}
if (contentLines[contentLines.length - 1]?.startsWith("```")) {
contentLines.pop();
}
content = contentLines.join("\n");
tags.push({ path: normalizePath(path), content, description });
} else {
logger.warn(
"Found <dyad-write> tag without a valid 'path' attribute:",
match[0],
);
}
}
return tags;
}
export function getDyadRenameTags(fullResponse: string): {
from: string;
to: string;
}[] {
const dyadRenameRegex =
/<dyad-rename from="([^"]+)" to="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-rename>/g;
let match;
const tags: { from: string; to: string }[] = [];
while ((match = dyadRenameRegex.exec(fullResponse)) !== null) {
tags.push({
from: normalizePath(match[1]),
to: normalizePath(match[2]),
});
}
return tags;
}
export function getDyadDeleteTags(fullResponse: string): string[] {
const dyadDeleteRegex =
/<dyad-delete path="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-delete>/g;
let match;
const paths: string[] = [];
while ((match = dyadDeleteRegex.exec(fullResponse)) !== null) {
paths.push(normalizePath(match[1]));
}
return paths;
}
export function getDyadAddDependencyTags(fullResponse: string): string[] {
const dyadAddDependencyRegex =
/<dyad-add-dependency packages="([^"]+)">[^<]*<\/dyad-add-dependency>/g;
let match;
const packages: string[] = [];
while ((match = dyadAddDependencyRegex.exec(fullResponse)) !== null) {
packages.push(...match[1].split(" "));
}
return packages;
}
export function getDyadChatSummaryTag(fullResponse: string): string | null {
const dyadChatSummaryRegex =
/<dyad-chat-summary>([\s\S]*?)<\/dyad-chat-summary>/g;
const match = dyadChatSummaryRegex.exec(fullResponse);
if (match && match[1]) {
return match[1].trim();
}
return null;
}
export function getDyadExecuteSqlTags(fullResponse: string): SqlQuery[] {
const dyadExecuteSqlRegex =
/<dyad-execute-sql([^>]*)>([\s\S]*?)<\/dyad-execute-sql>/g;
const descriptionRegex = /description="([^"]+)"/;
let match;
const queries: { content: string; description?: string }[] = [];
while ((match = dyadExecuteSqlRegex.exec(fullResponse)) !== null) {
const attributesString = match[1] || "";
let content = match[2].trim();
const descriptionMatch = descriptionRegex.exec(attributesString);
const description = descriptionMatch?.[1];
// Handle markdown code blocks if present
const contentLines = content.split("\n");
if (contentLines[0]?.startsWith("```")) {
contentLines.shift();
}
if (contentLines[contentLines.length - 1]?.startsWith("```")) {
contentLines.pop();
}
content = contentLines.join("\n");
queries.push({ content, description });
}
return queries;
}
export function getDyadCommandTags(fullResponse: string): string[] {
const dyadCommandRegex =
/<dyad-command type="([^"]+)"[^>]*><\/dyad-command>/g;
let match;
const commands: string[] = [];
while ((match = dyadCommandRegex.exec(fullResponse)) !== null) {
commands.push(match[1]);
}
return commands;
}
interface Output {
message: string;
error: unknown;

View File

@@ -1,29 +1,19 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { Worker } from "node:worker_threads";
import { ProblemReport } from "../ipc_types";
import { Problem } from "../ipc_types";
import { normalizePath } from "./normalizePath";
import { SyncVirtualFileSystem } from "../../utils/VirtualFilesystem";
import log from "electron-log";
import { WorkerInput, WorkerOutput } from "../../../shared/tsc_types";
import {
getDyadDeleteTags,
getDyadRenameTags,
getDyadWriteTags,
} from "../utils/dyad_tag_parser";
import { getTypeScriptCachePath } from "@/paths/paths";
const logger = log.scope("tsc");
function loadLocalTypeScript(appPath: string): typeof import("typescript") {
try {
// Try to load TypeScript from the project's node_modules
const requirePath = require.resolve("typescript", { paths: [appPath] });
logger.info(`Loading TypeScript from ${requirePath} for app ${appPath}`);
const ts = require(requirePath);
return ts;
} catch (error) {
throw new Error(
`Failed to load TypeScript from ${appPath} because of ${error}`,
);
}
}
export async function generateProblemReport({
fullResponse,
appPath,
@@ -31,181 +21,61 @@ export async function generateProblemReport({
fullResponse: string;
appPath: string;
}): Promise<ProblemReport> {
// Load the local TypeScript version from the app's node_modules
const ts = loadLocalTypeScript(appPath);
return new Promise((resolve, reject) => {
// Determine the worker script path
const workerPath = path.join(__dirname, "tsc_worker.js");
// Create virtual file system with TypeScript system delegate and apply changes from response
const vfs = new SyncVirtualFileSystem(appPath, {
fileExists: (fileName: string) => ts.sys.fileExists(fileName),
readFile: (fileName: string) => ts.sys.readFile(fileName),
});
vfs.applyResponseChanges(fullResponse);
logger.info(`Starting TSC worker for app ${appPath}`);
// Find TypeScript config - throw error if not found
const tsconfigPath = findTypeScriptConfig(appPath);
// Create the worker
const worker = new Worker(workerPath);
// Create TypeScript program with virtual file system
const result = await runTypeScriptCheck(ts, appPath, tsconfigPath, vfs);
return result;
}
// Handle worker messages
worker.on("message", (output: WorkerOutput) => {
worker.terminate();
function findTypeScriptConfig(appPath: string): string {
const possibleConfigs = [
// For vite applications, we want to check tsconfig.app.json, since it's the
// most important one (client-side app).
// The tsconfig.json in vite apps is a project reference and doesn't
// actually check anything unless you do "--build" which requires a complex
// programmatic approach
"tsconfig.app.json",
// For Next.js applications, it typically has a single tsconfig.json file
"tsconfig.json",
];
for (const config of possibleConfigs) {
const configPath = path.join(appPath, config);
if (fs.existsSync(configPath)) {
return configPath;
}
}
throw new Error(
`No TypeScript configuration file found in ${appPath}. Expected one of: ${possibleConfigs.join(", ")}`,
);
}
async function runTypeScriptCheck(
ts: typeof import("typescript"),
appPath: string,
tsconfigPath: string,
vfs: SyncVirtualFileSystem,
): Promise<ProblemReport> {
return runSingleProject(ts, appPath, tsconfigPath, vfs);
}
async function runSingleProject(
ts: typeof import("typescript"),
appPath: string,
tsconfigPath: string,
vfs: SyncVirtualFileSystem,
): Promise<ProblemReport> {
// Use the idiomatic way to parse TypeScript config
const parsedCommandLine = ts.getParsedCommandLineOfConfigFile(
tsconfigPath,
undefined, // No additional options
{
// Custom system object that can handle our virtual files
...ts.sys,
fileExists: (fileName: string) => vfs.fileExists(fileName),
readFile: (fileName: string) => vfs.readFile(fileName),
onUnRecoverableConfigFileDiagnostic: (
diagnostic: import("typescript").Diagnostic,
) => {
throw new Error(
`TypeScript config error: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
);
},
},
);
if (!parsedCommandLine) {
throw new Error(`Failed to parse TypeScript config: ${tsconfigPath}`);
}
let rootNames = parsedCommandLine.fileNames;
// Add any virtual files that aren't already included
const virtualTsFiles = vfs
.getVirtualFiles()
.map((file) => path.resolve(appPath, file.path))
.filter(isTypeScriptFile);
// Remove deleted files from rootNames
const deletedFiles = vfs
.getDeletedFiles()
.map((file) => path.resolve(appPath, file));
rootNames = rootNames.filter((fileName) => {
const resolvedPath = path.resolve(fileName);
return !deletedFiles.includes(resolvedPath);
});
for (const virtualFile of virtualTsFiles) {
if (!rootNames.includes(virtualFile)) {
rootNames.push(virtualFile);
}
}
// Create custom compiler host
const host = createVirtualCompilerHost(
ts,
appPath,
vfs,
parsedCommandLine.options,
);
// Create TypeScript program - this is the idiomatic way
const program = ts.createProgram(rootNames, parsedCommandLine.options, host);
// Get diagnostics
const diagnostics = [
...program.getSyntacticDiagnostics(),
...program.getSemanticDiagnostics(),
...program.getGlobalDiagnostics(),
];
// Convert diagnostics to our format
const problems: Problem[] = [];
for (const diagnostic of diagnostics) {
if (!diagnostic.file) continue;
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(
diagnostic.start!,
);
const message = ts.flattenDiagnosticMessageText(
diagnostic.messageText,
"\n",
);
if (diagnostic.category !== ts.DiagnosticCategory.Error) {
continue;
}
problems.push({
file: normalizePath(path.relative(appPath, diagnostic.file.fileName)),
line: line + 1, // Convert to 1-based
column: character + 1, // Convert to 1-based
message,
code: diagnostic.code,
if (output.success && output.data) {
logger.info(`TSC worker completed successfully for app ${appPath}`);
resolve(output.data);
} else {
logger.error(`TSC worker failed for app ${appPath}: ${output.error}`);
reject(new Error(output.error || "Unknown worker error"));
}
});
}
return {
problems,
};
}
function createVirtualCompilerHost(
ts: typeof import("typescript"),
appPath: string,
vfs: SyncVirtualFileSystem,
compilerOptions: import("typescript").CompilerOptions,
): import("typescript").CompilerHost {
const host = ts.createCompilerHost(compilerOptions);
// Override file reading to use virtual files
host.readFile = (fileName: string) => {
return vfs.readFile(fileName);
};
// Override file existence check
host.fileExists = (fileName: string) => {
return vfs.fileExists(fileName);
};
return host;
}
function isTypeScriptFile(fileName: string): boolean {
const ext = path.extname(fileName).toLowerCase();
return [".ts", ".tsx", ".js", ".jsx"].includes(ext);
// Handle worker errors
worker.on("error", (error) => {
logger.error(`TSC worker error for app ${appPath}:`, error);
worker.terminate();
reject(error);
});
// Handle worker exit
worker.on("exit", (code) => {
if (code !== 0) {
logger.error(`TSC worker exited with code ${code} for app ${appPath}`);
reject(new Error(`Worker exited with code ${code}`));
}
});
const writeTags = getDyadWriteTags(fullResponse);
const renameTags = getDyadRenameTags(fullResponse);
const deletePaths = getDyadDeleteTags(fullResponse);
const virtualChanges = {
deletePaths,
renameTags,
writeTags,
};
// Send input to worker
const input: WorkerInput = {
virtualChanges,
appPath,
tsBuildInfoCacheDir: getTypeScriptCachePath(),
};
logger.info(`Sending input to TSC worker for app ${appPath}`);
worker.postMessage(input);
});
}

View File

@@ -0,0 +1,139 @@
import { normalizePath } from "../../../shared/normalizePath";
import log from "electron-log";
import { SqlQuery } from "../../lib/schemas";
const logger = log.scope("dyad_tag_parser");
export function getDyadWriteTags(fullResponse: string): {
path: string;
content: string;
description?: string;
}[] {
const dyadWriteRegex = /<dyad-write([^>]*)>([\s\S]*?)<\/dyad-write>/gi;
const pathRegex = /path="([^"]+)"/;
const descriptionRegex = /description="([^"]+)"/;
let match;
const tags: { path: string; content: string; description?: string }[] = [];
while ((match = dyadWriteRegex.exec(fullResponse)) !== null) {
const attributesString = match[1];
let content = match[2].trim();
const pathMatch = pathRegex.exec(attributesString);
const descriptionMatch = descriptionRegex.exec(attributesString);
if (pathMatch && pathMatch[1]) {
const path = pathMatch[1];
const description = descriptionMatch?.[1];
const contentLines = content.split("\n");
if (contentLines[0]?.startsWith("```")) {
contentLines.shift();
}
if (contentLines[contentLines.length - 1]?.startsWith("```")) {
contentLines.pop();
}
content = contentLines.join("\n");
tags.push({ path: normalizePath(path), content, description });
} else {
logger.warn(
"Found <dyad-write> tag without a valid 'path' attribute:",
match[0],
);
}
}
return tags;
}
export function getDyadRenameTags(fullResponse: string): {
from: string;
to: string;
}[] {
const dyadRenameRegex =
/<dyad-rename from="([^"]+)" to="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-rename>/g;
let match;
const tags: { from: string; to: string }[] = [];
while ((match = dyadRenameRegex.exec(fullResponse)) !== null) {
tags.push({
from: normalizePath(match[1]),
to: normalizePath(match[2]),
});
}
return tags;
}
export function getDyadDeleteTags(fullResponse: string): string[] {
const dyadDeleteRegex =
/<dyad-delete path="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-delete>/g;
let match;
const paths: string[] = [];
while ((match = dyadDeleteRegex.exec(fullResponse)) !== null) {
paths.push(normalizePath(match[1]));
}
return paths;
}
export function getDyadAddDependencyTags(fullResponse: string): string[] {
const dyadAddDependencyRegex =
/<dyad-add-dependency packages="([^"]+)">[^<]*<\/dyad-add-dependency>/g;
let match;
const packages: string[] = [];
while ((match = dyadAddDependencyRegex.exec(fullResponse)) !== null) {
packages.push(...match[1].split(" "));
}
return packages;
}
export function getDyadChatSummaryTag(fullResponse: string): string | null {
const dyadChatSummaryRegex =
/<dyad-chat-summary>([\s\S]*?)<\/dyad-chat-summary>/g;
const match = dyadChatSummaryRegex.exec(fullResponse);
if (match && match[1]) {
return match[1].trim();
}
return null;
}
export function getDyadExecuteSqlTags(fullResponse: string): SqlQuery[] {
const dyadExecuteSqlRegex =
/<dyad-execute-sql([^>]*)>([\s\S]*?)<\/dyad-execute-sql>/g;
const descriptionRegex = /description="([^"]+)"/;
let match;
const queries: { content: string; description?: string }[] = [];
while ((match = dyadExecuteSqlRegex.exec(fullResponse)) !== null) {
const attributesString = match[1] || "";
let content = match[2].trim();
const descriptionMatch = descriptionRegex.exec(attributesString);
const description = descriptionMatch?.[1];
// Handle markdown code blocks if present
const contentLines = content.split("\n");
if (contentLines[0]?.startsWith("```")) {
contentLines.shift();
}
if (contentLines[contentLines.length - 1]?.startsWith("```")) {
contentLines.pop();
}
content = contentLines.join("\n");
queries.push({ content, description });
}
return queries;
}
export function getDyadCommandTags(fullResponse: string): string[] {
const dyadCommandRegex =
/<dyad-command type="([^"]+)"[^>]*><\/dyad-command>/g;
let match;
const commands: string[] = [];
while ((match = dyadCommandRegex.exec(fullResponse)) !== null) {
commands.push(match[1]);
}
return commands;
}

View File

@@ -193,11 +193,6 @@ export interface FileChange {
isServerFunction: boolean;
}
export interface SqlQuery {
content: string;
description?: string;
}
export interface CodeProposal {
type: "code-proposal";
title: string;
@@ -268,3 +263,8 @@ export interface ProposalResult {
chatId: number;
messageId: number;
}
export interface SqlQuery {
content: string;
description?: string;
}

View File

@@ -10,6 +10,11 @@ export function getDyadAppPath(appPath: string): string {
return path.join(os.homedir(), "dyad-apps", appPath);
}
export function getTypeScriptCachePath(): string {
const electron = getElectron();
return path.join(electron!.app.getPath("sessionData"), "typescript-cache");
}
/**
* Gets the user data path, handling both Electron and non-Electron environments
* In Electron: returns the app's userData directory

View File

@@ -1,368 +0,0 @@
import * as fs from "node:fs";
import * as path from "node:path";
import {
getDyadWriteTags,
getDyadRenameTags,
getDyadDeleteTags,
} from "../ipc/processors/response_processor";
import { normalizePath } from "../ipc/processors/normalizePath";
import log from "electron-log";
const logger = log.scope("VirtualFileSystem");
export interface VirtualFile {
path: string;
content: string;
}
export interface VirtualRename {
from: string;
to: string;
}
export interface SyncFileSystemDelegate {
fileExists?: (fileName: string) => boolean;
readFile?: (fileName: string) => string | undefined;
}
export interface AsyncFileSystemDelegate {
fileExists?: (fileName: string) => Promise<boolean>;
readFile?: (fileName: string) => Promise<string | undefined>;
}
/**
* Base class containing shared virtual filesystem functionality
*/
export abstract class BaseVirtualFileSystem {
protected virtualFiles = new Map<string, string>();
protected deletedFiles = new Set<string>();
protected baseDir: string;
constructor(baseDir: string) {
this.baseDir = path.resolve(baseDir);
}
/**
* Normalize path for consistent cross-platform behavior
*/
private normalizePathForKey(filePath: string): string {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
// Normalize separators and handle case-insensitive Windows paths
const normalized = normalizePath(path.normalize(absolutePath));
// Intentionally do NOT lowercase for Windows which is case-insensitive
// because this avoids issues with path comparison.
//
// This is a trade-off and introduces a small edge case where
// e.g. foo.txt and Foo.txt are treated as different files by the VFS
// even though Windows treats them as the same file.
//
// This should be a pretty rare occurence and it's not worth the extra
// complexity to handle it.
return normalized;
}
/**
* Convert normalized path back to platform-appropriate format
*/
private denormalizePath(normalizedPath: string): string {
return process.platform === "win32"
? normalizedPath.replace(/\//g, "\\")
: normalizedPath;
}
/**
* Apply changes from a response containing dyad tags
*/
public applyResponseChanges(fullResponse: string): void {
const writeTags = getDyadWriteTags(fullResponse);
const renameTags = getDyadRenameTags(fullResponse);
const deletePaths = getDyadDeleteTags(fullResponse);
// Process deletions
for (const deletePath of deletePaths) {
this.deleteFile(deletePath);
}
// Process renames (delete old, create new)
for (const rename of renameTags) {
this.renameFile(rename.from, rename.to);
}
// Process writes
for (const writeTag of writeTags) {
this.writeFile(writeTag.path, writeTag.content);
}
}
/**
* Write a file to the virtual filesystem
*/
protected writeFile(relativePath: string, content: string): void {
const absolutePath = path.resolve(this.baseDir, relativePath);
const normalizedKey = this.normalizePathForKey(absolutePath);
this.virtualFiles.set(normalizedKey, content);
// Remove from deleted files if it was previously deleted
this.deletedFiles.delete(normalizedKey);
}
/**
* Delete a file from the virtual filesystem
*/
protected deleteFile(relativePath: string): void {
const absolutePath = path.resolve(this.baseDir, relativePath);
const normalizedKey = this.normalizePathForKey(absolutePath);
this.deletedFiles.add(normalizedKey);
// Remove from virtual files if it exists there
this.virtualFiles.delete(normalizedKey);
}
/**
* Rename a file in the virtual filesystem
*/
protected renameFile(fromPath: string, toPath: string): void {
const fromAbsolute = path.resolve(this.baseDir, fromPath);
const toAbsolute = path.resolve(this.baseDir, toPath);
const fromNormalized = this.normalizePathForKey(fromAbsolute);
const toNormalized = this.normalizePathForKey(toAbsolute);
// Mark old file as deleted
this.deletedFiles.add(fromNormalized);
// If the source file exists in virtual files, move its content
if (this.virtualFiles.has(fromNormalized)) {
const content = this.virtualFiles.get(fromNormalized)!;
this.virtualFiles.delete(fromNormalized);
this.virtualFiles.set(toNormalized, content);
} else {
// Try to read from actual filesystem
try {
const content = fs.readFileSync(fromAbsolute, "utf8");
this.virtualFiles.set(toNormalized, content);
} catch (error) {
// If we can't read the source file, we'll let the consumer handle it
logger.warn(
`Could not read source file for rename: ${fromPath}`,
error,
);
}
}
// Remove destination from deleted files if it was previously deleted
this.deletedFiles.delete(toNormalized);
}
/**
* Get all virtual files (files that have been written or modified)
*/
public getVirtualFiles(): VirtualFile[] {
return Array.from(this.virtualFiles.entries()).map(
([normalizedKey, content]) => {
// Convert normalized key back to relative path
const denormalizedPath = this.denormalizePath(normalizedKey);
return {
path: path.relative(this.baseDir, denormalizedPath),
content,
};
},
);
}
/**
* Get all deleted file paths (relative to base directory)
*/
public getDeletedFiles(): string[] {
return Array.from(this.deletedFiles).map((normalizedKey) => {
// Convert normalized key back to relative path
const denormalizedPath = this.denormalizePath(normalizedKey);
return path.relative(this.baseDir, denormalizedPath);
});
}
/**
* Check if a file is deleted in the virtual filesystem
*/
protected isDeleted(filePath: string): boolean {
const normalizedKey = this.normalizePathForKey(filePath);
return this.deletedFiles.has(normalizedKey);
}
/**
* Check if a file exists in virtual files
*/
protected hasVirtualFile(filePath: string): boolean {
const normalizedKey = this.normalizePathForKey(filePath);
return this.virtualFiles.has(normalizedKey);
}
/**
* Get virtual file content
*/
protected getVirtualFileContent(filePath: string): string | undefined {
const normalizedKey = this.normalizePathForKey(filePath);
return this.virtualFiles.get(normalizedKey);
}
}
/**
* Synchronous virtual filesystem
*/
export class SyncVirtualFileSystem extends BaseVirtualFileSystem {
private delegate: SyncFileSystemDelegate;
constructor(baseDir: string, delegate?: SyncFileSystemDelegate) {
super(baseDir);
this.delegate = delegate || {};
}
/**
* Check if a file exists in the virtual filesystem
*/
public fileExists(filePath: string): boolean {
// Check if file is deleted
if (this.isDeleted(filePath)) {
return false;
}
// Check if file exists in virtual files
if (this.hasVirtualFile(filePath)) {
return true;
}
// Delegate to custom fileExists if provided
if (this.delegate.fileExists) {
return this.delegate.fileExists(filePath);
}
// Fall back to actual filesystem
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
return fs.existsSync(absolutePath);
}
/**
* Read a file from the virtual filesystem
*/
public readFile(filePath: string): string | undefined {
// Check if file is deleted
if (this.isDeleted(filePath)) {
return undefined;
}
// Check virtual files first
const virtualContent = this.getVirtualFileContent(filePath);
if (virtualContent !== undefined) {
return virtualContent;
}
// Delegate to custom readFile if provided
if (this.delegate.readFile) {
return this.delegate.readFile(filePath);
}
// Fall back to actual filesystem
try {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
return fs.readFileSync(absolutePath, "utf8");
} catch {
return undefined;
}
}
/**
* Create a custom file system interface for other tools
*/
public createFileSystemInterface() {
return {
fileExists: (fileName: string) => this.fileExists(fileName),
readFile: (fileName: string) => this.readFile(fileName),
writeFile: (fileName: string, content: string) =>
this.writeFile(fileName, content),
deleteFile: (fileName: string) => this.deleteFile(fileName),
};
}
}
/**
* Asynchronous virtual filesystem
*/
export class AsyncVirtualFileSystem extends BaseVirtualFileSystem {
private delegate: AsyncFileSystemDelegate;
constructor(baseDir: string, delegate?: AsyncFileSystemDelegate) {
super(baseDir);
this.delegate = delegate || {};
}
/**
* Check if a file exists in the virtual filesystem
*/
public async fileExists(filePath: string): Promise<boolean> {
// Check if file is deleted
if (this.isDeleted(filePath)) {
return false;
}
// Check if file exists in virtual files
if (this.hasVirtualFile(filePath)) {
return true;
}
// Delegate to custom fileExists if provided
if (this.delegate.fileExists) {
return this.delegate.fileExists(filePath);
}
// Fall back to actual filesystem
try {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
await fs.promises.access(absolutePath);
return true;
} catch {
return false;
}
}
/**
* Read a file from the virtual filesystem
*/
public async readFile(filePath: string): Promise<string | undefined> {
// Check if file is deleted
if (this.isDeleted(filePath)) {
return undefined;
}
// Check virtual files first
const virtualContent = this.getVirtualFileContent(filePath);
if (virtualContent !== undefined) {
return virtualContent;
}
// Delegate to custom readFile if provided
if (this.delegate.readFile) {
return this.delegate.readFile(filePath);
}
// Fall back to actual filesystem
try {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
return await fs.promises.readFile(absolutePath, "utf8");
} catch {
return undefined;
}
}
}

View File

@@ -7,7 +7,7 @@ import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
import { glob } from "glob";
import { AppChatContext } from "../lib/schemas";
import { readSettings } from "@/main/settings";
import { AsyncVirtualFileSystem } from "./VirtualFilesystem";
import { AsyncVirtualFileSystem } from "../../shared/VirtualFilesystem";
const logger = log.scope("utils/codebase");