diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-26 11:25:26 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-26 11:25:26 +0100 |
| commit | f161ba41344c6834f10eaa5064dc02bc9c1cfdf6 (patch) | |
| tree | 941d27d5f25c961b4b6fe3f1f68175814255a72a | |
| parent | cf4f2665ee02058ed65cf08fc2b5f313523f6191 (diff) | |
| download | quickshell-f161ba41344c6834f10eaa5064dc02bc9c1cfdf6.tar.gz quickshell-f161ba41344c6834f10eaa5064dc02bc9c1cfdf6.zip | |
hosts: the vault's LAN map as a searchable overlay
A new standalone shell, shaped like keybinds, that replaces the rofi
SSH menu (blackpearl-sshmenu.sh) on ALT+SHIFT+Return. hosts.py reads
the frontmatter of every host note in the vault's Local Network/services
folder and the plain lines of ~/.ssh/config, and joins each alias to the
note whose ip or hostname is its HostName (or whose short hostname is the
alias). Aliases that match no note get cards of their own, so everything
the rofi menu listed is still reachable. It runs on every opening, so a
vault edit shows next time with no cache to go stale.
Each card shows ip:port, hostname, the Proxmox node and CT id, and only
the actions its data allows: ssh per alias (a kitty tab, via ssh-tab,
lifted from the rofi script), open for a url, copy for the address.
Enter runs the first.
The cards are laid out in rows of five inside a Flickable rather than a
GridView, because a GridView gives every cell one height: a fixed height
tall enough for the most aliased host left every other card half empty,
and a short one clipped its chips. Each row now takes its busiest card's
height. The grid keeps a 24px margin inside its clip, without which the
first row's glow was cut off at the top.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | AGENTS.md | 2 | ||||
| l--------- | hosts/Glow.qml | 1 | ||||
| -rw-r--r-- | hosts/Hosts.qml | 361 | ||||
| -rw-r--r-- | hosts/README.md | 46 | ||||
| l--------- | hosts/Theme.qml | 1 | ||||
| -rwxr-xr-x | hosts/hosts.py | 135 | ||||
| -rw-r--r-- | hosts/shell.qml | 26 | ||||
| -rwxr-xr-x | hosts/ssh-tab | 24 |
8 files changed, 596 insertions, 0 deletions
@@ -23,6 +23,8 @@ modules. window-switcher/ expose: open windows scattered as live previews, on ALT+TAB or the top-right hot corner of DP-1 keybinds/ live keybind reminder, on SUPER+k + hosts/ the vault's LAN map with ssh aliases, on ALT+SHIFT+Return; + replaces the rofi SSH menu notifications/ balloons for the notification daemon's published files They are started from `~/.config/hypr/sections/autostart.lua` and keep running diff --git a/hosts/Glow.qml b/hosts/Glow.qml new file mode 120000 index 0000000..779c66a --- /dev/null +++ b/hosts/Glow.qml @@ -0,0 +1 @@ +../shared/Glow.qml
\ No newline at end of file diff --git a/hosts/Hosts.qml b/hosts/Hosts.qml new file mode 100644 index 0000000..fb2c0e1 --- /dev/null +++ b/hosts/Hosts.qml @@ -0,0 +1,361 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick + +Scope { + id: root + + property bool open: false + property string monitor: "DP-1" + property var hosts: [] + property string query: "" + property int current: 0 + // Non-empty while loading or after a failure; the list shows only when + // this is empty, so a stale list from the last opening never shows. + property string status: "Loading..." + + // Every word must appear somewhere in the host's fields. + readonly property var shown: { + const words = root.query.toLowerCase().split(/\s+/).filter(w => w); + return root.hosts.filter(h => { + const hay = [h.name, h.ip, h.port, h.hostname, h.node, h.ct_id] + .concat(h.aliases).join(" ").toLowerCase(); + return words.every(w => hay.includes(w)); + }); + } + onShownChanged: root.current = 0 + + readonly property var screenObj: + Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] + + function toggle() { root.open = !root.open; } + function close() { root.open = false; } + + function address(h) { return h.port ? `${h.ip}:${h.port}` : h.ip; } + + // What a host offers, from what its note and the ssh config carry. The + // first is what Enter does. + function actions(h) { + const out = h.aliases.map(a => ({ label: `ssh ${a}`, + cmd: [`${Quickshell.shellDir}/ssh-tab`, a] })); + if (h.url) + out.push({ label: "open", cmd: ["xdg-open", h.url] }); + if (h.ip) + out.push({ label: `copy ${root.address(h)}`, + cmd: ["wl-copy", "--", root.address(h)] }); + return out; + } + + function run(action) { + Quickshell.execDetached(action.cmd); + root.close(); + } + + function move(step) { + root.current = Math.max(0, Math.min(root.shown.length - 1, root.current + step)); + } + + function runFirst(h) { + const a = h ? root.actions(h) : []; + if (a.length) root.run(a[0]); + } + + // Read afresh on every opening, so a note edited in the vault or an alias + // added to the ssh config shows the next time, with no cache to go stale. + onOpenChanged: if (open) { + root.status = "Loading..."; + root.query = ""; + // A Process already running ignores running = true. + proc.running = false; + proc.running = true; + } + + Process { + id: proc + command: ["/usr/bin/python3", `${Quickshell.shellDir}/hosts.py`] + stdout: StdioCollector { + onStreamFinished: { + try { + root.hosts = JSON.parse(text); + root.status = ""; + } catch (e) { + root.status = "hosts.py gave nothing readable"; + } + } + } + } + + // A config with no visible window exits, reporting nothing. This 1x1 + // click-through window holds the shell open while the overlay is hidden. + PanelWindow { + anchors { top: true; left: true } + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } + + LazyLoader { + active: root.open + + PanelWindow { + screen: root.screenObj + anchors { top: true; left: true; right: true; bottom: true } + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "keystone-hosts" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + Rectangle { + anchors.fill: parent + color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.82) + + MouseArea { + anchors.fill: parent + onClicked: root.close() + } + + Column { + id: panel + anchors.fill: parent + anchors.margins: 40 + spacing: 20 + + Rectangle { + width: 720 + anchors.horizontalCenter: parent.horizontalCenter + height: 52 + radius: 14 + color: Theme.surface + + // Keys reach a focused item, not a window, so the + // field takes all of them. + TextInput { + id: field + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + verticalAlignment: TextInput.AlignVCenter + focus: true + activeFocusOnTab: true + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 20 + text: root.query + onTextEdited: root.query = text + + Keys.onEscapePressed: root.close() + // The arrows move through the grid, not the + // text: a query is short enough to retype. + Keys.onLeftPressed: root.move(-1) + Keys.onRightPressed: root.move(1) + Keys.onUpPressed: root.move(-grid.columns) + Keys.onDownPressed: root.move(grid.columns) + Keys.onReturnPressed: root.runFirst(root.shown[root.current]) + Keys.onEnterPressed: root.runFirst(root.shown[root.current]) + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: field.text === "" + text: "host, ip, alias, node..." + color: Theme.overlay + font: field.font + } + } + } + + Text { + visible: root.status !== "" + text: root.status + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 18 + } + + // Five columns in rows, each row as tall as its + // busiest card, so one with many chips grows its own row + // and no other. The margins keep the selected card's + // glow inside the clip. Rows rather than a GridView, + // whose cells all share one height. + Flickable { + id: grid + readonly property int columns: 5 + visible: root.status === "" + width: parent.width + height: parent.height - y + clip: true + contentHeight: rows.height + 48 + boundsBehavior: Flickable.StopAtBounds + + // Keep the selected card in view as the arrows move. + function reveal(i) { + const r = rowRepeater.itemAt(Math.floor(i / columns)); + if (!r) return; + const top = rows.y + r.y - 24; + const bottom = rows.y + r.y + r.height + 24; + if (top < contentY) + contentY = top; + else if (bottom > contentY + height) + contentY = bottom - height; + } + + Connections { + target: root + function onCurrentChanged() { grid.reveal(root.current); } + function onShownChanged() { grid.contentY = 0; } + } + + Column { + id: rows + x: 24 + y: 24 + width: grid.width - 48 + spacing: 28 + + Repeater { + id: rowRepeater + model: { + const out = []; + for (let i = 0; i < root.shown.length; i += grid.columns) + out.push(root.shown.slice(i, i + grid.columns)); + return out; + } + + Row { + id: row + required property var modelData + required property int index + readonly property real tallest: { + let m = 0; + for (const c of children) + if (c.need) m = Math.max(m, c.need); + return m; + } + spacing: rows.spacing + + Repeater { + model: row.modelData + + Rectangle { + id: card + required property var modelData + required property int index + readonly property int at: row.index * grid.columns + index + readonly property bool selected: at === root.current + // What this card needs; the row takes the most. + readonly property real need: info.implicitHeight + 24 + + width: (rows.width - rows.spacing * (grid.columns - 1)) / grid.columns + height: row.tallest + radius: 14 + color: selected ? Theme.surfaceAlt : Theme.surface + + Glow { + anchors.fill: parent + radius: parent.radius + z: -1 + on: card.selected + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: root.current = card.at + onClicked: root.runFirst(card.modelData) + } + + Column { + id: info + x: 16 + y: 12 + width: parent.width - 32 + spacing: 4 + + Text { + text: card.modelData.name + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 16 + font.bold: true + } + Text { + text: [root.address(card.modelData), + card.modelData.hostname !== card.modelData.ip + ? card.modelData.hostname : ""] + .filter(s => s).join(" · ") + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: 13 + } + Text { + visible: card.modelData.node !== "" + text: card.modelData.node + + (card.modelData.ct_id ? ` · CT ${card.modelData.ct_id}` : "") + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: 13 + } + + Flow { + width: parent.width + spacing: 8 + topPadding: 4 + + Repeater { + model: root.actions(card.modelData) + + Rectangle { + id: chip + required property var modelData + required property int index + width: label.width + 20 + height: 24 + radius: 12 + color: chipMouse.containsMouse ? Theme.accent : Theme.base + border.width: card.selected && index === 0 ? 1 : 0 + border.color: Theme.accent + + Text { + id: label + anchors.centerIn: parent + text: chip.modelData.label + color: chipMouse.containsMouse ? Theme.base : Theme.text + font.family: Theme.fontFamily + font.pixelSize: 13 + } + MouseArea { + id: chipMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.run(chip.modelData) + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/hosts/README.md b/hosts/README.md new file mode 100644 index 0000000..1be1dd7 --- /dev/null +++ b/hosts/README.md @@ -0,0 +1,46 @@ +# hosts + +The LAN map from the Obsidian vault as a searchable list, over a dimmed +screen. ALT+SHIFT+Return opens it, Escape or a click outside closes it. It +replaces the rofi SSH menu, `blackpearl-sshmenu.sh`. + +Each card shows a host's `ip:port`, its hostname, the Proxmox node and CT id +it runs on, and the actions its data allows: + +- `ssh <alias>`, one per `~/.ssh/config` alias pointing at it, in a kitty tab + (`ssh-tab`, lifted from the rofi script); +- `open`, when the note has a `url`; +- `copy <ip:port>`, through `wl-copy`. + +Type to filter (every word must match name, ip, port, hostname, node, CT id +or alias), Up/Down to move, Enter runs the first action, chips run their own. + +`hosts.py` builds the list on every opening, so there is no cache. It reads +the frontmatter of each note in the vault's `Local Network/services/` +(`HOSTS_DIR` overrides the path) and the ssh config. An alias joins the note +whose `ip` or `hostname` is its `HostName`, or whose short hostname is the +alias; an alias that matches no note gets a card of its own, so everything +the rofi menu listed is still here. Aliases with the same `HostName` share a +card. Like the rofi script, it reads plain `Keyword value` lines only: +`Include` and `Match` are not followed, wildcard `Host` patterns are skipped. + +## Running + + qs -p ./hosts + qs -p ./hosts ipc call hosts toggle + +## Blur + +Optional, in the Hyprland config: + + hl.layer_rule({ + name = "blur-hosts", + match = { namespace = "^(keystone-hosts)$" }, + blur = true, + xray = false, + ignore_alpha = 0.1, + }) + +## Check + + /usr/bin/python3 hosts/hosts.py --check diff --git a/hosts/Theme.qml b/hosts/Theme.qml new file mode 120000 index 0000000..3d2e40f --- /dev/null +++ b/hosts/Theme.qml @@ -0,0 +1 @@ +../shared/Theme.qml
\ No newline at end of file diff --git a/hosts/hosts.py b/hosts/hosts.py new file mode 100755 index 0000000..424a674 --- /dev/null +++ b/hosts/hosts.py @@ -0,0 +1,135 @@ +#!/usr/bin/python3 +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +"""The LAN map as JSON: one entry per host note in the vault, each carrying +the ~/.ssh/config aliases that point at it, plus one entry per alias that +points at no note, so every alias the rofi menu listed is still here. + + hosts.py print the list + hosts.py --check run the self-check +""" + +import json +import os +import sys +from pathlib import Path + +VAULT = os.environ.get( + "HOSTS_DIR", "~/Documents/Obsidian/my KB/Knowledge base/Local Network/services") +SSH_CONFIG = "~/.ssh/config" + + +def frontmatter(text): + """The `key: value` lines between the first two `---` lines.""" + # ponytail: flat scalars only, no yaml lib; a list value stays its raw + # string, which is fine for the fields read here. + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + out = {} + for line in lines[1:]: + if line.strip() == "---": + break + key, sep, value = line.partition(":") + if sep and key.strip() and not key.startswith(" "): + out[key.strip()] = value.strip().strip("\"'") + return out + + +def ssh_hosts(text): + """Aliases in an ssh config: {alias, hostname, user, port}.""" + # ponytail: plain "Keyword value" lines only, like blackpearl-sshmenu.sh; + # Include and Match are not followed, wildcard Host patterns are skipped. + hosts, cur = [], None + for line in text.splitlines(): + parts = line.split(None, 1) + if len(parts) < 2 or parts[0].startswith("#"): + continue + key, value = parts[0].lower(), parts[1].strip() + if key == "host": + name = value.split()[0] + cur = None if any(c in name for c in "*?") else { + "alias": name, "hostname": name, "user": "", "port": ""} + if cur: + hosts.append(cur) + elif cur and key in ("hostname", "user", "port"): + cur[key] = value + return hosts + + +def merge(notes, aliases): + """notes: [(title, frontmatter)]. Each alias joins the first note whose ip + or hostname is its HostName, or whose short hostname is the alias.""" + out = [] + for title, fm in notes: + out.append({ + "name": title, + "ip": fm.get("ip", ""), + "port": fm.get("port", ""), + "url": fm.get("url", ""), + "hostname": fm.get("hostname", ""), + "node": fm.get("node", ""), + "ct_id": fm.get("ct_id", ""), + "aliases": [], + "user": "", + }) + for a in aliases: + target = a["hostname"] + host = next((h for h in out if target and target in (h["ip"], h["hostname"]) + or a["alias"] == h["hostname"].split(".")[0]), None) + if host: + host["aliases"].append(a["alias"]) + host["user"] = host["user"] or a["user"] + else: + out.append({ + "name": a["alias"], "ip": target, "port": a["port"], "url": "", + "hostname": target, "node": "", "ct_id": "", + "aliases": [a["alias"]], "user": a["user"], + }) + return out + + +def main(): + notes = [(p.stem, frontmatter(p.read_text(errors="replace"))) + for p in sorted(Path(VAULT).expanduser().glob("*.md"))] + cfg = Path(SSH_CONFIG).expanduser() + aliases = ssh_hosts(cfg.read_text(errors="replace")) if cfg.exists() else [] + json.dump(merge(notes, aliases), sys.stdout) + + +def check(): + fm = frontmatter("---\ntags: [a, b]\nip: 10.0.0.7\nport: 5000\n" + "node: HP server\n---\nbody: not this\n") + assert fm == {"tags": "[a, b]", "ip": "10.0.0.7", "port": "5000", + "node": "HP server"}, fm + assert frontmatter("no frontmatter\n") == {} + + cfg = ssh_hosts("Host *\n User x\nHost box\n HostName 10.0.0.7\n" + " User root\nHost web\n HostName web.example.org\n" + "Host lone\n Port 2222\n") + assert [h["alias"] for h in cfg] == ["box", "web", "lone"], cfg + assert cfg[0]["user"] == "root" and cfg[2]["port"] == "2222" + + hosts = merge([("Box", fm), ("Web", {"hostname": "web.example.org"}), + ("Media", {"ip": "10.0.0.9", "hostname": "media.example.org"})], + cfg + ssh_hosts("Host media\n HostName 10.0.0.99\n")) + by = {h["name"]: h for h in hosts} + assert by["Box"]["aliases"] == ["box"] and by["Box"]["user"] == "root" + assert by["Web"]["aliases"] == ["web"] + assert by["Media"]["aliases"] == ["media"], "short hostname match" + assert by["lone"]["port"] == "2222" and by["lone"]["node"] == "" + assert len(hosts) == 4 + print("ok") + + +if __name__ == "__main__": + check() if "--check" in sys.argv else main() diff --git a/hosts/shell.qml b/hosts/shell.qml new file mode 100644 index 0000000..be1eb9c --- /dev/null +++ b/hosts/shell.qml @@ -0,0 +1,26 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Io + +ShellRoot { + Hosts { id: hosts } + + // Bound to ALT + SHIFT + Return in Hyprland: + // `qs -p <this dir> ipc call hosts toggle` + IpcHandler { + target: "hosts" + function toggle() { hosts.toggle(); } + function open() { hosts.open = true; } + function close() { hosts.close(); } + } +} diff --git a/hosts/ssh-tab b/hosts/ssh-tab new file mode 100755 index 0000000..8e8a310 --- /dev/null +++ b/hosts/ssh-tab @@ -0,0 +1,24 @@ +#!/bin/bash +# ssh-tab - ssh to an alias in a tab of the running kitty +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# Lifted from blackpearl-sshmenu.sh, the rofi menu this shell replaces. +# A tab in the running kitty, through the socket kitty.conf's listen_on opens; +# a new terminal when no kitty is up. +# ponytail: first socket wins when several kitty instances run. +name=${1:?usage: ssh-tab <alias>} +sockets=("$XDG_RUNTIME_DIR"/kitty-*) +if [[ -S ${sockets[0]} ]] && kitten @ --to "unix:${sockets[0]}" launch \ + --type=tab --tab-title "$name" ssh "$name" >/dev/null; then + exec hyprctl dispatch 'hl.dsp.focus({ window = "class:kitty" })' >/dev/null +fi +exec kitty ssh "$name" |
