|
|
|
@@ -1,14 +1,21 @@
|
|
|
|
|
// 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.
|
|
|
|
|
// 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
|
|
|
|
@@ -19,18 +26,19 @@ final class ManageWindowController: NSWindowController {
|
|
|
|
|
|
|
|
|
|
private init() {
|
|
|
|
|
let window = NSWindow(
|
|
|
|
|
contentRect: NSRect(x: 0, y: 0, width: 460, height: 360),
|
|
|
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
let tabs = NSTabViewController()
|
|
|
|
|
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)
|
|
|
|
@@ -38,8 +46,11 @@ final class ManageWindowController: NSWindowController {
|
|
|
|
|
|
|
|
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
|
|
|
|
|
|
func show() {
|
|
|
|
|
func show(tab: Tab? = nil) {
|
|
|
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
if let tab {
|
|
|
|
|
tabs.selectedTabViewItemIndex = tab.rawValue
|
|
|
|
|
}
|
|
|
|
|
window?.makeKeyAndOrderFront(nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -57,108 +68,262 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
|
|
|
|
|
|
|
|
|
|
private var infos: [ProfileInfo] = []
|
|
|
|
|
private let tableView = NSTableView()
|
|
|
|
|
private let removeButton = NSButton(title: "\u{2212}", target: nil, action: nil)
|
|
|
|
|
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: 460, height: 360))
|
|
|
|
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 260))
|
|
|
|
|
|
|
|
|
|
let column = NSTableColumn(identifier: .init("profile"))
|
|
|
|
|
column.title = "Profile"
|
|
|
|
|
column.width = 380
|
|
|
|
|
column.width = 340
|
|
|
|
|
tableView.addTableColumn(column)
|
|
|
|
|
tableView.headerView = nil
|
|
|
|
|
tableView.dataSource = self
|
|
|
|
|
tableView.delegate = self
|
|
|
|
|
tableView.rowHeight = 22
|
|
|
|
|
tableView.rowHeight = 24
|
|
|
|
|
tableView.usesAlternatingRowBackgroundColors = true
|
|
|
|
|
tableView.menu = buildContextMenu()
|
|
|
|
|
|
|
|
|
|
let scroll = NSScrollView()
|
|
|
|
|
scroll.documentView = tableView
|
|
|
|
|
scroll.hasVerticalScroller = true
|
|
|
|
|
scroll.borderType = .bezelBorder
|
|
|
|
|
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()])
|
|
|
|
|
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.spacing = 4
|
|
|
|
|
buttonRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
|
|
buildDetailStack()
|
|
|
|
|
detailStack.isHidden = true
|
|
|
|
|
detailStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
|
|
buildAddForm()
|
|
|
|
|
addForm.isHidden = true
|
|
|
|
|
addForm.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
|
|
let stack = NSStackView(views: [scroll, buttonRow, addForm])
|
|
|
|
|
let stack = NSStackView(views: [scroll, buttonRow, detailStack, addForm])
|
|
|
|
|
stack.orientation = .vertical
|
|
|
|
|
stack.spacing = 8
|
|
|
|
|
stack.edgeInsets = NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
|
|
|
|
|
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.heightAnchor.constraint(greaterThanOrEqualToConstant: 160),
|
|
|
|
|
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 = 6
|
|
|
|
|
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 = 6
|
|
|
|
|
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 buttonRow = NSStackView(views: [NSView(), cancelButton, createButton])
|
|
|
|
|
buttonRow.orientation = .horizontal
|
|
|
|
|
buttonRow.spacing = 8
|
|
|
|
|
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 = 6
|
|
|
|
|
[nameField, codeRow, appRow, errorLabel, buttonRow].forEach(addForm.addArrangedSubview)
|
|
|
|
|
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
|
|
|
|
|
buttonRow.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()
|
|
|
|
|
removeButton.isEnabled = tableView.selectedRow >= 0
|
|
|
|
|
&& infos.indices.contains(tableView.selectedRow)
|
|
|
|
|
&& infos[tableView.selectedRow].profile.name != "default"
|
|
|
|
|
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
|
|
|
|
@@ -183,24 +348,31 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
|
|
|
|
|
cell.imageView = imageView
|
|
|
|
|
cell.textField = textField
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
|
|
|
imageView.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4),
|
|
|
|
|
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: 6),
|
|
|
|
|
textField.trailingAnchor.constraint(lessThanOrEqualTo: cell.trailingAnchor, constant: -4),
|
|
|
|
|
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)
|
|
|
|
|
cell.textField?.stringValue = "\(info.profile.name) — \(info.running ? "running" : "stopped")"
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
removeButton.isEnabled = tableView.selectedRow >= 0
|
|
|
|
|
&& infos.indices.contains(tableView.selectedRow)
|
|
|
|
|
&& infos[tableView.selectedRow].profile.name != "default"
|
|
|
|
|
refreshForSelection()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: NSTextFieldDelegate — live default-path preview as the name is typed
|
|
|
|
@@ -214,6 +386,14 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
|
|
|
|
|
|
|
|
|
|
// 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 {
|
|
|
|
@@ -223,6 +403,7 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
|
|
|
|
|
errorLabel.stringValue = ""
|
|
|
|
|
view.window?.makeFirstResponder(nameField)
|
|
|
|
|
}
|
|
|
|
|
updatePreferredSize()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@objc private func chooseCodeDir() { choose(into: codeField) }
|
|
|
|
@@ -251,6 +432,7 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
|
|
|
|
|
onChange?()
|
|
|
|
|
} catch {
|
|
|
|
|
errorLabel.stringValue = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
|
|
|
|
updatePreferredSize()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -320,9 +502,10 @@ private final class SettingsViewController: NSViewController {
|
|
|
|
|
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 var stack: NSStackView!
|
|
|
|
|
|
|
|
|
|
override func loadView() {
|
|
|
|
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 460, height: 360))
|
|
|
|
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
|
|
|
|
|
|
|
|
|
|
launchAtLoginCheckbox.target = self
|
|
|
|
|
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
|
|
|
|
@@ -334,17 +517,43 @@ private final class SettingsViewController: NSViewController {
|
|
|
|
|
|
|
|
|
|
checkNowButton.target = self
|
|
|
|
|
checkNowButton.action = #selector(checkNow)
|
|
|
|
|
checkNowButton.bezelStyle = .rounded
|
|
|
|
|
|
|
|
|
|
let stack = NSStackView(views: [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton])
|
|
|
|
|
stack = NSStackView(views: [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton])
|
|
|
|
|
stack.orientation = .vertical
|
|
|
|
|
stack.alignment = .leading
|
|
|
|
|
stack.spacing = 12
|
|
|
|
|
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, constant: 20),
|
|
|
|
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
|
|
|
|
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() {
|
|
|
|
|
let widest = [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]
|
|
|
|
|
.map(\.intrinsicContentSize.width)
|
|
|
|
|
.max() ?? 200
|
|
|
|
|
preferredContentSize = NSSize(width: widest + 40, height: stack.fittingSize.height)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@objc private func toggleLaunchAtLogin() {
|
|
|
|
@@ -388,3 +597,66 @@ private final class SettingsViewController: NSViewController {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - About tab
|
|
|
|
|
|
|
|
|
|
private let projectAuthor = "bdeshi"
|
|
|
|
|
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),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
let description = NSTextField(wrappingLabelWithString:
|
|
|
|
|
"Runs multiple Claude Desktop profiles side by side, each paired with its own isolated Claude Code config.")
|
|
|
|
|
description.font = .systemFont(ofSize: NSFont.systemFontSize)
|
|
|
|
|
|
|
|
|
|
let authorField = NSTextField(labelWithString: "Author: \(projectAuthor)")
|
|
|
|
|
let sourceField = NSTextField(labelWithString: "Source: \(projectHomepage)")
|
|
|
|
|
let versionField = NSTextField(labelWithString: "Version: \(UpdateChecker.displayVersion)")
|
|
|
|
|
for field in [authorField, sourceField, versionField] {
|
|
|
|
|
field.textColor = .secondaryLabelColor
|
|
|
|
|
field.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let stack = NSStackView(views: [iconContainer, description, authorField, 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),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
view.layoutSubtreeIfNeeded()
|
|
|
|
|
preferredContentSize = NSSize(width: Self.contentWidth + 40, height: stack.fittingSize.height)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|