The previous layout was a fixed 460x360 window regardless of tab or state, with a plain unbordered table and text +/- buttons — it read as a generic cross-platform port rather than something native. Several rows also had no real width anchor at all, so they floated: flush against the window's left edge with no margin, and in the add form's case wide enough to overflow past the right edge. - The window is no longer manually resizable. Each tab reports its own preferredContentSize, recomputed in viewDidLayout and after every state change, and NSTabViewController resizes the window to match. That is what actually fixes the large empty areas: a tab with less content gets a smaller window rather than sitting inside a fixed larger one. - Every row now anchors its width to the one element with a real absolute width, fixing both the edge-touching and the overflow. - Table gains a bezel border and alternating rows; form labels get a fixed trailing-aligned width so fields line up; the error label wraps instead of truncating; tab padding is consistent. - About moves out of a separate panel into a third tab, with the app icon centred and enlarged to 96px. Verified visually with a standalone harness that drives the real window and screenshots it, rather than simulating input — scripting clicks into the app itself needs an Accessibility grant this environment lacks.
282 lines
12 KiB
Swift
282 lines
12 KiB
Swift
// Menu bar behavior: title dots, per-profile menu, Manage Profiles window.
|
|
// Reads live state from LiveState (push-based, no polling) and calls
|
|
// ProfileStore/ClaudeControl in-process — no subprocess, no text parsing.
|
|
import AppKit
|
|
|
|
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
|
|
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
let menu = NSMenu()
|
|
let liveState = LiveState()
|
|
private var currentInfos: [ProfileInfo] = []
|
|
private var collisionAlertShown = false
|
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
menu.delegate = self
|
|
menu.autoenablesItems = false
|
|
statusItem.menu = menu
|
|
statusItem.button?.image = menuBarIcon()
|
|
statusItem.button?.imagePosition = .imageLeft
|
|
|
|
liveState.delegate = self
|
|
ManageWindowController.shared.onProfilesChanged = { [weak self] in self?.liveState.reconcile() }
|
|
liveState.reconcile()
|
|
|
|
// Launching this app fresh with nothing running means there's no
|
|
// menu-bar title/dots to click yet — offer a picker up front
|
|
// instead of leaving the user to discover the dropdown.
|
|
if currentInfos.filter(\.running).isEmpty {
|
|
showStartupPicker(currentInfos)
|
|
}
|
|
|
|
UpdateChecker.checkAutomaticallyIfDue()
|
|
}
|
|
|
|
// Claude's own app icon (bundled as Contents/Resources/AppIcon.icns —
|
|
// currently a placeholder, see icon/generate-icon.swift), scaled down
|
|
// for the menu bar.
|
|
private func menuBarIcon() -> NSImage {
|
|
let source = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName) ?? NSImage()
|
|
let resized = NSImage(size: NSSize(width: 18, height: 18))
|
|
resized.lockFocus()
|
|
source.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18), from: .zero, operation: .sourceOver, fraction: 1.0)
|
|
resized.unlockFocus()
|
|
return resized
|
|
}
|
|
|
|
// MARK: - LiveStateDelegate
|
|
|
|
func liveStateDidChange(_ infos: [ProfileInfo]) {
|
|
collisionAlertShown = false
|
|
currentInfos = infos
|
|
applyTitle(infos)
|
|
ManageWindowController.shared.update(infos)
|
|
}
|
|
|
|
// Two profiles sharing a dir is a fatal misconfiguration (would
|
|
// silently merge their Claude sessions) — surfaced with an alert
|
|
// rather than crashing the app outright, since that would leave the
|
|
// user with no way to fix it short of hand-editing files blind.
|
|
func liveStateFoundCollisions(_ collisions: [ProfileStore.DirCollision]) {
|
|
guard !collisionAlertShown else { return }
|
|
collisionAlertShown = true
|
|
let lines = collisions.map { "\u{2022} \($0.kind): \"\($0.profileA)\" and \"\($0.profileB)\" both use \($0.dir)" }
|
|
let alert = NSAlert()
|
|
alert.alertStyle = .critical
|
|
alert.messageText = "shannoncoat: configuration error"
|
|
alert.informativeText = "Two profiles can't share a directory:\n\n" + lines.joined(separator: "\n")
|
|
alert.addButton(withTitle: "OK")
|
|
alert.runModal()
|
|
}
|
|
|
|
// Title reflects what's ACTUALLY running — one profile shows as a dot
|
|
// + name; several show as dots only, to keep the menu bar from growing
|
|
// unbounded, with the full list available via tooltip and the dropdown.
|
|
private func applyTitle(_ profiles: [ProfileInfo]) {
|
|
guard let button = statusItem.button else { return }
|
|
let running = profiles.filter(\.running)
|
|
switch running.count {
|
|
case 0:
|
|
button.attributedTitle = NSAttributedString(string: "")
|
|
button.toolTip = "Claude not running"
|
|
case 1:
|
|
let name = running[0].profile.name
|
|
let title = NSMutableAttributedString(
|
|
string: "\u{25cf} ", attributes: [.foregroundColor: ProfileColor.dotColor(for: name)])
|
|
title.append(NSAttributedString(string: name, attributes: [.foregroundColor: NSColor.labelColor]))
|
|
button.attributedTitle = title
|
|
button.toolTip = nil
|
|
default:
|
|
let title = NSMutableAttributedString()
|
|
for info in running {
|
|
title.append(NSAttributedString(
|
|
string: "\u{25cf}", attributes: [.foregroundColor: ProfileColor.dotColor(for: info.profile.name)]))
|
|
}
|
|
button.attributedTitle = title
|
|
button.toolTip = running.map(\.profile.name).joined(separator: ", ")
|
|
}
|
|
}
|
|
|
|
// MARK: - Menu
|
|
|
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
|
menu.removeAllItems()
|
|
liveState.reconcile() // correctness backstop; live updates are the primary mechanism
|
|
let profiles = currentInfos
|
|
let runningCount = profiles.filter(\.running).count
|
|
|
|
let header = NSMenuItem(
|
|
title: runningCount == 0 ? "Claude not running"
|
|
: runningCount == 1 ? "1 profile running" : "\(runningCount) profiles running",
|
|
action: nil, keyEquivalent: "")
|
|
header.isEnabled = false
|
|
menu.addItem(header)
|
|
menu.addItem(.separator())
|
|
|
|
let hint = NSMenuItem(title: "\u{21e7}-click to open alongside", action: nil, keyEquivalent: "")
|
|
hint.isEnabled = false
|
|
menu.addItem(hint)
|
|
menu.addItem(.separator())
|
|
|
|
for info in profiles {
|
|
// Click = exclusive switch (quits every other running profile).
|
|
// Shift-click = open alongside instead, without disturbing
|
|
// anything else that's running.
|
|
let title = info.profile.name == liveState.lastActiveName
|
|
? "\(info.profile.name) \u{21c6}" : info.profile.name
|
|
let item = NSMenuItem(title: title, action: #selector(pick(_:)), keyEquivalent: "")
|
|
item.target = self
|
|
item.representedObject = info.profile.name
|
|
item.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running)
|
|
item.state = info.running ? .on : .off
|
|
item.toolTip = info.running ? "Running — click to focus" : "Click to switch • Shift-click to open alongside"
|
|
menu.addItem(item)
|
|
}
|
|
|
|
menu.addItem(.separator())
|
|
let manageItem = NSMenuItem(
|
|
title: "Manage Profiles\u{2026}", action: #selector(openManageWindow), keyEquivalent: ",")
|
|
manageItem.keyEquivalentModifierMask = [.command]
|
|
manageItem.target = self
|
|
menu.addItem(manageItem)
|
|
|
|
let closeItem = NSMenuItem(title: "Close Profile", action: nil, keyEquivalent: "")
|
|
let closeMenu = NSMenu()
|
|
let closeable = profiles.filter(\.running)
|
|
if closeable.isEmpty {
|
|
let none = NSMenuItem(title: "(none running)", action: nil, keyEquivalent: "")
|
|
none.isEnabled = false
|
|
closeMenu.addItem(none)
|
|
} else {
|
|
for info in closeable {
|
|
let mi = NSMenuItem(title: info.profile.name, action: #selector(closeProfile(_:)), keyEquivalent: "")
|
|
mi.target = self
|
|
mi.representedObject = info.profile.name
|
|
closeMenu.addItem(mi)
|
|
}
|
|
}
|
|
closeItem.submenu = closeMenu
|
|
menu.addItem(closeItem)
|
|
|
|
menu.addItem(.separator())
|
|
let aboutItem = NSMenuItem(title: "About shannoncoat", action: #selector(showAbout), keyEquivalent: "")
|
|
aboutItem.target = self
|
|
menu.addItem(aboutItem)
|
|
|
|
menu.addItem(.separator())
|
|
let quitClaudeItem = NSMenuItem(title: "Quit Claude", action: #selector(quitClaude), keyEquivalent: "")
|
|
quitClaudeItem.target = self
|
|
menu.addItem(quitClaudeItem)
|
|
|
|
let quitSelfItem = NSMenuItem(title: "Quit shannoncoat", action: #selector(quitSelf), keyEquivalent: "q")
|
|
quitSelfItem.target = self
|
|
menu.addItem(quitSelfItem)
|
|
}
|
|
|
|
// Shown once at launch when nothing is running. A checklist rather
|
|
// than a single pick: unlike the menu's click/Shift-click (one
|
|
// profile, exclusive vs. alongside), there's nothing running yet to be
|
|
// "alongside" of, so this lets several profiles be selected to start
|
|
// together in one go.
|
|
private func showStartupPicker(_ profiles: [ProfileInfo]) {
|
|
guard !profiles.isEmpty else { return }
|
|
let alert = NSAlert()
|
|
alert.messageText = "Start Claude"
|
|
alert.informativeText = "Nothing is running. Choose which profiles to start:"
|
|
alert.addButton(withTitle: "Start")
|
|
alert.addButton(withTitle: "Cancel")
|
|
|
|
let rowHeight: CGFloat = 22
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: rowHeight * CGFloat(profiles.count)))
|
|
var checkboxes: [(box: NSButton, name: String)] = []
|
|
for (i, info) in profiles.enumerated() {
|
|
let y = rowHeight * CGFloat(profiles.count - 1 - i)
|
|
let box = NSButton(checkboxWithTitle: info.profile.name, target: nil, action: nil)
|
|
box.frame = NSRect(x: 0, y: y, width: 240, height: rowHeight)
|
|
box.state = info.profile.name == liveState.lastActiveName ? .on : .off
|
|
container.addSubview(box)
|
|
checkboxes.append((box, info.profile.name))
|
|
}
|
|
alert.accessoryView = container
|
|
|
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
|
|
for (box, name) in checkboxes where box.state == .on {
|
|
guard let profile = profiles.first(where: { $0.profile.name == name })?.profile else { continue }
|
|
liveState.markActive(name)
|
|
ClaudeControl.launch(profile)
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() }
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
@objc func pick(_ sender: NSMenuItem) {
|
|
guard let name = sender.representedObject as? String,
|
|
let profile = currentInfos.first(where: { $0.profile.name == name })?.profile
|
|
else { return }
|
|
liveState.markActive(name)
|
|
if NSApp.currentEvent?.modifierFlags.contains(.shift) == true {
|
|
openProfile(profile)
|
|
} else {
|
|
switchTo(profile)
|
|
}
|
|
}
|
|
|
|
private func openProfile(_ profile: ResolvedProfile) {
|
|
if let pid = ClaudeControl.pid(for: profile) {
|
|
ClaudeControl.focus(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } }
|
|
} else {
|
|
ClaudeControl.launch(profile)
|
|
// The launch notification usually beats this, but a short
|
|
// explicit poke keeps the UI snappy even if it doesn't.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() }
|
|
}
|
|
}
|
|
|
|
private func switchTo(_ profile: ResolvedProfile) {
|
|
let others = currentInfos.filter { $0.running && $0.profile.name != profile.name }
|
|
guard !others.isEmpty else { openProfile(profile); return }
|
|
var remaining = others.count
|
|
for info in others {
|
|
guard let pid = info.pid else { remaining -= 1; continue }
|
|
ClaudeControl.quit(pid: pid) { [weak self] _ in
|
|
remaining -= 1
|
|
if remaining == 0 {
|
|
DispatchQueue.main.async { self?.openProfile(profile) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func closeProfile(_ sender: NSMenuItem) {
|
|
guard let name = sender.representedObject as? String,
|
|
let info = currentInfos.first(where: { $0.profile.name == name }), let pid = info.pid
|
|
else { return }
|
|
ClaudeControl.quit(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } }
|
|
}
|
|
|
|
@objc func openManageWindow() {
|
|
ManageWindowController.shared.update(currentInfos)
|
|
ManageWindowController.shared.show()
|
|
}
|
|
|
|
// About lives as a tab in the same window (see ManageWindow.swift)
|
|
// rather than the standard NSApp About panel — the standard panel
|
|
// auto-appends its own "Version X (Y)" line from Info.plist's
|
|
// CFBundleVersion/CFBundleShortVersionString, which are identical in
|
|
// this build, so it was showing that duplicated alongside our own
|
|
// version line.
|
|
@objc func showAbout() {
|
|
ManageWindowController.shared.update(currentInfos)
|
|
ManageWindowController.shared.show(tab: .about)
|
|
}
|
|
|
|
@objc func quitClaude() {
|
|
ClaudeControl.quitAll { DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } }
|
|
}
|
|
|
|
@objc func quitSelf() {
|
|
NSApp.terminate(nil)
|
|
}
|
|
}
|