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 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-07-20 14:26:19 -07:00
co-authored by Claude Fable 5
parent a7c0d0f6a1
commit 000fa2a972
3 changed files with 50 additions and 67 deletions
+19 -63
View File
@@ -42,14 +42,14 @@ Panel {
readonly property color dim: Qt.darker(foreground, 1.55) readonly property color dim: Qt.darker(foreground, 1.55)
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied 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 recentMullvadRegions: settings.recentMullvadRegions instanceof Array ? settings.recentMullvadRegions : (settings.recentMullvadCountries instanceof Array ? settings.recentMullvadCountries : [])
readonly property var recentMullvadExitNodes: recentMullvadNodes() readonly property var recentMullvadExitNodes: recentMullvadNodes()
readonly property var exitNodes: displayExitNodes() 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 var filteredMullvadRegions: filteredMullvadRegionNodes()
readonly property color iconColor: tailscale.running ? foreground : dim readonly property color iconColor: tailscale.active ? foreground : dim
readonly property color barIconColor: tailscale.running ? barForeground : Qt.darker(barForeground, 1.55) 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 hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent"
readonly property color selectedFill: bar ? Style.selectedFillFor(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 toggle(): void { root.toggle() }
function refresh(): string { tailscale.refresh(); return "ok" } function refresh(): string { tailscale.refresh(); return "ok" }
function up(): string { tailscale.loginOrUp(); 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 } function status(): string { return tailscale.statusText }
} }
@@ -370,7 +370,7 @@ Panel {
iconSize: Style.space(11) iconSize: Style.space(11)
color: root.barIconColor color: root.barIconColor
badgeColor: root.urgent badgeColor: root.urgent
crossed: !tailscale.running && !tailscale.needsLogin crossed: !tailscale.active && !tailscale.needsLogin
warning: tailscale.needsLogin warning: tailscale.needsLogin
} }
} }
@@ -435,10 +435,10 @@ Panel {
id: hero id: hero
width: parent.width width: parent.width
title: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale" title: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale"
meta: root.heroPhraseText meta: tailscale.active ? root.heroPhraseText : "Tailscale is disconnected"
foreground: root.foreground foreground: root.foreground
fontFamily: root.fontFamily fontFamily: root.fontFamily
iconOpacity: tailscale.running ? 1.0 : 0.5 iconOpacity: tailscale.active ? 1.0 : 0.5
iconComponent: Component { iconComponent: Component {
Item { Item {
implicitWidth: icon.implicitWidth implicitWidth: icon.implicitWidth
@@ -449,7 +449,7 @@ Panel {
iconSize: Style.font.display iconSize: Style.font.display
color: root.iconColor color: root.iconColor
badgeColor: root.urgent badgeColor: root.urgent
crossed: !tailscale.running && !tailscale.needsLogin crossed: !tailscale.active && !tailscale.needsLogin
warning: tailscale.needsLogin warning: tailscale.needsLogin
anchors.centerIn: parent anchors.centerIn: parent
} }
@@ -466,61 +466,17 @@ Panel {
} }
onClicked: tailscale.toggleTailscale() 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 { Text {
visible: tailscale.actionStatus !== "" || tailscale.lastError !== "" visible: tailscale.actionStatus !== "" || tailscale.lastError !== ""
width: parent.width width: parent.width
@@ -689,12 +645,12 @@ Panel {
} }
PanelSeparator { PanelSeparator {
visible: tailscale.installed && tailscale.running visible: tailscale.installed && tailscale.active
foreground: root.foreground foreground: root.foreground
} }
Column { Column {
visible: tailscale.installed && tailscale.running visible: tailscale.installed && tailscale.active
width: parent.width width: parent.width
spacing: Style.space(10) spacing: Style.space(10)
@@ -705,7 +661,7 @@ Panel {
} }
Text { Text {
visible: tailscale.installed && tailscale.running && tailscale.peers.length === 0 visible: tailscale.installed && tailscale.active && tailscale.peers.length === 0
width: parent.width width: parent.width
text: "No machines found on this tailnet." text: "No machines found on this tailnet."
color: root.dim color: root.dim
@@ -740,7 +696,7 @@ Panel {
Timer { Timer {
id: phraseTimer id: phraseTimer
interval: 2800 interval: 2800
running: root.opened running: root.opened && tailscale.active
repeat: true repeat: true
onTriggered: phraseSwap.restart() onTriggered: phraseSwap.restart()
} }
+30 -3
View File
@@ -12,6 +12,12 @@ Item {
property bool installed: false property bool installed: false
property bool running: false property bool running: false
property bool needsLogin: 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 bool refreshing: false
property string backendState: "Unknown" property string backendState: "Unknown"
property string statusText: "Checking…" property string statusText: "Checking…"
@@ -163,6 +169,7 @@ Item {
function resetUnavailable(message) { function resetUnavailable(message) {
running = false running = false
needsLogin = false needsLogin = false
_desired = -1
backendState = "Unavailable" backendState = "Unavailable"
statusText = message statusText = message
selfName = "" selfName = ""
@@ -197,6 +204,8 @@ Item {
backendState = parsed.backendState backendState = parsed.backendState
running = parsed.running running = parsed.running
// Reality caught up to the pending toggle — stop overriding.
if (_desired !== -1 && running === (_desired === 1)) _desired = -1
needsLogin = parsed.needsLogin needsLogin = parsed.needsLogin
authUrl = parsed.authUrl authUrl = parsed.authUrl
if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) openAuthUrlFrom(authUrl, false) if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) openAuthUrlFrom(authUrl, false)
@@ -238,12 +247,20 @@ Item {
function toggleTailscale() { function toggleTailscale() {
if (!installed) return if (!installed) return
if (running) runAction(["tailscale", "down"], "Turning Tailscale off…") if (active) down()
else loginOrUp() 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() { function loginOrUp() {
if (!installed || loginProcess.running) return if (!installed || loginProcess.running) return
_desired = -1
var plan = Model.loginPlan(needsLogin, authUrl) var plan = Model.loginPlan(needsLogin, authUrl)
if (plan.authUrl !== "") { if (plan.authUrl !== "") {
_loginUrlOpened = false _loginUrlOpened = false
@@ -252,7 +269,8 @@ Item {
} }
_loginOutput = "" _loginOutput = ""
_loginError = "" _loginError = ""
actionStatus = needsLogin ? "Starting Tailscale login…" : "Turning Tailscale on…" if (needsLogin) actionStatus = "Starting Tailscale login…"
else _desired = 1
_loginInProgress = needsLogin _loginInProgress = needsLogin
_loginUrlOpened = false _loginUrlOpened = false
_preLoginAuthUrl = authUrl _preLoginAuthUrl = authUrl
@@ -308,7 +326,7 @@ Item {
if (actionProcess.running) return if (actionProcess.running) return
_actionOutput = "" _actionOutput = ""
_actionError = "" _actionError = ""
actionStatus = label || "Working…" actionStatus = label || ""
actionProcess.command = command actionProcess.command = command
actionProcess.running = true actionProcess.running = true
} }
@@ -318,6 +336,8 @@ Item {
var match = String(text || "").match(/https?:\/\/\S+/) var match = String(text || "").match(/https?:\/\/\S+/)
var url = match && match[0] ? match[0] : (allowFallback === true ? authUrl : "") var url = match && match[0] ? match[0] : (allowFallback === true ? authUrl : "")
if (url !== "") { if (url !== "") {
// Turning on ended up needing browser auth — stop pretending we're up.
_desired = -1
_loginUrlOpened = true _loginUrlOpened = true
_loginInProgress = false _loginInProgress = false
loginTimeoutTimer.stop() loginTimeoutTimer.stop()
@@ -447,8 +467,10 @@ Item {
var stdout = String(actionStdout.text || root._actionOutput || "") var stdout = String(actionStdout.text || root._actionOutput || "")
var stderr = String(actionStderr.text || root._actionError || "") var stderr = String(actionStderr.text || root._actionError || "")
if (exitCode !== 0) { if (exitCode !== 0) {
root._desired = -1
root.lastError = elideStatus(stderr || stdout || "Tailscale command failed") root.lastError = elideStatus(stderr || stdout || "Tailscale command failed")
root.actionStatus = root.lastError root.actionStatus = root.lastError
actionStatusTimer.restart()
} else { } else {
root.lastError = "" root.lastError = ""
root.actionStatus = "" root.actionStatus = ""
@@ -467,9 +489,11 @@ Item {
var combined = String(root._loginOutput || "") + "\n" + String(root._loginError || "") var combined = String(root._loginOutput || "") + "\n" + String(root._loginError || "")
var opened = root.openAuthUrlFrom(combined, true) var opened = root.openAuthUrlFrom(combined, true)
if (exitCode !== 0 && !opened) { if (exitCode !== 0 && !opened) {
root._desired = -1
root._loginInProgress = false root._loginInProgress = false
root.lastError = elideStatus(combined || "tailscale up failed") root.lastError = elideStatus(combined || "tailscale up failed")
root.actionStatus = root.lastError root.actionStatus = root.lastError
actionStatusTimer.restart()
} else if (!opened) { } else if (!opened) {
root.lastError = "" root.lastError = ""
root.actionStatus = "" root.actionStatus = ""
@@ -490,6 +514,7 @@ Item {
if (exitCode !== 0) { if (exitCode !== 0) {
root.lastError = elideStatus(stderr || stdout || "Account switch failed") root.lastError = elideStatus(stderr || stdout || "Account switch failed")
root.actionStatus = root.lastError root.actionStatus = root.lastError
actionStatusTimer.restart()
} else { } else {
root.lastError = "" root.lastError = ""
root.actionStatus = "" root.actionStatus = ""
@@ -512,6 +537,7 @@ Item {
if (exitCode !== 0) { if (exitCode !== 0) {
root.lastError = elideStatus(stderr || stdout || "Exit node selection failed") root.lastError = elideStatus(stderr || stdout || "Exit node selection failed")
root.actionStatus = root.lastError root.actionStatus = root.lastError
actionStatusTimer.restart()
} else { } else {
root.lastError = "" root.lastError = ""
root.actionStatus = "" root.actionStatus = ""
@@ -533,6 +559,7 @@ Item {
if (exitCode !== 0) { if (exitCode !== 0) {
root.lastError = elideStatus(stderr || stdout || "Tailscale authorization failed") root.lastError = elideStatus(stderr || stdout || "Tailscale authorization failed")
root.actionStatus = root.lastError root.actionStatus = root.lastError
actionStatusTimer.restart()
} else { } else {
root.accountsAccessDenied = false root.accountsAccessDenied = false
root.lastError = "" root.lastError = ""
@@ -39,7 +39,7 @@ Item {
width: parent.width * 1.22 width: parent.width * 1.22
height: Math.max(2, parent.height * 0.14) height: Math.max(2, parent.height * 0.14)
radius: height / 2 radius: height / 2
color: root.badgeColor color: root.color
rotation: -45 rotation: -45
} }