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/main.swift | |
| 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/main.swift')
| -rw-r--r-- | menubar/main.swift | 98 |
1 files changed, 94 insertions, 4 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") { |
