diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-23 13:59:59 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-23 13:59:59 +0300 |
| commit | 9c9c9dad93b16781ec2fc40bd7582db62240171c (patch) | |
| tree | cf85f3a53f8441eae36982d06244041cfe0ff6f3 /menubar | |
| parent | c082f5044eedd2294d72f920d1018e40d73aab33 (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')
| -rw-r--r-- | menubar/main.swift | 98 | ||||
| -rw-r--r-- | menubar/model.swift | 52 | ||||
| -rw-r--r-- | menubar/panel.swift | 128 |
3 files changed, 269 insertions, 9 deletions
diff --git a/menubar/main.swift b/menubar/main.swift index a00288c..0d367b8 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -25,6 +25,10 @@ // --snapshot-search <png> <query> [<down>] // render agent search offscreen, then exit // --click <pane> perform a row click for <pane>, then exit +// --restore [<session>...] +// restore saved tmux sessions as the panel and menu do +// (through the login shell), then exit +// --dismiss-offer dismiss the restore offer as "Not now" does, then exit // --selftest check the panel and search rules, then exit import AppKit @@ -33,6 +37,17 @@ func log(_ message: String) { FileHandle.standardError.write(Data("\(Date()) \(message)\n".utf8)) } +/// Restore saved tmux sessions (all when `sessions` is empty) through the +/// login shell, and log the report. Blocks until done. +@discardableResult +func performRestore(sessions: [String]) -> Bool { + let args = ["restore", "--report"] + sessions.flatMap { ["--session", $0] } + let result = runDinggerInLoginShell(args) + let lines = result.output.split(separator: "\n").filter { !$0.contains("can't change option: zle") } + log("restore: \(result.ok ? "done" : "failed")\n" + lines.map { " \($0)" }.joined(separator: "\n")) + return result.ok +} + final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let menu = NSMenu() @@ -49,6 +64,8 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private var searchHotkeyRegistered = false private var prefersFull = UserDefaults.standard.bool(forKey: "panelFull") private var rows: [Row] = [] + private var restoreOffer: RestoreOffer? + private var restoring = false /// The panel settings; the first run after the on/off split migrates the /// old three-way mode. @@ -71,7 +88,9 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { item.menu = menu panel = PanelController( onClick: { [weak self] paneId in self?.jump(to: paneId) }, - onToggleFull: { [weak self] in self?.toggleFull() }) + onToggleFull: { [weak self] in self?.toggleFull() }, + onRestore: { [weak self] in self?.restore(sessions: []) }, + onDismissRestore: { [weak self] in self?.dismissRestore() }) searchWindow = SearchController(onJump: { [weak self] paneId in self?.jump(to: paneId) }) Settings.ensureFile() reloadSettings() @@ -124,6 +143,8 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { item.button?.setAccessibilityLabel("dingger, " + tipText) visibility.anyWaiting = rows.contains(where: \.waiting) + restoreOffer = fetchRestoreOffer() + visibility.restoreOffered = restoreOffer != nil applyPanel() searchWindow.update(rows: rows) } @@ -141,7 +162,8 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private var panelContent: PanelContent { PanelContent(rows: rows, full: panelShowsFull(policy: visibility.policy, prefersFull: prefersFull), - canToggle: visibility.policy == .always) + canToggle: visibility.policy == .always, + offer: restoreOffer, restoring: restoring) } private func applyPanel() { @@ -163,6 +185,7 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private var panelState: String { "\(visibility.enabled ? "on" : "off"), \(visibility.policy.rawValue), " + (visibility.anyWaiting ? "agents waiting" : "nothing waiting") + + (visibility.restoreOffered ? ", restore offered" : "") } /// The shortcut and the menu item: turn the panel on or off, persistently. @@ -188,6 +211,33 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + /// Restore saved tmux sessions (all when `sessions` is empty). + func restore(sessions: [String], completion: (() -> Void)? = nil) { + guard !restoring else { return } + restoring = true + applyPanel() + log("restore: \(sessions.isEmpty ? "all sessions" : sessions.joined(separator: ", "))") + DispatchQueue.global().async { + performRestore(sessions: sessions) + DispatchQueue.main.async { + self.restoring = false + self.refresh() + completion?() + } + } + } + + /// "Not now": stop offering this saved layout. + @objc private func dismissRestore() { + let result = runReporting(dinggerBinary, ["restore", "--dismiss"]) + log("restore: dismissed (\(result.ok ? "ok" : result.output))") + refresh() + } + + @objc private func restoreFromMenu(_ sender: NSMenuItem) { + restore(sessions: (sender.representedObject as? String).map { [$0] } ?? []) + } + @objc private func selectPolicy(_ sender: NSMenuItem) { guard let raw = sender.representedObject as? String, let policy = PanelPolicy(rawValue: raw) else { return } visibility.policy = policy @@ -213,6 +263,37 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() rows = fetchRows() + restoreOffer = fetchRestoreOffer() + if let offer = restoreOffer { + let item = NSMenuItem(title: restoring ? "Restoring tmux Sessions…" : "↺ \(offer.title)", + action: nil, keyEquivalent: "") + if !restoring { + let submenu = NSMenu() + let all = NSMenuItem(title: "Restore All", action: #selector(restoreFromMenu(_:)), keyEquivalent: "") + all.target = self + submenu.addItem(all) + submenu.addItem(.separator()) + for session in offer.sessions { + var detail = "\(session.windows) \(session.windows == 1 ? "window" : "windows")" + if session.agents > 0 { detail += ", \(session.agents) \(session.agents == 1 ? "agent" : "agents")" } + let one = NSMenuItem(title: session.name, action: #selector(restoreFromMenu(_:)), keyEquivalent: "") + one.subtitle = detail + one.target = self + one.representedObject = session.name + submenu.addItem(one) + } + submenu.addItem(.separator()) + let later = NSMenuItem(title: "Not Now", action: #selector(dismissRestore), keyEquivalent: "") + later.target = self + submenu.addItem(later) + item.submenu = submenu + } else { + item.isEnabled = false + } + item.subtitle = offer.detail + menu.addItem(item) + menu.addItem(.separator()) + } if rows.isEmpty { let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "") none.isEnabled = false @@ -321,9 +402,18 @@ if arguments.contains("--print") { } if arguments.contains("--print-panel") { let full = arguments.contains("--full") - print(PanelContent(rows: fetchRows(), full: full, canToggle: full || arguments.contains("--toggle")).plainText) + print(PanelContent(rows: fetchRows(), full: full, canToggle: full || arguments.contains("--toggle"), + offer: fetchRestoreOffer()).plainText) exit(0) } +if let index = arguments.firstIndex(of: "--restore") { + exit(performRestore(sessions: Array(arguments[(index + 1)...])) ? 0 : 1) +} +if arguments.contains("--dismiss-offer") { + let result = runReporting(dinggerBinary, ["restore", "--dismiss"]) + print(result.output) + exit(result.ok ? 0 : 1) +} if let index = arguments.firstIndex(of: "--snapshot-panel"), index + 1 < arguments.count { _ = NSApplication.shared let controller = PanelController(persistFrame: false, onClick: { _ in }, onToggleFull: {}) @@ -332,7 +422,7 @@ if let index = arguments.firstIndex(of: "--snapshot-panel"), index + 1 < argumen paneId: "%stale", agent: "pi", name: "STALE ROW FROM A PREVIOUS RENDER", status: .blocked, session: "old", window: "old", since: 0)])) let full = arguments.contains("--full") - controller.render(PanelContent(rows: fetchRows(), full: full, canToggle: full)) + controller.render(PanelContent(rows: fetchRows(), full: full, canToggle: full, offer: fetchRestoreOffer())) exit(controller.snapshot(to: arguments[index + 1]) ? 0 : 1) } if let index = arguments.firstIndex(of: "--print-search") { diff --git a/menubar/model.swift b/menubar/model.swift index e891697..da5ad15 100644 --- a/menubar/model.swift +++ b/menubar/model.swift @@ -165,6 +165,58 @@ func tmux(_ args: [String]) -> String? { let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/bin/dingger") +// MARK: restore offer + +/// tmux sessions the daemon offers to restore after tmux stopped +/// (`offer.json`, written by `dingger watch`). +struct RestoreOffer: Decodable, Equatable { + struct Session: Decodable, Equatable { + let name: String + let windows: Int + let panes: Int + let agents: Int + } + let saved_at: Int + let sessions: [Session] + let agents: Int + let interrupted: Int + + /// "Restore 9 sessions · 22 agents" + var title: String { + var text = "Restore \(sessions.count) \(sessions.count == 1 ? "session" : "sessions")" + if agents > 0 { text += " · \(agents) \(agents == 1 ? "agent" : "agents")" } + return text + } + + /// "saved 13:01 · 1 was mid-turn" + var detail: String { + let formatter = DateFormatter() + let saved = Date(timeIntervalSince1970: TimeInterval(saved_at) / 1000) + formatter.dateFormat = Calendar.current.isDateInToday(saved) ? "HH:mm" : "MMM d, HH:mm" + var parts = ["tmux stopped · saved \(formatter.string(from: saved))"] + if interrupted > 0 { parts.append("\(interrupted) \(interrupted == 1 ? "was" : "were") mid-turn") } + return parts.joined(separator: " · ") + } +} + +let stateDirectory = environment["DINGGER_STATE_DIR"] ?? (NSHomeDirectory() + "/.local/state/dingger") + +func fetchRestoreOffer() -> RestoreOffer? { + guard let data = FileManager.default.contents(atPath: stateDirectory + "/offer.json") else { return nil } + return try? JSONDecoder().decode(RestoreOffer.self, from: data) +} + +/// Run `dingger <args>` through the user's login shell, the way a terminal +/// starts tmux: restored panes inherit this environment (PATH, LANG), and +/// launchd's is bare. DINGGER_RESTORE_DIRECT runs it directly (tests). +func runDinggerInLoginShell(_ args: [String]) -> (ok: Bool, output: String) { + if environment["DINGGER_RESTORE_DIRECT"] != nil { + return runReporting(dinggerBinary, args) + } + let shell = getpwuid(getuid()).flatMap { String(validatingUTF8: $0.pointee.pw_shell) } ?? "/bin/zsh" + return runReporting(shell, ["--login", "-i", "-c", "exec \"$0\" \"$@\"", dinggerBinary] + args) +} + private let home = NSHomeDirectory() /// Every agent pane, in herdr's priority order, oldest waiting first. 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 } |
