1 Commits
Author SHA1 Message Date
Claude Opus 5andbdeshi 9673dc2792 Redesign the Manage window and fold About into it as a tab
The previous layout was a fixed 460x360 window regardless of tab or
state, with a plain unbordered table and text +/- buttons — it read as a
generic cross-platform port rather than something native. Several rows
also had no real width anchor at all, so they floated: flush against the
window's left edge with no margin, and in the add form's case wide enough
to overflow past the right edge.

- The window is no longer manually resizable. Each tab reports its own
  preferredContentSize, recomputed in viewDidLayout and after every state
  change, and NSTabViewController resizes the window to match. That is
  what actually fixes the large empty areas: a tab with less content gets
  a smaller window rather than sitting inside a fixed larger one.
- Every row now anchors its width to the one element with a real absolute
  width, fixing both the edge-touching and the overflow.
- Table gains a bezel border and alternating rows; form labels get a
  fixed trailing-aligned width so fields line up; the error label wraps
  instead of truncating; tab padding is consistent.
- About moves out of a separate panel into a third tab, with the app icon
  centred and enlarged to 96px.

Verified visually with a standalone harness that drives the real window
and screenshots it, rather than simulating input — scripting clicks into
the app itself needs an Accessibility grant this environment lacks.
2026-08-04 23:54:56 +06:00
5 changed files with 301 additions and 69 deletions
+6
View File
@@ -28,6 +28,12 @@ jobs:
- name: Build shannoncoat.app
run: ./build.sh
env:
# This workflow only ever runs against a tag (see the trigger
# comment above) — told to build.sh directly rather than having
# it re-derive that via `git describe`, which needs full tag
# refs a CI runner's checkout isn't guaranteed to have fetched.
SHANNONCOAT_RELEASE_BUILD: "1"
- name: Zip app bundle
run: ditto -c -k --sequesterRsrc --keepParent ".build/shannoncoat.app" "shannoncoat.app.zip"
+8 -19
View File
@@ -3,9 +3,6 @@
// ProfileStore/ClaudeControl in-process no subprocess, no text parsing.
import AppKit
private let author = "bdeshi"
private let homepage = "https://github.com/bdeshi/shannoncoat"
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
let menu = NSMenu()
@@ -263,23 +260,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
ManageWindowController.shared.show()
}
// About lives as a tab in the same window (see ManageWindow.swift)
// rather than the standard NSApp About panel the standard panel
// auto-appends its own "Version X (Y)" line from Info.plist's
// CFBundleVersion/CFBundleShortVersionString, which are identical in
// this build, so it was showing that duplicated alongside our own
// version line.
@objc func showAbout() {
// Accessory apps (no Dock icon) don't auto-activate for a window
// they open, so the standard About panel would appear behind
// whatever's frontmost without this.
NSApp.activate(ignoringOtherApps: true)
let credits = """
Which coat will Claude wear today? Menu bar switcher for running multiple isolated Claude Desktop profiles side by side — each paired with its own Claude Code config so the two never cross-contaminate.
Author: \(author)
Source: \(homepage)
Version: \(UpdateChecker.displayVersion)
"""
NSApp.orderFrontStandardAboutPanel(options: [
.applicationName: "shannoncoat",
.credits: NSAttributedString(string: credits),
])
ManageWindowController.shared.update(currentInfos)
ManageWindowController.shared.show(tab: .about)
}
@objc func quitClaude() {
+273 -45
View File
@@ -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,41 +68,60 @@ 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()
@@ -102,44 +132,102 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
addForm.isHidden = true
addForm.translatesAutoresizingMaskIntoConstraints = false
let stack = NSStackView(views: [scroll, detailStack, 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),
detailStack.widthAnchor.constraint(equalTo: stack.widthAnchor),
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.
// 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() {
for value in [detailAppValue, detailCodeValue] {
value.isSelectable = true
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 = 6
appRow.spacing = 8
let codeRow = NSStackView(views: [labeled("Claude Code:"), detailCodeValue])
codeRow.orientation = .horizontal
codeRow.spacing = 6
codeRow.spacing = 8
detailStack.orientation = .vertical
detailStack.alignment = .leading
detailStack.spacing = 2
detailStack.spacing = 4
[appRow, codeRow].forEach(detailStack.addArrangedSubview)
appRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
codeRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
@@ -149,62 +237,93 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
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()
scrollHeightConstraint.constant = heightForRows(infos.count)
refreshForSelection()
}
private func refreshForSelection() {
let row = tableView.selectedRow
guard infos.indices.contains(row) else {
removeButton.isEnabled = false
addRemoveControl.setEnabled(false, forSegment: 1)
detailStack.isHidden = true
updatePreferredSize()
return
}
let profile = infos[row].profile
removeButton.isEnabled = profile.name != "default"
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
@@ -229,17 +348,26 @@ 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
}
@@ -258,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 {
@@ -267,6 +403,7 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
errorLabel.stringValue = ""
view.window?.makeFirstResponder(nameField)
}
updatePreferredSize()
}
@objc private func chooseCodeDir() { choose(into: codeField) }
@@ -295,6 +432,7 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
onChange?()
} catch {
errorLabel.stringValue = (error as? LocalizedError)?.errorDescription ?? "\(error)"
updatePreferredSize()
}
}
@@ -364,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)
@@ -378,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() {
@@ -432,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)
}
}
+1 -1
View File
@@ -1 +1 @@
0.0.2
0.0.3
+13 -4
View File
@@ -54,12 +54,21 @@ command -v swiftc >/dev/null || { echo "error: swiftc not found (install Xcode C
# Info.plist above.
cp "$VERSION_FILE" "$APP/Contents/Resources/VERSION"
# COMMIT is the short SHA the About panel appends to the version, so a dev
# COMMIT is the short SHA the About tab appends to the version, so one dev
# build is distinguishable from another — left empty for a real release
# build (HEAD sitting exactly on a tag, which is how release.yml checks
# this out), since the version number alone is unambiguous there.
# build, since the version number alone is unambiguous there. A release
# build is one of:
# - SHANNONCOAT_RELEASE_BUILD set by release.yml, which already knows
# for certain whether it's building a tag — trusted outright rather
# than re-derived, since `git describe` needs full tag refs that a
# CI runner's shallow checkout isn't guaranteed to have fetched.
# - a local/manual build with HEAD sitting exactly on a tag (checked
# via `git describe`, which is reliable here since a normal
# non-shallow local clone has its full tag history).
GIT_SHA=""
if git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1 \
if [[ -n "${SHANNONCOAT_RELEASE_BUILD:-}" ]]; then
: # release build — leave GIT_SHA empty
elif git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1 \
&& ! git -C "$SCRIPT_DIR" describe --tags --exact-match >/dev/null 2>&1; then
GIT_SHA="$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || true)"
fi