From dab2c6056de8ae5877884ca360cfdae005fa47a9 Mon Sep 17 00:00:00 2001 From: Jeff Hurray Date: Wed, 2 Jul 2025 02:28:15 -1000 Subject: [PATCH] Fix various SessionRow menu bugs (#196) --- .../Core/Services/ServerManager.swift | 10 + .../Core/Services/SessionService.swift | 5 +- .../Core/Utilities/DashboardURLBuilder.swift | 14 + .../Components/NewSessionForm.swift | 2 +- .../Components/StatusBarMenuManager.swift | 2 +- .../Components/VibeTunnelMenuView.swift | 36 +- .../Presentation/Views/MenuBarView.swift | 522 ------------------ .../Presentation/Views/NewSessionView.swift | 434 --------------- 8 files changed, 52 insertions(+), 973 deletions(-) create mode 100644 mac/VibeTunnel/Core/Utilities/DashboardURLBuilder.swift delete mode 100644 mac/VibeTunnel/Presentation/Views/MenuBarView.swift delete mode 100644 mac/VibeTunnel/Presentation/Views/NewSessionView.swift diff --git a/mac/VibeTunnel/Core/Services/ServerManager.swift b/mac/VibeTunnel/Core/Services/ServerManager.swift index 6fc02daa..77b351d4 100644 --- a/mac/VibeTunnel/Core/Services/ServerManager.swift +++ b/mac/VibeTunnel/Core/Services/ServerManager.swift @@ -539,6 +539,16 @@ class ServerManager { } } } + + // MARK: - Authentication + + /// Add authentication headers to a request + func authenticate(request: inout URLRequest) throws { + guard let server = bunServer else { + throw ServerError.startupFailed("Server not running") + } + request.setValue(server.localToken, forHTTPHeaderField: "X-VibeTunnel-Local") + } } // MARK: - Server Manager Error diff --git a/mac/VibeTunnel/Core/Services/SessionService.swift b/mac/VibeTunnel/Core/Services/SessionService.swift index fec57fdb..0cbded2e 100644 --- a/mac/VibeTunnel/Core/Services/SessionService.swift +++ b/mac/VibeTunnel/Core/Services/SessionService.swift @@ -28,6 +28,7 @@ final class SessionService { request.httpMethod = "PATCH" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("localhost", forHTTPHeaderField: "Host") + try serverManager.authenticate(request: &request) let body = ["name": trimmedName] request.httpBody = try JSONEncoder().encode(body) @@ -53,6 +54,7 @@ final class SessionService { var request = URLRequest(url: url) request.httpMethod = "DELETE" request.setValue("localhost", forHTTPHeaderField: "Host") + try serverManager.authenticate(request: &request) let (_, response) = try await URLSession.shared.data(for: request) @@ -91,7 +93,7 @@ final class SessionService { "titleMode": titleMode ] - if let name, !name.isEmpty { + if let name = name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { body["name"] = name } @@ -107,6 +109,7 @@ final class SessionService { request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("localhost", forHTTPHeaderField: "Host") + try serverManager.authenticate(request: &request) request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) diff --git a/mac/VibeTunnel/Core/Utilities/DashboardURLBuilder.swift b/mac/VibeTunnel/Core/Utilities/DashboardURLBuilder.swift new file mode 100644 index 00000000..676f36f8 --- /dev/null +++ b/mac/VibeTunnel/Core/Utilities/DashboardURLBuilder.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Utility for building VibeTunnel dashboard URLs +enum DashboardURLBuilder { + /// Builds the base dashboard URL + /// - Parameters: + /// - port: The server port\ + /// - sessionId: The session ID to open + /// - Returns: The base dashboard URL + static func dashboardURL(port: String, sessionId: String? = nil) -> URL? { + let sessionIDQueryParameter = sessionId.map { "/?session=\($0)" } ?? "" + return URL(string: "http://127.0.0.1:\(port)\(sessionIDQueryParameter)") + } +} diff --git a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift index dfc32aad..4e638941 100644 --- a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift +++ b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift @@ -362,7 +362,7 @@ struct NewSessionForm: View { // If not spawning window, open in browser if !spawnWindow { - if let webURL = URL(string: "http://127.0.0.1:\(serverManager.port)/?sessionId=\(sessionId)") { + if let webURL = DashboardURLBuilder.dashboardURL(port: serverManager.port, sessionId: sessionId) { NSWorkspace.shared.open(webURL) } } diff --git a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift index 95e4feca..3578e22e 100644 --- a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift +++ b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift @@ -278,7 +278,7 @@ final class StatusBarMenuManager: NSObject { @objc private func openDashboard() { guard let serverManager else { return } - if let url = URL(string: "http://127.0.0.1:\(serverManager.port)") { + if let url = DashboardURLBuilder.dashboardURL(port: serverManager.port) { NSWorkspace.shared.open(url) } } diff --git a/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift b/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift index 0d2e480d..95076776 100644 --- a/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift +++ b/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift @@ -433,6 +433,13 @@ struct SessionRow: View { @FocusState private var isEditFieldFocused: Bool var body: some View { + Button(action: handleTap) { + content + } + .buttonStyle(PlainButtonStyle()) + } + + var content: some View { HStack(spacing: 8) { // Activity indicator with subtle glow ZStack { @@ -568,18 +575,6 @@ struct SessionRow: View { .padding(.horizontal, 12) .padding(.vertical, 6) .contentShape(Rectangle()) - .onTapGesture { - guard !isEditing else { return } - - if hasWindow { - WindowTracker.shared.focusWindow(for: session.key) - } else { - // Open browser for sessions without windows - if let url = URL(string: "http://127.0.0.1:\(serverManager.port)/?sessionId=\(session.key)") { - NSWorkspace.shared.open(url) - } - } - } .background( RoundedRectangle(cornerRadius: 6) .fill(isHovered ? hoverBackgroundColor : Color.clear) @@ -601,7 +596,7 @@ struct SessionRow: View { } } else { Button("Open in Browser") { - if let url = URL(string: "http://127.0.0.1:\(serverManager.port)/?sessionId=\(session.key)") { + if let url = DashboardURLBuilder.dashboardURL(port: serverManager.port, sessionId: session.key) { NSWorkspace.shared.open(url) } } @@ -634,6 +629,19 @@ struct SessionRow: View { } } + private func handleTap() { + guard !isEditing else { return } + + if hasWindow { + WindowTracker.shared.focusWindow(for: session.key) + } else { + // Open browser for sessions without windows + if let url = DashboardURLBuilder.dashboardURL(port: serverManager.port, sessionId: session.key) { + NSWorkspace.shared.open(url) + } + } + } + private func terminateSession() { isTerminating = true @@ -821,7 +829,7 @@ struct EmptySessionsView: View { if serverManager.isRunning { Button("Open Dashboard") { - if let url = URL(string: "http://127.0.0.1:\(serverManager.port)") { + if let url = DashboardURLBuilder.dashboardURL(port: serverManager.port) { NSWorkspace.shared.open(url) } } diff --git a/mac/VibeTunnel/Presentation/Views/MenuBarView.swift b/mac/VibeTunnel/Presentation/Views/MenuBarView.swift deleted file mode 100644 index c1951bdc..00000000 --- a/mac/VibeTunnel/Presentation/Views/MenuBarView.swift +++ /dev/null @@ -1,522 +0,0 @@ -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 - } - } -} diff --git a/mac/VibeTunnel/Presentation/Views/NewSessionView.swift b/mac/VibeTunnel/Presentation/Views/NewSessionView.swift deleted file mode 100644 index 08cb6b97..00000000 --- a/mac/VibeTunnel/Presentation/Views/NewSessionView.swift +++ /dev/null @@ -1,434 +0,0 @@ -import AppKit -import SwiftUI - -/// Native macOS dialog for creating new terminal sessions -struct NewSessionView: View { - @Environment(\.dismiss) private var dismiss - @Environment(ServerManager.self) private var serverManager - - // Form fields - @State private var sessionName = "" - @State private var command = "zsh" - @State private var workingDirectory = "~/" - @State private var spawnWindow = true - @State private var titleMode: TitleMode = .dynamic - - // UI state - @State private var isCreating = false - @State private var showError = false - @State private var errorMessage = "" - @FocusState private var focusedField: Field? - - /// Quick commands - private let quickCommands = [ - ("claude", "✨"), - ("aider", "✨"), - ("zsh", nil), - ("python3", nil), - ("node", nil), - ("pnpm run dev", "▶️") - ] - - enum Field: Hashable { - case name - case command - case directory - } - - enum TitleMode: String, CaseIterable { - case none = "none" - case filter = "filter" - case `static` = "static" - case dynamic = "dynamic" - - var displayName: String { - switch self { - case .none: "None" - case .filter: "Filter" - case .static: "Static" - case .dynamic: "Dynamic" - } - } - - var description: String { - switch self { - case .none: "Apps control their own titles" - case .filter: "Block all title changes" - case .static: "Show path and command" - case .dynamic: "Show activity indicators" - } - } - } - - var body: some View { - VStack(spacing: 0) { - // Form content - Form { - // Command Section - Section { - // Command field with integrated name - HStack(alignment: .center, spacing: 12) { - TextField("Command", text: $command) - .textFieldStyle(.squareBorder) - .focused($focusedField, equals: .command) - .onChange(of: command) { _, newValue in - // Auto-select dynamic title mode for AI tools - if newValue.lowercased().contains("claude") || - newValue.lowercased().contains("aider") - { - titleMode = .dynamic - } - } - - // Optional session name - TextField("Session Name (optional)", text: $sessionName) - .textFieldStyle(.squareBorder) - .focused($focusedField, equals: .name) - .frame(width: 160) - } - - // Working Directory - HStack(spacing: 8) { - Text("Working Directory") - .foregroundColor(.secondary) - .frame(width: 120, alignment: .trailing) - - TextField("", text: $workingDirectory) - .textFieldStyle(.squareBorder) - .focused($focusedField, equals: .directory) - - Button(action: selectDirectory) { - Image(systemName: "folder") - } - .buttonStyle(.borderless) - } - } - - // Quick Start Grid - Section("Quick Start") { - LazyVGrid(columns: [ - GridItem(.flexible()), - GridItem(.flexible()), - GridItem(.flexible()) - ], spacing: 8) { - ForEach(quickCommands, id: \.0) { cmd in - Button(action: { - command = cmd.0 - // Clear session name when selecting quick command - sessionName = "" - }) { - HStack(spacing: 4) { - if let emoji = cmd.1 { - Text(emoji) - .font(.system(size: 13)) - } - Text(cmd.0) - .font(.system(size: 12)) - Spacer() - } - .frame(maxWidth: .infinity) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(6) - } - .buttonStyle(.plain) - } - } - .padding(.vertical, 4) - } - - Divider() - .padding(.vertical, 8) - - // Options Section - Section { - // Terminal Title Mode - Single line - HStack(spacing: 12) { - Text("Terminal Title Mode") - .foregroundColor(.secondary) - .frame(width: 120, alignment: .trailing) - - Picker("", selection: $titleMode) { - ForEach(TitleMode.allCases, id: \.self) { mode in - Text(mode.displayName) - .tag(mode) - } - } - .pickerStyle(.segmented) - .frame(width: 200) - - Text(titleMode.description) - .font(.system(size: 11)) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - - // Open in Terminal Window - Toggle on right - HStack { - Text("Open in Terminal Window") - .foregroundColor(.secondary) - .frame(width: 120, alignment: .trailing) - - Text("Launch session in native terminal app") - .font(.system(size: 11)) - .foregroundColor(.secondary) - - Spacer() - - Toggle("", isOn: $spawnWindow) - .toggleStyle(.switch) - .labelsHidden() - } - } - } - .formStyle(.grouped) - .scrollDisabled(true) - .padding(.top, 12) - - // Buttons - HStack { - Button("Cancel") { - dismiss() - } - .keyboardShortcut(.escape, modifiers: []) - - Spacer() - - Button(action: createSession) { - if isCreating { - HStack(spacing: 4) { - ProgressView() - .scaleEffect(0.7) - .controlSize(.small) - Text("Creating...") - } - } else { - Text("Create") - } - } - .keyboardShortcut(.return, modifiers: []) - .disabled(isCreating || !isFormValid) - .buttonStyle(.borderedProminent) - .controlSize(.large) - } - .padding(.horizontal, 20) - .padding(.vertical, 16) - .background(Color(NSColor.controlBackgroundColor)) - } - .frame(width: 620, height: 380) - .background(Color(NSColor.windowBackgroundColor)) - .onAppear { - loadPreferences() - focusedField = .command - } - .alert("Error", isPresented: $showError) { - Button("OK") {} - } message: { - Text(errorMessage) - } - } - - // MARK: - Computed Properties - - private var isFormValid: Bool { - !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !workingDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - // MARK: - Actions - - private func selectDirectory() { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.directoryURL = URL(fileURLWithPath: NSString(string: workingDirectory).expandingTildeInPath) - - if panel.runModal() == .OK, let url = panel.url { - let path = url.path - let homeDir = NSHomeDirectory() - if path.hasPrefix(homeDir) { - workingDirectory = "~" + path.dropFirst(homeDir.count) - } else { - workingDirectory = path - } - } - } - - private func createSession() { - guard isFormValid else { return } - - // Check if server is running - guard serverManager.isRunning else { - errorMessage = "Server is not running. Please start the server first." - showError = true - return - } - - isCreating = true - savePreferences() - - Task { - do { - // Parse command into array - let commandArray = parseCommand(command.trimmingCharacters(in: .whitespacesAndNewlines)) - - // Expand tilde in working directory - let expandedWorkingDir = NSString(string: workingDirectory).expandingTildeInPath - - // Prepare request body - var body: [String: Any] = [ - "command": commandArray, - "workingDir": expandedWorkingDir, - "titleMode": titleMode.rawValue - ] - - if !sessionName.isEmpty { - body["name"] = sessionName.trimmingCharacters(in: .whitespacesAndNewlines) - } - - if spawnWindow { - body["spawn_terminal"] = true - } else { - // Web sessions need terminal dimensions - body["cols"] = 120 - body["rows"] = 30 - } - - // Create session - let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions")! - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("localhost", forHTTPHeaderField: "Host") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await URLSession.shared.data(for: request) - - if let httpResponse = response as? HTTPURLResponse { - if httpResponse.statusCode == 201 { - // Success - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let sessionId = json["id"] as? String - { - // If not spawning window, open in browser - if !spawnWindow { - if let webURL = - URL(string: "http://127.0.0.1:\(serverManager.port)/?sessionId=\(sessionId)") - { - NSWorkspace.shared.open(webURL) - } - } - - await MainActor.run { - dismiss() - } - } - } else { - // Parse error response - var errorMessage = "Failed to create session" - if let errorData = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let error = errorData["error"] as? String - { - errorMessage = error - } - - throw NSError(domain: "VibeTunnel", code: httpResponse.statusCode, userInfo: [ - NSLocalizedDescriptionKey: errorMessage - ]) - } - } else { - throw NSError(domain: "VibeTunnel", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Invalid server response" - ]) - } - } catch { - await MainActor.run { - isCreating = false - errorMessage = error.localizedDescription - showError = true - } - } - } - } - - private func parseCommand(_ cmd: String) -> [String] { - // Simple command parsing that respects quotes - var result: [String] = [] - var current = "" - var inQuotes = false - var quoteChar: Character? - - for char in cmd { - if !inQuotes && (char == "\"" || char == "'") { - inQuotes = true - quoteChar = char - } else if inQuotes && char == quoteChar { - inQuotes = false - quoteChar = nil - } else if !inQuotes && char == " " { - if !current.isEmpty { - result.append(current) - current = "" - } - } else { - current.append(char) - } - } - - if !current.isEmpty { - result.append(current) - } - - return result.isEmpty ? ["zsh"] : result - } - - // MARK: - Preferences - - private func loadPreferences() { - if let savedCommand = UserDefaults.standard.string(forKey: "NewSession.command") { - command = savedCommand - } - if let savedDir = UserDefaults.standard.string(forKey: "NewSession.workingDirectory") { - workingDirectory = savedDir - } - - // Check if spawn window preference has been explicitly set - if UserDefaults.standard.object(forKey: "NewSession.spawnWindow") != nil { - spawnWindow = UserDefaults.standard.bool(forKey: "NewSession.spawnWindow") - } else { - // Default to true if never set - spawnWindow = true - } - - if let savedMode = UserDefaults.standard.string(forKey: "NewSession.titleMode"), - let mode = TitleMode(rawValue: savedMode) - { - titleMode = mode - } - } - - private func savePreferences() { - UserDefaults.standard.set(command, forKey: "NewSession.command") - UserDefaults.standard.set(workingDirectory, forKey: "NewSession.workingDirectory") - UserDefaults.standard.set(spawnWindow, forKey: "NewSession.spawnWindow") - UserDefaults.standard.set(true, forKey: "NewSession.hasSetSpawnWindow") - UserDefaults.standard.set(titleMode.rawValue, forKey: "NewSession.titleMode") - } -} - -// MARK: - Window Scene - -struct NewSessionWindow: Scene { - var body: some Scene { - Window("New Session", id: "new-session") { - NewSessionView() - .environment(ServerManager.shared) - } - .windowStyle(.titleBar) - .windowToolbarStyle(.unified(showsTitle: true)) - .windowResizability(.contentSize) - .defaultPosition(.center) - } -}