Unverified 提交 583334f2 authored 作者: Ryan Groch's avatar Ryan Groch 提交者: GitHub

Feat: allow user to choose which directory to save Dyad apps in (#2875)

Closes #399. Adds a setting (under "General Settings") to select a custom directory to store new apps in, replacing the default `dyad-apps` folder. In order to make sure that users don't lose access to older apps, I opted for creating symlinks inside the new folder to the old app locations. I went with symlinking because moving every single app could be an expensive operation depending on how many apps there are, and the user might not want that. However, this is inconsistent with the import apps and move folder features (i.e. #2000), which copy the apps instead of symlinking. I'm happy to change the approach on request. Some options I've been thinking about: - Add a button in the settings which moves all the apps (replacing the symlinks) after the user chooses a new custom directory. This way, the user would get to choose. - Convert all of previously-created apps to absolute paths, which avoids all of the symlinking. The only potential issue with this is that if the user wants to move the apps to their new directory after all, they'd have to use the "move app" feature for every single app, otherwise the database wouldn't get updated. As is, they at least could just delete all the symlinks and mass-move all of their apps into the new directory (though they'd currently have to do this outside of Dyad). Also, since I'm tentatively adding in the use of symlinks, I modified the move/copy/delete app features so that they always target the true location of the app, not the symlink. I can also write tests if desired. <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2875" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
上级 ed87736d
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("new apps are stored in the user's custom folder", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.navigation.goToSettingsTab();
const defaultBasePath = path.join(po.userDataDir, "dyad-apps");
const newBasePath = path.join(po.userDataDir, "alt-app-storage");
if (!fs.existsSync(newBasePath)) {
fs.mkdirSync(newBasePath, { recursive: true });
}
const browseButton = po.page.getByTestId("customize-apps-folder-button");
// Stub the file dialog to return the new base path BEFORE clicking the button
await eph.stubDialog(po.electronApp, "showOpenDialog", {
filePaths: [newBasePath],
});
await browseButton.click();
// Create new app after customizing directory path
await po.navigation.goToAppsTab();
await po.sendPrompt("hello");
const appName = await po.appManagement.getCurrentAppName();
expect(appName).toBeTruthy();
// The app should be in the custom directory, not the default
expect(fs.existsSync(path.join(newBasePath, appName!))).toBe(true);
expect(fs.existsSync(path.join(defaultBasePath, appName!))).toBe(false);
});
test("store apps in default folder after resetting path", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.navigation.goToSettingsTab();
const defaultBasePath = path.join(po.userDataDir, "dyad-apps");
const newBasePath = path.join(po.userDataDir, "alt-app-storage");
if (!fs.existsSync(newBasePath)) {
fs.mkdirSync(newBasePath, { recursive: true });
}
const browseButton = po.page.getByTestId("customize-apps-folder-button");
// Customize directory path
await eph.stubDialog(po.electronApp, "showOpenDialog", {
filePaths: [newBasePath],
});
await browseButton.click();
// Immediately reset directory path to default
const resetButton = po.page.getByRole("button", {
name: /Reset to Default/i,
});
await expect(resetButton).toBeVisible();
await resetButton.click();
// Create an app under the default path
await po.navigation.goToAppsTab();
await po.sendPrompt("hello");
const appName = await po.appManagement.getCurrentAppName();
expect(appName).toBeTruthy();
// App should be located under the default path
expect(fs.existsSync(path.join(newBasePath, appName!))).toBe(false);
expect(fs.existsSync(path.join(defaultBasePath, appName!))).toBe(true);
});
test("custom folder change doesn't make apps inaccessible", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.navigation.goToSettingsTab();
const defaultBasePath = path.join(po.userDataDir, "dyad-apps");
const newBasePath = path.join(po.userDataDir, "alt-app-storage");
if (!fs.existsSync(newBasePath)) {
fs.mkdirSync(newBasePath, { recursive: true });
}
const browseButton = po.page.getByTestId("customize-apps-folder-button");
// Customize directory path
await eph.stubDialog(po.electronApp, "showOpenDialog", {
filePaths: [newBasePath],
});
await browseButton.click();
// Create an app under the custom path
await po.navigation.goToAppsTab();
await po.sendPrompt("hello");
const appName = await po.appManagement.getCurrentAppName();
expect(appName).toBeTruthy();
// Reset directory path to default
await po.navigation.goToSettingsTab();
const resetButton = po.page.getByRole("button", {
name: /Reset to Default/i,
});
await resetButton.click();
await po.navigation.goToAppsTab();
await po.appManagement.clickAppListItem({ appName: appName! });
await po.appManagement.clickOpenInChatButton();
// Should be able to start up app; if we can't then we'll see an error
let toast;
try {
toast = await po.page.waitForSelector(
`[data-sonner-toast]:has-text("Error")`,
{
timeout: Timeout.SHORT,
},
);
} catch {
// Fall through
}
expect(toast).toBe(undefined);
const appPathIfCustom = path.join(newBasePath, appName!);
const appPathIfDefault = path.join(defaultBasePath, appName!);
// App should still be located in the custom directory
expect(fs.existsSync(appPathIfCustom)).toBe(true);
expect(fs.existsSync(appPathIfDefault)).toBe(false);
});
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 CustomAppsFolderSelector() {
const [isSelectingPath, setIsSelectingPath] = useState(false);
const [customAppsFolder, setCustomAppsFolder] =
useState<string>("Loading...");
const [isPathAvailable, setIsPathAvailable] = useState(true);
const [isPathDefault, setIsPathDefault] = useState(true);
useEffect(() => {
// Fetch path on mount
fetchCustomAppsFolder();
}, []);
const handleSelectCustomAppsFolder = async () => {
setIsSelectingPath(true);
try {
// Call the IPC method to select folder
const result = await ipc.system.selectCustomAppsFolder();
if (result.path) {
// Save the custom path to settings
await ipc.system.setCustomAppsFolder(result.path);
await fetchCustomAppsFolder();
showSuccess("Custom apps folder updated successfully");
} else if (result.path === null && result.canceled === false) {
showError(
"Unable to use selected folder. Please ensure it is a valid directory with write permissions.",
);
}
} catch (error: any) {
showError(`Failed to set custom apps folder: ${error.message}`);
} finally {
setIsSelectingPath(false);
}
};
const handleResetToDefault = async () => {
try {
// Clear the custom path
await ipc.system.setCustomAppsFolder(null);
// Update UI to show default directory
await fetchCustomAppsFolder();
showSuccess("Dyad apps folder reset successfully");
} catch (error: any) {
showError(`Failed to reset Dyad Apps folder path: ${error.message}`);
}
};
const fetchCustomAppsFolder = async () => {
try {
const { path, isPathAvailable, isPathDefault } =
await ipc.system.getCustomAppsFolder();
setCustomAppsFolder(path);
setIsPathAvailable(isPathAvailable);
setIsPathDefault(isPathDefault);
} 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">Customize Apps Folder</Label>
<Button
onClick={handleSelectCustomAppsFolder}
disabled={isSelectingPath}
variant="outline"
size="sm"
className="flex items-center gap-2"
data-testid="customize-apps-folder-button"
>
<FolderOpen className="w-4 h-4" />
{isSelectingPath ? "Selecting..." : "Select A Folder"}
</Button>
{!isPathDefault && (
<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">
{isPathDefault ? "Default Folder:" : "Custom Folder:"}
</span>
</div>
<p
className={`text-sm font-mono ${isPathAvailable ? "text-gray-700 dark:text-gray-300" : "text-red-800 dark:text-red-400"} break-all max-h-32 overflow-y-auto`}
>
{customAppsFolder}
</p>
</div>
</div>
</div>
{/* Help Text */}
<div className="text-sm text-gray-500 dark:text-gray-400">
<p>
{isPathAvailable
? "This is the top-level folder that Dyad will store new applications in."
: "Your apps folder is inaccessible. Make sure that the folder exists and has write permissions, or change it."}
</p>
</div>
</div>
</div>
);
}
......@@ -8,7 +8,7 @@ import * as schema from "./schema";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import path from "node:path";
import fs from "node:fs";
import { getDyadAppPath, getUserDataPath } from "../paths/paths";
import { getUserDataPath } from "../paths/paths";
import log from "electron-log";
const logger = log.scope("db");
......@@ -48,7 +48,6 @@ export function initializeDatabase(): BetterSQLite3Database<typeof schema> & {
}
fs.mkdirSync(getUserDataPath(), { recursive: true });
fs.mkdirSync(getDyadAppPath("."), { recursive: true });
const sqlite = new Database(dbPath, { timeout: 10000 });
sqlite.pragma("foreign_keys = ON");
......
......@@ -9,7 +9,14 @@ import { miscContracts } from "../types/misc";
import { systemContracts } from "../types/system";
import fs from "node:fs";
import path from "node:path";
import { getDyadAppPath, getUserDataPath } from "../../paths/paths";
import {
getDyadAppPath,
getDefaultDyadAppsDirectory,
isAppLocationAccessible,
getUserDataPath,
getDyadAppsBaseDirectory,
invalidateDyadAppsBaseDirectoryCache,
} from "../../paths/paths";
import { ChildProcess, spawn } from "node:child_process";
import { promises as fsPromises } from "node:fs";
......@@ -845,6 +852,13 @@ export function registerAppHandlers() {
createTypedHandler(appContracts.createApp, async (_, params) => {
const appPath = params.name;
const fullAppPath = getDyadAppPath(appPath);
if (!isAppLocationAccessible(fullAppPath)) {
throw new Error(
`The path ${fullAppPath} is inaccessible. Please check your custom apps folder setting.`,
);
}
if (fs.existsSync(fullAppPath)) {
throw new DyadError(
`App already exists at: ${fullAppPath}`,
......@@ -927,6 +941,12 @@ export function registerAppHandlers() {
const originalAppPath = getDyadAppPath(originalApp.path);
const newAppPath = getDyadAppPath(newAppName);
if (!isAppLocationAccessible(newAppPath)) {
throw new Error(
`The path ${newAppPath} is inaccessible. Please check your custom apps folder setting.`,
);
}
// 3. Copy the app folder
try {
await copyDir(
......@@ -1702,6 +1722,12 @@ export function registerAppHandlers() {
}
}
logger.log("all running apps stopped.");
// Determine the paths of all apps in the database so that we can delete them.
// We do the deletion last, so technically this is a TOCTOU race, but
// it allows us to do the deletion last after removing the database
const allAppPaths = await db.select({ appPath: apps.path }).from(apps);
// To resolve app paths later
const basePath = getDyadAppsBaseDirectory();
logger.log("deleting database...");
// 1. Drop the database by deleting the SQLite file
const dbPath = getDatabasePath();
......@@ -1723,12 +1749,26 @@ export function registerAppHandlers() {
await fsPromises.unlink(settingsPath);
logger.log(`Settings file deleted: ${settingsPath}`);
}
// Reset base directory cache to default, because settings are gone anyway
invalidateDyadAppsBaseDirectoryCache();
logger.log("settings deleted.");
// 3. Remove all app files recursively
// Doing this last because it's the most time-consuming and the least important
// in terms of resetting the app state.
logger.log("removing all app files...");
const dyadAppPath = getDyadAppPath(".");
// Delete any app paths that were in the database before we deleted it
for (const { appPath } of allAppPaths) {
// We don't rely on getDyadAppPath here because we've already cleared the settings
const resolvedAppPath = path.isAbsolute(appPath)
? appPath
: path.join(basePath, appPath);
await fsPromises.rm(resolvedAppPath, {
recursive: true,
force: true,
});
}
const dyadAppPath = getDefaultDyadAppsDirectory();
// Delete the default `dyad-apps` folder, even if the user no longer uses it
if (fs.existsSync(dyadAppPath)) {
await fsPromises.rm(dyadAppPath, { recursive: true, force: true });
// Recreate the base directory
......
import { dialog } from "electron";
import { mkdir } from "fs/promises";
import log from "electron-log";
import { join, isAbsolute, normalize } from "path";
import { db } from "../../db";
import { apps } from "../../db/schema";
import { eq } from "drizzle-orm";
import { createTypedHandler } from "./base";
import { systemContracts } from "../types/system";
import {
getCustomFolderCache,
getDefaultDyadAppsDirectory,
getDyadAppsBaseDirectory,
invalidateDyadAppsBaseDirectoryCache,
isDirectoryAccessible,
} from "@/paths/paths";
import { gitAddSafeDirectory } from "../utils/git_utils";
import { readSettings, writeSettings } from "@/main/settings";
const logger = log.scope("custom_apps_folder_handlers");
export function registerCustomAppsFolderHandlers() {
createTypedHandler(systemContracts.getCustomAppsFolder, async () => {
invalidateDyadAppsBaseDirectoryCache(); // ensure UI is up-to-date
const directory = getDyadAppsBaseDirectory();
return {
path: directory,
isPathAvailable: isDirectoryAccessible(directory),
isPathDefault: getCustomFolderCache() == null, // if null or undefined
};
});
createTypedHandler(systemContracts.selectCustomAppsFolder, async () => {
const { filePaths, canceled } = await dialog.showOpenDialog({
title: "Select Custom Apps Folder",
properties: ["openDirectory"],
message: "Select the folder where Dyad apps should be stored",
});
if (canceled) {
return { path: null, canceled: true };
}
const dirPath = filePaths[0];
if (!dirPath || !isAbsolute(dirPath) || !isDirectoryAccessible(dirPath)) {
return { path: null, canceled: false };
}
return { path: dirPath, canceled: false };
});
createTypedHandler(systemContracts.setCustomAppsFolder, async (_, input) => {
// Ensure fresh settings read
invalidateDyadAppsBaseDirectoryCache();
const prevPath = getDyadAppsBaseDirectory();
let newDyadAppsBaseDir = getDefaultDyadAppsDirectory();
let updatedSettingValue = null;
if (input) {
// Custom path; cannot be relative
if (!isAbsolute(input)) throw new Error("Directory path is not absolute");
// Make sure it exists
if (!isDirectoryAccessible(input))
throw new Error("Path is not a directory");
newDyadAppsBaseDir = normalize(input);
updatedSettingValue = newDyadAppsBaseDir;
} else {
// Resetting to default
await mkdir(newDyadAppsBaseDir, { recursive: true });
}
// Only convert paths and make git config changes if the user selected
// a directory different from the one they're currently using
if (newDyadAppsBaseDir !== prevPath) {
logger.info("Beginning path updates");
// We don't want to make current apps inaccessible after changing the directory.
// So, convert all current apps to absolute paths.
db.transaction((tx) => {
const allApps = tx.select().from(apps).all();
for (const app of allApps) {
if (isAbsolute(app.path)) {
logger.info(
`${app.name} already has an absolute path; skipping path update`,
);
continue;
}
const newPath = join(prevPath, app.path);
logger.info(
`updating ${app.name} from relative path ${app.path} to absolute path ${newPath}`,
);
tx.update(apps)
.set({
path: newPath,
})
.where(eq(apps.id, app.id))
.run();
}
});
// Add custom apps folder to git safe.directory (required for Windows).
// The trailing /* allows access to all repositories under the named directory.
// See: https://git-scm.com/docs/git-config#Documentation/git-config.txt-safedirectory
if (readSettings().enableNativeGit) {
const directory = updatedSettingValue ?? getDefaultDyadAppsDirectory();
await gitAddSafeDirectory(`${directory}/*`);
}
}
writeSettings({
customAppsFolder: updatedSettingValue,
});
invalidateDyadAppsBaseDirectoryCache();
});
}
......@@ -26,7 +26,7 @@ import {
} from "../utils/git_utils";
import * as schema from "../../db/schema";
import fs from "node:fs";
import { getDyadAppPath } from "../../paths/paths";
import { getDyadAppPath, isAppLocationAccessible } from "../../paths/paths";
import { db } from "../../db";
import { apps } from "../../db/schema";
import { eq } from "drizzle-orm";
......@@ -1288,6 +1288,13 @@ async function handleCloneRepoFromUrl(
}
const appPath = getDyadAppPath(finalAppName);
if (!isAppLocationAccessible(appPath)) {
throw new Error(
`The path ${appPath} is inaccessible. Please check your custom apps folder setting.`,
);
}
// Ensure the app directory exists if native git is disabled
if (!settings.enableNativeGit) {
if (!fs.existsSync(appPath)) {
......
......@@ -3,7 +3,7 @@ import fs from "fs/promises";
import path from "path";
import { createLoggedHandler } from "./safe_handle";
import log from "electron-log";
import { getDyadAppPath } from "../../paths/paths";
import { getDyadAppPath, isAppLocationAccessible } from "../../paths/paths";
import { apps } from "@/db/schema";
import { db } from "@/db";
import { chats } from "@/db/schema";
......@@ -99,6 +99,12 @@ export function registerImportHandlers() {
const appPath = skipCopy ? sourcePath : getDyadAppPath(appName);
if (!skipCopy) {
if (!isAppLocationAccessible(appPath)) {
throw new Error(
`The path ${appPath} is inaccessible. Please check your custom apps folder setting.`,
);
}
// Check if the app already exists in dyad-apps
const errorMessage = "An app with this name already exists";
try {
......
......@@ -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 { registerCustomAppsFolderHandlers } from "./handlers/custom_apps_folder_handlers";
import { registerGithubHandlers } from "./handlers/github_handlers";
import { registerGithubBranchHandlers } from "./handlers/git_branch_handlers";
import { registerVercelHandlers } from "./handlers/vercel_handlers";
......@@ -49,6 +50,7 @@ export function registerIpcHandlers() {
registerSettingsHandlers();
registerShellHandlers();
registerDependencyHandlers();
registerCustomAppsFolderHandlers();
registerGithubHandlers();
registerGithubBranchHandlers();
registerVercelHandlers();
......
......@@ -49,11 +49,17 @@ export const SelectAppFolderResultSchema = z.object({
name: z.string().nullable(),
});
export const SelectAppLocationResultSchema = z.object({
export const SelectCustomAppsFolderResultSchema = z.object({
path: z.string().nullable(),
canceled: z.boolean(),
});
export const GetCustomAppsFolderResultSchema = z.object({
path: z.string(),
isPathAvailable: z.boolean(),
isPathDefault: z.boolean(),
});
export const DoesReleaseNoteExistParamsSchema = z.object({
version: z.string(),
});
......@@ -168,6 +174,25 @@ export const systemContracts = {
output: SelectAppFolderResultSchema,
}),
// Custom apps folder
getCustomAppsFolder: defineContract({
channel: "get-custom-apps-folder",
input: z.void(),
output: GetCustomAppsFolderResultSchema,
}),
selectCustomAppsFolder: defineContract({
channel: "select-custom-apps-folder",
input: z.void(),
output: SelectCustomAppsFolderResultSchema,
}),
setCustomAppsFolder: defineContract({
channel: "set-custom-apps-folder",
input: z.string().nullable(),
output: z.void(),
}),
// External
openExternalUrl: defineContract({
channel: "open-external-url",
......
......@@ -338,6 +338,7 @@ const BaseUserSettingsFields = {
releaseChannel: ReleaseChannelSchema,
runtimeMode2: RuntimeMode2Schema.optional(),
customNodePath: z.string().optional().nullable(),
customAppsFolder: z.string().optional().nullable(),
isRunning: z.boolean().optional(),
lastKnownPerformance: z
.object({
......
......@@ -18,6 +18,7 @@ export const SETTING_IDS = {
releaseChannel: "setting-release-channel",
runtimeMode: "setting-runtime-mode",
nodePath: "setting-node-path",
customAppsFolder: "setting-custom-apps-folder",
defaultChatMode: "setting-default-chat-mode",
autoApprove: "setting-auto-approve",
autoFix: "setting-auto-fix",
......@@ -98,6 +99,15 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [
sectionId: SECTION_IDS.general,
sectionLabel: "General",
},
{
id: SETTING_IDS.customAppsFolder,
label: "Customize Apps Folder",
description:
"Set the top-level folder that Dyad will store new applications in",
keywords: ["customize", "apps", "path", "folder", "directory", "dyad-apps"],
sectionId: SECTION_IDS.general,
sectionLabel: "General",
},
// Workflow Settings
{
......
......@@ -17,7 +17,7 @@ import { useRouter } from "@tanstack/react-router";
import { GitHubIntegration } from "@/components/GitHubIntegration";
import { VercelIntegration } from "@/components/VercelIntegration";
import { SupabaseIntegration } from "@/components/SupabaseIntegration";
import { CustomAppsFolderSelector } from "@/components/CustomAppsFolderSelector";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { AutoFixProblemsSwitch } from "@/components/AutoFixProblemsSwitch";
......@@ -362,6 +362,9 @@ export function GeneralSettings({ appVersion }: { appVersion: string | null }) {
<div id={SETTING_IDS.nodePath} className="mt-4">
<NodePathSelector />
</div>
<div id={SETTING_IDS.customAppsFolder} className="mt-4">
<CustomAppsFolderSelector />
</div>
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400 mt-4">
<span className="mr-2 font-medium">App Version:</span>
......
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
import { readSettings } from "../main/settings";
// Cached result of getDyadAppsBaseDirectory
let cachedBaseDirectory: string | null = null;
let cachedCustomFolderSetting: string | null | undefined;
// Whether `dyad-apps` has been created
let defaultDirCreated = false;
/**
* Gets the base dyad-apps directory path (without a specific app subdirectory)
* Gets the default path of the base dyad-apps directory (without a specific app subdirectory)
*/
export function getDyadAppsBaseDirectory(): string {
export function getDefaultDyadAppsDirectory(): string {
if (IS_TEST_BUILD) {
const electron = getElectron();
return path.join(electron!.app.getPath("userData"), "dyad-apps");
......@@ -13,15 +21,85 @@ export function getDyadAppsBaseDirectory(): string {
return path.join(os.homedir(), "dyad-apps");
}
/**
* Gets the default path of the base dyad-apps directory (without a specific app subdirectory),
* but creates the directory the first time that this function is called
*/
function resolveDefaultDyadAppsDirectory(): string {
const defaultDir = getDefaultDyadAppsDirectory();
if (!defaultDirCreated) {
try {
fs.mkdirSync(defaultDir, { recursive: true });
defaultDirCreated = true;
} catch {
// Fall through; if it fails then the user will see error toasts
// when they try to do anything meaningful, but we don't want Dyad to crash
}
}
return defaultDir;
}
/**
* Clears base directory cache, so the next call to getDyadAppsBaseDirectory will re-read the settings
*/
export function invalidateDyadAppsBaseDirectoryCache(): void {
cachedBaseDirectory = null;
cachedCustomFolderSetting = undefined;
}
/**
* Returns the cached value of the custom folder path
*/
export function getCustomFolderCache(): string | null | undefined {
return cachedCustomFolderSetting;
}
/**
* Gets the user's preferred apps directory path (without a specific app subdirectory)
*/
export function getDyadAppsBaseDirectory(): string {
const appsPath =
cachedBaseDirectory ??
(cachedCustomFolderSetting = readSettings().customAppsFolder) ??
resolveDefaultDyadAppsDirectory();
cachedBaseDirectory = appsPath;
return cachedBaseDirectory;
}
/**
* Given a path, determines whether that path exists, is a directory, and is writable.
* Can determine, for example, whether the output of `getDyadAppsBaseDirectory` is usable
*/
export function isDirectoryAccessible(directoryPath: string): boolean {
try {
const st = fs.statSync(directoryPath);
if (!st.isDirectory()) return false;
fs.accessSync(directoryPath, fs.constants.W_OK);
return true;
} catch {
return false;
}
}
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
// Otherwise, use the user's preferred base path
return path.join(getDyadAppsBaseDirectory(), appPath);
}
/**
* Given an app path, determines whether that path is accessible within the filesystem.
* The input to this function is assumed to be the result of `getDyadAppPath`.
*/
export function isAppLocationAccessible(resolvedPath: string): boolean {
const containingFolder = path.dirname(resolvedPath);
return isDirectoryAccessible(containingFolder);
}
export function getTypeScriptCachePath(): string {
const electron = getElectron();
return path.join(electron!.app.getPath("sessionData"), "typescript-cache");
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论