vibetunnel/ios/VibeTunnel/Views/Sessions/SessionCreateView.swift
Peter Steinberger baaaa5a033 fix: CI and linting issues across all platforms
- Fix code signing in Mac and iOS test workflows
- Fix all SwiftFormat and SwiftLint issues
- Fix ESLint issues in web code
- Remove force casts and unwrapping in Swift code
- Update build scripts to use correct file paths
2025-06-23 19:40:53 +02:00

426 lines
20 KiB
Swift

import SwiftUI
private let logger = Logger(category: "SessionCreate")
/// Custom text field style for terminal-like appearance.
///
/// Applies terminal-themed styling to text fields including
/// monospace font, dark background, and subtle border.
struct TerminalTextFieldStyle: TextFieldStyle {
// swiftlint:disable:next identifier_name
func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.font(Theme.Typography.terminalSystem(size: 16))
.foregroundColor(Theme.Colors.terminalForeground)
.padding()
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.fill(Theme.Colors.cardBackground)
)
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
)
}
}
/// View for creating new terminal sessions.
///
/// Provides form inputs for command, working directory, and session name
/// with file browser integration for directory selection.
struct SessionCreateView: View {
@Binding var isPresented: Bool
let onCreated: (String) -> Void
@State private var command = "zsh"
@State private var workingDirectory = "~/"
@State private var sessionName = ""
@State private var isCreating = false
@State private var presentedError: IdentifiableError?
@State private var showFileBrowser = false
@FocusState private var focusedField: Field?
@Environment(\.horizontalSizeClass)
private var horizontalSizeClass
enum Field {
case command
case workingDir
case name
}
var body: some View {
NavigationStack {
ZStack {
Theme.Colors.terminalBackground
.ignoresSafeArea()
ScrollView {
VStack(spacing: Theme.Spacing.large) {
// Configuration Fields
VStack(spacing: Theme.Spacing.large) {
// Command Field
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
Label("Command", systemImage: "terminal")
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.primaryAccent)
TextField("zsh", text: $command)
.textFieldStyle(TerminalTextFieldStyle())
.autocapitalization(.none)
.disableAutocorrection(true)
.focused($focusedField, equals: .command)
}
// Working Directory
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
Label("Working Directory", systemImage: "folder")
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.primaryAccent)
HStack(spacing: Theme.Spacing.small) {
TextField("~/", text: $workingDirectory)
.textFieldStyle(TerminalTextFieldStyle())
.autocapitalization(.none)
.disableAutocorrection(true)
.focused($focusedField, equals: .workingDir)
Button(action: {
HapticFeedback.impact(.light)
showFileBrowser = true
}, label: {
Image(systemName: "folder")
.font(.system(size: 16))
.foregroundColor(Theme.Colors.primaryAccent)
.frame(width: 44, height: 44)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.fill(Theme.Colors.primaryAccent)
)
})
.buttonStyle(PlainButtonStyle())
}
}
// Session Name
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
Label("Session Name (Optional)", systemImage: "tag")
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.primaryAccent)
TextField("My Session", text: $sessionName)
.textFieldStyle(TerminalTextFieldStyle())
.focused($focusedField, equals: .name)
}
// Error Message
if presentedError != nil {
ErrorBanner(
message: presentedError?.error.localizedDescription ?? "An error occurred"
) {
presentedError = nil
}
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.stroke(Theme.Colors.errorAccent.opacity(0.3), lineWidth: 1)
)
.transition(.asymmetric(
insertion: .scale(scale: 0.95).combined(with: .opacity),
removal: .scale(scale: 0.95).combined(with: .opacity)
))
}
}
.padding(.horizontal)
.padding(.top, Theme.Spacing.large)
// Quick Directories
if focusedField == .workingDir {
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
Text("COMMON DIRECTORIES")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
.tracking(1)
.padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.small) {
ForEach(commonDirectories, id: \.self) { dir in
Button(action: {
workingDirectory = dir
HapticFeedback.selection()
}, label: {
HStack(spacing: 4) {
Image(systemName: "folder.fill")
.font(.system(size: 12))
Text(dir)
.font(Theme.Typography.terminalSystem(size: 13))
}
.foregroundColor(workingDirectory == dir ? Theme.Colors
.terminalBackground : Theme.Colors.terminalForeground
)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.fill(workingDirectory == dir ? Theme.Colors
.primaryAccent : Theme.Colors.cardBorder.opacity(0.1)
)
)
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.stroke(
workingDirectory == dir ? Theme.Colors.primaryAccent : Theme
.Colors.cardBorder.opacity(0.3),
lineWidth: 1
)
)
})
.buttonStyle(PlainButtonStyle())
}
}
.padding(.horizontal)
}
}
}
// Quick Start Commands
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
Text("QUICK START")
.font(Theme.Typography.terminalSystem(size: 11))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.4))
.tracking(0.5)
.padding(.horizontal)
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: Theme.Spacing.small) {
ForEach(quickStartCommands, id: \.title) { item in
Button(action: {
command = item.command
HapticFeedback.selection()
}, label: {
HStack(spacing: Theme.Spacing.small) {
Image(systemName: item.icon)
.font(.system(size: 16))
.frame(width: 20)
Text(item.title)
.font(Theme.Typography.terminalSystem(size: 15))
Spacer()
}
.foregroundColor(command == item.command ? Theme.Colors
.terminalBackground : Theme.Colors
.terminalForeground
)
.padding(.horizontal, Theme.Spacing.medium)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.fill(command == item.command ? Theme.Colors.primaryAccent : Theme
.Colors
.cardBackground
)
)
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(
command == item.command ? Theme.Colors.primaryAccent : Theme.Colors
.cardBorder.opacity(0.3),
lineWidth: 1
)
)
})
.buttonStyle(PlainButtonStyle())
}
}
.padding(.horizontal)
}
Spacer(minLength: 40)
}
.frame(maxWidth: horizontalSizeClass == .regular ? 600 : .infinity)
.frame(maxWidth: .infinity)
}
}
.navigationBarHidden(true)
.safeAreaInset(edge: .top) {
// Custom Navigation Bar with proper safe area handling
ZStack {
// Background with blur and transparency
Rectangle()
.fill(.ultraThinMaterial)
.background(Theme.Colors.terminalBackground.opacity(0.5))
// Content
HStack {
Button(action: {
HapticFeedback.impact(.light)
isPresented = false
}, label: {
Text("Cancel")
.font(.system(size: 17))
.foregroundColor(Theme.Colors.errorAccent)
})
.buttonStyle(PlainButtonStyle())
Spacer()
Text("New Session")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(Theme.Colors.terminalForeground)
Spacer()
Button(action: {
HapticFeedback.impact(.medium)
createSession()
}, label: {
if isCreating {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
.scaleEffect(0.8)
} else {
Text("Create")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(command.isEmpty ? Theme.Colors.primaryAccent.opacity(0.5) : Theme
.Colors.primaryAccent
)
}
})
.buttonStyle(PlainButtonStyle())
.disabled(isCreating || command.isEmpty)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.frame(height: 56) // Fixed height for the header
.overlay(
// Subtle bottom border
Rectangle()
.fill(Theme.Colors.cardBorder.opacity(0.15))
.frame(height: 0.5),
alignment: .bottom
)
}
.onAppear {
loadDefaults()
focusedField = .command
}
}
.preferredColorScheme(.dark)
.sheet(isPresented: $showFileBrowser) {
FileBrowserView(initialPath: workingDirectory) { selectedPath in
workingDirectory = selectedPath
HapticFeedback.notification(.success)
}
}
.errorAlert(item: $presentedError)
}
private struct QuickStartItem {
let title: String
let command: String
let icon: String
}
private var quickStartCommands: [QuickStartItem] {
[
QuickStartItem(title: "claude", command: "claude", icon: "sparkle"),
QuickStartItem(title: "zsh", command: "zsh", icon: "terminal"),
QuickStartItem(title: "bash", command: "bash", icon: "terminal.fill"),
QuickStartItem(title: "python3", command: "python3", icon: "chevron.left.forwardslash.chevron.right"),
QuickStartItem(title: "node", command: "node", icon: "server.rack"),
QuickStartItem(title: "npm run dev", command: "npm run dev", icon: "play.circle")
]
}
private var commonDirectories: [String] {
["~/", "~/Desktop", "~/Documents", "~/Downloads", "~/Projects", "/tmp"]
}
private func commandIcon(for command: String) -> String {
switch command {
case "claude":
"sparkle"
case "zsh", "bash":
"terminal"
case "python3":
"chevron.left.forwardslash.chevron.right"
case "node":
"server.rack"
case "npm run dev":
"play.circle"
case "irb":
"diamond"
default:
"terminal"
}
}
private func loadDefaults() {
// Load last used values matching web behavior
if let lastCommand = UserDefaults.standard.string(forKey: "vibetunnel_last_command"), !lastCommand.isEmpty {
command = lastCommand
} else {
// Default to zsh
command = "zsh"
}
if let lastDir = UserDefaults.standard.string(forKey: "vibetunnel_last_working_dir"), !lastDir.isEmpty {
workingDirectory = lastDir
} else {
// Default to home directory
workingDirectory = "~/"
}
// Match the web's selectedQuickStart behavior
if quickStartCommands.contains(where: { $0.command == command }) {
// Command matches a quick start option
}
}
private func createSession() {
isCreating = true
presentedError = nil
// Save preferences matching web localStorage keys
UserDefaults.standard.set(command, forKey: "vibetunnel_last_command")
UserDefaults.standard.set(workingDirectory, forKey: "vibetunnel_last_working_dir")
Task {
do {
let sessionData = SessionCreateData(
command: command,
workingDir: workingDirectory.isEmpty ? "~" : workingDirectory,
name: sessionName.isEmpty ? nil : sessionName
)
// Log the request for debugging
logger.info("Creating session with data:")
logger.debug(" Command: \(sessionData.command)")
logger.debug(" Working Dir: \(sessionData.workingDir)")
logger.debug(" Name: \(sessionData.name ?? "nil")")
logger.debug(" Spawn Terminal: \(sessionData.spawnTerminal ?? false)")
logger.debug(" Cols: \(sessionData.cols ?? 0), Rows: \(sessionData.rows ?? 0)")
let sessionId = try await SessionService.shared.createSession(sessionData)
logger.info("Session created successfully with ID: \(sessionId)")
await MainActor.run {
onCreated(sessionId)
isPresented = false
}
} catch {
logger.error("Failed to create session: \(error)")
if let apiError = error as? APIError {
logger.error(" API Error: \(apiError)")
}
await MainActor.run {
presentedError = IdentifiableError(error: error)
isCreating = false
}
}
}
}
}