*;5P
zG3y+VJPLI)y}Sd)^(CM*e$9tR#BsAUE&ljZbh5Jh^!hG}dGZ%ZK=upc@CD-2(5(F!
z(IO9vkWi42vhK%CyDB%6ELwPf1T0d15OlJ<`%ZNjTAqKkgfm`fIYkDfJT6Aunun{R
zC~4N3xQ9V6o1C(T<6neA(x`J$G$<_FYx9=OG9GM|r`t^XA0&H7NPAB3kYjz@
zl{;0V!OQREIwnTP3VU;ET=x7%>7F+(RPK9@&8($+`g-)?ObNp&x_s7I%JOHfrHZPO
zTJ2b0z53!>8^Pg@jk$;lC-vIWo^S5>9ONHQT8=ZAJ>&CkHA!D{4Af>MZ%~lJbJA@y
zM--N<{S2jVI=Q3QLGw=c!klYCYiYjQ!WRFGLr8b=U>z=WVK=70e+Xgn#OhSlwIfUb
z%mZJ6dBj-ekoWLm=+xNo({6DibPi~i8d5Q#(*6=~;>Q#eBD5e;n)wh??6iu;9WKY<
z?73v6T>gGIuEe92@J8Zc#?_jh%>W|YRAx&`WnIhD+xzlTYYOV@B#4Es^+4*)(u=QozK6R;M-XBLMqn;J)8GM9=
z!hz0iK?o$)!+_0HRV%Y=`bnuANx%dK65gc=t>gs*2(!2N{Ai^>SS9gxwxed}A895G
z9_ljE!aMq8P$R0ceL04~mWdL75#m4Jt>W)jq}8QfSC7GZ5;cT(;DlWB@;^HmvuzbC
zGCmkeMGXluO;`J(nZ5%0^Vb@33seyS`g9IZ$EkbEl`VTABEqj6J@8+wXuRY2sD2M
z1o+-w#6&QyEiRh{8pz`f;x$+v=0h)LeEfo1>h`Jd3C>vT{&VL`d?|_lPW?-AA$2}{
z$LZlg4Tq=a{A+t&CkPGHU6M9!COilN4hP7dq^W&S)VUZmFZ=ol@RCEXFM#SMR1?
zzMP2p=Hfn=R6b9Re}KMrU_ydgx&U?{+)!)5G_1E+(HDcOHxs;Qg<3dGv8Y_BKMC8Z
zmaVCx)+|zw(E4;HdzYmmP}X*mINa%HXyOYVYA+dov5Zc0bs$fwgSgJ-41Jy3(0SY_
z`A4U}6Ofv#8C+>?Jk68?5POA;y86Pf#saS`N1bL5i^-vrRC;-uY=fYlkgAhnfiBfI
zP)34!!$uB8!QntNjw3wngR23QtOO8%L5qQf^W%#3n8>yv
z{oK3xs-f4s(k-lv7k?MZ8zC+z7(L@P^+Q%p7IK7`S0pIi5vK3b>*ebM$pc@Dwg93;
zqy_H8Fy^@mtlDEy|I_33Uh1kNx)PlA}P
z>=akWITc+NyfA1-X+FJEJ1$Xhsx6!0Y57yWDB`Ak=C-(Ak*3;CcCN(5qF()Mf^#l~Toci43I_Wk0
zfJnSN!|iL;%dar*3uJ6&{@$ppmcyXIlTmh_663~+EXsTnul+;WRUOeH8cMmIwf+u+S;1##NA;itT@SDDykS=ORfl4aU@eK
z%&n%=?PwkmEaD=YqhE0lcSHc78_5WhOB0Kb{*c=*T3NFoDuF6RZ3hjT$P?E^g=KM)
zJ+H<5$r6fXF5O6FBQ2rLDnD<_@|8{Wh(i?Kc=6^d*LoL9J|Xid!qJ4UYLr07|vgf@rfslg7B^Qhp(CA!_8|B$krz>KaLsw`#ww!PGA
zk2{Pv+~o9htzF^$Q9r;j97_$l5B{u^{#_9#q7eTxOHKUgdFnFwBe4r+J*HVuwCl~f
z@7kBv4(PQ0W?y9!g=24vKa4@W13K31YfKLINhDWc%ORJ&Hg0NWC%!1+vCc7_n07&+$&l>V#VOGoIpjeUM)`#ZhY9Ns|7>XQRzwv)^D`Qt8147*9sl
z$6ersdc>~lwEe;m@N9?k%aO@MI6Uq-RX=0P$=XpHQvK4h^y2*TN6mWX`2D1t~eN%I{qEO9|5OJm*A$#-OCYEy=4sq4CjjMYf2
zQU@YP;ID59jd}35n&!*nC)j%e;{VLHQQBkm>X=wgV{wfl+PxfDi=oL;xdg9vVb3xY
zanu;8L`o`t
z%;1{|^#E=^f_2UhSvC0ImP4;5gm9=)ny#rf9QZnwiv-{k4GW^zj2J@F_hu>E!>2W+3zcWV*mib;!qzPKp@#=%5I0HLx^rs(5W!o_+sc<5k7>~
z2`H>h^A(9P<^;APkrQ5WXZ}~Juu0J^?M#&IEQ`C9Z~sF0m2dYxU2>u5!r>{ZEJSMg
zjnEeKt+A2zs|jgvizBGL4nsioAXOA<*T@$DMc`{O^q9hcd2`Wl&28*~`N+J5MfRyb
zVjODXY_T~2XGl|ddI>eQI#oCSZqdKCW_N?7VE`FnAXOiD{f@lyOA35KpF#d9jLR%!
z>$v>j8{Q5?w=Cyz%qM)?ehI7F{@f&!8Bgm{3zMQ}5D{L|9T$i0l8bRrNh#07f`Kd0U
zizERYOVXY0c%;|D;S{QCixxkeF3YV2>C}K
z%23}gslEy9k)Wwl@s2=C`c+}q(*>8PR00-D$Hu+7TZ0W%Cx-mf$;G1u+(iFmOc|Ot
zLg&GETVRbz6lpisy?-r-#*^exIPm*g-iD#q_3OR2hG#qGZi@fGnpwC-6Vk|4HwWBP
zZgp8G)h=9R;=u~%S0A6Ans_;4t#lX|CPZ7?k=j)sR{C2?H|AlUba@W?0RU+Y*ph-y
z0w3T7%HQS^&R;Q5a$%oCx;c~R=%)mfq=kn*4on>_3kg>Bs_EDBI?@-M1dsT`~q^#k8owKRh%
z!Lrq#u5-WH_>63hf=G;8hXjM2FrF`)LzH8#OvX#q{U@`$VTb_4B8K2RseXE3k5Qm4
z6>gDuq}C{jb}y+_Nne
z&JVn#elBgs1VC_k^>SDQ5a}ur^uF%Ee?kfyR;b}=1onk}-;5~>bEzNL1W_%;`Ekv2
zPIoPQy;B>DO1{0p+K<%@w3((FP1XWJjZk2<+aNCH~w%DZ<*
zpT+!EynhruJaAT(Sbj*1@!E$grUf@5sSr|;#gy{rB$&9XBTZ3G(P2)|FXNl*!HAEu
zH8ML|wos5h?|d8eDX`zHXSk?t=ze6O>#RBb>Bm|$t~lJqoyFbAp+tJbA|AN_A2_U5
zMPG?1Ds7Qu+YrX5iTB%J{d@z95%ZV9sGxcgY(!3!OB1Ppqt-j}y41>Vsc(}X<
zsc;EF>O^_E{W%rVf_Q3|%Y>sA!A}nwC8hYX7_mBZE1|eyGwQ8HN7jj+
z++FQQ$ja6-rur;C#LSb40umZSMVG`!quSa@qgX9d_k6y_!!M?dYXTiSv)XTjc7oxy
ztW+!6lw4@~@Pl5B2CqFu7^Rz|FE@~q{I&R4{orI2`>+TgQdWrV-QLJmp0`61_0rloHckI7L5$36dC<)H?}YZz?}s}~6K#6Iz%
z0hBh>H66+`C65qVab*>Q&z>aHSI>ZlP#AU4t~Wpf8NC21C7G7Y;c1bkePARFCvPmf
z$RHC#ltBqk)H2WG74U?Ub_&@USWVQCKZR>?OQy9;AHfVpaU2#Rd={+DNo!Xo1LFtVDVrEi$=YWaUOo0-;-FvvZ^3P$YOuu7ua_gVFg
zh2^RlDKYOdxi9+@L3tEWQo~%>%bO||HM@hKsgx%a$h8UTeu+ECM&gM3=e!Z>Kz~36
ztAwa39Z;J6M^4*vV{eO7Ib+I>Y>?OiM=8B=KPFx`
z!QYbi{M>^j2f>$xb`b{lB?F7Hjt
zi}ySR6#zZJJ8=m6*hd=f8F5B*YDx9x;p4byFsdd+MoRNp3a$$Ggr$xJ(WL!TW6(dy;4AN
zq5Wf)Oeleg5B~$slsNMd8t2pmiBQ(oN>-OkRYfY+>vX~=RLfTONUS306RciGzR*
zpAOa!uE~-Sf+lVr*NDzq&?{a9U)IQP#klZWu~6Dri9yjuTCc9e45Nc$kot#d{?xBy
zmYC9Uaqs4=vL?xPM!&>@6PF^QGry@)Nen_MoxOad)tRKGWpqN`H$q9lcik3hgDAvq
zOyme=uSSE@o+OCUHQ$%|FG3m)#+emIz2Sx1Iy$UWY6WL%nPjfwZn{e295osdxKn3D
z^u{T&8U+t%*`6jDT2gLuOL(g4_5L6Q8D^B8npUot38piHUrlgW}rxvA~ga!_d`AUx#i@o5KlMC|SW(;U2hLH_(mxK38EwU7|%b-H`cCrr|6=R5wrc$%s
zQIX%`N*StoX?#ET_{{Z>B$Qpcz85uT*FZ?ZP7gNTWskYMbc;%y{@A+c9y=rZ!$^id
zzx?Eb??Y`xPg5zOL(b#5Q0>#UMY~~Cld8tebD|9YEv7=70E@HK7VD~#2>MROmGvoD
z(`1`;S^PI%F(DmS+Ui8n$KareUDSr|6Z9?QR$
zL)efcQDzaAzSh@}!YeSra^ZskaZ;uWUiH80A1fG@wKJp55~3O?R0*XBBxBNb_`H%Z
zh_WTC2HZs@mg_apyZ-tY$~k2zNJ(Ip;Cw9|h26Y;EoG
z=l)bK{3J<|Lat2K6uMliFY9mGFcSS<^cY*U@4fxm<1Xuyb**MOcAEF#x&M8Ix(a8!
za%2^Pk@pZXDE3DgBKeVgff>C9K(cBt1jF3Eh3D@X4Uqo*2Im^MLZYK&gNJmK^SF$w
z#LP`il#&?i;!(naJ~pwDZoQL{P1P^7ilN)WQt=a)k|Je<>wUusQS?M9S3a;DqvQP1
zgmP2FYgp*lO3rDAMpzp&?Hn0e(^p_r#j+CBC96p-_Na&wQNv6s+ua8lvK-3u?+58M
zai6S>?>^XC_OsAVJAA+8HQ?;|;Q9RY={QN`c$U?fxo73{$#h$rske3cn`!Ol%K$l*
zjJOJ8!OGRGNFb#d7Q2~jVg-a(a>)32SKAh<_L_j$1q1($kUPvWI#?A3o!f;mdd&sE
z@&ya(vjhtJN?!#Fr8Hos4fatvH)U`>9dRaEW6WV@bLQRBqGoO?4RIpMjDjrKa;|zX
z;m>3~iHh(qBkF2$QUYQ*0ODAlWFkLXoID9=EStVb
zU3pbz*j9{`q*u3n{t!Jn^})JAg_4rBI=@A9-4Okj%%3jIJ7J}I?04_;YgF#>Z|}>?
zY1+%}>k9V;g`F1-zCUkGnCRUe+Fwd>b5Rs>_TFWb2`q_owl-pOX{EGz<(*#@FXU2`
z+$}5s&W`w>Hu)uNsvh6}masxxDGmreG}bYj5~1ku`?yh%Ht{d-N9i5T4q+Z1huRM4
zxcK$16#ty(^snLb$BL#~zQtVl>^7@PR2IzoPPl=@@AhtD(6)N&vx>JDO9^_F-LC$;
zX)#&uA)Zyha&k2*Q;8p*ZpDDY$&&B~Se|2E5upnBxuvglE
zY@Nuy3$(rkDoRe+zSk@g2ABtTNy0Rrro$nYL6r9{m}fLHZ-ne4u$wJZLr}OE7%KG+
zU{O!7SlmUc*jFOUswv5V6Kx#j3o8dfODGuQ<_>nkS5s5+Ixy}Y3E0)xh5QXZPPWyE
znq_9=AFWmLGuU(v)T~wXe0!I3%fO^pb!ruG!atb$V8cm`m9u4|aT%qlZwkxym(>J_
zEambA&_4Fxu_8N!7FZB+aaybbjRy=iNh#CU)=Z2pl`g$Lm$ZBw=SgK@@81=8jE?+j
zLjxN9aynV_w{rI8g`;{bY^fx=u9m-{-qMUHe@sw~=~w5{+S-x{W72LTzkyznS^qr^k4tlD
z{BylxsxJ2_YKbAX-yUS90NOqC?8%m~BQf`U2|ac(`0qV)6@QV~Otg~31bHEUHT@_1
zRxMNKiCM@3!}l8Xi?hFAoD%b*-e2OJf(0|@p8fAjNOiJi7AC&tL;p;>T9|=CV1(}N)3dOs*dZwuOK2)=;4#a`b{Go8MwRk?^eT%5_juO9Z+k1zBF0a3l|BO!9Ztc3+E#(!E
zzq#qiicxVH77nd}67NxjOX~M%(;G|Fm@N<7m#XE2b$^H{e%YHl!o|#0$&(DW)R<_7
zD(XrXRI22WF=)*%C8GqonCm&MW5zK?q00ayW%B40Lz<=U%SIOZirs!2i#n)%8&?N6
zs*(d!9iGQnOdC>n1KjZ~G5KH_iGDt9eyz6+c)V2<&-jIu0YpCl4JG0BOdiiI_b`cl
z{Vw%lpPEDZfNhwNSz-FM;x(Y&B(giokZcTqXXcx
z1E>I)=MiGBUT&=5HPz>}fzaY+;(!&07)&2BB+4VYiD4~vq`5#f4_6gXUu9+WBX_Yl
z%kM6S3z6hcEId?(*v)1V1e}+rC+tyP?L0waE5svVG={x*>N0pN+UOuUh%qz~$k8LY
zpo?m`FN9tpzgKvFxDw=zsY6yKTsHE%Yhmu!z{PXQ{YvG_#d~Ab(QgbdQ^wxc7&Df{
z55qfOTe6~_wW)`h=qLUpj_@jImrogYXkKmx-s$u%85(c@^$d+&M>-
zV2^0PniEtjWm7(r*fBSvLGeDSr2X1nyCP$R;hFfy{1y&wBf}U4>(wQXPeHmM-o^DwZQq0qCBr9?uk+O_IhG`eZ-h*sVW%xr2hq9Q*aH#X
z&e>y;K!j1)R}nh?HB;zVZ2qRJYd(Aq)r>znhF&j+GGB7_A9;}Q8
z=ZVZ&_I!VxB>8uD4W6GH9~`w)=Q>AM3hVBcVp%mBJX^-K0N71D0TT-#XFtAW+0C%j
zRgKK86O)TCJ50&LiKj(IYAZi=ngLS%B)EjMd6iT;4_9La#%kRBP2bH9;xCnknM#TI
z`d!DFM{4@CR&wR9Ia|kQrjd3~TQDpYB*EU^acT>qu_#3wYtK+@`L4XR6;mIEMW0mG
z;43gG4KhdYzpl)T2r*4j$AHa}Q~mK+IyK8ns9nGd6uS7olo1k?UbKcJDWzik8|A!p{{504RR5?+99e?%_lU%A9en4d$f{&R&+w
zt1~B3UDw~uq}3Qn1liC>?wx21(C+v#Cj4}GTNYTW!T~CI33|3AR#7Q#nrXngs4D^^}C@nT!OT^*)e*|K|bPd{ekz(|fIBtz+g(o%nhXi?qlsIreELbmbb
zSKmzcq|m&3y)jYcN$uI*jds0id4ZcR9;7o4L801AC@dal>!daGaLzpOC;~12jOtue
zWlj}`ff4l5(U4I}6^ARCfQ@AzkO$7<__<}Oo3H((bS89jH*>2j%B#~sFOad%+_GJ;
z_hf)m)v;attY}>ll=`9hw;)S&dS#?nF87EZSmQPqaB;)k9sE3>&%Sp*cmx!
zSX|d#mLIQuJXUy3`uZJ1gh9@xJ`11)a-w3=(WGpS&vq0-(w=OrqdxC-W*UJq0XS
z!uq_}LFL#qjp7fxvSyFGjrSHe>qbJTM>j*ytKvK}?65|0EEpl^35rmJkb)>8?|5nr
zPCXU*J(M
zv6r@R!Gq$KE_p`02|$}j_wrwHo{+k`KTbQjIT`D`1Sp>PqgX$z2y$Nv0-yAbT0PNC
zWmnt77|AG(aiB;*9g5IcWJ~XZ4N#fdPN_iZwJJi%F{20dRtE`M<22^jkDxqxKNdz=
z%G8yr7)Vj-*Q(8-;#RRXE4jrqBIC?)p0!50v@E{J5o&pUysPYCq@sm!zrUZ8-
zL7=WucKx`+^rVtZ54FGeS!0T~AHoLwP>GS>RN2q_6H7<^4`arJ-s>E9^TE9JsDc(1
zG;Z6PJ5R!e9=6?QwRV+jCB`;S;2PK8`(@1{sT>t=gbd%^WgE}HFmoL+{wwN(F`p!a
z!tUBv_OHmP&BbnI(LRzIx%m(4x<9X*`bjJY=I!4j3Jfu`%;vW51>z;z2k=cjA{h`;
zq1YT=ltfe=ra2#_euG5&c}0v~$$HfR?{bJtpf%@PL{6TM-t3K&<^m
zl5);)gwXLjHXK}fw4jQXwn^9Jb*iR{+AziV$Kp-+TIex&OUxl^_G)UEaslus(lnim
zNxKTqQaBbCMmFvZrq4BXHTfuQWy`89sS|j&wm%}EUi>my}Mes&JBi(ixO^xW?hvs5s)vcP(j%W)8u=QO{T!EvBQe^6S*Cxsnsy33tKyM#v=m?k{+TiJANRYfRxSwvD--#E(+6
z99a2A2mr{IPjyW72O0a4T`QUt-&bX)rY9aqmRSP5hXbYG$%L~%{BBmQjVqY8@lB=u
zt7yRzZk#Pk?5-3Xj<`?Z`Xg{d)&lHnc?c~;V~3D|K^b;X$>Ee{b%uXB*Q?!Oq?UQB
zov~nxOrG?fyWq(?0#fI>uPfMk$5l#$hNf%!dz+5wgTLu!&hPXm!+_D9-^DK^pSM&S
zqNjz1|Cp7^4-N|7eFy0~CiE1`U>LYwdMks9!C80GEf+fiPly54CE4};JOM2;O~Q{<
zxsIS~yNfPQPjVe-7{svTI~EPS6u>nE&W5d=Gn}R)HJ>)fpKk4f9}lKM1`DW*j=YM_
zkt>DY!PKUqNBspK?|a>}5(>$2MdoB_gWOeKhk+u$g1q7c^TOD@2UD#%m%0vwcjbeG
zU(Z@Y%8IUY7{vqVNi}zbRgU4eQnPcxmlTmHAK6879u`P-bG5c`-)8Q*2A5>^#(=UpRV5vv>wJPa=kc|{FBhA1GsAf&kUk)?NOT{1im7{)Q;dspGJ%U!{Fi;-i
z^5;klX#4OYcv$fJEQ0rpI2o?n^QrKmp$LW{<3UbIV5ORv(4ZxsOy*BQ64oKf!JXL!
z0cFe$)UG#486qNs37oWFY0b-hQ%nkRnuSzC570^N3e78o(o`E!g~GeKWE?)Z(-#H!
zZLo0}RECUall`c!PT_4?r|J0^UaM_?4X$93_ROslrC%%7iuJy#`W-tRO;(T&$7(kU
z>zzS%DPa@s#*ewykVseN9$ufZAh-!yV_1*W`IHy_b*#Mbr)nx*JRBwNK-oMz%JWd=}^hZ3mBh8D~!P@6ZUpE|$HaOnE0O)GckIDBX{
z`*rTh%8iQ=bP7uarf77@EI16MC@Y8-;j)rqly7(HsXC67k!fJi(&WyVWBa})%LXq6
zjHsf3NQrn9b~f|{1tSgZ1s-G<%gzN*E2=H3zGIhrcpX#igEcr;z2rE+kq{N}f-QBl
zyY_MFn)p8zh&ocW!j-k1)nvbMeLsv?sjKw)4m&N4jwYEbvtCpv$^!s@K?SQAQLap7
zs37vz_Tk7@B9-?Y`2Kd7Du(n>!B#~uSc#PHbfJRoWKff_=dd*uiO?_#u)|$6qd1!B
z@u~^pTuX3d5HQ5c%WyB|%c9EkFcw0K95U~d79=mJ+oBsyX}++`PyPI{|H!n`DkCK1
zdYgSk_-$ZX7tA9;gVDdI=r|HuG!&VK4Pev2XYf;mO20Gqo2@d^T*W9%Qpec@?MLF
z(Ru8W9Q_I-MTrI(r#=Qmq@D#xibiP(#O8f7G^c}LsyxABO{#^EkL)y(#=3|XYZ^x5
z%#67~La9E9>-aMljtcyG@=XZ<2w;$u;~mN39IB#)P^@T0lfKO5Ze@Er#A+>LbM&iU
zKa-o>R>mzy=9g2-hV%(#P9AeQr>jlJyOpNh_GkY}4$hsJlW|3wy2PP|p%hY2VxhT<
zkS?L2JUm9wM*P{q>~mvF6G+)$`_i81375-$=8ruv?CmSc+QQV3z0x7g8|l{*GEjXp
z1K%Qy^uhe$NKtYH^?C3FzNvTs|m%!<$mO@((@>Ve6ys%5(kH7YZf~Au+qz+&5
z;|ygDteqDT%xtBDbd7KE#U(BX$*nxhitN9}XpE|~k4SuYHgNt{DzNe6Y0_|gQ*8yaZ2bp?O+wqD1&U1M%1hgY3;<#sT&R(i@
zq%}2GPBn!4W)y@K>8Hrf%hXyJ;$qP#;!S*0)JC;NoNLOqDUg+ii96`NUiXO#>?5H~
zfmxu6xFpar!6zW1qApWYX-@{b=lTqFN0O=td5M(!v|O0@Kk*p+wZ>T(c5twa*FtH?
zk!kQF$M&0vcy>LZzTk;mtnyDd)?Sp99R}$*Z%+D;Eo%7KY&!h8|59SxmZoBfGs(6;
z+l}k|^0%{if2cDi0UiJiP{chZ^<3F1>X=xVw6&jazAS%OHf<+-uam{)FI=$w=!e&N
znY>S07e249BdiC9ZsFwePeP+rgjTG@bOeYtY|K;VFBzi!t
zc2Z7MJ3;BYlpBi&&U_6|JhcwT$V^XsSE+rNX7us<
zy@$h@E<8pQ7470sq^mhD@N<+%Mc8H$HE|J?;|XOALfwMX_hfv3GB-~G2!@RQ6_^5d
zAzNm}hyZxr1@hTS6g;;Y6x(QM=;`9|ldn>4Ss@l%+HEyRSY3#9jD}wsSYh%_Xvi5|
zy0-1LQg|P(X+cb?jbuESgw8l%kc$*ZrZu*K92FcQoxp
zp47#aaAttmVanCX)GyIHQPt{bOxlfQhYxvmawg50KgyM<+>@pnKEN_EpqgVXMK`L)
z$d1Gy6Oa(|n|TDP1zKBL&SRzk39#gO6P2T;UpHwzM%x!8>EEf*ut-2+QoT;z7s~6T
zM39e!uzxT~^;}GA@K-Wc{xJQZ#gT+s#8vUw`mH9Qy!&$Xg7!GuloEublMrxJ?Q`UO
z1F^486IeCj`?~OJw|z%j(HGCv*x5nptFg1u$W2t~44v-a>K2B(g@~DqS9jwlXhDhLtF5
z5Va>akHV+6Yw}P@c)mF~oseZ1^XY#k+kk)a8L{${xT}g*11kb4LSKCzhmxnP6^&xs
zgzdGi{`5wf12^Br*Xhf(eJkzCJGIqr^FYUWDr`iq1asf!BY)L4k`i*nc~9*Hok2zA
z&W3ci*RydN5ye#tY7!H?>*nh!h09Y3;d>ZbitM%?7#WC>6Rv3DKTLj8ORAi(Tk~--
zr$kG0mQZIt5Jc|iD7RpT(C{E4<>vm0>s4i;*Anu>k7=}OaWesiQt_}JBj^0eS?jQ*
zWObU?AIy48AJO!D34MZUCGVIUinAo`H9tC=c(QMHT&0c>#L~w2yVu-&?umAtHh!Zd=v#mezFUMk3ydG
zV*$JE7@nZ8M>+-99!T)30&<)28eTaSkS5)Xtu>8>yJ!TU8_8=wC*mkJ4
zGRHvU7B8to-pckHA<(;f3Jc{|LK>`q*RUJ_fDK9EM5$X1jQy9B%VMbUQjT9VX|Z@r
zElTH`?2)Od==u`IE}4!A(k&{~n=J!CRDvn26l!ztz#;mGdPp{btE(YE|sWZzwaBx70_
zPToUWa+K8xCQTu(YHWfIPz`yOJH6}N;{oRQgw3R)P%xhSPg@!;muHmcv0XA}hD8dG
zf~@oMQLdcB`&xAoqMi*g$Bx^yi-vJqMFn1A8GARQ?JLr0#R*&2EVVxq#@G@#$)D5i
zJ7g?mKI+!L8(}-|bm)C)U5YP1dT$SY+_Z?Y{R2TFd46g7KiWI5sHVCsjsw!00U}DT
zfj~kLA^|>8dPhQUh7v%kfD}P2kWeH*kWeBmN^v{1vyg6BN9`xil5sTXXUnCs601V3Q+L?UyprZN*Ao4flZ>cM-8mwp$FaNP
zHvv)7l2-TrBWOGkb60~;{UuZ5KV=GW@1DjETNW<*f<%%|=Pr&LHVkZ-_R12R?VXbk
z81>fF$wH-Dt9b#>+`zp&3Bo~?j-7YenV!uqF_{;gQ^e>?z;g>)vbJD-A?>BmQ8iV1
zf82L-d6p=hs-I_pZO~I~T!5|fmvtfs^bHB@qBbs(u9Kh(pPrKUwQ6{aBJ(~uZ2%$J`-+O
zSl$ory@C=ReG`Sk@@n7n5dd4swFpN-%_vf}lZ%Z^g9^j>x6}AjdyLAOu6S0J8*EOF
zqK9Bo0&-s4;b38mQHgXK3eP#>7Yg>C>ki}^H?lmuS!OTmPiuF5*D73qri
zMSH0x`BbphEZVyzS@Hn1`=AiDzvTEs>mT}Ox9PKL6YilkO)T-?BSPR~yY)U3Wl&9&
zy!4-fRcHwdJ4zJ#8LDl@ky`otK3Q)zvZRn^rnw?w-ftMCy-9zZ|zNZU**#DH`-i({HLTax#*nQ{msTh;cMGovmuj|*mmsn
zK)POP=Q^YNW$9;K6P$2B{CQnT;8+AE-9^bh37h>J^8V(7pa4^G=@kMClh0HN%zG0W
zCDBXvOCLAQ5ow5Scyv+Up?oSUEl@?wNfaixyLAV$(TPnRnu46c%yT)q?nAvZxy9Md
z(|mc@UihPsLUX49`pM>dWKLcK&ajBgCTL~yomdpwm>yYp#dEwSC)!PyVgN6=MkK!g
z?bKUozRkbF8qWvFgzX0!^@h|HNOhBH-nKZ$`Vcd1uLnLf+LIA821!CbcN9Hqg+yRg
zqY4IcDY51DRA?2---IkuDX5NSVupNSCr~sI+}*VFcnuv^jWMX+NR)rDQn~LsOxysb
z!NP?Ef4@P|v5ST~*3icysbk!NT1f$UNEJD>2xhk7h>$G%ZmQn}BVMsD8i{}65CFui
z!&@H1+0K|VW)NzPbFSIfcu=k96*DE_U=1yObLw#>3tujP^DPg;G+6CCJ7>g=zI!$p
z$j|R^<`oHBmeb$q0qif^-xi#{oyuDRH&1Zo;r%N{9ENH?g5{)5BId7wd|qGf7-GX6;^>m|7_iOM-TWA2f|r0DrP
ziz%Jp=xpeCNOzA6;#g6?2+eoqQF4}aZ$*1)=bV!GU
zsh`Df`P_@Ul^fynnrEve4lb{nEYfaLoV9-1bb>6M@OuRYiq-KYYGo&_1c$BW?m+<(d(Gu`>5f@81;+CFOf#T!cpZrBABl4#_MsKNn|XW{zm!g}_)+
zlNTLb0*noSw+A?I1Q}osH=B$BCeC`46JLiuBBXeGcgxSlND0>Wr#-}oLk9UU-tzgx
zhNCG~CMLyG=TdRp_^;Y_Pd>Og9ecV2%3?}-UZfT3b9;saUPxIEw7ws7)@IE{ci=kG
zL33P4!6EcoE%!5=pkE?RLNmv$5(RI4l2V+7kkn3aX^Lw&Lb%OXyfp4wP5;{_bUU{;76xCx%pw(La}
zU>-RG&GABGMEr?|?{UR`Key}#7H?gx>E3`|0;U{cTB2%{IL$`Dq8%(`V(=LEH^RIW
zLl){Xc(fQp$8QQy+%)5(0wH-5IxWxkQpW$1IY|AurtEfn6ZLF=g=|`2TkEW{_qRrB
zOp>xld-1QrEsgZa$+|2`tHN{DYN??DX(s0R@G=qAoWa2DjB|l1ppv?InPxNL<9yu+
z57q0wwyP&=lUGC}Wq6rand~B?z(<}jej*&O0$-bIF7dCb-hV{Ff*Dq&4kw`}*8iyb&DBpTzctP{?h2IDbPky>ySEnU
zBHeV)?RA|YdRTdUqmDH-+TK{4I6IO)eKMNrD=?+~Yp<+qebN?L=k$6bhdvU5KWvdO
zdB3=Q_?k#;ZD}u0NKnRY`GBVJvRR#&RFp9>M;l*-k9MxZPRR;h8gPetJ6T+h!qj%h
z=*@(e{W^j4Y8i7Rf;!mF?~2vA*x-nDPa8^9x(p0Jt-BEDdLsl6G($+x5Fllx-a
zu~&%id}myod7=tUwJ-W13gH)z2myI^uc7)W?`-we-!KR>jEaKJ1PXnR#sSmGAm+Q&-;v#ytn&)EpwT
z*cs=sV_{ts3chD?Jutc
zQvFPS_tLkHwYs~m2xckUAoIADxvOqm3X}bgq^k>V4|c18l%OVGDHjK^3NC#vHIBJv
zQ2Lp5$8+vqruy(d)cb9%th7+rwIOr5tJ4kH>>mP=qR$)bd#!~#dN2k-sz~0qm82cl
zWG`G~pMy@$X_F3cd4wmYr1UC>ny~c}2xZTZokdTtOjTRn1K9WFUWbta71-7F{knF`$)A1(iBuus&O#fjF0JCFAS
zv^y8NvYXBEh=khv%`ZkC5jw%NOZ73pelUrImogkle8c$s@tjZPBZohnDSZOS(0OF1
zDj?J`2?1KwU-gcJD>CvMDNtjEl^YcOsYV!WIN0g!;IT!D2;_dpAu$!1QV_uQ2=>JG>^eB(ZM{k>LR(KqK8qr=R?FhLqCp_^T=G19c2kH
zXeyQj1S2>(V+0p}A+tb{15m`Oo$~PTi=2(Nw6xhl7tecc)R$AW{V&`KTQ92f2OQ(~
zmRriQgmgLS-EuCuzqaP!TjM5N-hoeek%z_Q`zsGx)2ZBocD
zS?go+cRlvsHcsGOVe%w&ffT|{IXVe(EEIIt=+*!)&3X$0O6()bm+X0a8oj5i15EW6{(k2(nAxN0KEFcejGqH;i*Lv_(G=q2I^kS^`L8!nO2l
z*tq=N*HgRs)V>eP3*M-9rP)U)3cC6@_EcD)(Gtwka%=_Q&1Y}AA>Rr1jM~BVx@M@8
zk=Dbq;Kob#VG#}q;Os%PMZq#%iw2)?c_T2}hu?Ia)8>|%XbX1VwI;i3b8b%7
literal 0
HcmV?d00001
diff --git a/src/main/config/windows.ts b/src/main/config/windows.ts
index 020732c..e3c3722 100644
--- a/src/main/config/windows.ts
+++ b/src/main/config/windows.ts
@@ -10,6 +10,7 @@ const WEB_PREFERENCES: WebPreferences = {
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
+ autoplayPolicy: "no-user-gesture-required",
}
/**
diff --git a/src/main/lib/constants.ts b/src/main/lib/constants.ts
index 8c452ce..4b32745 100644
--- a/src/main/lib/constants.ts
+++ b/src/main/lib/constants.ts
@@ -27,6 +27,7 @@ const CONSTANTS = {
lfsCommandsGetLockableFiles: "lfs-commands:get-lockable-files",
lfsCommandsLockPaths: "lfs-commands:lock-paths",
lfsCommandsUnlockPaths: "lfs-commands:unlock-paths",
+ lfsCommandsLockProgress: "lfs-commands:lock-progress",
lfsCommandsMigrateLocks: "lfs-commands:migrate-locks",
// File tree
fileTreeGet: "file-tree:get",
diff --git a/src/main/lib/dialogs/index.ts b/src/main/lib/dialogs/index.ts
index 3aaf09d..80ce8e5 100644
--- a/src/main/lib/dialogs/index.ts
+++ b/src/main/lib/dialogs/index.ts
@@ -80,8 +80,8 @@ export function registerDialogsHandlers() {
openDialog(BrowserWindow.fromWebContents(event.sender), { ...options, variant: "confirm" }),
)
- ipcMain.on(CONSTANTS.ipc.dialogsError, (event, title: string, content: string) => {
- openDialog(BrowserWindow.fromWebContents(event.sender), { variant: "error", title, message: content })
+ ipcMain.on(CONSTANTS.ipc.dialogsError, (event, title: string, message: string, detail?: string) => {
+ openDialog(BrowserWindow.fromWebContents(event.sender), { variant: "error", title, message, detail })
})
ipcMain.handle(CONSTANTS.ipc.dialogsGetOptions, event => {
diff --git a/src/main/lib/fileTree/handlers.ts b/src/main/lib/fileTree/handlers.ts
index 0541489..5ccff46 100644
--- a/src/main/lib/fileTree/handlers.ts
+++ b/src/main/lib/fileTree/handlers.ts
@@ -1,11 +1,10 @@
import CONSTANTS from "@main/lib/constants"
import { getFileTree } from "@main/lib/fileTree/service"
-import { ipcMain } from "electron"
+import { safeHandle } from "@main/lib/ipc"
/**
- * Registers the IPC handler that exposes the repository file tree to the
- * renderer process.
+ * Registers the IPC handler for the repository file tree.
*/
export function registerFileTreeHandlers() {
- ipcMain.handle(CONSTANTS.ipc.fileTreeGet, (_event, dir: string) => getFileTree(dir))
+ safeHandle(CONSTANTS.ipc.fileTreeGet, (_event, dir: string) => getFileTree(dir))
}
diff --git a/src/main/lib/gitCommands/handlers.ts b/src/main/lib/gitCommands/handlers.ts
index e085e0d..0375907 100644
--- a/src/main/lib/gitCommands/handlers.ts
+++ b/src/main/lib/gitCommands/handlers.ts
@@ -7,17 +7,16 @@ import {
isRepository,
listBranches,
} from "@main/lib/gitCommands/service"
-import { ipcMain } from "electron"
+import { safeHandle } from "@main/lib/ipc"
/**
- * Registers the IPC handlers that expose the read-only Git service to the
- * renderer process.
+ * Registers the IPC handlers for the read-only Git service.
*/
export function registerGitCommandsHandlers() {
- ipcMain.handle(CONSTANTS.ipc.gitCommandsIsRepository, (_event, dir: string) => isRepository(dir))
- ipcMain.handle(CONSTANTS.ipc.gitCommandsGetRepositoryRoot, (_event, dir: string) => getRepositoryRoot(dir))
- ipcMain.handle(CONSTANTS.ipc.gitCommandsGetStatus, (_event, dir: string) => getStatus(dir))
- ipcMain.handle(CONSTANTS.ipc.gitCommandsListBranches, (_event, dir: string) => listBranches(dir))
- ipcMain.handle(CONSTANTS.ipc.gitCommandsGetLog, (_event, dir: string, limit?: number) => getLog(dir, limit))
- ipcMain.handle(CONSTANTS.ipc.gitCommandsGetRemoteUrl, (_event, dir: string) => getRemoteUrl(dir))
+ safeHandle(CONSTANTS.ipc.gitCommandsIsRepository, (_event, dir: string) => isRepository(dir))
+ safeHandle(CONSTANTS.ipc.gitCommandsGetRepositoryRoot, (_event, dir: string) => getRepositoryRoot(dir))
+ safeHandle(CONSTANTS.ipc.gitCommandsGetStatus, (_event, dir: string) => getStatus(dir))
+ safeHandle(CONSTANTS.ipc.gitCommandsListBranches, (_event, dir: string) => listBranches(dir))
+ safeHandle(CONSTANTS.ipc.gitCommandsGetLog, (_event, dir: string, limit?: number) => getLog(dir, limit))
+ safeHandle(CONSTANTS.ipc.gitCommandsGetRemoteUrl, (_event, dir: string) => getRemoteUrl(dir))
}
diff --git a/src/main/lib/ipc.ts b/src/main/lib/ipc.ts
new file mode 100644
index 0000000..bfbfdf9
--- /dev/null
+++ b/src/main/lib/ipc.ts
@@ -0,0 +1,30 @@
+import { type IpcMainInvokeEvent, ipcMain } from "electron"
+import type { IpcResult } from "@/main/types/ipc"
+
+/**
+ * Registers an IPC handler that catches its own errors and returns an
+ * `IpcResult`, so `ipcMain.handle` never rejects.
+ * @param channel The IPC channel to register.
+ * @param fn The handler body.
+ */
+export function safeHandle(
+ channel: string,
+ fn: (event: IpcMainInvokeEvent, ...args: Args) => Promise | T,
+): void {
+ ipcMain.handle(channel, async (event, ...args): Promise> => {
+ try {
+ return {
+ ok: true,
+ value: await fn(event, ...(args as Args)),
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ console.error(`[ipc] ${channel} failed:`, message)
+
+ return {
+ ok: false,
+ error: message,
+ }
+ }
+ })
+}
diff --git a/src/main/lib/lfsCommands/handlers.ts b/src/main/lib/lfsCommands/handlers.ts
index 56fc73d..fd1b340 100644
--- a/src/main/lib/lfsCommands/handlers.ts
+++ b/src/main/lib/lfsCommands/handlers.ts
@@ -1,14 +1,14 @@
import CONSTANTS from "@main/lib/constants"
+import { safeHandle } from "@main/lib/ipc"
import { getLockableFiles, listLocks, lockPaths, migrateLocks, unlockPaths } from "@main/lib/lfsCommands/service"
import { getConfig, updateConfig } from "@main/lib/store"
-import { ipcMain } from "electron"
+import type { LfsLockProgress } from "@/main/types/lfsCommands"
/**
- * Registers the IPC handlers that expose the Git LFS locking service to the
- * renderer process.
+ * Registers the IPC handlers for the Git LFS locking service.
*/
export function registerLfsCommandsHandlers() {
- ipcMain.handle(CONSTANTS.ipc.lfsCommandsListLocks, async (_event, dir: string) => {
+ safeHandle(CONSTANTS.ipc.lfsCommandsListLocks, async (_event, dir: string) => {
const locks = await listLocks(dir)
updateConfig(config => {
@@ -19,20 +19,32 @@ export function registerLfsCommandsHandlers() {
return locks
})
- ipcMain.handle(CONSTANTS.ipc.lfsCommandsGetCachedLocks, async (_event, dir: string) => {
+ safeHandle(CONSTANTS.ipc.lfsCommandsGetCachedLocks, async (_event, dir: string) => {
const config = await getConfig()
return config.lfsLockCache[dir] ?? []
})
- ipcMain.handle(CONSTANTS.ipc.lfsCommandsGetLockableFiles, (_event, dir: string) => getLockableFiles(dir))
+ safeHandle(CONSTANTS.ipc.lfsCommandsGetLockableFiles, (_event, dir: string) => getLockableFiles(dir))
- ipcMain.handle(CONSTANTS.ipc.lfsCommandsLockPaths, (_event, dir: string, paths: string[]) => lockPaths(dir, paths))
+ safeHandle(CONSTANTS.ipc.lfsCommandsLockPaths, (event, dir: string, paths: string[], requestId: string | null) =>
+ lockPaths(dir, paths, (done, total) => {
+ if (!requestId || event.sender.isDestroyed()) return
+ const payload: LfsLockProgress = { requestId, done, total }
+ event.sender.send(CONSTANTS.ipc.lfsCommandsLockProgress, payload)
+ }),
+ )
- ipcMain.handle(CONSTANTS.ipc.lfsCommandsUnlockPaths, (_event, dir: string, paths: string[], force?: boolean) =>
- unlockPaths(dir, paths, force),
+ safeHandle(
+ CONSTANTS.ipc.lfsCommandsUnlockPaths,
+ (event, dir: string, paths: string[], force: boolean | undefined, requestId: string | null) =>
+ unlockPaths(dir, paths, force, (done, total) => {
+ if (!requestId || event.sender.isDestroyed()) return
+ const payload: LfsLockProgress = { requestId, done, total }
+ event.sender.send(CONSTANTS.ipc.lfsCommandsLockProgress, payload)
+ }),
)
- ipcMain.handle(CONSTANTS.ipc.lfsCommandsMigrateLocks, async (_event, dir: string) => {
+ safeHandle(CONSTANTS.ipc.lfsCommandsMigrateLocks, async (_event, dir: string) => {
const migrations = await migrateLocks(dir)
// Any migration that actually touched a lock invalidates the cached lock list
diff --git a/src/main/lib/lfsCommands/service.ts b/src/main/lib/lfsCommands/service.ts
index b6ec911..3ea4f5b 100644
--- a/src/main/lib/lfsCommands/service.ts
+++ b/src/main/lib/lfsCommands/service.ts
@@ -126,30 +126,66 @@ async function runLockCommand(dir: string, args: string[], file: string): Promis
}
/**
- * Locks every lockable file covered by the given paths, in parallel.
+ * Locks every lockable file covered by the given paths, in parallel, reporting
+ * per-file completion through the optional `onProgress` callback so a caller
+ * can render a live progress bar.
* @param dir A path inside the repository.
* @param paths The repository-relative paths to lock (files or folders).
+ * @param onProgress Called once with `(0, total)` after expansion, then again
+ * as each per-file command settles, until `done === total`.
* @returns The per-file results.
*/
-export async function lockPaths(dir: string, paths: string[]): Promise {
+export async function lockPaths(
+ dir: string,
+ paths: string[],
+ onProgress?: (done: number, total: number) => void,
+): Promise {
const files = await expandToLockableFiles(dir, paths)
- return Promise.all(files.map(file => runLockCommand(dir, ["lfs", "lock", file], file)))
+ const total = files.length
+
+ onProgress?.(0, total)
+
+ let done = 0
+ return Promise.all(
+ files.map(async file => {
+ const result = await runLockCommand(dir, ["lfs", "lock", file], file)
+ done += 1
+ onProgress?.(done, total)
+ return result
+ }),
+ )
}
/**
- * Unlocks every lockable file covered by the given paths, in parallel.
+ * Unlocks every lockable file covered by the given paths, in parallel, reporting
+ * per-file completion through the optional `onProgress` callback so a caller
+ * can render a live progress bar.
* @param dir A path inside the repository.
* @param paths The repository-relative paths to unlock (files or folders).
* @param force Whether to force-unlock files locked by other users.
+ * @param onProgress Called once with `(0, total)` after expansion, then again
+ * as each per-file command settles, until `done === total`.
* @returns The per-file results.
*/
-export async function unlockPaths(dir: string, paths: string[], force = false): Promise {
+export async function unlockPaths(
+ dir: string,
+ paths: string[],
+ force = false,
+ onProgress?: (done: number, total: number) => void,
+): Promise {
const files = await expandToLockableFiles(dir, paths)
+ const total = files.length
+
+ onProgress?.(0, total)
+ let done = 0
return Promise.all(
- files.map(file => {
+ files.map(async file => {
const args = force ? ["lfs", "unlock", "--force", file] : ["lfs", "unlock", file]
- return runLockCommand(dir, args, file)
+ const result = await runLockCommand(dir, args, file)
+ done += 1
+ onProgress?.(done, total)
+ return result
}),
)
}
diff --git a/src/main/lib/projects/handlers.ts b/src/main/lib/projects/handlers.ts
index d493199..9576388 100644
--- a/src/main/lib/projects/handlers.ts
+++ b/src/main/lib/projects/handlers.ts
@@ -1,4 +1,5 @@
import CONSTANTS from "@main/lib/constants"
+import { safeHandle } from "@main/lib/ipc"
import {
addLocalProject,
clearRecentProjects,
@@ -8,23 +9,21 @@ import {
removeRecentProject,
setPreferences,
} from "@main/lib/projects/service"
-import { BrowserWindow, ipcMain } from "electron"
+import { BrowserWindow } from "electron"
import type { AppPreferences } from "@/main/types/store"
/**
- * Registers the IPC handlers that expose the project service (folder picker,
- * recent projects, and preferences) to the renderer process.
+ * Registers the IPC handlers for the project service (folder picker, recent
+ * projects, and preferences).
*/
export function registerProjectsHandlers() {
- ipcMain.handle(CONSTANTS.ipc.projectsAddLocal, event =>
- addLocalProject(BrowserWindow.fromWebContents(event.sender)),
- )
- ipcMain.handle(CONSTANTS.ipc.projectsOpen, (_event, dir: string) => openProject(dir))
- ipcMain.handle(CONSTANTS.ipc.projectsGetRecent, () => getRecentProjects())
- ipcMain.handle(CONSTANTS.ipc.projectsRemoveRecent, (_event, dir: string) => removeRecentProject(dir))
- ipcMain.handle(CONSTANTS.ipc.projectsClearRecent, () => clearRecentProjects())
- ipcMain.handle(CONSTANTS.ipc.projectsGetPreferences, () => getPreferences())
- ipcMain.handle(CONSTANTS.ipc.projectsSetPreferences, (_event, preferences: Partial) =>
+ safeHandle(CONSTANTS.ipc.projectsAddLocal, event => addLocalProject(BrowserWindow.fromWebContents(event.sender)))
+ safeHandle(CONSTANTS.ipc.projectsOpen, (_event, dir: string) => openProject(dir))
+ safeHandle(CONSTANTS.ipc.projectsGetRecent, () => getRecentProjects())
+ safeHandle(CONSTANTS.ipc.projectsRemoveRecent, (_event, dir: string) => removeRecentProject(dir))
+ safeHandle(CONSTANTS.ipc.projectsClearRecent, () => clearRecentProjects())
+ safeHandle(CONSTANTS.ipc.projectsGetPreferences, () => getPreferences())
+ safeHandle(CONSTANTS.ipc.projectsSetPreferences, (_event, preferences: Partial) =>
setPreferences(preferences),
)
}
diff --git a/src/main/lib/projects/service.ts b/src/main/lib/projects/service.ts
index 9c62bc5..b6b3593 100644
--- a/src/main/lib/projects/service.ts
+++ b/src/main/lib/projects/service.ts
@@ -1,26 +1,12 @@
-import { access } from "node:fs/promises"
import path from "node:path"
import STORE_CONFIG from "@main/config/store"
import { getRepositoryRoot, isRepository } from "@main/lib/gitCommands/service"
import { getConfig, updateConfig } from "@main/lib/store"
+import { pathExists } from "@main/lib/utils/fs"
import { app, type BrowserWindow, dialog } from "electron"
import type { OpenProjectResult, Project } from "@/main/types/projects"
import type { AppPreferences } from "@/main/types/store"
-/**
- * Checks whether a path currently exists on disk.
- * @param target The absolute path to check.
- * @returns True if the path exists, `false` otherwise.
- */
-async function pathExists(target: string): Promise {
- try {
- await access(target)
- return true
- } catch {
- return false
- }
-}
-
/**
* Records a repository as the most recently opened project, moving it to the
* front of the recent projects list (deduplicated by path and capped).
diff --git a/src/main/lib/utils/fs.ts b/src/main/lib/utils/fs.ts
new file mode 100644
index 0000000..55a2b46
--- /dev/null
+++ b/src/main/lib/utils/fs.ts
@@ -0,0 +1,15 @@
+import { access } from "node:fs/promises"
+
+/**
+ * Checks whether a path currently exists on disk.
+ * @param target The absolute path to check.
+ * @returns True if the path exists, `false` otherwise.
+ */
+export async function pathExists(target: string): Promise {
+ try {
+ await access(target)
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/src/main/types/ipc.d.ts b/src/main/types/ipc.d.ts
new file mode 100644
index 0000000..647c5f4
--- /dev/null
+++ b/src/main/types/ipc.d.ts
@@ -0,0 +1,5 @@
+/**
+ * The envelope every fallible IPC handler wraps its return value in, so the
+ * handler never rejects and the renderer side can decide how to react.
+ */
+export type IpcResult = { ok: true; value: T } | { ok: false; error: string }
diff --git a/src/main/types/lfsCommands.d.ts b/src/main/types/lfsCommands.d.ts
index 9b4d88a..2df9fb2 100644
--- a/src/main/types/lfsCommands.d.ts
+++ b/src/main/types/lfsCommands.d.ts
@@ -18,6 +18,18 @@ export type LfsLockResult = {
error?: string
}
+/**
+ * The payload streamed over the LFS lock progress channel, letting the renderer
+ * track a live `done/total` count while a lock or unlock batch is in flight.
+ * The `requestId` scopes each event to the originating invocation so multiple
+ * concurrent batches do not cross-contaminate.
+ */
+export type LfsLockProgress = {
+ requestId: string
+ done: number
+ total: number
+}
+
/**
* The outcome of a lock migration for a single staged rename, describing how
* the old path's lock was carried over to the new path.
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 2f1292d..3e54dc0 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -40,7 +40,7 @@ export type GitgameApi = {
}
dialogs: {
confirm: (options: ConfirmDialogOptions) => Promise
- error: (title: string, content: string) => void
+ error: (title: string, message: string, detail?: string) => void
getOptions: () => Promise
respond: (result: boolean) => void
}
@@ -59,8 +59,17 @@ export type GitgameApi = {
listLocks: (dir: string) => Promise
getCachedLocks: (dir: string) => Promise
getLockableFiles: (dir: string) => Promise
- lockPaths: (dir: string, paths: string[]) => Promise
- unlockPaths: (dir: string, paths: string[], force?: boolean) => Promise
+ lockPaths: (
+ dir: string,
+ paths: string[],
+ onProgress?: (done: number, total: number) => void,
+ ) => Promise
+ unlockPaths: (
+ dir: string,
+ paths: string[],
+ force?: boolean,
+ onProgress?: (done: number, total: number) => void,
+ ) => Promise
migrateLocks: (dir: string) => Promise
}
projects: {
diff --git a/src/preload/lib/audio.ts b/src/preload/lib/audio.ts
new file mode 100644
index 0000000..de062f4
--- /dev/null
+++ b/src/preload/lib/audio.ts
@@ -0,0 +1,19 @@
+import errorSound from "@main/assets/sounds/error.mp3"
+
+/**
+ * The cached error-chime audio element.
+ */
+let errorAudio: HTMLAudioElement | null = null
+
+/**
+ * Returns the preloaded Win95 error chime.
+ * @returns The cached error-chime audio element.
+ */
+export function getErrorAudio(): HTMLAudioElement {
+ if (!errorAudio) {
+ errorAudio = new Audio(errorSound)
+ errorAudio.preload = "auto"
+ }
+
+ return errorAudio
+}
diff --git a/src/preload/lib/ipc.ts b/src/preload/lib/ipc.ts
new file mode 100644
index 0000000..0248c56
--- /dev/null
+++ b/src/preload/lib/ipc.ts
@@ -0,0 +1,15 @@
+import { ipcRenderer } from "electron"
+import type { IpcResult } from "@/main/types/ipc"
+
+/**
+ * Invokes a `safeHandle`-registered channel and unwraps its `IpcResult`,
+ * throwing when the handler reported a failure.
+ * @param channel The IPC channel to invoke.
+ * @param args The arguments forwarded to the handler.
+ * @returns The value returned by the handler.
+ */
+export async function safeInvoke(channel: string, ...args: unknown[]): Promise {
+ const result = (await ipcRenderer.invoke(channel, ...args)) as IpcResult
+ if (!result.ok) throw new Error(result.error)
+ return result.value
+}
diff --git a/src/preload/lib/lockProgress.ts b/src/preload/lib/lockProgress.ts
new file mode 100644
index 0000000..a52fa68
--- /dev/null
+++ b/src/preload/lib/lockProgress.ts
@@ -0,0 +1,31 @@
+import { randomUUID } from "node:crypto"
+import CONSTANTS from "@main/lib/constants"
+import { ipcRenderer } from "electron"
+import type { LfsLockProgress } from "@/main/types/lfsCommands"
+
+/**
+ * Runs a lock/unlock invocation while forwarding per-file progress to the
+ * caller's `onProgress` callback, scoped to a fresh request id.
+ * @param onProgress The renderer-side progress callback, or `undefined`.
+ * @param invoke Thunk that runs the underlying `safeInvoke` call.
+ * @returns The value resolved by `invoke`.
+ */
+export async function withLockProgress(
+ onProgress: ((done: number, total: number) => void) | undefined,
+ invoke: (requestId: string | null) => Promise,
+): Promise {
+ if (!onProgress) return invoke(null)
+
+ const requestId = randomUUID()
+ const listener = (_: unknown, payload: LfsLockProgress) => {
+ if (payload.requestId === requestId) onProgress(payload.done, payload.total)
+ }
+
+ ipcRenderer.on(CONSTANTS.ipc.lfsCommandsLockProgress, listener)
+
+ try {
+ return await invoke(requestId)
+ } finally {
+ ipcRenderer.off(CONSTANTS.ipc.lfsCommandsLockProgress, listener)
+ }
+}
diff --git a/src/preload/routes/dialogs.ts b/src/preload/routes/dialogs.ts
index 3208afc..b0432a5 100644
--- a/src/preload/routes/dialogs.ts
+++ b/src/preload/routes/dialogs.ts
@@ -1,10 +1,17 @@
import CONSTANTS from "@main/lib/constants"
import type { GitgameApi } from "@preload/index"
+import { getErrorAudio } from "@preload/lib/audio"
import { ipcRenderer } from "electron"
const dialogsApiRoutes: GitgameApi["dialogs"] = {
confirm: options => ipcRenderer.invoke(CONSTANTS.ipc.dialogsConfirm, options),
- error: (title, content) => ipcRenderer.send(CONSTANTS.ipc.dialogsError, title, content),
+ error: (title, message, detail) => {
+ const audio = getErrorAudio()
+ audio.currentTime = 0
+ audio.play().catch(() => null)
+
+ ipcRenderer.send(CONSTANTS.ipc.dialogsError, title, message, detail)
+ },
getOptions: () => ipcRenderer.invoke(CONSTANTS.ipc.dialogsGetOptions),
respond: result => ipcRenderer.send(CONSTANTS.ipc.dialogsRespond, result),
}
diff --git a/src/preload/routes/fileTree.ts b/src/preload/routes/fileTree.ts
index 4ab8504..dfe98ad 100644
--- a/src/preload/routes/fileTree.ts
+++ b/src/preload/routes/fileTree.ts
@@ -1,9 +1,10 @@
import CONSTANTS from "@main/lib/constants"
import type { GitgameApi } from "@preload/index"
-import { ipcRenderer } from "electron"
+import { safeInvoke } from "@preload/lib/ipc"
+import type { FileTreeNode } from "@/main/types/fileTree"
const fileTreeApiRoutes: GitgameApi["fileTree"] = {
- get: dir => ipcRenderer.invoke(CONSTANTS.ipc.fileTreeGet, dir),
+ get: dir => safeInvoke(CONSTANTS.ipc.fileTreeGet, dir),
}
export default fileTreeApiRoutes
diff --git a/src/preload/routes/gitCommands.ts b/src/preload/routes/gitCommands.ts
index 8b51cb9..34702be 100644
--- a/src/preload/routes/gitCommands.ts
+++ b/src/preload/routes/gitCommands.ts
@@ -1,14 +1,15 @@
import CONSTANTS from "@main/lib/constants"
import type { GitgameApi } from "@preload/index"
-import { ipcRenderer } from "electron"
+import { safeInvoke } from "@preload/lib/ipc"
+import type { GitBranch, GitCommit, GitStatus } from "@/main/types/gitCommands"
const gitCommandsApiRoutes: GitgameApi["gitCommands"] = {
- isRepository: dir => ipcRenderer.invoke(CONSTANTS.ipc.gitCommandsIsRepository, dir),
- getRepositoryRoot: dir => ipcRenderer.invoke(CONSTANTS.ipc.gitCommandsGetRepositoryRoot, dir),
- getStatus: dir => ipcRenderer.invoke(CONSTANTS.ipc.gitCommandsGetStatus, dir),
- listBranches: dir => ipcRenderer.invoke(CONSTANTS.ipc.gitCommandsListBranches, dir),
- getLog: (dir, limit) => ipcRenderer.invoke(CONSTANTS.ipc.gitCommandsGetLog, dir, limit),
- getRemoteUrl: dir => ipcRenderer.invoke(CONSTANTS.ipc.gitCommandsGetRemoteUrl, dir),
+ isRepository: dir => safeInvoke(CONSTANTS.ipc.gitCommandsIsRepository, dir),
+ getRepositoryRoot: dir => safeInvoke(CONSTANTS.ipc.gitCommandsGetRepositoryRoot, dir),
+ getStatus: dir => safeInvoke(CONSTANTS.ipc.gitCommandsGetStatus, dir),
+ listBranches: dir => safeInvoke(CONSTANTS.ipc.gitCommandsListBranches, dir),
+ getLog: (dir, limit) => safeInvoke(CONSTANTS.ipc.gitCommandsGetLog, dir, limit),
+ getRemoteUrl: dir => safeInvoke(CONSTANTS.ipc.gitCommandsGetRemoteUrl, dir),
}
export default gitCommandsApiRoutes
diff --git a/src/preload/routes/lfsCommands.ts b/src/preload/routes/lfsCommands.ts
index 1b40e6b..1c6ed23 100644
--- a/src/preload/routes/lfsCommands.ts
+++ b/src/preload/routes/lfsCommands.ts
@@ -1,14 +1,22 @@
import CONSTANTS from "@main/lib/constants"
import type { GitgameApi } from "@preload/index"
-import { ipcRenderer } from "electron"
+import { safeInvoke } from "@preload/lib/ipc"
+import { withLockProgress } from "@preload/lib/lockProgress"
+import type { LfsLock, LfsLockMigration, LfsLockResult } from "@/main/types/lfsCommands"
const lfsCommandsApiRoutes: GitgameApi["lfsCommands"] = {
- listLocks: dir => ipcRenderer.invoke(CONSTANTS.ipc.lfsCommandsListLocks, dir),
- getCachedLocks: dir => ipcRenderer.invoke(CONSTANTS.ipc.lfsCommandsGetCachedLocks, dir),
- getLockableFiles: dir => ipcRenderer.invoke(CONSTANTS.ipc.lfsCommandsGetLockableFiles, dir),
- lockPaths: (dir, paths) => ipcRenderer.invoke(CONSTANTS.ipc.lfsCommandsLockPaths, dir, paths),
- unlockPaths: (dir, paths, force) => ipcRenderer.invoke(CONSTANTS.ipc.lfsCommandsUnlockPaths, dir, paths, force),
- migrateLocks: dir => ipcRenderer.invoke(CONSTANTS.ipc.lfsCommandsMigrateLocks, dir),
+ listLocks: dir => safeInvoke(CONSTANTS.ipc.lfsCommandsListLocks, dir),
+ getCachedLocks: dir => safeInvoke(CONSTANTS.ipc.lfsCommandsGetCachedLocks, dir),
+ getLockableFiles: dir => safeInvoke(CONSTANTS.ipc.lfsCommandsGetLockableFiles, dir),
+ lockPaths: (dir, paths, onProgress) =>
+ withLockProgress(onProgress, requestId =>
+ safeInvoke(CONSTANTS.ipc.lfsCommandsLockPaths, dir, paths, requestId),
+ ),
+ unlockPaths: (dir, paths, force, onProgress) =>
+ withLockProgress(onProgress, requestId =>
+ safeInvoke(CONSTANTS.ipc.lfsCommandsUnlockPaths, dir, paths, force, requestId),
+ ),
+ migrateLocks: dir => safeInvoke(CONSTANTS.ipc.lfsCommandsMigrateLocks, dir),
}
export default lfsCommandsApiRoutes
diff --git a/src/preload/routes/projects.ts b/src/preload/routes/projects.ts
index 99082b9..119ac4f 100644
--- a/src/preload/routes/projects.ts
+++ b/src/preload/routes/projects.ts
@@ -1,15 +1,17 @@
import CONSTANTS from "@main/lib/constants"
import type { GitgameApi } from "@preload/index"
-import { ipcRenderer } from "electron"
+import { safeInvoke } from "@preload/lib/ipc"
+import type { OpenProjectResult, Project } from "@/main/types/projects"
+import type { AppPreferences } from "@/main/types/store"
const projectsApiRoutes: GitgameApi["projects"] = {
- addLocal: () => ipcRenderer.invoke(CONSTANTS.ipc.projectsAddLocal),
- open: dir => ipcRenderer.invoke(CONSTANTS.ipc.projectsOpen, dir),
- getRecent: () => ipcRenderer.invoke(CONSTANTS.ipc.projectsGetRecent),
- removeRecent: dir => ipcRenderer.invoke(CONSTANTS.ipc.projectsRemoveRecent, dir),
- clearRecent: () => ipcRenderer.invoke(CONSTANTS.ipc.projectsClearRecent),
- getPreferences: () => ipcRenderer.invoke(CONSTANTS.ipc.projectsGetPreferences),
- setPreferences: preferences => ipcRenderer.invoke(CONSTANTS.ipc.projectsSetPreferences, preferences),
+ addLocal: () => safeInvoke(CONSTANTS.ipc.projectsAddLocal),
+ open: dir => safeInvoke(CONSTANTS.ipc.projectsOpen, dir),
+ getRecent: () => safeInvoke(CONSTANTS.ipc.projectsGetRecent),
+ removeRecent: dir => safeInvoke(CONSTANTS.ipc.projectsRemoveRecent, dir),
+ clearRecent: () => safeInvoke(CONSTANTS.ipc.projectsClearRecent),
+ getPreferences: () => safeInvoke(CONSTANTS.ipc.projectsGetPreferences),
+ setPreferences: preferences => safeInvoke(CONSTANTS.ipc.projectsSetPreferences, preferences),
}
export default projectsApiRoutes
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 3841547..b22654c 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -77,6 +77,31 @@ function AppShell() {
case "shell:open-external":
window.api.shells.openExternal(action.url)
break
+ case "devtools:test-confirm":
+ window.api.dialogs.confirm({
+ title: "Test confirm",
+ message: "This is a test confirm dialog.",
+ detail: "Use it to preview the Win95 confirm styling from the Dev Tools menu.",
+ confirmLabel: "Sure",
+ cancelLabel: "Nope",
+ })
+ break
+ case "devtools:test-error":
+ window.api.dialogs.error("Test error", "This is a test error message.")
+ break
+ case "devtools:test-error-with-detail":
+ window.api.dialogs.error(
+ "Test error with detail",
+ "5 files could not be updated.",
+ [
+ "Content/Characters/Hero/BP_Hero.uasset: locked by john",
+ "Content/Characters/Hero/SK_Hero.uasset: locked by jane",
+ "Content/Maps/MainMenu.umap: locked by bob",
+ "Content/UI/HUD/WBP_HUD.uasset: locked by alice",
+ "Content/VFX/P_Explosion.uasset",
+ ].join("\n"),
+ )
+ break
}
},
[addLocalProject, openProject, clearRecentProjects],
diff --git a/src/renderer/DialogApp.tsx b/src/renderer/DialogApp.tsx
index 1fc198a..105d7b6 100644
--- a/src/renderer/DialogApp.tsx
+++ b/src/renderer/DialogApp.tsx
@@ -1,7 +1,8 @@
+import errorIcon from "@react95-icons/Hand_32x32_4.png"
import AppRoot from "@renderer/components/layouts/AppRoot"
import MainLayout from "@renderer/components/layouts/main"
import { useCallback, useEffect, useState } from "react"
-import { Button } from "react95"
+import { Button, Frame } from "react95"
import type { DialogOptions } from "@/main/types/dialogs"
import TitleBar from "@/renderer/components/bars/Title"
@@ -22,7 +23,7 @@ export default function DialogApp() {
window.api.dialogs.respond(false)
}, [])
- // Fetch the options for this dialog window once, on mount
+ // Fetch the options for this dialog window once, on mount.
useEffect(() => {
window.api.dialogs.getOptions().then(setOptions)
}, [])
@@ -47,11 +48,27 @@ export default function DialogApp() {
-
-
- {options?.message}
+
+
+ {options?.variant === "error" && (
+ 
+ )}
+
+
+ {options?.message}
- {options?.detail && {options.detail} }
+ {options?.detail && (
+
+ {options.detail}
+
+ )}
+
diff --git a/src/renderer/components/contexts/FileTree.tsx b/src/renderer/components/contexts/FileTree.tsx
index c456b27..52e6d0a 100644
--- a/src/renderer/components/contexts/FileTree.tsx
+++ b/src/renderer/components/contexts/FileTree.tsx
@@ -3,6 +3,7 @@ import { useStatusContext } from "@renderer/components/contexts/Status"
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"
import type { FileTreeNode } from "@/main/types/fileTree"
import type { LfsLock, LfsLockResult } from "@/main/types/lfsCommands"
+import { reportError } from "@/renderer/lib/utils/errors"
import { indexLocks } from "@/renderer/lib/utils/lockStates"
/**
@@ -12,7 +13,6 @@ export type FileTreeContextType = {
fileTree: FileTreeNode[]
locksByPath: Map
isLoading: boolean
- error: Error | null
refresh: () => Promise
lock: (paths: string[]) => Promise
unlock: (paths: string[], force?: boolean) => Promise
@@ -35,7 +35,6 @@ export default function FileTreeProvider({ children }: FileTreeProviderProps) {
const [fileTree, setFileTree] = useState([])
const [locksByPath, setLocksByPath] = useState |