From 57bbefdc499fc958834fd96f5948524982e4517d Mon Sep 17 00:00:00 2001 From: Andrea Chiumenti Date: Mon, 4 May 2026 18:43:01 +0200 Subject: [PATCH 1/3] apply suggested fixes --- build.sh | 2 +- metadata.json | 2 +- ...nsions.easy_docker_containers.gschema.xml} | 2 +- src/docker.js | 15 +++----- src/dockerMenu.js | 4 +- src/prefPages/dockerPrefLogging.js | 38 ------------------- 6 files changed, 10 insertions(+), 53 deletions(-) rename schemas/{red.software.systems.easy_docker_containers.gschema.xml => org.gnome.shell.extensions.easy_docker_containers.gschema.xml} (83%) delete mode 100644 src/prefPages/dockerPrefLogging.js 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/metadata.json b/metadata.json index ddf5db9..5fca903 100644 --- a/metadata.json +++ b/metadata.json @@ -4,6 +4,6 @@ "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", + "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..953d901 100644 --- a/src/docker.js +++ b/src/docker.js @@ -24,18 +24,13 @@ export const hasPodman = !!GLib.find_program_in_path("podman"); /** * 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 diff --git a/src/dockerMenu.js b/src/dockerMenu.js index 91f80c3..a62e71c 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"); @@ -135,7 +135,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.)" ); 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; -} From b8b006e67020c129b600d1470d6b03d72cbfefa6 Mon Sep 17 00:00:00 2001 From: Andrea Chiumenti Date: Sun, 10 May 2026 14:14:13 +0200 Subject: [PATCH 2/3] devcontainer feature --- CHANGELOG.md | 7 + icons/docker-devcontainer-info-symbolic.svg | 89 ++++ .../docker-devcontainer-recreate-symbolic.svg | 88 ++++ metadata.json | 2 +- src/docker.js | 421 +++++++++++++++++- src/dockerMenu.js | 24 +- src/dockerMenuItem.js | 32 ++ src/dockerSubMenuMenuItem.js | 99 +++- 8 files changed, 733 insertions(+), 29 deletions(-) create mode 100644 icons/docker-devcontainer-info-symbolic.svg create mode 100644 icons/docker-devcontainer-recreate-symbolic.svg 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/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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/icons/docker-devcontainer-recreate-symbolic.svg b/icons/docker-devcontainer-recreate-symbolic.svg new file mode 100644 index 0000000..1dd5140 --- /dev/null +++ b/icons/docker-devcontainer-recreate-symbolic.svg @@ -0,0 +1,88 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/metadata.json b/metadata.json index 5fca903..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, + "version": 33, "settings-schema": "org.gnome.shell.extensions.easy_docker_containers", "shell-version": ["45", "46", "47", "48", "49", "50"] } diff --git a/src/docker.js b/src/docker.js index 953d901..5874de9 100644 --- a/src/docker.js +++ b/src/docker.js @@ -2,8 +2,212 @@ 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, + }; +}; + +/** + * 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 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"), + }; + + let argv; + if (validTerminals.kgx) { + argv = ["kgx", "--working-directory", folderPath]; + } else if (validTerminals.ptyxis) { + argv = ["ptyxis", "--working-directory", folderPath]; + } else if (validTerminals["gnome-terminal"]) { + argv = ["gnome-terminal", "--working-directory", folderPath]; + } else if (validTerminals["x-terminal-emulator"]) { + argv = ["sh", "-c", `x-terminal-emulator -e sh -c 'cd "${folderPath}"; exec $SHELL'`]; + } else { + logError( + new Error( + `No valid terminal found (${Object.keys(validTerminals).join(", ")})` + ) + ); + 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,8 +223,211 @@ 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 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"), + }; + + // 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 (validTerminals.kgx) { + return ["kgx", "-e", userShell, ...shellFlags, "-c", shellCmd]; + } else if (validTerminals.ptyxis) { + return ["ptyxis", "--", userShell, ...shellFlags, "-c", shellCmd]; + } else if (validTerminals["gnome-terminal"]) { + return ["gnome-terminal", "--", userShell, ...shellFlags, "-c", shellCmd]; + } else if (validTerminals["x-terminal-emulator"]) { + return ["x-terminal-emulator", "-e", userShell, ...shellFlags, "-c", shellCmd]; + } + + logError( + new Error( + `No valid terminal found (${Object.keys(validTerminals).join(", ")})` + ) + ); + 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); + const logFile = `${GLib.get_tmp_dir()}/devcontainer-${folderName}.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) => { + const shellCmd = `cat "${logFile}"; rm -f "${logFile}"; cd "${localFolder}"; 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') @@ -43,7 +450,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([ @@ -77,9 +484,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`] ? { @@ -92,13 +500,14 @@ export const getContainers = async () => { }, } : {}), + ...(devcontainerInfo ? { devcontainer: devcontainerInfo } : {}), ...images[i], }; } catch (e) { logError(e); return images[i]; } - }); + })); }; /** @@ -205,7 +614,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 a62e71c..13c00f0 100644 --- a/src/dockerMenu.js +++ b/src/dockerMenu.js @@ -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); + } } ); From da4c79290c4c8122b2f4bf727271394d7b462651 Mon Sep 17 00:00:00 2001 From: Andrea Chiumenti Date: Sun, 10 May 2026 19:23:04 +0200 Subject: [PATCH 3/3] code optimizations --- .../docker-devcontainer-recreate-symbolic.svg | 2 +- src/docker.js | 89 +++++++++---------- 2 files changed, 43 insertions(+), 48 deletions(-) diff --git a/icons/docker-devcontainer-recreate-symbolic.svg b/icons/docker-devcontainer-recreate-symbolic.svg index 1dd5140..7b07be6 100644 --- a/icons/docker-devcontainer-recreate-symbolic.svg +++ b/icons/docker-devcontainer-recreate-symbolic.svg @@ -83,6 +83,6 @@ + d="M 7.5,1 1,4 v 8 L 7.5,15 14,12 V 4 Z M 7.5,3 C 8.6046,3 9.5,3.8954 9.5,5 C 9.5,6.1046 8.6046,7 7.5,7 C 6.3954,7 5.5,6.1046 5.5,5 C 5.5,3.8954 6.3954,3 7.5,3 Z M 7.5,9 C 8.6046,9 9.5,9.8954 9.5,11 C 9.5,12.1046 8.6046,13 7.5,13 C 6.3954,13 5.5,12.1046 5.5,11 C 5.5,9.8954 6.3954,9 7.5,9 Z M 4.5,6 C 5.6046,6 6.5,6.8954 6.5,8 C 6.5,9.1046 5.6046,10 4.5,10 C 3.3954,10 2.5,9.1046 2.5,8 C 2.5,6.8954 3.3954,6 4.5,6 Z M 10.5,6 C 11.6046,6 12.5,6.8954 12.5,8 C 12.5,9.1046 11.6046,10 10.5,10 C 9.3954,10 8.5,9.1046 8.5,8 C 8.5,6.8954 9.3954,6 10.5,6 Z" /> diff --git a/src/docker.js b/src/docker.js index 5874de9..5b3676f 100644 --- a/src/docker.js +++ b/src/docker.js @@ -167,33 +167,39 @@ const getDevcontainerInfo = async (labels) => { }; }; +/** + * 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 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 argv; - if (validTerminals.kgx) { + if (terminal === "kgx") { argv = ["kgx", "--working-directory", folderPath]; - } else if (validTerminals.ptyxis) { + } else if (terminal === "ptyxis") { argv = ["ptyxis", "--working-directory", folderPath]; - } else if (validTerminals["gnome-terminal"]) { + } else if (terminal === "gnome-terminal") { argv = ["gnome-terminal", "--working-directory", folderPath]; - } else if (validTerminals["x-terminal-emulator"]) { - argv = ["sh", "-c", `x-terminal-emulator -e sh -c 'cd "${folderPath}"; exec $SHELL'`]; + } 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 (${Object.keys(validTerminals).join(", ")})` - ) - ); + logError(new Error(`No valid terminal found (kgx, ptyxis, gnome-terminal, x-terminal-emulator)`)); return; } @@ -299,33 +305,24 @@ export const detectDevcontainerCli = async () => { * @return {String[]|null} argv array, or null if no terminal was found */ const devcontainerTerminalArgv = (shellCmd) => { - 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(); // 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 (validTerminals.kgx) { + if (terminal === "kgx") { return ["kgx", "-e", userShell, ...shellFlags, "-c", shellCmd]; - } else if (validTerminals.ptyxis) { + } else if (terminal === "ptyxis") { return ["ptyxis", "--", userShell, ...shellFlags, "-c", shellCmd]; - } else if (validTerminals["gnome-terminal"]) { + } else if (terminal === "gnome-terminal") { return ["gnome-terminal", "--", userShell, ...shellFlags, "-c", shellCmd]; - } else if (validTerminals["x-terminal-emulator"]) { + } else if (terminal === "x-terminal-emulator") { return ["x-terminal-emulator", "-e", userShell, ...shellFlags, "-c", shellCmd]; } - logError( - new Error( - `No valid terminal found (${Object.keys(validTerminals).join(", ")})` - ) - ); + logError(new Error(`No valid terminal found (kgx, ptyxis, gnome-terminal, x-terminal-emulator)`)); return null; }; @@ -339,7 +336,9 @@ const devcontainerTerminalArgv = (shellCmd) => { */ const runDevcontainerProcess = (shellCmd, localFolder) => { const folderName = GLib.path_get_basename(localFolder); - const logFile = `${GLib.get_tmp_dir()}/devcontainer-${folderName}.log`; + // 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(); @@ -376,7 +375,11 @@ const runDevcontainerProcess = (shellCmd, localFolder) => { * @param {String} localFolder */ const openTerminalWithLog = (logFile, localFolder) => { - const shellCmd = `cat "${logFile}"; rm -f "${logFile}"; cd "${localFolder}"; exec $SHELL`; + // 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 { @@ -542,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; }