Graduate file editing from experimental (#599)

This commit is contained in:
Will Chen
2025-07-08 11:39:46 -07:00
committed by GitHub
parent dfdd267f53
commit a1aee5c2b8
9 changed files with 6041 additions and 42 deletions

View File

@@ -0,0 +1,84 @@
import { test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
import fs from "fs";
import path from "path";
test("edit code", async ({ po }) => {
const editedFilePath = path.join("src", "components", "made-with-dyad.tsx");
await po.sendPrompt("foo");
const appPath = await po.getCurrentAppPath();
await po.clickTogglePreviewPanel();
await po.selectPreviewMode("code");
await po.page.getByText("made-with-dyad.tsx").click();
await po.page
.getByRole("code")
.locator("div")
.filter({ hasText: "export const" })
.nth(4)
.click();
await po.page
.getByRole("textbox", { name: "Editor content" })
.fill("export const MadeWithDyad = ;");
// Save the file
await po.page.getByTestId("save-file-button").click();
// Expect toast to be visible
await expect(po.page.getByText("File saved")).toBeVisible();
// We are NOT snapshotting the app files because the Monaco UI edit
// is not deterministic.
const editedFile = fs.readFileSync(
path.join(appPath, editedFilePath),
"utf8",
);
expect(editedFile).toContain("export const MadeWithDyad = ;");
});
test("edit code edits the right file", async ({ po }) => {
const editedFilePath = path.join("src", "components", "made-with-dyad.tsx");
const robotsFilePath = path.join("public", "robots.txt");
await po.sendPrompt("foo");
const appPath = await po.getCurrentAppPath();
const originalRobotsFile = fs.readFileSync(
path.join(appPath, robotsFilePath),
"utf8",
);
await po.clickTogglePreviewPanel();
await po.selectPreviewMode("code");
await po.page.getByText("made-with-dyad.tsx").click();
await po.page
.getByRole("code")
.locator("div")
.filter({ hasText: "export const" })
.nth(4)
.click();
await po.page
.getByRole("textbox", { name: "Editor content" })
.fill("export const MadeWithDyad = ;");
// Save the file by switching files
await po.page.getByText("robots.txt").click();
// Expect toast to be visible
await expect(po.page.getByText("File saved")).toBeVisible();
// We are NOT snapshotting the app files because the Monaco UI edit
// is not deterministic.
const editedFile = fs.readFileSync(
path.join(appPath, editedFilePath),
"utf8",
);
expect(editedFile).toContain("export const MadeWithDyad = ;");
// Make sure the robots.txt file is not edited
const editedRobotsFile = fs.readFileSync(
path.join(appPath, robotsFilePath),
"utf8",
);
expect(editedRobotsFile).toEqual(originalRobotsFile);
});

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,19 @@ import React, { useState, useRef, useEffect } from "react";
import Editor, { OnMount } from "@monaco-editor/react";
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
import { useTheme } from "@/contexts/ThemeContext";
import { ChevronRight, Circle } from "lucide-react";
import { ChevronRight, Circle, Save } from "lucide-react";
import "@/components/chat/monaco";
import { IpcClient } from "@/ipc/ipc_client";
import { showError, showSuccess, showWarning } from "@/lib/toast";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useQueryClient } from "@tanstack/react-query";
import { useSettings } from "@/hooks/useSettings";
import { showError } from "@/lib/toast";
import { useCheckProblems } from "@/hooks/useCheckProblems";
interface FileEditorProps {
appId: number | null;
@@ -16,9 +24,16 @@ interface FileEditorProps {
interface BreadcrumbProps {
path: string;
hasUnsavedChanges: boolean;
onSave: () => void;
isSaving: boolean;
}
const Breadcrumb: React.FC<BreadcrumbProps> = ({ path, hasUnsavedChanges }) => {
const Breadcrumb: React.FC<BreadcrumbProps> = ({
path,
hasUnsavedChanges,
onSave,
isSaving,
}) => {
const segments = path.split("/").filter(Boolean);
return (
@@ -39,7 +54,24 @@ const Breadcrumb: React.FC<BreadcrumbProps> = ({ path, hasUnsavedChanges }) => {
</React.Fragment>
))}
</div>
<div className="flex-shrink-0 ml-2">
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={onSave}
disabled={!hasUnsavedChanges || isSaving}
className="h-6 w-6 p-0"
data-testid="save-file-button"
>
<Save size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>
{hasUnsavedChanges ? "Save changes" : "No unsaved changes"}
</TooltipContent>
</Tooltip>
{hasUnsavedChanges && (
<Circle
size={8}
@@ -56,10 +88,10 @@ const Breadcrumb: React.FC<BreadcrumbProps> = ({ path, hasUnsavedChanges }) => {
export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
const { content, loading, error } = useLoadAppFile(appId, filePath);
const { theme } = useTheme();
const { settings } = useSettings();
const [value, setValue] = useState<string | undefined>(undefined);
const [displayUnsavedChanges, setDisplayUnsavedChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const { settings } = useSettings();
// Use refs for values that need to be current in event handlers
const originalValueRef = useRef<string | undefined>(undefined);
const editorRef = useRef<any>(null);
@@ -67,6 +99,9 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
const needsSaveRef = useRef<boolean>(false);
const currentValueRef = useRef<string | undefined>(undefined);
const queryClient = useQueryClient();
const { checkProblems } = useCheckProblems(appId);
// Update state when content loads
useEffect(() => {
if (content !== null) {
@@ -75,6 +110,7 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
currentValueRef.current = content;
needsSaveRef.current = false;
setDisplayUnsavedChanges(false);
setIsSaving(false);
}
}, [content, filePath]);
@@ -125,9 +161,23 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
try {
isSavingRef.current = true;
setIsSaving(true);
const ipcClient = IpcClient.getInstance();
await ipcClient.editAppFile(appId, filePath, currentValueRef.current);
const { warning } = await ipcClient.editAppFile(
appId,
filePath,
currentValueRef.current,
);
await queryClient.invalidateQueries({ queryKey: ["versions", appId] });
if (settings?.enableAutoFixProblems) {
checkProblems();
}
if (warning) {
showWarning(warning);
} else {
showSuccess("File saved");
}
originalValueRef.current = currentValueRef.current;
needsSaveRef.current = false;
@@ -136,6 +186,7 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
showError(error);
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
};
@@ -182,7 +233,12 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
return (
<div className="h-full flex flex-col">
<Breadcrumb path={filePath} hasUnsavedChanges={displayUnsavedChanges} />
<Breadcrumb
path={filePath}
hasUnsavedChanges={displayUnsavedChanges}
onSave={saveFile}
isSaving={isSaving}
/>
<div className="flex-1 overflow-hidden">
<Editor
height="100%"
@@ -199,7 +255,6 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
fontFamily: "monospace",
fontSize: 13,
lineNumbers: "on",
readOnly: !settings?.experiments?.enableFileEditing,
}}
/>
</div>

View File

@@ -7,6 +7,7 @@ import type {
CreateAppParams,
RenameBranchParams,
CopyAppParams,
EditAppFileReturnType,
} from "../ipc_types";
import fs from "node:fs";
import path from "node:path";
@@ -32,7 +33,10 @@ import fixPath from "fix-path";
import killPort from "kill-port";
import util from "util";
import log from "electron-log";
import { getSupabaseProjectName } from "../../supabase_admin/supabase_management_client";
import {
deploySupabaseFunctions,
getSupabaseProjectName,
} from "../../supabase_admin/supabase_management_client";
import { createLoggedHandler } from "./safe_handle";
import { getLanguageModelProviders } from "../shared/language_model_helpers";
import { startProxy } from "../utils/start_proxy_server";
@@ -41,6 +45,7 @@ import { createFromTemplate } from "./createFromTemplate";
import { gitCommit } from "../utils/git_utils";
import { safeSend } from "../utils/safe_sender";
import { normalizePath } from "../../../shared/normalizePath";
import { isServerFunction } from "@/supabase_admin/supabase_utils";
async function copyDir(
source: string,
@@ -604,7 +609,7 @@ export function registerAppHandlers() {
filePath,
content,
}: { appId: number; filePath: string; content: string },
): Promise<void> => {
): Promise<EditAppFileReturnType> => {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
@@ -641,12 +646,26 @@ export function registerAppHandlers() {
message: `Updated ${filePath}`,
});
}
return;
} catch (error: any) {
logger.error(`Error writing file ${filePath} for app ${appId}:`, error);
throw new Error(`Failed to write file: ${error.message}`);
}
if (isServerFunction(filePath) && app.supabaseProjectId) {
try {
await deploySupabaseFunctions({
supabaseProjectId: app.supabaseProjectId,
functionName: path.basename(path.dirname(filePath)),
content: content,
});
} catch (error) {
logger.error(`Error deploying Supabase function ${filePath}:`, error);
return {
warning: `File saved, but failed to deploy Supabase function: ${filePath}: ${error}`,
};
}
}
return {};
},
);

View File

@@ -37,6 +37,7 @@ import type {
ComponentSelection,
AppUpgrade,
ProblemReport,
EditAppFileReturnType,
} from "./ipc_types";
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
import { showError } from "@/lib/toast";
@@ -220,8 +221,8 @@ export class IpcClient {
appId: number,
filePath: string,
content: string,
): Promise<void> {
await this.ipcRenderer.invoke("edit-app-file", {
): Promise<EditAppFileReturnType> {
return this.ipcRenderer.invoke("edit-app-file", {
appId,
filePath,
content,

View File

@@ -248,3 +248,7 @@ export interface AppUpgrade {
manualUpgradeUrl: string;
isNeeded: boolean;
}
export interface EditAppFileReturnType {
warning?: string;
}

View File

@@ -4,6 +4,9 @@ import path from "node:path";
import fsExtra from "fs-extra";
import { generateCuteAppName } from "../../lib/utils";
// Directories to exclude when scanning files
const EXCLUDED_DIRS = ["node_modules", ".git", ".next"];
/**
* Recursively gets all files in a directory, excluding node_modules and .git
* @param dir The directory to scan
@@ -22,8 +25,8 @@ export function getFilesRecursively(dir: string, baseDir: string): string[] {
const res = path.join(dir, dirent.name);
if (dirent.isDirectory()) {
// For directories, concat the results of recursive call
// Exclude node_modules and .git directories
if (dirent.name !== "node_modules" && dirent.name !== ".git") {
// Exclude specified directories
if (!EXCLUDED_DIRS.includes(dirent.name)) {
files.push(...getFilesRecursively(res, baseDir));
}
} else {

View File

@@ -93,7 +93,7 @@ export type Supabase = z.infer<typeof SupabaseSchema>;
export const ExperimentsSchema = z.object({
// Deprecated
enableSupabaseIntegration: z.boolean().describe("DEPRECATED").optional(),
enableFileEditing: z.boolean().optional(),
enableFileEditing: z.boolean().describe("DEPRECATED").optional(),
});
export type Experiments = z.infer<typeof ExperimentsSchema>;

View File

@@ -150,30 +150,6 @@ export default function SettingsPage() {
.
</div>
</div>
{/* Enable File Editing Experiment */}
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Switch
id="enable-file-editing"
checked={!!settings?.experiments?.enableFileEditing}
onCheckedChange={(checked) =>
updateSettings({
experiments: {
...settings?.experiments,
enableFileEditing: checked,
},
})
}
/>
<Label htmlFor="enable-file-editing">
Enable File Editing
</Label>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
File editing is not reliable and requires you to manually
commit changes and update Supabase edge functions.
</div>
</div>
</div>
</div>