mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-26 15:07:39 +00:00
mac
This commit is contained in:
parent
c6a6f67872
commit
be56723fbe
8 changed files with 431 additions and 120 deletions
Binary file not shown.
128
VibeTunnel/Core/Services/SessionMonitor.swift
Normal file
128
VibeTunnel/Core/Services/SessionMonitor.swift
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
//
|
||||
// SessionMonitor.swift
|
||||
// VibeTunnel
|
||||
//
|
||||
// Created by Assistant on 6/16/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Monitors tty-fwd sessions and provides real-time session count
|
||||
@MainActor
|
||||
class SessionMonitor: ObservableObject {
|
||||
static let shared = SessionMonitor()
|
||||
|
||||
@Published var sessionCount: Int = 0
|
||||
@Published var sessions: [String: SessionInfo] = [:]
|
||||
@Published var lastError: String?
|
||||
|
||||
private var timer: Timer?
|
||||
private let refreshInterval: TimeInterval = 5.0 // Check every 5 seconds
|
||||
private var serverPort: Int
|
||||
|
||||
struct SessionInfo: Codable {
|
||||
let cmdline: [String]
|
||||
let cwd: String
|
||||
let exit_code: Int?
|
||||
let name: String
|
||||
let pid: Int
|
||||
let started_at: String
|
||||
let status: String
|
||||
let stdin: String
|
||||
let `stream-out`: String
|
||||
|
||||
var isRunning: Bool {
|
||||
status == "running"
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
let port = UserDefaults.standard.integer(forKey: "serverPort")
|
||||
self.serverPort = port > 0 ? port : 8080
|
||||
}
|
||||
|
||||
func startMonitoring() {
|
||||
stopMonitoring()
|
||||
|
||||
// Update port from UserDefaults in case it changed
|
||||
let port = UserDefaults.standard.integer(forKey: "serverPort")
|
||||
self.serverPort = port > 0 ? port : 8080
|
||||
|
||||
// Initial fetch
|
||||
Task {
|
||||
await fetchSessions()
|
||||
}
|
||||
|
||||
// Set up periodic fetching
|
||||
timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in
|
||||
Task { @MainActor in
|
||||
await self.fetchSessions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopMonitoring() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func fetchSessions() async {
|
||||
do {
|
||||
// First check if server is running
|
||||
let healthURL = URL(string: "http://localhost:\(serverPort)/health")!
|
||||
let healthRequest = URLRequest(url: healthURL, timeoutInterval: 2.0)
|
||||
|
||||
do {
|
||||
let (_, healthResponse) = try await URLSession.shared.data(for: healthRequest)
|
||||
guard let httpResponse = healthResponse as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
// Server not running
|
||||
self.sessions = [:]
|
||||
self.sessionCount = 0
|
||||
self.lastError = nil
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Server not reachable
|
||||
self.sessions = [:]
|
||||
self.sessionCount = 0
|
||||
self.lastError = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Server is running, fetch sessions
|
||||
let url = URL(string: "http://localhost:\(serverPort)/sessions")!
|
||||
let request = URLRequest(url: url, timeoutInterval: 5.0)
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
self.lastError = "Failed to fetch sessions"
|
||||
return
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
let sessionsData = try JSONDecoder().decode([String: SessionInfo].self, from: data)
|
||||
self.sessions = sessionsData
|
||||
|
||||
// Count only running sessions
|
||||
self.sessionCount = sessionsData.values.filter { $0.isRunning }.count
|
||||
self.lastError = nil
|
||||
|
||||
} catch {
|
||||
// Don't set error for connection issues when server is likely not running
|
||||
if !(error is URLError) {
|
||||
self.lastError = "Error fetching sessions: \(error.localizedDescription)"
|
||||
}
|
||||
// Clear sessions on error
|
||||
self.sessions = [:]
|
||||
self.sessionCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
func refreshNow() async {
|
||||
await fetchSessions()
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ public class TunnelClient {
|
|||
|
||||
/// Default base URL for the tunnel server
|
||||
private static let defaultBaseURL: URL = {
|
||||
guard let url = URL(string: "http://localhost:8080") else {
|
||||
guard let url = URL(string: "http://127.0.0.1:8080") else {
|
||||
fatalError("Invalid default base URL - this should never happen with a hardcoded URL")
|
||||
}
|
||||
return url
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@ import NIOCore
|
|||
import NIOPosix
|
||||
import os
|
||||
|
||||
enum ServerError: LocalizedError {
|
||||
case failedToStart(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .failedToStart(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP server implementation for the macOS app
|
||||
@MainActor
|
||||
public final class TunnelServerDemo: ObservableObject {
|
||||
|
|
@ -27,6 +38,8 @@ public final class TunnelServerDemo: ObservableObject {
|
|||
public func start() async throws {
|
||||
guard !isRunning else { return }
|
||||
|
||||
logger.info("Starting TunnelServerDemo on port \(port)")
|
||||
|
||||
do {
|
||||
let router = Router(context: BasicRequestContext.self)
|
||||
|
||||
|
|
@ -170,25 +183,43 @@ public final class TunnelServerDemo: ObservableObject {
|
|||
logger: logger
|
||||
)
|
||||
|
||||
// Store the app reference first
|
||||
self.app = app
|
||||
|
||||
// Run the server in a separate task
|
||||
serverTask = Task { @Sendable [weak self] in
|
||||
// Run the server in a detached task to ensure it keeps running
|
||||
serverTask = Task.detached(priority: .background) { [weak self, logger] in
|
||||
do {
|
||||
logger.info("Starting Hummingbird application...")
|
||||
try await app.run()
|
||||
logger.info("Hummingbird application stopped")
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
logger.error("Hummingbird error: \(error)")
|
||||
await MainActor.run { [weak self] in
|
||||
self?.lastError = error
|
||||
self?.isRunning = false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Give the server a moment to start
|
||||
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
||||
// Wait for the server to actually start listening
|
||||
var serverStarted = false
|
||||
for _ in 0..<10 { // Try for up to 1 second
|
||||
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
||||
|
||||
// Check if the server is actually listening
|
||||
if await isServerListening(on: port) {
|
||||
serverStarted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
isRunning = true
|
||||
logger.info("Server started on port \(port)")
|
||||
if serverStarted {
|
||||
isRunning = true
|
||||
logger.info("Server started and listening on port \(port)")
|
||||
} else {
|
||||
throw ServerError.failedToStart("Server did not start listening on port \(port)")
|
||||
}
|
||||
|
||||
} catch {
|
||||
lastError = error
|
||||
|
|
@ -211,4 +242,20 @@ public final class TunnelServerDemo: ObservableObject {
|
|||
|
||||
isRunning = false
|
||||
}
|
||||
|
||||
/// Check if the server is actually listening on the specified port
|
||||
private func isServerListening(on port: Int) async -> Bool {
|
||||
do {
|
||||
let url = URL(string: "http://localhost:\(port)/health")!
|
||||
let request = URLRequest(url: url, timeoutInterval: 1.0)
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
return httpResponse.statusCode == 200
|
||||
}
|
||||
} catch {
|
||||
// Server not yet ready
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -59,7 +59,7 @@ struct SettingsView: View {
|
|||
}
|
||||
.tag(SettingsTab.about)
|
||||
}
|
||||
.frame(minWidth: 400, idealWidth: 400, minHeight: 400, idealHeight: 500)
|
||||
.frame(minWidth: 200, idealWidth: 200, minHeight: 400, idealHeight: 400)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openSettingsTab)) { notification in
|
||||
if let tab = notification.object as? SettingsTab {
|
||||
selectedTab = tab
|
||||
|
|
@ -155,12 +155,6 @@ struct AdvancedSettingsView: View {
|
|||
private var updateChannelRaw = UpdateChannel.stable.rawValue
|
||||
|
||||
@State private var isCheckingForUpdates = false
|
||||
@StateObject private var tunnelServer: TunnelServerDemo
|
||||
|
||||
init() {
|
||||
let port = Int(UserDefaults.standard.string(forKey: "serverPort") ?? "8080") ?? 8_080
|
||||
_tunnelServer = StateObject(wrappedValue: TunnelServerDemo(port: port))
|
||||
}
|
||||
|
||||
var updateChannel: UpdateChannel {
|
||||
UpdateChannel(rawValue: updateChannelRaw) ?? .stable
|
||||
|
|
@ -212,52 +206,22 @@ struct AdvancedSettingsView: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
// Tunnel Server
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Tunnel Server")
|
||||
if tunnelServer.isRunning {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
Text(tunnelServer
|
||||
.isRunning ? "Server is running on port \(serverPort)" : "Server is stopped"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(tunnelServer.isRunning ? "Stop" : "Start") {
|
||||
toggleServer()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(tunnelServer.isRunning ? .red : .blue)
|
||||
}
|
||||
|
||||
if tunnelServer.isRunning, let serverURL = URL(string: "http://localhost:\(serverPort)") {
|
||||
Link("Open in Browser", destination: serverURL)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Server port:")
|
||||
TextField("", text: $serverPort)
|
||||
.frame(width: 80)
|
||||
.disabled(tunnelServer.isRunning)
|
||||
.onChange(of: serverPort) { oldValue, newValue in
|
||||
// Validate port number
|
||||
if let port = Int(newValue), port > 0, port < 65536 {
|
||||
restartServerWithNewPort(port)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("The port used for the local tunnel server. Restart server to apply changes.")
|
||||
Text("The server will automatically restart when the port is changed.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} header: {
|
||||
Text("Server")
|
||||
.font(.headline)
|
||||
|
|
@ -306,23 +270,36 @@ struct AdvancedSettingsView: View {
|
|||
isCheckingForUpdates = false
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleServer() {
|
||||
|
||||
private func restartServerWithNewPort(_ port: Int) {
|
||||
guard let appDelegate = NSApp.delegate as? AppDelegate else { return }
|
||||
|
||||
Task {
|
||||
if tunnelServer.isRunning {
|
||||
try await tunnelServer.stop()
|
||||
} else {
|
||||
do {
|
||||
try await tunnelServer.start()
|
||||
} catch {
|
||||
// Show error alert
|
||||
await MainActor.run {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Failed to Start Server"
|
||||
alert.informativeText = error.localizedDescription
|
||||
alert.alertStyle = .critical
|
||||
alert.runModal()
|
||||
}
|
||||
// Stop the current server if running
|
||||
if let server = appDelegate.httpServer, server.isRunning {
|
||||
try? await server.stop()
|
||||
}
|
||||
|
||||
// Create and start new server with the new port
|
||||
let newServer = TunnelServerDemo(port: port)
|
||||
appDelegate.setHTTPServer(newServer)
|
||||
|
||||
do {
|
||||
try await newServer.start()
|
||||
print("Server restarted on port \(port)")
|
||||
|
||||
// Restart session monitoring with new port
|
||||
SessionMonitor.shared.stopMonitoring()
|
||||
SessionMonitor.shared.startMonitoring()
|
||||
} catch {
|
||||
print("Failed to restart server on port \(port): \(error)")
|
||||
// Show error alert
|
||||
await MainActor.run {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Failed to Restart Server"
|
||||
alert.informativeText = "Could not start server on port \(port): \(error.localizedDescription)"
|
||||
alert.alertStyle = .critical
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import SwiftUI
|
|||
struct VibeTunnelApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self)
|
||||
var appDelegate
|
||||
@StateObject private var sessionMonitor = SessionMonitor.shared
|
||||
|
||||
var body: some Scene {
|
||||
#if os(macOS)
|
||||
|
|
@ -18,6 +19,14 @@ struct VibeTunnelApp: App {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarExtra {
|
||||
MenuBarView()
|
||||
.environmentObject(sessionMonitor)
|
||||
} label: {
|
||||
Image("menubar")
|
||||
.renderingMode(.template)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -27,8 +36,8 @@ struct VibeTunnelApp: App {
|
|||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
|
||||
private var statusItem: NSStatusItem?
|
||||
private(set) var httpServer: TunnelServerDemo?
|
||||
private let sessionMonitor = SessionMonitor.shared
|
||||
|
||||
/// Distributed notification name used to ask an existing instance to show the Settings window.
|
||||
private static let showSettingsNotification = Notification.Name("com.amantus.vibetunnel.showSettings")
|
||||
|
|
@ -53,9 +62,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
|
||||
NSApp.setActivationPolicy(showInDock ? .regular : .accessory)
|
||||
|
||||
// Setup status item (menu bar icon)
|
||||
setupStatusItem()
|
||||
|
||||
// Show settings on first launch or when no window is open
|
||||
if !showInDock {
|
||||
// For menu bar apps, we need to ensure the settings window is accessible
|
||||
|
|
@ -75,15 +81,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
)
|
||||
|
||||
// Initialize and start HTTP server
|
||||
let serverPort = UserDefaults.standard.integer(forKey: "httpServerPort")
|
||||
let serverPort = UserDefaults.standard.integer(forKey: "serverPort")
|
||||
httpServer = TunnelServerDemo(port: serverPort > 0 ? serverPort : 8080)
|
||||
|
||||
Task {
|
||||
do {
|
||||
print("Attempting to start HTTP server on port \(httpServer?.port ?? 8080)...")
|
||||
try await httpServer?.start()
|
||||
print("HTTP server started automatically on port \(httpServer?.port ?? 8080)")
|
||||
print("HTTP server started successfully on port \(httpServer?.port ?? 8080)")
|
||||
print("Server is running: \(httpServer?.isRunning ?? false)")
|
||||
|
||||
// Start monitoring sessions after server starts
|
||||
sessionMonitor.startMonitoring()
|
||||
|
||||
// Test the server after a short delay
|
||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
|
||||
if let url = URL(string: "http://localhost:\(httpServer?.port ?? 8080)/health") {
|
||||
let (_, response) = try await URLSession.shared.data(from: url)
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
print("Server health check response: \(httpResponse.statusCode)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Failed to start HTTP server: \(error)")
|
||||
print("Error type: \(type(of: error))")
|
||||
print("Error description: \(error.localizedDescription)")
|
||||
if let nsError = error as NSError? {
|
||||
print("NSError domain: \(nsError.domain)")
|
||||
print("NSError code: \(nsError.code)")
|
||||
print("NSError userInfo: \(nsError.userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -139,6 +166,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
// Stop session monitoring
|
||||
sessionMonitor.stopMonitoring()
|
||||
|
||||
// Stop HTTP server
|
||||
Task {
|
||||
try? await httpServer?.stop()
|
||||
|
|
@ -167,52 +197,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
)
|
||||
}
|
||||
|
||||
// MARK: - Status Item
|
||||
|
||||
private func setupStatusItem() {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
|
||||
if let button = statusItem?.button {
|
||||
button.image = NSImage(named: "menubar")
|
||||
button.image?.isTemplate = true
|
||||
button.action = #selector(statusItemClicked)
|
||||
button.target = self
|
||||
}
|
||||
|
||||
// Create menu
|
||||
let menu = NSMenu()
|
||||
|
||||
let settingsItem = NSMenuItem(title: "Settings\u{2026}", action: #selector(showSettings), keyEquivalent: ",")
|
||||
settingsItem.target = self
|
||||
menu.addItem(settingsItem)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let aboutItem = NSMenuItem(title: "About VibeTunnel", action: #selector(showAbout), keyEquivalent: "")
|
||||
aboutItem.target = self
|
||||
menu.addItem(aboutItem)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
|
||||
|
||||
statusItem?.menu = menu
|
||||
}
|
||||
|
||||
@objc
|
||||
private func statusItemClicked() {
|
||||
// Left click shows menu
|
||||
}
|
||||
|
||||
@objc
|
||||
private func showSettings() {
|
||||
NSApp.openSettings()
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func showAbout() {
|
||||
showAboutInSettings()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the About section in the Settings window
|
||||
|
|
|
|||
173
VibeTunnel/Views/MenuBarView.swift
Normal file
173
VibeTunnel/Views/MenuBarView.swift
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
//
|
||||
// MenuBarView.swift
|
||||
// VibeTunnel
|
||||
//
|
||||
// SwiftUI menu bar implementation
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MenuBarView: View {
|
||||
@EnvironmentObject var sessionMonitor: SessionMonitor
|
||||
@AppStorage("showInDock") private var showInDock = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Session count header
|
||||
SessionCountView(count: sessionMonitor.sessionCount)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
// Session list
|
||||
if sessionMonitor.sessionCount > 0 {
|
||||
SessionListView(sessions: sessionMonitor.sessions)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// Settings button
|
||||
Button(action: {
|
||||
NSApp.openSettings()
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}) {
|
||||
Label("Settings…", systemImage: "gear")
|
||||
}
|
||||
.buttonStyle(MenuButtonStyle())
|
||||
.keyboardShortcut(",", modifiers: .command)
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// About button
|
||||
Button(action: {
|
||||
showAboutInSettings()
|
||||
}) {
|
||||
Label("About VibeTunnel", systemImage: "info.circle")
|
||||
}
|
||||
.buttonStyle(MenuButtonStyle())
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// Quit button
|
||||
Button(action: {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}) {
|
||||
Label("Quit", systemImage: "power")
|
||||
}
|
||||
.buttonStyle(MenuButtonStyle())
|
||||
.keyboardShortcut("q", modifiers: .command)
|
||||
}
|
||||
.frame(minWidth: 200)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Count View
|
||||
|
||||
struct SessionCountView: View {
|
||||
let count: Int
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "terminal")
|
||||
.foregroundColor(.secondary)
|
||||
Text(sessionText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var sessionText: String {
|
||||
count == 1 ? "1 active session" : "\(count) active sessions"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session List View
|
||||
|
||||
struct SessionListView: View {
|
||||
let sessions: [String: SessionMonitor.SessionInfo]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(Array(activeSessions.prefix(5)), id: \.key) { session in
|
||||
SessionRowView(session: session)
|
||||
}
|
||||
|
||||
if activeSessions.count > 5 {
|
||||
HStack {
|
||||
Text(" • ...")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeSessions: [(key: String, value: SessionMonitor.SessionInfo)] {
|
||||
sessions.filter { $0.value.isRunning }
|
||||
.sorted { $0.value.started_at > $1.value.started_at }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Row View
|
||||
|
||||
struct SessionRowView: View {
|
||||
let session: (key: String, value: SessionMonitor.SessionInfo)
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(" • \(sessionName)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var sessionName: String {
|
||||
session.value.name.isEmpty ? session.value.cmdline.first ?? "Unknown" : session.value.name
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu Button Style
|
||||
|
||||
struct MenuButtonStyle: ButtonStyle {
|
||||
@State private var isHovered = false
|
||||
|
||||
func makeBody(configuration: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Functions
|
||||
|
||||
/// Shows the About section in the Settings window
|
||||
private func showAboutInSettings() {
|
||||
NSApp.openSettings()
|
||||
Task {
|
||||
// Small delay to ensure the settings window is fully initialized
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
||||
NotificationCenter.default.post(
|
||||
name: .openSettingsTab,
|
||||
object: SettingsTab.about
|
||||
)
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue