Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
e246b23
feat: allow user to choose which directory to save Dyad apps in
RyanGroch Mar 3, 2026
2ccc4ba
perf: cache base directory path to remove bottleneck
RyanGroch Mar 4, 2026
26199b1
fix: avoid silent settings overwrite
RyanGroch Mar 4, 2026
7f70285
fix: initialize isCustomPath to false
RyanGroch Mar 4, 2026
9a3ac36
refactor: don't retry symlink on windows upon EPERM
RyanGroch Mar 5, 2026
936768c
fix: removes symlinks; changes app path to absolute upon directory ch…
RyanGroch Mar 6, 2026
dc1463a
revise directory fallback logic
RyanGroch Mar 7, 2026
be29cdc
fix: make custom folder setting searchable
RyanGroch Mar 8, 2026
39f7f65
fix: custom folder should be marked as git safe directory upon settin…
RyanGroch Mar 8, 2026
0d737dd
fix: adds fallback logic to github and import handlers
RyanGroch Mar 8, 2026
33d02e0
fix: throw when attempting to use inaccessible custom directory, rath…
RyanGroch Mar 9, 2026
0f73d38
fix: avoid throwing if skipcopy is true
RyanGroch Mar 10, 2026
59bbbec
fix: only create dyad-apps folder on first attempt to access
RyanGroch Mar 10, 2026
13ae0f6
fix: invalidate base directory cache when UI fetches it
RyanGroch Mar 10, 2026
9f10a94
test: adds e2e tests for custom apps folder
RyanGroch Mar 13, 2026
ffe1735
fix: remove fallback from getDyadAppPathAvailability
RyanGroch Mar 14, 2026
882c8a3
test: fix e2e test logic
RyanGroch Mar 14, 2026
eddf744
refactor: split up functions and change return types in paths.ts
RyanGroch Mar 15, 2026
859065d
fix: improve clarity of text by custom folder setting
RyanGroch Mar 15, 2026
ffe31f9
style: reverse prior comment changes
RyanGroch Mar 15, 2026
d2f96b4
fix: allows 'reset to default' button to appear even when user manual…
RyanGroch Mar 15, 2026
be535aa
fix: enforce absolute path constraint
RyanGroch Mar 15, 2026
2a0d91e
fix: catch mkdirSync error to prevent crashing on startup
RyanGroch Mar 15, 2026
a8a166c
refactor: replaces customDirectoryStatus with boolean logic
RyanGroch Mar 16, 2026
67b38dd
fix: invalidate cache at start of setDyadAppsBaseDirectory
RyanGroch Mar 16, 2026
22c86f9
fix: enforce that contents of filePaths exists in handler
RyanGroch Mar 16, 2026
7ce4837
fix: safeguard against race condition in setDyadAppsBaseDirectory
RyanGroch Mar 17, 2026
08bff78
refactor: rename variables, functions, and files to be more consistent
RyanGroch Mar 17, 2026
9c9fc8d
fix: revert race condition safeguard
RyanGroch Mar 17, 2026
178aaef
test: fix misleading test comment
RyanGroch Mar 17, 2026
955603e
fix: include query inside transaction
RyanGroch Mar 17, 2026
c1e6963
fix: await gitAddSafeDirectory
RyanGroch Mar 17, 2026
d50363f
refactor: clear cached custom folder
RyanGroch Mar 17, 2026
c5eba95
fix: ensure directory is writable when checking if accessible
RyanGroch Mar 18, 2026
95087dc
fix: change help text to reflect requirement for write permissions
RyanGroch Mar 18, 2026
9d272a8
fix: prevent resetAll from deleting non-dyad content in custom folders
RyanGroch Mar 19, 2026
bcac2dc
fix: prevent path conversion if user reselects their current directory
RyanGroch Mar 19, 2026
c104214
fix: make folder selection error more accurate
RyanGroch Mar 25, 2026
87546e3
fix: second-guess error message
RyanGroch Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/components/DyadAppsBaseDirectorySelector.tsx
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>
);
}
65 changes: 61 additions & 4 deletions src/ipc/handlers/app_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Comment on lines +858 to +863
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isAppLocationAccessible(...) guards are my effort to account for cases where the user's custom folder is unavailable (e.g. maybe on a separate drive that's not plugged in).

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 (createApp, copyApp, import-app, cloneRepoFromUrl). The main appeal here is having a specific error message for the user, so I wasn't concerned about putting guards in places where the error message might not fit.

const newAppPath = getDyadAppPath(newAppName);

// 3. Copy the app folder
Expand Down Expand Up @@ -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
}

try {
await fsPromises.rm(appPath, { recursive: true, force: true });
} catch (error: any) {
Expand All @@ -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);
}
}
});
});

Expand Down Expand Up @@ -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
}

// 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,
Expand All @@ -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,
Expand Down Expand Up @@ -1937,6 +1991,7 @@ export function registerAppHandlers() {
.update(apps)
.set({ path: nextResolvedPath })
.where(eq(apps.id, appId));
await cleanupSymlink();
return {
resolvedPath: nextResolvedPath,
};
Expand Down Expand Up @@ -1978,6 +2033,8 @@ export function registerAppHandlers() {
);
}

await cleanupSymlink();

return {
resolvedPath: nextResolvedPath,
};
Expand Down
137 changes: 137 additions & 0 deletions src/ipc/handlers/dyad_apps_base_directory_handlers.ts
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();
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 });

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") {
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();
},
);
}
2 changes: 2 additions & 0 deletions src/ipc/ipc_host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { registerChatStreamHandlers } from "./handlers/chat_stream_handlers";
import { registerSettingsHandlers } from "./handlers/settings_handlers";
import { registerShellHandlers } from "./handlers/shell_handler";
import { registerDependencyHandlers } from "./handlers/dependency_handlers";
import { registerDyadAppsBaseDirectoryHandlers } from "./handlers/dyad_apps_base_directory_handlers";
import { registerGithubHandlers } from "./handlers/github_handlers";
import { registerGithubBranchHandlers } from "./handlers/git_branch_handlers";
import { registerVercelHandlers } from "./handlers/vercel_handlers";
Expand Down Expand Up @@ -47,6 +48,7 @@ export function registerIpcHandlers() {
registerSettingsHandlers();
registerShellHandlers();
registerDependencyHandlers();
registerDyadAppsBaseDirectoryHandlers();
registerGithubHandlers();
registerGithubBranchHandlers();
registerVercelHandlers();
Expand Down
Loading