mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-09 11:55:53 +00:00
386 lines
12 KiB
Swift
386 lines
12 KiB
Swift
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
|
|
|
|
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)
|
|
}
|
|
|
|
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))
|
|
)
|
|
|
|
// 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 {
|
|
// Update sessions periodically while view is visible
|
|
while true {
|
|
_ = await sessionMonitor.getSessions()
|
|
try? await Task.sleep(for: .seconds(3))
|
|
}
|
|
}
|
|
}
|
|
|
|
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: {
|
|
HStack {
|
|
Text(" • \(sessionName)")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.primary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
|
|
Spacer()
|
|
|
|
Text("PID: \(session.value.pid)")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.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)
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Copy Session ID") {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(session.key, forType: .string)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|