- 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>
435 lines
18 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|