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 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue