lots of work on iOS

This commit is contained in:
Peter Steinberger 2025-06-23 14:50:39 +02:00
parent 9ee544b28b
commit 7531e6f12b
60 changed files with 4086 additions and 943 deletions

View file

@ -17,7 +17,7 @@ excluded:
- ../mac/build - ../mac/build
- ../ios/build - ../ios/build
- Package.swift - Package.swift
- *.xcodeproj - "*.xcodeproj"
# Rule configuration # Rule configuration
opt_in_rules: opt_in_rules:
@ -128,9 +128,6 @@ custom_rules:
regex: '\bprint\(' regex: '\bprint\('
message: "Use proper logging instead of print statements" message: "Use proper logging instead of print statements"
severity: warning severity: warning
excluded:
- "*/Tests/*"
- "*/UITests/*"
analyzer_rules: analyzer_rules:
- unused_import - unused_import

View file

@ -14,13 +14,15 @@ let package = Package(
) )
], ],
dependencies: [ 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: [ targets: [
.target( .target(
name: "VibeTunnelDependencies", name: "VibeTunnelDependencies",
dependencies: [ dependencies: [
.product(name: "SwiftTerm", package: "SwiftTerm") .product(name: "SwiftTerm", package: "SwiftTerm"),
.product(name: "Dynamic", package: "Dynamic")
] ]
) )
] ]

View file

@ -69,7 +69,10 @@
78868B612DFF808300B22C15 /* Exceptions for "VibeTunnel" folder in "VibeTunnel" target */ = { 78868B612DFF808300B22C15 /* Exceptions for "VibeTunnel" folder in "VibeTunnel" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Local.xcconfig,
Resources/Info.plist, Resources/Info.plist,
Shared.xcconfig,
version.xcconfig,
); );
target = 788687F02DFF4FCB00B22C15 /* VibeTunnel */; target = 788687F02DFF4FCB00B22C15 /* VibeTunnel */;
}; };
@ -219,8 +222,11 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1600; LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 1600; LastUpgradeCheck = 2600;
TargetAttributes = { TargetAttributes = {
04469FB37E8A42F9D06BF670 = {
TestTargetID = 788687F02DFF4FCB00B22C15;
};
788687F02DFF4FCB00B22C15 = { 788687F02DFF4FCB00B22C15 = {
CreatedOnToolsVersion = 16.0; CreatedOnToolsVersion = 16.0;
}; };
@ -399,6 +405,7 @@
6BD919EBC6E8FC8AE5C0AD08 /* Release */ = { 6BD919EBC6E8FC8AE5C0AD08 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ENABLE_OBJC_WEAK = NO; CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
ENABLE_TESTING_FRAMEWORKS = YES; ENABLE_TESTING_FRAMEWORKS = YES;
@ -408,6 +415,7 @@
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 6.0; SWIFT_VERSION = 6.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VibeTunnel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VibeTunnel";
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
name = Release; name = Release;
@ -492,6 +500,7 @@
AB5CCE958CDF40666B1D5C7F /* Debug */ = { AB5CCE958CDF40666B1D5C7F /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ENABLE_OBJC_WEAK = NO; CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
ENABLE_TESTING_FRAMEWORKS = YES; ENABLE_TESTING_FRAMEWORKS = YES;
@ -501,6 +510,7 @@
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 6.0; SWIFT_VERSION = 6.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VibeTunnel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VibeTunnel";
}; };
name = Debug; name = Debug;
}; };
@ -508,6 +518,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@ -561,6 +572,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
@ -570,6 +582,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@ -616,6 +629,7 @@
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;

View file

