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.
This commit is contained in:
Ebeneezer (Hermes Agent) 2026-07-15 01:11:59 -07:00
parent 61626dff51
commit 7c31598594

View file

@ -16,22 +16,66 @@ PluginComponent {
property string _copyText: "" property string _copyText: ""
property int _copyIndex: 0 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" layerNamespacePlugin: "tailscalectl"
popoutWidth: 360 popoutWidth: 360
popoutHeight: 400 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: { Component.onCompleted: {
// Initial status fetch on load. Subsequent fetches are on-demand (popout open, explicit refresh, or post-action verification).
root._runStatusCheck(); root._runStatusCheck();
} }
function _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) => { Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => {
if (code === 0) { if (code === 0) {
const state = TailscaleLib.parseStatusResult(stdout); const state = TailscaleLib.parseStatusResult(stdout);
@ -46,51 +90,51 @@ PluginComponent {
root.peers = []; root.peers = [];
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status"))); ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status")));
} }
root._busy = false;
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 = "";
}
}); });
} }
function toggleTailscale() { function toggleTailscale() {
root._pendingAction = "toggle"; if (root._busy) {
return;
}
root._pendingAction = TailscaleLib.PendingAction.TOGGLE;
root._runStatusCheck(); root._runStatusCheck();
} }
function refreshStatus() { function refreshStatus() {
if (root._busy) {
return;
}
root._runStatusCheck(); root._runStatusCheck();
} }
function setExitNode(hostname) { function setExitNode(hostname) {
if (root._busy) {
return;
}
const cmd = TailscaleLib.makeExitNodeCommand(hostname); const cmd = TailscaleLib.makeExitNodeCommand(hostname);
if (!cmd) { if (!cmd) {
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.getStrings().invalidExitNodeHostname)); ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.getStrings().invalidExitNodeHostname));
return; return;
} }
root._busy = true;
Proc.runCommand("tailscale-exit", cmd, (stdout, code) => { Proc.runCommand("tailscale-exit", cmd, (stdout, code) => {
if (code !== 0) { if (code !== 0) {
// Note: no stderr detail available from Proc (accepted per plan).
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set"))); ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set")));
} }
root._runStatusCheck(); // Verify via unlocked status continuation (busy already held).
root._runStatusCheckUnlocked();
}); });
} }
function copyToClipboard(text) { function copyToClipboard(text) {
if (root._busy) {
return;
}
root._copyText = text; root._copyText = text;
root._copyIndex = 0; root._copyIndex = 0;
root._busy = true;
root._runNextCopy(); root._runNextCopy();
} }
@ -98,11 +142,13 @@ PluginComponent {
const cmds = TailscaleLib.getClipboardCommands(root._copyText); const cmds = TailscaleLib.getClipboardCommands(root._copyText);
if (root._copyIndex >= cmds.length) { if (root._copyIndex >= cmds.length) {
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("clipboard"))); ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("clipboard")));
root._busy = false;
return; return;
} }
Proc.runCommand("tailscale-copy-" + root._copyIndex, cmds[root._copyIndex], (stdout, code) => { Proc.runCommand("tailscale-copy-" + root._copyIndex, cmds[root._copyIndex], (stdout, code) => {
if (code === 0) { if (code === 0) {
ToastService.showInfo(I18n.tr(TailscaleLib.getStrings().copied).arg(root._copyText)); ToastService.showInfo(I18n.tr(TailscaleLib.getStrings().copied).arg(root._copyText));
root._busy = false;
} else { } else {
root._copyIndex += 1; root._copyIndex += 1;
root._runNextCopy(); root._runNextCopy();
@ -119,9 +165,9 @@ PluginComponent {
Item { Item {
id: contentItem id: contentItem
width: parent.width 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 id: statusRow
y: Theme.spacingM y: Theme.spacingM
width: parent.width width: parent.width
@ -134,11 +180,11 @@ PluginComponent {
MouseArea { MouseArea {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
hoverEnabled: true hoverEnabled: true
anchors.verticalCenter: parent.verticalCenter Layout.alignment: Qt.AlignVCenter
width: toggleIcon.implicitWidth width: toggleIcon.implicitWidth
height: toggleIcon.implicitHeight height: toggleIcon.implicitHeight
onClicked: { onClicked: {
root.toggleTailscale() root.toggleTailscale();
} }
DankIcon { DankIcon {
@ -154,27 +200,30 @@ PluginComponent {
text: root.tailscaleIP || "—" text: root.tailscaleIP || "—"
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.primary 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 { StyledText {
text: I18n.tr(TailscaleLib.getStrings().exitNodePrefix) + (root.currentExitNode || I18n.tr(TailscaleLib.getStrings().none)) text: I18n.tr(TailscaleLib.getStrings().exitNodePrefix) + (root.currentExitNode || I18n.tr(TailscaleLib.getStrings().none))
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter Layout.alignment: Qt.AlignVCenter
} }
MouseArea { MouseArea {
visible: TailscaleLib.shouldShowClearExitNode(root.currentExitNode) visible: root.currentExitNode !== ""
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
hoverEnabled: true hoverEnabled: true
anchors.verticalCenter: parent.verticalCenter Layout.alignment: Qt.AlignVCenter
width: clearExitNodeText.implicitWidth width: clearExitNodeText.implicitWidth
height: clearExitNodeText.implicitHeight height: clearExitNodeText.implicitHeight
onClicked: { onClicked: {
root.setExitNode("") root.setExitNode("");
} }
StyledText { StyledText {
@ -186,78 +235,100 @@ PluginComponent {
} }
} }
ListView { // Peer list when connected; empty-state hint when not (#55).
id: peerList Item {
id: peerArea
y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM
width: parent.width - Theme.spacingM * 2 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.left: parent.left
anchors.leftMargin: Theme.spacingM anchors.leftMargin: Theme.spacingM
model: root.peers
interactive: true
boundsBehavior: Flickable.DragAndOvershootBounds
delegate: Item { StyledText {
width: peerList.width visible: !root.isConnected
height: Theme.fontSizeSmall + Theme.spacingXS anchors.fill: parent
text: I18n.tr(TailscaleLib.getStrings().notConnectedHint)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
Row { ListView {
anchors.fill: parent id: peerList
spacing: Theme.spacingS visible: root.isConnected
anchors.verticalCenter: parent.verticalCenter anchors.fill: parent
model: root.peers
interactive: true
// Desktop popout: no rubber-band overshoot (#27).
boundsBehavior: Flickable.StopAtBounds
clip: true
MouseArea { delegate: Item {
cursorShape: Qt.PointingHandCursor width: peerList.width
hoverEnabled: true height: Theme.fontSizeSmall + Theme.spacingXS
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: peerHostnameText.implicitWidth spacing: Theme.spacingS
height: peerHostnameText.implicitHeight
onClicked: { MouseArea {
root.copyToClipboard(modelData.hostname) 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 { MouseArea {
id: peerHostnameText cursorShape: Qt.PointingHandCursor
text: modelData.hostname hoverEnabled: true
font.pixelSize: Theme.fontSizeSmall anchors.verticalCenter: parent.verticalCenter
color: modelData.online ? Theme.primary : Theme.surfaceVariantText width: peerIpText.implicitWidth
} height: peerIpText.implicitHeight
} onClicked: {
root.copyToClipboard(modelData.ip);
}
MouseArea { StyledText {
cursorShape: Qt.PointingHandCursor id: peerIpText
hoverEnabled: true text: modelData.ip
anchors.verticalCenter: parent.verticalCenter font.pixelSize: Theme.fontSizeSmall
width: peerIpText.implicitWidth color: Theme.surfaceVariantText
height: peerIpText.implicitHeight }
onClicked: {
root.copyToClipboard(modelData.ip)
} }
StyledText { MouseArea {
id: peerIpText visible: modelData.exitNode
text: modelData.ip cursorShape: Qt.PointingHandCursor
font.pixelSize: Theme.fontSizeSmall hoverEnabled: true
color: Theme.surfaceVariantText anchors.verticalCenter: parent.verticalCenter
} width: exitNodeButton.implicitWidth
} height: exitNodeButton.implicitHeight
onClicked: {
root.setExitNode(modelData.hostname);
}
MouseArea { StyledText {
visible: modelData.exitNode id: exitNodeButton
cursorShape: Qt.PointingHandCursor text: "↗"
hoverEnabled: true font.pixelSize: Theme.fontSizeSmall
anchors.verticalCenter: parent.verticalCenter color: (root.currentExitNode === modelData.hostname) ? Theme.primary : Theme.surfaceVariantText
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
} }
} }
} }
@ -271,7 +342,7 @@ PluginComponent {
anchors.fill: parent anchors.fill: parent
acceptedButtons: Qt.RightButton acceptedButtons: Qt.RightButton
onClicked: { onClicked: {
root.toggleTailscale() root.toggleTailscale();
} }
} }