From 1e30e9bf9e7165cc1dbe11e81e50c3418ef08eaa Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Fri, 13 Mar 2026 17:25:00 +1100 Subject: [PATCH] Fix add button. Fix hotkeys in settings. Add Workspace hotkeys --- .../UserInterfaceState.xcuserstate | Bin 40698 -> 42772 bytes .../Components/HotkeyRecorderView.swift | 14 +++-- Downterm/CommandNotch/Components/TabBar.swift | 20 +++---- .../CommandNotch/Managers/HotkeyManager.swift | 41 ++++++++++++-- .../CommandNotch/Managers/ScreenManager.swift | 37 ++++++++++++ .../CommandNotch/Models/AppSettings.swift | 4 ++ .../Models/AppSettingsStore.swift | 4 ++ .../CommandNotch/Models/HotkeyBinding.swift | 2 + .../CommandNotch/Models/NotchSettings.swift | 6 ++ .../Models/WorkspaceController.swift | 9 ++- .../Models/WorkspaceRegistry.swift | 31 ++++++++++ .../Models/WorkspaceSummary.swift | 4 +- .../Views/HotkeySettingsView.swift | 7 ++- .../Views/TerminalSettingsView.swift | 53 ++++++++++-------- .../Views/WorkspacesSettingsView.swift | 20 +++++++ .../WorkspaceRegistryTests.swift | 24 ++++++++ .../WorkspaceStoreTests.swift | 6 +- 17 files changed, 232 insertions(+), 50 deletions(-) diff --git a/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate b/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate index 6e7d28c0316cdb62fab92dd6439e09bfb70a2250..0087c70aeeafac6a66d6ecce8608e2c7363afb84 100644 GIT binary patch delta 20068 zcmbW92Urxx`}l8XZ_DkjQj}hfjv&YhNC)Xi@4dqv9gZp@!tGFvB}OksjY(9n#ol9& z8cU+aUSf$cc4JJU(Igs;|5;8F_~!e3pa1jYp1Wh`otd5a%scPAGqZCq_ks6Mf%gir z-nm89dYeRhMf*hiMW2ffhz^U6h`tgX7kxeVPP8fz3&p~)a4ZQ+#>BnRb{o5oJ;Hv(e!+gje#c&7e`2q|XrKr5fdMcCM!*=D08?NF%vHb&H~>d57PtX- zFab;ifglJ3gK!WDVnG~81L+_GWP&V^4T^yrC_o7)1(l!%G=e5D6?B1a&;xqGbnp&P zgZIIF@B!!p{a`s*0ak)7U@O=Lwu2quI5+`Lg0I0Ta2lKeXTdq}4Y&es0QLcR2z~(1 zz;p1M+6ycnBPPV8m=bG=wZ%GO6S1k-Ol&T;65EN#iCx4pv6t9e94(F)r>VtwHg;cK zfJ;>-gp{x#YzSMzfp8?o5iW!q;ZArF-b5miL?ja_L^2lsPU9HaSkqPnRb>OJaxY9Y0hT1NFz{nT=59krhNjM_u(r4CRB zsUy@;>I`+3I!B$SE>qu9H>mHZ`_u#KA@u|Gi28+kLOrJ`nx-WxnxR>mqesvqXLrS0f(v*#Itc6tYWfIdurNuQz5(hup!^fUSu{hIzuLP{tJy1%YO zUt%n=l2}W8B@-p#k~m4cBteoP$&?gJN+p$&21%o2mgGIj0?87|QpqyOr;;_2ZIYdm zy^_O{Ba&|=HzeOlZc4tF+>+du+>zXs+>_jwJd!+>yp;SYdBunsg3)5M868HK(PxYq zE5@2}U|bj(H3FahohB?bzWG*pRnQP2<%uVJFbC-F@{J{Lg{LK8yJY)V~UNEnjzgWQH zEXC3+$BtliSY1}1HDFCxQ`UmDWNleH){z~{s$5tZ>&{MKy;&bNfSt&Oun}xD8_y=Q z>1;Ne&la=gYz14xHnL4@3){v*wwry2oz1G*_t_8GPuM{sm9>{<3Adxia$z0KZX@3IfspV?p8*X&>H8&1S=+z4(Yr^Sup^i`Y@XUa*rv78g< z%#Gt*ISVYh1>8bz5%(dtm|MZE9*BLVu9;kEvl_5Z)lXsE?@u` z6Xck&>O3w$_vt)k%IbC0NL@XBLt`^@OKW@e1}&j-DsAi>Eg3a>8LKTihiPe# zMpp)-`&brT7>#xdI<{Ge^)3oE(dlDNGZN~R@}}a(20^TYLZzdZS>%K$tE&rZi<0&^x%4@YvlhMmvp!&<`L9nl z-a4@Uz`%udV9zG?suAN#w~IbQgUmrRynH1(C%P=UD!MItAbKqNS@a6SFb31YMqx&n z31)#=VfL5<=7hOmURV$se_}Pc0!;HQa((;#_6tipCsK)c(L9klU)xT-d8D2?dE_&r zK2g7DxyVMeQe?|d;63;tegq%PkK~=zH?&ODyjFp_NXvY@HaIUDAsQ*t5^Wak5IHRu zZ4qr1Z4+(hJ$WzQoA+6cO1%^1vYYqi15geV)nl|5S05C8u}pM`_v8JC(;FzqQPHuS zxa8QT#=@d1MY5u?UvyOTrKmGCE*V{9dXyAem| z|C&F=|9~i!8d|I88#rs_#THH}uP&dekS8}5HY(J24WiYZ2BX!74J_5;482F3$NVsV zkroypI*UzITRAuemkd+~HC=FaTykWMTrnk~u(7NU3lVh=4bT>8i!Nai*d#0xC6B_Q zu^22Ci^Jlv1oaN1Dt0no$+z&+_zFHq?QHDjlY;3k$5OF0EFH_>t9T_}&o}bv=do-o z2c^#yEywb+BISyj#`2PKMSbePjrnTS?ltNq#+G;yR;<2nEXDgUx%#fLwMvt$1S`ez z`C7h?ugO9kp;lRgGHB4o2eYjbvgI3uT!uCEpuBEC-t=GOlTo3Xu_;&!HZ@zWEN-es z8K)-2WHy#pD;gRL+3H%pnV-Uo^0h0~6Dxhyr%gOe+pzY0?NOm=DWPu@#3+jk8_Sh7 zeV9sZX=-W!u@0;=s}|u*Zmch_Ddk)FsrlMb>L-a&>ZPXS=pL*$8zm1fuTlui`?2ZR zJL=u0cKA$emio4-Gd5eTW9AkyM|5%-_8#BP2fo!QOpSdga$1SKkIlzEz!qQ&u|>R! z=XuC?@SS`Y>OW;3O0v5iO@v31ybYy&@?e}|vZk8Q#> zV_W!{{C@s`dV+b3%T7#p8Mcd`#iw7v_G0_6{n+Q&0mSDZb_n|d%f*ghN3mll%~#lQ z>;!gFy~+HDs+wQOf5^{9d3?_AhU>~H?6gK#M9ca2`F963MWfmm1y#9(UBbVxxA>QQPB{utxZejJEuK}UDnn6<+y7T0_*tA1J(mXfJtcxPikanABaFE z3q+yx(f>{#dHZY0Dl%sG`*u|Z&0CsW_|4XV@}&bq!(a{KL6S%ZRUrW+^4s|xeIOZQ z`Onk~Y%JqytOMVUBHE3E(Q`n)P~lvV$M50y_F*hY=F|Akc)QdRWtF@kZ?H4RC~8U@ z%W}(W${Qz+DOSoA^=iV_#jFgJ=WCCS3=IpDmp3ZwC!rBtS>F=bsHpA(73xG=saX}K zyA)}oX50ZvP$zO)25R|3%RoK5fBf%G3R;0mpgj$=fp-1~ zf0RGg4|tIc=-|IZ&HR-bGm5eRGr+t5iZlmBn#-R+kxr^Bja=gg@4WzggvLCu5G(>8 zg2nu4{tSPXKerrw3_cMp2TS?${5MF~zvrDZViYBff)kLLR9;#(@LyI;VT-b|ma&z&at%)!`@^%#3I$?xC~k<-$p zXdrBI_|3=%VHgQ-Ck8n%;mS7U`yXbviWCJm8)Sx3F<|lki8nlJ#5!tLd*=m&fXAd29c6PYB*mJ5Y z8b^ZoAc*;*uXOF1Z`B$9ON=3FfWHxoAm$ITfIcx0;}Dw&v0(LqqDVtAEoM;!u>>SS zED%{2F^91b3sMLATG;7|^^u~9M~O#^^&l1ku~3MG^@|O}h5+Ri4zY+KHZqlxinPQQ zAWS{S*OJ+MPHc@XZP4XzUrVdaZ_6XL7dsAEC$R&+2V#-PJc&6zO}OD2?*gCkVt1i1 zuJ~Dq#XwBfC!T;`fLJWV5Ttxly#MwK?9-s>|t z8?i|f*pv!v(vdl}`wug!qce4|FxBF^{|2pI+#o>9gjkjU?VPw7;hBQ)_<0Y|YDJgR z&}FH&rQYAPRN@W|Ey(ZL`ipOZ80Y_17L&2!TjDz+{bl0Y5Nls1z6&uGZ{At5`jbzegppwzX>r{h%JVg8^qipHUVNE zLF{9Qc|gn)VqR+qq)5Vxh$5ni81x+{bjeSIE-7eKAH>!|Y@P7$di8hJ^~ppkQY-=~ z^OCoUMPwqyBC?2V;SI5+5L>4CM5^VfEq$voL=mAttxOaXa)|XqYe%-(8Lvl@G3$`HVhE`7G2&zm*X2O3;rhhJ%M^*A4nu_^QgpM3efHe(7yV&l_359 zUSt?7@{a@>%#piB{3IYh`A=#Axr%sBivDlPOJW-3B?<9tk`mdH5&`+CLFGLMv3!VK z6O{KlY6j;4%Setu*hv(0`YrZgQU`G-b;(h}8)9c5c2@I=xF>6yzO4jlOq!uekS3%l z#Lh$PLLX^PN+EU;V&4edvq>9r%zx2f(t&goxL<eFG!Ke3 zT>D_5`SH&$ZbDu*>58Jdp|(Jb~-OL9UN96Bmg6KA_3bB=VM*k<}3U;Vn|K77_jTlx2`)GdWeLz!b6tVn0Ic zaUa=APJ`G_5PKqw*Eu94yM#(4lAVIK{VY_XN2tUv|JF7GqrqHfl5-G#auzw8d>3L* zA@(c8p7oROk#hz7&ms2PFtsNaBDRYVTP+(w?a7bO<;UnU-sYc?oLoxwYiRZfH2?Ug zAOng^eoAipKPv9_w}5wPYz4Vb0Q_L}_MiY6_2xeZAx}IqU@ypH#BLH9 z0Aw#-X+DwOjvl1)HHnNtA9;#Irr!5S#^h!4s)jXEB?!>g&|uFN zs+djQBvHrzmwQCsCGR1s0Ea-VQR*KMzDNI7>T%<)wg35Bm`_hib>(?(L8GA5KcT2U zBhqckA*7#@&o!ixUW{V$Pl^=u_7(Y>{EK`;i71Q$6i$gL0s2bmnu_Z zsIjQnlmq1mfgS|tUnj~L0s{z)1iVI+D>Xrg;D$OYFhrddO>DQIiRuA?u07>TO+<7l zKgyp9fWR0669`QEsX!`7plb$!`GC&e^m11x722y`7g(y!QIkYkROG)=|9h5wj*3B1 zWB(oX-xK<`>Ov(@$pg(mB?--7(T8n8GwTDw%=)b}sD4qXrJ^z^`TtGvDaG67D5ol@ zO4KaXLUULRHis<);~;QB78}U^)_kf?Xp?#ftlzdtAk~B#g_u7l_bpGflu zmtd(jiWkVYQz{7TAh7SFAk_iE7zoA=cpOwOHS@oyHZ_ZyEx>nxz;QsesksQ^JfyF` zH427;nvX6&K$n(H|JY<|5%rO9vk!%4c0$ceeT-}}a8?&JjWMQHP^&diR|!zZBi*Jx zM&=BOkrMb(8>lU4b&lFdZK5_q;0D122t50#t<*MZI|SYkL_$zF%oC+{54eE9{XeIF z)aM$3hcL|>>P%9HH3Bd7^^7!^!PY)Toe=K)CG{0`90DH*d?E1br%qB|Q>UN+Ap(L) zXnLKPs;_>WYNAfh$aTFSG{!{;0{HX`)Hi7P6ggzn73wN=jk=!Mpr}#|Ok5xcf*=rr zi4cUNLds34o78PIous~}Zb1+XK}a8Uhq?E;S7t~AYPwEx*8iFVYq9KTZAQpl+2;w0~SVg^|MKneO z8mGlHvJt3Rk|0Qi0I5qV1UV4oqI!)>c2r+Rb*s!UQFo+1rD-i%Tezb-KV2{3?M>;? zwBE2%(1x^;CSbsypiL1F8gI*Cpe3SACC83d(5p|59@n9~__ z_J0&Qht7qd0)k4E@!U*(byuciAze%(t01h$45BNgt3^&L=rXk6;*a~$s31y+Ngzf+ z47Ew!3c7|?(zSFQjXHM&1gLku2f@b>tQn^5bhAcxYt>p=(}qlh=xLhpdKB)V){(`i zXcdjtEdDRfJ3Uk5ywh`t0q31o3wEjTt@93={~nO}zs(Z8Kw!8Kf~L1-iS}QN%o6>P zXy6UOWVBQvd?2GF9wgFFuSBMRUQVxoUlOW$9C~PaT zBlKp$j!aX($T5}CpV51e!TL|fp59CE6Z&d91gZgpMIRJt(T7mCI+!aMEcyt#Jc=%V z%>8G)rN5$24%j66gkY1Ppj4X$rJ9Cpl99zZ`deYR{hw~4Y7KqkEwo#2q1_Xpbq+%7 z{u|o6|3Ldefc6N2u0d!&4KO3J#VMlY2-m>$F;Rdvi0nE2hk)ca`gaI=An5I*U(hch zn2vmpnF2GTHxeLJB~gM29?UzaDiX0!l^N;Y<$ zQ6eqLXoP8fp=Ipma}on|Y54D<({_K|dfX+VWKmgOnV0&3E z6yt!>$ihbA^8Z0pBGV9+xNC??yab|i2Z^fxCc5}jta;AvRke_AUg2OBj;5|C884$zp-nBFTpkEQ6r0Px6uE zV+i^oSfL@-Cs`px=odQea@1)hVxiN{;M3HomXPU!3GF6La+$}n1I#vXzre~)U6Bjbcb%vcDHE@P0he~fp| z*ET{9l96WZl^KudFs=}M$){gL8;s4&1Z*palYgn6NE|~%!H()%{X~sV_`7v1Hp-bjW;HY3D5oeDTGiY z>_)Zc1FeU3}iZ( zPNs|LW_p-jW;*i@GlQAQ%wlFU?=o|k_n5iNJVp(1U8urGL3}jC^&qYfaRZ1OLfi=A z#t=7wxEaLFA#MS2ONd)R+#2FG5VwW69mK~#+yUZ_5FZP1Cx|;k+y&w?h`T}D9pV!p z?g4R6hE#wSA$WX{tM|}bAJ^n@d*IejV{C`!e_A8EJ0yQK7c#7L7c^1OW1vyVLlIxm zgg+d}wtje;8yeZ80ogahWw$l5#{)9Em|^#NpppHoelK>|y&h}ApA4i~9y+WFPc^b% zhgQKod{~s;H4bwrE)X4rEki9=?SUOfC zdo9R(!zY9Xdjy4sIfr;o2zPc53-xdg_C)&Q9ugGfK0)Rg7Ub?Rq}5n$P2x9$WqveC zHl*QLy#X|k&PIT9U2bSOC!^oH)&WY{58Qk!kx0CZB&{a95#>*8a8=i zLs_(?Gh{Z!PGTd62gIR0icirvDZ1>#{P94y05b`P3Tv%3Un z;}N$$b}#a|a95!gSq+N9`r@(_#gxXxs=)+@*e`~c;3#`+c))QMt>z4^R_ZDC^ze{# z?D^pVm)Oh01Fo{yh6mhWZ=(HI_B)9CE@QukxSx7Ui*?~Wt~;{zp=kVJP6{!5D)2RU$8IPKOr6p@mPqb zsXeC7lyMk`mVKAgP8`mOIkaSep6CdJcsRr(mU9$Ga}o}@3X>on1@UNz$Ecr7^-*!! zA}vmb(-j;i;Xyv@pku3VxE&yFKs<85GvY>bdKynj(Z`KO&n*q4FyM4i%neo_P6}Qr z#2g4T9thn0)2wqJ;C~02X=2*!-Wop>1-@+{&VsWQ$~wl0v*N5drxhv26} zJfn{bZVYK3BjMaz#QXhPVPP|Bl2< zAYKabGKiN$yh82Y{wgpdwy=2o1gG(?gLP=)rXu3pWUiT;!nHuW3gXoeuYtI7IoHZf zg#rg%R9z(aC=99c~7P+O84eC|TRzPbP8ia`Vty8aIb~kDCke zCWudlcym7($-U3D@;f0u1?4AL89VjHZgcfCzAEq|4w;UBrQnuveOy1pr$W3H;?sC% z>xsW;9%3sNS=a)R#ejRyt!6^Dj)6MjxD$g_TFare;FYKV>$wfwTy7J$d7v1m9x7B4 zi1QGKE4i)QHf}q&1AT6Tcn8EgA>IY??ty)`WPMkSWc~^)}o^y8>!9IH`LG6D|C`Wi`G%0qa1qZFozL3 z&S8cQa>!^`bdbY?_M&~zagG4Aa#>4Hrx&238Mn~Mi;0pfNwy?M(k7W9`52wESSIP0 ztdOiiXD!xB)=M@@4xzIaN72;wIGWa;lAMv8lU$Hok~~D`E40yh3Qr~qO=Bw1gkUza z5FMlV2pyzQEoJ)98H$a}W^{&PJ32+Ni@DDH$!ejK4-sq%n~M%M6r!UI3UsicmaRvJ z8z!?;*g0%JyO+H@aA4sc`+$AIzTyZ@moq>e+!vijh~{Fs1f=9C==8x9Zaz9?uxMm6 z+Ts6nY3FF?X_so3YgcMlYb&+uwBJD!pxZiPoe?_PI=VWe zbu4upbv$(fbV7CFbkcP)bh31cb?S9mbf8YB&SIVYIwy3_>0Hpcr1Oo=cRJtesBY`r z)w!>$rR%Pnq1&$evF;k(t-9NFKhxc%yGQr1?or(@b&u z16>0TgNX)#2Ehi=1_=h423ZC<1`30EgI<-vI|efiW*f{gm}{Umm~XJaV3EOMgG~lo z47M5UFxY9Z+hDK3euD!BhYSuI95r}osBdU%C^JkmtTF5`Tw%D!@P^?d!#75{Mx%}N zjig3njbuiCMgc~FMwv!cMy*C_qm@R7j1C(eHTu%%xY0?YQ^q1=iLtKnXk&e2Lt|rO zQ)88bvAeO4v7d2(aiDRwvD~=Rc)IZ_<88(}jCUIEHa=o}%=m)wCF5_5uNvPserEie z@gK%7O(>JmCKe`+CQc^fOk^gXn5;6{XL88o8X8k07_$ts zVzWB4ZnFhupPH>T+iCW>*+H`}%#N6yG&^N>#_XKg1+$0dn7N_3rMZo{o%tB^So3o8 z+2%{k_nDtJziTrtQfe*rlLklwrNPorX}ENfG*Ox*&5`Cw z3#6(dsa#qoZICueo26aS9_e)H4Cw;tBI#o3$I>;@b4vo zPo%#}pG$wYP*~JhG+MM+%(3{~;-tm*7Pl?#THLpIXz|G6vBl38Pb_}5cy7s9a+V`4 zwJmimM_cM!8d@4#np&D$T3Gs9=2-HUeU?X6mOol)Te(=pSyfs!S~Xj>ShZSpSQ=GZ8hI&fz=|b#a64W)?00~+HAGMYNyq1tG!m=S-rBBSZiDBT93BYw>GnuT3cFM zTiaU4S@YJ*tk+n7W_{fHr1eGX>(<{}|6u*Q4P(RE=-F7=*xNYRjJ1*3xZ1eec-Vy5 zB-*G_Y;tY#ZK`bQY#MBuY^K^wvuU^CZF+3x+w|KUw>fX~gU!#jq^*{%wymzM)Yi(@ z#@61}!FH_eMB7QWv9|HHiMGkM1-7NO<+hc!)wYeclWnKiPPLt8JHvK??Hb!{w)v>b5hxjvy<7m+PT~L+fB3!vJ0^bvx~4x zu*2i~E> z;T?yW4znHRILvie?6BNnv%?mLtq$8A4mligIOcHN;iSVUhaVkYIQ-=(as-Z)qr{PQ z9N}o|7~z=WsC2AzY;bIHY<6sMY;|mNR5?P&PL*S~W3S^oj>{caI)3W7&T)g|CdaLg z+Z}g0?s44jc+l~%<59;i9dC~njU6%8eQeIynz3DDmyO*%_T<<%PC8CwoW?taJ56$m za*B0Ia7uPcbINqeamsfpa*{jMIW;&nIZbhT;Pl+-cc&Llubh*d^PLNwi=9iHk2_y> zzT$k%`NlX^#yG{e(sAYEs>aloLuuH#(YTs>U9U430AxkkChxW>C~bv@wvh3iq3>sPKPT~E2*aed=o#3FRJyN0djpM~+9nN0CRVM}K$P0L}UZcJAy$rofyv)3$UY1_tyxhDzyu7^vyn?(! zy~4eUy?VUVUJJbzdwuNH=e5FXmDi_UJH7UK9rrrxb;0Yh%Im7v_g;6r?t4A-dgMLY zJKVd}yVkqS`#tY@-iy6gd++hy=Y8D!wD(!>3*J|}uX*3_zUlq5kB3i)&m^B{pE#dn zpERFLpKPCMpQ%3Y_{{Q|<1^1^zRyCR#Xg_-toPaIv&Cnd&u2cneD?b6_c`G6mCs3^ z(>`Z?uKL{ex$AS^=La9vW1pXWp7{LgEAl0LXQv#wXf2* z(YM*R#kbXWhVN|O4}F*UF85vKyT*63?>67he0TZo@jdK&*7uI@J>Q>wU-@bI>G+xZ zS^L@gjq!8#bMbTabN37Ji}8#1OY%$c%k``9tM;q)Yw(-w*Wx$LuU+NW>-V1DBELSr zwSL?D4*DJTJLY%X?`yx)es}$z`aSpi!|zYOzx+l1qx~)Y$N7)E3Ny4}=tuWm%y)eTtlQ8ozw=j<|uP~o5 zzp#m6!C_%x5n)kbF=26G)nO`i*k@q}!oCPQ8un$_*|4i&--g``yB&5f>_ONMVLyhw z2>UA>3m1n=!nts*aGh}X@Wk-Ea7B1ocx8A^cvJY4@Ye9Qa8>y9@cH4Z!`Fmw4L=Zm zKKx?%t?>KdKZO4n{v`Za_;2BVL{Je-#Ha|p2&)MD2!{x#i186_5grj<5fQ40l!)?( zst9F7eMD2ll!(@d_6Qi!710~9V4A1Ws$Ct?vWmmA(4|Jqa))YlOxk2 zGb6Jj+ani8u8!Ooxg~OY&Iumsz>R!~NsK-&iL_LrCJ?drDt7tabFxoj<7VQ@8 z5$zrA7dBYIZ!d{y)( z(aWNjN3V)r6TL2aL-eNTebI-ak3@eN{dM%2==0GRqyLD}i?NDvh;fQ>iE)kbj`53` z7!wo|5)%`X8B-Ng6Vn{i6|*2_QOwGiwJ{rFHpOg@*%`AtW^c@im{T!lV!n;J6>}%% ze$0*Xl-C|W9vEH$Mu@hs1W7A`^Vsm2iVhdu6V->Nb zvE{K#h!_M8mAxU7grFciu)vPPu$_SV{ymhPR3n~yAgLY?pEBLxO;IA z;-1I7jC&m~iYMafcs71SykmT9d{%sZd||vizC6A%z9zmlUKQUFKPUc!_(ky_#V?6p zsfzzJeqH>A_)YOw64(T%gb4}338@L`2?Yt&34B6F!kmQn6Fx{-l<-Ny(uDqm6$#rC z4klbmxRP)^;k$%e33n47Bs@y^Dd9=NvqVWEmpC#}J5e`LFVQg3IMFmwnrNA5o#>S4 zlIWV~p6H(#o*0=JlNg_vl$es3mROuvmROmns!41}oSfK_*qXR7aZloxiC-t4PCS=* zIq^#3^~4*Aj}u=eVM*d7Dv3$bNgADGkYto(l4O%KJ}ERQJSjdYCrO!9m(-rrnbe&$ zJ!y8*oTPb4?U{h9PS=}j`0EKVkq z>10NgJR(^uStog1a%^&4vO4**kIyE*`mAX3htJHI;-=#iHeU|zlGd8mo(9vp(t6Woq|HuyFKtEI>a;a!>(Vx)ZBE;k_F3Alw7qHj(+;G4m3A`i zblTapt7*5>?xj6U`!Vh3v?po5ri;>vbUK|)*G?amuAgq0?wg*Gu1K#+SEkpeH>FQY zSEYBT(!0`o(&waql)f>2bNcS|W9e7Zuctpu|0(@R`mgDKr2m=zI{i(?$PAqf;|$Xb zhm3Iv z8NX$`$c)a+$jr*j$;{8(o_Q$qtIU&`r!&uGUdgyxZyS=<<<=Eud=Q!p#=g4y0ay)Xpb9{3YIq&Bj z&Uut;oExIbg}LwM&dGf*cV6y_+%36VbGPO0$o(SsaPE=ZW4UK?&*xss{U-Ne?vJ@Y z=RVDSo)?jqke8cRl&8om%d5_-&1=YO%A1}yH}8|YC3#Ep`tmmBZOi*CZ+G73c?a_j z=N-+vn@{Dl`G)z@e5-uh{4x11`L6jB^1bqX^2_rV<{!#eoyb3xe>VSo{+;~$`9I`8 z&i|z#ydb_Hr=YMvUQk+4RiG@WFK8^7QLvz3eZlU6Jq3FU_7|KexKwbZ;CjJ#1-AK5u1zE}8h;gZ5-h0BYiMNUOQMWICzMNvhuMF~a8 zMQKHuML9+JMU6$xs-l*n)}po|zNoXPyQsHlM$xRIcZ(JjeOUBy(UPLoMVpJZ740b6 zRkWvQU(x49CyUM&T`0O-biL@iqFY6Gim_sw;_<~E#ooof#eu~k#bLz}#c9PE#fsvp zVr6lCaZ~ZMVpVZRaaVCq@toq1iZ>Q-F5X>ytoUm2_2P%cKNbH{topV1kK&ibuZ!Qv zN6Jm)GP#@FL+&m2lTVZf%fsZ8OKvb;s!DsPkb$lsCA zlD{ioAYUr)m#>t6DqkyKFW)WyTz*J?M1EZUwfv0yocw2nu0pD?QP?RQ6yp>!g_~l6 zB2*EfNKs@f@>GgKgEwwC_mHL;4mPV9DmByB)l%|(vmFAS@m6nz^miCsuQ#!A7N$Hl-ZKVfG zkCuK}dZP49>ABL2rI${ml>29mzkAWlv$VAmHCzhlm(UrmxY!^ zltq=rl*N@Ls>+hfQqgllg=O-xk}_r4l(MO1)5_Y*__B_&Ic4vcEhzi2Y)M&P*^07N zWrxe|mpv_ad2V@ad3||n z`SkL|%{-pd_`S0bbm*uZ3L=|`iSs|(5DzqwW zD(ov9D#lhgSI8>dDkfBTR`^u-RRmOIS4^$=q~buuol2&1TxD!!RpmRC^D5_8F0A~p zvcGa&<%Y^lm0K#eRqm+#qVmhi6P2eb&sScmyi$3s@{cN$sxej0RkAAAD$gpPD!;0L zs@STms=_LHRjI0~qN=W{v8uVMrK+`RdDW$=->XH{BdRT`t*XaXdsRnQ$5v-m=T{e2 z%d5+)E30d&YpYe&v#RG*&#Rtay|DVj>Q&Wis@GR$OK2rXqe4>1&{9XA{`MOqAi`SC1l3K1-t5&yG zuhy{EL{)2EYgubkYhUYF>s%|Vb*uHL^{(}+omd-G8&{i9n^c=xyQ+3u?T*@=wR`GD z)EU>A)|uB?)-}}ebscqGb-i_0>h9G&sQaPras9;lnEJT-g!<(A74=){x7F{c-_^i2 z7&aI;m^Mfo>KfV`_=b*#?uN?^cN*?BJZN~-=-sFaZ=BQ^)fn5jq;Xy2hQ>{eTbr<^ zkxkl7x=ngbrA>`ZlbfbAwKjd-bhYVv(~YL@C%a7!oE$tkbaKSxMUz)fUOjouFN9sj{iAQ{AU}PVJsLZ|eI~KbX2` z>iwzDrv5hdkEwsQ#FL4X-3oRrj42AIL&#QYTB%6?@oJf zntIyZX-}s;oA%qZ7j4mP8Esi@Ic@oEo7?ucecpDU?NHn4wli&K+f?VVU$h@?KiYo2{X+Z2_RH;e z+V8gCYk$!Gy#2TK-`iiPNR^60kEAjx1C^o5NM)iLqZ+Fkry8$vQ%z7!Ql+XgRN1OL zRe`EVrBv0b>QoIXUIkShsxH-B)jXA2HDA@I>Q^mStyFDQ?NIGf?N#kp9Z;QD-BjIH z-BUeOJyJbZy;QwHyKn^0pp8Q=M(+g_!`we~Qw+UABYte56Y(l^3Z~`3+zl;uhUqeT(AMua* zU-)17=R7(@g7zQ?s0)ptDU?Dh=m4Fd3v`9*JKz`aBs>kz!HX*R4g4P7f%oAL z@G<4%GJOi>$=!=tLu5!A6`?^2tKG=Pw`)T)! z?l(O)J&rvSdIEZ)ds2GRd$M|RdkT8Wd#ZYrJ@q|JJVrF(UH z4SP*`&3i3-t$Ss?p1nT3{=I>{5xtSUF}?A;MSNC4%z0`ZV_lMrcy}$JS+WWHi z^>op6d^$0G&a}B**3GCXM4={o}Dw>S~0ssv+D$a=9@dJ`5ye7UH1O~Nl)?r delta 18997 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 Void)? var onNextTab: (() -> Void)? var onPreviousTab: (() -> Void)? + var onNextWorkspace: (() -> Void)? + var onPreviousWorkspace: (() -> Void)? var onDetachTab: (() -> Void)? var onApplySizePreset: ((TerminalSizePreset) -> Void)? var onSwitchToTab: ((Int) -> Void)? + var onSwitchToWorkspace: ((WorkspaceID) -> Void)? - /// Tab-level hotkeys only fire when the notch is open. + /// Notch-scoped hotkeys only fire when the notch is open. var isNotchOpen: Bool = false private var hotKeyRef: EventHotKeyRef? private var eventHandlerRef: EventHandlerRef? private var localMonitor: Any? private let settingsProvider: TerminalSessionConfigurationProviding + private let workspaceRegistry: WorkspaceRegistry private var settingsCancellable: AnyCancellable? - init(settingsProvider: TerminalSessionConfigurationProviding? = nil) { + init( + settingsProvider: TerminalSessionConfigurationProviding? = nil, + workspaceRegistry: WorkspaceRegistry? = nil + ) { self.settingsProvider = settingsProvider ?? AppSettingsController.shared + self.workspaceRegistry = workspaceRegistry ?? WorkspaceRegistry.shared } // MARK: - Resolved bindings from typed runtime settings @@ -53,6 +61,12 @@ class HotkeyManager { private var prevTabBinding: HotkeyBinding { settingsProvider.hotkeySettings.previousTab } + private var nextWorkspaceBinding: HotkeyBinding { + settingsProvider.hotkeySettings.nextWorkspace + } + private var previousWorkspaceBinding: HotkeyBinding { + settingsProvider.hotkeySettings.previousWorkspace + } private var detachBinding: HotkeyBinding { settingsProvider.hotkeySettings.detachTab } @@ -173,7 +187,7 @@ class HotkeyManager { } } - // MARK: - Local monitor (tab-level hotkeys, only when our app is active) + // MARK: - Local monitor (notch-level hotkeys, only when our app is active) private func installLocalMonitor() { localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in @@ -189,9 +203,9 @@ class HotkeyManager { } } - /// Handles tab-level hotkeys. Returns true if the event was consumed. + /// Handles notch-scoped hotkeys. Returns true if the event was consumed. private func handleLocalKeyEvent(_ event: NSEvent) -> Bool { - // Tab hotkeys only when the notch is open and focused + // Local shortcuts only fire when the notch is open and focused. guard isNotchOpen else { return false } if newTabBinding.matches(event) { @@ -210,10 +224,25 @@ class HotkeyManager { onPreviousTab?() return true } + if nextWorkspaceBinding.matches(event) { + onNextWorkspace?() + return true + } + if previousWorkspaceBinding.matches(event) { + onPreviousWorkspace?() + return true + } if detachBinding.matches(event) { onDetachTab?() return true } + for summary in workspaceRegistry.workspaceSummaries { + guard let binding = summary.hotkey else { continue } + if binding.matches(event) { + onSwitchToWorkspace?(summary.id) + return true + } + } for preset in sizePresets { guard let binding = preset.hotkey else { continue } if binding.matches(event) { diff --git a/Downterm/CommandNotch/Managers/ScreenManager.swift b/Downterm/CommandNotch/Managers/ScreenManager.swift index 2c171f4..fe30ac7 100644 --- a/Downterm/CommandNotch/Managers/ScreenManager.swift +++ b/Downterm/CommandNotch/Managers/ScreenManager.swift @@ -9,6 +9,7 @@ final class ScreenManager: ObservableObject { static let shared = ScreenManager() private let screenRegistry = ScreenRegistry.shared + private let workspaceRegistry = WorkspaceRegistry.shared private let windowCoordinator = WindowCoordinator() private lazy var orchestrator = NotchOrchestrator(screenRegistry: screenRegistry, host: self) @@ -55,6 +56,12 @@ final class ScreenManager: ObservableObject { hotkeyManager.onPreviousTab = { [weak self] in MainActor.assumeIsolated { self?.activeWorkspace().previousTab() } } + hotkeyManager.onNextWorkspace = { [weak self] in + MainActor.assumeIsolated { self?.switchWorkspace(offset: 1) } + } + hotkeyManager.onPreviousWorkspace = { [weak self] in + MainActor.assumeIsolated { self?.switchWorkspace(offset: -1) } + } hotkeyManager.onDetachTab = { [weak self] in MainActor.assumeIsolated { self?.detachActiveTab() } } @@ -64,6 +71,9 @@ final class ScreenManager: ObservableObject { hotkeyManager.onSwitchToTab = { [weak self] index in MainActor.assumeIsolated { self?.activeWorkspace().switchToTab(at: index) } } + hotkeyManager.onSwitchToWorkspace = { [weak self] workspaceID in + MainActor.assumeIsolated { self?.switchActiveScreen(to: workspaceID) } + } hotkeyManager.start() } @@ -92,6 +102,33 @@ final class ScreenManager: ObservableObject { } } + private func switchWorkspace(offset: Int) { + guard let screenID = screenRegistry.activeScreenID() else { return } + let currentWorkspaceID = screenRegistry.screenContext(for: screenID)?.workspaceID ?? workspaceRegistry.defaultWorkspaceID + let nextWorkspaceID = offset >= 0 + ? workspaceRegistry.nextWorkspaceID(after: currentWorkspaceID) + : workspaceRegistry.previousWorkspaceID(before: currentWorkspaceID) + + guard let nextWorkspaceID else { return } + switchScreen(screenID, to: nextWorkspaceID) + } + + private func switchActiveScreen(to workspaceID: WorkspaceID) { + guard let screenID = screenRegistry.activeScreenID() else { return } + switchScreen(screenID, to: workspaceID) + } + + private func switchScreen(_ screenID: ScreenID, to workspaceID: WorkspaceID) { + screenRegistry.assignWorkspace(workspaceID, to: screenID) + + guard let context = screenRegistry.screenContext(for: screenID), + context.notchState == .open else { + return + } + + orchestrator.open(screenID: screenID) + } + func applySizePreset(_ preset: TerminalSizePreset) { guard let context = screenRegistry.allScreens().first(where: { $0.notchState == .open }) else { AppSettingsController.shared.update { diff --git a/Downterm/CommandNotch/Models/AppSettings.swift b/Downterm/CommandNotch/Models/AppSettings.swift index f13b730..89ac030 100644 --- a/Downterm/CommandNotch/Models/AppSettings.swift +++ b/Downterm/CommandNotch/Models/AppSettings.swift @@ -56,6 +56,8 @@ struct AppSettings: Equatable, Codable { closeTab: .cmdW, nextTab: .cmdShiftRB, previousTab: .cmdShiftLB, + nextWorkspace: .cmdShiftDown, + previousWorkspace: .cmdShiftUp, detachTab: .cmdD ) ) @@ -121,6 +123,8 @@ extension AppSettings { var closeTab: HotkeyBinding var nextTab: HotkeyBinding var previousTab: HotkeyBinding + var nextWorkspace: HotkeyBinding + var previousWorkspace: HotkeyBinding var detachTab: HotkeyBinding } } diff --git a/Downterm/CommandNotch/Models/AppSettingsStore.swift b/Downterm/CommandNotch/Models/AppSettingsStore.swift index 855d6c9..74960bb 100644 --- a/Downterm/CommandNotch/Models/AppSettingsStore.swift +++ b/Downterm/CommandNotch/Models/AppSettingsStore.swift @@ -60,6 +60,8 @@ struct UserDefaultsAppSettingsStore: AppSettingsStoreType { closeTab: hotkey(NotchSettings.Keys.hotkeyCloseTab, default: .cmdW), nextTab: hotkey(NotchSettings.Keys.hotkeyNextTab, default: .cmdShiftRB), previousTab: hotkey(NotchSettings.Keys.hotkeyPreviousTab, default: .cmdShiftLB), + nextWorkspace: hotkey(NotchSettings.Keys.hotkeyNextWorkspace, default: .cmdShiftDown), + previousWorkspace: hotkey(NotchSettings.Keys.hotkeyPreviousWorkspace, default: .cmdShiftUp), detachTab: hotkey(NotchSettings.Keys.hotkeyDetachTab, default: .cmdD) ) ) @@ -106,6 +108,8 @@ struct UserDefaultsAppSettingsStore: AppSettingsStoreType { defaults.set(settings.hotkeys.closeTab.toJSON(), forKey: NotchSettings.Keys.hotkeyCloseTab) defaults.set(settings.hotkeys.nextTab.toJSON(), forKey: NotchSettings.Keys.hotkeyNextTab) defaults.set(settings.hotkeys.previousTab.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousTab) + defaults.set(settings.hotkeys.nextWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyNextWorkspace) + defaults.set(settings.hotkeys.previousWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousWorkspace) defaults.set(settings.hotkeys.detachTab.toJSON(), forKey: NotchSettings.Keys.hotkeyDetachTab) } diff --git a/Downterm/CommandNotch/Models/HotkeyBinding.swift b/Downterm/CommandNotch/Models/HotkeyBinding.swift index 0e767fd..0d5fd2d 100644 --- a/Downterm/CommandNotch/Models/HotkeyBinding.swift +++ b/Downterm/CommandNotch/Models/HotkeyBinding.swift @@ -88,6 +88,8 @@ struct HotkeyBinding: Codable, Equatable, Hashable { static let cmdW = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 13) static let cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ] static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [ + static let cmdShiftDown = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 125) + static let cmdShiftUp = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 126) static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2) static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? { diff --git a/Downterm/CommandNotch/Models/NotchSettings.swift b/Downterm/CommandNotch/Models/NotchSettings.swift index e692097..b56dabd 100644 --- a/Downterm/CommandNotch/Models/NotchSettings.swift +++ b/Downterm/CommandNotch/Models/NotchSettings.swift @@ -57,6 +57,8 @@ enum NotchSettings { static let hotkeyCloseTab = "hotkey_closeTab" static let hotkeyNextTab = "hotkey_nextTab" static let hotkeyPreviousTab = "hotkey_previousTab" + static let hotkeyNextWorkspace = "hotkey_nextWorkspace" + static let hotkeyPreviousWorkspace = "hotkey_previousWorkspace" static let hotkeyDetachTab = "hotkey_detachTab" } @@ -104,6 +106,8 @@ enum NotchSettings { static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON() static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON() static let hotkeyPreviousTab: String = HotkeyBinding.cmdShiftLB.toJSON() + static let hotkeyNextWorkspace: String = HotkeyBinding.cmdShiftDown.toJSON() + static let hotkeyPreviousWorkspace: String = HotkeyBinding.cmdShiftUp.toJSON() static let hotkeyDetachTab: String = HotkeyBinding.cmdD.toJSON() } @@ -151,6 +155,8 @@ enum NotchSettings { Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab, Keys.hotkeyNextTab: Defaults.hotkeyNextTab, Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab, + Keys.hotkeyNextWorkspace: Defaults.hotkeyNextWorkspace, + Keys.hotkeyPreviousWorkspace: Defaults.hotkeyPreviousWorkspace, Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab, ]) } diff --git a/Downterm/CommandNotch/Models/WorkspaceController.swift b/Downterm/CommandNotch/Models/WorkspaceController.swift index db7500f..73eb80e 100644 --- a/Downterm/CommandNotch/Models/WorkspaceController.swift +++ b/Downterm/CommandNotch/Models/WorkspaceController.swift @@ -18,6 +18,7 @@ final class WorkspaceController: ObservableObject { let createdAt: Date @Published private(set) var name: String + @Published private(set) var hotkey: HotkeyBinding? @Published private(set) var tabs: [TerminalSession] = [] @Published private(set) var activeTabIndex: Int = 0 @@ -34,6 +35,7 @@ final class WorkspaceController: ObservableObject { self.id = summary.id self.name = summary.name self.createdAt = summary.createdAt + self.hotkey = summary.hotkey self.sessionFactory = sessionFactory self.settingsProvider = settingsProvider @@ -51,7 +53,7 @@ final class WorkspaceController: ObservableObject { } var summary: WorkspaceSummary { - WorkspaceSummary(id: id, name: name, createdAt: createdAt) + WorkspaceSummary(id: id, name: name, createdAt: createdAt, hotkey: hotkey) } var state: WorkspaceState { @@ -78,6 +80,11 @@ final class WorkspaceController: ObservableObject { name = trimmed } + func updateHotkey(_ updatedHotkey: HotkeyBinding?) { + guard hotkey != updatedHotkey else { return } + hotkey = updatedHotkey + } + func newTab() { let config = settingsProvider.terminalSessionConfiguration let session = sessionFactory.makeSession( diff --git a/Downterm/CommandNotch/Models/WorkspaceRegistry.swift b/Downterm/CommandNotch/Models/WorkspaceRegistry.swift index 7dbcec4..caf893c 100644 --- a/Downterm/CommandNotch/Models/WorkspaceRegistry.swift +++ b/Downterm/CommandNotch/Models/WorkspaceRegistry.swift @@ -104,6 +104,37 @@ final class WorkspaceRegistry: ObservableObject { persistWorkspaceSummaries() } + func updateWorkspaceHotkey(id: WorkspaceID, to hotkey: HotkeyBinding?) { + guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { return } + guard workspaceSummaries[index].hotkey != hotkey else { return } + + workspaceSummaries[index].hotkey = hotkey + controllers[id]?.updateHotkey(hotkey) + persistWorkspaceSummaries() + } + + func nextWorkspaceID(after id: WorkspaceID) -> WorkspaceID? { + guard !workspaceSummaries.isEmpty else { return nil } + guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { + return workspaceSummaries.first?.id + } + + let nextIndex = workspaceSummaries.index(after: index) + return workspaceSummaries[nextIndex == workspaceSummaries.endIndex ? workspaceSummaries.startIndex : nextIndex].id + } + + func previousWorkspaceID(before id: WorkspaceID) -> WorkspaceID? { + guard !workspaceSummaries.isEmpty else { return nil } + guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { + return workspaceSummaries.last?.id + } + + let previousIndex = index == workspaceSummaries.startIndex + ? workspaceSummaries.index(before: workspaceSummaries.endIndex) + : workspaceSummaries.index(before: index) + return workspaceSummaries[previousIndex].id + } + @discardableResult func deleteWorkspace(id: WorkspaceID) -> Bool { guard canDeleteWorkspace(id: id) else { return false } diff --git a/Downterm/CommandNotch/Models/WorkspaceSummary.swift b/Downterm/CommandNotch/Models/WorkspaceSummary.swift index 1ad8593..2d8c442 100644 --- a/Downterm/CommandNotch/Models/WorkspaceSummary.swift +++ b/Downterm/CommandNotch/Models/WorkspaceSummary.swift @@ -6,11 +6,13 @@ struct WorkspaceSummary: Identifiable, Equatable, Codable { var id: WorkspaceID var name: String var createdAt: Date + var hotkey: HotkeyBinding? - init(id: WorkspaceID = UUID(), name: String, createdAt: Date = Date()) { + init(id: WorkspaceID = UUID(), name: String, createdAt: Date = Date(), hotkey: HotkeyBinding? = nil) { self.id = id self.name = name self.createdAt = createdAt + self.hotkey = hotkey } } diff --git a/Downterm/CommandNotch/Views/HotkeySettingsView.swift b/Downterm/CommandNotch/Views/HotkeySettingsView.swift index 71a92f9..259e430 100644 --- a/Downterm/CommandNotch/Views/HotkeySettingsView.swift +++ b/Downterm/CommandNotch/Views/HotkeySettingsView.swift @@ -17,8 +17,13 @@ struct HotkeySettingsView: View { HotkeyRecorderView(label: "Detach tab", binding: settingsController.binding(\.hotkeys.detachTab)) } + Section("Workspaces (active when notch is open)") { + HotkeyRecorderView(label: "Next workspace", binding: settingsController.binding(\.hotkeys.nextWorkspace)) + HotkeyRecorderView(label: "Previous workspace", binding: settingsController.binding(\.hotkeys.previousWorkspace)) + } + Section { - Text("⌘1–9 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.") + Text("⌘1–9 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets. Per-workspace jump hotkeys are configured in Workspaces.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/Downterm/CommandNotch/Views/TerminalSettingsView.swift b/Downterm/CommandNotch/Views/TerminalSettingsView.swift index 1f60fd6..5662940 100644 --- a/Downterm/CommandNotch/Views/TerminalSettingsView.swift +++ b/Downterm/CommandNotch/Views/TerminalSettingsView.swift @@ -2,21 +2,7 @@ import SwiftUI struct TerminalSettingsView: View { @ObservedObject private var settingsController = AppSettingsController.shared - - private var sizePresetsBinding: Binding<[TerminalSizePreset]> { - Binding( - get: { - TerminalSizePresetStore.decodePresets( - from: settingsController.settings.terminal.sizePresetsJSON - ) ?? TerminalSizePresetStore.loadDefaults() - }, - set: { newValue in - settingsController.update { - $0.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets(newValue) - } - } - ) - } + @State private var sizePresets: [TerminalSizePreset] = [] var body: some View { Form { @@ -55,7 +41,7 @@ struct TerminalSettingsView: View { } Section("Size Presets") { - ForEach(sizePresetsBinding) { $preset in + ForEach($sizePresets) { $preset in TerminalSizePresetEditor( preset: $preset, currentOpenWidth: settingsController.settings.display.openWidth, @@ -67,20 +53,18 @@ struct TerminalSettingsView: View { HStack { Button("Add Preset") { - var presets = sizePresetsBinding.wrappedValue - presets.append( + sizePresets.append( TerminalSizePreset( - name: "Preset \(presets.count + 1)", + name: "Preset \(sizePresets.count + 1)", width: settingsController.settings.display.openWidth, height: settingsController.settings.display.openHeight, - hotkey: TerminalSizePresetStore.suggestedHotkey(for: presets) + hotkey: TerminalSizePresetStore.suggestedHotkey(for: sizePresets) ) ) - sizePresetsBinding.wrappedValue = presets } Button("Reset Presets") { - sizePresetsBinding.wrappedValue = TerminalSizePresetStore.loadDefaults() + sizePresets = TerminalSizePresetStore.loadDefaults() } } @@ -90,10 +74,24 @@ struct TerminalSettingsView: View { } } .formStyle(.grouped) + .onAppear { + synchronizePresetsFromSettings() + } + .onChange(of: settingsController.settings.terminal.sizePresetsJSON) { _, _ in + synchronizePresetsFromSettings() + } + .onChange(of: sizePresets) { _, newValue in + let encoded = TerminalSizePresetStore.encodePresets(newValue) + guard encoded != settingsController.settings.terminal.sizePresetsJSON else { return } + + settingsController.update { + $0.terminal.sizePresetsJSON = encoded + } + } } private func deletePreset(id: UUID) { - sizePresetsBinding.wrappedValue.removeAll { $0.id == id } + sizePresets.removeAll { $0.id == id } } private func applyPreset(_ preset: TerminalSizePreset) { @@ -103,6 +101,15 @@ struct TerminalSettingsView: View { } ScreenManager.shared.applySizePreset(preset) } + + private func synchronizePresetsFromSettings() { + let decoded = TerminalSizePresetStore.decodePresets( + from: settingsController.settings.terminal.sizePresetsJSON + ) ?? TerminalSizePresetStore.loadDefaults() + + guard decoded != sizePresets else { return } + sizePresets = decoded + } } private struct TerminalSizePresetEditor: View { diff --git a/Downterm/CommandNotch/Views/WorkspacesSettingsView.swift b/Downterm/CommandNotch/Views/WorkspacesSettingsView.swift index 39795ab..6fc8961 100644 --- a/Downterm/CommandNotch/Views/WorkspacesSettingsView.swift +++ b/Downterm/CommandNotch/Views/WorkspacesSettingsView.swift @@ -75,6 +75,11 @@ struct WorkspacesSettingsView: View { renameSelectedWorkspace() } + OptionalHotkeyRecorderView( + label: "Jump Hotkey", + binding: workspaceHotkeyBinding(for: summary.id) + ) + HStack { Button("Save Name") { renameSelectedWorkspace() @@ -86,6 +91,10 @@ struct WorkspacesSettingsView: View { } .accessibilityIdentifier("settings.workspaces.new") } + + Text("Workspace jump hotkeys are active when the notch is open and switch the current screen to this workspace.") + .font(.caption) + .foregroundStyle(.secondary) } Section("Usage") { @@ -256,6 +265,17 @@ struct WorkspacesSettingsView: View { renameDraft = workspaceRegistry.summary(for: workspaceID)?.name ?? "" } + private func workspaceHotkeyBinding(for workspaceID: WorkspaceID) -> Binding { + Binding( + get: { + workspaceRegistry.summary(for: workspaceID)?.hotkey + }, + set: { newValue in + workspaceRegistry.updateWorkspaceHotkey(id: workspaceID, to: newValue) + } + ) + } + private func deleteSelectedWorkspace() { guard let effectiveSelectedWorkspaceID, let fallbackWorkspaceID = screenRegistry.deleteWorkspace( diff --git a/Downterm/CommandNotchTests/WorkspaceRegistryTests.swift b/Downterm/CommandNotchTests/WorkspaceRegistryTests.swift index 49de141..193d1c1 100644 --- a/Downterm/CommandNotchTests/WorkspaceRegistryTests.swift +++ b/Downterm/CommandNotchTests/WorkspaceRegistryTests.swift @@ -74,6 +74,30 @@ final class WorkspaceRegistryTests: XCTestCase { XCTAssertEqual(store.savedSummaries.map(\.name), ["Main"]) } + func testUpdateWorkspaceHotkeyPersistsAndUpdatesSummary() { + let store = InMemoryWorkspaceStore() + let registry = makeRegistry(store: store) + let docsID = registry.createWorkspace(named: "Docs") + let hotkey = HotkeyBinding.cmdShiftDigit(4) + + registry.updateWorkspaceHotkey(id: docsID, to: hotkey) + + XCTAssertEqual(registry.summary(for: docsID)?.hotkey, hotkey) + XCTAssertEqual(store.savedSummaries.last?.hotkey, hotkey) + } + + func testNextAndPreviousWorkspaceWrapAroundRegistryOrder() { + let registry = makeRegistry() + let mainID = registry.defaultWorkspaceID + let docsID = registry.createWorkspace(named: "Docs") + let reviewID = registry.createWorkspace(named: "Review") + + XCTAssertEqual(registry.nextWorkspaceID(after: mainID), docsID) + XCTAssertEqual(registry.nextWorkspaceID(after: reviewID), mainID) + XCTAssertEqual(registry.previousWorkspaceID(before: mainID), reviewID) + XCTAssertEqual(registry.previousWorkspaceID(before: docsID), mainID) + } + private func makeRegistry( initialWorkspaces: [WorkspaceSummary]? = [], store: (any WorkspaceStoreType)? = nil diff --git a/Downterm/CommandNotchTests/WorkspaceStoreTests.swift b/Downterm/CommandNotchTests/WorkspaceStoreTests.swift index 578388a..f88bc45 100644 --- a/Downterm/CommandNotchTests/WorkspaceStoreTests.swift +++ b/Downterm/CommandNotchTests/WorkspaceStoreTests.swift @@ -9,7 +9,11 @@ final class WorkspaceStoreTests: XCTestCase { let store = UserDefaultsWorkspaceStore(defaults: defaults) let summaries = [ - WorkspaceSummary(id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!, name: "Main"), + WorkspaceSummary( + id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!, + name: "Main", + hotkey: HotkeyBinding.cmdShiftDigit(4) + ), WorkspaceSummary(id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, name: "Docs") ]