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
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 179 B |
|
After Width: | Height: | Size: 216 B |
|
After Width: | Height: | Size: 133 B |
|
After Width: | Height: | Size: 192 B |
|
After Width: | Height: | Size: 153 B |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 254 B |
|
After Width: | Height: | Size: 435 B |
|
After Width: | Height: | Size: 256 B |
|
After Width: | Height: | Size: 440 B |
|
After Width: | Height: | Size: 256 B |
|
After Width: | Height: | Size: 439 B |
|
After Width: | Height: | Size: 163 B |
|
After Width: | Height: | Size: 253 B |
|
After Width: | Height: | Size: 166 B |
|
After Width: | Height: | Size: 255 B |
|
After Width: | Height: | Size: 213 B |
|
After Width: | Height: | Size: 346 B |
|
After Width: | Height: | Size: 213 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 208 B |
|
After Width: | Height: | Size: 352 B |
|
After Width: | Height: | Size: 198 B |
|
After Width: | Height: | Size: 318 B |
|
After Width: | Height: | Size: 145 B |
|
After Width: | Height: | Size: 227 B |
|
After Width: | Height: | Size: 196 B |
|
After Width: | Height: | Size: 319 B |
|
After Width: | Height: | Size: 213 B |
|
After Width: | Height: | Size: 379 B |
|
After Width: | Height: | Size: 176 B |
|
After Width: | Height: | Size: 213 B |
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
(block_mapping_pair
|
||||
":" @start
|
||||
) @end.after
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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=""" close=""" />
|
||||
<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>
|
||||
@@ -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=""" close=""" />
|
||||
</autoclosing-pairs>
|
||||
|
||||
<surrounding-pairs>
|
||||
<pair open="[" close="]" />
|
||||
<pair open="{" close="}" />
|
||||
<pair open=""" close=""" />
|
||||
<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>
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||