aboutsummaryrefslogtreecommitdiff
path: root/menubar
diff options
context:
space:
mode:
Diffstat (limited to 'menubar')
-rw-r--r--menubar/main.swift98
-rw-r--r--menubar/model.swift52
-rw-r--r--menubar/panel.swift128
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
}