aboutsummaryrefslogtreecommitdiff
path: root/menubar/panel.swift
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-23 13:59:59 +0300
committerIgor Soarez <igor@soarez.org>2026-10-23 13:59:59 +0300
commit9c9c9dad93b16781ec2fc40bd7582db62240171c (patch)
treecf85f3a53f8441eae36982d06244041cfe0ff6f3 /menubar/panel.swift
parentc082f5044eedd2294d72f920d1018e40d73aab33 (diff)
restore, step 3: offer it in the panel, menu, and status line
when tmux stops (and its sessions are missing from the running server, or no server runs), the daemon publishes the restore offer: offer.json for the menu bar app and @dingger_restore for the status line, withdrawn once restored or dismissed. - panel: a first row "↺ Restore 9 sessions · 22 agents" with when it was saved and how many agents were mid-turn; clicking restores, "Not now" dismisses. an offer shows the panel like a waiting agent; off stays off. - menu: the offer on top; restore all, one session, or not now. - status line: a clickable blue ↺N before the clock. - restore runs through the user's login shell, as a terminal starts tmux, so restored panes get a real environment, not launchd's; --report writes restore.log and notifies a summary. restore --dismiss. - commands log only warnings by default; SIGPIPE ends them quietly. e2e: the offer through the real menu bar binary, the summary notification, the offer clearing, a restore with no tmux running, and not now staying dismissed when a terminal starts tmux. the second test daemon is started directly (it leaked through a function's $! before, and dozens of leaked daemons fought over the test socket and loaded the machine); each run uses its own socket and checks its daemons stop.
Diffstat (limited to 'menubar/panel.swift')
-rw-r--r--menubar/panel.swift128
1 files changed, 123 insertions, 5 deletions
diff --git a/menubar/panel.swift b/menubar/panel.swift
index 003a061..6ab85f2 100644
--- a/menubar/panel.swift
+++ b/menubar/panel.swift
@@ -36,13 +36,15 @@ struct PanelVisibility: Equatable {
var enabled = true
var policy: PanelPolicy = .whenWaiting
var anyWaiting = false
+ /// tmux stopped and its sessions can be restored.
+ var restoreOffered = false
/// Turning the panel on with nothing to show shows it briefly, so the
/// shortcut visibly did something.
var peekUntil: Date?
func visible(at now: Date) -> Bool {
guard enabled else { return false }
- if policy == .always || anyWaiting { return true }
+ if policy == .always || anyWaiting || restoreOffered { return true }
return peekUntil.map { now < $0 } ?? false
}
@@ -84,7 +86,15 @@ struct PanelContent {
/// Whether the panel offers the compact/full toggle (shown always).
let canToggle: Bool
- init(rows all: [Row], full: Bool = false, canToggle: Bool = false) {
+ /// Sessions to restore, shown first.
+ let offer: RestoreOffer?
+ /// A restore is running.
+ let restoring: Bool
+
+ init(rows all: [Row], full: Bool = false, canToggle: Bool = false,
+ offer: RestoreOffer? = nil, restoring: Bool = false) {
+ self.offer = offer
+ self.restoring = restoring
let waiting = all.filter(\.waiting)
if full {
rows = all
@@ -127,14 +137,28 @@ struct PanelContent {
var toggleLabel: String { full ? "Show less ▴" : "Show all ▾" }
+ /// The offer row's title: what restore will do, or that it runs.
+ var offerTitle: String? {
+ guard let offer else { return nil }
+ return restoring ? "Restoring \(offer.sessions.count) \(offer.sessions.count == 1 ? "session" : "sessions")…" : offer.title
+ }
+
+ /// The summary line is noise when tmux is gone and only the offer shows.
+ var showsSummary: Bool { offer == nil || working + idle + rows.count > 0 }
+
var plainText: String {
var lines: [String] = []
+ if let offer, let offerTitle {
+ lines.append("↺ \(offerTitle)\t\(offer.detail)" + (restoring ? "" : "\t[Not now]"))
+ }
for (index, row) in rows.enumerated() {
if index == separatorBefore { lines.append("---") }
lines.append("\(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)")
}
if overflow > 0 { lines.append("+\(overflow) more waiting") }
- lines.append(summary.string + (canToggle ? "\t[\(toggleLabel)]" : ""))
+ if showsSummary {
+ lines.append(summary.string + (canToggle ? "\t[\(toggleLabel)]" : ""))
+ }
return lines.joined(separator: "\n")
}
}
@@ -272,6 +296,43 @@ final class PanelRowView: ClickView {
}
}
+/// The restore offer: "↺ Restore 9 sessions · 22 agents", what it was.
+private final class OfferView: ClickView {
+ init(title: String, detail: String, onClick: @escaping () -> Void) {
+ super.init(frame: .zero)
+ self.onClick = onClick
+ let font = NSFont.menuFont(ofSize: 0)
+ let text = NSMutableAttributedString(string: "↺ ", attributes: [
+ .font: NSFont.monospacedSystemFont(ofSize: font.pointSize, weight: .bold), .foregroundColor: mochaBlue,
+ ])
+ text.append(NSAttributedString(string: title, attributes: [.font: font, .foregroundColor: mochaText]))
+ let titleLabel = singleLine(NSTextField(labelWithAttributedString: text))
+ let detailLabel = NSTextField(labelWithString: detail)
+ detailLabel.font = .systemFont(ofSize: 11)
+ detailLabel.textColor = mochaSubtext
+ let stack = NSStackView(views: [titleLabel, singleLine(detailLabel)])
+ stack.orientation = .vertical
+ stack.alignment = .leading
+ stack.spacing = 1
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(stack)
+ NSLayoutConstraint.activate([
+ stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
+ stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8),
+ stack.topAnchor.constraint(equalTo: topAnchor, constant: 5),
+ stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5),
+ ])
+ }
+
+ required init?(coder: NSCoder) { fatalError("not used") }
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard hovered else { return }
+ NSColor.white.withAlphaComponent(0.08).setFill()
+ NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill()
+ }
+}
+
/// "Show all ▾" / "Show less ▴".
private final class ToggleView: ClickView {
private let label: NSTextField
@@ -302,11 +363,16 @@ final class PanelController {
private let stack = NSStackView()
private let onClick: (String) -> Void
private let onToggleFull: () -> Void
+ private let onRestore: () -> Void
+ private let onDismissRestore: () -> Void
private var rendered: String?
- init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, onToggleFull: @escaping () -> Void) {
+ init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, onToggleFull: @escaping () -> Void,
+ onRestore: @escaping () -> Void = {}, onDismissRestore: @escaping () -> Void = {}) {
self.onClick = onClick
self.onToggleFull = onToggleFull
+ self.onRestore = onRestore
+ self.onDismissRestore = onDismissRestore
panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: 60),
styleMask: [.nonactivatingPanel, .borderless],
backing: .buffered, defer: false)
@@ -356,6 +422,10 @@ final class PanelController {
stack.removeArrangedSubview(view)
view.removeFromSuperview()
}
+ if let offer = content.offer, let title = content.offerTitle {
+ add(offerLine(title: title, detail: offer.detail, restoring: content.restoring), fullWidth: true)
+ if content.showsSummary || !content.rows.isEmpty { add(separatorLine()) }
+ }
for (index, row) in content.rows.enumerated() {
if index == content.separatorBefore { add(separatorLine()) }
add(PanelRowView(row: row, onClick: onClick), fullWidth: true)
@@ -365,7 +435,7 @@ final class PanelController {
string: "+\(content.overflow) more waiting, see the menu bar",
attributes: [.font: NSFont.systemFont(ofSize: 11), .foregroundColor: mochaSubtext])))
}
- add(summaryLine(content), fullWidth: true)
+ if content.showsSummary { add(summaryLine(content), fullWidth: true) }
// Grow or shrink downward from the top edge.
stack.layoutSubtreeIfNeeded()
@@ -413,6 +483,34 @@ final class PanelController {
return box
}
+ /// The offer row, with "Not now" beside it (a row takes every click
+ /// inside it, so the link cannot be inside).
+ private func offerLine(title: String, detail: String, restoring: Bool) -> NSView {
+ let box = NSView()
+ let row = OfferView(title: title, detail: detail, onClick: restoring ? {} : onRestore)
+ row.translatesAutoresizingMaskIntoConstraints = false
+ box.addSubview(row)
+ var constraints = [
+ row.leadingAnchor.constraint(equalTo: box.leadingAnchor),
+ row.topAnchor.constraint(equalTo: box.topAnchor),
+ row.bottomAnchor.constraint(equalTo: box.bottomAnchor),
+ ]
+ if restoring {
+ constraints.append(row.trailingAnchor.constraint(equalTo: box.trailingAnchor))
+ } else {
+ let dismiss = ToggleView(text: "Not now", onClick: onDismissRestore)
+ dismiss.translatesAutoresizingMaskIntoConstraints = false
+ box.addSubview(dismiss)
+ constraints += [
+ dismiss.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -8),
+ dismiss.centerYAnchor.constraint(equalTo: box.centerYAnchor),
+ row.trailingAnchor.constraint(equalTo: dismiss.leadingAnchor, constant: -8),
+ ]
+ }
+ NSLayoutConstraint.activate(constraints)
+ return box
+ }
+
/// Summary on the left, the compact/full toggle on the right.
private func summaryLine(_ content: PanelContent) -> NSView {
let box = caption(content.summary)
@@ -511,6 +609,12 @@ func panelSelftest() -> [String] {
check(PanelVisibility.migrated(fromMode: "auto") == PanelVisibility(enabled: true, policy: .whenWaiting),
"migration: when waiting")
+ var offered = PanelVisibility(enabled: true, policy: .whenWaiting)
+ offered.restoreOffered = true
+ check(offered.visible(at: t0), "a restore offer shows the panel")
+ offered.toggle(at: t0)
+ check(!offered.visible(at: at(10)), "off: a restore offer does not show it")
+
check(!panelShowsFull(policy: .whenWaiting, prefersFull: true), "layout: compact when only waiting")
check(panelShowsFull(policy: .always, prefersFull: true), "layout: full in always when chosen")
check(!panelShowsFull(policy: .always, prefersFull: false), "layout: compact in always by default")
@@ -529,5 +633,19 @@ func panelSelftest() -> [String] {
"full: no separator without waiting agents")
check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"),
"toggle offered only when allowed, labelled by layout")
+
+ let offer = RestoreOffer(saved_at: 0, sessions: [
+ .init(name: "a", windows: 2, panes: 3, agents: 2), .init(name: "b", windows: 1, panes: 1, agents: 0),
+ ], agents: 2, interrupted: 1)
+ let afterReboot = PanelContent(rows: [], offer: offer)
+ check(afterReboot.plainText.hasPrefix("↺ Restore 2 sessions · 2 agents\t")
+ && afterReboot.plainText.contains("1 was mid-turn") && afterReboot.plainText.hasSuffix("[Not now]"),
+ "offer: first, with what it brings back and a way to dismiss it")
+ check(!afterReboot.plainText.contains("No agents"), "offer: no empty summary when tmux is gone")
+ let running = PanelContent(rows: [], offer: offer, restoring: true)
+ check(running.plainText.hasPrefix("↺ Restoring 2 sessions…") && !running.plainText.contains("Not now"),
+ "offer: while restoring, no dismiss")
+ check(PanelContent(rows: many, offer: offer).plainText.contains("○ 1 idle"),
+ "offer: the summary stays when there are agents")
return failures
}