-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Feat: allow user to choose which directory to save Dyad apps in #2875
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
e246b23
2ccc4ba
26199b1
7f70285
9a3ac36
936768c
dc1463a
be29cdc
39f7f65
0d737dd
33d02e0
0f73d38
59bbbec
13ae0f6
9f10a94
ffe1735
882c8a3
eddf744
859065d
ffe31f9
d2f96b4
be535aa
2a0d91e
a8a166c
67b38dd
22c86f9
7ce4837
08bff78
9c9fc8d
178aaef
955603e
c1e6963
d50363f
c5eba95
95087dc
9d272a8
bcac2dc
c104214
87546e3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import { useState, useEffect } from "react"; | ||
| import { Label } from "@/components/ui/label"; | ||
| import { Button } from "@/components/ui/button"; | ||
| import { showError, showSuccess } from "@/lib/toast"; | ||
| import { ipc } from "@/ipc/types"; | ||
| import { FolderOpen, RotateCcw } from "lucide-react"; | ||
|
|
||
| export function DyadAppsBaseDirectorySelector() { | ||
| const [isSelectingPath, setIsSelectingPath] = useState(false); | ||
| const [dyadAppsBasePath, setDyadAppsBasePath] = | ||
| useState<string>("Loading..."); | ||
| const [isCustomPath, setIsCustomPath] = useState(false); | ||
|
|
||
| useEffect(() => { | ||
| // Fetch path on mount | ||
| fetchDyadAppsBaseDirectory(); | ||
| }, []); | ||
|
|
||
| const handleSelectDyadAppsBaseDirectory = async () => { | ||
| setIsSelectingPath(true); | ||
| try { | ||
| // Call the IPC method to select folder | ||
| const result = await ipc.system.selectDyadAppsBaseDirectory(); | ||
| if (result.path) { | ||
| // Save the custom path to settings | ||
| await ipc.system.setDyadAppsBaseDirectory(result.path); | ||
| await fetchDyadAppsBaseDirectory(); | ||
| showSuccess("Dyad apps folder updated successfully"); | ||
| } else if (result.path === null && result.canceled === false) { | ||
| showError(`Could not find folder`); | ||
| } | ||
| } catch (error: any) { | ||
| showError(`Failed to set Dyad apps folder: ${error.message}`); | ||
| } finally { | ||
| setIsSelectingPath(false); | ||
| } | ||
| }; | ||
|
|
||
| const handleResetToDefault = async () => { | ||
| try { | ||
| // Clear the custom path | ||
| await ipc.system.setDyadAppsBaseDirectory(null); | ||
| // Update UI to show default directory | ||
| await fetchDyadAppsBaseDirectory(); | ||
| showSuccess("Dyad apps folder reset successfully"); | ||
| } catch (error: any) { | ||
| showError(`Failed to reset Dyad Apps folder path: ${error.message}`); | ||
| } | ||
| }; | ||
|
|
||
| const fetchDyadAppsBaseDirectory = async () => { | ||
| try { | ||
| const { path, isCustomPath } = | ||
| await ipc.system.getDyadAppsBaseDirectory(); | ||
| setDyadAppsBasePath(path); | ||
| setIsCustomPath(isCustomPath); | ||
| } catch (error: any) { | ||
| showError(`Failed to fetch Dyad apps folder path: ${error.message}`); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="space-y-4"> | ||
| <div className="space-y-2"> | ||
| <div className="flex gap-2"> | ||
| <Label className="text-sm font-medium"> | ||
| Folder to Store Dyad Apps | ||
| </Label> | ||
|
|
||
| <Button | ||
| onClick={handleSelectDyadAppsBaseDirectory} | ||
| disabled={isSelectingPath} | ||
| variant="outline" | ||
| size="sm" | ||
| className="flex items-center gap-2" | ||
| > | ||
| <FolderOpen className="w-4 h-4" /> | ||
| {isSelectingPath ? "Selecting..." : "Select A Folder"} | ||
| </Button> | ||
|
|
||
| {isCustomPath && ( | ||
| <Button | ||
| onClick={handleResetToDefault} | ||
| variant="ghost" | ||
| size="sm" | ||
| className="flex items-center gap-2" | ||
| > | ||
| <RotateCcw className="w-4 h-4" /> | ||
| Reset to Default | ||
| </Button> | ||
| )} | ||
| </div> | ||
| <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"> | ||
| <div className="flex items-start justify-between"> | ||
| <div className="flex-1 min-w-0"> | ||
| <div className="flex items-center gap-2 mb-1"> | ||
| <span className="text-xs font-medium text-gray-500 dark:text-gray-400"> | ||
| {isCustomPath ? "Custom Folder:" : "Default Folder:"} | ||
| </span> | ||
| </div> | ||
| <p className="text-sm font-mono text-gray-700 dark:text-gray-300 break-all max-h-32 overflow-y-auto"> | ||
| {dyadAppsBasePath} | ||
| </p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Help Text */} | ||
| <div className="text-sm text-gray-500 dark:text-gray-400"> | ||
| <p> | ||
| This is the top-level folder that Dyad will store new applications | ||
| in. | ||
| </p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -838,7 +838,14 @@ export function registerAppHandlers() { | |
| throw new Error("Original app not found."); | ||
| } | ||
|
|
||
| const originalAppPath = getDyadAppPath(originalApp.path); | ||
| const maybeSymlinkOriginalAppPath = getDyadAppPath(originalApp.path); | ||
| let originalAppPath = maybeSymlinkOriginalAppPath; | ||
| try { | ||
| originalAppPath = await fsPromises.realpath(maybeSymlinkOriginalAppPath); | ||
| } catch { | ||
| // Fall through; use original path if we can't resolve symlink | ||
| } | ||
RyanGroch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
|
Comment on lines
+858
to
+863
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The I didn't add these guards to every app handler; I focused on adding them in places where the custom apps folder is most likely to be the cause of failure. This primarily includes handlers that create apps ( |
||
| const newAppPath = getDyadAppPath(newAppName); | ||
|
|
||
| // 3. Copy the app folder | ||
|
|
@@ -1329,7 +1336,14 @@ export function registerAppHandlers() { | |
| } | ||
|
|
||
| // Delete app files | ||
| const appPath = getDyadAppPath(app.path); | ||
| const maybeSymlinkAppPath = getDyadAppPath(app.path); | ||
| let appPath = maybeSymlinkAppPath; | ||
| try { | ||
| appPath = await fsPromises.realpath(maybeSymlinkAppPath); | ||
| } catch { | ||
| // Fall through; use original path if we can't resolve symlink | ||
| } | ||
RyanGroch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| try { | ||
| await fsPromises.rm(appPath, { recursive: true, force: true }); | ||
| } catch (error: any) { | ||
|
|
@@ -1338,6 +1352,15 @@ export function registerAppHandlers() { | |
| `App deleted from database, but failed to delete app files. Please delete app files from ${appPath} manually.\n\nError: ${error.message}`, | ||
| ); | ||
| } | ||
|
|
||
| // If the original path is a symlink, delete it | ||
| if (maybeSymlinkAppPath !== appPath) { | ||
| try { | ||
| await fsPromises.unlink(maybeSymlinkAppPath); | ||
| } catch (error: any) { | ||
| logger.warn(`Failed to delete symlink for app ${appId}:`, error); | ||
| } | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
|
|
@@ -1888,7 +1911,33 @@ export function registerAppHandlers() { | |
| throw new Error("App not found"); | ||
| } | ||
|
|
||
| const currentResolvedPath = getDyadAppPath(app.path); | ||
| const maybeSymlinkPath = getDyadAppPath(app.path); | ||
| let currentResolvedPath = maybeSymlinkPath; | ||
| // If the resolved path is a symlink, we assume the user wants to move the true app directory | ||
| try { | ||
| currentResolvedPath = await fsPromises.realpath(currentResolvedPath); | ||
| } catch { | ||
| // Fall through; use original path if we can't resolve symlink | ||
| } | ||
RyanGroch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| // Cleans up the symlink if it exists. We call this whenever we change an app's path | ||
| const cleanupSymlink = async () => { | ||
| let st; | ||
| try { | ||
| st = await fsPromises.lstat(maybeSymlinkPath); | ||
| } catch { | ||
| // Fall through; setting up to check existence+symlink status | ||
| } | ||
|
|
||
| if (!st || !st.isSymbolicLink()) return; | ||
|
|
||
| try { | ||
| await fsPromises.unlink(maybeSymlinkPath); | ||
| } catch (error: any) { | ||
| logger.warn(`Error deleting old symlink ${maybeSymlinkPath}:`, error); | ||
| } | ||
| }; | ||
|
|
||
| // 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, | ||
|
|
@@ -1897,11 +1946,16 @@ export function registerAppHandlers() { | |
|
|
||
| if (currentResolvedPath === nextResolvedPath) { | ||
| // Path hasn't changed, but we should update to absolute path format if needed | ||
| if (!path.isAbsolute(app.path)) { | ||
| // Or, if the original path was a symlink, update to the true path | ||
| if ( | ||
| !path.isAbsolute(app.path) || | ||
| maybeSymlinkPath !== nextResolvedPath | ||
| ) { | ||
| await db | ||
| .update(apps) | ||
| .set({ path: nextResolvedPath }) | ||
| .where(eq(apps.id, appId)); | ||
| await cleanupSymlink(); | ||
| } | ||
| return { | ||
| resolvedPath: nextResolvedPath, | ||
|
|
@@ -1937,6 +1991,7 @@ export function registerAppHandlers() { | |
| .update(apps) | ||
| .set({ path: nextResolvedPath }) | ||
| .where(eq(apps.id, appId)); | ||
| await cleanupSymlink(); | ||
| return { | ||
| resolvedPath: nextResolvedPath, | ||
| }; | ||
|
|
@@ -1978,6 +2033,8 @@ export function registerAppHandlers() { | |
| ); | ||
| } | ||
|
|
||
| await cleanupSymlink(); | ||
|
|
||
| return { | ||
| resolvedPath: nextResolvedPath, | ||
| }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import { dialog } from "electron"; | ||
| import { mkdir, stat, symlink, realpath } from "fs/promises"; | ||
| import log from "electron-log"; | ||
| import { join, isAbsolute } from "path"; | ||
| import { db } from "../../db"; | ||
| import { apps } from "../../db/schema"; | ||
| import { desc } from "drizzle-orm"; | ||
| import { createTypedHandler } from "./base"; | ||
| import { systemContracts } from "../types/system"; | ||
| import { getDyadAppsBaseDirectory, invalidateDyadAppsBaseDirectoryCache } from "@/paths/paths"; | ||
| import { writeSettings } from "@/main/settings"; | ||
|
|
||
| const logger = log.scope("dyad_apps_base_directory_handlers"); | ||
|
|
||
| export function registerDyadAppsBaseDirectoryHandlers() { | ||
| createTypedHandler(systemContracts.getDyadAppsBaseDirectory, async () => { | ||
| const { path, isCustomPath } = getDyadAppsBaseDirectory(); | ||
|
|
||
| return { | ||
| path, | ||
| isCustomPath, | ||
| }; | ||
| }); | ||
|
|
||
| createTypedHandler(systemContracts.selectDyadAppsBaseDirectory, async () => { | ||
| const { filePaths, canceled } = await dialog.showOpenDialog({ | ||
| title: "Select Dyad Apps Folder", | ||
| properties: ["openDirectory"], | ||
| message: "Select the folder where Dyad apps should be stored", | ||
| }); | ||
|
|
||
| if (canceled) { | ||
| return { path: null, canceled: true }; | ||
| } | ||
|
|
||
| let st; | ||
| try { | ||
| st = await stat(filePaths[0]); | ||
| } catch { | ||
| // Just setting up to check directory existence, so fall through | ||
| } | ||
|
|
||
| if (!st || !st.isDirectory()) { | ||
| return { path: null, canceled: false }; | ||
| } | ||
|
|
||
| return { path: filePaths[0], canceled: false }; | ||
| }); | ||
|
|
||
| createTypedHandler( | ||
| systemContracts.setDyadAppsBaseDirectory, | ||
| async (_, input) => { | ||
| const { path: prevCustomPath, defaultPath } = getDyadAppsBaseDirectory(); | ||
RyanGroch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| let newDyadAppsBaseDir = defaultPath; // If input is null/falsey, reset to default | ||
|
|
||
| if (input) { | ||
| let st; | ||
| try { | ||
| st = await stat(input); | ||
| } catch { | ||
| // Setting up to check existence+type; fall through | ||
| } | ||
|
|
||
| if (!st || !st.isDirectory()) | ||
| throw new Error("Path is not a directory"); | ||
|
|
||
| newDyadAppsBaseDir = input; | ||
| } | ||
|
|
||
| await mkdir(newDyadAppsBaseDir, { recursive: true }); | ||
RyanGroch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| const allApps = await db.query.apps.findMany({ | ||
| orderBy: [desc(apps.createdAt)], | ||
| }); | ||
|
|
||
| // We don't want to make current apps inaccessible after changing the directory. | ||
| // So, we add symlinks in the new directory to each of the user's apps. | ||
| for (const app of allApps) { | ||
| if (isAbsolute(app.path)) continue; | ||
|
|
||
| const link = join(newDyadAppsBaseDir, app.path); | ||
| let target = join(prevCustomPath, app.path); | ||
|
|
||
| // Make sure we link to original directory, not a symlink | ||
| try { | ||
| target = await realpath(target); | ||
| } catch { | ||
| // Fall through. If realpath fails, we keep the original path | ||
| } | ||
|
|
||
| try { | ||
| // On Windows, symlinks require more permissions than junctions. | ||
| // Try symlink first; if that fails, fall back to a junction | ||
| if (process.platform === "win32") { | ||
| try { | ||
| await symlink(target, link, "dir"); | ||
| continue; | ||
| } catch (error: any) { | ||
| // if it's not a permissions error, it's not worth retrying | ||
| if (error.code !== "EPERM") throw error; | ||
| } | ||
| } | ||
|
|
||
| await symlink(target, link, "junction"); | ||
| } catch (err: any) { | ||
| // If we already have access to the app (or one with the same name), | ||
| // or the app no longer exists, then we can safely skip the symlink | ||
| if (err.code === "EEXIST" || err.code === "ENOENT") { | ||
RyanGroch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| logger.debug( | ||
| [ | ||
| "Skipping symlink creation", | ||
| `FROM: ${link}`, | ||
| `TO: ${target}`, | ||
| `REASON: ${err.code}`, | ||
| ].join("\n"), | ||
| ); | ||
| continue; | ||
| } | ||
|
|
||
| // We stop the settings change if we're removing access to apps | ||
| logger.error( | ||
| [ | ||
| "Failed to create required symlink", | ||
| `FROM: ${link}`, | ||
| `TO: ${target}`, | ||
| `ERROR: ${err.code ?? err.message}`, | ||
| ].join("\n"), | ||
| ); | ||
| throw err; | ||
| } | ||
| } | ||
|
|
||
| writeSettings({ customDyadAppsBaseDirectory: input }); | ||
| invalidateDyadAppsBaseDirectoryCache(); | ||
| }, | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.