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:
parent
61626dff51
commit
7c31598594
1 changed files with 165 additions and 94 deletions
|
|
@ -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,25 +235,46 @@ 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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: peerList
|
||||
visible: root.isConnected
|
||||
anchors.fill: parent
|
||||
model: root.peers
|
||||
interactive: true
|
||||
boundsBehavior: Flickable.DragAndOvershootBounds
|
||||
// Desktop popout: no rubber-band overshoot (#27).
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
|
||||
delegate: Item {
|
||||
width: peerList.width
|
||||
height: Theme.fontSizeSmall + Theme.spacingXS
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
spacing: Theme.spacingS
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingS
|
||||
|
||||
MouseArea {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
|
@ -213,7 +283,7 @@ PluginComponent {
|
|||
width: peerHostnameText.implicitWidth
|
||||
height: peerHostnameText.implicitHeight
|
||||
onClicked: {
|
||||
root.copyToClipboard(modelData.hostname)
|
||||
root.copyToClipboard(modelData.hostname);
|
||||
}
|
||||
|
||||
StyledText {
|
||||
|
|
@ -231,7 +301,7 @@ PluginComponent {
|
|||
width: peerIpText.implicitWidth
|
||||
height: peerIpText.implicitHeight
|
||||
onClicked: {
|
||||
root.copyToClipboard(modelData.ip)
|
||||
root.copyToClipboard(modelData.ip);
|
||||
}
|
||||
|
||||
StyledText {
|
||||
|
|
@ -250,14 +320,15 @@ PluginComponent {
|
|||
width: exitNodeButton.implicitWidth
|
||||
height: exitNodeButton.implicitHeight
|
||||
onClicked: {
|
||||
root.setExitNode(modelData.hostname)
|
||||
root.setExitNode(modelData.hostname);
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: exitNodeButton
|
||||
text: "↗"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: TailscaleLib.isActiveExitNode(root.currentExitNode, modelData.hostname) ? Theme.primary : Theme.surfaceVariantText
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue