vibetunnel/mac/VibeTunnel/Presentation/Views/MenuBarView.swift
Peter Steinberger 42021bb514 Fix inconsistent button state management
- Remove all uses of deprecated highlight() method in CustomMenuWindow
- Consistently use state property for NSStatusBarButton management
- Update StatusBarMenuManager to reset button state when menu state is .none
- Fix concurrency issues in CustomMenuWindow frame observer
- Ensure button state is properly managed throughout menu lifecycle

This fixes the issue where the button could display inconsistent visual states
or get stuck due to conflicting approaches between highlight() and state.
2025-07-02 00:00:53 +01:00

522 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
/// Main menu bar view displaying session status and app controls.
///
/// Appears in the macOS menu bar and provides quick access to VibeTunnel's
/// key features including server status, dashboard access, session monitoring,
/// and application preferences. Updates in real-time to reflect server state.
struct MenuBarView: View {
@Environment(SessionMonitor.self)
var sessionMonitor
@Environment(ServerManager.self)
var serverManager
@AppStorage("showInDock")
private var showInDock = false
@Environment(\.openWindow)
private var openWindow
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Server status header
ServerStatusView(isRunning: serverManager.isRunning, port: Int(serverManager.port) ?? 4_020)
.padding(.horizontal, 12)
.padding(.vertical, 8)
// Open Dashboard button
Button(action: {
if let dashboardURL = URL(string: "http://127.0.0.1:\(serverManager.port)") {
NSWorkspace.shared.open(dashboardURL)
}
}, label: {
Label("Open Dashboard", systemImage: "safari")
})
.buttonStyle(MenuButtonStyle())
.disabled(!serverManager.isRunning)
Divider()
.padding(.vertical, 4)
// Session count header
SessionCountView(count: sessionMonitor.sessionCount)
.padding(.horizontal, 12)
.padding(.vertical, 8)
// Session list with clickable items
if !sessionMonitor.sessions.isEmpty {
SessionListView(sessions: sessionMonitor.sessions)
.padding(.horizontal, 4)
} else {
Text("No sessions")
.font(.system(size: 11))
.foregroundColor(.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 4)
}
Divider()
.padding(.vertical, 4)
// Help menu with submenu indicator
HStack {
Menu {
// Show Tutorial
Button(action: {
#if !SWIFT_PACKAGE
AppDelegate.showWelcomeScreen()
#endif
}, label: {
HStack {
Image(systemName: "book")
Text("Show Tutorial")
}
})
Divider()
// Website
Button(action: {
if let url = URL(string: "http://vibetunnel.sh") {
NSWorkspace.shared.open(url)
}
}, label: {
HStack {
Image(systemName: "globe")
Text("Website")
}
})
// Report Issue
Button(action: {
if let url = URL(string: "https://github.com/amantus-ai/vibetunnel/issues") {
NSWorkspace.shared.open(url)
}
}, label: {
HStack {
Image(systemName: "exclamationmark.triangle")
Text("Report Issue")
}
})
Divider()
// Check for Updates
Button(action: {
SparkleUpdaterManager.shared.checkForUpdates()
}, label: {
HStack {
Image(systemName: "arrow.down.circle")
Text("Check for Updates…")
}
})
// Version (non-interactive)
HStack {
Color.clear
.frame(width: 16, height: 16) // Match the typical SF Symbol size
Text("Version \(appVersion)")
.foregroundColor(.secondary)
}
Divider()
// About
Button(
action: {
SettingsOpener.openSettings()
// Navigate to About tab after settings opens
Task {
try? await Task.sleep(for: .milliseconds(100))
NotificationCenter.default.post(
name: .openSettingsTab,
object: SettingsTab.about
)
}
},
label: {
HStack {
Image(systemName: "info.circle")
Text("About VibeTunnel")
}
}
)
} label: {
Label("Help", systemImage: "questionmark.circle")
}
.menuStyle(BorderlessButtonMenuStyle())
.fixedSize()
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color.accentColor.opacity(0.001))
)
// New Session button
Button(
action: {
// Close menu and show custom window with new session form
NSApp.sendAction(#selector(NSWindow.performClose(_:)), to: nil, from: nil)
if let statusBarController = (NSApp.delegate as? AppDelegate)?.statusBarController {
statusBarController.showCustomWindow()
// Navigate to new session form
}
},
label: {
Label("New Session…", systemImage: "plus.square")
}
)
.buttonStyle(MenuButtonStyle())
.keyboardShortcut("n", modifiers: .command)
// Settings button
Button(
action: {
SettingsOpener.openSettings()
},
label: {
Label("Settings…", systemImage: "gear")
}
)
.buttonStyle(MenuButtonStyle())
.keyboardShortcut(",", modifiers: .command)
Divider()
.padding(.vertical, 4)
// Quit button
Button(action: {
NSApplication.shared.terminate(nil)
}, label: {
Label("Quit", systemImage: "power")
})
.buttonStyle(MenuButtonStyle())
.keyboardShortcut("q", modifiers: .command)
}
.frame(minWidth: 200)
.task {
// Wait for server to be running before fetching sessions
while !serverManager.isRunning {
try? await Task.sleep(for: .milliseconds(500))
}
// Give the server a moment to fully initialize after starting
try? await Task.sleep(for: .milliseconds(100))
// Force initial refresh
await sessionMonitor.refresh()
// Update sessions periodically while view is visible
while true {
_ = await sessionMonitor.getSessions()
try? await Task.sleep(for: .seconds(1))
}
}
}
private var appVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
}
}
// MARK: - Server Status View
/// Displays the HTTP server status
struct ServerStatusView: View {
let isRunning: Bool
let port: Int
@Environment(ServerManager.self)
var serverManager
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Circle()
.fill(isRunning ? Color.green : Color.red)
.frame(width: 8, height: 8)
Text(statusText)
.font(.system(size: 13))
.foregroundColor(.primary)
.lineLimit(1)
}
if isRunning {
Text(accessText)
.font(.system(size: 11))
.foregroundColor(.secondary)
.lineLimit(1)
.padding(.leading, 14) // Align with the status text
}
}
.fixedSize(horizontal: false, vertical: true)
}
private var statusText: String {
if isRunning {
"Server running"
} else {
"Server stopped"
}
}
private var accessText: String {
let bindAddress = serverManager.bindAddress
if bindAddress == "127.0.0.1" {
return "127.0.0.1:\(port)"
} else {
// Network mode - show local IP if available
if let localIP = NetworkUtility.getLocalIPAddress() {
return "\(localIP):\(port)"
} else {
return "0.0.0.0:\(port)"
}
}
}
}
// MARK: - Session Count View
/// Displays the count of active SSH sessions
struct SessionCountView: View {
let count: Int
var body: some View {
HStack(spacing: 6) {
Text(sessionText)
.font(.system(size: 13))
.foregroundColor(.secondary)
.lineLimit(1)
}
.fixedSize(horizontal: false, vertical: true)
}
private var sessionText: String {
count == 1 ? "1 active session" : "\(count) active sessions"
}
}
// MARK: - Session List View
/// Lists active SSH sessions with truncation for large lists
struct SessionListView: View {
let sessions: [String: ServerSessionInfo]
@Environment(\.openWindow)
private var openWindow
var body: some View {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(activeSessions.prefix(5)), id: \.key) { session in
SessionRowView(session: session, openWindow: openWindow)
}
if activeSessions.count > 5 {
HStack {
Text(" • ...")
.font(.system(size: 12))
.foregroundColor(.secondary)
}
.padding(.vertical, 2)
}
}
}
private var activeSessions: [(key: String, value: ServerSessionInfo)] {
sessions.filter(\.value.isRunning)
.sorted { $0.value.startedAt > $1.value.startedAt }
}
}
// MARK: - Session Row View
/// Individual row displaying session information
struct SessionRowView: View {
let session: (key: String, value: ServerSessionInfo)
let openWindow: OpenWindowAction
@State private var isHovered = false
var body: some View {
Button(action: {
// Focus the terminal window for this session
WindowTracker.shared.focusWindow(for: session.key)
}, label: {
VStack(alignment: .leading, spacing: 2) {
// Main session row
HStack {
Text("\(commandName)")
.font(.system(size: 12))
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
// Show session name if available
if let name = session.value.name, !name.isEmpty {
Text("")
.font(.system(size: 12))
.foregroundColor(.secondary.opacity(0.6))
Text(name)
.font(.system(size: 12))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
if let pid = session.value.pid {
Text("PID: \(pid)")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
}
// Activity status and path row
HStack(spacing: 4) {
Text(" ")
.font(.system(size: 11))
if let activityStatus {
Text(activityStatus)
.font(.system(size: 11))
.foregroundColor(Color(red: 0.8, green: 0.4, blue: 0.0))
Text("·")
.font(.system(size: 11))
.foregroundColor(.secondary.opacity(0.5))
}
Text(compactPath)
.font(.system(size: 11))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
}
}
.padding(.vertical, 2)
.padding(.horizontal, 8)
.contentShape(Rectangle())
})
.buttonStyle(PlainButtonStyle())
.background(
RoundedRectangle(cornerRadius: 4)
.fill(isHovered ? Color.accentColor.opacity(0.1) : Color.clear)
)
.onHover { hovering in
isHovered = hovering
}
.contextMenu {
Button("Focus Terminal Window") {
WindowTracker.shared.focusWindow(for: session.key)
}
Button("View Session Details") {
openWindow(id: "session-detail", value: session.key)
}
Button("Show in Finder") {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: session.value.workingDir)
}
Divider()
Button("Copy Session ID") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(session.key, forType: .string)
}
}
}
private var commandName: String {
// Extract the process name from the command
guard let firstCommand = session.value.command.first else {
return "Unknown"
}
// Extract just the executable name from the path
let executableName = (firstCommand as NSString).lastPathComponent
// Special handling for common commands
switch executableName {
case "zsh", "bash", "sh":
// For shells, check if there's a -c argument with the actual command
if session.value.command.count > 2,
session.value.command.contains("-c"),
let cIndex = session.value.command.firstIndex(of: "-c"),
cIndex + 1 < session.value.command.count
{
let actualCommand = session.value.command[cIndex + 1]
return (actualCommand as NSString).lastPathComponent
}
return executableName
default:
return executableName
}
}
private var sessionName: String {
// Extract the working directory name as the session name
let workingDir = session.value.workingDir
let name = (workingDir as NSString).lastPathComponent
// Truncate long session names
if name.count > 30 {
let prefix = String(name.prefix(15))
let suffix = String(name.suffix(10))
return "\(prefix)...\(suffix)"
}
return name
}
private var activityStatus: String? {
if let specificStatus = session.value.activityStatus?.specificStatus {
return specificStatus.status
}
return nil
}
private var compactPath: String {
let path = session.value.workingDir
let homeDir = NSHomeDirectory()
// Replace home directory with ~
if path.hasPrefix(homeDir) {
let relativePath = String(path.dropFirst(homeDir.count))
return "~" + relativePath
}
// For other paths, show last two components
let components = (path as NSString).pathComponents
if components.count > 2 {
let lastTwo = components.suffix(2).joined(separator: "/")
return ".../" + lastTwo
}
return path
}
}
// MARK: - Menu Button Style
/// Custom button style for menu items with hover effects
struct MenuButtonStyle: ButtonStyle {
@State private var isHovered = false
func makeBody(configuration: ButtonStyle.Configuration) -> some View {
configuration.label
.font(.system(size: 13))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(isHovered ? Color.accentColor.opacity(0.1) : Color.clear)
)
.onHover { hovering in
isHovered = hovering
}
}
}