Click to edit UI (#385)

- [x] add e2e test - happy case (make sure it clears selection and next
prompt is empty, and preview is cleared); de-selection case
- [x] shim - old & new file
- [x] upgrade path
- [x] add docs
- [x] add try-catch to parser script
- [x] make it work for next.js
- [x] extract npm package
- [x] make sure plugin doesn't apply in prod
This commit is contained in:
Will Chen
2025-06-11 13:05:27 -07:00
committed by GitHub
parent b86738f3ab
commit c1aa6803ce
79 changed files with 12896 additions and 113 deletions

View File

@@ -442,6 +442,8 @@ export function registerAppHandlers() {
const appPath = getDyadAppPath(app.path);
try {
// Kill any orphaned process on port 32100 (in case previous run left it)
await killProcessOnPort(32100);
await executeApp({ appPath, appId, event });
return;

View File

@@ -0,0 +1,179 @@
import { createLoggedHandler } from "./safe_handle";
import log from "electron-log";
import { AppUpgrade } from "../ipc_types";
import { db } from "../../db";
import { apps } from "../../db/schema";
import { eq } from "drizzle-orm";
import { getDyadAppPath } from "../../paths/paths";
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
const logger = log.scope("app_upgrade_handlers");
const handle = createLoggedHandler(logger);
const availableUpgrades: Omit<AppUpgrade, "isNeeded">[] = [
{
id: "component-tagger",
title: "Enable select component to edit",
description:
"Installs the Dyad component tagger Vite plugin and its dependencies.",
manualUpgradeUrl: "https://dyad.sh/docs/upgrades/select-component",
},
];
async function getApp(appId: number) {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error(`App with id ${appId} not found`);
}
return app;
}
function isComponentTaggerUpgradeNeeded(appPath: string): boolean {
const viteConfigPathJs = path.join(appPath, "vite.config.js");
const viteConfigPathTs = path.join(appPath, "vite.config.ts");
let viteConfigPath;
if (fs.existsSync(viteConfigPathTs)) {
viteConfigPath = viteConfigPathTs;
} else if (fs.existsSync(viteConfigPathJs)) {
viteConfigPath = viteConfigPathJs;
} else {
return false;
}
try {
const viteConfigContent = fs.readFileSync(viteConfigPath, "utf-8");
return !viteConfigContent.includes("@dyad-sh/react-vite-component-tagger");
} catch (e) {
logger.error("Error reading vite config", e);
return false;
}
}
async function applyComponentTagger(appPath: string) {
const viteConfigPathJs = path.join(appPath, "vite.config.js");
const viteConfigPathTs = path.join(appPath, "vite.config.ts");
let viteConfigPath;
if (fs.existsSync(viteConfigPathTs)) {
viteConfigPath = viteConfigPathTs;
} else if (fs.existsSync(viteConfigPathJs)) {
viteConfigPath = viteConfigPathJs;
} else {
throw new Error("Could not find vite.config.js or vite.config.ts");
}
let content = await fs.promises.readFile(viteConfigPath, "utf-8");
// Add import statement if not present
if (
!content.includes(
"import dyadComponentTagger from '@dyad-sh/react-vite-component-tagger';",
)
) {
// Add it after the last import statement
const lines = content.split("\n");
let lastImportIndex = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].startsWith("import ")) {
lastImportIndex = i;
break;
}
}
lines.splice(
lastImportIndex + 1,
0,
"import dyadComponentTagger from '@dyad-sh/react-vite-component-tagger';",
);
content = lines.join("\n");
}
// Add plugin to plugins array
if (content.includes("plugins: [")) {
if (!content.includes("dyadComponentTagger()")) {
content = content.replace(
"plugins: [",
"plugins: [dyadComponentTagger(), ",
);
}
} else {
throw new Error(
"Could not find `plugins: [` in vite.config.ts. Manual installation required.",
);
}
await fs.promises.writeFile(viteConfigPath, content);
// Install the dependency
return new Promise<void>((resolve, reject) => {
logger.info("Installing component-tagger dependency");
const process = spawn(
"pnpm add -D @dyad-sh/react-vite-component-tagger || npm install --save-dev --legacy-peer-deps @dyad-sh/react-vite-component-tagger",
{
cwd: appPath,
shell: true,
stdio: "pipe",
},
);
process.stdout?.on("data", (data) => logger.info(data.toString()));
process.stderr?.on("data", (data) => logger.error(data.toString()));
process.on("close", (code) => {
if (code === 0) {
logger.info("component-tagger dependency installed successfully");
resolve();
} else {
logger.error(`Failed to install dependency, exit code ${code}`);
reject(new Error("Failed to install dependency"));
}
});
process.on("error", (err) => {
logger.error("Failed to spawn pnpm", err);
reject(err);
});
});
}
export function registerAppUpgradeHandlers() {
handle(
"get-app-upgrades",
async (_, { appId }: { appId: number }): Promise<AppUpgrade[]> => {
const app = await getApp(appId);
const appPath = getDyadAppPath(app.path);
const upgradesWithStatus = availableUpgrades.map((upgrade) => {
let isNeeded = false;
if (upgrade.id === "component-tagger") {
isNeeded = isComponentTaggerUpgradeNeeded(appPath);
}
return { ...upgrade, isNeeded };
});
return upgradesWithStatus;
},
);
handle(
"execute-app-upgrade",
async (_, { appId, upgradeId }: { appId: number; upgradeId: string }) => {
if (!upgradeId) {
throw new Error("upgradeId is required");
}
const app = await getApp(appId);
const appPath = getDyadAppPath(app.path);
if (upgradeId === "component-tagger") {
await applyComponentTagger(appPath);
} else {
throw new Error(`Unknown upgrade id: ${upgradeId}`);
}
},
);
}

