diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 45418f6..f62eacf 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -274,7 +274,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt Author: \(author) Source: \(homepage) - Version: \(UpdateChecker.currentVersion) + Version: \(UpdateChecker.displayVersion) """ NSApp.orderFrontStandardAboutPanel(options: [ .applicationName: "shannoncoat", diff --git a/Sources/ManageWindow.swift b/Sources/ManageWindow.swift index 20fbd6a..0b2df70 100644 --- a/Sources/ManageWindow.swift +++ b/Sources/ManageWindow.swift @@ -58,6 +58,9 @@ 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 detailStack = NSStackView() + private let detailAppValue = NSTextField(labelWithString: "") + private let detailCodeValue = NSTextField(labelWithString: "") private let addForm = NSStackView() private let nameField = NSTextField() private let codeField = NSTextField() @@ -91,11 +94,15 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou 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, detailStack, buttonRow, addForm]) stack.orientation = .vertical stack.spacing = 8 stack.edgeInsets = NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) @@ -108,9 +115,36 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou 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 @@ -156,9 +190,21 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou 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" + 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 @@ -198,9 +244,7 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou } 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 diff --git a/Sources/ProfileColor.swift b/Sources/ProfileColor.swift index e8e63a5..b60c4b6 100644 --- a/Sources/ProfileColor.swift +++ b/Sources/ProfileColor.swift @@ -18,9 +18,18 @@ enum ProfileColor { return hash } + // A brightness/saturation pair that reads clearly on its own + // background — a color bright enough to pop on a dark menu would wash + // out on a light one, and vice versa, so this picks per current + // appearance rather than using one fixed value for both. + private static func isDarkAppearance() -> Bool { + NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + } + static func dotColor(for name: String) -> NSColor { let hue = CGFloat(fnv1aHash(name) % 360) / 360.0 - return NSColor(calibratedHue: hue, saturation: 0.65, brightness: 0.9, alpha: 1.0) + let (saturation, brightness): (CGFloat, CGFloat) = isDarkAppearance() ? (0.90, 1.0) : (1.0, 0.55) + return NSColor(calibratedHue: hue, saturation: saturation, brightness: brightness, alpha: 1.0) } // `dimmed` marks a profile that isn't actually running right now — the diff --git a/Sources/UpdateChecker.swift b/Sources/UpdateChecker.swift index 8258fe9..4716e24 100644 --- a/Sources/UpdateChecker.swift +++ b/Sources/UpdateChecker.swift @@ -33,6 +33,21 @@ enum UpdateChecker { return contents.trimmingCharacters(in: .whitespacesAndNewlines) } + // What the About panel shows: the version alone for a real release + // build (COMMIT is written empty when build.sh finds HEAD sitting + // exactly on a tag), or version + short SHA for a dev build, so two + // local builds off the same version number are distinguishable. + static var displayVersion: String { + commitSHA.isEmpty ? currentVersion : "\(currentVersion) (\(commitSHA))" + } + + private static var commitSHA: String { + guard let resourceURL = Bundle.main.resourceURL, + let contents = try? String(contentsOf: resourceURL.appendingPathComponent("COMMIT"), encoding: .utf8) + else { return "" } + return contents.trimmingCharacters(in: .whitespacesAndNewlines) + } + static func checkManually(completion: @escaping (Outcome) -> Void) { fetchLatestVersion { latest in guard let latest else { completion(.failed); return } diff --git a/VERSION b/VERSION index 8acdd82..4e379d2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.1 +0.0.2 diff --git a/build.sh b/build.sh index 9c91167..4204218 100755 --- a/build.sh +++ b/build.sh @@ -53,6 +53,18 @@ command -v swiftc >/dev/null || { echo "error: swiftc not found (install Xcode C # panel and the update checker read the same file this script stamped into # 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 +# 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. +GIT_SHA="" +if 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 +printf '%s' "$GIT_SHA" >"$APP/Contents/Resources/COMMIT" + swiftc -O "$SOURCES_DIR"/*.swift -o "$APP/Contents/MacOS/launcher" touch "$APP"