Files
shannoncoat/Sources/ManageWindow.swift
T
Claude Opus 5andbdeshi ffbd1536b6 Let the menu's click and shift-click actions be swapped
Clicking a profile switched to it exclusively and shift-click opened it
alongside, with no way round for anyone who opens alongside far more often
than they switch. Settings now carries the choice, storing only the plain
click's action — the other gesture gets whatever is left, so the two can't
both end up meaning the same thing, and no migration is needed for anyone
who never touches it.

Switching also quits the other profiles again when the profile clicked is
already up, which e7777d5 had carved out on the reasoning that a click
reaching for a second open window is someone moving between windows rather
than asking for one to go. That guess is what this setting now settles:
with each action bound to a gesture of its own, choosing the switch gesture
is the instruction, and second-guessing it left no way to ask for an
exclusive switch at all once the target was running.

The menu spells out both gestures rather than only the modified one, since
which of them quits things is no longer fixed. Each profile's tooltip
describes what a click will actually do to it as things stand — it promises
to quit the neighbours only when there are neighbours to quit.
2026-08-14 23:14:49 +06:00

755 lines
33 KiB
Swift

// One ordinary, non-modal window for everything profile-management related
// — replaces what would otherwise be a string of separate popup alerts.
// Opened from the menu bar's "Manage Profiles…" item or the standard
// Cmd+, shortcut. Fixed-size (not user-resizable) — each tab reports its
// own natural size via preferredContentSize, and NSTabViewController
// resizes the window to match automatically, so it's never left showing
// empty space sized for a different tab or state.
import AppKit
final class ManageWindowController: NSWindowController {
static let shared = ManageWindowController()
enum Tab: Int { case profiles = 0, settings = 1, about = 2 }
private let profilesVC = ProfilesViewController()
private let settingsVC = SettingsViewController()
private let aboutVC = AboutViewController()
private let tabs = NSTabViewController()
// Called after a profile is added/removed so the caller can trigger an
// immediate LiveState reconcile instead of waiting on the next
// notification.
var onProfilesChanged: (() -> Void)? {
didSet { profilesVC.onChange = onProfilesChanged }
}
// Called when the window-tag style changes, so open tags can be
// rebuilt in the new style immediately rather than at the next
// launch/quit of a Claude window.
var onOverlayStyleChanged: (() -> Void)? {
didSet { settingsVC.onOverlayStyleChanged = onOverlayStyleChanged }
}
private init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered, defer: false)
window.title = "shannoncoat"
window.isReleasedWhenClosed = false
window.center()
profilesVC.title = "Profiles"
settingsVC.title = "Settings"
aboutVC.title = "About"
tabs.addTabViewItem(NSTabViewItem(viewController: profilesVC))
tabs.addTabViewItem(NSTabViewItem(viewController: settingsVC))
tabs.addTabViewItem(NSTabViewItem(viewController: aboutVC))
window.contentViewController = tabs
super.init(window: window)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func show(tab: Tab? = nil) {
NSApp.activate(ignoringOtherApps: true)
if let tab {
tabs.selectedTabViewItemIndex = tab.rawValue
}
window?.makeKeyAndOrderFront(nil)
}
func update(_ infos: [ProfileInfo]) {
profilesVC.update(infos)
}
}
// MARK: - Profiles tab
private final class ProfilesViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate,
NSTextFieldDelegate
{
var onChange: (() -> Void)?
private var infos: [ProfileInfo] = []
private let tableView = NSTableView()
private let addRemoveControl = NSSegmentedControl()
private let detailStack = NSStackView()
private let detailAppValue = NSTextField(labelWithString: "")
private let detailCodeValue = NSTextField(labelWithString: "")
private let detailAppCopyItem = NSMenuItem(title: "Copy Path", action: #selector(copyDetailPath(_:)), keyEquivalent: "")
private let detailCodeCopyItem = NSMenuItem(title: "Copy Path", action: #selector(copyDetailPath(_:)), keyEquivalent: "")
private let addForm = NSStackView()
private let nameField = NSTextField()
private let codeField = NSTextField()
private let appField = NSTextField()
private let errorLabel = NSTextField(labelWithString: "")
private var outerStack: NSStackView!
private var scrollHeightConstraint: NSLayoutConstraint!
private static let margin: CGFloat = 20
private static let tableWidth: CGFloat = 340
private static let minVisibleRows = 3
private static let maxVisibleRows = 8
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 260))
let column = NSTableColumn(identifier: .init("profile"))
column.title = "Profile"
column.width = 340
tableView.addTableColumn(column)
tableView.headerView = nil
tableView.dataSource = self
tableView.delegate = self
tableView.rowHeight = 24
tableView.usesAlternatingRowBackgroundColors = true
tableView.menu = buildContextMenu()
let scroll = NSScrollView()
scroll.documentView = tableView
scroll.hasVerticalScroller = true
scroll.borderType = .bezelBorder
scroll.translatesAutoresizingMaskIntoConstraints = false
addRemoveControl.segmentCount = 2
addRemoveControl.trackingMode = .momentary
addRemoveControl.segmentStyle = .smallSquare
addRemoveControl.setImage(NSImage(systemSymbolName: "plus", accessibilityDescription: "Add Profile"), forSegment: 0)
addRemoveControl.setImage(
NSImage(systemSymbolName: "minus", accessibilityDescription: "Remove Profile"), forSegment: 1)
addRemoveControl.setWidth(28, forSegment: 0)
addRemoveControl.setWidth(28, forSegment: 1)
addRemoveControl.setEnabled(false, forSegment: 1)
addRemoveControl.setToolTip("Add Profile", forSegment: 0)
addRemoveControl.setToolTip("Remove Profile", forSegment: 1)
addRemoveControl.target = self
addRemoveControl.action = #selector(addRemoveClicked(_:))
let buttonRow = NSStackView(views: [addRemoveControl, NSView()])
buttonRow.orientation = .horizontal
buttonRow.translatesAutoresizingMaskIntoConstraints = false
buildDetailStack()
detailStack.isHidden = true
detailStack.translatesAutoresizingMaskIntoConstraints = false
buildAddForm()
addForm.isHidden = true
addForm.translatesAutoresizingMaskIntoConstraints = false
let stack = NSStackView(views: [scroll, buttonRow, detailStack, addForm])
stack.orientation = .vertical
stack.spacing = 12
stack.edgeInsets = NSEdgeInsets(top: Self.margin, left: Self.margin, bottom: Self.margin, right: Self.margin)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
outerStack = stack
scrollHeightConstraint = scroll.heightAnchor.constraint(equalToConstant: heightForRows(0))
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
scroll.widthAnchor.constraint(equalToConstant: Self.tableWidth),
scrollHeightConstraint,
// `scroll` is the one arranged subview with a real, absolute
// width — everything else in this stack ties its width to
// *that*, not to `stack` itself (whose own frame spans
// edge-to-edge; edgeInsets only govern how the stack lays out
// its children, not its own bounds). Left unconstrained, these
// rows have no hard width anywhere in their constraint chain —
// NSStackView doesn't fill them to the inset content width by
// default, so they end up sized/positioned ambiguously (in
// practice: flush against the window edge, sometimes wider
// than the window).
buttonRow.widthAnchor.constraint(equalTo: scroll.widthAnchor),
detailStack.widthAnchor.constraint(equalTo: scroll.widthAnchor),
addForm.widthAnchor.constraint(equalTo: scroll.widthAnchor),
])
updatePreferredSize()
}
// Sized to the actual row count (clamped to a sensible range) rather
// than one fixed height — with only a couple of profiles that avoids a
// big block of empty striped background below the last row; with many
// it caps out and scrolls instead of taking over the window.
private func heightForRows(_ count: Int) -> CGFloat {
let rows = max(Self.minVisibleRows, min(count, Self.maxVisibleRows))
return CGFloat(rows) * tableView.rowHeight + 2
}
// NSTabViewController watches its selected child's preferredContentSize
// and resizes the window to match live — so recomputing this after
// toggling the add form or the path-details row is what makes the
// window grow/shrink to fit instead of sitting at one fixed size with
// empty space in the states that don't need it.
private func updatePreferredSize() {
view.layoutSubtreeIfNeeded()
preferredContentSize = outerStack.fittingSize
}
// A second, authoritative recompute after every real layout pass —
// explicit calls from action handlers (below) update it eagerly for
// responsiveness, but this is the one that can't be thrown off by
// metrics not being fully resolved yet at the point an eager call runs.
override func viewDidLayout() {
super.viewDidLayout()
preferredContentSize = outerStack.fittingSize
}
// Shows the selected profile's two dirs — the thing you'd otherwise
// have to right-click "Reveal" to find out. Not `.isSelectable` — that
// makes a label click-into-edit, and the field editor that appears
// draws the *untruncated* string at its full intrinsic width, which
// can extend past the window and get clipped by it rather than
// wrapping or scrolling cleanly. A tooltip (full path) plus a
// right-click "Copy Path" menu covers the same "get the value out"
// need without that.
private func buildDetailStack() {
detailAppCopyItem.target = self
detailCodeCopyItem.target = self
let appMenu = NSMenu()
appMenu.addItem(detailAppCopyItem)
let codeMenu = NSMenu()
codeMenu.addItem(detailCodeCopyItem)
for (value, menu) in [(detailAppValue, appMenu), (detailCodeValue, codeMenu)] {
value.textColor = .secondaryLabelColor
value.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
value.lineBreakMode = .byTruncatingMiddle
value.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
value.menu = menu
}
let appRow = NSStackView(views: [labeled("Claude Desktop:"), detailAppValue])
appRow.orientation = .horizontal
appRow.spacing = 8
let codeRow = NSStackView(views: [labeled("Claude Code:"), detailCodeValue])
codeRow.orientation = .horizontal
codeRow.spacing = 8
detailStack.orientation = .vertical
detailStack.alignment = .leading
detailStack.spacing = 4
[appRow, codeRow].forEach(detailStack.addArrangedSubview)
appRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
codeRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
}
private func buildAddForm() {
nameField.placeholderString = "Profile name"
nameField.delegate = self
for field in [codeField, appField] {
field.lineBreakMode = .byTruncatingMiddle
field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
codeField.placeholderString = "~/.shannoncoat/data/<name>/code"
let codeChoose = NSButton(title: "Choose\u{2026}", target: self, action: #selector(chooseCodeDir))
let codeRow = NSStackView(views: [labeled("Claude Code:"), codeField, codeChoose])
codeRow.orientation = .horizontal
codeRow.spacing = 8
appField.placeholderString = "~/.shannoncoat/data/<name>/app"
let appChoose = NSButton(title: "Choose\u{2026}", target: self, action: #selector(chooseAppDir))
let appRow = NSStackView(views: [labeled("Claude Desktop:"), appField, appChoose])
appRow.orientation = .horizontal
appRow.spacing = 8
errorLabel.textColor = .systemRed
errorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
errorLabel.maximumNumberOfLines = 0
errorLabel.lineBreakMode = .byWordWrapping
let createButton = NSButton(title: "Create", target: self, action: #selector(createProfile))
createButton.keyEquivalent = "\r"
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(toggleAddForm))
let formButtonRow = NSStackView(views: [NSView(), cancelButton, createButton])
formButtonRow.orientation = .horizontal
formButtonRow.spacing = 8
let divider = NSBox()
divider.boxType = .separator
addForm.orientation = .vertical
addForm.alignment = .leading
addForm.spacing = 10
[divider, nameField, codeRow, appRow, errorLabel, formButtonRow].forEach(addForm.addArrangedSubview)
divider.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
nameField.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
codeRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
appRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
formButtonRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
}
// Fixed-width, trailing-aligned label so the fields in a two-row form
// (differing label lengths: "Claude Code:" vs "Claude Desktop:") start
// at the same x position instead of a ragged left edge.
private func labeled(_ text: String) -> NSTextField {
let field = NSTextField(labelWithString: text)
field.font = .systemFont(ofSize: NSFont.systemFontSize)
field.textColor = .secondaryLabelColor
field.setContentHuggingPriority(.required, for: .horizontal)
field.alignment = .right
field.widthAnchor.constraint(equalToConstant: 104).isActive = true
return field
}
func update(_ infos: [ProfileInfo]) {
self.infos = infos
tableView.reloadData()
scrollHeightConstraint.constant = heightForRows(infos.count)
refreshForSelection()
}
private func refreshForSelection() {
let row = tableView.selectedRow
guard infos.indices.contains(row) else {
addRemoveControl.setEnabled(false, forSegment: 1)
detailStack.isHidden = true
updatePreferredSize()
return
}
let profile = infos[row].profile
addRemoveControl.setEnabled(profile.name != "default", forSegment: 1)
detailAppValue.stringValue = profile.appDir
detailAppValue.toolTip = profile.appDir
detailAppCopyItem.representedObject = profile.appDir
detailCodeValue.stringValue = profile.codeDir
detailCodeValue.toolTip = profile.codeDir
detailCodeCopyItem.representedObject = profile.codeDir
detailStack.isHidden = false
updatePreferredSize()
}
@objc private func copyDetailPath(_ sender: NSMenuItem) {
guard let path = sender.representedObject as? String else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(path, forType: .string)
}
// MARK: NSTableViewDataSource / Delegate
func numberOfRows(in tableView: NSTableView) -> Int { infos.count }
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let identifier = NSUserInterfaceItemIdentifier("profileCell")
let info = infos[row]
let cell: NSTableCellView
if let reused = tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView {
cell = reused
} else {
cell = NSTableCellView()
cell.identifier = identifier
let imageView = NSImageView()
let textField = NSTextField(labelWithString: "")
imageView.translatesAutoresizingMaskIntoConstraints = false
textField.translatesAutoresizingMaskIntoConstraints = false
cell.addSubview(imageView)
cell.addSubview(textField)
cell.imageView = imageView
cell.textField = textField
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 6),
imageView.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
imageView.widthAnchor.constraint(equalToConstant: 10),
imageView.heightAnchor.constraint(equalToConstant: 10),
textField.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 8),
textField.trailingAnchor.constraint(lessThanOrEqualTo: cell.trailingAnchor, constant: -6),
textField.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
])
}
cell.imageView?.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running)
let text = NSMutableAttributedString(
string: info.profile.name, attributes: [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)])
text.append(NSAttributedString(
string: " \(info.running ? "Running" : "Stopped")",
attributes: [
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
.foregroundColor: NSColor.secondaryLabelColor,
]))
cell.textField?.attributedStringValue = text
return cell
}
func tableViewSelectionDidChange(_ notification: Notification) {
refreshForSelection()
}
// MARK: NSTextFieldDelegate — live default-path preview as the name is typed
func controlTextDidChange(_ obj: Notification) {
guard (obj.object as? NSTextField) === nameField else { return }
let name = nameField.stringValue.isEmpty ? "<name>" : nameField.stringValue
codeField.placeholderString = "~/.shannoncoat/data/\(name)/code"
appField.placeholderString = "~/.shannoncoat/data/\(name)/app"
}
// MARK: Actions
@objc private func addRemoveClicked(_ sender: NSSegmentedControl) {
switch sender.selectedSegment {
case 0: toggleAddForm()
case 1: removeSelected()
default: break
}
}
@objc private func toggleAddForm() {
addForm.isHidden.toggle()
if !addForm.isHidden {
nameField.stringValue = ""
codeField.stringValue = ""
appField.stringValue = ""
errorLabel.stringValue = ""
view.window?.makeFirstResponder(nameField)
}
updatePreferredSize()
}
@objc private func chooseCodeDir() { choose(into: codeField) }
@objc private func chooseAppDir() { choose(into: appField) }
private func choose(into field: NSTextField) {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
guard let window = view.window else { return }
panel.beginSheetModal(for: window) { response in
if response == .OK, let url = panel.url {
field.stringValue = url.path
}
}
}
@objc private func createProfile() {
let name = nameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
let code = codeField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
let app = appField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
do {
try ProfileStore.create(name: name, codeDir: code.isEmpty ? nil : code, appDir: app.isEmpty ? nil : app)
toggleAddForm()
onChange?()
} catch {
errorLabel.stringValue = (error as? LocalizedError)?.errorDescription ?? "\(error)"
updatePreferredSize()
}
}
@objc private func removeSelected() {
let row = tableView.selectedRow
guard infos.indices.contains(row) else { return }
let info = infos[row]
guard info.profile.name != "default", let window = view.window else { return }
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Delete profile \u{201C}\(info.profile.name)\u{201D}?"
alert.informativeText = info.running
? "This profile is currently running \u{2014} deleting it will quit Claude first. Its Claude Desktop and Claude Code data stay on disk; only the profile pointer is removed."
: "Its Claude Desktop and Claude Code data stay on disk; only the profile pointer is removed."
alert.addButton(withTitle: "Delete")
alert.addButton(withTitle: "Cancel")
alert.buttons.first?.hasDestructiveAction = true
alert.beginSheetModal(for: window) { [weak self] response in
guard response == .alertFirstButtonReturn else { return }
let finish = {
try? ProfileStore.delete(info.profile.name)
self?.onChange?()
}
if info.running, let pid = info.pid {
ClaudeControl.quit(pid: pid) { _ in DispatchQueue.main.async(execute: finish) }
} else {
finish()
}
}
}
private func buildContextMenu() -> NSMenu {
let menu = NSMenu()
let revealCode = NSMenuItem(title: "Reveal Code Folder", action: #selector(revealCodeDir), keyEquivalent: "")
revealCode.target = self
let revealApp = NSMenuItem(
title: "Reveal Desktop Folder", action: #selector(revealAppDir), keyEquivalent: "")
revealApp.target = self
menu.addItem(revealCode)
menu.addItem(revealApp)
return menu
}
private func clickedInfo() -> ProfileInfo? {
let row = tableView.clickedRow
guard infos.indices.contains(row) else { return nil }
return infos[row]
}
@objc private func revealCodeDir() {
guard let info = clickedInfo() else { return }
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: info.profile.codeDir)
}
@objc private func revealAppDir() {
guard let info = clickedInfo() else { return }
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: info.profile.appDir)
}
}
// MARK: - Settings tab
private final class SettingsViewController: NSViewController {
private let autoUpdateCheckbox = NSButton(
checkboxWithTitle: "Automatically check for updates", target: nil, action: nil)
private let launchAtLoginCheckbox = NSButton(checkboxWithTitle: "Launch at Login", target: nil, action: nil)
private let checkNowButton = NSButton(title: "Check for Updates\u{2026}", target: nil, action: nil)
private let tagStyleLabel = NSTextField(labelWithString: "Window tag:")
private let tagStylePopUp = NSPopUpButton(frame: .zero, pullsDown: false)
private var tagStyleRow: NSStackView!
private let clickActionLabel = NSTextField(labelWithString: "Click a profile to:")
private let clickActionPopUp = NSPopUpButton(frame: .zero, pullsDown: false)
private var clickActionRow: NSStackView!
private var stack: NSStackView!
var onOverlayStyleChanged: (() -> Void)?
// Order matches the popup's items, so the selected index maps straight
// onto a style without a title-string comparison.
private static let tagStyles: [(title: String, style: WindowOverlayStyle)] = [
("Name chip", .chip),
("Colored dot", .dot),
]
// Same index-to-value arrangement. Each title spells out the Shift-click
// half too: the two actions always swap together, so showing only the
// plain click's half would leave the other looking unset.
private static let clickActions: [(title: String, action: MenuClickAction)] = [
("Switch to it (\u{21e7}: open alongside)", .switchTo),
("Open it alongside (\u{21e7}: switch to it)", .openAlongside),
]
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
tagStylePopUp.addItems(withTitles: Self.tagStyles.map(\.title))
tagStylePopUp.selectItem(at: Self.tagStyles.firstIndex { $0.style == WindowOverlayPosition.style } ?? 0)
tagStylePopUp.target = self
tagStylePopUp.action = #selector(changeTagStyle)
tagStyleRow = NSStackView(views: [tagStyleLabel, tagStylePopUp])
tagStyleRow.orientation = .horizontal
tagStyleRow.spacing = 8
clickActionPopUp.addItems(withTitles: Self.clickActions.map(\.title))
clickActionPopUp.selectItem(
at: Self.clickActions.firstIndex { $0.action == MenuClickBehavior.plainClick } ?? 0)
clickActionPopUp.target = self
clickActionPopUp.action = #selector(changeClickAction)
clickActionRow = NSStackView(views: [clickActionLabel, clickActionPopUp])
clickActionRow.orientation = .horizontal
clickActionRow.spacing = 8
launchAtLoginCheckbox.target = self
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
autoUpdateCheckbox.target = self
autoUpdateCheckbox.action = #selector(toggleAutoUpdate)
autoUpdateCheckbox.state = UpdateChecker.automaticCheckEnabled ? .on : .off
checkNowButton.target = self
checkNowButton.action = #selector(checkNow)
checkNowButton.bezelStyle = .rounded
stack = NSStackView(views: [
launchAtLoginCheckbox, clickActionRow, tagStyleRow, autoUpdateCheckbox, checkNowButton,
])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 14
stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
updatePreferredSize()
}
override func viewDidLayout() {
super.viewDidLayout()
updatePreferredSize()
}
// NOT `stack.fittingSize` for the width — that was measured observing
// it clip a checkbox's label by a consistent amount regardless of
// when it's read (loadView vs. viewDidLayout: same result), so this
// isn't a measure-too-early timing issue, it's `NSStackView.fittingSize`
// itself undercounting a checkbox's true rendered width. Asking each
// control directly for its own `intrinsicContentSize` instead — which
// is what AutoLayout itself uses to size an unconstrained control —
// sidesteps whatever the stack's own aggregation is getting wrong.
private func updatePreferredSize() {
// Same reasoning for the label+popup rows: summed from their two
// controls plus the row spacing rather than trusting the row's
// own fittingSize.
let rowWidths = [
tagStyleLabel.intrinsicContentSize.width
+ tagStylePopUp.intrinsicContentSize.width + tagStyleRow.spacing,
clickActionLabel.intrinsicContentSize.width
+ clickActionPopUp.intrinsicContentSize.width + clickActionRow.spacing,
]
let widest = ([launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]
.map(\.intrinsicContentSize.width) + rowWidths)
.max() ?? 0
preferredContentSize = NSSize(width: widest + 40, height: stack.fittingSize.height)
}
@objc private func changeTagStyle() {
let index = tagStylePopUp.indexOfSelectedItem
guard Self.tagStyles.indices.contains(index) else { return }
WindowOverlayPosition.style = Self.tagStyles[index].style
onOverlayStyleChanged?()
}
// Nothing to notify: the menu bar rebuilds its menu on every open and
// reads the setting then.
@objc private func changeClickAction() {
let index = clickActionPopUp.indexOfSelectedItem
guard Self.clickActions.indices.contains(index) else { return }
MenuClickBehavior.plainClick = Self.clickActions[index].action
}
@objc private func toggleLaunchAtLogin() {
do {
if launchAtLoginCheckbox.state == .on {
try LaunchAtLogin.enable()
} else {
try LaunchAtLogin.disable()
}
} catch {
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
NSAlert(error: error).runModal()
}
}
@objc private func toggleAutoUpdate() {
UpdateChecker.automaticCheckEnabled = autoUpdateCheckbox.state == .on
}
@objc private func checkNow() {
UpdateChecker.checkManually { result in
DispatchQueue.main.async {
let alert = NSAlert()
switch result {
case .upToDate:
alert.messageText = "You're up to date."
alert.runModal()
case .updateAvailable(let version, let url):
alert.messageText = "shannoncoat \(version) is available"
alert.addButton(withTitle: "View Release")
alert.addButton(withTitle: "Later")
if alert.runModal() == .alertFirstButtonReturn {
NSWorkspace.shared.open(url)
}
case .failed:
alert.alertStyle = .warning
alert.messageText = "Couldn't check for updates."
alert.runModal()
}
}
}
}
}
// MARK: - About tab
private let projectHomepage = "https://github.com/bdeshi/shannoncoat"
private final class AboutViewController: NSViewController {
private static let contentWidth: CGFloat = 340
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
// Centered within a full-width wrapper rather than centered in the
// stack directly — the stack is leading-aligned (for the text
// rows below), and its own per-arranged-subview alignment
// constraint on the icon would conflict with an added centering
// constraint on the icon itself.
let icon = NSImageView()
icon.image = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName)
icon.translatesAutoresizingMaskIntoConstraints = false
let iconContainer = NSView()
iconContainer.translatesAutoresizingMaskIntoConstraints = false
iconContainer.addSubview(icon)
NSLayoutConstraint.activate([
icon.widthAnchor.constraint(equalToConstant: 96),
icon.heightAnchor.constraint(equalToConstant: 96),
icon.topAnchor.constraint(equalTo: iconContainer.topAnchor),
icon.bottomAnchor.constraint(equalTo: iconContainer.bottomAnchor),
icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
])
// Centred under the icon, same wrapper trick as the icon itself.
let tagline = NSTextField(labelWithString: "which coat will Claude wear today?")
tagline.font = .systemFont(ofSize: NSFont.systemFontSize)
tagline.textColor = .secondaryLabelColor
tagline.translatesAutoresizingMaskIntoConstraints = false
let taglineContainer = NSView()
taglineContainer.translatesAutoresizingMaskIntoConstraints = false
taglineContainer.addSubview(tagline)
NSLayoutConstraint.activate([
tagline.topAnchor.constraint(equalTo: taglineContainer.topAnchor),
tagline.bottomAnchor.constraint(equalTo: taglineContainer.bottomAnchor),
tagline.centerXAnchor.constraint(equalTo: taglineContainer.centerXAnchor),
])
// Names itself rather than opening with a bare verb — this panel is
// reachable without the window title in view.
let description = NSTextField(wrappingLabelWithString:
"shannoncoat runs multiple Claude Desktop profiles side by side, each paired with its own "
+ "isolated Claude Code config.")
description.font = .systemFont(ofSize: NSFont.systemFontSize)
let sourceField = NSTextField(labelWithString: "Source: \(projectHomepage)")
let versionField = NSTextField(labelWithString: "Version: \(UpdateChecker.displayVersion)")
for field in [sourceField, versionField] {
field.textColor = .secondaryLabelColor
field.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
}
let stack = NSStackView(views: [iconContainer, taglineContainer, description,
sourceField, versionField])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 10
stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
description.widthAnchor.constraint(equalToConstant: Self.contentWidth),
iconContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
taglineContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
])
view.layoutSubtreeIfNeeded()
preferredContentSize = NSSize(width: Self.contentWidth + 40, height: stack.fittingSize.height)
}
}