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 0000000..91f26f5 Binary files /dev/null and b/Images/extension.png differ diff --git a/Images/extension@2x.png b/Images/extension@2x.png new file mode 100644 index 0000000..fd439c5 Binary files /dev/null and b/Images/extension@2x.png differ diff --git a/Images/icons/box.png b/Images/icons/box.png new file mode 100644 index 0000000..4decc78 Binary files /dev/null and b/Images/icons/box.png differ diff --git a/Images/icons/box@2x.png b/Images/icons/box@2x.png new file mode 100644 index 0000000..ed84a84 Binary files /dev/null and b/Images/icons/box@2x.png differ diff --git a/Images/icons/folder.png b/Images/icons/folder.png new file mode 100644 index 0000000..eeddc51 Binary files /dev/null and b/Images/icons/folder.png differ diff --git a/Images/icons/folder@2x.png b/Images/icons/folder@2x.png new file mode 100644 index 0000000..47a1b42 Binary files /dev/null and b/Images/icons/folder@2x.png differ diff --git a/Images/icons/layers.png b/Images/icons/layers.png new file mode 100644 index 0000000..29ad6b0 Binary files /dev/null and b/Images/icons/layers.png differ diff --git a/Images/icons/layers@2x.png b/Images/icons/layers@2x.png new file mode 100644 index 0000000..3127b66 Binary files /dev/null and b/Images/icons/layers@2x.png differ diff --git a/Images/icons/network.png b/Images/icons/network.png new file mode 100644 index 0000000..0ffe825 Binary files /dev/null and b/Images/icons/network.png differ diff --git a/Images/icons/network@2x.png b/Images/icons/network@2x.png new file mode 100644 index 0000000..dca11c6 Binary files /dev/null and b/Images/icons/network@2x.png differ diff --git a/Images/icons/status/power-off.png b/Images/icons/status/power-off.png new file mode 100644 index 0000000..496a804 Binary files /dev/null and b/Images/icons/status/power-off.png differ diff --git a/Images/icons/status/power-off@2x.png b/Images/icons/status/power-off@2x.png new file mode 100644 index 0000000..6f32599 Binary files /dev/null and b/Images/icons/status/power-off@2x.png differ diff --git a/Images/icons/status/power-on.png b/Images/icons/status/power-on.png new file mode 100644 index 0000000..998c104 Binary files /dev/null and b/Images/icons/status/power-on.png differ diff --git a/Images/icons/status/power-on@2x.png b/Images/icons/status/power-on@2x.png new file mode 100644 index 0000000..86520e1 Binary files /dev/null and b/Images/icons/status/power-on@2x.png differ diff --git a/Images/icons/status/toggle-off.png b/Images/icons/status/toggle-off.png new file mode 100644 index 0000000..2e10b73 Binary files /dev/null and b/Images/icons/status/toggle-off.png differ diff --git a/Images/icons/status/toggle-off@2x.png b/Images/icons/status/toggle-off@2x.png new file mode 100644 index 0000000..9d1c1e8 Binary files /dev/null and b/Images/icons/status/toggle-off@2x.png differ diff --git a/Images/icons/status/toggle-on.png b/Images/icons/status/toggle-on.png new file mode 100644 index 0000000..a03fd5d Binary files /dev/null and b/Images/icons/status/toggle-on.png differ diff --git a/Images/icons/status/toggle-on@2x.png b/Images/icons/status/toggle-on@2x.png new file mode 100644 index 0000000..001c6df Binary files /dev/null and b/Images/icons/status/toggle-on@2x.png differ diff --git a/Images/icons/status/traffic-off.png b/Images/icons/status/traffic-off.png new file mode 100644 index 0000000..55192e1 Binary files /dev/null and b/Images/icons/status/traffic-off.png differ diff --git a/Images/icons/status/traffic-off@2x.png b/Images/icons/status/traffic-off@2x.png new file mode 100644 index 0000000..783ff5a Binary files /dev/null and b/Images/icons/status/traffic-off@2x.png differ diff --git a/Images/icons/status/traffic-on.png b/Images/icons/status/traffic-on.png new file mode 100644 index 0000000..fbd348c Binary files /dev/null and b/Images/icons/status/traffic-on.png differ diff --git a/Images/icons/status/traffic-on@2x.png b/Images/icons/status/traffic-on@2x.png new file mode 100644 index 0000000..c8b566a Binary files /dev/null and b/Images/icons/status/traffic-on@2x.png differ diff --git a/Images/icons/status/warning.png b/Images/icons/status/warning.png new file mode 100644 index 0000000..7d7c6f2 Binary files /dev/null and b/Images/icons/status/warning.png differ diff --git a/Images/icons/status/warning@2x.png b/Images/icons/status/warning@2x.png new file mode 100644 index 0000000..d9df359 Binary files /dev/null and b/Images/icons/status/warning@2x.png differ diff --git a/Images/icons/system.png b/Images/icons/system.png new file mode 100644 index 0000000..72bfc22 Binary files /dev/null and b/Images/icons/system.png differ diff --git a/Images/icons/system@2x.png b/Images/icons/system@2x.png new file mode 100644 index 0000000..97b6511 Binary files /dev/null and b/Images/icons/system@2x.png differ diff --git a/Images/icons/tag.png b/Images/icons/tag.png new file mode 100644 index 0000000..e55e73e Binary files /dev/null and b/Images/icons/tag.png differ diff --git a/Images/icons/tag@2x.png b/Images/icons/tag@2x.png new file mode 100644 index 0000000..ca974c3 Binary files /dev/null and b/Images/icons/tag@2x.png differ diff --git a/Images/icons/volume.png b/Images/icons/volume.png new file mode 100644 index 0000000..ef5ac70 Binary files /dev/null and b/Images/icons/volume.png differ diff --git a/Images/icons/volume@2x.png b/Images/icons/volume@2x.png new file mode 100644 index 0000000..2085496 Binary files /dev/null and b/Images/icons/volume@2x.png differ diff --git a/Images/sidebar-large.png b/Images/sidebar-large.png new file mode 100644 index 0000000..a3db862 Binary files /dev/null and b/Images/sidebar-large.png differ diff --git a/Images/sidebar-large@2x.png b/Images/sidebar-large@2x.png new file mode 100644 index 0000000..96ca8fc Binary files /dev/null and b/Images/sidebar-large@2x.png differ diff --git a/Images/sidebar-small.png b/Images/sidebar-small.png new file mode 100644 index 0000000..98ac564 Binary files /dev/null and b/Images/sidebar-small.png differ diff --git a/Images/sidebar-small@2x.png b/Images/sidebar-small@2x.png new file mode 100644 index 0000000..a3db862 Binary files /dev/null and b/Images/sidebar-small@2x.png differ 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 0000000..b273d3b Binary files /dev/null and b/Syntaxes/libtree-sitter-compose.dylib differ diff --git a/Syntaxes/libtree-sitter-dockerfile.dylib b/Syntaxes/libtree-sitter-dockerfile.dylib new file mode 100755 index 0000000..8ffda92 Binary files /dev/null and b/Syntaxes/libtree-sitter-dockerfile.dylib differ diff --git a/Tools/build-syntaxes.sh b/Tools/build-syntaxes.sh new file mode 100755 index 0000000..97e9856 --- /dev/null +++ b/Tools/build-syntaxes.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# +# Builds the tree-sitter grammars the syntaxes depend on. +# +# Nova loads grammars as dylibs named libtree-sitter-.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" + } + ] }