Unverified 提交 2dc478cd authored 作者: Adeniji Adekunle James's avatar Adeniji Adekunle James 提交者: GitHub

Pick different location for storing dyad-apps (#2000)

To do list: Clean up and try to reuse the existing fnc to avoid altering many files. Closes #1991 <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Let users pick where each app is stored and move apps safely. Adds a simple “Change location” flow, supports absolute paths, and shows each app’s resolved path. - **New Features** - Paths: getDyadAppPath accepts absolute paths; apps include resolvedPath; removed global appBasePath from list-apps and state. - UI: App Details shows full path with “Show in folder” and a “Change location” dialog. We stop the app, copy without node_modules, check conflicts, and update DB to an absolute path. - IPC: Added select-app-location and change-app-location. Rename blocks absolute paths; copy/rename exclude node_modules; conflict checks use resolved paths. - Tests: Added e2e test for moving an app to a custom folder. <sup>Written for commit 8a417fb2b5a28efebed2778d8e5715180dfd6bc7. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces per-app folder relocation with safety checks and exposes each app’s resolved path for clarity. > > - **IPC/Backend**: Adds `select-app-location` and `change-app-location` to move app folders (stop app, validate absolute destination, conflict checks via `getDyadAppPath`, copy without `node_modules`, cleanup/rollback, store absolute `path`). `list-apps`/`get-app` now include `resolvedPath`. `getDyadAppPath` accepts absolute paths. `rename-app` forbids absolute targets, preserves dir for existing absolute paths, adds robust conflict checks and cleanup; copy/rename exclude `node_modules`. > - **UI**: App Details shows `resolvedPath`, adds "Move folder" dialog and "Show in folder" action; removes reliance on `appBasePath`. > - **Client/Preload**: `IpcClient` methods for new handlers; preload whitelists new channels. > - **Tests**: New e2e covers moving an app and verifying path update. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8a417fb2b5a28efebed2778d8e5715180dfd6bc7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: 's avatarWill Chen <willchen90@gmail.com>
上级 ba0de4ee
import fs from "fs";
import path from "path";
import { expect } from "@playwright/test";
import { test, Timeout } from "./helpers/test_helper";
import * as eph from "electron-playwright-helpers";
test("move app to a custom storage location", async ({ po }) => {
await po.setUp();
await po.sendPrompt("hello");
const appName = await po.getCurrentAppName();
const originalPath = await po.getCurrentAppPath();
await po.getTitleBarAppNameButton().click();
const newBasePath = path.join(po.userDataDir, "alt-app-storage");
if (!fs.existsSync(newBasePath)) {
fs.mkdirSync(newBasePath, { recursive: true });
}
// Stub the file dialog to return the new base path BEFORE clicking the button
await eph.stubDialog(po.electronApp, "showOpenDialog", {
filePaths: [newBasePath],
});
// Click the overflow menu and then "Move folder"
await po.page.getByTestId("app-details-more-options-button").click();
await po.page.getByRole("button", { name: "Move folder" }).click();
// Wait for the dialog to be visible and click "Select Folder" button
const selectFolderButton = po.page
.getByRole("dialog")
.getByRole("button", { name: "Select Folder" });
await expect(selectFolderButton).toBeVisible();
await selectFolderButton.click();
// Wait for the move operation to complete (button shows "Moving..." then dialog closes)
await expect(selectFolderButton).not.toBeVisible({ timeout: Timeout.MEDIUM });
const newAppPath = path.join(newBasePath, appName ?? "");
await expect(async () => {
expect(fs.existsSync(newAppPath)).toBe(true);
expect(fs.existsSync(originalPath)).toBe(false);
await expect(
po.page
.locator("span.text-sm.break-all")
.filter({ hasText: newAppPath })
.first(),
).toBeVisible();
}).toPass();
});
......@@ -5,7 +5,6 @@ import type { UserSettings } from "@/lib/schemas";
export const currentAppAtom = atom<App | null>(null);
export const selectedAppIdAtom = atom<number | null>(null);
export const appsListAtom = atom<App[]>([]);
export const appBasePathAtom = atom<string>("");
export const versionsListAtom = atom<Version[]>([]);
export const previewModeAtom = atom<
"preview" | "code" | "problems" | "configure" | "publish" | "security"
......
import { useState, useEffect, useCallback } from "react";
import { useAtom } from "jotai";
import { appBasePathAtom, appsListAtom } from "@/atoms/appAtoms";
import { appsListAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client";
export function useLoadApps() {
const [apps, setApps] = useAtom(appsListAtom);
const [, setAppBasePath] = useAtom(appBasePathAtom);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
......@@ -15,7 +14,6 @@ export function useLoadApps() {
const ipcClient = IpcClient.getInstance();
const appListResponse = await ipcClient.listApps();
setApps(appListResponse.apps);
setAppBasePath(appListResponse.appBasePath);
setError(null);
} catch (error) {
console.error("Error refreshing apps:", error);
......
......@@ -73,6 +73,8 @@ import type {
SupabaseProject,
DeleteSupabaseOrganizationParams,
SelectNodeFolderResult,
ChangeAppLocationParams,
ChangeAppLocationResult,
ApplyVisualEditingChangesParams,
AnalyseComponentParams,
AgentTool,
......@@ -1316,6 +1318,18 @@ export class IpcClient {
return this.ipcRenderer.invoke("select-app-folder");
}
public async selectAppLocation(
defaultPath?: string,
): Promise<{ path: string | null; canceled: boolean }> {
return this.ipcRenderer.invoke("select-app-location", { defaultPath });
}
public async changeAppLocation(
params: ChangeAppLocationParams,
): Promise<ChangeAppLocationResult> {
return this.ipcRenderer.invoke("change-app-location", params);
}
// Add these methods to IpcClient class
public async selectNodeFolder(): Promise<SelectNodeFolderResult> {
......
......@@ -28,7 +28,6 @@ export interface RespondToAppInputParams {
export interface ListAppsResponse {
apps: App[];
appBasePath: string;
}
export interface ChatStreamParams {
......@@ -120,6 +119,7 @@ export interface App {
installCommand: string | null;
startCommand: string | null;
isFavorite: boolean;
resolvedPath?: string;
}
export interface Version {
......@@ -277,6 +277,15 @@ export interface RenameBranchParams {
newBranchName: string;
}
export interface ChangeAppLocationParams {
appId: number;
parentDirectory: string;
}
export interface ChangeAppLocationResult {
resolvedPath: string;
}
export const UserBudgetInfoSchema = z.object({
usedCredits: z.number(),
totalCredits: z.number(),
......
import { useNavigate, useRouter, useSearch } from "@tanstack/react-router";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
appBasePathAtom,
appsListAtom,
selectedAppIdAtom,
} from "@/atoms/appAtoms";
import { normalizePath } from "../../shared/normalizePath";
import { useAtom, useSetAtom } from "jotai";
import { appsListAtom, selectedAppIdAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client";
import { useLoadApps } from "@/hooks/useLoadApps";
import { useState } from "react";
......@@ -32,7 +29,7 @@ import {
} from "@/components/ui/dialog";
import { GitHubConnector } from "@/components/GitHubConnector";
import { SupabaseConnector } from "@/components/SupabaseConnector";
import { showError } from "@/lib/toast";
import { showError, showSuccess } from "@/lib/toast";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Label } from "@/components/ui/label";
import { Loader2 } from "lucide-react";
......@@ -59,10 +56,11 @@ export default function AppDetailsPage() {
useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [isRenamingFolder, setIsRenamingFolder] = useState(false);
const appBasePath = useAtomValue(appBasePathAtom);
const [isCopyDialogOpen, setIsCopyDialogOpen] = useState(false);
const [newCopyAppName, setNewCopyAppName] = useState("");
const [isChangeLocationDialogOpen, setIsChangeLocationDialogOpen] =
useState(false);
const queryClient = useQueryClient();
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
......@@ -103,7 +101,9 @@ export default function AppDetailsPage() {
const handleOpenRenameFolderDialog = () => {
if (selectedApp) {
setNewFolderName(selectedApp.path.split("/").pop() || selectedApp.path);
setNewFolderName(
normalizePath(selectedApp.path).split("/").pop() || selectedApp.path,
);
setIsRenameFolderDialogOpen(true);
}
};
......@@ -172,6 +172,34 @@ export default function AppDetailsPage() {
}
};
const handleChangeLocation = async () => {
if (!selectedApp || !appId) return;
try {
// Get the current parent directory as default
const currentPath = selectedApp.resolvedPath || "";
const currentParentDir = currentPath
? currentPath.replace(/[/\\][^/\\]*$/, "") // Remove last path component
: undefined;
const response =
await IpcClient.getInstance().selectAppLocation(currentParentDir);
if (!response.canceled && response.path) {
await changeLocationMutation.mutateAsync({
appId,
parentDirectory: response.path,
});
setIsChangeLocationDialogOpen(false);
} else {
// User canceled the file dialog, close the change location dialog
setIsChangeLocationDialogOpen(false);
}
} catch {
// Error is already shown by the mutation's onError
setIsChangeLocationDialogOpen(false);
}
};
const copyAppMutation = useMutation({
mutationFn: async ({ withHistory }: { withHistory: boolean }) => {
if (!appId || !newCopyAppName.trim()) {
......@@ -197,6 +225,20 @@ export default function AppDetailsPage() {
},
});
const changeLocationMutation = useMutation({
mutationFn: async (params: { appId: number; parentDirectory: string }) => {
return IpcClient.getInstance().changeAppLocation(params);
},
onSuccess: async () => {
await invalidateAppQuery(queryClient, { appId });
await refreshApps();
showSuccess("App location updated");
},
onError: (error) => {
showError(error);
},
});
if (!selectedApp) {
return (
<div className="relative min-h-screen p-8">
......@@ -216,7 +258,7 @@ export default function AppDetailsPage() {
);
}
const fullAppPath = appBasePath.replace("$APP_BASE_PATH", selectedApp.path);
const currentAppPath = selectedApp.resolvedPath || "";
return (
<div
......@@ -270,6 +312,14 @@ export default function AppDetailsPage() {
>
Rename folder
</Button>
<Button
onClick={() => setIsChangeLocationDialogOpen(true)}
variant="ghost"
size="sm"
className="h-8 justify-start text-xs"
>
Move folder
</Button>
<Button
onClick={handleOpenCopyDialog}
variant="ghost"
......@@ -309,18 +359,18 @@ export default function AppDetailsPage() {
Path
</span>
<div className="flex items-center gap-1">
<span className="text-sm break-all">{fullAppPath}</span>
<Button
variant="ghost"
size="sm"
className="p-0.5 h-auto cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
size="icon"
className="ml-[-8px] p-0.5 h-auto cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onClick={() => {
IpcClient.getInstance().showItemInFolder(fullAppPath);
IpcClient.getInstance().showItemInFolder(currentAppPath);
}}
title="Show in folder"
>
<Folder className="h-3.5 w-3.5" />
</Button>
<span className="text-sm break-all">{currentAppPath}</span>
</div>
</div>
</div>
......@@ -626,6 +676,46 @@ export default function AppDetailsPage() {
</Dialog>
)}
{/* Change Location Dialog */}
<Dialog
open={isChangeLocationDialogOpen}
onOpenChange={setIsChangeLocationDialogOpen}
>
<DialogContent className="max-w-sm p-4">
<DialogHeader className="pb-2">
<DialogTitle>Change App Location</DialogTitle>
<DialogDescription className="text-xs">
Select a folder where this app will be stored. The app folder
name will remain the same.
</DialogDescription>
</DialogHeader>
<DialogFooter className="pt-2">
<Button
variant="outline"
onClick={() => setIsChangeLocationDialogOpen(false)}
disabled={changeLocationMutation.isPending}
size="sm"
>
Cancel
</Button>
<Button
onClick={handleChangeLocation}
disabled={changeLocationMutation.isPending}
size="sm"
>
{changeLocationMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Moving...
</>
) : (
"Select Folder"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="max-w-sm p-4">
......
......@@ -3,6 +3,11 @@ import os from "node:os";
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
export function getDyadAppPath(appPath: string): string {
// If appPath is already absolute, use it as-is
if (path.isAbsolute(appPath)) {
return appPath;
}
// Otherwise, use the default base path
if (IS_TEST_BUILD) {
const electron = getElectron();
return path.join(electron!.app.getPath("userData"), "dyad-apps", appPath);
......
......@@ -103,6 +103,8 @@ const validInvokeChannels = [
"check-app-name",
"rename-branch",
"clear-session-data",
"select-app-location",
"change-app-location",
"get-user-budget",
"get-context-paths",
"set-context-paths",
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论