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 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()
}
+30 -3
View File
@@ -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 = ""
@@ -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
}