Files
shannoncoat/Sources/ManageWindow.swift
T
bdeshiandClaude Opus 5 68e74fcbbb Show profile paths, report the build version, and colour each profile
- Selecting a row in the Profiles tab shows its Claude Desktop and Claude
  Code dirs inline and selectable, instead of requiring a right-click
  "Reveal in Finder" to find out where a profile actually lives.
- build.sh stamps Contents/Resources/COMMIT with the short HEAD SHA, left
  empty when HEAD sits exactly on a tag (how a real release is built),
  since the version number alone is unambiguous there. About appends it
  when present, so two dev builds off the same VERSION are
  distinguishable. The update checker still compares plain semver.
- Profile dot colours are picked per NSApp.effectiveAppearance at render
  time: same hue either way, but full saturation with brightness split
  per background (1.0 dark, 0.55 light), so they read as bold hues rather
  than washing out on white or muddying against a dark menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:44:21 +06:00

435 lines
18 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.
import AppKit
final class ManageWindowController: NSWindowController {
static let shared = ManageWindowController()
private let profilesVC = ProfilesViewController()
private let settingsVC = SettingsViewController()
// 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 }
}
private init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 460, height: 360),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false)
window.title = "shannoncoat"
window.isReleasedWhenClosed = false
window.center()
let tabs = NSTabViewController()
profilesVC.title = "Profiles"
settingsVC.title = "Settings"
tabs.addTabViewItem(NSTabViewItem(viewController: profilesVC))
tabs.addTabViewItem(NSTabViewItem(viewController: settingsVC))
window.contentViewController = tabs
super.init(window: window)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func show() {
NSApp.activate(ignoringOtherApps: true)
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 removeButton = NSButton(title: "\u{2212}", target: nil, action: nil)
private let detailStack = NSStackView()
private let detailAppValue = NSTextField(labelWithString: "")
private let detailCodeValue = NSTextField(labelWithString: "")
private let addForm = NSStackView()
private let nameField = NSTextField()
private let codeField = NSTextField()
private let appField = NSTextField()
private let errorLabel = NSTextField(labelWithString: "")
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 460, height: 360))
let column = NSTableColumn(identifier: .init("profile"))
column.title = "Profile"
column.width = 380
tableView.addTableColumn(column)
tableView.headerView = nil
tableView.dataSource = self
tableView.delegate = self
tableView.rowHeight = 22
tableView.menu = buildContextMenu()
let scroll = NSScrollView()
scroll.documentView = tableView
scroll.hasVerticalScroller = true
scroll.translatesAutoresizingMaskIntoConstraints = false
let addButton = NSButton(title: "+", target: self, action: #selector(toggleAddForm))
removeButton.target = self
removeButton.action = #selector(removeSelected)
removeButton.isEnabled = false
let buttonRow = NSStackView(views: [addButton, removeButton, NSView()])
buttonRow.orientation = .horizontal
buttonRow.spacing = 4
buttonRow.translatesAutoresizingMaskIntoConstraints = false
buildDetailStack()
detailStack.isHidden = true
detailStack.translatesAutoresizingMaskIntoConstraints = false
buildAddForm()
addForm.isHidden = true
addForm.translatesAutoresizingMaskIntoConstraints = false
let stack = NSStackView(views: [scroll, detailStack, buttonRow, addForm])
stack.orientation = .vertical
stack.spacing = 8
stack.edgeInsets = NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
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.heightAnchor.constraint(greaterThanOrEqualToConstant: 160),
detailStack.widthAnchor.constraint(equalTo: stack.widthAnchor),
])
}
// Shows the selected profile's two dirs — the thing you'd otherwise
// have to right-click "Reveal" to find out.
private func buildDetailStack() {
for value in [detailAppValue, detailCodeValue] {
value.isSelectable = true
value.textColor = .secondaryLabelColor
value.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
value.lineBreakMode = .byTruncatingMiddle
value.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
let appRow = NSStackView(views: [labeled("Claude Desktop:"), detailAppValue])
appRow.orientation = .horizontal
appRow.spacing = 6
let codeRow = NSStackView(views: [labeled("Claude Code:"), detailCodeValue])
codeRow.orientation = .horizontal
codeRow.spacing = 6
detailStack.orientation = .vertical
detailStack.alignment = .leading
detailStack.spacing = 2
[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
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 = 6
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 = 6
errorLabel.textColor = .systemRed
errorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
let createButton = NSButton(title: "Create", target: self, action: #selector(createProfile))
createButton.keyEquivalent = "\r"
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(toggleAddForm))
let buttonRow = NSStackView(views: [NSView(), cancelButton, createButton])
buttonRow.orientation = .horizontal
buttonRow.spacing = 8
addForm.orientation = .vertical
addForm.alignment = .leading
addForm.spacing = 6
[nameField, codeRow, appRow, errorLabel, buttonRow].forEach(addForm.addArrangedSubview)
nameField.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
codeRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
appRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
buttonRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
}
private func labeled(_ text: String) -> NSTextField {
let field = NSTextField(labelWithString: text)
field.setContentHuggingPriority(.required, for: .horizontal)
return field
}
func update(_ infos: [ProfileInfo]) {
self.infos = infos
tableView.reloadData()
refreshForSelection()
}
private func refreshForSelection() {
let row = tableView.selectedRow
guard infos.indices.contains(row) else {
removeButton.isEnabled = false
detailStack.isHidden = true
return
}
let profile = infos[row].profile
removeButton.isEnabled = profile.name != "default"
detailAppValue.stringValue = profile.appDir
detailCodeValue.stringValue = profile.codeDir
detailStack.isHidden = false
}
// 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: 4),
imageView.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
imageView.widthAnchor.constraint(equalToConstant: 10),
imageView.heightAnchor.constraint(equalToConstant: 10),
textField.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 6),
textField.trailingAnchor.constraint(lessThanOrEqualTo: cell.trailingAnchor, constant: -4),
textField.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
])
}
cell.imageView?.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running)
cell.textField?.stringValue = "\(info.profile.name) — \(info.running ? "running" : "stopped")"
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 toggleAddForm() {
addForm.isHidden.toggle()
if !addForm.isHidden {
nameField.stringValue = ""
codeField.stringValue = ""
appField.stringValue = ""
errorLabel.stringValue = ""
view.window?.makeFirstResponder(nameField)
}
}
@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)"
}
}
@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)
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 460, height: 360))
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)
let stack = NSStackView(views: [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
])
}
@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()
}
}
}
}
}