feat(desktop): add configurable worktree location#1626
Conversation
Allow users to customize where worktrees are created on disk, both globally (in Behavior settings) and per-project (in Project settings).
📝 WalkthroughWalkthroughThis PR adds configurable worktree base directories (global and per-project), database columns to persist them, TRPC endpoints and a directory picker UI, a WorktreeLocationPicker component, and a centralized resolveWorktreePath utility used when creating workspaces. Changes
Sequence DiagramsequenceDiagram
participant User
participant UI as WorktreeLocationPicker
participant TRPC as TRPC Routers<br/>(window/settings/projects)
participant DB as LocalDB<br/>(settings/projects)
participant Resolver as resolveWorktreePath
rect rgba(100,150,255,0.5)
User->>UI: Click "Browse..."
UI->>TRPC: selectDirectory(title, defaultPath)
TRPC->>User: Open native directory picker
User->>TRPC: Select directory
TRPC->>UI: Return { canceled: false, path }
UI->>TRPC: setWorktreeBaseDir(path) or updateProject({ worktreeBaseDir: path })
TRPC->>DB: Upsert worktree_base_dir
DB->>TRPC: Confirm write
TRPC->>UI: Return success
UI->>User: Show updated path
end
rect rgba(150,200,100,0.5)
Resolver->>DB: Read project.worktree_base_dir
alt project has value
DB->>Resolver: Return project.worktree_base_dir
else
Resolver->>DB: Read settings.worktree_base_dir
alt settings has value
DB->>Resolver: Return settings.worktree_base_dir
else
Resolver->>Resolver: Use default (~/.superset/worktrees)
end
end
Resolver->>Resolver: Return baseDir/projectName/branch
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🚀 Preview Deployment🔗 Preview Links
Preview updates automatically with new commits |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (4)
apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts (1)
450-465: Consider adding"workspace"to both new items' keyword lists for better search discoverability.Other worktree-adjacent behavior entries (
BEHAVIOR_DELETE_LOCAL_BRANCH,BEHAVIOR_BRANCH_PREFIX) include"workspace"as a keyword since that's the user-facing term for a worktree in this app. A user typing "workspace location" or "workspace directory" won't surface either of these new settings.✨ Suggested keyword additions
keywords: [ "worktree", "location", "directory", "path", "folder", "storage", "base", "default", + "workspace", ],keywords: [ "project", "worktree", "location", "directory", "path", "folder", "storage", "override", + "workspace", ],Also applies to: 726-741
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts` around lines 450 - 465, The settings entries for the new worktree-related items need "workspace" added to their keywords for better search discoverability; update the keywords array for SETTING_ITEM_ID.BEHAVIOR_WORKTREE_LOCATION and the other newly added worktree-related setting (the second new item in the same changeset) to include the string "workspace" so searches like "workspace location" or "workspace directory" will match.apps/desktop/src/lib/trpc/routers/settings/index.ts (1)
629-642: Consider validating path accessibility before persisting.
setWorktreeBaseDirstores an arbitrary string without checking that the path exists or is a writable directory. The error would surface only at workspace creation time (via the failinggit worktree add), which can be confusing for users.Since the primary flow goes through the OS directory picker (
selectDirectory), this is largely self-mitigating, but defensive validation would improve the experience if the API is called by other means.🛡️ Optional: add fs-level validation
+import { statSync } from "node:fs"; + setWorktreeBaseDir: publicProcedure .input(z.object({ path: z.string().nullable() })) .mutation(({ input }) => { + if (input.path !== null) { + try { + const stat = statSync(input.path); + if (!stat.isDirectory()) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Path must be a directory" }); + } + } catch (err) { + if (err instanceof TRPCError) throw err; + throw new TRPCError({ code: "BAD_REQUEST", message: `Directory does not exist: ${input.path}` }); + } + } localDb🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/lib/trpc/routers/settings/index.ts` around lines 629 - 642, The setWorktreeBaseDir mutation currently persists whatever string is passed without checking that the path exists and is a writable directory; change the mutation to be async, validate input.path using Node fs (e.g., fs.promises.stat and fs.access with fs.constants.W_OK) to ensure the path exists and is a directory and is writable, and if validation fails throw a TRPC error (e.g., BAD_REQUEST) instead of persisting; only after successful validation perform the localDb.insert(...).onConflictDoUpdate(...).run() call to save the worktreeBaseDir in the settings table.apps/desktop/src/lib/trpc/routers/workspaces/utils/resolve-worktree-path.ts (1)
34-34: Useproject.idto ensure unique worktree paths for same-named projects.
project.nameis not unique in the schema — multiple projects can have the same name. If they share the samebaseDirand branch name,resolveWorktreePathreturns identical paths, causing the secondgit worktree addto fail.Include
project.idto guarantee uniqueness:♻️ Proposed fix: use a unique path component
- return join(baseDir, project.name, branch); + // Include project id prefix to avoid collisions between same-named projects + return join(baseDir, `${project.name}-${project.id.slice(0, 8)}`, branch);Update the
Pickconstraint to includeid:export function resolveWorktreePath( - project: Pick<SelectProject, "name" | "worktreeBaseDir">, + project: Pick<SelectProject, "id" | "name" | "worktreeBaseDir">, branch: string, ): string {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/resolve-worktree-path.ts` at line 34, resolveWorktreePath currently builds worktree paths using project.name which is not unique; change resolveWorktreePath to include project.id (e.g., join(baseDir, project.id, project.name, branch) or join(baseDir, `${project.id}-${project.name}`, branch)) so paths are unique per project id, and update the Pick type/param that supplies project to include id (in addition to name) so callers still type-check.apps/desktop/src/lib/trpc/routers/window.ts (1)
75-75: Redundant?? undefined—input?.defaultPathis alreadystring | undefined
z.string().optional()producesstring | undefined(nevernull), soinput?.defaultPath ?? undefinedis identical toinput?.defaultPath. The null-coalescing fallback is a no-op here.🔧 Suggested cleanup
- defaultPath: input?.defaultPath ?? undefined, + defaultPath: input?.defaultPath,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/lib/trpc/routers/window.ts` at line 75, The property assignment uses a redundant null-coalescing fallback: replace the expression "input?.defaultPath ?? undefined" with the simpler "input?.defaultPath" because the schema (z.string().optional()) already yields string | undefined; update the code where the object property defaultPath is set (look for the assignment using input?.defaultPath) to remove the "?? undefined" no-op.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/behavior/components/BehaviorSettings/BehaviorSettings.tsx`:
- Line 419: The file's BehaviorSettings component passes
defaultBrowsePath={worktreeBaseDir} which can be null/undefined so the OS dialog
opens at the OS default instead of the app default; update BehaviorSettings to
provide a fallback when worktreeBaseDir is unset (e.g., compute a
resolvedBrowsePath = worktreeBaseDir ?? APP_DEFAULT_WORKTREE_DIR or call a
helper like getDefaultWorktreeDir()) and pass
defaultBrowsePath={resolvedBrowsePath} so the browse dialog always pre-navigates
to the app default; reference the defaultBrowsePath prop and the worktreeBaseDir
value in your change.
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/components/WorktreeLocationPicker/WorktreeLocationPicker.tsx`:
- Around line 29-38: The call to selectDirectory.mutateAsync in handleBrowse can
reject and cause an unhandled promise—wrap the await in a try/catch (or replace
mutateAsync with mutate + onSuccess) so rejections are handled; specifically,
update handleBrowse (which calls selectDirectory.mutateAsync) to catch errors
and either log/handle them and only call onSelect(result.path) when there is no
error, or switch to selectDirectory.mutate({ input }, { onSuccess: res => { if
(!res.canceled && res.path) onSelect(res.path) }, onError: err => { /*
handle/log */ } }) to let react-query handle errors.
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/project/`$projectId/components/ProjectSettings/ProjectSettings.tsx:
- Line 346: The defaultBrowsePath prop currently uses project.worktreeBaseDir ??
globalWorktreeBaseDir which can be undefined; change it to use the precomputed
defaultWorktreePath (which already incorporates project/global fallbacks) so the
OS dialog opens at the intended app default when both are unset; update the JSX
prop defaultBrowsePath={defaultWorktreePath} to reference defaultWorktreePath
instead of project.worktreeBaseDir/globalWorktreeBaseDir.
---
Nitpick comments:
In `@apps/desktop/src/lib/trpc/routers/settings/index.ts`:
- Around line 629-642: The setWorktreeBaseDir mutation currently persists
whatever string is passed without checking that the path exists and is a
writable directory; change the mutation to be async, validate input.path using
Node fs (e.g., fs.promises.stat and fs.access with fs.constants.W_OK) to ensure
the path exists and is a directory and is writable, and if validation fails
throw a TRPC error (e.g., BAD_REQUEST) instead of persisting; only after
successful validation perform the
localDb.insert(...).onConflictDoUpdate(...).run() call to save the
worktreeBaseDir in the settings table.
In `@apps/desktop/src/lib/trpc/routers/window.ts`:
- Line 75: The property assignment uses a redundant null-coalescing fallback:
replace the expression "input?.defaultPath ?? undefined" with the simpler
"input?.defaultPath" because the schema (z.string().optional()) already yields
string | undefined; update the code where the object property defaultPath is set
(look for the assignment using input?.defaultPath) to remove the "?? undefined"
no-op.
In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/resolve-worktree-path.ts`:
- Line 34: resolveWorktreePath currently builds worktree paths using
project.name which is not unique; change resolveWorktreePath to include
project.id (e.g., join(baseDir, project.id, project.name, branch) or
join(baseDir, `${project.id}-${project.name}`, branch)) so paths are unique per
project id, and update the Pick type/param that supplies project to include id
(in addition to name) so callers still type-check.
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts`:
- Around line 450-465: The settings entries for the new worktree-related items
need "workspace" added to their keywords for better search discoverability;
update the keywords array for SETTING_ITEM_ID.BEHAVIOR_WORKTREE_LOCATION and the
other newly added worktree-related setting (the second new item in the same
changeset) to include the string "workspace" so searches like "workspace
location" or "workspace directory" will match.
| <WorktreeLocationPicker | ||
| currentPath={worktreeBaseDir} | ||
| defaultPathLabel={`Default (${defaultWorktreePath})`} | ||
| defaultBrowsePath={worktreeBaseDir} |
There was a problem hiding this comment.
Browse dialog doesn't pre-navigate to the app default when no global path is saved
defaultBrowsePath={worktreeBaseDir} passes null/undefined when the global setting is unset, so the OS dialog opens at the OS default (e.g., ~/), not at ~/.superset/worktrees. The label correctly shows the default, but the dialog starting directory is inconsistent with it.
🔧 Suggested fix
- defaultBrowsePath={worktreeBaseDir}
+ defaultBrowsePath={worktreeBaseDir ?? defaultWorktreePath}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| defaultBrowsePath={worktreeBaseDir} | |
| defaultBrowsePath={worktreeBaseDir ?? defaultWorktreePath} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/behavior/components/BehaviorSettings/BehaviorSettings.tsx`
at line 419, The file's BehaviorSettings component passes
defaultBrowsePath={worktreeBaseDir} which can be null/undefined so the OS dialog
opens at the OS default instead of the app default; update BehaviorSettings to
provide a fallback when worktreeBaseDir is unset (e.g., compute a
resolvedBrowsePath = worktreeBaseDir ?? APP_DEFAULT_WORKTREE_DIR or call a
helper like getDefaultWorktreeDir()) and pass
defaultBrowsePath={resolvedBrowsePath} so the browse dialog always pre-navigates
to the app default; reference the defaultBrowsePath prop and the worktreeBaseDir
value in your change.
| const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); | ||
|
|
||
| const handleBrowse = async () => { | ||
| const result = await selectDirectory.mutateAsync({ | ||
| title: dialogTitle, | ||
| defaultPath: defaultBrowsePath ?? undefined, | ||
| }); | ||
| if (!result.canceled && result.path) { | ||
| onSelect(result.path); | ||
| } |
There was a problem hiding this comment.
Unhandled promise rejection: mutateAsync without try-catch
mutateAsync returns a promise that can throw; if no try-catch (or .catch) is present, a failed IPC call produces an unhandled promise rejection. Unhandled promise rejections occur when the browser sees a failed Promise without a .catch anywhere — using .mutate instead lets react-query catch it automatically.
If the Electron IPC channel errors (e.g., window is destroyed mid-flight), the rejection surfaces as a fatal unhandled error in the renderer process.
Two valid fixes:
Option A — wrap with try-catch (keeps mutateAsync):
🛡️ Proposed fix (try-catch)
- const handleBrowse = async () => {
- const result = await selectDirectory.mutateAsync({
- title: dialogTitle,
- defaultPath: defaultBrowsePath ?? undefined,
- });
- if (!result.canceled && result.path) {
- onSelect(result.path);
- }
- };
+ const handleBrowse = async () => {
+ try {
+ const result = await selectDirectory.mutateAsync({
+ title: dialogTitle,
+ defaultPath: defaultBrowsePath ?? undefined,
+ });
+ if (!result.canceled && result.path) {
+ onSelect(result.path);
+ }
+ } catch {
+ // IPC errors are surfaced via selectDirectory.error
+ }
+ };Option B — switch to .mutate() with onSuccess callback:
🛡️ Proposed fix (mutate + onSuccess)
- const selectDirectory = electronTrpc.window.selectDirectory.useMutation();
+ const selectDirectory = electronTrpc.window.selectDirectory.useMutation({
+ onSuccess: (result) => {
+ if (!result.canceled && result.path) {
+ onSelect(result.path);
+ }
+ },
+ });
- const handleBrowse = async () => {
- const result = await selectDirectory.mutateAsync({
- title: dialogTitle,
- defaultPath: defaultBrowsePath ?? undefined,
- });
- if (!result.canceled && result.path) {
- onSelect(result.path);
- }
- };
+ const handleBrowse = () => {
+ selectDirectory.mutate({
+ title: dialogTitle,
+ defaultPath: defaultBrowsePath ?? undefined,
+ });
+ };📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); | |
| const handleBrowse = async () => { | |
| const result = await selectDirectory.mutateAsync({ | |
| title: dialogTitle, | |
| defaultPath: defaultBrowsePath ?? undefined, | |
| }); | |
| if (!result.canceled && result.path) { | |
| onSelect(result.path); | |
| } | |
| const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); | |
| const handleBrowse = async () => { | |
| try { | |
| const result = await selectDirectory.mutateAsync({ | |
| title: dialogTitle, | |
| defaultPath: defaultBrowsePath ?? undefined, | |
| }); | |
| if (!result.canceled && result.path) { | |
| onSelect(result.path); | |
| } | |
| } catch { | |
| // IPC errors are surfaced via selectDirectory.error | |
| } | |
| }; |
| const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); | |
| const handleBrowse = async () => { | |
| const result = await selectDirectory.mutateAsync({ | |
| title: dialogTitle, | |
| defaultPath: defaultBrowsePath ?? undefined, | |
| }); | |
| if (!result.canceled && result.path) { | |
| onSelect(result.path); | |
| } | |
| const selectDirectory = electronTrpc.window.selectDirectory.useMutation({ | |
| onSuccess: (result) => { | |
| if (!result.canceled && result.path) { | |
| onSelect(result.path); | |
| } | |
| }, | |
| }); | |
| const handleBrowse = () => { | |
| selectDirectory.mutate({ | |
| title: dialogTitle, | |
| defaultPath: defaultBrowsePath ?? undefined, | |
| }); | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/components/WorktreeLocationPicker/WorktreeLocationPicker.tsx`
around lines 29 - 38, The call to selectDirectory.mutateAsync in handleBrowse
can reject and cause an unhandled promise—wrap the await in a try/catch (or
replace mutateAsync with mutate + onSuccess) so rejections are handled;
specifically, update handleBrowse (which calls selectDirectory.mutateAsync) to
catch errors and either log/handle them and only call onSelect(result.path) when
there is no error, or switch to selectDirectory.mutate({ input }, { onSuccess:
res => { if (!res.canceled && res.path) onSelect(res.path) }, onError: err => {
/* handle/log */ } }) to let react-query handle errors.
| currentPath={project.worktreeBaseDir} | ||
| defaultPathLabel={`Using global default: ${globalPath}`} | ||
| dialogTitle="Select worktree location for this project" | ||
| defaultBrowsePath={project.worktreeBaseDir ?? globalWorktreeBaseDir} |
There was a problem hiding this comment.
Browse dialog doesn't fall back to the app default path when neither project nor global path is set
defaultBrowsePath={project.worktreeBaseDir ?? globalWorktreeBaseDir} resolves to undefined when both are unset, opening the OS dialog at ~/ instead of ~/.superset/worktrees — inconsistent with the defaultPathLabel that already shows the computed globalPath.
defaultWorktreePath is already in scope from line 184.
🔧 Suggested fix
- defaultBrowsePath={project.worktreeBaseDir ?? globalWorktreeBaseDir}
+ defaultBrowsePath={project.worktreeBaseDir ?? globalWorktreeBaseDir ?? defaultWorktreePath}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| defaultBrowsePath={project.worktreeBaseDir ?? globalWorktreeBaseDir} | |
| defaultBrowsePath={project.worktreeBaseDir ?? globalWorktreeBaseDir ?? defaultWorktreePath} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/project/`$projectId/components/ProjectSettings/ProjectSettings.tsx
at line 346, The defaultBrowsePath prop currently uses project.worktreeBaseDir
?? globalWorktreeBaseDir which can be undefined; change it to use the
precomputed defaultWorktreePath (which already incorporates project/global
fallbacks) so the OS dialog opens at the intended app default when both are
unset; update the JSX prop defaultBrowsePath={defaultWorktreePath} to reference
defaultWorktreePath instead of project.worktreeBaseDir/globalWorktreeBaseDir.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
apps/desktop/src/lib/trpc/routers/workspaces/utils/resolve-worktree-path.ts (1)
16-16: Narrow theselectto only the needed column.
localDb.select().from(settings).get()fetches every column from the settings row when onlyworktreeBaseDiris required. Drizzle supports column-level selection.♻️ Proposed refactor
- const row = localDb.select().from(settings).get(); - const baseDir = - row?.worktreeBaseDir ?? - join(homedir(), SUPERSET_DIR_NAME, WORKTREES_DIR_NAME); + const row = localDb + .select({ worktreeBaseDir: settings.worktreeBaseDir }) + .from(settings) + .get(); + const baseDir = + row?.worktreeBaseDir ?? + join(homedir(), SUPERSET_DIR_NAME, WORKTREES_DIR_NAME);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/resolve-worktree-path.ts` at line 16, The code currently queries the entire settings row via localDb.select().from(settings).get(); narrow the query to only the needed column by selecting settings.worktreeBaseDir explicitly (e.g. localDb.select({ worktreeBaseDir: settings.worktreeBaseDir }).from(settings).get()), update uses of the resulting variable (const row) to access row.worktreeBaseDir, and keep existing null/undefined checks in resolve-worktree-path.ts to handle a missing settings row.apps/desktop/src/lib/trpc/routers/window.ts (2)
68-68: Redundant?? undefined—input?.defaultPathalready resolves toundefinedwhen absent
input?.defaultPathevaluates toundefinedwheninputisundefinedordefaultPathis not set. The?? undefinedfallback is a no-op (compare with line 67 where?? "Select Directory"provides a meaningful default).♻️ Proposed cleanup
- defaultPath: input?.defaultPath ?? undefined, + defaultPath: input?.defaultPath,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/lib/trpc/routers/window.ts` at line 68, Remove the redundant "?? undefined" fallback for defaultPath: the expression input?.defaultPath already yields undefined when absent, so update the code where defaultPath is assigned (the property defaultPath that currently uses input?.defaultPath ?? undefined) to simply use input?.defaultPath; this keeps behavior identical while cleaning up the unnecessary nullish coalescing.
68-68: Redundant?? undefined—input?.defaultPathalready resolves toundefinedwhen absent
input?.defaultPathevaluates toundefinedwheninputisundefinedor the property is absent. The?? undefinedfallback is a no-op.♻️ Proposed cleanup
- defaultPath: input?.defaultPath ?? undefined, + defaultPath: input?.defaultPath,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/lib/trpc/routers/window.ts` at line 68, The expression input?.defaultPath ?? undefined is redundant; change the assignment to use input?.defaultPath directly (remove the "?? undefined" fallback) wherever defaultPath is set (the line using defaultPath: input?.defaultPath ?? undefined) so the value will already be undefined when missing; update any similar occurrences of "?? undefined" on the same property to keep behavior consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/desktop/src/lib/trpc/routers/window.ts`:
- Around line 65-66: The open-directory dialog only passes the macOS-specific
"createDirectory" option, so on Windows it won't prompt to create non-existent
folders; update the dialog.showOpenDialog call (the invocation that assigns to
result) to include the cross-platform option "promptToCreate" in the properties
array alongside "openDirectory" and "createDirectory" so Windows users are
prompted to create the directory. Ensure the properties array passed to
dialog.showOpenDialog(window, { properties: [...] }) contains both
"createDirectory" and "promptToCreate".
- Around line 65-69: The dialog.openDirectory call in the window router uses the
macOS-only "createDirectory" property and so on Windows/Linux users can't create
folders; update the properties array passed to dialog.showOpenDialog (the call
assigning to result) to include "promptToCreate" in addition to "openDirectory"
and keep "createDirectory" for macOS, then after the dialog returns (check
result.canceled and result.filePaths[0]) verify the selected path exists and, if
not, create it programmatically (use fs/promises.mkdir with { recursive: true }
or equivalent) before returning/using the path; ensure you handle and propagate
errors (or return a sensible failure) and reference the same variables used in
this scope (result, window, input) so callers receive the created directory
path.
In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/resolve-worktree-path.ts`:
- Line 13: resolveWorktreePath is joining raw branch names that may contain '/'
so node:path interprets them as path separators, causing nested directories;
ensure the branch is sanitized to replace '/' with a safe character (e.g. '-')
before joining. Update resolveWorktreePath to normalize the branch parameter
(sanitize slashes -> '-') prior to calling join(project.worktreeBaseDir,
project.name, branch) so callers like getPrLocalBranchName and
sanitizeBranchName don't need to change, and keep the unique identifier logic
intact (use replace-all on the branch string or equivalent).
---
Nitpick comments:
In `@apps/desktop/src/lib/trpc/routers/window.ts`:
- Line 68: Remove the redundant "?? undefined" fallback for defaultPath: the
expression input?.defaultPath already yields undefined when absent, so update
the code where defaultPath is assigned (the property defaultPath that currently
uses input?.defaultPath ?? undefined) to simply use input?.defaultPath; this
keeps behavior identical while cleaning up the unnecessary nullish coalescing.
- Line 68: The expression input?.defaultPath ?? undefined is redundant; change
the assignment to use input?.defaultPath directly (remove the "?? undefined"
fallback) wherever defaultPath is set (the line using defaultPath:
input?.defaultPath ?? undefined) so the value will already be undefined when
missing; update any similar occurrences of "?? undefined" on the same property
to keep behavior consistent.
In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/resolve-worktree-path.ts`:
- Line 16: The code currently queries the entire settings row via
localDb.select().from(settings).get(); narrow the query to only the needed
column by selecting settings.worktreeBaseDir explicitly (e.g. localDb.select({
worktreeBaseDir: settings.worktreeBaseDir }).from(settings).get()), update uses
of the resulting variable (const row) to access row.worktreeBaseDir, and keep
existing null/undefined checks in resolve-worktree-path.ts to handle a missing
settings row.
| const result = await dialog.showOpenDialog(window, { | ||
| properties: ["openDirectory", "createDirectory"], |
There was a problem hiding this comment.
Add "promptToCreate" for Windows — "createDirectory" is macOS-only
Per Electron's documentation, "createDirectory" only applies on macOS to allow creating new directories from the dialog, while "promptToCreate" is the Windows equivalent that prompts for creation if the entered path doesn't exist. Without "promptToCreate", Windows users must create the target directory externally before they can select it.
🛠️ Proposed fix for cross-platform directory creation
const result = await dialog.showOpenDialog(window, {
- properties: ["openDirectory", "createDirectory"],
+ properties: [
+ "openDirectory",
+ "createDirectory", // macOS: allow creating a new directory in-dialog
+ "promptToCreate", // Windows: prompt to create if path doesn't exist
+ ],
title: input?.title ?? "Select Directory",
defaultPath: input?.defaultPath ?? undefined,
});🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/lib/trpc/routers/window.ts` around lines 65 - 66, The
open-directory dialog only passes the macOS-specific "createDirectory" option,
so on Windows it won't prompt to create non-existent folders; update the
dialog.showOpenDialog call (the invocation that assigns to result) to include
the cross-platform option "promptToCreate" in the properties array alongside
"openDirectory" and "createDirectory" so Windows users are prompted to create
the directory. Ensure the properties array passed to
dialog.showOpenDialog(window, { properties: [...] }) contains both
"createDirectory" and "promptToCreate".
| const result = await dialog.showOpenDialog(window, { | ||
| properties: ["openDirectory", "createDirectory"], | ||
| title: input?.title ?? "Select Directory", | ||
| defaultPath: input?.defaultPath ?? undefined, | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's check if the file exists and view the relevant lines
fd window.ts apps/desktop/src/lib/trpc/routers/Repository: superset-sh/superset
Length of output: 107
🌐 Web query:
Electron 40.2.1 dialog.showOpenDialog properties createDirectory promptToCreate macOS Windows
💡 Result:
In Electron v40.2.1, both createDirectory and promptToCreate are values you put in the options.properties: string[] array passed to dialog.showOpenDialog(...) / dialog.showOpenDialogSync(...):
createDirectory(macOS only): enables creating new folders directly from the open dialog UI. [1]promptToCreate(Windows only): if the user types a path that doesn’t exist, Windows will prompt about creating it; Electron may then return a non-existent path, but Electron does not create it—your app must create the file/folder itself if you want it created. [1]
Example:
const { dialog } = require('electron');
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'] // macOS-only behavior
});
// Windows-only behavior would be: properties: ['openFile', 'promptToCreate']Source: Electron dialog API docs (v40.2.1 / latest docs page). [1]
🌐 Web query:
Electron dialog.showOpenDialog cross-platform properties documentation
💡 Result:
Electron’s official docs for dialog.showOpenDialog([window,] options) list these cross‑platform options fields and properties flags:
Common options fields (all platforms)
title,defaultPath,buttonLabel,filters,properties(electronjs.org)
properties (cross‑platform unless noted)
openFile(select files) (electronjs.org)openDirectory(select folders) (electronjs.org)multiSelections(select multiple paths) (electronjs.org)showHiddenFiles(electronjs.org)
Platform-specific properties / options
- macOS:
createDirectory(electronjs.org)noResolveAliases(electronjs.org)treatPackageAsDirectory(electronjs.org)message(option) (electronjs.org)securityScopedBookmarks(Mac App Store builds) +bookmarksreturn value (electronjs.org)
- Windows:
promptToCreate(electronjs.org)dontAddToRecent(electronjs.org)
Important cross-platform behavior notes
- Windows + Linux: you can’t have both a file selector and directory selector at once; if you set
['openFile','openDirectory'], Electron will show a directory selector. (electronjs.org) - Linux:
defaultPathmay be ignored when using portal file choosers unless the portal backend is v4+. (electronjs.org)
Citations:
- 1: https://www.electronjs.org/docs/latest/api/dialog
- 2: https://www.electronjs.org/docs/latest/api/dialog
- 3: https://www.electronjs.org/docs/latest/api/dialog
- 4: https://www.electronjs.org/docs/latest/api/dialog
- 5: https://www.electronjs.org/docs/latest/api/dialog
- 6: https://www.electronjs.org/docs/latest/api/dialog
- 7: https://www.electronjs.org/docs/latest/api/dialog
- 8: https://www.electronjs.org/docs/latest/api/dialog
- 9: https://www.electronjs.org/docs/latest/api/dialog
- 10: https://www.electronjs.org/docs/latest/api/dialog
- 11: https://www.electronjs.org/docs/latest/api/dialog
- 12: https://www.electronjs.org/docs/latest/api/dialog
- 13: https://www.electronjs.org/docs/latest/api/dialog
- 14: https://www.electronjs.org/docs/latest/api/dialog
🏁 Script executed:
# View the actual file around lines 50-76 to verify the code snippet
cat -n apps/desktop/src/lib/trpc/routers/window.ts | sed -n '50,76p'Repository: superset-sh/superset
Length of output: 963
🏁 Script executed:
# Also check the full context of imports and zod usage
cat -n apps/desktop/src/lib/trpc/routers/window.ts | head -20Repository: superset-sh/superset
Length of output: 869
🏁 Script executed:
# Check if there's any directory creation logic elsewhere in the file
rg "mkdir|createDirectory|writeFile" apps/desktop/src/lib/trpc/routers/window.tsRepository: superset-sh/superset
Length of output: 118
"createDirectory" is a macOS-only dialog property; Windows users cannot create directories from this picker
Electron's dialog.showOpenDialog properties accepts "createDirectory" only on macOS and is silently ignored on Windows and Linux. Windows has "promptToCreate", which prompts the user about non-existent paths, but does not automatically create the directory—the app must implement the directory creation logic (e.g., fs.mkdir()) after the user confirms.
To improve Windows/Linux UX, add "promptToCreate" and implement directory creation logic:
Proposed fix for cross-platform directory handling
Add "promptToCreate" to the properties array and implement directory creation when the selected path doesn't exist:
const result = await dialog.showOpenDialog(window, {
- properties: ["openDirectory", "createDirectory"],
+ properties: ["openDirectory", "createDirectory", "promptToCreate"],
title: input?.title ?? "Select Directory",
defaultPath: input?.defaultPath ?? undefined,
});
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true, path: null };
}
+const selectedPath = result.filePaths[0];
+// Ensure directory exists (Windows users may need to create it after promptToCreate)
+try {
+ await fs.mkdir(selectedPath, { recursive: true });
+} catch (err) {
+ // Handle error if directory creation fails
+}
-return { canceled: false, path: result.filePaths[0] };
+return { canceled: false, path: selectedPath };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/lib/trpc/routers/window.ts` around lines 65 - 69, The
dialog.openDirectory call in the window router uses the macOS-only
"createDirectory" property and so on Windows/Linux users can't create folders;
update the properties array passed to dialog.showOpenDialog (the call assigning
to result) to include "promptToCreate" in addition to "openDirectory" and keep
"createDirectory" for macOS, then after the dialog returns (check
result.canceled and result.filePaths[0]) verify the selected path exists and, if
not, create it programmatically (use fs/promises.mkdir with { recursive: true }
or equivalent) before returning/using the path; ensure you handle and propagate
errors (or return a sensible failure) and reference the same variables used in
this scope (result, window, input) so callers receive the created directory
path.
| branch: string, | ||
| ): string { | ||
| if (project.worktreeBaseDir) { | ||
| return join(project.worktreeBaseDir, project.name, branch); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find calls to resolveWorktreePath with context
rg -n "resolveWorktreePath" --type ts -A 5 -B 5Repository: superset-sh/superset
Length of output: 4671
🏁 Script executed:
#!/bin/bash
# Find the resolve-worktree-path.ts file to see the implementation
fd "resolve-worktree-path.ts" -type fRepository: superset-sh/superset
Length of output: 234
🏁 Script executed:
#!/bin/bash
# Search for branch sanitization patterns in the codebase
rg "branch.*sanitiz|sanitiz.*branch|replace.*\/" --type ts -iRepository: superset-sh/superset
Length of output: 214
🏁 Script executed:
#!/bin/bash
# View the context around line 208 in create.ts
sed -n '180,220p' apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.tsRepository: superset-sh/superset
Length of output: 1068
🏁 Script executed:
#!/bin/bash
# View the context around line 432 in create.ts
sed -n '410,450p' apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.tsRepository: superset-sh/superset
Length of output: 1168
🏁 Script executed:
#!/bin/bash
# Find the sanitizeBranchName function
rg -n "function sanitizeBranchName|const sanitizeBranchName|export.*sanitizeBranchName" --type ts -A 10Repository: superset-sh/superset
Length of output: 752
🏁 Script executed:
#!/bin/bash
# Also look at the git utils file to find sanitizeBranchName
fd "git.ts" --type f | head -5Repository: superset-sh/superset
Length of output: 121
🏁 Script executed:
#!/bin/bash
# View the sanitizeSegment function and more context in branch.ts
cat apps/desktop/src/shared/utils/branch.tsRepository: superset-sh/superset
Length of output: 1288
🏁 Script executed:
#!/bin/bash
# Look at more context around localBranchName on line 208
sed -n '190,210p' apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.tsRepository: superset-sh/superset
Length of output: 603
🏁 Script executed:
#!/bin/bash
# Look at more context around branch on line 432 - trace back further
sed -n '370,435p' apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.tsRepository: superset-sh/superset
Length of output: 1982
🏁 Script executed:
#!/bin/bash
# Find where handleNewWorktree is called
rg -n "handleNewWorktree" --type ts -B 5 -A 2Repository: superset-sh/superset
Length of output: 1389
🏁 Script executed:
#!/bin/bash
# Look for HandleNewWorktreeParams definition to understand the interface
rg -n "interface HandleNewWorktreeParams" --type ts -A 10Repository: superset-sh/superset
Length of output: 1086
🏁 Script executed:
#!/bin/bash
# Get more context around line 949 where handleNewWorktree is called
sed -n '920,960p' apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.tsRepository: superset-sh/superset
Length of output: 952
🏁 Script executed:
#!/bin/bash
# Also check what the actual branch value is and trace back where it comes from
sed -n '850,950p' apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts | head -80Repository: superset-sh/superset
Length of output: 2018
🏁 Script executed:
#!/bin/bash
# Find getPrLocalBranchName function definition
rg -n "function getPrLocalBranchName|const getPrLocalBranchName|export.*getPrLocalBranchName" --type ts -A 5Repository: superset-sh/superset
Length of output: 692
🏁 Script executed:
#!/bin/bash
# Also look at the full create.ts around line 378 to see what happens with branch variable before line 432
sed -n '350,400p' apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.tsRepository: superset-sh/superset
Length of output: 1613
Branch names containing / create unexpected nested directory structures in worktrees.
When resolveWorktreePath receives branch names with slashes (e.g., from getPrLocalBranchName returning "forkowner/feature" or from sanitizeBranchName preserving slashes like "feature/my-branch"), node:path's join treats them as path separators instead of literal characters. This results in nested directories like baseDir/project/forkowner/feature instead of a flat layout. Multiple branches sharing a namespace prefix create intermediate directories that become orphaned when worktrees are removed.
The issue occurs in both code paths:
- PR imports via
handleNewWorktreeusegetPrLocalBranchNamewhich returns unsanitized branch names containing/ - Manual branch creation sanitizes via
sanitizeBranchName, which intentionally preserves/structure by splitting, sanitizing segments, and rejoining
Branch names should be sanitized to replace / with a safe character (like -) before being passed to resolveWorktreePath.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/resolve-worktree-path.ts`
at line 13, resolveWorktreePath is joining raw branch names that may contain '/'
so node:path interprets them as path separators, causing nested directories;
ensure the branch is sanitized to replace '/' with a safe character (e.g. '-')
before joining. Update resolveWorktreePath to normalize the branch parameter
(sanitize slashes -> '-') prior to calling join(project.worktreeBaseDir,
project.name, branch) so callers like getPrLocalBranchName and
sanitizeBranchName don't need to change, and keep the unique identifier logic
intact (use replace-all on the branch string or equivalent).
Summary
worktree_base_dircolumn to bothprojectsandsettingstables with migration0030Changes
Schema & Migration
worktree_base_dirtext column toprojectsandsettingstables inpackages/local-db0030_shallow_the_leader.sqlBackend (tRPC)
getWorktreeBaseDir/setWorktreeBaseDirprocedures to the settings routerworktreeBaseDirto the project update patchresolveWorktreePath()utility that resolves the effective worktree path (project override > global setting > default)selectDirectorywindow procedureFrontend
WorktreeLocationPickerreusable component with browse/reset supportTest Plan
Summary by CodeRabbit
New Features
Refactor
Chores