@ -6,11 +6,15 @@ import UniformTypeIdentifiers
/// Displays either the connection view or session list based on /// Displays either the connection view or session list based on
/// connection state, and handles opening cast files. /// connection state, and handles opening cast files.
struct ContentView: View { struct ContentView: View {
@Environment(ConnectionManager.self) var connectionManager @Environment(ConnectionManager.self)
var connectionManager
@State private var showingFilePicker = false @State private var showingFilePicker = false
@State private var showingCastPlayer = false @State private var showingCastPlayer = false
@State private var selectedCastFile: URL? @State private var selectedCastFile: URL?
@State private var isValidatingConnection = true @State private var isValidatingConnection = true
@State private var showingWelcome = false
@AppStorage("welcomeCompleted")
private var welcomeCompleted = false
var body: some View { var body: some View {
Group { Group {
@ -30,12 +34,20 @@ struct ContentView: View {
} else if connectionManager.isConnected, connectionManager.serverConfig != nil { } else if connectionManager.isConnected, connectionManager.serverConfig != nil {
SessionListView() SessionListView()
} else { } else {
ConnectionView() EnhancedConnectionView()
} }
} }
.animation(.default, value: connectionManager.isConnected) .animation(.default, value: connectionManager.isConnected)
.onAppear { .onAppear {
validateRestoredConnection() validateRestoredConnection()
// Show welcome on first launch
if !welcomeCompleted {
showingWelcome = true
}
}
.fullScreenCover(isPresented: $showingWelcome) {
WelcomeView()
} }
.onOpenURL { url in .onOpenURL { url in
// Handle cast file opening // Handle cast file opening

View file

@ -26,9 +26,19 @@ struct VibeTunnelApp: App {
// Initialize network monitoring // Initialize network monitoring
_ = networkMonitor _ = 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) { private func handleURL(_ url: URL) {
// Handle vibetunnel://session/{sessionId} URLs // Handle vibetunnel://session/{sessionId} URLs
guard url.scheme == "vibetunnel" else { return } guard url.scheme == "vibetunnel" else { return }
@ -112,8 +122,7 @@ class ConnectionManager {
/// Make ConnectionManager accessible globally for APIClient /// Make ConnectionManager accessible globally for APIClient
extension ConnectionManager { extension ConnectionManager {
@MainActor @MainActor static let shared = ConnectionManager()
static let shared = ConnectionManager()
} }
/// Manages app-wide navigation state. /// Manages app-wide navigation state.

View file

@ -1,16 +1,17 @@
import Foundation import Foundation
/// App configuration for VibeTunnel /// App configuration for VibeTunnel
struct AppConfig { enum AppConfig {
/// Set the logging level for the app /// Set the logging level for the app
/// Change this to control verbosity of logs /// Change this to control verbosity of logs
static func configureLogging() { static func configureLogging() {
#if DEBUG #if DEBUG
// In debug builds, you can change this to .verbose to see all logs // In debug builds, default to info level to reduce noise
Logger.globalLevel = .info // Change to .verbose for detailed logging // Change to .verbose only when debugging binary protocol issues
Logger.globalLevel = .info
#else #else
// In release builds, only show warnings and errors // In release builds, only show warnings and errors
Logger.globalLevel = .warning Logger.globalLevel = .warning
#endif #endif
} }
} }

View file

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

View file

@ -37,7 +37,16 @@ struct FileEntry: Codable, Identifiable {
/// - modTime: The modification time /// - modTime: The modification time
/// - isGitTracked: Whether the file is in a git repository /// - isGitTracked: Whether the file is in a git repository
/// - gitStatus: The git status of the file /// - 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.name = name
self.path = path self.path = path
self.isDir = isDir self.isDir = isDir

View file

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

View file

@ -7,7 +7,7 @@ import Foundation
/// and terminal dimensions. /// and terminal dimensions.
struct Session: Codable, Identifiable, Equatable, Hashable { struct Session: Codable, Identifiable, Equatable, Hashable {
let id: String 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 workingDir: String
let name: String? let name: String?
let status: SessionStatus let status: SessionStatus
@ -50,7 +50,7 @@ struct Session: Codable, Identifiable, Equatable, Hashable {
/// ///
/// Returns the custom name if not empty, otherwise the command. /// Returns the custom name if not empty, otherwise the command.
var displayName: String { var displayName: String {
if let name = name, !name.isEmpty { if let name, !name.isEmpty {
return name return name
} }
return command.joined(separator: " ") return command.joined(separator: " ")

View file

@ -8,26 +8,26 @@ enum TerminalRenderer: String, CaseIterable, Codable {
var displayName: String { var displayName: String {
switch self { switch self {
case .swiftTerm: case .swiftTerm:
return "SwiftTerm (Native)" "SwiftTerm (Native)"
case .xterm: case .xterm:
return "xterm.js (WebView)" "xterm.js (WebView)"
} }
} }
var description: String { var description: String {
switch self { switch self {
case .swiftTerm: case .swiftTerm:
return "Native Swift terminal emulator with best performance" "Native Swift terminal emulator with best performance"
case .xterm: case .xterm:
return "JavaScript-based terminal, identical to web version" "JavaScript-based terminal, identical to web version"
} }
} }
/// The currently selected renderer (persisted in UserDefaults) /// The currently selected renderer (persisted in UserDefaults)
static var selected: TerminalRenderer { static var selected: Self {
get { get {
if let rawValue = UserDefaults.standard.string(forKey: "selectedTerminalRenderer"), if let rawValue = UserDefaults.standard.string(forKey: "selectedTerminalRenderer"),
let renderer = TerminalRenderer(rawValue: rawValue) { let renderer = Self(rawValue: rawValue) {
return renderer return renderer
} }
return .swiftTerm // Default return .swiftTerm // Default

View file

@ -12,61 +12,61 @@ enum TerminalWidth: CaseIterable, Equatable {
var value: Int { var value: Int {
switch self { switch self {
case .unlimited: return 0 case .unlimited: 0
case .classic80: return 80 case .classic80: 80
case .modern100: return 100 case .modern100: 100
case .wide120: return 120 case .wide120: 120
case .mainframe132: return 132 case .mainframe132: 132
case .ultraWide160: return 160 case .ultraWide160: 160
case .custom(let width): return width case .custom(let width): width
} }
} }
var label: String { var label: String {
switch self { switch self {
case .unlimited: return "" case .unlimited: ""
case .classic80: return "80" case .classic80: "80"
case .modern100: return "100" case .modern100: "100"
case .wide120: return "120" case .wide120: "120"
case .mainframe132: return "132" case .mainframe132: "132"
case .ultraWide160: return "160" case .ultraWide160: "160"
case .custom(let width): return "\(width)" case .custom(let width): "\(width)"
} }
} }
var description: String { var description: String {
switch self { switch self {
case .unlimited: return "Unlimited" case .unlimited: "Unlimited"
case .classic80: return "Classic terminal" case .classic80: "Classic terminal"
case .modern100: return "Modern standard" case .modern100: "Modern standard"
case .wide120: return "Wide terminal" case .wide120: "Wide terminal"
case .mainframe132: return "Mainframe width" case .mainframe132: "Mainframe width"
case .ultraWide160: return "Ultra-wide" case .ultraWide160: "Ultra-wide"
case .custom: return "Custom width" case .custom: "Custom width"
} }
} }
static var allCases: [TerminalWidth] { static var allCases: [Self] {
[.unlimited, .classic80, .modern100, .wide120, .mainframe132, .ultraWide160] [.unlimited, .classic80, .modern100, .wide120, .mainframe132, .ultraWide160]
} }
static func from(value: Int) -> TerminalWidth { static func from(value: Int) -> Self {
switch value { switch value {
case 0: return .unlimited case 0: .unlimited
case 80: return .classic80 case 80: .classic80
case 100: return .modern100 case 100: .modern100
case 120: return .wide120 case 120: .wide120
case 132: return .mainframe132 case 132: .mainframe132
case 160: return .ultraWide160 case 160: .ultraWide160
default: return .custom(value) default: .custom(value)
} }
} }
/// Check if this is a standard preset width /// Check if this is a standard preset width
var isPreset: Bool { var isPreset: Bool {
switch self { switch self {
case .custom: return false case .custom: false
default: return true default: true
} }
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

View file

@ -1,6 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "AppIcon.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"

View file

@ -1,5 +1,7 @@
import Foundation import Foundation
private let logger = Logger(category: "APIClient")
/// Errors that can occur during API operations. /// Errors that can occur during API operations.
enum APIError: LocalizedError { enum APIError: LocalizedError {
case invalidURL case invalidURL
@ -120,15 +122,15 @@ class APIClient: APIClientProtocol {
// Debug logging // Debug logging
if let jsonString = String(data: data, encoding: .utf8) { if let jsonString = String(data: data, encoding: .utf8) {
print("[APIClient] getSessions response: \(jsonString)") logger.debug("getSessions response: \(jsonString)")
} }
do { do {
return try decoder.decode([Session].self, from: data) return try decoder.decode([Session].self, from: data)
} catch { } catch {
print("[APIClient] Decoding error: \(error)") logger.error("Decoding error: \(error)")
if let decodingError = error as? DecodingError { if let decodingError = error as? DecodingError {
print("[APIClient] Decoding error details: \(decodingError)") logger.error("Decoding error details: \(decodingError)")
} }
throw APIError.decodingError(error) throw APIError.decodingError(error)
} }
@ -153,12 +155,12 @@ class APIClient: APIClientProtocol {
func createSession(_ data: SessionCreateData) async throws -> String { func createSession(_ data: SessionCreateData) async throws -> String {
guard let baseURL else { guard let baseURL else {
print("[APIClient] No server configured") logger.error("No server configured")
throw APIError.noServerConfigured throw APIError.noServerConfigured
} }
let url = baseURL.appendingPathComponent("api/sessions") 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) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
@ -168,24 +170,24 @@ class APIClient: APIClientProtocol {
do { do {
request.httpBody = try encoder.encode(data) request.httpBody = try encoder.encode(data)
if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) { if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) {
print("[APIClient] Request body: \(bodyString)") logger.debug("Request body: \(bodyString)")
} }
} catch { } catch {
print("[APIClient] Failed to encode session data: \(error)") logger.error("Failed to encode session data: \(error)")
throw error throw error
} }
do { do {
let (responseData, response) = try await session.data(for: request) let (responseData, response) = try await session.data(for: request)
print("[APIClient] Response received") logger.debug("Response received")
if let httpResponse = response as? HTTPURLResponse { if let httpResponse = response as? HTTPURLResponse {
print("[APIClient] Status code: \(httpResponse.statusCode)") logger.debug("Status code: \(httpResponse.statusCode)")
print("[APIClient] Headers: \(httpResponse.allHeaderFields)") logger.debug("Headers: \(httpResponse.allHeaderFields)")
} }
if let responseString = String(data: responseData, encoding: .utf8) { 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 // Check if the response is an error
@ -199,7 +201,7 @@ class APIClient: APIClientProtocol {
if let errorResponse = try? decoder.decode(ErrorResponse.self, from: responseData) { if let errorResponse = try? decoder.decode(ErrorResponse.self, from: responseData) {
let errorMessage = errorResponse.details ?? errorResponse.error ?? "Unknown error" let errorMessage = errorResponse.details ?? errorResponse.error ?? "Unknown error"
print("[APIClient] Server error: \(errorMessage)") logger.error("Server error: \(errorMessage)")
throw APIError.serverError(httpResponse.statusCode, errorMessage) throw APIError.serverError(httpResponse.statusCode, errorMessage)
} else { } else {
// Fallback to generic error // Fallback to generic error
@ -212,12 +214,12 @@ class APIClient: APIClientProtocol {
} }
let createResponse = try decoder.decode(CreateResponse.self, from: responseData) 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 return createResponse.sessionId
} catch { } catch {
print("[APIClient] Request failed: \(error)") logger.error("Request failed: \(error)")
if let urlError = error as? URLError { 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 throw error
} }
@ -453,12 +455,12 @@ class APIClient: APIClientProtocol {
private func validateResponse(_ response: URLResponse) throws { private func validateResponse(_ response: URLResponse) throws {
guard let httpResponse = response as? HTTPURLResponse else { 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)) throw APIError.networkError(URLError(.badServerResponse))
} }
guard 200..<300 ~= httpResponse.statusCode else { 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) throw APIError.serverError(httpResponse.statusCode, nil)
} }
} }
@ -472,7 +474,12 @@ class APIClient: APIClientProtocol {
// MARK: - File System Operations // 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 { guard let baseURL else {
throw APIError.noServerConfigured throw APIError.noServerConfigured
} }
@ -503,10 +510,10 @@ class APIClient: APIClientProtocol {
// Log response for debugging // Log response for debugging
if let httpResponse = response as? HTTPURLResponse { 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 httpResponse.statusCode >= 400 {
if let errorString = String(data: data, encoding: .utf8) { if let errorString = String(data: data, encoding: .utf8) {
print("[APIClient] Error response body: \(errorString)") logger.error("Error response body: \(errorString)")
} }
} }
} }
@ -707,6 +714,7 @@ class APIClient: APIClientProtocol {
} }
// MARK: - File Preview Types // MARK: - File Preview Types
struct FilePreview: Codable { struct FilePreview: Codable {
let type: FilePreviewType let type: FilePreviewType
let content: String? let content: String?

View file

@ -46,6 +46,8 @@ enum WebSocketError: Error {
@MainActor @MainActor
@Observable @Observable
class BufferWebSocketClient: NSObject { class BufferWebSocketClient: NSObject {
static let shared = BufferWebSocketClient()
private let logger = Logger(category: "BufferWebSocket") private let logger = Logger(category: "BufferWebSocket")
/// Magic byte for binary messages /// Magic byte for binary messages
private static let bufferMagicByte: UInt8 = 0xBF private static let bufferMagicByte: UInt8 = 0xBF
@ -388,7 +390,10 @@ class BufferWebSocketClient: NSObject {
if offset < data.count { if offset < data.count {
let typeByte = data[offset] let typeByte = data[offset]
logger.verbose("Type byte: 0x\(String(format: "%02X", typeByte))") 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 break
} }
@ -526,10 +531,10 @@ class BufferWebSocketClient: NSObject {
logger.debug("RGB foreground decode failed: insufficient data") logger.debug("RGB foreground decode failed: insufficient data")
return nil return nil
} }
let r = Int(data[currentOffset]) let red = Int(data[currentOffset])
let g = Int(data[currentOffset + 1]) let green = Int(data[currentOffset + 1])
let b = Int(data[currentOffset + 2]) let blue = Int(data[currentOffset + 2])
fg = (r << 16) | (g << 8) | b | 0xFF00_0000 // Add alpha for RGB fg = (red << 16) | (green << 8) | blue | 0xFF00_0000 // Add alpha for RGB
currentOffset += 3 currentOffset += 3
} else { } else {
// Palette color (1 byte) // Palette color (1 byte)
@ -550,10 +555,10 @@ class BufferWebSocketClient: NSObject {
logger.debug("RGB background decode failed: insufficient data") logger.debug("RGB background decode failed: insufficient data")
return nil return nil
} }
let r = Int(data[currentOffset]) let red = Int(data[currentOffset])
let g = Int(data[currentOffset + 1]) let green = Int(data[currentOffset + 1])
let b = Int(data[currentOffset + 2]) let blue = Int(data[currentOffset + 2])
bg = (r << 16) | (g << 8) | b | 0xFF00_0000 // Add alpha for RGB bg = (red << 16) | (green << 8) | blue | 0xFF00_0000 // Add alpha for RGB
currentOffset += 3 currentOffset += 3
} else { } else {
// Palette color (1 byte) // Palette color (1 byte)
@ -737,7 +742,11 @@ extension BufferWebSocketClient: WebSocketDelegate {
handleDisconnection() 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)") logger.info("Disconnected with code: \(closeCode)")
handleDisconnection() handleDisconnection()
} }

View file

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

View file

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

View file

@ -2,15 +2,18 @@ import Foundation
import Network import Network
import SwiftUI import SwiftUI
private let logger = Logger(category: "NetworkMonitor")
/// Monitors network connectivity and provides offline/online state /// Monitors network connectivity and provides offline/online state
@MainActor @MainActor
final class NetworkMonitor: ObservableObject { @Observable
final class NetworkMonitor {
static let shared = NetworkMonitor() static let shared = NetworkMonitor()
@Published private(set) var isConnected = true private(set) var isConnected = true
@Published private(set) var connectionType = NWInterface.InterfaceType.other private(set) var connectionType = NWInterface.InterfaceType.other
@Published private(set) var isExpensive = false private(set) var isExpensive = false
@Published private(set) var isConstrained = false private(set) var isConstrained = false
private let monitor = NWPathMonitor() private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor") private let queue = DispatchQueue(label: "NetworkMonitor")
@ -40,7 +43,7 @@ final class NetworkMonitor: ObservableObject {
// Log state changes // Log state changes
if wasConnected != self.isConnected { 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 // Post notification for other parts of the app
NotificationCenter.default.post( NotificationCenter.default.post(
@ -121,7 +124,7 @@ extension Notification.Name {
// MARK: - View Modifier for Offline Banner // MARK: - View Modifier for Offline Banner
struct OfflineBanner: ViewModifier { struct OfflineBanner: ViewModifier {
@ObservedObject private var networkMonitor = NetworkMonitor.shared @State private var networkMonitor = NetworkMonitor.shared
@State private var showBanner = false @State private var showBanner = false
func body(content: Content) -> some View { func body(content: Content) -> some View {
@ -132,17 +135,17 @@ struct OfflineBanner: ViewModifier {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
Image(systemName: "wifi.slash") Image(systemName: "wifi.slash")
.foregroundColor(.white) .foregroundColor(Theme.Colors.terminalBackground)
Text("No Internet Connection") Text("No Internet Connection")
.foregroundColor(.white) .foregroundColor(Theme.Colors.terminalBackground)
.font(.footnote.bold()) .font(.footnote.bold())
Spacer() Spacer()
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.vertical, 8)
.background(Color.red) .background(Theme.Colors.errorAccent)
.animation(.easeInOut(duration: 0.3), value: showBanner) .animation(.easeInOut(duration: 0.3), value: showBanner)
.transition(.move(edge: .top).combined(with: .opacity)) .transition(.move(edge: .top).combined(with: .opacity))
@ -178,32 +181,32 @@ extension View {
// MARK: - Connection Status View // MARK: - Connection Status View
struct ConnectionStatusView: View { struct ConnectionStatusView: View {
@ObservedObject private var networkMonitor = NetworkMonitor.shared @State private var networkMonitor = NetworkMonitor.shared
var body: some View { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Circle() Circle()
.fill(networkMonitor.isConnected ? Color.green : Color.red) .fill(networkMonitor.isConnected ? Theme.Colors.successAccent : Theme.Colors.errorAccent)
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
Text(networkMonitor.isConnected ? "Online" : "Offline") Text(networkMonitor.isConnected ? "Online" : "Offline")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(Theme.Colors.terminalGray)
if networkMonitor.isConnected { if networkMonitor.isConnected {
switch networkMonitor.connectionType { switch networkMonitor.connectionType {
case .wifi: case .wifi:
Image(systemName: "wifi") Image(systemName: "wifi")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(Theme.Colors.terminalGray)
case .cellular: case .cellular:
Image(systemName: "antenna.radiowaves.left.and.right") Image(systemName: "antenna.radiowaves.left.and.right")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(Theme.Colors.terminalGray)
case .wiredEthernet: case .wiredEthernet:
Image(systemName: "cable.connector") Image(systemName: "cable.connector")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(Theme.Colors.terminalGray)
default: default:
EmptyView() EmptyView()
} }
@ -211,14 +214,14 @@ struct ConnectionStatusView: View {
if networkMonitor.isExpensive { if networkMonitor.isExpensive {
Image(systemName: "dollarsign.circle") Image(systemName: "dollarsign.circle")
.font(.caption) .font(.caption)
.foregroundColor(.orange) .foregroundColor(Theme.Colors.warningAccent)
.help("Connection may incur charges") .help("Connection may incur charges")
} }
if networkMonitor.isConstrained { if networkMonitor.isConstrained {
Image(systemName: "tortoise") Image(systemName: "tortoise")
.font(.caption) .font(.caption)
.foregroundColor(.orange) .foregroundColor(Theme.Colors.warningAccent)
.help("Low Data Mode is enabled") .help("Low Data Mode is enabled")
} }
} }

View file

@ -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<Void, Never>?
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")
}

View file

@ -1,5 +1,7 @@
import Foundation import Foundation
private let logger = Logger(category: "SSEClient")
/// Server-Sent Events (SSE) client for real-time terminal output streaming. /// Server-Sent Events (SSE) client for real-time terminal output streaming.
/// ///
/// SSEClient handles the text-based streaming protocol used by the VibeTunnel server /// SSEClient handles the text-based streaming protocol used by the VibeTunnel server
@ -94,7 +96,7 @@ final class SSEClient: NSObject, @unchecked Sendable {
if eventData == nil { if eventData == nil {
eventData = data eventData = data
} else { } else {
eventData! += "\n" + data eventData = (eventData ?? "") + "\n" + data
} }
} }
} }
@ -122,12 +124,15 @@ final class SSEClient: NSObject, @unchecked Sendable {
else if let timestamp = array[0] as? Double, else if let timestamp = array[0] as? Double,
let type = array[1] as? String, let type = array[1] as? String,
let outputData = array[2] 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 { } catch {
print("[SSEClient] Failed to parse event data: \(error)") logger.error("Failed to parse event data: \(error)")
} }
} }
@ -137,8 +142,14 @@ final class SSEClient: NSObject, @unchecked Sendable {
} }
// MARK: - URLSessionDataDelegate // MARK: - URLSessionDataDelegate
extension SSEClient: 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 { guard let httpResponse = response as? HTTPURLResponse else {
completionHandler(.cancel) completionHandler(.cancel)
return return
@ -158,8 +169,12 @@ extension SSEClient: URLSessionDataDelegate {
} }
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error { if let error {
if (error as NSError).code != NSURLErrorCancelled { // 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)) delegate?.sseClient(self, didReceiveEvent: .error(error.localizedDescription))
} }
} }
@ -167,6 +182,7 @@ extension SSEClient: URLSessionDataDelegate {
} }
// MARK: - SSEClientDelegate // MARK: - SSEClientDelegate
protocol SSEClientDelegate: AnyObject { protocol SSEClientDelegate: AnyObject {
func sseClient(_ client: SSEClient, didReceiveEvent event: SSEClient.SSEEvent) func sseClient(_ client: SSEClient, didReceiveEvent event: SSEClient.SSEEvent)
} }

View file

@ -1,5 +1,7 @@
import Foundation import Foundation
private let logger = Logger(category: "SessionService")
/// Service layer for managing terminal sessions. /// Service layer for managing terminal sessions.
/// ///
/// SessionService provides a simplified interface for session-related operations, /// SessionService provides a simplified interface for session-related operations,
@ -19,7 +21,7 @@ class SessionService {
do { do {
return try await apiClient.createSession(data) return try await apiClient.createSession(data)
} catch { } catch {
print("[SessionService] Failed to create session: \(error)") logger.error("Failed to create session: \(error)")
throw error throw error
} }
} }

View file

@ -10,6 +10,6 @@ protocol WebSocketFactory {
@MainActor @MainActor
class DefaultWebSocketFactory: WebSocketFactory { class DefaultWebSocketFactory: WebSocketFactory {
func createWebSocket() -> WebSocketProtocol { func createWebSocket() -> WebSocketProtocol {
return URLSessionWebSocket() URLSessionWebSocket()
} }
} }

View file

@ -23,7 +23,11 @@ protocol WebSocketDelegate: AnyObject {
func webSocketDidConnect(_ webSocket: WebSocketProtocol) func webSocketDidConnect(_ webSocket: WebSocketProtocol)
func webSocket(_ webSocket: WebSocketProtocol, didReceiveMessage message: WebSocketMessage) func webSocket(_ webSocket: WebSocketProtocol, didReceiveMessage message: WebSocketMessage)
func webSocket(_ webSocket: WebSocketProtocol, didFailWithError error: Error) 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 /// Real implementation of WebSocketProtocol using URLSessionWebSocketTask
@ -84,7 +88,7 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
task.sendPing { error in task.sendPing { error in
if let error = error { if let error {
continuation.resume(throwing: error) continuation.resume(throwing: error)
} else { } else {
continuation.resume() continuation.resume()
@ -105,7 +109,7 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
guard isReceiving, let task = webSocketTask else { return } guard isReceiving, let task = webSocketTask else { return }
task.receive { [weak self] result in task.receive { [weak self] result in
guard let self = self else { return } guard let self else { return }
switch result { switch result {
case .success(let message): case .success(let message):
@ -139,11 +143,20 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
} }
extension URLSessionWebSocket: URLSessionWebSocketDelegate { 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() // 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 Task { @MainActor in
self.isReceiving = false self.isReceiving = false
self.delegate?.webSocketDidDisconnect(self, closeCode: closeCode, reason: reason) self.delegate?.webSocketDidDisconnect(self, closeCode: closeCode, reason: reason)

View file

@ -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<Error?>,
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<IdentifiableError?>) -> 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<T>(
priority: TaskPriority? = nil,
errorHandler: @escaping @Sendable (Error) -> Void,
operation: @escaping @Sendable () async throws -> T
) -> Task<T, Error> {
Task<T, Error>(priority: priority) {
do {
return try await operation()
} catch {
await MainActor.run {
errorHandler(error)
}
throw error
}
}
}
}

View file

@ -10,11 +10,11 @@ enum LogLevel: Int {
var prefix: String { var prefix: String {
switch self { switch self {
case .verbose: return "🔍" case .verbose: "🔍"
case .debug: return "🐛" case .debug: "🐛"
case .info: return "" case .info: ""
case .warning: return "⚠️" case .warning: "⚠️"
case .error: return "" case .error: ""
} }
} }
} }
@ -22,11 +22,11 @@ enum LogLevel: Int {
struct Logger { struct Logger {
private let category: String 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 #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 #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 #endif
init(category: String) { init(category: String) {
@ -54,7 +54,7 @@ struct Logger {
} }
private func log(_ message: String, level: LogLevel) { 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)") print("\(level.prefix) [\(category)] \(message)")
} }
} }

View file

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

View file

@ -15,25 +15,31 @@ enum Theme {
static let cardBackground = Color(light: Color(hex: "F8F9FA"), dark: Color(hex: "0D1117")) static let cardBackground = Color(light: Color(hex: "F8F9FA"), dark: Color(hex: "0D1117"))
static let headerBackground = Color(light: Color(hex: "FFFFFF"), dark: Color(hex: "010409")) 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")) 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")) static let terminalForeground = Color(light: Color(hex: "24292E"), dark: Color(hex: "B3B1AD"))
// Accent colors (same for both modes) // 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 secondaryAccent = Color(hex: "59C2FF")
static let successAccent = Color(hex: "AAD94C") static let successAccent = Color(hex: "AAD94C")
static let warningAccent = Color(hex: "FFB454") static let warningAccent = Color(hex: "FFB454")
static let errorAccent = Color(hex: "F07178") static let errorAccent = Color(hex: "F07178")
// Selection colors /// Selection colors
static let terminalSelection = Color(light: Color(hex: "E1E4E8"), dark: Color(hex: "273747")) 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)) 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 // Additional UI colors for FileBrowser
static let terminalAccent = primaryAccent static let terminalAccent = primaryAccent
static let terminalGray = Color(light: Color(hex: "586069"), dark: Color(hex: "8B949E")) 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 ansiBrightMagenta = Color(light: Color(hex: "5A32A3"), dark: Color(hex: "FFEE99"))
static let ansiBrightCyan = Color(light: Color(hex: "0598BC"), dark: Color(hex: "95E6CB")) static let ansiBrightCyan = Color(light: Color(hex: "0598BC"), dark: Color(hex: "95E6CB"))
static let ansiBrightWhite = Color(light: Color(hex: "24292E"), dark: Color(hex: "FFFFFF")) 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 // MARK: - Typography
@ -77,6 +92,18 @@ enum Theme {
static func terminalSystem(size: CGFloat) -> Font { static func terminalSystem(size: CGFloat) -> Font {
Font.system(size: size, design: .monospaced) 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 // MARK: - Spacing
@ -88,6 +115,7 @@ enum Theme {
static let medium: CGFloat = 12 static let medium: CGFloat = 12
static let large: CGFloat = 16 static let large: CGFloat = 16
static let extraLarge: CGFloat = 24 static let extraLarge: CGFloat = 24
static let xlarge: CGFloat = 24 // Alias for extraLarge
static let extraExtraLarge: CGFloat = 32 static let extraExtraLarge: CGFloat = 32
} }
@ -101,6 +129,13 @@ enum Theme {
static let card: CGFloat = 12 static let card: CGFloat = 12
} }
// MARK: - Layout
/// Layout constants
enum Layout {
static let cornerRadius: CGFloat = 10
}
// MARK: - Animation // MARK: - Animation
/// Animation presets. /// Animation presets.
@ -160,9 +195,9 @@ extension Color {
self.init(UIColor { traitCollection in self.init(UIColor { traitCollection in
switch traitCollection.userInterfaceStyle { switch traitCollection.userInterfaceStyle {
case .dark: case .dark:
return UIColor(dark) UIColor(dark)
default: default:
return UIColor(light) UIColor(light)
} }
}) })
} }
@ -206,13 +241,7 @@ extension View {
) )
} }
/// Interactive button style with press and hover animations // Removed: interactiveButton - use explicit scaleEffect and animation instead
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)
}
} }
// MARK: - Haptic Feedback // MARK: - Haptic Feedback
@ -268,36 +297,5 @@ struct HapticFeedback {
} }
} }
// MARK: - SwiftUI Haptic View Modifiers // Note: Call HapticFeedback methods directly instead of using view modifiers
// Example: HapticFeedback.impact(.light) or HapticFeedback.selection()
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)
}
}
}
}

View file

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

View file

@ -11,7 +11,7 @@ struct LoadingView: View {
@State private var isAnimating = false @State private var isAnimating = false
@State private var spinnerFrame = 0 @State private var spinnerFrame = 0
// Unicode spinner frames matching web UI /// Unicode spinner frames matching web UI
private let spinnerFrames = ["", "", "", "", "", "", "", "", "", ""] private let spinnerFrames = ["", "", "", "", "", "", "", "", "", ""]
init(message: String, useUnicodeSpinner: Bool = false) { init(message: String, useUnicodeSpinner: Bool = false) {

View file

@ -8,7 +8,7 @@ import SwiftUI
struct ConnectionView: View { struct ConnectionView: View {
@Environment(ConnectionManager.self) @Environment(ConnectionManager.self)
var connectionManager var connectionManager
@ObservedObject private var networkMonitor = NetworkMonitor.shared @State private var networkMonitor = NetworkMonitor.shared
@State private var viewModel = ConnectionViewModel() @State private var viewModel = ConnectionViewModel()
@State private var logoScale: CGFloat = 0.8 @State private var logoScale: CGFloat = 0.8
@State private var contentOpacity: Double = 0 @State private var contentOpacity: Double = 0

View file

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

View file

@ -12,7 +12,7 @@ struct ServerConfigForm: View {
let isConnecting: Bool let isConnecting: Bool
let errorMessage: String? let errorMessage: String?
let onConnect: () -> Void let onConnect: () -> Void
@ObservedObject private var networkMonitor = NetworkMonitor.shared @State private var networkMonitor = NetworkMonitor.shared
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
@State private var recentServers: [ServerConfig] = [] @State private var recentServers: [ServerConfig] = []

View file

@ -4,10 +4,11 @@ import WebKit
/// View for previewing files with syntax highlighting /// View for previewing files with syntax highlighting
struct FilePreviewView: View { struct FilePreviewView: View {
let path: String let path: String
@Environment(\.dismiss) var dismiss @Environment(\.dismiss)
var dismiss
@State private var preview: FilePreview? @State private var preview: FilePreview?
@State private var isLoading = true @State private var isLoading = true
@State private var error: String? @State private var presentedError: IdentifiableError?
@State private var showingDiff = false @State private var showingDiff = false
@State private var gitDiff: FileDiff? @State private var gitDiff: FileDiff?
@ -20,15 +21,12 @@ struct FilePreviewView: View {
if isLoading { if isLoading {
ProgressView("Loading...") ProgressView("Loading...")
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent)) .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
} else if let error = error { } else if presentedError != nil {
VStack { ContentUnavailableView {
Text("Error loading file") Label("Failed to Load File", systemImage: "exclamationmark.triangle")
.font(.headline) } description: {
.foregroundColor(Theme.Colors.errorAccent) Text("The file could not be loaded. Please try again.")
Text(error) } actions: {
.font(.subheadline)
.foregroundColor(Theme.Colors.terminalForeground)
.multilineTextAlignment(.center)
Button("Retry") { Button("Retry") {
Task { Task {
await loadPreview() await loadPreview()
@ -36,7 +34,7 @@ struct FilePreviewView: View {
} }
.terminalButton() .terminalButton()
} }
} else if let preview = preview { } else if let preview {
previewContent(for: preview) previewContent(for: preview)
} }
} }
@ -50,7 +48,7 @@ struct FilePreviewView: View {
.foregroundColor(Theme.Colors.primaryAccent) .foregroundColor(Theme.Colors.primaryAccent)
} }
if let preview = preview, preview.type == .text { if let preview, preview.type == .text {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Diff") { Button("Diff") {
showingDiff = true showingDiff = true
@ -74,6 +72,7 @@ struct FilePreviewView: View {
} }
} }
} }
.errorAlert(item: $presentedError)
} }
@ViewBuilder @ViewBuilder
@ -116,13 +115,13 @@ struct FilePreviewView: View {
private func loadPreview() async { private func loadPreview() async {
isLoading = true isLoading = true
error = nil presentedError = nil
do { do {
preview = try await APIClient.shared.previewFile(path: path) preview = try await APIClient.shared.previewFile(path: path)
isLoading = false isLoading = false
} catch { } catch {
self.error = error.localizedDescription presentedError = IdentifiableError(error: error)
isLoading = false isLoading = false
} }
} }
@ -218,7 +217,8 @@ struct SyntaxHighlightedView: UIViewRepresentable {
/// View for displaying git diffs /// View for displaying git diffs
struct GitDiffView: View { struct GitDiffView: View {
let diff: FileDiff let diff: FileDiff
@Environment(\.dismiss) var dismiss @Environment(\.dismiss)
var dismiss
var body: some View { var body: some View {
NavigationStack { NavigationStack {

View file

@ -28,10 +28,15 @@ struct FileBrowserView: View {
enum FileBrowserMode { enum FileBrowserMode {
case selectDirectory case selectDirectory
case browseFiles 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.initialPath = initialPath
self.mode = mode self.mode = mode
self.onSelect = onSelect self.onSelect = onSelect
@ -103,12 +108,16 @@ struct FileBrowserView: View {
Text(viewModel.gitFilter == .changed ? "Git Changes" : "All Files") Text(viewModel.gitFilter == .changed ? "Git Changes" : "All Files")
.font(.custom("SF Mono", size: 12)) .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(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background( .background(
RoundedRectangle(cornerRadius: 6) 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()) .buttonStyle(TerminalButtonStyle())
@ -130,7 +139,9 @@ struct FileBrowserView: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background( .background(
RoundedRectangle(cornerRadius: 6) 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()) .buttonStyle(TerminalButtonStyle())
@ -146,7 +157,7 @@ struct FileBrowserView: View {
NavigationStack { NavigationStack {
ZStack { ZStack {
// Background // Background
Color.black.ignoresSafeArea() Theme.Colors.terminalBackground.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
navigationHeader navigationHeader
@ -210,7 +221,7 @@ struct FileBrowserView: View {
.foregroundColor(Theme.Colors.terminalGray) .foregroundColor(Theme.Colors.terminalGray)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .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: { }, label: {
Text("select") Text("select")
.font(.custom("SF Mono", size: 14)) .font(.custom("SF Mono", size: 14))
.foregroundColor(.black) .foregroundColor(Theme.Colors.terminalBackground)
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 10) .padding(.vertical, 10)
.background( .background(
@ -514,29 +525,29 @@ struct FileBrowserRow: View {
switch ext { switch ext {
case "js", "jsx", "mjs", "cjs": case "js", "jsx", "mjs", "cjs":
return .yellow return Theme.Colors.fileTypeJS
case "ts", "tsx": case "ts", "tsx":
return Color(red: 0.0, green: 0.48, blue: 0.78) // TypeScript blue return Theme.Colors.fileTypeTS
case "json": case "json":
return .orange return Theme.Colors.fileTypeJSON
case "html", "htm": case "html", "htm":
return .orange return Theme.Colors.fileTypeJSON
case "css", "scss", "sass", "less": 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": case "md", "markdown":
return .gray return Theme.Colors.terminalGray
case "png", "jpg", "jpeg", "gif", "svg", "ico", "webp": case "png", "jpg", "jpeg", "gif", "svg", "ico", "webp":
return .green return Theme.Colors.fileTypeImage
case "swift": case "swift":
return .orange return Theme.Colors.fileTypeJSON
case "py": case "py":
return Color(red: 0.22, green: 0.49, blue: 0.72) // Python blue return Theme.Colors.fileTypePython
case "go": case "go":
return Color(red: 0.0, green: 0.68, blue: 0.85) // Go cyan return Theme.Colors.fileTypeGo
case "rs": case "rs":
return .orange return Theme.Colors.fileTypeJSON
case "sh", "bash", "zsh", "fish": case "sh", "bash", "zsh", "fish":
return .green return Theme.Colors.fileTypeImage
default: default:
return Theme.Colors.terminalGray.opacity(0.6) return Theme.Colors.terminalGray.opacity(0.6)
} }
@ -563,7 +574,7 @@ struct FileBrowserRow: View {
Spacer() Spacer()
// Git status indicator // Git status indicator
if let gitStatus = gitStatus, gitStatus != .unchanged { if let gitStatus, gitStatus != .unchanged {
GitStatusBadge(status: gitStatus) GitStatusBadge(status: gitStatus)
.padding(.trailing, 8) .padding(.trailing, 8)
} }
@ -780,21 +791,21 @@ struct GitStatusBadge: View {
var label: String { var label: String {
switch status { switch status {
case .modified: return "M" case .modified: "M"
case .added: return "A" case .added: "A"
case .deleted: return "D" case .deleted: "D"
case .untracked: return "?" case .untracked: "?"
case .unchanged: return "" case .unchanged: ""
} }
} }
var color: Color { var color: Color {
switch status { switch status {
case .modified: return .yellow case .modified: .yellow
case .added: return .green case .added: .green
case .deleted: return .red case .deleted: .red
case .untracked: return .gray case .untracked: .gray
case .unchanged: return .clear case .unchanged: .clear
} }
} }

View file

@ -3,7 +3,8 @@ import SwiftUI
/// File editor view for creating and editing text files. /// File editor view for creating and editing text files.
struct FileEditorView: View { struct FileEditorView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss)
private var dismiss
@State private var viewModel: FileEditorViewModel @State private var viewModel: FileEditorViewModel
@State private var showingSaveAlert = false @State private var showingSaveAlert = false
@State private var showingDiscardAlert = false @State private var showingDiscardAlert = false
@ -110,13 +111,13 @@ struct FileEditorView: View {
} }
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.onAppear { .onAppear {
if !viewModel.isNewFile {
Task {
await viewModel.loadFile()
}
}
isTextEditorFocused = true isTextEditorFocused = true
} }
.task {
if !viewModel.isNewFile {
await viewModel.loadFile()
}
}
} }
} }

View file

@ -43,7 +43,8 @@ struct QuickLookWrapper: UIViewControllerRepresentable {
} }
@MainActor @MainActor
@objc func dismiss() { @objc
func dismiss() {
quickLookManager.isPresenting = false quickLookManager.isPresenting = false
} }
} }

View file

@ -19,6 +19,8 @@ struct SessionCardView: View {
@State private var rotation: Double = 0 @State private var rotation: Double = 0
@State private var brightness: Double = 1.0 @State private var brightness: Double = 1.0
@Environment(\.livePreviewSubscription) private var livePreview
private var displayWorkingDir: String { private var displayWorkingDir: String {
// Convert absolute paths back to ~ notation for display // Convert absolute paths back to ~ notation for display
let homePrefix = "/Users/" let homePrefix = "/Users/"
@ -71,95 +73,23 @@ struct SessionCardView: View {
.fill(Theme.Colors.terminalBackground) .fill(Theme.Colors.terminalBackground)
.frame(height: 120) .frame(height: 120)
.overlay( .overlay(
VStack(alignment: .leading, spacing: Theme.Spacing.small) { Group {
if session.isRunning { if session.isRunning {
if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty { // Show live preview if available
// Show terminal output preview if let bufferSnapshot = livePreview?.latestSnapshot {
ScrollView(.vertical, showsIndicators: false) { CompactTerminalPreview(snapshot: bufferSnapshot)
VStack(alignment: .leading, spacing: 4) { .animation(.easeInOut(duration: 0.2), value: bufferSnapshot.cursorY)
// ESC indicator if present } else if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty {
if snapshot.cleanOutputPreview.lowercased().contains("esc to interrupt") { // Show static snapshot as fallback
HStack(spacing: 4) { staticSnapshotView(snapshot)
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)
} else { } else {
// Show command and working directory info as fallback // Show command and working directory info as fallback
VStack(alignment: .leading, spacing: 4) { commandInfoView
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()
} }
} else { } else {
// For exited sessions, show last output if available
if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty { if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty {
// Show last output for exited sessions exitedSessionView(snapshot)
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)
} else { } else {
Text("Session exited") Text("Session exited")
.font(Theme.Typography.terminalSystem(size: 12)) .font(Theme.Typography.terminalSystem(size: 12))
@ -184,6 +114,25 @@ struct SessionCardView: View {
.foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors .foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors
.terminalForeground.opacity(0.5) .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() Spacer()
@ -307,4 +256,93 @@ struct SessionCardView: View {
opacity = 1.0 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)
}
} }

View file

@ -1,5 +1,7 @@
import SwiftUI import SwiftUI
private let logger = Logger(category: "SessionCreate")
/// Custom text field style for terminal-like appearance. /// Custom text field style for terminal-like appearance.
/// ///
/// Applies terminal-themed styling to text fields including /// Applies terminal-themed styling to text fields including
@ -34,11 +36,12 @@ struct SessionCreateView: View {
@State private var workingDirectory = "~/" @State private var workingDirectory = "~/"
@State private var sessionName = "" @State private var sessionName = ""
@State private var isCreating = false @State private var isCreating = false
@State private var errorMessage: String? @State private var presentedError: IdentifiableError?
@State private var showFileBrowser = false @State private var showFileBrowser = false
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass)
private var horizontalSizeClass
enum Field { enum Field {
case command case command
@ -111,21 +114,12 @@ struct SessionCreateView: View {
} }
// Error Message // Error Message
if let error = errorMessage { if presentedError != nil {
HStack(spacing: Theme.Spacing.small) { ErrorBanner(
Image(systemName: "exclamationmark.triangle.fill") message: presentedError?.error.localizedDescription ?? "An error occurred",
.font(.system(size: 14)) onDismiss: {
Text(error) presentedError = nil
.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))
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small) RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
@ -321,6 +315,7 @@ struct SessionCreateView: View {
HapticFeedback.notification(.success) HapticFeedback.notification(.success)
} }
} }
.errorAlert(item: $presentedError)
} }
private struct QuickStartItem { private struct QuickStartItem {
@ -386,7 +381,7 @@ struct SessionCreateView: View {
private func createSession() { private func createSession() {
isCreating = true isCreating = true
errorMessage = nil presentedError = nil
// Save preferences matching web localStorage keys // Save preferences matching web localStorage keys
UserDefaults.standard.set(command, forKey: "vibetunnel_last_command") UserDefaults.standard.set(command, forKey: "vibetunnel_last_command")
@ -401,30 +396,29 @@ struct SessionCreateView: View {
) )
// Log the request for debugging // Log the request for debugging
print("[SessionCreate] Creating session with data:") logger.info("Creating session with data:")
print(" Command: \(sessionData.command)") logger.debug(" Command: \(sessionData.command)")
print(" Working Dir: \(sessionData.workingDir)") logger.debug(" Working Dir: \(sessionData.workingDir)")
print(" Name: \(sessionData.name ?? "nil")") logger.debug(" Name: \(sessionData.name ?? "nil")")
print(" Spawn Terminal: \(sessionData.spawnTerminal ?? false)") logger.debug(" Spawn Terminal: \(sessionData.spawnTerminal ?? false)")
print(" Cols: \(sessionData.cols ?? 0), Rows: \(sessionData.rows ?? 0)") logger.debug(" Cols: \(sessionData.cols ?? 0), Rows: \(sessionData.rows ?? 0)")
let sessionId = try await SessionService.shared.createSession(sessionData) 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 { await MainActor.run {
onCreated(sessionId) onCreated(sessionId)
isPresented = false isPresented = false
} }
} catch { } catch {
print("[SessionCreate] Failed to create session:") logger.error("Failed to create session: \(error)")
print(" Error: \(error)")
if let apiError = error as? APIError { if let apiError = error as? APIError {
print(" API Error: \(apiError)") logger.error(" API Error: \(apiError)")
} }
await MainActor.run { await MainActor.run {
errorMessage = error.localizedDescription presentedError = IdentifiableError(error: error)
isCreating = false isCreating = false
} }
} }

View file

@ -7,9 +7,11 @@ import UniformTypeIdentifiers
/// Shows active and exited sessions with options to create new sessions, /// Shows active and exited sessions with options to create new sessions,
/// manage existing ones, and navigate to terminal views. /// manage existing ones, and navigate to terminal views.
struct SessionListView: View { struct SessionListView: View {
@Environment(ConnectionManager.self) var connectionManager @Environment(ConnectionManager.self)
@Environment(NavigationManager.self) var navigationManager var connectionManager
@ObservedObject private var networkMonitor = NetworkMonitor.shared @Environment(NavigationManager.self)
var navigationManager
@State private var networkMonitor = NetworkMonitor.shared
@State private var viewModel = SessionListViewModel() @State private var viewModel = SessionListViewModel()
@State private var showingCreateSession = false @State private var showingCreateSession = false
@State private var selectedSession: Session? @State private var selectedSession: Session?
@ -19,6 +21,8 @@ struct SessionListView: View {
@State private var searchText = "" @State private var searchText = ""
@State private var showingCastImporter = false @State private var showingCastImporter = false
@State private var importedCastFile: CastFileItem? @State private var importedCastFile: CastFileItem?
@State private var presentedError: IdentifiableError?
@AppStorage("enableLivePreviews") private var enableLivePreviews = true
var filteredSessions: [Session] { var filteredSessions: [Session] {
let sessions = viewModel.sessions.filter { showExitedSessions || $0.isRunning } let sessions = viewModel.sessions.filter { showExitedSessions || $0.isRunning }
@ -101,16 +105,16 @@ struct SessionListView: View {
Button(action: { Button(action: {
HapticFeedback.impact(.light) HapticFeedback.impact(.light)
showingSettings = true showingSettings = true
}) { }, label: {
Label("Settings", systemImage: "gearshape") Label("Settings", systemImage: "gearshape")
} })
Button(action: { Button(action: {
HapticFeedback.impact(.light) HapticFeedback.impact(.light)
showingCastImporter = true showingCastImporter = true
}) { }, label: {
Label("Import Recording", systemImage: "square.and.arrow.down") Label("Import Recording", systemImage: "square.and.arrow.down")
} })
} label: { } label: {
Image(systemName: "ellipsis.circle") Image(systemName: "ellipsis.circle")
.font(.title3) .font(.title3)
@ -170,21 +174,27 @@ struct SessionListView: View {
importedCastFile = CastFileItem(url: url) importedCastFile = CastFileItem(url: url)
} }
case .failure(let error): case .failure(let error):
print("Failed to import cast file: \(error)") logger.error("Failed to import cast file: \(error)")
} }
} }
.sheet(item: $importedCastFile) { item in .sheet(item: $importedCastFile) { item in
CastPlayerView(castFileURL: item.url) CastPlayerView(castFileURL: item.url)
} }
.errorAlert(item: $presentedError)
.refreshable { .refreshable {
await viewModel.loadSessions() await viewModel.loadSessions()
} }
.searchable(text: $searchText, prompt: "Search sessions") .searchable(text: $searchText, prompt: "Search sessions")
.onAppear { .task {
viewModel.startAutoRefresh() await viewModel.loadSessions()
}
.onDisappear { // Refresh every 3 seconds
viewModel.stopAutoRefresh() 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 .onChange(of: navigationManager.shouldNavigateToSession) { _, shouldNavigate in
@ -195,6 +205,12 @@ struct SessionListView: View {
navigationManager.clearNavigation() 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 { private var emptyStateView: some View {
@ -256,10 +272,10 @@ struct SessionListView: View {
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
} }
Button(action: { searchText = "" }) { Button(action: { searchText = "" }, label: {
Label("Clear Search", systemImage: "xmark.circle.fill") Label("Clear Search", systemImage: "xmark.circle.fill")
.font(Theme.Typography.terminalSystem(size: 14)) .font(Theme.Typography.terminalSystem(size: 14))
} })
.terminalButton() .terminalButton()
} }
.padding() .padding()
@ -295,7 +311,6 @@ struct SessionListView: View {
GridItem(.flexible(), spacing: Theme.Spacing.medium), GridItem(.flexible(), spacing: Theme.Spacing.medium),
GridItem(.flexible(), spacing: Theme.Spacing.medium) GridItem(.flexible(), spacing: Theme.Spacing.medium)
], spacing: Theme.Spacing.medium) { ], spacing: Theme.Spacing.medium) {
ForEach(filteredSessions) { session in ForEach(filteredSessions) { session in
SessionCardView(session: session) { SessionCardView(session: session) {
HapticFeedback.selection() HapticFeedback.selection()
@ -313,6 +328,7 @@ struct SessionListView: View {
await viewModel.cleanupSession(session.id) await viewModel.cleanupSession(session.id)
} }
} }
.livePreview(for: session.id, enabled: session.isRunning && enableLivePreviews)
.transition(.asymmetric( .transition(.asymmetric(
insertion: .scale(scale: 0.8).combined(with: .opacity), insertion: .scale(scale: 0.8).combined(with: .opacity),
removal: .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. /// View model for managing session list state and operations.
@MainActor @MainActor
@ -406,29 +397,8 @@ class SessionListViewModel {
var isLoading = false var isLoading = false
var errorMessage: String? var errorMessage: String?
private var refreshTask: Task<Void, Never>?
private let sessionService = SessionService.shared 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 { func loadSessions() async {
if sessions.isEmpty { if sessions.isEmpty {
isLoading = true isLoading = true
@ -493,8 +463,8 @@ struct SessionHeaderView: View {
let onKillAll: () -> Void let onKillAll: () -> Void
let onCleanupAll: () -> Void let onCleanupAll: () -> Void
private var runningCount: Int { sessions.count(where: { $0.isRunning }) } private var runningCount: Int { sessions.count { $0.isRunning }}
private var exitedCount: Int { sessions.count(where: { !$0.isRunning }) } private var exitedCount: Int { sessions.count { !$0.isRunning }}
var body: some View { var body: some View {
VStack(spacing: Theme.Spacing.medium) { VStack(spacing: Theme.Spacing.medium) {
@ -683,3 +653,7 @@ struct CastFileItem: Identifiable {
let id = UUID() let id = UUID()
let url: URL let url: URL
} }
// MARK: - Logging
private let logger = Logger(category: "SessionListView")

View file

@ -9,11 +9,13 @@ struct SettingsView: View {
enum SettingsTab: String, CaseIterable { enum SettingsTab: String, CaseIterable {
case general = "General" case general = "General"
case advanced = "Advanced" case advanced = "Advanced"
case about = "About"
var icon: String { var icon: String {
switch self { switch self {
case .general: "gear" case .general: "gear"
case .advanced: "gearshape.2" case .advanced: "gearshape.2"
case .about: "info.circle"
} }
} }
} }
@ -60,6 +62,8 @@ struct SettingsView: View {
GeneralSettingsView() GeneralSettingsView()
case .advanced: case .advanced:
AdvancedSettingsView() AdvancedSettingsView()
case .about:
AboutSettingsView()
} }
} }
.padding() .padding()
@ -91,6 +95,8 @@ struct GeneralSettingsView: View {
private var autoScrollEnabled = true private var autoScrollEnabled = true
@AppStorage("enableURLDetection") @AppStorage("enableURLDetection")
private var enableURLDetection = true private var enableURLDetection = true
@AppStorage("enableLivePreviews")
private var enableLivePreviews = true
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.large) { VStack(alignment: .leading, spacing: Theme.Spacing.large) {
@ -166,6 +172,26 @@ struct GeneralSettingsView: View {
.padding() .padding()
.background(Theme.Colors.cardBackground) .background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.card) .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)
} }
} }
@ -182,6 +208,16 @@ struct AdvancedSettingsView: View {
private var debugModeEnabled = false private var debugModeEnabled = false
@State private var showingSystemLogs = 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 { var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.large) { VStack(alignment: .leading, spacing: Theme.Spacing.large) {
// Logging Section // Logging Section
@ -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 // Developer Section
VStack(alignment: .leading, spacing: Theme.Spacing.medium) { VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
Text("Developer") 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 { #Preview {
SettingsView() SettingsView()
} }

View file

@ -2,10 +2,11 @@ import SwiftUI
/// System logs viewer with filtering and search capabilities /// System logs viewer with filtering and search capabilities
struct SystemLogsView: View { struct SystemLogsView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss)
var dismiss
@State private var logs = "" @State private var logs = ""
@State private var isLoading = true @State private var isLoading = true
@State private var error: String? @State private var presentedError: IdentifiableError?
@State private var searchText = "" @State private var searchText = ""
@State private var selectedLevel: LogLevel = .all @State private var selectedLevel: LogLevel = .all
@State private var showClientLogs = true @State private var showClientLogs = true
@ -27,19 +28,19 @@ struct SystemLogsView: View {
func matches(_ line: String) -> Bool { func matches(_ line: String) -> Bool {
switch self { switch self {
case .all: case .all:
return true true
case .error: case .error:
return line.localizedCaseInsensitiveContains("[ERROR]") || line.localizedCaseInsensitiveContains("[ERROR]") ||
line.localizedCaseInsensitiveContains("error:") line.localizedCaseInsensitiveContains("error:")
case .warn: case .warn:
return line.localizedCaseInsensitiveContains("[WARN]") || line.localizedCaseInsensitiveContains("[WARN]") ||
line.localizedCaseInsensitiveContains("warning:") line.localizedCaseInsensitiveContains("warning:")
case .log: case .log:
return line.localizedCaseInsensitiveContains("[LOG]") || line.localizedCaseInsensitiveContains("[LOG]") ||
line.localizedCaseInsensitiveContains("log:") line.localizedCaseInsensitiveContains("log:")
case .debug: case .debug:
return line.localizedCaseInsensitiveContains("[DEBUG]") || line.localizedCaseInsensitiveContains("[DEBUG]") ||
line.localizedCaseInsensitiveContains("debug:") line.localizedCaseInsensitiveContains("debug:")
} }
} }
} }
@ -57,7 +58,7 @@ struct SystemLogsView: View {
// Filter by source // Filter by source
let isClientLog = line.contains("[Client]") || line.contains("client:") 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 { if !showClientLogs && isClientLog {
return false return false
@ -95,15 +96,12 @@ struct SystemLogsView: View {
ProgressView("Loading logs...") ProgressView("Loading logs...")
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent)) .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = error { } else if presentedError != nil {
VStack { ContentUnavailableView {
Text("Error loading logs") Label("Failed to Load Logs", systemImage: "exclamationmark.triangle")
.font(.headline) } description: {
.foregroundColor(Theme.Colors.errorAccent) Text("The logs could not be loaded. Please try again.")
Text(error) } actions: {
.font(.subheadline)
.foregroundColor(Theme.Colors.terminalForeground)
.multilineTextAlignment(.center)
Button("Retry") { Button("Retry") {
Task { Task {
await loadLogs() await loadLogs()
@ -133,9 +131,9 @@ struct SystemLogsView: View {
Label("Download", systemImage: "square.and.arrow.down") Label("Download", systemImage: "square.and.arrow.down")
} }
Button(action: { showingClearConfirmation = true }) { Button(action: { showingClearConfirmation = true }, label: {
Label("Clear Logs", systemImage: "trash") Label("Clear Logs", systemImage: "trash")
} })
Toggle("Auto-scroll", isOn: $autoScroll) Toggle("Auto-scroll", isOn: $autoScroll)
@ -169,6 +167,7 @@ struct SystemLogsView: View {
} message: { } message: {
Text("Are you sure you want to clear all system logs? This action cannot be undone.") Text("Are you sure you want to clear all system logs? This action cannot be undone.")
} }
.errorAlert(item: $presentedError)
} }
private var filtersToolbar: some View { private var filtersToolbar: some View {
@ -177,14 +176,14 @@ struct SystemLogsView: View {
// Level filter // Level filter
Menu { Menu {
ForEach(LogLevel.allCases, id: \.self) { level in ForEach(LogLevel.allCases, id: \.self) { level in
Button(action: { selectedLevel = level }) { Button(action: { selectedLevel = level }, label: {
HStack { HStack {
Text(level.displayName) Text(level.displayName)
if selectedLevel == level { if selectedLevel == level {
Image(systemName: "checkmark") Image(systemName: "checkmark")
} }
} }
} })
} }
} label: { } label: {
HStack(spacing: 4) { HStack(spacing: 4) {
@ -226,10 +225,10 @@ struct SystemLogsView: View {
.disableAutocorrection(true) .disableAutocorrection(true)
if !searchText.isEmpty { if !searchText.isEmpty {
Button(action: { searchText = "" }) { Button(action: { searchText = "" }, label: {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
} })
} }
} }
.padding(.horizontal) .padding(.horizontal)
@ -261,7 +260,7 @@ struct SystemLogsView: View {
private func loadLogs() async { private func loadLogs() async {
isLoading = true isLoading = true
error = nil presentedError = nil
do { do {
// Load logs content // Load logs content
@ -272,7 +271,7 @@ struct SystemLogsView: View {
isLoading = false isLoading = false
} catch { } catch {
self.error = error.localizedDescription presentedError = IdentifiableError(error: error)
isLoading = false isLoading = false
} }
} }
@ -283,7 +282,7 @@ struct SystemLogsView: View {
logs = "" logs = ""
await loadLogs() await loadLogs()
} catch { } catch {
self.error = error.localizedDescription presentedError = IdentifiableError(error: error)
} }
} }
@ -325,7 +324,7 @@ struct SystemLogsView: View {
/// Custom toggle style for filter chips /// Custom toggle style for filter chips
struct ChipToggleStyle: ToggleStyle { struct ChipToggleStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.isOn.toggle() }) { Button(action: { configuration.isOn.toggle() }, label: {
HStack(spacing: 4) { HStack(spacing: 4) {
if configuration.isOn { if configuration.isOn {
Image(systemName: "checkmark") Image(systemName: "checkmark")
@ -339,7 +338,7 @@ struct ChipToggleStyle: ToggleStyle {
.background(configuration.isOn ? Theme.Colors.primaryAccent.opacity(0.2) : Theme.Colors.cardBackground) .background(configuration.isOn ? Theme.Colors.primaryAccent.opacity(0.2) : Theme.Colors.cardBackground)
.foregroundColor(configuration.isOn ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground) .foregroundColor(configuration.isOn ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground)
.cornerRadius(6) .cornerRadius(6)
} })
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
} }
} }

View file

@ -1,5 +1,7 @@
import SwiftUI import SwiftUI
private let logger = Logger(category: "AdvancedKeyboard")
/// Advanced keyboard view with special keys and control combinations /// Advanced keyboard view with special keys and control combinations
struct AdvancedKeyboardView: View { struct AdvancedKeyboardView: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@ -230,8 +232,9 @@ struct CtrlKeyButton: View {
var body: some View { var body: some View {
Button(action: { Button(action: {
// Calculate control character (Ctrl+A = 1, Ctrl+B = 2, etc.) // Calculate control character (Ctrl+A = 1, Ctrl+B = 2, etc.)
if let scalar = char.unicodeScalars.first { if let scalar = char.unicodeScalars.first,
let ctrlChar = Character(UnicodeScalar(scalar.value - 64)!) let ctrlScalar = UnicodeScalar(scalar.value - 64) {
let ctrlChar = Character(ctrlScalar)
onPress(String(ctrlChar)) onPress(String(ctrlChar))
} }
}) { }) {
@ -292,6 +295,6 @@ struct FunctionKeyButton: View {
#Preview { #Preview {
AdvancedKeyboardView(isPresented: .constant(true)) { input in AdvancedKeyboardView(isPresented: .constant(true)) { input in
print("Input: \(input)") logger.debug("Input: \(input)")
} }
} }

View file

@ -9,7 +9,8 @@ import UniformTypeIdentifiers
/// supporting the Asciinema cast v2 format. /// supporting the Asciinema cast v2 format.
struct CastPlayerView: View { struct CastPlayerView: View {
let castFileURL: URL let castFileURL: URL
@Environment(\.dismiss) var dismiss @Environment(\.dismiss)
var dismiss
@State private var viewModel = CastPlayerViewModel() @State private var viewModel = CastPlayerViewModel()
@State private var fontSize: CGFloat = 14 @State private var fontSize: CGFloat = 14
@State private var isPlaying = false @State private var isPlaying = false

View file

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

View file

@ -1,5 +1,7 @@
import SwiftUI import SwiftUI
private let logger = Logger(category: "FileBrowserFAB")
/// Floating action button for opening file browser /// Floating action button for opening file browser
struct FileBrowserFAB: View { struct FileBrowserFAB: View {
let isVisible: Bool let isVisible: Bool
@ -7,9 +9,11 @@ struct FileBrowserFAB: View {
var body: some View { var body: some View {
Button(action: { Button(action: {
HapticFeedback.impact(.medium) Task { @MainActor in
HapticFeedback.impact(.medium)
}
action() action()
}) { }, label: {
Image(systemName: "folder.fill") Image(systemName: "folder.fill")
.font(.system(size: 20, weight: .medium)) .font(.system(size: 20, weight: .medium))
.foregroundColor(Theme.Colors.terminalBackground) .foregroundColor(Theme.Colors.terminalBackground)
@ -23,7 +27,7 @@ struct FileBrowserFAB: View {
) )
) )
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4) .shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
} })
.opacity(isVisible ? 1 : 0) .opacity(isVisible ? 1 : 0)
.scaleEffect(isVisible ? 1 : 0.8) .scaleEffect(isVisible ? 1 : 0.8)
.animation(Theme.Animation.smooth, value: isVisible) .animation(Theme.Animation.smooth, value: isVisible)
@ -31,23 +35,14 @@ struct FileBrowserFAB: View {
} }
} }
/// Extension to add file browser FAB overlay modifier // Note: Use FileBrowserFAB directly with overlay instead of this extension
extension View { // Example:
func fileBrowserFABOverlay( // .overlay(
isVisible: Bool, // FileBrowserFAB(isVisible: showFAB, action: { })
action: @escaping () -> Void // .padding(.bottom, Theme.Spacing.extraLarge)
) -> some View { // .padding(.trailing, Theme.Spacing.large),
self.overlay( // alignment: .bottomTrailing
FileBrowserFAB( // )
isVisible: isVisible,
action: action
)
.padding(.bottom, Theme.Spacing.extraLarge)
.padding(.trailing, Theme.Spacing.large),
alignment: .bottomTrailing
)
}
}
#Preview { #Preview {
ZStack { ZStack {
@ -55,7 +50,7 @@ extension View {
.ignoresSafeArea() .ignoresSafeArea()
FileBrowserFAB(isVisible: true) { FileBrowserFAB(isVisible: true) {
print("Open file browser") logger.debug("Open file browser")
} }
} }
} }

View file

@ -6,7 +6,8 @@ import SwiftUI
/// of how the terminal text will appear. /// of how the terminal text will appear.
struct FontSizeSheet: View { struct FontSizeSheet: View {
@Binding var fontSize: CGFloat @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] let fontSizes: [CGFloat] = [10, 12, 14, 16, 18, 20, 22, 24, 28, 32]

View file

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

View file

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

View file

@ -1,6 +1,8 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
private let logger = Logger(category: "RecordingExport")
/// Sheet for exporting terminal recordings. /// Sheet for exporting terminal recordings.
/// ///
/// Provides interface for exporting recorded terminal sessions /// Provides interface for exporting recorded terminal sessions
@ -8,7 +10,8 @@ import UniformTypeIdentifiers
struct RecordingExportSheet: View { struct RecordingExportSheet: View {
var recorder: CastRecorder var recorder: CastRecorder
let sessionName: String let sessionName: String
@Environment(\.dismiss) var dismiss @Environment(\.dismiss)
var dismiss
@State private var isExporting = false @State private var isExporting = false
@State private var showingShareSheet = false @State private var showingShareSheet = false
@State private var exportedFileURL: URL? @State private var exportedFileURL: URL?
@ -126,7 +129,7 @@ struct RecordingExportSheet: View {
showingShareSheet = true showingShareSheet = true
} }
} catch { } catch {
print("Failed to save cast file: \(error)") logger.error("Failed to save cast file: \(error)")
await MainActor.run { await MainActor.run {
isExporting = false isExporting = false
} }

View file

@ -1,5 +1,7 @@
import SwiftUI import SwiftUI
private let logger = Logger(category: "ScrollToBottomButton")
/// Floating action button to scroll terminal to bottom /// Floating action button to scroll terminal to bottom
struct ScrollToBottomButton: View { struct ScrollToBottomButton: View {
let isVisible: Bool let isVisible: Bool
@ -11,7 +13,7 @@ struct ScrollToBottomButton: View {
Button(action: { Button(action: {
HapticFeedback.impact(.light) HapticFeedback.impact(.light)
action() action()
}) { }, label: {
Text("") Text("")
.font(.system(size: 24, weight: .bold)) .font(.system(size: 24, weight: .bold))
.foregroundColor(isHovered ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground) .foregroundColor(isHovered ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground)
@ -35,7 +37,7 @@ struct ScrollToBottomButton: View {
) )
.scaleEffect(isPressed ? 0.95 : 1.0) .scaleEffect(isPressed ? 0.95 : 1.0)
.offset(y: isHovered && !isPressed ? -1 : 0) .offset(y: isHovered && !isPressed ? -1 : 0)
} })
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.opacity(isVisible ? 1 : 0) .opacity(isVisible ? 1 : 0)
.scaleEffect(isVisible ? 1 : 0.8) .scaleEffect(isVisible ? 1 : 0.8)
@ -54,24 +56,14 @@ struct ScrollToBottomButton: View {
} }
} }
/// Extension to add scroll-to-bottom overlay modifier // Note: Use ScrollToBottomButton directly with overlay instead of this extension
extension View { // Example:
func scrollToBottomOverlay( // .overlay(
isVisible: Bool, // ScrollToBottomButton(isVisible: showButton, action: { })
action: @escaping () -> Void // .padding(.bottom, Theme.Spacing.large)
) // .padding(.leading, Theme.Spacing.large),
-> some View { // alignment: .bottomLeading
self.overlay( // )
ScrollToBottomButton(
isVisible: isVisible,
action: action
)
.padding(.bottom, Theme.Spacing.large)
.padding(.leading, Theme.Spacing.large),
alignment: .bottomLeading
)
}
}
#Preview { #Preview {
ZStack { ZStack {
@ -79,7 +71,7 @@ extension View {
.ignoresSafeArea() .ignoresSafeArea()
ScrollToBottomButton(isVisible: true) { ScrollToBottomButton(isVisible: true) {
print("Scroll to bottom") logger.debug("Scroll to bottom")
} }
} }
} }

View file

@ -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..<snapshot.rows, id: \.self) { row in
HStack(spacing: 0) {
ForEach(0..<min(snapshot.cols, 80), id: \.self) { col in
// Get cell at position, with bounds checking
if row < snapshot.cells.count && col < snapshot.cells[row].count {
let cell = snapshot.cells[row][col]
cellView(for: cell)
} else {
// Empty cell
Text(" ")
.font(Theme.Typography.terminalSystem(size: fontSize))
.frame(width: fontSize * 0.6)
}
}
Spacer(minLength: 0)
}
}
}
.padding(4)
.id("content")
}
.onAppear {
// Scroll to show cursor area if visible
if snapshot.cursorY >= 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..<snapshot.rows).reversed() {
guard row < snapshot.cells.count else { continue }
let line = snapshot.cells[row]
.map { $0.char.isEmpty ? " " : $0.char }
.joined()
.trimmingCharacters(in: .whitespaces)
if !line.isEmpty {
lines.insert(line, at: 0)
if lines.count >= 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
}
}

View file

@ -13,7 +13,8 @@ struct TerminalHostingView: UIViewRepresentable {
let onResize: (Int, Int) -> Void let onResize: (Int, Int) -> Void
var viewModel: TerminalViewModel var viewModel: TerminalViewModel
@State private var isAutoScrollEnabled = true @State private var isAutoScrollEnabled = true
@AppStorage("enableURLDetection") private var enableURLDetection = true @AppStorage("enableURLDetection")
private var enableURLDetection = true
func makeUIView(context: Context) -> SwiftTerm.TerminalView { func makeUIView(context: Context) -> SwiftTerm.TerminalView {
let terminal = SwiftTerm.TerminalView() let terminal = SwiftTerm.TerminalView()
@ -180,7 +181,10 @@ struct TerminalHostingView: UIViewRepresentable {
func updateBuffer(from snapshot: BufferSnapshot) { func updateBuffer(from snapshot: BufferSnapshot) {
guard let terminal else { return } 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 // Update terminal dimensions if needed
let currentCols = terminal.getTerminal().cols let currentCols = terminal.getTerminal().cols
@ -207,10 +211,14 @@ struct TerminalHostingView: UIViewRepresentable {
ansiData = convertBufferToOptimizedANSI(snapshot, clearScreen: isFirstUpdate) ansiData = convertBufferToOptimizedANSI(snapshot, clearScreen: isFirstUpdate)
isFirstUpdate = false isFirstUpdate = false
logger.verbose("Full redraw performed") logger.verbose("Full redraw performed")
} else { } else if let previous = previousSnapshot {
// Incremental update // Incremental update
ansiData = generateIncrementalUpdate(from: previousSnapshot!, to: snapshot) ansiData = generateIncrementalUpdate(from: previous, to: snapshot)
logger.verbose("Incremental update performed") 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 // Store current snapshot for next update
@ -322,10 +330,10 @@ struct TerminalHostingView: UIViewRepresentable {
if let fg = cell.fg { if let fg = cell.fg {
if fg & 0xFF00_0000 != 0 { if fg & 0xFF00_0000 != 0 {
// RGB color // RGB color
let r = (fg >> 16) & 0xFF let red = (fg >> 16) & 0xFF
let g = (fg >> 8) & 0xFF let green = (fg >> 8) & 0xFF
let b = fg & 0xFF let blue = fg & 0xFF
output += "\u{001B}[38;2;\(r);\(g);\(b)m" output += "\u{001B}[38;2;\(red);\(green);\(blue)m"
} else if fg <= 255 { } else if fg <= 255 {
// Palette color // Palette color
output += "\u{001B}[38;5;\(fg)m" output += "\u{001B}[38;5;\(fg)m"
@ -341,10 +349,10 @@ struct TerminalHostingView: UIViewRepresentable {
if let bg = cell.bg { if let bg = cell.bg {
if bg & 0xFF00_0000 != 0 { if bg & 0xFF00_0000 != 0 {
// RGB color // RGB color
let r = (bg >> 16) & 0xFF let red = (bg >> 16) & 0xFF
let g = (bg >> 8) & 0xFF let green = (bg >> 8) & 0xFF
let b = bg & 0xFF let blue = bg & 0xFF
output += "\u{001B}[48;2;\(r);\(g);\(b)m" output += "\u{001B}[48;2;\(red);\(green);\(blue)m"
} else if bg <= 255 { } else if bg <= 255 {
// Palette color // Palette color
output += "\u{001B}[48;5;\(bg)m" output += "\u{001B}[48;5;\(bg)m"
@ -552,10 +560,10 @@ struct TerminalHostingView: UIViewRepresentable {
if let color = new { if let color = new {
if color & 0xFF00_0000 != 0 { if color & 0xFF00_0000 != 0 {
// RGB color // RGB color
let r = (color >> 16) & 0xFF let red = (color >> 16) & 0xFF
let g = (color >> 8) & 0xFF let green = (color >> 8) & 0xFF
let b = color & 0xFF let blue = color & 0xFF
output += "\u{001B}[\(isBackground ? 48 : 38);2;\(r);\(g);\(b)m" output += "\u{001B}[\(isBackground ? 48 : 38);2;\(red);\(green);\(blue)m"
} else if color <= 255 { } else if color <= 255 {
// Palette color // Palette color
output += "\u{001B}[\(isBackground ? 48 : 38);5;\(color)m" output += "\u{001B}[\(isBackground ? 48 : 38);5;\(color)m"
@ -652,7 +660,7 @@ struct TerminalHostingView: UIViewRepresentable {
// When maxWidth is 0, it means unlimited // When maxWidth is 0, it means unlimited
// This could be used to constrain terminal rendering in the future // This could be used to constrain terminal rendering in the future
// For now, just log the preference // For now, just log the preference
print("[Terminal] Max width set to: \(maxWidth == 0 ? "unlimited" : "\(maxWidth) columns")") logger.info("Max width set to: \(maxWidth == 0 ? "unlimited" : "\(maxWidth) columns")")
} }
func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) { func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) {
@ -684,7 +692,7 @@ struct TerminalHostingView: UIViewRepresentable {
// If we have buffer data, we can provide additional context // If we have buffer data, we can provide additional context
if previousSnapshot != nil { if previousSnapshot != nil {
// Log selection range for debugging // Log selection range for debugging
print("[Terminal] Copied \(string.count) characters") logger.debug("Copied \(string.count) characters")
} }
} }
} }

View file

@ -3,7 +3,8 @@ import SwiftUI
/// Sheet for selecting terminal color themes. /// Sheet for selecting terminal color themes.
struct TerminalThemeSheet: View { struct TerminalThemeSheet: View {
@Binding var selectedTheme: TerminalTheme @Binding var selectedTheme: TerminalTheme
@Environment(\.dismiss) var dismiss @Environment(\.dismiss)
var dismiss
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@ -29,7 +30,7 @@ struct TerminalThemeSheet: View {
HapticFeedback.impact(.light) HapticFeedback.impact(.light)
// Save to UserDefaults // Save to UserDefaults
TerminalTheme.selected = theme TerminalTheme.selected = theme
}) { }, label: {
HStack(spacing: Theme.Spacing.medium) { HStack(spacing: Theme.Spacing.medium) {
// Color preview // Color preview
HStack(spacing: 2) { HStack(spacing: 2) {
@ -86,7 +87,7 @@ struct TerminalThemeSheet: View {
lineWidth: 1 lineWidth: 1
) )
) )
} })
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
} }
} }

View file

@ -235,8 +235,8 @@ struct ToolbarButton: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.small) RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.fill( .fill(
isActive ? Theme.Colors.primaryAccent.opacity(0.2) : isActive ? Theme.Colors.primaryAccent.opacity(0.2) :
isPressed ? Theme.Colors.primaryAccent.opacity(0.1) : isPressed ? Theme.Colors.primaryAccent.opacity(0.1) :
Theme.Colors.cardBorder.opacity(0.3) Theme.Colors.cardBorder.opacity(0.3)
) )
) )
.overlay( .overlay(

View file

@ -2,13 +2,16 @@ import Observation
import SwiftTerm import SwiftTerm
import SwiftUI import SwiftUI
private let logger = Logger(category: "TerminalView")
/// Interactive terminal view for a session. /// Interactive terminal view for a session.
/// ///
/// Displays a full terminal emulator using SwiftTerm with support for /// Displays a full terminal emulator using SwiftTerm with support for
/// input, output, recording, and font size adjustment. /// input, output, recording, and font size adjustment.
struct TerminalView: View { struct TerminalView: View {
let session: Session let session: Session
@Environment(\.dismiss) var dismiss @Environment(\.dismiss)
var dismiss
@State private var viewModel: TerminalViewModel @State private var viewModel: TerminalViewModel
@State private var fontSize: CGFloat = 14 @State private var fontSize: CGFloat = 14
@State private var showingFontSizeSheet = false @State private var showingFontSizeSheet = false
@ -26,6 +29,8 @@ struct TerminalView: View {
@State private var exportedFileURL: URL? @State private var exportedFileURL: URL?
@State private var showingWidthSelector = false @State private var showingWidthSelector = false
@State private var currentTerminalWidth: TerminalWidth = .unlimited @State private var currentTerminalWidth: TerminalWidth = .unlimited
@State private var showingFullscreenInput = false
@State private var showingCtrlKeyGrid = false
@FocusState private var isInputFocused: Bool @FocusState private var isInputFocused: Bool
init(session: Session) { init(session: Session) {
@ -80,13 +85,23 @@ struct TerminalView: View {
onSelect: { _ in onSelect: { _ in
showingFileBrowser = false showingFileBrowser = false
}, },
onInsertPath: { [weak viewModel] path, isDirectory in onInsertPath: { [weak viewModel] path, _ in
// Insert the path into the terminal // Insert the path into the terminal
viewModel?.sendInput(path) viewModel?.sendInput(path)
showingFileBrowser = false showingFileBrowser = false
} }
) )
} }
.sheet(isPresented: $showingFullscreenInput) {
FullscreenTextInput(isPresented: $showingFullscreenInput) { [weak viewModel] text in
viewModel?.sendInput(text)
}
}
.sheet(isPresented: $showingCtrlKeyGrid) {
CtrlKeyGrid(isPresented: $showingCtrlKeyGrid) { [weak viewModel] controlChar in
viewModel?.sendInput(controlChar)
}
}
.gesture( .gesture(
DragGesture() DragGesture()
.onEnded { value in .onEnded { value in
@ -96,16 +111,20 @@ struct TerminalView: View {
} }
} }
) )
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in .task {
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { for await notification in NotificationCenter.default.notifications(named: UIResponder.keyboardWillShowNotification) {
withAnimation(Theme.Animation.standard) { if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
keyboardHeight = keyboardFrame.height withAnimation(Theme.Animation.standard) {
keyboardHeight = keyboardFrame.height
}
} }
} }
} }
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in .task {
withAnimation(Theme.Animation.standard) { for await _ in NotificationCenter.default.notifications(named: UIResponder.keyboardWillHideNotification) {
keyboardHeight = 0 withAnimation(Theme.Animation.standard) {
keyboardHeight = 0
}
} }
} }
.onChange(of: selectedTerminalWidth) { _, newValue in .onChange(of: selectedTerminalWidth) { _, newValue in
@ -160,7 +179,7 @@ struct TerminalView: View {
exportedFileURL = tempURL exportedFileURL = tempURL
showingExportSheet = true showingExportSheet = true
} catch { } catch {
print("Failed to export terminal buffer: \(error)") logger.error("Failed to export terminal buffer: \(error)")
} }
} }
@ -193,6 +212,8 @@ struct TerminalView: View {
} }
ToolbarItemGroup(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
QuickFontSizeButtons(fontSize: $fontSize)
.fixedSize()
fileBrowserButton fileBrowserButton
widthSelectorButton widthSelectorButton
menuButton menuButton
@ -221,15 +242,15 @@ struct TerminalView: View {
Button(action: { Button(action: {
HapticFeedback.impact(.light) HapticFeedback.impact(.light)
showingFileBrowser = true showingFileBrowser = true
}) { }, label: {
Image(systemName: "folder") Image(systemName: "folder")
.font(.system(size: 16)) .font(.system(size: 16))
.foregroundColor(Theme.Colors.primaryAccent) .foregroundColor(Theme.Colors.primaryAccent)
} })
} }
private var widthSelectorButton: some View { private var widthSelectorButton: some View {
Button(action: { showingWidthSelector = true }) { Button(action: { showingWidthSelector = true }, label: {
HStack(spacing: 2) { HStack(spacing: 2) {
Image(systemName: "arrow.left.and.right") Image(systemName: "arrow.left.and.right")
.font(.system(size: 12)) .font(.system(size: 12))
@ -245,7 +266,7 @@ struct TerminalView: View {
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
.stroke(Theme.Colors.primaryAccent.opacity(0.3), lineWidth: 1) .stroke(Theme.Colors.primaryAccent.opacity(0.3), lineWidth: 1)
) )
} })
.foregroundColor(Theme.Colors.primaryAccent) .foregroundColor(Theme.Colors.primaryAccent)
.popover(isPresented: $showingWidthSelector, arrowEdge: .top) { .popover(isPresented: $showingWidthSelector, arrowEdge: .top) {
WidthSelectorPopover( WidthSelectorPopover(
@ -270,6 +291,16 @@ struct TerminalView: View {
Label("Clear", systemImage: "clear") Label("Clear", systemImage: "clear")
}) })
Button(action: { showingFullscreenInput = true }, label: {
Label("Compose Command", systemImage: "text.viewfinder")
})
Button(action: { showingCtrlKeyGrid = true }, label: {
Label("Ctrl Shortcuts", systemImage: "command.square")
})
Divider()
Menu { Menu {
Button(action: { Button(action: {
fontSize = max(8, fontSize - 1) fontSize = max(8, fontSize - 1)
@ -366,14 +397,14 @@ struct TerminalView: View {
selectedRenderer = renderer selectedRenderer = renderer
TerminalRenderer.selected = renderer TerminalRenderer.selected = renderer
viewModel.terminalViewId = UUID() // Force recreate terminal view viewModel.terminalViewId = UUID() // Force recreate terminal view
}) { }, label: {
HStack { HStack {
Text(renderer.displayName) Text(renderer.displayName)
if renderer == selectedRenderer { if renderer == selectedRenderer {
Image(systemName: "checkmark") Image(systemName: "checkmark")
} }
} }
} })
} }
} label: { } label: {
Label("Terminal Renderer", systemImage: "gearshape.2") Label("Terminal Renderer", systemImage: "gearshape.2")
@ -389,15 +420,14 @@ struct TerminalView: View {
} }
} }
private var recordingView: some View { private var recordingView: some View {
HStack(spacing: 4) { HStack(spacing: 4) {
Circle() Circle()
.fill(Color.red) .fill(Theme.Colors.errorAccent)
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
.overlay( .overlay(
Circle() Circle()
.fill(Color.red.opacity(0.3)) .fill(Theme.Colors.errorAccent.opacity(0.3))
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.scaleEffect(viewModel.recordingPulse ? 1.5 : 1.0) .scaleEffect(viewModel.recordingPulse ? 1.5 : 1.0)
.animation( .animation(
@ -407,7 +437,7 @@ struct TerminalView: View {
) )
Text("REC") Text("REC")
.font(.system(size: 12, weight: .bold)) .font(.system(size: 12, weight: .bold))
.foregroundColor(.red) .foregroundColor(Theme.Colors.errorAccent)
} }
.onAppear { .onAppear {
viewModel.recordingPulse = true viewModel.recordingPulse = true
@ -491,12 +521,17 @@ struct TerminalView: View {
.id(viewModel.terminalViewId) .id(viewModel.terminalViewId)
.background(selectedTheme.background) .background(selectedTheme.background)
.focused($isInputFocused) .focused($isInputFocused)
.scrollToBottomOverlay( .overlay(
isVisible: showScrollToBottom, ScrollToBottomButton(
action: { isVisible: showScrollToBottom,
viewModel.scrollToBottom() action: {
showScrollToBottom = false viewModel.scrollToBottom()
} showScrollToBottom = false
}
)
.padding(.bottom, Theme.Spacing.large)
.padding(.leading, Theme.Spacing.large),
alignment: .bottomLeading
) )
// Keyboard toolbar // Keyboard toolbar
@ -539,7 +574,7 @@ class TerminalViewModel {
var bufferWebSocketClient: BufferWebSocketClient? var bufferWebSocketClient: BufferWebSocketClient?
private var connectionStatusTask: Task<Void, Never>? private var connectionStatusTask: Task<Void, Never>?
private var connectionErrorTask: Task<Void, Never>? private var connectionErrorTask: Task<Void, Never>?
weak var terminalCoordinator: TerminalHostingView.Coordinator? weak var terminalCoordinator: AnyObject? // Can be TerminalHostingView.Coordinator
init(session: Session) { init(session: Session) {
self.session = session self.session = session
@ -630,18 +665,20 @@ class TerminalViewModel {
// Initialize terminal with dimensions from header // Initialize terminal with dimensions from header
terminalCols = header.width terminalCols = header.width
terminalRows = header.height 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 // Feed all output events to the terminal
for event in snapshot.events { for event in snapshot.events {
if event.type == .output { if event.type == .output {
// Feed the actual terminal output data // Feed the actual terminal output data
terminalCoordinator?.feedData(event.data) if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.feedData(event.data)
}
} }
} }
} catch { } catch {
print("Failed to load terminal snapshot: \(error)") logger.error("Failed to load terminal snapshot: \(error)")
} }
} }
@ -659,22 +696,22 @@ class TerminalViewModel {
switch event { switch event {
case .header(let width, let height): case .header(let width, let height):
// Initial terminal setup // Initial terminal setup
print("Terminal initialized: \(width)x\(height)") logger.info("Terminal initialized: \(width)x\(height)")
terminalCols = width terminalCols = width
terminalRows = height terminalRows = height
// The terminal will be resized when created // The terminal will be resized when created
case .output(_, let data): case .output(_, let data):
// Feed output data directly to the terminal // Feed output data directly to the terminal
if let coordinator = terminalCoordinator { if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.feedData(data) coordinator.feedData(data)
} else { } else {
// Queue the data to be fed once coordinator is ready // 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 { Task {
// Wait a bit for coordinator to be initialized // Wait a bit for coordinator to be initialized
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s 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) coordinator.feedData(data)
} }
} }
@ -691,7 +728,7 @@ class TerminalViewModel {
// Update terminal dimensions // Update terminal dimensions
terminalCols = cols terminalCols = cols
terminalRows = rows terminalRows = rows
print("Terminal resize: \(cols)x\(rows)") logger.info("Terminal resize: \(cols)x\(rows)")
// Record resize event // Record resize event
castRecorder.recordResize(cols: cols, rows: rows) castRecorder.recordResize(cols: cols, rows: rows)
} }
@ -716,7 +753,7 @@ class TerminalViewModel {
case .bufferUpdate(let snapshot): case .bufferUpdate(let snapshot):
// Update terminal buffer directly // Update terminal buffer directly
if let coordinator = terminalCoordinator { if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.updateBuffer(from: TerminalHostingView.BufferSnapshot( coordinator.updateBuffer(from: TerminalHostingView.BufferSnapshot(
cols: snapshot.cols, cols: snapshot.cols,
rows: snapshot.rows, rows: snapshot.rows,
@ -737,7 +774,7 @@ class TerminalViewModel {
)) ))
} else { } else {
// Fallback: buffer updates not available yet // Fallback: buffer updates not available yet
print("Warning: Direct buffer update not available") logger.warning("Direct buffer update not available")
} }
case .bell: case .bell:
@ -755,7 +792,7 @@ class TerminalViewModel {
do { do {
try await SessionService.shared.sendInput(to: session.id, text: text) try await SessionService.shared.sendInput(to: session.id, text: text)
} catch { } 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 // If resize succeeded, ensure the flag is cleared
isResizeBlockedByServer = false isResizeBlockedByServer = false
} catch { } catch {
print("Failed to resize terminal: \(error)") logger.error("Failed to resize terminal: \(error)")
// Check if the error is specifically about resize being disabled // Check if the error is specifically about resize being disabled
if case APIError.resizeDisabledByServer = error { if case APIError.resizeDisabledByServer = error {
isResizeBlockedByServer = true isResizeBlockedByServer = true
@ -789,7 +826,7 @@ class TerminalViewModel {
func getBufferContent() -> String? { func getBufferContent() -> String? {
// Get the current terminal buffer content // Get the current terminal buffer content
if let coordinator = terminalCoordinator { if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
return coordinator.getBufferContent() return coordinator.getBufferContent()
} }
return nil return nil
@ -810,7 +847,7 @@ class TerminalViewModel {
@MainActor @MainActor
private func handleTerminalAlert(title: String?, message: String) { private func handleTerminalAlert(title: String?, message: String) {
// Log the alert // 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 // Show as a system notification if app is in background
// For now, just provide haptic feedback // For now, just provide haptic feedback
@ -822,7 +859,9 @@ class TerminalViewModel {
isAutoScrollEnabled = true isAutoScrollEnabled = true
isAtBottom = true isAtBottom = true
// The actual scrolling is handled by the terminal coordinator // The actual scrolling is handled by the terminal coordinator
terminalCoordinator?.scrollToBottom() if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.scrollToBottom()
}
} }
func updateScrollState(isAtBottom: Bool) { func updateScrollState(isAtBottom: Bool) {
@ -859,6 +898,8 @@ class TerminalViewModel {
} }
// Update the terminal coordinator if using constrained width // Update the terminal coordinator if using constrained width
terminalCoordinator?.setMaxWidth(maxWidth) if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.setMaxWidth(maxWidth)
}
} }
} }

View file

@ -7,7 +7,8 @@ import SwiftUI
struct TerminalWidthSheet: View { struct TerminalWidthSheet: View {
@Binding var selectedWidth: Int? @Binding var selectedWidth: Int?
let isResizeBlockedByServer: Bool let isResizeBlockedByServer: Bool
@Environment(\.dismiss) var dismiss @Environment(\.dismiss)
var dismiss
@State private var showCustomInput = false @State private var showCustomInput = false
@State private var customWidthText = "" @State private var customWidthText = ""
@FocusState private var isCustomInputFocused: Bool @FocusState private var isCustomInputFocused: Bool

View file

@ -14,20 +14,19 @@ struct WidthSelectorPopover: View {
ForEach(TerminalWidth.allCases, id: \.value) { width in ForEach(TerminalWidth.allCases, id: \.value) { width in
WidthPresetRow( WidthPresetRow(
width: width, width: width,
isSelected: currentWidth.value == width.value, isSelected: currentWidth.value == width.value
onSelect: { ) {
currentWidth = width currentWidth = width
HapticFeedback.impact(.light) HapticFeedback.impact(.light)
isPresented = false isPresented = false
} }
)
} }
} }
Section { Section {
Button(action: { Button(action: {
showCustomInput = true showCustomInput = true
}) { }, label: {
HStack { HStack {
Image(systemName: "square.and.pencil") Image(systemName: "square.and.pencil")
.font(.system(size: 16)) .font(.system(size: 16))
@ -38,7 +37,7 @@ struct WidthSelectorPopover: View {
Spacer() Spacer()
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} })
} }
// Show recent custom widths if any // Show recent custom widths if any
@ -51,13 +50,12 @@ struct WidthSelectorPopover: View {
ForEach(customWidths, id: \.self) { width in ForEach(customWidths, id: \.self) { width in
WidthPresetRow( WidthPresetRow(
width: .custom(width), width: .custom(width),
isSelected: currentWidth.value == width && !currentWidth.isPreset, isSelected: currentWidth.value == width && !currentWidth.isPreset
onSelect: { ) {
currentWidth = .custom(width) currentWidth = .custom(width)
HapticFeedback.impact(.light) HapticFeedback.impact(.light)
isPresented = false isPresented = false
} }
)
} }
} }
} }
@ -78,17 +76,16 @@ struct WidthSelectorPopover: View {
.frame(width: 320, height: 400) .frame(width: 320, height: 400)
.sheet(isPresented: $showCustomInput) { .sheet(isPresented: $showCustomInput) {
CustomWidthSheet( CustomWidthSheet(
customWidth: $customWidth, customWidth: $customWidth
onSave: { width in ) { width in
if let intWidth = Int(width), intWidth >= 20 && intWidth <= 500 { if let intWidth = Int(width), intWidth >= 20 && intWidth <= 500 {
currentWidth = .custom(intWidth) currentWidth = .custom(intWidth)
TerminalWidthManager.shared.addCustomWidth(intWidth) TerminalWidthManager.shared.addCustomWidth(intWidth)
HapticFeedback.notification(.success) HapticFeedback.notification(.success)
showCustomInput = false showCustomInput = false
isPresented = false isPresented = false
}
} }
) }
} }
} }
} }
@ -139,7 +136,8 @@ private struct WidthPresetRow: View {
private struct CustomWidthSheet: View { private struct CustomWidthSheet: View {
@Binding var customWidth: String @Binding var customWidth: String
let onSave: (String) -> Void let onSave: (String) -> Void
@Environment(\.dismiss) var dismiss @Environment(\.dismiss)
var dismiss
@FocusState private var isFocused: Bool @FocusState private var isFocused: Bool
var body: some View { var body: some View {

View file

@ -57,7 +57,7 @@ struct XtermWebView: UIViewRepresentable {
} }
func loadTerminal() { func loadTerminal() {
guard let webView = webView else { return } guard let webView else { return }
let html = """ let html = """
<!DOCTYPE html> <!DOCTYPE html>
@ -238,7 +238,10 @@ struct XtermWebView: UIViewRepresentable {
webView.navigationDelegate = self webView.navigationDelegate = self
} }
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
switch message.name { switch message.name {
case "terminalInput": case "terminalInput":
if let data = message.body as? String { if let data = message.body as? String {
@ -296,7 +299,7 @@ struct XtermWebView: UIViewRepresentable {
case .output(_, let data): case .output(_, let data):
writeToTerminal(data) writeToTerminal(data)
case .resize(_, _): case .resize:
// Handle resize if needed // Handle resize if needed
break break
@ -330,7 +333,7 @@ struct XtermWebView: UIViewRepresentable {
.replacingOccurrences(of: "\r", with: "\\r") .replacingOccurrences(of: "\r", with: "\\r")
webView?.evaluateJavaScript("window.xtermAPI.writeToTerminal('\(escaped)')") { _, error in webView?.evaluateJavaScript("window.xtermAPI.writeToTerminal('\(escaped)')") { _, error in
if let error = error { if let error {
self.logger.error("Error writing to terminal: \(error)") self.logger.error("Error writing to terminal: \(error)")
} }
} }
@ -364,25 +367,26 @@ struct XtermWebView: UIViewRepresentable {
} }
// MARK: - SSEClientDelegate // MARK: - SSEClientDelegate
@MainActor @MainActor
extension XtermWebView.Coordinator: SSEClientDelegate { extension XtermWebView.Coordinator: SSEClientDelegate {
nonisolated func sseClient(_ client: SSEClient, didReceiveEvent event: SSEClient.SSEEvent) { nonisolated func sseClient(_ client: SSEClient, didReceiveEvent event: SSEClient.SSEEvent) {
Task { @MainActor in Task { @MainActor in
switch event { switch event {
case .terminalOutput(_, let type, let data): case .terminalOutput(_, let type, let data):
if type == "o" { // output if type == "o" { // output
writeToTerminal(data) writeToTerminal(data)
}
case .exit(let exitCode, _):
writeToTerminal("\r\n[Process exited with code \(exitCode)]\r\n")
case .error(let error):
logger.error("SSE error: \(error)")
} }
case .exit(let exitCode, _):
writeToTerminal("\r\n[Process exited with code \(exitCode)]\r\n")
case .error(let error):
logger.error("SSE error: \(error)")
}
} }
} }
} }
// Helper extension for Color to hex /// Helper extension for Color to hex
extension Color { extension Color {
var hex: String { var hex: String {
let uiColor = UIColor(self) let uiColor = UIColor(self)
@ -393,9 +397,11 @@ extension Color {
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return String(format: "#%02X%02X%02X", return String(
Int(red * 255), format: "#%02X%02X%02X",
Int(green * 255), Int(red * 255),
Int(blue * 255)) Int(green * 255),
Int(blue * 255)
)
} }
} }

View file

@ -0,0 +1,458 @@
import SwiftUI
/// Welcome onboarding view for first-time users.
///
/// Presents a multi-page onboarding experience that introduces VibeTunnel's features
/// on iOS. The view tracks completion state to ensure it's only shown once.
struct WelcomeView: View {
@State private var currentPage = 0
@Environment(\.dismiss)
private var dismiss
@AppStorage("welcomeCompleted")
private var welcomeCompleted = false
@AppStorage("welcomeVersion")
private var welcomeVersion = 0
@State private var selectedTheme: TerminalTheme = .vibeTunnel
private let currentWelcomeVersion = 1
var body: some View {
NavigationStack {
GeometryReader { geometry in
VStack(spacing: 0) {
// Page content
TabView(selection: $currentPage) {
WelcomePageView()
.tag(0)
ConnectServerPageView()
.tag(1)
TerminalFeaturesPageView(selectedTheme: $selectedTheme)
.tag(2)
MobileControlsPageView()
.tag(3)
GetStartedPageView()
.tag(4)
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.animation(.easeInOut(duration: 0.3), value: currentPage)
// Custom page indicator and navigation
VStack(spacing: Theme.Spacing.medium) {
// Page indicators
HStack(spacing: 8) {
ForEach(0..<5) { index in
Circle()
.fill(index == currentPage ? Theme.Colors.primaryAccent : Color.gray.opacity(0.3))
.frame(width: 8, height: 8)
.animation(.easeInOut, value: currentPage)
}
}
.padding(.top, Theme.Spacing.small)
// Navigation buttons
HStack(spacing: Theme.Spacing.medium) {
if currentPage > 0 {
Button(action: {
withAnimation {
currentPage -= 1
}
}, label: {
HStack {
Image(systemName: "chevron.left")
Text("Back")
}
.font(Theme.Typography.terminalSystem(size: 16))
})
.foregroundColor(Theme.Colors.primaryAccent)
}
Spacer()
Button(action: handleNextAction) {
Text(currentPage == 4 ? "Get Started" : "Continue")
.font(Theme.Typography.terminalSystem(size: 16, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Theme.Colors.primaryAccent)
.cornerRadius(Theme.Layout.cornerRadius)
}
}
.padding(.horizontal, Theme.Spacing.large)
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : Theme.Spacing.medium)
}
.padding(.vertical, Theme.Spacing.medium)
.background(Theme.Colors.terminalBackground.opacity(0.95))
}
}
.background(Theme.Colors.terminalBackground)
.ignoresSafeArea(.keyboard)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Skip") {
completeOnboarding()
}
.font(Theme.Typography.terminalSystem(size: 16))
.foregroundColor(Theme.Colors.primaryAccent)
}
}
}
}
private func handleNextAction() {
if currentPage < 4 {
withAnimation {
currentPage += 1
}
} else {
completeOnboarding()
}
}
private func completeOnboarding() {
// Save the selected theme
TerminalTheme.selected = selectedTheme
// Mark onboarding as completed
welcomeCompleted = true
welcomeVersion = currentWelcomeVersion
// Generate haptic feedback
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.impactOccurred()
dismiss()
}
}
// MARK: - Individual Page Views
struct WelcomePageView: View {
var body: some View {
VStack(spacing: Theme.Spacing.xlarge) {
Spacer()
// App icon with glow effect
ZStack {
// Glow background
Image("AppIcon")
.resizable()
.frame(width: 120, height: 120)
.blur(radius: 20)
.opacity(0.5)
// Main icon
Image("AppIcon")
.resizable()
.frame(width: 120, height: 120)
.cornerRadius(26)
.shadow(color: Theme.Colors.primaryAccent.opacity(0.3), radius: 10, y: 5)
}
.padding(.bottom, Theme.Spacing.medium)
VStack(spacing: Theme.Spacing.medium) {
Text("Welcome to VibeTunnel")
.font(Theme.Typography.largeTitle())
.multilineTextAlignment(.center)
Text("Access your terminal sessions from anywhere, right on your iPhone or iPad")
.font(Theme.Typography.terminalSystem(size: 17))
.foregroundColor(Theme.Colors.secondaryText)
.multilineTextAlignment(.center)
.padding(.horizontal, Theme.Spacing.xlarge)
}
Spacer()
Spacer()
}
.padding(.horizontal, Theme.Spacing.large)
}
}
struct ConnectServerPageView: View {
var body: some View {
VStack(spacing: Theme.Spacing.xlarge) {
Spacer()
// Server connection illustration
VStack(spacing: Theme.Spacing.medium) {
Image(systemName: "server.rack")
.font(.system(size: 80))
.foregroundColor(Theme.Colors.primaryAccent)
.padding(.bottom, Theme.Spacing.small)
Text("Connect to Your Server")
.font(Theme.Typography.title())
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
FeatureRow(
icon: "network",
text: "Connect via HTTP or HTTPS"
)
FeatureRow(
icon: "lock.shield",
text: "Secure authentication support"
)
FeatureRow(
icon: "clock.arrow.circlepath",
text: "Automatic reconnection"
)
}
.padding(.top, Theme.Spacing.medium)
}
Spacer()
Spacer()
}
.padding(.horizontal, Theme.Spacing.xlarge)
}
}
struct TerminalFeaturesPageView: View {
@Binding var selectedTheme: TerminalTheme
var body: some View {
VStack(spacing: Theme.Spacing.xlarge) {
Spacer()
Text("Powerful Terminal Experience")
.font(Theme.Typography.title())
.multilineTextAlignment(.center)
.padding(.horizontal, Theme.Spacing.large)
// Theme preview
VStack(spacing: Theme.Spacing.medium) {
Text("Choose Your Theme")
.font(Theme.Typography.terminalSystem(size: 16, weight: .medium))
.foregroundColor(Theme.Colors.secondaryText)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.small) {
ForEach(TerminalTheme.allThemes, id: \.id) { theme in
ThemePreviewCard(
theme: theme,
isSelected: selectedTheme.id == theme.id
) { selectedTheme = theme }
}
}
.padding(.horizontal, Theme.Spacing.large)
}
}
// Features list
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
FeatureRow(icon: "keyboard", text: "Advanced keyboard with special keys")
FeatureRow(icon: "folder", text: "Built-in file browser")
FeatureRow(icon: "doc.plaintext", text: "Session recording & export")
}
.padding(.horizontal, Theme.Spacing.xlarge)
.padding(.top, Theme.Spacing.medium)
Spacer()
}
}
}
struct MobileControlsPageView: View {
var body: some View {
VStack(spacing: Theme.Spacing.xlarge) {
Spacer()
Text("Optimized for Mobile")
.font(Theme.Typography.title())
.multilineTextAlignment(.center)
.padding(.horizontal, Theme.Spacing.large)
// Feature grid
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: Theme.Spacing.medium) {
ControlFeatureCard(
icon: "arrow.up.arrow.down",
title: "Quick Controls",
description: "Access arrow keys and special functions"
)
ControlFeatureCard(
icon: "textformat.size",
title: "Adjustable Text",
description: "Customize font size for readability"
)
ControlFeatureCard(
icon: "rectangle.expand.vertical",
title: "Terminal Width",
description: "Optimize layout for your screen"
)
ControlFeatureCard(
icon: "hand.tap",
title: "Touch Gestures",
description: "Intuitive touch-based interactions"
)
}
.padding(.horizontal, Theme.Spacing.large)
Spacer()
Spacer()
}
}
}
struct GetStartedPageView: View {
var body: some View {
VStack(spacing: Theme.Spacing.xlarge) {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 80))
.foregroundColor(Theme.Colors.success)
.padding(.bottom, Theme.Spacing.medium)
Text("You're All Set!")
.font(Theme.Typography.title())
.multilineTextAlignment(.center)
VStack(spacing: Theme.Spacing.medium) {
Text("Start by connecting to your VibeTunnel server")
.font(Theme.Typography.terminalSystem(size: 17))
.foregroundColor(Theme.Colors.secondaryText)
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
HStack {
Image(systemName: "1.circle.fill")
.foregroundColor(Theme.Colors.primaryAccent)
Text("Enter your server URL")
.font(Theme.Typography.terminalSystem(size: 15))
}
HStack {
Image(systemName: "2.circle.fill")
.foregroundColor(Theme.Colors.primaryAccent)
Text("Add credentials if needed")
.font(Theme.Typography.terminalSystem(size: 15))
}
HStack {
Image(systemName: "3.circle.fill")
.foregroundColor(Theme.Colors.primaryAccent)
Text("Start managing your terminals")
.font(Theme.Typography.terminalSystem(size: 15))
}
}
.padding(.top, Theme.Spacing.large)
}
.padding(.horizontal, Theme.Spacing.xlarge)
Spacer()
Spacer()
}
}
}
// MARK: - Helper Views
struct FeatureRow: View {
let icon: String
let text: String
var body: some View {
HStack(spacing: Theme.Spacing.medium) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundColor(Theme.Colors.primaryAccent)
.frame(width: 30)
Text(text)
.font(Theme.Typography.terminalSystem(size: 16))
.foregroundColor(Theme.Colors.terminalForeground)
Spacer()
}
}
}
struct ThemePreviewCard: View {
let theme: TerminalTheme
let isSelected: Bool
let onTap: () -> Void
var body: some View {
VStack(spacing: 4) {
// Mini terminal preview
VStack(spacing: 2) {
ForEach(0..<3) { _ in
HStack(spacing: 2) {
Rectangle()
.fill(theme.green)
.frame(width: 20, height: 2)
Rectangle()
.fill(theme.blue)
.frame(width: 30, height: 2)
Spacer()
}
}
}
.padding(8)
.background(theme.background)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(
isSelected ? Theme.Colors.primaryAccent : Color.clear,
lineWidth: 2
)
)
Text(theme.name)
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(isSelected ? Theme.Colors.primaryAccent : Theme.Colors.secondaryText)
}
.frame(width: 80, height: 80)
.onTapGesture {
let impactFeedback = UIImpactFeedbackGenerator(style: .light)
impactFeedback.impactOccurred()
onTap()
}
}
}
struct ControlFeatureCard: View {
let icon: String
let title: String
let description: String
var body: some View {
VStack(spacing: Theme.Spacing.small) {
Image(systemName: icon)
.font(.system(size: 30))
.foregroundColor(Theme.Colors.primaryAccent)
Text(title)
.font(Theme.Typography.terminalSystem(size: 14, weight: .semibold))
.multilineTextAlignment(.center)
Text(description)
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.secondaryText)
.multilineTextAlignment(.center)
}
.padding(Theme.Spacing.medium)
.frame(maxWidth: .infinity)
.background(Theme.Colors.secondaryBackground)
.cornerRadius(Theme.Layout.cornerRadius)
}
}
// MARK: - Preview
#Preview {
WelcomeView()
}