diff --git a/forge.config.ts b/forge.config.ts index f4d37e0..c051191 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -22,7 +22,8 @@ const ignore = (file: string) => { if (file.startsWith("/scaffold")) { return false; } - if (file.startsWith("/worker")) { + + if (file.startsWith("/worker") && !file.startsWith("/workers")) { return false; } if (file.startsWith("/node_modules/stacktrace-js")) { @@ -121,6 +122,11 @@ const config: ForgeConfig = { config: "vite.preload.config.mts", target: "preload", }, + { + entry: "workers/tsc/tsc_worker.ts", + config: "vite.worker.config.mts", + target: "main", + }, ], renderer: [ { diff --git a/package.json b/package.json index 1e9a00f..7fa6660 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "package": "npm run clean && electron-forge package", "make": "npm run clean && electron-forge make", "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:fix": "npx oxlint --fix --fix-suggestions --fix-dangerously", "db:generate": "drizzle-kit generate", diff --git a/src/utils/VirtualFilesystem.ts b/shared/VirtualFilesystem.ts similarity index 91% rename from src/utils/VirtualFilesystem.ts rename to shared/VirtualFilesystem.ts index f876a0f..0e3a2d3 100644 --- a/src/utils/VirtualFilesystem.ts +++ b/shared/VirtualFilesystem.ts @@ -1,30 +1,13 @@ 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; -} + SyncFileSystemDelegate, + SyncVirtualFileSystem, + VirtualChanges, + VirtualFile, +} from "./tsc_types"; +import { normalizePath } from "./normalizePath"; export interface AsyncFileSystemDelegate { fileExists?: (fileName: string) => Promise; @@ -78,11 +61,11 @@ export abstract class BaseVirtualFileSystem { /** * 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); - + public applyResponseChanges({ + deletePaths, + renameTags, + writeTags, + }: VirtualChanges): void { // Process deletions for (const deletePath of deletePaths) { this.deleteFile(deletePath); @@ -147,7 +130,7 @@ export abstract class BaseVirtualFileSystem { this.virtualFiles.set(toNormalized, content); } catch (error) { // 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}`, error, ); @@ -214,7 +197,10 @@ export abstract class BaseVirtualFileSystem { /** * Synchronous virtual filesystem */ -export class SyncVirtualFileSystem extends BaseVirtualFileSystem { +export class SyncVirtualFileSystemImpl + extends BaseVirtualFileSystem + implements SyncVirtualFileSystem +{ private delegate: SyncFileSystemDelegate; constructor(baseDir: string, delegate?: SyncFileSystemDelegate) { diff --git a/src/ipc/processors/normalizePath.ts b/shared/normalizePath.ts similarity index 100% rename from src/ipc/processors/normalizePath.ts rename to shared/normalizePath.ts diff --git a/shared/tsc_types.ts b/shared/tsc_types.ts new file mode 100644 index 0000000..b825ce5 --- /dev/null +++ b/shared/tsc_types.ts @@ -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; +} diff --git a/src/__tests__/chat_stream_handlers.test.ts b/src/__tests__/chat_stream_handlers.test.ts index d124028..eae5200 100644 --- a/src/__tests__/chat_stream_handlers.test.ts +++ b/src/__tests__/chat_stream_handlers.test.ts @@ -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, diff --git a/src/components/preview_panel/PreviewPanel.tsx b/src/components/preview_panel/PreviewPanel.tsx index 893ea0b..73a5a9a 100644 --- a/src/components/preview_panel/PreviewPanel.tsx +++ b/src/components/preview_panel/PreviewPanel.tsx @@ -198,9 +198,9 @@ const PreviewHeader = ({
- Clear Preview Data + Clear Cache - Clears cookies and local storage for the app preview + Clears cookies and local storage and other app cache
diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index 7938d2d..ad22f04 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -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, diff --git a/src/ipc/handlers/chat_stream_handlers.ts b/src/ipc/handlers/chat_stream_handlers.ts index 5dd6bb2..99f1444 100644 --- a/src/ipc/handlers/chat_stream_handlers.ts +++ b/src/ipc/handlers/chat_stream_handlers.ts @@ -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 = AsyncIterable & ReadableStream; @@ -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({ diff --git a/src/ipc/handlers/proposal_handlers.ts b/src/ipc/handlers/proposal_handlers.ts index 6a8732a..c207b4b 100644 --- a/src/ipc/handlers/proposal_handlers.ts +++ b/src/ipc/handlers/proposal_handlers.ts @@ -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 { diff --git a/src/ipc/handlers/session_handlers.ts b/src/ipc/handlers/session_handlers.ts index 6947575..5cad676 100644 --- a/src/ipc/handlers/session_handlers.ts +++ b/src/ipc/handlers/session_handlers.ts @@ -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 + } }); }; diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index d4c18ce..81478eb 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -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[]; -} diff --git a/src/ipc/processors/response_processor.ts b/src/ipc/processors/response_processor.ts index 5485485..b3e4668 100644 --- a/src/ipc/processors/response_processor.ts +++ b/src/ipc/processors/response_processor.ts @@ -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 = /]*)>([\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 tag without a valid 'path' attribute:", - match[0], - ); - } - } - return tags; -} - -export function getDyadRenameTags(fullResponse: string): { - from: string; - to: string; -}[] { - const dyadRenameRegex = - /]*>([\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 = - /]*>([\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>/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 = - /([\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 = - /]*)>([\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>/g; - let match; - const commands: string[] = []; - - while ((match = dyadCommandRegex.exec(fullResponse)) !== null) { - commands.push(match[1]); - } - - return commands; -} - interface Output { message: string; error: unknown; diff --git a/src/ipc/processors/tsc.ts b/src/ipc/processors/tsc.ts index ab567d5..fd116bf 100644 --- a/src/ipc/processors/tsc.ts +++ b/src/ipc/processors/tsc.ts @@ -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 { - // 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 { - return runSingleProject(ts, appPath, tsconfigPath, vfs); -} - -async function runSingleProject( - ts: typeof import("typescript"), - appPath: string, - tsconfigPath: string, - vfs: SyncVirtualFileSystem, -): Promise { - // 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); + }); } diff --git a/src/ipc/utils/dyad_tag_parser.ts b/src/ipc/utils/dyad_tag_parser.ts new file mode 100644 index 0000000..1f1a691 --- /dev/null +++ b/src/ipc/utils/dyad_tag_parser.ts @@ -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 = /]*)>([\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 tag without a valid 'path' attribute:", + match[0], + ); + } + } + return tags; +} + +export function getDyadRenameTags(fullResponse: string): { + from: string; + to: string; +}[] { + const dyadRenameRegex = + /]*>([\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 = + /]*>([\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>/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 = + /([\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 = + /]*)>([\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>/g; + let match; + const commands: string[] = []; + + while ((match = dyadCommandRegex.exec(fullResponse)) !== null) { + commands.push(match[1]); + } + + return commands; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index fc20de7..82c5a7a 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -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; +} diff --git a/src/paths/paths.ts b/src/paths/paths.ts index 932feb8..2e955d8 100644 --- a/src/paths/paths.ts +++ b/src/paths/paths.ts @@ -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 diff --git a/src/utils/codebase.ts b/src/utils/codebase.ts index 572088f..95611d0 100644 --- a/src/utils/codebase.ts +++ b/src/utils/codebase.ts @@ -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"); diff --git a/tsconfig.app.json b/tsconfig.app.json index 3f95d87..2367590 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -27,6 +27,6 @@ "@/*": ["./src/*"] } }, - "include": ["src", "e2e-tests"], + "include": ["src", "e2e-tests", "shared"], "exclude": ["e2e-tests/fixtures"] } diff --git a/vite.worker.config.mts b/vite.worker.config.mts new file mode 100644 index 0000000..b6cf77a --- /dev/null +++ b/vite.worker.config.mts @@ -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, + }, +}); diff --git a/workers/tsc/tsc_worker.ts b/workers/tsc/tsc_worker.ts new file mode 100644 index 0000000..89fba1d --- /dev/null +++ b/workers/tsc/tsc_worker.ts @@ -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 { + 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 { + // 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 { + 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, "/"); +} diff --git a/workers/tsc/tsconfig.json b/workers/tsc/tsconfig.json new file mode 100644 index 0000000..1afb4cf --- /dev/null +++ b/workers/tsc/tsconfig.json @@ -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"] +}