From 7c31598594b72bf3a58acacd5ba3b7b27707eac9 Mon Sep 17 00:00:00 2001 From: "Ebeneezer (Hermes Agent)" Date: Wed, 15 Jul 2026 01:11:59 -0700 Subject: [PATCH] fix(ui): layout spacer, peer empty state, busy mutex, style - RowLayout + real Layout.fillWidth spacer (#18) - ListView boundsBehavior StopAtBounds (#27) - Show Not connected empty state when disconnected (#55) - Single-flight _busy guard across status/toggle/exit/copy - Inline exit-node active/clear checks (no over-abstract predicates) - Terminate JS statements in QML handlers with semicolons Written by AI agent working for @jtmorris. Model: Grok 4.5. --- tailscalectl/TailscaleWidget.qml | 259 ++++++++++++++++++++----------- 1 file changed, 165 insertions(+), 94 deletions(-) diff --git a/tailscalectl/TailscaleWidget.qml b/tailscalectl/TailscaleWidget.qml index 292cc41..5df7d3a 100644 --- a/tailscalectl/TailscaleWidget.qml +++ b/tailscalectl/TailscaleWidget.qml @@ -16,22 +16,66 @@ PluginComponent { property string _copyText: "" property int _copyIndex: 0 + // Transient coordination for defensive poll-act-poll toggle (not long-term cache). + // Poll for ground truth → act → poll again for verification. + property string _pendingAction: "" + + // Single-flight guard: prevent interleaved status/toggle/exit/copy chains (#15/#30 class). + property bool _busy: false + layerNamespacePlugin: "tailscalectl" popoutWidth: 360 popoutHeight: 400 - // Transient coordination for the defensive poll-act-poll toggle (exact behavior preserved). - // We poll for on-the-ground truth (so we choose the correct "up"/"down" and don't lie to the user), - // act, then poll again for verification. This is *not* long-term cached state. - // The _pendingAction is short-lived per user action only. - property string _pendingAction: "" - Component.onCompleted: { - // Initial status fetch on load. Subsequent fetches are on-demand (popout open, explicit refresh, or post-action verification). root._runStatusCheck(); } function _runStatusCheck() { + if (root._busy && root._pendingAction === "") { + // A non-toggle status refresh while something is already in flight: skip. + // Toggle path sets _pendingAction first and is allowed to chain after actions clear busy carefully. + return; + } + root._busy = true; + Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => { + var statusOk = (code === 0); + if (statusOk) { + const state = TailscaleLib.parseStatusResult(stdout); + root.isConnected = state.isConnected; + root.tailscaleIP = state.tailscaleIP; + root.currentExitNode = state.currentExitNode; + root.peers = state.peers; + } else { + root.isConnected = false; + root.tailscaleIP = ""; + root.currentExitNode = ""; + root.peers = []; + ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status"))); + } + + const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected, statusOk); + if (cmd) { + // Fresh poll succeeded; act, then verify with another status poll. + Proc.runCommand("tailscale-toggle", cmd, (out, c) => { + if (c !== 0) { + const action = root.isConnected ? "disconnect" : "connect"; + ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError(action))); + } + root._pendingAction = ""; + // Keep busy through verification poll: call internal runner that assumes we own the lock. + root._runStatusCheckUnlocked(); + }); + } else { + // Includes failed status while a toggle was pending: abort rather than invent up/down. + root._pendingAction = ""; + root._busy = false; + } + }); + } + + // Used only as the continuation after toggle action; assumes _busy is already true. + function _runStatusCheckUnlocked() { Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => { if (code === 0) { const state = TailscaleLib.parseStatusResult(stdout); @@ -46,51 +90,51 @@ PluginComponent { root.peers = []; ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status"))); } - - const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected); - if (cmd) { - // If toggle action on deck, then we just retrieved on-the-ground truth and can now act to toggle. - Proc.runCommand("tailscale-toggle", cmd, (out, c) => { - if (c !== 0) { - const action = root.isConnected ? "disconnect" : "connect"; - ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError(action))); - } - root._pendingAction = ""; - root._runStatusCheck(); // post-action verification poll (exact behavior) - }); - } else { - root._pendingAction = ""; - } + root._busy = false; }); } function toggleTailscale() { - root._pendingAction = "toggle"; + if (root._busy) { + return; + } + root._pendingAction = TailscaleLib.PendingAction.TOGGLE; root._runStatusCheck(); } function refreshStatus() { + if (root._busy) { + return; + } root._runStatusCheck(); } function setExitNode(hostname) { + if (root._busy) { + return; + } const cmd = TailscaleLib.makeExitNodeCommand(hostname); if (!cmd) { ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.getStrings().invalidExitNodeHostname)); return; } + root._busy = true; Proc.runCommand("tailscale-exit", cmd, (stdout, code) => { if (code !== 0) { - // Note: no stderr detail available from Proc (accepted per plan). ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set"))); } - root._runStatusCheck(); + // Verify via unlocked status continuation (busy already held). + root._runStatusCheckUnlocked(); }); } function copyToClipboard(text) { + if (root._busy) { + return; + } root._copyText = text; root._copyIndex = 0; + root._busy = true; root._runNextCopy(); } @@ -98,11 +142,13 @@ PluginComponent { const cmds = TailscaleLib.getClipboardCommands(root._copyText); if (root._copyIndex >= cmds.length) { ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("clipboard"))); + root._busy = false; return; } Proc.runCommand("tailscale-copy-" + root._copyIndex, cmds[root._copyIndex], (stdout, code) => { if (code === 0) { ToastService.showInfo(I18n.tr(TailscaleLib.getStrings().copied).arg(root._copyText)); + root._busy = false; } else { root._copyIndex += 1; root._runNextCopy(); @@ -119,9 +165,9 @@ PluginComponent { Item { id: contentItem width: parent.width - height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerList.height + Theme.spacingM + height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerArea.height + Theme.spacingM - Row { + RowLayout { id: statusRow y: Theme.spacingM width: parent.width @@ -134,11 +180,11 @@ PluginComponent { MouseArea { cursorShape: Qt.PointingHandCursor hoverEnabled: true - anchors.verticalCenter: parent.verticalCenter + Layout.alignment: Qt.AlignVCenter width: toggleIcon.implicitWidth height: toggleIcon.implicitHeight onClicked: { - root.toggleTailscale() + root.toggleTailscale(); } DankIcon { @@ -154,27 +200,30 @@ PluginComponent { text: root.tailscaleIP || "—" font.pixelSize: Theme.fontSizeSmall color: Theme.primary - anchors.verticalCenter: parent.verticalCenter + Layout.alignment: Qt.AlignVCenter } - Item { width: 1; height: 1; Layout.fillWidth: true } + Item { + Layout.fillWidth: true + height: 1 + } StyledText { text: I18n.tr(TailscaleLib.getStrings().exitNodePrefix) + (root.currentExitNode || I18n.tr(TailscaleLib.getStrings().none)) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText - anchors.verticalCenter: parent.verticalCenter + Layout.alignment: Qt.AlignVCenter } MouseArea { - visible: TailscaleLib.shouldShowClearExitNode(root.currentExitNode) + visible: root.currentExitNode !== "" cursorShape: Qt.PointingHandCursor hoverEnabled: true - anchors.verticalCenter: parent.verticalCenter + Layout.alignment: Qt.AlignVCenter width: clearExitNodeText.implicitWidth height: clearExitNodeText.implicitHeight onClicked: { - root.setExitNode("") + root.setExitNode(""); } StyledText { @@ -186,78 +235,100 @@ PluginComponent { } } - ListView { - id: peerList + // Peer list when connected; empty-state hint when not (#55). + Item { + id: peerArea y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM width: parent.width - Theme.spacingM * 2 - height: Math.min(root.peers.length * (Theme.fontSizeSmall + Theme.spacingXS), 200) + height: root.isConnected + ? Math.min(Math.max(root.peers.length, 1) * (Theme.fontSizeSmall + Theme.spacingXS), 200) + : (Theme.fontSizeSmall + Theme.spacingXS) anchors.left: parent.left anchors.leftMargin: Theme.spacingM - model: root.peers - interactive: true - boundsBehavior: Flickable.DragAndOvershootBounds - delegate: Item { - width: peerList.width - height: Theme.fontSizeSmall + Theme.spacingXS + StyledText { + visible: !root.isConnected + anchors.fill: parent + text: I18n.tr(TailscaleLib.getStrings().notConnectedHint) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } - Row { - anchors.fill: parent - spacing: Theme.spacingS - anchors.verticalCenter: parent.verticalCenter + ListView { + id: peerList + visible: root.isConnected + anchors.fill: parent + model: root.peers + interactive: true + // Desktop popout: no rubber-band overshoot (#27). + boundsBehavior: Flickable.StopAtBounds + clip: true - MouseArea { - cursorShape: Qt.PointingHandCursor - hoverEnabled: true + delegate: Item { + width: peerList.width + height: Theme.fontSizeSmall + Theme.spacingXS + + Row { + anchors.left: parent.left + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - width: peerHostnameText.implicitWidth - height: peerHostnameText.implicitHeight - onClicked: { - root.copyToClipboard(modelData.hostname) + spacing: Theme.spacingS + + MouseArea { + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + anchors.verticalCenter: parent.verticalCenter + width: peerHostnameText.implicitWidth + height: peerHostnameText.implicitHeight + onClicked: { + root.copyToClipboard(modelData.hostname); + } + + StyledText { + id: peerHostnameText + text: modelData.hostname + font.pixelSize: Theme.fontSizeSmall + color: modelData.online ? Theme.primary : Theme.surfaceVariantText + } } - StyledText { - id: peerHostnameText - text: modelData.hostname - font.pixelSize: Theme.fontSizeSmall - color: modelData.online ? Theme.primary : Theme.surfaceVariantText - } - } + MouseArea { + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + anchors.verticalCenter: parent.verticalCenter + width: peerIpText.implicitWidth + height: peerIpText.implicitHeight + onClicked: { + root.copyToClipboard(modelData.ip); + } - MouseArea { - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - anchors.verticalCenter: parent.verticalCenter - width: peerIpText.implicitWidth - height: peerIpText.implicitHeight - onClicked: { - root.copyToClipboard(modelData.ip) + StyledText { + id: peerIpText + text: modelData.ip + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } } - StyledText { - id: peerIpText - text: modelData.ip - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - } - } + MouseArea { + visible: modelData.exitNode + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + anchors.verticalCenter: parent.verticalCenter + width: exitNodeButton.implicitWidth + height: exitNodeButton.implicitHeight + onClicked: { + root.setExitNode(modelData.hostname); + } - MouseArea { - visible: modelData.exitNode - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - anchors.verticalCenter: parent.verticalCenter - width: exitNodeButton.implicitWidth - height: exitNodeButton.implicitHeight - onClicked: { - root.setExitNode(modelData.hostname) - } - - StyledText { - id: exitNodeButton - text: "↗" - font.pixelSize: Theme.fontSizeSmall - color: TailscaleLib.isActiveExitNode(root.currentExitNode, modelData.hostname) ? Theme.primary : Theme.surfaceVariantText + StyledText { + id: exitNodeButton + text: "↗" + font.pixelSize: Theme.fontSizeSmall + color: (root.currentExitNode === modelData.hostname) ? Theme.primary : Theme.surfaceVariantText + } } } } @@ -271,7 +342,7 @@ PluginComponent { anchors.fill: parent acceptedButtons: Qt.RightButton onClicked: { - root.toggleTailscale() + root.toggleTailscale(); } }