diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac41d14..b91e38d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
All notable changes to this project will be documented in this file. _(The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).)_
+## [33] - 2026-05-10
+
+### Added
+
+- Show devcontainer names below Docker container menu entries in smaller italic text when detected.
+- Show devcontainer local folder in a hover tooltip.
+
## [32] - 2026-04-27
Added Gnome 50 support
diff --git a/build.sh b/build.sh
index af40188..b3b13a4 100755
--- a/build.sh
+++ b/build.sh
@@ -3,5 +3,5 @@
# Simple bash script to build the GNOME Shell extension
echo "Zipping the extension..."
glib-compile-schemas schemas
-zip -r easy_docker_containers@red.software.systems.zip . -x *.git* -x *.idea* -x *.history* -x *.*~ -x *.sh -x *.vscode/*
+zip -r easy_docker_containers@red.software.systems.zip . -x "*.git*" -x "*.idea*" -x "*.history*" -x "*.*~" -x "*.sh" -x "*.vscode/*" -x "schemas/gschemas.compiled" -x "venv/*"
echo "Building is done."
diff --git a/icons/docker-devcontainer-info-symbolic.svg b/icons/docker-devcontainer-info-symbolic.svg
new file mode 100644
index 0000000..724cba8
--- /dev/null
+++ b/icons/docker-devcontainer-info-symbolic.svg
@@ -0,0 +1,89 @@
+
+
diff --git a/icons/docker-devcontainer-recreate-symbolic.svg b/icons/docker-devcontainer-recreate-symbolic.svg
new file mode 100644
index 0000000..7b07be6
--- /dev/null
+++ b/icons/docker-devcontainer-recreate-symbolic.svg
@@ -0,0 +1,88 @@
+
+
diff --git a/metadata.json b/metadata.json
index ddf5db9..a7b199e 100644
--- a/metadata.json
+++ b/metadata.json
@@ -3,7 +3,7 @@
"uuid": "easy_docker_containers@red.software.systems",
"description": "A GNOME Shell extension (GNOME Panel applet) to be able to generally control your available Docker containers.",
"url": "https://github.com/RedSoftwareSystems/easy_docker_containers",
- "version": 32,
- "settings-schema": "red.software.systems.easy_docker_containers",
+ "version": 33,
+ "settings-schema": "org.gnome.shell.extensions.easy_docker_containers",
"shell-version": ["45", "46", "47", "48", "49", "50"]
}
diff --git a/schemas/red.software.systems.easy_docker_containers.gschema.xml b/schemas/org.gnome.shell.extensions.easy_docker_containers.gschema.xml
similarity index 83%
rename from schemas/red.software.systems.easy_docker_containers.gschema.xml
rename to schemas/org.gnome.shell.extensions.easy_docker_containers.gschema.xml
index 748b3ae..2e51651 100644
--- a/schemas/red.software.systems.easy_docker_containers.gschema.xml
+++ b/schemas/org.gnome.shell.extensions.easy_docker_containers.gschema.xml
@@ -1,6 +1,6 @@
-
+
2
diff --git a/src/docker.js b/src/docker.js
index 7c3b03c..5b3676f 100644
--- a/src/docker.js
+++ b/src/docker.js
@@ -2,8 +2,218 @@
import Gio from "gi://Gio";
import GLib from "gi://GLib";
+import * as Main from "resource:///org/gnome/shell/ui/main.js";
+
+Gio._promisify(Gio.File.prototype, "load_contents_async", "load_contents_finish");
const COMPOSE_PREFIX = "com.docker.compose";
+const DEVCONTAINER_PREFIX = "devcontainer";
+
+const stripJsonComments = (json) => {
+ let out = "";
+ let inString = false;
+ let inLineComment = false;
+ let inBlockComment = false;
+ let escaped = false;
+
+ for (let i = 0; i < json.length; i++) {
+ const char = json[i];
+ const nextChar = json[i + 1];
+
+ if (inLineComment) {
+ if (char === "\n") {
+ inLineComment = false;
+ out += char;
+ }
+ continue;
+ }
+
+ if (inBlockComment) {
+ if (char === "*" && nextChar === "/") {
+ inBlockComment = false;
+ i++;
+ }
+ continue;
+ }
+
+ if (!inString && char === "/" && nextChar === "/") {
+ inLineComment = true;
+ i++;
+ continue;
+ }
+
+ if (!inString && char === "/" && nextChar === "*") {
+ inBlockComment = true;
+ i++;
+ continue;
+ }
+
+ out += char;
+
+ if (char === "\\" && inString) {
+ escaped = !escaped;
+ continue;
+ }
+
+ if (char === '"' && !escaped) {
+ inString = !inString;
+ }
+
+ if (char !== "\\") {
+ escaped = false;
+ }
+ }
+
+ return out;
+};
+
+const stripJsonTrailingCommas = (json) => {
+ let out = "";
+ let inString = false;
+ let escaped = false;
+
+ for (let i = 0; i < json.length; i++) {
+ const char = json[i];
+
+ if (!inString && char === ",") {
+ let j = i + 1;
+ while (j < json.length && /\s/.test(json[j])) j++;
+ if (json[j] === "}" || json[j] === "]") continue;
+ }
+
+ out += char;
+
+ if (char === "\\" && inString) {
+ escaped = !escaped;
+ continue;
+ }
+
+ if (char === '"' && !escaped) {
+ inString = !inString;
+ }
+
+ if (char !== "\\") {
+ escaped = false;
+ }
+ }
+
+ return out;
+};
+
+const parseJsonc = (json) =>
+ JSON.parse(stripJsonTrailingCommas(stripJsonComments(json)));
+
+const getJsonName = (value) => {
+ if (!value) return null;
+
+ if (Array.isArray(value)) {
+ return value.map(getJsonName).find(Boolean) || null;
+ }
+
+ if (typeof value !== "object") return null;
+
+ if (typeof value.name === "string" && value.name.trim().length) {
+ return value.name.trim();
+ }
+
+ return null;
+};
+
+const readDevcontainerNameFromConfig = async (configFile) => {
+ if (!configFile) return null;
+
+ try {
+ const file = Gio.File.new_for_path(configFile);
+ const [ok, contents] = await file.load_contents_async(null);
+ if (!ok) return null;
+
+ const decoder = new TextDecoder("utf-8");
+ const config = parseJsonc(decoder.decode(contents));
+ return getJsonName(config);
+ } catch (e) {
+ logError(e);
+ return null;
+ }
+};
+
+const getDevcontainerNameFromMetadata = (metadata) => {
+ if (!metadata) return null;
+
+ try {
+ return getJsonName(JSON.parse(metadata));
+ } catch (e) {
+ logError(e);
+ return null;
+ }
+};
+
+const getDevcontainerInfo = async (labels) => {
+ const localFolder = labels?.[`${DEVCONTAINER_PREFIX}.local_folder`];
+ const configFile = labels?.[`${DEVCONTAINER_PREFIX}.config_file`];
+ const metadata = labels?.[`${DEVCONTAINER_PREFIX}.metadata`];
+
+ if (!localFolder && !configFile && !metadata) return null;
+
+ const name =
+ labels?.[`${DEVCONTAINER_PREFIX}.name`] ||
+ getDevcontainerNameFromMetadata(metadata) ||
+ (await readDevcontainerNameFromConfig(configFile)) ||
+ (localFolder ? GLib.path_get_basename(localFolder) : null);
+
+ return {
+ name,
+ localFolder,
+ configFile,
+ };
+};
+
+/**
+ * Return the name of the first available terminal emulator, or null if none
+ * found. Priority order: kgx > ptyxis > gnome-terminal > x-terminal-emulator.
+ * @return {String|null}
+ */
+const detectTerminal = () => {
+ for (const name of ["kgx", "ptyxis", "gnome-terminal", "x-terminal-emulator"]) {
+ if (GLib.find_program_in_path(name)) return name;
+ }
+ return null;
+};
+
+/**
+ * Open a terminal window at the given folder path
+ * @param {String} folderPath The local folder to open the terminal in
+ */
+export const openTerminalAtFolder = (folderPath) => {
+ const terminal = detectTerminal();
+
+ let argv;
+ if (terminal === "kgx") {
+ argv = ["kgx", "--working-directory", folderPath];
+ } else if (terminal === "ptyxis") {
+ argv = ["ptyxis", "--working-directory", folderPath];
+ } else if (terminal === "gnome-terminal") {
+ argv = ["gnome-terminal", "--working-directory", folderPath];
+ } else if (terminal === "x-terminal-emulator") {
+ // Use GLib.shell_quote so paths with spaces, quotes, or metacharacters
+ // are passed safely to the inner shell.
+ argv = ["x-terminal-emulator", "-e", "sh", "-c",
+ "cd " + GLib.shell_quote(folderPath) + "; exec $SHELL"];
+ } else {
+ logError(new Error(`No valid terminal found (kgx, ptyxis, gnome-terminal, x-terminal-emulator)`));
+ return;
+ }
+
+ try {
+ const proc = new Gio.Subprocess({
+ argv,
+ flags: Gio.SubprocessFlags.NONE,
+ });
+ proc.init(null);
+ } catch (e) {
+ logError(e);
+ }
+};
+
export const dockerCommandsToLabels = {
start: "Start",
restart: "Restart",
@@ -19,23 +229,218 @@ export const dockerCommandsToLabels = {
logs: "Logs",
};
-export const hasDocker = !!GLib.find_program_in_path("docker");
-export const hasPodman = !!GLib.find_program_in_path("podman");
+// Lazily resolved and cached so that GLib.find_program_in_path() is NOT called
+// at module-import time (which runs on the GNOME Shell main thread during startup).
+// The first actual call happens only after the extension has been fully enabled.
+let _hasDocker;
+let _hasPodman;
+let _hasDevcontainer;
+
+export const hasDocker = () => {
+ if (_hasDocker === undefined)
+ _hasDocker = !!GLib.find_program_in_path("docker");
+ return _hasDocker;
+};
+
+export const hasPodman = () => {
+ if (_hasPodman === undefined)
+ _hasPodman = !!GLib.find_program_in_path("podman");
+ return _hasPodman;
+};
+
+// Note: GLib.find_program_in_path only searches the GNOME Shell process PATH,
+// which will miss tools installed via version managers (NVM, pyenv, rbenv…).
+// Use detectDevcontainerCli() for a reliable async check via the user's login shell.
+export const hasDevcontainer = () => {
+ if (_hasDevcontainer === undefined)
+ _hasDevcontainer = !!GLib.find_program_in_path("devcontainer");
+ return _hasDevcontainer;
+};
+
+/**
+ * Return the user's preferred shell, as set by PAM/login in the SHELL
+ * environment variable. Falls back to 'sh' if the variable is absent.
+ * @return {String} Absolute path to the user's shell (e.g. /bin/bash)
+ */
+const getUserShell = () => GLib.getenv("SHELL") || "sh";
+
+/**
+ * Return the flags needed to make a shell source the user's full environment.
+ *
+ * Using only -l (login) is not enough for zsh: it sources ~/.zprofile but
+ * skips ~/.zshrc, where NVM/pyenv/etc. typically live.
+ * Using only -i (interactive) skips login files on bash.
+ * Using both -i -l sources everything on bash, zsh, fish, and POSIX sh:
+ * bash : /etc/profile + ~/.bash_profile (which usually sources ~/.bashrc)
+ * zsh : ~/.zshenv + ~/.zprofile + ~/.zshrc + ~/.zlogin
+ * fish : ~/.config/fish/config.fish (config.fish checks `status is-login`)
+ * sh/dash: /etc/profile + ~/.profile
+ *
+ * @return {String[]} Shell flags array, e.g. ["-i", "-l"]
+ */
+const getLoginShellFlags = () => ["-i", "-l"];
+
+/**
+ * Asynchronously check whether the devcontainer CLI is reachable by running
+ * `command -v devcontainer` inside the user's login shell. This correctly
+ * finds tools installed via NVM, pyenv, and other version managers.
+ * @return {Promise}
+ */
+export const detectDevcontainerCli = async () => {
+ try {
+ const userShell = getUserShell();
+ const result = await execCommand([userShell, ...getLoginShellFlags(), "-c", "command -v devcontainer"]);
+ return result.trim().length > 0;
+ } catch (e) {
+ return false;
+ }
+};
+
+/**
+ * Build the argv array for launching a devcontainer command in a terminal.
+ * The command is run via the user's login shell (`$SHELL -l -c `) so
+ * that tools installed through version managers (NVM, pyenv, rbenv…) are
+ * available on PATH without requiring any global symlinks.
+ * @param {String} shellCmd The shell command string to run inside the terminal
+ * @return {String[]|null} argv array, or null if no terminal was found
+ */
+const devcontainerTerminalArgv = (shellCmd) => {
+ const terminal = detectTerminal();
+
+ // Use the user's interactive login shell so that version-manager shims
+ // (NVM, pyenv, rbenv, volta…) in both login files and rc files are on PATH.
+ const userShell = getUserShell();
+ const shellFlags = getLoginShellFlags();
+
+ if (terminal === "kgx") {
+ return ["kgx", "-e", userShell, ...shellFlags, "-c", shellCmd];
+ } else if (terminal === "ptyxis") {
+ return ["ptyxis", "--", userShell, ...shellFlags, "-c", shellCmd];
+ } else if (terminal === "gnome-terminal") {
+ return ["gnome-terminal", "--", userShell, ...shellFlags, "-c", shellCmd];
+ } else if (terminal === "x-terminal-emulator") {
+ return ["x-terminal-emulator", "-e", userShell, ...shellFlags, "-c", shellCmd];
+ }
+
+ logError(new Error(`No valid terminal found (kgx, ptyxis, gnome-terminal, x-terminal-emulator)`));
+ return null;
+};
+
+/**
+ * Run a devcontainer CLI command in the background, capturing stdout+stderr
+ * to a temporary log file. Resolves with the log path on success, rejects
+ * with the log path on failure so the caller can show it in a terminal.
+ * @param {String} shellCmd The full shell command to run
+ * @param {String} localFolder Workspace folder (used to name the log file)
+ * @return {Promise}
+ */
+const runDevcontainerProcess = (shellCmd, localFolder) => {
+ const folderName = GLib.path_get_basename(localFolder);
+ // Include a timestamp to avoid collisions across concurrent runs or
+ // multiple workspaces that share the same basename.
+ const logFile = `${GLib.get_tmp_dir()}/devcontainer-${folderName}-${Date.now()}.log`;
+ const userShell = getUserShell();
+ const shellFlags = getLoginShellFlags();
+
+ return new Promise((resolve, reject) => {
+ try {
+ const proc = new Gio.Subprocess({
+ argv: [userShell, ...shellFlags, "-c", `${shellCmd} > "${logFile}" 2>&1`],
+ flags: Gio.SubprocessFlags.NONE,
+ });
+ proc.init(null);
+ proc.wait_async(null, (proc, res) => {
+ try {
+ proc.wait_finish(res);
+ if (proc.get_successful()) {
+ resolve(logFile);
+ } else {
+ reject(logFile);
+ }
+ } catch (e) {
+ reject(logFile);
+ }
+ });
+ } catch (e) {
+ logError(e);
+ reject(null);
+ }
+ });
+};
+
+/**
+ * Open a terminal showing the contents of a log file, then an interactive
+ * shell at the given folder. Used to surface devcontainer errors.
+ * @param {String} logFile
+ * @param {String} localFolder
+ */
+const openTerminalWithLog = (logFile, localFolder) => {
+ // shell_quote produces a safely-escaped token for arbitrary paths
+ // (spaces, quotes, semicolons, etc.) without risking command injection.
+ const quotedLog = GLib.shell_quote(logFile);
+ const quotedFolder = GLib.shell_quote(localFolder);
+ const shellCmd = `cat ${quotedLog}; rm -f ${quotedLog}; cd ${quotedFolder}; exec $SHELL`;
+ const argv = devcontainerTerminalArgv(shellCmd);
+ if (!argv) return;
+ try {
+ const proc = new Gio.Subprocess({ argv, flags: Gio.SubprocessFlags.NONE });
+ proc.init(null);
+ } catch (e) {
+ logError(e);
+ }
+};
+
+/**
+ * Start a devcontainer using the devcontainer CLI.
+ * Runs in the background; notifies on success or opens a terminal with the
+ * captured log on failure.
+ * @param {String} localFolder The workspace folder path for the devcontainer
+ */
+export const runDevcontainerUp = (localFolder) => {
+ const folderName = GLib.path_get_basename(localFolder);
+ Main.notify("Devcontainer", `Starting ${folderName}…`);
+
+ runDevcontainerProcess(
+ `devcontainer up --workspace-folder "${localFolder}"`,
+ localFolder
+ ).then((logFile) => {
+ try { Gio.File.new_for_path(logFile).delete(null); } catch (_) {}
+ Main.notify("Devcontainer", `${folderName} started`);
+ }).catch((logFile) => {
+ if (logFile) openTerminalWithLog(logFile, localFolder);
+ });
+};
+
+/**
+ * Recreate a devcontainer using the devcontainer CLI (removes the existing container first).
+ * Runs in the background; notifies on success or opens a terminal with the
+ * captured log on failure.
+ * @param {String} localFolder The workspace folder path for the devcontainer
+ */
+export const runDevcontainerRecreate = (localFolder) => {
+ const folderName = GLib.path_get_basename(localFolder);
+ Main.notify("Devcontainer", `Recreating ${folderName}…`);
+
+ runDevcontainerProcess(
+ `devcontainer up --remove-existing-container --workspace-folder "${localFolder}"`,
+ localFolder
+ ).then((logFile) => {
+ try { Gio.File.new_for_path(logFile).delete(null); } catch (_) {}
+ Main.notify("Devcontainer", `${folderName} recreated`);
+ }).catch((logFile) => {
+ if (logFile) openTerminalWithLog(logFile, localFolder);
+ });
+};
/**
* Check if Linux user is in 'docker' group (to manage Docker without 'sudo')
- * @return {Boolean} whether current Linux user is in 'docker' group or not
+ * @return {Promise} whether current Linux user is in 'docker' group or not
*/
-export const isUserInDockerGroup = (() => {
+export const isUserInDockerGroup = async () => {
const _userName = GLib.get_user_name();
- let _userGroups = GLib.ByteArray.toString(
- GLib.spawn_command_line_sync("groups " + _userName)[1],
- );
- let _inDockerGroup = false;
- if (_userGroups.match(/\sdocker[\s\n]/g)) _inDockerGroup = true; // Regex search for ' docker ' or ' docker' in Linux user's groups
-
- return _inDockerGroup;
-})();
+ const userGroups = await execCommand(["groups", _userName]);
+ return !!userGroups.match(/\sdocker[\s\n]/g); // Regex search for ' docker ' or ' docker' in Linux user's groups
+};
/**
* Check if docker daemon is running
@@ -48,7 +453,7 @@ export const isDockerRunning = async () => {
/**
* Get an array of containers
- * @return {Array} The array of containers as { compose?: {service: string, project: string, conmfigFiles: string, workingDir: string}, name: string, status: string }
+ * @return {Array} The array of containers as { compose?: {service: string, project: string, conmfigFiles: string, workingDir: string}, devcontainer?: {name: string, localFolder: string, configFile: string}, name: string, status: string }
*/
export const getContainers = async () => {
const psOut = await execCommand([
@@ -82,9 +487,10 @@ export const getContainers = async () => {
containersInfo = inspectOut.trim().split("\n");
}
- return containersInfo.map((commandOutput, i) => {
+ return Promise.all(containersInfo.map(async (commandOutput, i) => {
try {
const jsonOutput = JSON.parse(commandOutput);
+ const devcontainerInfo = await getDevcontainerInfo(jsonOutput);
return {
...(jsonOutput[`${COMPOSE_PREFIX}.project`]
? {
@@ -97,13 +503,14 @@ export const getContainers = async () => {
},
}
: {}),
+ ...(devcontainerInfo ? { devcontainer: devcontainerInfo } : {}),
...images[i],
};
} catch (e) {
logError(e);
return images[i];
}
- });
+ }));
};
/**
@@ -138,27 +545,19 @@ export const getContainerCount = async () => {
* @param {Function} callback A callback that takes the status, command, and stdErr
*/
export const runCommand = async (command, containerName, callback) => {
- const validTerminals = {
- "x-terminal-emulator": !!GLib.find_program_in_path("x-terminal-emulator"),
- "gnome-terminal": !!GLib.find_program_in_path("gnome-terminal"),
- ptyxis: !!GLib.find_program_in_path("ptyxis"),
- kgx: !!GLib.find_program_in_path("kgx"),
- };
+ const terminal = detectTerminal();
let cmd = [];
- if (validTerminals.kgx) {
+ if (terminal === "kgx") {
cmd = ["kgx", "-e"];
- } else if (validTerminals.ptyxis) {
+ } else if (terminal === "ptyxis") {
cmd = ["ptyxis", "--", "sh", "-c"];
- } else if (validTerminals["gnome-terminal"]) {
+ } else if (terminal === "gnome-terminal") {
cmd = ["gnome-terminal", "--", "sh", "-c"];
- } else if (validTerminals["x-terminal-emulator"]) {
+ } else if (terminal === "x-terminal-emulator") {
cmd = ["x-terminal-emulator", "-e", "sh", "-c"];
} else {
- const errMsg = `No valid terminal found (${Object.keys(validTerminals).join(
- ", ",
- )})`;
- callback(false, command, errMsg);
+ callback(false, command, `No valid terminal found (kgx, ptyxis, gnome-terminal, x-terminal-emulator)`);
return;
}
@@ -210,7 +609,7 @@ export async function execCommand(
proc.init(null);
return new Promise((resolve, reject) => {
// communicate_utf8() returns a string, communicate() returns a
- // a GLib.Bytes and there are "headless" functions available as well
+ // GLib.Bytes and there are "headless" functions available as well
proc.communicate_utf8_async(null, cancellable, (proc, res) => {
let ok, stdout, stderr;
diff --git a/src/dockerMenu.js b/src/dockerMenu.js
index 91f80c3..13c00f0 100644
--- a/src/dockerMenu.js
+++ b/src/dockerMenu.js
@@ -26,7 +26,7 @@ export const DockerMenu = GObject.registerClass(
this._updateCountLabel = this._updateCountLabel.bind(this);
this._timeout = null;
this.settings = getExtensionObject().getSettings(
- "red.software.systems.easy_docker_containers"
+ "org.gnome.shell.extensions.easy_docker_containers"
);
this._counterEnabled = this.settings.get_boolean("counter-enabled");
@@ -72,8 +72,18 @@ export const DockerMenu = GObject.registerClass(
this.menu._section.addMenuItem(new PopupMenuItem(loading));
- this._refreshCount();
- if (Docker.hasPodman || Docker.hasDocker) {
+ // Defer the first docker ps call by 5 s so it does not compete with
+ // GNOME Shell's own startup work. The counter will show "Loading…" until
+ // the timeout fires, which is preferable to stalling the shell.
+ this._timeout = GLib.timeout_add_seconds(
+ GLib.PRIORITY_DEFAULT_IDLE,
+ 5,
+ () => {
+ this._refreshCount();
+ return GLib.SOURCE_REMOVE;
+ }
+ );
+ if (Docker.hasPodman() || Docker.hasDocker()) {
this.show();
}
}
@@ -118,7 +128,7 @@ export const DockerMenu = GObject.registerClass(
}
_checkServices() {
- if (!Docker.hasPodman && !Docker.hasDocker) {
+ if (!Docker.hasPodman() && !Docker.hasDocker()) {
let errMsg = _("Please install Docker or Podman to use this plugin");
this.menu._section.addMenuItem(new PopupMenuItem(errMsg));
throw new Error(errMsg);
@@ -126,7 +136,7 @@ export const DockerMenu = GObject.registerClass(
}
async _checkDockerRunning() {
- if (!Docker.hasPodman && !(await Docker.isDockerRunning())) {
+ if (!Docker.hasPodman() && !(await Docker.isDockerRunning())) {
let errMsg = _(
"Please start your Docker service first!\n(Seems Docker daemon not started yet.)"
);
@@ -135,7 +145,7 @@ export const DockerMenu = GObject.registerClass(
}
async _checkUserInDockerGroup() {
- if (!Docker.hasPodman && !(await Docker.isUserInDockerGroup)) {
+ if (!Docker.hasPodman() && !(await Docker.isUserInDockerGroup())) {
let errMsg = _(
"Please put your Linux user into `docker` group first!\n(Seems not in that yet.)"
);
@@ -195,6 +205,9 @@ export const DockerMenu = GObject.registerClass(
return (
currContainer.project !== container.project ||
currContainer.name !== container.name ||
+ currContainer.devcontainer?.name !== container.devcontainer?.name ||
+ currContainer.devcontainer?.localFolder !==
+ container.devcontainer?.localFolder ||
isContainerUp(currContainer) !== isContainerUp(container)
);
})
@@ -204,6 +217,7 @@ export const DockerMenu = GObject.registerClass(
this._containers.forEach((container) => {
const subMenu = new DockerSubMenu(
container.compose,
+ container.devcontainer,
container.name,
container.status,
this.menu,
diff --git a/src/dockerMenuItem.js b/src/dockerMenuItem.js
index e7c1ca1..86882c3 100644
--- a/src/dockerMenuItem.js
+++ b/src/dockerMenuItem.js
@@ -37,3 +37,35 @@ export const DockerMenuItem = GObject.registerClass(
}
}
);
+
+// Start a devcontainer via `devcontainer up --workspace-folder `
+export const DevcontainerStartMenuItem = GObject.registerClass(
+ class DevcontainerStartMenuItem extends PopupMenuItem {
+ _init(localFolder, icon, closePopup) {
+ super._init("Start");
+ if (icon) {
+ this.insert_child_at_index(icon, 1);
+ }
+ this.connect("activate", () => {
+ closePopup?.();
+ Docker.runDevcontainerUp(localFolder);
+ });
+ }
+ }
+);
+
+// Recreate a stopped devcontainer via `devcontainer up --remove-existing-container`
+export const DevcontainerRecreateMenuItem = GObject.registerClass(
+ class DevcontainerRecreateMenuItem extends PopupMenuItem {
+ _init(localFolder, icon, closePopup) {
+ super._init("Recreate and start");
+ if (icon) {
+ this.insert_child_at_index(icon, 1);
+ }
+ this.connect("activate", () => {
+ closePopup?.();
+ Docker.runDevcontainerRecreate(localFolder);
+ });
+ }
+ }
+);
diff --git a/src/dockerSubMenuMenuItem.js b/src/dockerSubMenuMenuItem.js
index ca567cc..05deb5c 100644
--- a/src/dockerSubMenuMenuItem.js
+++ b/src/dockerSubMenuMenuItem.js
@@ -1,10 +1,16 @@
"use strict";
+import Clutter from "gi://Clutter";
import St from "gi://St";
import Gio from "gi://Gio";
import GObject from "gi://GObject";
-import { PopupSubMenuMenuItem } from "resource:///org/gnome/shell/ui/popupMenu.js";
-import { DockerMenuItem } from "./dockerMenuItem.js";
+import {
+ PopupMenuItem,
+ PopupSeparatorMenuItem,
+ PopupSubMenuMenuItem,
+} from "resource:///org/gnome/shell/ui/popupMenu.js";
+import { DockerMenuItem, DevcontainerStartMenuItem, DevcontainerRecreateMenuItem } from "./dockerMenuItem.js";
+import * as Docker from "./docker.js";
import { getExtensionObject } from "../extension.js";
/**
@@ -44,20 +50,38 @@ const getStatus = (statusMessage) => {
return status;
};
+const getMenuLabel = (compose, containerName) =>
+ compose ? `${compose.project} ∘ ${compose.service}` : containerName;
+
// Menu entry representing a Docker container
export const DockerSubMenu = GObject.registerClass(
class DockerSubMenu extends PopupSubMenuMenuItem {
_init(
compose,
+ devcontainer,
containerName,
containerStatusMessage,
parentMenu,
closePopup
) {
- super._init(
- compose ? `${compose.project} ∘ ${compose.service}` : containerName
- );
+ super._init(getMenuLabel(compose, containerName));
this._parentMenu = parentMenu;
+ if (devcontainer?.name) {
+ this._setupDevcontainerName(devcontainer.name);
+ }
+ if (devcontainer?.localFolder) {
+ const localFolderItem = new PopupMenuItem(devcontainer.localFolder);
+ localFolderItem.insert_child_at_index(
+ menuIcon("docker-devcontainer-info-symbolic"),
+ 1
+ );
+ localFolderItem.connect("activate", () => {
+ closePopup?.();
+ Docker.openTerminalAtFolder(devcontainer.localFolder);
+ });
+ this.menu.addMenuItem(localFolderItem);
+ this.menu.addMenuItem(new PopupSeparatorMenuItem());
+ }
const composeParams = compose
? [
"-f",
@@ -87,18 +111,39 @@ export const DockerSubMenu = GObject.registerClass(
);
}
- this.menu.addMenuItem(
- new DockerMenuItem(
- containerName,
- ["start"],
- menuIcon(
- compose
- ? "docker-container-start-symbolic-alt"
- : "docker-container-start-symbolic"
- ),
- closePopup
- )
- );
+ if (devcontainer?.localFolder) {
+ this.menu.addMenuItem(
+ new DevcontainerStartMenuItem(
+ devcontainer.localFolder,
+ menuIcon(
+ compose
+ ? "docker-container-start-symbolic-alt"
+ : "docker-container-start-symbolic"
+ ),
+ closePopup
+ )
+ );
+ this.menu.addMenuItem(
+ new DevcontainerRecreateMenuItem(
+ devcontainer.localFolder,
+ menuIcon("docker-devcontainer-recreate-symbolic"),
+ closePopup
+ )
+ );
+ } else {
+ this.menu.addMenuItem(
+ new DockerMenuItem(
+ containerName,
+ ["start"],
+ menuIcon(
+ compose
+ ? "docker-container-start-symbolic-alt"
+ : "docker-container-start-symbolic"
+ ),
+ closePopup
+ )
+ );
+ }
break;
@@ -241,5 +286,25 @@ export const DockerSubMenu = GObject.registerClass(
_getTopMenu() {
return this._parentMenu?._getTopMenu() || super._getTopMenu();
}
+
+ _setupDevcontainerName(devcontainerName) {
+ if (!this.label) return;
+
+ const labelIndex = this.get_children().indexOf(this.label);
+ const labelBox = new St.BoxLayout({
+ vertical: true,
+ x_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ const devcontainerLabel = new St.Label({
+ text: devcontainerName,
+ style: "font-size: 80%; font-style: italic;",
+ });
+
+ this.remove_child(this.label);
+ labelBox.add_child(this.label);
+ labelBox.add_child(devcontainerLabel);
+ this.insert_child_at_index(labelBox, labelIndex >= 0 ? labelIndex : 0);
+ }
}
);
diff --git a/src/prefPages/dockerPrefLogging.js b/src/prefPages/dockerPrefLogging.js
deleted file mode 100644
index 99d16a6..0000000
--- a/src/prefPages/dockerPrefLogging.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Adw from "gi://Adw";
-import {
- ExtensionPreferences,
- gettext as _,
-} from "resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js";
-
-import * as libs from "./libs.js";
-
-export function makePrefCouterGroup(settings) {
- const parent = new Adw.PreferencesGroup({ title: _("Counter Indicator") });
- libs.makeSwitch({
- parent,
- settings,
- title: _("Show"),
- settingsProperty: "counter-enabled",
- });
- libs.makeSpin({
- parent,
- settings,
- title: _("Font size %"),
- min: 50,
- max: 100,
- step: 10,
- value: 70,
- settingsProperty: "counter-font-size",
- });
- libs.makeSpin({
- parent,
- settings,
- title: _("Update frequency (sec)"),
- min: 1,
- max: 120,
- step: 1,
- value: 2,
- settingsProperty: "refresh-delay",
- });
- return parent;
-}