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",