Fix various SessionRow menu bugs (#196)

This commit is contained in:
Jeff Hurray 2025-07-02 02:28:15 -10:00 committed by GitHub
parent f602f2936e
commit dab2c6056d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 52 additions and 973 deletions

View file

@ -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

View file

@ -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)

View file

@ -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)")
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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)
}
}