Build the Apple Container extension

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UnSBcR5Lywz5Fbj5FmVbfc
This commit is contained in:
2026-08-18 22:45:14 -03:00
co-authored by Claude Opus 5
parent 0879e5a163
commit 0506f44db3
80 changed files with 5201 additions and 3 deletions
+151
View File
@@ -0,0 +1,151 @@
//
// Base class for the sidebar's tree controllers.
//
// Apple Container has no event stream, so the sidebar polls. Reloading a
// TreeView drops the user's selection, so each refresh compares a fingerprint
// of the fetched data and only reloads when something actually changed.
//
class Controller {
constructor(id) {
this.id = id;
this.elements = [];
this.fingerprint = null;
// Collapsed rather than expanded, so groups keep their default of open.
this.collapsed = new Set();
this.disposables = new CompositeDisposable();
this.tree = new TreeView(id, { dataProvider: this });
this.disposables.add(this.tree);
this.disposables.add(
this.tree.onDidExpandElement((element) => {
this.collapsed.delete(element.identifier);
element.expanded = true;
})
);
this.disposables.add(
this.tree.onDidCollapseElement((element) => {
this.collapsed.add(element.identifier);
element.expanded = false;
})
);
}
// Subclasses return the elements to display, or null to leave the tree as
// it is (used when the backing service is unavailable).
async fetch() {
return [];
}
// A stable string describing the current data. When it does not change,
// the tree is left alone.
signature(elements) {
let describe = (element) => [element.identifier, element.revision];
return JSON.stringify(
elements.map((element) => describe(element).concat([(element.children || []).map(describe)]))
);
}
get visible() {
// TreeView.visible is only meaningful once the sidebar has been shown.
return this.tree.visible !== false;
}
async refresh(options = {}) {
let elements;
try {
elements = await this.fetch();
} catch (error) {
if (nova.inDevMode()) {
console.warn(`${this.id}: ${error.message}`);
}
elements = [];
}
if (elements === null) {
return;
}
this.restoreExpansion(elements);
let signature = this.signature(elements);
if (!options.force && signature === this.fingerprint) {
return;
}
this.fingerprint = signature;
this.elements = elements;
this.tree.reload();
}
// Rebuilt models are fresh objects, so expansion has to be carried over.
restoreExpansion(elements) {
for (let element of elements) {
if (!element.children || element.children.length === 0) {
continue;
}
if (this.collapsed.has(element.identifier)) {
element.expanded = false;
}
}
}
// Empties the tree, for when the backing service goes away.
clear() {
if (this.elements.length === 0) {
return;
}
this.elements = [];
this.fingerprint = null;
this.tree.reload();
}
// Invalidates the fingerprint so the next refresh always reloads.
invalidate() {
this.fingerprint = null;
}
reload() {
this.invalidate();
return this.refresh({ force: true });
}
get selection() {
return this.tree.selection || [];
}
getChildren(element) {
if (!element) {
return this.elements;
}
return element.children || [];
}
getParent(element) {
return element.parent;
}
getTreeItem() {
throw new Error("getTreeItem must be implemented by the controller.");
}
register(command, handler) {
this.disposables.add(nova.commands.register(command, handler));
}
watch(key, handler) {
this.disposables.add(nova.config.onDidChange(key, handler));
}
dispose() {
this.disposables.dispose();
}
}
module.exports = { Controller };