Clicking a profile switched to it exclusively and shift-click opened it
alongside, with no way round for anyone who opens alongside far more often
than they switch. Settings now carries the choice, storing only the plain
click's action — the other gesture gets whatever is left, so the two can't
both end up meaning the same thing, and no migration is needed for anyone
who never touches it.
Switching also quits the other profiles again when the profile clicked is
already up, which e7777d5 had carved out on the reasoning that a click
reaching for a second open window is someone moving between windows rather
than asking for one to go. That guess is what this setting now settles:
with each action bound to a gesture of its own, choosing the switch gesture
is the instruction, and second-guessing it left no way to ask for an
exclusive switch at all once the target was running.
The menu spells out both gestures rather than only the modified one, since
which of them quits things is no longer fixed. Each profile's tooltip
describes what a click will actually do to it as things stand — it promises
to quit the neighbours only when there are neighbours to quit.
487 lines
23 KiB
Swift
487 lines
23 KiB
Swift
// Menu bar behavior: title dots, per-profile menu, Manage Profiles window.
|
||
// Reads live state from LiveState (push-based, no polling) and calls
|
||
// ProfileStore/ClaudeControl in-process — no subprocess, no text parsing.
|
||
import AppKit
|
||
import ApplicationServices
|
||
|
||
// What clicking a profile in the menu does. The two actions are always both
|
||
// reachable — whichever one isn't on the plain click is on Shift-click — so
|
||
// this is a swap, not an on/off.
|
||
enum MenuClickAction: String {
|
||
case switchTo
|
||
case openAlongside
|
||
|
||
// Names the action generically, for the menu's two hint lines — the
|
||
// per-profile tooltips say what it does to that profile as things
|
||
// actually stand (see AppDelegate.outcome).
|
||
var phrase: String {
|
||
switch self {
|
||
case .switchTo: return "switch (quits the others)"
|
||
case .openAlongside: return "open alongside"
|
||
}
|
||
}
|
||
}
|
||
|
||
// One setting for the whole app, stored as the *plain* click's action; the
|
||
// Shift-click action is derived, which keeps the two from ever being set to
|
||
// the same thing.
|
||
enum MenuClickBehavior {
|
||
private static let plainClickKey = "MenuPlainClickAction"
|
||
|
||
static var plainClick: MenuClickAction {
|
||
get {
|
||
UserDefaults.standard.string(forKey: plainClickKey)
|
||
.flatMap(MenuClickAction.init(rawValue:)) ?? .switchTo
|
||
}
|
||
set { UserDefaults.standard.set(newValue.rawValue, forKey: plainClickKey) }
|
||
}
|
||
|
||
static var shiftClick: MenuClickAction {
|
||
plainClick == .switchTo ? .openAlongside : .switchTo
|
||
}
|
||
}
|
||
|
||
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
|
||
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||
let menu = NSMenu()
|
||
let liveState = LiveState()
|
||
private var currentInfos: [ProfileInfo] = []
|
||
private var collisionAlertShown = false
|
||
private var windowOverlays: [String: WindowOverlay] = [:]
|
||
private var pendingWindowOverlays: Set<String> = []
|
||
private var accessibilityPromptShown = false
|
||
private var accessibilityRecheckScheduled = false
|
||
private var activationObserver: NSObjectProtocol?
|
||
private var appearanceObserver: NSKeyValueObservation?
|
||
|
||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||
menu.delegate = self
|
||
menu.autoenablesItems = false
|
||
statusItem.menu = menu
|
||
statusItem.button?.image = MenuBarIcon.image()
|
||
statusItem.button?.imagePosition = .imageLeft
|
||
|
||
liveState.delegate = self
|
||
ManageWindowController.shared.onProfilesChanged = { [weak self] in self?.liveState.reconcile() }
|
||
ManageWindowController.shared.onOverlayStyleChanged = { [weak self] in self?.rebuildWindowOverlays() }
|
||
|
||
// Every app activation, not just Claude's — LiveState deliberately
|
||
// filters to the Claude binary, so on its own it never hears that
|
||
// some unrelated app came forward, which is exactly when the tags
|
||
// need to get out of the way.
|
||
activationObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||
forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main
|
||
) { [weak self] _ in self?.updateOverlayVisibility() }
|
||
|
||
// The glyph is a template image and re-tints itself, but the coloured
|
||
// dots beside it don't: ProfileColor picks a brightness per current
|
||
// appearance at the moment it's asked, and the title is only rebuilt
|
||
// when profiles start or stop. Without this, switching to dark mode
|
||
// leaves dots mixed for that appearance sitting in the menu bar until
|
||
// something unrelated happens to redraw them.
|
||
appearanceObserver = NSApp.observe(\.effectiveAppearance) { [weak self] _, _ in
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self else { return }
|
||
self.applyTitle(self.currentInfos)
|
||
ManageWindowController.shared.update(self.currentInfos)
|
||
}
|
||
}
|
||
|
||
liveState.reconcile()
|
||
|
||
// Launching this app fresh with nothing running means there's no
|
||
// menu-bar title/dots to click yet — offer a picker up front
|
||
// instead of leaving the user to discover the dropdown.
|
||
if currentInfos.filter(\.running).isEmpty {
|
||
showStartupPicker(currentInfos)
|
||
}
|
||
|
||
UpdateChecker.checkAutomaticallyIfDue()
|
||
}
|
||
|
||
// The menu bar draws its own glyph (see MenuBarIcon) rather than the app
|
||
// icon scaled down, which is what it used to do: at 18pt the app icon's
|
||
// three coats collapse into a smudge, and its colours ignore the menu
|
||
// bar's appearance entirely. MenuBarIcon returns a template image, so
|
||
// macOS tints it for light/dark and for the open-menu inversion, and
|
||
// re-tints it by itself when the theme changes.
|
||
|
||
// MARK: - LiveStateDelegate
|
||
|
||
func liveStateDidChange(_ infos: [ProfileInfo]) {
|
||
collisionAlertShown = false
|
||
currentInfos = infos
|
||
applyTitle(infos)
|
||
ManageWindowController.shared.update(infos)
|
||
updateWindowOverlays(infos)
|
||
}
|
||
|
||
// Each tag decides for itself whether the window it labels is actually
|
||
// visible underneath it (see WindowOverlay.isTargetVisible) — this just
|
||
// says "now would be a good time to look again". Activation is the
|
||
// usual way a window gets buried or uncovered without moving at all,
|
||
// which no AX notification reports.
|
||
private func updateOverlayVisibility() {
|
||
for overlay in windowOverlays.values { overlay.refreshVisibility() }
|
||
}
|
||
|
||
// macOS doesn't notify an app that it has just been granted
|
||
// Accessibility, and overlays are otherwise only reconsidered when a
|
||
// Claude window launches, quits or activates — so a permission granted
|
||
// while this is running would do nothing visible until the user
|
||
// happened to touch a Claude window, or restarted the app. Polling for
|
||
// it costs a cheap local check every couple of seconds, and only while
|
||
// the permission is actually missing: the moment it lands, the guard in
|
||
// `updateWindowOverlays` passes and this stops rescheduling itself.
|
||
//
|
||
// Also covers a subtler case — replacing the installed bundle and
|
||
// relaunching immediately can start the app before TCC has settled on
|
||
// the new copy, and a one-shot check there would strand the tags for
|
||
// the rest of the session over a denial that was only ever momentary.
|
||
private func scheduleAccessibilityRecheck() {
|
||
guard !accessibilityRecheckScheduled else { return }
|
||
accessibilityRecheckScheduled = true
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||
guard let self else { return }
|
||
accessibilityRecheckScheduled = false
|
||
updateWindowOverlays(currentInfos)
|
||
}
|
||
}
|
||
|
||
// A style change rewrites the tag's shape and size, which is decided
|
||
// when its panel is built — so the existing ones are dropped (deinit
|
||
// tears down each panel and its AXObserver) and rebuilt rather than
|
||
// patched in place.
|
||
private func rebuildWindowOverlays() {
|
||
windowOverlays.removeAll()
|
||
updateWindowOverlays(currentInfos)
|
||
}
|
||
|
||
// Shown on every running *managed* profile — never on "default",
|
||
// which isn't a shannoncoat profile at all, just the real underlying
|
||
// Claude install. Removing an entry here deinits its WindowOverlay,
|
||
// which tears down its AXObserver and hides the tag panel.
|
||
private func updateWindowOverlays(_ infos: [ProfileInfo]) {
|
||
let managed = infos.filter { $0.running && $0.profile.name != "default" }
|
||
let managedNames = Set(managed.map(\.profile.name))
|
||
windowOverlays = windowOverlays.filter { managedNames.contains($0.key) }
|
||
pendingWindowOverlays.formIntersection(managedNames)
|
||
|
||
// A tag is positioned entirely from Accessibility data, so without
|
||
// that permission there is nothing to position: `attach` would
|
||
// burn its dozen retries per profile and give up without a word,
|
||
// which reads as "the feature is broken" rather than "macOS said
|
||
// no". Prompt instead — the same call `ClaudeControl.focus` makes
|
||
// for the same reason.
|
||
//
|
||
// Worth knowing when this bites, because it looks impossible:
|
||
// a local build gets a fresh code identity every time build.sh
|
||
// runs, and that silently invalidates an existing grant while
|
||
// leaving the app still listed *and still ticked* under Privacy &
|
||
// Security → Accessibility. Re-granting means removing that stale
|
||
// row with "−" and adding the newly built app back; toggling it
|
||
// off and on again is not enough. (Note that running the binary
|
||
// straight out of the bundle from a terminal will appear to work
|
||
// regardless — it inherits the terminal's grant, not the app's,
|
||
// so it's useless for testing this.)
|
||
if !managed.isEmpty, !AXIsProcessTrusted() {
|
||
if !accessibilityPromptShown {
|
||
accessibilityPromptShown = true
|
||
ClaudeControl.promptForAccessibility()
|
||
}
|
||
scheduleAccessibilityRecheck()
|
||
return
|
||
}
|
||
|
||
for info in managed
|
||
where windowOverlays[info.profile.name] == nil && !pendingWindowOverlays.contains(info.profile.name) {
|
||
guard let pid = info.pid else { continue }
|
||
let name = info.profile.name
|
||
pendingWindowOverlays.insert(name)
|
||
WindowOverlay.attach(profileName: name, color: ProfileColor.dotColor(for: name), pid: pid) { [weak self] overlay in
|
||
guard let self else { return }
|
||
pendingWindowOverlays.remove(name)
|
||
// The profile may have quit again while this was retrying
|
||
// for its window — don't attach a stale overlay if so.
|
||
guard currentInfos.contains(where: { $0.profile.name == name && $0.running }) else { return }
|
||
windowOverlays[name] = overlay
|
||
// A tag created while some other app is frontmost must not
|
||
// appear over it — `attach` can complete seconds after the
|
||
// launch that triggered it, by which point focus has often
|
||
// moved on.
|
||
updateOverlayVisibility()
|
||
}
|
||
}
|
||
|
||
updateOverlayVisibility()
|
||
}
|
||
|
||
// Two profiles sharing a dir is a fatal misconfiguration (would
|
||
// silently merge their Claude sessions) — surfaced with an alert
|
||
// rather than crashing the app outright, since that would leave the
|
||
// user with no way to fix it short of hand-editing files blind.
|
||
func liveStateFoundCollisions(_ collisions: [ProfileStore.DirCollision]) {
|
||
guard !collisionAlertShown else { return }
|
||
collisionAlertShown = true
|
||
let lines = collisions.map { "\u{2022} \($0.kind): \"\($0.profileA)\" and \"\($0.profileB)\" both use \($0.dir)" }
|
||
let alert = NSAlert()
|
||
alert.alertStyle = .critical
|
||
alert.messageText = "shannoncoat: configuration error"
|
||
alert.informativeText = "Two profiles can't share a directory:\n\n" + lines.joined(separator: "\n")
|
||
alert.addButton(withTitle: "OK")
|
||
alert.runModal()
|
||
}
|
||
|
||
// Title reflects what's ACTUALLY running — one profile shows as a dot
|
||
// + name; several show as dots only, to keep the menu bar from growing
|
||
// unbounded, with the full list available via tooltip and the dropdown.
|
||
private func applyTitle(_ profiles: [ProfileInfo]) {
|
||
guard let button = statusItem.button else { return }
|
||
let running = profiles.filter(\.running)
|
||
switch running.count {
|
||
case 0:
|
||
button.attributedTitle = NSAttributedString(string: "")
|
||
button.toolTip = "Claude not running"
|
||
case 1:
|
||
let name = running[0].profile.name
|
||
let title = NSMutableAttributedString(
|
||
string: "\u{25cf} ", attributes: [.foregroundColor: ProfileColor.dotColor(for: name)])
|
||
title.append(NSAttributedString(string: name, attributes: [.foregroundColor: NSColor.labelColor]))
|
||
button.attributedTitle = title
|
||
button.toolTip = nil
|
||
default:
|
||
let title = NSMutableAttributedString()
|
||
for info in running {
|
||
title.append(NSAttributedString(
|
||
string: "\u{25cf}", attributes: [.foregroundColor: ProfileColor.dotColor(for: info.profile.name)]))
|
||
}
|
||
button.attributedTitle = title
|
||
button.toolTip = running.map(\.profile.name).joined(separator: ", ")
|
||
}
|
||
}
|
||
|
||
// MARK: - Menu
|
||
|
||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||
menu.removeAllItems()
|
||
liveState.reconcile() // correctness backstop; live updates are the primary mechanism
|
||
let profiles = currentInfos
|
||
let runningCount = profiles.filter(\.running).count
|
||
|
||
let header = NSMenuItem(
|
||
title: runningCount == 0 ? "Claude not running"
|
||
: runningCount == 1 ? "1 profile running" : "\(runningCount) profiles running",
|
||
action: nil, keyEquivalent: "")
|
||
header.isEnabled = false
|
||
menu.addItem(header)
|
||
menu.addItem(.separator())
|
||
|
||
// Read once per rebuild rather than per item. The menu is rebuilt
|
||
// from scratch on every open, so changing the setting is reflected
|
||
// the next time the menu is shown without anything having to tell
|
||
// the delegate about it.
|
||
let plainClick = MenuClickBehavior.plainClick
|
||
let shiftClick = MenuClickBehavior.shiftClick
|
||
|
||
// Both gestures are spelled out, not just the modified one: which is
|
||
// which is now a setting, so a single line about Shift would leave the
|
||
// plain click to be assumed — and it's the plain click that quits
|
||
// things when it's the one bound to switching.
|
||
for (gesture, action) in [("Click", plainClick), ("\u{21e7}-click", shiftClick)] {
|
||
let hint = NSMenuItem(title: "\(gesture) to \(action.phrase)", action: nil, keyEquivalent: "")
|
||
hint.isEnabled = false
|
||
menu.addItem(hint)
|
||
}
|
||
menu.addItem(.separator())
|
||
|
||
for info in profiles {
|
||
// One click switches (quitting every other running profile), the
|
||
// other opens alongside without disturbing what's running — which
|
||
// is on the plain click and which is on Shift is the user's
|
||
// choice, see MenuClickBehavior.
|
||
let title = info.profile.name == liveState.lastActiveName
|
||
? "\(info.profile.name) \u{21c6}" : info.profile.name
|
||
let item = NSMenuItem(title: title, action: #selector(pick(_:)), keyEquivalent: "")
|
||
item.target = self
|
||
item.representedObject = info.profile.name
|
||
item.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running)
|
||
item.state = info.running ? .on : .off
|
||
item.toolTip = "Click to \(outcome(of: plainClick, for: info))"
|
||
+ " • \u{21e7}-click to \(outcome(of: shiftClick, for: info))"
|
||
menu.addItem(item)
|
||
}
|
||
|
||
menu.addItem(.separator())
|
||
let manageItem = NSMenuItem(
|
||
title: "Manage Profiles\u{2026}", action: #selector(openManageWindow), keyEquivalent: ",")
|
||
manageItem.keyEquivalentModifierMask = [.command]
|
||
manageItem.target = self
|
||
menu.addItem(manageItem)
|
||
|
||
let closeItem = NSMenuItem(title: "Close Profile", action: nil, keyEquivalent: "")
|
||
let closeMenu = NSMenu()
|
||
let closeable = profiles.filter(\.running)
|
||
if closeable.isEmpty {
|
||
let none = NSMenuItem(title: "(none running)", action: nil, keyEquivalent: "")
|
||
none.isEnabled = false
|
||
closeMenu.addItem(none)
|
||
} else {
|
||
for info in closeable {
|
||
let mi = NSMenuItem(title: info.profile.name, action: #selector(closeProfile(_:)), keyEquivalent: "")
|
||
mi.target = self
|
||
mi.representedObject = info.profile.name
|
||
closeMenu.addItem(mi)
|
||
}
|
||
}
|
||
closeItem.submenu = closeMenu
|
||
menu.addItem(closeItem)
|
||
|
||
menu.addItem(.separator())
|
||
let aboutItem = NSMenuItem(title: "About shannoncoat", action: #selector(showAbout), keyEquivalent: "")
|
||
aboutItem.target = self
|
||
menu.addItem(aboutItem)
|
||
|
||
menu.addItem(.separator())
|
||
let quitClaudeItem = NSMenuItem(title: "Quit Claude", action: #selector(quitClaude), keyEquivalent: "")
|
||
quitClaudeItem.target = self
|
||
menu.addItem(quitClaudeItem)
|
||
|
||
let quitSelfItem = NSMenuItem(title: "Quit shannoncoat", action: #selector(quitSelf), keyEquivalent: "q")
|
||
quitSelfItem.target = self
|
||
menu.addItem(quitSelfItem)
|
||
}
|
||
|
||
// What a gesture will actually do to THIS profile as things stand — the
|
||
// same action reads differently depending on what's up. Switching only
|
||
// quits neighbours when there are neighbours to quit, and opening
|
||
// alongside is only "alongside" something when something else is running.
|
||
private func outcome(of action: MenuClickAction, for info: ProfileInfo) -> String {
|
||
let others = currentInfos.filter { $0.running && $0.profile.name != info.profile.name }.count
|
||
let reach = info.running ? "focus it" : "start it"
|
||
switch action {
|
||
case .openAlongside:
|
||
return others == 0 ? reach : "\(reach), leaving the others running"
|
||
case .switchTo:
|
||
guard others > 0 else { return reach }
|
||
return "\(reach), quitting the other \(others == 1 ? "profile" : "\(others) profiles")"
|
||
}
|
||
}
|
||
|
||
// Shown once at launch when nothing is running. A checklist rather
|
||
// than a single pick: unlike the menu's click/Shift-click (one
|
||
// profile, exclusive vs. alongside), there's nothing running yet to be
|
||
// "alongside" of, so this lets several profiles be selected to start
|
||
// together in one go.
|
||
private func showStartupPicker(_ profiles: [ProfileInfo]) {
|
||
guard !profiles.isEmpty else { return }
|
||
let alert = NSAlert()
|
||
alert.messageText = "Start Claude"
|
||
alert.informativeText = "Nothing is running. Choose which profiles to start:"
|
||
alert.addButton(withTitle: "Start")
|
||
alert.addButton(withTitle: "Cancel")
|
||
|
||
let rowHeight: CGFloat = 22
|
||
let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: rowHeight * CGFloat(profiles.count)))
|
||
var checkboxes: [(box: NSButton, name: String)] = []
|
||
for (i, info) in profiles.enumerated() {
|
||
let y = rowHeight * CGFloat(profiles.count - 1 - i)
|
||
let box = NSButton(checkboxWithTitle: info.profile.name, target: nil, action: nil)
|
||
box.frame = NSRect(x: 0, y: y, width: 240, height: rowHeight)
|
||
box.state = info.profile.name == liveState.lastActiveName ? .on : .off
|
||
container.addSubview(box)
|
||
checkboxes.append((box, info.profile.name))
|
||
}
|
||
alert.accessoryView = container
|
||
|
||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||
|
||
for (box, name) in checkboxes where box.state == .on {
|
||
guard let profile = profiles.first(where: { $0.profile.name == name })?.profile else { continue }
|
||
liveState.markActive(name)
|
||
ClaudeControl.launch(profile)
|
||
}
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() }
|
||
}
|
||
|
||
// MARK: - Actions
|
||
|
||
@objc func pick(_ sender: NSMenuItem) {
|
||
guard let name = sender.representedObject as? String,
|
||
let profile = currentInfos.first(where: { $0.profile.name == name })?.profile
|
||
else { return }
|
||
liveState.markActive(name)
|
||
let shiftHeld = NSApp.currentEvent?.modifierFlags.contains(.shift) == true
|
||
switch shiftHeld ? MenuClickBehavior.shiftClick : MenuClickBehavior.plainClick {
|
||
case .openAlongside: openProfile(profile)
|
||
case .switchTo: switchTo(profile)
|
||
}
|
||
}
|
||
|
||
private func openProfile(_ profile: ResolvedProfile) {
|
||
if let pid = ClaudeControl.pid(for: profile) {
|
||
ClaudeControl.focus(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } }
|
||
} else {
|
||
ClaudeControl.launch(profile)
|
||
// The launch notification usually beats this, but a short
|
||
// explicit poke keeps the UI snappy even if it doesn't.
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() }
|
||
}
|
||
}
|
||
|
||
// Switching means "make this the one that's running", so it quits the
|
||
// others whether or not this profile is already up.
|
||
//
|
||
// It used to bail out early for an already-running profile, on the
|
||
// reasoning that a click reaching for a second open window is someone
|
||
// moving between windows rather than asking for one to go. That guess is
|
||
// exactly what MenuClickBehavior now settles: both actions have a gesture
|
||
// of their own, so choosing the switch gesture *is* the instruction, and
|
||
// second-guessing it left no way to ask for an exclusive switch at all
|
||
// once the target was up.
|
||
private func switchTo(_ profile: ResolvedProfile) {
|
||
let others = currentInfos.filter { $0.running && $0.profile.name != profile.name }
|
||
guard !others.isEmpty else { openProfile(profile); return }
|
||
var remaining = others.count
|
||
for info in others {
|
||
guard let pid = info.pid else { remaining -= 1; continue }
|
||
ClaudeControl.quit(pid: pid) { [weak self] _ in
|
||
remaining -= 1
|
||
if remaining == 0 {
|
||
DispatchQueue.main.async { self?.openProfile(profile) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@objc func closeProfile(_ sender: NSMenuItem) {
|
||
guard let name = sender.representedObject as? String,
|
||
let info = currentInfos.first(where: { $0.profile.name == name }), let pid = info.pid
|
||
else { return }
|
||
ClaudeControl.quit(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } }
|
||
}
|
||
|
||
@objc func openManageWindow() {
|
||
ManageWindowController.shared.update(currentInfos)
|
||
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() {
|
||
ManageWindowController.shared.update(currentInfos)
|
||
ManageWindowController.shared.show(tab: .about)
|
||
}
|
||
|
||
@objc func quitClaude() {
|
||
ClaudeControl.quitAll { DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } }
|
||
}
|
||
|
||
@objc func quitSelf() {
|
||
NSApp.terminate(nil)
|
||
}
|
||
}
|