mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-21 18:54:01 -04:00
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.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user