Graduate file editing from experimental (#599)
This commit is contained in:
84
e2e-tests/edit_code.spec.ts
Normal file
84
e2e-tests/edit_code.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
5857
e2e-tests/snapshots/edit_code.spec.ts_edited-mde-with-dyad.txt
Normal file
5857
e2e-tests/snapshots/edit_code.spec.ts_edited-mde-with-dyad.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,19 @@ import React, { useState, useRef, useEffect } from "react";
|
|||||||
import Editor, { OnMount } from "@monaco-editor/react";
|
import Editor, { OnMount } from "@monaco-editor/react";
|
||||||
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
|
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { ChevronRight, Circle } from "lucide-react";
|
import { ChevronRight, Circle, Save } from "lucide-react";
|
||||||
import "@/components/chat/monaco";
|
import "@/components/chat/monaco";
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
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 { useSettings } from "@/hooks/useSettings";
|
||||||
import { showError } from "@/lib/toast";
|
import { useCheckProblems } from "@/hooks/useCheckProblems";
|
||||||
|
|
||||||
interface FileEditorProps {
|
interface FileEditorProps {
|
||||||
appId: number | null;
|
appId: number | null;
|
||||||
@@ -16,9 +24,16 @@ interface FileEditorProps {
|
|||||||
interface BreadcrumbProps {
|
interface BreadcrumbProps {
|
||||||
path: string;
|
path: string;
|
||||||
hasUnsavedChanges: boolean;
|
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);
|
const segments = path.split("/").filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,7 +54,24 @@ const Breadcrumb: React.FC<BreadcrumbProps> = ({ path, hasUnsavedChanges }) => {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 && (
|
{hasUnsavedChanges && (
|
||||||
<Circle
|
<Circle
|
||||||
size={8}
|
size={8}
|
||||||
@@ -56,10 +88,10 @@ const Breadcrumb: React.FC<BreadcrumbProps> = ({ path, hasUnsavedChanges }) => {
|
|||||||
export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
|
export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
|
||||||
const { content, loading, error } = useLoadAppFile(appId, filePath);
|
const { content, loading, error } = useLoadAppFile(appId, filePath);
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { settings } = useSettings();
|
|
||||||
const [value, setValue] = useState<string | undefined>(undefined);
|
const [value, setValue] = useState<string | undefined>(undefined);
|
||||||
const [displayUnsavedChanges, setDisplayUnsavedChanges] = useState(false);
|
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
|
// Use refs for values that need to be current in event handlers
|
||||||
const originalValueRef = useRef<string | undefined>(undefined);
|
const originalValueRef = useRef<string | undefined>(undefined);
|
||||||
const editorRef = useRef<any>(null);
|
const editorRef = useRef<any>(null);
|
||||||
@@ -67,6 +99,9 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
|
|||||||
const needsSaveRef = useRef<boolean>(false);
|
const needsSaveRef = useRef<boolean>(false);
|
||||||
const currentValueRef = useRef<string | undefined>(undefined);
|
const currentValueRef = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { checkProblems } = useCheckProblems(appId);
|
||||||
|
|
||||||
// Update state when content loads
|
// Update state when content loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (content !== null) {
|
if (content !== null) {
|
||||||
@@ -75,6 +110,7 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
|
|||||||
currentValueRef.current = content;
|
currentValueRef.current = content;
|
||||||
needsSaveRef.current = false;
|
needsSaveRef.current = false;
|
||||||
setDisplayUnsavedChanges(false);
|
setDisplayUnsavedChanges(false);
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [content, filePath]);
|
}, [content, filePath]);
|
||||||
|
|
||||||
@@ -125,9 +161,23 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
isSavingRef.current = true;
|
isSavingRef.current = true;
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
const ipcClient = IpcClient.getInstance();
|
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;
|
originalValueRef.current = currentValueRef.current;
|
||||||
needsSaveRef.current = false;
|
needsSaveRef.current = false;
|
||||||
@@ -136,6 +186,7 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
|
|||||||
showError(error);
|
showError(error);
|
||||||
} finally {
|
} finally {
|
||||||
isSavingRef.current = false;
|
isSavingRef.current = false;
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,7 +233,12 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<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">
|
<div className="flex-1 overflow-hidden">
|
||||||
<Editor
|
<Editor
|
||||||
height="100%"
|
height="100%"
|
||||||
@@ -199,7 +255,6 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
|
|||||||
fontFamily: "monospace",
|
fontFamily: "monospace",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
lineNumbers: "on",
|
lineNumbers: "on",
|
||||||
readOnly: !settings?.experiments?.enableFileEditing,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
CreateAppParams,
|
CreateAppParams,
|
||||||
RenameBranchParams,
|
RenameBranchParams,
|
||||||
CopyAppParams,
|
CopyAppParams,
|
||||||
|
EditAppFileReturnType,
|
||||||
} from "../ipc_types";
|
} from "../ipc_types";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -32,7 +33,10 @@ import fixPath from "fix-path";
|
|||||||
import killPort from "kill-port";
|
import killPort from "kill-port";
|
||||||
import util from "util";
|
import util from "util";
|
||||||
import log from "electron-log";
|
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 { createLoggedHandler } from "./safe_handle";
|
||||||
import { getLanguageModelProviders } from "../shared/language_model_helpers";
|
import { getLanguageModelProviders } from "../shared/language_model_helpers";
|
||||||
import { startProxy } from "../utils/start_proxy_server";
|
import { startProxy } from "../utils/start_proxy_server";
|
||||||
@@ -41,6 +45,7 @@ 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 "../../../shared/normalizePath";
|
import { normalizePath } from "../../../shared/normalizePath";
|
||||||
|
import { isServerFunction } from "@/supabase_admin/supabase_utils";
|
||||||
|
|
||||||
async function copyDir(
|
async function copyDir(
|
||||||
source: string,
|
source: string,
|
||||||
@@ -604,7 +609,7 @@ export function registerAppHandlers() {
|
|||||||
filePath,
|
filePath,
|
||||||
content,
|
content,
|
||||||
}: { appId: number; filePath: string; content: string },
|
}: { appId: number; filePath: string; content: string },
|
||||||
): Promise<void> => {
|
): Promise<EditAppFileReturnType> => {
|
||||||
const app = await db.query.apps.findFirst({
|
const app = await db.query.apps.findFirst({
|
||||||
where: eq(apps.id, appId),
|
where: eq(apps.id, appId),
|
||||||
});
|
});
|
||||||
@@ -641,12 +646,26 @@ export function registerAppHandlers() {
|
|||||||
message: `Updated ${filePath}`,
|
message: `Updated ${filePath}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`Error writing file ${filePath} for app ${appId}:`, error);
|
logger.error(`Error writing file ${filePath} for app ${appId}:`, error);
|
||||||
throw new Error(`Failed to write file: ${error.message}`);
|
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 {};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
ComponentSelection,
|
ComponentSelection,
|
||||||
AppUpgrade,
|
AppUpgrade,
|
||||||
ProblemReport,
|
ProblemReport,
|
||||||
|
EditAppFileReturnType,
|
||||||
} from "./ipc_types";
|
} from "./ipc_types";
|
||||||
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
||||||
import { showError } from "@/lib/toast";
|
import { showError } from "@/lib/toast";
|
||||||
@@ -220,8 +221,8 @@ export class IpcClient {
|
|||||||
appId: number,
|
appId: number,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
content: string,
|
content: string,
|
||||||
): Promise<void> {
|
): Promise<EditAppFileReturnType> {
|
||||||
await this.ipcRenderer.invoke("edit-app-file", {
|
return this.ipcRenderer.invoke("edit-app-file", {
|
||||||
appId,
|
appId,
|
||||||
filePath,
|
filePath,
|
||||||
content,
|
content,
|
||||||
|
|||||||
@@ -248,3 +248,7 @@ export interface AppUpgrade {
|
|||||||
manualUpgradeUrl: string;
|
manualUpgradeUrl: string;
|
||||||
isNeeded: boolean;
|
isNeeded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EditAppFileReturnType {
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import path from "node:path";
|
|||||||
import fsExtra from "fs-extra";
|
import fsExtra from "fs-extra";
|
||||||
import { generateCuteAppName } from "../../lib/utils";
|
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
|
* Recursively gets all files in a directory, excluding node_modules and .git
|
||||||
* @param dir The directory to scan
|
* @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);
|
const res = path.join(dir, dirent.name);
|
||||||
if (dirent.isDirectory()) {
|
if (dirent.isDirectory()) {
|
||||||
// For directories, concat the results of recursive call
|
// For directories, concat the results of recursive call
|
||||||
// Exclude node_modules and .git directories
|
// Exclude specified directories
|
||||||
if (dirent.name !== "node_modules" && dirent.name !== ".git") {
|
if (!EXCLUDED_DIRS.includes(dirent.name)) {
|
||||||
files.push(...getFilesRecursively(res, baseDir));
|
files.push(...getFilesRecursively(res, baseDir));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export type Supabase = z.infer<typeof SupabaseSchema>;
|
|||||||
export const ExperimentsSchema = z.object({
|
export const ExperimentsSchema = z.object({
|
||||||
// Deprecated
|
// Deprecated
|
||||||
enableSupabaseIntegration: z.boolean().describe("DEPRECATED").optional(),
|
enableSupabaseIntegration: z.boolean().describe("DEPRECATED").optional(),
|
||||||
enableFileEditing: z.boolean().optional(),
|
enableFileEditing: z.boolean().describe("DEPRECATED").optional(),
|
||||||
});
|
});
|
||||||
export type Experiments = z.infer<typeof ExperimentsSchema>;
|
export type Experiments = z.infer<typeof ExperimentsSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -150,30 +150,6 @@ export default function SettingsPage() {
|
|||||||
.
|
.
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user