From 000fa2a972d4cc237748cfa16863cd3a4f140e0b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 20 Jul 2026 14:26:19 -0700 Subject: [PATCH] Let the Tailscale panel toggle on and off like Dropbox The hero icon is now the optimistic on/off toggle with a tooltip, replacing the connect block and the progress lines beneath the hero. The disconnected cross is drawn grey rather than red, and action errors expire after a couple of seconds instead of sticking forever. Co-Authored-By: Claude Fable 5 --- shell/plugins/panels/tailscale/Panel.qml | 82 +++++-------------- shell/plugins/panels/tailscale/Service.qml | 33 +++++++- .../panels/tailscale/TailscaleIcon.qml | 2 +- 3 files changed, 50 insertions(+), 67 deletions(-) diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index e0de662e..7c51babb 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -42,14 +42,14 @@ Panel { readonly property color dim: Qt.darker(foreground, 1.55) readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied - readonly property bool showPeers: tailscale.running && tailscale.peers.length > 0 + readonly property bool showPeers: tailscale.active && tailscale.peers.length > 0 readonly property var recentMullvadRegions: settings.recentMullvadRegions instanceof Array ? settings.recentMullvadRegions : (settings.recentMullvadCountries instanceof Array ? settings.recentMullvadCountries : []) readonly property var recentMullvadExitNodes: recentMullvadNodes() readonly property var exitNodes: displayExitNodes() - readonly property bool showExitNodes: tailscale.running && (exitNodes.length > 0 || tailscale.mullvadRegions.length > 0) + readonly property bool showExitNodes: tailscale.active && (exitNodes.length > 0 || tailscale.mullvadRegions.length > 0) readonly property var filteredMullvadRegions: filteredMullvadRegionNodes() - readonly property color iconColor: tailscale.running ? foreground : dim - readonly property color barIconColor: tailscale.running ? barForeground : Qt.darker(barForeground, 1.55) + readonly property color iconColor: tailscale.active ? foreground : dim + readonly property color barIconColor: tailscale.active ? barForeground : Qt.darker(barForeground, 1.55) readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" @@ -355,7 +355,7 @@ Panel { function toggle(): void { root.toggle() } function refresh(): string { tailscale.refresh(); return "ok" } function up(): string { tailscale.loginOrUp(); return "ok" } - function down(): string { tailscale.runAction(["tailscale", "down"], "Turning Tailscale off…"); return "ok" } + function down(): string { tailscale.down(); return "ok" } function status(): string { return tailscale.statusText } } @@ -370,7 +370,7 @@ Panel { iconSize: Style.space(11) color: root.barIconColor badgeColor: root.urgent - crossed: !tailscale.running && !tailscale.needsLogin + crossed: !tailscale.active && !tailscale.needsLogin warning: tailscale.needsLogin } } @@ -435,10 +435,10 @@ Panel { id: hero width: parent.width title: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale" - meta: root.heroPhraseText + meta: tailscale.active ? root.heroPhraseText : "Tailscale is disconnected" foreground: root.foreground fontFamily: root.fontFamily - iconOpacity: tailscale.running ? 1.0 : 0.5 + iconOpacity: tailscale.active ? 1.0 : 0.5 iconComponent: Component { Item { implicitWidth: icon.implicitWidth @@ -449,7 +449,7 @@ Panel { iconSize: Style.font.display color: root.iconColor badgeColor: root.urgent - crossed: !tailscale.running && !tailscale.needsLogin + crossed: !tailscale.active && !tailscale.needsLogin warning: tailscale.needsLogin anchors.centerIn: parent } @@ -466,61 +466,17 @@ Panel { } onClicked: tailscale.toggleTailscale() } + + PanelToolTip { + visible: heroIconMouse.containsMouse + text: tailscale.active ? "Turn Tailscale off" : (tailscale.needsLogin ? "Authorize this device" : "Turn Tailscale on") + fontFamily: root.fontFamily + } } } } } - CursorSurface { - id: connectionRow - visible: tailscale.installed && !tailscale.running - width: parent.width - implicitHeight: connectionText.implicitHeight + Style.spacing.rowPaddingX - hasCursor: root.cursorActive && root.focusSection === "header" - foreground: root.foreground - fill: root.hoverFill - - MouseArea { - anchors.fill: parent - hoverEnabled: true - enabled: !tailscale.busy - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onEntered: { - root.cursorActive = true - root.focusSection = "header" - } - onClicked: tailscale.loginOrUp() - } - - Column { - id: connectionText - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: Style.space(12) - anchors.rightMargin: Style.space(12) - spacing: Style.space(2) - - Text { - width: parent.width - text: tailscale.needsLogin ? "Authorize this device" : "Connect Tailscale" - color: root.foreground - font.family: root.fontFamily - font.pixelSize: Style.font.body - font.weight: Font.Medium - } - - Text { - width: parent.width - text: tailscale.needsLogin ? "Open Tailscale to restore this device's access" : "Reconnect with the current Tailscale settings" - color: root.dim - font.family: root.fontFamily - font.pixelSize: Style.font.caption - wrapMode: Text.WordWrap - } - } - } - Text { visible: tailscale.actionStatus !== "" || tailscale.lastError !== "" width: parent.width @@ -689,12 +645,12 @@ Panel { } PanelSeparator { - visible: tailscale.installed && tailscale.running + visible: tailscale.installed && tailscale.active foreground: root.foreground } Column { - visible: tailscale.installed && tailscale.running + visible: tailscale.installed && tailscale.active width: parent.width spacing: Style.space(10) @@ -705,7 +661,7 @@ Panel { } Text { - visible: tailscale.installed && tailscale.running && tailscale.peers.length === 0 + visible: tailscale.installed && tailscale.active && tailscale.peers.length === 0 width: parent.width text: "No machines found on this tailnet." color: root.dim @@ -740,7 +696,7 @@ Panel { Timer { id: phraseTimer interval: 2800 - running: root.opened + running: root.opened && tailscale.active repeat: true onTriggered: phraseSwap.restart() } diff --git a/shell/plugins/panels/tailscale/Service.qml b/shell/plugins/panels/tailscale/Service.qml index aef8bfc3..f1ef912e 100644 --- a/shell/plugins/panels/tailscale/Service.qml +++ b/shell/plugins/panels/tailscale/Service.qml @@ -12,6 +12,12 @@ Item { property bool installed: false property bool running: false property bool needsLogin: false + + // Optimistic off state so the UI reacts the instant you click, rather than + // waiting for the next status refresh. _desired is -1 while we just follow + // the real state, or 0/1 while a toggle is still catching up. + property int _desired: -1 + readonly property bool active: _desired === -1 ? running : (_desired === 1) property bool refreshing: false property string backendState: "Unknown" property string statusText: "Checking…" @@ -163,6 +169,7 @@ Item { function resetUnavailable(message) { running = false needsLogin = false + _desired = -1 backendState = "Unavailable" statusText = message selfName = "" @@ -197,6 +204,8 @@ Item { backendState = parsed.backendState running = parsed.running + // Reality caught up to the pending toggle — stop overriding. + if (_desired !== -1 && running === (_desired === 1)) _desired = -1 needsLogin = parsed.needsLogin authUrl = parsed.authUrl if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) openAuthUrlFrom(authUrl, false) @@ -238,12 +247,20 @@ Item { function toggleTailscale() { if (!installed) return - if (running) runAction(["tailscale", "down"], "Turning Tailscale off…") + if (active) down() else loginOrUp() } + function down() { + // No progress status here — the greyed icon and hero line already convey + // the optimistic off; only surface a message if the command fails. + _desired = 0 + runAction(["tailscale", "down"]) + } + function loginOrUp() { if (!installed || loginProcess.running) return + _desired = -1 var plan = Model.loginPlan(needsLogin, authUrl) if (plan.authUrl !== "") { _loginUrlOpened = false @@ -252,7 +269,8 @@ Item { } _loginOutput = "" _loginError = "" - actionStatus = needsLogin ? "Starting Tailscale login…" : "Turning Tailscale on…" + if (needsLogin) actionStatus = "Starting Tailscale login…" + else _desired = 1 _loginInProgress = needsLogin _loginUrlOpened = false _preLoginAuthUrl = authUrl @@ -308,7 +326,7 @@ Item { if (actionProcess.running) return _actionOutput = "" _actionError = "" - actionStatus = label || "Working…" + actionStatus = label || "" actionProcess.command = command actionProcess.running = true } @@ -318,6 +336,8 @@ Item { var match = String(text || "").match(/https?:\/\/\S+/) var url = match && match[0] ? match[0] : (allowFallback === true ? authUrl : "") if (url !== "") { + // Turning on ended up needing browser auth — stop pretending we're up. + _desired = -1 _loginUrlOpened = true _loginInProgress = false loginTimeoutTimer.stop() @@ -447,8 +467,10 @@ Item { var stdout = String(actionStdout.text || root._actionOutput || "") var stderr = String(actionStderr.text || root._actionError || "") if (exitCode !== 0) { + root._desired = -1 root.lastError = elideStatus(stderr || stdout || "Tailscale command failed") root.actionStatus = root.lastError + actionStatusTimer.restart() } else { root.lastError = "" root.actionStatus = "" @@ -467,9 +489,11 @@ Item { var combined = String(root._loginOutput || "") + "\n" + String(root._loginError || "") var opened = root.openAuthUrlFrom(combined, true) if (exitCode !== 0 && !opened) { + root._desired = -1 root._loginInProgress = false root.lastError = elideStatus(combined || "tailscale up failed") root.actionStatus = root.lastError + actionStatusTimer.restart() } else if (!opened) { root.lastError = "" root.actionStatus = "" @@ -490,6 +514,7 @@ Item { if (exitCode !== 0) { root.lastError = elideStatus(stderr || stdout || "Account switch failed") root.actionStatus = root.lastError + actionStatusTimer.restart() } else { root.lastError = "" root.actionStatus = "" @@ -512,6 +537,7 @@ Item { if (exitCode !== 0) { root.lastError = elideStatus(stderr || stdout || "Exit node selection failed") root.actionStatus = root.lastError + actionStatusTimer.restart() } else { root.lastError = "" root.actionStatus = "" @@ -533,6 +559,7 @@ Item { if (exitCode !== 0) { root.lastError = elideStatus(stderr || stdout || "Tailscale authorization failed") root.actionStatus = root.lastError + actionStatusTimer.restart() } else { root.accountsAccessDenied = false root.lastError = "" diff --git a/shell/plugins/panels/tailscale/TailscaleIcon.qml b/shell/plugins/panels/tailscale/TailscaleIcon.qml index c8b25560..8de7933c 100644 --- a/shell/plugins/panels/tailscale/TailscaleIcon.qml +++ b/shell/plugins/panels/tailscale/TailscaleIcon.qml @@ -39,7 +39,7 @@ Item { width: parent.width * 1.22 height: Math.max(2, parent.height * 0.14) radius: height / 2 - color: root.badgeColor + color: root.color rotation: -45 }