fix(mac): refine node submenu copy behavior
This commit is contained in:
@@ -58,6 +58,7 @@
|
||||
### macOS app
|
||||
- Update-ready state surfaced in the menu; menu sections regrouped with session submenus.
|
||||
- Menu bar now shows a dedicated Nodes section under Context with inline rows, overflow submenu, and iconized actions.
|
||||
- Nodes now expose consistent inline details with per-node submenus for quick copy of key fields.
|
||||
- Session list polish: sleeping/disconnected/error states, usage bar restored, padding + bar sizing tuned, syncing menu removed, header hidden when disconnected.
|
||||
- Chat UI polish: tool call cards + merged tool results, glass background, tighter composer spacing, visual effect host tweaks.
|
||||
- OAuth storage moved; legacy session syncing metadata removed.
|
||||
|
||||
@@ -226,6 +226,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
item.view = HighlightedMenuItemHostView(
|
||||
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||
width: width)
|
||||
item.submenu = self.buildNodeSubmenu(entry: entry)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
@@ -417,11 +418,61 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
item.view = HighlightedMenuItemHostView(
|
||||
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||
width: width)
|
||||
item.submenu = self.buildNodeSubmenu(entry: entry)
|
||||
menu.addItem(item)
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
private func buildNodeSubmenu(entry: InstanceInfo) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
|
||||
menu.addItem(self.makeNodeCopyItem(label: "ID", value: entry.id))
|
||||
|
||||
if let host = entry.host?.nonEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Host", value: host))
|
||||
}
|
||||
|
||||
if let ip = entry.ip?.nonEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "IP", value: ip))
|
||||
}
|
||||
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Role", value: NodeMenuEntryFormatter.roleText(entry)))
|
||||
|
||||
if let platform = NodeMenuEntryFormatter.platformText(entry) {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
||||
}
|
||||
|
||||
if let version = entry.version?.nonEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Version", value: "v\(version)"))
|
||||
}
|
||||
|
||||
menu.addItem(self.makeNodeDetailItem(label: "Last seen", value: entry.ageDescription))
|
||||
|
||||
if entry.lastInputSeconds != nil {
|
||||
menu.addItem(self.makeNodeDetailItem(label: "Last input", value: entry.lastInputDescription))
|
||||
}
|
||||
|
||||
if let reason = entry.reason?.nonEmpty {
|
||||
menu.addItem(self.makeNodeDetailItem(label: "Reason", value: reason))
|
||||
}
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
private func makeNodeDetailItem(label: String, value: String) -> NSMenuItem {
|
||||
let item = NSMenuItem(title: "\(label): \(value)", action: nil, keyEquivalent: "")
|
||||
item.isEnabled = false
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeNodeCopyItem(label: String, value: String) -> NSMenuItem {
|
||||
let item = NSMenuItem(title: "\(label): \(value)", action: #selector(self.copyNodeValue(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
item.representedObject = value
|
||||
return item
|
||||
}
|
||||
|
||||
@objc
|
||||
private func patchThinking(_ sender: NSMenuItem) {
|
||||
guard let dict = sender.representedObject as? [String: Any],
|
||||
@@ -531,6 +582,13 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
NSPasteboard.general.setString(summary, forType: .string)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func copyNodeValue(_ sender: NSMenuItem) {
|
||||
guard let value = sender.representedObject as? String else { return }
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
}
|
||||
|
||||
// MARK: - Width + placement
|
||||
|
||||
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
||||
|
||||
@@ -23,39 +23,67 @@ struct NodeMenuEntryFormatter {
|
||||
entry.text.nonEmpty ?? self.primaryName(entry)
|
||||
}
|
||||
|
||||
static func roleText(_ entry: InstanceInfo) -> String {
|
||||
if self.isGateway(entry) { return "gateway" }
|
||||
if let mode = entry.mode?.nonEmpty { return mode }
|
||||
return "node"
|
||||
}
|
||||
|
||||
static func detailLeft(_ entry: InstanceInfo) -> String {
|
||||
var modeLabel: String?
|
||||
if self.isGateway(entry) {
|
||||
modeLabel = "gateway"
|
||||
} else if let mode = entry.mode?.nonEmpty {
|
||||
modeLabel = mode
|
||||
}
|
||||
if let version = entry.version?.nonEmpty {
|
||||
let base = modeLabel ?? "node"
|
||||
modeLabel = "\(base) v\(version)"
|
||||
}
|
||||
|
||||
if let modeLabel { return modeLabel }
|
||||
|
||||
if let text = entry.text.nonEmpty {
|
||||
let trimmed = text
|
||||
.replacingOccurrences(of: "Node: ", with: "")
|
||||
.replacingOccurrences(of: "Gateway: ", with: "")
|
||||
let candidates = trimmed
|
||||
.components(separatedBy: " · ")
|
||||
.filter { !$0.hasPrefix("mode ") && !$0.hasPrefix("reason ") }
|
||||
if let first = candidates.first, !first.isEmpty { return first }
|
||||
}
|
||||
|
||||
return entry.ageDescription
|
||||
let role = self.roleText(entry)
|
||||
if let ip = entry.ip?.nonEmpty { return "\(ip) · \(role)" }
|
||||
return role
|
||||
}
|
||||
|
||||
static func detailRight(_ entry: InstanceInfo) -> String? {
|
||||
if let ip = entry.ip?.nonEmpty { return ip }
|
||||
if let platform = entry.platform?.nonEmpty { return platform }
|
||||
var parts: [String] = []
|
||||
if let platform = self.platformText(entry) { parts.append(platform) }
|
||||
if let version = entry.version?.nonEmpty { parts.append("v\(version)") }
|
||||
if parts.isEmpty { return nil }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
static func platformText(_ entry: InstanceInfo) -> String? {
|
||||
if let raw = entry.platform?.nonEmpty {
|
||||
return self.prettyPlatform(raw) ?? raw
|
||||
}
|
||||
if let family = entry.deviceFamily?.lowercased() {
|
||||
if family.contains("mac") { return "macOS" }
|
||||
if family.contains("iphone") { return "iOS" }
|
||||
if family.contains("ipad") { return "iPadOS" }
|
||||
if family.contains("android") { return "Android" }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func prettyPlatform(_ raw: String) -> String? {
|
||||
let (prefix, version) = self.parsePlatform(raw)
|
||||
if prefix.isEmpty { return nil }
|
||||
let name: String = switch prefix {
|
||||
case "macos": "macOS"
|
||||
case "ios": "iOS"
|
||||
case "ipados": "iPadOS"
|
||||
case "tvos": "tvOS"
|
||||
case "watchos": "watchOS"
|
||||
default: prefix.prefix(1).uppercased() + prefix.dropFirst()
|
||||
}
|
||||
guard let version, !version.isEmpty else { return name }
|
||||
let parts = version.split(separator: ".").map(String.init)
|
||||
if parts.count >= 2 {
|
||||
return "\(name) \(parts[0]).\(parts[1])"
|
||||
}
|
||||
return "\(name) \(version)"
|
||||
}
|
||||
|
||||
private static func parsePlatform(_ raw: String) -> (prefix: String, version: String?) {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return ("", nil) }
|
||||
let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
let prefix = parts.first?.lowercased() ?? ""
|
||||
let versionToken = parts.dropFirst().first
|
||||
return (prefix, versionToken)
|
||||
}
|
||||
|
||||
static func leadingSymbol(_ entry: InstanceInfo) -> String {
|
||||
if self.isGateway(entry) { return self.safeSystemSymbol("dot.radiowaves.left.and.right", fallback: "network") }
|
||||
if let family = entry.deviceFamily?.lowercased() {
|
||||
@@ -109,7 +137,7 @@ struct NodeMenuRowView: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(NodeMenuEntryFormatter.detailLeft(self.entry))
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
@@ -118,12 +146,19 @@ struct NodeMenuRowView: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if let right = NodeMenuEntryFormatter.detailRight(self.entry) {
|
||||
Text(right)
|
||||
.font(.caption.monospacedDigit())
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
if let right = NodeMenuEntryFormatter.detailRight(self.entry) {
|
||||
Text(right)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user