From 610e3c0c4372a2aef5e28030f61624b53e5fea49 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Jun 2025 11:32:32 +0200 Subject: [PATCH] lint+format --- ios/.swiftlint.yml | 39 ++++ ios/Package.swift | 9 +- ios/VibeTunnel/App/ContentView.swift | 4 +- ios/VibeTunnel/App/VibeTunnelApp.swift | 22 +- ios/VibeTunnel/Models/CastFile.swift | 100 ++++---- ios/VibeTunnel/Models/FileEntry.swift | 20 +- ios/VibeTunnel/Models/ServerConfig.swift | 21 +- ios/VibeTunnel/Models/Session.swift | 40 +++- ios/VibeTunnel/Models/TerminalData.swift | 29 +-- ios/VibeTunnel/Models/TerminalSnapshot.swift | 18 +- ios/VibeTunnel/Services/APIClient.swift | 170 +++++++------- .../Services/BufferWebSocketClient.swift | 145 ++++++------ ios/VibeTunnel/Services/SessionService.swift | 22 +- ios/VibeTunnel/Utils/Theme.swift | 128 ++++++----- ios/VibeTunnel/Views/Common/LoadingView.swift | 10 +- .../Views/Connection/ConnectionView.swift | 46 ++-- .../Views/Connection/ServerConfigForm.swift | 73 +++--- ios/VibeTunnel/Views/FileBrowserView.swift | 101 +++++---- .../Views/Sessions/SessionCardView.swift | 76 ++++--- .../Views/Sessions/SessionCreateView.swift | 214 ++++++++++-------- .../Views/Sessions/SessionListView.swift | 172 +++++++------- .../Views/Terminal/CastPlayerView.swift | 156 ++++++------- .../Views/Terminal/FontSizeSheet.swift | 54 +++-- .../Views/Terminal/RecordingExportSheet.swift | 47 ++-- .../Views/Terminal/TerminalHostingView.swift | 136 ++++++----- .../Views/Terminal/TerminalToolbar.swift | 86 +++---- .../Views/Terminal/TerminalView.swift | 141 ++++++------ 27 files changed, 1118 insertions(+), 961 deletions(-) create mode 100644 ios/.swiftlint.yml diff --git a/ios/.swiftlint.yml b/ios/.swiftlint.yml new file mode 100644 index 00000000..2c82e9dc --- /dev/null +++ b/ios/.swiftlint.yml @@ -0,0 +1,39 @@ +# SwiftLint configuration for VibeTunnel iOS + +# Adjust file length thresholds +file_length: + warning: 800 + error: 1000 + ignore_comment_only_lines: true + +# Adjust type body length thresholds +type_body_length: + warning: 500 + error: 800 + +# Keep other rules at their defaults +line_length: + warning: 120 + error: 200 + ignores_urls: true + ignores_function_declarations: true + ignores_comments: true + +# Opt-in rules +opt_in_rules: + - empty_count + - empty_string + - first_where + - force_unwrapping + - implicitly_unwrapped_optional + - last_where + - reduce_boolean + - reduce_into + - yoda_condition + +# Excluded paths +excluded: + - .build + - Package.swift + - VibeTunnel.xcodeproj + - VibeTunnelTests \ No newline at end of file diff --git a/ios/Package.swift b/ios/Package.swift index fbd5f7c7..62d973ff 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -9,17 +9,18 @@ let package = Package( products: [ .library( name: "VibeTunnelDependencies", - targets: ["VibeTunnelDependencies"]) + targets: ["VibeTunnelDependencies"] + ) ], dependencies: [ - .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0"), + .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0") ], targets: [ .target( name: "VibeTunnelDependencies", dependencies: [ - .product(name: "SwiftTerm", package: "SwiftTerm"), + .product(name: "SwiftTerm", package: "SwiftTerm") ] ) ] -) \ No newline at end of file +) diff --git a/ios/VibeTunnel/App/ContentView.swift b/ios/VibeTunnel/App/ContentView.swift index a0f3e8e4..28c51e4d 100644 --- a/ios/VibeTunnel/App/ContentView.swift +++ b/ios/VibeTunnel/App/ContentView.swift @@ -6,7 +6,7 @@ struct ContentView: View { @State private var showingFilePicker = false @State private var showingCastPlayer = false @State private var selectedCastFile: URL? - + var body: some View { Group { if connectionManager.isConnected, connectionManager.serverConfig != nil { @@ -29,4 +29,4 @@ struct ContentView: View { } } } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/App/VibeTunnelApp.swift b/ios/VibeTunnel/App/VibeTunnelApp.swift index 3cfdde34..dd2d55fb 100644 --- a/ios/VibeTunnel/App/VibeTunnelApp.swift +++ b/ios/VibeTunnel/App/VibeTunnelApp.swift @@ -1,11 +1,11 @@ -import SwiftUI import Observation +import SwiftUI @main struct VibeTunnelApp: App { @State private var connectionManager = ConnectionManager() @State private var navigationManager = NavigationManager() - + var body: some Scene { WindowGroup { ContentView() @@ -16,11 +16,11 @@ struct VibeTunnelApp: App { } } } - + private func handleURL(_ url: URL) { // Handle vibetunnel://session/{sessionId} URLs guard url.scheme == "vibetunnel" else { return } - + if url.host == "session", let sessionId = url.pathComponents.last, !sessionId.isEmpty { @@ -33,25 +33,25 @@ struct VibeTunnelApp: App { class ConnectionManager { var isConnected: Bool = false var serverConfig: ServerConfig? - + init() { loadSavedConnection() } - + private func loadSavedConnection() { if let data = UserDefaults.standard.data(forKey: "savedServerConfig"), let config = try? JSONDecoder().decode(ServerConfig.self, from: data) { self.serverConfig = config } } - + func saveConnection(_ config: ServerConfig) { if let data = try? JSONEncoder().encode(config) { UserDefaults.standard.set(data, forKey: "savedServerConfig") self.serverConfig = config } } - + func disconnect() { isConnected = false } @@ -61,14 +61,14 @@ class ConnectionManager { class NavigationManager { var selectedSessionId: String? var shouldNavigateToSession: Bool = false - + func navigateToSession(_ sessionId: String) { selectedSessionId = sessionId shouldNavigateToSession = true } - + func clearNavigation() { selectedSessionId = nil shouldNavigateToSession = false } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Models/CastFile.swift b/ios/VibeTunnel/Models/CastFile.swift index 31e2a5d4..2f6672e6 100644 --- a/ios/VibeTunnel/Models/CastFile.swift +++ b/ios/VibeTunnel/Models/CastFile.swift @@ -1,7 +1,20 @@ import Foundation import Observation -// Asciinema cast v2 format support +/// Cast file theme configuration +struct CastTheme: Codable { + let foreground: String? + let background: String? + let palette: String? + + enum CodingKeys: String, CodingKey { + case foreground = "fg" + case background = "bg" + case palette + } +} + +/// Asciinema cast v2 format support struct CastFile: Codable { let version: Int let width: Int @@ -10,12 +23,6 @@ struct CastFile: Codable { let title: String? let env: [String: String]? let theme: CastTheme? - - struct CastTheme: Codable { - let fg: String? - let bg: String? - let palette: String? - } } struct CastEvent: Codable { @@ -24,72 +31,72 @@ struct CastEvent: Codable { let data: String } -// Cast file recorder for terminal sessions +/// Cast file recorder for terminal sessions @MainActor @Observable class CastRecorder { var isRecording = false var recordingStartTime: Date? var events: [CastEvent] = [] - + private let sessionId: String private let width: Int private let height: Int private var startTime: TimeInterval = 0 - + init(sessionId: String, width: Int = 80, height: Int = 24) { self.sessionId = sessionId self.width = width self.height = height } - + func startRecording() { guard !isRecording else { return } - + isRecording = true recordingStartTime = Date() startTime = Date().timeIntervalSince1970 events.removeAll() } - + func stopRecording() { guard isRecording else { return } - + isRecording = false recordingStartTime = nil } - + func recordOutput(_ data: String) { guard isRecording else { return } - + let currentTime = Date().timeIntervalSince1970 let relativeTime = currentTime - startTime - + let event = CastEvent( time: relativeTime, type: "o", // output data: data ) - + events.append(event) } - + func recordResize(cols: Int, rows: Int) { guard isRecording else { return } - + let currentTime = Date().timeIntervalSince1970 let relativeTime = currentTime - startTime - + let resizeData = "\(cols)x\(rows)" let event = CastEvent( time: relativeTime, type: "r", // resize data: resizeData ) - + events.append(event) } - + func exportCastFile() -> Data? { // Create header let header = CastFile( @@ -101,75 +108,78 @@ class CastRecorder { env: ["TERM": "xterm-256color", "SHELL": "/bin/zsh"], theme: nil ) - + guard let headerData = try? JSONEncoder().encode(header), - let headerString = String(data: headerData, encoding: .utf8) else { + let headerString = String(data: headerData, encoding: .utf8) + else { return nil } - + // Build the cast file content var castContent = headerString + "\n" - + // Add all events for event in events { // Cast events are encoded as arrays [time, type, data] let eventArray: [Any] = [event.time, event.type, event.data] - + if let jsonData = try? JSONSerialization.data(withJSONObject: eventArray), let jsonString = String(data: jsonData, encoding: .utf8) { castContent += jsonString + "\n" } } - + return castContent.data(using: .utf8) } } -// Cast file player for imported recordings +/// Cast file player for imported recordings class CastPlayer { let header: CastFile let events: [CastEvent] - + init?(data: Data) { guard let content = String(data: data, encoding: .utf8) else { return nil } - + let lines = content.components(separatedBy: .newlines) guard !lines.isEmpty else { return nil } - + // Parse header (first line) guard let headerData = lines[0].data(using: .utf8), - let header = try? JSONDecoder().decode(CastFile.self, from: headerData) else { + let header = try? JSONDecoder().decode(CastFile.self, from: headerData) + else { return nil } - + // Parse events (remaining lines) var parsedEvents: [CastEvent] = [] - for i in 1..= 3, let time = array[0] as? Double, let type = array[1] as? String, - let data = array[2] as? String else { + let data = array[2] as? String + else { continue } - + let event = CastEvent(time: time, type: type, data: data) parsedEvents.append(event) } - + self.header = header self.events = parsedEvents } - + var duration: TimeInterval { events.last?.time ?? 0 } - + func play(onEvent: @escaping @Sendable (CastEvent) -> Void, completion: @escaping @Sendable () -> Void) { let eventsToPlay = self.events Task { @Sendable in @@ -178,15 +188,15 @@ class CastPlayer { if event.time > 0 { try? await Task.sleep(nanoseconds: UInt64(event.time * 1_000_000_000)) } - + await MainActor.run { onEvent(event) } } - + await MainActor.run { completion() } } } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Models/FileEntry.swift b/ios/VibeTunnel/Models/FileEntry.swift index 85a9bca0..bc4d257f 100644 --- a/ios/VibeTunnel/Models/FileEntry.swift +++ b/ios/VibeTunnel/Models/FileEntry.swift @@ -7,9 +7,9 @@ struct FileEntry: Codable, Identifiable { let size: Int64 let mode: String let modTime: Date - + var id: String { path } - + enum CodingKeys: String, CodingKey { case name case path @@ -18,7 +18,7 @@ struct FileEntry: Codable, Identifiable { case mode case modTime = "mod_time" } - + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) name = try container.decode(String.self, forKey: .name) @@ -26,7 +26,7 @@ struct FileEntry: Codable, Identifiable { isDir = try container.decode(Bool.self, forKey: .isDir) size = try container.decode(Int64.self, forKey: .size) mode = try container.decode(String.self, forKey: .mode) - + // Decode mod_time string as Date let modTimeString = try container.decode(String.self, forKey: .modTime) let formatter = ISO8601DateFormatter() @@ -39,17 +39,21 @@ struct FileEntry: Codable, Identifiable { if let date = formatter.date(from: modTimeString) { modTime = date } else { - throw DecodingError.dataCorruptedError(forKey: .modTime, in: container, debugDescription: "Invalid date format") + throw DecodingError.dataCorruptedError( + forKey: .modTime, + in: container, + debugDescription: "Invalid date format" + ) } } } - + var formattedSize: String { let formatter = ByteCountFormatter() formatter.countStyle = .binary return formatter.string(fromByteCount: size) } - + var formattedDate: String { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .abbreviated @@ -60,4 +64,4 @@ struct FileEntry: Codable, Identifiable { struct DirectoryListing: Codable { let absolutePath: String let files: [FileEntry] -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Models/ServerConfig.swift b/ios/VibeTunnel/Models/ServerConfig.swift index 54622448..847d9d95 100644 --- a/ios/VibeTunnel/Models/ServerConfig.swift +++ b/ios/VibeTunnel/Models/ServerConfig.swift @@ -5,24 +5,29 @@ struct ServerConfig: Codable, Equatable { let port: Int let name: String? let password: String? - + var baseURL: URL { - URL(string: "http://\(host):\(port)")! + // This should always succeed with valid host and port + // Fallback ensures we always have a valid URL + URL(string: "http://\(host):\(port)") ?? URL(fileURLWithPath: "/") } - + var displayName: String { name ?? "\(host):\(port)" } - + var requiresAuthentication: Bool { - password != nil && !password!.isEmpty + if let password { + return !password.isEmpty + } + return false } - + var authorizationHeader: String? { - guard let password = password, !password.isEmpty else { return nil } + guard let password, !password.isEmpty else { return nil } let credentials = "admin:\(password)" guard let data = credentials.data(using: .utf8) else { return nil } let base64 = data.base64EncodedString() return "Basic \(base64)" } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Models/Session.swift b/ios/VibeTunnel/Models/Session.swift index b269233d..89a28e9a 100644 --- a/ios/VibeTunnel/Models/Session.swift +++ b/ios/VibeTunnel/Models/Session.swift @@ -13,7 +13,7 @@ struct Session: Codable, Identifiable, Equatable { let waiting: Bool? let width: Int? let height: Int? - + enum CodingKeys: String, CodingKey { case id case command = "cmdline" @@ -28,15 +28,15 @@ struct Session: Codable, Identifiable, Equatable { case width case height } - + var displayName: String { name ?? command } - + var isRunning: Bool { status == .running } - + var formattedStartTime: String { // Parse and format the startedAt string // Try ISO8601 first @@ -47,7 +47,7 @@ struct Session: Codable, Identifiable, Equatable { displayFormatter.timeStyle = .short return displayFormatter.string(from: date) } - + // Try RFC3339 format (what Go uses) let rfc3339Formatter = DateFormatter() rfc3339Formatter.locale = Locale(identifier: "en_US_POSIX") @@ -58,7 +58,7 @@ struct Session: Codable, Identifiable, Equatable { displayFormatter.timeStyle = .short return displayFormatter.string(from: date) } - + // Try without fractional seconds rfc3339Formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX" if let date = rfc3339Formatter.date(from: startedAt) { @@ -67,7 +67,7 @@ struct Session: Codable, Identifiable, Equatable { displayFormatter.timeStyle = .short return displayFormatter.string(from: date) } - + return startedAt } } @@ -82,16 +82,32 @@ struct SessionCreateData: Codable { let command: [String] let workingDir: String let name: String? - let spawn_terminal: Bool? + let spawnTerminal: Bool? let cols: Int? let rows: Int? - - init(command: String = "zsh", workingDir: String, name: String? = nil, spawnTerminal: Bool = false, cols: Int = 120, rows: Int = 30) { + + enum CodingKeys: String, CodingKey { + case command + case workingDir + case name + case spawnTerminal = "spawn_terminal" + case cols + case rows + } + + init( + command: String = "zsh", + workingDir: String, + name: String? = nil, + spawnTerminal: Bool = false, + cols: Int = 120, + rows: Int = 30 + ) { self.command = [command] self.workingDir = workingDir self.name = name - self.spawn_terminal = spawnTerminal + self.spawnTerminal = spawnTerminal self.cols = cols self.rows = rows } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Models/TerminalData.swift b/ios/VibeTunnel/Models/TerminalData.swift index ad009a84..eefdd5b9 100644 --- a/ios/VibeTunnel/Models/TerminalData.swift +++ b/ios/VibeTunnel/Models/TerminalData.swift @@ -5,21 +5,21 @@ enum TerminalEvent { case output(timestamp: Double, data: String) case resize(timestamp: Double, dimensions: String) case exit(code: Int, sessionId: String) - + init?(from line: String) { guard let data = line.data(using: .utf8) else { return nil } - + // Try to parse as header first if let header = try? JSONDecoder().decode(AsciinemaHeader.self, from: data) { self = .header(header) return } - + // Try to parse as array event guard let array = try? JSONSerialization.jsonObject(with: data) as? [Any] else { return nil } - + // Check for exit event: ["exit", exitCode, sessionId] if array.count == 3, let exitString = array[0] as? String, @@ -29,15 +29,16 @@ enum TerminalEvent { self = .exit(code: exitCode, sessionId: sessionId) return } - + // Parse normal events: [timestamp, "type", "data"] guard array.count >= 3, let timestamp = array[0] as? Double, let typeString = array[1] as? String, - let eventData = array[2] as? String else { + let eventData = array[2] as? String + else { return nil } - + switch typeString { case "o": self = .output(timestamp: timestamp, data: eventData) @@ -61,19 +62,19 @@ struct AsciinemaHeader: Codable { struct TerminalInput: Codable { let text: String - + enum SpecialKey: String { // Arrow keys use ANSI escape sequences case arrowUp = "\u{001B}[A" case arrowDown = "\u{001B}[B" case arrowRight = "\u{001B}[C" case arrowLeft = "\u{001B}[D" - + // Special keys case escape = "\u{001B}" case enter = "\r" case tab = "\t" - + // Control keys case ctrlC = "\u{0003}" case ctrlD = "\u{0004}" @@ -81,16 +82,16 @@ struct TerminalInput: Codable { case ctrlL = "\u{000C}" case ctrlA = "\u{0001}" case ctrlE = "\u{0005}" - + // For compatibility with web frontend case ctrlEnter = "ctrl_enter" case shiftEnter = "shift_enter" } - + init(specialKey: SpecialKey) { self.text = specialKey.rawValue } - + init(text: String) { self.text = text } @@ -99,4 +100,4 @@ struct TerminalInput: Codable { struct TerminalResize: Codable { let cols: Int let rows: Int -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Models/TerminalSnapshot.swift b/ios/VibeTunnel/Models/TerminalSnapshot.swift index 93d4e0b1..f08f936f 100644 --- a/ios/VibeTunnel/Models/TerminalSnapshot.swift +++ b/ios/VibeTunnel/Models/TerminalSnapshot.swift @@ -4,7 +4,7 @@ struct TerminalSnapshot: Codable { let sessionId: String let header: AsciinemaHeader? let events: [AsciinemaEvent] - + enum CodingKeys: String, CodingKey { case sessionId = "session_id" case header @@ -16,7 +16,7 @@ struct AsciinemaEvent: Codable { let time: Double let type: EventType let data: String - + enum EventType: String, Codable { case output = "o" case input = "i" @@ -26,22 +26,22 @@ struct AsciinemaEvent: Codable { } extension TerminalSnapshot { - // Get the last few lines of terminal output for preview + /// Get the last few lines of terminal output for preview var outputPreview: String { // Combine all output events let outputEvents = events.filter { $0.type == .output } - let combinedOutput = outputEvents.map { $0.data }.joined() - + let combinedOutput = outputEvents.map(\.data).joined() + // Split into lines and get last few non-empty lines let lines = combinedOutput.components(separatedBy: .newlines) let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - + // Take last 3-5 lines for preview let previewLines = Array(nonEmptyLines.suffix(4)) return previewLines.joined(separator: "\n") } - - // Get a cleaned version without ANSI escape codes (basic implementation) + + /// Get a cleaned version without ANSI escape codes (basic implementation) var cleanOutputPreview: String { let output = outputPreview // Remove common ANSI escape sequences (this is a simplified version) @@ -51,4 +51,4 @@ extension TerminalSnapshot { let cleaned = regex?.stringByReplacingMatches(in: output, options: [], range: range, withTemplate: "") ?? output return cleaned } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Services/APIClient.swift b/ios/VibeTunnel/Services/APIClient.swift index 9aaa005b..083d0295 100644 --- a/ios/VibeTunnel/Services/APIClient.swift +++ b/ios/VibeTunnel/Services/APIClient.swift @@ -8,7 +8,7 @@ enum APIError: LocalizedError { case networkError(Error) case noServerConfigured case invalidResponse - + var errorDescription: String? { switch self { case .invalidURL: @@ -18,7 +18,7 @@ enum APIError: LocalizedError { case .decodingError(let error): return "Failed to decode response: \(error.localizedDescription)" case .serverError(let code, let message): - if let message = message { + if let message { return message } switch code { @@ -81,50 +81,51 @@ class APIClient: APIClientProtocol { private let session = URLSession.shared private let decoder = JSONDecoder() private let encoder = JSONEncoder() - + private var baseURL: URL? { guard let config = UserDefaults.standard.data(forKey: "savedServerConfig"), - let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config) else { + let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config) + else { return nil } return serverConfig.baseURL } - + private init() {} - + // MARK: - Session Management - + func getSessions() async throws -> [Session] { - guard let baseURL = baseURL else { + guard let baseURL else { throw APIError.noServerConfigured } - + let url = baseURL.appendingPathComponent("api/sessions") let (data, response) = try await session.data(from: url) - + try validateResponse(response) - + do { return try decoder.decode([Session].self, from: data) } catch { throw APIError.decodingError(error) } } - + func createSession(_ data: SessionCreateData) async throws -> String { - guard let baseURL = baseURL else { + guard let baseURL else { print("[APIClient] No server configured") throw APIError.noServerConfigured } - + let url = baseURL.appendingPathComponent("api/sessions") print("[APIClient] Creating session at URL: \(url)") - + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") addAuthenticationIfNeeded(&request) - + do { request.httpBody = try encoder.encode(data) if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) { @@ -134,26 +135,26 @@ class APIClient: APIClientProtocol { print("[APIClient] Failed to encode session data: \(error)") throw error } - + do { let (responseData, response) = try await session.data(for: request) - + print("[APIClient] Response received") if let httpResponse = response as? HTTPURLResponse { print("[APIClient] Status code: \(httpResponse.statusCode)") print("[APIClient] Headers: \(httpResponse.allHeaderFields)") } - + if let responseString = String(data: responseData, encoding: .utf8) { print("[APIClient] Response body: \(responseString)") } - + try validateResponse(response) - + struct CreateResponse: Codable { let sessionId: String } - + let createResponse = try decoder.decode(CreateResponse.self, from: responseData) print("[APIClient] Session created with ID: \(createResponse.sessionId)") return createResponse.sessionId @@ -165,57 +166,57 @@ class APIClient: APIClientProtocol { throw error } } - + func killSession(_ sessionId: String) async throws { - guard let baseURL = baseURL else { + guard let baseURL else { throw APIError.noServerConfigured } - + let url = baseURL.appendingPathComponent("api/sessions/\(sessionId)") var request = URLRequest(url: url) request.httpMethod = "DELETE" addAuthenticationIfNeeded(&request) - + let (_, response) = try await session.data(for: request) try validateResponse(response) } - + func cleanupSession(_ sessionId: String) async throws { - guard let baseURL = baseURL else { + guard let baseURL else { throw APIError.noServerConfigured } - + let url = baseURL.appendingPathComponent("api/sessions/\(sessionId)/cleanup") var request = URLRequest(url: url) request.httpMethod = "DELETE" addAuthenticationIfNeeded(&request) - + let (_, response) = try await session.data(for: request) try validateResponse(response) } - + func cleanupAllExitedSessions() async throws -> [String] { - guard let baseURL = baseURL else { + guard let baseURL else { throw APIError.noServerConfigured } - + let url = baseURL.appendingPathComponent("api/cleanup-exited") var request = URLRequest(url: url) request.httpMethod = "POST" addAuthenticationIfNeeded(&request) - + let (data, response) = try await session.data(for: request) try validateResponse(response) - + // Handle empty response (204 No Content) from Go server if data.isEmpty { return [] } - + struct CleanupResponse: Codable { let cleanedSessions: [String] } - + do { let cleanupResponse = try decoder.decode(CleanupResponse.self, from: data) return cleanupResponse.cleanedSessions @@ -224,116 +225,121 @@ class APIClient: APIClientProtocol { return [] } } - + // MARK: - Terminal I/O - + func sendInput(sessionId: String, text: String) async throws { - guard let baseURL = baseURL else { + guard let baseURL else { throw APIError.noServerConfigured } - + let url = baseURL.appendingPathComponent("api/sessions/\(sessionId)/input") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") addAuthenticationIfNeeded(&request) - + let input = TerminalInput(text: text) request.httpBody = try encoder.encode(input) - + let (_, response) = try await session.data(for: request) try validateResponse(response) } - + func resizeTerminal(sessionId: String, cols: Int, rows: Int) async throws { - guard let baseURL = baseURL else { + guard let baseURL else { throw APIError.noServerConfigured } - + let url = baseURL.appendingPathComponent("api/sessions/\(sessionId)/resize") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") addAuthenticationIfNeeded(&request) - + let resize = TerminalResize(cols: cols, rows: rows) request.httpBody = try encoder.encode(resize) - + let (_, response) = try await session.data(for: request) try validateResponse(response) } - + // MARK: - SSE Stream URL - + func streamURL(for sessionId: String) -> URL? { - guard let baseURL = baseURL else { return nil } + guard let baseURL else { return nil } return baseURL.appendingPathComponent("api/sessions/\(sessionId)/stream") } - + func snapshotURL(for sessionId: String) -> URL? { - guard let baseURL = baseURL else { return nil } + guard let baseURL else { return nil } return baseURL.appendingPathComponent("api/sessions/\(sessionId)/snapshot") } - + func getSessionSnapshot(sessionId: String) async throws -> TerminalSnapshot { - guard let baseURL = baseURL else { + guard let baseURL else { throw APIError.noServerConfigured } - + let url = baseURL.appendingPathComponent("api/sessions/\(sessionId)/snapshot") let (data, response) = try await session.data(from: url) - + try validateResponse(response) - + do { return try decoder.decode(TerminalSnapshot.self, from: data) } catch { throw APIError.decodingError(error) } } - + // MARK: - Helpers - + private func validateResponse(_ response: URLResponse) throws { guard let httpResponse = response as? HTTPURLResponse else { print("[APIClient] Invalid response type (not HTTP)") throw APIError.networkError(URLError(.badServerResponse)) } - + guard 200..<300 ~= httpResponse.statusCode else { print("[APIClient] Server error: HTTP \(httpResponse.statusCode)") throw APIError.serverError(httpResponse.statusCode, nil) } } - + private func addAuthenticationIfNeeded(_ request: inout URLRequest) { // For now, we don't have authentication configured in the iOS app // This is a placeholder for future authentication support // The server might be running without password protection } - + // MARK: - File System Operations - + func browseDirectory(path: String) async throws -> (absolutePath: String, files: [FileEntry]) { - guard let baseURL = baseURL else { + guard let baseURL else { throw APIError.noServerConfigured } - - var components = URLComponents(url: baseURL.appendingPathComponent("api/fs/browse"), resolvingAgainstBaseURL: false)! + + guard var components = URLComponents( + url: baseURL.appendingPathComponent("api/fs/browse"), + resolvingAgainstBaseURL: false + ) else { + throw APIError.invalidURL + } components.queryItems = [URLQueryItem(name: "path", value: path)] - + guard let url = components.url else { throw APIError.invalidResponse } - + var request = URLRequest(url: url) request.httpMethod = "GET" - + // Add authentication header if needed addAuthenticationIfNeeded(&request) - + let (data, response) = try await session.data(for: request) - + // Log response for debugging if let httpResponse = response as? HTTPURLResponse { print("[APIClient] Browse directory response: \(httpResponse.statusCode)") @@ -343,38 +349,38 @@ class APIClient: APIClientProtocol { } } } - + try validateResponse(response) - + // Decode the response which includes absolutePath and files struct BrowseResponse: Codable { let absolutePath: String let files: [FileEntry] } - + let browseResponse = try decoder.decode(BrowseResponse.self, from: data) return (absolutePath: browseResponse.absolutePath, files: browseResponse.files) } - + func createDirectory(path: String) async throws { - guard let baseURL = baseURL else { + guard let baseURL else { throw APIError.noServerConfigured } - + let url = baseURL.appendingPathComponent("api/mkdir") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") addAuthenticationIfNeeded(&request) - + struct CreateDirectoryRequest: Codable { let path: String } - + let requestBody = CreateDirectoryRequest(path: path) request.httpBody = try encoder.encode(requestBody) - + let (_, response) = try await session.data(for: request) try validateResponse(response) } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Services/BufferWebSocketClient.swift b/ios/VibeTunnel/Services/BufferWebSocketClient.swift index 152d5ab4..fe1b8f25 100644 --- a/ios/VibeTunnel/Services/BufferWebSocketClient.swift +++ b/ios/VibeTunnel/Services/BufferWebSocketClient.swift @@ -1,7 +1,7 @@ -import Foundation import Combine +import Foundation -// Terminal event types that match the server's output +/// Terminal event types that match the server's output enum TerminalWebSocketEvent { case header(width: Int, height: Int) case output(timestamp: Double, data: String) @@ -18,72 +18,73 @@ enum WebSocketError: Error { @MainActor class BufferWebSocketClient: NSObject { - // Magic byte for binary messages - private static let BUFFER_MAGIC_BYTE: UInt8 = 0xbf - + /// Magic byte for binary messages + private static let bufferMagicByte: UInt8 = 0xBF + private var webSocketTask: URLSessionWebSocketTask? private let session = URLSession(configuration: .default) - private var subscriptions = [String: ((TerminalWebSocketEvent) -> Void)]() + private var subscriptions = [String: (TerminalWebSocketEvent) -> Void]() private var reconnectTimer: Timer? private var reconnectAttempts = 0 private var isConnecting = false private var pingTimer: Timer? - + // Published events @Published private(set) var isConnected = false @Published private(set) var connectionError: Error? - + private var baseURL: URL? { guard let config = UserDefaults.standard.data(forKey: "savedServerConfig"), - let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config) else { + let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config) + else { return nil } return serverConfig.baseURL } - + func connect() { guard !isConnecting else { return } - guard let baseURL = baseURL else { + guard let baseURL else { connectionError = WebSocketError.invalidURL return } - + isConnecting = true connectionError = nil - + // Convert HTTP URL to WebSocket URL var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) components?.scheme = baseURL.scheme == "https" ? "wss" : "ws" components?.path = "/buffers" - + guard let wsURL = components?.url else { connectionError = WebSocketError.invalidURL isConnecting = false return } - + print("[BufferWebSocket] Connecting to \(wsURL)") - + // Cancel existing task if any webSocketTask?.cancel(with: .goingAway, reason: nil) - + // Create request with authentication var request = URLRequest(url: wsURL) - + // Add authentication header if needed if let config = UserDefaults.standard.data(forKey: "savedServerConfig"), let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config), let authHeader = serverConfig.authorizationHeader { request.setValue(authHeader, forHTTPHeaderField: "Authorization") } - + // Create new WebSocket task webSocketTask = session.webSocketTask(with: request) webSocketTask?.resume() - + // Start receiving messages receiveMessage() - + // Send initial ping to establish connection Task { do { @@ -92,7 +93,7 @@ class BufferWebSocketClient: NSObject { isConnecting = false reconnectAttempts = 0 startPingTimer() - + // Re-subscribe to all sessions for sessionId in subscriptions.keys { try await subscribe(to: sessionId) @@ -105,18 +106,18 @@ class BufferWebSocketClient: NSObject { } } } - + private func receiveMessage() { webSocketTask?.receive { [weak self] result in - guard let self = self else { return } - + guard let self else { return } + switch result { case .success(let message): Task { @MainActor in self.handleMessage(message) self.receiveMessage() // Continue receiving } - + case .failure(let error): print("[BufferWebSocket] Receive error: \(error)") Task { @MainActor in @@ -125,26 +126,27 @@ class BufferWebSocketClient: NSObject { } } } - + private func handleMessage(_ message: URLSessionWebSocketTask.Message) { switch message { case .data(let data): handleBinaryMessage(data) - + case .string(let text): handleTextMessage(text) - + @unknown default: break } } - + private func handleTextMessage(_ text: String) { guard let data = text.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return } - + if let type = json["type"] as? String { switch type { case "ping": @@ -152,83 +154,82 @@ class BufferWebSocketClient: NSObject { Task { try? await sendMessage(["type": "pong"]) } - + case "error": if let message = json["message"] as? String { print("[BufferWebSocket] Server error: \(message)") } - + default: print("[BufferWebSocket] Unknown message type: \(type)") } } } - + private func handleBinaryMessage(_ data: Data) { guard data.count > 5 else { return } - + var offset = 0 - + // Check magic byte let magic = data[offset] offset += 1 - - guard magic == Self.BUFFER_MAGIC_BYTE else { + + guard magic == Self.bufferMagicByte else { print("[BufferWebSocket] Invalid magic byte: \(magic)") return } - + // Read session ID length (4 bytes, little endian) let sessionIdLength = data.withUnsafeBytes { bytes in bytes.loadUnaligned(fromByteOffset: offset, as: UInt32.self).littleEndian } offset += 4 - + // Read session ID guard data.count >= offset + Int(sessionIdLength) else { return } let sessionIdData = data.subdata(in: offset..<(offset + Int(sessionIdLength))) guard let sessionId = String(data: sessionIdData, encoding: .utf8) else { return } offset += Int(sessionIdLength) - + // Remaining data is the message payload let messageData = data.subdata(in: offset.. TerminalWebSocketEvent? { // Decode the JSON payload from the binary message do { if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let type = json["type"] as? String { - switch type { case "header": if let width = json["width"] as? Int, let height = json["height"] as? Int { return .header(width: width, height: height) } - + case "output": if let timestamp = json["timestamp"] as? Double, let outputData = json["data"] as? String { return .output(timestamp: timestamp, data: outputData) } - + case "resize": if let timestamp = json["timestamp"] as? Double, let dimensions = json["dimensions"] as? String { return .resize(timestamp: timestamp, dimensions: dimensions) } - + case "exit": let code = json["code"] as? Int ?? 0 return .exit(code: code) - + default: print("[BufferWebSocket] Unknown message type: \(type)") } @@ -238,74 +239,74 @@ class BufferWebSocketClient: NSObject { } return nil } - + func subscribe(to sessionId: String, handler: @escaping (TerminalWebSocketEvent) -> Void) { subscriptions[sessionId] = handler - + Task { try? await subscribe(to: sessionId) } } - + private func subscribe(to sessionId: String) async throws { try await sendMessage(["type": "subscribe", "sessionId": sessionId]) } - + func unsubscribe(from sessionId: String) { subscriptions.removeValue(forKey: sessionId) - + Task { try? await sendMessage(["type": "unsubscribe", "sessionId": sessionId]) } } - + private func sendMessage(_ message: [String: Any]) async throws { - guard let webSocketTask = webSocketTask else { + guard let webSocketTask else { throw WebSocketError.connectionFailed } - + let data = try JSONSerialization.data(withJSONObject: message) guard let string = String(data: data, encoding: .utf8) else { throw WebSocketError.invalidData } - + try await webSocketTask.send(.string(string)) } - + private func sendPing() async throws { try await sendMessage(["type": "ping"]) } - + private func startPingTimer() { stopPingTimer() - + pingTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { _ in Task { [weak self] in try? await self?.sendPing() } } } - + private func stopPingTimer() { pingTimer?.invalidate() pingTimer = nil } - + private func handleDisconnection() { isConnected = false webSocketTask = nil stopPingTimer() scheduleReconnect() } - + private func scheduleReconnect() { guard reconnectTimer == nil else { return } - + let delay = min(pow(2.0, Double(reconnectAttempts)), 30.0) reconnectAttempts += 1 - + print("[BufferWebSocket] Reconnecting in \(delay)s (attempt \(reconnectAttempts))") - + reconnectTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in Task { @MainActor [weak self] in self?.reconnectTimer = nil @@ -313,22 +314,22 @@ class BufferWebSocketClient: NSObject { } } } - + func disconnect() { reconnectTimer?.invalidate() reconnectTimer = nil stopPingTimer() - + webSocketTask?.cancel(with: .goingAway, reason: nil) webSocketTask = nil - + subscriptions.removeAll() isConnected = false } - + deinit { // Cancel the WebSocket task webSocketTask?.cancel(with: .goingAway, reason: nil) // Timers will be cleaned up automatically when the object is deallocated } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Services/SessionService.swift b/ios/VibeTunnel/Services/SessionService.swift index 3e16244c..51fbf5c2 100644 --- a/ios/VibeTunnel/Services/SessionService.swift +++ b/ios/VibeTunnel/Services/SessionService.swift @@ -4,13 +4,13 @@ import Foundation class SessionService { static let shared = SessionService() private let apiClient = APIClient.shared - + private init() {} - + func getSessions() async throws -> [Session] { - return try await apiClient.getSessions() + try await apiClient.getSessions() } - + func createSession(_ data: SessionCreateData) async throws -> String { do { return try await apiClient.createSession(data) @@ -19,24 +19,24 @@ class SessionService { throw error } } - + func killSession(_ sessionId: String) async throws { try await apiClient.killSession(sessionId) } - + func cleanupSession(_ sessionId: String) async throws { try await apiClient.cleanupSession(sessionId) } - + func cleanupAllExitedSessions() async throws -> [String] { - return try await apiClient.cleanupAllExitedSessions() + try await apiClient.cleanupAllExitedSessions() } - + func sendInput(to sessionId: String, text: String) async throws { try await apiClient.sendInput(sessionId: sessionId, text: text) } - + func resizeTerminal(sessionId: String, cols: Int, rows: Int) async throws { try await apiClient.resizeTerminal(sessionId: sessionId, cols: cols, rows: rows) } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Utils/Theme.swift b/ios/VibeTunnel/Utils/Theme.swift index c860d1fa..9aa1a487 100644 --- a/ios/VibeTunnel/Utils/Theme.swift +++ b/ios/VibeTunnel/Utils/Theme.swift @@ -1,32 +1,33 @@ import SwiftUI -struct Theme { +enum Theme { // MARK: - Colors - struct Colors { + + enum Colors { // Terminal-inspired colors static let terminalBackground = Color(hex: "0A0E14") static let terminalForeground = Color(hex: "B3B1AD") static let terminalSelection = Color(hex: "273747") - + // Accent colors static let primaryAccent = Color(hex: "39BAE6") static let secondaryAccent = Color(hex: "59C2FF") static let successAccent = Color(hex: "AAD94C") static let warningAccent = Color(hex: "FFB454") static let errorAccent = Color(hex: "F07178") - + // UI colors static let cardBackground = Color(hex: "0D1117") static let cardBorder = Color(hex: "1C2128") static let headerBackground = Color(hex: "010409") static let overlayBackground = Color.black.opacity(0.7) - + // Additional UI colors for FileBrowser static let terminalAccent = primaryAccent static let terminalGray = Color(hex: "8B949E") static let terminalDarkGray = Color(hex: "161B22") static let terminalWhite = Color.white - + // Terminal ANSI colors static let ansiBlack = Color(hex: "01060E") static let ansiRed = Color(hex: "EA6C73") @@ -36,7 +37,7 @@ struct Theme { static let ansiMagenta = Color(hex: "FAE994") static let ansiCyan = Color(hex: "90E1C6") static let ansiWhite = Color(hex: "C7C7C7") - + // Bright ANSI colors static let ansiBrightBlack = Color(hex: "686868") static let ansiBrightRed = Color(hex: "F07178") @@ -47,95 +48,100 @@ struct Theme { static let ansiBrightCyan = Color(hex: "95E6CB") static let ansiBrightWhite = Color(hex: "FFFFFF") } - + // MARK: - Typography - struct Typography { + + enum Typography { static let terminalFont = "SF Mono" static let terminalFontFallback = "Menlo" static let uiFont = "SF Pro Display" - + static func terminal(size: CGFloat) -> Font { - return Font.custom(terminalFont, size: size) + Font.custom(terminalFont, size: size) .monospaced() } - + static func terminalSystem(size: CGFloat) -> Font { - return Font.system(size: size, design: .monospaced) + Font.system(size: size, design: .monospaced) } } - + // MARK: - Spacing - struct Spacing { - static let xs: CGFloat = 4 - static let sm: CGFloat = 8 - static let md: CGFloat = 12 - static let lg: CGFloat = 16 - static let xl: CGFloat = 24 - static let xxl: CGFloat = 32 + + enum Spacing { + static let extraSmall: CGFloat = 4 + static let small: CGFloat = 8 + static let medium: CGFloat = 12 + static let large: CGFloat = 16 + static let extraLarge: CGFloat = 24 + static let extraExtraLarge: CGFloat = 32 } - + // MARK: - Corner Radius - struct CornerRadius { + + enum CornerRadius { static let small: CGFloat = 6 static let medium: CGFloat = 10 static let large: CGFloat = 16 static let card: CGFloat = 12 } - + // MARK: - Animation - struct Animation { + + enum Animation { static let quick = SwiftUI.Animation.easeInOut(duration: 0.2) static let standard = SwiftUI.Animation.easeInOut(duration: 0.3) static let smooth = SwiftUI.Animation.spring(response: 0.4, dampingFraction: 0.8) } - + // MARK: - Shadows - struct Shadows { - struct Card { - static let color = Color.black.opacity(0.3) - static let radius: CGFloat = 8 - static let x: CGFloat = 0 - static let y: CGFloat = 2 - } - - struct Button { - static let color = Color.black.opacity(0.2) - static let radius: CGFloat = 4 - static let x: CGFloat = 0 - static let y: CGFloat = 1 - } + + enum CardShadow { + static let color = Color.black.opacity(0.3) + static let radius: CGFloat = 8 + static let xOffset: CGFloat = 0 + static let yOffset: CGFloat = 2 + } + + enum ButtonShadow { + static let color = Color.black.opacity(0.2) + static let radius: CGFloat = 4 + static let xOffset: CGFloat = 0 + static let yOffset: CGFloat = 1 } } // MARK: - Color Extension + extension Color { init(hex: String) { let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) var int: UInt64 = 0 Scanner(string: hex).scanHexInt64(&int) - let a, r, g, b: UInt64 + let alpha, red, green, blue: UInt64 switch hex.count { case 3: // RGB (12-bit) - (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + (alpha, red, green, blue) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) case 6: // RGB (24-bit) - (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + (alpha, red, green, blue) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) case 8: // ARGB (32-bit) - (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + (alpha, red, green, blue) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) default: - (a, r, g, b) = (255, 0, 0, 0) + (alpha, red, green, blue) = (255, 0, 0, 0) } - + self.init( .sRGB, - red: Double(r) / 255, - green: Double(g) / 255, - blue: Double(b) / 255, - opacity: Double(a) / 255 + red: Double(red) / 255, + green: Double(green) / 255, + blue: Double(blue) / 255, + opacity: Double(alpha) / 255 ) } } // MARK: - View Modifiers + extension View { func terminalCard() -> some View { self @@ -145,20 +151,25 @@ extension View { RoundedRectangle(cornerRadius: Theme.CornerRadius.card) .stroke(Theme.Colors.cardBorder, lineWidth: 1) ) - .shadow(color: Color.black.opacity(0.3), radius: 8, x: 0, y: 2) + .shadow( + color: Theme.CardShadow.color, + radius: Theme.CardShadow.radius, + x: Theme.CardShadow.xOffset, + y: Theme.CardShadow.yOffset + ) } - + func glowEffect(color: Color = Theme.Colors.primaryAccent) -> some View { self .shadow(color: color.opacity(0.5), radius: 10) .shadow(color: color.opacity(0.3), radius: 20) } - + func terminalButton() -> some View { self .foregroundColor(Theme.Colors.terminalForeground) - .padding(.horizontal, Theme.Spacing.lg) - .padding(.vertical, Theme.Spacing.md) + .padding(.horizontal, Theme.Spacing.large) + .padding(.vertical, Theme.Spacing.medium) .background(Theme.Colors.primaryAccent.opacity(0.1)) .cornerRadius(Theme.CornerRadius.medium) .overlay( @@ -169,20 +180,21 @@ extension View { } // MARK: - Haptic Feedback + @MainActor struct HapticFeedback { static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { let generator = UIImpactFeedbackGenerator(style: style) generator.impactOccurred() } - + static func selection() { let generator = UISelectionFeedbackGenerator() generator.selectionChanged() } - + static func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) { let generator = UINotificationFeedbackGenerator() generator.notificationOccurred(type) } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Common/LoadingView.swift b/ios/VibeTunnel/Views/Common/LoadingView.swift index 70a23e01..d6728db2 100644 --- a/ios/VibeTunnel/Views/Common/LoadingView.swift +++ b/ios/VibeTunnel/Views/Common/LoadingView.swift @@ -3,14 +3,14 @@ import SwiftUI struct LoadingView: View { let message: String @State private var isAnimating = false - + var body: some View { - VStack(spacing: Theme.Spacing.lg) { + VStack(spacing: Theme.Spacing.large) { ZStack { Circle() .stroke(Theme.Colors.cardBorder, lineWidth: 3) .frame(width: 50, height: 50) - + Circle() .trim(from: 0, to: 0.2) .stroke(Theme.Colors.primaryAccent, lineWidth: 3) @@ -22,7 +22,7 @@ struct LoadingView: View { value: isAnimating ) } - + Text(message) .font(Theme.Typography.terminalSystem(size: 14)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) @@ -31,4 +31,4 @@ struct LoadingView: View { isAnimating = true } } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Connection/ConnectionView.swift b/ios/VibeTunnel/Views/Connection/ConnectionView.swift index a18bb38c..3dee365c 100644 --- a/ios/VibeTunnel/Views/Connection/ConnectionView.swift +++ b/ios/VibeTunnel/Views/Connection/ConnectionView.swift @@ -1,23 +1,23 @@ -import SwiftUI import Observation +import SwiftUI struct ConnectionView: View { @Environment(ConnectionManager.self) var connectionManager @State private var viewModel = ConnectionViewModel() @State private var logoScale: CGFloat = 0.8 @State private var contentOpacity: Double = 0 - + var body: some View { NavigationStack { ZStack { // Background Theme.Colors.terminalBackground .ignoresSafeArea() - + // Content - VStack(spacing: Theme.Spacing.xxl) { + VStack(spacing: Theme.Spacing.extraExtraLarge) { // Logo and Title - VStack(spacing: Theme.Spacing.lg) { + VStack(spacing: Theme.Spacing.large) { ZStack { // Glow effect Image(systemName: "terminal.fill") @@ -25,7 +25,7 @@ struct ConnectionView: View { .foregroundColor(Theme.Colors.primaryAccent) .blur(radius: 20) .opacity(0.5) - + // Main icon Image(systemName: "terminal.fill") .font(.system(size: 80)) @@ -38,12 +38,12 @@ struct ConnectionView: View { logoScale = 1.0 } } - - VStack(spacing: Theme.Spacing.sm) { + + VStack(spacing: Theme.Spacing.small) { Text("VibeTunnel") .font(.system(size: 42, weight: .bold, design: .rounded)) .foregroundColor(Theme.Colors.terminalForeground) - + Text("Terminal Multiplexer") .font(Theme.Typography.terminalSystem(size: 16)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) @@ -51,7 +51,7 @@ struct ConnectionView: View { } } .padding(.top, 60) - + // Connection Form ServerConfigForm( host: $viewModel.host, @@ -68,7 +68,7 @@ struct ConnectionView: View { contentOpacity = 1.0 } } - + Spacer() } .padding() @@ -81,7 +81,7 @@ struct ConnectionView: View { viewModel.loadLastConnection() } } - + private func connectToServer() { Task { await viewModel.testConnection { config in @@ -100,7 +100,7 @@ class ConnectionViewModel { var password: String = "" var isConnecting: Bool = false var errorMessage: String? - + func loadLastConnection() { if let config = UserDefaults.standard.data(forKey: "savedServerConfig"), let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config) { @@ -110,30 +110,30 @@ class ConnectionViewModel { self.password = serverConfig.password ?? "" } } - + @MainActor func testConnection(onSuccess: @escaping (ServerConfig) -> Void) async { errorMessage = nil - + guard !host.isEmpty else { errorMessage = "Please enter a server address" return } - - guard let portNumber = Int(port), portNumber > 0, portNumber <= 65535 else { + + guard let portNumber = Int(port), portNumber > 0, portNumber <= 65_535 else { errorMessage = "Please enter a valid port number" return } - + isConnecting = true - + let config = ServerConfig( host: host, port: portNumber, name: name.isEmpty ? nil : name, password: password.isEmpty ? nil : password ) - + do { // Test connection by fetching sessions let url = config.baseURL.appendingPathComponent("api/sessions") @@ -142,7 +142,7 @@ class ConnectionViewModel { request.setValue(authHeader, forHTTPHeaderField: "Authorization") } let (_, response) = try await URLSession.shared.data(for: request) - + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 { onSuccess(config) @@ -152,7 +152,7 @@ class ConnectionViewModel { } catch { errorMessage = "Connection failed: \(error.localizedDescription)" } - + isConnecting = false } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift b/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift index 9dd5d190..329d0f79 100644 --- a/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift +++ b/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift @@ -8,24 +8,27 @@ struct ServerConfigForm: View { let isConnecting: Bool let errorMessage: String? let onConnect: () -> Void - + @FocusState private var focusedField: Field? @State private var recentServers: [ServerConfig] = [] - + enum Field { - case host, port, name, password + case host + case port + case name + case password } - + var body: some View { - VStack(spacing: Theme.Spacing.xl) { + VStack(spacing: Theme.Spacing.extraLarge) { // Input Fields - VStack(spacing: Theme.Spacing.lg) { + VStack(spacing: Theme.Spacing.large) { // Host/IP Field - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + VStack(alignment: .leading, spacing: Theme.Spacing.small) { Label("Server Address", systemImage: "network") .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.primaryAccent) - + TextField("192.168.1.100 or localhost", text: $host) .textFieldStyle(TerminalTextFieldStyle()) .autocapitalization(.none) @@ -36,13 +39,13 @@ struct ServerConfigForm: View { focusedField = .port } } - + // Port Field - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + VStack(alignment: .leading, spacing: Theme.Spacing.small) { Label("Port", systemImage: "number.circle") .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.primaryAccent) - + TextField("3000", text: $port) .textFieldStyle(TerminalTextFieldStyle()) .keyboardType(.numberPad) @@ -52,13 +55,13 @@ struct ServerConfigForm: View { focusedField = .name } } - + // Name Field (Optional) - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + VStack(alignment: .leading, spacing: Theme.Spacing.small) { Label("Connection Name (Optional)", systemImage: "tag") .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.primaryAccent) - + TextField("My Mac", text: $name) .textFieldStyle(TerminalTextFieldStyle()) .focused($focusedField, equals: .name) @@ -67,13 +70,13 @@ struct ServerConfigForm: View { focusedField = .password } } - + // Password Field (Optional) - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + VStack(alignment: .leading, spacing: Theme.Spacing.small) { Label("Password (Optional)", systemImage: "lock") .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.primaryAccent) - + SecureField("Enter password if required", text: $password) .textFieldStyle(TerminalTextFieldStyle()) .focused($focusedField, equals: .password) @@ -85,10 +88,10 @@ struct ServerConfigForm: View { } } .padding(.horizontal) - + // Error Message - if let errorMessage = errorMessage { - HStack(spacing: Theme.Spacing.sm) { + if let errorMessage { + HStack(spacing: Theme.Spacing.small) { Image(systemName: "exclamationmark.triangle") .font(.caption) Text(errorMessage) @@ -101,14 +104,14 @@ struct ServerConfigForm: View { removal: .scale.combined(with: .opacity) )) } - + // Connect Button Button(action: { HapticFeedback.impact(.medium) onConnect() - }) { + }, label: { if isConnecting { - HStack(spacing: Theme.Spacing.sm) { + HStack(spacing: Theme.Spacing.small) { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.terminalBackground)) .scaleEffect(0.8) @@ -117,7 +120,7 @@ struct ServerConfigForm: View { } .frame(maxWidth: .infinity) } else { - HStack(spacing: Theme.Spacing.sm) { + HStack(spacing: Theme.Spacing.small) { Image(systemName: "bolt.fill") Text("Connect") } @@ -125,9 +128,9 @@ struct ServerConfigForm: View { .fontWeight(.semibold) .frame(maxWidth: .infinity) } - } + }) .foregroundColor(isConnecting ? Theme.Colors.terminalForeground : Theme.Colors.primaryAccent) - .padding(.vertical, Theme.Spacing.md) + .padding(.vertical, Theme.Spacing.medium) .background( RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .fill(isConnecting ? Theme.Colors.cardBackground : Theme.Colors.terminalBackground) @@ -141,17 +144,17 @@ struct ServerConfigForm: View { .padding(.horizontal) .scaleEffect(isConnecting ? 0.98 : 1.0) .animation(Theme.Animation.quick, value: isConnecting) - + // Recent Servers (if any) if !recentServers.isEmpty { - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + VStack(alignment: .leading, spacing: Theme.Spacing.small) { Text("Recent Connections") .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) .padding(.horizontal) - + ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: Theme.Spacing.sm) { + HStack(spacing: Theme.Spacing.small) { ForEach(recentServers.prefix(3), id: \.host) { server in Button(action: { host = server.host @@ -159,7 +162,7 @@ struct ServerConfigForm: View { name = server.name ?? "" password = server.password ?? "" HapticFeedback.selection() - }) { + }, label: { VStack(alignment: .leading, spacing: 4) { Text(server.displayName) .font(Theme.Typography.terminalSystem(size: 12)) @@ -169,13 +172,13 @@ struct ServerConfigForm: View { .opacity(0.7) } .foregroundColor(Theme.Colors.terminalForeground) - .padding(.horizontal, Theme.Spacing.md) - .padding(.vertical, Theme.Spacing.sm) + .padding(.horizontal, Theme.Spacing.medium) + .padding(.vertical, Theme.Spacing.small) .background( RoundedRectangle(cornerRadius: Theme.CornerRadius.small) .stroke(Theme.Colors.cardBorder, lineWidth: 1) ) - } + }) .buttonStyle(PlainButtonStyle()) } } @@ -190,7 +193,7 @@ struct ServerConfigForm: View { loadRecentServers() } } - + private func loadRecentServers() { // Load recent servers from UserDefaults if let data = UserDefaults.standard.data(forKey: "recentServers"), diff --git a/ios/VibeTunnel/Views/FileBrowserView.swift b/ios/VibeTunnel/Views/FileBrowserView.swift index 55856cae..c84fb116 100644 --- a/ios/VibeTunnel/Views/FileBrowserView.swift +++ b/ios/VibeTunnel/Views/FileBrowserView.swift @@ -1,31 +1,31 @@ -import SwiftUI import Observation +import SwiftUI struct FileBrowserView: View { @State private var viewModel = FileBrowserViewModel() @Environment(\.dismiss) private var dismiss - + let onSelect: (String) -> Void let initialPath: String - + init(initialPath: String = "~", onSelect: @escaping (String) -> Void) { self.initialPath = initialPath self.onSelect = onSelect } - + var body: some View { NavigationStack { ZStack { // Background Color.black.ignoresSafeArea() - + VStack(spacing: 0) { // Current path display HStack(spacing: 12) { Image(systemName: "folder.fill") .foregroundColor(Theme.Colors.terminalAccent) .font(.system(size: 16)) - + Text(viewModel.currentPath) .font(.custom("SF Mono", size: 14)) .foregroundColor(Theme.Colors.terminalGray) @@ -36,7 +36,7 @@ struct FileBrowserView: View { .padding(.horizontal, 20) .padding(.vertical, 16) .background(Theme.Colors.terminalDarkGray) - + // File list ScrollView { LazyVStack(spacing: 0) { @@ -52,7 +52,7 @@ struct FileBrowserView: View { ) .transition(.opacity) } - + // Directories first, then files ForEach(viewModel.sortedEntries) { entry in FileBrowserRow( @@ -77,7 +77,7 @@ struct FileBrowserView: View { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.terminalAccent)) .scaleEffect(1.2) - + Text("Loading...") .font(.custom("SF Mono", size: 14)) .foregroundColor(Theme.Colors.terminalGray) @@ -86,11 +86,11 @@ struct FileBrowserView: View { .background(Color.black.opacity(0.8)) } } - + // Bottom toolbar HStack(spacing: 20) { // Cancel button - Button(action: { dismiss() }) { + Button(action: { dismiss() }, label: { Text("cancel") .font(.custom("SF Mono", size: 14)) .foregroundColor(Theme.Colors.terminalGray) @@ -101,13 +101,13 @@ struct FileBrowserView: View { .stroke(Theme.Colors.terminalGray.opacity(0.3), lineWidth: 1) ) .contentShape(Rectangle()) - } + }) .buttonStyle(TerminalButtonStyle()) - + Spacer() - + // Create folder button - Button(action: { viewModel.showCreateFolder = true }) { + Button(action: { viewModel.showCreateFolder = true }, label: { Label("new folder", systemImage: "folder.badge.plus") .font(.custom("SF Mono", size: 14)) .foregroundColor(Theme.Colors.terminalAccent) @@ -118,14 +118,14 @@ struct FileBrowserView: View { .stroke(Theme.Colors.terminalAccent.opacity(0.5), lineWidth: 1) ) .contentShape(Rectangle()) - } + }) .buttonStyle(TerminalButtonStyle()) - + // Select button - Button(action: { + Button(action: { onSelect(viewModel.currentPath) dismiss() - }) { + }, label: { Text("select") .font(.custom("SF Mono", size: 14)) .foregroundColor(.black) @@ -141,7 +141,7 @@ struct FileBrowserView: View { .blur(radius: 10) ) .contentShape(Rectangle()) - } + }) .buttonStyle(TerminalButtonStyle()) } .padding(.horizontal, 20) @@ -154,11 +154,11 @@ struct FileBrowserView: View { TextField("Folder name", text: $viewModel.newFolderName) .textInputAutocapitalization(.never) .autocorrectionDisabled() - + Button("Cancel", role: .cancel) { viewModel.newFolderName = "" } - + Button("Create") { viewModel.createFolder() } @@ -167,7 +167,7 @@ struct FileBrowserView: View { Text("Enter a name for the new folder") } .alert("Error", isPresented: $viewModel.showError, presenting: viewModel.errorMessage) { _ in - Button("OK") { } + Button("OK") {} } message: { error in Text(error) } @@ -186,8 +186,15 @@ struct FileBrowserRow: View { let size: String? let modifiedTime: String? let onTap: () -> Void - - init(name: String, isDirectory: Bool, isParent: Bool = false, size: String? = nil, modifiedTime: String? = nil, onTap: @escaping () -> Void) { + + init( + name: String, + isDirectory: Bool, + isParent: Bool = false, + size: String? = nil, + modifiedTime: String? = nil, + onTap: @escaping () -> Void + ) { self.name = name self.isDirectory = isDirectory self.isParent = isParent @@ -195,7 +202,7 @@ struct FileBrowserRow: View { self.modifiedTime = modifiedTime self.onTap = onTap } - + var body: some View { Button(action: onTap) { HStack(spacing: 12) { @@ -204,33 +211,35 @@ struct FileBrowserRow: View { .foregroundColor(isDirectory ? Theme.Colors.terminalAccent : Theme.Colors.terminalGray.opacity(0.6)) .font(.system(size: 16)) .frame(width: 24) - + // Name Text(name) .font(.custom("SF Mono", size: 14)) - .foregroundColor(isParent ? Theme.Colors.terminalAccent : (isDirectory ? Theme.Colors.terminalWhite : Theme.Colors.terminalGray)) + .foregroundColor(isParent ? Theme.Colors + .terminalAccent : (isDirectory ? Theme.Colors.terminalWhite : Theme.Colors.terminalGray) + ) .lineLimit(1) .truncationMode(.middle) - + Spacer() - + // Details if !isParent { VStack(alignment: .trailing, spacing: 2) { - if let size = size { + if let size { Text(size) .font(.custom("SF Mono", size: 11)) .foregroundColor(Theme.Colors.terminalGray.opacity(0.6)) } - - if let modifiedTime = modifiedTime { + + if let modifiedTime { Text(modifiedTime) .font(.custom("SF Mono", size: 11)) .foregroundColor(Theme.Colors.terminalGray.opacity(0.5)) } } } - + // Chevron for directories if isDirectory && !isParent { Image(systemName: "chevron.right") @@ -269,9 +278,9 @@ class FileBrowserViewModel { var newFolderName = "" var showError = false var errorMessage: String? - + private let apiClient = APIClient.shared - + var sortedEntries: [FileEntry] { entries.sorted { entry1, entry2 in // Directories come first @@ -282,22 +291,22 @@ class FileBrowserViewModel { return entry1.name.localizedCaseInsensitiveCompare(entry2.name) == .orderedAscending } } - + var canGoUp: Bool { currentPath != "/" && currentPath != "~" } - + func loadDirectory(path: String) { Task { await loadDirectoryAsync(path: path) } } - + @MainActor private func loadDirectoryAsync(path: String) async { isLoading = true defer { isLoading = false } - + do { let result = try await apiClient.browseDirectory(path: path) // Use the absolute path returned by the server @@ -311,26 +320,26 @@ class FileBrowserViewModel { showError = true } } - + func navigate(to path: String) { UIImpactFeedbackGenerator(style: .light).impactOccurred() loadDirectory(path: path) } - + func navigateToParent() { let parentPath = URL(fileURLWithPath: currentPath).deletingLastPathComponent().path navigate(to: parentPath) } - + func createFolder() { let folderName = newFolderName.trimmingCharacters(in: .whitespacesAndNewlines) guard !folderName.isEmpty else { return } - + Task { await createFolderAsync(name: folderName) } } - + @MainActor private func createFolderAsync(name: String) async { do { @@ -353,4 +362,4 @@ class FileBrowserViewModel { FileBrowserView { path in print("Selected path: \(path)") } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Sessions/SessionCardView.swift b/ios/VibeTunnel/Views/Sessions/SessionCardView.swift index 36eb8533..5a4905f4 100644 --- a/ios/VibeTunnel/Views/Sessions/SessionCardView.swift +++ b/ios/VibeTunnel/Views/Sessions/SessionCardView.swift @@ -5,14 +5,14 @@ struct SessionCardView: View { let onTap: () -> Void let onKill: () -> Void let onCleanup: () -> Void - + @State private var isPressed = false @State private var terminalSnapshot: TerminalSnapshot? @State private var isLoadingSnapshot = false @State private var isKilling = false @State private var opacity: Double = 1.0 @State private var scale: CGFloat = 1.0 - + private var displayWorkingDir: String { // Convert absolute paths back to ~ notation for display let homePrefix = "/Users/" @@ -23,10 +23,10 @@ struct SessionCardView: View { } return session.workingDir } - + var body: some View { Button(action: onTap) { - VStack(alignment: .leading, spacing: Theme.Spacing.md) { + VStack(alignment: .leading, spacing: Theme.Spacing.medium) { // Header with session ID/name and kill button HStack { Text(session.name ?? String(session.id.prefix(8))) @@ -34,9 +34,9 @@ struct SessionCardView: View { .fontWeight(.medium) .foregroundColor(Theme.Colors.primaryAccent) .lineLimit(1) - + Spacer() - + Button(action: { HapticFeedback.impact(.medium) if session.isRunning { @@ -44,26 +44,26 @@ struct SessionCardView: View { } else { animateCleanup() } - }) { + }, label: { Text(session.isRunning ? "kill" : "clean") .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.terminalForeground) - .padding(.horizontal, Theme.Spacing.sm) + .padding(.horizontal, Theme.Spacing.small) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: Theme.CornerRadius.small) .stroke(Theme.Colors.cardBorder, lineWidth: 1) ) - } + }) .buttonStyle(PlainButtonStyle()) } - + // Terminal content area showing command and terminal output preview RoundedRectangle(cornerRadius: Theme.CornerRadius.small) .fill(Theme.Colors.terminalBackground) .frame(height: 120) .overlay( - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + VStack(alignment: .leading, spacing: Theme.Spacing.small) { if session.isRunning { if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty { // Show terminal output preview @@ -75,7 +75,7 @@ struct SessionCardView: View { .lineLimit(nil) .multilineTextAlignment(.leading) } - .padding(Theme.Spacing.sm) + .padding(Theme.Spacing.small) } else { // Show command and working directory info as fallback VStack(alignment: .leading, spacing: 4) { @@ -87,26 +87,28 @@ struct SessionCardView: View { .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.terminalForeground) } - + Text(displayWorkingDir) .font(Theme.Typography.terminalSystem(size: 10)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.6)) .lineLimit(1) - + if isLoadingSnapshot { HStack { ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent)) + .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors + .primaryAccent + )) .scaleEffect(0.8) Text("Loading output...") .font(Theme.Typography.terminalSystem(size: 10)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) } - .padding(.top, Theme.Spacing.xs) + .padding(.top, Theme.Spacing.extraSmall) } } - .padding(Theme.Spacing.sm) - + .padding(Theme.Spacing.small) + Spacer() } } else { @@ -125,7 +127,7 @@ struct SessionCardView: View { .multilineTextAlignment(.leading) } } - .padding(Theme.Spacing.sm) + .padding(Theme.Spacing.small) } else { Text("Session exited") .font(Theme.Typography.terminalSystem(size: 12)) @@ -135,21 +137,25 @@ struct SessionCardView: View { } } ) - + // Status bar at bottom - HStack(spacing: Theme.Spacing.sm) { + HStack(spacing: Theme.Spacing.small) { // Status indicator HStack(spacing: 4) { Circle() - .fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.3)) + .fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground + .opacity(0.3) + ) .frame(width: 6, height: 6) Text(session.isRunning ? "running" : "exited") .font(Theme.Typography.terminalSystem(size: 10)) - .foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.5)) + .foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors + .terminalForeground.opacity(0.5) + ) } - + Spacer() - + // PID info if session.isRunning, let pid = session.pid { Text("PID: \(pid)") @@ -162,7 +168,7 @@ struct SessionCardView: View { } } } - .padding(Theme.Spacing.md) + .padding(Theme.Spacing.medium) .background( RoundedRectangle(cornerRadius: Theme.CornerRadius.card) .fill(Theme.Colors.cardBackground) @@ -200,10 +206,10 @@ struct SessionCardView: View { loadSnapshot() } } - + private func loadSnapshot() { guard terminalSnapshot == nil else { return } - + isLoadingSnapshot = true Task { do { @@ -220,16 +226,16 @@ struct SessionCardView: View { } } } - + private func animateKill() { guard !isKilling else { return } isKilling = true - + // Shake animation withAnimation(.linear(duration: 0.05).repeatCount(4, autoreverses: true)) { scale = 0.97 } - + // Fade out after shake DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { withAnimation(.easeOut(duration: 0.3)) { @@ -237,7 +243,7 @@ struct SessionCardView: View { scale = 0.95 } onKill() - + // Reset after a delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { isKilling = false @@ -248,16 +254,16 @@ struct SessionCardView: View { } } } - + private func animateCleanup() { // Shrink and fade animation for cleanup withAnimation(.easeOut(duration: 0.3)) { scale = 0.8 opacity = 0 } - + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onCleanup() } } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Sessions/SessionCreateView.swift b/ios/VibeTunnel/Views/Sessions/SessionCreateView.swift index cdd61221..79919d99 100644 --- a/ios/VibeTunnel/Views/Sessions/SessionCreateView.swift +++ b/ios/VibeTunnel/Views/Sessions/SessionCreateView.swift @@ -1,8 +1,8 @@ import SwiftUI -// Custom text field style for terminal-like appearance +/// Custom text field style for terminal-like appearance struct TerminalTextFieldStyle: TextFieldStyle { - func _body(configuration: TextField) -> some View { + func makeBody(configuration: TextField) -> some View { configuration .font(Theme.Typography.terminalSystem(size: 16)) .foregroundColor(Theme.Colors.terminalForeground) @@ -21,60 +21,62 @@ struct TerminalTextFieldStyle: TextFieldStyle { struct SessionCreateView: View { @Binding var isPresented: Bool let onCreated: (String) -> Void - + @State private var command = "claude" @State private var workingDirectory = "~" @State private var sessionName = "" @State private var isCreating = false @State private var errorMessage: String? @State private var showFileBrowser = false - + @FocusState private var focusedField: Field? - + enum Field { - case command, workingDir, name + case command + case workingDir + case name } - + var body: some View { NavigationStack { ZStack { Theme.Colors.terminalBackground .ignoresSafeArea() - + ScrollView { - VStack(spacing: Theme.Spacing.lg) { + VStack(spacing: Theme.Spacing.large) { // Configuration Fields - VStack(spacing: Theme.Spacing.lg) { + VStack(spacing: Theme.Spacing.large) { // Command Field - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + VStack(alignment: .leading, spacing: Theme.Spacing.small) { Label("Command", systemImage: "terminal") .font(Theme.Typography.terminalSystem(size: 12)) .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.sm) { + VStack(alignment: .leading, spacing: Theme.Spacing.small) { Label("Working Directory", systemImage: "folder") .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.primaryAccent) - - HStack(spacing: Theme.Spacing.sm) { + + 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) @@ -87,25 +89,25 @@ struct SessionCreateView: View { RoundedRectangle(cornerRadius: Theme.CornerRadius.small) .stroke(Theme.Colors.cardBorder.opacity(0.3), lineWidth: 1) ) - } + }) .buttonStyle(PlainButtonStyle()) } } - + // Session Name - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + VStack(alignment: .leading, spacing: Theme.Spacing.small) { Label("Session Name (Optional)", systemImage: "tag") .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.primaryAccent) - + TextField("My Session", text: $sessionName) .textFieldStyle(TerminalTextFieldStyle()) .focused($focusedField, equals: .name) } - + // Error Message if let error = errorMessage { - HStack(spacing: Theme.Spacing.sm) { + HStack(spacing: Theme.Spacing.small) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 14)) Text(error) @@ -113,8 +115,8 @@ struct SessionCreateView: View { .fixedSize(horizontal: false, vertical: true) } .foregroundColor(Theme.Colors.errorAccent) - .padding(.horizontal, Theme.Spacing.md) - .padding(.vertical, Theme.Spacing.sm) + .padding(.horizontal, Theme.Spacing.medium) + .padding(.vertical, Theme.Spacing.small) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: Theme.CornerRadius.small) @@ -131,67 +133,75 @@ struct SessionCreateView: View { } } .padding(.horizontal) - .padding(.top, Theme.Spacing.lg) - + .padding(.top, Theme.Spacing.large) + // Quick Directories if focusedField == .workingDir { - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + 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.sm) { - ForEach(commonDirectories, id: \.self) { dir in - Button(action: { - workingDirectory = dir - HapticFeedback.selection() - }) { - 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) - ) + 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()) } - .buttonStyle(PlainButtonStyle()) - } } .padding(.horizontal) } } } - + // Quick Start Commands - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + VStack(alignment: .leading, spacing: Theme.Spacing.small) { Text("QUICK START") .font(Theme.Typography.terminalSystem(size: 10)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) .tracking(1) .padding(.horizontal) - + LazyVGrid(columns: [ GridItem(.flexible()), GridItem(.flexible()) - ], spacing: Theme.Spacing.sm) { + ], spacing: Theme.Spacing.small) { ForEach(recentCommands, id: \.self) { cmd in Button(action: { command = cmd HapticFeedback.selection() - }) { + }, label: { HStack { Image(systemName: commandIcon(for: cmd)) .font(.system(size: 14)) @@ -199,18 +209,26 @@ struct SessionCreateView: View { .font(Theme.Typography.terminalSystem(size: 14)) Spacer() } - .foregroundColor(command == cmd ? Theme.Colors.terminalBackground : Theme.Colors.terminalForeground) - .padding(.horizontal, Theme.Spacing.md) - .padding(.vertical, Theme.Spacing.sm) + .foregroundColor(command == cmd ? Theme.Colors.terminalBackground : Theme.Colors + .terminalForeground + ) + .padding(.horizontal, Theme.Spacing.medium) + .padding(.vertical, Theme.Spacing.small) .background( RoundedRectangle(cornerRadius: Theme.CornerRadius.small) - .fill(command == cmd ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder.opacity(0.3)) + .fill(command == cmd ? Theme.Colors.primaryAccent : Theme.Colors + .cardBorder.opacity(0.3) + ) ) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.small) - .stroke(command == cmd ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder, lineWidth: 1) + .stroke( + command == cmd ? Theme.Colors.primaryAccent : Theme.Colors + .cardBorder, + lineWidth: 1 + ) ) - } + }) .buttonStyle(PlainButtonStyle()) .scaleEffect(command == cmd ? 0.95 : 1.0) .animation(Theme.Animation.quick, value: command == cmd) @@ -218,7 +236,7 @@ struct SessionCreateView: View { } .padding(.horizontal) } - + Spacer(minLength: 40) } } @@ -231,31 +249,31 @@ struct SessionCreateView: View { 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)) @@ -263,9 +281,11 @@ struct SessionCreateView: View { } else { Text("Create") .font(.system(size: 17, weight: .semibold)) - .foregroundColor(command.isEmpty ? Theme.Colors.primaryAccent.opacity(0.5) : Theme.Colors.primaryAccent) + .foregroundColor(command.isEmpty ? Theme.Colors.primaryAccent.opacity(0.5) : Theme + .Colors.primaryAccent + ) } - } + }) .buttonStyle(PlainButtonStyle()) .disabled(isCreating || command.isEmpty) } @@ -294,34 +314,34 @@ struct SessionCreateView: View { } } } - + private var recentCommands: [String] { ["claude", "zsh", "bash", "python3", "node", "npm run dev"] } - + private var commonDirectories: [String] { ["~", "~/Desktop", "~/Documents", "~/Downloads", "~/Projects", "/tmp"] } - + private func commandIcon(for command: String) -> String { switch command { case "claude": - return "sparkle" + "sparkle" case "zsh", "bash": - return "terminal" + "terminal" case "python3": - return "chevron.left.forwardslash.chevron.right" + "chevron.left.forwardslash.chevron.right" case "node": - return "server.rack" + "server.rack" case "npm run dev": - return "play.circle" + "play.circle" case "irb": - return "diamond" + "diamond" default: - return "terminal" + "terminal" } } - + private func loadDefaults() { // Load last used values if let lastCommand = UserDefaults.standard.string(forKey: "lastCommand") { @@ -334,15 +354,15 @@ struct SessionCreateView: View { workingDirectory = "~" } } - + private func createSession() { isCreating = true errorMessage = nil - + // Save preferences UserDefaults.standard.set(command, forKey: "lastCommand") UserDefaults.standard.set(workingDirectory, forKey: "lastWorkingDir") - + Task { do { let sessionData = SessionCreateData( @@ -350,7 +370,7 @@ struct SessionCreateView: View { workingDir: workingDirectory.isEmpty ? "~" : workingDirectory, name: sessionName.isEmpty ? nil : sessionName ) - + // Log the request for debugging print("[SessionCreate] Creating session with data:") print(" Command: \(sessionData.command)") @@ -358,11 +378,11 @@ struct SessionCreateView: View { print(" Name: \(sessionData.name ?? "nil")") print(" Spawn Terminal: \(sessionData.spawn_terminal ?? false)") print(" Cols: \(sessionData.cols ?? 0), Rows: \(sessionData.rows ?? 0)") - + let sessionId = try await SessionService.shared.createSession(sessionData) - + print("[SessionCreate] Session created successfully with ID: \(sessionId)") - + await MainActor.run { onCreated(sessionId) isPresented = false @@ -373,7 +393,7 @@ struct SessionCreateView: View { if let apiError = error as? APIError { print(" API Error: \(apiError)") } - + await MainActor.run { errorMessage = error.localizedDescription isCreating = false @@ -381,4 +401,4 @@ struct SessionCreateView: View { } } } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Sessions/SessionListView.swift b/ios/VibeTunnel/Views/Sessions/SessionListView.swift index 6631b1a4..4de33d66 100644 --- a/ios/VibeTunnel/Views/Sessions/SessionListView.swift +++ b/ios/VibeTunnel/Views/Sessions/SessionListView.swift @@ -1,5 +1,5 @@ -import SwiftUI import Observation +import SwiftUI struct SessionListView: View { @Environment(ConnectionManager.self) var connectionManager @@ -8,14 +8,14 @@ struct SessionListView: View { @State private var showingCreateSession = false @State private var selectedSession: Session? @State private var showExitedSessions = true - + var body: some View { NavigationStack { ZStack { // Background Theme.Colors.terminalBackground .ignoresSafeArea() - + if viewModel.isLoading && viewModel.sessions.isEmpty { ProgressView("Loading sessions...") .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent)) @@ -35,24 +35,24 @@ struct SessionListView: View { Button(action: { HapticFeedback.impact(.medium) connectionManager.disconnect() - }) { + }, label: { HStack(spacing: 4) { Image(systemName: "xmark.circle") Text("Disconnect") } .foregroundColor(Theme.Colors.errorAccent) - } + }) } - + ToolbarItem(placement: .navigationBarTrailing) { Button(action: { HapticFeedback.impact(.light) showingCreateSession = true - }) { + }, label: { Image(systemName: "plus.circle.fill") .font(.title3) .foregroundColor(Theme.Colors.primaryAccent) - } + }) } } .sheet(isPresented: $showingCreateSession) { @@ -89,58 +89,58 @@ struct SessionListView: View { } } } - + private var emptyStateView: some View { - VStack(spacing: Theme.Spacing.xl) { + VStack(spacing: Theme.Spacing.extraLarge) { ZStack { Image(systemName: "terminal") .font(.system(size: 60)) .foregroundColor(Theme.Colors.primaryAccent) .blur(radius: 20) .opacity(0.3) - + Image(systemName: "terminal") .font(.system(size: 60)) .foregroundColor(Theme.Colors.primaryAccent) } - - VStack(spacing: Theme.Spacing.sm) { + + VStack(spacing: Theme.Spacing.small) { Text("No Sessions") .font(.title2) .fontWeight(.semibold) .foregroundColor(Theme.Colors.terminalForeground) - + Text("Create a new terminal session to get started") .font(Theme.Typography.terminalSystem(size: 14)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) .multilineTextAlignment(.center) } - + Button(action: { HapticFeedback.impact(.medium) showingCreateSession = true - }) { - HStack(spacing: Theme.Spacing.sm) { + }, label: { + HStack(spacing: Theme.Spacing.small) { Image(systemName: "plus.circle") Text("Create Session") } .font(Theme.Typography.terminalSystem(size: 16)) .fontWeight(.medium) - } + }) .terminalButton() } .padding() } - + private var sessionList: some View { ScrollView { - VStack(spacing: Theme.Spacing.lg) { + VStack(spacing: Theme.Spacing.large) { // Header with session count and kill all button HStack { - let runningCount = viewModel.sessions.filter { $0.isRunning }.count - let exitedCount = viewModel.sessions.filter { !$0.isRunning }.count - - HStack(spacing: Theme.Spacing.md) { + let runningCount = viewModel.sessions.count(where: { $0.isRunning }) + let exitedCount = viewModel.sessions.count(where: { !$0.isRunning }) + + HStack(spacing: Theme.Spacing.medium) { if runningCount > 0 { HStack(spacing: 4) { Text("Running:") @@ -150,7 +150,7 @@ struct SessionListView: View { .fontWeight(.semibold) } } - + if exitedCount > 0 { HStack(spacing: 4) { Text("Exited:") @@ -160,16 +160,16 @@ struct SessionListView: View { .fontWeight(.semibold) } } - + if runningCount == 0 && exitedCount == 0 { Text("No Sessions") .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) } } .font(Theme.Typography.terminalSystem(size: 16)) - + Spacer() - + // Toggle to show/hide exited sessions if exitedCount > 0 { Button(action: { @@ -177,7 +177,7 @@ struct SessionListView: View { withAnimation(Theme.Animation.smooth) { showExitedSessions.toggle() } - }) { + }, label: { HStack(spacing: 4) { Image(systemName: showExitedSessions ? "eye.slash" : "eye") .font(.caption) @@ -185,31 +185,31 @@ struct SessionListView: View { .font(Theme.Typography.terminalSystem(size: 12)) } .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) - .padding(.horizontal, Theme.Spacing.sm) + .padding(.horizontal, Theme.Spacing.small) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: Theme.CornerRadius.small) .fill(Theme.Colors.terminalForeground.opacity(0.1)) ) - } + }) .buttonStyle(PlainButtonStyle()) } - - if viewModel.sessions.contains(where: { $0.isRunning }) { + + if viewModel.sessions.contains(where: \.isRunning) { Button(action: { HapticFeedback.impact(.medium) Task { await viewModel.killAllSessions() } - }) { - HStack(spacing: Theme.Spacing.sm) { + }, label: { + HStack(spacing: Theme.Spacing.small) { Image(systemName: "stop.circle") Text("Kill All") } .font(Theme.Typography.terminalSystem(size: 14)) .foregroundColor(Theme.Colors.errorAccent) - .padding(.horizontal, Theme.Spacing.md) - .padding(.vertical, Theme.Spacing.sm) + .padding(.horizontal, Theme.Spacing.medium) + .padding(.vertical, Theme.Spacing.small) .background( RoundedRectangle(cornerRadius: Theme.CornerRadius.small) .fill(Theme.Colors.errorAccent.opacity(0.1)) @@ -218,49 +218,49 @@ struct SessionListView: View { RoundedRectangle(cornerRadius: Theme.CornerRadius.small) .stroke(Theme.Colors.errorAccent.opacity(0.3), lineWidth: 1) ) - } + }) .buttonStyle(PlainButtonStyle()) } } .padding(.horizontal) - + // Sessions grid LazyVGrid(columns: [ - GridItem(.flexible(), spacing: Theme.Spacing.md), - GridItem(.flexible(), spacing: Theme.Spacing.md) - ], spacing: Theme.Spacing.md) { - // Clean up all button if there are exited sessions - if showExitedSessions && viewModel.sessions.contains(where: { !$0.isRunning }) { - Button(action: { - HapticFeedback.impact(.medium) - Task { - await viewModel.cleanupAllExited() - } - }) { - HStack { - Image(systemName: "trash") - Text("Clean Up All Exited") - Spacer() - } - .font(Theme.Typography.terminalSystem(size: 14)) - .foregroundColor(Theme.Colors.warningAccent) - .padding() - .background( - RoundedRectangle(cornerRadius: Theme.CornerRadius.card) - .fill(Theme.Colors.warningAccent.opacity(0.1)) - ) - .overlay( - RoundedRectangle(cornerRadius: Theme.CornerRadius.card) - .stroke(Theme.Colors.warningAccent.opacity(0.3), lineWidth: 1) - ) + GridItem(.flexible(), spacing: Theme.Spacing.medium), + GridItem(.flexible(), spacing: Theme.Spacing.medium) + ], spacing: Theme.Spacing.medium) { + // Clean up all button if there are exited sessions + if showExitedSessions && viewModel.sessions.contains(where: { !$0.isRunning }) { + Button(action: { + HapticFeedback.impact(.medium) + Task { + await viewModel.cleanupAllExited() + } + }, label: { + HStack { + Image(systemName: "trash") + Text("Clean Up All Exited") + Spacer() + } + .font(Theme.Typography.terminalSystem(size: 14)) + .foregroundColor(Theme.Colors.warningAccent) + .padding() + .background( + RoundedRectangle(cornerRadius: Theme.CornerRadius.card) + .fill(Theme.Colors.warningAccent.opacity(0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.CornerRadius.card) + .stroke(Theme.Colors.warningAccent.opacity(0.3), lineWidth: 1) + ) + }) + .buttonStyle(PlainButtonStyle()) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .scale.combined(with: .opacity) + )) } - .buttonStyle(PlainButtonStyle()) - .transition(.asymmetric( - insertion: .scale.combined(with: .opacity), - removal: .scale.combined(with: .opacity) - )) - } - + ForEach(viewModel.sessions.filter { showExitedSessions || $0.isRunning }) { session in SessionCardView(session: session) { HapticFeedback.selection() @@ -298,15 +298,15 @@ class SessionListViewModel { var sessions: [Session] = [] var isLoading = false var errorMessage: String? - + private var refreshTask: Task? private let sessionService = SessionService.shared - + func startAutoRefresh() { refreshTask?.cancel() refreshTask = Task { await loadSessions() - + // Refresh every 3 seconds using modern async approach while !Task.isCancelled { try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds @@ -316,27 +316,27 @@ class SessionListViewModel { } } } - + func stopAutoRefresh() { refreshTask?.cancel() refreshTask = nil } - + func loadSessions() async { if sessions.isEmpty { isLoading = true } - + do { sessions = try await sessionService.getSessions() errorMessage = nil } catch { errorMessage = error.localizedDescription } - + isLoading = false } - + func killSession(_ sessionId: String) async { do { try await sessionService.killSession(sessionId) @@ -345,7 +345,7 @@ class SessionListViewModel { errorMessage = error.localizedDescription } } - + func cleanupSession(_ sessionId: String) async { do { try await sessionService.cleanupSession(sessionId) @@ -354,7 +354,7 @@ class SessionListViewModel { errorMessage = error.localizedDescription } } - + func cleanupAllExited() async { do { _ = try await sessionService.cleanupAllExitedSessions() @@ -365,9 +365,9 @@ class SessionListViewModel { HapticFeedback.notification(.error) } } - + func killAllSessions() async { - let runningSessions = sessions.filter { $0.isRunning } + let runningSessions = sessions.filter(\.isRunning) for session in runningSessions { do { try await sessionService.killSession(session.id) @@ -377,4 +377,4 @@ class SessionListViewModel { } await loadSessions() } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Terminal/CastPlayerView.swift b/ios/VibeTunnel/Views/Terminal/CastPlayerView.swift index 4e5e0d0b..82fa95e1 100644 --- a/ios/VibeTunnel/Views/Terminal/CastPlayerView.swift +++ b/ios/VibeTunnel/Views/Terminal/CastPlayerView.swift @@ -1,6 +1,6 @@ -import SwiftUI import Observation import SwiftTerm +import SwiftUI import UniformTypeIdentifiers struct CastPlayerView: View { @@ -11,13 +11,13 @@ struct CastPlayerView: View { @State private var isPlaying = false @State private var currentTime: TimeInterval = 0 @State private var playbackSpeed: Double = 1.0 - + var body: some View { NavigationStack { ZStack { Theme.Colors.terminalBackground .ignoresSafeArea() - + VStack(spacing: 0) { if viewModel.isLoading { loadingView @@ -45,30 +45,30 @@ struct CastPlayerView: View { viewModel.loadCastFile(from: castFileURL) } } - + private var loadingView: some View { - VStack(spacing: Theme.Spacing.lg) { + VStack(spacing: Theme.Spacing.large) { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent)) .scaleEffect(1.5) - + Text("Loading recording...") .font(Theme.Typography.terminalSystem(size: 14)) .foregroundColor(Theme.Colors.terminalForeground) } .frame(maxWidth: .infinity, maxHeight: .infinity) } - + private func errorView(_ error: String) -> some View { - VStack(spacing: Theme.Spacing.lg) { + VStack(spacing: Theme.Spacing.large) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 48)) .foregroundColor(Theme.Colors.errorAccent) - + Text("Failed to load recording") .font(.headline) .foregroundColor(Theme.Colors.terminalForeground) - + Text(error) .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) @@ -77,17 +77,17 @@ struct CastPlayerView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - + private var playerContent: some View { VStack(spacing: 0) { // Terminal display CastTerminalView(fontSize: $fontSize, viewModel: viewModel) .background(Theme.Colors.terminalBackground) - + // Playback controls - VStack(spacing: Theme.Spacing.md) { + VStack(spacing: Theme.Spacing.medium) { // Progress bar - VStack(spacing: Theme.Spacing.xs) { + VStack(spacing: Theme.Spacing.extraSmall) { Slider(value: $currentTime, in: 0...viewModel.duration) { editing in if !editing && isPlaying { // Resume playback from new position @@ -95,7 +95,7 @@ struct CastPlayerView: View { } } .accentColor(Theme.Colors.primaryAccent) - + HStack { Text(formatTime(currentTime)) .font(Theme.Typography.terminalSystem(size: 10)) @@ -105,9 +105,9 @@ struct CastPlayerView: View { } .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) } - + // Control buttons - HStack(spacing: Theme.Spacing.xl) { + HStack(spacing: Theme.Spacing.extraLarge) { // Speed control Menu { Button("0.5x") { playbackSpeed = 0.5 } @@ -118,21 +118,21 @@ struct CastPlayerView: View { Text("\(playbackSpeed, specifier: "%.1f")x") .font(Theme.Typography.terminalSystem(size: 14)) .foregroundColor(Theme.Colors.primaryAccent) - .padding(.horizontal, Theme.Spacing.sm) - .padding(.vertical, Theme.Spacing.xs) + .padding(.horizontal, Theme.Spacing.small) + .padding(.vertical, Theme.Spacing.extraSmall) .background( RoundedRectangle(cornerRadius: Theme.CornerRadius.small) .stroke(Theme.Colors.primaryAccent, lineWidth: 1) ) } - + // Play/Pause Button(action: togglePlayback) { Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 44)) .foregroundColor(Theme.Colors.primaryAccent) } - + // Restart Button(action: restart) { Image(systemName: "arrow.counterclockwise") @@ -150,7 +150,7 @@ struct CastPlayerView: View { } } } - + private func togglePlayback() { if isPlaying { viewModel.pause() @@ -159,7 +159,7 @@ struct CastPlayerView: View { } isPlaying.toggle() } - + private func restart() { viewModel.restart() currentTime = 0 @@ -167,7 +167,7 @@ struct CastPlayerView: View { viewModel.play(speed: playbackSpeed) } } - + private func formatTime(_ seconds: TimeInterval) -> String { let minutes = Int(seconds) / 60 let remainingSeconds = Int(seconds) % 60 @@ -175,75 +175,74 @@ struct CastPlayerView: View { } } -// Simple terminal view for cast playback +/// Simple terminal view for cast playback struct CastTerminalView: UIViewRepresentable { @Binding var fontSize: CGFloat let viewModel: CastPlayerViewModel - + func makeUIView(context: Context) -> SwiftTerm.TerminalView { let terminal = SwiftTerm.TerminalView() - + terminal.backgroundColor = UIColor(Theme.Colors.terminalBackground) terminal.nativeForegroundColor = UIColor(Theme.Colors.terminalForeground) terminal.nativeBackgroundColor = UIColor(Theme.Colors.terminalBackground) - + terminal.allowMouseReporting = false - // TODO: Check SwiftTerm API for link detection - // terminal.linkRecognizer = .autodetect - + // SwiftTerm doesn't have built-in link detection API + // URL detection would need to be implemented manually + updateFont(terminal, size: fontSize) - + // Set initial size from cast file if available if let header = viewModel.header { terminal.resize(cols: Int(header.width), rows: Int(header.height)) } else { terminal.resize(cols: 80, rows: 24) } - + context.coordinator.terminal = terminal return terminal } - + func updateUIView(_ terminal: SwiftTerm.TerminalView, context: Context) { updateFont(terminal, size: fontSize) } - + func makeCoordinator() -> Coordinator { Coordinator(viewModel: viewModel) } - + private func updateFont(_ terminal: SwiftTerm.TerminalView, size: CGFloat) { - let font: UIFont - if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) { - font = customFont + let font: UIFont = if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) { + customFont } else if let fallbackFont = UIFont(name: Theme.Typography.terminalFontFallback, size: size) { - font = fallbackFont + fallbackFont } else { - font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular) + UIFont.monospacedSystemFont(ofSize: size, weight: .regular) } terminal.font = font } - + @MainActor class Coordinator: NSObject { weak var terminal: SwiftTerm.TerminalView? let viewModel: CastPlayerViewModel - + init(viewModel: CastPlayerViewModel) { self.viewModel = viewModel super.init() - + // Set up terminal output handler viewModel.onTerminalOutput = { [weak self] data in Task { @MainActor in self?.terminal?.feed(text: data) } } - + viewModel.onTerminalClear = { [weak self] in Task { @MainActor in - // TODO: Check SwiftTerm API for clearing terminal - // For now, we'll feed a clear screen sequence + // SwiftTerm uses standard ANSI escape sequences for clearing + // This is the correct approach for clearing the terminal self?.terminal?.feed(text: "\u{001B}[2J\u{001B}[H") } } @@ -258,27 +257,27 @@ class CastPlayerViewModel { var errorMessage: String? var currentTime: TimeInterval = 0 var isSeeking = false - + var player: CastPlayer? var header: CastFile? { player?.header } var duration: TimeInterval { player?.duration ?? 0 } - + var onTerminalOutput: ((String) -> Void)? var onTerminalClear: (() -> Void)? - + private var playbackTask: Task? - + func loadCastFile(from url: URL) { Task { do { let data = try Data(contentsOf: url) - + guard let player = CastPlayer(data: data) else { errorMessage = "Invalid cast file format" isLoading = false return } - + self.player = player isLoading = false } catch { @@ -287,17 +286,17 @@ class CastPlayerViewModel { } } } - + func play(speed: Double = 1.0) { playbackTask?.cancel() - + playbackTask = Task { - guard let player = player else { return } - + guard let player else { return } + player.play(from: currentTime, speed: speed) { [weak self] event in Task { @MainActor in - guard let self = self else { return } - + guard let self else { return } + switch event.type { case "o": self.onTerminalOutput?(event.data) @@ -307,7 +306,7 @@ class CastPlayerViewModel { default: break } - + self.currentTime = event.time } } completion: { @@ -315,30 +314,30 @@ class CastPlayerViewModel { } } } - + func pause() { playbackTask?.cancel() } - + func seekTo(time: TimeInterval) { isSeeking = true currentTime = time - + // Clear terminal and replay up to the seek point onTerminalClear?() - - guard let player = player else { return } - + + guard let player else { return } + // Replay all events up to the seek time instantly for event in player.events where event.time <= time { if event.type == "o" { onTerminalOutput?(event.data) } } - + isSeeking = false } - + func restart() { playbackTask?.cancel() currentTime = 0 @@ -346,33 +345,38 @@ class CastPlayerViewModel { } } -// Extension to CastPlayer for playback from specific time +/// Extension to CastPlayer for playback from specific time extension CastPlayer { - func play(from startTime: TimeInterval = 0, speed: Double = 1.0, onEvent: @escaping @Sendable (CastEvent) -> Void, completion: @escaping @Sendable () -> Void) { + func play( + from startTime: TimeInterval = 0, + speed: Double = 1.0, + onEvent: @escaping @Sendable (CastEvent) -> Void, + completion: @escaping @Sendable () -> Void + ) { let eventsToPlay = events.filter { $0.time > startTime } Task { @Sendable in var lastEventTime = startTime - + for event in eventsToPlay { // Calculate wait time adjusted for playback speed let waitTime = (event.time - lastEventTime) / speed if waitTime > 0 { try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000)) } - + // Check if task was cancelled if Task.isCancelled { break } - + await MainActor.run { onEvent(event) } - + lastEventTime = event.time } - + await MainActor.run { completion() } } } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Terminal/FontSizeSheet.swift b/ios/VibeTunnel/Views/Terminal/FontSizeSheet.swift index b9c8a63b..d81a0b28 100644 --- a/ios/VibeTunnel/Views/Terminal/FontSizeSheet.swift +++ b/ios/VibeTunnel/Views/Terminal/FontSizeSheet.swift @@ -3,18 +3,18 @@ import SwiftUI struct FontSizeSheet: View { @Binding var fontSize: CGFloat @Environment(\.dismiss) var dismiss - + let fontSizes: [CGFloat] = [10, 12, 14, 16, 18, 20, 22, 24, 28, 32] - + var body: some View { NavigationStack { VStack(spacing: 0) { // Font size preview - VStack(spacing: Theme.Spacing.lg) { + VStack(spacing: Theme.Spacing.large) { Text("Font Size Preview") .font(.caption) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) - + Text("VibeTunnel:~ $ echo 'Hello, World!'") .font(Theme.Typography.terminal(size: fontSize)) .foregroundColor(Theme.Colors.terminalForeground) @@ -28,16 +28,16 @@ struct FontSizeSheet: View { ) } .padding() - + // Font size slider - VStack(alignment: .leading, spacing: Theme.Spacing.md) { + VStack(alignment: .leading, spacing: Theme.Spacing.medium) { HStack { Text("Size: \(Int(fontSize))pt") .font(.headline) .foregroundColor(Theme.Colors.terminalForeground) - + Spacer() - + Button("Reset") { withAnimation(Theme.Animation.quick) { fontSize = 14 @@ -47,43 +47,53 @@ struct FontSizeSheet: View { .font(.caption) .foregroundColor(Theme.Colors.primaryAccent) } - + Slider(value: $fontSize, in: 10...32, step: 1) { _ in HapticFeedback.selection() } .accentColor(Theme.Colors.primaryAccent) } .padding() - + Divider() .background(Theme.Colors.cardBorder) - + // Quick selection grid - VStack(alignment: .leading, spacing: Theme.Spacing.md) { + VStack(alignment: .leading, spacing: Theme.Spacing.medium) { Text("Quick Selection") .font(.caption) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) - - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: Theme.Spacing.sm) { + + LazyVGrid( + columns: Array(repeating: GridItem(.flexible()), count: 5), + spacing: Theme.Spacing.small + ) { ForEach(fontSizes, id: \.self) { size in Button(action: { fontSize = size HapticFeedback.impact(.light) - }) { + }, label: { Text("\(Int(size))") .font(.system(size: 14, weight: .medium)) - .foregroundColor(fontSize == size ? Theme.Colors.terminalBackground : Theme.Colors.terminalForeground) + .foregroundColor(fontSize == size ? Theme.Colors.terminalBackground : Theme.Colors + .terminalForeground + ) .frame(maxWidth: .infinity) - .padding(.vertical, Theme.Spacing.sm) + .padding(.vertical, Theme.Spacing.small) .background( RoundedRectangle(cornerRadius: Theme.CornerRadius.small) - .fill(fontSize == size ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder.opacity(0.3)) + .fill(fontSize == size ? Theme.Colors.primaryAccent : Theme.Colors + .cardBorder.opacity(0.3) + ) ) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.small) - .stroke(fontSize == size ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder, lineWidth: 1) + .stroke( + fontSize == size ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder, + lineWidth: 1 + ) ) - } + }) .buttonStyle(PlainButtonStyle()) .scaleEffect(fontSize == size ? 0.95 : 1.0) .animation(Theme.Animation.quick, value: fontSize == size) @@ -91,7 +101,7 @@ struct FontSizeSheet: View { } } .padding() - + Spacer() } .background(Theme.Colors.cardBackground) @@ -108,4 +118,4 @@ struct FontSizeSheet: View { } .preferredColorScheme(.dark) } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift b/ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift index 5f592fdf..f0f4c0ca 100644 --- a/ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift +++ b/ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift @@ -8,39 +8,39 @@ struct RecordingExportSheet: View { @State private var isExporting = false @State private var showingShareSheet = false @State private var exportedFileURL: URL? - + var body: some View { NavigationStack { - VStack(spacing: Theme.Spacing.xl) { + VStack(spacing: Theme.Spacing.extraLarge) { // Icon ZStack { Circle() .fill(Theme.Colors.primaryAccent.opacity(0.1)) .frame(width: 80, height: 80) - + Image(systemName: "record.circle.fill") .font(.system(size: 40)) .foregroundColor(Theme.Colors.primaryAccent) } - .padding(.top, Theme.Spacing.xl) - + .padding(.top, Theme.Spacing.extraLarge) + // Info - VStack(spacing: Theme.Spacing.sm) { + VStack(spacing: Theme.Spacing.small) { Text("Recording Export") .font(.title2) .fontWeight(.semibold) .foregroundColor(Theme.Colors.terminalForeground) - + if recorder.isRecording { Text("Recording in progress...") .font(Theme.Typography.terminalSystem(size: 14)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) } else if !recorder.events.isEmpty { - VStack(spacing: Theme.Spacing.xs) { + VStack(spacing: Theme.Spacing.extraSmall) { Text("\(recorder.events.count) events recorded") .font(Theme.Typography.terminalSystem(size: 14)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) - + if let duration = recorder.events.last?.time { Text("Duration: \(formatDuration(duration))") .font(Theme.Typography.terminalSystem(size: 12)) @@ -53,9 +53,9 @@ struct RecordingExportSheet: View { .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) } } - + Spacer() - + // Export button if !recorder.events.isEmpty { Button(action: exportRecording) { @@ -64,7 +64,7 @@ struct RecordingExportSheet: View { .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.terminalBackground)) .scaleEffect(0.8) } else { - HStack(spacing: Theme.Spacing.sm) { + HStack(spacing: Theme.Spacing.small) { Image(systemName: "square.and.arrow.up") Text("Export as .cast file") } @@ -74,7 +74,7 @@ struct RecordingExportSheet: View { .fontWeight(.medium) .foregroundColor(Theme.Colors.terminalBackground) .frame(maxWidth: .infinity) - .padding(.vertical, Theme.Spacing.md) + .padding(.vertical, Theme.Spacing.medium) .background( RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .fill(Theme.Colors.primaryAccent) @@ -82,7 +82,7 @@ struct RecordingExportSheet: View { .disabled(isExporting) .padding(.horizontal) } - + Spacer() } .background(Theme.Colors.terminalBackground) @@ -102,19 +102,20 @@ struct RecordingExportSheet: View { } } } - + private func exportRecording() { isExporting = true - + Task { if let castData = recorder.exportCastFile() { // Create temporary file - let fileName = "\(sessionName.replacingOccurrences(of: " ", with: "_"))_\(Date().timeIntervalSince1970).cast" + let fileName = + "\(sessionName.replacingOccurrences(of: " ", with: "_"))_\(Date().timeIntervalSince1970).cast" let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) - + do { try castData.write(to: tempURL) - + await MainActor.run { exportedFileURL = tempURL isExporting = false @@ -133,7 +134,7 @@ struct RecordingExportSheet: View { } } } - + private func formatDuration(_ seconds: TimeInterval) -> String { let minutes = Int(seconds) / 60 let remainingSeconds = Int(seconds) % 60 @@ -143,11 +144,11 @@ struct RecordingExportSheet: View { struct ShareSheet: UIViewControllerRepresentable { let items: [Any] - + func makeUIViewController(context: Context) -> UIActivityViewController { let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) return controller } - + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift b/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift index fc5f22e7..ffece677 100644 --- a/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift +++ b/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift @@ -1,5 +1,5 @@ -import SwiftUI import SwiftTerm +import SwiftUI struct TerminalHostingView: UIViewRepresentable { let session: Session @@ -8,45 +8,45 @@ struct TerminalHostingView: UIViewRepresentable { let onResize: (Int, Int) -> Void var viewModel: TerminalViewModel @State private var isAutoScrollEnabled = true - + func makeUIView(context: Context) -> SwiftTerm.TerminalView { let terminal = SwiftTerm.TerminalView() - + // Configure terminal appearance terminal.backgroundColor = UIColor(Theme.Colors.terminalBackground) terminal.nativeForegroundColor = UIColor(Theme.Colors.terminalForeground) terminal.nativeBackgroundColor = UIColor(Theme.Colors.terminalBackground) - + // Set up delegates // SwiftTerm's TerminalView uses terminalDelegate, not delegate terminal.terminalDelegate = context.coordinator - + // Configure terminal options terminal.allowMouseReporting = false terminal.optionAsMetaKey = true - + // Enable URL detection - // TODO: Check SwiftTerm API for link detection - // terminal.linkRecognizer = .autodetect - + // SwiftTerm doesn't have built-in link detection API + // URL detection would need to be implemented manually + // Configure font updateFont(terminal, size: fontSize) - + // Start with default size let cols = Int(UIScreen.main.bounds.width / 9) // Approximate char width let rows = 24 terminal.resize(cols: cols, rows: rows) - + return terminal } - + func updateUIView(_ terminal: SwiftTerm.TerminalView, context: Context) { updateFont(terminal, size: fontSize) - + // Update terminal content from viewModel context.coordinator.terminal = terminal } - + func makeCoordinator() -> Coordinator { Coordinator( onInput: onInput, @@ -54,108 +54,106 @@ struct TerminalHostingView: UIViewRepresentable { viewModel: viewModel ) } - + private func updateFont(_ terminal: SwiftTerm.TerminalView, size: CGFloat) { - let font: UIFont - if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) { - font = customFont + let font: UIFont = if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) { + customFont } else if let fallbackFont = UIFont(name: Theme.Typography.terminalFontFallback, size: size) { - font = fallbackFont + fallbackFont } else { - font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular) + UIFont.monospacedSystemFont(ofSize: size, weight: .regular) } // SwiftTerm uses the font property directly terminal.font = font } - + @MainActor class Coordinator: NSObject { let onInput: (String) -> Void let onResize: (Int, Int) -> Void let viewModel: TerminalViewModel weak var terminal: SwiftTerm.TerminalView? - - init(onInput: @escaping (String) -> Void, - onResize: @escaping (Int, Int) -> Void, - viewModel: TerminalViewModel) { + + init( + onInput: @escaping (String) -> Void, + onResize: @escaping (Int, Int) -> Void, + viewModel: TerminalViewModel + ) { self.onInput = onInput self.onResize = onResize self.viewModel = viewModel super.init() - + // Set the coordinator reference on the viewModel Task { @MainActor in viewModel.terminalCoordinator = self } } - + func feedData(_ data: String) { Task { @MainActor in - guard let terminal = terminal else { return } - + guard let terminal else { return } + // Store current scroll position before feeding data let wasAtBottom = viewModel.isAutoScrollEnabled - + // Feed the output to the terminal terminal.feed(text: data) - + // Auto-scroll to bottom if enabled if wasAtBottom { // SwiftTerm automatically scrolls when feeding data at bottom - // TODO: Check SwiftTerm API for explicit scrolling if needed - // terminal.scrollToBottom() + // No explicit API needed for auto-scrolling } } } - + // MARK: - TerminalViewDelegate - + func send(source: SwiftTerm.TerminalView, data: ArraySlice) { if let string = String(bytes: data, encoding: .utf8) { onInput(string) } } - + func sizeChanged(source: SwiftTerm.TerminalView, newCols: Int, newRows: Int) { onResize(newCols, newRows) } - + func scrolled(source: SwiftTerm.TerminalView, position: Double) { - // TODO: Implement scroll position tracking with SwiftTerm API - // The current implementation needs to be updated for the actual SwiftTerm API - /* - // Check if user manually scrolled away from bottom - if let terminal = terminal { - let buffer = terminal.buffer - let totalRows = buffer.lines.count - let viewportHeight = terminal.rows - let maxScroll = Double(max(0, totalRows - viewportHeight)) - - // If user scrolled away from bottom (with some tolerance) - let isAtBottom = position >= maxScroll - 5 - - Task { @MainActor in - if !isAtBottom && viewModel.isAutoScrollEnabled { - // User manually scrolled up - disable auto-scroll - viewModel.isAutoScrollEnabled = false - } else if isAtBottom && !viewModel.isAutoScrollEnabled { - // User scrolled back to bottom - re-enable auto-scroll - viewModel.isAutoScrollEnabled = true - } - } - } - */ + // SwiftTerm doesn't expose detailed scroll position tracking + // The position parameter represents the relative scroll position + // // Check if user manually scrolled away from bottom + // if let terminal = terminal { + // let buffer = terminal.buffer + // let totalRows = buffer.lines.count + // let viewportHeight = terminal.rows + // let maxScroll = Double(max(0, totalRows - viewportHeight)) + // + // // If user scrolled away from bottom (with some tolerance) + // let isAtBottom = position >= maxScroll - 5 + // + // Task { @MainActor in + // if !isAtBottom && viewModel.isAutoScrollEnabled { + // // User manually scrolled up - disable auto-scroll + // viewModel.isAutoScrollEnabled = false + // } else if isAtBottom && !viewModel.isAutoScrollEnabled { + // // User scrolled back to bottom - re-enable auto-scroll + // viewModel.isAutoScrollEnabled = true + // } + // } + // } } - + func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) { // Handle title change if needed } - + func hostCurrentDirectoryUpdate(source: SwiftTerm.TerminalView, directory: String?) { // Handle directory update if needed } - - func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String : String]) { + + func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String: String]) { // Open URL if let url = URL(string: link) { DispatchQueue.main.async { @@ -163,19 +161,19 @@ struct TerminalHostingView: UIViewRepresentable { } } } - + func clipboardCopy(source: SwiftTerm.TerminalView, content: Data) { // Handle clipboard copy if let string = String(data: content, encoding: .utf8) { UIPasteboard.general.string = string } } - + func rangeChanged(source: SwiftTerm.TerminalView, startY: Int, endY: Int) { // Handle range change if needed } } } -// Add conformance with proper isolation -extension TerminalHostingView.Coordinator: @preconcurrency SwiftTerm.TerminalViewDelegate {} \ No newline at end of file +/// Add conformance with proper isolation +extension TerminalHostingView.Coordinator: @preconcurrency SwiftTerm.TerminalViewDelegate {} diff --git a/ios/VibeTunnel/Views/Terminal/TerminalToolbar.swift b/ios/VibeTunnel/Views/Terminal/TerminalToolbar.swift index b711950b..630bb92d 100644 --- a/ios/VibeTunnel/Views/Terminal/TerminalToolbar.swift +++ b/ios/VibeTunnel/Views/Terminal/TerminalToolbar.swift @@ -5,34 +5,36 @@ struct TerminalToolbar: View { let onDismissKeyboard: () -> Void let onRawInput: ((String) -> Void)? @State private var showMoreKeys = false - - init(onSpecialKey: @escaping (TerminalInput.SpecialKey) -> Void, - onDismissKeyboard: @escaping () -> Void, - onRawInput: ((String) -> Void)? = nil) { + + init( + onSpecialKey: @escaping (TerminalInput.SpecialKey) -> Void, + onDismissKeyboard: @escaping () -> Void, + onRawInput: ((String) -> Void)? = nil + ) { self.onSpecialKey = onSpecialKey self.onDismissKeyboard = onDismissKeyboard self.onRawInput = onRawInput } - + var body: some View { VStack(spacing: 0) { Divider() .background(Theme.Colors.cardBorder) - - HStack(spacing: Theme.Spacing.xs) { + + HStack(spacing: Theme.Spacing.extraSmall) { // Tab key ToolbarButton(label: "TAB", systemImage: "arrow.right.to.line.compact") { HapticFeedback.impact(.light) onSpecialKey(.tab) } - + // Arrow keys HStack(spacing: 2) { ToolbarButton(label: "←", width: 35) { HapticFeedback.impact(.light) onSpecialKey(.arrowLeft) } - + VStack(spacing: 2) { ToolbarButton(label: "↑", width: 35, height: 20) { HapticFeedback.impact(.light) @@ -43,19 +45,19 @@ struct TerminalToolbar: View { onSpecialKey(.arrowDown) } } - + ToolbarButton(label: "→", width: 35) { HapticFeedback.impact(.light) onSpecialKey(.arrowRight) } } - + // ESC key ToolbarButton(label: "ESC") { HapticFeedback.impact(.light) onSpecialKey(.escape) } - + // More keys toggle ToolbarButton( label: "•••", @@ -66,88 +68,88 @@ struct TerminalToolbar: View { showMoreKeys.toggle() } } - + Spacer() - + // Dismiss keyboard ToolbarButton(systemImage: "keyboard.chevron.compact.down") { HapticFeedback.impact(.light) onDismissKeyboard() } } - .padding(.horizontal, Theme.Spacing.sm) - .padding(.vertical, Theme.Spacing.xs) + .padding(.horizontal, Theme.Spacing.small) + .padding(.vertical, Theme.Spacing.extraSmall) .background(Theme.Colors.cardBackground) - + // Extended toolbar if showMoreKeys { Divider() .background(Theme.Colors.cardBorder) - - VStack(spacing: Theme.Spacing.xs) { + + VStack(spacing: Theme.Spacing.extraSmall) { // First row of control keys - HStack(spacing: Theme.Spacing.xs) { + HStack(spacing: Theme.Spacing.extraSmall) { ToolbarButton(label: "CTRL+A") { HapticFeedback.impact(.medium) onSpecialKey(.ctrlA) } - + ToolbarButton(label: "CTRL+C") { HapticFeedback.impact(.medium) onSpecialKey(.ctrlC) } - + ToolbarButton(label: "CTRL+D") { HapticFeedback.impact(.medium) onSpecialKey(.ctrlD) } - + ToolbarButton(label: "CTRL+E") { HapticFeedback.impact(.medium) onSpecialKey(.ctrlE) } } - + // Second row of control keys - HStack(spacing: Theme.Spacing.xs) { + HStack(spacing: Theme.Spacing.extraSmall) { ToolbarButton(label: "CTRL+L") { HapticFeedback.impact(.medium) onSpecialKey(.ctrlL) } - + ToolbarButton(label: "CTRL+Z") { HapticFeedback.impact(.medium) onSpecialKey(.ctrlZ) } - + ToolbarButton(label: "ENTER") { HapticFeedback.impact(.light) onSpecialKey(.enter) } - + ToolbarButton(label: "HOME") { HapticFeedback.impact(.light) // Send Ctrl+A for home onSpecialKey(.ctrlA) } } - + // Third row - custom Ctrl key input - HStack(spacing: Theme.Spacing.xs) { + HStack(spacing: Theme.Spacing.extraSmall) { Text("CTRL +") .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) - .padding(.leading, Theme.Spacing.sm) - + .padding(.leading, Theme.Spacing.small) + ForEach(["K", "U", "W", "R", "T"], id: \.self) { letter in ToolbarButton(label: letter, width: 44) { HapticFeedback.impact(.medium) // Send the control character for the letter if let charCode = letter.first?.asciiValue { let controlCharCode = Int(charCode - 64) // A=1, B=2, etc. - let controlChar = String(UnicodeScalar(controlCharCode)!) + let controlChar = UnicodeScalar(controlCharCode).map(String.init) ?? "" // Use raw input if available, otherwise fall back to sending as text - if let onRawInput = onRawInput { + if let onRawInput { onRawInput(controlChar) } else { // Fallback - just send Ctrl+C @@ -156,12 +158,12 @@ struct TerminalToolbar: View { } } } - + Spacer() } } - .padding(.horizontal, Theme.Spacing.sm) - .padding(.vertical, Theme.Spacing.xs) + .padding(.horizontal, Theme.Spacing.small) + .padding(.vertical, Theme.Spacing.extraSmall) .background(Theme.Colors.cardBackground) .transition(.asymmetric( insertion: .move(edge: .top).combined(with: .opacity), @@ -180,7 +182,7 @@ struct ToolbarButton: View { let height: CGFloat? let isActive: Bool let action: () -> Void - + init( label: String? = nil, systemImage: String? = nil, @@ -196,15 +198,15 @@ struct ToolbarButton: View { self.isActive = isActive self.action = action } - + var body: some View { Button(action: action) { Group { - if let label = label { + if let label { Text(label) .font(Theme.Typography.terminalSystem(size: 12)) .fontWeight(.medium) - } else if let systemImage = systemImage { + } else if let systemImage { Image(systemName: systemImage) .font(.system(size: 16)) } @@ -228,4 +230,4 @@ struct ToolbarButton: View { .scaleEffect(isActive ? 0.95 : 1.0) .animation(Theme.Animation.quick, value: isActive) } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Terminal/TerminalView.swift b/ios/VibeTunnel/Views/Terminal/TerminalView.swift index c09f7afb..52ede710 100644 --- a/ios/VibeTunnel/Views/Terminal/TerminalView.swift +++ b/ios/VibeTunnel/Views/Terminal/TerminalView.swift @@ -1,6 +1,6 @@ -import SwiftUI import Observation import SwiftTerm +import SwiftUI struct TerminalView: View { let session: Session @@ -11,19 +11,19 @@ struct TerminalView: View { @State private var showingRecordingSheet = false @State private var keyboardHeight: CGFloat = 0 @FocusState private var isInputFocused: Bool - + init(session: Session) { self.session = session self._viewModel = State(initialValue: TerminalViewModel(session: session)) } - + var body: some View { NavigationStack { ZStack { // Background Theme.Colors.terminalBackground .ignoresSafeArea() - + // Terminal content VStack(spacing: 0) { if viewModel.isConnecting { @@ -47,40 +47,40 @@ struct TerminalView: View { } .foregroundColor(Theme.Colors.primaryAccent) } - + ToolbarItem(placement: .navigationBarTrailing) { Menu { - Button(action: { viewModel.clearTerminal() }) { + Button(action: { viewModel.clearTerminal() }, label: { Label("Clear", systemImage: "clear") - } - - Button(action: { showingFontSizeSheet = true }) { + }) + + Button(action: { showingFontSizeSheet = true }, label: { Label("Font Size", systemImage: "textformat.size") - } - - Button(action: { viewModel.copyBuffer() }) { + }) + + Button(action: { viewModel.copyBuffer() }, label: { Label("Copy All", systemImage: "doc.on.doc") - } - + }) + Divider() - + if viewModel.castRecorder.isRecording { - Button(action: { + Button(action: { viewModel.stopRecording() showingRecordingSheet = true - }) { + }, label: { Label("Stop Recording", systemImage: "stop.circle.fill") .foregroundColor(.red) - } + }) } else { - Button(action: { viewModel.startRecording() }) { + Button(action: { viewModel.startRecording() }, label: { Label("Start Recording", systemImage: "record.circle") - } + }) } - - Button(action: { showingRecordingSheet = true }) { + + Button(action: { showingRecordingSheet = true }, label: { Label("Export Recording", systemImage: "square.and.arrow.up") - } + }) .disabled(viewModel.castRecorder.events.isEmpty) } label: { Image(systemName: "ellipsis.circle") @@ -97,7 +97,7 @@ struct TerminalView: View { .toolbar { ToolbarItemGroup(placement: .bottomBar) { if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 { - HStack(spacing: Theme.Spacing.xs) { + HStack(spacing: Theme.Spacing.extraSmall) { Image(systemName: "rectangle.split.3x1") .font(.caption) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) @@ -106,22 +106,26 @@ struct TerminalView: View { .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) } } - + Spacer() - + // Session status HStack(spacing: 4) { Circle() - .fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.3)) + .fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground + .opacity(0.3) + ) .frame(width: 6, height: 6) Text(session.isRunning ? "Running" : "Exited") .font(Theme.Typography.terminalSystem(size: 12)) - .foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.5)) + .foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors + .terminalForeground.opacity(0.5) + ) } - + if let pid = session.pid { Spacer() - + Text("PID: \(pid)") .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) @@ -131,7 +135,7 @@ struct TerminalView: View { } } } - + // Recording indicator ToolbarItem(placement: .navigationBarTrailing) { if viewModel.castRecorder.isRecording { @@ -144,7 +148,10 @@ struct TerminalView: View { .fill(Color.red.opacity(0.3)) .frame(width: 16, height: 16) .scaleEffect(viewModel.recordingPulse ? 1.5 : 1.0) - .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: viewModel.recordingPulse) + .animation( + .easeInOut(duration: 1.0).repeatForever(autoreverses: true), + value: viewModel.recordingPulse + ) ) Text("REC") .font(.system(size: 12, weight: .bold)) @@ -165,7 +172,9 @@ struct TerminalView: View { .onDisappear { viewModel.disconnect() } - .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in + .onReceive(NotificationCenter.default + .publisher(for: UIResponder.keyboardWillShowNotification) + ) { notification in if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { withAnimation(Theme.Animation.standard) { keyboardHeight = keyboardFrame.height @@ -178,36 +187,36 @@ struct TerminalView: View { } } } - + private var loadingView: some View { - VStack(spacing: Theme.Spacing.lg) { + VStack(spacing: Theme.Spacing.large) { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent)) .scaleEffect(1.5) - + Text("Connecting to session...") .font(Theme.Typography.terminalSystem(size: 14)) .foregroundColor(Theme.Colors.terminalForeground) } .frame(maxWidth: .infinity, maxHeight: .infinity) } - + private func errorView(_ error: String) -> some View { - VStack(spacing: Theme.Spacing.lg) { + VStack(spacing: Theme.Spacing.large) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 48)) .foregroundColor(Theme.Colors.errorAccent) - + Text("Connection Error") .font(.headline) .foregroundColor(Theme.Colors.terminalForeground) - + Text(error) .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) .multilineTextAlignment(.center) .padding(.horizontal) - + Button("Retry") { viewModel.connect() } @@ -215,7 +224,7 @@ struct TerminalView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - + private var terminalContent: some View { VStack(spacing: 0) { // Terminal hosting view @@ -235,7 +244,7 @@ struct TerminalView: View { .id(viewModel.terminalViewId) .background(Theme.Colors.terminalBackground) .focused($isInputFocused) - + // Keyboard toolbar if keyboardHeight > 0 { TerminalToolbar( @@ -266,51 +275,51 @@ class TerminalViewModel { var terminalRows: Int = 0 var isAutoScrollEnabled = true var recordingPulse = false - + let session: Session let castRecorder: CastRecorder private var bufferWebSocketClient: BufferWebSocketClient? private var connectionStatusTask: Task? private var connectionErrorTask: Task? weak var terminalCoordinator: TerminalHostingView.Coordinator? - + init(session: Session) { self.session = session self.castRecorder = CastRecorder(sessionId: session.id, width: 80, height: 24) setupTerminal() } - + private func setupTerminal() { // Terminal setup now handled by SimpleTerminalView } - + func startRecording() { castRecorder.startRecording() } - + func stopRecording() { castRecorder.stopRecording() } - + func connect() { isConnecting = true errorMessage = nil - + // Create WebSocket client if needed if bufferWebSocketClient == nil { bufferWebSocketClient = BufferWebSocketClient() } - + // Connect to WebSocket bufferWebSocketClient?.connect() - + // Subscribe to terminal events bufferWebSocketClient?.subscribe(to: session.id) { [weak self] event in Task { @MainActor in self?.handleWebSocketEvent(event) } } - + // Monitor connection status connectionStatusTask?.cancel() connectionStatusTask = Task { [weak self] in @@ -329,7 +338,7 @@ class TerminalViewModel { try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds } } - + // Monitor connection errors connectionErrorTask?.cancel() connectionErrorTask = Task { [weak self] in @@ -345,11 +354,11 @@ class TerminalViewModel { } } } - + @MainActor private func loadSnapshot() async { guard let snapshotURL = APIClient.shared.snapshotURL(for: session.id) else { return } - + do { let (data, _) = try await URLSession.shared.data(from: snapshotURL) if let snapshot = String(data: data, encoding: .utf8) { @@ -360,7 +369,7 @@ class TerminalViewModel { print("Failed to load terminal snapshot: \(error)") } } - + func disconnect() { connectionStatusTask?.cancel() connectionErrorTask?.cancel() @@ -369,7 +378,7 @@ class TerminalViewModel { bufferWebSocketClient = nil isConnected = false } - + @MainActor private func handleWebSocketEvent(_ event: TerminalWebSocketEvent) { switch event { @@ -379,13 +388,13 @@ class TerminalViewModel { terminalCols = width terminalRows = height // The terminal will be resized when created - + case .output(_, let data): // Feed output data directly to the terminal terminalCoordinator?.feedData(data) // Record output if recording castRecorder.recordOutput(data) - + case .resize(_, let dimensions): // Parse dimensions like "120x30" let parts = dimensions.split(separator: "x") @@ -399,7 +408,7 @@ class TerminalViewModel { // Record resize event castRecorder.recordResize(cols: cols, rows: rows) } - + case .exit(let code): // Session has exited isConnected = false @@ -412,7 +421,7 @@ class TerminalViewModel { } } } - + func sendInput(_ text: String) { Task { do { @@ -422,7 +431,7 @@ class TerminalViewModel { } } } - + func resize(cols: Int, rows: Int) { Task { do { @@ -432,15 +441,15 @@ class TerminalViewModel { } } } - + func clearTerminal() { // Reset the terminal by recreating it terminalViewId = UUID() HapticFeedback.impact(.medium) } - + func copyBuffer() { // Terminal copy is handled by SwiftTerm's built-in functionality HapticFeedback.notification(.success) } -} \ No newline at end of file +}