Skip to content

Commit e93a322

Browse files
authored
fix: add configurable global worktrees root setting (#568)
1 parent 771a3c4 commit e93a322

4 files changed

Lines changed: 439 additions & 41 deletions

File tree

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

Lines changed: 284 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -429,39 +429,49 @@ 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);
439-
440-
const props: ComponentProps<typeof SettingsView> = {
443+
const defaultGroupedWorkspaces =
444+
options.groupedWorkspaces ??
445+
[
446+
{
447+
id: null,
448+
name: "Ungrouped",
449+
workspaces: [
450+
workspace({
451+
id: "w1",
452+
name: "Project One",
453+
settings: {
454+
sidebarCollapsed: false,
455+
worktreeSetupScript: "echo one",
456+
},
457+
}),
458+
],
459+
},
460+
];
461+
462+
const buildProps = (
463+
nextOptions: {
464+
appSettings?: Partial<AppSettings>;
465+
groupedWorkspaces?: ComponentProps<typeof SettingsView>["groupedWorkspaces"];
466+
} = {},
467+
): ComponentProps<typeof SettingsView> => ({
441468
reduceTransparency: false,
442469
onToggleTransparency: vi.fn(),
443-
appSettings: baseSettings,
470+
appSettings: { ...baseSettings, ...options.appSettings, ...nextOptions.appSettings },
444471
openAppIconById: {},
445-
onUpdateAppSettings: vi.fn().mockResolvedValue(undefined),
472+
onUpdateAppSettings,
446473
workspaceGroups: [],
447-
groupedWorkspaces:
448-
options.groupedWorkspaces ??
449-
[
450-
{
451-
id: null,
452-
name: "Ungrouped",
453-
workspaces: [
454-
workspace({
455-
id: "w1",
456-
name: "Project One",
457-
settings: {
458-
sidebarCollapsed: false,
459-
worktreeSetupScript: "echo one",
460-
},
461-
}),
462-
],
463-
},
464-
],
474+
groupedWorkspaces: nextOptions.groupedWorkspaces ?? defaultGroupedWorkspaces,
465475
ungroupedLabel: "Ungrouped",
466476
onClose: vi.fn(),
467477
onMoveWorkspace: vi.fn(),
@@ -482,10 +492,19 @@ const renderEnvironmentsSection = (
482492
onCancelDictationDownload: vi.fn(),
483493
onRemoveDictationModel: vi.fn(),
484494
initialSection: "environments",
485-
};
495+
});
486496

487-
render(<SettingsView {...props} />);
488-
return { onUpdateWorkspaceSettings };
497+
const renderResult = render(<SettingsView {...buildProps()} />);
498+
return {
499+
onUpdateAppSettings,
500+
onUpdateWorkspaceSettings,
501+
rerender: (
502+
nextOptions: {
503+
appSettings?: Partial<AppSettings>;
504+
groupedWorkspaces?: ComponentProps<typeof SettingsView>["groupedWorkspaces"];
505+
} = {},
506+
) => renderResult.rerender(<SettingsView {...buildProps(nextOptions)} />),
507+
};
489508
};
490509

