Build the Apple Container extension

Turns the empty scaffold into a working Nova extension for Apple Container
and container-compose, in the shape of the Docker Suite extension.

Sidebar (Scripts/Sidebar):
- System, Containers, Images, Volumes and Networks sections
- Containers grouped into compose projects via the com.docker.compose.project
  label container-compose stamps; project rows drive compose up/down/build
  against the matching workspace file
- Lifecycle, shell, logs, inspect, browse and copy commands per resource
- Apple Container publishes no event stream, so the sidebar polls while
  visible and only reloads a section when a fingerprint of its data changes,
  which keeps selection and expansion intact

Hostnames (Scripts/Hostnames.js):
- A container's hostname is <name>.<domain> and the domain lives in Apple
  Container's config.toml, which has no CLI setter, so the Hostname Domain
  preference reads and writes that file directly
- Changing it offers to register the domain with macOS and restart the
  services; the write preserves the file's other tables and its mode

Language support:
- Dockerfile and Compose syntaxes backed by tree-sitter grammars built by
  Tools/build-syntaxes.sh, pinned to revisions that generate ABI 14
- The Compose syntax is named dockercompose because that is the languageId
  docker-language-server recognises compose files by
- docker-language-server is downloaded on first use rather than bundled, at
  roughly 40 MB

Icons are generated by Tools/make-icons.py so they can be regenerated rather
than hand-maintained.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UnSBcR5Lywz5Fbj5FmVbfc
This commit is contained in:
2026-08-18 22:45:14 -03:00
co-authored by Claude Opus 5
parent 0879e5a163
commit 0506f44db3
80 changed files with 5201 additions and 3 deletions
+15 -1
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 B

+21
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
(block_mapping_pair
":" @start
) @end.after
+70
View File
@@ -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
+28
View File
@@ -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))
+87
View File
@@ -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
+11
View File
@@ -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))
+176
View File
@@ -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 `<name>.<domain>`, 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.
+248
View File
@@ -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 };
+155
View File
@@ -0,0 +1,155 @@
//
// Keeps the "Hostname Domain" preference and Apple Container's own
// configuration in step.
//
// A container's hostname is `<name>.<domain>`, 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 };
+137
View File
@@ -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 };
+251
View File
@@ -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 };
+61
View File
@@ -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 };
@@ -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 };
+166
View File
@@ -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 };
+211
View File
@@ -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 };
+30
View File
@@ -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 };
+119
View File
@@ -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 };
+60
View File
@@ -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 };
+55
View File
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
+16
View File
@@ -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 };
@@ -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 };
@@ -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 };
+388
View File
@@ -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 };
+151
View File
@@ -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 };
+198
View File
@@ -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 };
+114
View File
@@ -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 };
+221
View File
@@ -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 <name>.<domain>."
].join("\n");
}
if (resolved) {
return `Containers are reachable as <name>.${domain} from this Mac.`;
}
return [
`Containers are named <name>.${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 };
+116
View File
@@ -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 };
+107
View File
@@ -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 };
+53
View File
@@ -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 };
+89
View File
@@ -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 };
+34
View File
@@ -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 };
+142
View File
@@ -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 };
+73
View File
@@ -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;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<syntax name="dockercompose">
<meta>
<name>Compose</name>
<type>structured</type>
<preferred-file-extension>yaml</preferred-file-extension>
<parent>yaml</parent>
</meta>
<detectors>
<filename priority="1.0">compose.yaml,compose.yml,docker-compose.yaml,docker-compose.yml,container-compose.yaml,container-compose.yml</filename>
<combo priority="0.6">
<extension>yaml,yml</extension>
<match-content lines="40">^services:</match-content>
</combo>
</detectors>
<indentation>
<increase>
<expression>:\s*$</expression>
</increase>
</indentation>
<comments>
<single>
<expression>#</expression>
</single>
</comments>
<autoclosing-pairs>
<pair open="[" close="]" />
<pair open="{" close="}" />
</autoclosing-pairs>
<surrounding-pairs>
<pair open="[" close="]" />
<pair open="{" close="}" />
<pair open="&quot;" close="&quot;" />
<pair open="'" close="'" />
</surrounding-pairs>
<injection>
<expression>^(compose|docker-compose|dockercompose)$</expression>
</injection>
<tree-sitter language="compose">
<highlights path="compose/highlights.scm" />
<folds path="compose/folds.scm" />
</tree-sitter>
</syntax>
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<syntax name="dockerfile">
<meta>
<name>Dockerfile</name>
<type>script</type>
<preferred-file-extension>dockerfile</preferred-file-extension>
</meta>
<detectors>
<filename priority="1.0">Dockerfile,Containerfile,dockerfile,containerfile</filename>
<extension priority="1.0">dockerfile,containerfile</extension>
<match-content lines="1" priority="0.4">^#\s*syntax\s*=\s*docker</match-content>
<match-content lines="20" priority="0.1">^\s*FROM\s+\S+</match-content>
</detectors>
<comments>
<single>
<expression>#</expression>
</single>
</comments>
<autoclosing-pairs>
<pair open="[" close="]" />
<pair open="{" close="}" />
<pair open="&quot;" close="&quot;" />
</autoclosing-pairs>
<surrounding-pairs>
<pair open="[" close="]" />
<pair open="{" close="}" />
<pair open="&quot;" close="&quot;" />
<pair open="'" close="'" />
</surrounding-pairs>
<injection>
<expression>^(docker|dockerfile|containerfile)$</expression>
</injection>
<tree-sitter language="dockerfile">
<highlights path="dockerfile/highlights.scm" />
<folds path="dockerfile/folds.scm" />
<injections path="dockerfile/injections.scm" />
</tree-sitter>
</syntax>
Binary file not shown.
Binary file not shown.
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
#
# Builds the tree-sitter grammars the syntaxes depend on.
#
# Nova loads grammars as dylibs named libtree-sitter-<language>.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
+322
View File
@@ -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 [email protected] 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()
+770 -2
View File
@@ -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.<domain>. 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"
}
]
}