// Copyright (C) 2026 Danilo M. // // 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.Wayland import QtQuick Scope { id: root // The screen the drawer opens on. Falls back to the focused one when // this monitor is not connected, so the panel is never invisible. property string monitor: "DP-3" property bool open: false property string selected: "" // A pending destructive action, shown as a confirm step instead of the // action list: { kind, vm, snap }. Null when nothing is being confirmed. property var confirming: null property string typed: "" 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; root.confirming = null; root.typed = ""; } onOpenChanged: { Virsh.sampling = open; confirming = null; typed = ""; if (open) { Virsh.refreshList(); if (!selected && Virsh.names.length) selected = Virsh.names[0]; if (selected) Virsh.loadSnapshots(selected); } } onSelectedChanged: if (selected) Virsh.loadSnapshots(selected) function fmtBytes(b) { if (b < 0) return "—"; const g = b / (1024 * 1024 * 1024); return g >= 10 ? g.toFixed(0) + " GB" : g.toFixed(1) + " GB"; } function stateColor(s) { if (s === "running") return Theme.green; if (s === "paused" || s === "suspended" || s === "shutting down") return Theme.yellow; if (s === "crashed") return Theme.red; return Theme.overlay; } // Which verbs make sense in the current state, mirroring the states the // old rofi script switched on. function actionsFor(s) { if (s === "running") return [["shutdown", "Shutdown"], ["reboot", "Reboot"], ["suspend", "Suspend"], ["reset", "Reset"], ["destroy", "Force stop"]]; if (s === "paused" || s === "suspended") return [["resume", "Resume"], ["shutdown", "Shutdown"], ["destroy", "Force stop"]]; return [["start", "Start"]]; } function isDestructive(a) { return a === "reset" || a === "destroy"; } function run(vm, action) { if (isDestructive(action)) root.confirming = { kind: action, vm: vm, snap: "" }; else Virsh.act(vm, action); } // Quickshell exits once no window is visible, and the drawer is closed // most of the time, so a 1x1 transparent window holds the process open. // Without it the shell quits and the keybind, which reaches it over IPC, // has nothing left to talk to. PanelWindow { visible: true implicitWidth: 1 implicitHeight: 1 color: "transparent" exclusionMode: ExclusionMode.Ignore mask: Region {} WlrLayershell.keyboardFocus: WlrKeyboardFocus.None } LazyLoader { active: root.open PanelWindow { id: win screen: root.screenObj anchors { top: true; left: true; right: true; bottom: true } color: "transparent" exclusionMode: ExclusionMode.Ignore WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "quickshell-vm-manager" // The panel needs keys: Escape to close, typing to confirm a delete. WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive // Dim the screen behind: the secondary monitor usually has an // editor or a chat window on it, and this says the drawer has focus. Rectangle { anchors.fill: parent color: "#000000" opacity: 0.45 MouseArea { anchors.fill: parent; onClicked: root.close() } } // Keys reach an item, not the window, so something inside has to // hold focus. The TextInput in a delete confirmation takes it when // it appears; this takes it back whenever that is gone. Item { id: keyCatcher anchors.fill: parent focus: true Keys.onEscapePressed: { if (root.confirming) root.confirming = null; else root.close(); } Connections { target: root function onConfirmingChanged() { if (!root.confirming) keyCatcher.forceActiveFocus(); } } } Rectangle { id: drawer anchors { top: parent.top; left: parent.left; right: parent.right } height: Math.min(content.implicitHeight + 40, win.height - 60) color: Qt.alpha(Theme.base, 0.72) bottomLeftRadius: 14 bottomRightRadius: 14 border.width: 1 border.color: Qt.alpha(Theme.text, 0.12) // Clicks on the drawer must not fall through to the dimmer. MouseArea { anchors.fill: parent } Column { id: content anchors { left: parent.left; right: parent.right; top: parent.top; margins: 20 } spacing: 16 Row { spacing: 10 Text { text: "Virtual machines" font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2; bold: true } color: Theme.text } Text { anchors.verticalCenter: parent.verticalCenter text: "Esc to close" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.overlay } } // One row per VM, so several VMs stay readable at a glance. Repeater { model: Virsh.names Rectangle { required property string modelData readonly property var vm: Virsh.vms[modelData] ?? ({}) readonly property bool isSel: root.selected === modelData width: content.width implicitHeight: vmCol.implicitHeight + 24 radius: 10 color: isSel ? Qt.alpha(Theme.surface, 0.7) : Qt.alpha(Theme.surface, 0.35) border.width: 1 border.color: isSel ? Qt.alpha(Theme.accent, 0.5) : "transparent" MouseArea { anchors.fill: parent onClicked: root.selected = modelData } Column { id: vmCol anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 } spacing: 10 Row { spacing: 10 Rectangle { anchors.verticalCenter: parent.verticalCenter width: 9; height: 9; radius: 5 color: root.stateColor(vm.state ?? "") } Text { text: modelData font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true } color: Theme.text } Text { anchors.verticalCenter: parent.verticalCenter text: vm.state ?? "" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.subtext } Text { anchors.verticalCenter: parent.verticalCenter text: (vm.vcpus ?? 0) + " vCPU" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.overlay } Text { anchors.verticalCenter: parent.verticalCenter visible: vm.state === "running" && !(vm.agent ?? false) text: "agent starting" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.overlay } } // Stats only mean anything while the VM runs. Row { visible: vm.state === "running" spacing: 24 Stat { label: "CPU" value: (vm.cpu ?? -1) < 0 ? "—" : (vm.cpu).toFixed(0) + "%" fraction: (vm.cpu ?? 0) / 100 } Stat { label: "RAM" value: (vm.memUsed ?? -1) < 0 ? "—" : root.fmtBytes(vm.memUsed) + " / " + root.fmtBytes(vm.memTotal) fraction: (vm.memUsed ?? -1) < 0 ? -1 : vm.memUsed / vm.memTotal } Stat { label: "Disk" value: (vm.fsUsed ?? -1) < 0 ? "—" : root.fmtBytes(vm.fsUsed) + " / " + root.fmtBytes(vm.fsTotal) fraction: (vm.fsUsed ?? -1) < 0 ? -1 : vm.fsUsed / vm.fsTotal } Stat { label: "Address" value: (vm.ip ?? "") === "" ? "—" : vm.ip fraction: -1 } } // Actions and snapshots, for the selected VM only. Loader { active: isSel width: parent.width sourceComponent: detail property string vmName: modelData property string vmState: vm.state ?? "" } } } } } } } } Component { id: detail Column { spacing: 12 readonly property string vmName: parent.vmName readonly property string vmState: parent.vmState Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.08) } // Confirm step replaces the buttons, so the action cannot be // clicked again while it is being confirmed. Loader { active: root.confirming !== null && root.confirming.vm === vmName width: parent.width sourceComponent: confirmUi } Flow { visible: !(root.confirming !== null && root.confirming.vm === vmName) width: parent.width spacing: 8 Repeater { model: root.actionsFor(vmState) Button { required property var modelData text: modelData[1] danger: root.isDestructive(modelData[0]) onClicked: root.run(vmName, modelData[0]) } } Button { text: "Snapshot" onClicked: Virsh.snapshotCreate(vmName) } Button { text: "Delete VM" danger: true onClicked: root.confirming = { kind: "delete", vm: vmName, snap: "" } } } Text { visible: (Virsh.snapshots[vmName] ?? []).length > 0 text: "Snapshots" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } color: Theme.subtext } Repeater { model: Virsh.snapshots[vmName] ?? [] Row { required property var modelData width: parent.width spacing: 10 Text { anchors.verticalCenter: parent.verticalCenter width: 260 elide: Text.ElideRight text: modelData.name font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.text } Text { anchors.verticalCenter: parent.verticalCenter width: 150 text: modelData.created font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.overlay } Text { anchors.verticalCenter: parent.verticalCenter width: 70 text: modelData.state font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.overlay } Button { text: "Revert" danger: true onClicked: root.confirming = { kind: "revert", vm: vmName, snap: modelData.name } } Button { text: "Delete" danger: true onClicked: root.confirming = { kind: "snapdelete", vm: vmName, snap: modelData.name } } } } } } Component { id: confirmUi Column { spacing: 10 readonly property var c: root.confirming // Deleting a VM erases its disk image, so that one asks for the // name to be typed. The rest are recoverable enough for a click. readonly property bool needsTyping: c && c.kind === "delete" Text { width: parent.width wrapMode: Text.Wrap font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } color: Theme.red text: { if (!c) return ""; if (c.kind === "delete") return `Delete ${c.vm}? This erases its disk image and cannot be undone.`; if (c.kind === "revert") return `Revert ${c.vm} to "${c.snap}"? Changes since that snapshot are lost.`; if (c.kind === "snapdelete") return `Delete snapshot "${c.snap}"?`; if (c.kind === "destroy") return `Force stop ${c.vm}? This is a power cut, not a shutdown.`; if (c.kind === "reset") return `Reset ${c.vm}? This is a hard reset, not a reboot.`; return ""; } } TextInput { id: nameField visible: needsTyping width: 260 text: root.typed onTextChanged: root.typed = text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } color: Theme.text focus: needsTyping Component.onCompleted: if (needsTyping) forceActiveFocus() Rectangle { anchors.fill: parent anchors.margins: -6 z: -1 radius: 6 color: Qt.alpha(Theme.surface, 0.8) border.width: 1 border.color: Qt.alpha(Theme.text, 0.15) } Text { visible: !nameField.text text: "type the VM name" font: nameField.font color: Theme.overlay } } Row { spacing: 8 Button { text: "Confirm" danger: true enabled: !needsTyping || root.typed === c.vm onClicked: { if (c.kind === "delete") Virsh.deleteVm(c.vm); else if (c.kind === "revert") Virsh.snapshotRevert(c.vm, c.snap); else if (c.kind === "snapdelete") Virsh.snapshotDelete(c.vm, c.snap); else Virsh.act(c.vm, c.kind); root.confirming = null; root.typed = ""; } } Button { text: "Cancel" onClicked: { root.confirming = null; root.typed = ""; } } } } } }