vibetunnel/mac/VibeTunnel/Presentation/Components/StatusBarController.swift
Jan Remeš ac2f3da586
Better folder selection with git repo discovery (#274)
* added folder selection with git repo discovery

* Increase touch target for repository and folder selection buttons

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2025-07-09 09:24:30 +02:00

282 lines
9.4 KiB
Swift

import AppKit
import Combine
import Network
import Observation
import SwiftUI
/// Manages the macOS status bar item with custom left-click view and right-click menu.
///
/// Central controller for VibeTunnel's menu bar presence, handling status bar icon updates,
/// tooltip management, and coordination between the visual menu interface and context menu.
/// Monitors server and session states to update the status bar appearance accordingly.
@MainActor
final class StatusBarController: NSObject {
// MARK: - Core Properties
private var statusItem: NSStatusItem?
let menuManager: StatusBarMenuManager
// MARK: - Dependencies
private let sessionMonitor: SessionMonitor
private let serverManager: ServerManager
private let ngrokService: NgrokService
private let tailscaleService: TailscaleService
private let terminalLauncher: TerminalLauncher
private let gitRepositoryMonitor: GitRepositoryMonitor
private let repositoryDiscovery: RepositoryDiscoveryService
// MARK: - State Tracking
private var cancellables = Set<AnyCancellable>()
private var updateTimer: Timer?
private let monitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "vibetunnel.network.monitor")
private var hasNetworkAccess = true
// MARK: - Initialization
init(
sessionMonitor: SessionMonitor,
serverManager: ServerManager,
ngrokService: NgrokService,
tailscaleService: TailscaleService,
terminalLauncher: TerminalLauncher,
gitRepositoryMonitor: GitRepositoryMonitor,
repositoryDiscovery: RepositoryDiscoveryService
) {
self.sessionMonitor = sessionMonitor
self.serverManager = serverManager
self.ngrokService = ngrokService
self.tailscaleService = tailscaleService
self.terminalLauncher = terminalLauncher
self.gitRepositoryMonitor = gitRepositoryMonitor
self.repositoryDiscovery = repositoryDiscovery
self.menuManager = StatusBarMenuManager()
super.init()
setupStatusItem()
setupMenuManager()
setupObservers()
startNetworkMonitoring()
}
// MARK: - Setup
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.imagePosition = .imageLeading
button.action = #selector(handleClick(_:))
button.target = self
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
// Use pushOnPushOff for proper state management
button.setButtonType(.toggle)
// Accessibility
button.setAccessibilityTitle("VibeTunnel")
button.setAccessibilityRole(.button)
button.setAccessibilityHelp("Shows terminal sessions and server information")
updateStatusItemDisplay()
}
}
private func setupMenuManager() {
let configuration = StatusBarMenuManager.Configuration(
sessionMonitor: sessionMonitor,
serverManager: serverManager,
ngrokService: ngrokService,
tailscaleService: tailscaleService,
terminalLauncher: terminalLauncher,
gitRepositoryMonitor: gitRepositoryMonitor,
repositoryDiscovery: repositoryDiscovery
)
menuManager.setup(with: configuration)
}
private func setupObservers() {
// Start observing server state changes
observeServerState()
// Create a timer to periodically update the display
// since SessionMonitor doesn't have a publisher
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
_ = await self?.sessionMonitor.getSessions()
self?.updateStatusItemDisplay()
}
}
}
private func observeServerState() {
withObservationTracking {
_ = serverManager.isRunning
} onChange: { [weak self] in
Task { @MainActor in
self?.updateStatusItemDisplay()
// Re-register the observation for continuous tracking
self?.observeServerState()
}
}
}
private func startNetworkMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in
self?.hasNetworkAccess = path.status == .satisfied
self?.updateStatusItemDisplay()
}
}
monitor.start(queue: monitorQueue)
}
// MARK: - Display Updates
func updateStatusItemDisplay() {
guard let button = statusItem?.button else { return }
// Update icon based on server status only
let iconName = serverManager.isRunning ? "menubar" : "menubar.inactive"
if let image = NSImage(named: iconName) {
image.isTemplate = true
button.image = image
} else {
// Fallback to regular icon
if let image = NSImage(named: "menubar") {
image.isTemplate = true
button.image = image
button.alphaValue = serverManager.isRunning ? 1.0 : 0.5
}
}
// Update session count display
let sessions = sessionMonitor.sessions.values.filter(\.isRunning)
let activeSessions = sessions.filter { session in
// Check if session has recent activity (Claude Code or other custom actions)
if let activityStatus = session.activityStatus?.specificStatus?.status {
return !activityStatus.isEmpty
}
return false
}
let activeCount = activeSessions.count
let totalCount = sessions.count
let idleCount = totalCount - activeCount
// Format the title with minimalist indicator
let indicator = formatSessionIndicator(activeCount: activeCount, idleCount: idleCount)
button.title = indicator.isEmpty ? "" : " " + indicator
// Update tooltip
updateTooltip()
}
private func updateTooltip() {
guard let button = statusItem?.button else { return }
var tooltipParts: [String] = []
// Server status
if serverManager.isRunning {
let bindAddress = serverManager.bindAddress
if bindAddress == "127.0.0.1" {
tooltipParts.append("Server: 127.0.0.1:\(serverManager.port)")
} else if let localIP = NetworkUtility.getLocalIPAddress() {
tooltipParts.append("Server: \(localIP):\(serverManager.port)")
}
// ngrok status
if ngrokService.isActive, let publicURL = ngrokService.publicUrl {
tooltipParts.append("ngrok: \(publicURL)")
}
// Tailscale status
if tailscaleService.isRunning, let hostname = tailscaleService.tailscaleHostname {
tooltipParts.append("Tailscale: \(hostname)")
}
} else {
tooltipParts.append("Server stopped")
}
// Session info
let sessions = sessionMonitor.sessions.values.filter(\.isRunning)
if !sessions.isEmpty {
let activeSessions = sessions.filter { session in
if let activityStatus = session.activityStatus?.specificStatus?.status {
return !activityStatus.isEmpty
}
return false
}
let idleCount = sessions.count - activeSessions.count
if !activeSessions.isEmpty {
if idleCount > 0 {
tooltipParts
.append(
"\(activeSessions.count) active, \(idleCount) idle session\(sessions.count == 1 ? "" : "s")"
)
} else {
tooltipParts.append("\(activeSessions.count) active session\(activeSessions.count == 1 ? "" : "s")")
}
} else {
tooltipParts.append("\(sessions.count) idle session\(sessions.count == 1 ? "" : "s")")
}
}
button.toolTip = tooltipParts.joined(separator: "\n")
}
// MARK: - Click Handling
@objc
private func handleClick(_ sender: NSStatusBarButton) {
guard let currentEvent = NSApp.currentEvent else {
handleLeftClick(sender)
return
}
switch currentEvent.type {
case .leftMouseUp:
handleLeftClick(sender)
case .rightMouseUp:
handleRightClick(sender)
default:
handleLeftClick(sender)
}
}
private func handleLeftClick(_ button: NSStatusBarButton) {
menuManager.toggleCustomWindow(relativeTo: button)
}
private func handleRightClick(_ button: NSStatusBarButton) {
guard let statusItem else { return }
menuManager.showContextMenu(for: button, statusItem: statusItem)
}
// MARK: - Public Methods
func showCustomWindow() {
guard let button = statusItem?.button else { return }
menuManager.showCustomWindow(relativeTo: button)
}
func toggleCustomWindow() {
guard let button = statusItem?.button else { return }
menuManager.toggleCustomWindow(relativeTo: button)
}
// MARK: - Cleanup
deinit {
MainActor.assumeIsolated {
updateTimer?.invalidate()
}
monitor.cancel()
}
}