From 0506f44db3951d1851af21c679c0e0b3e1e9249c Mon Sep 17 00:00:00 2001 From: James Griffin Date: Tue, 18 Aug 2026 22:45:14 -0300 Subject: [PATCH] Build the Apple Container extension Turns the empty scaffold into a working Nova extension for Apple Container and container-compose, in the shape of the Docker Suite extension. Sidebar (Scripts/Sidebar): - System, Containers, Images, Volumes and Networks sections - Containers grouped into compose projects via the com.docker.compose.project label container-compose stamps; project rows drive compose up/down/build against the matching workspace file - Lifecycle, shell, logs, inspect, browse and copy commands per resource - Apple Container publishes no event stream, so the sidebar polls while visible and only reloads a section when a fingerprint of its data changes, which keeps selection and expansion intact Hostnames (Scripts/Hostnames.js): - A container's hostname is . and the domain lives in Apple Container's config.toml, which has no CLI setter, so the Hostname Domain preference reads and writes that file directly - Changing it offers to register the domain with macOS and restart the services; the write preserves the file's other tables and its mode Language support: - Dockerfile and Compose syntaxes backed by tree-sitter grammars built by Tools/build-syntaxes.sh, pinned to revisions that generate ABI 14 - The Compose syntax is named dockercompose because that is the languageId docker-language-server recognises compose files by - docker-language-server is downloaded on first use rather than bundled, at roughly 40 MB Icons are generated by Tools/make-icons.py so they can be regenerated rather than hand-maintained. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UnSBcR5Lywz5Fbj5FmVbfc --- CHANGELOG.md | 16 +- Images/extension.png | Bin 0 -> 1444 bytes Images/extension@2x.png | Bin 0 -> 3095 bytes Images/icons/box.png | Bin 0 -> 179 bytes Images/icons/box@2x.png | Bin 0 -> 216 bytes Images/icons/folder.png | Bin 0 -> 133 bytes Images/icons/folder@2x.png | Bin 0 -> 192 bytes Images/icons/layers.png | Bin 0 -> 153 bytes Images/icons/layers@2x.png | Bin 0 -> 207 bytes Images/icons/network.png | Bin 0 -> 254 bytes Images/icons/network@2x.png | Bin 0 -> 435 bytes Images/icons/status/power-off.png | Bin 0 -> 256 bytes Images/icons/status/power-off@2x.png | Bin 0 -> 440 bytes Images/icons/status/power-on.png | Bin 0 -> 256 bytes Images/icons/status/power-on@2x.png | Bin 0 -> 439 bytes Images/icons/status/toggle-off.png | Bin 0 -> 163 bytes Images/icons/status/toggle-off@2x.png | Bin 0 -> 253 bytes Images/icons/status/toggle-on.png | Bin 0 -> 166 bytes Images/icons/status/toggle-on@2x.png | Bin 0 -> 255 bytes Images/icons/status/traffic-off.png | Bin 0 -> 213 bytes Images/icons/status/traffic-off@2x.png | Bin 0 -> 346 bytes Images/icons/status/traffic-on.png | Bin 0 -> 213 bytes Images/icons/status/traffic-on@2x.png | Bin 0 -> 364 bytes Images/icons/status/warning.png | Bin 0 -> 208 bytes Images/icons/status/warning@2x.png | Bin 0 -> 352 bytes Images/icons/system.png | Bin 0 -> 198 bytes Images/icons/system@2x.png | Bin 0 -> 318 bytes Images/icons/tag.png | Bin 0 -> 145 bytes Images/icons/tag@2x.png | Bin 0 -> 227 bytes Images/icons/volume.png | Bin 0 -> 196 bytes Images/icons/volume@2x.png | Bin 0 -> 319 bytes Images/sidebar-large.png | Bin 0 -> 213 bytes Images/sidebar-large@2x.png | Bin 0 -> 379 bytes Images/sidebar-small.png | Bin 0 -> 176 bytes Images/sidebar-small@2x.png | Bin 0 -> 213 bytes LICENSE.md | 21 + Queries/compose/folds.scm | 3 + Queries/compose/highlights.scm | 70 ++ Queries/dockerfile/folds.scm | 28 + Queries/dockerfile/highlights.scm | 87 ++ Queries/dockerfile/injections.scm | 11 + README.md | 176 ++++ Scripts/Commands.js | 248 ++++++ Scripts/Hostnames.js | 155 ++++ Scripts/Interfaces/ComposeCLI.js | 137 ++++ Scripts/Interfaces/ContainerCLI.js | 251 ++++++ Scripts/Interfaces/Terminal.js | 61 ++ .../LanguageClients/DockerLanguageClient.js | 183 +++++ Scripts/Sidebar/ContainerSidebar.js | 166 ++++ Scripts/Sidebar/Models/Container.js | 211 +++++ Scripts/Sidebar/Models/Element.js | 30 + Scripts/Sidebar/Models/Image.js | 119 +++ Scripts/Sidebar/Models/Network.js | 60 ++ Scripts/Sidebar/Models/Volume.js | 55 ++ .../Sidebar/TreeItems/ContainerTreeItem.js | 45 + Scripts/Sidebar/TreeItems/ImageTreeItem.js | 38 + Scripts/Sidebar/TreeItems/NetworkTreeItem.js | 15 + Scripts/Sidebar/TreeItems/ProjectTreeItem.js | 18 + Scripts/Sidebar/TreeItems/StatusIcon.js | 16 + Scripts/Sidebar/TreeItems/SystemTreeItem.js | 15 + Scripts/Sidebar/TreeItems/VolumeTreeItem.js | 17 + Scripts/Sidebar/TreeViews/Containers.js | 388 +++++++++ Scripts/Sidebar/TreeViews/Controller.js | 151 ++++ Scripts/Sidebar/TreeViews/Images.js | 198 +++++ Scripts/Sidebar/TreeViews/Networks.js | 114 +++ Scripts/Sidebar/TreeViews/System.js | 221 +++++ Scripts/Sidebar/TreeViews/Volumes.js | 116 +++ Scripts/Utilities/Format.js | 107 +++ Scripts/Utilities/Notify.js | 53 ++ Scripts/Utilities/Process.js | 89 ++ Scripts/Utilities/Prompt.js | 34 + Scripts/Utilities/SystemConfig.js | 142 ++++ Scripts/main.js | 73 ++ Syntaxes/Compose.xml | 50 ++ Syntaxes/Dockerfile.xml | 44 + Syntaxes/libtree-sitter-compose.dylib | Bin 0 -> 430208 bytes Syntaxes/libtree-sitter-dockerfile.dylib | Bin 0 -> 150208 bytes Tools/build-syntaxes.sh | 78 ++ Tools/make-icons.py | 322 ++++++++ extension.json | 772 +++++++++++++++++- 80 files changed, 5201 insertions(+), 3 deletions(-) create mode 100644 Images/extension.png create mode 100644 Images/extension@2x.png create mode 100644 Images/icons/box.png create mode 100644 Images/icons/box@2x.png create mode 100644 Images/icons/folder.png create mode 100644 Images/icons/folder@2x.png create mode 100644 Images/icons/layers.png create mode 100644 Images/icons/layers@2x.png create mode 100644 Images/icons/network.png create mode 100644 Images/icons/network@2x.png create mode 100644 Images/icons/status/power-off.png create mode 100644 Images/icons/status/power-off@2x.png create mode 100644 Images/icons/status/power-on.png create mode 100644 Images/icons/status/power-on@2x.png create mode 100644 Images/icons/status/toggle-off.png create mode 100644 Images/icons/status/toggle-off@2x.png create mode 100644 Images/icons/status/toggle-on.png create mode 100644 Images/icons/status/toggle-on@2x.png create mode 100644 Images/icons/status/traffic-off.png create mode 100644 Images/icons/status/traffic-off@2x.png create mode 100644 Images/icons/status/traffic-on.png create mode 100644 Images/icons/status/traffic-on@2x.png create mode 100644 Images/icons/status/warning.png create mode 100644 Images/icons/status/warning@2x.png create mode 100644 Images/icons/system.png create mode 100644 Images/icons/system@2x.png create mode 100644 Images/icons/tag.png create mode 100644 Images/icons/tag@2x.png create mode 100644 Images/icons/volume.png create mode 100644 Images/icons/volume@2x.png create mode 100644 Images/sidebar-large.png create mode 100644 Images/sidebar-large@2x.png create mode 100644 Images/sidebar-small.png create mode 100644 Images/sidebar-small@2x.png create mode 100644 LICENSE.md create mode 100644 Queries/compose/folds.scm create mode 100644 Queries/compose/highlights.scm create mode 100644 Queries/dockerfile/folds.scm create mode 100644 Queries/dockerfile/highlights.scm create mode 100644 Queries/dockerfile/injections.scm create mode 100644 Scripts/Commands.js create mode 100644 Scripts/Hostnames.js create mode 100644 Scripts/Interfaces/ComposeCLI.js create mode 100644 Scripts/Interfaces/ContainerCLI.js create mode 100644 Scripts/Interfaces/Terminal.js create mode 100644 Scripts/LanguageClients/DockerLanguageClient.js create mode 100644 Scripts/Sidebar/ContainerSidebar.js create mode 100644 Scripts/Sidebar/Models/Container.js create mode 100644 Scripts/Sidebar/Models/Element.js create mode 100644 Scripts/Sidebar/Models/Image.js create mode 100644 Scripts/Sidebar/Models/Network.js create mode 100644 Scripts/Sidebar/Models/Volume.js create mode 100644 Scripts/Sidebar/TreeItems/ContainerTreeItem.js create mode 100644 Scripts/Sidebar/TreeItems/ImageTreeItem.js create mode 100644 Scripts/Sidebar/TreeItems/NetworkTreeItem.js create mode 100644 Scripts/Sidebar/TreeItems/ProjectTreeItem.js create mode 100644 Scripts/Sidebar/TreeItems/StatusIcon.js create mode 100644 Scripts/Sidebar/TreeItems/SystemTreeItem.js create mode 100644 Scripts/Sidebar/TreeItems/VolumeTreeItem.js create mode 100644 Scripts/Sidebar/TreeViews/Containers.js create mode 100644 Scripts/Sidebar/TreeViews/Controller.js create mode 100644 Scripts/Sidebar/TreeViews/Images.js create mode 100644 Scripts/Sidebar/TreeViews/Networks.js create mode 100644 Scripts/Sidebar/TreeViews/System.js create mode 100644 Scripts/Sidebar/TreeViews/Volumes.js create mode 100644 Scripts/Utilities/Format.js create mode 100644 Scripts/Utilities/Notify.js create mode 100644 Scripts/Utilities/Process.js create mode 100644 Scripts/Utilities/Prompt.js create mode 100644 Scripts/Utilities/SystemConfig.js create mode 100644 Scripts/main.js create mode 100644 Syntaxes/Compose.xml create mode 100644 Syntaxes/Dockerfile.xml create mode 100755 Syntaxes/libtree-sitter-compose.dylib create mode 100755 Syntaxes/libtree-sitter-dockerfile.dylib create mode 100755 Tools/build-syntaxes.sh create mode 100644 Tools/make-icons.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cc688d6..ecf4083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ ## Version 1.0 -Initial release +Initial release. + +- Sidebar with System, Containers, Images, Volumes and Networks sections +- Containers grouped into compose projects, with compose up, down and build +- Start, stop, restart, kill, delete, shell, logs, inspect and browse for containers +- Run, pull, push, tag, inspect, delete and prune for images +- Create, inspect, reveal, delete and prune for volumes and networks +- Container services and builder control, disk usage and local DNS domains +- Hostname Domain preference: writes Apple Container's DNS domain and registers + it with macOS +- Open a container's published port in the browser, with a picker when several + ports are published +- Extension commands for compose, image builds and pulls, and system control +- Dockerfile and Compose syntax highlighting and folding, via bundled tree-sitter grammars +- Dockerfile and Compose language features via docker-language-server diff --git a/Images/extension.png b/Images/extension.png new file mode 100644 index 0000000000000000000000000000000000000000..91f26f57d3f657c4ad516559b8d63fdeceb41a35 GIT binary patch literal 1444 zcmcgq{XY|U9RJR&c}QL!a+pjwcGEZgmOHRwk9@Uy;GD=v6k8*9+V)J$QO=7>0_nj)9WIt$wIgQrY7 z>r2dD6T5WHLZ7=NK9o3o^&P1>}#Qjg!>zI!-A0zm33E|YqQQw0p9HHjn!s)skYo7j0owRPKh z)L2ZgIyjVCn`yNM7Uf*9Q&0eeNRYhOhy-8}w)c}rK!&^8D9XAnzba4q>Xo!HwI%HS z)n7jfOEJt#r%5W2tILy(ch}pv!Fk5m%7#=3ud$+MmVr!pNVBZ!&nq1VQ^}tQNn5Ms z=w?b@si{Do%;BG~Uoo3SeK~nie?ulz%8VV_J|}WXqEg|y#WW`D+F*fE0h`q~-nhev z>D+CN+@>+9Ho=pGfuG}z;tJ5MRd7^pzZNWEXeE)VCVHYtQxGWnRL>JLkG9-k{;l>h^nl@ zb-(cP;CBRuQ?Wp0gcXrh95K1zsg3m^fu{?H;7b;`=ATw~~)s%n(VmzcfVR-KbqJauE=3c6f&2C-ZtYjILyghnjm`aOcar{{?Q+eJALFwIH#vEl+PPX&qXx_ zt>n+NZyfmCF;VMH9c*Ak3%J~$R$F`H>9(@uvI+~UNa6w4<(T7t3aK9MMDM>~Q$7a3 zB9$`sNrX&NF1N<-#ynPQ@vo2kZ;-JVAsU>FAOvF$;O8O38b!Tyxq;lRmyj88ep*;g zKT=m*u~Ro3?3d6Z$g)A0g4>s9r*Kn~BKZqVWsZFze#f+H0<8PRP$L9K&lg_iQI1|A zekXLAc&D;xy`6NXmj)e|V$iE)7T^%`f}r>zU*#$Dke#wY%9yFXrdiWj@^lA+su@rA zwHLhYcvEKzuT|m8J;J*WT*;BS4;{N>B420WPW_m3?mjdcdfkG_&BEweD#TdoZH9NL zKSR@W0s=j|VdX6mzhwD1d=>vdAL2WfxUmgY-yEOzOvIz3|8{P)WI&kikg_Xbl0Grg;hd0Gl^shIbI{uA(Pg~a35gSul>V7# gdkLp(>?OR$Y%z6{rSU;VLEk3peA?Ba(LNycUxKTFasU7T literal 0 HcmV?d00001 diff --git a/Images/extension@2x.png b/Images/extension@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fd439c5e70e5b81cc22639748b9d8c886a3f3e75 GIT binary patch literal 3095 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 G*Z%>=x*kCQ literal 0 HcmV?d00001 diff --git a/Images/icons/box.png b/Images/icons/box.png new file mode 100644 index 0000000000000000000000000000000000000000..4decc789d2b6e62df0ff84d1b57c1754c6bef04d GIT binary patch literal 179 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Rh}-6Ar-fh6C~ssRzI~j7C6uL z=&wuCgs8L>=0lA?>;>kxYyIFma=h_}orrvU*W310j4OHF%@pLcGWef1o1A1Wv=wQ- z@qJ;@w}7o57qeYB(eN{X_kH6IdChyj7bblTFqL5QVE&lAg2hl`Ntl$a=_cp7UOZ{? cHa|HSR4-^8DAU@&4Ri&Ar>mdKI;Vst03}yJ0{{R3 literal 0 HcmV?d00001 diff --git a/Images/icons/box@2x.png b/Images/icons/box@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ed84a846fa3dc0a0a39f85685add8de6f5f11051 GIT binary patch literal 216 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJ`JOJ0Ar-fh6C_v{Cx|RyTsQ6i zABPy8BcE6HIHr9P-V`I%A)nK! zSGIl(cm8?);ukr$pxyH$*pED(Fxg#*d9~}K)5)7zQm(CFc9S)D)ey+uW0i1)@rYG| zx$LUPcYnxC@MULDd0fn3EO36=!lw$GXLe?acQJ3Yox1TyMt8Lcj~4^O8=)P3vuEV! Q1D(p?>FVdQ&MBb@0PinVX#fBK literal 0 HcmV?d00001 diff --git a/Images/icons/folder.png b/Images/icons/folder.png new file mode 100644 index 0000000000000000000000000000000000000000..eeddc51671d41cc5efde662691cd4e7b143e7447 GIT binary patch literal 133 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`KAtX)Ar-fh6C~sotj{kp6nKBU zNnAMlK%Tjl3zt)rPYv5*s9MG8AV--SAhr g*E@-?&5VJ8=YE7l|Hfg$~ROiRXx)_vxUriL3AG9Q`EdBlU?j`M_#_N-lcN$F9VSjl3iG{SeYp`mGd1GLotZ0O^N~aNSa}|o)iO$u^3QHiUIsUd>ctKh|N!` z0k460J`}$u)qv?hd>@F}p%}!TPON5QXsG)miTOjtjB#mV24Zg@jt1h>K)fD{n)Oh* zXsB9dWXa8FA?}UE0BqU<@E zL^u{Ngrli>4v$(OLmr4{L&G8*PYi`a)qvE>Qzh2|0Dja3&?76du>b%707*qoM6N<$ Ef+S*RNB{r; literal 0 HcmV?d00001 diff --git a/Images/icons/network@2x.png b/Images/icons/network@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..dca11c667f36f8605c4c77de95ab9fb440c2a7aa GIT binary patch literal 435 zcmV;k0ZjghP)uwN<>Em69E;^J43ZP4r32>Uu^fiy)5>yY zApT5*13+q+Y2g5QA}ohtd0IGtp9BZ+(<&Mc6JhybS|&ngA{^jM>#SIg$MSM|6hzC> z3LoS`X*sPeM~;HsNaDL`9tF^H;5yWj??9}MB&QA)`wvw^lX9RM$(PMoH1mowlfO6i4nBRgO+$-#y@{)vsw$;g_liEQif6X5`UBHNDE=o+UJ z(2z_*E=VR|)^uF8T{BW40>ke~u^i12@6pWq4z+`sQ0F5Yh|{6PFgFFoI5*Ucbf}r) d45I)I002IAnLhHWG`j!*002ovPDHLkV1ib=!4?1j literal 0 HcmV?d00001 diff --git a/Images/icons/status/power-off.png b/Images/icons/status/power-off.png new file mode 100644 index 0000000000000000000000000000000000000000..496a8043d766aae940853ccc4c1463c2e5d3dce3 GIT binary patch literal 256 zcmV+b0ssDqP)7@Xs$o5n7)bGZB>sA+8h>mCq(dd^k;K$N z7>QpGRg;d*fN-efbUa}+9jYcAo1`?7AH}iAiz9`RG%g!vLv6T@9PiL{aUCi*8(%bn zV&X7T^gV`R82>QV7{HSL%YpbdlE&LmF=kSeG&eyTDFp!7+1x>D$7ad^0000Y*#^OLJ@VRaKVA@B6`uos2=gui>Dtu z2{0m#sX>p4CcuFi9RdA&U4R_on%aZ3_?Q`70k2Kl5V@o=J;ss%YbJh|&H>agkF_Mg z?p}KMZ*T1H8=T@!J7y6*3nv0>2$5yK$2@ZFn5{h8xKfR4AfK2xR1^=H$4Mg2m^e`c zNSH?^5zoB(*gi*Q(&H7@Xs$o5n7)bGZB>sA+8h>mCq(dd^k;K$N z7>QpGRg;d*fN-efbUa}+9jYcAo1`?7AH}iAiz9`RG%g!vLv6T@9PiL{aUCi*8(%bn zV&X7T^gV`R82>QV7{HSL%YpbdlE&LmF=kSeG&eyTDFpxnGP)trE4Hct0000fQpcJ5uh*v6#r3h z0QdrYxB}oFaRzM{Ujj-hwyFuRqTk3}wpBbe3N;OIL+V?c8%hKG@ioOK)MBRD4`5Ui zU_=~KgB}x2fITxh0Q&d306D}pwR>stF*CRTUYoWda!F%)j3oiqO#CUG1E^shYe|6Z zz4Y+k&e-2IIK{1Y%p!UgP6XHxBFlb{dF0wLTY0o`r5e{jJ~DBrC>}D8qePrBaiR#2 zFpo?ko_qDNeU8kf$5%3gnLs~d+EV-&>FwqtHEDSCe1vZ_Z*ZS?0|D>*p@l!BJ)C%`x1>V4$G hR(&Ic!E-(8`UONkbcGYQkGKE;002ovPDHLkV1lWixDfyV literal 0 HcmV?d00001 diff --git a/Images/icons/status/toggle-off.png b/Images/icons/status/toggle-off.png new file mode 100644 index 0000000000000000000000000000000000000000..2e10b73a217ae6ac3631bed471741d1a1e337397 GIT binary patch literal 163 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`S)MMAAr-fh6C_v*E?hUCDckY5 zamPFcwv~zQ*LCbtPn~(K?l4MkgR@{MO2Rn+vBmPL@)E)_3Up8?^0(Y6NQvpXSgs5|-r!Yv%b0 zb$zB95qr&Zbcrjq*1{e*Ml^v*KQ9Q*_d`1kzF#qNQCFn}?-VVOy00000NkvXXu0mjf D&xvX4 literal 0 HcmV?d00001 diff --git a/Images/icons/status/toggle-on.png b/Images/icons/status/toggle-on.png new file mode 100644 index 0000000000000000000000000000000000000000..a03fd5d0a24fba0390c42d955eeab7c46486ad44 GIT binary patch literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`xt=bLAr-fh6C_v*5{hgl$#y($ z+%b=VZDpeSbsfGV*BgJ>o8?p+J^a7LUHkw1KLs!U&pH13-|hKcOe;m*%@S<&&MMS= z`P=&c#Dg=(S1Ww{dV=lGv89#|Vh+~lD7;EKn!ifQVWYSPw@cUJ!WL$RN)_I9>Mlx~ QfEF`&y85}Sb4q9e0EpN^c>n+a literal 0 HcmV?d00001 diff --git a/Images/icons/status/toggle-on@2x.png b/Images/icons/status/toggle-on@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..001c6dfaae3d93db18aa6f9e95717c7a0422d7ec GIT binary patch literal 255 zcmV6uELW$*=OD|!{`vkN7XZnDLI!Gw zIyMK)r^NTze2xwteSiMn{CG4-{d{Z=cutA$i3OQgTS0oBqg&2TiSHZkOefj_{STJF z^zjcK2Ml-=43}1CrE5rus(m2rGUEg zMoUep-o_s`0{`6)D;Opxnbf~~KmSwxwKYHgFZY?*nRg<6PuqchJ%Vm(!AWu*{!-u*+dqV#UEG)&CnSj?2tuJZZq{J$)TZ_Prg2r~i9SUY2aEAi17V zf#(tj7q`x=LaBs4XT!oc2R;|3A2tUwjx7FZyfL$JPm9acB|ha047TdB0fN<=41w-s N@O1TaS?83{1OSqhRy_a! literal 0 HcmV?d00001 diff --git a/Images/icons/status/traffic-off@2x.png b/Images/icons/status/traffic-off@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..783ff5a5700269a4748c25653f3330f0fd514de6 GIT binary patch literal 346 zcmV-g0j2(lP)?u9maMf5ytiTqVYn8peq{qh}1CrE5L@T6jirGUEg zMoUep-o_s`0{`6)D;OpxnbezE%YD87JLg<|d$8w=wwhzv9A}=LS*`njb-wSv_{!o- z_0KP_l+Zi)Jbi=Ggft00+xSoN-Q{OCN>1Pj7N}pzwr1xOxA*n4Cv)E5@i>;vz!5#a z=RZFex6Z6Wsf0df!@@WRJ{P7RHU~3~EdFV{F|%<`i_6p{KIIGy{te$Y?Pz%V7U)g} MPgg&ebxsLQ0LY$If&c&j literal 0 HcmV?d00001 diff --git a/Images/icons/status/traffic-on@2x.png b/Images/icons/status/traffic-on@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c8b566a49faaa7979399002f0bc0791efac10c60 GIT binary patch literal 364 zcmV-y0h9iTP)k<38s55$v!_&5;XhvMTSSzp8y7_Nv5X7+(5jY2#e7% zNDntPEWb{MqM~}hEuOJ{wFeFGLzx}e`EtJPQ+lf9E3rk zLS%aIC&dAiku{qFamCGvSRGJwy_;AEOeVzv$I%T)xKxYP0pS8K@6K@~7)$xEgNCridY|u1HN5Lqd6#xM1xu*`(o^93u0000< KMNUMnLSTX)yq^OA literal 0 HcmV?d00001 diff --git a/Images/icons/status/warning.png b/Images/icons/status/warning.png new file mode 100644 index 0000000000000000000000000000000000000000..7d7c6f22fd49f17f0822d1cae3dc56a277457fb8 GIT binary patch literal 208 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`(>+}rLn>}1CrE60@V`c{Si!F? zsW`|)iuq%u!oU7D$E0INGXDLkXFUJ^zjo;V|M5zVc_-5Mv>nL3_wMKa|BXNX|Nnmc z-~atSY=>2U{NV{?xx{n$HP8R#kA0SA%#$8+nca3WdC&R(fBi-dApfH@!=dh$|ILS) zf{p!{&lGB?o#WPM?2wo!VjwwYT3Mb9#E*gaKLHFTZYg6#nrYIk*5d7suF zPjfJs#8mUc{LIuFJSWu-HRR+h%;i6N;+!4NkzWBd5@)Rqz8>PakZ~w&vfU)rUlv#H xEbC&NQrY;(VM&xpkg{Unv?-eBe4eDGGu*T4^-%rtU_Q`c44$rjF6*2UngD#sPX_=1 literal 0 HcmV?d00001 diff --git a/Images/icons/system@2x.png b/Images/icons/system@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..97b65114233d45d26c58cebfa8a15a469f16ad5c GIT binary patch literal 318 zcmV-E0m1%>P)7hlEXB0wWNX8Iu)s&Y-H?Wh!TJh?xH^S zvFr)s9|*Wh(3$At+awbH)l^%7JA^@uOZwQ7A($URTIEvT(NZi>OMkT_Dsstq8zc>2 QN&o-=07*qoM6N<$g4Yd#cK`qY literal 0 HcmV?d00001 diff --git a/Images/icons/tag.png b/Images/icons/tag.png new file mode 100644 index 0000000000000000000000000000000000000000..e55e73e51b6065c67eb0f65d0aa96bd37a7019eb GIT binary patch literal 145 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`5uPrNAr-fh6BdYh?EErcvSV+f z#XJVKm5IleRhkR@R~LHkX2_EDs$OWL`6kAX9zr(*#J36?D9BA`JQHA;TEv^to;A;Z t9pjNlfy@_p7FZ|-PMe~6&Swe(!^djt<4JJ~)PZ&|c)I$ztaD0e0su!1FYy2X literal 0 HcmV?d00001 diff --git a/Images/icons/tag@2x.png b/Images/icons/tag@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ca974c35d55820b1e696fd21ce7c928c4bc40909 GIT binary patch literal 227 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJRh}-6Ar-fh6C_xh1((G9I?sRP zsly)Gj_<)8+nvuoQomMIs!+#s3@^u_vlxJoud!WyCuC=YKLXT^#fvxO?zGZEa z?N+laAK06mySvctzW;j{k6fEq4T0=ERtZ-an{P8R-&2tMpmy_snHT$>NIMNHwZ#qh a7#Lpie0-;L=shFQ)eN4lelF{r5}E+g6j}iQ literal 0 HcmV?d00001 diff --git a/Images/icons/volume.png b/Images/icons/volume.png new file mode 100644 index 0000000000000000000000000000000000000000..ef5ac70cc747acbc9d7f6187e09740a329668274 GIT binary patch literal 196 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`-JULvAr-fh6Be-ku%GWO)v>qH zLaxg(Co{=p7W2oK|36p$`yaph|NsAD>>O+x9g`9)^@?L={cQYUEb#w-{i;X*|DP5P zF!bP2VGlgqf0m(gMbrQP<*fhy*Dn-aD0zgf^Wc-ltdG(RhrLx4Bx2`eFc&-yWX?!s uP*Qw&{D!blR?xAPlI20mywa4|85n~7Y6C1Te7Om97K5j&pUXO@geCymE==+O literal 0 HcmV?d00001 diff --git a/Images/icons/volume@2x.png b/Images/icons/volume@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..20854961f38e318ecabe671f519cba30ecb5e6bc GIT binary patch literal 319 zcmV-F0l@x=P)gz#bz`k7N<=0FYYDG+|Gf1LpJm z|G&xR|NmnJ*h2#(29g7*0jZ_F1Ca0nzW@K1%l-eqMjL`bY>*iKTtP(#kcUSdFzSF& z2aGyk)B&RouqVGkLLF>QS(A)1e3|6NDb_|QdH6Kh&GZ3nmJf=K$ta))000W&gz`0_ R3Pu0`002ovPDHLkV1gd+h1viB literal 0 HcmV?d00001 diff --git a/Images/sidebar-large.png b/Images/sidebar-large.png new file mode 100644 index 0000000000000000000000000000000000000000..a3db86299705be1dfa3cc795150b0a1a58e67e91 GIT binary patch literal 213 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJIi4<#Ar-fh6C_v{Cx|R){Qv*| zXXZjff%D6a*k+#eKT(vaQ1@qk8AHl86FYXF|Nhkmvh$h0DQnvFh_#$w_-OUSBMA)0 z;(4FiyI-!B@O)dBBrDMG>?h~R!!zN>ryDwqGfiJL1hV&7C0t=#%qLOGu#xYEZ~LnX zb368w%Lf^6{*XDqb7XU7hUJNzw4__Dj~sIRQ+4caJ(6Q>7GhwiV`II2_a>_*(47pP Lu6{1-oD!M<{%lWB literal 0 HcmV?d00001 diff --git a/Images/sidebar-large@2x.png b/Images/sidebar-large@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..96ca8fc95058968c05a47a47cd6254ff08e39094 GIT binary patch literal 379 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEV07|yaSW-r^>&tG-ysK)w&RKd z9DD~9zcFZA9*|?`e8Rwy!panRbFo4dLtlf7LRgqWTKr_evnlcId)}(eJ^5XAzvBAK z-|ZHN8lPhX8p^<+^5Nck75Pc$XD)l6`&GU$Z~yn7o451r zpHd^PuwZ^-rr?IpPd9Al*de0wAW86oM)zHz^^1P&@>*HVlO^Zn`-dS<==(lRTk~M9 z9ct^W_kR97`TYClS-)r39}a$WFPuezfsw<3fk~i&0S8lp<;tm~*o-*uJ&XT@yozg{ zH9hF_pVZ~$Z|=>#RGceVppjAhB|&gQ=f0&eFVk;C@Oxfi`Gd^F)wU7B@p~61?qsJE}{a%>mpdfRzp<=}X+bR5)RQQj44d6+- z-+t<*^VMVhQVz<@&zw#7us^C>Qt`gM>#FnB1PKM+BmMt{`5m&BUJO>YzOwR2!ncEy b`x$OYe_}Yh@6?h~R!!zN>ryDwqGfiJL1hV&7C0t=#%qLOGu#xYEZ~LnX zb368w%Lf^6{*XDqb7XU7hUJNzw4__Dj~sIRQ+4caJ(6Q>7GhwiV`II2_a>_*(47pP Lu6{1-oD!M<{%lWB literal 0 HcmV?d00001 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..9dac201 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 James Griffin, Unsupervised + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Queries/compose/folds.scm b/Queries/compose/folds.scm new file mode 100644 index 0000000..ec48f05 --- /dev/null +++ b/Queries/compose/folds.scm @@ -0,0 +1,3 @@ +(block_mapping_pair + ":" @start +) @end.after diff --git a/Queries/compose/highlights.scm b/Queries/compose/highlights.scm new file mode 100644 index 0000000..f82c9f9 --- /dev/null +++ b/Queries/compose/highlights.scm @@ -0,0 +1,70 @@ +; Compose files are YAML, with the sections and service names picked out. + +(comment) @comment + +; Top-level sections: services, volumes, networks, name, include, … + +(document + (block_node + (block_mapping + (block_mapping_pair + key: (flow_node) @keyword)))) + +; Service, volume, network and secret names + +((block_mapping_pair + key: (flow_node) @_section + value: (block_node + (block_mapping + (block_mapping_pair + key: (flow_node) @identifier.type)))) + (#match? @_section "^(services|volumes|networks|configs|secrets)$")) + +; Everything else that is a key + +(block_mapping_pair + key: (_) @identifier.key) + +(flow_pair + key: (_) @identifier.key) + +; Scalars + +((string_scalar) @string + (#not-match? @string "(?i)^(yes|no|true|false|on|off)$")) + +((string_scalar) @value.boolean + (#match? @value.boolean "(?i)^(yes|no|true|false|on|off)$")) + +(double_quote_scalar) @string +(single_quote_scalar) @string +(boolean_scalar) @value.boolean +(integer_scalar) @value.number +(float_scalar) @value.number +(null_scalar) @value.null +(block_scalar) @string + +(anchor_name) @identifier.constant +(alias_name) @identifier.constant +(tag) @identifier.type +(escape_sequence) @string.escape + +[ + "---" + "..." + "-" + "?" + ":" + "|" + ">" + "*" + "&" + "," +] @operator + +[ + "[" + "]" + "{" + "}" +] @bracket diff --git a/Queries/dockerfile/folds.scm b/Queries/dockerfile/folds.scm new file mode 100644 index 0000000..0ec7dbf --- /dev/null +++ b/Queries/dockerfile/folds.scm @@ -0,0 +1,28 @@ +; Instructions fold across their line continuations. + +((run_instruction) @subtree + (#set! scope.byLine)) + +((cmd_instruction) @subtree + (#set! scope.byLine)) + +((entrypoint_instruction) @subtree + (#set! scope.byLine)) + +((copy_instruction) @subtree + (#set! scope.byLine)) + +((add_instruction) @subtree + (#set! scope.byLine)) + +((env_instruction) @subtree + (#set! scope.byLine)) + +((label_instruction) @subtree + (#set! scope.byLine)) + +((healthcheck_instruction) @subtree + (#set! scope.byLine)) + +((heredoc_block) @subtree + (#set! scope.byLine)) diff --git a/Queries/dockerfile/highlights.scm b/Queries/dockerfile/highlights.scm new file mode 100644 index 0000000..1efadbf --- /dev/null +++ b/Queries/dockerfile/highlights.scm @@ -0,0 +1,87 @@ +; Instructions + +[ + "ADD" + "ARG" + "AS" + "CMD" + "COPY" + "CROSS_BUILD" + "ENTRYPOINT" + "ENV" + "EXPOSE" + "FROM" + "HEALTHCHECK" + "LABEL" + "MAINTAINER" + "ONBUILD" + "RUN" + "SHELL" + "STOPSIGNAL" + "USER" + "VOLUME" + "WORKDIR" +] @keyword + +"NONE" @value.null + +; Images + +(image_name) @identifier.type +(image_tag) @identifier.constant +(image_digest) @identifier.constant +(image_alias) @identifier.type.declare + +; Flags such as --from=builder and --mount=type=cache,target=/root/.cache + +(param) @keyword.modifier +(mount_param) @keyword.modifier +(mount_param_param) @keyword.modifier + +; Keys and values + +(env_pair + name: (_) @identifier.key) + +(label_pair + key: (_) @identifier.key) + +(expose_port) @value.number + +; Variables: $HOME and ${HOME:-/root} + +(expansion) @identifier.variable +(variable) @identifier.variable + +; Strings + +(double_quoted_string) @string +(single_quoted_string) @string +(json_string) @string +(escape_sequence) @string.escape + +(heredoc_line) @string + +[ + (heredoc_marker) + (heredoc_end) +] @operator + +(line_continuation) @operator + +[ + ":" + "@" + "=" +] @operator + +[ + "[" + "]" + "{" + "}" +] @bracket + +"," @punctuation.delimiter + +(comment) @comment diff --git a/Queries/dockerfile/injections.scm b/Queries/dockerfile/injections.scm new file mode 100644 index 0000000..ec710e2 --- /dev/null +++ b/Queries/dockerfile/injections.scm @@ -0,0 +1,11 @@ +; Shell fragments in RUN, CMD and ENTRYPOINT are shell script. Nova's built-in +; shell syntax picks them up when it is available; when it is not, the fragment +; is simply left unhighlighted. + +((shell_fragment) @injection.content + (#set! injection.language "shell") + (#set! injection.combined)) + +((heredoc_block) @injection.content + (#set! injection.language "shell") + (#set! injection.combined)) diff --git a/README.md b/README.md index e69de29..099e9a8 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,176 @@ +# Apple Container for Nova + +Manage [Apple Container](https://github.com/apple/container) and +[container-compose](https://github.com/mcrich23/container-compose) from Nova: +a sidebar for the things you run, commands for the things you do, and language +support for the files you write. + +Built for people whose local development runs on `container` rather than +Docker, and modelled on the shape of Jonathan Coole's Docker Suite for Nova. + +## Requirements + +- macOS with [Apple Container](https://github.com/apple/container) installed + (`container` on your `PATH`, or point at it in the extension's preferences) +- [container-compose](https://github.com/mcrich23/container-compose) for the + compose commands — optional, everything else works without it +- Nova 11 or later + +## The sidebar + +**System** — the container services and the builder, with their state, plus +disk usage, the hostname domain and the DNS domains macOS resolves. Start, stop +and restart the services from here; when they are not running, the rest of the +sidebar stays out of your way until they are. + +**Containers** — grouped into compose projects using the +`com.docker.compose.project` label that container-compose stamps on the +containers it creates. Containers created any other way collect in an ungrouped +bucket. Start, stop, restart, kill and delete them; open a shell or follow the +logs in your terminal; read logs or `inspect` output in a Nova tab; open a +published port in your browser; copy a name, address or URL. Containers +publishing more than one port ask which to open, and the list includes the +container's DNS name and IP as well. + +Select a compose project to run `container-compose up`, `down` or `build` +against the compose file it came from, or to open that file. + +**Images** — grouped by repository, with sizes and ages. Run, pull, push, tag, +inspect and delete, plus prune for the whole section. Apple Container's own +builder and init images are hidden unless you ask for them. + +**Volumes** — inspect, reveal the backing disk image in Finder, create, delete +and prune. + +**Networks** — subnets and gateways, create, inspect, delete and prune. The +built-in `default` network is protected from deletion. + +Apple Container publishes no event stream, so the sidebar polls while it is +visible. The interval is configurable, and a section only reloads when +something actually changed, so your selection survives. + +## Hostnames and DNS + +Apple Container gives a container the hostname `.`, where the +domain comes from its own `config.toml`, and macOS only resolves that domain +once a resolver entry exists for it. Those are two separate steps, and the +extension treats them as one. + +Set **Hostname Domain** in the extension's preferences. Changing it writes the +domain to +`~/Library/Application Support/com.apple.container/config/config.toml`, offers +to register it with macOS through `sudo container system dns create` in your +terminal if it is not registered already, and offers to restart the services so +the change takes hold. After that, `mysql.test` resolves from your Mac and from +other containers. Clearing the field goes back to unqualified hostnames. + +The setting starts out showing whatever `config.toml` already says, so it +reflects reality rather than overwriting it. + +The System section shows both halves: a **Hostname Domain** row for the domain +containers are named under — flagged when macOS is not resolving it yet — and a +row per registered domain, which can be unregistered from its context menu. +**Register DNS Domain with macOS…** handles the resolver half on its own, and +**Open System Configuration** opens `config.toml` in Nova when you would rather +edit it by hand. + +There is no per-container hostname to set: the hostname is the container's name +plus the domain, so name the container (or the compose service) accordingly. + +## Commands + +Under **Extensions → Apple Container**: + +| Command | What it does | +| --- | --- | +| Reload Sidebar | Refetch everything | +| Start / Stop Container Services | `container system start` / `stop` | +| Show System Status / Logs | Status in a tab, logs followed in your terminal | +| Register DNS Domain with macOS… | Adds the resolver entry, via sudo in your terminal | +| Open System Configuration | Opens Apple Container's `config.toml` | +| Compose Up / Down / Build | Runs against the workspace's compose file | +| Build Image from Editor… | `container build` using the Dockerfile in the front editor | +| Pull Image… / Run Image… | Prompts for a reference and runs it in your terminal | +| Restart Language Server | Restarts docker-language-server | + +Anything long-running or interactive — shells, followed logs, pulls, builds, +compose — runs in Terminal or iTerm2 so you can watch it. Everything else runs +in the background and reports failures as notifications. + +## Language support + +Syntax highlighting and folding for **Dockerfiles** (`Dockerfile`, +`Containerfile`, `*.dockerfile`) and **Compose files** (`compose.yaml`, +`docker-compose.yml`, and YAML files with a top-level `services:` key), both +backed by tree-sitter grammars bundled with the extension. + +Completion, hover, validation and linting come from +[docker-language-server](https://github.com/docker/docker-language-server). The +binary is about 40 MB, too large to ship inside an extension, so the managed +mode downloads the pinned release into the extension's storage the first time +it is needed. Point at your own binary instead, or turn the server off, in the +extension's preferences. + +The Compose syntax is registered under the name `dockercompose` because that is +the language identifier docker-language-server recognises compose files by. + +## Preferences + +| Setting | Default | Notes | +| --- | --- | --- | +| `container` / `container-compose` path | resolved from PATH | Set these if you install somewhere unusual | +| Container Shell | `/bin/sh` | Used by Open Shell | +| Terminal | Terminal | Or iTerm2 | +| Refresh Interval | 5s | 0 refreshes only on demand | +| Start Services Automatically | off | Runs `container system start` when the sidebar loads | +| Group by Compose Project | on | | +| Container label / description | Name, image and ports | What each row shows | +| Hostname Domain | from `config.toml` | Suffix for container hostnames; writes Apple Container's own config | +| Status Icons | Traffic Lights | Or toggles, or power symbols | +| Group Images by Repository | on | | +| Show Registry Host | off | `docker.io/library/nginx` rather than `nginx` | +| Show Infrastructure Images | off | Apple Container's builder and init images | +| Detach on Up | on | `--detach` for compose up | +| Run Compose in Terminal | on | Off runs it in the background instead | +| Language Server | Managed Binary | Or a custom path, or disabled | + +Per-workspace, under **Project Settings**, you can pin the compose file the +compose commands use and the profiles they pass. + +## Notes and limits + +- `container-compose down` stops a project's containers but leaves them + defined, so they stay in the sidebar as stopped rows until you delete them. +- Volume rows show their driver and filesystem rather than a size: the backing + disk images are sparse, so the reported size is the maximum they may grow to, + not what they occupy. +- A compose project row can only run compose commands when the matching compose + file is in the open workspace, since project names are all the containers + carry. +- Changing the hostname domain only affects containers created afterwards, and + the services need a restart to pick it up. + +## Development + +```sh +./Tools/build-syntaxes.sh # rebuild the tree-sitter grammars (needs cc and git) +python3 Tools/make-icons.py # regenerate the icon set +``` + +Both write into the extension in place. The grammars are pinned to revisions +that generate tree-sitter ABI 14, which is what Nova loads. + +To run the extension from source, open this folder in Nova and choose +**Extensions → Activate Project as Extension**. + +## Credits + +- Inspired by [Docker Suite](https://gitlab.com/joncoole/nova-docker-suite) by + Jonathan Coole. +- Bundled grammars: + [tree-sitter-dockerfile](https://github.com/camdencheek/tree-sitter-dockerfile) + and [tree-sitter-yaml](https://github.com/tree-sitter-grammars/tree-sitter-yaml), + both MIT licensed. +- Language features from + [docker-language-server](https://github.com/docker/docker-language-server), + Apache 2.0 licensed, downloaded at runtime rather than bundled. diff --git a/Scripts/Commands.js b/Scripts/Commands.js new file mode 100644 index 0000000..8f680f3 --- /dev/null +++ b/Scripts/Commands.js @@ -0,0 +1,248 @@ +// +// Commands that are available whether or not the sidebar is showing: the +// Extensions menu entries and anything that acts on the workspace rather than +// on a sidebar selection. +// + +const { ContainerCLI } = require("./Interfaces/ContainerCLI"); +const { ComposeCLI } = require("./Interfaces/ComposeCLI"); +const { Terminal } = require("./Interfaces/Terminal"); +const { Hostnames } = require("./Hostnames"); +const systemConfig = require("./Utilities/SystemConfig"); +const notify = require("./Utilities/Notify"); +const prompt = require("./Utilities/Prompt"); + +class Commands { + // `context` exposes the pieces owned by main.js, which are replaced when + // the extension reloads. + constructor(context) { + this.context = context; + this.disposables = new CompositeDisposable(); + this.register(); + } + + dispose() { + this.disposables.dispose(); + } + + add(command, handler) { + this.disposables.add(nova.commands.register(command, handler)); + } + + reloadSidebar() { + let sidebar = this.context.sidebar(); + + if (sidebar) { + sidebar.reload(); + } + } + + register() { + this.add("apple_container.reload", () => this.reloadSidebar()); + this.add("apple_container.preferences", () => nova.openConfig()); + + // System ------------------------------------------------------------ + + this.add("apple_container.system.start", async () => { + try { + await ContainerCLI.systemStart(); + } catch (error) { + notify.configFailure("Unable to start container services", error, "apple-container-system"); + } + + this.reloadSidebar(); + }); + + this.add("apple_container.system.stop", async () => { + try { + await ContainerCLI.systemStop(); + } catch (error) { + notify.failure("Unable to stop container services", error, "apple-container-system"); + } + + this.reloadSidebar(); + }); + + this.add("apple_container.system.restart", async () => { + try { + await ContainerCLI.systemStop(); + await ContainerCLI.systemStart(); + } catch (error) { + notify.failure("Unable to restart container services", error, "apple-container-system"); + } + + this.reloadSidebar(); + }); + + this.add("apple_container.system.status", async () => { + try { + let result = await ContainerCLI.run(["system", "status"]); + nova.workspace.openNewTextDocument({ content: result.stdout }); + } catch (error) { + notify.failure("Unable to read system status", error, "apple-container-system"); + } + }); + + this.add("apple_container.system.logs", () => + Terminal.run(Terminal.command([ContainerCLI.path(), "system", "logs", "--follow"])) + ); + + // Builder ----------------------------------------------------------- + + this.add("apple_container.builder.start", () => + this.runBuilder(() => ContainerCLI.builderStart(), "Unable to start the builder") + ); + this.add("apple_container.builder.stop", () => + this.runBuilder(() => ContainerCLI.builderStop(), "Unable to stop the builder") + ); + this.add("apple_container.builder.delete", async () => { + if (!(await prompt.confirm("Delete the builder container?", "Delete"))) { + return; + } + + return this.runBuilder(() => ContainerCLI.builderDelete(), "Unable to delete the builder"); + }); + + // Hostnames and DNS --------------------------------------------------- + + this.add("apple_container.dns.register", async () => { + let domain = await prompt.input("Domain to resolve on this Mac", { + placeholder: "test", + value: systemConfig.defaultDomain() || "" + }); + + if (!domain) { + return; + } + + await Hostnames.register(domain); + }); + + this.add("apple_container.dns.openConfig", () => { + let path = systemConfig.path(); + + if (path && nova.fs.access(path, nova.fs.R_OK)) { + nova.workspace.openFile(path); + } else { + notify.info( + "No system configuration yet", + "Apple Container writes config.toml the first time a setting is changed.", + "apple-container-config" + ); + } + }); + + // Compose ----------------------------------------------------------- + + this.add("apple_container.compose.up", () => + this.runCompose((file) => ComposeCLI.upArgs(file), "Compose up failed") + ); + this.add("apple_container.compose.down", () => + this.runCompose((file) => ComposeCLI.downArgs(file), "Compose down failed") + ); + this.add("apple_container.compose.build", () => + this.runCompose((file) => ComposeCLI.buildArgs(file), "Compose build failed") + ); + + // Images ------------------------------------------------------------ + + this.add("apple_container.image.pullPrompt", async () => { + let reference = await prompt.input("Image to pull", { placeholder: "docker.io/library/nginx:latest" }); + + if (!reference) { + return; + } + + // Pulls are slow and chatty, so they run where the progress shows. + await Terminal.run(Terminal.command([ContainerCLI.path(), "image", "pull", reference])); + }); + + this.add("apple_container.image.runPrompt", async () => { + let reference = await prompt.input("Image to run", { placeholder: "docker.io/library/nginx:latest" }); + + if (!reference) { + return; + } + + let { runImage } = require("./Sidebar/TreeViews/Images"); + await runImage(reference); + }); + + this.add("apple_container.image.buildEditor", () => this.buildFromEditor()); + + // Language server ---------------------------------------------------- + + this.add("apple_container.lsp.restart", () => this.context.restartLanguageServer()); + } + + async runBuilder(action, message) { + try { + await action(); + } catch (error) { + notify.failure(message, error, "apple-container-builder"); + } + + this.reloadSidebar(); + } + + async runCompose(build, message) { + let file = ComposeCLI.defaultFile(); + + if (!file) { + notify.configFailure( + "No compose file found", + "Add a compose file to the workspace, or set one in Project Settings.", + "apple-container-compose-missing" + ); + return; + } + + let directory = nova.path.dirname(file); + let args = build(file); + + if (nova.config.get("apple_container.compose.terminal", "boolean") !== false) { + await Terminal.run(Terminal.command([ComposeCLI.path()].concat(args)), directory); + return; + } + + try { + await ComposeCLI.run(args, { cwd: directory }); + } catch (error) { + notify.failure(message, error, "apple-container-compose"); + } + + this.reloadSidebar(); + } + + // Builds an image from the Dockerfile in the frontmost editor. + async buildFromEditor() { + let editor = nova.workspace.activeTextEditor; + let path = editor && editor.document ? editor.document.path : null; + + if (!path) { + notify.info( + "No file to build", + "Open a Dockerfile or Containerfile first.", + "apple-container-build" + ); + return; + } + + let directory = nova.path.dirname(path); + let suggestion = `${nova.path.basename(directory).toLowerCase().replace(/[^a-z0-9._-]/g, "-")}:latest`; + let tag = await prompt.input("Tag for the built image", { + placeholder: suggestion, + value: suggestion + }); + + if (!tag) { + return; + } + + await Terminal.run( + Terminal.command([ContainerCLI.path(), "build", "--tag", tag, "--file", path, directory]) + ); + } +} + +module.exports = { Commands }; diff --git a/Scripts/Hostnames.js b/Scripts/Hostnames.js new file mode 100644 index 0000000..1cbc373 --- /dev/null +++ b/Scripts/Hostnames.js @@ -0,0 +1,155 @@ +// +// Keeps the "Hostname Domain" preference and Apple Container's own +// configuration in step. +// +// A container's hostname is `.`, where the domain lives in +// Apple Container's config.toml. There is no CLI setter for it, so the +// preference is the front end for that file: reading it on launch, writing it +// back when it changes, and following up with the two things a new domain also +// needs -- a macOS resolver entry, and a restart of the services. +// + +const { ContainerCLI } = require("./Interfaces/ContainerCLI"); +const { Terminal } = require("./Interfaces/Terminal"); +const systemConfig = require("./Utilities/SystemConfig"); +const notify = require("./Utilities/Notify"); +const prompt = require("./Utilities/Prompt"); + +const KEY = "apple_container.dns.domain"; + +// Nova reports config changes as they are typed, so settle before writing. +const SETTLE_MS = 750; + +class Hostnames { + constructor(context) { + this.context = context; + this.timer = null; + // Set while the preference is being seeded from disk, so the resulting + // change notification is not mistaken for the user editing it. + this.seeding = false; + this.disposables = new CompositeDisposable(); + + this.seed(); + this.disposables.add(nova.config.onDidChange(KEY, (value) => this.onChange(value))); + } + + dispose() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + this.disposables.dispose(); + } + + // The file is the source of truth: whatever it says is what the preference + // shows when Nova starts. + seed() { + let domain = systemConfig.defaultDomain() || ""; + + if (nova.config.get(KEY, "string") === domain) { + return; + } + + this.seeding = true; + nova.config.set(KEY, domain); + this.seeding = false; + } + + onChange(value) { + if (this.seeding) { + return; + } + + if (this.timer) { + clearTimeout(this.timer); + } + + this.timer = setTimeout(() => { + this.timer = null; + this.apply(typeof value === "string" ? value.trim() : ""); + }, SETTLE_MS); + } + + async apply(value) { + let domain = value.length > 0 ? value : null; + + if (domain === systemConfig.defaultDomain()) { + return; + } + + if (domain !== null && !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(domain)) { + notify.failure( + "Invalid hostname domain", + `“${domain}” is not a valid domain name.`, + "apple-container-dns" + ); + return; + } + + try { + await systemConfig.setDefaultDomain(domain); + } catch (error) { + notify.failure("Unable to update the container configuration", error, "apple-container-dns"); + return; + } + + this.context.reload(); + + if (!domain) { + notify.info( + "Hostname domain cleared", + "Containers created from now on will have unqualified hostnames.", + "apple-container-dns" + ); + return; + } + + let registered = await ContainerCLI.dnsDomains(); + + if (!registered.includes(domain)) { + await Hostnames.register(domain); + } + + await this.restart(); + } + + async restart() { + if ( + !(await prompt.confirm( + "Restart container services so the hostname domain takes effect?", + "Restart" + )) + ) { + return; + } + + try { + await ContainerCLI.systemStop(); + await ContainerCLI.systemStart(); + } catch (error) { + notify.failure("Unable to restart container services", error, "apple-container-system"); + } + + this.context.reload(); + } + + // Registering a domain writes to /etc/resolver, so it needs an + // administrator password: it runs in the terminal, where one can be given. + static async register(domain) { + if ( + !(await prompt.confirm( + `Let macOS resolve *.${domain} through Apple Container? This runs sudo in your terminal.`, + "Register" + )) + ) { + return; + } + + await Terminal.run( + `sudo ${Terminal.command([ContainerCLI.path(), "system", "dns", "create", domain])}` + ); + } +} + +module.exports = { Hostnames, HOSTNAME_KEY: KEY }; diff --git a/Scripts/Interfaces/ComposeCLI.js b/Scripts/Interfaces/ComposeCLI.js new file mode 100644 index 0000000..5639a72 --- /dev/null +++ b/Scripts/Interfaces/ComposeCLI.js @@ -0,0 +1,137 @@ +// +// Wrapper around `container-compose`. +// +// container-compose has no `ls`, so projects are discovered from the +// com.docker.compose.project labels it stamps on containers, and compose files +// are discovered from the workspace. +// + +const { exec } = require("../Utilities/Process"); + +const SEARCH_PATHS = [ + "/opt/homebrew/bin/container-compose", + "/usr/local/bin/container-compose" +]; + +const FILE_NAMES = [ + "compose.yaml", + "compose.yml", + "docker-compose.yaml", + "docker-compose.yml", + "container-compose.yaml", + "container-compose.yml" +]; + +class ComposeCLI { + static resolve() { + let configured = nova.config.get("apple_container.cli.compose", "string"); + + if (configured && configured.trim().length > 0) { + return [configured.trim(), []]; + } + + for (let path of SEARCH_PATHS) { + if (nova.fs.access(path, nova.fs.X_OK)) { + return [path, []]; + } + } + + return ["/usr/bin/env", ["container-compose"]]; + } + + static path() { + let [path, leading] = this.resolve(); + return leading.length > 0 ? "container-compose" : path; + } + + // Compose files in the workspace root, in the order compose itself would + // prefer them. + static workspaceFiles() { + if (!nova.workspace.path) { + return []; + } + + let found = []; + + for (let name of FILE_NAMES) { + let path = nova.path.join(nova.workspace.path, name); + if (nova.fs.access(path, nova.fs.R_OK)) { + found.push(path); + } + } + + return found; + } + + // The compose file commands act on: the workspace setting when present, + // otherwise the first file found in the workspace. + static defaultFile() { + let configured = nova.workspace.config.get("apple_container.compose.file", "string"); + + if (configured && configured.trim().length > 0) { + return configured.trim(); + } + + return this.workspaceFiles()[0] || null; + } + + // Mirrors container-compose: the compose file's `name:` when set, otherwise + // the containing directory with dots replaced, since dots are not valid in + // container names. + static projectName(file) { + if (!file) { + return null; + } + + try { + let contents = nova.fs.open(file, "r").read(); + let match = contents.match(/^name:\s*["']?([^"'\s#]+)["']?\s*$/m); + + if (match) { + return match[1]; + } + } catch (error) { + // Fall through to the directory name. + } + + return nova.path.basename(nova.path.dirname(file)).replace(/\./g, "_"); + } + + static profiles() { + let configured = nova.workspace.config.get("apple_container.compose.profiles", "string"); + + if (!configured) { + return []; + } + + return configured + .split(/\s+/) + .filter((profile) => profile.length > 0) + .reduce((args, profile) => args.concat(["--profile", profile]), []); + } + + static upArgs(file, services = []) { + let args = ["up", "--file", file]; + + if (nova.config.get("apple_container.compose.detach", "boolean") !== false) { + args.push("--detach"); + } + + return args.concat(this.profiles(), services); + } + + static downArgs(file, services = []) { + return ["down", "--file", file].concat(this.profiles(), services); + } + + static buildArgs(file, services = []) { + return ["build", "--file", file].concat(this.profiles(), services); + } + + static run(args, options = {}) { + let [path, leading] = this.resolve(); + return exec(path, leading.concat(args), options); + } +} + +module.exports = { ComposeCLI }; diff --git a/Scripts/Interfaces/ContainerCLI.js b/Scripts/Interfaces/ContainerCLI.js new file mode 100644 index 0000000..b9a3104 --- /dev/null +++ b/Scripts/Interfaces/ContainerCLI.js @@ -0,0 +1,251 @@ +// +// Wrapper around the `container` command line tool. +// +// Every list command is asked for JSON; Apple Container's JSON is a single +// document rather than the line delimited JSON Docker emits, so output is +// buffered and parsed once the process exits. +// + +const { exec, execJSON } = require("../Utilities/Process"); + +const SEARCH_PATHS = [ + "/usr/local/bin/container", + "/opt/homebrew/bin/container", + "/usr/bin/container" +]; + +class ContainerCLI { + // Resolves the tool as a [path, leadingArgs] pair. Nova does not inherit a + // login shell's PATH reliably, so a configured or well known absolute path + // is preferred and `/usr/bin/env` is only the last resort. + static resolve() { + let configured = nova.config.get("apple_container.cli.container", "string"); + + if (configured && configured.trim().length > 0) { + return [configured.trim(), []]; + } + + for (let path of SEARCH_PATHS) { + if (nova.fs.access(path, nova.fs.X_OK)) { + return [path, []]; + } + } + + return ["/usr/bin/env", ["container"]]; + } + + // Absolute path to the tool, or null when it could not be found. Used to + // build shell commands for the terminal. + static path() { + let [path, leading] = this.resolve(); + return leading.length > 0 ? "container" : path; + } + + static run(args, options = {}) { + let [path, leading] = this.resolve(); + return exec(path, leading.concat(args), options); + } + + static runJSON(args, options = {}) { + let [path, leading] = this.resolve(); + return execJSON(path, leading.concat(args), options); + } + + // System --------------------------------------------------------------- + + // Resolves to a status object even when the services are down, so the + // sidebar can render the stopped state rather than an empty section. + static async systemStatus() { + try { + let status = await this.runJSON(["system", "status", "--format", "json"]); + return status && typeof status === "object" ? status : { status: "unknown" }; + } catch (error) { + return { status: "stopped", error: error.message }; + } + } + + static systemStart() { + return this.run(["system", "start"]); + } + + static systemStop() { + return this.run(["system", "stop"]); + } + + static async systemDiskUsage() { + try { + return await this.runJSON(["system", "df", "--format", "json"]); + } catch (error) { + return null; + } + } + + static async dnsDomains() { + try { + let result = await this.run(["system", "dns", "ls"]); + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .slice(1); + } catch (error) { + return []; + } + } + + static version() { + return this.run(["--version"]); + } + + // Builder -------------------------------------------------------------- + + // `container builder status` reports through its message rather than a + // machine readable field, so the text is matched. + static async builderRunning() { + try { + let result = await this.run(["builder", "status"], { ignoreStatus: true }); + let output = `${result.stdout} ${result.stderr}`.toLowerCase(); + + if (output.includes("not running") || output.includes("does not exist")) { + return false; + } + + return result.status === 0; + } catch (error) { + return false; + } + } + + static builderStart() { + return this.run(["builder", "start"]); + } + + static builderStop() { + return this.run(["builder", "stop"]); + } + + static builderDelete() { + return this.run(["builder", "delete"]); + } + + // Containers ----------------------------------------------------------- + + static listContainers() { + return this.runJSON(["list", "--all", "--format", "json"]); + } + + static startContainers(ids) { + // `container start` takes a single container, so fan out. + return Promise.all(ids.map((id) => this.run(["start", id]))); + } + + static stopContainers(ids) { + return this.run(["stop"].concat(ids)); + } + + static killContainers(ids) { + return this.run(["kill"].concat(ids)); + } + + static async restartContainers(ids) { + await this.stopContainers(ids); + return this.startContainers(ids); + } + + static deleteContainers(ids, force = false) { + let args = force ? ["delete", "--force"] : ["delete"]; + return this.run(args.concat(ids)); + } + + static pruneContainers() { + return this.run(["prune"]); + } + + static async inspectContainer(id) { + let result = await this.run(["inspect", id]); + return result.stdout; + } + + // Images --------------------------------------------------------------- + + static listImages() { + return this.runJSON(["image", "list", "--format", "json"]); + } + + static pullImage(reference) { + return this.run(["image", "pull", reference]); + } + + static pushImage(reference) { + return this.run(["image", "push", reference]); + } + + static tagImage(reference, target) { + return this.run(["image", "tag", reference, target]); + } + + static deleteImages(references) { + return this.run(["image", "delete"].concat(references)); + } + + static pruneImages() { + return this.run(["image", "prune"]); + } + + static async inspectImage(reference) { + let result = await this.run(["image", "inspect", reference]); + return result.stdout; + } + + // Volumes -------------------------------------------------------------- + + static listVolumes() { + return this.runJSON(["volume", "list", "--format", "json"]); + } + + static createVolume(nameOrArgs) { + let args = Array.isArray(nameOrArgs) ? nameOrArgs : [nameOrArgs]; + return this.run(["volume", "create"].concat(args)); + } + + static deleteVolumes(names) { + return this.run(["volume", "delete"].concat(names)); + } + + static pruneVolumes() { + return this.run(["volume", "prune"]); + } + + static async inspectVolume(name) { + let result = await this.run(["volume", "inspect", name]); + return result.stdout; + } + + // Networks ------------------------------------------------------------- + + static listNetworks() { + return this.runJSON(["network", "list", "--format", "json"]); + } + + // Accepts a bare name or a full argument list such as + // ["backend", "--subnet", "192.168.70.0/24"]. + static createNetwork(nameOrArgs) { + let args = Array.isArray(nameOrArgs) ? nameOrArgs : [nameOrArgs]; + return this.run(["network", "create"].concat(args)); + } + + static deleteNetworks(names) { + return this.run(["network", "delete"].concat(names)); + } + + static pruneNetworks() { + return this.run(["network", "prune"]); + } + + static async inspectNetwork(name) { + let result = await this.run(["network", "inspect", name]); + return result.stdout; + } +} + +module.exports = { ContainerCLI }; diff --git a/Scripts/Interfaces/Terminal.js b/Scripts/Interfaces/Terminal.js new file mode 100644 index 0000000..586270b --- /dev/null +++ b/Scripts/Interfaces/Terminal.js @@ -0,0 +1,61 @@ +// +// Runs commands in the user's terminal application. +// +// Nova has no terminal of its own, so anything interactive or long running -- +// shells, followed logs, compose output -- is handed to Terminal or iTerm2 +// through AppleScript. +// + +const { exec } = require("../Utilities/Process"); + +class Terminal { + // Quotes a single argument for /bin/sh. + static quote(argument) { + return `'${String(argument).replace(/'/g, `'\\''`)}'`; + } + + // Joins an argument vector into a shell command. + static command(parts) { + return parts.map((part) => this.quote(part)).join(" "); + } + + static escapeAppleScript(text) { + return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + } + + // Opens a new terminal window running the given shell command. + static async run(command, cwd) { + let full = cwd ? `cd ${this.quote(cwd)} && ${command}` : command; + let script = + nova.config.get("apple_container.cli.terminal", "string") === "iTerm2" + ? this.iTermScript(full) + : this.terminalScript(full); + + return exec("/usr/bin/osascript", ["-e", script]); + } + + static terminalScript(command) { + let escaped = this.escapeAppleScript(command); + return [ + 'tell application "Terminal"', + ` do script "${escaped}"`, + " activate", + "end tell" + ].join("\n"); + } + + static iTermScript(command) { + let escaped = this.escapeAppleScript(command); + return [ + 'tell application "iTerm"', + " activate", + " set newWindow to (create window with default profile)", + " tell current session of newWindow", + ` write text "${escaped}"`, + " end tell", + "end tell" + ].join("\n"); + } +} + +module.exports = { Terminal }; diff --git a/Scripts/LanguageClients/DockerLanguageClient.js b/Scripts/LanguageClients/DockerLanguageClient.js new file mode 100644 index 0000000..45d676e --- /dev/null +++ b/Scripts/LanguageClients/DockerLanguageClient.js @@ -0,0 +1,183 @@ +// +// Language support for Dockerfiles and Compose files, backed by +// docker-language-server (github.com/docker/docker-language-server). +// +// The binary is a single ~40 MB executable, which is too large to ship inside +// the extension, so the managed mode downloads the pinned release into the +// extension's global storage the first time it is needed. +// + +const { exec } = require("../Utilities/Process"); +const notify = require("../Utilities/Notify"); + +const RELEASE_URL = "https://github.com/docker/docker-language-server/releases/download"; +// docker-language-server decides what a document is from the languageId the +// editor sends, and Nova sends the syntax name. Compose support only switches +// on for "dockercompose", which is why the Compose syntax carries that name. +const SYNTAXES = ["dockerfile", "dockercompose"]; + +class DockerLanguageClient { + constructor() { + this.client = null; + this.disposables = new CompositeDisposable(); + + for (let key of ["apple_container.lsp.mode", "apple_container.lsp.path", "apple_container.lsp.version"]) { + this.disposables.add(nova.config.onDidChange(key, () => this.restart())); + } + } + + get mode() { + return nova.config.get("apple_container.lsp.mode", "string") || "Managed Binary"; + } + + get version() { + return (nova.config.get("apple_container.lsp.version", "string") || "").trim() || "v0.20.1"; + } + + async start() { + if (this.mode === "Disabled") { + return; + } + + let path = null; + + try { + path = this.mode === "Custom Path" ? this.customPath() : await this.managedPath(); + } catch (error) { + notify.configFailure("Docker Language Server unavailable", error, "apple-container-lsp"); + return; + } + + if (!path) { + return; + } + + this.stop(); + + try { + this.client = new LanguageClient( + "unsupervised.AppleContainer.dls", + "Docker Language Server", + { + path: path, + args: ["start", "--stdio"], + type: "stdio" + }, + { + syntaxes: SYNTAXES + } + ); + + this.client.onDidStop((error) => { + if (error) { + notify.failure( + "Docker Language Server stopped", + error, + "apple-container-lsp-stopped" + ); + } + }); + + this.client.start(); + nova.subscriptions.add(this.client); + } catch (error) { + notify.failure("Docker Language Server failed to launch", error, "apple-container-lsp"); + this.client = null; + } + } + + stop() { + if (!this.client) { + return; + } + + this.client.stop(); + nova.subscriptions.remove(this.client); + this.client = null; + } + + async restart() { + this.stop(); + await this.start(); + } + + dispose() { + this.stop(); + this.disposables.dispose(); + } + + customPath() { + let path = (nova.config.get("apple_container.lsp.path", "string") || "").trim(); + + if (!path) { + throw new Error("Set the path to docker-language-server in the extension's preferences."); + } + + if (!nova.fs.access(path, nova.fs.X_OK)) { + throw new Error(`${path} is not an executable file.`); + } + + return path; + } + + // Returns the managed binary, downloading it when it is missing. + async managedPath() { + let architecture = await this.architecture(); + let name = `docker-language-server-darwin-${architecture}-${this.version}`; + let directory = nova.extension.globalStoragePath; + let path = nova.path.join(directory, name); + + if (nova.fs.access(path, nova.fs.X_OK)) { + return path; + } + + if (!nova.fs.access(directory, nova.fs.F_OK)) { + nova.fs.mkdir(directory); + } + + await this.download(`${RELEASE_URL}/${this.version}/${name}`, path); + + return path; + } + + async architecture() { + let result = await exec("/usr/bin/uname", ["-m"]); + return result.stdout.trim() === "x86_64" ? "amd64" : "arm64"; + } + + async download(url, destination) { + notify.info( + "Downloading Docker Language Server", + `${this.version} for Dockerfile and Compose support. This happens once.`, + "apple-container-lsp-download" + ); + + // A partial file would look like a working binary on the next launch, + // so download beside the target and move it into place at the end. + let temporary = `${destination}.download`; + + try { + await exec("/usr/bin/curl", [ + "--fail", + "--location", + "--silent", + "--show-error", + "--output", + temporary, + url + ]); + await exec("/bin/chmod", ["+x", temporary]); + nova.fs.move(temporary, destination); + } catch (error) { + try { + nova.fs.remove(temporary); + } catch (cleanupError) { + // Nothing to clean up. + } + + throw new Error(`Could not download ${url}: ${error.message}`); + } + } +} + +module.exports = { DockerLanguageClient }; diff --git a/Scripts/Sidebar/ContainerSidebar.js b/Scripts/Sidebar/ContainerSidebar.js new file mode 100644 index 0000000..697c925 --- /dev/null +++ b/Scripts/Sidebar/ContainerSidebar.js @@ -0,0 +1,166 @@ +// +// Owns the sidebar's controllers and the polling that keeps them current. +// +// Apple Container publishes no event stream, so the sidebar polls on an +// interval while it is visible. Controllers only reload their tree when the +// data actually changed, which keeps selection and expansion intact. +// + +const { SystemController } = require("./TreeViews/System"); +const { ContainersController } = require("./TreeViews/Containers"); +const { ImagesController } = require("./TreeViews/Images"); +const { VolumesController } = require("./TreeViews/Volumes"); +const { NetworksController } = require("./TreeViews/Networks"); +const { ContainerCLI } = require("../Interfaces/ContainerCLI"); +const notify = require("../Utilities/Notify"); + +class ContainerSidebar { + constructor() { + this.controllers = null; + this.timer = null; + this.refreshing = false; + this.watchers = new CompositeDisposable(); + + this.watchers.add( + nova.config.onDidChange("apple_container.sidebar.disabled", () => this.restart()) + ); + this.watchers.add( + nova.config.onDidChange("apple_container.sidebar.refresh", () => this.schedule()) + ); + + this.start(); + } + + start() { + if (nova.config.get("apple_container.sidebar.disabled", "boolean")) { + return; + } + + this.system = new SystemController(this); + this.containers = new ContainersController(this); + this.images = new ImagesController(this); + this.volumes = new VolumesController(this); + this.networks = new NetworksController(this); + + this.controllers = [this.system, this.containers, this.images, this.volumes, this.networks]; + + this.bootstrap(); + this.schedule(); + } + + stop() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + + if (this.controllers) { + for (let controller of this.controllers) { + controller.dispose(); + } + this.controllers = null; + } + } + + dispose() { + this.stop(); + this.watchers.dispose(); + } + + restart() { + this.stop(); + this.start(); + } + + // First pass: start the services when asked to, then fill every section. + async bootstrap() { + if (nova.config.get("apple_container.sidebar.autostart", "boolean")) { + let status = await ContainerCLI.systemStatus(); + + if (status.status !== "running") { + try { + await ContainerCLI.systemStart(); + } catch (error) { + notify.configFailure( + "Unable to start container services", + error, + "apple-container-autostart" + ); + } + } + } + + return this.refresh({ force: true }); + } + + schedule() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + + if (!this.controllers) { + return; + } + + let seconds = nova.config.get("apple_container.sidebar.refresh", "number"); + + if (typeof seconds !== "number" || seconds <= 0) { + return; + } + + this.timer = setInterval(() => this.refresh(), Math.max(1, seconds) * 1000); + } + + // One refresh at a time, and only for sections the user can actually see. + async refresh(options = {}) { + if (!this.controllers || this.refreshing) { + return; + } + + this.refreshing = true; + + try { + await this.system.refresh(options); + + if (!this.system.running) { + for (let controller of this.sections()) { + controller.clear(); + } + return; + } + + let pending = this.sections() + .filter((controller) => options.force || controller.visible) + .map((controller) => controller.refresh(options)); + + await Promise.all(pending); + } finally { + this.refreshing = false; + } + } + + sections() { + return [this.containers, this.images, this.volumes, this.networks]; + } + + reload() { + if (!this.controllers) { + return Promise.resolve(); + } + + for (let controller of this.controllers) { + controller.invalidate(); + } + + return this.refresh({ force: true }); + } + + reloadContainers() { + if (this.containers) { + this.containers.reload(); + } + } +} + +module.exports = { ContainerSidebar }; diff --git a/Scripts/Sidebar/Models/Container.js b/Scripts/Sidebar/Models/Container.js new file mode 100644 index 0000000..7079655 --- /dev/null +++ b/Scripts/Sidebar/Models/Container.js @@ -0,0 +1,211 @@ +// +// Containers, and the compose projects they are grouped into. +// +// container-compose stamps com.docker.compose.project and +// com.docker.compose.service on the containers it creates, which is the only +// link back to a project; containers created any other way fall into an +// ungrouped bucket. +// + +const { Element } = require("./Element"); +const format = require("../../Utilities/Format"); + +const PROJECT_LABEL = "com.docker.compose.project"; +const SERVICE_LABEL = "com.docker.compose.service"; +const UNGROUPED = "Containers"; + +class ContainerModel extends Element { + constructor(json) { + let configuration = (json && json.configuration) || {}; + let status = (json && json.status) || {}; + + super(`container:${configuration.id || json.id}`); + + this.id = configuration.id || json.id; + this.name = this.id; + this.state = typeof status.state === "string" ? status.state : "unknown"; + this.labels = configuration.labels || {}; + this.image = (configuration.image && configuration.image.reference) || ""; + this.imageName = format.reference(this.image).short; + this.platform = configuration.platform || {}; + this.startedDate = status.startedDate || null; + this.project = this.labels[PROJECT_LABEL] || null; + this.service = this.labels[SERVICE_LABEL] || null; + + this.cpus = (configuration.resources && configuration.resources.cpus) || null; + this.memory = (configuration.resources && configuration.resources.memoryInBytes) || null; + + this.ports = (configuration.publishedPorts || []).map((port) => ({ + hostAddress: port.hostAddress, + hostPort: port.hostPort, + containerPort: port.containerPort, + proto: port.proto || "tcp" + })); + + this.mounts = (configuration.mounts || []).map((mount) => ({ + destination: mount.destination, + source: mount.source, + volume: mount.type && mount.type.volume ? mount.type.volume.name : null + })); + + // Addresses only exist while the container is running. + this.addresses = (status.networks || []).map((attachment) => ({ + network: attachment.network, + hostname: stripTrailingDot(attachment.hostname), + address: stripPrefix(attachment.ipv4Address) + })); + + this.hostname = this.addresses.length > 0 ? this.addresses[0].hostname : null; + this.address = this.addresses.length > 0 ? this.addresses[0].address : null; + } + + get running() { + return this.state === "running"; + } + + get revision() { + return `${this.state}:${this.address || ""}:${this.portSummary}`; + } + + get portSummary() { + return this.ports + .map((port) => `${port.hostPort}→${port.containerPort}/${port.proto}`) + .join(" "); + } + + // Every way this container can be reached over HTTP, most useful first: + // published ports resolve from the host unconditionally, while the DNS + // hostname only resolves once its domain is registered with macOS. + get urls() { + let urls = this.ports + .filter((port) => port.proto === "tcp") + .map((port) => { + let host = + !port.hostAddress || port.hostAddress === "0.0.0.0" ? "localhost" : port.hostAddress; + return { + url: `http://${host}:${port.hostPort}`, + label: `http://${host}:${port.hostPort} → container port ${port.containerPort}` + }; + }); + + if (this.running && this.hostname) { + urls.push({ url: `http://${this.hostname}`, label: `http://${this.hostname} (DNS name)` }); + } + + if (this.running && this.address) { + urls.push({ url: `http://${this.address}`, label: `http://${this.address} (container IP)` }); + } + + return urls; + } + + get url() { + let urls = this.urls; + return urls.length > 0 ? urls[0].url : null; + } + + get tooltip() { + let lines = [ + `Name:\t${this.name}`, + `Image:\t${this.image}`, + `State:\t${this.state}` + ]; + + if (this.service) { + lines.push(`Service:\t${this.service}`); + } + if (this.address) { + lines.push(`Address:\t${this.address}`); + } + if (this.hostname) { + lines.push(`Hostname:\t${this.hostname}`); + } + if (this.ports.length > 0) { + lines.push(`Ports:\t${this.portSummary}`); + } + if (this.memory) { + lines.push(`Memory:\t${format.bytes(this.memory)}`); + } + if (this.cpus) { + lines.push(`CPUs:\t${this.cpus}`); + } + if (this.startedDate) { + lines.push(`Started:\t${format.age(this.startedDate)}`); + } + + return lines.join("\n"); + } +} + +class ProjectModel extends Element { + constructor(name, compose) { + super(`project:${name}`); + this.name = name; + // A compose project is one the sidebar can also drive with + // container-compose; the ungrouped bucket is not. + this.compose = compose; + this.file = null; + this.expanded = true; + } + + get running() { + return this.children.some((child) => child.running); + } + + get summary() { + let running = this.children.filter((child) => child.running).length; + return `${running}/${this.children.length} running`; + } + + get revision() { + return `${this.file || ""}:${this.summary}`; + } +} + +function stripTrailingDot(hostname) { + if (!hostname) { + return null; + } + return String(hostname).replace(/\.$/, ""); +} + +function stripPrefix(cidr) { + if (!cidr) { + return null; + } + return String(cidr).split("/")[0]; +} + +// Builds the container tree. Returns projects when grouping is on, and a flat +// list of containers when it is off. +function build(list, options = {}) { + let containers = (list || []).map((entry) => new ContainerModel(entry)); + + containers.sort((left, right) => left.name.localeCompare(right.name)); + + if (!options.group) { + return containers; + } + + let projects = new Map(); + + for (let container of containers) { + let name = container.project || UNGROUPED; + + if (!projects.has(name)) { + projects.set(name, new ProjectModel(name, container.project !== null)); + } + + projects.get(name).addChild(container); + } + + // Ungrouped containers sort last, everything else alphabetically. + return [...projects.values()].sort((left, right) => { + if (left.compose !== right.compose) { + return left.compose ? -1 : 1; + } + return left.name.localeCompare(right.name); + }); +} + +module.exports = { ContainerModel, ProjectModel, build, PROJECT_LABEL, SERVICE_LABEL }; diff --git a/Scripts/Sidebar/Models/Element.js b/Scripts/Sidebar/Models/Element.js new file mode 100644 index 0000000..6bb4fa3 --- /dev/null +++ b/Scripts/Sidebar/Models/Element.js @@ -0,0 +1,30 @@ +// +// Shared base for everything shown in the sidebar trees. +// + +class Element { + constructor(identifier) { + this.identifier = identifier; + this.children = []; + this.parent = null; + this.expanded = false; + } + + addChild(child) { + child.parent = this; + this.children.push(child); + return child; + } + + get hasChildren() { + return this.children.length > 0; + } + + // Everything about the element that should trigger a tree reload when it + // changes. Identity is compared separately. + get revision() { + return ""; + } +} + +module.exports = { Element }; diff --git a/Scripts/Sidebar/Models/Image.js b/Scripts/Sidebar/Models/Image.js new file mode 100644 index 0000000..863ac4b --- /dev/null +++ b/Scripts/Sidebar/Models/Image.js @@ -0,0 +1,119 @@ +// +// Images, optionally grouped by repository. +// + +const { Element } = require("./Element"); +const format = require("../../Utilities/Format"); + +const INFRA_PREFIXES = [ + "ghcr.io/apple/container-builder-shim/", + "ghcr.io/apple/containerization/vminit" +]; + +class ImageModel extends Element { + constructor(json) { + let configuration = (json && json.configuration) || {}; + + super(`image:${configuration.name}`); + + this.reference = configuration.name || ""; + this.digest = (configuration.descriptor && configuration.descriptor.digest) || ""; + // The CLI identifies images by the first twelve hex digits of the digest. + this.shortDigest = this.digest.replace(/^[a-z0-9]+:/, "").slice(0, 12); + this.id = json.id || ""; + this.created = configuration.creationDate || null; + + let parts = format.reference(this.reference); + this.registry = parts.registry; + this.repository = parts.repository; + this.tag = parts.tag; + this.short = parts.short; + + this.variants = (json.variants || []).map((variant) => ({ + architecture: (variant.platform && variant.platform.architecture) || "", + os: (variant.platform && variant.platform.os) || "", + size: variant.size || 0 + })); + + this.size = this.variants.reduce((total, variant) => total + variant.size, 0); + } + + get revision() { + return `${this.digest}:${this.size}`; + } + + get infrastructure() { + return INFRA_PREFIXES.some((prefix) => this.reference.startsWith(prefix)); + } + + get platforms() { + return this.variants + .map((variant) => (variant.os ? `${variant.os}/${variant.architecture}` : variant.architecture)) + .filter((platform) => platform.length > 0) + .join(" "); + } + + get tooltip() { + let lines = [`Image:\t${this.reference}`, `Digest:\t${this.shortDigest}`]; + + if (this.platforms) { + lines.push(`Platform:\t${this.platforms}`); + } + if (this.size) { + lines.push(`Size:\t${format.bytes(this.size)}`); + } + if (this.created) { + lines.push(`Created:\t${format.age(this.created)}`); + } + + return lines.join("\n"); + } +} + +class RepositoryModel extends Element { + constructor(name) { + super(`repository:${name}`); + this.name = name; + this.expanded = true; + } + + get size() { + return this.children.reduce((total, image) => total + image.size, 0); + } + + get revision() { + return String(this.size); + } +} + +function build(list, options = {}) { + let images = (list || []).map((entry) => new ImageModel(entry)); + + if (!options.infrastructure) { + images = images.filter((image) => !image.infrastructure); + } + + let label = (image) => (options.registry ? image.reference : `${image.short}:${image.tag}`); + + images.sort((left, right) => label(left).localeCompare(label(right))); + + if (!options.group) { + return images; + } + + let repositories = new Map(); + + for (let image of images) { + let name = options.registry && image.registry ? `${image.registry}/${image.repository}` : image.short; + + if (!repositories.has(name)) { + repositories.set(name, new RepositoryModel(name)); + } + + repositories.get(name).addChild(image); + } + + return [...repositories.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +module.exports = { ImageModel, RepositoryModel, build }; diff --git a/Scripts/Sidebar/Models/Network.js b/Scripts/Sidebar/Models/Network.js new file mode 100644 index 0000000..5323a0e --- /dev/null +++ b/Scripts/Sidebar/Models/Network.js @@ -0,0 +1,60 @@ +// +// Networks. +// + +const { Element } = require("./Element"); + +const ROLE_LABEL = "com.apple.container.resource.role"; + +class NetworkModel extends Element { + constructor(json) { + let configuration = (json && json.configuration) || {}; + let status = (json && json.status) || {}; + + super(`network:${configuration.name || json.id}`); + + this.name = configuration.name || json.id; + this.mode = configuration.mode || ""; + this.plugin = configuration.plugin || ""; + this.created = configuration.creationDate || null; + this.labels = configuration.labels || {}; + this.subnet = status.ipv4Subnet || ""; + this.gateway = status.ipv4Gateway || ""; + this.subnet6 = status.ipv6Subnet || ""; + } + + get revision() { + return `${this.subnet}:${this.gateway}:${this.mode}`; + } + + get builtin() { + return this.labels[ROLE_LABEL] === "builtin"; + } + + get tooltip() { + let lines = [`Network:\t${this.name}`]; + + if (this.mode) { + lines.push(`Mode:\t${this.mode}`); + } + if (this.subnet) { + lines.push(`Subnet:\t${this.subnet}`); + } + if (this.gateway) { + lines.push(`Gateway:\t${this.gateway}`); + } + if (this.plugin) { + lines.push(`Plugin:\t${this.plugin}`); + } + + return lines.join("\n"); + } +} + +function build(list) { + return (list || []) + .map((entry) => new NetworkModel(entry)) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +module.exports = { NetworkModel, build }; diff --git a/Scripts/Sidebar/Models/Volume.js b/Scripts/Sidebar/Models/Volume.js new file mode 100644 index 0000000..e17dfba --- /dev/null +++ b/Scripts/Sidebar/Models/Volume.js @@ -0,0 +1,55 @@ +// +// Volumes. +// + +const { Element } = require("./Element"); +const format = require("../../Utilities/Format"); + +class VolumeModel extends Element { + constructor(json) { + let configuration = (json && json.configuration) || {}; + + super(`volume:${configuration.name || json.id}`); + + this.name = configuration.name || json.id; + this.driver = configuration.driver || ""; + this.filesystem = configuration.format || ""; + this.source = configuration.source || ""; + // The volume image is sparse: sizeInBytes is the maximum it can grow + // to, not what it occupies, so it is reported as capacity. + this.capacity = configuration.sizeInBytes || 0; + this.created = configuration.creationDate || null; + this.labels = configuration.labels || {}; + } + + get revision() { + return `${this.capacity}:${this.filesystem}`; + } + + get tooltip() { + let lines = [`Volume:\t${this.name}`, `Driver:\t${this.driver}`]; + + if (this.filesystem) { + lines.push(`Format:\t${this.filesystem}`); + } + if (this.capacity) { + lines.push(`Capacity:\t${format.bytes(this.capacity)}`); + } + if (this.created) { + lines.push(`Created:\t${format.age(this.created)}`); + } + if (this.source) { + lines.push(`Source:\t${this.source}`); + } + + return lines.join("\n"); + } +} + +function build(list) { + return (list || []) + .map((entry) => new VolumeModel(entry)) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +module.exports = { VolumeModel, build }; diff --git a/Scripts/Sidebar/TreeItems/ContainerTreeItem.js b/Scripts/Sidebar/TreeItems/ContainerTreeItem.js new file mode 100644 index 0000000..d5a70fa --- /dev/null +++ b/Scripts/Sidebar/TreeItems/ContainerTreeItem.js @@ -0,0 +1,45 @@ +const { statusIcon } = require("./StatusIcon"); + +class ContainerTreeItem extends TreeItem { + constructor(container) { + super(label(container), TreeItemCollapsibleState.None); + + this.identifier = container.identifier; + this.image = statusIcon(container.running); + this.contextValue = container.running ? "container.running" : "container.stopped"; + this.tooltip = container.tooltip; + this.descriptiveText = description(container); + } +} + +function label(container) { + switch (nova.config.get("apple_container.containers.label", "string")) { + case "Image": + return container.imageName; + case "Service": + return container.service || container.name; + default: + return container.name; + } +} + +function description(container) { + let parts = []; + + if (nova.config.get("apple_container.containers.desc.image", "boolean")) { + parts.push(container.imageName); + } + if (nova.config.get("apple_container.containers.desc.ports", "boolean") && container.ports.length > 0) { + parts.push(container.portSummary); + } + if (nova.config.get("apple_container.containers.desc.address", "boolean") && container.address) { + parts.push(container.address); + } + if (nova.config.get("apple_container.containers.desc.state", "boolean")) { + parts.push(container.state); + } + + return parts.join(" "); +} + +module.exports = { ContainerTreeItem }; diff --git a/Scripts/Sidebar/TreeItems/ImageTreeItem.js b/Scripts/Sidebar/TreeItems/ImageTreeItem.js new file mode 100644 index 0000000..b59e067 --- /dev/null +++ b/Scripts/Sidebar/TreeItems/ImageTreeItem.js @@ -0,0 +1,38 @@ +const format = require("../../Utilities/Format"); + +class ImageTreeItem extends TreeItem { + constructor(image, options = {}) { + super(options.grouped ? image.tag : label(image), TreeItemCollapsibleState.None); + + this.identifier = image.identifier; + this.image = "icons/tag.png"; + this.contextValue = "image.tag"; + this.tooltip = image.tooltip; + this.descriptiveText = [format.bytes(image.size), format.age(image.created)] + .filter((part) => part.length > 0) + .join(" "); + } +} + +class RepositoryTreeItem extends TreeItem { + constructor(repository) { + super( + repository.name, + repository.expanded ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed + ); + + this.identifier = repository.identifier; + this.image = "icons/layers.png"; + this.contextValue = "image.repo"; + this.descriptiveText = format.bytes(repository.size); + this.tooltip = repository.name; + } +} + +function label(image) { + return nova.config.get("apple_container.images.registry", "boolean") + ? image.reference + : `${image.short}:${image.tag}`; +} + +module.exports = { ImageTreeItem, RepositoryTreeItem }; diff --git a/Scripts/Sidebar/TreeItems/NetworkTreeItem.js b/Scripts/Sidebar/TreeItems/NetworkTreeItem.js new file mode 100644 index 0000000..0109cce --- /dev/null +++ b/Scripts/Sidebar/TreeItems/NetworkTreeItem.js @@ -0,0 +1,15 @@ +class NetworkTreeItem extends TreeItem { + constructor(network) { + super(network.name, TreeItemCollapsibleState.None); + + this.identifier = network.identifier; + this.image = "icons/network.png"; + this.contextValue = "network"; + this.tooltip = network.tooltip; + this.descriptiveText = [network.subnet, network.mode] + .filter((part) => part && part.length > 0) + .join(" "); + } +} + +module.exports = { NetworkTreeItem }; diff --git a/Scripts/Sidebar/TreeItems/ProjectTreeItem.js b/Scripts/Sidebar/TreeItems/ProjectTreeItem.js new file mode 100644 index 0000000..ba9afea --- /dev/null +++ b/Scripts/Sidebar/TreeItems/ProjectTreeItem.js @@ -0,0 +1,18 @@ +class ProjectTreeItem extends TreeItem { + constructor(project) { + super( + project.name, + project.expanded ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed + ); + + this.identifier = project.identifier; + this.image = project.compose ? "icons/layers.png" : "icons/folder.png"; + // Compose projects gain the compose commands; the ungrouped bucket only + // gets the bulk start and stop. + this.contextValue = project.compose ? "project.compose" : "project"; + this.descriptiveText = project.summary; + this.tooltip = project.file ? `Compose file:\t${project.file}` : project.name; + } +} + +module.exports = { ProjectTreeItem }; diff --git a/Scripts/Sidebar/TreeItems/StatusIcon.js b/Scripts/Sidebar/TreeItems/StatusIcon.js new file mode 100644 index 0000000..532d01d --- /dev/null +++ b/Scripts/Sidebar/TreeItems/StatusIcon.js @@ -0,0 +1,16 @@ +// +// Maps a running/stopped state onto the icon set chosen in preferences. +// + +const SETS = { + "Traffic Lights": ["icons/status/traffic-on.png", "icons/status/traffic-off.png"], + Toggles: ["icons/status/toggle-on.png", "icons/status/toggle-off.png"], + Power: ["icons/status/power-on.png", "icons/status/power-off.png"] +}; + +function statusIcon(running) { + let set = SETS[nova.config.get("apple_container.containers.icons", "string")] || SETS["Traffic Lights"]; + return running ? set[0] : set[1]; +} + +module.exports = { statusIcon }; diff --git a/Scripts/Sidebar/TreeItems/SystemTreeItem.js b/Scripts/Sidebar/TreeItems/SystemTreeItem.js new file mode 100644 index 0000000..f6fb0b8 --- /dev/null +++ b/Scripts/Sidebar/TreeItems/SystemTreeItem.js @@ -0,0 +1,15 @@ +const { statusIcon } = require("./StatusIcon"); + +class SystemTreeItem extends TreeItem { + constructor(row) { + super(row.label, TreeItemCollapsibleState.None); + + this.identifier = row.identifier; + this.contextValue = row.contextValue; + this.descriptiveText = row.description || ""; + this.tooltip = row.tooltip || row.label; + this.image = row.running === undefined ? row.image || "icons/system.png" : statusIcon(row.running); + } +} + +module.exports = { SystemTreeItem }; diff --git a/Scripts/Sidebar/TreeItems/VolumeTreeItem.js b/Scripts/Sidebar/TreeItems/VolumeTreeItem.js new file mode 100644 index 0000000..f8bc668 --- /dev/null +++ b/Scripts/Sidebar/TreeItems/VolumeTreeItem.js @@ -0,0 +1,17 @@ +class VolumeTreeItem extends TreeItem { + constructor(volume) { + super(volume.name, TreeItemCollapsibleState.None); + + this.identifier = volume.identifier; + this.image = "icons/volume.png"; + this.contextValue = "volume"; + this.tooltip = volume.tooltip; + // Volume images are sparse, so a size here would only mislead; the + // capacity is in the tooltip instead. + this.descriptiveText = [volume.driver, volume.filesystem] + .filter((part) => part && part.length > 0) + .join(" "); + } +} + +module.exports = { VolumeTreeItem }; diff --git a/Scripts/Sidebar/TreeViews/Containers.js b/Scripts/Sidebar/TreeViews/Containers.js new file mode 100644 index 0000000..de01b8e --- /dev/null +++ b/Scripts/Sidebar/TreeViews/Containers.js @@ -0,0 +1,388 @@ +// +// Containers section, grouped by compose project. +// + +const { Controller } = require("./Controller"); +const { ContainerTreeItem } = require("../TreeItems/ContainerTreeItem"); +const { ProjectTreeItem } = require("../TreeItems/ProjectTreeItem"); +const { ContainerCLI } = require("../../Interfaces/ContainerCLI"); +const { ComposeCLI } = require("../../Interfaces/ComposeCLI"); +const { Terminal } = require("../../Interfaces/Terminal"); +const containers = require("../Models/Container"); +const notify = require("../../Utilities/Notify"); +const prompt = require("../../Utilities/Prompt"); + +class ContainersController extends Controller { + constructor(sidebar) { + super("apple_container.containers"); + this.sidebar = sidebar; + this.registerCommands(); + } + + async fetch() { + let list = await ContainerCLI.listContainers(); + let group = nova.config.get("apple_container.containers.group", "boolean") !== false; + let elements = containers.build(list, { group: group }); + + // Attach the compose file to each project so the compose commands and + // the tooltip have something to work with. + for (let element of elements) { + if (element.compose) { + element.file = this.composeFileFor(element.name); + } + } + + return elements; + } + + getTreeItem(element) { + return element instanceof containers.ProjectModel + ? new ProjectTreeItem(element) + : new ContainerTreeItem(element); + } + + // Containers in the current selection, expanding any selected projects. + selectedContainers() { + let selected = []; + + for (let element of this.selection) { + if (element instanceof containers.ProjectModel) { + selected = selected.concat(element.children); + } else { + selected.push(element); + } + } + + return selected; + } + + selectedProjects() { + return this.selection.filter((element) => element instanceof containers.ProjectModel); + } + + // Finds the workspace compose file whose project name matches, which is how + // a sidebar row is tied back to a file on disk. + composeFileFor(name) { + for (let file of ComposeCLI.workspaceFiles()) { + if (ComposeCLI.projectName(file) === name) { + return file; + } + } + + let fallback = ComposeCLI.defaultFile(); + + if (fallback && ComposeCLI.projectName(fallback) === name) { + return fallback; + } + + return null; + } + + // `build` receives the resolved compose file: the arguments cannot be built + // before the file is known. + async runCompose(project, build, title) { + let file = project.file || this.composeFileFor(project.name); + + if (!file) { + notify.failure( + `No compose file for “${project.name}”`, + "Open the project's workspace, or set the compose file in workspace settings.", + "apple-container-compose-missing" + ); + return; + } + + let directory = nova.path.dirname(file); + let args = build(file); + + if (nova.config.get("apple_container.compose.terminal", "boolean") !== false) { + await Terminal.run(Terminal.command([ComposeCLI.path()].concat(args)), directory); + return; + } + + try { + await ComposeCLI.run(args, { cwd: directory }); + } catch (error) { + notify.failure(title, error, "apple-container-compose"); + } finally { + this.reload(); + } + } + + registerCommands() { + this.register("apple_container.containers.reload", () => this.reload()); + + this.register("apple_container.containers.start", async () => { + let ids = this.selectedContainers().map((container) => container.id); + + try { + await ContainerCLI.startContainers(ids); + } catch (error) { + notify.failure("Unable to start container", error, "apple-container-start"); + } + + this.reload(); + }); + + this.register("apple_container.containers.stop", () => + this.runOnSelection((ids) => ContainerCLI.stopContainers(ids), "Unable to stop container") + ); + + this.register("apple_container.containers.restart", () => + this.runOnSelection((ids) => ContainerCLI.restartContainers(ids), "Unable to restart container") + ); + + this.register("apple_container.containers.kill", () => + this.runOnSelection((ids) => ContainerCLI.killContainers(ids), "Unable to kill container") + ); + + this.register("apple_container.containers.startGroup", async () => { + let ids = this.selectedProjects() + .reduce((all, project) => all.concat(project.children), []) + .filter((container) => !container.running) + .map((container) => container.id); + + try { + await ContainerCLI.startContainers(ids); + } catch (error) { + notify.failure("Unable to start containers", error, "apple-container-start"); + } + + this.reload(); + }); + + this.register("apple_container.containers.stopGroup", async () => { + let ids = this.selectedProjects() + .reduce((all, project) => all.concat(project.children), []) + .filter((container) => container.running) + .map((container) => container.id); + + if (ids.length === 0) { + return; + } + + try { + await ContainerCLI.stopContainers(ids); + } catch (error) { + notify.failure("Unable to stop containers", error, "apple-container-stop"); + } + + this.reload(); + }); + + this.register("apple_container.containers.delete", async () => { + let selected = this.selectedContainers(); + + if (selected.length === 0) { + return; + } + + let running = selected.some((container) => container.running); + let names = selected.map((container) => container.name).join(", "); + let message = running + ? `Delete ${names}? Running containers will be stopped first.` + : `Delete ${names}?`; + + if (!(await prompt.confirm(message))) { + return; + } + + try { + await ContainerCLI.deleteContainers(selected.map((container) => container.id), running); + } catch (error) { + notify.failure("Unable to delete container", error, "apple-container-delete"); + } + + this.reload(); + }); + + this.register("apple_container.containers.prune", async () => { + if (!(await prompt.confirm("Remove all stopped containers?", "Remove"))) { + return; + } + + try { + await ContainerCLI.pruneContainers(); + } catch (error) { + notify.failure("Unable to prune containers", error, "apple-container-prune"); + } + + this.reload(); + }); + + this.register("apple_container.containers.shell", async () => { + let shell = nova.config.get("apple_container.cli.shell", "string") || "/bin/sh"; + + for (let container of this.selectedContainers()) { + await Terminal.run( + Terminal.command([ContainerCLI.path(), "exec", "--interactive", "--tty", container.id, shell]) + ); + } + }); + + this.register("apple_container.containers.follow", async () => { + for (let container of this.selectedContainers()) { + await Terminal.run(Terminal.command([ContainerCLI.path(), "logs", "--follow", container.id])); + } + }); + + this.register("apple_container.containers.logs", () => this.openLogs([])); + this.register("apple_container.containers.bootLogs", () => this.openLogs(["--boot"])); + + this.register("apple_container.containers.browse", async () => { + let url = await this.chooseURL(); + + if (url) { + nova.openURL(url); + } + }); + + this.register("apple_container.containers.inspect", async () => { + for (let container of this.selectedContainers()) { + try { + let output = await ContainerCLI.inspectContainer(container.id); + nova.workspace.openNewTextDocument({ content: output, syntax: "json" }); + } catch (error) { + notify.failure("Unable to inspect container", error, "apple-container-inspect"); + } + } + }); + + this.register("apple_container.containers.copy", async () => { + let container = this.selectedContainers()[0]; + + if (!container) { + return; + } + + let fields = [["Name", container.name], ["Image", container.image]]; + + if (container.address) { + fields.push(["Address", container.address]); + } + if (container.hostname) { + fields.push(["Hostname", container.hostname]); + } + if (container.url) { + fields.push(["URL", container.url]); + } + + let choice = await prompt.choose( + fields.map(([label, value]) => `${label}: ${value}`), + { placeholder: "Copy" } + ); + + if (choice) { + nova.clipboard.writeText(choice.slice(choice.indexOf(": ") + 2)); + } + }); + + this.register("apple_container.compose.upProject", async () => { + for (let project of this.selectedProjects()) { + await this.runCompose(project, (file) => ComposeCLI.upArgs(file), "Compose up failed"); + } + }); + + this.register("apple_container.compose.downProject", async () => { + for (let project of this.selectedProjects()) { + await this.runCompose(project, (file) => ComposeCLI.downArgs(file), "Compose down failed"); + } + }); + + this.register("apple_container.compose.buildProject", async () => { + for (let project of this.selectedProjects()) { + await this.runCompose(project, (file) => ComposeCLI.buildArgs(file), "Compose build failed"); + } + }); + + this.register("apple_container.compose.openFile", () => { + for (let project of this.selectedProjects()) { + let file = project.file || this.composeFileFor(project.name); + + if (file) { + nova.workspace.openFile(file); + } + } + }); + + for (let key of [ + "apple_container.containers.group", + "apple_container.containers.label", + "apple_container.containers.icons", + "apple_container.containers.desc.image", + "apple_container.containers.desc.ports", + "apple_container.containers.desc.address", + "apple_container.containers.desc.state" + ]) { + this.watch(key, () => this.reload()); + } + } + + async runOnSelection(action, message) { + let ids = this.selectedContainers().map((container) => container.id); + + if (ids.length === 0) { + return; + } + + try { + await action(ids); + } catch (error) { + notify.failure(message, error, "apple-container-action"); + } + + this.reload(); + } + + // Picks the address to open: straight through when there is only one, and a + // palette when a container publishes several ports. + async chooseURL() { + let container = this.selectedContainers()[0]; + + if (!container) { + return null; + } + + let urls = container.urls; + + if (urls.length === 0) { + notify.info( + `Nothing to open for ${container.name}`, + container.running + ? "The container publishes no ports and has no address yet." + : "The container is not running.", + "apple-container-browse" + ); + return null; + } + + if (urls.length === 1) { + return urls[0].url; + } + + let choice = await prompt.choose(urls.map((entry) => entry.label), { + placeholder: `Open ${container.name}` + }); + + if (!choice) { + return null; + } + + let picked = urls.find((entry) => entry.label === choice); + return picked ? picked.url : null; + } + + async openLogs(flags) { + for (let container of this.selectedContainers()) { + try { + let result = await ContainerCLI.run(["logs"].concat(flags, [container.id])); + let output = result.stdout || result.stderr || "No log output."; + nova.workspace.openNewTextDocument({ content: output }); + } catch (error) { + notify.failure("Unable to read logs", error, "apple-container-logs"); + } + } + } +} + +module.exports = { ContainersController }; diff --git a/Scripts/Sidebar/TreeViews/Controller.js b/Scripts/Sidebar/TreeViews/Controller.js new file mode 100644 index 0000000..a3df9c6 --- /dev/null +++ b/Scripts/Sidebar/TreeViews/Controller.js @@ -0,0 +1,151 @@ +// +// Base class for the sidebar's tree controllers. +// +// Apple Container has no event stream, so the sidebar polls. Reloading a +// TreeView drops the user's selection, so each refresh compares a fingerprint +// of the fetched data and only reloads when something actually changed. +// + +class Controller { + constructor(id) { + this.id = id; + this.elements = []; + this.fingerprint = null; + // Collapsed rather than expanded, so groups keep their default of open. + this.collapsed = new Set(); + this.disposables = new CompositeDisposable(); + + this.tree = new TreeView(id, { dataProvider: this }); + this.disposables.add(this.tree); + + this.disposables.add( + this.tree.onDidExpandElement((element) => { + this.collapsed.delete(element.identifier); + element.expanded = true; + }) + ); + + this.disposables.add( + this.tree.onDidCollapseElement((element) => { + this.collapsed.add(element.identifier); + element.expanded = false; + }) + ); + } + + // Subclasses return the elements to display, or null to leave the tree as + // it is (used when the backing service is unavailable). + async fetch() { + return []; + } + + // A stable string describing the current data. When it does not change, + // the tree is left alone. + signature(elements) { + let describe = (element) => [element.identifier, element.revision]; + + return JSON.stringify( + elements.map((element) => describe(element).concat([(element.children || []).map(describe)])) + ); + } + + get visible() { + // TreeView.visible is only meaningful once the sidebar has been shown. + return this.tree.visible !== false; + } + + async refresh(options = {}) { + let elements; + + try { + elements = await this.fetch(); + } catch (error) { + if (nova.inDevMode()) { + console.warn(`${this.id}: ${error.message}`); + } + elements = []; + } + + if (elements === null) { + return; + } + + this.restoreExpansion(elements); + + let signature = this.signature(elements); + + if (!options.force && signature === this.fingerprint) { + return; + } + + this.fingerprint = signature; + this.elements = elements; + this.tree.reload(); + } + + // Rebuilt models are fresh objects, so expansion has to be carried over. + restoreExpansion(elements) { + for (let element of elements) { + if (!element.children || element.children.length === 0) { + continue; + } + if (this.collapsed.has(element.identifier)) { + element.expanded = false; + } + } + } + + // Empties the tree, for when the backing service goes away. + clear() { + if (this.elements.length === 0) { + return; + } + + this.elements = []; + this.fingerprint = null; + this.tree.reload(); + } + + // Invalidates the fingerprint so the next refresh always reloads. + invalidate() { + this.fingerprint = null; + } + + reload() { + this.invalidate(); + return this.refresh({ force: true }); + } + + get selection() { + return this.tree.selection || []; + } + + getChildren(element) { + if (!element) { + return this.elements; + } + return element.children || []; + } + + getParent(element) { + return element.parent; + } + + getTreeItem() { + throw new Error("getTreeItem must be implemented by the controller."); + } + + register(command, handler) { + this.disposables.add(nova.commands.register(command, handler)); + } + + watch(key, handler) { + this.disposables.add(nova.config.onDidChange(key, handler)); + } + + dispose() { + this.disposables.dispose(); + } +} + +module.exports = { Controller }; diff --git a/Scripts/Sidebar/TreeViews/Images.js b/Scripts/Sidebar/TreeViews/Images.js new file mode 100644 index 0000000..103b965 --- /dev/null +++ b/Scripts/Sidebar/TreeViews/Images.js @@ -0,0 +1,198 @@ +// +// Images section, optionally grouped by repository. +// + +const { Controller } = require("./Controller"); +const { ImageTreeItem, RepositoryTreeItem } = require("../TreeItems/ImageTreeItem"); +const { ContainerCLI } = require("../../Interfaces/ContainerCLI"); +const { Terminal } = require("../../Interfaces/Terminal"); +const images = require("../Models/Image"); +const notify = require("../../Utilities/Notify"); +const prompt = require("../../Utilities/Prompt"); + +class ImagesController extends Controller { + constructor(sidebar) { + super("apple_container.images"); + this.sidebar = sidebar; + this.registerCommands(); + } + + async fetch() { + let list = await ContainerCLI.listImages(); + + return images.build(list, { + group: nova.config.get("apple_container.images.group", "boolean") !== false, + registry: nova.config.get("apple_container.images.registry", "boolean") === true, + infrastructure: nova.config.get("apple_container.images.infra", "boolean") === true + }); + } + + getTreeItem(element) { + if (element instanceof images.RepositoryModel) { + return new RepositoryTreeItem(element); + } + + return new ImageTreeItem(element, { grouped: element.parent !== null }); + } + + // References for the current selection, expanding repositories into tags. + selectedReferences() { + let selected = []; + + for (let element of this.selection) { + if (element instanceof images.RepositoryModel) { + selected = selected.concat(element.children.map((image) => image.reference)); + } else { + selected.push(element.reference); + } + } + + return selected; + } + + registerCommands() { + this.register("apple_container.images.reload", () => this.reload()); + + this.register("apple_container.images.run", async () => { + let reference = this.selectedReferences()[0]; + + if (!reference) { + return; + } + + await runImage(reference); + this.sidebar.reloadContainers(); + }); + + this.register("apple_container.images.pull", async () => { + for (let reference of this.selectedReferences()) { + try { + await ContainerCLI.pullImage(reference); + } catch (error) { + notify.failure(`Unable to pull ${reference}`, error, "apple-container-pull"); + } + } + + this.reload(); + }); + + this.register("apple_container.images.push", async () => { + for (let reference of this.selectedReferences()) { + try { + await ContainerCLI.pushImage(reference); + notify.info("Push complete", reference, "apple-container-push"); + } catch (error) { + notify.failure(`Unable to push ${reference}`, error, "apple-container-push"); + } + } + }); + + this.register("apple_container.images.tag", async () => { + let reference = this.selectedReferences()[0]; + + if (!reference) { + return; + } + + let target = await prompt.input("New tag", { placeholder: reference, value: reference }); + + if (!target) { + return; + } + + try { + await ContainerCLI.tagImage(reference, target); + } catch (error) { + notify.failure("Unable to tag image", error, "apple-container-tag"); + } + + this.reload(); + }); + + this.register("apple_container.images.inspect", async () => { + for (let reference of this.selectedReferences()) { + try { + let output = await ContainerCLI.inspectImage(reference); + nova.workspace.openNewTextDocument({ content: output, syntax: "json" }); + } catch (error) { + notify.failure("Unable to inspect image", error, "apple-container-inspect"); + } + } + }); + + this.register("apple_container.images.copy", () => { + let reference = this.selectedReferences()[0]; + + if (reference) { + nova.clipboard.writeText(reference); + } + }); + + this.register("apple_container.images.delete", async () => { + let references = this.selectedReferences(); + + if (references.length === 0) { + return; + } + + if (!(await prompt.confirm(`Delete ${references.join(", ")}?`))) { + return; + } + + try { + await ContainerCLI.deleteImages(references); + } catch (error) { + notify.failure("Unable to delete image", error, "apple-container-delete-image"); + } + + this.reload(); + }); + + this.register("apple_container.images.prune", async () => { + if (!(await prompt.confirm("Remove images not referenced by a container?", "Remove"))) { + return; + } + + try { + await ContainerCLI.pruneImages(); + } catch (error) { + notify.failure("Unable to prune images", error, "apple-container-prune-image"); + } + + this.reload(); + }); + + for (let key of [ + "apple_container.images.group", + "apple_container.images.registry", + "apple_container.images.infra" + ]) { + this.watch(key, () => this.reload()); + } + } +} + +// Runs an image in the terminal, so its output is visible and an interactive +// image still works. Arguments are offered because `container run` takes no +// defaults worth guessing. +async function runImage(reference) { + let options = await prompt.input("Arguments for container run", { + placeholder: "--detach --name my-container --publish 8080:80" + }); + + if (options === null) { + return null; + } + + let parts = [ContainerCLI.path(), "run"]; + + if (options) { + parts = parts.concat(options.split(/\s+/)); + } + + parts.push(reference); + + return Terminal.run(Terminal.command(parts)); +} + +module.exports = { ImagesController, runImage }; diff --git a/Scripts/Sidebar/TreeViews/Networks.js b/Scripts/Sidebar/TreeViews/Networks.js new file mode 100644 index 0000000..9f539c6 --- /dev/null +++ b/Scripts/Sidebar/TreeViews/Networks.js @@ -0,0 +1,114 @@ +// +// Networks section. +// + +const { Controller } = require("./Controller"); +const { NetworkTreeItem } = require("../TreeItems/NetworkTreeItem"); +const { ContainerCLI } = require("../../Interfaces/ContainerCLI"); +const networks = require("../Models/Network"); +const notify = require("../../Utilities/Notify"); +const prompt = require("../../Utilities/Prompt"); + +class NetworksController extends Controller { + constructor(sidebar) { + super("apple_container.networks"); + this.sidebar = sidebar; + this.registerCommands(); + } + + async fetch() { + return networks.build(await ContainerCLI.listNetworks()); + } + + getTreeItem(element) { + return new NetworkTreeItem(element); + } + + registerCommands() { + this.register("apple_container.networks.reload", () => this.reload()); + + this.register("apple_container.networks.create", async () => { + // A name on its own is the common case, but `container network + // create` also takes --subnet, --internal and friends, so the whole + // argument line is accepted here. + let answer = await prompt.input("Network name, and any options", { + placeholder: "backend --subnet 192.168.70.0/24" + }); + + if (!answer) { + return; + } + + try { + await ContainerCLI.createNetwork(answer.split(/\s+/)); + } catch (error) { + notify.failure("Unable to create network", error, "apple-container-network"); + } + + this.reload(); + }); + + this.register("apple_container.networks.inspect", async () => { + for (let network of this.selection) { + try { + let output = await ContainerCLI.inspectNetwork(network.name); + nova.workspace.openNewTextDocument({ content: output, syntax: "json" }); + } catch (error) { + notify.failure("Unable to inspect network", error, "apple-container-inspect"); + } + } + }); + + this.register("apple_container.networks.copy", () => { + let network = this.selection[0]; + + if (network) { + nova.clipboard.writeText(network.subnet || network.name); + } + }); + + this.register("apple_container.networks.delete", async () => { + let selected = this.selection; + let builtin = selected.filter((network) => network.builtin); + + if (builtin.length > 0) { + notify.info( + "Built-in network", + `${builtin.map((network) => network.name).join(", ")} is managed by Apple Container and cannot be deleted.`, + "apple-container-network-builtin" + ); + return; + } + + let names = selected.map((network) => network.name); + + if (names.length === 0 || !(await prompt.confirm(`Delete ${names.join(", ")}?`))) { + return; + } + + try { + await ContainerCLI.deleteNetworks(names); + } catch (error) { + notify.failure("Unable to delete network", error, "apple-container-delete-network"); + } + + this.reload(); + }); + + this.register("apple_container.networks.prune", async () => { + if (!(await prompt.confirm("Remove networks with no connected containers?", "Remove"))) { + return; + } + + try { + await ContainerCLI.pruneNetworks(); + } catch (error) { + notify.failure("Unable to prune networks", error, "apple-container-prune-network"); + } + + this.reload(); + }); + } +} + +module.exports = { NetworksController }; diff --git a/Scripts/Sidebar/TreeViews/System.js b/Scripts/Sidebar/TreeViews/System.js new file mode 100644 index 0000000..4ed4765 --- /dev/null +++ b/Scripts/Sidebar/TreeViews/System.js @@ -0,0 +1,221 @@ +// +// System section: the container services, the builder, disk usage and any +// local DNS domains. +// + +const { Controller } = require("./Controller"); +const { SystemTreeItem } = require("../TreeItems/SystemTreeItem"); +const { ContainerCLI } = require("../../Interfaces/ContainerCLI"); +const systemConfig = require("../../Utilities/SystemConfig"); +const format = require("../../Utilities/Format"); +const { Terminal } = require("../../Interfaces/Terminal"); +const notify = require("../../Utilities/Notify"); +const prompt = require("../../Utilities/Prompt"); + +class SystemController extends Controller { + constructor(sidebar) { + super("apple_container.system"); + this.sidebar = sidebar; + this.running = false; + this.registerCommands(); + } + + async fetch() { + let status = await ContainerCLI.systemStatus(); + let running = status.status === "running"; + let rows = []; + + this.running = running; + + rows.push({ + identifier: "system.services", + label: "Services", + running: running, + contextValue: running ? "system.services.running" : "system.services.stopped", + description: running ? version(status.apiServerVersion) : "stopped", + tooltip: statusTooltip(status), + copy: status.apiServerVersion || "", + children: [] + }); + + if (!running) { + return rows; + } + + let [builder, usage, domains] = await Promise.all([ + ContainerCLI.builderRunning(), + ContainerCLI.systemDiskUsage(), + ContainerCLI.dnsDomains() + ]); + + rows.push({ + identifier: "system.builder", + label: "Builder", + running: builder, + contextValue: builder ? "system.builder.running" : "system.builder.stopped", + description: builder ? "running" : "stopped", + tooltip: "The builder container backing `container build`.", + copy: "builder", + children: [] + }); + + if (usage) { + rows.push({ + identifier: "system.disk", + label: "Disk Usage", + contextValue: "system.disk", + image: "icons/system.png", + description: usageSummary(usage), + tooltip: usageTooltip(usage), + copy: usageSummary(usage), + children: [] + }); + } + + // The domain containers are named under, which is separate from the + // domains macOS has been told to resolve. + let hostnameDomain = systemConfig.defaultDomain(); + let resolved = hostnameDomain !== null && domains.includes(hostnameDomain); + + rows.push({ + identifier: "system.dns.default", + label: "Hostname Domain", + contextValue: "system.dns.default", + image: hostnameDomain && !resolved ? "icons/status/warning.png" : "icons/network.png", + description: hostnameDomain ? `.${hostnameDomain}` : "not set", + tooltip: hostnameTooltip(hostnameDomain, resolved), + copy: hostnameDomain || "", + children: [] + }); + + for (let domain of domains) { + rows.push({ + identifier: `system.dns.${domain}`, + label: domain, + contextValue: "system.dns", + image: "icons/network.png", + description: "resolved by macOS", + tooltip: `macOS resolves *.${domain} through Apple Container's DNS service.`, + copy: domain, + children: [] + }); + } + + return rows; + } + + signature(rows) { + return JSON.stringify(rows.map((row) => [row.identifier, row.description, row.running])); + } + + getTreeItem(element) { + return new SystemTreeItem(element); + } + + registerCommands() { + this.register("apple_container.system.df", async () => { + try { + let result = await ContainerCLI.run(["system", "df", "--format", "json"]); + nova.workspace.openNewTextDocument({ content: result.stdout, syntax: "json" }); + } catch (error) { + notify.failure("Unable to read disk usage", error, "apple-container-df"); + } + }); + + this.register("apple_container.dns.unregister", async () => { + let row = this.selection[0]; + + if (!row || !row.copy) { + return; + } + + if ( + !(await prompt.confirm( + `Stop macOS resolving *.${row.copy}? This runs sudo in your terminal.`, + "Unregister" + )) + ) { + return; + } + + await Terminal.run( + `sudo ${Terminal.command([ContainerCLI.path(), "system", "dns", "delete", row.copy])}` + ); + }); + + this.register("apple_container.system.copy", () => { + let row = this.selection[0]; + + if (row && row.copy) { + nova.clipboard.writeText(row.copy); + } + }); + } +} + +function hostnameTooltip(domain, resolved) { + if (!domain) { + return [ + "Containers have unqualified hostnames.", + "Set a domain to reach them as .." + ].join("\n"); + } + + if (resolved) { + return `Containers are reachable as .${domain} from this Mac.`; + } + + return [ + `Containers are named .${domain}, but macOS does not resolve that domain yet.`, + "Use Set Up Container Hostnames to register it." + ].join("\n"); +} + +function version(text) { + if (!text) { + return "running"; + } + + let match = String(text).match(/version\s+(\S+)/); + return match ? `running ${match[1]}` : "running"; +} + +function statusTooltip(status) { + if (status.status !== "running") { + return "Container services are not running.\nUse Start Services to launch them."; + } + + return [ + `Status:\t${status.status}`, + status.apiServerVersion ? `Version:\t${status.apiServerVersion}` : "", + status.appRoot ? `App root:\t${status.appRoot}` : "", + status.installRoot ? `Install:\t${status.installRoot}` : "" + ] + .filter((line) => line.length > 0) + .join("\n"); +} + +function usageSummary(usage) { + let total = ["images", "containers", "volumes"].reduce( + (sum, key) => sum + ((usage[key] && usage[key].sizeInBytes) || 0), + 0 + ); + let reclaimable = ["images", "containers", "volumes"].reduce( + (sum, key) => sum + ((usage[key] && usage[key].reclaimable) || 0), + 0 + ); + + return `${format.bytes(total)} ${format.bytes(reclaimable)} reclaimable`; +} + +function usageTooltip(usage) { + return ["images", "containers", "volumes"] + .map((key) => { + let entry = usage[key] || {}; + let name = key.charAt(0).toUpperCase() + key.slice(1); + return `${name}:\t${entry.total || 0}\t${format.bytes(entry.sizeInBytes || 0)}`; + }) + .join("\n"); +} + +module.exports = { SystemController }; diff --git a/Scripts/Sidebar/TreeViews/Volumes.js b/Scripts/Sidebar/TreeViews/Volumes.js new file mode 100644 index 0000000..f490cdf --- /dev/null +++ b/Scripts/Sidebar/TreeViews/Volumes.js @@ -0,0 +1,116 @@ +// +// Volumes section. +// + +const { Controller } = require("./Controller"); +const { VolumeTreeItem } = require("../TreeItems/VolumeTreeItem"); +const { ContainerCLI } = require("../../Interfaces/ContainerCLI"); +const { exec } = require("../../Utilities/Process"); +const volumes = require("../Models/Volume"); +const notify = require("../../Utilities/Notify"); +const prompt = require("../../Utilities/Prompt"); + +class VolumesController extends Controller { + constructor(sidebar) { + super("apple_container.volumes"); + this.sidebar = sidebar; + this.registerCommands(); + } + + async fetch() { + return volumes.build(await ContainerCLI.listVolumes()); + } + + getTreeItem(element) { + return new VolumeTreeItem(element); + } + + registerCommands() { + this.register("apple_container.volumes.reload", () => this.reload()); + + this.register("apple_container.volumes.create", async () => { + let name = await prompt.input("Volume name", { placeholder: "database-data" }); + + if (!name) { + return; + } + + try { + await ContainerCLI.createVolume(name); + } catch (error) { + notify.failure("Unable to create volume", error, "apple-container-volume"); + } + + this.reload(); + }); + + this.register("apple_container.volumes.inspect", async () => { + for (let volume of this.selection) { + try { + let output = await ContainerCLI.inspectVolume(volume.name); + nova.workspace.openNewTextDocument({ content: output, syntax: "json" }); + } catch (error) { + notify.failure("Unable to inspect volume", error, "apple-container-inspect"); + } + } + }); + + this.register("apple_container.volumes.reveal", async () => { + for (let volume of this.selection) { + if (!volume.source) { + continue; + } + + try { + await exec("/usr/bin/open", ["-R", volume.source]); + } catch (error) { + notify.failure("Unable to reveal volume", error, "apple-container-reveal"); + } + } + }); + + this.register("apple_container.volumes.copy", () => { + let volume = this.selection[0]; + + if (volume) { + nova.clipboard.writeText(volume.name); + } + }); + + this.register("apple_container.volumes.delete", async () => { + let names = this.selection.map((volume) => volume.name); + + if (names.length === 0) { + return; + } + + if (!(await prompt.confirm(`Delete ${names.join(", ")}? Their contents are lost.`))) { + return; + } + + try { + await ContainerCLI.deleteVolumes(names); + } catch (error) { + notify.failure("Unable to delete volume", error, "apple-container-delete-volume"); + } + + this.reload(); + }); + + this.register("apple_container.volumes.prune", async () => { + if (!(await prompt.confirm("Remove volumes no container references?", "Remove"))) { + return; + } + + try { + await ContainerCLI.pruneVolumes(); + } catch (error) { + notify.failure("Unable to prune volumes", error, "apple-container-prune-volume"); + } + + this.reload(); + }); + } +} + +module.exports = { VolumesController }; diff --git a/Scripts/Utilities/Format.js b/Scripts/Utilities/Format.js new file mode 100644 index 0000000..7208053 --- /dev/null +++ b/Scripts/Utilities/Format.js @@ -0,0 +1,107 @@ +// +// Formatting helpers shared by the tree items. +// + +const UNITS = ["B", "KB", "MB", "GB", "TB", "PB"]; + +function bytes(value) { + if (typeof value !== "number" || !isFinite(value) || value < 0) { + return ""; + } + + let index = 0; + let size = value; + + while (size >= 1000 && index < UNITS.length - 1) { + size = size / 1000; + index += 1; + } + + let precision = size >= 100 || index === 0 ? 0 : 1; + return `${size.toFixed(precision)} ${UNITS[index]}`; +} + +// Relative age, in the same spirit as Docker's "2 hours ago". +function age(value) { + if (!value) { + return ""; + } + + let date = value instanceof Date ? value : new Date(value); + + if (isNaN(date.getTime()) || date.getTime() === 0) { + return ""; + } + + let seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000)); + + if (seconds < 60) { + return "just now"; + } + + let steps = [ + ["minute", 60], + ["hour", 60], + ["day", 24], + ["week", 7], + ["month", 4.35], + ["year", 12] + ]; + + let amount = seconds; + let label = "second"; + + for (let [name, divisor] of steps) { + if (amount < divisor) { + break; + } + amount = amount / divisor; + label = name; + } + + let rounded = Math.floor(amount); + return `${rounded} ${label}${rounded === 1 ? "" : "s"} ago`; +} + +// Splits an image reference into its registry, repository and tag. +function reference(value) { + let text = String(value || ""); + let digest = null; + let tag = null; + + let atIndex = text.lastIndexOf("@"); + if (atIndex > 0) { + digest = text.slice(atIndex + 1); + text = text.slice(0, atIndex); + } + + let colonIndex = text.lastIndexOf(":"); + let slashIndex = text.lastIndexOf("/"); + if (colonIndex > slashIndex) { + tag = text.slice(colonIndex + 1); + text = text.slice(0, colonIndex); + } + + let registry = null; + let repository = text; + let firstSlash = text.indexOf("/"); + + if (firstSlash > 0) { + let candidate = text.slice(0, firstSlash); + if (candidate.includes(".") || candidate.includes(":") || candidate === "localhost") { + registry = candidate; + repository = text.slice(firstSlash + 1); + } + } + + return { + registry: registry, + repository: repository, + tag: tag || "latest", + digest: digest, + // docker.io/library/nginx reads better as nginx. + short: repository.replace(/^library\//, "") + }; +} + +module.exports = { bytes, age, reference }; diff --git a/Scripts/Utilities/Notify.js b/Scripts/Utilities/Notify.js new file mode 100644 index 0000000..b23936f --- /dev/null +++ b/Scripts/Utilities/Notify.js @@ -0,0 +1,53 @@ +// +// Notification helpers. Each notification uses a stable identifier so that +// repeated failures replace one another rather than stacking up. +// + +function notify(identifier, title, body, actions) { + let request = new NotificationRequest(identifier); + request.title = title; + request.body = body; + + if (actions && actions.length > 0) { + request.actions = actions.map((action) => action.title); + } + + return nova.notifications.add(request).then((reply) => { + if (!actions || reply.actionIdx === undefined || reply.actionIdx === null) { + return reply; + } + + let action = actions[reply.actionIdx]; + if (action && action.handler) { + action.handler(); + } + return reply; + }); +} + +// Reports a failed operation, logging the underlying error for the console. +function failure(title, error, identifier) { + let message = error instanceof Error ? error.message : String(error || ""); + + if (nova.inDevMode()) { + console.error(title, message); + } + + return notify(identifier || "apple-container-failure", title, message); +} + +// Reports a failure with a shortcut to the extension's preferences. +function configFailure(title, error, identifier) { + let message = error instanceof Error ? error.message : String(error || ""); + + return notify(identifier || "apple-container-config", title, message, [ + { title: "Preferences", handler: () => nova.openConfig() }, + { title: "Dismiss" } + ]); +} + +function info(title, body, identifier) { + return notify(identifier || "apple-container-info", title, body); +} + +module.exports = { notify, failure, configFailure, info }; diff --git a/Scripts/Utilities/Process.js b/Scripts/Utilities/Process.js new file mode 100644 index 0000000..5257944 --- /dev/null +++ b/Scripts/Utilities/Process.js @@ -0,0 +1,89 @@ +// +// Thin promise wrappers around Nova's Process API. +// + +// Runs a command to completion and resolves with its captured output. +// Rejects with an Error carrying the exit status and stderr when the +// command fails, so callers can surface a useful message. +function exec(path, args, options = {}) { + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + let process; + + try { + process = new Process(path, { + args: args, + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"] + }); + } catch (error) { + reject(error); + return; + } + + process.onStdout((line) => { + stdout += line; + }); + + process.onStderr((line) => { + stderr += line; + }); + + process.onDidExit((status) => { + if (status === 0 || options.ignoreStatus) { + resolve({ status: status, stdout: stdout, stderr: stderr }); + return; + } + + let error = new Error(cleanMessage(stderr) || `${path} exited with status ${status}.`); + error.status = status; + error.stdout = stdout; + error.stderr = stderr; + reject(error); + }); + + try { + process.start(); + } catch (error) { + reject(error); + } + }); +} + +// Runs a command and parses its stdout as JSON. +async function execJSON(path, args, options = {}) { + let result = await exec(path, args, options); + + if (!result.stdout.trim()) { + return []; + } + + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error(`Unable to parse output of ${path}: ${error.message}`); + } +} + +// Apple Container prefixes failures with "Error: " and appends a hint about +// starting the system services. Keep the first meaningful line only. +function cleanMessage(stderr) { + if (!stderr) { + return ""; + } + + let line = stderr + .split("\n") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0)[0]; + + if (!line) { + return ""; + } + + return line.replace(/^Error:\s*/, ""); +} + +module.exports = { exec, execJSON, cleanMessage }; diff --git a/Scripts/Utilities/Prompt.js b/Scripts/Utilities/Prompt.js new file mode 100644 index 0000000..289e3df --- /dev/null +++ b/Scripts/Utilities/Prompt.js @@ -0,0 +1,34 @@ +// +// Promise wrappers around Nova's workspace prompts. +// + +// Asks for confirmation before something destructive. Resolves true when the +// destructive button is chosen. +function confirm(message, buttonTitle = "Delete") { + return new Promise((resolve) => { + nova.workspace.showActionPanel( + message, + { buttons: [buttonTitle, "Cancel"] }, + (index) => resolve(index === 0) + ); + }); +} + +// Asks for a single line of text. Resolves null when cancelled, and the +// trimmed text otherwise -- which may be an empty string. +function input(label, options = {}) { + return new Promise((resolve) => { + nova.workspace.showInputPanel(label, options, (value) => { + resolve(value === null || value === undefined ? null : value.trim()); + }); + }); +} + +// Asks the user to pick from a list. Resolves null when cancelled. +function choose(choices, options = {}) { + return new Promise((resolve) => { + nova.workspace.showChoicePalette(choices, options, (choice) => resolve(choice || null)); + }); +} + +module.exports = { confirm, input, choose }; diff --git a/Scripts/Utilities/SystemConfig.js b/Scripts/Utilities/SystemConfig.js new file mode 100644 index 0000000..a0bda38 --- /dev/null +++ b/Scripts/Utilities/SystemConfig.js @@ -0,0 +1,142 @@ +// +// Reads and writes Apple Container's system configuration. +// +// The default DNS domain -- the suffix every container's hostname gets -- lives +// in config.toml, and the CLI has no setter for it (`container system property` +// only lists). The file is small and hand-editable, so it is edited in place. +// + +const { exec } = require("./Process"); + +const RELATIVE_PATH = "Library/Application Support/com.apple.container/config/config.toml"; + +function path() { + let home = nova.environment.HOME; + + if (!home) { + return null; + } + + return nova.path.join(home, RELATIVE_PATH); +} + +function read() { + let file = path(); + + if (!file || !nova.fs.access(file, nova.fs.R_OK)) { + return ""; + } + + try { + let handle = nova.fs.open(file, "r"); + let contents = handle.read(); + handle.close(); + return contents || ""; + } catch (error) { + return ""; + } +} + +// Locates the [dns] table's body within a set of lines. A table runs until the +// next table header, which is a line that *starts* with "[" -- an array value +// such as `nameservers = ["1.1.1.1"]` does not end it. +function dnsTable(lines) { + let start = lines.findIndex((line) => /^\s*\[dns\]\s*$/.test(line)); + + if (start === -1) { + return null; + } + + let end = lines.findIndex((line, index) => index > start && /^\s*\[/.test(line)); + + return { start: start, end: end === -1 ? lines.length : end }; +} + +// The configured default DNS domain, or null when containers get no suffix. +function defaultDomain() { + let lines = read().split("\n"); + let table = dnsTable(lines); + + if (!table) { + return null; + } + + for (let line of lines.slice(table.start + 1, table.end)) { + let match = line.match(/^\s*domain\s*=\s*["']([^"']*)["']/); + + if (match) { + return match[1].length > 0 ? match[1] : null; + } + } + + return null; +} + +// Rewrites the `domain` key inside the `[dns]` table, adding either as needed. +// Passing null removes the key, which restores unsuffixed hostnames. +function withDomain(contents, domain) { + let assignment = domain === null ? "" : `domain = "${domain}"\n`; + let lines = contents.length > 0 ? contents.split("\n") : []; + let table = dnsTable(lines); + + if (!table) { + if (domain === null) { + return contents; + } + + let prefix = contents.length > 0 && !contents.endsWith("\n") ? `${contents}\n` : contents; + return `${prefix}\n[dns]\n${assignment}`; + } + + let body = lines.slice(table.start + 1, table.end); + let existing = body.findIndex((line) => /^\s*domain\s*=/.test(line)); + + if (existing === -1) { + if (domain !== null) { + body.unshift(assignment.trimEnd()); + } + } else if (domain === null) { + body.splice(existing, 1); + } else { + body[existing] = assignment.trimEnd(); + } + + return lines + .slice(0, table.start + 1) + .concat(body, lines.slice(table.end)) + .join("\n"); +} + +async function setDefaultDomain(domain) { + let file = path(); + + if (!file) { + throw new Error("Could not locate Apple Container's configuration directory."); + } + + let contents = nova.fs.access(file, nova.fs.F_OK) ? read() : ""; + let updated = withDomain(contents, domain); + + // The file ships read-only, so widen it, write, and put the mode back. + let existed = nova.fs.access(file, nova.fs.F_OK); + + if (existed) { + await exec("/bin/chmod", ["u+w", file]); + } else { + let directory = nova.path.dirname(file); + + if (!nova.fs.access(directory, nova.fs.F_OK)) { + nova.fs.mkdir(directory); + } + } + + let handle = nova.fs.open(file, "w"); + handle.write(updated); + handle.close(); + + await exec("/bin/chmod", ["444", file]); + + return updated; +} + +module.exports = { path, read, defaultDomain, setDefaultDomain, withDomain }; diff --git a/Scripts/main.js b/Scripts/main.js new file mode 100644 index 0000000..b7d7149 --- /dev/null +++ b/Scripts/main.js @@ -0,0 +1,73 @@ +// +// Apple Container for Nova. +// +// Entry point: wires up the sidebar, the extension's commands and the Docker +// language server, and tears them all down again on deactivate. +// + +const { ContainerSidebar } = require("./Sidebar/ContainerSidebar"); +const { Hostnames } = require("./Hostnames"); +const { DockerLanguageClient } = require("./LanguageClients/DockerLanguageClient"); +const { ContainerCLI } = require("./Interfaces/ContainerCLI"); +const { Commands } = require("./Commands"); +const notify = require("./Utilities/Notify"); + +let sidebar = null; +let languageClient = null; +let commands = null; +let hostnames = null; + +exports.activate = async function () { + commands = new Commands({ + sidebar: () => sidebar, + restartLanguageServer: () => (languageClient ? languageClient.restart() : null) + }); + + if (!(await installed())) { + notify.configFailure( + "Apple Container was not found", + "Install it from github.com/apple/container, or set the path to the container tool in the extension's preferences.", + "apple-container-missing" + ); + } else { + sidebar = new ContainerSidebar(); + hostnames = new Hostnames({ + reload: () => (sidebar ? sidebar.reload() : null) + }); + } + + languageClient = new DockerLanguageClient(); + await languageClient.start(); +}; + +exports.deactivate = function () { + if (sidebar) { + sidebar.dispose(); + sidebar = null; + } + + if (languageClient) { + languageClient.dispose(); + languageClient = null; + } + + if (hostnames) { + hostnames.dispose(); + hostnames = null; + } + + if (commands) { + commands.dispose(); + commands = null; + } +}; + +// The CLI is the one hard requirement: without it there is nothing to show. +async function installed() { + try { + await ContainerCLI.version(); + return true; + } catch (error) { + return false; + } +} diff --git a/Syntaxes/Compose.xml b/Syntaxes/Compose.xml new file mode 100644 index 0000000..bdfc37c --- /dev/null +++ b/Syntaxes/Compose.xml @@ -0,0 +1,50 @@ + + + + Compose + structured + yaml + yaml + + + + compose.yaml,compose.yml,docker-compose.yaml,docker-compose.yml,container-compose.yaml,container-compose.yml + + yaml,yml + ^services: + + + + + + :\s*$ + + + + + + # + + + + + + + + + + + + + + + + + ^(compose|docker-compose|dockercompose)$ + + + + + + + diff --git a/Syntaxes/Dockerfile.xml b/Syntaxes/Dockerfile.xml new file mode 100644 index 0000000..f8767d3 --- /dev/null +++ b/Syntaxes/Dockerfile.xml @@ -0,0 +1,44 @@ + + + + Dockerfile + script + dockerfile + + + + Dockerfile,Containerfile,dockerfile,containerfile + dockerfile,containerfile + ^#\s*syntax\s*=\s*docker + ^\s*FROM\s+\S+ + + + + + # + + + + + + + + + + + + + + + + + + ^(docker|dockerfile|containerfile)$ + + + + + + + + diff --git a/Syntaxes/libtree-sitter-compose.dylib b/Syntaxes/libtree-sitter-compose.dylib new file mode 100755 index 0000000000000000000000000000000000000000..b273d3b6948f5e9a6180057ca1b80566b3e63829 GIT binary patch literal 430208 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

XtR*BSOR(VjqOGefX(O8``r1plj39h<~S=++B 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

Kj1?Y`p_UbyJ>(wo+ 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

y(pY*;l<9 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

O6I1ZDv9fb)S%fop)9fjfbF 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.dylib and +# expects ABI 14, so both grammars are pinned to a revision that generates it. +# Run from anywhere: ./Tools/build-syntaxes.sh +# +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="${TMPDIR:-/tmp}/apple-container-grammars" +out="$root/Syntaxes" + +dockerfile_repo="https://github.com/camdencheek/tree-sitter-dockerfile.git" +dockerfile_ref="971acdd908568b4531b0ba28a445bf0bb720aba5" + +yaml_repo="https://github.com/tree-sitter-grammars/tree-sitter-yaml.git" +yaml_ref="v0.7.2" + +# Universal so the extension works on Apple silicon and Intel alike. +arches=(-arch arm64 -arch x86_64) + +fetch() { + local name="$1" + local repo="$2" + local ref="$3" + local dir="$work/$name" + + if [ ! -d "$dir" ]; then + git clone --quiet "$repo" "$dir" + fi + + git -C "$dir" fetch --quiet --tags origin + git -C "$dir" checkout --quiet "$ref" + + local version + version="$(grep -m1 'define LANGUAGE_VERSION' "$dir/src/parser.c" | awk '{print $3}')" + + if [ "$version" != "14" ]; then + echo "error: $name generates tree-sitter ABI $version, Nova expects 14" >&2 + exit 1 + fi +} + +mkdir -p "$work" "$out" + +fetch dockerfile "$dockerfile_repo" "$dockerfile_ref" +fetch yaml "$yaml_repo" "$yaml_ref" + +echo "building libtree-sitter-dockerfile.dylib" +cc "${arches[@]}" -dynamiclib -O2 -fPIC -std=c11 \ + -I "$work/dockerfile/src" \ + "$work/dockerfile/src/parser.c" "$work/dockerfile/src/scanner.c" \ + -o "$out/libtree-sitter-dockerfile.dylib" + +# Nova already ships a grammar registered as "yaml", so the compose grammar is +# built from the same sources under its own symbol names. That keeps the +# Compose syntax on the grammar these queries were written against instead of +# whichever yaml grammar happens to win. +echo "building libtree-sitter-compose.dylib" +renames=( + -Dtree_sitter_yaml=tree_sitter_compose + -Dtree_sitter_yaml_external_scanner_create=tree_sitter_compose_external_scanner_create + -Dtree_sitter_yaml_external_scanner_destroy=tree_sitter_compose_external_scanner_destroy + -Dtree_sitter_yaml_external_scanner_scan=tree_sitter_compose_external_scanner_scan + -Dtree_sitter_yaml_external_scanner_serialize=tree_sitter_compose_external_scanner_serialize + -Dtree_sitter_yaml_external_scanner_deserialize=tree_sitter_compose_external_scanner_deserialize +) + +cc "${arches[@]}" -dynamiclib -O2 -fPIC -std=c11 -DYAML_SCHEMA=core "${renames[@]}" \ + -I "$work/yaml/src" \ + "$work/yaml/src/parser.c" "$work/yaml/src/scanner.c" \ + -o "$out/libtree-sitter-compose.dylib" + +for library in "$out"/libtree-sitter-*.dylib; do + echo "$(basename "$library"): $(lipo -archs "$library")" +done diff --git a/Tools/make-icons.py b/Tools/make-icons.py new file mode 100644 index 0000000..9312079 --- /dev/null +++ b/Tools/make-icons.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Generates the extension's PNG assets. + +Everything is drawn at 8x and box filtered down, which is enough anti-aliasing +for icons this small and keeps the script free of dependencies. + +Usage: python3 Tools/make-icons.py +""" + +import math +import os +import struct +import zlib + +SS = 8 # supersampling factor + +GREEN = (52, 199, 89) +RED = (255, 69, 58) +GREY = (142, 142, 147) +BLUE = (10, 132, 255) +PURPLE = (175, 82, 222) +ORANGE = (255, 159, 10) +TEAL = (100, 210, 255) +WHITE = (255, 255, 255) + + +class Canvas: + def __init__(self, size): + self.size = size * SS + self.scale = SS + self.px = [[(0, 0, 0, 0)] * self.size for _ in range(self.size)] + + def _blend(self, x, y, colour, alpha): + if not (0 <= x < self.size and 0 <= y < self.size) or alpha <= 0: + return + r, g, b = colour + dr, dg, db, da = self.px[y][x] + na = alpha + da * (1 - alpha) + if na == 0: + return + self.px[y][x] = ( + int((r * alpha + dr * da * (1 - alpha)) / na), + int((g * alpha + dg * da * (1 - alpha)) / na), + int((b * alpha + db * da * (1 - alpha)) / na), + na, + ) + + def fill(self, test, colour, alpha=1.0): + for y in range(self.size): + for x in range(self.size): + if test(x + 0.5, y + 0.5): + self._blend(x, y, colour, alpha) + + def erase(self, test): + for y in range(self.size): + for x in range(self.size): + if test(x + 0.5, y + 0.5): + self.px[y][x] = (0, 0, 0, 0) + + # Shapes are described in 0..1 unit space and scaled up here. + def u(self, value): + return value * self.size + + def circle(self, cx, cy, r, colour, alpha=1.0): + cx, cy, r = self.u(cx), self.u(cy), self.u(r) + self.fill(lambda x, y: (x - cx) ** 2 + (y - cy) ** 2 <= r * r, colour, alpha) + + def ring(self, cx, cy, r, width, colour, alpha=1.0): + cx, cy, r, width = self.u(cx), self.u(cy), self.u(r), self.u(width) + inner = r - width + self.fill( + lambda x, y: inner * inner <= (x - cx) ** 2 + (y - cy) ** 2 <= r * r, + colour, + alpha, + ) + + def ellipse(self, cx, cy, rx, ry, colour, alpha=1.0): + cx, cy, rx, ry = self.u(cx), self.u(cy), self.u(rx), self.u(ry) + self.fill( + lambda x, y: ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1, colour, alpha + ) + + def rect(self, x0, y0, x1, y1, colour, alpha=1.0): + x0, y0, x1, y1 = self.u(x0), self.u(y0), self.u(x1), self.u(y1) + self.fill(lambda x, y: x0 <= x <= x1 and y0 <= y <= y1, colour, alpha) + + def rounded(self, x0, y0, x1, y1, radius, colour, alpha=1.0): + x0, y0, x1, y1, radius = ( + self.u(x0), + self.u(y0), + self.u(x1), + self.u(y1), + self.u(radius), + ) + + def test(x, y): + if not (x0 <= x <= x1 and y0 <= y <= y1): + return False + cx = min(max(x, x0 + radius), x1 - radius) + cy = min(max(y, y0 + radius), y1 - radius) + return (x - cx) ** 2 + (y - cy) ** 2 <= radius * radius + + self.fill(test, colour, alpha) + + def line(self, x0, y0, x1, y1, width, colour, alpha=1.0): + ax, ay, bx, by, w = ( + self.u(x0), + self.u(y0), + self.u(x1), + self.u(y1), + self.u(width) / 2, + ) + dx, dy = bx - ax, by - ay + length = math.hypot(dx, dy) or 1 + + def test(x, y): + t = max(0, min(1, ((x - ax) * dx + (y - ay) * dy) / (length * length))) + px, py = ax + t * dx, ay + t * dy + return (x - px) ** 2 + (y - py) ** 2 <= w * w + + self.fill(test, colour, alpha) + + def downsample(self): + out = self.size // SS + rows = [] + for y in range(out): + row = bytearray() + for x in range(out): + r = g = b = a = 0.0 + for sy in range(SS): + for sx in range(SS): + pr, pg, pb, pa = self.px[y * SS + sy][x * SS + sx] + r += pr * pa + g += pg * pa + b += pb * pa + a += pa + count = SS * SS + if a > 0: + row += bytes((int(r / a), int(g / a), int(b / a), int(255 * a / count))) + else: + row += bytes((0, 0, 0, 0)) + rows.append(bytes(row)) + return out, rows + + +def write_png(path, size, rows): + raw = b"".join(b"\x00" + row for row in rows) + + def chunk(tag, data): + payload = tag + data + return struct.pack(">I", len(data)) + payload + struct.pack(">I", zlib.crc32(payload)) + + png = b"\x89PNG\r\n\x1a\n" + png += chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)) + png += chunk(b"IDAT", zlib.compress(raw, 9)) + png += chunk(b"IEND", b"") + + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as handle: + handle.write(png) + + +def emit(path, base_size, draw): + """Writes name.png at base_size and name@2x.png at double.""" + for suffix, size in ((".png", base_size), ("@2x.png", base_size * 2)): + canvas = Canvas(size) + draw(canvas) + out_size, rows = canvas.downsample() + write_png(path + suffix, out_size, rows) + + +# --- glyphs ----------------------------------------------------------------- + + +def traffic(colour): + def draw(c): + c.circle(0.5, 0.5, 0.34, colour) + c.circle(0.42, 0.4, 0.11, WHITE, 0.35) + + return draw + + +def toggle(colour, on): + def draw(c): + c.rounded(0.06, 0.28, 0.94, 0.72, 0.22, colour) + c.circle(0.72 if on else 0.28, 0.5, 0.17, WHITE) + + return draw + + +def power(colour): + def draw(c): + c.ring(0.5, 0.54, 0.34, 0.12, colour) + c.erase(lambda x, y: abs(x - c.u(0.5)) <= c.u(0.11) and y <= c.u(0.42)) + c.rounded(0.44, 0.12, 0.56, 0.52, 0.06, colour) + + return draw + + +def box(colour): + """Stylised container: a crate seen straight on.""" + + def draw(c): + c.rounded(0.1, 0.2, 0.9, 0.82, 0.1, colour) + c.erase(lambda x, y: c.u(0.2) <= x <= c.u(0.8) and c.u(0.3) <= y <= c.u(0.72)) + c.rect(0.32, 0.3, 0.38, 0.72, colour) + c.rect(0.47, 0.3, 0.53, 0.72, colour) + c.rect(0.62, 0.3, 0.68, 0.72, colour) + + return draw + + +def layers(colour): + def draw(c): + c.rounded(0.12, 0.14, 0.72, 0.5, 0.08, colour) + c.rounded(0.28, 0.5, 0.88, 0.86, 0.08, colour, 0.75) + + return draw + + +def tag(colour): + def draw(c): + c.rounded(0.1, 0.1, 0.9, 0.9, 0.16, colour) + c.erase( + lambda x, y: (x - c.u(0.34)) ** 2 + (y - c.u(0.34)) ** 2 <= c.u(0.09) ** 2 + ) + + return draw + + +def volume(colour): + def draw(c): + c.rect(0.18, 0.24, 0.82, 0.76, colour) + c.ellipse(0.5, 0.76, 0.32, 0.14, colour) + c.ellipse(0.5, 0.24, 0.32, 0.14, colour) + c.ellipse(0.5, 0.24, 0.19, 0.08, WHITE, 0.55) + + return draw + + +def network(colour): + def draw(c): + c.line(0.5, 0.28, 0.22, 0.74, 0.08, colour) + c.line(0.5, 0.28, 0.78, 0.74, 0.08, colour) + c.line(0.22, 0.74, 0.78, 0.74, 0.08, colour) + for cx, cy in ((0.5, 0.24), (0.2, 0.76), (0.8, 0.76)): + c.circle(cx, cy, 0.16, colour) + + return draw + + +def gear(colour): + def draw(c): + for index in range(8): + angle = index * math.pi / 4 + c.line( + 0.5 + 0.2 * math.cos(angle), + 0.5 + 0.2 * math.sin(angle), + 0.5 + 0.42 * math.cos(angle), + 0.5 + 0.42 * math.sin(angle), + 0.16, + colour, + ) + c.circle(0.5, 0.5, 0.3, colour) + c.erase( + lambda x, y: (x - c.u(0.5)) ** 2 + (y - c.u(0.5)) ** 2 <= c.u(0.12) ** 2 + ) + + return draw + + +def folder(colour): + def draw(c): + c.rounded(0.08, 0.2, 0.5, 0.34, 0.05, colour) + c.rounded(0.08, 0.26, 0.92, 0.8, 0.09, colour) + + return draw + + +def logo(c): + """Extension icon: a crate on a rounded plate.""" + plate = (28, 30, 34) + c.rounded(0.02, 0.02, 0.98, 0.98, 0.22, plate) + c.rounded(0.16, 0.26, 0.84, 0.78, 0.09, WHITE) + # The crate is hollow: paint the interior back to the plate colour rather + # than erasing, which would punch a hole through the plate as well. + c.rounded(0.24, 0.34, 0.76, 0.7, 0.04, plate) + c.rect(0.34, 0.34, 0.4, 0.7, WHITE) + c.rect(0.47, 0.34, 0.53, 0.7, WHITE) + c.rect(0.6, 0.34, 0.66, 0.7, WHITE) + + +ICONS = { + "Images/icons/status/traffic-on": (16, traffic(GREEN)), + "Images/icons/status/traffic-off": (16, traffic(GREY)), + "Images/icons/status/toggle-on": (16, toggle(GREEN, True)), + "Images/icons/status/toggle-off": (16, toggle(GREY, False)), + "Images/icons/status/power-on": (16, power(GREEN)), + "Images/icons/status/power-off": (16, power(GREY)), + "Images/icons/status/warning": (16, traffic(ORANGE)), + "Images/icons/box": (16, box(BLUE)), + "Images/icons/layers": (16, layers(PURPLE)), + "Images/icons/tag": (16, tag(TEAL)), + "Images/icons/volume": (16, volume(ORANGE)), + "Images/icons/network": (16, network(BLUE)), + "Images/icons/system": (16, gear(GREY)), + "Images/icons/folder": (16, folder(GREY)), + "Images/sidebar-small": (16, box(WHITE)), + "Images/sidebar-large": (32, box(WHITE)), + "Images/extension": (128, logo), +} + + +def main(): + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + for name, (size, draw) in ICONS.items(): + emit(os.path.join(root, name), size, draw) + print(f"wrote {name}.png") + + +if __name__ == "__main__": + main() diff --git a/extension.json b/extension.json index 39fbad1..af716e0 100644 --- a/extension.json +++ b/extension.json @@ -2,7 +2,775 @@ "identifier": "unsupervised.AppleContainer", "name": "Apple Container", "organization": "Unsupervised", - "description": "Lorem ipsum, dolor sit amet.", + "description": "Sidebar, commands and language support for Apple Container and container-compose.", "version": "1.0", - "categories": [] + "license": "MIT", + "repository": "https://git.unsupervised.ca/", + "main": "main.js", + "keywords": [ + "apple container", + "container", + "containers", + "compose", + "container-compose", + "dockerfile", + "containerfile", + "image", + "images", + "volume", + "network", + "sidebar" + ], + "categories": [ + "commands", + "completions", + "issues", + "languages", + "sidebars" + ], + "entitlements": { + "clipboard": true, + "filesystem": "readwrite", + "process": true + }, + "activationEvents": [ + "onLanguage:dockerfile", + "onLanguage:dockercompose", + "onLanguage:yaml", + "onWorkspaceContains:*compose*.y*ml", + "onWorkspaceContains:Dockerfile", + "onWorkspaceContains:Containerfile" + ], + "commands": { + "extensions": [ + { + "title": "Reload Sidebar", + "command": "apple_container.reload" + }, + { + "separator": true + }, + { + "title": "Start Container Services", + "command": "apple_container.system.start" + }, + { + "title": "Stop Container Services", + "command": "apple_container.system.stop" + }, + { + "title": "Show System Status", + "command": "apple_container.system.status" + }, + { + "title": "Show System Logs", + "command": "apple_container.system.logs" + }, + { + "separator": true + }, + { + "title": "Register DNS Domain with macOS\u2026", + "command": "apple_container.dns.register" + }, + { + "title": "Open System Configuration", + "command": "apple_container.dns.openConfig" + }, + { + "separator": true + }, + { + "title": "Compose Up", + "command": "apple_container.compose.up" + }, + { + "title": "Compose Down", + "command": "apple_container.compose.down" + }, + { + "title": "Compose Build", + "command": "apple_container.compose.build" + }, + { + "separator": true + }, + { + "title": "Build Image from Editor\u2026", + "command": "apple_container.image.buildEditor" + }, + { + "title": "Pull Image\u2026", + "command": "apple_container.image.pullPrompt" + }, + { + "title": "Run Image\u2026", + "command": "apple_container.image.runPrompt" + }, + { + "separator": true + }, + { + "title": "Restart Language Server", + "command": "apple_container.lsp.restart" + }, + { + "title": "Extension Preferences", + "command": "apple_container.preferences" + } + ] + }, + "sidebars": [ + { + "id": "apple_container", + "name": "Apple Container", + "smallImage": "sidebar-small.png", + "smallSelectedImage": "sidebar-small.png", + "largeImage": "sidebar-large.png", + "largeSelectedImage": "sidebar-large.png", + "sections": [ + { + "id": "apple_container.system", + "name": "System", + "allowMultiple": false, + "placeholderText": "Apple Container was not found.\nInstall it from github.com/apple/container.", + "headerCommands": [ + { + "title": "Refresh", + "image": "__builtin.refresh", + "tooltip": "Refresh Apple Container", + "command": "apple_container.reload" + }, + { + "title": "Settings", + "image": "__builtin.action", + "tooltip": "Extension Preferences", + "command": "apple_container.preferences" + } + ], + "contextCommands": [ + { + "title": "Hostname Settings\u2026", + "command": "apple_container.preferences", + "when": "viewItem == 'system.dns.default'" + }, + { + "title": "Register with macOS\u2026", + "command": "apple_container.dns.register", + "when": "viewItem == 'system.dns.default'" + }, + { + "title": "Open System Configuration", + "command": "apple_container.dns.openConfig", + "when": "viewItem == 'system.dns.default'" + }, + { + "title": "Unregister from macOS\u2026", + "command": "apple_container.dns.unregister", + "when": "viewItem == 'system.dns'" + }, + { + "separator": true + }, + { + "title": "Start Services", + "command": "apple_container.system.start", + "when": "viewItem == 'system.services.stopped'" + }, + { + "title": "Stop Services", + "command": "apple_container.system.stop", + "when": "viewItem == 'system.services.running'" + }, + { + "title": "Restart Services", + "command": "apple_container.system.restart", + "when": "viewItem == 'system.services.running'" + }, + { + "separator": true + }, + { + "title": "Start Builder", + "command": "apple_container.builder.start", + "when": "viewItem == 'system.builder.stopped'" + }, + { + "title": "Stop Builder", + "command": "apple_container.builder.stop", + "when": "viewItem == 'system.builder.running'" + }, + { + "title": "Delete Builder", + "command": "apple_container.builder.delete", + "when": "viewItem == 'system.builder.running' || viewItem == 'system.builder.stopped'" + }, + { + "separator": true + }, + { + "title": "Show Logs", + "command": "apple_container.system.logs", + "when": "viewItem == 'system.services.running' || viewItem == 'system.services.stopped'" + }, + { + "title": "Show Disk Usage", + "command": "apple_container.system.df", + "when": "viewItem == 'system.disk'" + }, + { + "separator": true + }, + { + "title": "Copy", + "command": "apple_container.system.copy" + } + ], + "placeholderImage": "sidebar-large.png" + }, + { + "id": "apple_container.containers", + "name": "Containers", + "allowMultiple": true, + "placeholderText": "No containers.", + "headerCommands": [ + { + "title": "Refresh", + "image": "__builtin.refresh", + "tooltip": "Refresh containers", + "command": "apple_container.containers.reload" + }, + { + "title": "Prune", + "image": "__builtin.remove", + "tooltip": "Remove all stopped containers", + "command": "apple_container.containers.prune" + } + ], + "contextCommands": [ + { + "title": "Compose Up", + "command": "apple_container.compose.upProject", + "when": "viewItem == 'project.compose'" + }, + { + "title": "Compose Down", + "command": "apple_container.compose.downProject", + "when": "viewItem == 'project.compose'" + }, + { + "title": "Compose Build", + "command": "apple_container.compose.buildProject", + "when": "viewItem == 'project.compose'" + }, + { + "title": "Open Compose File", + "command": "apple_container.compose.openFile", + "when": "viewItem == 'project.compose'" + }, + { + "separator": true + }, + { + "title": "Start All", + "command": "apple_container.containers.startGroup", + "when": "viewItem == 'project.compose' || viewItem == 'project'" + }, + { + "title": "Stop All", + "command": "apple_container.containers.stopGroup", + "when": "viewItem == 'project.compose' || viewItem == 'project'" + }, + { + "separator": true + }, + { + "title": "Start", + "command": "apple_container.containers.start", + "when": "viewItem == 'container.stopped'" + }, + { + "title": "Stop", + "command": "apple_container.containers.stop", + "when": "viewItem == 'container.running'" + }, + { + "title": "Restart", + "command": "apple_container.containers.restart", + "when": "viewItem == 'container.running'" + }, + { + "title": "Kill", + "command": "apple_container.containers.kill", + "when": "viewItem == 'container.running'" + }, + { + "separator": true + }, + { + "title": "Open Shell", + "command": "apple_container.containers.shell", + "when": "viewItem == 'container.running'" + }, + { + "title": "Follow Logs", + "command": "apple_container.containers.follow", + "when": "viewItem == 'container.running'" + }, + { + "title": "Show Logs", + "command": "apple_container.containers.logs", + "when": "viewItem == 'container.running' || viewItem == 'container.stopped'" + }, + { + "title": "Show Boot Logs", + "command": "apple_container.containers.bootLogs", + "when": "viewItem == 'container.running' || viewItem == 'container.stopped'" + }, + { + "separator": true + }, + { + "title": "Open in Browser", + "command": "apple_container.containers.browse", + "when": "viewItem == 'container.running'" + }, + { + "title": "Inspect", + "command": "apple_container.containers.inspect", + "when": "viewItem == 'container.running' || viewItem == 'container.stopped'" + }, + { + "title": "Copy", + "command": "apple_container.containers.copy", + "when": "viewItem == 'container.running' || viewItem == 'container.stopped'" + }, + { + "separator": true + }, + { + "title": "Delete", + "command": "apple_container.containers.delete", + "when": "viewItem == 'container.running' || viewItem == 'container.stopped'" + } + ], + "placeholderImage": "sidebar-large.png" + }, + { + "id": "apple_container.images", + "name": "Images", + "allowMultiple": true, + "placeholderText": "No images.", + "headerCommands": [ + { + "title": "Pull", + "image": "__builtin.add", + "tooltip": "Pull an image", + "command": "apple_container.image.pullPrompt" + }, + { + "title": "Refresh", + "image": "__builtin.refresh", + "tooltip": "Refresh images", + "command": "apple_container.images.reload" + }, + { + "title": "Prune", + "image": "__builtin.remove", + "tooltip": "Remove unreferenced images", + "command": "apple_container.images.prune" + } + ], + "contextCommands": [ + { + "title": "Run\u2026", + "command": "apple_container.images.run", + "when": "viewItem == 'image.tag'" + }, + { + "title": "Pull", + "command": "apple_container.images.pull", + "when": "viewItem == 'image.tag'" + }, + { + "title": "Push", + "command": "apple_container.images.push", + "when": "viewItem == 'image.tag'" + }, + { + "title": "Tag\u2026", + "command": "apple_container.images.tag", + "when": "viewItem == 'image.tag'" + }, + { + "separator": true + }, + { + "title": "Inspect", + "command": "apple_container.images.inspect", + "when": "viewItem == 'image.tag'" + }, + { + "title": "Copy", + "command": "apple_container.images.copy", + "when": "viewItem == 'image.tag' || viewItem == 'image.repo'" + }, + { + "separator": true + }, + { + "title": "Delete", + "command": "apple_container.images.delete", + "when": "viewItem == 'image.tag' || viewItem == 'image.repo'" + } + ], + "placeholderImage": "sidebar-large.png" + }, + { + "id": "apple_container.volumes", + "name": "Volumes", + "allowMultiple": true, + "placeholderText": "No volumes.", + "headerCommands": [ + { + "title": "Create", + "image": "__builtin.add", + "tooltip": "Create a volume", + "command": "apple_container.volumes.create" + }, + { + "title": "Refresh", + "image": "__builtin.refresh", + "tooltip": "Refresh volumes", + "command": "apple_container.volumes.reload" + }, + { + "title": "Prune", + "image": "__builtin.remove", + "tooltip": "Remove unreferenced volumes", + "command": "apple_container.volumes.prune" + } + ], + "contextCommands": [ + { + "title": "Inspect", + "command": "apple_container.volumes.inspect", + "when": "viewItem == 'volume'" + }, + { + "title": "Reveal in Finder", + "command": "apple_container.volumes.reveal", + "when": "viewItem == 'volume'" + }, + { + "title": "Copy", + "command": "apple_container.volumes.copy", + "when": "viewItem == 'volume'" + }, + { + "separator": true + }, + { + "title": "Delete", + "command": "apple_container.volumes.delete", + "when": "viewItem == 'volume'" + } + ], + "placeholderImage": "sidebar-large.png" + }, + { + "id": "apple_container.networks", + "name": "Networks", + "allowMultiple": true, + "placeholderText": "No networks.", + "headerCommands": [ + { + "title": "Create", + "image": "__builtin.add", + "tooltip": "Create a network", + "command": "apple_container.networks.create" + }, + { + "title": "Refresh", + "image": "__builtin.refresh", + "tooltip": "Refresh networks", + "command": "apple_container.networks.reload" + }, + { + "title": "Prune", + "image": "__builtin.remove", + "tooltip": "Remove unused networks", + "command": "apple_container.networks.prune" + } + ], + "contextCommands": [ + { + "title": "Inspect", + "command": "apple_container.networks.inspect", + "when": "viewItem == 'network'" + }, + { + "title": "Copy", + "command": "apple_container.networks.copy", + "when": "viewItem == 'network'" + }, + { + "separator": true + }, + { + "title": "Delete", + "command": "apple_container.networks.delete", + "when": "viewItem == 'network'" + } + ], + "placeholderImage": "sidebar-large.png" + } + ] + } + ], + "config": [ + { + "key": "apple_container.cli", + "title": "Command Line Tools", + "type": "section", + "children": [ + { + "key": "apple_container.cli.container", + "title": "container", + "description": "Path to the Apple Container CLI. Leave empty to resolve `container` from your PATH.", + "type": "path", + "placeholder": "/usr/local/bin/container" + }, + { + "key": "apple_container.cli.compose", + "title": "container-compose", + "description": "Path to container-compose. Leave empty to resolve `container-compose` from your PATH.", + "type": "path", + "placeholder": "/opt/homebrew/bin/container-compose" + }, + { + "key": "apple_container.cli.shell", + "title": "Container Shell", + "description": "Shell used by the \u201cOpen Shell\u201d command.", + "type": "string", + "default": "/bin/sh" + }, + { + "key": "apple_container.cli.terminal", + "title": "Terminal", + "description": "Application used for interactive commands such as logs and shells.", + "type": "enum", + "values": [ + "Terminal", + "iTerm2" + ], + "default": "Terminal" + } + ] + }, + { + "key": "apple_container.sidebar", + "title": "Sidebar", + "type": "section", + "children": [ + { + "key": "apple_container.sidebar.disabled", + "title": "Disable Sidebar", + "description": "Stop the sidebar from querying Apple Container entirely.", + "type": "boolean", + "default": false + }, + { + "key": "apple_container.sidebar.refresh", + "title": "Refresh Interval", + "description": "Seconds between refreshes while the sidebar is visible. Apple Container has no event stream, so the sidebar polls. Set to 0 to refresh only on demand.", + "type": "number", + "default": 5, + "min": 0, + "max": 300 + }, + { + "key": "apple_container.sidebar.autostart", + "title": "Start Services Automatically", + "description": "Run `container system start` when the sidebar loads and the services are not running.", + "type": "boolean", + "default": false + } + ] + }, + { + "key": "apple_container.containers", + "title": "Containers", + "type": "section", + "children": [ + { + "key": "apple_container.containers.group", + "title": "Group by Compose Project", + "description": "Group containers using the `com.docker.compose.project` label stamped by container-compose.", + "type": "boolean", + "default": true + }, + { + "key": "apple_container.containers.label", + "title": "Label", + "type": "enum", + "values": [ + "Name", + "Image", + "Service" + ], + "default": "Name" + }, + { + "key": "apple_container.containers.desc.image", + "title": "Describe with Image", + "type": "boolean", + "default": true + }, + { + "key": "apple_container.containers.desc.ports", + "title": "Describe with Published Ports", + "type": "boolean", + "default": true + }, + { + "key": "apple_container.containers.desc.address", + "title": "Describe with IP Address", + "type": "boolean", + "default": false + }, + { + "key": "apple_container.containers.desc.state", + "title": "Describe with State", + "type": "boolean", + "default": false + }, + { + "key": "apple_container.containers.icons", + "title": "Status Icons", + "type": "enum", + "values": [ + "Traffic Lights", + "Toggles", + "Power" + ], + "default": "Traffic Lights" + } + ] + }, + { + "key": "apple_container.dns", + "title": "Hostnames", + "type": "section", + "children": [ + { + "key": "apple_container.dns.domain", + "title": "Hostname Domain", + "description": "Domain appended to container hostnames, so a container named mysql is reachable as mysql.. This is Apple Container's own `dns.domain` setting: changing it here rewrites config.toml, offers to register the domain with macOS, and offers to restart the services. Leave empty for unqualified hostnames.", + "type": "string", + "placeholder": "test" + } + ] + }, + { + "key": "apple_container.images", + "title": "Images", + "type": "section", + "children": [ + { + "key": "apple_container.images.group", + "title": "Group by Repository", + "type": "boolean", + "default": true + }, + { + "key": "apple_container.images.registry", + "title": "Show Registry Host", + "description": "Show `docker.io/library/nginx` rather than `nginx`.", + "type": "boolean", + "default": false + }, + { + "key": "apple_container.images.infra", + "title": "Show Infrastructure Images", + "description": "Include Apple Container's own images, such as the builder and init images.", + "type": "boolean", + "default": false + } + ] + }, + { + "key": "apple_container.compose", + "title": "Compose", + "type": "section", + "children": [ + { + "key": "apple_container.compose.detach", + "title": "Detach on Up", + "description": "Pass `--detach` to `container-compose up`.", + "type": "boolean", + "default": true + }, + { + "key": "apple_container.compose.terminal", + "title": "Run Compose in Terminal", + "description": "Run compose commands in your terminal app so you can watch the output, rather than in the background.", + "type": "boolean", + "default": true + } + ] + }, + { + "key": "apple_container.lsp", + "title": "Language Server", + "type": "section", + "children": [ + { + "key": "apple_container.lsp.mode", + "title": "Language Server", + "description": "Docker Language Server provides completion, hover, validation and linting for Dockerfiles and Compose files.", + "type": "enum", + "values": [ + "Managed Binary", + "Custom Path", + "Disabled" + ], + "default": "Managed Binary" + }, + { + "key": "apple_container.lsp.path", + "title": "Custom Path", + "description": "Path to a docker-language-server binary. Used when Language Server is set to Custom Path.", + "type": "path", + "placeholder": "/opt/homebrew/bin/docker-language-server" + }, + { + "key": "apple_container.lsp.version", + "title": "Managed Version", + "description": "Release of docker/docker-language-server to download and run.", + "type": "string", + "default": "v0.20.1" + } + ] + } + ], + "configWorkspace": [ + { + "key": "apple_container.compose.file", + "title": "Compose File", + "description": "Compose file used by the Compose commands. Defaults to the first compose file found in the workspace.", + "type": "path", + "filetype": [ + "yml", + "yaml" + ] + }, + { + "key": "apple_container.compose.profiles", + "title": "Compose Profiles", + "description": "Space separated profiles passed to container-compose as `--profile`.", + "type": "string", + "placeholder": "debug tools" + } + ] }