diff --git a/CHANGELOG.md b/CHANGELOG.md index ac41d14..fad4e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ 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-21 + +### Added + +- Show devcontainer names below Docker container menu entries in smaller italic text when detected. +- Show devcontainer local folder in a hover tooltip. +- Open preferred IDE for devcontainer +- Added configuration for devcontainer IDE + ## [32] - 2026-04-27 Added Gnome 50 support diff --git a/README.md b/README.md index c2b7831..605660e 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,43 @@ The following actions are available from the GNOME Panel menu per Docker contain - **Exec** _(Will login to the running container interactively through your default terminal application.)_ - **Logs** _(Will start the running container's Docker logs in your default terminal application.)_ +### Devcontainer support + +When a stopped container was created from a [Dev Container](https://containers.dev/) workspace (i.e. the workspace folder contains a `.devcontainer/devcontainer.json`), the extension shows additional information and actions: + +- The **devcontainer name** (from `devcontainer.json`) is displayed as a subtitle under the container entry. +- The **workspace folder path** is shown as a clickable item — clicking it opens a terminal at that folder. +- **Start** _(Runs `devcontainer up --workspace-folder ` to start the container and apply all lifecycle commands.)_ +- **Recreate and start** _(Runs `devcontainer up --remove-existing-container --workspace-folder ` to destroy the existing container and create a fresh one from the image.)_ + +For **running** devcontainers, an additional action is available: + +- **Open in IDE** _(Runs the configured IDE command to attach your editor to the running container — see [IDE command](#post-recreate-command-ide-reattachment) below.)_ + +> **Note:** these actions require the [`devcontainer` CLI](https://github.com/devcontainers/cli) to be installed and reachable on `PATH` (including version-manager-managed paths such as NVM or pyenv). + +#### Open in IDE command + +Configure a shell command in the extension preferences (_Devcontainer → Open in IDE command_) to attach your editor to a devcontainer. The command is triggered in two situations: + +- Clicking **Open in IDE** on any **running** devcontainer. +- Automatically after a successful **Recreate and start** (since recreation replaces the container ID, causing IDEs to lose their connection). + +Use `%workspaceFolder%` as a placeholder for the workspace folder path. Examples: + +| IDE | Command | +|-----|---------| +| VS Code / Cursor | `code --folder-uri "vscode-remote://dev-container+$(printf '%s' '%workspaceFolder%' \| od -An -tx1 \| tr -dc '[:xdigit:]')/workspaceFolder"` | +| Zed | `zed %workspaceFolder%` _(Zed detects the devcontainer and prompts to reopen)_ | +| IntelliJ / JetBrains | No CLI hook available — reconnect manually from inside the IDE. | + +Leave the field empty to skip this step entirely. + ## Prerequisite[^1] 1. Properly installed and already running Docker service. 2. Corresponding Linux user in `docker` Linux group for manage '_Docker_' without `sudo` permission. +3. _(Devcontainer features only)_ [`devcontainer` CLI](https://github.com/devcontainers/cli) installed and on `PATH`. [^1]: independently from the extension itself 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/icons/docker-devcontainer-open-ide-symbolic.svg b/icons/docker-devcontainer-open-ide-symbolic.svg new file mode 100644 index 0000000..d83ed4e --- /dev/null +++ b/icons/docker-devcontainer-open-ide-symbolic.svg @@ -0,0 +1,18 @@ + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + 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/prefs.js b/prefs.js index c4740d3..a1ebda2 100644 --- a/prefs.js +++ b/prefs.js @@ -7,6 +7,7 @@ import { } from "resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js"; import { makePrefCouterGroup } from "./src/prefPages/dockerPrefCounter.js"; +import { makePrefDevcontainerGroup } from "./src/prefPages/dockerPrefDevcontainer.js"; const DOCKER_LOG_COMMAND = "'docker logs -f --tail 2000 %containerName%; exec $SHELL'"; @@ -113,9 +114,8 @@ export default class DockerContainersPreferences extends ExtensionPreferences { fillPreferencesWindow(window) { const settings = this.getSettings(); const page = new Adw.PreferencesPage(); - //const group = new Adw.PreferencesGroup(); - const counterGroup = makePrefCouterGroup(settings); - page.add(counterGroup); + page.add(makePrefCouterGroup(settings)); + page.add(makePrefDevcontainerGroup(settings)); window.add(page); } 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 55% 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..5088204 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 @@ -17,5 +17,10 @@ true + + '' + Command to open a devcontainer in the configured IDE + Shell command executed when "Open in IDE" is clicked on a running devcontainer, and automatically after a successful recreate. Use %workspaceFolder% as a placeholder for the workspace path. + diff --git a/src/docker.js b/src/docker.js index 7c3b03c..7670eb0 100644 --- a/src/docker.js +++ b/src/docker.js @@ -2,8 +2,219 @@ import Gio from "gi://Gio"; import GLib from "gi://GLib"; +import * as Main from "resource:///org/gnome/shell/ui/main.js"; +import { getExtensionObject } from "../extension.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,36 +230,321 @@ export const dockerCommandsToLabels = { logs: "Logs", }; -export const hasDocker = !!GLib.find_program_in_path("docker"); -export const hasPodman = !!GLib.find_program_in_path("podman"); +// Tracks workspace folders whose devcontainer is currently being recreated. +// Consumed by the menu to display an animated spinner during the operation. +const _recreatingFolders = new Set(); /** - * 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 + * Returns true if the devcontainer for the given workspace folder is currently + * being recreated (i.e. `devcontainer up --remove-existing-container` is running). + * @param {string} localFolder */ -export const isUserInDockerGroup = (() => { - 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 +export const isRecreating = (localFolder) => _recreatingFolders.has(localFolder); + +/** + * Returns true if any devcontainer recreation is currently in progress. + * Used by the menu to decide whether to force a rebuild on open. + */ +export const hasAnyRecreating = () => _recreatingFolders.size > 0; + +// Listeners called synchronously (no idle_add) the moment recreation is +// registered, so the menu can update the spinner without any async delay. +const _recreatingStartListeners = new Set(); + +export const addRecreatingSolderStartListener = + (fn) => _recreatingStartListeners.add(fn); +export const removeRecreatingSolderStartListener = + (fn) => _recreatingStartListeners.delete(fn); - return _inDockerGroup; -})(); +/** + * Schedule an async menu refresh on the next main-loop idle tick. + * + * Uses GLib.idle_add so the call returns immediately and never blocks the + * Promise continuation that triggered it. The refresh runs on the GNOME + * Shell main loop, which is the only thread allowed to touch UI actors. + */ +const _scheduleMenuRefresh = () => { + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + try { + getExtensionObject()?._indicator?._refreshMenu?.(); + } catch (e) { + logError(e); + } + return GLib.SOURCE_REMOVE; + }); +}; + +// 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"; /** - * Check if docker daemon is running - * @return {Boolean} whether docker daemon is running or not + * 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"] */ -export const isDockerRunning = async () => { - const cmdResult = await execCommand(["sh", "-c", "ps cax"]); - return cmdResult.search(/dockerd/) >= 0; +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}…`); + _recreatingFolders.add(localFolder); + // Notify DockerSubMenu instances synchronously so the spinner appears + // immediately, without waiting for a docker-ps cycle. + _recreatingStartListeners.forEach(fn => { + try { fn(localFolder); } catch (e) { logError(e); } + }); + + runDevcontainerProcess( + `devcontainer up --remove-existing-container --workspace-folder "${localFolder}"`, + localFolder + ).then((logFile) => { + _recreatingFolders.delete(localFolder); + _scheduleMenuRefresh(); // async: removes spinner from menu + try { Gio.File.new_for_path(logFile).delete(null); } catch (_) { } + Main.notify("Devcontainer", `${folderName} recreated`); + runDevcontainerIDE(localFolder, { silent: true }); // IDE opens only after process done + }).catch((logFile) => { + _recreatingFolders.delete(localFolder); + _scheduleMenuRefresh(); // async: removes spinner from menu + if (logFile) openTerminalWithLog(logFile, localFolder); + }); +}; + +/** + * Open a devcontainer in the user's configured IDE. + * + * Reads the `devcontainer-ide-command` setting, substitutes %workspaceFolder%, + * and spawns the command in the background via the user's login shell. + * + * @param {string} localFolder The workspace folder path. + * @param {{ silent?: boolean }} options + * silent=true – do nothing (no notification) when no command is configured. + * Used by the post-recreate auto-trigger. + * silent=false – show a notification pointing to prefs when unconfigured. + * Used by the explicit "Open in IDE" menu action. + */ +export const runDevcontainerIDE = (localFolder, { silent = false } = {}) => { + try { + const settings = getExtensionObject().getSettings( + "org.gnome.shell.extensions.easy_docker_containers" + ); + const cmd = settings.get_string("devcontainer-ide-command"); + if (!cmd || !cmd.trim()) { + if (!silent) { + Main.notify( + "Devcontainer", + "No IDE command configured. Set one in the extension preferences." + ); + } + return; + } + + const resolvedCmd = cmd.replaceAll("%workspaceFolder%", localFolder); + const userShell = getUserShell(); + const shellFlags = getLoginShellFlags(); + const proc = new Gio.Subprocess({ + argv: [userShell, ...shellFlags, "-c", resolvedCmd], + flags: Gio.SubprocessFlags.NONE, + }); + proc.init(null); + } catch (e) { + logError(e); + } +}; + +/** + * Check if Linux user is in 'docker' group (to manage Docker without 'sudo') + * @return {Promise} whether current Linux user is in 'docker' group or not + */ +export const isUserInDockerGroup = async () => { + const _userName = GLib.get_user_name(); + const userGroups = await execCommand(["groups", _userName]); + return !!userGroups.match(/\sdocker[\s\n]/g); // Regex search for ' docker ' or ' docker' in Linux user's groups +}; + + + /** * 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 {Promise} The array of containers as { compose?: {service: string, project: string, configFiles: string, workingDir: string}, devcontainer?: {name: string, localFolder: string, configFile: string}, name: string, status: string } */ export const getContainers = async () => { const psOut = await execCommand([ @@ -82,28 +578,30 @@ export const getContainers = async () => { containersInfo = inspectOut.trim().split("\n"); } - return containersInfo.map((commandOutput, i) => { + return await Promise.all(containersInfo.map(async (commandOutput, i) => { try { const jsonOutput = JSON.parse(commandOutput); + const devcontainerInfo = await getDevcontainerInfo(jsonOutput); return { ...(jsonOutput[`${COMPOSE_PREFIX}.project`] ? { - compose: { - service: jsonOutput[`${COMPOSE_PREFIX}.service`], - project: jsonOutput[`${COMPOSE_PREFIX}.project`], - configFiles: - jsonOutput[`${COMPOSE_PREFIX}.project.config_files`], - workingDir: jsonOutput[`${COMPOSE_PREFIX}.project.working_dir`], - }, - } + compose: { + service: jsonOutput[`${COMPOSE_PREFIX}.service`], + project: jsonOutput[`${COMPOSE_PREFIX}.project`], + configFiles: + jsonOutput[`${COMPOSE_PREFIX}.project.config_files`], + workingDir: jsonOutput[`${COMPOSE_PREFIX}.project.working_dir`], + }, + } : {}), + ...(devcontainerInfo ? { devcontainer: devcontainerInfo } : {}), ...images[i], }; } catch (e) { logError(e); return images[i]; } - }); + })); }; /** @@ -138,27 +636,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 +700,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..2b56bd9 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(); } } @@ -91,6 +101,7 @@ export const DockerMenu = GObject.registerClass( _updateCountLabel(count) { if ( + this._counterEnabled && this._refreshDelay > 0 && this._counterFontSize > 0 && this.buttonText.get_text() !== count @@ -102,40 +113,31 @@ export const DockerMenu = GObject.registerClass( // Refresh the menu everytime the user opens it // It allows to have up-to-date information on docker containers async _refreshMenu() { + if (!this.menu.isOpen) return; try { - if (this.menu.isOpen) { - const containers = await Docker.getContainers(); - this._updateCountLabel( - containers.filter((container) => isContainerUp(container)).length - ); - this._feedMenu(containers).catch((e) => - this.menu._section.addMenuItem(new PopupMenuItem(e.message)) - ); - } + await this._check(); + const containers = await Docker.getContainers(); + this._updateCountLabel( + containers.filter((container) => isContainerUp(container)).length + ); + await this._feedMenu(containers); } catch (e) { + this.menu._section.removeAll(); + this.menu._section.addMenuItem(new PopupMenuItem(e.message)); logError(e); } } _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); } } - async _checkDockerRunning() { - if (!Docker.hasPodman && !(await Docker.isDockerRunning())) { - let errMsg = _( - "Please start your Docker service first!\n(Seems Docker daemon not started yet.)" - ); - throw new Error(errMsg); - } - } - 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.)" ); @@ -144,11 +146,13 @@ export const DockerMenu = GObject.registerClass( } async _check() { - return Promise.all([ - this._checkServices(), - this._checkDockerRunning(), - //this._checkUserInDockerGroup() - ]); + // Only verify the docker/podman binary is installed. + // Daemon-availability is implicitly checked by getContainers() running + // `docker ps -a`, which fails fast with a descriptive error if the + // daemon is unreachable. Avoid heavy probes like `docker info` here: + // they can take many seconds or stall when registry/plugin lookups + // are slow, leaving the menu stuck on "Loading...". + this._checkServices(); } clearLoop() { @@ -185,8 +189,17 @@ export const DockerMenu = GObject.registerClass( // Append containers to menu async _feedMenu(dockerContainers) { - await this._check(); + + // Snapshot the recreating state once so we compare consistently. + // Rebuild when: + // - recreation state changed since last build (started or just finished) + // → this is the hook that removes the spinner automatically + // - recreation is still in progress (keep the spinner fresh) + // - containers list changed (normal docker-ps diff) + const anyRecreating = Docker.hasAnyRecreating(); if ( + anyRecreating !== this._anyRecreating || + anyRecreating || !this._containers || dockerContainers.length !== this._containers.length || dockerContainers.some((currContainer, i) => { @@ -195,15 +208,20 @@ 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) ); }) ) { + this._anyRecreating = anyRecreating; this.menu._section.removeAll(); this._containers = dockerContainers; 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..2dbe35c 100644 --- a/src/dockerMenuItem.js +++ b/src/dockerMenuItem.js @@ -37,3 +37,53 @@ 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); + }); + } + } +); + +// Open a running devcontainer in the user's configured IDE +export const DevcontainerOpenInIDEMenuItem = GObject.registerClass( + class DevcontainerOpenInIDEMenuItem extends PopupMenuItem { + _init(localFolder, icon, closePopup) { + super._init("Open in IDE"); + if (icon) { + this.insert_child_at_index(icon, 1); + } + this.connect("activate", () => { + closePopup?.(); + Docker.runDevcontainerIDE(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", () => { + // Intentionally do NOT close the popup: keeping the menu open lets + // the synchronous listener call in runDevcontainerRecreate update + // the spinner in place, so the user sees it immediately. + Docker.runDevcontainerRecreate(localFolder); + }); + } + } +); diff --git a/src/dockerSubMenuMenuItem.js b/src/dockerSubMenuMenuItem.js index ca567cc..7573aaf 100644 --- a/src/dockerSubMenuMenuItem.js +++ b/src/dockerSubMenuMenuItem.js @@ -1,10 +1,17 @@ "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 { Spinner } from "resource:///org/gnome/shell/ui/animation.js"; +import { + PopupMenuItem, + PopupSeparatorMenuItem, + PopupSubMenuMenuItem, +} from "resource:///org/gnome/shell/ui/popupMenu.js"; +import { DockerMenuItem, DevcontainerStartMenuItem, DevcontainerRecreateMenuItem, DevcontainerOpenInIDEMenuItem } from "./dockerMenuItem.js"; +import * as Docker from "./docker.js"; import { getExtensionObject } from "../extension.js"; /** @@ -44,31 +51,76 @@ 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; + + // Store data needed for re-rendering the recreating state later. + this._devcontainer = devcontainer; + this._containerName = containerName; + this._closePopup = closePopup; + this._inRecreatingSolderState = false; + + 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()); + + // Register a synchronous listener so that when recreation starts + // while this menu item is alive, the spinner appears instantly + // without waiting for a docker-ps rebuild. + this._onRecreatingSolderStart = (folder) => { + if (folder === devcontainer.localFolder) + this._enterRecreatingSolderState(); + }; + Docker.addRecreatingSolderStartListener(this._onRecreatingSolderStart); + this.connect("destroy", () => + Docker.removeRecreatingSolderStartListener(this._onRecreatingSolderStart) + ); + } + const composeParams = compose ? [ - "-f", - `${compose.configFiles}`, - "--project-directory", - `${compose.workingDir}`, - "-p", - `${compose.project}`, - ] + "-f", + `${compose.configFiles}`, + "--project-directory", + `${compose.workingDir}`, + "-p", + `${compose.project}`, + ] : []; + // If recreation is already in progress when the menu is built, + // enter the spinner state immediately (no docker-ps round-trip needed). + if (devcontainer?.localFolder && Docker.isRecreating(devcontainer.localFolder)) { + this._enterRecreatingSolderState(); + return; + } + switch (getStatus(containerStatusMessage)) { case "stopped": this.insert_child_at_index( @@ -87,18 +139,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; @@ -108,6 +181,17 @@ export const DockerSubMenu = GObject.registerClass( 1 ); + if (devcontainer?.localFolder) { + this.menu.addMenuItem( + new DevcontainerOpenInIDEMenuItem( + devcontainer.localFolder, + menuIcon("docker-devcontainer-open-ide-symbolic"), + closePopup + ) + ); + this.menu.addMenuItem(new PopupSeparatorMenuItem()); + } + if (compose) { this.menu.addMenuItem( new DockerMenuItem( @@ -238,8 +322,82 @@ export const DockerSubMenu = GObject.registerClass( ) ); } + /** + * Switch this menu item to the "recreating" visual state in-place. + * + * Replaces the status icon in the header with an animated spinner and + * rebuilds the submenu to show only a disabled "Recreating…" label and + * the Logs action. Called synchronously by the recreation-start listener + * so the update is instant — no docker-ps round-trip required. + * + * Guarded by `_inRecreatingSolderState` so repeated calls are no-ops. + */ + _enterRecreatingSolderState() { + if (this._inRecreatingSolderState) return; + this._inRecreatingSolderState = true; + + // ── Header: swap status icon → animated spinner ────────────────────── + // PopupSubMenuMenuItem children without an icon: [label/labelBox, arrow] + // With a status icon inserted at index 1: [label/labelBox, icon, arrow] + // Remove the icon (if present) before inserting the spinner. + if (this.get_n_children() >= 3) { + this.remove_child(this.get_child_at_index(1)); + } + const spinner = new Spinner(16); + spinner.play(); + this.insert_child_at_index(spinner, 1); + + // ── Submenu: rebuild with recreating-state items ────────────────────── + this.menu.removeAll(); + + if (this._devcontainer?.localFolder) { + const localFolderItem = new PopupMenuItem(this._devcontainer.localFolder); + localFolderItem.insert_child_at_index( + menuIcon("docker-devcontainer-info-symbolic"), 1 + ); + localFolderItem.connect("activate", () => { + this._closePopup?.(); + Docker.openTerminalAtFolder(this._devcontainer.localFolder); + }); + this.menu.addMenuItem(localFolderItem); + this.menu.addMenuItem(new PopupSeparatorMenuItem()); + } + + const busyItem = new PopupMenuItem("Recreating\u2026"); + busyItem.sensitive = false; + this.menu.addMenuItem(busyItem); + this.menu.addMenuItem(new PopupSeparatorMenuItem()); + this.menu.addMenuItem( + new DockerMenuItem( + this._containerName, ["logs"], + menuIcon("docker-container-logs-symbolic"), + this._closePopup + ) + ); + } + _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/dockerPrefDevcontainer.js b/src/prefPages/dockerPrefDevcontainer.js new file mode 100644 index 0000000..cbf2adf --- /dev/null +++ b/src/prefPages/dockerPrefDevcontainer.js @@ -0,0 +1,41 @@ +import Adw from "gi://Adw"; +import { + gettext as _, +} from "resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js"; + +import * as libs from "./libs.js"; + +const PLACEHOLDER = "%workspaceFolder%"; + +// Example commands shown as hints in the group description. +// Each IDE replaces %workspaceFolder% with the actual path at runtime. +// IntelliJ / JetBrains IDEs have no CLI hook for devcontainer reattachment +// (their flow is fully UI-driven), so no example is provided for them. +const EXAMPLES = [ + `VS Code / Cursor:`, + ` code --folder-uri "vscode-remote://dev-container+$(printf '%s' '${PLACEHOLDER}' | od -An -tx1 | tr -dc '[:xdigit:]')/workspaceFolder"`, + `Zed (detects devcontainer on open):`, + ` zed ${PLACEHOLDER}`, + `IntelliJ / JetBrains: no CLI hook — reconnect manually from the IDE.`, +].join("\n"); + +export function makePrefDevcontainerGroup(settings) { + const parent = new Adw.PreferencesGroup({ + title: _("Devcontainer"), + description: _( + `Shell command used to open a devcontainer in your IDE.\n` + + `Triggered by \"Open in IDE\" on running containers and automatically after \"Recreate and start\".\n` + + `${PLACEHOLDER} is replaced with the workspace folder path. Leave empty to skip.\n\n` + + EXAMPLES + ), + }); + + libs.makeEntry({ + parent, + settings, + title: _("Open in IDE command"), + settingsProperty: "devcontainer-ide-command", + }); + + return parent; +} 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; -} diff --git a/src/prefPages/libs.js b/src/prefPages/libs.js index 1d77f0d..ed82733 100644 --- a/src/prefPages/libs.js +++ b/src/prefPages/libs.js @@ -98,6 +98,33 @@ export function makeSwitch( return row; } +export function makeEntry( + options = { + settings: null, + settingsProperty: "", + parent: null, + title: "default", + } +) { + const row = new Adw.EntryRow({ + title: options.title, + }); + + if (options.parent) { + options.parent.add(row); + } + + if (options.settings && options.settingsProperty) { + options.settings.bind( + options.settingsProperty, + row, + "text", + Gio.SettingsBindFlags.DEFAULT + ); + } + return row; +} + export function makeCombo( options = { settings: null,