// 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 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 buildAddForm() addForm.isHidden = true addForm.translatesAutoresizingMaskIntoConstraints = false let stack = NSStackView(views: [scroll, 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), ]) } private func buildAddForm() { nameField.placeholderString = "Profile name" nameField.delegate = self codeField.placeholderString = "~/.shannoncoat/data//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//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() removeButton.isEnabled = tableView.selectedRow >= 0 && infos.indices.contains(tableView.selectedRow) && infos[tableView.selectedRow].profile.name != "default" } // 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) { removeButton.isEnabled = tableView.selectedRow >= 0 && infos.indices.contains(tableView.selectedRow) && infos[tableView.selectedRow].profile.name != "default" } // 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 ? "" : 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() } } } } }