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

@@ -22,7 +22,8 @@ const ignore = (file: string) => {
if (file.startsWith("/scaffold")) { if (file.startsWith("/scaffold")) {
return false; return false;
} }
if (file.startsWith("/worker")) {
if (file.startsWith("/worker") && !file.startsWith("/workers")) {
return false; return false;
} }
if (file.startsWith("/node_modules/stacktrace-js")) { if (file.startsWith("/node_modules/stacktrace-js")) {
@@ -121,6 +122,11 @@ const config: ForgeConfig = {
config: "vite.preload.config.mts", config: "vite.preload.config.mts",
target: "preload", target: "preload",
}, },
{
entry: "workers/tsc/tsc_worker.ts",
config: "vite.worker.config.mts",
target: "main",
},
], ],
renderer: [ renderer: [
{ {

View File

@@ -20,7 +20,9 @@
"package": "npm run clean && electron-forge package", "package": "npm run clean && electron-forge package",
"make": "npm run clean && electron-forge make", "make": "npm run clean && electron-forge make",
"publish": "npm run clean && electron-forge publish", "publish": "npm run clean && electron-forge publish",
"ts": "npx tsc -p tsconfig.app.json --noEmit", "ts": "npm run ts:main && npm run ts:workers",
"ts:main": "npx tsc -p tsconfig.app.json --noEmit",
"ts:workers": "npx tsc -p workers/tsc/tsconfig.json --noEmit",
"lint": "npx oxlint --fix", "lint": "npx oxlint --fix",
"lint:fix": "npx oxlint --fix --fix-suggestions --fix-dangerously", "lint:fix": "npx oxlint --fix --fix-suggestions --fix-dangerously",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",

View File

@@ -1,30 +1,13 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { import {
getDyadWriteTags, SyncFileSystemDelegate,
getDyadRenameTags, SyncVirtualFileSystem,
getDyadDeleteTags, VirtualChanges,
} from "../ipc/processors/response_processor"; VirtualFile,
import { normalizePath } from "../ipc/processors/normalizePath"; } from "./tsc_types";
import { normalizePath } from "./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 { export interface AsyncFileSystemDelegate {
fileExists?: (fileName: string) => Promise<boolean>; fileExists?: (fileName: string) => Promise<boolean>;
@@ -78,11 +61,11 @@ export abstract class BaseVirtualFileSystem {
/** /**
* Apply changes from a response containing dyad tags * Apply changes from a response containing dyad tags
*/ */
public applyResponseChanges(fullResponse: string): void { public applyResponseChanges({
const writeTags = getDyadWriteTags(fullResponse); deletePaths,
const renameTags = getDyadRenameTags(fullResponse); renameTags,
const deletePaths = getDyadDeleteTags(fullResponse); writeTags,
}: VirtualChanges): void {
// Process deletions // Process deletions
for (const deletePath of deletePaths) { for (const deletePath of deletePaths) {
this.deleteFile(deletePath); this.deleteFile(deletePath);
@@ -147,7 +130,7 @@ export abstract class BaseVirtualFileSystem {
this.virtualFiles.set(toNormalized, content); this.virtualFiles.set(toNormalized, content);
} catch (error) { } catch (error) {
// If we can't read the source file, we'll let the consumer handle it // If we can't read the source file, we'll let the consumer handle it
logger.warn( console.warn(
`Could not read source file for rename: ${fromPath}`, `Could not read source file for rename: ${fromPath}`,
error, error,
); );
@@ -214,7 +197,10 @@ export abstract class BaseVirtualFileSystem {
/** /**
* Synchronous virtual filesystem * Synchronous virtual filesystem
*/ */
export class SyncVirtualFileSystem extends BaseVirtualFileSystem { export class SyncVirtualFileSystemImpl
extends BaseVirtualFileSystem
implements SyncVirtualFileSystem
{
private delegate: SyncFileSystemDelegate; private delegate: SyncFileSystemDelegate;
constructor(baseDir: string, delegate?: SyncFileSystemDelegate) { constructor(baseDir: string, delegate?: SyncFileSystemDelegate) {

52
shared/tsc_types.ts Normal file
View File

@@ -0,0 +1,52 @@
export interface SyncVirtualFileSystem {
fileExists: (fileName: string) => boolean;
readFile: (fileName: string) => string | undefined;
getVirtualFiles: () => { path: string }[];
getDeletedFiles: () => string[];
}
export interface SyncFileSystemDelegate {
fileExists?: (fileName: string) => boolean;
readFile?: (fileName: string) => string | undefined;
}
export interface Problem {
file: string;
line: number;
column: number;
message: string;
code: number;
}
export interface ProblemReport {
problems: Problem[];
}
export interface WorkerInput {
appPath: string;
virtualChanges: VirtualChanges;
tsBuildInfoCacheDir: string;
}
export interface WorkerOutput {
success: boolean;
data?: ProblemReport;
error?: string;
}
export interface VirtualChanges {
deletePaths: string[];
renameTags: VirtualRename[];
writeTags: VirtualFile[];
}
export interface VirtualFile {
path: string;
content: string;
}
export interface VirtualRename {
from: string;
to: string;
}

View File

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

View File

@@ -198,9 +198,9 @@ const PreviewHeader = ({
<DropdownMenuItem onClick={onClearSessionData}> <DropdownMenuItem onClick={onClearSessionData}>
<Trash2 size={16} /> <Trash2 size={16} />
<div className="flex flex-col"> <div className="flex flex-col">
<span>Clear Preview Data</span> <span>Clear Cache</span>
<span className="text-xs text-muted-foreground"> <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> </span>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
import { ipcMain, session } from "electron"; import { ipcMain, session } from "electron";
import fs from "node:fs/promises";
import { getTypeScriptCachePath } from "@/paths/paths";
export const registerSessionHandlers = () => { export const registerSessionHandlers = () => {
ipcMain.handle("clear-session-data", async (_event) => { ipcMain.handle("clear-session-data", async (_event) => {
@@ -8,5 +10,12 @@ export const registerSessionHandlers = () => {
storages: ["cookies", "localstorage"], storages: ["cookies", "localstorage"],
}); });
console.info(`[IPC] All session data cleared for default session`); 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 { z } from "zod";
import type { ProblemReport, Problem } from "../../shared/tsc_types";
export type { ProblemReport, Problem };
export interface AppOutput { export interface AppOutput {
type: "stdout" | "stderr" | "info" | "client-error"; type: "stdout" | "stderr" | "info" | "client-error";
@@ -246,15 +248,3 @@ export interface AppUpgrade {
manualUpgradeUrl: string; manualUpgradeUrl: string;
isNeeded: boolean; isNeeded: boolean;
} }
export interface Problem {
file: string;
line: number;
column: number;
message: string;
code: number;
}
export interface ProblemReport {
problems: Problem[];
}

View File

@@ -15,149 +15,21 @@ import {
executeSupabaseSql, executeSupabaseSql,
} from "../../supabase_admin/supabase_management_client"; } from "../../supabase_admin/supabase_management_client";
import { isServerFunction } from "../../supabase_admin/supabase_utils"; 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 { gitCommit } from "../utils/git_utils";
import { readSettings } from "@/main/settings"; import { readSettings } from "@/main/settings";
import { writeMigrationFile } from "../utils/file_utils"; 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 readFile = fs.promises.readFile;
const logger = log.scope("response_processor"); 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 { interface Output {
message: string; message: string;
error: unknown; error: unknown;

View File

@@ -1,29 +1,19 @@
import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { Worker } from "node:worker_threads";
import { ProblemReport } from "../ipc_types"; 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 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"); 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({ export async function generateProblemReport({
fullResponse, fullResponse,
appPath, appPath,
@@ -31,181 +21,61 @@ export async function generateProblemReport({
fullResponse: string; fullResponse: string;
appPath: string; appPath: string;
}): Promise<ProblemReport> { }): Promise<ProblemReport> {
// Load the local TypeScript version from the app's node_modules return new Promise((resolve, reject) => {
const ts = loadLocalTypeScript(appPath); // 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 logger.info(`Starting TSC worker for app ${appPath}`);
const vfs = new SyncVirtualFileSystem(appPath, {
fileExists: (fileName: string) => ts.sys.fileExists(fileName),
readFile: (fileName: string) => ts.sys.readFile(fileName),
});
vfs.applyResponseChanges(fullResponse);
// Find TypeScript config - throw error if not found // Create the worker
const tsconfigPath = findTypeScriptConfig(appPath); const worker = new Worker(workerPath);
// Create TypeScript program with virtual file system // Handle worker messages
const result = await runTypeScriptCheck(ts, appPath, tsconfigPath, vfs); worker.on("message", (output: WorkerOutput) => {
return result; worker.terminate();
}
function findTypeScriptConfig(appPath: string): string { if (output.success && output.data) {
const possibleConfigs = [ logger.info(`TSC worker completed successfully for app ${appPath}`);
// For vite applications, we want to check tsconfig.app.json, since it's the resolve(output.data);
// most important one (client-side app). } else {
// The tsconfig.json in vite apps is a project reference and doesn't logger.error(`TSC worker failed for app ${appPath}: ${output.error}`);
// actually check anything unless you do "--build" which requires a complex reject(new Error(output.error || "Unknown worker error"));
// 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,
}); });
}
return { // Handle worker errors
problems, worker.on("error", (error) => {
}; logger.error(`TSC worker error for app ${appPath}:`, error);
} worker.terminate();
reject(error);
function createVirtualCompilerHost( });
ts: typeof import("typescript"),
appPath: string, // Handle worker exit
vfs: SyncVirtualFileSystem, worker.on("exit", (code) => {
compilerOptions: import("typescript").CompilerOptions, if (code !== 0) {
): import("typescript").CompilerHost { logger.error(`TSC worker exited with code ${code} for app ${appPath}`);
const host = ts.createCompilerHost(compilerOptions); reject(new Error(`Worker exited with code ${code}`));
}
// Override file reading to use virtual files });
host.readFile = (fileName: string) => {
return vfs.readFile(fileName); const writeTags = getDyadWriteTags(fullResponse);
}; const renameTags = getDyadRenameTags(fullResponse);
const deletePaths = getDyadDeleteTags(fullResponse);
// Override file existence check const virtualChanges = {
host.fileExists = (fileName: string) => { deletePaths,
return vfs.fileExists(fileName); renameTags,
}; writeTags,
};
return host;
} // Send input to worker
const input: WorkerInput = {
function isTypeScriptFile(fileName: string): boolean { virtualChanges,
const ext = path.extname(fileName).toLowerCase(); appPath,
return [".ts", ".tsx", ".js", ".jsx"].includes(ext); 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; isServerFunction: boolean;
} }
export interface SqlQuery {
content: string;
description?: string;
}
export interface CodeProposal { export interface CodeProposal {
type: "code-proposal"; type: "code-proposal";
title: string; title: string;
@@ -268,3 +263,8 @@ export interface ProposalResult {
chatId: number; chatId: number;
messageId: 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); 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 * Gets the user data path, handling both Electron and non-Electron environments
* In Electron: returns the app's userData directory * In Electron: returns the app's userData directory

View File

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

View File

@@ -27,6 +27,6 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src", "e2e-tests"], "include": ["src", "e2e-tests", "shared"],
"exclude": ["e2e-tests/fixtures"] "exclude": ["e2e-tests/fixtures"]
} }

29
vite.worker.config.mts Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vite";
import path from "path";
// https://vitejs.dev/config
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
sourcemap: true,
// target: "node16",
lib: {
entry: path.resolve(__dirname, "workers/tsc/tsc_worker.ts"),
name: "tsc_worker",
fileName: "tsc_worker",
formats: ["cjs"],
},
rollupOptions: {
external: ["node:fs", "node:path", "node:worker_threads", "typescript"],
// output: {
// dir: "dist/workers/tsc",
// },
},
// outDir: "dist/workers/tsc",
// emptyOutDir: true,
},
});

307
workers/tsc/tsc_worker.ts Normal file
View File

@@ -0,0 +1,307 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { parentPort } from "node:worker_threads";
import {
Problem,
ProblemReport,
SyncVirtualFileSystem,
WorkerInput,
WorkerOutput,
} from "../../shared/tsc_types";
import { SyncVirtualFileSystemImpl } from "../../shared/VirtualFilesystem";
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] });
const ts = require(requirePath);
return ts;
} catch (error) {
throw new Error(
`Failed to load TypeScript from ${appPath} because of ${error}`,
);
}
}
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"),
vfs: SyncVirtualFileSystem,
{
appPath,
tsconfigPath,
tsBuildInfoCacheDir,
}: {
appPath: string;
tsconfigPath: string;
tsBuildInfoCacheDir: string;
},
): Promise<ProblemReport> {
return runSingleProject(ts, vfs, {
appPath,
tsconfigPath,
tsBuildInfoCacheDir,
});
}
async function runSingleProject(
ts: typeof import("typescript"),
vfs: SyncVirtualFileSystem,
{
appPath,
tsconfigPath,
tsBuildInfoCacheDir,
}: {
appPath: string;
tsconfigPath: string;
tsBuildInfoCacheDir: string;
},
): 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}`);
}
// Enable incremental compilation by setting tsBuildInfoFile if not already set
const options = { ...parsedCommandLine.options };
if (!options.tsBuildInfoFile && options.incremental !== false) {
// Place the buildinfo file in a temp directory to avoid polluting the project
const tmpDir = tsBuildInfoCacheDir;
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
// Create a unique filename based on both the app path and tsconfig path to prevent collisions
const configName = path.basename(tsconfigPath, path.extname(tsconfigPath));
const appHash = Buffer.from(appPath)
.toString("base64")
.replace(/[/+=]/g, "_");
options.tsBuildInfoFile = path.join(
tmpDir,
`${appHash}-${configName}.tsbuildinfo`,
);
options.incremental = true;
}
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, options);
// Create incremental program - TypeScript will automatically use the tsBuildInfo file
const builderProgram = ts.createIncrementalProgram({
rootNames,
options,
host,
configFileParsingDiagnostics:
ts.getConfigFileParsingDiagnostics(parsedCommandLine),
});
// Get diagnostics - the incremental program optimizes this by only checking changed files
const diagnostics = [
...builderProgram.getSyntacticDiagnostics(),
...builderProgram.getSemanticDiagnostics(),
...builderProgram.getGlobalDiagnostics(),
];
// Emit the build info file to persist the incremental state
builderProgram.emit();
// 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,
});
}
return {
problems,
};
}
function createVirtualCompilerHost(
ts: typeof import("typescript"),
appPath: string,
vfs: SyncVirtualFileSystem,
compilerOptions: import("typescript").CompilerOptions,
): import("typescript").CompilerHost {
const host = ts.createIncrementalCompilerHost(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);
};
// Override getCurrentDirectory to ensure proper resolution
host.getCurrentDirectory = () => appPath;
// Override writeFile to handle virtual file system
// This is important for writing the tsBuildInfo file
const originalWriteFile = host.writeFile;
host.writeFile = (
fileName: string,
data: string,
writeByteOrderMark?: boolean,
onError?: (message: string) => void,
) => {
// Only write build info files to disk, not emit files
if (fileName.endsWith(".tsbuildinfo")) {
originalWriteFile?.call(
host,
fileName,
data,
!!writeByteOrderMark,
onError,
);
}
// Ignore other emit files since we're only doing type checking
};
return host;
}
function isTypeScriptFile(fileName: string): boolean {
const ext = path.extname(fileName).toLowerCase();
return [".ts", ".tsx", ".js", ".jsx"].includes(ext);
}
async function processTypeScriptCheck(
input: WorkerInput,
): Promise<WorkerOutput> {
try {
const { appPath, virtualChanges, tsBuildInfoCacheDir } = input;
// Load the local TypeScript version from the app's node_modules
const ts = loadLocalTypeScript(appPath);
const vfs = new SyncVirtualFileSystemImpl(appPath, {
fileExists: (fileName: string) => ts.sys.fileExists(fileName),
readFile: (fileName: string) => ts.sys.readFile(fileName),
});
vfs.applyResponseChanges(virtualChanges);
// Find TypeScript config - throw error if not found
const tsconfigPath = findTypeScriptConfig(appPath);
// Create TypeScript program with virtual file system
const result = await runTypeScriptCheck(ts, vfs, {
appPath,
tsconfigPath,
tsBuildInfoCacheDir,
});
return {
success: true,
data: result,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
// Handle messages from main thread
parentPort?.on("message", async (input: WorkerInput) => {
const output = await processTypeScriptCheck(input);
parentPort?.postMessage(output);
});
/**
* 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.
*/
function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}

20
workers/tsc/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "System",
"lib": ["ES2022"],
"outFile": "./dist/tsc_worker.js",
"rootDir": "../../",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"declaration": false,
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"baseUrl": "./"
},
"include": ["*.ts"],
"exclude": ["node_modules", "dist"]
}