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"; ...@@ -5,7 +5,6 @@ import type { UserSettings } from "@/lib/schemas";
export const currentAppAtom = atom<App | null>(null); export const currentAppAtom = atom<App | null>(null);
export const selectedAppIdAtom = atom<number | null>(null); export const selectedAppIdAtom = atom<number | null>(null);
export const appsListAtom = atom<App[]>([]); export const appsListAtom = atom<App[]>([]);
export const appBasePathAtom = atom<string>("");
export const versionsListAtom = atom<Version[]>([]); export const versionsListAtom = atom<Version[]>([]);
export const previewModeAtom = atom< export const previewModeAtom = atom<
"preview" | "code" | "problems" | "configure" | "publish" | "security" "preview" | "code" | "problems" | "configure" | "publish" | "security"
......
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { appBasePathAtom, appsListAtom } from "@/atoms/appAtoms"; import { appsListAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
export function useLoadApps() { export function useLoadApps() {
const [apps, setApps] = useAtom(appsListAtom); const [apps, setApps] = useAtom(appsListAtom);
const [, setAppBasePath] = useAtom(appBasePathAtom);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
...@@ -15,7 +14,6 @@ export function useLoadApps() { ...@@ -15,7 +14,6 @@ export function useLoadApps() {
const ipcClient = IpcClient.getInstance(); const ipcClient = IpcClient.getInstance();
const appListResponse = await ipcClient.listApps(); const appListResponse = await ipcClient.listApps();
setApps(appListResponse.apps); setApps(appListResponse.apps);
setAppBasePath(appListResponse.appBasePath);
setError(null); setError(null);
} catch (error) { } catch (error) {
console.error("Error refreshing apps:", error); console.error("Error refreshing apps:", error);
......
import { ipcMain, app } from "electron"; import { ipcMain, app, dialog } from "electron";
import { db, getDatabasePath } from "../../db"; import { db, getDatabasePath } from "../../db";
import { apps, chats, messages } from "../../db/schema"; import { apps, chats, messages } from "../../db/schema";
import { desc, eq, like } from "drizzle-orm"; import { desc, eq, like } from "drizzle-orm";
...@@ -9,6 +9,8 @@ import type { ...@@ -9,6 +9,8 @@ import type {
CopyAppParams, CopyAppParams,
EditAppFileReturnType, EditAppFileReturnType,
RespondToAppInputParams, RespondToAppInputParams,
ChangeAppLocationParams,
ChangeAppLocationResult,
} 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";
...@@ -72,11 +74,15 @@ async function copyDir( ...@@ -72,11 +74,15 @@ async function copyDir(
source: string, source: string,
destination: string, destination: string,
filter?: (source: string) => boolean, filter?: (source: string) => boolean,
options?: { excludeNodeModules?: boolean },
) { ) {
await fsPromises.cp(source, destination, { await fsPromises.cp(source, destination, {
recursive: true, recursive: true,
filter: (src: string) => { filter: (src: string) => {
if (path.basename(src) === "node_modules") { if (
options?.excludeNodeModules &&
path.basename(src) === "node_modules"
) {
return false; return false;
} }
if (filter) { if (filter) {
...@@ -620,7 +626,10 @@ export function registerAppHandlers() { ...@@ -620,7 +626,10 @@ export function registerAppHandlers() {
}) })
.where(eq(chats.id, chat.id)); .where(eq(chats.id, chat.id));
return { app, chatId: chat.id }; return {
app: { ...app, resolvedPath: fullAppPath },
chatId: chat.id,
};
}, },
); );
...@@ -652,12 +661,17 @@ export function registerAppHandlers() { ...@@ -652,12 +661,17 @@ export function registerAppHandlers() {
// 3. Copy the app folder // 3. Copy the app folder
try { try {
await copyDir(originalAppPath, newAppPath, (source: string) => { await copyDir(
originalAppPath,
newAppPath,
(source: string) => {
if (!withHistory && path.basename(source) === ".git") { if (!withHistory && path.basename(source) === ".git") {
return false; return false;
} }
return true; return true;
}); },
{ excludeNodeModules: true },
);
} catch (error) { } catch (error) {
logger.error("Failed to copy app directory:", error); logger.error("Failed to copy app directory:", error);
throw new Error("Failed to copy app directory."); throw new Error("Failed to copy app directory.");
...@@ -744,6 +758,7 @@ export function registerAppHandlers() { ...@@ -744,6 +758,7 @@ export function registerAppHandlers() {
return { return {
...app, ...app,
files, files,
resolvedPath: appPath,
supabaseProjectName, supabaseProjectName,
vercelTeamSlug, vercelTeamSlug,
}; };
...@@ -753,9 +768,12 @@ export function registerAppHandlers() { ...@@ -753,9 +768,12 @@ export function registerAppHandlers() {
const allApps = await db.query.apps.findMany({ const allApps = await db.query.apps.findMany({
orderBy: [desc(apps.createdAt)], orderBy: [desc(apps.createdAt)],
}); });
const appsWithResolvedPath = allApps.map((app) => ({
...app,
resolvedPath: getDyadAppPath(app.path),
}));
return { return {
apps: allApps, apps: appsWithResolvedPath,
appBasePath: getDyadAppPath("$APP_BASE_PATH"),
}; };
}); });
...@@ -1242,6 +1260,16 @@ export function registerAppHandlers() { ...@@ -1242,6 +1260,16 @@ export function registerAppHandlers() {
const pathChanged = appPath !== app.path; const pathChanged = appPath !== app.path;
// Security: reject NEW absolute paths - rename-app should only accept relative paths for new paths
// Absolute paths should only be set through change-app-location handler
// If the path is changing and it's absolute, reject it
if (pathChanged && path.isAbsolute(appPath)) {
throw new Error(
"Absolute paths are not allowed when renaming an app folder. Please use a relative folder name only. To change the storage location, use the 'Change location' button.",
);
}
// Validate path for invalid characters when path changes (only for relative paths)
if (pathChanged) { if (pathChanged) {
const invalidChars = /[<>:"|?*/\\]/; const invalidChars = /[<>:"|?*/\\]/;
const hasInvalidChars = const hasInvalidChars =
...@@ -1259,16 +1287,32 @@ export function registerAppHandlers() { ...@@ -1259,16 +1287,32 @@ export function registerAppHandlers() {
where: eq(apps.name, appName), where: eq(apps.name, appName),
}); });
const pathConflict = await db.query.apps.findFirst({
where: eq(apps.path, appPath),
});
if (nameConflict && nameConflict.id !== appId) { if (nameConflict && nameConflict.id !== appId) {
throw new Error(`An app with the name '${appName}' already exists`); throw new Error(`An app with the name '${appName}' already exists`);
} }
if (pathConflict && pathConflict.id !== appId) { // If the current path is absolute, preserve the directory and only change the folder name
throw new Error(`An app with the path '${appPath}' already exists`); // Otherwise, resolve the new path using the default base path
const currentResolvedPath = getDyadAppPath(app.path);
const newAppPath = path.isAbsolute(app.path)
? path.join(path.dirname(app.path), appPath)
: getDyadAppPath(appPath);
let hasPathConflict = false;
if (pathChanged) {
const allApps = await db.query.apps.findMany();
hasPathConflict = allApps.some((existingApp) => {
if (existingApp.id === appId) {
return false;
}
return getDyadAppPath(existingApp.path) === newAppPath;
});
}
if (hasPathConflict) {
throw new Error(
`An app with the path '${newAppPath}' already exists`,
);
} }
// Stop the app if it's running // Stop the app if it's running
...@@ -1284,8 +1328,7 @@ export function registerAppHandlers() { ...@@ -1284,8 +1328,7 @@ export function registerAppHandlers() {
} }
} }
const oldAppPath = getDyadAppPath(app.path); const oldAppPath = currentResolvedPath;
const newAppPath = getDyadAppPath(appPath);
// Only move files if needed // Only move files if needed
if (newAppPath !== oldAppPath) { if (newAppPath !== oldAppPath) {
// Move app files // Move app files
...@@ -1303,12 +1346,28 @@ export function registerAppHandlers() { ...@@ -1303,12 +1346,28 @@ export function registerAppHandlers() {
}); });
// Copy the directory without node_modules // Copy the directory without node_modules
await copyDir(oldAppPath, newAppPath); await copyDir(oldAppPath, newAppPath, undefined, {
excludeNodeModules: true,
});
} catch (error: any) { } catch (error: any) {
logger.error( logger.error(
`Error moving app files from ${oldAppPath} to ${newAppPath}:`, `Error moving app files from ${oldAppPath} to ${newAppPath}:`,
error, error,
); );
// Attempt cleanup if destination exists (partial copy may have occurred)
if (fs.existsSync(newAppPath)) {
try {
await fsPromises.rm(newAppPath, {
recursive: true,
force: true,
});
} catch (cleanupError) {
logger.warn(
`Failed to clean up partial move at ${newAppPath}:`,
cleanupError,
);
}
}
throw new Error(`Failed to move app files: ${error.message}`); throw new Error(`Failed to move app files: ${error.message}`);
} }
...@@ -1329,12 +1388,14 @@ export function registerAppHandlers() { ...@@ -1329,12 +1388,14 @@ export function registerAppHandlers() {
} }
// Update app in database // Update app in database
// If the current path was absolute, store the new absolute path; otherwise store the relative path
const pathToStore = path.isAbsolute(app.path) ? newAppPath : appPath;
try { try {
await db await db
.update(apps) .update(apps)
.set({ .set({
name: appName, name: appName,
path: appPath, path: pathToStore,
}) })
.where(eq(apps.id, appId)) .where(eq(apps.id, appId))
.returning(); .returning();
...@@ -1345,7 +1406,9 @@ export function registerAppHandlers() { ...@@ -1345,7 +1406,9 @@ export function registerAppHandlers() {
if (newAppPath !== oldAppPath) { if (newAppPath !== oldAppPath) {
try { try {
// Copy back from new to old // Copy back from new to old
await copyDir(newAppPath, oldAppPath); await copyDir(newAppPath, oldAppPath, undefined, {
excludeNodeModules: true,
});
// Delete the new directory // Delete the new directory
await fsPromises.rm(newAppPath, { recursive: true, force: true }); await fsPromises.rm(newAppPath, { recursive: true, force: true });
} catch (rollbackError) { } catch (rollbackError) {
...@@ -1584,6 +1647,173 @@ export function registerAppHandlers() { ...@@ -1584,6 +1647,173 @@ export function registerAppHandlers() {
return uniqueApps; return uniqueApps;
}, },
); );
handle(
"select-app-location",
async (
_,
{ defaultPath }: { defaultPath?: string },
): Promise<{ path: string | null; canceled: boolean }> => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", "createDirectory"],
title: "Select a folder where this app will be stored",
defaultPath,
});
if (result.canceled || !result.filePaths[0]) {
return { path: null, canceled: true };
}
return { path: result.filePaths[0], canceled: false };
},
);
handle(
"change-app-location",
async (
_,
params: ChangeAppLocationParams,
): Promise<ChangeAppLocationResult> => {
const { appId, parentDirectory } = params;
if (!parentDirectory) {
throw new Error("No destination folder provided.");
}
if (!path.isAbsolute(parentDirectory)) {
throw new Error("Please select an absolute destination folder.");
}
const normalizedParentDir = path.normalize(parentDirectory);
return withLock(appId, async () => {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
const currentResolvedPath = getDyadAppPath(app.path);
// Extract app folder name from current path (works for both absolute and relative paths)
const appFolderName = path.basename(
path.isAbsolute(app.path) ? app.path : currentResolvedPath,
);
const nextResolvedPath = path.join(normalizedParentDir, appFolderName);
if (currentResolvedPath === nextResolvedPath) {
// Path hasn't changed, but we should update to absolute path format if needed
if (!path.isAbsolute(app.path)) {
await db
.update(apps)
.set({ path: nextResolvedPath })
.where(eq(apps.id, appId));
}
return {
resolvedPath: nextResolvedPath,
};
}
const allApps = await db.query.apps.findMany();
const conflict = allApps.some(
(existingApp) =>
existingApp.id !== appId &&
getDyadAppPath(existingApp.path) === nextResolvedPath,
);
if (conflict) {
throw new Error(
`Another app already exists at '${nextResolvedPath}'. Please choose a different folder.`,
);
}
if (fs.existsSync(nextResolvedPath)) {
throw new Error(
`Destination path '${nextResolvedPath}' already exists. Please choose an empty folder.`,
);
}
// Check if source path exists - if not, just update the DB path without copying
const sourceExists = fs.existsSync(currentResolvedPath);
if (!sourceExists) {
logger.warn(
`Source path ${currentResolvedPath} does not exist. Updating database path only.`,
);
await db
.update(apps)
.set({ path: nextResolvedPath })
.where(eq(apps.id, appId));
return {
resolvedPath: nextResolvedPath,
};
}
if (runningApps.has(appId)) {
const appInfo = runningApps.get(appId)!;
try {
await stopAppByInfo(appId, appInfo);
} catch (error: any) {
logger.error(`Error stopping app ${appId} before moving:`, error);
throw new Error(
`Failed to stop app before moving: ${error.message}`,
);
}
}
await fsPromises.mkdir(normalizedParentDir, { recursive: true });
try {
// Copy the directory without node_modules
await copyDir(currentResolvedPath, nextResolvedPath, undefined, {
excludeNodeModules: true,
});
// Update path to absolute path
await db
.update(apps)
.set({ path: nextResolvedPath })
.where(eq(apps.id, appId));
try {
await fsPromises.rm(currentResolvedPath, {
recursive: true,
force: true,
});
} catch (error: any) {
logger.warn(
`Error deleting old app directory ${currentResolvedPath}:`,
error,
);
}
return {
resolvedPath: nextResolvedPath,
};
} catch (error: any) {
// Attempt cleanup if destination exists (partial copy may have occurred)
if (fs.existsSync(nextResolvedPath)) {
try {
await fsPromises.rm(nextResolvedPath, {
recursive: true,
force: true,
});
} catch (cleanupError) {
logger.warn(
`Failed to clean up partial move at ${nextResolvedPath}:`,
cleanupError,
);
}
}
logger.error(
`Error moving app files from ${currentResolvedPath} to ${nextResolvedPath}:`,
error,
);
throw new Error(`Failed to move app files: ${error.message}`);
}
});
},
);
} }
function getCommand({ function getCommand({
......
...@@ -73,6 +73,8 @@ import type { ...@@ -73,6 +73,8 @@ import type {
SupabaseProject, SupabaseProject,
DeleteSupabaseOrganizationParams, DeleteSupabaseOrganizationParams,
SelectNodeFolderResult, SelectNodeFolderResult,
ChangeAppLocationParams,
ChangeAppLocationResult,
ApplyVisualEditingChangesParams, ApplyVisualEditingChangesParams,
AnalyseComponentParams, AnalyseComponentParams,
AgentTool, AgentTool,
...@@ -1316,6 +1318,18 @@ export class IpcClient { ...@@ -1316,6 +1318,18 @@ export class IpcClient {
return this.ipcRenderer.invoke("select-app-folder"); 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 // Add these methods to IpcClient class
public async selectNodeFolder(): Promise<SelectNodeFolderResult> { public async selectNodeFolder(): Promise<SelectNodeFolderResult> {
......
...@@ -28,7 +28,6 @@ export interface RespondToAppInputParams { ...@@ -28,7 +28,6 @@ export interface RespondToAppInputParams {
export interface ListAppsResponse { export interface ListAppsResponse {
apps: App[]; apps: App[];
appBasePath: string;
} }
export interface ChatStreamParams { export interface ChatStreamParams {
...@@ -120,6 +119,7 @@ export interface App { ...@@ -120,6 +119,7 @@ export interface App {
installCommand: string | null; installCommand: string | null;
startCommand: string | null; startCommand: string | null;
isFavorite: boolean; isFavorite: boolean;
resolvedPath?: string;
} }
export interface Version { export interface Version {
...@@ -277,6 +277,15 @@ export interface RenameBranchParams { ...@@ -277,6 +277,15 @@ export interface RenameBranchParams {
newBranchName: string; newBranchName: string;
} }
export interface ChangeAppLocationParams {
appId: number;
parentDirectory: string;
}
export interface ChangeAppLocationResult {
resolvedPath: string;
}
export const UserBudgetInfoSchema = z.object({ export const UserBudgetInfoSchema = z.object({
usedCredits: z.number(), usedCredits: z.number(),
totalCredits: z.number(), totalCredits: z.number(),
......
import { useNavigate, useRouter, useSearch } from "@tanstack/react-router"; import { useNavigate, useRouter, useSearch } from "@tanstack/react-router";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { normalizePath } from "../../shared/normalizePath";
import { import { useAtom, useSetAtom } from "jotai";
appBasePathAtom, import { appsListAtom, selectedAppIdAtom } from "@/atoms/appAtoms";
appsListAtom,
selectedAppIdAtom,
} from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { useLoadApps } from "@/hooks/useLoadApps"; import { useLoadApps } from "@/hooks/useLoadApps";
import { useState } from "react"; import { useState } from "react";
...@@ -32,7 +29,7 @@ import { ...@@ -32,7 +29,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { GitHubConnector } from "@/components/GitHubConnector"; import { GitHubConnector } from "@/components/GitHubConnector";
import { SupabaseConnector } from "@/components/SupabaseConnector"; import { SupabaseConnector } from "@/components/SupabaseConnector";
import { showError } from "@/lib/toast"; import { showError, showSuccess } from "@/lib/toast";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
...@@ -59,10 +56,11 @@ export default function AppDetailsPage() { ...@@ -59,10 +56,11 @@ export default function AppDetailsPage() {
useState(false); useState(false);
const [newFolderName, setNewFolderName] = useState(""); const [newFolderName, setNewFolderName] = useState("");
const [isRenamingFolder, setIsRenamingFolder] = useState(false); const [isRenamingFolder, setIsRenamingFolder] = useState(false);
const appBasePath = useAtomValue(appBasePathAtom);
const [isCopyDialogOpen, setIsCopyDialogOpen] = useState(false); const [isCopyDialogOpen, setIsCopyDialogOpen] = useState(false);
const [newCopyAppName, setNewCopyAppName] = useState(""); const [newCopyAppName, setNewCopyAppName] = useState("");
const [isChangeLocationDialogOpen, setIsChangeLocationDialogOpen] =
useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setSelectedAppId = useSetAtom(selectedAppIdAtom); const setSelectedAppId = useSetAtom(selectedAppIdAtom);
...@@ -103,7 +101,9 @@ export default function AppDetailsPage() { ...@@ -103,7 +101,9 @@ export default function AppDetailsPage() {
const handleOpenRenameFolderDialog = () => { const handleOpenRenameFolderDialog = () => {
if (selectedApp) { if (selectedApp) {
setNewFolderName(selectedApp.path.split("/").pop() || selectedApp.path); setNewFolderName(
normalizePath(selectedApp.path).split("/").pop() || selectedApp.path,
);
setIsRenameFolderDialogOpen(true); setIsRenameFolderDialogOpen(true);
} }
}; };
...@@ -172,6 +172,34 @@ export default function AppDetailsPage() { ...@@ -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({ const copyAppMutation = useMutation({
mutationFn: async ({ withHistory }: { withHistory: boolean }) => { mutationFn: async ({ withHistory }: { withHistory: boolean }) => {
if (!appId || !newCopyAppName.trim()) { if (!appId || !newCopyAppName.trim()) {
...@@ -197,6 +225,20 @@ export default function AppDetailsPage() { ...@@ -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) { if (!selectedApp) {
return ( return (
<div className="relative min-h-screen p-8"> <div className="relative min-h-screen p-8">
...@@ -216,7 +258,7 @@ export default function AppDetailsPage() { ...@@ -216,7 +258,7 @@ export default function AppDetailsPage() {
); );
} }
const fullAppPath = appBasePath.replace("$APP_BASE_PATH", selectedApp.path); const currentAppPath = selectedApp.resolvedPath || "";
return ( return (
<div <div
...@@ -270,6 +312,14 @@ export default function AppDetailsPage() { ...@@ -270,6 +312,14 @@ export default function AppDetailsPage() {
> >
Rename folder Rename folder
</Button> </Button>
<Button
onClick={() => setIsChangeLocationDialogOpen(true)}
variant="ghost"
size="sm"
className="h-8 justify-start text-xs"
>
Move folder
</Button>
<Button <Button
onClick={handleOpenCopyDialog} onClick={handleOpenCopyDialog}
variant="ghost" variant="ghost"
...@@ -309,18 +359,18 @@ export default function AppDetailsPage() { ...@@ -309,18 +359,18 @@ export default function AppDetailsPage() {
Path Path
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-sm break-all">{fullAppPath}</span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
className="p-0.5 h-auto cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" className="ml-[-8px] p-0.5 h-auto cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onClick={() => { onClick={() => {
IpcClient.getInstance().showItemInFolder(fullAppPath); IpcClient.getInstance().showItemInFolder(currentAppPath);
}} }}
title="Show in folder" title="Show in folder"
> >
<Folder className="h-3.5 w-3.5" /> <Folder className="h-3.5 w-3.5" />
</Button> </Button>
<span className="text-sm break-all">{currentAppPath}</span>
</div> </div>
</div> </div>
</div> </div>
...@@ -626,6 +676,46 @@ export default function AppDetailsPage() { ...@@ -626,6 +676,46 @@ export default function AppDetailsPage() {
</Dialog> </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 */} {/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}> <Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="max-w-sm p-4"> <DialogContent className="max-w-sm p-4">
......
...@@ -3,6 +3,11 @@ import os from "node:os"; ...@@ -3,6 +3,11 @@ import os from "node:os";
import { IS_TEST_BUILD } from "../ipc/utils/test_utils"; import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
export function getDyadAppPath(appPath: string): string { 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) { if (IS_TEST_BUILD) {
const electron = getElectron(); const electron = getElectron();
return path.join(electron!.app.getPath("userData"), "dyad-apps", appPath); return path.join(electron!.app.getPath("userData"), "dyad-apps", appPath);
......
...@@ -103,6 +103,8 @@ const validInvokeChannels = [ ...@@ -103,6 +103,8 @@ const validInvokeChannels = [
"check-app-name", "check-app-name",
"rename-branch", "rename-branch",
"clear-session-data", "clear-session-data",
"select-app-location",
"change-app-location",
"get-user-budget", "get-user-budget",
"get-context-paths", "get-context-paths",
"set-context-paths", "set-context-paths",
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论