diff --git a/apple/.swiftlint.yml b/apple/.swiftlint.yml index 37b464e3..39fbbaa0 100644 --- a/apple/.swiftlint.yml +++ b/apple/.swiftlint.yml @@ -17,7 +17,7 @@ excluded: - ../mac/build - ../ios/build - Package.swift - - *.xcodeproj + - "*.xcodeproj" # Rule configuration opt_in_rules: @@ -128,9 +128,6 @@ custom_rules: regex: '\bprint\(' message: "Use proper logging instead of print statements" severity: warning - excluded: - - "*/Tests/*" - - "*/UITests/*" analyzer_rules: - unused_import diff --git a/ios/Package.swift b/ios/Package.swift index 9d7ea81f..f95b9957 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -14,13 +14,15 @@ let package = Package( ) ], 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"), + .package(url: "https://github.com/mhdhejazi/Dynamic.git", from: "1.2.0") ], targets: [ .target( name: "VibeTunnelDependencies", dependencies: [ - .product(name: "SwiftTerm", package: "SwiftTerm") + .product(name: "SwiftTerm", package: "SwiftTerm"), + .product(name: "Dynamic", package: "Dynamic") ] ) ] diff --git a/ios/VibeTunnel-iOS.xcodeproj/project.pbxproj b/ios/VibeTunnel-iOS.xcodeproj/project.pbxproj index 27fd188f..7baab3ba 100644 --- a/ios/VibeTunnel-iOS.xcodeproj/project.pbxproj +++ b/ios/VibeTunnel-iOS.xcodeproj/project.pbxproj @@ -69,7 +69,10 @@ 78868B612DFF808300B22C15 /* Exceptions for "VibeTunnel" folder in "VibeTunnel" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Local.xcconfig, Resources/Info.plist, + Shared.xcconfig, + version.xcconfig, ); target = 788687F02DFF4FCB00B22C15 /* VibeTunnel */; }; @@ -219,8 +222,11 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1600; - LastUpgradeCheck = 1600; + LastUpgradeCheck = 2600; TargetAttributes = { + 04469FB37E8A42F9D06BF670 = { + TestTargetID = 788687F02DFF4FCB00B22C15; + }; 788687F02DFF4FCB00B22C15 = { CreatedOnToolsVersion = 16.0; }; @@ -399,6 +405,7 @@ 6BD919EBC6E8FC8AE5C0AD08 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_STYLE = Automatic; ENABLE_TESTING_FRAMEWORKS = YES; @@ -408,6 +415,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VibeTunnel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VibeTunnel"; VALIDATE_PRODUCT = YES; }; name = Release; @@ -492,6 +500,7 @@ AB5CCE958CDF40666B1D5C7F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_STYLE = Automatic; ENABLE_TESTING_FRAMEWORKS = YES; @@ -501,6 +510,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VibeTunnel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VibeTunnel"; }; name = Debug; }; @@ -508,6 +518,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -561,6 +572,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -570,6 +582,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -616,6 +629,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; diff --git a/ios/VibeTunnel/App/ContentView.swift b/ios/VibeTunnel/App/ContentView.swift index 6da64ee2..1c381d70 100644 --- a/ios/VibeTunnel/App/ContentView.swift +++ b/ios/VibeTunnel/App/ContentView.swift @@ -6,11 +6,15 @@ import UniformTypeIdentifiers /// Displays either the connection view or session list based on /// connection state, and handles opening cast files. struct ContentView: View { - @Environment(ConnectionManager.self) var connectionManager + @Environment(ConnectionManager.self) + var connectionManager @State private var showingFilePicker = false @State private var showingCastPlayer = false @State private var selectedCastFile: URL? @State private var isValidatingConnection = true + @State private var showingWelcome = false + @AppStorage("welcomeCompleted") + private var welcomeCompleted = false var body: some View { Group { @@ -30,12 +34,20 @@ struct ContentView: View { } else if connectionManager.isConnected, connectionManager.serverConfig != nil { SessionListView() } else { - ConnectionView() + EnhancedConnectionView() } } .animation(.default, value: connectionManager.isConnected) .onAppear { validateRestoredConnection() + + // Show welcome on first launch + if !welcomeCompleted { + showingWelcome = true + } + } + .fullScreenCover(isPresented: $showingWelcome) { + WelcomeView() } .onOpenURL { url in // Handle cast file opening diff --git a/ios/VibeTunnel/App/VibeTunnelApp.swift b/ios/VibeTunnel/App/VibeTunnelApp.swift index 84b9c6ba..6fc1acda 100644 --- a/ios/VibeTunnel/App/VibeTunnelApp.swift +++ b/ios/VibeTunnel/App/VibeTunnelApp.swift @@ -7,7 +7,7 @@ struct VibeTunnelApp: App { @State private var connectionManager = ConnectionManager() @State private var navigationManager = NavigationManager() @State private var networkMonitor = NetworkMonitor.shared - + init() { // Configure app logging level AppConfig.configureLogging() @@ -26,8 +26,18 @@ struct VibeTunnelApp: App { // Initialize network monitoring _ = networkMonitor } + #if targetEnvironment(macCatalyst) + .macCatalystWindowStyle(getStoredWindowStyle()) + #endif } } + + #if targetEnvironment(macCatalyst) + private func getStoredWindowStyle() -> MacWindowStyle { + let styleRaw = UserDefaults.standard.string(forKey: "macWindowStyle") ?? "standard" + return styleRaw == "inline" ? .inline : .standard + } + #endif private func handleURL(_ url: URL) { // Handle vibetunnel://session/{sessionId} URLs @@ -112,8 +122,7 @@ class ConnectionManager { /// Make ConnectionManager accessible globally for APIClient extension ConnectionManager { - @MainActor - static let shared = ConnectionManager() + @MainActor static let shared = ConnectionManager() } /// Manages app-wide navigation state. diff --git a/ios/VibeTunnel/Configuration/AppConfig.swift b/ios/VibeTunnel/Configuration/AppConfig.swift index f39bb8d6..7736f336 100644 --- a/ios/VibeTunnel/Configuration/AppConfig.swift +++ b/ios/VibeTunnel/Configuration/AppConfig.swift @@ -1,16 +1,17 @@ import Foundation /// App configuration for VibeTunnel -struct AppConfig { +enum AppConfig { /// Set the logging level for the app /// Change this to control verbosity of logs static func configureLogging() { #if DEBUG - // In debug builds, you can change this to .verbose to see all logs - Logger.globalLevel = .info // Change to .verbose for detailed logging + // In debug builds, default to info level to reduce noise + // Change to .verbose only when debugging binary protocol issues + Logger.globalLevel = .info #else - // In release builds, only show warnings and errors - Logger.globalLevel = .warning + // In release builds, only show warnings and errors + Logger.globalLevel = .warning #endif } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Models/CastFile.swift b/ios/VibeTunnel/Models/CastFile.swift index 4af2c71d..d6dba9cd 100644 --- a/ios/VibeTunnel/Models/CastFile.swift +++ b/ios/VibeTunnel/Models/CastFile.swift @@ -292,4 +292,26 @@ class CastPlayer { } } } + + /// Modern async version of play that supports cancellation and error handling. + /// + /// - Parameter onEvent: Async closure called for each event during playback. + /// - Throws: Throws if playback is cancelled or encounters an error. + /// + /// Events are delivered on the main actor with delays matching + /// their original timing. + @MainActor + func play(onEvent: @Sendable (CastEvent) async -> Void) async throws { + for event in events { + // Check for cancellation + try Task.checkCancellation() + + // Wait for the appropriate time + if event.time > 0 { + try await Task.sleep(nanoseconds: UInt64(event.time * 1_000_000_000)) + } + + await onEvent(event) + } + } } diff --git a/ios/VibeTunnel/Models/FileEntry.swift b/ios/VibeTunnel/Models/FileEntry.swift index ab03c7b2..a73cf2c8 100644 --- a/ios/VibeTunnel/Models/FileEntry.swift +++ b/ios/VibeTunnel/Models/FileEntry.swift @@ -37,7 +37,16 @@ struct FileEntry: Codable, Identifiable { /// - modTime: The modification time /// - isGitTracked: Whether the file is in a git repository /// - gitStatus: The git status of the file - init(name: String, path: String, isDir: Bool, size: Int64, mode: String, modTime: Date, isGitTracked: Bool? = nil, gitStatus: GitFileStatus? = nil) { + init( + name: String, + path: String, + isDir: Bool, + size: Int64, + mode: String, + modTime: Date, + isGitTracked: Bool? = nil, + gitStatus: GitFileStatus? = nil + ) { self.name = name self.path = path self.isDir = isDir @@ -134,13 +143,13 @@ struct GitStatus: Codable { struct DirectoryListing: Codable { /// The absolute path of the directory being listed. let absolutePath: String - + /// Array of file and subdirectory entries in this directory. let files: [FileEntry] - + /// Git status information for the directory let gitStatus: GitStatus? - + enum CodingKeys: String, CodingKey { case absolutePath = "fullPath" case files diff --git a/ios/VibeTunnel/Models/ServerProfile.swift b/ios/VibeTunnel/Models/ServerProfile.swift new file mode 100644 index 00000000..5f0a2ced --- /dev/null +++ b/ios/VibeTunnel/Models/ServerProfile.swift @@ -0,0 +1,132 @@ +import Foundation + +/// A saved server configuration profile +struct ServerProfile: Identifiable, Codable, Equatable { + let id: UUID + var name: String + var url: String + var requiresAuth: Bool + var username: String? + var lastConnected: Date? + var iconSymbol: String + var createdAt: Date + var updatedAt: Date + + init( + id: UUID = UUID(), + name: String, + url: String, + requiresAuth: Bool = false, + username: String? = nil, + lastConnected: Date? = nil, + iconSymbol: String = "server.rack", + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.name = name + self.url = url + self.requiresAuth = requiresAuth + self.username = username + self.lastConnected = lastConnected + self.iconSymbol = iconSymbol + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Create a ServerConfig from this profile + func toServerConfig(password: String? = nil) -> ServerConfig? { + guard let urlComponents = URLComponents(string: url), + let host = urlComponents.host else { + return nil + } + + // Determine default port based on scheme + let defaultPort: Int + if let scheme = urlComponents.scheme?.lowercased() { + defaultPort = scheme == "https" ? 443 : 80 + } else { + defaultPort = 80 + } + + let port = urlComponents.port ?? defaultPort + + return ServerConfig( + host: host, + port: port, + name: name, + password: requiresAuth ? password : nil + ) + } +} + +// MARK: - Storage + +extension ServerProfile { + static let storageKey = "savedServerProfiles" + + /// Load all saved profiles from UserDefaults + static func loadAll() -> [ServerProfile] { + guard let data = UserDefaults.standard.data(forKey: storageKey), + let profiles = try? JSONDecoder().decode([ServerProfile].self, from: data) else { + return [] + } + return profiles + } + + /// Save profiles to UserDefaults + static func saveAll(_ profiles: [ServerProfile]) { + if let data = try? JSONEncoder().encode(profiles) { + UserDefaults.standard.set(data, forKey: storageKey) + } + } + + /// Add or update a profile + static func save(_ profile: ServerProfile) { + var profiles = loadAll() + if let index = profiles.firstIndex(where: { $0.id == profile.id }) { + profiles[index] = profile + } else { + profiles.append(profile) + } + saveAll(profiles) + } + + /// Delete a profile + static func delete(_ profile: ServerProfile) { + var profiles = loadAll() + profiles.removeAll { $0.id == profile.id } + saveAll(profiles) + } + + /// Update last connected time + static func updateLastConnected(for profileId: UUID) { + var profiles = loadAll() + if let index = profiles.firstIndex(where: { $0.id == profileId }) { + profiles[index].lastConnected = Date() + profiles[index].updatedAt = Date() + saveAll(profiles) + } + } +} + +// MARK: - Common Server Templates + +extension ServerProfile { + static let commonPorts = ["3000", "8080", "8000", "5000", "3001", "4000"] + + static func suggestedName(for url: String) -> String { + if let urlComponents = URLComponents(string: url), + let host = urlComponents.host { + // Remove common suffixes + let cleanHost = host + .replacingOccurrences(of: ".local", with: "") + .replacingOccurrences(of: ".com", with: "") + .replacingOccurrences(of: ".dev", with: "") + + // Capitalize first letter + return cleanHost.prefix(1).uppercased() + cleanHost.dropFirst() + } + return "Server" + } +} diff --git a/ios/VibeTunnel/Models/Session.swift b/ios/VibeTunnel/Models/Session.swift index 3d8a1f9f..c6763069 100644 --- a/ios/VibeTunnel/Models/Session.swift +++ b/ios/VibeTunnel/Models/Session.swift @@ -7,7 +7,7 @@ import Foundation /// and terminal dimensions. struct Session: Codable, Identifiable, Equatable, Hashable { let id: String - let command: [String] // Changed from String to [String] to match server + let command: [String] // Changed from String to [String] to match server let workingDir: String let name: String? let status: SessionStatus @@ -15,12 +15,12 @@ struct Session: Codable, Identifiable, Equatable, Hashable { let startedAt: String let lastModified: String? let pid: Int? - + // Terminal dimensions let width: Int? let height: Int? let waiting: Bool? - + // Optional fields from HQ mode let source: String? let remoteId: String? @@ -50,7 +50,7 @@ struct Session: Codable, Identifiable, Equatable, Hashable { /// /// Returns the custom name if not empty, otherwise the command. var displayName: String { - if let name = name, !name.isEmpty { + if let name, !name.isEmpty { return name } return command.joined(separator: " ") diff --git a/ios/VibeTunnel/Models/TerminalRenderer.swift b/ios/VibeTunnel/Models/TerminalRenderer.swift index d4bf32c2..52c1d3c6 100644 --- a/ios/VibeTunnel/Models/TerminalRenderer.swift +++ b/ios/VibeTunnel/Models/TerminalRenderer.swift @@ -4,30 +4,30 @@ import Foundation enum TerminalRenderer: String, CaseIterable, Codable { case swiftTerm = "SwiftTerm" case xterm = "xterm.js" - + var displayName: String { switch self { case .swiftTerm: - return "SwiftTerm (Native)" + "SwiftTerm (Native)" case .xterm: - return "xterm.js (WebView)" + "xterm.js (WebView)" } } - + var description: String { switch self { case .swiftTerm: - return "Native Swift terminal emulator with best performance" + "Native Swift terminal emulator with best performance" case .xterm: - return "JavaScript-based terminal, identical to web version" + "JavaScript-based terminal, identical to web version" } } - + /// The currently selected renderer (persisted in UserDefaults) - static var selected: TerminalRenderer { + static var selected: Self { get { if let rawValue = UserDefaults.standard.string(forKey: "selectedTerminalRenderer"), - let renderer = TerminalRenderer(rawValue: rawValue) { + let renderer = Self(rawValue: rawValue) { return renderer } return .swiftTerm // Default @@ -36,4 +36,4 @@ enum TerminalRenderer: String, CaseIterable, Codable { UserDefaults.standard.set(newValue.rawValue, forKey: "selectedTerminalRenderer") } } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Models/TerminalWidth.swift b/ios/VibeTunnel/Models/TerminalWidth.swift index c3b23b36..7b8c34c3 100644 --- a/ios/VibeTunnel/Models/TerminalWidth.swift +++ b/ios/VibeTunnel/Models/TerminalWidth.swift @@ -9,64 +9,64 @@ enum TerminalWidth: CaseIterable, Equatable { case mainframe132 case ultraWide160 case custom(Int) - + var value: Int { switch self { - case .unlimited: return 0 - case .classic80: return 80 - case .modern100: return 100 - case .wide120: return 120 - case .mainframe132: return 132 - case .ultraWide160: return 160 - case .custom(let width): return width + case .unlimited: 0 + case .classic80: 80 + case .modern100: 100 + case .wide120: 120 + case .mainframe132: 132 + case .ultraWide160: 160 + case .custom(let width): width } } - + var label: String { switch self { - case .unlimited: return "∞" - case .classic80: return "80" - case .modern100: return "100" - case .wide120: return "120" - case .mainframe132: return "132" - case .ultraWide160: return "160" - case .custom(let width): return "\(width)" + case .unlimited: "∞" + case .classic80: "80" + case .modern100: "100" + case .wide120: "120" + case .mainframe132: "132" + case .ultraWide160: "160" + case .custom(let width): "\(width)" } } - + var description: String { switch self { - case .unlimited: return "Unlimited" - case .classic80: return "Classic terminal" - case .modern100: return "Modern standard" - case .wide120: return "Wide terminal" - case .mainframe132: return "Mainframe width" - case .ultraWide160: return "Ultra-wide" - case .custom: return "Custom width" + case .unlimited: "Unlimited" + case .classic80: "Classic terminal" + case .modern100: "Modern standard" + case .wide120: "Wide terminal" + case .mainframe132: "Mainframe width" + case .ultraWide160: "Ultra-wide" + case .custom: "Custom width" } } - - static var allCases: [TerminalWidth] { + + static var allCases: [Self] { [.unlimited, .classic80, .modern100, .wide120, .mainframe132, .ultraWide160] } - - static func from(value: Int) -> TerminalWidth { + + static func from(value: Int) -> Self { switch value { - case 0: return .unlimited - case 80: return .classic80 - case 100: return .modern100 - case 120: return .wide120 - case 132: return .mainframe132 - case 160: return .ultraWide160 - default: return .custom(value) + case 0: .unlimited + case 80: .classic80 + case 100: .modern100 + case 120: .wide120 + case 132: .mainframe132 + case 160: .ultraWide160 + default: .custom(value) } } - + /// Check if this is a standard preset width var isPreset: Bool { switch self { - case .custom: return false - default: return true + case .custom: false + default: true } } } @@ -75,12 +75,12 @@ enum TerminalWidth: CaseIterable, Equatable { @MainActor class TerminalWidthManager { static let shared = TerminalWidthManager() - + private let defaultWidthKey = "defaultTerminalWidth" private let customWidthsKey = "customTerminalWidths" - + private init() {} - + /// Get the default terminal width var defaultWidth: Int { get { @@ -90,7 +90,7 @@ class TerminalWidthManager { UserDefaults.standard.set(newValue, forKey: defaultWidthKey) } } - + /// Get saved custom widths var customWidths: [Int] { get { @@ -100,7 +100,7 @@ class TerminalWidthManager { UserDefaults.standard.set(newValue, forKey: customWidthsKey) } } - + /// Add a custom width to saved list func addCustomWidth(_ width: Int) { var widths = customWidths @@ -113,7 +113,7 @@ class TerminalWidthManager { customWidths = widths } } - + /// Get all available widths including custom ones func allWidths() -> [TerminalWidth] { var widths = TerminalWidth.allCases @@ -124,4 +124,4 @@ class TerminalWidthManager { } return widths } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/VibeTunnel/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 00000000..1a5ee251 Binary files /dev/null and b/ios/VibeTunnel/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/ios/VibeTunnel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/VibeTunnel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index dc70b540..ca34fdb4 100644 --- a/ios/VibeTunnel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/VibeTunnel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/ios/VibeTunnel/Services/APIClient.swift b/ios/VibeTunnel/Services/APIClient.swift index 9e27dc18..db978050 100644 --- a/ios/VibeTunnel/Services/APIClient.swift +++ b/ios/VibeTunnel/Services/APIClient.swift @@ -1,5 +1,7 @@ import Foundation +private let logger = Logger(category: "APIClient") + /// Errors that can occur during API operations. enum APIError: LocalizedError { case invalidURL @@ -120,15 +122,15 @@ class APIClient: APIClientProtocol { // Debug logging if let jsonString = String(data: data, encoding: .utf8) { - print("[APIClient] getSessions response: \(jsonString)") + logger.debug("getSessions response: \(jsonString)") } do { return try decoder.decode([Session].self, from: data) } catch { - print("[APIClient] Decoding error: \(error)") + logger.error("Decoding error: \(error)") if let decodingError = error as? DecodingError { - print("[APIClient] Decoding error details: \(decodingError)") + logger.error("Decoding error details: \(decodingError)") } throw APIError.decodingError(error) } @@ -153,12 +155,12 @@ class APIClient: APIClientProtocol { func createSession(_ data: SessionCreateData) async throws -> String { guard let baseURL else { - print("[APIClient] No server configured") + logger.error("No server configured") throw APIError.noServerConfigured } let url = baseURL.appendingPathComponent("api/sessions") - print("[APIClient] Creating session at URL: \(url)") + logger.debug("Creating session at URL: \(url)") var request = URLRequest(url: url) request.httpMethod = "POST" @@ -168,24 +170,24 @@ class APIClient: APIClientProtocol { do { request.httpBody = try encoder.encode(data) if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) { - print("[APIClient] Request body: \(bodyString)") + logger.debug("Request body: \(bodyString)") } } catch { - print("[APIClient] Failed to encode session data: \(error)") + logger.error("Failed to encode session data: \(error)") throw error } do { let (responseData, response) = try await session.data(for: request) - print("[APIClient] Response received") + logger.debug("Response received") if let httpResponse = response as? HTTPURLResponse { - print("[APIClient] Status code: \(httpResponse.statusCode)") - print("[APIClient] Headers: \(httpResponse.allHeaderFields)") + logger.debug("Status code: \(httpResponse.statusCode)") + logger.debug("Headers: \(httpResponse.allHeaderFields)") } if let responseString = String(data: responseData, encoding: .utf8) { - print("[APIClient] Response body: \(responseString)") + logger.debug("Response body: \(responseString)") } // Check if the response is an error @@ -199,7 +201,7 @@ class APIClient: APIClientProtocol { if let errorResponse = try? decoder.decode(ErrorResponse.self, from: responseData) { let errorMessage = errorResponse.details ?? errorResponse.error ?? "Unknown error" - print("[APIClient] Server error: \(errorMessage)") + logger.error("Server error: \(errorMessage)") throw APIError.serverError(httpResponse.statusCode, errorMessage) } else { // Fallback to generic error @@ -212,12 +214,12 @@ class APIClient: APIClientProtocol { } let createResponse = try decoder.decode(CreateResponse.self, from: responseData) - print("[APIClient] Session created with ID: \(createResponse.sessionId)") + logger.info("Session created with ID: \(createResponse.sessionId)") return createResponse.sessionId } catch { - print("[APIClient] Request failed: \(error)") + logger.error("Request failed: \(error)") if let urlError = error as? URLError { - print("[APIClient] URL Error code: \(urlError.code), description: \(urlError.localizedDescription)") + logger.error("URL Error code: \(urlError.code), description: \(urlError.localizedDescription)") } throw error } @@ -453,12 +455,12 @@ class APIClient: APIClientProtocol { private func validateResponse(_ response: URLResponse) throws { guard let httpResponse = response as? HTTPURLResponse else { - print("[APIClient] Invalid response type (not HTTP)") + logger.error("Invalid response type (not HTTP)") throw APIError.networkError(URLError(.badServerResponse)) } guard 200..<300 ~= httpResponse.statusCode else { - print("[APIClient] Server error: HTTP \(httpResponse.statusCode)") + logger.error("Server error: HTTP \(httpResponse.statusCode)") throw APIError.serverError(httpResponse.statusCode, nil) } } @@ -472,7 +474,12 @@ class APIClient: APIClientProtocol { // MARK: - File System Operations - func browseDirectory(path: String, showHidden: Bool = false, gitFilter: String = "all") async throws -> DirectoryListing { + func browseDirectory( + path: String, + showHidden: Bool = false, + gitFilter: String = "all" + ) + async throws -> DirectoryListing { guard let baseURL else { throw APIError.noServerConfigured } @@ -503,10 +510,10 @@ class APIClient: APIClientProtocol { // Log response for debugging if let httpResponse = response as? HTTPURLResponse { - print("[APIClient] Browse directory response: \(httpResponse.statusCode)") + logger.debug("Browse directory response: \(httpResponse.statusCode)") if httpResponse.statusCode >= 400 { if let errorString = String(data: data, encoding: .utf8) { - print("[APIClient] Error response body: \(errorString)") + logger.error("Error response body: \(errorString)") } } } @@ -598,12 +605,12 @@ class APIClient: APIClientProtocol { return try decoder.decode(FileInfo.self, from: data) } - + func previewFile(path: String) async throws -> FilePreview { guard let baseURL else { throw APIError.noServerConfigured } - + guard var components = URLComponents( url: baseURL.appendingPathComponent("api/fs/preview"), resolvingAgainstBaseURL: false @@ -611,26 +618,26 @@ class APIClient: APIClientProtocol { throw APIError.invalidURL } components.queryItems = [URLQueryItem(name: "path", value: path)] - + guard let url = components.url else { throw APIError.invalidURL } - + var request = URLRequest(url: url) request.httpMethod = "GET" addAuthenticationIfNeeded(&request) - + let (data, response) = try await session.data(for: request) try validateResponse(response) - + return try decoder.decode(FilePreview.self, from: data) } - + func getGitDiff(path: String) async throws -> FileDiff { guard let baseURL else { throw APIError.noServerConfigured } - + guard var components = URLComponents( url: baseURL.appendingPathComponent("api/fs/diff"), resolvingAgainstBaseURL: false @@ -638,75 +645,76 @@ class APIClient: APIClientProtocol { throw APIError.invalidURL } components.queryItems = [URLQueryItem(name: "path", value: path)] - + guard let url = components.url else { throw APIError.invalidURL } - + var request = URLRequest(url: url) request.httpMethod = "GET" addAuthenticationIfNeeded(&request) - + let (data, response) = try await session.data(for: request) try validateResponse(response) - + return try decoder.decode(FileDiff.self, from: data) } - + // MARK: - System Logs - + func getLogsRaw() async throws -> String { guard let baseURL else { throw APIError.noServerConfigured } - + let url = baseURL.appendingPathComponent("api/logs/raw") var request = URLRequest(url: url) request.httpMethod = "GET" addAuthenticationIfNeeded(&request) - + let (data, response) = try await session.data(for: request) try validateResponse(response) - + guard let logContent = String(data: data, encoding: .utf8) else { throw APIError.invalidResponse } - + return logContent } - + func getLogsInfo() async throws -> LogsInfo { guard let baseURL else { throw APIError.noServerConfigured } - + let url = baseURL.appendingPathComponent("api/logs/info") var request = URLRequest(url: url) request.httpMethod = "GET" addAuthenticationIfNeeded(&request) - + let (data, response) = try await session.data(for: request) try validateResponse(response) - + return try decoder.decode(LogsInfo.self, from: data) } - + func clearLogs() async throws { guard let baseURL else { throw APIError.noServerConfigured } - + let url = baseURL.appendingPathComponent("api/logs/clear") var request = URLRequest(url: url) request.httpMethod = "DELETE" addAuthenticationIfNeeded(&request) - + let (_, response) = try await session.data(for: request) try validateResponse(response) } } // MARK: - File Preview Types + struct FilePreview: Codable { let type: FilePreviewType let content: String? diff --git a/ios/VibeTunnel/Services/BufferWebSocketClient.swift b/ios/VibeTunnel/Services/BufferWebSocketClient.swift index 211ccbd9..167d46fe 100644 --- a/ios/VibeTunnel/Services/BufferWebSocketClient.swift +++ b/ios/VibeTunnel/Services/BufferWebSocketClient.swift @@ -46,6 +46,8 @@ enum WebSocketError: Error { @MainActor @Observable class BufferWebSocketClient: NSObject { + static let shared = BufferWebSocketClient() + private let logger = Logger(category: "BufferWebSocket") /// Magic byte for binary messages private static let bufferMagicByte: UInt8 = 0xBF @@ -70,7 +72,7 @@ class BufferWebSocketClient: NSObject { } return serverConfig.baseURL } - + init(webSocketFactory: WebSocketFactory = DefaultWebSocketFactory()) { self.webSocketFactory = webSocketFactory super.init() @@ -108,7 +110,7 @@ class BufferWebSocketClient: NSObject { // Build headers var headers: [String: String] = [:] - + // Add authentication header if needed if let config = UserDefaults.standard.data(forKey: "savedServerConfig"), let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config), @@ -388,7 +390,10 @@ class BufferWebSocketClient: NSObject { if offset < data.count { let typeByte = data[offset] logger.verbose("Type byte: 0x\(String(format: "%02X", typeByte))") - logger.verbose("Bits: hasExt=\((typeByte & 0x80) != 0), isUni=\((typeByte & 0x40) != 0), hasFg=\((typeByte & 0x20) != 0), hasBg=\((typeByte & 0x10) != 0), charType=\(typeByte & 0x03)") + logger + .verbose( + "Bits: hasExt=\((typeByte & 0x80) != 0), isUni=\((typeByte & 0x40) != 0), hasFg=\((typeByte & 0x20) != 0), hasBg=\((typeByte & 0x10) != 0), charType=\(typeByte & 0x03)" + ) } break } @@ -526,10 +531,10 @@ class BufferWebSocketClient: NSObject { logger.debug("RGB foreground decode failed: insufficient data") return nil } - let r = Int(data[currentOffset]) - let g = Int(data[currentOffset + 1]) - let b = Int(data[currentOffset + 2]) - fg = (r << 16) | (g << 8) | b | 0xFF00_0000 // Add alpha for RGB + let red = Int(data[currentOffset]) + let green = Int(data[currentOffset + 1]) + let blue = Int(data[currentOffset + 2]) + fg = (red << 16) | (green << 8) | blue | 0xFF00_0000 // Add alpha for RGB currentOffset += 3 } else { // Palette color (1 byte) @@ -550,10 +555,10 @@ class BufferWebSocketClient: NSObject { logger.debug("RGB background decode failed: insufficient data") return nil } - let r = Int(data[currentOffset]) - let g = Int(data[currentOffset + 1]) - let b = Int(data[currentOffset + 2]) - bg = (r << 16) | (g << 8) | b | 0xFF00_0000 // Add alpha for RGB + let red = Int(data[currentOffset]) + let green = Int(data[currentOffset + 1]) + let blue = Int(data[currentOffset + 2]) + bg = (red << 16) | (green << 8) | blue | 0xFF00_0000 // Add alpha for RGB currentOffset += 3 } else { // Palette color (1 byte) @@ -718,7 +723,7 @@ extension BufferWebSocketClient: WebSocketDelegate { isConnecting = false reconnectAttempts = 0 startPingTask() - + // Re-subscribe to all sessions Task { for sessionId in subscriptions.keys { @@ -726,18 +731,22 @@ extension BufferWebSocketClient: WebSocketDelegate { } } } - + func webSocket(_ webSocket: WebSocketProtocol, didReceiveMessage message: WebSocketMessage) { handleMessage(message) } - + func webSocket(_ webSocket: WebSocketProtocol, didFailWithError error: Error) { logger.error("Error: \(error)") connectionError = error handleDisconnection() } - - func webSocketDidDisconnect(_ webSocket: WebSocketProtocol, closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + + func webSocketDidDisconnect( + _ webSocket: WebSocketProtocol, + closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data? + ) { logger.info("Disconnected with code: \(closeCode)") handleDisconnection() } diff --git a/ios/VibeTunnel/Services/KeychainService.swift b/ios/VibeTunnel/Services/KeychainService.swift new file mode 100644 index 00000000..761f13f3 --- /dev/null +++ b/ios/VibeTunnel/Services/KeychainService.swift @@ -0,0 +1,118 @@ +import Foundation +import Security + +/// Service for securely storing credentials in the iOS Keychain +enum KeychainService { + private static let serviceName = "com.vibetunnel.ios" + + enum KeychainError: Error { + case unexpectedData + case unexpectedPasswordData + case unhandledError(status: OSStatus) + case itemNotFound + } + + /// Save a password for a server profile + static func savePassword(_ password: String, for profileId: UUID) throws { + let account = "server-\(profileId.uuidString)" + guard let passwordData = password.data(using: .utf8) else { + throw KeychainError.unexpectedPasswordData + } + + // Check if password already exists + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: account + ] + + let status = SecItemCopyMatching(query as CFDictionary, nil) + + if status == errSecItemNotFound { + // Add new password + let attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: account, + kSecValueData as String: passwordData, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + let addStatus = SecItemAdd(attributes as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw KeychainError.unhandledError(status: addStatus) + } + } else if status == errSecSuccess { + // Update existing password + let attributes: [String: Any] = [ + kSecValueData as String: passwordData + ] + + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + guard updateStatus == errSecSuccess else { + throw KeychainError.unhandledError(status: updateStatus) + } + } else { + throw KeychainError.unhandledError(status: status) + } + } + + /// Retrieve a password for a server profile + static func getPassword(for profileId: UUID) throws -> String { + let account = "server-\(profileId.uuidString)" + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + if status == errSecItemNotFound { + throw KeychainError.itemNotFound + } + throw KeychainError.unhandledError(status: status) + } + + guard let data = result as? Data, + let password = String(data: data, encoding: .utf8) else { + throw KeychainError.unexpectedData + } + + return password + } + + /// Delete a password for a server profile + static func deletePassword(for profileId: UUID) throws { + let account = "server-\(profileId.uuidString)" + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: account + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unhandledError(status: status) + } + } + + /// Delete all passwords for the app + static func deleteAllPasswords() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unhandledError(status: status) + } + } +} diff --git a/ios/VibeTunnel/Services/LivePreviewManager.swift b/ios/VibeTunnel/Services/LivePreviewManager.swift new file mode 100644 index 00000000..0a02906a --- /dev/null +++ b/ios/VibeTunnel/Services/LivePreviewManager.swift @@ -0,0 +1,193 @@ +import Observation +import SwiftUI + +/// Manages live terminal preview subscriptions for session cards. +/// +/// This service efficiently handles multiple WebSocket subscriptions +/// for terminal previews, with automatic cleanup and performance optimization. +@MainActor +@Observable +final class LivePreviewManager { + static let shared = LivePreviewManager() + + private let logger = Logger(category: "LivePreviewManager") + private let bufferClient = BufferWebSocketClient.shared + private var subscriptions: [String: LivePreviewSubscription] = [:] + private var updateTimers: [String: Timer] = [:] + + /// Maximum number of concurrent live previews + private let maxConcurrentPreviews = 6 + + /// Update interval for previews (in seconds) + private let updateInterval: TimeInterval = 1.0 + + private init() { + // Ensure WebSocket is connected when manager is created + if !bufferClient.isConnected { + bufferClient.connect() + } + } + + /// Subscribe to live updates for a session. + func subscribe(to sessionId: String) -> LivePreviewSubscription { + // Check if we already have a subscription + if let existing = subscriptions[sessionId] { + existing.referenceCount += 1 + return existing + } + + // Create new subscription + let subscription = LivePreviewSubscription(sessionId: sessionId) + subscriptions[sessionId] = subscription + + // Manage concurrent preview limit + if subscriptions.count > maxConcurrentPreviews { + // Remove oldest subscriptions that have no references + let sortedSubs = subscriptions.values + .filter { $0.referenceCount == 0 } + .sorted { $0.subscriptionTime < $1.subscriptionTime } + + if let oldest = sortedSubs.first { + unsubscribe(from: oldest.sessionId) + } + } + + // Set up WebSocket subscription with throttling + var lastUpdateTime: Date = .distantPast + var pendingSnapshot: BufferSnapshot? + + bufferClient.subscribe(to: sessionId) { [weak self, weak subscription] event in + guard let self, let subscription else { return } + + Task { @MainActor in + switch event { + case .bufferUpdate(let snapshot): + // Throttle updates to prevent overwhelming the UI + let now = Date() + if now.timeIntervalSince(lastUpdateTime) >= self.updateInterval { + subscription.latestSnapshot = snapshot + subscription.lastUpdate = now + lastUpdateTime = now + pendingSnapshot = nil + } else { + // Store pending update + pendingSnapshot = snapshot + + // Schedule delayed update if not already scheduled + if self.updateTimers[sessionId] == nil { + let timer = Timer.scheduledTimer(withTimeInterval: self.updateInterval, repeats: false) { _ in + Task { @MainActor in + if let pending = pendingSnapshot { + subscription.latestSnapshot = pending + subscription.lastUpdate = Date() + pendingSnapshot = nil + } + self.updateTimers.removeValue(forKey: sessionId) + } + } + self.updateTimers[sessionId] = timer + } + } + + case .exit(_): + subscription.isSessionActive = false + + default: + break + } + } + } + + return subscription + } + + /// Unsubscribe from a session's live updates. + func unsubscribe(from sessionId: String) { + guard let subscription = subscriptions[sessionId] else { return } + + subscription.referenceCount -= 1 + + if subscription.referenceCount <= 0 { + // Clean up + updateTimers[sessionId]?.invalidate() + updateTimers.removeValue(forKey: sessionId) + bufferClient.unsubscribe(from: sessionId) + subscriptions.removeValue(forKey: sessionId) + + logger.debug("Unsubscribed from session: \(sessionId)") + } + } + + /// Clean up all subscriptions. + func cleanup() { + for timer in updateTimers.values { + timer.invalidate() + } + updateTimers.removeAll() + + for sessionId in subscriptions.keys { + bufferClient.unsubscribe(from: sessionId) + } + subscriptions.removeAll() + } +} + +/// Represents a live preview subscription for a terminal session. +@MainActor +@Observable +final class LivePreviewSubscription { + let sessionId: String + let subscriptionTime = Date() + + var latestSnapshot: BufferSnapshot? + var lastUpdate = Date() + var isSessionActive = true + var referenceCount = 1 + + init(sessionId: String) { + self.sessionId = sessionId + } +} + +/// SwiftUI view modifier for managing live preview subscriptions. +struct LivePreviewModifier: ViewModifier { + let sessionId: String + let isEnabled: Bool + + @State private var subscription: LivePreviewSubscription? + + func body(content: Content) -> some View { + content + .onAppear { + if isEnabled { + subscription = LivePreviewManager.shared.subscribe(to: sessionId) + } + } + .onDisappear { + if let _ = subscription { + LivePreviewManager.shared.unsubscribe(from: sessionId) + subscription = nil + } + } + .environment(\.livePreviewSubscription, subscription) + } +} + +// Environment key for passing subscription down the view hierarchy +private struct LivePreviewSubscriptionKey: EnvironmentKey { + static let defaultValue: LivePreviewSubscription? = nil +} + +extension EnvironmentValues { + var livePreviewSubscription: LivePreviewSubscription? { + get { self[LivePreviewSubscriptionKey.self] } + set { self[LivePreviewSubscriptionKey.self] = newValue } + } +} + +extension View { + /// Enables live preview for a session. + func livePreview(for sessionId: String, enabled: Bool = true) -> some View { + modifier(LivePreviewModifier(sessionId: sessionId, isEnabled: enabled)) + } +} \ No newline at end of file diff --git a/ios/VibeTunnel/Services/NetworkMonitor.swift b/ios/VibeTunnel/Services/NetworkMonitor.swift index f3071acb..13b5c1c8 100644 --- a/ios/VibeTunnel/Services/NetworkMonitor.swift +++ b/ios/VibeTunnel/Services/NetworkMonitor.swift @@ -2,15 +2,18 @@ import Foundation import Network import SwiftUI +private let logger = Logger(category: "NetworkMonitor") + /// Monitors network connectivity and provides offline/online state @MainActor -final class NetworkMonitor: ObservableObject { +@Observable +final class NetworkMonitor { static let shared = NetworkMonitor() - @Published private(set) var isConnected = true - @Published private(set) var connectionType = NWInterface.InterfaceType.other - @Published private(set) var isExpensive = false - @Published private(set) var isConstrained = false + private(set) var isConnected = true + private(set) var connectionType = NWInterface.InterfaceType.other + private(set) var isExpensive = false + private(set) var isConstrained = false private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "NetworkMonitor") @@ -40,7 +43,7 @@ final class NetworkMonitor: ObservableObject { // Log state changes if wasConnected != self.isConnected { - print("[NetworkMonitor] Connection state changed: \(self.isConnected ? "Online" : "Offline")") + logger.info("Connection state changed: \(self.isConnected ? "Online" : "Offline")") // Post notification for other parts of the app NotificationCenter.default.post( @@ -121,7 +124,7 @@ extension Notification.Name { // MARK: - View Modifier for Offline Banner struct OfflineBanner: ViewModifier { - @ObservedObject private var networkMonitor = NetworkMonitor.shared + @State private var networkMonitor = NetworkMonitor.shared @State private var showBanner = false func body(content: Content) -> some View { @@ -132,17 +135,17 @@ struct OfflineBanner: ViewModifier { VStack(spacing: 0) { HStack { Image(systemName: "wifi.slash") - .foregroundColor(.white) + .foregroundColor(Theme.Colors.terminalBackground) Text("No Internet Connection") - .foregroundColor(.white) + .foregroundColor(Theme.Colors.terminalBackground) .font(.footnote.bold()) Spacer() } .padding(.horizontal, 16) .padding(.vertical, 8) - .background(Color.red) + .background(Theme.Colors.errorAccent) .animation(.easeInOut(duration: 0.3), value: showBanner) .transition(.move(edge: .top).combined(with: .opacity)) @@ -178,32 +181,32 @@ extension View { // MARK: - Connection Status View struct ConnectionStatusView: View { - @ObservedObject private var networkMonitor = NetworkMonitor.shared + @State private var networkMonitor = NetworkMonitor.shared var body: some View { HStack(spacing: 8) { Circle() - .fill(networkMonitor.isConnected ? Color.green : Color.red) + .fill(networkMonitor.isConnected ? Theme.Colors.successAccent : Theme.Colors.errorAccent) .frame(width: 8, height: 8) Text(networkMonitor.isConnected ? "Online" : "Offline") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(Theme.Colors.terminalGray) if networkMonitor.isConnected { switch networkMonitor.connectionType { case .wifi: Image(systemName: "wifi") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(Theme.Colors.terminalGray) case .cellular: Image(systemName: "antenna.radiowaves.left.and.right") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(Theme.Colors.terminalGray) case .wiredEthernet: Image(systemName: "cable.connector") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(Theme.Colors.terminalGray) default: EmptyView() } @@ -211,14 +214,14 @@ struct ConnectionStatusView: View { if networkMonitor.isExpensive { Image(systemName: "dollarsign.circle") .font(.caption) - .foregroundColor(.orange) + .foregroundColor(Theme.Colors.warningAccent) .help("Connection may incur charges") } if networkMonitor.isConstrained { Image(systemName: "tortoise") .font(.caption) - .foregroundColor(.orange) + .foregroundColor(Theme.Colors.warningAccent) .help("Low Data Mode is enabled") } } diff --git a/ios/VibeTunnel/Services/ReconnectionManager.swift b/ios/VibeTunnel/Services/ReconnectionManager.swift new file mode 100644 index 00000000..4ab966d0 --- /dev/null +++ b/ios/VibeTunnel/Services/ReconnectionManager.swift @@ -0,0 +1,124 @@ +import Foundation +import Network + +/// Manages automatic reconnection with exponential backoff +@MainActor +@Observable +class ReconnectionManager { + private let connectionManager: ConnectionManager + private let maxRetries = 5 + private var currentRetry = 0 + private var reconnectionTask: Task? + + var isReconnecting = false + var nextRetryTime: Date? + var lastError: Error? + + init(connectionManager: ConnectionManager) { + self.connectionManager = connectionManager + setupNetworkMonitoring() + } + + private func setupNetworkMonitoring() { + // Listen for network changes + NotificationCenter.default.addObserver( + self, + selector: #selector(networkStatusChanged), + name: NetworkMonitor.statusChangedNotification, + object: nil + ) + } + + @objc + private func networkStatusChanged() { + if NetworkMonitor.shared.isConnected && !connectionManager.isConnected { + // Network is back, attempt reconnection + startReconnection() + } + } + + func startReconnection() { + guard !isReconnecting, + let serverConfig = connectionManager.serverConfig else { return } + + isReconnecting = true + currentRetry = 0 + lastError = nil + + reconnectionTask?.cancel() + reconnectionTask = Task { + await performReconnection(config: serverConfig) + } + } + + func stopReconnection() { + isReconnecting = false + currentRetry = 0 + nextRetryTime = nil + reconnectionTask?.cancel() + reconnectionTask = nil + } + + private func performReconnection(config: ServerConfig) async { + while isReconnecting && currentRetry < maxRetries { + // Check if we still have network + guard NetworkMonitor.shared.isConnected else { + // Wait for network to come back + try? await Task.sleep(for: .seconds(5)) + continue + } + + do { + // Attempt connection + _ = try await APIClient.shared.getSessions() + + // Success! + connectionManager.isConnected = true + isReconnecting = false + currentRetry = 0 + nextRetryTime = nil + lastError = nil + + // Update last connection time + connectionManager.saveConnection(config) + + return + } catch { + lastError = error + currentRetry += 1 + + if currentRetry < maxRetries { + // Calculate exponential backoff + let backoffSeconds = min(pow(2.0, Double(currentRetry - 1)), 60.0) + nextRetryTime = Date().addingTimeInterval(backoffSeconds) + + try? await Task.sleep(for: .seconds(backoffSeconds)) + } + } + } + + // Max retries reached + isReconnecting = false + connectionManager.disconnect() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} + +// MARK: - Exponential Backoff Calculator + +extension ReconnectionManager { + /// Calculate the next retry delay using exponential backoff + static func calculateBackoff(attempt: Int, baseDelay: TimeInterval = 1.0, maxDelay: TimeInterval = 60.0) -> TimeInterval { + let exponentialDelay = baseDelay * pow(2.0, Double(attempt - 1)) + return min(exponentialDelay, maxDelay) + } +} + +// MARK: - NetworkMonitor Extension + +extension NetworkMonitor { + static let statusChangedNotification = Notification.Name("NetworkStatusChanged") +} \ No newline at end of file diff --git a/ios/VibeTunnel/Services/SSEClient.swift b/ios/VibeTunnel/Services/SSEClient.swift index ff96c997..34809751 100644 --- a/ios/VibeTunnel/Services/SSEClient.swift +++ b/ios/VibeTunnel/Services/SSEClient.swift @@ -1,5 +1,7 @@ import Foundation +private let logger = Logger(category: "SSEClient") + /// Server-Sent Events (SSE) client for real-time terminal output streaming. /// /// SSEClient handles the text-based streaming protocol used by the VibeTunnel server @@ -11,53 +13,53 @@ final class SSEClient: NSObject, @unchecked Sendable { private let url: URL private var buffer = Data() weak var delegate: SSEClientDelegate? - + /// Events received from the SSE stream enum SSEEvent { case terminalOutput(timestamp: Double, type: String, data: String) case exit(exitCode: Int, sessionId: String) case error(String) } - + init(url: URL) { self.url = url super.init() - + let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = 0 // No timeout for SSE configuration.timeoutIntervalForResource = 0 configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData - + self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: .main) } - + @MainActor func start() { var request = URLRequest(url: url) request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") - + // Add authentication if needed if let authHeader = ConnectionManager.shared.currentServerConfig?.authorizationHeader { request.setValue(authHeader, forHTTPHeaderField: "Authorization") } - + task = session.dataTask(with: request) task?.resume() } - + func stop() { task?.cancel() task = nil } - + private func processBuffer() { // Convert buffer to string guard let string = String(data: buffer, encoding: .utf8) else { return } - + // Split by double newline (SSE event separator) let events = string.components(separatedBy: "\n\n") - + // Keep the last incomplete event in buffer if !string.hasSuffix("\n\n") && events.count > 1 { if let lastEvent = events.last, let lastEventData = lastEvent.data(using: .utf8) { @@ -66,24 +68,24 @@ final class SSEClient: NSObject, @unchecked Sendable { } else { buffer = Data() } - + // Process complete events for (index, eventString) in events.enumerated() { // Skip the last event if buffer wasn't cleared (it's incomplete) if index == events.count - 1 && !buffer.isEmpty { continue } - + if !eventString.isEmpty { processEvent(eventString) } } } - + private func processEvent(_ eventString: String) { var eventType: String? var eventData: String? - + // Parse SSE format let lines = eventString.components(separatedBy: "\n") for line in lines { @@ -94,21 +96,21 @@ final class SSEClient: NSObject, @unchecked Sendable { if eventData == nil { eventData = data } else { - eventData! += "\n" + data + eventData = (eventData ?? "") + "\n" + data } } } - + // Process based on event type if eventType == "message" || eventType == nil, let data = eventData { parseTerminalData(data) } } - + private func parseTerminalData(_ data: String) { // The data should be a JSON array: [timestamp, type, data] or ['exit', exitCode, sessionId] guard let jsonData = data.data(using: .utf8) else { return } - + do { if let array = try JSONSerialization.jsonObject(with: jsonData) as? [Any] { if array.count >= 3 { @@ -122,28 +124,37 @@ final class SSEClient: NSObject, @unchecked Sendable { else if let timestamp = array[0] as? Double, let type = array[1] as? String, let outputData = array[2] as? String { - delegate?.sseClient(self, didReceiveEvent: .terminalOutput(timestamp: timestamp, type: type, data: outputData)) + delegate?.sseClient( + self, + didReceiveEvent: .terminalOutput(timestamp: timestamp, type: type, data: outputData) + ) } } } } catch { - print("[SSEClient] Failed to parse event data: \(error)") + logger.error("Failed to parse event data: \(error)") } } - + deinit { stop() } } // MARK: - URLSessionDataDelegate + extension SSEClient: URLSessionDataDelegate { - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void + ) { guard let httpResponse = response as? HTTPURLResponse else { completionHandler(.cancel) return } - + if httpResponse.statusCode == 200 { completionHandler(.allow) } else { @@ -151,15 +162,19 @@ extension SSEClient: URLSessionDataDelegate { completionHandler(.cancel) } } - + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { buffer.append(data) processBuffer() } - + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - if let error = error { - if (error as NSError).code != NSURLErrorCancelled { + if let error { + // Check if this is a URLError directly + if let urlError = error as? URLError, urlError.code != .cancelled { + delegate?.sseClient(self, didReceiveEvent: .error(error.localizedDescription)) + } else if (error as? URLError) == nil { + // Not a URLError, so it's some other error we should report delegate?.sseClient(self, didReceiveEvent: .error(error.localizedDescription)) } } @@ -167,6 +182,7 @@ extension SSEClient: URLSessionDataDelegate { } // MARK: - SSEClientDelegate + protocol SSEClientDelegate: AnyObject { func sseClient(_ client: SSEClient, didReceiveEvent event: SSEClient.SSEEvent) -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Services/SessionService.swift b/ios/VibeTunnel/Services/SessionService.swift index 580984e7..8ea3091d 100644 --- a/ios/VibeTunnel/Services/SessionService.swift +++ b/ios/VibeTunnel/Services/SessionService.swift @@ -1,5 +1,7 @@ import Foundation +private let logger = Logger(category: "SessionService") + /// Service layer for managing terminal sessions. /// /// SessionService provides a simplified interface for session-related operations, @@ -19,7 +21,7 @@ class SessionService { do { return try await apiClient.createSession(data) } catch { - print("[SessionService] Failed to create session: \(error)") + logger.error("Failed to create session: \(error)") throw error } } diff --git a/ios/VibeTunnel/Services/WebSocketFactory.swift b/ios/VibeTunnel/Services/WebSocketFactory.swift index b08d8d6c..eff67024 100644 --- a/ios/VibeTunnel/Services/WebSocketFactory.swift +++ b/ios/VibeTunnel/Services/WebSocketFactory.swift @@ -10,6 +10,6 @@ protocol WebSocketFactory { @MainActor class DefaultWebSocketFactory: WebSocketFactory { func createWebSocket() -> WebSocketProtocol { - return URLSessionWebSocket() + URLSessionWebSocket() } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Services/WebSocketProtocol.swift b/ios/VibeTunnel/Services/WebSocketProtocol.swift index 92febe4a..ff9c85b8 100644 --- a/ios/VibeTunnel/Services/WebSocketProtocol.swift +++ b/ios/VibeTunnel/Services/WebSocketProtocol.swift @@ -4,7 +4,7 @@ import Foundation @MainActor protocol WebSocketProtocol: AnyObject { var delegate: WebSocketDelegate? { get set } - + func connect(to url: URL, with headers: [String: String]) async throws func send(_ message: WebSocketMessage) async throws func sendPing() async throws @@ -23,7 +23,11 @@ protocol WebSocketDelegate: AnyObject { func webSocketDidConnect(_ webSocket: WebSocketProtocol) func webSocket(_ webSocket: WebSocketProtocol, didReceiveMessage message: WebSocketMessage) func webSocket(_ webSocket: WebSocketProtocol, didFailWithError error: Error) - func webSocketDidDisconnect(_ webSocket: WebSocketProtocol, closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) + func webSocketDidDisconnect( + _ webSocket: WebSocketProtocol, + closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data? + ) } /// Real implementation of WebSocketProtocol using URLSessionWebSocketTask @@ -33,23 +37,23 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol { private var webSocketTask: URLSessionWebSocketTask? private var session: URLSession! private var isReceiving = false - + override init() { super.init() self.session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) } - + func connect(to url: URL, with headers: [String: String]) async throws { var request = URLRequest(url: url) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } - + webSocketTask = session.webSocketTask(with: request) webSocketTask?.resume() - + // Start receiving messages isReceiving = true receiveNextMessage() - + // Send initial ping to verify connection do { try await sendPing() @@ -63,12 +67,12 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol { throw error } } - + func send(_ message: WebSocketMessage) async throws { guard let task = webSocketTask else { throw WebSocketError.connectionFailed } - + switch message { case .string(let text): try await task.send(.string(text)) @@ -76,15 +80,15 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol { try await task.send(.data(data)) } } - + func sendPing() async throws { guard let task = webSocketTask else { throw WebSocketError.connectionFailed } - + return try await withCheckedThrowingContinuation { continuation in task.sendPing { error in - if let error = error { + if let error { continuation.resume(throwing: error) } else { continuation.resume() @@ -92,7 +96,7 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol { } } } - + func disconnect(with code: URLSessionWebSocketTask.CloseCode, reason: Data?) { isReceiving = false webSocketTask?.cancel(with: code, reason: reason) @@ -100,13 +104,13 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol { self.delegate?.webSocketDidDisconnect(self, closeCode: code, reason: reason) } } - + private func receiveNextMessage() { guard isReceiving, let task = webSocketTask else { return } - + task.receive { [weak self] result in - guard let self = self else { return } - + guard let self else { return } + switch result { case .success(let message): let wsMessage: WebSocketMessage @@ -118,16 +122,16 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol { @unknown default: return } - + Task { @MainActor in self.delegate?.webSocket(self, didReceiveMessage: wsMessage) } - + // Continue receiving Task { @MainActor in self.receiveNextMessage() } - + case .failure(let error): Task { @MainActor in self.isReceiving = false @@ -139,14 +143,23 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol { } extension URLSessionWebSocket: URLSessionWebSocketDelegate { - nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + nonisolated func urlSession( + _ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String? + ) { // Connection opened - already handled in connect() } - - nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + + nonisolated func urlSession( + _ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data? + ) { Task { @MainActor in self.isReceiving = false self.delegate?.webSocketDidDisconnect(self, closeCode: closeCode, reason: reason) } } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Utils/ErrorHandling.swift b/ios/VibeTunnel/Utils/ErrorHandling.swift new file mode 100644 index 00000000..70c415d0 --- /dev/null +++ b/ios/VibeTunnel/Utils/ErrorHandling.swift @@ -0,0 +1,166 @@ +import SwiftUI + +// MARK: - Error Alert Modifier + +/// A view modifier that presents errors using SwiftUI's built-in alert system +struct ErrorAlertModifier: ViewModifier { + @Binding var error: Error? + let onDismiss: (() -> Void)? + + func body(content: Content) -> some View { + content + .alert( + "Error", + isPresented: .constant(error != nil), + presenting: error + ) { _ in + Button("OK") { + error = nil + onDismiss?() + } + } message: { error in + Text(error.localizedDescription) + } + } +} + +extension View { + /// Presents an error alert when an error is present + func errorAlert( + error: Binding, + onDismiss: (() -> Void)? = nil + ) -> some View { + modifier(ErrorAlertModifier(error: error, onDismiss: onDismiss)) + } +} + +// MARK: - Identifiable Error + +/// Makes any Error conform to Identifiable for SwiftUI presentation +struct IdentifiableError: Identifiable { + let id = UUID() + let error: Error +} + +extension View { + /// Presents an error alert using an identifiable error wrapper + func errorAlert(item: Binding) -> some View { + alert(item: item) { identifiableError in + Alert( + title: Text("Error"), + message: Text(identifiableError.error.localizedDescription), + dismissButton: .default(Text("OK")) + ) + } + } +} + +// MARK: - Error Handling State + +// AsyncState property wrapper removed as it's not used in the codebase + +// MARK: - Error Recovery + +/// Protocol for errors that can provide recovery suggestions +protocol RecoverableError: Error { + var recoverySuggestion: String? { get } +} + +extension APIError: RecoverableError { + var recoverySuggestion: String? { + switch self { + case .noServerConfigured: + return "Please configure a server connection in Settings." + case .networkError: + return "Check your internet connection and try again." + case .serverError(let code, _): + switch code { + case 401: + return "Check your authentication credentials in Settings." + case 500...599: + return "The server is experiencing issues. Please try again later." + default: + return nil + } + case .resizeDisabledByServer: + return "Terminal resizing is not supported by this server." + default: + return nil + } + } +} + +// MARK: - Error Banner View + +/// A reusable error banner component +struct ErrorBanner: View { + let message: String + let isOffline: Bool + let onDismiss: (() -> Void)? + + init( + message: String, + isOffline: Bool = false, + onDismiss: (() -> Void)? = nil + ) { + self.message = message + self.isOffline = isOffline + self.onDismiss = onDismiss + } + + var body: some View { + HStack(spacing: Theme.Spacing.small) { + Image(systemName: isOffline ? "wifi.exclamationmark" : "exclamationmark.triangle.fill") + .font(.system(size: 14)) + + Text(message) + .font(Theme.Typography.terminalSystem(size: 13)) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + + if let onDismiss { + Button(action: onDismiss) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) + } + } + } + .foregroundColor(Theme.Colors.errorAccent) + .padding(.horizontal, Theme.Spacing.medium) + .padding(.vertical, Theme.Spacing.small) + .background( + RoundedRectangle(cornerRadius: Theme.CornerRadius.small) + .fill(Theme.Colors.errorAccent.opacity(0.15)) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.CornerRadius.small) + .stroke(Theme.Colors.errorAccent.opacity(0.3), lineWidth: 1) + ) + .padding(.horizontal) + } +} + +// MARK: - Task Error Handling + +extension Task where Failure == Error { + /// Executes an async operation with error handling + @discardableResult + static func withErrorHandling( + priority: TaskPriority? = nil, + errorHandler: @escaping @Sendable (Error) -> Void, + operation: @escaping @Sendable () async throws -> T + ) -> Task { + Task(priority: priority) { + do { + return try await operation() + } catch { + await MainActor.run { + errorHandler(error) + } + throw error + } + } + } +} \ No newline at end of file diff --git a/ios/VibeTunnel/Utils/Logger.swift b/ios/VibeTunnel/Utils/Logger.swift index fbc9f857..a2e1c2c0 100644 --- a/ios/VibeTunnel/Utils/Logger.swift +++ b/ios/VibeTunnel/Utils/Logger.swift @@ -7,54 +7,54 @@ enum LogLevel: Int { case info = 2 case warning = 3 case error = 4 - + var prefix: String { switch self { - case .verbose: return "🔍" - case .debug: return "🐛" - case .info: return "ℹ️" - case .warning: return "⚠️" - case .error: return "❌" + case .verbose: "🔍" + case .debug: "🐛" + case .info: "ℹ️" + case .warning: "⚠️" + case .error: "❌" } } } struct Logger { private let category: String - - /// Global log level - only messages at this level or higher will be printed + + // Global log level - only messages at this level or higher will be printed #if DEBUG - nonisolated(unsafe) static var globalLevel: LogLevel = .info // Default to info level in debug builds + nonisolated(unsafe) static var globalLevel: LogLevel = .info // Default to info level in debug builds #else - nonisolated(unsafe) static var globalLevel: LogLevel = .warning // Only warnings and errors in release + nonisolated(unsafe) static var globalLevel: LogLevel = .warning // Only warnings and errors in release #endif - + init(category: String) { self.category = category } - + func verbose(_ message: String) { log(message, level: .verbose) } - + func debug(_ message: String) { log(message, level: .debug) } - + func info(_ message: String) { log(message, level: .info) } - + func warning(_ message: String) { log(message, level: .warning) } - + func error(_ message: String) { log(message, level: .error) } - + private func log(_ message: String, level: LogLevel) { - guard level.rawValue >= Logger.globalLevel.rawValue else { return } + guard level.rawValue >= Self.globalLevel.rawValue else { return } print("\(level.prefix) [\(category)] \(message)") } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Utils/MacCatalystWindow.swift b/ios/VibeTunnel/Utils/MacCatalystWindow.swift new file mode 100644 index 00000000..a9d88f65 --- /dev/null +++ b/ios/VibeTunnel/Utils/MacCatalystWindow.swift @@ -0,0 +1,279 @@ +import SwiftUI +#if targetEnvironment(macCatalyst) +import UIKit +import Dynamic + +// MARK: - Window Style + +enum MacWindowStyle { + case standard // Normal title bar with traffic lights + case inline // Hidden title bar with repositioned traffic lights +} + +// MARK: - UIWindow Extension + +extension UIWindow { + /// Access the underlying NSWindow in Mac Catalyst + var nsWindow: NSObject? { + var nsWindow = Dynamic.NSApplication.sharedApplication.delegate.hostWindowForUIWindow(self) + nsWindow = nsWindow.attachedWindow + return nsWindow.asObject + } +} + +// MARK: - Window Manager + +@MainActor +class MacCatalystWindowManager: ObservableObject { + static let shared = MacCatalystWindowManager() + + @Published var windowStyle: MacWindowStyle = .standard + + private var window: UIWindow? + private var windowResizeObserver: NSObjectProtocol? + private var windowDidBecomeKeyObserver: NSObjectProtocol? + private let logger = Logger(category: "MacCatalystWindow") + + // Traffic light button configuration + private let trafficLightInset = CGPoint(x: 20, y: 20) + private let trafficLightSpacing: CGFloat = 20 + + private init() {} + + /// Configure the window with the specified style + func configureWindow(_ window: UIWindow, style: MacWindowStyle) { + self.window = window + self.windowStyle = style + + // Wait for window to be fully initialized + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + self.applyWindowStyle(style) + } + + // Observe window events + setupWindowObservers() + } + + /// Switch between window styles at runtime + func setWindowStyle(_ style: MacWindowStyle) { + windowStyle = style + applyWindowStyle(style) + } + + private func applyWindowStyle(_ style: MacWindowStyle) { + guard let window = window, + let nsWindow = window.nsWindow else { + logger.warning("Unable to access NSWindow") + return + } + + let dynamic = Dynamic(nsWindow) + + switch style { + case .standard: + applyStandardStyle(dynamic) + case .inline: + applyInlineStyle(dynamic, window: window) + } + } + + private func applyStandardStyle(_ nsWindow: Dynamic) { + logger.info("Applying standard window style") + + // Show title bar + nsWindow.titlebarAppearsTransparent = false + nsWindow.titleVisibility = Dynamic.NSWindowTitleVisibility.visible + nsWindow.styleMask = nsWindow.styleMask.asObject! as! UInt | Dynamic.NSWindowStyleMask.titled.asObject! as! UInt + + // Reset traffic light positions + resetTrafficLightPositions(nsWindow) + + // Show all buttons + for i in 0...2 { + let button = nsWindow.standardWindowButton(i) + button.isHidden = false + } + } + + private func applyInlineStyle(_ nsWindow: Dynamic, window: UIWindow) { + logger.info("Applying inline window style") + + // Make title bar transparent and hide title + nsWindow.titlebarAppearsTransparent = true + nsWindow.titleVisibility = Dynamic.NSWindowTitleVisibility.hidden + nsWindow.backgroundColor = Dynamic.NSColor.clearColor + + // Keep the titled style mask to preserve traffic lights + let currentMask = nsWindow.styleMask.asObject! as! UInt + nsWindow.styleMask = currentMask | Dynamic.NSWindowStyleMask.titled.asObject! as! UInt + + // Reposition traffic lights + repositionTrafficLights(nsWindow, window: window) + } + + private func repositionTrafficLights(_ nsWindow: Dynamic, window: UIWindow) { + // Access the buttons (0=close, 1=minimize, 2=zoom) + let closeButton = nsWindow.standardWindowButton(0) + let minButton = nsWindow.standardWindowButton(1) + let zoomButton = nsWindow.standardWindowButton(2) + + // Get button size + let buttonFrame = closeButton.frame + let buttonSize = (buttonFrame.size.width.asDouble ?? 14.0) as CGFloat + + // Calculate positions + let yPosition = window.frame.height - trafficLightInset.y - buttonSize + + // Set new positions + closeButton.setFrameOrigin(Dynamic.NSMakePoint(trafficLightInset.x, yPosition)) + minButton.setFrameOrigin(Dynamic.NSMakePoint(trafficLightInset.x + trafficLightSpacing, yPosition)) + zoomButton.setFrameOrigin(Dynamic.NSMakePoint(trafficLightInset.x + (trafficLightSpacing * 2), yPosition)) + + // Make sure buttons are visible + closeButton.isHidden = false + minButton.isHidden = false + zoomButton.isHidden = false + + // Update tracking areas for hover effects + updateTrafficLightTrackingAreas(nsWindow) + + logger.debug("Repositioned traffic lights to inline positions") + } + + private func resetTrafficLightPositions(_ nsWindow: Dynamic) { + // Get the superview of the traffic lights + let closeButton = nsWindow.standardWindowButton(0) + if let superview = closeButton.superview { + // Force layout update to reset positions + superview.setNeedsLayout?.asObject = true + superview.layoutIfNeeded() + } + } + + private func updateTrafficLightTrackingAreas(_ nsWindow: Dynamic) { + // Update tracking areas for each button to ensure hover effects work + for i in 0...2 { + let button = nsWindow.standardWindowButton(i) + + // Remove old tracking areas + if let trackingAreas = button.trackingAreas { + for area in trackingAreas.asArray ?? [] { + button.removeTrackingArea(area) + } + } + + // Add new tracking area at the button's current position + let trackingRect = button.bounds + let options = Dynamic.NSTrackingAreaOptions.mouseEnteredAndExited.asObject! as! UInt | + Dynamic.NSTrackingAreaOptions.activeAlways.asObject! as! UInt + + let trackingArea = Dynamic.NSTrackingArea.alloc() + .initWithRect(trackingRect, options: options, owner: button, userInfo: nil) + + button.addTrackingArea(trackingArea) + } + } + + private func setupWindowObservers() { + // Clean up existing observers + if let observer = windowResizeObserver { + NotificationCenter.default.removeObserver(observer) + } + if let observer = windowDidBecomeKeyObserver { + NotificationCenter.default.removeObserver(observer) + } + + // Observe window resize events + windowResizeObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name("NSWindowDidResizeNotification"), + object: nil, + queue: .main + ) { [weak self] notification in + guard let self = self, + self.windowStyle == .inline, + let window = self.window, + let notificationWindow = notification.object as? NSObject, + let currentNSWindow = window.nsWindow, + notificationWindow == currentNSWindow else { return } + + // Reapply inline style on resize + DispatchQueue.main.async { + self.applyWindowStyle(.inline) + } + } + + // Observe window becoming key + windowDidBecomeKeyObserver = NotificationCenter.default.addObserver( + forName: UIWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + guard let self = self, + self.windowStyle == .inline else { return } + + // Reapply inline style when window becomes key + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.applyWindowStyle(.inline) + } + } + + // Also observe the NS notification for tracking area updates + NotificationCenter.default.addObserver( + forName: NSNotification.Name("NSViewDidUpdateTrackingAreasNotification"), + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self, + self.windowStyle == .inline else { return } + + // Reposition if needed + if let window = self.window, + let nsWindow = window.nsWindow { + self.repositionTrafficLights(Dynamic(nsWindow), window: window) + } + } + } + + deinit { + if let observer = windowResizeObserver { + NotificationCenter.default.removeObserver(observer) + } + if let observer = windowDidBecomeKeyObserver { + NotificationCenter.default.removeObserver(observer) + } + } +} + +// MARK: - View Modifier + +struct MacCatalystWindowStyle: ViewModifier { + let style: MacWindowStyle + @StateObject private var windowManager = MacCatalystWindowManager.shared + + func body(content: Content) -> some View { + content + .onAppear { + setupWindow() + } + } + + private func setupWindow() { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { + return + } + + windowManager.configureWindow(window, style: style) + } +} + +// MARK: - View Extension + +extension View { + /// Configure the Mac Catalyst window style + func macCatalystWindowStyle(_ style: MacWindowStyle) -> some View { + modifier(MacCatalystWindowStyle(style: style)) + } +} + +#endif \ No newline at end of file diff --git a/ios/VibeTunnel/Utils/Theme.swift b/ios/VibeTunnel/Utils/Theme.swift index a5f764ab..bdb8f17c 100644 --- a/ios/VibeTunnel/Utils/Theme.swift +++ b/ios/VibeTunnel/Utils/Theme.swift @@ -14,26 +14,32 @@ enum Theme { static let terminalBackground = Color(light: Color(hex: "FFFFFF"), dark: Color(hex: "0A0E14")) static let cardBackground = Color(light: Color(hex: "F8F9FA"), dark: Color(hex: "0D1117")) static let headerBackground = Color(light: Color(hex: "FFFFFF"), dark: Color(hex: "010409")) - - // Border colors + + /// Border colors static let cardBorder = Color(light: Color(hex: "E1E4E8"), dark: Color(hex: "1C2128")) - - // Text colors + + /// Text colors static let terminalForeground = Color(light: Color(hex: "24292E"), dark: Color(hex: "B3B1AD")) - + // Accent colors (same for both modes) - static let primaryAccent = Color(light: Color(hex: "22C55E"), dark: Color(hex: "00FF88")) // Darker green for light mode + static let primaryAccent = Color(hex: "007AFF") // iOS system blue static let secondaryAccent = Color(hex: "59C2FF") static let successAccent = Color(hex: "AAD94C") static let warningAccent = Color(hex: "FFB454") static let errorAccent = Color(hex: "F07178") - - // Selection colors + + /// Selection colors static let terminalSelection = Color(light: Color(hex: "E1E4E8"), dark: Color(hex: "273747")) - - // Overlay colors + + /// Overlay colors static let overlayBackground = Color(light: Color.black.opacity(0.5), dark: Color.black.opacity(0.7)) + // Additional UI colors + static let secondaryText = Color(light: Color(hex: "6E7781"), dark: Color(hex: "8B949E")) + static let secondaryBackground = Color(light: Color(hex: "F6F8FA"), dark: Color(hex: "161B22")) + static let success = successAccent + static let error = errorAccent + // Additional UI colors for FileBrowser static let terminalAccent = primaryAccent static let terminalGray = Color(light: Color(hex: "586069"), dark: Color(hex: "8B949E")) @@ -59,6 +65,15 @@ enum Theme { static let ansiBrightMagenta = Color(light: Color(hex: "5A32A3"), dark: Color(hex: "FFEE99")) static let ansiBrightCyan = Color(light: Color(hex: "0598BC"), dark: Color(hex: "95E6CB")) static let ansiBrightWhite = Color(light: Color(hex: "24292E"), dark: Color(hex: "FFFFFF")) + + // File type colors + static let fileTypeJS = Color(light: Color(hex: "B08800"), dark: Color(hex: "FFB454")) + static let fileTypeTS = Color(light: Color(hex: "0366D6"), dark: Color(hex: "007ACC")) + static let fileTypeJSON = Color(light: Color(hex: "E36209"), dark: Color(hex: "FF8C42")) + static let fileTypeCSS = Color(light: Color(hex: "563D7C"), dark: Color(hex: "7B68EE")) + static let fileTypePython = Color(light: Color(hex: "3776AB"), dark: Color(hex: "4B8BBE")) + static let fileTypeGo = Color(light: Color(hex: "00ADD8"), dark: Color(hex: "00ADD8")) + static let fileTypeImage = Color(light: Color(hex: "28A745"), dark: Color(hex: "91B362")) } // MARK: - Typography @@ -77,6 +92,18 @@ enum Theme { static func terminalSystem(size: CGFloat) -> Font { Font.system(size: size, design: .monospaced) } + + static func terminalSystem(size: CGFloat, weight: Font.Weight) -> Font { + Font.system(size: size, weight: weight, design: .monospaced) + } + + static func largeTitle() -> Font { + Font.largeTitle.weight(.semibold) + } + + static func title() -> Font { + Font.title2.weight(.medium) + } } // MARK: - Spacing @@ -88,6 +115,7 @@ enum Theme { static let medium: CGFloat = 12 static let large: CGFloat = 16 static let extraLarge: CGFloat = 24 + static let xlarge: CGFloat = 24 // Alias for extraLarge static let extraExtraLarge: CGFloat = 32 } @@ -101,6 +129,13 @@ enum Theme { static let card: CGFloat = 12 } + // MARK: - Layout + + /// Layout constants + enum Layout { + static let cornerRadius: CGFloat = 10 + } + // MARK: - Animation /// Animation presets. @@ -154,15 +189,15 @@ extension Color { opacity: Double(alpha) / 255 ) } - + /// Creates a color that automatically adapts to light/dark mode init(light: Color, dark: Color) { self.init(UIColor { traitCollection in switch traitCollection.userInterfaceStyle { case .dark: - return UIColor(dark) + UIColor(dark) default: - return UIColor(light) + UIColor(light) } }) } @@ -205,14 +240,8 @@ extension View { .stroke(Theme.Colors.primaryAccent, lineWidth: 1) ) } - - /// Interactive button style with press and hover animations - func interactiveButton(isPressed: Bool = false, isHovered: Bool = false) -> some View { - self - .scaleEffect(isPressed ? 0.95 : 1.0) - .animation(Theme.Animation.quick, value: isPressed) - .animation(Theme.Animation.quick, value: isHovered) - } + + // Removed: interactiveButton - use explicit scaleEffect and animation instead } // MARK: - Haptic Feedback @@ -268,36 +297,5 @@ struct HapticFeedback { } } -// MARK: - SwiftUI Haptic View Modifiers - -extension View { - /// Provides haptic feedback when the view is tapped - func hapticOnTap(_ style: HapticFeedback.ImpactStyle = .light) -> some View { - self.onTapGesture { - HapticFeedback.impact(style) - } - } - - /// Provides haptic feedback when a value changes - func hapticOnChange(of value: some Equatable, style: HapticFeedback.ImpactStyle = .light) -> some View { - self.onChange(of: value) { _, _ in - HapticFeedback.impact(style) - } - } - - /// Provides selection haptic feedback when a value changes - func hapticSelection(on value: some Equatable) -> some View { - self.onChange(of: value) { _, _ in - HapticFeedback.selection() - } - } - - /// Provides notification haptic feedback - func hapticNotification(_ type: HapticFeedback.NotificationType, when condition: Bool) -> some View { - self.onChange(of: condition) { _, newValue in - if newValue { - HapticFeedback.notification(type) - } - } - } -} \ No newline at end of file +// Note: Call HapticFeedback methods directly instead of using view modifiers +// Example: HapticFeedback.impact(.light) or HapticFeedback.selection() diff --git a/ios/VibeTunnel/ViewModels/ServerProfilesViewModel.swift b/ios/VibeTunnel/ViewModels/ServerProfilesViewModel.swift new file mode 100644 index 00000000..6efd97f9 --- /dev/null +++ b/ios/VibeTunnel/ViewModels/ServerProfilesViewModel.swift @@ -0,0 +1,156 @@ +import Foundation +import SwiftUI + +/// View model for managing server profiles +@MainActor +@Observable +class ServerProfilesViewModel { + var profiles: [ServerProfile] = [] + var isLoading = false + var errorMessage: String? + + init() { + loadProfiles() + } + + func loadProfiles() { + profiles = ServerProfile.loadAll().sorted { profile1, profile2 in + // Sort by last connected (most recent first), then by name + if let date1 = profile1.lastConnected, let date2 = profile2.lastConnected { + return date1 > date2 + } else if profile1.lastConnected != nil { + return true + } else if profile2.lastConnected != nil { + return false + } else { + return profile1.name < profile2.name + } + } + } + + func addProfile(_ profile: ServerProfile, password: String? = nil) async throws { + ServerProfile.save(profile) + + // Save password to keychain if provided + if let password = password, !password.isEmpty { + try KeychainService.savePassword(password, for: profile.id) + } + + loadProfiles() + } + + func updateProfile(_ profile: ServerProfile, password: String? = nil) async throws { + var updatedProfile = profile + updatedProfile.updatedAt = Date() + ServerProfile.save(updatedProfile) + + // Update password if provided + if let password = password { + if password.isEmpty { + // Delete password if empty + try KeychainService.deletePassword(for: profile.id) + } else { + // Save new password + try KeychainService.savePassword(password, for: profile.id) + } + } + + loadProfiles() + } + + func deleteProfile(_ profile: ServerProfile) async throws { + ServerProfile.delete(profile) + + // Delete password from keychain + try KeychainService.deletePassword(for: profile.id) + + loadProfiles() + } + + func getPassword(for profile: ServerProfile) -> String? { + do { + return try KeychainService.getPassword(for: profile.id) + } catch { + // Password not found or error occurred + return nil + } + } + + func connectToProfile(_ profile: ServerProfile, connectionManager: ConnectionManager) async throws { + isLoading = true + errorMessage = nil + + defer { isLoading = false } + + // Get password from keychain if needed + let password = profile.requiresAuth ? getPassword(for: profile) : nil + + // Create server config + guard let config = profile.toServerConfig(password: password) else { + throw APIError.invalidURL + } + + // Save connection + connectionManager.saveConnection(config) + + // Test connection + do { + _ = try await APIClient.shared.getSessions() + connectionManager.isConnected = true + + // Update last connected time + ServerProfile.updateLastConnected(for: profile.id) + loadProfiles() + } catch { + connectionManager.disconnect() + throw error + } + } + + func testConnection(for profile: ServerProfile) async -> Bool { + let password = profile.requiresAuth ? getPassword(for: profile) : nil + guard let config = profile.toServerConfig(password: password) else { + return false + } + + // Save the config temporarily to test + let connectionManager = ConnectionManager() + connectionManager.saveConnection(config) + + do { + _ = try await APIClient.shared.getSessions() + return true + } catch { + return false + } + } +} + +// MARK: - Profile Creation + +extension ServerProfilesViewModel { + func createProfileFromURL(_ urlString: String) -> ServerProfile? { + // Clean up the URL + var cleanURL = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + + // Add http:// if no scheme is present + if !cleanURL.contains("://") { + cleanURL = "http://\(cleanURL)" + } + + // Validate URL + guard let url = URL(string: cleanURL), + let _ = url.host else { + return nil + } + + // Generate suggested name + let suggestedName = ServerProfile.suggestedName(for: cleanURL) + + return ServerProfile( + name: suggestedName, + url: cleanURL, + requiresAuth: false + ) + } +} diff --git a/ios/VibeTunnel/Views/Common/LoadingView.swift b/ios/VibeTunnel/Views/Common/LoadingView.swift index ed5cf8b2..1037428d 100644 --- a/ios/VibeTunnel/Views/Common/LoadingView.swift +++ b/ios/VibeTunnel/Views/Common/LoadingView.swift @@ -7,13 +7,13 @@ import SwiftUI struct LoadingView: View { let message: String let useUnicodeSpinner: Bool - + @State private var isAnimating = false @State private var spinnerFrame = 0 - - // Unicode spinner frames matching web UI + + /// Unicode spinner frames matching web UI private let spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - + init(message: String, useUnicodeSpinner: Bool = false) { self.message = message self.useUnicodeSpinner = useUnicodeSpinner @@ -57,7 +57,7 @@ struct LoadingView: View { } } } - + private func startUnicodeAnimation() { Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in Task { @MainActor in diff --git a/ios/VibeTunnel/Views/Connection/ConnectionView.swift b/ios/VibeTunnel/Views/Connection/ConnectionView.swift index 7e0b1b7e..5356d676 100644 --- a/ios/VibeTunnel/Views/Connection/ConnectionView.swift +++ b/ios/VibeTunnel/Views/Connection/ConnectionView.swift @@ -8,7 +8,7 @@ import SwiftUI struct ConnectionView: View { @Environment(ConnectionManager.self) var connectionManager - @ObservedObject private var networkMonitor = NetworkMonitor.shared + @State private var networkMonitor = NetworkMonitor.shared @State private var viewModel = ConnectionViewModel() @State private var logoScale: CGFloat = 0.8 @State private var contentOpacity: Double = 0 diff --git a/ios/VibeTunnel/Views/Connection/EnhancedConnectionView.swift b/ios/VibeTunnel/Views/Connection/EnhancedConnectionView.swift new file mode 100644 index 00000000..0d1d42fc --- /dev/null +++ b/ios/VibeTunnel/Views/Connection/EnhancedConnectionView.swift @@ -0,0 +1,441 @@ +import SwiftUI + +/// Enhanced connection view with server profiles support +struct EnhancedConnectionView: View { + @Environment(ConnectionManager.self) + var connectionManager + @State private var networkMonitor = NetworkMonitor.shared + @State private var viewModel = ConnectionViewModel() + @State private var profilesViewModel = ServerProfilesViewModel() + @State private var logoScale: CGFloat = 0.8 + @State private var contentOpacity: Double = 0 + @State private var showingNewServerForm = false + @State private var selectedProfile: ServerProfile? + @State private var showingProfileEditor = false + + #if targetEnvironment(macCatalyst) + @StateObject private var windowManager = MacCatalystWindowManager.shared + #endif + + var body: some View { + NavigationStack { + ZStack { + ScrollView { + VStack(spacing: Theme.Spacing.extraLarge) { + // Logo and Title + headerView + .padding(.top, { + #if targetEnvironment(macCatalyst) + return windowManager.windowStyle == .inline ? 60 : 40 + #else + return 40 + #endif + }()) + + // Quick Connect Section + if !profilesViewModel.profiles.isEmpty && !showingNewServerForm { + quickConnectSection + .opacity(contentOpacity) + .onAppear { + withAnimation(Theme.Animation.smooth.delay(0.3)) { + contentOpacity = 1.0 + } + } + } + + // New Connection Form + if showingNewServerForm || profilesViewModel.profiles.isEmpty { + newConnectionSection + .opacity(contentOpacity) + .onAppear { + withAnimation(Theme.Animation.smooth.delay(0.3)) { + contentOpacity = 1.0 + } + } + } + + Spacer(minLength: 50) + } + .padding() + } + .scrollBounceBehavior(.basedOnSize) + } + .toolbar(.hidden, for: .navigationBar) + .background(Theme.Colors.terminalBackground.ignoresSafeArea()) + .sheet(item: $selectedProfile) { profile in + ServerProfileEditView( + profile: profile, + onSave: { updatedProfile, password in + Task { + try await profilesViewModel.updateProfile(updatedProfile, password: password) + selectedProfile = nil + } + }, + onDelete: { + Task { + try await profilesViewModel.deleteProfile(profile) + selectedProfile = nil + } + } + ) + } + } + .navigationViewStyle(StackNavigationViewStyle()) + .preferredColorScheme(.dark) + .onAppear { + profilesViewModel.loadProfiles() + } + } + + // MARK: - Header View + + private var headerView: some View { + VStack(spacing: Theme.Spacing.large) { + ZStack { + // Glow effect + Image(systemName: "terminal.fill") + .font(.system(size: 80)) + .foregroundColor(Theme.Colors.primaryAccent) + .blur(radius: 20) + .opacity(0.5) + + // Main icon + Image(systemName: "terminal.fill") + .font(.system(size: 80)) + .foregroundColor(Theme.Colors.primaryAccent) + .glowEffect() + } + .scaleEffect(logoScale) + .onAppear { + withAnimation(Theme.Animation.smooth.delay(0.1)) { + logoScale = 1.0 + } + } + + 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)) + .tracking(2) + + // Network status + ConnectionStatusView() + .padding(.top, Theme.Spacing.small) + } + } + } + + // MARK: - Quick Connect Section + + private var quickConnectSection: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.medium) { + HStack { + Text("Saved Servers") + .font(Theme.Typography.terminalSystem(size: 18, weight: .semibold)) + .foregroundColor(Theme.Colors.terminalForeground) + + Spacer() + + Button(action: { + withAnimation { + showingNewServerForm.toggle() + } + }) { + Image(systemName: showingNewServerForm ? "minus.circle" : "plus.circle") + .font(.system(size: 20)) + .foregroundColor(Theme.Colors.primaryAccent) + } + } + + VStack(spacing: Theme.Spacing.small) { + ForEach(profilesViewModel.profiles) { profile in + ServerProfileCard( + profile: profile, + isLoading: profilesViewModel.isLoading, + onConnect: { + connectToProfile(profile) + }, + onEdit: { + selectedProfile = profile + } + ) + } + } + } + } + + // MARK: - New Connection Section + + private var newConnectionSection: some View { + VStack(spacing: Theme.Spacing.large) { + if !profilesViewModel.profiles.isEmpty { + HStack { + Text("New Server Connection") + .font(Theme.Typography.terminalSystem(size: 18, weight: .semibold)) + .foregroundColor(Theme.Colors.terminalForeground) + + Spacer() + } + } + + ServerConfigForm( + host: $viewModel.host, + port: $viewModel.port, + name: $viewModel.name, + password: $viewModel.password, + isConnecting: viewModel.isConnecting, + errorMessage: viewModel.errorMessage, + onConnect: saveAndConnect + ) + + if !profilesViewModel.profiles.isEmpty { + Button(action: { + withAnimation { + showingNewServerForm = false + } + }) { + Text("Cancel") + .font(Theme.Typography.terminalSystem(size: 16)) + .foregroundColor(Theme.Colors.secondaryText) + } + .padding(.top, Theme.Spacing.small) + } + } + } + + // MARK: - Actions + + private func connectToProfile(_ profile: ServerProfile) { + guard networkMonitor.isConnected else { + viewModel.errorMessage = "No internet connection available" + return + } + + Task { + do { + try await profilesViewModel.connectToProfile(profile, connectionManager: connectionManager) + } catch { + viewModel.errorMessage = "Failed to connect: \(error.localizedDescription)" + } + } + } + + private func saveAndConnect() { + guard networkMonitor.isConnected else { + viewModel.errorMessage = "No internet connection available" + return + } + + // Create profile from form data + let urlString = viewModel.port.isEmpty ? viewModel.host : "\(viewModel.host):\(viewModel.port)" + guard let profile = profilesViewModel.createProfileFromURL(urlString) else { + viewModel.errorMessage = "Invalid server URL" + return + } + + var updatedProfile = profile + updatedProfile.name = viewModel.name.isEmpty ? profile.name : viewModel.name + updatedProfile.requiresAuth = !viewModel.password.isEmpty + updatedProfile.username = updatedProfile.requiresAuth ? "admin" : nil + + // Save profile and password + Task { + try await profilesViewModel.addProfile(updatedProfile, password: viewModel.password) + + // Connect + connectToProfile(updatedProfile) + } + + // Reset form + viewModel = ConnectionViewModel() + showingNewServerForm = false + } +} + +// MARK: - Server Profile Card + +struct ServerProfileCard: View { + let profile: ServerProfile + let isLoading: Bool + let onConnect: () -> Void + let onEdit: () -> Void + + @State private var isPressed = false + + var body: some View { + HStack(spacing: Theme.Spacing.medium) { + // Icon + Image(systemName: profile.iconSymbol) + .font(.system(size: 24)) + .foregroundColor(Theme.Colors.primaryAccent) + .frame(width: 40, height: 40) + .background(Theme.Colors.primaryAccent.opacity(0.1)) + .cornerRadius(Theme.CornerRadius.small) + + // Server Info + VStack(alignment: .leading, spacing: 2) { + Text(profile.name) + .font(Theme.Typography.terminalSystem(size: 16, weight: .medium)) + .foregroundColor(Theme.Colors.terminalForeground) + + HStack(spacing: 4) { + Text(profile.url) + .font(Theme.Typography.terminalSystem(size: 12)) + .foregroundColor(Theme.Colors.secondaryText) + + if profile.requiresAuth { + Image(systemName: "lock.fill") + .font(.system(size: 10)) + .foregroundColor(Theme.Colors.warningAccent) + } + } + + if let lastConnected = profile.lastConnected { + Text(RelativeDateTimeFormatter().localizedString(for: lastConnected, relativeTo: Date())) + .font(Theme.Typography.terminalSystem(size: 11)) + .foregroundColor(Theme.Colors.secondaryText.opacity(0.7)) + } + } + + Spacer() + + // Action Buttons + HStack(spacing: Theme.Spacing.small) { + Button(action: onEdit) { + Image(systemName: "ellipsis.circle") + .font(.system(size: 20)) + .foregroundColor(Theme.Colors.secondaryText) + } + .buttonStyle(.plain) + + Button(action: onConnect) { + HStack(spacing: 4) { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } else { + Image(systemName: "arrow.right.circle.fill") + .font(.system(size: 24)) + } + } + .foregroundColor(Theme.Colors.primaryAccent) + } + .buttonStyle(.plain) + .disabled(isLoading) + } + } + .padding(Theme.Spacing.medium) + .background(Theme.Colors.cardBackground) + .cornerRadius(Theme.CornerRadius.card) + .overlay( + RoundedRectangle(cornerRadius: Theme.CornerRadius.card) + .stroke(Theme.Colors.cardBorder, lineWidth: 1) + ) + .scaleEffect(isPressed ? 0.98 : 1.0) + .animation(.easeInOut(duration: 0.1), value: isPressed) + .onTapGesture { + onConnect() + } + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in isPressed = true } + .onEnded { _ in isPressed = false } + ) + } +} + +// MARK: - Server Profile Edit View + +struct ServerProfileEditView: View { + @State var profile: ServerProfile + let onSave: (ServerProfile, String?) -> Void + let onDelete: () -> Void + + @State private var password: String = "" + @State private var showingDeleteConfirmation = false + @Environment(\.dismiss) + private var dismiss + + var body: some View { + NavigationStack { + Form { + Section("Server Details") { + HStack { + Text("Icon") + Spacer() + Image(systemName: profile.iconSymbol) + .font(.system(size: 24)) + .foregroundColor(Theme.Colors.primaryAccent) + } + + TextField("Name", text: $profile.name) + TextField("URL", text: $profile.url) + + Toggle("Requires Authentication", isOn: $profile.requiresAuth) + + if profile.requiresAuth { + TextField("Username", text: Binding( + get: { profile.username ?? "admin" }, + set: { profile.username = $0 } + )) + SecureField("Password", text: $password) + .textContentType(.password) + } + } + + Section { + Button(role: .destructive, action: { + showingDeleteConfirmation = true + }) { + Label("Delete Server", systemImage: "trash") + .foregroundColor(.red) + } + } + } + .navigationTitle("Edit Server") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + onSave(profile, profile.requiresAuth ? password : nil) + dismiss() + } + .fontWeight(.semibold) + } + } + .alert("Delete Server?", isPresented: $showingDeleteConfirmation) { + Button("Delete", role: .destructive) { + onDelete() + dismiss() + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Are you sure you want to delete \"\(profile.name)\"? This action cannot be undone.") + } + } + .task { + // Load existing password from keychain + if profile.requiresAuth, + let existingPassword = try? KeychainService.getPassword(for: profile.id) { + password = existingPassword + } + } + } +} + +// MARK: - Preview + +#Preview { + EnhancedConnectionView() + .environment(ConnectionManager()) +} \ No newline at end of file diff --git a/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift b/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift index 9510c5ba..66765efb 100644 --- a/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift +++ b/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift @@ -12,7 +12,7 @@ struct ServerConfigForm: View { let isConnecting: Bool let errorMessage: String? let onConnect: () -> Void - @ObservedObject private var networkMonitor = NetworkMonitor.shared + @State private var networkMonitor = NetworkMonitor.shared @FocusState private var focusedField: Field? @State private var recentServers: [ServerConfig] = [] diff --git a/ios/VibeTunnel/Views/FileBrowser/FilePreviewView.swift b/ios/VibeTunnel/Views/FileBrowser/FilePreviewView.swift index 972804d6..8902c7c1 100644 --- a/ios/VibeTunnel/Views/FileBrowser/FilePreviewView.swift +++ b/ios/VibeTunnel/Views/FileBrowser/FilePreviewView.swift @@ -4,31 +4,29 @@ import WebKit /// View for previewing files with syntax highlighting struct FilePreviewView: View { let path: String - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) + var dismiss @State private var preview: FilePreview? @State private var isLoading = true - @State private var error: String? + @State private var presentedError: IdentifiableError? @State private var showingDiff = false @State private var gitDiff: FileDiff? - + var body: some View { NavigationStack { ZStack { Theme.Colors.terminalBackground .ignoresSafeArea() - + if isLoading { ProgressView("Loading...") .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent)) - } else if let error = error { - VStack { - Text("Error loading file") - .font(.headline) - .foregroundColor(Theme.Colors.errorAccent) - Text(error) - .font(.subheadline) - .foregroundColor(Theme.Colors.terminalForeground) - .multilineTextAlignment(.center) + } else if presentedError != nil { + ContentUnavailableView { + Label("Failed to Load File", systemImage: "exclamationmark.triangle") + } description: { + Text("The file could not be loaded. Please try again.") + } actions: { Button("Retry") { Task { await loadPreview() @@ -36,7 +34,7 @@ struct FilePreviewView: View { } .terminalButton() } - } else if let preview = preview { + } else if let preview { previewContent(for: preview) } } @@ -49,8 +47,8 @@ struct FilePreviewView: View { } .foregroundColor(Theme.Colors.primaryAccent) } - - if let preview = preview, preview.type == .text { + + if let preview, preview.type == .text { ToolbarItem(placement: .navigationBarTrailing) { Button("Diff") { showingDiff = true @@ -74,8 +72,9 @@ struct FilePreviewView: View { } } } + .errorAlert(item: $presentedError) } - + @ViewBuilder private func previewContent(for preview: FilePreview) -> some View { switch preview.type { @@ -100,11 +99,11 @@ struct FilePreviewView: View { Image(systemName: "doc.zipper") .font(.system(size: 64)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) - + Text("Binary File") .font(.headline) .foregroundColor(Theme.Colors.terminalForeground) - + if let size = preview.size { Text(formatFileSize(size)) .font(.caption) @@ -113,20 +112,20 @@ struct FilePreviewView: View { } } } - + private func loadPreview() async { isLoading = true - error = nil - + presentedError = nil + do { preview = try await APIClient.shared.previewFile(path: path) isLoading = false } catch { - self.error = error.localizedDescription + presentedError = IdentifiableError(error: error) isLoading = false } } - + private func loadDiff() async { do { gitDiff = try await APIClient.shared.getGitDiff(path: path) @@ -134,7 +133,7 @@ struct FilePreviewView: View { // Silently fail - diff might not be available } } - + private func formatFileSize(_ size: Int64) -> String { let formatter = ByteCountFormatter() formatter.countStyle = .binary @@ -146,22 +145,22 @@ struct FilePreviewView: View { struct SyntaxHighlightedView: UIViewRepresentable { let content: String let language: String - + func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() let webView = WKWebView(frame: .zero, configuration: configuration) webView.isOpaque = false webView.backgroundColor = UIColor(Theme.Colors.cardBackground) webView.scrollView.backgroundColor = UIColor(Theme.Colors.cardBackground) - + loadContent(in: webView) return webView } - + func updateUIView(_ webView: WKWebView, context: Context) { // Content is static, no updates needed } - + private func loadContent(in webView: WKWebView) { let escapedContent = content .replacingOccurrences(of: "&", with: "&") @@ -169,7 +168,7 @@ struct SyntaxHighlightedView: UIViewRepresentable { .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: "\"", with: """) .replacingOccurrences(of: "'", with: "'") - + let html = """ @@ -210,7 +209,7 @@ struct SyntaxHighlightedView: UIViewRepresentable { """ - + webView.loadHTMLString(html, baseURL: nil) } } @@ -218,14 +217,15 @@ struct SyntaxHighlightedView: UIViewRepresentable { /// View for displaying git diffs struct GitDiffView: View { let diff: FileDiff - @Environment(\.dismiss) var dismiss - + @Environment(\.dismiss) + var dismiss + var body: some View { NavigationStack { ZStack { Theme.Colors.terminalBackground .ignoresSafeArea() - + DiffWebView(content: diff.diff) } .navigationTitle("Git Diff") @@ -246,27 +246,27 @@ struct GitDiffView: View { /// WebView for displaying diffs with syntax highlighting struct DiffWebView: UIViewRepresentable { let content: String - + func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() let webView = WKWebView(frame: .zero, configuration: configuration) webView.isOpaque = false webView.backgroundColor = UIColor(Theme.Colors.cardBackground) - + loadDiff(in: webView) return webView } - + func updateUIView(_ webView: WKWebView, context: Context) { // Content is static } - + private func loadDiff(in webView: WKWebView) { let escapedContent = content .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") - + let html = """ @@ -305,7 +305,7 @@ struct DiffWebView: UIViewRepresentable { """ - + webView.loadHTMLString(html, baseURL: nil) } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/FileBrowserView.swift b/ios/VibeTunnel/Views/FileBrowserView.swift index 8196185c..503f0ce8 100644 --- a/ios/VibeTunnel/Views/FileBrowserView.swift +++ b/ios/VibeTunnel/Views/FileBrowserView.swift @@ -28,10 +28,15 @@ struct FileBrowserView: View { enum FileBrowserMode { case selectDirectory case browseFiles - case insertPath // New mode for inserting paths into terminal + case insertPath // New mode for inserting paths into terminal } - init(initialPath: String = "~", mode: FileBrowserMode = .selectDirectory, onSelect: @escaping (String) -> Void, onInsertPath: ((String, Bool) -> Void)? = nil) { + init( + initialPath: String = "~", + mode: FileBrowserMode = .selectDirectory, + onSelect: @escaping (String) -> Void, + onInsertPath: ((String, Bool) -> Void)? = nil + ) { self.initialPath = initialPath self.mode = mode self.onSelect = onSelect @@ -74,7 +79,7 @@ struct FileBrowserView: View { .foregroundColor(Theme.Colors.terminalGray) .lineLimit(1) .truncationMode(.middle) - + // Git branch indicator if let gitStatus = viewModel.gitStatus, gitStatus.isGitRepo, let branch = gitStatus.branch { Text("📍 \(branch)") @@ -88,7 +93,7 @@ struct FileBrowserView: View { .padding(.vertical, 16) .background(Theme.Colors.terminalDarkGray) } - + private var filterToolbar: some View { HStack(spacing: 12) { // Git filter toggle @@ -103,16 +108,20 @@ struct FileBrowserView: View { Text(viewModel.gitFilter == .changed ? "Git Changes" : "All Files") .font(.custom("SF Mono", size: 12)) } - .foregroundColor(viewModel.gitFilter == .changed ? Theme.Colors.successAccent : Theme.Colors.terminalGray) + .foregroundColor(viewModel.gitFilter == .changed ? Theme.Colors.successAccent : Theme.Colors + .terminalGray + ) .padding(.horizontal, 12) .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 6) - .fill(viewModel.gitFilter == .changed ? Theme.Colors.successAccent.opacity(0.2) : Theme.Colors.terminalGray.opacity(0.1)) + .fill(viewModel.gitFilter == .changed ? Theme.Colors.successAccent.opacity(0.2) : Theme.Colors + .terminalGray.opacity(0.1) + ) ) } .buttonStyle(TerminalButtonStyle()) - + // Hidden files toggle Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() @@ -130,23 +139,25 @@ struct FileBrowserView: View { .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 6) - .fill(viewModel.showHidden ? Theme.Colors.terminalAccent.opacity(0.2) : Theme.Colors.terminalGray.opacity(0.1)) + .fill(viewModel.showHidden ? Theme.Colors.terminalAccent.opacity(0.2) : Theme.Colors + .terminalGray.opacity(0.1) + ) ) } .buttonStyle(TerminalButtonStyle()) - + Spacer() } .padding(.horizontal, 20) .padding(.vertical, 12) .background(Theme.Colors.terminalDarkGray.opacity(0.5)) } - + var body: some View { NavigationStack { ZStack { // Background - Color.black.ignoresSafeArea() + Theme.Colors.terminalBackground.ignoresSafeArea() VStack(spacing: 0) { navigationHeader @@ -210,7 +221,7 @@ struct FileBrowserView: View { .foregroundColor(Theme.Colors.terminalGray) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.black.opacity(0.8)) + .background(Theme.Colors.terminalBackground.opacity(0.8)) } } @@ -274,7 +285,7 @@ struct FileBrowserView: View { }, label: { Text("select") .font(.custom("SF Mono", size: 14)) - .foregroundColor(.black) + .foregroundColor(Theme.Colors.terminalBackground) .padding(.horizontal, 24) .padding(.vertical, 10) .background( @@ -408,19 +419,19 @@ struct FileBrowserView: View { viewModel.loadDirectory(path: initialPath) } } - + // MARK: - Helper Methods - + private func insertPath(_ path: String, isDirectory: Bool) { // Escape the path if it contains spaces let escapedPath = path.contains(" ") ? "\"\(path)\"" : path - + // Call the insertion handler onInsertPath?(escapedPath, isDirectory) - + // Provide haptic feedback HapticFeedback.impact(.light) - + // Dismiss the file browser dismiss() } @@ -461,10 +472,10 @@ struct FileBrowserRow: View { if isDirectory { return "folder.fill" } - + // Get file extension let ext = name.split(separator: ".").last?.lowercased() ?? "" - + switch ext { case "js", "jsx", "ts", "tsx", "mjs", "cjs": return "doc.text.fill" @@ -504,44 +515,44 @@ struct FileBrowserRow: View { return "doc.fill" } } - + var iconColor: Color { if isDirectory { return Theme.Colors.terminalAccent } - + let ext = name.split(separator: ".").last?.lowercased() ?? "" - + switch ext { case "js", "jsx", "mjs", "cjs": - return .yellow + return Theme.Colors.fileTypeJS case "ts", "tsx": - return Color(red: 0.0, green: 0.48, blue: 0.78) // TypeScript blue + return Theme.Colors.fileTypeTS case "json": - return .orange + return Theme.Colors.fileTypeJSON case "html", "htm": - return .orange + return Theme.Colors.fileTypeJSON case "css", "scss", "sass", "less": - return Color(red: 0.21, green: 0.46, blue: 0.74) // CSS blue + return Theme.Colors.fileTypeCSS case "md", "markdown": - return .gray + return Theme.Colors.terminalGray case "png", "jpg", "jpeg", "gif", "svg", "ico", "webp": - return .green + return Theme.Colors.fileTypeImage case "swift": - return .orange + return Theme.Colors.fileTypeJSON case "py": - return Color(red: 0.22, green: 0.49, blue: 0.72) // Python blue + return Theme.Colors.fileTypePython case "go": - return Color(red: 0.0, green: 0.68, blue: 0.85) // Go cyan + return Theme.Colors.fileTypeGo case "rs": - return .orange + return Theme.Colors.fileTypeJSON case "sh", "bash", "zsh", "fish": - return .green + return Theme.Colors.fileTypeImage default: return Theme.Colors.terminalGray.opacity(0.6) } } - + var body: some View { Button(action: onTap) { HStack(spacing: 12) { @@ -563,7 +574,7 @@ struct FileBrowserRow: View { Spacer() // Git status indicator - if let gitStatus = gitStatus, gitStatus != .unchanged { + if let gitStatus, gitStatus != .unchanged { GitStatusBadge(status: gitStatus) .padding(.trailing, 8) } @@ -609,7 +620,7 @@ struct FileBrowserRow: View { } label: { Label("Copy Name", systemImage: "doc.on.doc") } - + Button { UIPasteboard.general.string = isDirectory ? "\(name)/" : name UINotificationFeedbackGenerator().notificationOccurred(.success) @@ -648,7 +659,7 @@ class FileBrowserViewModel { var gitStatus: GitStatus? var showHidden = false var gitFilter: GitFilterOption = .all - + enum GitFilterOption: String { case all = "all" case changed = "changed" @@ -702,8 +713,8 @@ class FileBrowserViewModel { do { let result = try await apiClient.browseDirectory( - path: path, - showHidden: showHidden, + path: path, + showHidden: showHidden, gitFilter: gitFilter.rawValue ) // Use the absolute path returned by the server @@ -777,27 +788,27 @@ class FileBrowserViewModel { /// Git status badge component for displaying file status struct GitStatusBadge: View { let status: GitFileStatus - + var label: String { switch status { - case .modified: return "M" - case .added: return "A" - case .deleted: return "D" - case .untracked: return "?" - case .unchanged: return "" + case .modified: "M" + case .added: "A" + case .deleted: "D" + case .untracked: "?" + case .unchanged: "" } } - + var color: Color { switch status { - case .modified: return .yellow - case .added: return .green - case .deleted: return .red - case .untracked: return .gray - case .unchanged: return .clear + case .modified: .yellow + case .added: .green + case .deleted: .red + case .untracked: .gray + case .unchanged: .clear } } - + var body: some View { if status != .unchanged { Text(label) diff --git a/ios/VibeTunnel/Views/FileEditorView.swift b/ios/VibeTunnel/Views/FileEditorView.swift index 1d6dd7ed..c5b46f47 100644 --- a/ios/VibeTunnel/Views/FileEditorView.swift +++ b/ios/VibeTunnel/Views/FileEditorView.swift @@ -3,7 +3,8 @@ import SwiftUI /// File editor view for creating and editing text files. struct FileEditorView: View { - @Environment(\.dismiss) private var dismiss + @Environment(\.dismiss) + private var dismiss @State private var viewModel: FileEditorViewModel @State private var showingSaveAlert = false @State private var showingDiscardAlert = false @@ -110,13 +111,13 @@ struct FileEditorView: View { } .preferredColorScheme(.dark) .onAppear { - if !viewModel.isNewFile { - Task { - await viewModel.loadFile() - } - } isTextEditorFocused = true } + .task { + if !viewModel.isNewFile { + await viewModel.loadFile() + } + } } } diff --git a/ios/VibeTunnel/Views/QuickLookWrapper.swift b/ios/VibeTunnel/Views/QuickLookWrapper.swift index ea64b97a..8f5c55cc 100644 --- a/ios/VibeTunnel/Views/QuickLookWrapper.swift +++ b/ios/VibeTunnel/Views/QuickLookWrapper.swift @@ -43,7 +43,8 @@ struct QuickLookWrapper: UIViewControllerRepresentable { } @MainActor - @objc func dismiss() { + @objc + func dismiss() { quickLookManager.isPresenting = false } } diff --git a/ios/VibeTunnel/Views/Sessions/SessionCardView.swift b/ios/VibeTunnel/Views/Sessions/SessionCardView.swift index ced22b3b..7330c409 100644 --- a/ios/VibeTunnel/Views/Sessions/SessionCardView.swift +++ b/ios/VibeTunnel/Views/Sessions/SessionCardView.swift @@ -18,6 +18,8 @@ struct SessionCardView: View { @State private var scale: CGFloat = 1.0 @State private var rotation: Double = 0 @State private var brightness: Double = 1.0 + + @Environment(\.livePreviewSubscription) private var livePreview private var displayWorkingDir: String { // Convert absolute paths back to ~ notation for display @@ -71,95 +73,23 @@ struct SessionCardView: View { .fill(Theme.Colors.terminalBackground) .frame(height: 120) .overlay( - VStack(alignment: .leading, spacing: Theme.Spacing.small) { + Group { if session.isRunning { - if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty { - // Show terminal output preview - ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: 4) { - // ESC indicator if present - if snapshot.cleanOutputPreview.lowercased().contains("esc to interrupt") { - HStack(spacing: 4) { - Image(systemName: "escape") - .font(.system(size: 10, weight: .bold)) - .foregroundColor(Theme.Colors.warningAccent) - Text("Press ESC to interrupt") - .font(Theme.Typography.terminalSystem(size: 10)) - .foregroundColor(Theme.Colors.warningAccent) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(Theme.Colors.warningAccent.opacity(0.2)) - ) - } - - Text(snapshot.cleanOutputPreview) - .font(Theme.Typography.terminalSystem(size: 10)) - .foregroundColor(Theme.Colors.terminalForeground.opacity(0.8)) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(nil) - .multilineTextAlignment(.leading) - } - } - .padding(Theme.Spacing.small) + // Show live preview if available + if let bufferSnapshot = livePreview?.latestSnapshot { + CompactTerminalPreview(snapshot: bufferSnapshot) + .animation(.easeInOut(duration: 0.2), value: bufferSnapshot.cursorY) + } else if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty { + // Show static snapshot as fallback + staticSnapshotView(snapshot) } else { // Show command and working directory info as fallback - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Text("$") - .font(Theme.Typography.terminalSystem(size: 12)) - .foregroundColor(Theme.Colors.primaryAccent) - Text(session.command.joined(separator: " ")) - .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) - .onTapGesture { - UIPasteboard.general.string = session.workingDir - HapticFeedback.notification(.success) - } - - if isLoadingSnapshot { - HStack { - ProgressView() - .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.extraSmall) - } - } - .padding(Theme.Spacing.small) - - Spacer() + commandInfoView } } else { + // For exited sessions, show last output if available if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty { - // Show last output for exited sessions - ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: 4) { - Text("Session exited") - .font(Theme.Typography.terminalSystem(size: 10)) - .foregroundColor(Theme.Colors.errorAccent) - Text(snapshot.cleanOutputPreview) - .font(Theme.Typography.terminalSystem(size: 10)) - .foregroundColor(Theme.Colors.terminalForeground.opacity(0.6)) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(nil) - .multilineTextAlignment(.leading) - } - } - .padding(Theme.Spacing.small) + exitedSessionView(snapshot) } else { Text("Session exited") .font(Theme.Typography.terminalSystem(size: 12)) @@ -184,6 +114,25 @@ struct SessionCardView: View { .foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors .terminalForeground.opacity(0.5) ) + + // Live preview indicator + if session.isRunning && livePreview?.latestSnapshot != nil { + HStack(spacing: 2) { + Image(systemName: "dot.radiowaves.left.and.right") + .font(.system(size: 8)) + .foregroundColor(Theme.Colors.primaryAccent) + .symbolEffect(.pulse) + Text("live") + .font(Theme.Typography.terminalSystem(size: 9)) + .foregroundColor(Theme.Colors.primaryAccent) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Theme.Colors.primaryAccent.opacity(0.1)) + ) + } } Spacer() @@ -307,4 +256,93 @@ struct SessionCardView: View { opacity = 1.0 } } + + // MARK: - View Components + + @ViewBuilder + private var commandInfoView: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text("$") + .font(Theme.Typography.terminalSystem(size: 12)) + .foregroundColor(Theme.Colors.primaryAccent) + Text(session.command.joined(separator: " ")) + .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) + .onTapGesture { + UIPasteboard.general.string = session.workingDir + HapticFeedback.notification(.success) + } + + if isLoadingSnapshot { + HStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent)) + .scaleEffect(0.8) + Text("Connecting...") + .font(Theme.Typography.terminalSystem(size: 10)) + .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) + } + .padding(.top, Theme.Spacing.extraSmall) + } + } + .padding(Theme.Spacing.small) + } + + @ViewBuilder + private func staticSnapshotView(_ snapshot: TerminalSnapshot) -> some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 4) { + // ESC indicator if present + if snapshot.cleanOutputPreview.lowercased().contains("esc to interrupt") { + HStack(spacing: 4) { + Image(systemName: "escape") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(Theme.Colors.warningAccent) + Text("Press ESC to interrupt") + .font(Theme.Typography.terminalSystem(size: 10)) + .foregroundColor(Theme.Colors.warningAccent) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Theme.Colors.warningAccent.opacity(0.2)) + ) + } + + Text(snapshot.cleanOutputPreview) + .font(Theme.Typography.terminalSystem(size: 10)) + .foregroundColor(Theme.Colors.terminalForeground.opacity(0.8)) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(nil) + .multilineTextAlignment(.leading) + } + } + .padding(Theme.Spacing.small) + } + + @ViewBuilder + private func exitedSessionView(_ snapshot: TerminalSnapshot) -> some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 4) { + Text("Session exited") + .font(Theme.Typography.terminalSystem(size: 10)) + .foregroundColor(Theme.Colors.errorAccent) + Text(snapshot.cleanOutputPreview) + .font(Theme.Typography.terminalSystem(size: 10)) + .foregroundColor(Theme.Colors.terminalForeground.opacity(0.6)) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(nil) + .multilineTextAlignment(.leading) + } + } + .padding(Theme.Spacing.small) + } } diff --git a/ios/VibeTunnel/Views/Sessions/SessionCreateView.swift b/ios/VibeTunnel/Views/Sessions/SessionCreateView.swift index 1228ba2f..1008126a 100644 --- a/ios/VibeTunnel/Views/Sessions/SessionCreateView.swift +++ b/ios/VibeTunnel/Views/Sessions/SessionCreateView.swift @@ -1,5 +1,7 @@ import SwiftUI +private let logger = Logger(category: "SessionCreate") + /// Custom text field style for terminal-like appearance. /// /// Applies terminal-themed styling to text fields including @@ -34,11 +36,12 @@ struct SessionCreateView: View { @State private var workingDirectory = "~/" @State private var sessionName = "" @State private var isCreating = false - @State private var errorMessage: String? + @State private var presentedError: IdentifiableError? @State private var showFileBrowser = false @FocusState private var focusedField: Field? - @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.horizontalSizeClass) + private var horizontalSizeClass enum Field { case command @@ -111,21 +114,12 @@ struct SessionCreateView: View { } // Error Message - if let error = errorMessage { - HStack(spacing: Theme.Spacing.small) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 14)) - Text(error) - .font(Theme.Typography.terminalSystem(size: 13)) - .fixedSize(horizontal: false, vertical: true) - } - .foregroundColor(Theme.Colors.errorAccent) - .padding(.horizontal, Theme.Spacing.medium) - .padding(.vertical, Theme.Spacing.small) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: Theme.CornerRadius.small) - .fill(Theme.Colors.errorAccent.opacity(0.15)) + if presentedError != nil { + ErrorBanner( + message: presentedError?.error.localizedDescription ?? "An error occurred", + onDismiss: { + presentedError = nil + } ) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.small) @@ -321,6 +315,7 @@ struct SessionCreateView: View { HapticFeedback.notification(.success) } } + .errorAlert(item: $presentedError) } private struct QuickStartItem { @@ -386,7 +381,7 @@ struct SessionCreateView: View { private func createSession() { isCreating = true - errorMessage = nil + presentedError = nil // Save preferences matching web localStorage keys UserDefaults.standard.set(command, forKey: "vibetunnel_last_command") @@ -401,30 +396,29 @@ struct SessionCreateView: View { ) // Log the request for debugging - print("[SessionCreate] Creating session with data:") - print(" Command: \(sessionData.command)") - print(" Working Dir: \(sessionData.workingDir)") - print(" Name: \(sessionData.name ?? "nil")") - print(" Spawn Terminal: \(sessionData.spawnTerminal ?? false)") - print(" Cols: \(sessionData.cols ?? 0), Rows: \(sessionData.rows ?? 0)") + logger.info("Creating session with data:") + logger.debug(" Command: \(sessionData.command)") + logger.debug(" Working Dir: \(sessionData.workingDir)") + logger.debug(" Name: \(sessionData.name ?? "nil")") + logger.debug(" Spawn Terminal: \(sessionData.spawnTerminal ?? false)") + logger.debug(" Cols: \(sessionData.cols ?? 0), Rows: \(sessionData.rows ?? 0)") let sessionId = try await SessionService.shared.createSession(sessionData) - print("[SessionCreate] Session created successfully with ID: \(sessionId)") + logger.info("Session created successfully with ID: \(sessionId)") await MainActor.run { onCreated(sessionId) isPresented = false } } catch { - print("[SessionCreate] Failed to create session:") - print(" Error: \(error)") + logger.error("Failed to create session: \(error)") if let apiError = error as? APIError { - print(" API Error: \(apiError)") + logger.error(" API Error: \(apiError)") } await MainActor.run { - errorMessage = error.localizedDescription + presentedError = IdentifiableError(error: error) isCreating = false } } diff --git a/ios/VibeTunnel/Views/Sessions/SessionListView.swift b/ios/VibeTunnel/Views/Sessions/SessionListView.swift index 8f8a22bb..8724c343 100644 --- a/ios/VibeTunnel/Views/Sessions/SessionListView.swift +++ b/ios/VibeTunnel/Views/Sessions/SessionListView.swift @@ -7,9 +7,11 @@ import UniformTypeIdentifiers /// Shows active and exited sessions with options to create new sessions, /// manage existing ones, and navigate to terminal views. struct SessionListView: View { - @Environment(ConnectionManager.self) var connectionManager - @Environment(NavigationManager.self) var navigationManager - @ObservedObject private var networkMonitor = NetworkMonitor.shared + @Environment(ConnectionManager.self) + var connectionManager + @Environment(NavigationManager.self) + var navigationManager + @State private var networkMonitor = NetworkMonitor.shared @State private var viewModel = SessionListViewModel() @State private var showingCreateSession = false @State private var selectedSession: Session? @@ -19,6 +21,8 @@ struct SessionListView: View { @State private var searchText = "" @State private var showingCastImporter = false @State private var importedCastFile: CastFileItem? + @State private var presentedError: IdentifiableError? + @AppStorage("enableLivePreviews") private var enableLivePreviews = true var filteredSessions: [Session] { let sessions = viewModel.sessions.filter { showExitedSessions || $0.isRunning } @@ -101,16 +105,16 @@ struct SessionListView: View { Button(action: { HapticFeedback.impact(.light) showingSettings = true - }) { + }, label: { Label("Settings", systemImage: "gearshape") - } - + }) + Button(action: { HapticFeedback.impact(.light) showingCastImporter = true - }) { + }, label: { Label("Import Recording", systemImage: "square.and.arrow.down") - } + }) } label: { Image(systemName: "ellipsis.circle") .font(.title3) @@ -170,21 +174,27 @@ struct SessionListView: View { importedCastFile = CastFileItem(url: url) } case .failure(let error): - print("Failed to import cast file: \(error)") + logger.error("Failed to import cast file: \(error)") } } .sheet(item: $importedCastFile) { item in CastPlayerView(castFileURL: item.url) } + .errorAlert(item: $presentedError) .refreshable { await viewModel.loadSessions() } .searchable(text: $searchText, prompt: "Search sessions") - .onAppear { - viewModel.startAutoRefresh() - } - .onDisappear { - viewModel.stopAutoRefresh() + .task { + await viewModel.loadSessions() + + // Refresh every 3 seconds + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds + if !Task.isCancelled { + await viewModel.loadSessions() + } + } } } .onChange(of: navigationManager.shouldNavigateToSession) { _, shouldNavigate in @@ -195,6 +205,12 @@ struct SessionListView: View { navigationManager.clearNavigation() } } + .onChange(of: viewModel.errorMessage) { _, newError in + if let error = newError { + presentedError = IdentifiableError(error: APIError.serverError(0, error)) + viewModel.errorMessage = nil + } + } } private var emptyStateView: some View { @@ -256,10 +272,10 @@ struct SessionListView: View { .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) } - Button(action: { searchText = "" }) { + Button(action: { searchText = "" }, label: { Label("Clear Search", systemImage: "xmark.circle.fill") .font(Theme.Typography.terminalSystem(size: 14)) - } + }) .terminalButton() } .padding() @@ -295,7 +311,6 @@ struct SessionListView: View { GridItem(.flexible(), spacing: Theme.Spacing.medium), GridItem(.flexible(), spacing: Theme.Spacing.medium) ], spacing: Theme.Spacing.medium) { - ForEach(filteredSessions) { session in SessionCardView(session: session) { HapticFeedback.selection() @@ -313,6 +328,7 @@ struct SessionListView: View { await viewModel.cleanupSession(session.id) } } + .livePreview(for: session.id, enabled: session.isRunning && enableLivePreviews) .transition(.asymmetric( insertion: .scale(scale: 0.8).combined(with: .opacity), removal: .scale(scale: 0.8).combined(with: .opacity) @@ -372,31 +388,6 @@ struct SessionListView: View { } } -// MARK: - Error Banner - -struct ErrorBanner: View { - let message: String - let isOffline: Bool - - var body: some View { - HStack { - Image(systemName: isOffline ? "wifi.slash" : "exclamationmark.triangle") - .foregroundColor(Theme.Colors.terminalBackground) - - Text(message) - .font(Theme.Typography.terminalSystem(size: 14)) - .foregroundColor(Theme.Colors.terminalBackground) - .lineLimit(2) - - Spacer() - } - .padding() - .background(isOffline ? Color.orange : Theme.Colors.errorAccent) - .cornerRadius(Theme.CornerRadius.small) - .padding(.horizontal) - .padding(.top, 8) - } -} /// View model for managing session list state and operations. @MainActor @@ -406,29 +397,8 @@ class SessionListViewModel { 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 - if !Task.isCancelled { - await loadSessions() - } - } - } - } - - func stopAutoRefresh() { - refreshTask?.cancel() - refreshTask = nil - } - func loadSessions() async { if sessions.isEmpty { isLoading = true @@ -493,8 +463,8 @@ struct SessionHeaderView: View { let onKillAll: () -> Void let onCleanupAll: () -> Void - private var runningCount: Int { sessions.count(where: { $0.isRunning }) } - private var exitedCount: Int { sessions.count(where: { !$0.isRunning }) } + private var runningCount: Int { sessions.count { $0.isRunning }} + private var exitedCount: Int { sessions.count { !$0.isRunning }} var body: some View { VStack(spacing: Theme.Spacing.medium) { @@ -505,28 +475,28 @@ struct SessionHeaderView: View { count: runningCount, color: Theme.Colors.successAccent ) - + SessionCountBadge( label: "Exited", count: exitedCount, color: Theme.Colors.errorAccent ) - + Spacer() } - + // Action buttons HStack(spacing: Theme.Spacing.medium) { if exitedCount > 0 { ExitedSessionToggle(showExitedSessions: $showExitedSessions) } - + Spacer() - + if showExitedSessions && sessions.contains(where: { !$0.isRunning }) { CleanupAllHeaderButton(onCleanup: onCleanupAll) } - + if sessions.contains(where: \.isRunning) { KillAllButton(onKillAll: onKillAll) } @@ -540,14 +510,14 @@ struct SessionCountBadge: View { let label: String let count: Int let color: Color - + var body: some View { VStack(alignment: .leading, spacing: 2) { Text(label) .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.6)) .textCase(.uppercase) - + Text("\(count)") .font(Theme.Typography.terminalSystem(size: 28)) .fontWeight(.bold) @@ -683,3 +653,7 @@ struct CastFileItem: Identifiable { let id = UUID() let url: URL } + +// MARK: - Logging + +private let logger = Logger(category: "SessionListView") diff --git a/ios/VibeTunnel/Views/Settings/SettingsView.swift b/ios/VibeTunnel/Views/Settings/SettingsView.swift index 66973f9a..fd39789e 100644 --- a/ios/VibeTunnel/Views/Settings/SettingsView.swift +++ b/ios/VibeTunnel/Views/Settings/SettingsView.swift @@ -9,11 +9,13 @@ struct SettingsView: View { enum SettingsTab: String, CaseIterable { case general = "General" case advanced = "Advanced" + case about = "About" var icon: String { switch self { case .general: "gear" case .advanced: "gearshape.2" + case .about: "info.circle" } } } @@ -60,6 +62,8 @@ struct SettingsView: View { GeneralSettingsView() case .advanced: AdvancedSettingsView() + case .about: + AboutSettingsView() } } .padding() @@ -91,6 +95,8 @@ struct GeneralSettingsView: View { private var autoScrollEnabled = true @AppStorage("enableURLDetection") private var enableURLDetection = true + @AppStorage("enableLivePreviews") + private var enableLivePreviews = true var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.large) { @@ -166,6 +172,26 @@ struct GeneralSettingsView: View { .padding() .background(Theme.Colors.cardBackground) .cornerRadius(Theme.CornerRadius.card) + + // Live Previews + Toggle(isOn: $enableLivePreviews) { + HStack { + Image(systemName: "dot.radiowaves.left.and.right") + .foregroundColor(Theme.Colors.primaryAccent) + VStack(alignment: .leading, spacing: 2) { + Text("Live Session Previews") + .font(Theme.Typography.terminalSystem(size: 14)) + .foregroundColor(Theme.Colors.terminalForeground) + Text("Show real-time terminal output in session cards") + .font(Theme.Typography.terminalSystem(size: 12)) + .foregroundColor(Theme.Colors.terminalForeground.opacity(0.6)) + } + } + } + .toggleStyle(SwitchToggleStyle(tint: Theme.Colors.primaryAccent)) + .padding() + .background(Theme.Colors.cardBackground) + .cornerRadius(Theme.CornerRadius.card) } } @@ -181,6 +207,16 @@ struct AdvancedSettingsView: View { @AppStorage("debugModeEnabled") private var debugModeEnabled = false @State private var showingSystemLogs = false + + #if targetEnvironment(macCatalyst) + @AppStorage("macWindowStyle") + private var macWindowStyleRaw = "standard" + @StateObject private var windowManager = MacCatalystWindowManager.shared + + private var macWindowStyle: MacWindowStyle { + macWindowStyleRaw == "inline" ? .inline : .standard + } + #endif var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.large) { @@ -210,7 +246,7 @@ struct AdvancedSettingsView: View { .padding() .background(Theme.Colors.cardBackground) .cornerRadius(Theme.CornerRadius.card) - + // View System Logs Button Button(action: { showingSystemLogs = true }) { HStack { @@ -231,6 +267,45 @@ struct AdvancedSettingsView: View { } } + #if targetEnvironment(macCatalyst) + // Mac Catalyst Section + VStack(alignment: .leading, spacing: Theme.Spacing.medium) { + Text("Mac Catalyst") + .font(.headline) + .foregroundColor(Theme.Colors.terminalForeground) + + VStack(spacing: Theme.Spacing.medium) { + // Window Style Picker + VStack(alignment: .leading, spacing: Theme.Spacing.small) { + Text("Window Style") + .font(Theme.Typography.terminalSystem(size: 14)) + .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) + + Picker("Window Style", selection: $macWindowStyleRaw) { + Label("Standard", systemImage: "macwindow") + .tag("standard") + Label("Inline Traffic Lights", systemImage: "macwindow.badge.plus") + .tag("inline") + } + .pickerStyle(SegmentedPickerStyle()) + .onChange(of: macWindowStyleRaw) { _, newValue in + let style: MacWindowStyle = newValue == "inline" ? .inline : .standard + windowManager.setWindowStyle(style) + } + + Text(macWindowStyle == .inline ? + "Traffic light buttons appear inline with content" : + "Standard macOS title bar with traffic lights") + .font(Theme.Typography.terminalSystem(size: 12)) + .foregroundColor(Theme.Colors.terminalForeground.opacity(0.6)) + } + .padding() + .background(Theme.Colors.cardBackground) + .cornerRadius(Theme.CornerRadius.card) + } + } + #endif + // Developer Section VStack(alignment: .leading, spacing: Theme.Spacing.medium) { Text("Developer") @@ -270,6 +345,129 @@ struct AdvancedSettingsView: View { } } +/// About settings tab content +struct AboutSettingsView: View { + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + } + + private var buildNumber: String { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" + } + + var body: some View { + VStack(spacing: Theme.Spacing.xlarge) { + // App icon and info + VStack(spacing: Theme.Spacing.large) { + Image("AppIcon") + .resizable() + .frame(width: 100, height: 100) + .cornerRadius(22) + .shadow(color: Theme.Colors.primaryAccent.opacity(0.3), radius: 10, y: 5) + + VStack(spacing: Theme.Spacing.small) { + Text("VibeTunnel") + .font(.largeTitle) + .fontWeight(.bold) + + Text("Version \(appVersion) (\(buildNumber))") + .font(Theme.Typography.terminalSystem(size: 14)) + .foregroundColor(Theme.Colors.secondaryText) + } + } + .padding(.top, Theme.Spacing.large) + + // Links section + VStack(spacing: Theme.Spacing.medium) { + LinkRow( + icon: "globe", + title: "Website", + subtitle: "vibetunnel.sh", + url: URL(string: "https://vibetunnel.sh") + ) + + LinkRow( + icon: "doc.text", + title: "Documentation", + subtitle: "Learn how to use VibeTunnel", + url: URL(string: "https://docs.vibetunnel.sh") + ) + + LinkRow( + icon: "exclamationmark.bubble", + title: "Report an Issue", + subtitle: "Help us improve", + url: URL(string: "https://github.com/vibetunnel/vibetunnel/issues") + ) + + LinkRow( + icon: "heart", + title: "Rate on App Store", + subtitle: "Share your feedback", + url: URL(string: "https://apps.apple.com/app/vibetunnel") + ) + } + + // Credits + VStack(spacing: Theme.Spacing.small) { + Text("Made with ❤️ by the VibeTunnel team") + .font(Theme.Typography.terminalSystem(size: 12)) + .foregroundColor(Theme.Colors.secondaryText) + .multilineTextAlignment(.center) + + Text("© 2024 VibeTunnel. All rights reserved.") + .font(Theme.Typography.terminalSystem(size: 11)) + .foregroundColor(Theme.Colors.secondaryText.opacity(0.7)) + } + .padding(.top, Theme.Spacing.large) + + Spacer() + } + } +} + +struct LinkRow: View { + let icon: String + let title: String + let subtitle: String + let url: URL? + + var body: some View { + Button(action: { + if let url = url { + UIApplication.shared.open(url) + } + }) { + HStack(spacing: Theme.Spacing.medium) { + Image(systemName: icon) + .font(.system(size: 20)) + .foregroundColor(Theme.Colors.primaryAccent) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(Theme.Typography.terminalSystem(size: 14)) + .foregroundColor(Theme.Colors.terminalForeground) + + Text(subtitle) + .font(Theme.Typography.terminalSystem(size: 12)) + .foregroundColor(Theme.Colors.secondaryText) + } + + Spacer() + + Image(systemName: "arrow.up.right.square") + .font(.system(size: 16)) + .foregroundColor(Theme.Colors.secondaryText.opacity(0.5)) + } + .padding() + .background(Theme.Colors.cardBackground) + .cornerRadius(Theme.CornerRadius.card) + } + .buttonStyle(PlainButtonStyle()) + } +} + #Preview { SettingsView() } diff --git a/ios/VibeTunnel/Views/SystemLogsView.swift b/ios/VibeTunnel/Views/SystemLogsView.swift index f4d4bb19..a9dbd8a7 100644 --- a/ios/VibeTunnel/Views/SystemLogsView.swift +++ b/ios/VibeTunnel/Views/SystemLogsView.swift @@ -2,10 +2,11 @@ import SwiftUI /// System logs viewer with filtering and search capabilities struct SystemLogsView: View { - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) + var dismiss @State private var logs = "" @State private var isLoading = true - @State private var error: String? + @State private var presentedError: IdentifiableError? @State private var searchText = "" @State private var selectedLevel: LogLevel = .all @State private var showClientLogs = true @@ -14,96 +15,93 @@ struct SystemLogsView: View { @State private var refreshTimer: Timer? @State private var showingClearConfirmation = false @State private var logsInfo: LogsInfo? - + enum LogLevel: String, CaseIterable { case all = "All" case error = "Error" case warn = "Warn" case log = "Log" case debug = "Debug" - + var displayName: String { rawValue } - + func matches(_ line: String) -> Bool { switch self { case .all: - return true + true case .error: - return line.localizedCaseInsensitiveContains("[ERROR]") || - line.localizedCaseInsensitiveContains("error:") + line.localizedCaseInsensitiveContains("[ERROR]") || + line.localizedCaseInsensitiveContains("error:") case .warn: - return line.localizedCaseInsensitiveContains("[WARN]") || - line.localizedCaseInsensitiveContains("warning:") + line.localizedCaseInsensitiveContains("[WARN]") || + line.localizedCaseInsensitiveContains("warning:") case .log: - return line.localizedCaseInsensitiveContains("[LOG]") || - line.localizedCaseInsensitiveContains("log:") + line.localizedCaseInsensitiveContains("[LOG]") || + line.localizedCaseInsensitiveContains("log:") case .debug: - return line.localizedCaseInsensitiveContains("[DEBUG]") || - line.localizedCaseInsensitiveContains("debug:") + line.localizedCaseInsensitiveContains("[DEBUG]") || + line.localizedCaseInsensitiveContains("debug:") } } } - + var filteredLogs: String { let lines = logs.components(separatedBy: .newlines) let filtered = lines.filter { line in // Skip empty lines guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false } - + // Filter by level if selectedLevel != .all && !selectedLevel.matches(line) { return false } - + // Filter by source let isClientLog = line.contains("[Client]") || line.contains("client:") - let isServerLog = line.contains("[Server]") || line.contains("server:") || (!isClientLog) - + let isServerLog = line.contains("[Server]") || line.contains("server:") || !isClientLog + if !showClientLogs && isClientLog { return false } if !showServerLogs && isServerLog { return false } - + // Filter by search text if !searchText.isEmpty && !line.localizedCaseInsensitiveContains(searchText) { return false } - + return true } - + return filtered.joined(separator: "\n") } - + var body: some View { NavigationStack { ZStack { Theme.Colors.terminalBackground .ignoresSafeArea() - + VStack(spacing: 0) { // Filters toolbar filtersToolbar - + // Search bar searchBar - + // Logs content if isLoading { ProgressView("Loading logs...") .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent)) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = error { - VStack { - Text("Error loading logs") - .font(.headline) - .foregroundColor(Theme.Colors.errorAccent) - Text(error) - .font(.subheadline) - .foregroundColor(Theme.Colors.terminalForeground) - .multilineTextAlignment(.center) + } else if presentedError != nil { + ContentUnavailableView { + Label("Failed to Load Logs", systemImage: "exclamationmark.triangle") + } description: { + Text("The logs could not be loaded. Please try again.") + } actions: { Button("Retry") { Task { await loadLogs() @@ -126,19 +124,19 @@ struct SystemLogsView: View { } .foregroundColor(Theme.Colors.primaryAccent) } - + ToolbarItem(placement: .navigationBarTrailing) { Menu { Button(action: downloadLogs) { Label("Download", systemImage: "square.and.arrow.down") } - - Button(action: { showingClearConfirmation = true }) { + + Button(action: { showingClearConfirmation = true }, label: { Label("Clear Logs", systemImage: "trash") - } - + }) + Toggle("Auto-scroll", isOn: $autoScroll) - + if let info = logsInfo { Section { Label(formatFileSize(info.size), systemImage: "doc") @@ -169,22 +167,23 @@ struct SystemLogsView: View { } message: { Text("Are you sure you want to clear all system logs? This action cannot be undone.") } + .errorAlert(item: $presentedError) } - + private var filtersToolbar: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { // Level filter Menu { ForEach(LogLevel.allCases, id: \.self) { level in - Button(action: { selectedLevel = level }) { + Button(action: { selectedLevel = level }, label: { HStack { Text(level.displayName) if selectedLevel == level { Image(systemName: "checkmark") } } - } + }) } } label: { HStack(spacing: 4) { @@ -197,14 +196,14 @@ struct SystemLogsView: View { .background(Theme.Colors.cardBackground) .cornerRadius(6) } - + // Source toggles Toggle("Client", isOn: $showClientLogs) .toggleStyle(ChipToggleStyle()) - + Toggle("Server", isOn: $showServerLogs) .toggleStyle(ChipToggleStyle()) - + Spacer() } .padding(.horizontal) @@ -212,31 +211,31 @@ struct SystemLogsView: View { .padding(.vertical, 8) .background(Theme.Colors.cardBackground) } - + private var searchBar: some View { HStack { Image(systemName: "magnifyingglass") .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) - + TextField("Search logs...", text: $searchText) .textFieldStyle(PlainTextFieldStyle()) .font(Theme.Typography.terminalSystem(size: 14)) .foregroundColor(Theme.Colors.terminalForeground) .autocapitalization(.none) .disableAutocorrection(true) - + if !searchText.isEmpty { - Button(action: { searchText = "" }) { + Button(action: { searchText = "" }, label: { Image(systemName: "xmark.circle.fill") .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) - } + }) } } .padding(.horizontal) .padding(.vertical, 8) .background(Theme.Colors.terminalDarkGray) } - + private var logsContent: some View { ScrollViewReader { proxy in ScrollView { @@ -258,42 +257,42 @@ struct SystemLogsView: View { } } } - + private func loadLogs() async { isLoading = true - error = nil - + presentedError = nil + do { // Load logs content logs = try await APIClient.shared.getLogsRaw() - + // Load logs info logsInfo = try await APIClient.shared.getLogsInfo() - + isLoading = false } catch { - self.error = error.localizedDescription + presentedError = IdentifiableError(error: error) isLoading = false } } - + private func clearLogs() async { do { try await APIClient.shared.clearLogs() logs = "" await loadLogs() } catch { - self.error = error.localizedDescription + presentedError = IdentifiableError(error: error) } } - + private func downloadLogs() { // Create activity controller with logs let activityVC = UIActivityViewController( activityItems: [logs], applicationActivities: nil ) - + // Present it if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first, @@ -301,7 +300,7 @@ struct SystemLogsView: View { rootVC.present(activityVC, animated: true) } } - + private func startAutoRefresh() { refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { _ in Task { @@ -309,12 +308,12 @@ struct SystemLogsView: View { } } } - + private func stopAutoRefresh() { refreshTimer?.invalidate() refreshTimer = nil } - + private func formatFileSize(_ size: Int64) -> String { let formatter = ByteCountFormatter() formatter.countStyle = .binary @@ -325,7 +324,7 @@ struct SystemLogsView: View { /// Custom toggle style for filter chips struct ChipToggleStyle: ToggleStyle { func makeBody(configuration: Configuration) -> some View { - Button(action: { configuration.isOn.toggle() }) { + Button(action: { configuration.isOn.toggle() }, label: { HStack(spacing: 4) { if configuration.isOn { Image(systemName: "checkmark") @@ -339,7 +338,7 @@ struct ChipToggleStyle: ToggleStyle { .background(configuration.isOn ? Theme.Colors.primaryAccent.opacity(0.2) : Theme.Colors.cardBackground) .foregroundColor(configuration.isOn ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground) .cornerRadius(6) - } + }) .buttonStyle(PlainButtonStyle()) } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Terminal/AdvancedKeyboardView.swift b/ios/VibeTunnel/Views/Terminal/AdvancedKeyboardView.swift index ddb5e8ca..623169e7 100644 --- a/ios/VibeTunnel/Views/Terminal/AdvancedKeyboardView.swift +++ b/ios/VibeTunnel/Views/Terminal/AdvancedKeyboardView.swift @@ -1,5 +1,7 @@ import SwiftUI +private let logger = Logger(category: "AdvancedKeyboard") + /// Advanced keyboard view with special keys and control combinations struct AdvancedKeyboardView: View { @Binding var isPresented: Bool @@ -230,8 +232,9 @@ struct CtrlKeyButton: View { var body: some View { Button(action: { // Calculate control character (Ctrl+A = 1, Ctrl+B = 2, etc.) - if let scalar = char.unicodeScalars.first { - let ctrlChar = Character(UnicodeScalar(scalar.value - 64)!) + if let scalar = char.unicodeScalars.first, + let ctrlScalar = UnicodeScalar(scalar.value - 64) { + let ctrlChar = Character(ctrlScalar) onPress(String(ctrlChar)) } }) { @@ -292,6 +295,6 @@ struct FunctionKeyButton: View { #Preview { AdvancedKeyboardView(isPresented: .constant(true)) { input in - print("Input: \(input)") + logger.debug("Input: \(input)") } } diff --git a/ios/VibeTunnel/Views/Terminal/CastPlayerView.swift b/ios/VibeTunnel/Views/Terminal/CastPlayerView.swift index 64ccbc9f..c44b0225 100644 --- a/ios/VibeTunnel/Views/Terminal/CastPlayerView.swift +++ b/ios/VibeTunnel/Views/Terminal/CastPlayerView.swift @@ -9,7 +9,8 @@ import UniformTypeIdentifiers /// supporting the Asciinema cast v2 format. struct CastPlayerView: View { let castFileURL: URL - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) + var dismiss @State private var viewModel = CastPlayerViewModel() @State private var fontSize: CGFloat = 14 @State private var isPlaying = false diff --git a/ios/VibeTunnel/Views/Terminal/CtrlKeyGrid.swift b/ios/VibeTunnel/Views/Terminal/CtrlKeyGrid.swift new file mode 100644 index 00000000..8ea6f9cc --- /dev/null +++ b/ios/VibeTunnel/Views/Terminal/CtrlKeyGrid.swift @@ -0,0 +1,216 @@ +import SwiftUI + +private let logger = Logger(category: "CtrlKeyGrid") + +/// Grid selector for Ctrl+key combinations +struct CtrlKeyGrid: View { + @Binding var isPresented: Bool + let onKeyPress: (String) -> Void + + // Common Ctrl combinations organized by category + let navigationKeys = [ + ("A", "Beginning of line"), + ("E", "End of line"), + ("B", "Back one character"), + ("F", "Forward one character"), + ("P", "Previous command"), + ("N", "Next command") + ] + + let editingKeys = [ + ("D", "Delete character"), + ("H", "Backspace"), + ("W", "Delete word"), + ("U", "Delete to beginning"), + ("K", "Delete to end"), + ("Y", "Paste") + ] + + let processKeys = [ + ("C", "Interrupt (SIGINT)"), + ("Z", "Suspend (SIGTSTP)"), + ("\\", "Quit (SIGQUIT)"), + ("S", "Stop output"), + ("Q", "Resume output"), + ("L", "Clear screen") + ] + + let searchKeys = [ + ("R", "Search history"), + ("T", "Transpose chars"), + ("_", "Undo"), + ("X", "Start selection"), + ("G", "Cancel command"), + ("O", "Execute + new line") + ] + + @State private var selectedCategory = 0 + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Category picker + Picker("Category", selection: $selectedCategory) { + Text("Navigation").tag(0) + Text("Editing").tag(1) + Text("Process").tag(2) + Text("Search").tag(3) + } + .pickerStyle(SegmentedPickerStyle()) + .padding() + + // Key grid + ScrollView { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: Theme.Spacing.medium) { + ForEach(currentKeys, id: \.0) { key, description in + CtrlGridKeyButton( + key: key, + description: description, + onPress: { sendCtrlKey(key) } + ) + } + } + .padding() + } + + // Quick reference + VStack(alignment: .leading, spacing: Theme.Spacing.small) { + Text("Tip: Long press any key to see its function") + .font(Theme.Typography.terminalSystem(size: 12)) + .foregroundColor(Theme.Colors.secondaryText) + + Text("These shortcuts work in most terminal applications") + .font(Theme.Typography.terminalSystem(size: 11)) + .foregroundColor(Theme.Colors.secondaryText.opacity(0.7)) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.Colors.cardBackground) + } + .navigationTitle("Ctrl Key Shortcuts") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + isPresented = false + } + .foregroundColor(Theme.Colors.primaryAccent) + } + } + } + .preferredColorScheme(.dark) + } + + private var currentKeys: [(String, String)] { + switch selectedCategory { + case 0: return navigationKeys + case 1: return editingKeys + case 2: return processKeys + case 3: return searchKeys + default: return navigationKeys + } + } + + private func sendCtrlKey(_ key: String) { + // Convert letter to control character + if let charCode = key.first?.asciiValue { + let controlCharCode = Int(charCode & 0x1F) // Convert to control character + if let controlChar = UnicodeScalar(controlCharCode).map(String.init) { + onKeyPress(controlChar) + Task { @MainActor in + HapticFeedback.impact(.medium) + } + + // Auto-dismiss for common keys + if ["C", "D", "Z"].contains(key) { + isPresented = false + } + } + } + } +} + +/// Individual Ctrl key button for the grid +struct CtrlGridKeyButton: View { + let key: String + let description: String + let onPress: () -> Void + + @State private var isPressed = false + @State private var showingTooltip = false + + var body: some View { + Button(action: onPress, label: { + VStack(spacing: 4) { + Text("^" + key) + .font(Theme.Typography.terminalSystem(size: 20, weight: .bold)) + .foregroundColor(isPressed ? .white : Theme.Colors.primaryAccent) + + Text("Ctrl+" + key) + .font(Theme.Typography.terminalSystem(size: 10)) + .foregroundColor(isPressed ? .white.opacity(0.8) : Theme.Colors.secondaryText) + } + .frame(maxWidth: .infinity) + .frame(height: 80) + .background( + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .fill(isPressed ? Theme.Colors.primaryAccent : Theme.Colors.cardBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .stroke( + isPressed ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder, + lineWidth: isPressed ? 2 : 1 + ) + ) + .shadow( + color: isPressed ? Theme.Colors.primaryAccent.opacity(0.3) : .clear, + radius: isPressed ? 8 : 0 + ) + }) + .buttonStyle(PlainButtonStyle()) + .scaleEffect(isPressed ? 0.95 : 1.0) + .animation(.easeInOut(duration: 0.1), value: isPressed) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in isPressed = true } + .onEnded { _ in isPressed = false } + ) + .onLongPressGesture(minimumDuration: 0.5) { + showingTooltip = true + Task { @MainActor in + HapticFeedback.impact(.light) + } + + // Hide tooltip after 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + showingTooltip = false + } + } + .popover(isPresented: $showingTooltip) { + VStack(alignment: .leading, spacing: Theme.Spacing.small) { + Text("Ctrl+" + key) + .font(Theme.Typography.terminalSystem(size: 14, weight: .bold)) + .foregroundColor(Theme.Colors.primaryAccent) + + Text(description) + .font(Theme.Typography.terminalSystem(size: 12)) + .foregroundColor(Theme.Colors.terminalForeground) + } + .padding() + .presentationCompactAdaptation(.popover) + } + } +} + +// MARK: - Preview + +#Preview { + CtrlKeyGrid(isPresented: .constant(true)) { key in + logger.debug("Ctrl key pressed: \(key)") + } +} \ No newline at end of file diff --git a/ios/VibeTunnel/Views/Terminal/FileBrowserFAB.swift b/ios/VibeTunnel/Views/Terminal/FileBrowserFAB.swift index e1cdd737..96e305d2 100644 --- a/ios/VibeTunnel/Views/Terminal/FileBrowserFAB.swift +++ b/ios/VibeTunnel/Views/Terminal/FileBrowserFAB.swift @@ -1,15 +1,19 @@ import SwiftUI +private let logger = Logger(category: "FileBrowserFAB") + /// Floating action button for opening file browser struct FileBrowserFAB: View { let isVisible: Bool let action: () -> Void - + var body: some View { Button(action: { - HapticFeedback.impact(.medium) + Task { @MainActor in + HapticFeedback.impact(.medium) + } action() - }) { + }, label: { Image(systemName: "folder.fill") .font(.system(size: 20, weight: .medium)) .foregroundColor(Theme.Colors.terminalBackground) @@ -23,7 +27,7 @@ struct FileBrowserFAB: View { ) ) .shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4) - } + }) .opacity(isVisible ? 1 : 0) .scaleEffect(isVisible ? 1 : 0.8) .animation(Theme.Animation.smooth, value: isVisible) @@ -31,31 +35,22 @@ struct FileBrowserFAB: View { } } -/// Extension to add file browser FAB overlay modifier -extension View { - func fileBrowserFABOverlay( - isVisible: Bool, - action: @escaping () -> Void - ) -> some View { - self.overlay( - FileBrowserFAB( - isVisible: isVisible, - action: action - ) - .padding(.bottom, Theme.Spacing.extraLarge) - .padding(.trailing, Theme.Spacing.large), - alignment: .bottomTrailing - ) - } -} +// Note: Use FileBrowserFAB directly with overlay instead of this extension +// Example: +// .overlay( +// FileBrowserFAB(isVisible: showFAB, action: { }) +// .padding(.bottom, Theme.Spacing.extraLarge) +// .padding(.trailing, Theme.Spacing.large), +// alignment: .bottomTrailing +// ) #Preview { ZStack { Theme.Colors.terminalBackground .ignoresSafeArea() - + FileBrowserFAB(isVisible: true) { - print("Open file browser") + logger.debug("Open file browser") } } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Terminal/FontSizeSheet.swift b/ios/VibeTunnel/Views/Terminal/FontSizeSheet.swift index 95415569..ff7b96d3 100644 --- a/ios/VibeTunnel/Views/Terminal/FontSizeSheet.swift +++ b/ios/VibeTunnel/Views/Terminal/FontSizeSheet.swift @@ -6,7 +6,8 @@ import SwiftUI /// of how the terminal text will appear. struct FontSizeSheet: View { @Binding var fontSize: CGFloat - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) + var dismiss let fontSizes: [CGFloat] = [10, 12, 14, 16, 18, 20, 22, 24, 28, 32] diff --git a/ios/VibeTunnel/Views/Terminal/FullscreenTextInput.swift b/ios/VibeTunnel/Views/Terminal/FullscreenTextInput.swift new file mode 100644 index 00000000..e624817a --- /dev/null +++ b/ios/VibeTunnel/Views/Terminal/FullscreenTextInput.swift @@ -0,0 +1,214 @@ +import SwiftUI + +private let logger = Logger(category: "FullscreenTextInput") + +/// Full-screen text input overlay for better typing experience +struct FullscreenTextInput: View { + @Binding var isPresented: Bool + let onSubmit: (String) -> Void + @State private var text: String = "" + @FocusState private var isFocused: Bool + @State private var showingOptions = false + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Text editor + ScrollView { + TextEditor(text: $text) + .font(Theme.Typography.terminalSystem(size: 16)) + .foregroundColor(Theme.Colors.terminalForeground) + .padding(Theme.Spacing.medium) + .background(Color.clear) + .focused($isFocused) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .frame(minHeight: 200) + } + .background(Theme.Colors.cardBackground) + .cornerRadius(Theme.CornerRadius.medium) + .padding() + + // Quick actions + HStack(spacing: Theme.Spacing.medium) { + // Template commands + Menu { + Button(action: { insertTemplate("ls -la") }, label: { + Label("List Files", systemImage: "folder") + }) + + Button(action: { insertTemplate("cd ") }, label: { + Label("Change Directory", systemImage: "arrow.right.square") + }) + + Button(action: { insertTemplate("git status") }, label: { + Label("Git Status", systemImage: "arrow.triangle.branch") + }) + + Button(action: { insertTemplate("sudo ") }, label: { + Label("Sudo Command", systemImage: "lock") + }) + + Divider() + + Button(action: { insertTemplate("ssh ") }, label: { + Label("SSH Connect", systemImage: "network") + }) + + Button(action: { insertTemplate("docker ps") }, label: { + Label("Docker List", systemImage: "shippingbox") + }) + } label: { + Label("Templates", systemImage: "text.badge.plus") + .font(Theme.Typography.terminalSystem(size: 14)) + } + .buttonStyle(.bordered) + + Spacer() + + // Character count + Text("\(text.count) characters") + .font(Theme.Typography.terminalSystem(size: 12)) + .foregroundColor(Theme.Colors.secondaryText) + + // Clear button + if !text.isEmpty { + Button(action: { + text = "" + HapticFeedback.impact(.light) + }, label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(Theme.Colors.secondaryText) + }) + } + } + .padding(.horizontal) + .padding(.bottom, Theme.Spacing.small) + + Divider() + .background(Theme.Colors.cardBorder) + + // Input options + VStack(spacing: Theme.Spacing.small) { + // Common special characters + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Theme.Spacing.small) { + ForEach(["~", "/", "|", "&", ";", "&&", "||", ">", "<", ">>", "2>&1"], id: \.self) { char in + Button(action: { insertText(char) }, label: { + Text(char) + .font(Theme.Typography.terminalSystem(size: 14)) + .padding(.horizontal, Theme.Spacing.medium) + .padding(.vertical, Theme.Spacing.small) + .background(Theme.Colors.cardBackground) + .cornerRadius(Theme.CornerRadius.small) + .overlay( + RoundedRectangle(cornerRadius: Theme.CornerRadius.small) + .stroke(Theme.Colors.cardBorder, lineWidth: 1) + ) + }) + } + } + .padding(.horizontal) + } + + // Submit options + HStack(spacing: Theme.Spacing.medium) { + // Execute immediately + Button(action: { + submitAndClose() + }, label: { + HStack { + Image(systemName: "arrow.right.circle.fill") + Text("Execute") + } + .font(Theme.Typography.terminalSystem(size: 16, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, Theme.Spacing.large) + .padding(.vertical, Theme.Spacing.medium) + .background(Theme.Colors.primaryAccent) + .cornerRadius(Theme.CornerRadius.medium) + }) + + // Insert without executing + Button(action: { + insertAndClose() + }, label: { + HStack { + Image(systemName: "text.insert") + Text("Insert") + } + .font(Theme.Typography.terminalSystem(size: 16)) + .foregroundColor(Theme.Colors.primaryAccent) + .padding(.horizontal, Theme.Spacing.large) + .padding(.vertical, Theme.Spacing.medium) + .background(Theme.Colors.primaryAccent.opacity(0.1)) + .cornerRadius(Theme.CornerRadius.medium) + .overlay( + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .stroke(Theme.Colors.primaryAccent, lineWidth: 1) + ) + }) + } + .padding(.horizontal) + .padding(.bottom, Theme.Spacing.medium) + } + .background(Theme.Colors.terminalBackground) + } + .navigationTitle("Compose Command") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + isPresented = false + } + .foregroundColor(Theme.Colors.primaryAccent) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { showingOptions.toggle() }, label: { + Image(systemName: "ellipsis.circle") + .foregroundColor(Theme.Colors.primaryAccent) + }) + } + } + } + .preferredColorScheme(.dark) + .onAppear { + isFocused = true + } + } + + private func insertText(_ text: String) { + self.text.append(text) + HapticFeedback.impact(.light) + } + + private func insertTemplate(_ template: String) { + self.text = template + HapticFeedback.impact(.light) + } + + private func submitAndClose() { + if !text.isEmpty { + onSubmit(text + "\n") // Add newline to execute + HapticFeedback.impact(.medium) + } + isPresented = false + } + + private func insertAndClose() { + if !text.isEmpty { + onSubmit(text) // Don't add newline, just insert + HapticFeedback.impact(.light) + } + isPresented = false + } +} + +// MARK: - Preview + +#Preview { + FullscreenTextInput(isPresented: .constant(true)) { text in + logger.debug("Submitted: \(text)") + } +} \ No newline at end of file diff --git a/ios/VibeTunnel/Views/Terminal/QuickFontSizeButtons.swift b/ios/VibeTunnel/Views/Terminal/QuickFontSizeButtons.swift new file mode 100644 index 00000000..8725b524 --- /dev/null +++ b/ios/VibeTunnel/Views/Terminal/QuickFontSizeButtons.swift @@ -0,0 +1,76 @@ +import SwiftUI + +/// Quick font size adjustment buttons +struct QuickFontSizeButtons: View { + @Binding var fontSize: CGFloat + let minSize: CGFloat = 8 + let maxSize: CGFloat = 32 + + var body: some View { + HStack(spacing: 0) { + // Decrease button + Button(action: decreaseFontSize) { + Image(systemName: "minus") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(fontSize > minSize ? Theme.Colors.primaryAccent : Theme.Colors.secondaryText.opacity(0.5)) + .frame(width: 30, height: 30) + .background(Theme.Colors.cardBackground) + .overlay( + RoundedRectangle(cornerRadius: Theme.CornerRadius.small) + .stroke(Theme.Colors.cardBorder, lineWidth: 1) + ) + } + .disabled(fontSize <= minSize) + + // Current size display + Text("\(Int(fontSize))") + .font(Theme.Typography.terminalSystem(size: 12, weight: .medium)) + .foregroundColor(Theme.Colors.terminalForeground) + .frame(width: 32) + .overlay( + VStack(spacing: 0) { + Divider() + .background(Theme.Colors.cardBorder) + Spacer() + Divider() + .background(Theme.Colors.cardBorder) + } + ) + + // Increase button + Button(action: increaseFontSize) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(fontSize < maxSize ? Theme.Colors.primaryAccent : Theme.Colors.secondaryText.opacity(0.5)) + .frame(width: 30, height: 30) + .background(Theme.Colors.cardBackground) + .overlay( + RoundedRectangle(cornerRadius: Theme.CornerRadius.small) + .stroke(Theme.Colors.cardBorder, lineWidth: 1) + ) + } + .disabled(fontSize >= maxSize) + } + .background(Theme.Colors.cardBackground) + .cornerRadius(Theme.CornerRadius.small) + .shadow(color: Theme.CardShadow.color, radius: 2, y: 1) + } + + private func decreaseFontSize() { + fontSize = max(minSize, fontSize - 1) + HapticFeedback.impact(.light) + } + + private func increaseFontSize() { + fontSize = min(maxSize, fontSize + 1) + HapticFeedback.impact(.light) + } +} + +// MARK: - Preview + +#Preview { + QuickFontSizeButtons(fontSize: .constant(14)) + .padding() + .background(Theme.Colors.terminalBackground) +} \ No newline at end of file diff --git a/ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift b/ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift index a3eae479..92f1b345 100644 --- a/ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift +++ b/ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift @@ -1,6 +1,8 @@ import SwiftUI import UniformTypeIdentifiers +private let logger = Logger(category: "RecordingExport") + /// Sheet for exporting terminal recordings. /// /// Provides interface for exporting recorded terminal sessions @@ -8,7 +10,8 @@ import UniformTypeIdentifiers struct RecordingExportSheet: View { var recorder: CastRecorder let sessionName: String - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) + var dismiss @State private var isExporting = false @State private var showingShareSheet = false @State private var exportedFileURL: URL? @@ -126,7 +129,7 @@ struct RecordingExportSheet: View { showingShareSheet = true } } catch { - print("Failed to save cast file: \(error)") + logger.error("Failed to save cast file: \(error)") await MainActor.run { isExporting = false } diff --git a/ios/VibeTunnel/Views/Terminal/ScrollToBottomButton.swift b/ios/VibeTunnel/Views/Terminal/ScrollToBottomButton.swift index 20a660d9..e63f7449 100644 --- a/ios/VibeTunnel/Views/Terminal/ScrollToBottomButton.swift +++ b/ios/VibeTunnel/Views/Terminal/ScrollToBottomButton.swift @@ -1,5 +1,7 @@ import SwiftUI +private let logger = Logger(category: "ScrollToBottomButton") + /// Floating action button to scroll terminal to bottom struct ScrollToBottomButton: View { let isVisible: Bool @@ -11,7 +13,7 @@ struct ScrollToBottomButton: View { Button(action: { HapticFeedback.impact(.light) action() - }) { + }, label: { Text("↓") .font(.system(size: 24, weight: .bold)) .foregroundColor(isHovered ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground) @@ -35,7 +37,7 @@ struct ScrollToBottomButton: View { ) .scaleEffect(isPressed ? 0.95 : 1.0) .offset(y: isHovered && !isPressed ? -1 : 0) - } + }) .buttonStyle(PlainButtonStyle()) .opacity(isVisible ? 1 : 0) .scaleEffect(isVisible ? 1 : 0.8) @@ -54,24 +56,14 @@ struct ScrollToBottomButton: View { } } -/// Extension to add scroll-to-bottom overlay modifier -extension View { - func scrollToBottomOverlay( - isVisible: Bool, - action: @escaping () -> Void - ) - -> some View { - self.overlay( - ScrollToBottomButton( - isVisible: isVisible, - action: action - ) - .padding(.bottom, Theme.Spacing.large) - .padding(.leading, Theme.Spacing.large), - alignment: .bottomLeading - ) - } -} +// Note: Use ScrollToBottomButton directly with overlay instead of this extension +// Example: +// .overlay( +// ScrollToBottomButton(isVisible: showButton, action: { }) +// .padding(.bottom, Theme.Spacing.large) +// .padding(.leading, Theme.Spacing.large), +// alignment: .bottomLeading +// ) #Preview { ZStack { @@ -79,7 +71,7 @@ extension View { .ignoresSafeArea() ScrollToBottomButton(isVisible: true) { - print("Scroll to bottom") + logger.debug("Scroll to bottom") } } } diff --git a/ios/VibeTunnel/Views/Terminal/TerminalBufferPreview.swift b/ios/VibeTunnel/Views/Terminal/TerminalBufferPreview.swift new file mode 100644 index 00000000..b9b15edf --- /dev/null +++ b/ios/VibeTunnel/Views/Terminal/TerminalBufferPreview.swift @@ -0,0 +1,188 @@ +import SwiftUI + +/// A lightweight terminal preview component that renders buffer snapshots. +/// +/// This view efficiently renders terminal content from BufferSnapshot data, +/// optimized for small preview sizes in session cards. +struct TerminalBufferPreview: View { + let snapshot: BufferSnapshot + let fontSize: CGFloat + + init(snapshot: BufferSnapshot, fontSize: CGFloat = 10) { + self.snapshot = snapshot + self.fontSize = fontSize + } + + var body: some View { + GeometryReader { geometry in + ScrollViewReader { scrollProxy in + ScrollView([.horizontal, .vertical], showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(0..= 0 && snapshot.cursorY < snapshot.rows { + withAnimation(.none) { + scrollProxy.scrollTo("content", anchor: .bottom) + } + } + } + } + } + .background(Theme.Colors.terminalBackground) + .cornerRadius(Theme.CornerRadius.small) + } + + @ViewBuilder + private func cellView(for cell: BufferCell) -> some View { + Text(cell.char.isEmpty ? " " : cell.char) + .font(Theme.Typography.terminalSystem(size: fontSize)) + .foregroundColor(foregroundColor(for: cell)) + .background(backgroundColor(for: cell)) + .frame(width: fontSize * 0.6 * CGFloat(max(1, cell.width))) + } + + private func foregroundColor(for cell: BufferCell) -> Color { + guard let fg = cell.fg else { + return Theme.Colors.terminalForeground + } + + // Check if RGB color (has alpha channel flag) + if (fg & 0xFF000000) != 0 { + // RGB color + let red = Double((fg >> 16) & 0xFF) / 255.0 + let green = Double((fg >> 8) & 0xFF) / 255.0 + let blue = Double(fg & 0xFF) / 255.0 + return Color(red: red, green: green, blue: blue) + } else { + // Palette color + return paletteColor(fg) + } + } + + private func backgroundColor(for cell: BufferCell) -> Color { + guard let bg = cell.bg else { + return .clear + } + + // Check if RGB color (has alpha channel flag) + if (bg & 0xFF000000) != 0 { + // RGB color + let red = Double((bg >> 16) & 0xFF) / 255.0 + let green = Double((bg >> 8) & 0xFF) / 255.0 + let blue = Double(bg & 0xFF) / 255.0 + return Color(red: red, green: green, blue: blue) + } else { + // Palette color + return paletteColor(bg) + } + } + + private func paletteColor(_ index: Int) -> Color { + // ANSI 256-color palette + switch index { + case 0: return Color(white: 0.0) // Black + case 1: return Color(red: 0.8, green: 0.0, blue: 0.0) // Red + case 2: return Color(red: 0.0, green: 0.8, blue: 0.0) // Green + case 3: return Color(red: 0.8, green: 0.8, blue: 0.0) // Yellow + case 4: return Color(red: 0.0, green: 0.0, blue: 0.8) // Blue + case 5: return Color(red: 0.8, green: 0.0, blue: 0.8) // Magenta + case 6: return Color(red: 0.0, green: 0.8, blue: 0.8) // Cyan + case 7: return Color(white: 0.8) // White + case 8: return Color(white: 0.4) // Bright Black + case 9: return Color(red: 1.0, green: 0.0, blue: 0.0) // Bright Red + case 10: return Color(red: 0.0, green: 1.0, blue: 0.0) // Bright Green + case 11: return Color(red: 1.0, green: 1.0, blue: 0.0) // Bright Yellow + case 12: return Color(red: 0.0, green: 0.0, blue: 1.0) // Bright Blue + case 13: return Color(red: 1.0, green: 0.0, blue: 1.0) // Bright Magenta + case 14: return Color(red: 0.0, green: 1.0, blue: 1.0) // Bright Cyan + case 15: return Color(white: 1.0) // Bright White + default: + // For extended colors, use a simplified mapping + if index < 256 { + let gray = Double(index - 232) / 23.0 + return Color(white: gray) + } + return Theme.Colors.terminalForeground + } + } +} + +/// A simplified terminal preview that shows only the last visible lines. +/// More efficient for small previews in session cards. +struct CompactTerminalPreview: View { + let snapshot: BufferSnapshot + let maxLines: Int + + init(snapshot: BufferSnapshot, maxLines: Int = 6) { + self.snapshot = snapshot + self.maxLines = maxLines + } + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + // Get the last non-empty lines + let visibleLines = getVisibleLines() + + ForEach(Array(visibleLines.enumerated()), id: \.offset) { _, line in + Text(line) + .font(Theme.Typography.terminalSystem(size: 10)) + .foregroundColor(Theme.Colors.terminalForeground.opacity(0.8)) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + + private func getVisibleLines() -> [String] { + var lines: [String] = [] + + // Start from the bottom and work up to find non-empty lines + for row in (0..= maxLines { + break + } + } + } + + // If we have fewer lines than maxLines, add empty lines at the top + while lines.count < min(maxLines, snapshot.rows) && lines.count < maxLines { + lines.insert("", at: 0) + } + + return lines + } +} \ No newline at end of file diff --git a/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift b/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift index ce8d41a2..48a744b0 100644 --- a/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift +++ b/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift @@ -13,7 +13,8 @@ struct TerminalHostingView: UIViewRepresentable { let onResize: (Int, Int) -> Void var viewModel: TerminalViewModel @State private var isAutoScrollEnabled = true - @AppStorage("enableURLDetection") private var enableURLDetection = true + @AppStorage("enableURLDetection") + private var enableURLDetection = true func makeUIView(context: Context) -> SwiftTerm.TerminalView { let terminal = SwiftTerm.TerminalView() @@ -120,7 +121,7 @@ struct TerminalHostingView: UIViewRepresentable { // Use system monospaced font which has better compatibility with SwiftTerm // The custom SF Mono font seems to have rendering issues let font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular) - + // SwiftTerm uses the font property directly terminal.font = font } @@ -179,8 +180,11 @@ struct TerminalHostingView: UIViewRepresentable { /// Update terminal buffer from binary buffer data using optimized ANSI sequences func updateBuffer(from snapshot: BufferSnapshot) { guard let terminal else { return } - - logger.verbose("updateBuffer called with snapshot: \(snapshot.cols)x\(snapshot.rows), cursor: (\(snapshot.cursorX),\(snapshot.cursorY))") + + logger + .verbose( + "updateBuffer called with snapshot: \(snapshot.cols)x\(snapshot.rows), cursor: (\(snapshot.cursorX),\(snapshot.cursorY))" + ) // Update terminal dimensions if needed let currentCols = terminal.getTerminal().cols @@ -207,10 +211,14 @@ struct TerminalHostingView: UIViewRepresentable { ansiData = convertBufferToOptimizedANSI(snapshot, clearScreen: isFirstUpdate) isFirstUpdate = false logger.verbose("Full redraw performed") - } else { + } else if let previous = previousSnapshot { // Incremental update - ansiData = generateIncrementalUpdate(from: previousSnapshot!, to: snapshot) + ansiData = generateIncrementalUpdate(from: previous, to: snapshot) logger.verbose("Incremental update performed") + } else { + // Fallback to full redraw if somehow previousSnapshot is nil + ansiData = convertBufferToOptimizedANSI(snapshot, clearScreen: false) + logger.verbose("Fallback full redraw performed") } // Store current snapshot for next update @@ -322,10 +330,10 @@ struct TerminalHostingView: UIViewRepresentable { if let fg = cell.fg { if fg & 0xFF00_0000 != 0 { // RGB color - let r = (fg >> 16) & 0xFF - let g = (fg >> 8) & 0xFF - let b = fg & 0xFF - output += "\u{001B}[38;2;\(r);\(g);\(b)m" + let red = (fg >> 16) & 0xFF + let green = (fg >> 8) & 0xFF + let blue = fg & 0xFF + output += "\u{001B}[38;2;\(red);\(green);\(blue)m" } else if fg <= 255 { // Palette color output += "\u{001B}[38;5;\(fg)m" @@ -341,10 +349,10 @@ struct TerminalHostingView: UIViewRepresentable { if let bg = cell.bg { if bg & 0xFF00_0000 != 0 { // RGB color - let r = (bg >> 16) & 0xFF - let g = (bg >> 8) & 0xFF - let b = bg & 0xFF - output += "\u{001B}[48;2;\(r);\(g);\(b)m" + let red = (bg >> 16) & 0xFF + let green = (bg >> 8) & 0xFF + let blue = bg & 0xFF + output += "\u{001B}[48;2;\(red);\(green);\(blue)m" } else if bg <= 255 { // Palette color output += "\u{001B}[48;5;\(bg)m" @@ -552,10 +560,10 @@ struct TerminalHostingView: UIViewRepresentable { if let color = new { if color & 0xFF00_0000 != 0 { // RGB color - let r = (color >> 16) & 0xFF - let g = (color >> 8) & 0xFF - let b = color & 0xFF - output += "\u{001B}[\(isBackground ? 48 : 38);2;\(r);\(g);\(b)m" + let red = (color >> 16) & 0xFF + let green = (color >> 8) & 0xFF + let blue = color & 0xFF + output += "\u{001B}[\(isBackground ? 48 : 38);2;\(red);\(green);\(blue)m" } else if color <= 255 { // Palette color output += "\u{001B}[\(isBackground ? 48 : 38);5;\(color)m" @@ -591,14 +599,14 @@ struct TerminalHostingView: UIViewRepresentable { } } } - + func getBufferContent() -> String? { guard let terminal else { return nil } - + // Get the terminal buffer content let terminalInstance = terminal.getTerminal() var content = "" - + // Read all lines from the terminal buffer for row in 0..= 32) - - Button(action: { + + Button(action: { fontSize = 14 HapticFeedback.impact(.light) }, label: { Label("Reset to Default", systemImage: "arrow.counterclockwise") }) .disabled(fontSize == 14) - + Divider() - + Button(action: { showingFontSizeSheet = true }, label: { Label("More Options...", systemImage: "slider.horizontal.3") }) } label: { Label("Font Size (\(Int(fontSize))pt)", systemImage: "textformat.size") } - + Button(action: { showingTerminalWidthSheet = true }, label: { Label("Terminal Width", systemImage: "arrow.left.and.right") }) - + Button(action: { viewModel.toggleFitToWidth() }, label: { Label( viewModel.fitToWidth ? "Fixed Width" : "Fit to Width", systemImage: viewModel.fitToWidth ? "arrow.left.and.right.square" : "arrow.left.and.right.square.fill" ) }) - + Button(action: { showingTerminalThemeSheet = true }, label: { Label("Theme", systemImage: "paintbrush") }) - + Button(action: { viewModel.copyBuffer() }, label: { Label("Copy All", systemImage: "square.on.square") }) - + Button(action: { exportTerminalBuffer() }, label: { Label("Export as Text", systemImage: "square.and.arrow.up") }) - + Divider() - + recordingMenuItems - + Divider() - + debugMenuItems } - + @ViewBuilder private var recordingMenuItems: some View { if viewModel.castRecorder.isRecording { @@ -351,13 +382,13 @@ struct TerminalView: View { Label("Start Recording", systemImage: "record.circle") }) } - + Button(action: { showingRecordingSheet = true }, label: { Label("Export Recording", systemImage: "square.and.arrow.up") }) .disabled(viewModel.castRecorder.events.isEmpty) } - + @ViewBuilder private var debugMenuItems: some View { Menu { @@ -366,20 +397,20 @@ struct TerminalView: View { selectedRenderer = renderer TerminalRenderer.selected = renderer viewModel.terminalViewId = UUID() // Force recreate terminal view - }) { + }, label: { HStack { Text(renderer.displayName) if renderer == selectedRenderer { Image(systemName: "checkmark") } } - } + }) } } label: { Label("Terminal Renderer", systemImage: "gearshape.2") } } - + @ViewBuilder private var terminalSizeIndicator: some View { if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 { @@ -388,16 +419,15 @@ struct TerminalView: View { .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) } } - - + private var recordingView: some View { HStack(spacing: 4) { Circle() - .fill(Color.red) + .fill(Theme.Colors.errorAccent) .frame(width: 8, height: 8) .overlay( Circle() - .fill(Color.red.opacity(0.3)) + .fill(Theme.Colors.errorAccent.opacity(0.3)) .frame(width: 16, height: 16) .scaleEffect(viewModel.recordingPulse ? 1.5 : 1.0) .animation( @@ -407,7 +437,7 @@ struct TerminalView: View { ) Text("REC") .font(.system(size: 12, weight: .bold)) - .foregroundColor(.red) + .foregroundColor(Theme.Colors.errorAccent) } .onAppear { viewModel.recordingPulse = true @@ -491,12 +521,17 @@ struct TerminalView: View { .id(viewModel.terminalViewId) .background(selectedTheme.background) .focused($isInputFocused) - .scrollToBottomOverlay( - isVisible: showScrollToBottom, - action: { - viewModel.scrollToBottom() - showScrollToBottom = false - } + .overlay( + ScrollToBottomButton( + isVisible: showScrollToBottom, + action: { + viewModel.scrollToBottom() + showScrollToBottom = false + } + ) + .padding(.bottom, Theme.Spacing.large) + .padding(.leading, Theme.Spacing.large), + alignment: .bottomLeading ) // Keyboard toolbar @@ -539,7 +574,7 @@ class TerminalViewModel { var bufferWebSocketClient: BufferWebSocketClient? private var connectionStatusTask: Task? private var connectionErrorTask: Task? - weak var terminalCoordinator: TerminalHostingView.Coordinator? + weak var terminalCoordinator: AnyObject? // Can be TerminalHostingView.Coordinator init(session: Session) { self.session = session @@ -630,18 +665,20 @@ class TerminalViewModel { // Initialize terminal with dimensions from header terminalCols = header.width terminalRows = header.height - print("Snapshot header: \(header.width)x\(header.height)") + logger.debug("Snapshot header: \(header.width)x\(header.height)") } // Feed all output events to the terminal for event in snapshot.events { if event.type == .output { // Feed the actual terminal output data - terminalCoordinator?.feedData(event.data) + if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator { + coordinator.feedData(event.data) + } } } } catch { - print("Failed to load terminal snapshot: \(error)") + logger.error("Failed to load terminal snapshot: \(error)") } } @@ -659,22 +696,22 @@ class TerminalViewModel { switch event { case .header(let width, let height): // Initial terminal setup - print("Terminal initialized: \(width)x\(height)") + logger.info("Terminal initialized: \(width)x\(height)") terminalCols = width terminalRows = height // The terminal will be resized when created case .output(_, let data): // Feed output data directly to the terminal - if let coordinator = terminalCoordinator { + if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator { coordinator.feedData(data) } else { // Queue the data to be fed once coordinator is ready - print("Warning: Terminal coordinator not ready, queueing data") + logger.warning("Terminal coordinator not ready, queueing data") Task { // Wait a bit for coordinator to be initialized try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s - if let coordinator = self.terminalCoordinator { + if let coordinator = self.terminalCoordinator as? TerminalHostingView.Coordinator { coordinator.feedData(data) } } @@ -691,7 +728,7 @@ class TerminalViewModel { // Update terminal dimensions terminalCols = cols terminalRows = rows - print("Terminal resize: \(cols)x\(rows)") + logger.info("Terminal resize: \(cols)x\(rows)") // Record resize event castRecorder.recordResize(cols: cols, rows: rows) } @@ -706,7 +743,7 @@ class TerminalViewModel { if castRecorder.isRecording { stopRecording() } - + // Load final snapshot for exited session Task { @MainActor in // Give the server a moment to finalize the snapshot @@ -716,7 +753,7 @@ class TerminalViewModel { case .bufferUpdate(let snapshot): // Update terminal buffer directly - if let coordinator = terminalCoordinator { + if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator { coordinator.updateBuffer(from: TerminalHostingView.BufferSnapshot( cols: snapshot.cols, rows: snapshot.rows, @@ -737,7 +774,7 @@ class TerminalViewModel { )) } else { // Fallback: buffer updates not available yet - print("Warning: Direct buffer update not available") + logger.warning("Direct buffer update not available") } case .bell: @@ -755,7 +792,7 @@ class TerminalViewModel { do { try await SessionService.shared.sendInput(to: session.id, text: text) } catch { - print("Failed to send input: \(error)") + logger.error("Failed to send input: \(error)") } } } @@ -767,7 +804,7 @@ class TerminalViewModel { // If resize succeeded, ensure the flag is cleared isResizeBlockedByServer = false } catch { - print("Failed to resize terminal: \(error)") + logger.error("Failed to resize terminal: \(error)") // Check if the error is specifically about resize being disabled if case APIError.resizeDisabledByServer = error { isResizeBlockedByServer = true @@ -786,10 +823,10 @@ class TerminalViewModel { // Terminal copy is handled by SwiftTerm's built-in functionality HapticFeedback.notification(.success) } - + func getBufferContent() -> String? { // Get the current terminal buffer content - if let coordinator = terminalCoordinator { + if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator { return coordinator.getBufferContent() } return nil @@ -810,7 +847,7 @@ class TerminalViewModel { @MainActor private func handleTerminalAlert(title: String?, message: String) { // Log the alert - print("[Terminal Alert] \(title ?? "Alert"): \(message)") + logger.info("Terminal Alert - \(title ?? "Alert"): \(message)") // Show as a system notification if app is in background // For now, just provide haptic feedback @@ -822,7 +859,9 @@ class TerminalViewModel { isAutoScrollEnabled = true isAtBottom = true // The actual scrolling is handled by the terminal coordinator - terminalCoordinator?.scrollToBottom() + if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator { + coordinator.scrollToBottom() + } } func updateScrollState(isAtBottom: Bool) { @@ -845,20 +884,22 @@ class TerminalViewModel { resize(cols: optimalCols, rows: terminalRows) } } - + func setMaxWidth(_ maxWidth: Int) { // Store the max width preference // When maxWidth is 0, it means unlimited let targetWidth = maxWidth == 0 ? nil : maxWidth - + if let width = targetWidth, width != terminalCols { // Maintain aspect ratio when changing width let aspectRatio = Double(terminalRows) / Double(terminalCols) let newHeight = Int(Double(width) * aspectRatio) resize(cols: width, rows: newHeight) } - + // Update the terminal coordinator if using constrained width - terminalCoordinator?.setMaxWidth(maxWidth) + if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator { + coordinator.setMaxWidth(maxWidth) + } } } diff --git a/ios/VibeTunnel/Views/Terminal/TerminalWidthSheet.swift b/ios/VibeTunnel/Views/Terminal/TerminalWidthSheet.swift index 701776dd..441b9326 100644 --- a/ios/VibeTunnel/Views/Terminal/TerminalWidthSheet.swift +++ b/ios/VibeTunnel/Views/Terminal/TerminalWidthSheet.swift @@ -7,7 +7,8 @@ import SwiftUI struct TerminalWidthSheet: View { @Binding var selectedWidth: Int? let isResizeBlockedByServer: Bool - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) + var dismiss @State private var showCustomInput = false @State private var customWidthText = "" @FocusState private var isCustomInputFocused: Bool diff --git a/ios/VibeTunnel/Views/Terminal/WidthSelectorPopover.swift b/ios/VibeTunnel/Views/Terminal/WidthSelectorPopover.swift index f2f1cb2c..96770091 100644 --- a/ios/VibeTunnel/Views/Terminal/WidthSelectorPopover.swift +++ b/ios/VibeTunnel/Views/Terminal/WidthSelectorPopover.swift @@ -6,7 +6,7 @@ struct WidthSelectorPopover: View { @Binding var isPresented: Bool @State private var customWidth: String = "" @State private var showCustomInput = false - + var body: some View { NavigationStack { List { @@ -14,20 +14,19 @@ struct WidthSelectorPopover: View { ForEach(TerminalWidth.allCases, id: \.value) { width in WidthPresetRow( width: width, - isSelected: currentWidth.value == width.value, - onSelect: { - currentWidth = width - HapticFeedback.impact(.light) - isPresented = false - } - ) + isSelected: currentWidth.value == width.value + ) { + currentWidth = width + HapticFeedback.impact(.light) + isPresented = false + } } } - + Section { Button(action: { showCustomInput = true - }) { + }, label: { HStack { Image(systemName: "square.and.pencil") .font(.system(size: 16)) @@ -38,9 +37,9 @@ struct WidthSelectorPopover: View { Spacer() } .padding(.vertical, 4) - } + }) } - + // Show recent custom widths if any let customWidths = TerminalWidthManager.shared.customWidths if !customWidths.isEmpty { @@ -51,13 +50,12 @@ struct WidthSelectorPopover: View { ForEach(customWidths, id: \.self) { width in WidthPresetRow( width: .custom(width), - isSelected: currentWidth.value == width && !currentWidth.isPreset, - onSelect: { - currentWidth = .custom(width) - HapticFeedback.impact(.light) - isPresented = false - } - ) + isSelected: currentWidth.value == width && !currentWidth.isPreset + ) { + currentWidth = .custom(width) + HapticFeedback.impact(.light) + isPresented = false + } } } } @@ -78,17 +76,16 @@ struct WidthSelectorPopover: View { .frame(width: 320, height: 400) .sheet(isPresented: $showCustomInput) { CustomWidthSheet( - customWidth: $customWidth, - onSave: { width in - if let intWidth = Int(width), intWidth >= 20 && intWidth <= 500 { - currentWidth = .custom(intWidth) - TerminalWidthManager.shared.addCustomWidth(intWidth) - HapticFeedback.notification(.success) - showCustomInput = false - isPresented = false - } + customWidth: $customWidth + ) { width in + if let intWidth = Int(width), intWidth >= 20 && intWidth <= 500 { + currentWidth = .custom(intWidth) + TerminalWidthManager.shared.addCustomWidth(intWidth) + HapticFeedback.notification(.success) + showCustomInput = false + isPresented = false } - ) + } } } } @@ -98,7 +95,7 @@ private struct WidthPresetRow: View { let width: TerminalWidth let isSelected: Bool let onSelect: () -> Void - + var body: some View { Button(action: onSelect) { HStack { @@ -108,21 +105,21 @@ private struct WidthPresetRow: View { .font(Theme.Typography.terminalSystem(size: 16)) .fontWeight(.medium) .foregroundColor(Theme.Colors.terminalForeground) - + if width.value > 0 { Text("columns") .font(.caption) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) } } - + Text(width.description) .font(.caption) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) } - + Spacer() - + if isSelected { Image(systemName: "checkmark.circle.fill") .font(.system(size: 20)) @@ -139,9 +136,10 @@ private struct WidthPresetRow: View { private struct CustomWidthSheet: View { @Binding var customWidth: String let onSave: (String) -> Void - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) + var dismiss @FocusState private var isFocused: Bool - + var body: some View { NavigationStack { VStack(spacing: Theme.Spacing.large) { @@ -150,7 +148,7 @@ private struct CustomWidthSheet: View { .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) .multilineTextAlignment(.center) .padding(.horizontal) - + HStack { TextField("Width", text: $customWidth) .font(Theme.Typography.terminalSystem(size: 24)) @@ -162,12 +160,12 @@ private struct CustomWidthSheet: View { .padding() .background(Theme.Colors.cardBackground) .cornerRadius(Theme.CornerRadius.medium) - + Text("columns") .font(.body) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) } - + Spacer() } .padding(.top, Theme.Spacing.extraLarge) @@ -180,7 +178,7 @@ private struct CustomWidthSheet: View { } .foregroundColor(Theme.Colors.primaryAccent) } - + ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { onSave(customWidth) @@ -195,4 +193,4 @@ private struct CustomWidthSheet: View { isFocused = true } } -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Views/Terminal/XtermWebView.swift b/ios/VibeTunnel/Views/Terminal/XtermWebView.swift index 78667015..bbae2c38 100644 --- a/ios/VibeTunnel/Views/Terminal/XtermWebView.swift +++ b/ios/VibeTunnel/Views/Terminal/XtermWebView.swift @@ -9,56 +9,56 @@ struct XtermWebView: UIViewRepresentable { let onInput: (String) -> Void let onResize: (Int, Int) -> Void var viewModel: TerminalViewModel - + func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() configuration.allowsInlineMediaPlayback = true configuration.userContentController = WKUserContentController() - + // Add message handlers configuration.userContentController.add(context.coordinator, name: "terminalInput") configuration.userContentController.add(context.coordinator, name: "terminalResize") configuration.userContentController.add(context.coordinator, name: "terminalReady") configuration.userContentController.add(context.coordinator, name: "terminalLog") - + let webView = WKWebView(frame: .zero, configuration: configuration) webView.isOpaque = false webView.backgroundColor = UIColor(theme.background) webView.scrollView.isScrollEnabled = false - + context.coordinator.webView = webView context.coordinator.loadTerminal() - + return webView } - + func updateUIView(_ webView: WKWebView, context: Context) { // Update font size context.coordinator.updateFontSize(fontSize) - + // Update theme context.coordinator.updateTheme(theme) } - + func makeCoordinator() -> Coordinator { Coordinator(self) } - + class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { let parent: XtermWebView weak var webView: WKWebView? private var bufferWebSocketClient: BufferWebSocketClient? private let logger = Logger(category: "XtermWebView") private var sseClient: SSEClient? - + init(_ parent: XtermWebView) { self.parent = parent super.init() } - + func loadTerminal() { - guard let webView = webView else { return } - + guard let webView else { return } + let html = """ @@ -66,14 +66,14 @@ struct XtermWebView: UIViewRepresentable {