491510
describe("SettingsView Display", () => {
@@ -755,6 +774,246 @@ describe("SettingsView About", () => {
755774
});
756775

757776
describe("SettingsView Environments", () => {
777+
it("shows the global worktrees root input", () => {
778+
renderEnvironmentsSection({
779+
appSettings: { globalWorktreesFolder: "I:/existing-worktrees" },
780+
});
781+
782+
const input = screen.getByLabelText("Global worktrees root");
783+
expect(input).toBeTruthy();
784+
expect((input as HTMLInputElement).value).toBe("I:/existing-worktrees");
785+
expect((input as HTMLInputElement).placeholder).toBe("/path/to/worktrees-root");
786+
});
787+
788+
it("saves the global worktrees root through app settings", async () => {
789+
const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined);
790+
const onUpdateWorkspaceSettings = vi.fn().mockResolvedValue(undefined);
791+
renderEnvironmentsSection({
792+
onUpdateAppSettings,
793+
onUpdateWorkspaceSettings,
794+
});
795+
796+
const input = screen.getByLabelText("Global worktrees root");
797+
fireEvent.change(input, { target: { value: "I:/cm-worktrees" } });
798+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
799+
800+
await waitFor(() => {
801+
expect(onUpdateAppSettings).toHaveBeenCalledWith(
802+
expect.objectContaining({
803+
globalWorktreesFolder: "I:/cm-worktrees",
804+
}),
805+
);
806+
});
807+
expect(onUpdateWorkspaceSettings).not.toHaveBeenCalled();
808+
});
809+
810+
it("does not clear an existing global worktrees root when saving project-only changes", async () => {
811+
const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined);
812+
const onUpdateWorkspaceSettings = vi.fn().mockResolvedValue(undefined);
813+
renderEnvironmentsSection({
814+
appSettings: { globalWorktreesFolder: "I:/existing-worktrees" },
815+
onUpdateAppSettings,
816+
onUpdateWorkspaceSettings,
817+
});
818+
819+
const textarea = screen.getByPlaceholderText("pnpm install");
820+
fireEvent.change(textarea, { target: { value: "echo updated" } });
821+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
822+
823+
await waitFor(() => {
824+
expect(onUpdateWorkspaceSettings).toHaveBeenCalledWith("w1", {
825+
worktreeSetupScript: "echo updated",
826+
worktreesFolder: null,
827+
});
828+
});
829+
expect(onUpdateAppSettings).not.toHaveBeenCalled();
830+
});
831+
832+
it("keeps the global worktrees root marked as saved after workspace save fails", async () => {
833+
const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined);
834+
const onUpdateWorkspaceSettings = vi
835+
.fn()
836+
.mockRejectedValueOnce(new Error("Failed to save workspace settings"))
837+
.mockResolvedValueOnce(undefined);
838+
renderEnvironmentsSection({
839+
appSettings: { globalWorktreesFolder: "I:/existing-worktrees" },
840+
onUpdateAppSettings,
841+
onUpdateWorkspaceSettings,
842+
});
843+
844+
fireEvent.change(screen.getByLabelText("Global worktrees root"), {
845+
target: { value: "I:/cm-worktrees" },
846+
});
847+
fireEvent.change(screen.getByPlaceholderText("pnpm install"), {
848+
target: { value: "echo updated" },
849+
});
850+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
851+
852+
expect(
853+
await screen.findByText("Failed to save workspace settings"),
854+
).toBeTruthy();
855+
expect(onUpdateAppSettings).toHaveBeenCalledTimes(1);
856+
expect(onUpdateWorkspaceSettings).toHaveBeenCalledTimes(1);
857+
858+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
859+
860+
await waitFor(() => {
861+
expect(onUpdateWorkspaceSettings).toHaveBeenCalledTimes(2);
862+
});
863+
expect(onUpdateAppSettings).toHaveBeenCalledTimes(1);
864+
});
865+
866+
it("keeps the global worktrees root editable when there are no projects", async () => {
867+
const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined);
868+
renderEnvironmentsSection({
869+
groupedWorkspaces: [],
870+
onUpdateAppSettings,
871+
});
872+
873+
expect(screen.getByText("No projects yet.")).toBeTruthy();
874+
const input = screen.getByLabelText("Global worktrees root");
875+
fireEvent.change(input, { target: { value: "I:/cm-worktrees" } });
876+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
877+
878+
await waitFor(() => {
879+
expect(onUpdateAppSettings).toHaveBeenCalledWith(
880+
expect.objectContaining({
881+
globalWorktreesFolder: "I:/cm-worktrees",
882+
}),
883+
);
884+
});
885+
});
886+
887+
it("keeps the no-project global worktrees root save state active until the request resolves", async () => {
888+
let resolveSave: (() => void) | null = null;
889+
const pendingSave = new Promise<void>((resolve) => {
890+
resolveSave = resolve;
891+
});
892+
const onUpdateAppSettings = vi.fn().mockImplementation(() => pendingSave);
893+
renderEnvironmentsSection({
894+
groupedWorkspaces: [],
895+
onUpdateAppSettings,
896+
});
897+
898+
fireEvent.change(screen.getByLabelText("Global worktrees root"), {
899+
target: { value: "I:/cm-worktrees" },
900+
});
901+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
902+
903+
await waitFor(() => {
904+
expect(
905+
(screen.getByRole("button", { name: "Saving..." }) as HTMLButtonElement).disabled,
906+
).toBe(true);
907+
});
908+
expect((screen.getByLabelText("Global worktrees root") as HTMLInputElement).disabled).toBe(
909+
true,
910+
);
911+
expect(onUpdateAppSettings).toHaveBeenCalledTimes(1);
912+
913+
fireEvent.click(screen.getByRole("button", { name: "Saving..." }));
914+
expect(onUpdateAppSettings).toHaveBeenCalledTimes(1);
915+
916+
await act(async () => {
917+
resolveSave?.();
918+
await pendingSave;
919+
});
920+
921+
await waitFor(() => {
922+
expect((screen.getByRole("button", { name: "Save" }) as HTMLButtonElement).disabled).toBe(
923+
true,
924+
);
925+
});
926+
});
927+
928+
it("resyncs the global worktrees root baseline after dirty state clears", async () => {
929+
const { rerender } = renderEnvironmentsSection({
930+
groupedWorkspaces: [],
931+
appSettings: { globalWorktreesFolder: null },
932+
});
933+
934+
const input = screen.getByLabelText("Global worktrees root");
935+
fireEvent.change(input, { target: { value: "I:/typing" } });
936+
937+
rerender({
938+
groupedWorkspaces: [],
939+
appSettings: { globalWorktreesFolder: "I:/loaded-from-settings" },
940+
});
941+
942+
expect((screen.getByLabelText("Global worktrees root") as HTMLInputElement).value).toBe(
943+
"I:/typing",
944+
);
945+
946+
fireEvent.click(screen.getByRole("button", { name: "Reset" }));
947+
948+
await waitFor(() => {
949+
expect((screen.getByLabelText("Global worktrees root") as HTMLInputElement).value).toBe(
950+
"I:/loaded-from-settings",
951+
);
952+
});
953+
});
954+
955+
it("shows save errors for the global worktrees root when there are no projects", async () => {
956+
const onUpdateAppSettings = vi
957+
.fn()
958+
.mockRejectedValue(new Error("Failed to save global worktrees root"));
959+
renderEnvironmentsSection({
960+
groupedWorkspaces: [],
961+
onUpdateAppSettings,
962+
});
963+
964+
const input = screen.getByLabelText("Global worktrees root");
965+
fireEvent.change(input, { target: { value: "I:/cm-worktrees" } });
966+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
967+
968+
expect(
969+
await screen.findByText("Failed to save global worktrees root"),
970+
).toBeTruthy();
971+
});
972+
973+
it("keeps the new global worktrees root as saved when workspace settings fail afterward", async () => {
974+
const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined);
975+
const onUpdateWorkspaceSettings = vi
976+
.fn()
977+
.mockRejectedValue(new Error("Failed to save workspace settings"));
978+
renderEnvironmentsSection({
979+
appSettings: { globalWorktreesFolder: "I:/existing-worktrees" },
980+
onUpdateAppSettings,
981+
onUpdateWorkspaceSettings,
982+
});
983+
984+
const input = screen.getByLabelText("Global worktrees root");
985+
const textarea = screen.getByPlaceholderText("pnpm install");
986+
fireEvent.change(input, { target: { value: "I:/cm-worktrees" } });
987+
fireEvent.change(textarea, { target: { value: "echo updated" } });
988+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
989+
990+
expect(
991+
await screen.findByText("Failed to save workspace settings"),
992+
).toBeTruthy();
993+
994+
await waitFor(() => {
995+
expect(onUpdateAppSettings).toHaveBeenCalledWith(
996+
expect.objectContaining({
997+
globalWorktreesFolder: "I:/cm-worktrees",
998+
}),
999+
);
1000+
expect(onUpdateWorkspaceSettings).toHaveBeenCalledWith("w1", {
1001+
worktreeSetupScript: "echo updated",
1002+
worktreesFolder: null,
1003+
});
1004+
});
1005+
1006+
expect((input as HTMLInputElement).value).toBe("I:/cm-worktrees");
1007+
1008+
onUpdateWorkspaceSettings.mockResolvedValueOnce(undefined);
1009+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
1010+
1011+
await waitFor(() => {
1012+
expect(onUpdateWorkspaceSettings).toHaveBeenCalledTimes(2);
1013+
});
1014+
expect(onUpdateAppSettings).toHaveBeenCalledTimes(1);
1015+
});
1016+
7581017
it("saves the setup script for the selected project", async () => {
7591018
const onUpdateWorkspaceSettings = vi.fn().mockResolvedValue(undefined);
7601019
renderEnvironmentsSection({ onUpdateWorkspaceSettings });

0 commit comments

Comments
 (0)