Unverified 提交 e3f9c348 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Support import app inplace (#2189)

<!-- CURSOR_SUMMARY --> > [!NOTE] > Enables importing apps in-place without copying into `dyad-apps`. > > - UI: `ImportAppDialog` adds a "Copy to the dyad-apps folder" checkbox (checked by default), wires `skipCopy` through name checks, import action, and re-checks on toggle; resets on clear > - IPC: Extends `ImportAppParams` and `check-app-name` to accept `skipCopy`; `import-app` handler respects in-place import (skips copy, stores absolute path, initializes git if needed) and keeps existing copy/duplicate checks when copying > - Types/Client: Updates `ipc_types` and `IpcClient.checkAppName/importApp` to include optional `skipCopy` > - Tests: Adds e2e test and snapshot for importing without copying > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 33f58b060664ab10906dfdbf8815839d787f909d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Add in-place app import. Users can now register an existing folder without copying it into dyad-apps, reducing disk usage and speeding up import. - **New Features** - Import dialog: added “Copy to the dyad-apps folder” checkbox (on by default). When unchecked, we import in place and pass skipCopy to the backend. Control resets on dialog close. - Import handler: if skipCopy is true, do not copy; store the absolute source path; initialize a git repo in the chosen path if missing. If copying, keep existing duplicate-name checks and copy behavior. Updated ImportAppParams to include optional skipCopy. - Added e2e test and snapshot for importing without copying. <sup>Written for commit 7df1df626fc4f2a2c6e58c667496590ac68da922. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. -->
上级 ea229c75
import path from "path";
import os from "os";
import fs from "fs";
import { testSkipIfWindows } from "./helpers/test_helper";
import * as eph from "electron-playwright-helpers";
testSkipIfWindows("import app without copying to dyad-apps", async ({ po }) => {
await po.setUp();
// Copy fixture to temp directory to avoid modifying original fixture
const fixtureSource = path.join(
__dirname,
"fixtures",
"import-app",
"minimal",
);
const tempDir = path.join(os.tmpdir(), `dyad-import-test-${Date.now()}`);
fs.cpSync(fixtureSource, tempDir, { recursive: true });
await po.page.getByRole("button", { name: "Import App" }).click();
await eph.stubDialog(po.electronApp, "showOpenDialog", {
filePaths: [tempDir],
});
await po.page.getByRole("button", { name: "Select Folder" }).click();
// Uncheck the copy checkbox
await po.page.getByRole("checkbox", { name: /Copy to the/ }).uncheck();
// Fill in app name (folder basename is used by default)
await po.page
.getByRole("textbox", { name: "Enter new app name" })
.fill("minimal-in-place");
await po.page.getByRole("button", { name: "Import" }).click();
// Verify import succeeded
await po.snapshotPreview();
});
......@@ -15,6 +15,7 @@ import { Folder, X, Loader2, Info } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@radix-ui/react-label";
import { useNavigate } from "@tanstack/react-router";
import { useStreamChat } from "@/hooks/useStreamChat";
......@@ -52,6 +53,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
const [isCheckingName, setIsCheckingName] = useState<boolean>(false);
const [installCommand, setInstallCommand] = useState("");
const [startCommand, setStartCommand] = useState("");
const [copyToDyadApps, setCopyToDyadApps] = useState(true);
const navigate = useNavigate();
const { streamMessage } = useStreamChat({ hasChatId: false });
const { refreshApps } = useLoadApps();
......@@ -78,6 +80,13 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
}
}, [isOpen, isAuthenticated]);
// Re-check app name when copyToDyadApps changes
useEffect(() => {
if (customAppName.trim() && selectedPath) {
checkAppName({ name: customAppName, skipCopy: !copyToDyadApps });
}
}, [copyToDyadApps]);
const fetchRepos = async () => {
setLoading(true);
try {
......@@ -200,11 +209,18 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
}
};
const checkAppName = async (name: string): Promise<void> => {
const checkAppName = async ({
name,
skipCopy,
}: {
name: string;
skipCopy?: boolean;
}): Promise<void> => {
setIsCheckingName(true);
try {
const result = await IpcClient.getInstance().checkAppName({
appName: name,
skipCopy,
});
setNameExists(result.exists);
} catch (error: unknown) {
......@@ -228,7 +244,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
// Use the folder name from the IPC response
setCustomAppName(result.name);
// Check if the app name already exists
await checkAppName(result.name);
await checkAppName({ name: result.name, skipCopy: !copyToDyadApps });
return result;
},
onError: (error: Error) => {
......@@ -244,6 +260,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
appName: customAppName,
installCommand: installCommand || undefined,
startCommand: startCommand || undefined,
skipCopy: !copyToDyadApps,
});
},
onSuccess: async (result) => {
......@@ -284,6 +301,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
setNameExists(false);
setInstallCommand("");
setStartCommand("");
setCopyToDyadApps(true);
};
const handleAppNameChange = async (
......@@ -292,7 +310,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
const newName = e.target.value;
setCustomAppName(newName);
if (newName.trim()) {
await checkAppName(newName);
await checkAppName({ name: newName, skipCopy: !copyToDyadApps });
}
};
......@@ -381,6 +399,27 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="copy-to-dyad-apps"
checked={copyToDyadApps}
onCheckedChange={(checked) =>
setCopyToDyadApps(checked === true)
}
disabled={importAppMutation.isPending}
/>
<label
htmlFor="copy-to-dyad-apps"
className="text-xs sm:text-sm cursor-pointer"
>
Copy to the{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
dyad-apps
</code>{" "}
folder
</label>
</div>
<div className="space-y-2">
{nameExists && (
<p className="text-xs sm:text-sm text-yellow-500">
......
......@@ -45,23 +45,31 @@ export function registerImportHandlers() {
});
// Handler for checking if an app name is already taken
handle("check-app-name", async (_, { appName }: { appName: string }) => {
// Check filesystem
const appPath = getDyadAppPath(appName);
try {
await fs.access(appPath);
return { exists: true };
} catch {
// Path doesn't exist, continue checking database
}
handle(
"check-app-name",
async (
_,
{ appName, skipCopy }: { appName: string; skipCopy?: boolean },
) => {
// Only check filesystem if we're copying to dyad-apps
if (!skipCopy) {
const appPath = getDyadAppPath(appName);
try {
await fs.access(appPath);
return { exists: true };
} catch {
// Path doesn't exist, continue checking database
}
}
// Check database
const existingApp = await db.query.apps.findFirst({
where: eq(apps.name, appName),
});
// Check database
const existingApp = await db.query.apps.findFirst({
where: eq(apps.name, appName),
});
return { exists: !!existingApp };
});
return { exists: !!existingApp };
},
);
// Handler for importing an app
handle(
......@@ -73,6 +81,7 @@ export function registerImportHandlers() {
appName,
installCommand,
startCommand,
skipCopy,
}: ImportAppParams,
): Promise<ImportAppResult> => {
// Validate the source path exists
......@@ -82,49 +91,52 @@ export function registerImportHandlers() {
throw new Error("Source folder does not exist");
}
const destPath = getDyadAppPath(appName);
// Check if the app already exists
const errorMessage = "An app with this name already exists";
try {
await fs.access(destPath);
throw new Error(errorMessage);
} catch (error: any) {
if (error.message === errorMessage) {
throw error;
// Determine the app path based on skipCopy
const appPath = skipCopy ? sourcePath : getDyadAppPath(appName);
if (!skipCopy) {
// Check if the app already exists in dyad-apps
const errorMessage = "An app with this name already exists";
try {
await fs.access(appPath);
throw new Error(errorMessage);
} catch (error: any) {
if (error.message === errorMessage) {
throw error;
}
}
// Copy the app folder to the Dyad apps directory.
// Why not use fs.cp? Because we want stable ordering for
// tests.
await copyDirectoryRecursive(sourcePath, appPath);
}
// Copy the app folder to the Dyad apps directory.
// Why not use fs.cp? Because we want stable ordering for
// tests.
await copyDirectoryRecursive(sourcePath, destPath);
const isGitRepo = await fs
.access(path.join(destPath, ".git"))
.access(path.join(appPath, ".git"))
.then(() => true)
.catch(() => false);
if (!isGitRepo) {
// Initialize git repo and create first commit
await gitInit({ path: destPath, ref: "main" });
await gitInit({ path: appPath, ref: "main" });
// Stage all files
await gitAdd({ path: destPath, filepath: "." });
await gitAdd({ path: appPath, filepath: "." });
// Create initial commit
await gitCommit({
path: destPath,
path: appPath,
message: "Init Dyad app",
});
}
// Create a new app
// Store the full absolute path when skipCopy is true, otherwise store appName
const [app] = await db
.insert(apps)
.values({
name: appName,
// Use the name as the path for now
path: appName,
path: skipCopy ? sourcePath : appName,
installCommand: installCommand ?? null,
startCommand: startCommand ?? null,
})
......
......@@ -1546,6 +1546,7 @@ export class IpcClient {
async checkAppName(params: {
appName: string;
skipCopy?: boolean;
}): Promise<{ exists: boolean }> {
return this.ipcRenderer.invoke("check-app-name", params);
}
......
......@@ -284,6 +284,7 @@ export interface ImportAppParams {
appName: string;
installCommand?: string;
startCommand?: string;
skipCopy?: boolean;
}
export interface CopyAppParams {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论