@@ -429,39 +429,49 @@ const workspace = (
429429
430430const 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
491510describe ( "SettingsView Display" , ( ) => {
@@ -755,6 +774,246 @@ describe("SettingsView About", () => {
755774} ) ;
756775
757776describe ( "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