Skip to content

Commit dd72acd

Browse files
committed
Add configurable global worktrees root setting
1 parent 771a3c4 commit dd72acd

4 files changed

Lines changed: 217 additions & 13 deletions

File tree

src/features/settings/components/SettingsView.test.tsx

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -429,20 +429,24 @@ const workspace = (
429429

430430
const renderEnvironmentsSection = (
431431
options: {
432+
appSettings?: Partial<AppSettings>;
432433
groupedWorkspaces?: ComponentProps<typeof SettingsView>["groupedWorkspaces"];
434+
onUpdateAppSettings?: ComponentProps<typeof SettingsView>["onUpdateAppSettings"];
433435
onUpdateWorkspaceSettings?: ComponentProps<typeof SettingsView>["onUpdateWorkspaceSettings"];
434436
} = {},
435437
) => {
436438
cleanup();
439+
const onUpdateAppSettings =
440+
options.onUpdateAppSettings ?? vi.fn().mockResolvedValue(undefined);
437441
const onUpdateWorkspaceSettings =
438442
options.onUpdateWorkspaceSettings ?? vi.fn().mockResolvedValue(undefined);
439443

440444
const props: ComponentProps<typeof SettingsView> = {
441445
reduceTransparency: false,
442446
onToggleTransparency: vi.fn(),
443-
appSettings: baseSettings,
447+
appSettings: { ...baseSettings, ...options.appSettings },
444448
openAppIconById: {},
445-
onUpdateAppSettings: vi.fn().mockResolvedValue(undefined),
449+
onUpdateAppSettings,
446450
workspaceGroups: [],
447451
groupedWorkspaces:
448452
options.groupedWorkspaces ??
@@ -485,7 +489,7 @@ const renderEnvironmentsSection = (
485489
};
486490

487491
render(<SettingsView {...props} />);
488-
return { onUpdateWorkspaceSettings };
492+
return { onUpdateAppSettings, onUpdateWorkspaceSettings };
489493
};
490494

491495
describe("SettingsView Display", () => {
@@ -755,6 +759,81 @@ describe("SettingsView About", () => {
755759
});
756760

757761
describe("SettingsView Environments", () => {
762+
it("shows the global worktrees root input", () => {
763+
renderEnvironmentsSection({
764+
appSettings: { globalWorktreesFolder: "I:/existing-worktrees" },
765+
});
766+
767+
const input = screen.getByLabelText("Global worktrees root");
768+
expect(input).toBeTruthy();
769+
expect((input as HTMLInputElement).value).toBe("I:/existing-worktrees");
770+
expect((input as HTMLInputElement).placeholder).toBe("/path/to/worktrees-root");
771+
});
772+
773+
it("saves the global worktrees root through app settings", async () => {
774+
const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined);
775+
const onUpdateWorkspaceSettings = vi.fn().mockResolvedValue(undefined);
776+
renderEnvironmentsSection({
777+
onUpdateAppSettings,
778+
onUpdateWorkspaceSettings,
779+
});
780+
781+
const input = screen.getByLabelText("Global worktrees root");
782+
fireEvent.change(input, { target: { value: "I:/cm-worktrees" } });
783+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
784+
785+
await waitFor(() => {
786+
expect(onUpdateAppSettings).toHaveBeenCalledWith(
787+
expect.objectContaining({
788+
globalWorktreesFolder: "I:/cm-worktrees",
789+
}),
790+
);
791+
});
792+
});
793+
794+
it("does not clear an existing global worktrees root when saving project-only changes", async () => {
795+
const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined);
796+
const onUpdateWorkspaceSettings = vi.fn().mockResolvedValue(undefined);
797+
renderEnvironmentsSection({
798+
appSettings: { globalWorktreesFolder: "I:/existing-worktrees" },
799+
onUpdateAppSettings,
800+
onUpdateWorkspaceSettings,
801+
});
802+
803+
const textarea = screen.getByPlaceholderText("pnpm install");
804+
fireEvent.change(textarea, { target: { value: "echo updated" } });
805+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
806+
807+
await waitFor(() => {
808+
expect(onUpdateWorkspaceSettings).toHaveBeenCalledWith("w1", {
809+
worktreeSetupScript: "echo updated",
810+
worktreesFolder: null,
811+
});
812+
});
813+
expect(onUpdateAppSettings).not.toHaveBeenCalled();
814+
});
815+
816+
it("keeps the global worktrees root editable when there are no projects", async () => {
817+
const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined);
818+
renderEnvironmentsSection({
819+
groupedWorkspaces: [],
820+
onUpdateAppSettings,
821+
});
822+
823+
expect(screen.getByText("No projects yet.")).toBeTruthy();
824+
const input = screen.getByLabelText("Global worktrees root");
825+
fireEvent.change(input, { target: { value: "I:/cm-worktrees" } });
826+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
827+
828+
await waitFor(() => {
829+
expect(onUpdateAppSettings).toHaveBeenCalledWith(
830+
expect.objectContaining({
831+
globalWorktreesFolder: "I:/cm-worktrees",
832+
}),
833+
);
834+
});
835+
});
836+
758837
it("saves the setup script for the selected project", async () => {
759838
const onUpdateWorkspaceSettings = vi.fn().mockResolvedValue(undefined);
760839
renderEnvironmentsSection({ onUpdateWorkspaceSettings });

src/features/settings/components/sections/SettingsEnvironmentsSection.tsx

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ type SettingsEnvironmentsSectionProps = {
1111
environmentDraftScript: string;
1212
environmentSavedScript: string | null;
1313
environmentDirty: boolean;
14+
globalWorktreesFolderDraft: string;
15+
globalWorktreesFolderSaved: string | null;
16+
globalWorktreesFolderDirty: boolean;
1417
worktreesFolderDraft: string;
1518
worktreesFolderSaved: string | null;
1619
worktreesFolderDirty: boolean;
1720
onSetEnvironmentWorkspaceId: Dispatch<SetStateAction<string | null>>;
1821
onSetEnvironmentDraftScript: Dispatch<SetStateAction<string>>;
22+
onSetGlobalWorktreesFolderDraft: Dispatch<SetStateAction<string>>;
1923
onSetWorktreesFolderDraft: Dispatch<SetStateAction<string>>;
2024
onSaveEnvironmentSetup: () => Promise<void>;
2125
};
@@ -28,22 +32,96 @@ export function SettingsEnvironmentsSection({
2832
environmentDraftScript,
2933
environmentSavedScript,
3034
environmentDirty,
35+
globalWorktreesFolderDraft,
36+
globalWorktreesFolderSaved: _globalWorktreesFolderSaved,
37+
globalWorktreesFolderDirty,
3138
worktreesFolderDraft,
3239
worktreesFolderSaved: _worktreesFolderSaved,
3340
worktreesFolderDirty,
3441
onSetEnvironmentWorkspaceId,
3542
onSetEnvironmentDraftScript,
43+
onSetGlobalWorktreesFolderDraft,
3644
onSetWorktreesFolderDraft,
3745
onSaveEnvironmentSetup,
3846
}: SettingsEnvironmentsSectionProps) {
39-
const hasAnyChanges = environmentDirty || worktreesFolderDirty;
47+
const hasAnyChanges =
48+
environmentDirty || globalWorktreesFolderDirty || worktreesFolderDirty;
49+
const hasProjects = mainWorkspaces.length > 0;
4050

4151
return (
4252
<SettingsSection
4353
title="Environments"
4454
subtitle="Configure per-project setup scripts and worktree locations."
4555
>
46-
{mainWorkspaces.length === 0 ? (
56+
<div className="settings-field">
57+
<label className="settings-field-label" htmlFor="settings-global-worktrees-folder">
58+
Global worktrees root
59+
</label>
60+
<div className="settings-help">
61+
Default location for new worktrees when a project does not override it. Each
62+
project gets its own subfolder under this root.
63+
</div>
64+
<div className="settings-field-row">
65+
<input
66+
id="settings-global-worktrees-folder"
67+
type="text"
68+
className="settings-input"
69+
value={globalWorktreesFolderDraft}
70+
onChange={(event) => onSetGlobalWorktreesFolderDraft(event.target.value)}
71+
placeholder="/path/to/worktrees-root"
72+
disabled={environmentSaving}
73+
/>
74+
<button
75+
type="button"
76+
className="ghost settings-button-compact"
77+
onClick={async () => {
78+
try {
79+
const { open } = await import("@tauri-apps/plugin-dialog");
80+
const selected = await open({
81+
directory: true,
82+
multiple: false,
83+
title: "Select global worktrees root",
84+
});
85+
if (selected && typeof selected === "string") {
86+
onSetGlobalWorktreesFolderDraft(selected);
87+
}
88+
} catch (error) {
89+
pushErrorToast({
90+
title: "Failed to open folder picker",
91+
message: error instanceof Error ? error.message : String(error),
92+
});
93+
}
94+
}}
95+
disabled={environmentSaving}
96+
>
97+
Browse
98+
</button>
99+
</div>
100+
{!hasProjects ? (
101+
<div className="settings-field-actions">
102+
<button
103+
type="button"
104+
className="ghost settings-button-compact"
105+
onClick={() => onSetGlobalWorktreesFolderDraft(_globalWorktreesFolderSaved ?? "")}
106+
disabled={environmentSaving || !globalWorktreesFolderDirty}
107+
>
108+
Reset
109+
</button>
110+
<button
111+
type="button"
112+
className="primary settings-button-compact"
113+
onClick={() => {
114+
void onSaveEnvironmentSetup();
115+
}}
116+
disabled={environmentSaving || !globalWorktreesFolderDirty}
117+
>
118+
{environmentSaving ? "Saving..." : "Save"}
119+
</button>
120+
</div>
121+
) : null}
122+
</div>
123+
124+
{!hasProjects ? (
47125
<div className="settings-empty">No projects yet.</div>
48126
) : (
49127
<>
@@ -138,7 +216,8 @@ export function SettingsEnvironmentsSection({
138216
Worktrees folder
139217
</label>
140218
<div className="settings-help">
141-
Custom location for worktrees. Leave empty to use the default location.
219+
Custom location for this project's worktrees. Leave empty to use the global root or
220+
the built-in default.
142221
</div>
143222
<div className="settings-field-row">
144223
<input

src/features/settings/hooks/useSettingsEnvironmentsSection.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { useEffect, useMemo, useState } from "react";
22
import type { Dispatch, SetStateAction } from "react";
3-
import type { WorkspaceInfo } from "@/types";
3+
import type { AppSettings, WorkspaceInfo } from "@/types";
44
import { normalizeWorktreeSetupScript } from "@settings/components/settingsViewHelpers";
55

66
type UseSettingsEnvironmentsSectionArgs = {
7+
appSettings: AppSettings;
8+
onUpdateAppSettings: (next: AppSettings) => Promise<void>;
79
mainWorkspaces: WorkspaceInfo[];
810
onUpdateWorkspaceSettings: (
911
id: string,
@@ -19,16 +21,22 @@ export type SettingsEnvironmentsSectionProps = {
1921
environmentDraftScript: string;
2022
environmentSavedScript: string | null;
2123
environmentDirty: boolean;
24+
globalWorktreesFolderDraft: string;
25+
globalWorktreesFolderSaved: string | null;
26+
globalWorktreesFolderDirty: boolean;
2227
worktreesFolderDraft: string;
2328
worktreesFolderSaved: string | null;
2429
worktreesFolderDirty: boolean;
2530
onSetEnvironmentWorkspaceId: Dispatch<SetStateAction<string | null>>;
2631
onSetEnvironmentDraftScript: Dispatch<SetStateAction<string>>;
32+
onSetGlobalWorktreesFolderDraft: Dispatch<SetStateAction<string>>;
2733
onSetWorktreesFolderDraft: Dispatch<SetStateAction<string>>;
2834
onSaveEnvironmentSetup: () => Promise<void>;
2935
};
3036

3137
export const useSettingsEnvironmentsSection = ({
38+
appSettings,
39+
onUpdateAppSettings,
3240
mainWorkspaces,
3341
onUpdateWorkspaceSettings,
3442
}: UseSettingsEnvironmentsSectionArgs): SettingsEnvironmentsSectionProps => {
@@ -38,6 +46,12 @@ export const useSettingsEnvironmentsSection = ({
3846
const [environmentLoadedWorkspaceId, setEnvironmentLoadedWorkspaceId] = useState<string | null>(null);
3947
const [environmentError, setEnvironmentError] = useState<string | null>(null);
4048
const [environmentSaving, setEnvironmentSaving] = useState(false);
49+
const [globalWorktreesFolderDraft, setGlobalWorktreesFolderDraft] = useState(
50+
appSettings.globalWorktreesFolder ?? "",
51+
);
52+
const [globalWorktreesFolderSaved, setGlobalWorktreesFolderSaved] = useState<string | null>(
53+
appSettings.globalWorktreesFolder,
54+
);
4155
const [worktreesFolderDraft, setWorktreesFolderDraft] = useState("");
4256
const [worktreesFolderSaved, setWorktreesFolderSaved] = useState<string | null>(null);
4357

@@ -63,8 +77,21 @@ export const useSettingsEnvironmentsSection = ({
6377
}, [environmentDraftScript]);
6478

6579
const environmentDirty = environmentDraftNormalized !== environmentSavedScript;
80+
const globalWorktreesFolderDirty =
81+
(globalWorktreesFolderDraft.trim() || null) !== globalWorktreesFolderSaved;
6682
const worktreesFolderDirty = (worktreesFolderDraft.trim() || null) !== worktreesFolderSaved;
6783

84+
useEffect(() => {
85+
if (!globalWorktreesFolderDirty && globalWorktreesFolderSaved !== appSettings.globalWorktreesFolder) {
86+
setGlobalWorktreesFolderSaved(appSettings.globalWorktreesFolder);
87+
setGlobalWorktreesFolderDraft(appSettings.globalWorktreesFolder ?? "");
88+
}
89+
}, [
90+
appSettings.globalWorktreesFolder,
91+
globalWorktreesFolderDirty,
92+
globalWorktreesFolderSaved,
93+
]);
94+
6895
useEffect(() => {
6996
if (!environmentWorkspace) {
7097
setEnvironmentWorkspaceId(null);
@@ -73,14 +100,16 @@ export const useSettingsEnvironmentsSection = ({
73100
setEnvironmentDraftScript("");
74101
setEnvironmentError(null);
75102
setEnvironmentSaving(false);
103+
setGlobalWorktreesFolderDraft(appSettings.globalWorktreesFolder ?? "");
104+
setGlobalWorktreesFolderSaved(appSettings.globalWorktreesFolder);
76105
setWorktreesFolderDraft("");
77106
setWorktreesFolderSaved(null);
78107
return;
79108
}
80109
if (environmentWorkspaceId !== environmentWorkspace.id) {
81110
setEnvironmentWorkspaceId(environmentWorkspace.id);
82111
}
83-
}, [environmentWorkspace, environmentWorkspaceId]);
112+
}, [appSettings.globalWorktreesFolder, environmentWorkspace, environmentWorkspaceId]);
84113

85114
useEffect(() => {
86115
if (!environmentWorkspace) return;
@@ -114,16 +143,27 @@ export const useSettingsEnvironmentsSection = ({
114143
]);
115144

116145
const handleSaveEnvironmentSetup = async () => {
117-
if (!environmentWorkspace || environmentSaving) return;
146+
if (environmentSaving) return;
118147
const nextScript = environmentDraftNormalized;
148+
const nextGlobalFolder = globalWorktreesFolderDraft.trim() || null;
119149
const nextFolder = worktreesFolderDraft.trim() || null;
120150
setEnvironmentSaving(true);
121151
setEnvironmentError(null);
122152
try {
123-
await onUpdateWorkspaceSettings(environmentWorkspace.id, {
124-
worktreeSetupScript: nextScript,
125-
worktreesFolder: nextFolder,
126-
});
153+
if (nextGlobalFolder !== globalWorktreesFolderSaved) {
154+
await onUpdateAppSettings({
155+
...appSettings,
156+
globalWorktreesFolder: nextGlobalFolder,
157+
});
158+
}
159+
if (environmentWorkspace) {
160+
await onUpdateWorkspaceSettings(environmentWorkspace.id, {
161+
worktreeSetupScript: nextScript,
162+
worktreesFolder: nextFolder,
163+
});
164+
}
165+
setGlobalWorktreesFolderSaved(nextGlobalFolder);
166+
setGlobalWorktreesFolderDraft(nextGlobalFolder ?? "");
127167
setEnvironmentSavedScript(nextScript);
128168
setEnvironmentDraftScript(nextScript ?? "");
129169
setWorktreesFolderSaved(nextFolder);
@@ -143,11 +183,15 @@ export const useSettingsEnvironmentsSection = ({
143183
environmentDraftScript,
144184
environmentSavedScript,
145185
environmentDirty,
186+
globalWorktreesFolderDraft,
187+
globalWorktreesFolderSaved,
188+
globalWorktreesFolderDirty,
146189
worktreesFolderDraft,
147190
worktreesFolderSaved,
148191
worktreesFolderDirty,
149192
onSetEnvironmentWorkspaceId: setEnvironmentWorkspaceId,
150193
onSetEnvironmentDraftScript: setEnvironmentDraftScript,
194+
onSetGlobalWorktreesFolderDraft: setGlobalWorktreesFolderDraft,
151195
onSetWorktreesFolderDraft: setWorktreesFolderDraft,
152196
onSaveEnvironmentSetup: handleSaveEnvironmentSetup,
153197
};

src/features/settings/hooks/useSettingsViewOrchestration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ export function useSettingsViewOrchestration({
169169
});
170170

171171
const environmentsSectionProps = useSettingsEnvironmentsSection({
172+
appSettings,
173+
onUpdateAppSettings,
172174
mainWorkspaces,
173175
onUpdateWorkspaceSettings,
174176
});

0 commit comments

Comments
 (0)