From cc8a6053c53d8f1816b8660bca994b07e7546b41 Mon Sep 17 00:00:00 2001 From: Matt Jiang <67533680+matthewyjiang@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:26:56 -0400 Subject: [PATCH] feat(mux): add Herdr backend (#3531) * mux: add herdr backend * mux: remove herdr service tests * mux: include herdr in settings subtitle * mux: add translator context to the multiplexers subtitle * mux: drive the service from a backend table Each backend (tmux, zellij, herdr) is one entry in MuxBackends.js with its commands and parser, so the service no longer branches on muxType in eight places and the settings dropdown lists the table keys. One availability check runs for the selected backend and re-runs when the backend changes. The herdr parser reads the real {sessions:[...]} shape only. --- quickshell/Common/MuxBackends.js | 77 ++++++++++ quickshell/Common/SettingsTabs.qml | 2 +- quickshell/Modules/Settings/MuxTab.qml | 7 +- quickshell/Services/MuxService.qml | 141 ++++-------------- quickshell/tests/mux-backends.test.mjs | 50 +++++++ .../translations/settings_search_index.json | 4 +- 6 files changed, 163 insertions(+), 118 deletions(-) create mode 100644 quickshell/Common/MuxBackends.js create mode 100644 quickshell/tests/mux-backends.test.mjs diff --git a/quickshell/Common/MuxBackends.js b/quickshell/Common/MuxBackends.js new file mode 100644 index 000000000..9d6ad4337 --- /dev/null +++ b/quickshell/Common/MuxBackends.js @@ -0,0 +1,77 @@ +.pragma library + +// Terminal multiplexer backends for the mux launcher. Each command is an argv +// array. attach and create run inside the user's terminal; the rest run +// headless. rename is optional: backends without it hide the rename action. +// Parsers turn `list` output into [{ name, windows, attached }]. + +function parseTmux(output) { + return output.trim().split("\n").map(line => line.trim().split("|")).filter(parts => parts.length >= 3 && parts[0]).map(parts => ({ + name: parts[0], + windows: parts[1], + attached: parts[2] === "1" + })); +} + +function parseZellij(output) { + return output.trim().split("\n").map(line => line.trim()).filter(line => line).map(line => { + const bracketIdx = line.indexOf(" ["); + return { + name: (bracketIdx > 0 ? line.substring(0, bracketIdx) : line).trim(), + windows: "N/A", + attached: !line.includes("(EXITED") + }; + }); +} + +function parseHerdr(output) { + return (JSON.parse(output).sessions ?? []).map(session => ({ + name: session.name, + windows: "N/A", + attached: session.running === true + })); +} + +// var, not const: QML only exposes var and function bindings from .js libraries +var BACKENDS = { + tmux: { + displayName: "Tmux", + list: ["tmux", "list-sessions", "-F", "#{session_name}|#{session_windows}|#{session_attached}"], + parse: parseTmux, + attach: name => ["tmux", "attach", "-t", name], + create: name => ["tmux", "new-session", "-s", name], + kill: name => ["tmux", "kill-session", "-t", name], + rename: (oldName, newName) => ["tmux", "rename-session", "-t", oldName, newName] + }, + zellij: { + displayName: "Zellij", + list: ["zellij", "list-sessions", "--no-formatting"], + parse: parseZellij, + attach: name => ["zellij", "attach", name], + create: name => ["zellij", "-s", name], + kill: name => ["zellij", "kill-session", name] + }, + herdr: { + displayName: "Herdr", + list: ["herdr", "session", "list", "--json"], + parse: parseHerdr, + attach: name => ["herdr", "session", "attach", name], + create: name => ["herdr", "--session", name], + // stop, not delete: the session stays listed so it can be reattached + kill: name => ["herdr", "session", "stop", name] + } +}; + +// filter is a comma separated list of exact names or /regex/ patterns. +function isSessionExcluded(name, filter) { + return filter.split(",").map(pattern => pattern.trim()).filter(pattern => pattern).some(pattern => { + if (pattern.startsWith("/") && pattern.endsWith("/") && pattern.length > 2) { + try { + return new RegExp(pattern.slice(1, -1)).test(name); + } catch (e) { + return false; + } + } + return name.toLowerCase() === pattern.toLowerCase(); + }); +} diff --git a/quickshell/Common/SettingsTabs.qml b/quickshell/Common/SettingsTabs.qml index 5dfa74047..bb123da11 100644 --- a/quickshell/Common/SettingsTabs.qml +++ b/quickshell/Common/SettingsTabs.qml @@ -553,7 +553,7 @@ Singleton { "text": I18n.tr("Multiplexers", "settings page name for terminal multiplexers like tmux"), "icon": "terminal", "tabIndex": 32, - "hint": I18n.tr("tmux and zellij sessions") + "hint": I18n.tr("tmux, zellij, and herdr sessions", "settings page description for terminal multiplexer sessions; tmux, zellij, and herdr are product names") }, { "id": "printers", diff --git a/quickshell/Modules/Settings/MuxTab.qml b/quickshell/Modules/Settings/MuxTab.qml index d915b91af..064cdf8a1 100644 --- a/quickshell/Modules/Settings/MuxTab.qml +++ b/quickshell/Modules/Settings/MuxTab.qml @@ -3,24 +3,25 @@ import qs.Common import qs.Services import qs.Widgets import qs.Modules.Settings.Widgets +import "../../Common/MuxBackends.js" as MuxBackends Item { id: root - readonly property var muxTypeOptions: ["tmux", "zellij"] + readonly property var muxTypeOptions: Object.keys(MuxBackends.BACKENDS) SettingsPage { id: mainColumn SettingsCard { tab: "mux" - tags: ["mux", "multiplexer", "tmux", "zellij", "type"] + tags: ["mux", "multiplexer", "tmux", "zellij", "herdr", "type"] title: I18n.tr("General") iconName: "terminal" SettingsDropdownRow { tab: "mux" - tags: ["mux", "multiplexer", "tmux", "zellij", "type", "backend"] + tags: ["mux", "multiplexer", "tmux", "zellij", "herdr", "type", "backend"] settingKey: "muxType" text: I18n.tr("Type") options: root.muxTypeOptions diff --git a/quickshell/Services/MuxService.qml b/quickshell/Services/MuxService.qml index 0591dc781..73a7fd32f 100644 --- a/quickshell/Services/MuxService.qml +++ b/quickshell/Services/MuxService.qml @@ -6,6 +6,7 @@ import Quickshell import Quickshell.Io import qs.Common import qs.Services +import "../Common/MuxBackends.js" as MuxBackends Singleton { id: root @@ -13,13 +14,12 @@ Singleton { property var sessions: [] property bool loading: false - - property bool tmuxAvailable: false - property bool zellijAvailable: false - readonly property bool currentMuxAvailable: muxType === "zellij" ? zellijAvailable : tmuxAvailable + property bool currentMuxAvailable: false readonly property string muxType: SettingsData.muxType - readonly property string displayName: muxType === "zellij" ? "Zellij" : "Tmux" + readonly property var backend: MuxBackends.BACKENDS[muxType] ?? MuxBackends.BACKENDS.tmux + readonly property string displayName: backend.displayName + readonly property bool supportsRename: !!backend.rename readonly property var terminalFlags: ({ "ghostty": ["-e"], @@ -46,28 +46,24 @@ Singleton { } Process { - id: tmuxCheckProcess - command: ["sh", "-c", "command -v tmux"] + id: availabilityCheck + command: ["sh", "-c", "command -v " + root.backend.list[0]] running: false onExited: code => { - root.tmuxAvailable = (code === 0); - } - } - - Process { - id: zellijCheckProcess - command: ["sh", "-c", "command -v zellij"] - running: false - onExited: code => { - root.zellijAvailable = (code === 0); + root.currentMuxAvailable = (code === 0); } } + // Restart so a backend switch mid-check never inherits the old binary's result function checkAvailability() { - tmuxCheckProcess.running = true; - zellijCheckProcess.running = true; + if (availabilityCheck.running) + availabilityCheck.running = false; + Qt.callLater(function () { + availabilityCheck.running = true; + }); } + onBackendChanged: checkAvailability() Component.onCompleted: checkAvailability() Process { @@ -77,10 +73,7 @@ Singleton { stdout: StdioCollector { onStreamFinished: { try { - if (root.muxType === "zellij") - root._parseZellijSessions(text); - else - root._parseTmuxSessions(text); + root.sessions = root.backend.parse(text).filter(session => !root._isSessionExcluded(session.name)); } catch (e) { log.error("Error parsing sessions:", e); root.sessions = []; @@ -116,119 +109,41 @@ Singleton { if (listProcess.running) listProcess.running = false; - if (root.muxType === "zellij") - listProcess.command = ["zellij", "list-sessions", "--no-formatting"]; - else - listProcess.command = ["tmux", "list-sessions", "-F", "#{session_name}|#{session_windows}|#{session_attached}"]; - + listProcess.command = root.backend.list; Qt.callLater(function () { listProcess.running = true; }); } function _isSessionExcluded(name) { - var filter = SettingsData.muxSessionFilter.trim(); - if (filter.length === 0) - return false; - var parts = filter.split(","); - for (var i = 0; i < parts.length; i++) { - var pattern = parts[i].trim(); - if (pattern.length === 0) - continue; - if (pattern.startsWith("/") && pattern.endsWith("/") && pattern.length > 2) { - try { - var re = new RegExp(pattern.slice(1, -1)); - if (re.test(name)) - return true; - } catch (e) {} - } else { - if (name.toLowerCase() === pattern.toLowerCase()) - return true; - } - } - return false; + return MuxBackends.isSessionExcluded(name, SettingsData.muxSessionFilter); } - function _parseTmuxSessions(output) { - var sessionList = []; - var lines = output.trim().split('\n'); - - for (var i = 0; i < lines.length; i++) { - var line = lines[i].trim(); - if (line.length === 0) - continue; - var parts = line.split('|'); - if (parts.length >= 3 && !_isSessionExcluded(parts[0])) { - sessionList.push({ - name: parts[0], - windows: parts[1], - attached: parts[2] === "1" - }); - } + function _runInTerminal(name, argv) { + if (SettingsData.muxUseCustomCommand && SettingsData.muxCustomCommand) { + Quickshell.execDetached([Paths.expandTilde(SettingsData.muxCustomCommand), name]); + return; } - - root.sessions = sessionList; - } - - function _parseZellijSessions(output) { - var sessionList = []; - var lines = output.trim().split('\n'); - - for (var i = 0; i < lines.length; i++) { - var line = lines[i].trim(); - if (line.length === 0) - continue; - var exited = line.includes("(EXITED"); - var bracketIdx = line.indexOf(" ["); - var name = (bracketIdx > 0 ? line.substring(0, bracketIdx) : line).trim(); - - if (!_isSessionExcluded(name)) { - sessionList.push({ - name: name, - windows: "N/A", - attached: !exited - }); - } - } - - root.sessions = sessionList; + Quickshell.execDetached(_terminalPrefix().concat(argv)); } function attachToSession(name) { - if (SettingsData.muxUseCustomCommand && SettingsData.muxCustomCommand) { - Quickshell.execDetached([Paths.expandTilde(SettingsData.muxCustomCommand), name]); - } else if (root.muxType === "zellij") { - Quickshell.execDetached(_terminalPrefix().concat(["zellij", "attach", name])); - } else { - Quickshell.execDetached(_terminalPrefix().concat(["tmux", "attach", "-t", name])); - } + _runInTerminal(name, root.backend.attach(name)); } function createSession(name) { - if (SettingsData.muxUseCustomCommand && SettingsData.muxCustomCommand) { - Quickshell.execDetached([Paths.expandTilde(SettingsData.muxCustomCommand), name]); - } else if (root.muxType === "zellij") { - Quickshell.execDetached(_terminalPrefix().concat(["zellij", "-s", name])); - } else { - Quickshell.execDetached(_terminalPrefix().concat(["tmux", "new-session", "-s", name])); - } + _runInTerminal(name, root.backend.create(name)); } - readonly property bool supportsRename: muxType !== "zellij" - function renameSession(oldName, newName) { - if (root.muxType === "zellij") + if (!root.supportsRename) return; - Quickshell.execDetached(["tmux", "rename-session", "-t", oldName, newName]); + Quickshell.execDetached(root.backend.rename(oldName, newName)); Qt.callLater(refreshSessions); } function killSession(name) { - if (root.muxType === "zellij") { - Quickshell.execDetached(["zellij", "kill-session", name]); - } else { - Quickshell.execDetached(["tmux", "kill-session", "-t", name]); - } + Quickshell.execDetached(root.backend.kill(name)); Qt.callLater(refreshSessions); } } diff --git a/quickshell/tests/mux-backends.test.mjs b/quickshell/tests/mux-backends.test.mjs new file mode 100644 index 000000000..f12a13a98 --- /dev/null +++ b/quickshell/tests/mux-backends.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { loadScript } from "./qml-script.mjs"; + +const mux = loadScript(new URL("../Common/MuxBackends.js", import.meta.url)); +const plain = value => JSON.parse(JSON.stringify(value)); + +test("tmux parser reads the pipe format and drops short lines", () => { + const sessions = mux.BACKENDS.tmux.parse("main|3|1\nwork|1|0\n\nbroken\n"); + assert.deepEqual(plain(sessions), [ + { name: "main", windows: "3", attached: true }, + { name: "work", windows: "1", attached: false } + ]); +}); + +test("zellij parser strips the bracket suffix and marks exited sessions", () => { + const sessions = mux.BACKENDS.zellij.parse("main [Created 2h ago]\nold [Created 1d ago] (EXITED - attach to resurrect)\n"); + assert.deepEqual(plain(sessions), [ + { name: "main", windows: "N/A", attached: true }, + { name: "old", windows: "N/A", attached: false } + ]); +}); + +test("herdr parser reads the sessions envelope and treats missing running as stopped", () => { + const output = JSON.stringify({ sessions: [{ name: "default", running: true, default: true }, { name: "stopped", running: false }, { name: "unknown" }] }); + assert.deepEqual(plain(mux.BACKENDS.herdr.parse(output)), [ + { name: "default", windows: "N/A", attached: true }, + { name: "stopped", windows: "N/A", attached: false }, + { name: "unknown", windows: "N/A", attached: false } + ]); + assert.deepEqual(plain(mux.BACKENDS.herdr.parse("{}")), []); + assert.throws(() => mux.BACKENDS.herdr.parse("not json")); +}); + +test("only tmux can rename and each backend builds argv from the session name unchanged", () => { + const name = "my session; echo nope"; + assert.deepEqual(Object.keys(mux.BACKENDS).filter(key => mux.BACKENDS[key].rename), ["tmux"]); + assert.deepEqual(plain(mux.BACKENDS.herdr.attach(name)), ["herdr", "session", "attach", name]); + assert.deepEqual(plain(mux.BACKENDS.herdr.create(name)), ["herdr", "--session", name]); + assert.deepEqual(plain(mux.BACKENDS.herdr.kill(name)), ["herdr", "session", "stop", name]); + assert.deepEqual(plain(mux.BACKENDS.tmux.rename(name, "renamed")), ["tmux", "rename-session", "-t", name, "renamed"]); +}); + +test("session filter matches names case-insensitively and by /regex/, ignoring bad patterns", () => { + const filter = " Scratch, /^tmp_/, /[bad/ "; + assert.equal(mux.isSessionExcluded("scratch", filter), true); + assert.equal(mux.isSessionExcluded("tmp_build", filter), true); + assert.equal(mux.isSessionExcluded("main", filter), false); + assert.equal(mux.isSessionExcluded("main", ""), false); +}); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 4ff5b0d4d..1aa32579e 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -9937,6 +9937,7 @@ "category": "Multiplexers", "keywords": [ "general", + "herdr", "integrations", "multiplexer", "multiplexers", @@ -9956,6 +9957,7 @@ "tabIndex": 32, "category": "Multiplexers", "keywords": [ + "herdr", "integrations", "multiplexers", "sessions", @@ -9966,7 +9968,7 @@ ], "icon": "terminal", "parentLabel": "System & integrations", - "description": "tmux and zellij sessions" + "description": "tmux, zellij, and herdr sessions" }, { "section": "muxSessionFilter",