From dd72acdbe32e5dbd0024addd5f28162a5a33823e Mon Sep 17 00:00:00 2001 From: Reekin Date: Fri, 20 Mar 2026 00:20:23 +0800 Subject: [PATCH 1/5] Add configurable global worktrees root setting --- .../settings/components/SettingsView.test.tsx | 85 ++++++++++++++++++- .../sections/SettingsEnvironmentsSection.tsx | 85 ++++++++++++++++++- .../hooks/useSettingsEnvironmentsSection.ts | 58 +++++++++++-- .../hooks/useSettingsViewOrchestration.ts | 2 + 4 files changed, 217 insertions(+), 13 deletions(-) diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 5cd553999..733b17c04 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -429,20 +429,24 @@ 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 = { reduceTransparency: false, onToggleTransparency: vi.fn(), - appSettings: baseSettings, + appSettings: { ...baseSettings, ...options.appSettings }, openAppIconById: {}, - onUpdateAppSettings: vi.fn().mockResolvedValue(undefined), + onUpdateAppSettings, workspaceGroups: [], groupedWorkspaces: options.groupedWorkspaces ?? @@ -485,7 +489,7 @@ const renderEnvironmentsSection = ( }; render(); - return { onUpdateWorkspaceSettings }; + return { onUpdateAppSettings, onUpdateWorkspaceSettings }; }; describe("SettingsView Display", () => { @@ -755,6 +759,81 @@ 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", + }), + ); + }); + }); + + 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 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("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..9f51944f1 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,96 @@ 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 ? (
No projects yet.
) : ( <> @@ -138,7 +216,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,6 +46,12 @@ 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); @@ -63,8 +77,21 @@ export const useSettingsEnvironmentsSection = ({ }, [environmentDraftScript]); const environmentDirty = environmentDraftNormalized !== environmentSavedScript; + const globalWorktreesFolderDirty = + (globalWorktreesFolderDraft.trim() || null) !== globalWorktreesFolderSaved; const worktreesFolderDirty = (worktreesFolderDraft.trim() || null) !== worktreesFolderSaved; + useEffect(() => { + if (!globalWorktreesFolderDirty && globalWorktreesFolderSaved !== appSettings.globalWorktreesFolder) { + setGlobalWorktreesFolderSaved(appSettings.globalWorktreesFolder); + setGlobalWorktreesFolderDraft(appSettings.globalWorktreesFolder ?? ""); + } + }, [ + appSettings.globalWorktreesFolder, + globalWorktreesFolderDirty, + globalWorktreesFolderSaved, + ]); + useEffect(() => { if (!environmentWorkspace) { setEnvironmentWorkspaceId(null); @@ -73,6 +100,8 @@ export const useSettingsEnvironmentsSection = ({ setEnvironmentDraftScript(""); setEnvironmentError(null); setEnvironmentSaving(false); + setGlobalWorktreesFolderDraft(appSettings.globalWorktreesFolder ?? ""); + setGlobalWorktreesFolderSaved(appSettings.globalWorktreesFolder); setWorktreesFolderDraft(""); setWorktreesFolderSaved(null); return; @@ -80,7 +109,7 @@ export const useSettingsEnvironmentsSection = ({ if (environmentWorkspaceId !== environmentWorkspace.id) { setEnvironmentWorkspaceId(environmentWorkspace.id); } - }, [environmentWorkspace, environmentWorkspaceId]); + }, [appSettings.globalWorktreesFolder, environmentWorkspace, environmentWorkspaceId]); useEffect(() => { if (!environmentWorkspace) return; @@ -114,16 +143,27 @@ 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; setEnvironmentSaving(true); setEnvironmentError(null); try { - await onUpdateWorkspaceSettings(environmentWorkspace.id, { - worktreeSetupScript: nextScript, - worktreesFolder: nextFolder, - }); + if (nextGlobalFolder !== globalWorktreesFolderSaved) { + await onUpdateAppSettings({ + ...appSettings, + globalWorktreesFolder: nextGlobalFolder, + }); + } + if (environmentWorkspace) { + await onUpdateWorkspaceSettings(environmentWorkspace.id, { + worktreeSetupScript: nextScript, + worktreesFolder: nextFolder, + }); + } + setGlobalWorktreesFolderSaved(nextGlobalFolder); + setGlobalWorktreesFolderDraft(nextGlobalFolder ?? ""); setEnvironmentSavedScript(nextScript); setEnvironmentDraftScript(nextScript ?? ""); setWorktreesFolderSaved(nextFolder); @@ -143,11 +183,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, }); From d56053bc3a140bcd2471b360383221d4c652eaf5 Mon Sep 17 00:00:00 2001 From: Reekin Date: Fri, 20 Mar 2026 00:38:51 +0800 Subject: [PATCH 2/5] Refine global worktrees root save behavior --- .../settings/components/SettingsView.test.tsx | 19 +++++++++++++++++++ .../sections/SettingsEnvironmentsSection.tsx | 3 +++ .../hooks/useSettingsEnvironmentsSection.ts | 3 ++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 733b17c04..3ceb29050 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -789,6 +789,7 @@ describe("SettingsView Environments", () => { }), ); }); + expect(onUpdateWorkspaceSettings).not.toHaveBeenCalled(); }); it("does not clear an existing global worktrees root when saving project-only changes", async () => { @@ -834,6 +835,24 @@ describe("SettingsView Environments", () => { }); }); + 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("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 9f51944f1..bff9f01f4 100644 --- a/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx +++ b/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx @@ -119,6 +119,9 @@ export function SettingsEnvironmentsSection({
) : null} + {!hasProjects && environmentError ? ( +
{environmentError}
+ ) : null} {!hasProjects ? ( diff --git a/src/features/settings/hooks/useSettingsEnvironmentsSection.ts b/src/features/settings/hooks/useSettingsEnvironmentsSection.ts index 2bbce002a..35aedc4b2 100644 --- a/src/features/settings/hooks/useSettingsEnvironmentsSection.ts +++ b/src/features/settings/hooks/useSettingsEnvironmentsSection.ts @@ -147,6 +147,7 @@ export const useSettingsEnvironmentsSection = ({ const nextScript = environmentDraftNormalized; const nextGlobalFolder = globalWorktreesFolderDraft.trim() || null; const nextFolder = worktreesFolderDraft.trim() || null; + const workspaceSettingsDirty = environmentDirty || worktreesFolderDirty; setEnvironmentSaving(true); setEnvironmentError(null); try { @@ -156,7 +157,7 @@ export const useSettingsEnvironmentsSection = ({ globalWorktreesFolder: nextGlobalFolder, }); } - if (environmentWorkspace) { + if (environmentWorkspace && workspaceSettingsDirty) { await onUpdateWorkspaceSettings(environmentWorkspace.id, { worktreeSetupScript: nextScript, worktreesFolder: nextFolder, From 50676b5fea98d1f2655153b31f697db7e17786fb Mon Sep 17 00:00:00 2001 From: Reekin Date: Fri, 20 Mar 2026 00:49:28 +0800 Subject: [PATCH 3/5] Fix global worktrees root partial-save state --- .../settings/components/SettingsView.test.tsx | 78 +++++++++++++++++++ .../hooks/useSettingsEnvironmentsSection.ts | 31 ++++---- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 3ceb29050..9c84aeb3a 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -814,6 +814,40 @@ describe("SettingsView Environments", () => { 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({ @@ -853,6 +887,50 @@ describe("SettingsView Environments", () => { ).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/hooks/useSettingsEnvironmentsSection.ts b/src/features/settings/hooks/useSettingsEnvironmentsSection.ts index 35aedc4b2..ec9fb6973 100644 --- a/src/features/settings/hooks/useSettingsEnvironmentsSection.ts +++ b/src/features/settings/hooks/useSettingsEnvironmentsSection.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { Dispatch, SetStateAction } from "react"; import type { AppSettings, WorkspaceInfo } from "@/types"; import { normalizeWorktreeSetupScript } from "@settings/components/settingsViewHelpers"; @@ -54,6 +54,7 @@ export const useSettingsEnvironmentsSection = ({ ); const [worktreesFolderDraft, setWorktreesFolderDraft] = useState(""); const [worktreesFolderSaved, setWorktreesFolderSaved] = useState(null); + const lastGlobalWorktreesFolderProp = useRef(appSettings.globalWorktreesFolder); const environmentWorkspace = useMemo(() => { if (mainWorkspaces.length === 0) return null; @@ -82,15 +83,16 @@ export const useSettingsEnvironmentsSection = ({ const worktreesFolderDirty = (worktreesFolderDraft.trim() || null) !== worktreesFolderSaved; useEffect(() => { - if (!globalWorktreesFolderDirty && globalWorktreesFolderSaved !== appSettings.globalWorktreesFolder) { + const previousGlobalWorktreesFolder = lastGlobalWorktreesFolderProp.current; + lastGlobalWorktreesFolderProp.current = appSettings.globalWorktreesFolder; + if (previousGlobalWorktreesFolder === appSettings.globalWorktreesFolder) { + return; + } + if (!globalWorktreesFolderDirty) { setGlobalWorktreesFolderSaved(appSettings.globalWorktreesFolder); setGlobalWorktreesFolderDraft(appSettings.globalWorktreesFolder ?? ""); } - }, [ - appSettings.globalWorktreesFolder, - globalWorktreesFolderDirty, - globalWorktreesFolderSaved, - ]); + }, [appSettings.globalWorktreesFolder, globalWorktreesFolderDirty]); useEffect(() => { if (!environmentWorkspace) { @@ -147,28 +149,29 @@ export const useSettingsEnvironmentsSection = ({ 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 { - if (nextGlobalFolder !== globalWorktreesFolderSaved) { + 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 ?? ""); } - setGlobalWorktreesFolderSaved(nextGlobalFolder); - setGlobalWorktreesFolderDraft(nextGlobalFolder ?? ""); - setEnvironmentSavedScript(nextScript); - setEnvironmentDraftScript(nextScript ?? ""); - setWorktreesFolderSaved(nextFolder); - setWorktreesFolderDraft(nextFolder ?? ""); } catch (error) { setEnvironmentError(error instanceof Error ? error.message : String(error)); } finally { From 405d24fed4a29f62348ff55cccf3639d7e57205c Mon Sep 17 00:00:00 2001 From: Reekin Date: Fri, 20 Mar 2026 01:11:00 +0800 Subject: [PATCH 4/5] Fix global worktrees root resync after dirty edits --- .../settings/components/SettingsView.test.tsx | 90 ++++++++++++++----- .../hooks/useSettingsEnvironmentsSection.ts | 28 +++--- 2 files changed, 84 insertions(+), 34 deletions(-) diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 9c84aeb3a..dbf24cea1 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -440,32 +440,38 @@ const renderEnvironmentsSection = ( 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, ...options.appSettings }, + appSettings: { ...baseSettings, ...options.appSettings, ...nextOptions.appSettings }, openAppIconById: {}, 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(), @@ -486,10 +492,19 @@ const renderEnvironmentsSection = ( onCancelDictationDownload: vi.fn(), onRemoveDictationModel: vi.fn(), initialSection: "environments", - }; + }); - render(); - return { onUpdateAppSettings, onUpdateWorkspaceSettings }; + const renderResult = render(); + return { + onUpdateAppSettings, + onUpdateWorkspaceSettings, + rerender: ( + nextOptions: { + appSettings?: Partial; + groupedWorkspaces?: ComponentProps["groupedWorkspaces"]; + } = {}, + ) => renderResult.rerender(), + }; }; describe("SettingsView Display", () => { @@ -869,6 +884,33 @@ describe("SettingsView Environments", () => { }); }); + 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() diff --git a/src/features/settings/hooks/useSettingsEnvironmentsSection.ts b/src/features/settings/hooks/useSettingsEnvironmentsSection.ts index ec9fb6973..f65f91eb0 100644 --- a/src/features/settings/hooks/useSettingsEnvironmentsSection.ts +++ b/src/features/settings/hooks/useSettingsEnvironmentsSection.ts @@ -54,7 +54,7 @@ export const useSettingsEnvironmentsSection = ({ ); const [worktreesFolderDraft, setWorktreesFolderDraft] = useState(""); const [worktreesFolderSaved, setWorktreesFolderSaved] = useState(null); - const lastGlobalWorktreesFolderProp = useRef(appSettings.globalWorktreesFolder); + const lastSyncedGlobalWorktreesFolderProp = useRef(appSettings.globalWorktreesFolder); const environmentWorkspace = useMemo(() => { if (mainWorkspaces.length === 0) return null; @@ -83,15 +83,15 @@ export const useSettingsEnvironmentsSection = ({ const worktreesFolderDirty = (worktreesFolderDraft.trim() || null) !== worktreesFolderSaved; useEffect(() => { - const previousGlobalWorktreesFolder = lastGlobalWorktreesFolderProp.current; - lastGlobalWorktreesFolderProp.current = appSettings.globalWorktreesFolder; - if (previousGlobalWorktreesFolder === appSettings.globalWorktreesFolder) { + if (lastSyncedGlobalWorktreesFolderProp.current === appSettings.globalWorktreesFolder) { return; } - if (!globalWorktreesFolderDirty) { - setGlobalWorktreesFolderSaved(appSettings.globalWorktreesFolder); - setGlobalWorktreesFolderDraft(appSettings.globalWorktreesFolder ?? ""); + if (globalWorktreesFolderDirty) { + return; } + lastSyncedGlobalWorktreesFolderProp.current = appSettings.globalWorktreesFolder; + setGlobalWorktreesFolderSaved(appSettings.globalWorktreesFolder); + setGlobalWorktreesFolderDraft(appSettings.globalWorktreesFolder ?? ""); }, [appSettings.globalWorktreesFolder, globalWorktreesFolderDirty]); useEffect(() => { @@ -102,8 +102,11 @@ export const useSettingsEnvironmentsSection = ({ setEnvironmentDraftScript(""); setEnvironmentError(null); setEnvironmentSaving(false); - setGlobalWorktreesFolderDraft(appSettings.globalWorktreesFolder ?? ""); - setGlobalWorktreesFolderSaved(appSettings.globalWorktreesFolder); + if (!globalWorktreesFolderDirty) { + lastSyncedGlobalWorktreesFolderProp.current = appSettings.globalWorktreesFolder; + setGlobalWorktreesFolderDraft(appSettings.globalWorktreesFolder ?? ""); + setGlobalWorktreesFolderSaved(appSettings.globalWorktreesFolder); + } setWorktreesFolderDraft(""); setWorktreesFolderSaved(null); return; @@ -111,7 +114,12 @@ export const useSettingsEnvironmentsSection = ({ if (environmentWorkspaceId !== environmentWorkspace.id) { setEnvironmentWorkspaceId(environmentWorkspace.id); } - }, [appSettings.globalWorktreesFolder, environmentWorkspace, environmentWorkspaceId]); + }, [ + appSettings.globalWorktreesFolder, + environmentWorkspace, + environmentWorkspaceId, + globalWorktreesFolderDirty, + ]); useEffect(() => { if (!environmentWorkspace) return; From 3d598c6851ce6891e8adc9fb496ed92b12c086d7 Mon Sep 17 00:00:00 2001 From: Reekin Date: Fri, 20 Mar 2026 01:30:25 +0800 Subject: [PATCH 5/5] Keep no-project global root save state active --- .../settings/components/SettingsView.test.tsx | 41 +++++++++++++++++++ .../hooks/useSettingsEnvironmentsSection.ts | 1 - 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index dbf24cea1..9633cbca0 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -884,6 +884,47 @@ describe("SettingsView Environments", () => { }); }); + 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: [], diff --git a/src/features/settings/hooks/useSettingsEnvironmentsSection.ts b/src/features/settings/hooks/useSettingsEnvironmentsSection.ts index f65f91eb0..5e315b986 100644 --- a/src/features/settings/hooks/useSettingsEnvironmentsSection.ts +++ b/src/features/settings/hooks/useSettingsEnvironmentsSection.ts @@ -101,7 +101,6 @@ export const useSettingsEnvironmentsSection = ({ setEnvironmentSavedScript(null); setEnvironmentDraftScript(""); setEnvironmentError(null); - setEnvironmentSaving(false); if (!globalWorktreesFolderDirty) { lastSyncedGlobalWorktreesFolderProp.current = appSettings.globalWorktreesFolder; setGlobalWorktreesFolderDraft(appSettings.globalWorktreesFolder ?? "");