View File

@@ -160,7 +160,45 @@ export function registerChatStreamHandlers() {
}
// Add user message to database with attachment info
const userPrompt = req.prompt + (attachmentInfo ? attachmentInfo : "");
let userPrompt = req.prompt + (attachmentInfo ? attachmentInfo : "");
if (req.selectedComponent) {
let componentSnippet = "[component snippet not available]";
try {
const componentFileContent = await readFile(
path.join(
getDyadAppPath(chat.app.path),
req.selectedComponent.relativePath,
),
"utf8",
);
const lines = componentFileContent.split("\n");
const selectedIndex = req.selectedComponent.lineNumber - 1;
// Let's get one line before and three after for context.
const startIndex = Math.max(0, selectedIndex - 1);
const endIndex = Math.min(lines.length, selectedIndex + 4);
const snippetLines = lines.slice(startIndex, endIndex);
const selectedLineInSnippetIndex = selectedIndex - startIndex;
if (snippetLines[selectedLineInSnippetIndex]) {
snippetLines[selectedLineInSnippetIndex] =
`${snippetLines[selectedLineInSnippetIndex]} // <-- EDIT HERE`;
}
componentSnippet = snippetLines.join("\n");
} catch (err) {
logger.error(`Error reading selected component file content: ${err}`);
}
userPrompt += `\n\nSelected component: ${req.selectedComponent.name} (file: ${req.selectedComponent.relativePath})
Snippet:
\`\`\`
${componentSnippet}
\`\`\`
`;
}
await db
.insert(messages)
.values({
@@ -228,7 +266,16 @@ export function registerChatStreamHandlers() {
try {
const out = await extractCodebase({
appPath,
chatContext: validateChatContext(updatedChat.app.chatContext),
chatContext: req.selectedComponent
? {
contextPaths: [
{
globPath: req.selectedComponent.relativePath,
},
],
smartContextAutoIncludes: [],
}
: validateChatContext(updatedChat.app.chatContext),
});
codebaseInfo = out.formattedOutput;
files = out.files;