diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 5cd553999..9633cbca0 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -429,39 +429,49 @@ const workspace = ( const renderEnvironmentsSection = ( options: { + appSettings?: Partial; groupedWorkspaces?: ComponentProps["groupedWorkspaces"]; + onUpdateAppSettings?: ComponentProps["onUpdateAppSettings"]; onUpdateWorkspaceSettings?: ComponentProps["onUpdateWorkspaceSettings"]; } = {}, ) => { cleanup(); + const onUpdateAppSettings = + options.onUpdateAppSettings ?? vi.fn().mockResolvedValue(undefined); const onUpdateWorkspaceSettings = options.onUpdateWorkspaceSettings ?? vi.fn().mockResolvedValue(undefined); - - const props: ComponentProps = { + const defaultGroupedWorkspaces = + options.groupedWorkspaces ?? + [ + { + id: null, + name: "Ungrouped", + workspaces: [ + workspace({ + id: "w1", + name: "Project One", + settings: { + sidebarCollapsed: false, + worktreeSetupScript: "echo one", + }, + }), + ], + }, + ]; + + const buildProps = ( + nextOptions: { + appSettings?: Partial; + groupedWorkspaces?: ComponentProps["groupedWorkspaces"]; + } = {}, + ): ComponentProps => ({ reduceTransparency: false, onToggleTransparency: vi.fn(), - appSettings: baseSettings, + appSettings: { ...baseSettings, ...options.appSettings, ...nextOptions.appSettings }, openAppIconById: {}, - onUpdateAppSettings: vi.fn().mockResolvedValue(undefined), + onUpdateAppSettings, workspaceGroups: [], - groupedWorkspaces: - options.groupedWorkspaces ?? - [ - { - id: null, - name: "Ungrouped", - workspaces: [ - workspace({ - id: "w1", - name: "Project One", - settings: { - sidebarCollapsed: false, - worktreeSetupScript: "echo one", - }, - }), - ], - }, - ], + groupedWorkspaces: nextOptions.groupedWorkspaces ?? defaultGroupedWorkspaces, ungroupedLabel: "Ungrouped", onClose: vi.fn(), onMoveWorkspace: vi.fn(), @@ -482,10 +492,19 @@ const renderEnvironmentsSection = ( onCancelDictationDownload: vi.fn(), onRemoveDictationModel: vi.fn(), initialSection: "environments", - }; + }); - render(); - return { onUpdateWorkspaceSettings }; + const renderResult = render(); + return { + onUpdateAppSettings, + onUpdateWorkspaceSettings, + rerender: ( + nextOptions: { + appSettings?: Partial; + groupedWorkspaces?: ComponentProps["groupedWorkspaces"]; + } = {}, + ) => renderResult.rerender(), + }; }; describe("SettingsView Display", () => { @@ -755,6 +774,246 @@ describe("SettingsView About", () => { }); describe("SettingsView Environments", () => { + it("shows the global worktrees root input", () => { + renderEnvironmentsSection({ + appSettings: { globalWorktreesFolder: "I:/existing-worktrees" }, + }); + + const input = screen.getByLabelText("Global worktrees root"); + expect(input).toBeTruthy(); + expect((input as HTMLInputElement).value).toBe("I:/existing-worktrees"); + expect((input as HTMLInputElement).placeholder).toBe("/path/to/worktrees-root"); + }); + + it("saves the global worktrees root through app settings", async () => { + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + const onUpdateWorkspaceSettings = vi.fn().mockResolvedValue(undefined); + renderEnvironmentsSection({ + onUpdateAppSettings, + onUpdateWorkspaceSettings, + }); + + const input = screen.getByLabelText("Global worktrees root"); + fireEvent.change(input, { target: { value: "I:/cm-worktrees" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ + globalWorktreesFolder: "I:/cm-worktrees", + }), + ); + }); + expect(onUpdateWorkspaceSettings).not.toHaveBeenCalled(); + }); + + it("does not clear an existing global worktrees root when saving project-only changes", async () => { + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + const onUpdateWorkspaceSettings = vi.fn().mockResolvedValue(undefined); + renderEnvironmentsSection({ + appSettings: { globalWorktreesFolder: "I:/existing-worktrees" }, + onUpdateAppSettings, + onUpdateWorkspaceSettings, + }); + + const textarea = screen.getByPlaceholderText("pnpm install"); + fireEvent.change(textarea, { target: { value: "echo updated" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(onUpdateWorkspaceSettings).toHaveBeenCalledWith("w1", { + worktreeSetupScript: "echo updated", + worktreesFolder: null, + }); + }); + expect(onUpdateAppSettings).not.toHaveBeenCalled(); + }); + + it("keeps the global worktrees root marked as saved after workspace save fails", async () => { + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + const onUpdateWorkspaceSettings = vi + .fn() + .mockRejectedValueOnce(new Error("Failed to save workspace settings")) + .mockResolvedValueOnce(undefined); + renderEnvironmentsSection({ + appSettings: { globalWorktreesFolder: "I:/existing-worktrees" }, + onUpdateAppSettings, + onUpdateWorkspaceSettings, + }); + + fireEvent.change(screen.getByLabelText("Global worktrees root"), { + target: { value: "I:/cm-worktrees" }, + }); + fireEvent.change(screen.getByPlaceholderText("pnpm install"), { + target: { value: "echo updated" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect( + await screen.findByText("Failed to save workspace settings"), + ).toBeTruthy(); + expect(onUpdateAppSettings).toHaveBeenCalledTimes(1); + expect(onUpdateWorkspaceSettings).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(onUpdateWorkspaceSettings).toHaveBeenCalledTimes(2); + }); + expect(onUpdateAppSettings).toHaveBeenCalledTimes(1); + }); + + it("keeps the global worktrees root editable when there are no projects", async () => { + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + renderEnvironmentsSection({ + groupedWorkspaces: [], + onUpdateAppSettings, + }); + + expect(screen.getByText("No projects yet.")).toBeTruthy(); + const input = screen.getByLabelText("Global worktrees root"); + fireEvent.change(input, { target: { value: "I:/cm-worktrees" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ + globalWorktreesFolder: "I:/cm-worktrees", + }), + ); + }); + }); + + it("keeps the no-project global worktrees root save state active until the request resolves", async () => { + let resolveSave: (() => void) | null = null; + const pendingSave = new Promise((resolve) => { + resolveSave = resolve; + }); + const onUpdateAppSettings = vi.fn().mockImplementation(() => pendingSave); + renderEnvironmentsSection({ + groupedWorkspaces: [], + onUpdateAppSettings, + }); + + fireEvent.change(screen.getByLabelText("Global worktrees root"), { + target: { value: "I:/cm-worktrees" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect( + (screen.getByRole("button", { name: "Saving..." }) as HTMLButtonElement).disabled, + ).toBe(true); + }); + expect((screen.getByLabelText("Global worktrees root") as HTMLInputElement).disabled).toBe( + true, + ); + expect(onUpdateAppSettings).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Saving..." })); + expect(onUpdateAppSettings).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveSave?.(); + await pendingSave; + }); + + await waitFor(() => { + expect((screen.getByRole("button", { name: "Save" }) as HTMLButtonElement).disabled).toBe( + true, + ); + }); + }); + + it("resyncs the global worktrees root baseline after dirty state clears", async () => { + const { rerender } = renderEnvironmentsSection({ + groupedWorkspaces: [], + appSettings: { globalWorktreesFolder: null }, + }); + + const input = screen.getByLabelText("Global worktrees root"); + fireEvent.change(input, { target: { value: "I:/typing" } }); + + rerender({ + groupedWorkspaces: [], + appSettings: { globalWorktreesFolder: "I:/loaded-from-settings" }, + }); + + expect((screen.getByLabelText("Global worktrees root") as HTMLInputElement).value).toBe( + "I:/typing", + ); + + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + + await waitFor(() => { + expect((screen.getByLabelText("Global worktrees root") as HTMLInputElement).value).toBe( + "I:/loaded-from-settings", + ); + }); + }); + + it("shows save errors for the global worktrees root when there are no projects", async () => { + const onUpdateAppSettings = vi + .fn() + .mockRejectedValue(new Error("Failed to save global worktrees root")); + renderEnvironmentsSection({ + groupedWorkspaces: [], + onUpdateAppSettings, + }); + + const input = screen.getByLabelText("Global worktrees root"); + fireEvent.change(input, { target: { value: "I:/cm-worktrees" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect( + await screen.findByText("Failed to save global worktrees root"), + ).toBeTruthy(); + }); + + it("keeps the new global worktrees root as saved when workspace settings fail afterward", async () => { + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + const onUpdateWorkspaceSettings = vi + .fn() + .mockRejectedValue(new Error("Failed to save workspace settings")); + renderEnvironmentsSection({ + appSettings: { globalWorktreesFolder: "I:/existing-worktrees" }, + onUpdateAppSettings, + onUpdateWorkspaceSettings, + }); + + const input = screen.getByLabelText("Global worktrees root"); + const textarea = screen.getByPlaceholderText("pnpm install"); + fireEvent.change(input, { target: { value: "I:/cm-worktrees" } }); + fireEvent.change(textarea, { target: { value: "echo updated" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect( + await screen.findByText("Failed to save workspace settings"), + ).toBeTruthy(); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ + globalWorktreesFolder: "I:/cm-worktrees", + }), + ); + expect(onUpdateWorkspaceSettings).toHaveBeenCalledWith("w1", { + worktreeSetupScript: "echo updated", + worktreesFolder: null, + }); + }); + + expect((input as HTMLInputElement).value).toBe("I:/cm-worktrees"); + + onUpdateWorkspaceSettings.mockResolvedValueOnce(undefined); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(onUpdateWorkspaceSettings).toHaveBeenCalledTimes(2); + }); + expect(onUpdateAppSettings).toHaveBeenCalledTimes(1); + }); + it("saves the setup script for the selected project", async () => { const onUpdateWorkspaceSettings = vi.fn().mockResolvedValue(undefined); renderEnvironmentsSection({ onUpdateWorkspaceSettings }); diff --git a/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx b/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx index f04194cf5..bff9f01f4 100644 --- a/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx +++ b/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx @@ -11,11 +11,15 @@ type SettingsEnvironmentsSectionProps = { environmentDraftScript: string; environmentSavedScript: string | null; environmentDirty: boolean; + globalWorktreesFolderDraft: string; + globalWorktreesFolderSaved: string | null; + globalWorktreesFolderDirty: boolean; worktreesFolderDraft: string; worktreesFolderSaved: string | null; worktreesFolderDirty: boolean; onSetEnvironmentWorkspaceId: Dispatch>; onSetEnvironmentDraftScript: Dispatch>; + onSetGlobalWorktreesFolderDraft: Dispatch>; onSetWorktreesFolderDraft: Dispatch>; onSaveEnvironmentSetup: () => Promise; }; @@ -28,22 +32,99 @@ export function SettingsEnvironmentsSection({ environmentDraftScript, environmentSavedScript, environmentDirty, + globalWorktreesFolderDraft, + globalWorktreesFolderSaved: _globalWorktreesFolderSaved, + globalWorktreesFolderDirty, worktreesFolderDraft, worktreesFolderSaved: _worktreesFolderSaved, worktreesFolderDirty, onSetEnvironmentWorkspaceId, onSetEnvironmentDraftScript, + onSetGlobalWorktreesFolderDraft, onSetWorktreesFolderDraft, onSaveEnvironmentSetup, }: SettingsEnvironmentsSectionProps) { - const hasAnyChanges = environmentDirty || worktreesFolderDirty; + const hasAnyChanges = + environmentDirty || globalWorktreesFolderDirty || worktreesFolderDirty; + const hasProjects = mainWorkspaces.length > 0; return ( - {mainWorkspaces.length === 0 ? ( +
+ +
+ Default location for new worktrees when a project does not override it. Each + project gets its own subfolder under this root. +
+
+ onSetGlobalWorktreesFolderDraft(event.target.value)} + placeholder="/path/to/worktrees-root" + disabled={environmentSaving} + /> + +
+ {!hasProjects ? ( +
+ + +
+ ) : null} + {!hasProjects && environmentError ? ( +
{environmentError}
+ ) : null} +
+ + {!hasProjects ? (
No projects yet.
) : ( <> @@ -138,7 +219,8 @@ export function SettingsEnvironmentsSection({ Worktrees folder
- Custom location for worktrees. Leave empty to use the default location. + Custom location for this project's worktrees. Leave empty to use the global root or + the built-in default.
Promise; mainWorkspaces: WorkspaceInfo[]; onUpdateWorkspaceSettings: ( id: string, @@ -19,16 +21,22 @@ export type SettingsEnvironmentsSectionProps = { environmentDraftScript: string; environmentSavedScript: string | null; environmentDirty: boolean; + globalWorktreesFolderDraft: string; + globalWorktreesFolderSaved: string | null; + globalWorktreesFolderDirty: boolean; worktreesFolderDraft: string; worktreesFolderSaved: string | null; worktreesFolderDirty: boolean; onSetEnvironmentWorkspaceId: Dispatch>; onSetEnvironmentDraftScript: Dispatch>; + onSetGlobalWorktreesFolderDraft: Dispatch>; onSetWorktreesFolderDraft: Dispatch>; onSaveEnvironmentSetup: () => Promise; }; export const useSettingsEnvironmentsSection = ({ + appSettings, + onUpdateAppSettings, mainWorkspaces, onUpdateWorkspaceSettings, }: UseSettingsEnvironmentsSectionArgs): SettingsEnvironmentsSectionProps => { @@ -38,8 +46,15 @@ export const useSettingsEnvironmentsSection = ({ const [environmentLoadedWorkspaceId, setEnvironmentLoadedWorkspaceId] = useState(null); const [environmentError, setEnvironmentError] = useState(null); const [environmentSaving, setEnvironmentSaving] = useState(false); + const [globalWorktreesFolderDraft, setGlobalWorktreesFolderDraft] = useState( + appSettings.globalWorktreesFolder ?? "", + ); + const [globalWorktreesFolderSaved, setGlobalWorktreesFolderSaved] = useState( + appSettings.globalWorktreesFolder, + ); const [worktreesFolderDraft, setWorktreesFolderDraft] = useState(""); const [worktreesFolderSaved, setWorktreesFolderSaved] = useState(null); + const lastSyncedGlobalWorktreesFolderProp = useRef(appSettings.globalWorktreesFolder); const environmentWorkspace = useMemo(() => { if (mainWorkspaces.length === 0) return null; @@ -63,8 +78,22 @@ export const useSettingsEnvironmentsSection = ({ }, [environmentDraftScript]); const environmentDirty = environmentDraftNormalized !== environmentSavedScript; + const globalWorktreesFolderDirty = + (globalWorktreesFolderDraft.trim() || null) !== globalWorktreesFolderSaved; const worktreesFolderDirty = (worktreesFolderDraft.trim() || null) !== worktreesFolderSaved; + useEffect(() => { + if (lastSyncedGlobalWorktreesFolderProp.current === appSettings.globalWorktreesFolder) { + return; + } + if (globalWorktreesFolderDirty) { + return; + } + lastSyncedGlobalWorktreesFolderProp.current = appSettings.globalWorktreesFolder; + setGlobalWorktreesFolderSaved(appSettings.globalWorktreesFolder); + setGlobalWorktreesFolderDraft(appSettings.globalWorktreesFolder ?? ""); + }, [appSettings.globalWorktreesFolder, globalWorktreesFolderDirty]); + useEffect(() => { if (!environmentWorkspace) { setEnvironmentWorkspaceId(null); @@ -72,7 +101,11 @@ export const useSettingsEnvironmentsSection = ({ setEnvironmentSavedScript(null); setEnvironmentDraftScript(""); setEnvironmentError(null); - setEnvironmentSaving(false); + if (!globalWorktreesFolderDirty) { + lastSyncedGlobalWorktreesFolderProp.current = appSettings.globalWorktreesFolder; + setGlobalWorktreesFolderDraft(appSettings.globalWorktreesFolder ?? ""); + setGlobalWorktreesFolderSaved(appSettings.globalWorktreesFolder); + } setWorktreesFolderDraft(""); setWorktreesFolderSaved(null); return; @@ -80,7 +113,12 @@ export const useSettingsEnvironmentsSection = ({ if (environmentWorkspaceId !== environmentWorkspace.id) { setEnvironmentWorkspaceId(environmentWorkspace.id); } - }, [environmentWorkspace, environmentWorkspaceId]); + }, [ + appSettings.globalWorktreesFolder, + environmentWorkspace, + environmentWorkspaceId, + globalWorktreesFolderDirty, + ]); useEffect(() => { if (!environmentWorkspace) return; @@ -114,20 +152,33 @@ export const useSettingsEnvironmentsSection = ({ ]); const handleSaveEnvironmentSetup = async () => { - if (!environmentWorkspace || environmentSaving) return; + if (environmentSaving) return; const nextScript = environmentDraftNormalized; + const nextGlobalFolder = globalWorktreesFolderDraft.trim() || null; const nextFolder = worktreesFolderDraft.trim() || null; + const globalWorktreesFolderChanged = nextGlobalFolder !== globalWorktreesFolderSaved; + const workspaceSettingsDirty = environmentDirty || worktreesFolderDirty; setEnvironmentSaving(true); setEnvironmentError(null); try { - await onUpdateWorkspaceSettings(environmentWorkspace.id, { - worktreeSetupScript: nextScript, - worktreesFolder: nextFolder, - }); - setEnvironmentSavedScript(nextScript); - setEnvironmentDraftScript(nextScript ?? ""); - setWorktreesFolderSaved(nextFolder); - setWorktreesFolderDraft(nextFolder ?? ""); + if (globalWorktreesFolderChanged) { + await onUpdateAppSettings({ + ...appSettings, + globalWorktreesFolder: nextGlobalFolder, + }); + setGlobalWorktreesFolderSaved(nextGlobalFolder); + setGlobalWorktreesFolderDraft(nextGlobalFolder ?? ""); + } + if (environmentWorkspace && workspaceSettingsDirty) { + await onUpdateWorkspaceSettings(environmentWorkspace.id, { + worktreeSetupScript: nextScript, + worktreesFolder: nextFolder, + }); + setEnvironmentSavedScript(nextScript); + setEnvironmentDraftScript(nextScript ?? ""); + setWorktreesFolderSaved(nextFolder); + setWorktreesFolderDraft(nextFolder ?? ""); + } } catch (error) { setEnvironmentError(error instanceof Error ? error.message : String(error)); } finally { @@ -143,11 +194,15 @@ export const useSettingsEnvironmentsSection = ({ environmentDraftScript, environmentSavedScript, environmentDirty, + globalWorktreesFolderDraft, + globalWorktreesFolderSaved, + globalWorktreesFolderDirty, worktreesFolderDraft, worktreesFolderSaved, worktreesFolderDirty, onSetEnvironmentWorkspaceId: setEnvironmentWorkspaceId, onSetEnvironmentDraftScript: setEnvironmentDraftScript, + onSetGlobalWorktreesFolderDraft: setGlobalWorktreesFolderDraft, onSetWorktreesFolderDraft: setWorktreesFolderDraft, onSaveEnvironmentSetup: handleSaveEnvironmentSetup, }; diff --git a/src/features/settings/hooks/useSettingsViewOrchestration.ts b/src/features/settings/hooks/useSettingsViewOrchestration.ts index 8850e47ca..1767d0d7f 100644 --- a/src/features/settings/hooks/useSettingsViewOrchestration.ts +++ b/src/features/settings/hooks/useSettingsViewOrchestration.ts @@ -169,6 +169,8 @@ export function useSettingsViewOrchestration({ }); const environmentsSectionProps = useSettingsEnvironmentsSection({ + appSettings, + onUpdateAppSettings, mainWorkspaces, onUpdateWorkspaceSettings, });