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

View file

@ -14,13 +14,15 @@ let package = Package(
)
],
dependencies: [
.package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0")
.package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0"),
.package(url: "https://github.com/mhdhejazi/Dynamic.git", from: "1.2.0")
],
targets: [
.target(
name: "VibeTunnelDependencies",
dependencies: [
.product(name: "SwiftTerm", package: "SwiftTerm")
.product(name: "SwiftTerm", package: "SwiftTerm"),
.product(name: "Dynamic", package: "Dynamic")
]
)
]

View file

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

View file

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

View file

@ -7,7 +7,7 @@ struct VibeTunnelApp: App {
@State private var connectionManager = ConnectionManager()
@State private var navigationManager = NavigationManager()
@State private var networkMonitor = NetworkMonitor.shared
init() {
// Configure app logging level
AppConfig.configureLogging()
@ -26,8 +26,18 @@ struct VibeTunnelApp: App {
// Initialize network monitoring
_ = networkMonitor
}
#if targetEnvironment(macCatalyst)
.macCatalystWindowStyle(getStoredWindowStyle())
#endif
}
}
#if targetEnvironment(macCatalyst)
private func getStoredWindowStyle() -> MacWindowStyle {
let styleRaw = UserDefaults.standard.string(forKey: "macWindowStyle") ?? "standard"
return styleRaw == "inline" ? .inline : .standard
}
#endif
private func handleURL(_ url: URL) {
// Handle vibetunnel://session/{sessionId} URLs
@ -112,8 +122,7 @@ class ConnectionManager {
/// Make ConnectionManager accessible globally for APIClient
extension ConnectionManager {
@MainActor
static let shared = ConnectionManager()
@MainActor static let shared = ConnectionManager()
}
/// Manages app-wide navigation state.

View file

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

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
/// - isGitTracked: Whether the file is in a git repository
/// - gitStatus: The git status of the file
init(name: String, path: String, isDir: Bool, size: Int64, mode: String, modTime: Date, isGitTracked: Bool? = nil, gitStatus: GitFileStatus? = nil) {
init(
name: String,
path: String,
isDir: Bool,
size: Int64,
mode: String,
modTime: Date,
isGitTracked: Bool? = nil,
gitStatus: GitFileStatus? = nil
) {
self.name = name
self.path = path
self.isDir = isDir
@ -134,13 +143,13 @@ struct GitStatus: Codable {
struct DirectoryListing: Codable {
/// The absolute path of the directory being listed.
let absolutePath: String
/// Array of file and subdirectory entries in this directory.
let files: [FileEntry]
/// Git status information for the directory
let gitStatus: GitStatus?
enum CodingKeys: String, CodingKey {
case absolutePath = "fullPath"
case files

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.
struct Session: Codable, Identifiable, Equatable, Hashable {
let id: String
let command: [String] // Changed from String to [String] to match server
let command: [String] // Changed from String to [String] to match server
let workingDir: String
let name: String?
let status: SessionStatus
@ -15,12 +15,12 @@ struct Session: Codable, Identifiable, Equatable, Hashable {
let startedAt: String
let lastModified: String?
let pid: Int?
// Terminal dimensions
let width: Int?
let height: Int?
let waiting: Bool?
// Optional fields from HQ mode
let source: String?
let remoteId: String?
@ -50,7 +50,7 @@ struct Session: Codable, Identifiable, Equatable, Hashable {
///
/// Returns the custom name if not empty, otherwise the command.
var displayName: String {
if let name = name, !name.isEmpty {
if let name, !name.isEmpty {
return name
}
return command.joined(separator: " ")

View file

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

View file

@ -9,64 +9,64 @@ enum TerminalWidth: CaseIterable, Equatable {
case mainframe132
case ultraWide160
case custom(Int)
var value: Int {
switch self {
case .unlimited: return 0
case .classic80: return 80
case .modern100: return 100
case .wide120: return 120
case .mainframe132: return 132
case .ultraWide160: return 160
case .custom(let width): return width
case .unlimited: 0
case .classic80: 80
case .modern100: 100
case .wide120: 120
case .mainframe132: 132
case .ultraWide160: 160
case .custom(let width): width
}
}
var label: String {
switch self {
case .unlimited: return ""
case .classic80: return "80"
case .modern100: return "100"
case .wide120: return "120"
case .mainframe132: return "132"
case .ultraWide160: return "160"
case .custom(let width): return "\(width)"
case .unlimited: ""
case .classic80: "80"
case .modern100: "100"
case .wide120: "120"
case .mainframe132: "132"
case .ultraWide160: "160"
case .custom(let width): "\(width)"
}
}
var description: String {
switch self {
case .unlimited: return "Unlimited"
case .classic80: return "Classic terminal"
case .modern100: return "Modern standard"
case .wide120: return "Wide terminal"
case .mainframe132: return "Mainframe width"
case .ultraWide160: return "Ultra-wide"
case .custom: return "Custom width"
case .unlimited: "Unlimited"
case .classic80: "Classic terminal"
case .modern100: "Modern standard"
case .wide120: "Wide terminal"
case .mainframe132: "Mainframe width"
case .ultraWide160: "Ultra-wide"
case .custom: "Custom width"
}
}
static var allCases: [TerminalWidth] {
static var allCases: [Self] {
[.unlimited, .classic80, .modern100, .wide120, .mainframe132, .ultraWide160]
}
static func from(value: Int) -> TerminalWidth {
static func from(value: Int) -> Self {
switch value {
case 0: return .unlimited
case 80: return .classic80
case 100: return .modern100
case 120: return .wide120
case 132: return .mainframe132
case 160: return .ultraWide160
default: return .custom(value)
case 0: .unlimited
case 80: .classic80
case 100: .modern100
case 120: .wide120
case 132: .mainframe132
case 160: .ultraWide160
default: .custom(value)
}
}
/// Check if this is a standard preset width
var isPreset: Bool {
switch self {
case .custom: return false
default: return true
case .custom: false
default: true
}
}
}
@ -75,12 +75,12 @@ enum TerminalWidth: CaseIterable, Equatable {
@MainActor
class TerminalWidthManager {
static let shared = TerminalWidthManager()
private let defaultWidthKey = "defaultTerminalWidth"
private let customWidthsKey = "customTerminalWidths"
private init() {}
/// Get the default terminal width
var defaultWidth: Int {
get {
@ -90,7 +90,7 @@ class TerminalWidthManager {
UserDefaults.standard.set(newValue, forKey: defaultWidthKey)
}
}
/// Get saved custom widths
var customWidths: [Int] {
get {
@ -100,7 +100,7 @@ class TerminalWidthManager {
UserDefaults.standard.set(newValue, forKey: customWidthsKey)
}
}
/// Add a custom width to saved list
func addCustomWidth(_ width: Int) {
var widths = customWidths
@ -113,7 +113,7 @@ class TerminalWidthManager {
customWidths = widths
}
}
/// Get all available widths including custom ones
func allWidths() -> [TerminalWidth] {
var widths = TerminalWidth.allCases
@ -124,4 +124,4 @@ class TerminalWidthManager {
}
return widths
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

View file

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

View file

@ -1,5 +1,7 @@
import Foundation
private let logger = Logger(category: "APIClient")
/// Errors that can occur during API operations.
enum APIError: LocalizedError {
case invalidURL
@ -120,15 +122,15 @@ class APIClient: APIClientProtocol {
// Debug logging
if let jsonString = String(data: data, encoding: .utf8) {
print("[APIClient] getSessions response: \(jsonString)")
logger.debug("getSessions response: \(jsonString)")
}
do {
return try decoder.decode([Session].self, from: data)
} catch {
print("[APIClient] Decoding error: \(error)")
logger.error("Decoding error: \(error)")
if let decodingError = error as? DecodingError {
print("[APIClient] Decoding error details: \(decodingError)")
logger.error("Decoding error details: \(decodingError)")
}
throw APIError.decodingError(error)
}
@ -153,12 +155,12 @@ class APIClient: APIClientProtocol {
func createSession(_ data: SessionCreateData) async throws -> String {
guard let baseURL else {
print("[APIClient] No server configured")
logger.error("No server configured")
throw APIError.noServerConfigured
}
let url = baseURL.appendingPathComponent("api/sessions")
print("[APIClient] Creating session at URL: \(url)")
logger.debug("Creating session at URL: \(url)")
var request = URLRequest(url: url)
request.httpMethod = "POST"
@ -168,24 +170,24 @@ class APIClient: APIClientProtocol {
do {
request.httpBody = try encoder.encode(data)
if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) {
print("[APIClient] Request body: \(bodyString)")
logger.debug("Request body: \(bodyString)")
}
} catch {
print("[APIClient] Failed to encode session data: \(error)")
logger.error("Failed to encode session data: \(error)")
throw error
}
do {
let (responseData, response) = try await session.data(for: request)
print("[APIClient] Response received")
logger.debug("Response received")
if let httpResponse = response as? HTTPURLResponse {
print("[APIClient] Status code: \(httpResponse.statusCode)")
print("[APIClient] Headers: \(httpResponse.allHeaderFields)")
logger.debug("Status code: \(httpResponse.statusCode)")
logger.debug("Headers: \(httpResponse.allHeaderFields)")
}
if let responseString = String(data: responseData, encoding: .utf8) {
print("[APIClient] Response body: \(responseString)")
logger.debug("Response body: \(responseString)")
}
// Check if the response is an error
@ -199,7 +201,7 @@ class APIClient: APIClientProtocol {
if let errorResponse = try? decoder.decode(ErrorResponse.self, from: responseData) {
let errorMessage = errorResponse.details ?? errorResponse.error ?? "Unknown error"
print("[APIClient] Server error: \(errorMessage)")
logger.error("Server error: \(errorMessage)")
throw APIError.serverError(httpResponse.statusCode, errorMessage)
} else {
// Fallback to generic error
@ -212,12 +214,12 @@ class APIClient: APIClientProtocol {
}
let createResponse = try decoder.decode(CreateResponse.self, from: responseData)
print("[APIClient] Session created with ID: \(createResponse.sessionId)")
logger.info("Session created with ID: \(createResponse.sessionId)")
return createResponse.sessionId
} catch {
print("[APIClient] Request failed: \(error)")
logger.error("Request failed: \(error)")
if let urlError = error as? URLError {
print("[APIClient] URL Error code: \(urlError.code), description: \(urlError.localizedDescription)")
logger.error("URL Error code: \(urlError.code), description: \(urlError.localizedDescription)")
}
throw error
}
@ -453,12 +455,12 @@ class APIClient: APIClientProtocol {
private func validateResponse(_ response: URLResponse) throws {
guard let httpResponse = response as? HTTPURLResponse else {
print("[APIClient] Invalid response type (not HTTP)")
logger.error("Invalid response type (not HTTP)")
throw APIError.networkError(URLError(.badServerResponse))
}
guard 200..<300 ~= httpResponse.statusCode else {
print("[APIClient] Server error: HTTP \(httpResponse.statusCode)")
logger.error("Server error: HTTP \(httpResponse.statusCode)")
throw APIError.serverError(httpResponse.statusCode, nil)
}
}
@ -472,7 +474,12 @@ class APIClient: APIClientProtocol {
// MARK: - File System Operations
func browseDirectory(path: String, showHidden: Bool = false, gitFilter: String = "all") async throws -> DirectoryListing {
func browseDirectory(
path: String,
showHidden: Bool = false,
gitFilter: String = "all"
)
async throws -> DirectoryListing {
guard let baseURL else {
throw APIError.noServerConfigured
}
@ -503,10 +510,10 @@ class APIClient: APIClientProtocol {
// Log response for debugging
if let httpResponse = response as? HTTPURLResponse {
print("[APIClient] Browse directory response: \(httpResponse.statusCode)")
logger.debug("Browse directory response: \(httpResponse.statusCode)")
if httpResponse.statusCode >= 400 {
if let errorString = String(data: data, encoding: .utf8) {
print("[APIClient] Error response body: \(errorString)")
logger.error("Error response body: \(errorString)")
}
}
}
@ -598,12 +605,12 @@ class APIClient: APIClientProtocol {
return try decoder.decode(FileInfo.self, from: data)
}
func previewFile(path: String) async throws -> FilePreview {
guard let baseURL else {
throw APIError.noServerConfigured
}
guard var components = URLComponents(
url: baseURL.appendingPathComponent("api/fs/preview"),
resolvingAgainstBaseURL: false
@ -611,26 +618,26 @@ class APIClient: APIClientProtocol {
throw APIError.invalidURL
}
components.queryItems = [URLQueryItem(name: "path", value: path)]
guard let url = components.url else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
addAuthenticationIfNeeded(&request)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
return try decoder.decode(FilePreview.self, from: data)
}
func getGitDiff(path: String) async throws -> FileDiff {
guard let baseURL else {
throw APIError.noServerConfigured
}
guard var components = URLComponents(
url: baseURL.appendingPathComponent("api/fs/diff"),
resolvingAgainstBaseURL: false
@ -638,75 +645,76 @@ class APIClient: APIClientProtocol {
throw APIError.invalidURL
}
components.queryItems = [URLQueryItem(name: "path", value: path)]
guard let url = components.url else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
addAuthenticationIfNeeded(&request)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
return try decoder.decode(FileDiff.self, from: data)
}
// MARK: - System Logs
func getLogsRaw() async throws -> String {
guard let baseURL else {
throw APIError.noServerConfigured
}
let url = baseURL.appendingPathComponent("api/logs/raw")
var request = URLRequest(url: url)
request.httpMethod = "GET"
addAuthenticationIfNeeded(&request)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
guard let logContent = String(data: data, encoding: .utf8) else {
throw APIError.invalidResponse
}
return logContent
}
func getLogsInfo() async throws -> LogsInfo {
guard let baseURL else {
throw APIError.noServerConfigured
}
let url = baseURL.appendingPathComponent("api/logs/info")
var request = URLRequest(url: url)
request.httpMethod = "GET"
addAuthenticationIfNeeded(&request)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
return try decoder.decode(LogsInfo.self, from: data)
}
func clearLogs() async throws {
guard let baseURL else {
throw APIError.noServerConfigured
}
let url = baseURL.appendingPathComponent("api/logs/clear")
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
addAuthenticationIfNeeded(&request)
let (_, response) = try await session.data(for: request)
try validateResponse(response)
}
}
// MARK: - File Preview Types
struct FilePreview: Codable {
let type: FilePreviewType
let content: String?

View file

@ -46,6 +46,8 @@ enum WebSocketError: Error {
@MainActor
@Observable
class BufferWebSocketClient: NSObject {
static let shared = BufferWebSocketClient()
private let logger = Logger(category: "BufferWebSocket")
/// Magic byte for binary messages
private static let bufferMagicByte: UInt8 = 0xBF
@ -70,7 +72,7 @@ class BufferWebSocketClient: NSObject {
}
return serverConfig.baseURL
}
init(webSocketFactory: WebSocketFactory = DefaultWebSocketFactory()) {
self.webSocketFactory = webSocketFactory
super.init()
@ -108,7 +110,7 @@ class BufferWebSocketClient: NSObject {
// Build headers
var headers: [String: String] = [:]
// Add authentication header if needed
if let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config),
@ -388,7 +390,10 @@ class BufferWebSocketClient: NSObject {
if offset < data.count {
let typeByte = data[offset]
logger.verbose("Type byte: 0x\(String(format: "%02X", typeByte))")
logger.verbose("Bits: hasExt=\((typeByte & 0x80) != 0), isUni=\((typeByte & 0x40) != 0), hasFg=\((typeByte & 0x20) != 0), hasBg=\((typeByte & 0x10) != 0), charType=\(typeByte & 0x03)")
logger
.verbose(
"Bits: hasExt=\((typeByte & 0x80) != 0), isUni=\((typeByte & 0x40) != 0), hasFg=\((typeByte & 0x20) != 0), hasBg=\((typeByte & 0x10) != 0), charType=\(typeByte & 0x03)"
)
}
break
}
@ -526,10 +531,10 @@ class BufferWebSocketClient: NSObject {
logger.debug("RGB foreground decode failed: insufficient data")
return nil
}
let r = Int(data[currentOffset])
let g = Int(data[currentOffset + 1])
let b = Int(data[currentOffset + 2])
fg = (r << 16) | (g << 8) | b | 0xFF00_0000 // Add alpha for RGB
let red = Int(data[currentOffset])
let green = Int(data[currentOffset + 1])
let blue = Int(data[currentOffset + 2])
fg = (red << 16) | (green << 8) | blue | 0xFF00_0000 // Add alpha for RGB
currentOffset += 3
} else {
// Palette color (1 byte)
@ -550,10 +555,10 @@ class BufferWebSocketClient: NSObject {
logger.debug("RGB background decode failed: insufficient data")
return nil
}
let r = Int(data[currentOffset])
let g = Int(data[currentOffset + 1])
let b = Int(data[currentOffset + 2])
bg = (r << 16) | (g << 8) | b | 0xFF00_0000 // Add alpha for RGB
let red = Int(data[currentOffset])
let green = Int(data[currentOffset + 1])
let blue = Int(data[currentOffset + 2])
bg = (red << 16) | (green << 8) | blue | 0xFF00_0000 // Add alpha for RGB
currentOffset += 3
} else {
// Palette color (1 byte)
@ -718,7 +723,7 @@ extension BufferWebSocketClient: WebSocketDelegate {
isConnecting = false
reconnectAttempts = 0
startPingTask()
// Re-subscribe to all sessions
Task {
for sessionId in subscriptions.keys {
@ -726,18 +731,22 @@ extension BufferWebSocketClient: WebSocketDelegate {
}
}
}
func webSocket(_ webSocket: WebSocketProtocol, didReceiveMessage message: WebSocketMessage) {
handleMessage(message)
}
func webSocket(_ webSocket: WebSocketProtocol, didFailWithError error: Error) {
logger.error("Error: \(error)")
connectionError = error
handleDisconnection()
}
func webSocketDidDisconnect(_ webSocket: WebSocketProtocol, closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
func webSocketDidDisconnect(
_ webSocket: WebSocketProtocol,
closeCode: URLSessionWebSocketTask.CloseCode,
reason: Data?
) {
logger.info("Disconnected with code: \(closeCode)")
handleDisconnection()
}

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

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
private let logger = Logger(category: "SSEClient")
/// Server-Sent Events (SSE) client for real-time terminal output streaming.
///
/// SSEClient handles the text-based streaming protocol used by the VibeTunnel server
@ -11,53 +13,53 @@ final class SSEClient: NSObject, @unchecked Sendable {
private let url: URL
private var buffer = Data()
weak var delegate: SSEClientDelegate?
/// Events received from the SSE stream
enum SSEEvent {
case terminalOutput(timestamp: Double, type: String, data: String)
case exit(exitCode: Int, sessionId: String)
case error(String)
}
init(url: URL) {
self.url = url
super.init()
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 0 // No timeout for SSE
configuration.timeoutIntervalForResource = 0
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: .main)
}
@MainActor
func start() {
var request = URLRequest(url: url)
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
// Add authentication if needed
if let authHeader = ConnectionManager.shared.currentServerConfig?.authorizationHeader {
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
}
task = session.dataTask(with: request)
task?.resume()
}
func stop() {
task?.cancel()
task = nil
}
private func processBuffer() {
// Convert buffer to string
guard let string = String(data: buffer, encoding: .utf8) else { return }
// Split by double newline (SSE event separator)
let events = string.components(separatedBy: "\n\n")
// Keep the last incomplete event in buffer
if !string.hasSuffix("\n\n") && events.count > 1 {
if let lastEvent = events.last, let lastEventData = lastEvent.data(using: .utf8) {
@ -66,24 +68,24 @@ final class SSEClient: NSObject, @unchecked Sendable {
} else {
buffer = Data()
}
// Process complete events
for (index, eventString) in events.enumerated() {
// Skip the last event if buffer wasn't cleared (it's incomplete)
if index == events.count - 1 && !buffer.isEmpty {
continue
}
if !eventString.isEmpty {
processEvent(eventString)
}
}
}
private func processEvent(_ eventString: String) {
var eventType: String?
var eventData: String?
// Parse SSE format
let lines = eventString.components(separatedBy: "\n")
for line in lines {
@ -94,21 +96,21 @@ final class SSEClient: NSObject, @unchecked Sendable {
if eventData == nil {
eventData = data
} else {
eventData! += "\n" + data
eventData = (eventData ?? "") + "\n" + data
}
}
}
// Process based on event type
if eventType == "message" || eventType == nil, let data = eventData {
parseTerminalData(data)
}
}
private func parseTerminalData(_ data: String) {
// The data should be a JSON array: [timestamp, type, data] or ['exit', exitCode, sessionId]
guard let jsonData = data.data(using: .utf8) else { return }
do {
if let array = try JSONSerialization.jsonObject(with: jsonData) as? [Any] {
if array.count >= 3 {
@ -122,28 +124,37 @@ final class SSEClient: NSObject, @unchecked Sendable {
else if let timestamp = array[0] as? Double,
let type = array[1] as? String,
let outputData = array[2] as? String {
delegate?.sseClient(self, didReceiveEvent: .terminalOutput(timestamp: timestamp, type: type, data: outputData))
delegate?.sseClient(
self,
didReceiveEvent: .terminalOutput(timestamp: timestamp, type: type, data: outputData)
)
}
}
}
} catch {
print("[SSEClient] Failed to parse event data: \(error)")
logger.error("Failed to parse event data: \(error)")
}
}
deinit {
stop()
}
}
// MARK: - URLSessionDataDelegate
extension SSEClient: URLSessionDataDelegate {
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void
) {
guard let httpResponse = response as? HTTPURLResponse else {
completionHandler(.cancel)
return
}
if httpResponse.statusCode == 200 {
completionHandler(.allow)
} else {
@ -151,15 +162,19 @@ extension SSEClient: URLSessionDataDelegate {
completionHandler(.cancel)
}
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
buffer.append(data)
processBuffer()
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
if (error as NSError).code != NSURLErrorCancelled {
if let error {
// Check if this is a URLError directly
if let urlError = error as? URLError, urlError.code != .cancelled {
delegate?.sseClient(self, didReceiveEvent: .error(error.localizedDescription))
} else if (error as? URLError) == nil {
// Not a URLError, so it's some other error we should report
delegate?.sseClient(self, didReceiveEvent: .error(error.localizedDescription))
}
}
@ -167,6 +182,7 @@ extension SSEClient: URLSessionDataDelegate {
}
// MARK: - SSEClientDelegate
protocol SSEClientDelegate: AnyObject {
func sseClient(_ client: SSEClient, didReceiveEvent event: SSEClient.SSEEvent)
}
}

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import Foundation
@MainActor
protocol WebSocketProtocol: AnyObject {
var delegate: WebSocketDelegate? { get set }
func connect(to url: URL, with headers: [String: String]) async throws
func send(_ message: WebSocketMessage) async throws
func sendPing() async throws
@ -23,7 +23,11 @@ protocol WebSocketDelegate: AnyObject {
func webSocketDidConnect(_ webSocket: WebSocketProtocol)
func webSocket(_ webSocket: WebSocketProtocol, didReceiveMessage message: WebSocketMessage)
func webSocket(_ webSocket: WebSocketProtocol, didFailWithError error: Error)
func webSocketDidDisconnect(_ webSocket: WebSocketProtocol, closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
func webSocketDidDisconnect(
_ webSocket: WebSocketProtocol,
closeCode: URLSessionWebSocketTask.CloseCode,
reason: Data?
)
}
/// Real implementation of WebSocketProtocol using URLSessionWebSocketTask
@ -33,23 +37,23 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
private var webSocketTask: URLSessionWebSocketTask?
private var session: URLSession!
private var isReceiving = false
override init() {
super.init()
self.session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
}
func connect(to url: URL, with headers: [String: String]) async throws {
var request = URLRequest(url: url)
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
webSocketTask = session.webSocketTask(with: request)
webSocketTask?.resume()
// Start receiving messages
isReceiving = true
receiveNextMessage()
// Send initial ping to verify connection
do {
try await sendPing()
@ -63,12 +67,12 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
throw error
}
}
func send(_ message: WebSocketMessage) async throws {
guard let task = webSocketTask else {
throw WebSocketError.connectionFailed
}
switch message {
case .string(let text):
try await task.send(.string(text))
@ -76,15 +80,15 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
try await task.send(.data(data))
}
}
func sendPing() async throws {
guard let task = webSocketTask else {
throw WebSocketError.connectionFailed
}
return try await withCheckedThrowingContinuation { continuation in
task.sendPing { error in
if let error = error {
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
@ -92,7 +96,7 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
}
}
}
func disconnect(with code: URLSessionWebSocketTask.CloseCode, reason: Data?) {
isReceiving = false
webSocketTask?.cancel(with: code, reason: reason)
@ -100,13 +104,13 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
self.delegate?.webSocketDidDisconnect(self, closeCode: code, reason: reason)
}
}
private func receiveNextMessage() {
guard isReceiving, let task = webSocketTask else { return }
task.receive { [weak self] result in
guard let self = self else { return }
guard let self else { return }
switch result {
case .success(let message):
let wsMessage: WebSocketMessage
@ -118,16 +122,16 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
@unknown default:
return
}
Task { @MainActor in
self.delegate?.webSocket(self, didReceiveMessage: wsMessage)
}
// Continue receiving
Task { @MainActor in
self.receiveNextMessage()
}
case .failure(let error):
Task { @MainActor in
self.isReceiving = false
@ -139,14 +143,23 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
}
extension URLSessionWebSocket: URLSessionWebSocketDelegate {
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
nonisolated func urlSession(
_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol protocol: String?
) {
// Connection opened - already handled in connect()
}
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
nonisolated func urlSession(
_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
reason: Data?
) {
Task { @MainActor in
self.isReceiving = false
self.delegate?.webSocketDidDisconnect(self, closeCode: closeCode, reason: reason)
}
}
}
}

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

@ -7,54 +7,54 @@ enum LogLevel: Int {
case info = 2
case warning = 3
case error = 4
var prefix: String {
switch self {
case .verbose: return "🔍"
case .debug: return "🐛"
case .info: return ""
case .warning: return "⚠️"
case .error: return ""
case .verbose: "🔍"
case .debug: "🐛"
case .info: ""
case .warning: "⚠️"
case .error: ""
}
}
}
struct Logger {
private let category: String
/// Global log level - only messages at this level or higher will be printed
// Global log level - only messages at this level or higher will be printed
#if DEBUG
nonisolated(unsafe) static var globalLevel: LogLevel = .info // Default to info level in debug builds
nonisolated(unsafe) static var globalLevel: LogLevel = .info // Default to info level in debug builds
#else
nonisolated(unsafe) static var globalLevel: LogLevel = .warning // Only warnings and errors in release
nonisolated(unsafe) static var globalLevel: LogLevel = .warning // Only warnings and errors in release
#endif
init(category: String) {
self.category = category
}
func verbose(_ message: String) {
log(message, level: .verbose)
}
func debug(_ message: String) {
log(message, level: .debug)
}
func info(_ message: String) {
log(message, level: .info)
}
func warning(_ message: String) {
log(message, level: .warning)
}
func error(_ message: String) {
log(message, level: .error)
}
private func log(_ message: String, level: LogLevel) {
guard level.rawValue >= Logger.globalLevel.rawValue else { return }
guard level.rawValue >= Self.globalLevel.rawValue else { return }
print("\(level.prefix) [\(category)] \(message)")
}
}
}

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

@ -14,26 +14,32 @@ enum Theme {
static let terminalBackground = Color(light: Color(hex: "FFFFFF"), dark: Color(hex: "0A0E14"))
static let cardBackground = Color(light: Color(hex: "F8F9FA"), dark: Color(hex: "0D1117"))
static let headerBackground = Color(light: Color(hex: "FFFFFF"), dark: Color(hex: "010409"))
// Border colors
/// Border colors
static let cardBorder = Color(light: Color(hex: "E1E4E8"), dark: Color(hex: "1C2128"))
// Text colors
/// Text colors
static let terminalForeground = Color(light: Color(hex: "24292E"), dark: Color(hex: "B3B1AD"))
// Accent colors (same for both modes)
static let primaryAccent = Color(light: Color(hex: "22C55E"), dark: Color(hex: "00FF88")) // Darker green for light mode
static let primaryAccent = Color(hex: "007AFF") // iOS system blue
static let secondaryAccent = Color(hex: "59C2FF")
static let successAccent = Color(hex: "AAD94C")
static let warningAccent = Color(hex: "FFB454")
static let errorAccent = Color(hex: "F07178")
// Selection colors
/// Selection colors
static let terminalSelection = Color(light: Color(hex: "E1E4E8"), dark: Color(hex: "273747"))
// Overlay colors
/// Overlay colors
static let overlayBackground = Color(light: Color.black.opacity(0.5), dark: Color.black.opacity(0.7))
// Additional UI colors
static let secondaryText = Color(light: Color(hex: "6E7781"), dark: Color(hex: "8B949E"))
static let secondaryBackground = Color(light: Color(hex: "F6F8FA"), dark: Color(hex: "161B22"))
static let success = successAccent
static let error = errorAccent
// Additional UI colors for FileBrowser
static let terminalAccent = primaryAccent
static let terminalGray = Color(light: Color(hex: "586069"), dark: Color(hex: "8B949E"))
@ -59,6 +65,15 @@ enum Theme {
static let ansiBrightMagenta = Color(light: Color(hex: "5A32A3"), dark: Color(hex: "FFEE99"))
static let ansiBrightCyan = Color(light: Color(hex: "0598BC"), dark: Color(hex: "95E6CB"))
static let ansiBrightWhite = Color(light: Color(hex: "24292E"), dark: Color(hex: "FFFFFF"))
// File type colors
static let fileTypeJS = Color(light: Color(hex: "B08800"), dark: Color(hex: "FFB454"))
static let fileTypeTS = Color(light: Color(hex: "0366D6"), dark: Color(hex: "007ACC"))
static let fileTypeJSON = Color(light: Color(hex: "E36209"), dark: Color(hex: "FF8C42"))
static let fileTypeCSS = Color(light: Color(hex: "563D7C"), dark: Color(hex: "7B68EE"))
static let fileTypePython = Color(light: Color(hex: "3776AB"), dark: Color(hex: "4B8BBE"))
static let fileTypeGo = Color(light: Color(hex: "00ADD8"), dark: Color(hex: "00ADD8"))
static let fileTypeImage = Color(light: Color(hex: "28A745"), dark: Color(hex: "91B362"))
}
// MARK: - Typography
@ -77,6 +92,18 @@ enum Theme {
static func terminalSystem(size: CGFloat) -> Font {
Font.system(size: size, design: .monospaced)
}
static func terminalSystem(size: CGFloat, weight: Font.Weight) -> Font {
Font.system(size: size, weight: weight, design: .monospaced)
}
static func largeTitle() -> Font {
Font.largeTitle.weight(.semibold)
}
static func title() -> Font {
Font.title2.weight(.medium)
}
}
// MARK: - Spacing
@ -88,6 +115,7 @@ enum Theme {
static let medium: CGFloat = 12
static let large: CGFloat = 16
static let extraLarge: CGFloat = 24
static let xlarge: CGFloat = 24 // Alias for extraLarge
static let extraExtraLarge: CGFloat = 32
}
@ -101,6 +129,13 @@ enum Theme {
static let card: CGFloat = 12
}
// MARK: - Layout
/// Layout constants
enum Layout {
static let cornerRadius: CGFloat = 10
}
// MARK: - Animation
/// Animation presets.
@ -154,15 +189,15 @@ extension Color {
opacity: Double(alpha) / 255
)
}
/// Creates a color that automatically adapts to light/dark mode
init(light: Color, dark: Color) {
self.init(UIColor { traitCollection in
switch traitCollection.userInterfaceStyle {
case .dark:
return UIColor(dark)
UIColor(dark)
default:
return UIColor(light)
UIColor(light)
}
})
}
@ -205,14 +240,8 @@ extension View {
.stroke(Theme.Colors.primaryAccent, lineWidth: 1)
)
}
/// Interactive button style with press and hover animations
func interactiveButton(isPressed: Bool = false, isHovered: Bool = false) -> some View {
self
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(Theme.Animation.quick, value: isPressed)
.animation(Theme.Animation.quick, value: isHovered)
}
// Removed: interactiveButton - use explicit scaleEffect and animation instead
}
// MARK: - Haptic Feedback
@ -268,36 +297,5 @@ struct HapticFeedback {
}
}
// MARK: - SwiftUI Haptic View Modifiers
extension View {
/// Provides haptic feedback when the view is tapped
func hapticOnTap(_ style: HapticFeedback.ImpactStyle = .light) -> some View {
self.onTapGesture {
HapticFeedback.impact(style)
}
}
/// Provides haptic feedback when a value changes
func hapticOnChange(of value: some Equatable, style: HapticFeedback.ImpactStyle = .light) -> some View {
self.onChange(of: value) { _, _ in
HapticFeedback.impact(style)
}
}
/// Provides selection haptic feedback when a value changes
func hapticSelection(on value: some Equatable) -> some View {
self.onChange(of: value) { _, _ in
HapticFeedback.selection()
}
}
/// Provides notification haptic feedback
func hapticNotification(_ type: HapticFeedback.NotificationType, when condition: Bool) -> some View {
self.onChange(of: condition) { _, newValue in
if newValue {
HapticFeedback.notification(type)
}
}
}
}
// Note: Call HapticFeedback methods directly instead of using view modifiers
// Example: HapticFeedback.impact(.light) or HapticFeedback.selection()

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

@ -7,13 +7,13 @@ import SwiftUI
struct LoadingView: View {
let message: String
let useUnicodeSpinner: Bool
@State private var isAnimating = false
@State private var spinnerFrame = 0
// Unicode spinner frames matching web UI
/// Unicode spinner frames matching web UI
private let spinnerFrames = ["", "", "", "", "", "", "", "", "", ""]
init(message: String, useUnicodeSpinner: Bool = false) {
self.message = message
self.useUnicodeSpinner = useUnicodeSpinner
@ -57,7 +57,7 @@ struct LoadingView: View {
}
}
}
private func startUnicodeAnimation() {
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in
Task { @MainActor in

View file

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

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 errorMessage: String?
let onConnect: () -> Void
@ObservedObject private var networkMonitor = NetworkMonitor.shared
@State private var networkMonitor = NetworkMonitor.shared
@FocusState private var focusedField: Field?
@State private var recentServers: [ServerConfig] = []

View file

@ -4,31 +4,29 @@ import WebKit
/// View for previewing files with syntax highlighting
struct FilePreviewView: View {
let path: String
@Environment(\.dismiss) var dismiss
@Environment(\.dismiss)
var dismiss
@State private var preview: FilePreview?
@State private var isLoading = true
@State private var error: String?
@State private var presentedError: IdentifiableError?
@State private var showingDiff = false
@State private var gitDiff: FileDiff?
var body: some View {
NavigationStack {
ZStack {
Theme.Colors.terminalBackground
.ignoresSafeArea()
if isLoading {
ProgressView("Loading...")
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
} else if let error = error {
VStack {
Text("Error loading file")
.font(.headline)
.foregroundColor(Theme.Colors.errorAccent)
Text(error)
.font(.subheadline)
.foregroundColor(Theme.Colors.terminalForeground)
.multilineTextAlignment(.center)
} else if presentedError != nil {
ContentUnavailableView {
Label("Failed to Load File", systemImage: "exclamationmark.triangle")
} description: {
Text("The file could not be loaded. Please try again.")
} actions: {
Button("Retry") {
Task {
await loadPreview()
@ -36,7 +34,7 @@ struct FilePreviewView: View {
}
.terminalButton()
}
} else if let preview = preview {
} else if let preview {
previewContent(for: preview)
}
}
@ -49,8 +47,8 @@ struct FilePreviewView: View {
}
.foregroundColor(Theme.Colors.primaryAccent)
}
if let preview = preview, preview.type == .text {
if let preview, preview.type == .text {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Diff") {
showingDiff = true
@ -74,8 +72,9 @@ struct FilePreviewView: View {
}
}
}
.errorAlert(item: $presentedError)
}
@ViewBuilder
private func previewContent(for preview: FilePreview) -> some View {
switch preview.type {
@ -100,11 +99,11 @@ struct FilePreviewView: View {
Image(systemName: "doc.zipper")
.font(.system(size: 64))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
Text("Binary File")
.font(.headline)
.foregroundColor(Theme.Colors.terminalForeground)
if let size = preview.size {
Text(formatFileSize(size))
.font(.caption)
@ -113,20 +112,20 @@ struct FilePreviewView: View {
}
}
}
private func loadPreview() async {
isLoading = true
error = nil
presentedError = nil
do {
preview = try await APIClient.shared.previewFile(path: path)
isLoading = false
} catch {
self.error = error.localizedDescription
presentedError = IdentifiableError(error: error)
isLoading = false
}
}
private func loadDiff() async {
do {
gitDiff = try await APIClient.shared.getGitDiff(path: path)
@ -134,7 +133,7 @@ struct FilePreviewView: View {
// Silently fail - diff might not be available
}
}
private func formatFileSize(_ size: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .binary
@ -146,22 +145,22 @@ struct FilePreviewView: View {
struct SyntaxHighlightedView: UIViewRepresentable {
let content: String
let language: String
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.isOpaque = false
webView.backgroundColor = UIColor(Theme.Colors.cardBackground)
webView.scrollView.backgroundColor = UIColor(Theme.Colors.cardBackground)
loadContent(in: webView)
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
// Content is static, no updates needed
}
private func loadContent(in webView: WKWebView) {
let escapedContent = content
.replacingOccurrences(of: "&", with: "&amp;")
@ -169,7 +168,7 @@ struct SyntaxHighlightedView: UIViewRepresentable {
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&#39;")
let html = """
<!DOCTYPE html>
<html>
@ -210,7 +209,7 @@ struct SyntaxHighlightedView: UIViewRepresentable {
</body>
</html>
"""
webView.loadHTMLString(html, baseURL: nil)
}
}
@ -218,14 +217,15 @@ struct SyntaxHighlightedView: UIViewRepresentable {
/// View for displaying git diffs
struct GitDiffView: View {
let diff: FileDiff
@Environment(\.dismiss) var dismiss
@Environment(\.dismiss)
var dismiss
var body: some View {
NavigationStack {
ZStack {
Theme.Colors.terminalBackground
.ignoresSafeArea()
DiffWebView(content: diff.diff)
}
.navigationTitle("Git Diff")
@ -246,27 +246,27 @@ struct GitDiffView: View {
/// WebView for displaying diffs with syntax highlighting
struct DiffWebView: UIViewRepresentable {
let content: String
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.isOpaque = false
webView.backgroundColor = UIColor(Theme.Colors.cardBackground)
loadDiff(in: webView)
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
// Content is static
}
private func loadDiff(in webView: WKWebView) {
let escapedContent = content
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
let html = """
<!DOCTYPE html>
<html>
@ -305,7 +305,7 @@ struct DiffWebView: UIViewRepresentable {
</body>
</html>
"""
webView.loadHTMLString(html, baseURL: nil)
}
}
}

View file

@ -28,10 +28,15 @@ struct FileBrowserView: View {
enum FileBrowserMode {
case selectDirectory
case browseFiles
case insertPath // New mode for inserting paths into terminal
case insertPath // New mode for inserting paths into terminal
}
init(initialPath: String = "~", mode: FileBrowserMode = .selectDirectory, onSelect: @escaping (String) -> Void, onInsertPath: ((String, Bool) -> Void)? = nil) {
init(
initialPath: String = "~",
mode: FileBrowserMode = .selectDirectory,
onSelect: @escaping (String) -> Void,
onInsertPath: ((String, Bool) -> Void)? = nil
) {
self.initialPath = initialPath
self.mode = mode
self.onSelect = onSelect
@ -74,7 +79,7 @@ struct FileBrowserView: View {
.foregroundColor(Theme.Colors.terminalGray)
.lineLimit(1)
.truncationMode(.middle)
// Git branch indicator
if let gitStatus = viewModel.gitStatus, gitStatus.isGitRepo, let branch = gitStatus.branch {
Text("📍 \(branch)")
@ -88,7 +93,7 @@ struct FileBrowserView: View {
.padding(.vertical, 16)
.background(Theme.Colors.terminalDarkGray)
}
private var filterToolbar: some View {
HStack(spacing: 12) {
// Git filter toggle
@ -103,16 +108,20 @@ struct FileBrowserView: View {
Text(viewModel.gitFilter == .changed ? "Git Changes" : "All Files")
.font(.custom("SF Mono", size: 12))
}
.foregroundColor(viewModel.gitFilter == .changed ? Theme.Colors.successAccent : Theme.Colors.terminalGray)
.foregroundColor(viewModel.gitFilter == .changed ? Theme.Colors.successAccent : Theme.Colors
.terminalGray
)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(viewModel.gitFilter == .changed ? Theme.Colors.successAccent.opacity(0.2) : Theme.Colors.terminalGray.opacity(0.1))
.fill(viewModel.gitFilter == .changed ? Theme.Colors.successAccent.opacity(0.2) : Theme.Colors
.terminalGray.opacity(0.1)
)
)
}
.buttonStyle(TerminalButtonStyle())
// Hidden files toggle
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
@ -130,23 +139,25 @@ struct FileBrowserView: View {
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(viewModel.showHidden ? Theme.Colors.terminalAccent.opacity(0.2) : Theme.Colors.terminalGray.opacity(0.1))
.fill(viewModel.showHidden ? Theme.Colors.terminalAccent.opacity(0.2) : Theme.Colors
.terminalGray.opacity(0.1)
)
)
}
.buttonStyle(TerminalButtonStyle())
Spacer()
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Theme.Colors.terminalDarkGray.opacity(0.5))
}
var body: some View {
NavigationStack {
ZStack {
// Background
Color.black.ignoresSafeArea()
Theme.Colors.terminalBackground.ignoresSafeArea()
VStack(spacing: 0) {
navigationHeader
@ -210,7 +221,7 @@ struct FileBrowserView: View {
.foregroundColor(Theme.Colors.terminalGray)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.8))
.background(Theme.Colors.terminalBackground.opacity(0.8))
}
}
@ -274,7 +285,7 @@ struct FileBrowserView: View {
}, label: {
Text("select")
.font(.custom("SF Mono", size: 14))
.foregroundColor(.black)
.foregroundColor(Theme.Colors.terminalBackground)
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(
@ -408,19 +419,19 @@ struct FileBrowserView: View {
viewModel.loadDirectory(path: initialPath)
}
}
// MARK: - Helper Methods
private func insertPath(_ path: String, isDirectory: Bool) {
// Escape the path if it contains spaces
let escapedPath = path.contains(" ") ? "\"\(path)\"" : path
// Call the insertion handler
onInsertPath?(escapedPath, isDirectory)
// Provide haptic feedback
HapticFeedback.impact(.light)
// Dismiss the file browser
dismiss()
}
@ -461,10 +472,10 @@ struct FileBrowserRow: View {
if isDirectory {
return "folder.fill"
}
// Get file extension
let ext = name.split(separator: ".").last?.lowercased() ?? ""
switch ext {
case "js", "jsx", "ts", "tsx", "mjs", "cjs":
return "doc.text.fill"
@ -504,44 +515,44 @@ struct FileBrowserRow: View {
return "doc.fill"
}
}
var iconColor: Color {
if isDirectory {
return Theme.Colors.terminalAccent
}
let ext = name.split(separator: ".").last?.lowercased() ?? ""
switch ext {
case "js", "jsx", "mjs", "cjs":
return .yellow
return Theme.Colors.fileTypeJS
case "ts", "tsx":
return Color(red: 0.0, green: 0.48, blue: 0.78) // TypeScript blue
return Theme.Colors.fileTypeTS
case "json":
return .orange
return Theme.Colors.fileTypeJSON
case "html", "htm":
return .orange
return Theme.Colors.fileTypeJSON
case "css", "scss", "sass", "less":
return Color(red: 0.21, green: 0.46, blue: 0.74) // CSS blue
return Theme.Colors.fileTypeCSS
case "md", "markdown":
return .gray
return Theme.Colors.terminalGray
case "png", "jpg", "jpeg", "gif", "svg", "ico", "webp":
return .green
return Theme.Colors.fileTypeImage
case "swift":
return .orange
return Theme.Colors.fileTypeJSON
case "py":
return Color(red: 0.22, green: 0.49, blue: 0.72) // Python blue
return Theme.Colors.fileTypePython
case "go":
return Color(red: 0.0, green: 0.68, blue: 0.85) // Go cyan
return Theme.Colors.fileTypeGo
case "rs":
return .orange
return Theme.Colors.fileTypeJSON
case "sh", "bash", "zsh", "fish":
return .green
return Theme.Colors.fileTypeImage
default:
return Theme.Colors.terminalGray.opacity(0.6)
}
}
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
@ -563,7 +574,7 @@ struct FileBrowserRow: View {
Spacer()
// Git status indicator
if let gitStatus = gitStatus, gitStatus != .unchanged {
if let gitStatus, gitStatus != .unchanged {
GitStatusBadge(status: gitStatus)
.padding(.trailing, 8)
}
@ -609,7 +620,7 @@ struct FileBrowserRow: View {
} label: {
Label("Copy Name", systemImage: "doc.on.doc")
}
Button {
UIPasteboard.general.string = isDirectory ? "\(name)/" : name
UINotificationFeedbackGenerator().notificationOccurred(.success)
@ -648,7 +659,7 @@ class FileBrowserViewModel {
var gitStatus: GitStatus?
var showHidden = false
var gitFilter: GitFilterOption = .all
enum GitFilterOption: String {
case all = "all"
case changed = "changed"
@ -702,8 +713,8 @@ class FileBrowserViewModel {
do {
let result = try await apiClient.browseDirectory(
path: path,
showHidden: showHidden,
path: path,
showHidden: showHidden,
gitFilter: gitFilter.rawValue
)
// Use the absolute path returned by the server
@ -777,27 +788,27 @@ class FileBrowserViewModel {
/// Git status badge component for displaying file status
struct GitStatusBadge: View {
let status: GitFileStatus
var label: String {
switch status {
case .modified: return "M"
case .added: return "A"
case .deleted: return "D"
case .untracked: return "?"
case .unchanged: return ""
case .modified: "M"
case .added: "A"
case .deleted: "D"
case .untracked: "?"
case .unchanged: ""
}
}
var color: Color {
switch status {
case .modified: return .yellow
case .added: return .green
case .deleted: return .red
case .untracked: return .gray
case .unchanged: return .clear
case .modified: .yellow
case .added: .green
case .deleted: .red
case .untracked: .gray
case .unchanged: .clear
}
}
var body: some View {
if status != .unchanged {
Text(label)

View file

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

View file

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

View file

@ -18,6 +18,8 @@ struct SessionCardView: View {
@State private var scale: CGFloat = 1.0
@State private var rotation: Double = 0
@State private var brightness: Double = 1.0
@Environment(\.livePreviewSubscription) private var livePreview
private var displayWorkingDir: String {
// Convert absolute paths back to ~ notation for display
@ -71,95 +73,23 @@ struct SessionCardView: View {
.fill(Theme.Colors.terminalBackground)
.frame(height: 120)
.overlay(
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
Group {
if session.isRunning {
if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty {
// Show terminal output preview
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 4) {
// ESC indicator if present
if snapshot.cleanOutputPreview.lowercased().contains("esc to interrupt") {
HStack(spacing: 4) {
Image(systemName: "escape")
.font(.system(size: 10, weight: .bold))
.foregroundColor(Theme.Colors.warningAccent)
Text("Press ESC to interrupt")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.warningAccent)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Theme.Colors.warningAccent.opacity(0.2))
)
}
Text(snapshot.cleanOutputPreview)
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.8))
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(nil)
.multilineTextAlignment(.leading)
}
}
.padding(Theme.Spacing.small)
// Show live preview if available
if let bufferSnapshot = livePreview?.latestSnapshot {
CompactTerminalPreview(snapshot: bufferSnapshot)
.animation(.easeInOut(duration: 0.2), value: bufferSnapshot.cursorY)
} else if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty {
// Show static snapshot as fallback
staticSnapshotView(snapshot)
} else {
// Show command and working directory info as fallback
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text("$")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.primaryAccent)
Text(session.command.joined(separator: " "))
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground)
}
Text(displayWorkingDir)
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
.lineLimit(1)
.onTapGesture {
UIPasteboard.general.string = session.workingDir
HapticFeedback.notification(.success)
}
if isLoadingSnapshot {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors
.primaryAccent
))
.scaleEffect(0.8)
Text("Loading output...")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
}
.padding(.top, Theme.Spacing.extraSmall)
}
}
.padding(Theme.Spacing.small)
Spacer()
commandInfoView
}
} else {
// For exited sessions, show last output if available
if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty {
// Show last output for exited sessions
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 4) {
Text("Session exited")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.errorAccent)
Text(snapshot.cleanOutputPreview)
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(nil)
.multilineTextAlignment(.leading)
}
}
.padding(Theme.Spacing.small)
exitedSessionView(snapshot)
} else {
Text("Session exited")
.font(Theme.Typography.terminalSystem(size: 12))
@ -184,6 +114,25 @@ struct SessionCardView: View {
.foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors
.terminalForeground.opacity(0.5)
)
// Live preview indicator
if session.isRunning && livePreview?.latestSnapshot != nil {
HStack(spacing: 2) {
Image(systemName: "dot.radiowaves.left.and.right")
.font(.system(size: 8))
.foregroundColor(Theme.Colors.primaryAccent)
.symbolEffect(.pulse)
Text("live")
.font(Theme.Typography.terminalSystem(size: 9))
.foregroundColor(Theme.Colors.primaryAccent)
}
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
Capsule()
.fill(Theme.Colors.primaryAccent.opacity(0.1))
)
}
}
Spacer()
@ -307,4 +256,93 @@ struct SessionCardView: View {
opacity = 1.0
}
}
// MARK: - View Components
@ViewBuilder
private var commandInfoView: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text("$")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.primaryAccent)
Text(session.command.joined(separator: " "))
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground)
}
Text(displayWorkingDir)
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
.lineLimit(1)
.onTapGesture {
UIPasteboard.general.string = session.workingDir
HapticFeedback.notification(.success)
}
if isLoadingSnapshot {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
.scaleEffect(0.8)
Text("Connecting...")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
}
.padding(.top, Theme.Spacing.extraSmall)
}
}
.padding(Theme.Spacing.small)
}
@ViewBuilder
private func staticSnapshotView(_ snapshot: TerminalSnapshot) -> some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 4) {
// ESC indicator if present
if snapshot.cleanOutputPreview.lowercased().contains("esc to interrupt") {
HStack(spacing: 4) {
Image(systemName: "escape")
.font(.system(size: 10, weight: .bold))
.foregroundColor(Theme.Colors.warningAccent)
Text("Press ESC to interrupt")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.warningAccent)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Theme.Colors.warningAccent.opacity(0.2))
)
}
Text(snapshot.cleanOutputPreview)
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.8))
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(nil)
.multilineTextAlignment(.leading)
}
}
.padding(Theme.Spacing.small)
}
@ViewBuilder
private func exitedSessionView(_ snapshot: TerminalSnapshot) -> some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 4) {
Text("Session exited")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.errorAccent)
Text(snapshot.cleanOutputPreview)
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(nil)
.multilineTextAlignment(.leading)
}
}
.padding(Theme.Spacing.small)
}
}

View file

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

View file

@ -7,9 +7,11 @@ import UniformTypeIdentifiers
/// Shows active and exited sessions with options to create new sessions,
/// manage existing ones, and navigate to terminal views.
struct SessionListView: View {
@Environment(ConnectionManager.self) var connectionManager
@Environment(NavigationManager.self) var navigationManager
@ObservedObject private var networkMonitor = NetworkMonitor.shared
@Environment(ConnectionManager.self)
var connectionManager
@Environment(NavigationManager.self)
var navigationManager
@State private var networkMonitor = NetworkMonitor.shared
@State private var viewModel = SessionListViewModel()
@State private var showingCreateSession = false
@State private var selectedSession: Session?
@ -19,6 +21,8 @@ struct SessionListView: View {
@State private var searchText = ""
@State private var showingCastImporter = false
@State private var importedCastFile: CastFileItem?
@State private var presentedError: IdentifiableError?
@AppStorage("enableLivePreviews") private var enableLivePreviews = true
var filteredSessions: [Session] {
let sessions = viewModel.sessions.filter { showExitedSessions || $0.isRunning }
@ -101,16 +105,16 @@ struct SessionListView: View {
Button(action: {
HapticFeedback.impact(.light)
showingSettings = true
}) {
}, label: {
Label("Settings", systemImage: "gearshape")
}
})
Button(action: {
HapticFeedback.impact(.light)
showingCastImporter = true
}) {
}, label: {
Label("Import Recording", systemImage: "square.and.arrow.down")
}
})
} label: {
Image(systemName: "ellipsis.circle")
.font(.title3)
@ -170,21 +174,27 @@ struct SessionListView: View {
importedCastFile = CastFileItem(url: url)
}
case .failure(let error):
print("Failed to import cast file: \(error)")
logger.error("Failed to import cast file: \(error)")
}
}
.sheet(item: $importedCastFile) { item in
CastPlayerView(castFileURL: item.url)
}
.errorAlert(item: $presentedError)
.refreshable {
await viewModel.loadSessions()
}
.searchable(text: $searchText, prompt: "Search sessions")
.onAppear {
viewModel.startAutoRefresh()
}
.onDisappear {
viewModel.stopAutoRefresh()
.task {
await viewModel.loadSessions()
// Refresh every 3 seconds
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
if !Task.isCancelled {
await viewModel.loadSessions()
}
}
}
}
.onChange(of: navigationManager.shouldNavigateToSession) { _, shouldNavigate in
@ -195,6 +205,12 @@ struct SessionListView: View {
navigationManager.clearNavigation()
}
}
.onChange(of: viewModel.errorMessage) { _, newError in
if let error = newError {
presentedError = IdentifiableError(error: APIError.serverError(0, error))
viewModel.errorMessage = nil
}
}
}
private var emptyStateView: some View {
@ -256,10 +272,10 @@ struct SessionListView: View {
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
}
Button(action: { searchText = "" }) {
Button(action: { searchText = "" }, label: {
Label("Clear Search", systemImage: "xmark.circle.fill")
.font(Theme.Typography.terminalSystem(size: 14))
}
})
.terminalButton()
}
.padding()
@ -295,7 +311,6 @@ struct SessionListView: View {
GridItem(.flexible(), spacing: Theme.Spacing.medium),
GridItem(.flexible(), spacing: Theme.Spacing.medium)
], spacing: Theme.Spacing.medium) {
ForEach(filteredSessions) { session in
SessionCardView(session: session) {
HapticFeedback.selection()
@ -313,6 +328,7 @@ struct SessionListView: View {
await viewModel.cleanupSession(session.id)
}
}
.livePreview(for: session.id, enabled: session.isRunning && enableLivePreviews)
.transition(.asymmetric(
insertion: .scale(scale: 0.8).combined(with: .opacity),
removal: .scale(scale: 0.8).combined(with: .opacity)
@ -372,31 +388,6 @@ struct SessionListView: View {
}
}
// MARK: - Error Banner
struct ErrorBanner: View {
let message: String
let isOffline: Bool
var body: some View {
HStack {
Image(systemName: isOffline ? "wifi.slash" : "exclamationmark.triangle")
.foregroundColor(Theme.Colors.terminalBackground)
Text(message)
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.terminalBackground)
.lineLimit(2)
Spacer()
}
.padding()
.background(isOffline ? Color.orange : Theme.Colors.errorAccent)
.cornerRadius(Theme.CornerRadius.small)
.padding(.horizontal)
.padding(.top, 8)
}
}
/// View model for managing session list state and operations.
@MainActor
@ -406,29 +397,8 @@ class SessionListViewModel {
var isLoading = false
var errorMessage: String?
private var refreshTask: Task<Void, Never>?
private let sessionService = SessionService.shared
func startAutoRefresh() {
refreshTask?.cancel()
refreshTask = Task {
await loadSessions()
// Refresh every 3 seconds using modern async approach
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
if !Task.isCancelled {
await loadSessions()
}
}
}
}
func stopAutoRefresh() {
refreshTask?.cancel()
refreshTask = nil
}
func loadSessions() async {
if sessions.isEmpty {
isLoading = true
@ -493,8 +463,8 @@ struct SessionHeaderView: View {
let onKillAll: () -> Void
let onCleanupAll: () -> Void
private var runningCount: Int { sessions.count(where: { $0.isRunning }) }
private var exitedCount: Int { sessions.count(where: { !$0.isRunning }) }
private var runningCount: Int { sessions.count { $0.isRunning }}
private var exitedCount: Int { sessions.count { !$0.isRunning }}
var body: some View {
VStack(spacing: Theme.Spacing.medium) {
@ -505,28 +475,28 @@ struct SessionHeaderView: View {
count: runningCount,
color: Theme.Colors.successAccent
)
SessionCountBadge(
label: "Exited",
count: exitedCount,
color: Theme.Colors.errorAccent
)
Spacer()
}
// Action buttons
HStack(spacing: Theme.Spacing.medium) {
if exitedCount > 0 {
ExitedSessionToggle(showExitedSessions: $showExitedSessions)
}
Spacer()
if showExitedSessions && sessions.contains(where: { !$0.isRunning }) {
CleanupAllHeaderButton(onCleanup: onCleanupAll)
}
if sessions.contains(where: \.isRunning) {
KillAllButton(onKillAll: onKillAll)
}
@ -540,14 +510,14 @@ struct SessionCountBadge: View {
let label: String
let count: Int
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
.textCase(.uppercase)
Text("\(count)")
.font(Theme.Typography.terminalSystem(size: 28))
.fontWeight(.bold)
@ -683,3 +653,7 @@ struct CastFileItem: Identifiable {
let id = UUID()
let url: URL
}
// MARK: - Logging
private let logger = Logger(category: "SessionListView")

View file

@ -9,11 +9,13 @@ struct SettingsView: View {
enum SettingsTab: String, CaseIterable {
case general = "General"
case advanced = "Advanced"
case about = "About"
var icon: String {
switch self {
case .general: "gear"
case .advanced: "gearshape.2"
case .about: "info.circle"
}
}
}
@ -60,6 +62,8 @@ struct SettingsView: View {
GeneralSettingsView()
case .advanced:
AdvancedSettingsView()
case .about:
AboutSettingsView()
}
}
.padding()
@ -91,6 +95,8 @@ struct GeneralSettingsView: View {
private var autoScrollEnabled = true
@AppStorage("enableURLDetection")
private var enableURLDetection = true
@AppStorage("enableLivePreviews")
private var enableLivePreviews = true
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.large) {
@ -166,6 +172,26 @@ struct GeneralSettingsView: View {
.padding()
.background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.card)
// Live Previews
Toggle(isOn: $enableLivePreviews) {
HStack {
Image(systemName: "dot.radiowaves.left.and.right")
.foregroundColor(Theme.Colors.primaryAccent)
VStack(alignment: .leading, spacing: 2) {
Text("Live Session Previews")
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.terminalForeground)
Text("Show real-time terminal output in session cards")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
}
}
}
.toggleStyle(SwitchToggleStyle(tint: Theme.Colors.primaryAccent))
.padding()
.background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.card)
}
}
@ -181,6 +207,16 @@ struct AdvancedSettingsView: View {
@AppStorage("debugModeEnabled")
private var debugModeEnabled = false
@State private var showingSystemLogs = false
#if targetEnvironment(macCatalyst)
@AppStorage("macWindowStyle")
private var macWindowStyleRaw = "standard"
@StateObject private var windowManager = MacCatalystWindowManager.shared
private var macWindowStyle: MacWindowStyle {
macWindowStyleRaw == "inline" ? .inline : .standard
}
#endif
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.large) {
@ -210,7 +246,7 @@ struct AdvancedSettingsView: View {
.padding()
.background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.card)
// View System Logs Button
Button(action: { showingSystemLogs = true }) {
HStack {
@ -231,6 +267,45 @@ struct AdvancedSettingsView: View {
}
}
#if targetEnvironment(macCatalyst)
// Mac Catalyst Section
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
Text("Mac Catalyst")
.font(.headline)
.foregroundColor(Theme.Colors.terminalForeground)
VStack(spacing: Theme.Spacing.medium) {
// Window Style Picker
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
Text("Window Style")
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
Picker("Window Style", selection: $macWindowStyleRaw) {
Label("Standard", systemImage: "macwindow")
.tag("standard")
Label("Inline Traffic Lights", systemImage: "macwindow.badge.plus")
.tag("inline")
}
.pickerStyle(SegmentedPickerStyle())
.onChange(of: macWindowStyleRaw) { _, newValue in
let style: MacWindowStyle = newValue == "inline" ? .inline : .standard
windowManager.setWindowStyle(style)
}
Text(macWindowStyle == .inline ?
"Traffic light buttons appear inline with content" :
"Standard macOS title bar with traffic lights")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
}
.padding()
.background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.card)
}
}
#endif
// Developer Section
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
Text("Developer")
@ -270,6 +345,129 @@ struct AdvancedSettingsView: View {
}
}
/// About settings tab content
struct AboutSettingsView: View {
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
}
var body: some View {
VStack(spacing: Theme.Spacing.xlarge) {
// App icon and info
VStack(spacing: Theme.Spacing.large) {
Image("AppIcon")
.resizable()
.frame(width: 100, height: 100)
.cornerRadius(22)
.shadow(color: Theme.Colors.primaryAccent.opacity(0.3), radius: 10, y: 5)
VStack(spacing: Theme.Spacing.small) {
Text("VibeTunnel")
.font(.largeTitle)
.fontWeight(.bold)
Text("Version \(appVersion) (\(buildNumber))")
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.secondaryText)
}
}
.padding(.top, Theme.Spacing.large)
// Links section
VStack(spacing: Theme.Spacing.medium) {
LinkRow(
icon: "globe",
title: "Website",
subtitle: "vibetunnel.sh",
url: URL(string: "https://vibetunnel.sh")
)
LinkRow(
icon: "doc.text",
title: "Documentation",
subtitle: "Learn how to use VibeTunnel",
url: URL(string: "https://docs.vibetunnel.sh")
)
LinkRow(
icon: "exclamationmark.bubble",
title: "Report an Issue",
subtitle: "Help us improve",
url: URL(string: "https://github.com/vibetunnel/vibetunnel/issues")
)
LinkRow(
icon: "heart",
title: "Rate on App Store",
subtitle: "Share your feedback",
url: URL(string: "https://apps.apple.com/app/vibetunnel")
)
}
// Credits
VStack(spacing: Theme.Spacing.small) {
Text("Made with ❤️ by the VibeTunnel team")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.secondaryText)
.multilineTextAlignment(.center)
Text("© 2024 VibeTunnel. All rights reserved.")
.font(Theme.Typography.terminalSystem(size: 11))
.foregroundColor(Theme.Colors.secondaryText.opacity(0.7))
}
.padding(.top, Theme.Spacing.large)
Spacer()
}
}
}
struct LinkRow: View {
let icon: String
let title: String
let subtitle: String
let url: URL?
var body: some View {
Button(action: {
if let url = url {
UIApplication.shared.open(url)
}
}) {
HStack(spacing: Theme.Spacing.medium) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundColor(Theme.Colors.primaryAccent)
.frame(width: 30)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.terminalForeground)
Text(subtitle)
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.secondaryText)
}
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.system(size: 16))
.foregroundColor(Theme.Colors.secondaryText.opacity(0.5))
}
.padding()
.background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.card)
}
.buttonStyle(PlainButtonStyle())
}
}
#Preview {
SettingsView()
}

View file

@ -2,10 +2,11 @@ import SwiftUI
/// System logs viewer with filtering and search capabilities
struct SystemLogsView: View {
@Environment(\.dismiss) var dismiss
@Environment(\.dismiss)
var dismiss
@State private var logs = ""
@State private var isLoading = true
@State private var error: String?
@State private var presentedError: IdentifiableError?
@State private var searchText = ""
@State private var selectedLevel: LogLevel = .all
@State private var showClientLogs = true
@ -14,96 +15,93 @@ struct SystemLogsView: View {
@State private var refreshTimer: Timer?
@State private var showingClearConfirmation = false
@State private var logsInfo: LogsInfo?
enum LogLevel: String, CaseIterable {
case all = "All"
case error = "Error"
case warn = "Warn"
case log = "Log"
case debug = "Debug"
var displayName: String { rawValue }
func matches(_ line: String) -> Bool {
switch self {
case .all:
return true
true
case .error:
return line.localizedCaseInsensitiveContains("[ERROR]") ||
line.localizedCaseInsensitiveContains("error:")
line.localizedCaseInsensitiveContains("[ERROR]") ||
line.localizedCaseInsensitiveContains("error:")
case .warn:
return line.localizedCaseInsensitiveContains("[WARN]") ||
line.localizedCaseInsensitiveContains("warning:")
line.localizedCaseInsensitiveContains("[WARN]") ||
line.localizedCaseInsensitiveContains("warning:")
case .log:
return line.localizedCaseInsensitiveContains("[LOG]") ||
line.localizedCaseInsensitiveContains("log:")
line.localizedCaseInsensitiveContains("[LOG]") ||
line.localizedCaseInsensitiveContains("log:")
case .debug:
return line.localizedCaseInsensitiveContains("[DEBUG]") ||
line.localizedCaseInsensitiveContains("debug:")
line.localizedCaseInsensitiveContains("[DEBUG]") ||
line.localizedCaseInsensitiveContains("debug:")
}
}
}
var filteredLogs: String {
let lines = logs.components(separatedBy: .newlines)
let filtered = lines.filter { line in
// Skip empty lines
guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
// Filter by level
if selectedLevel != .all && !selectedLevel.matches(line) {
return false
}
// Filter by source
let isClientLog = line.contains("[Client]") || line.contains("client:")
let isServerLog = line.contains("[Server]") || line.contains("server:") || (!isClientLog)
let isServerLog = line.contains("[Server]") || line.contains("server:") || !isClientLog
if !showClientLogs && isClientLog {
return false
}
if !showServerLogs && isServerLog {
return false
}
// Filter by search text
if !searchText.isEmpty && !line.localizedCaseInsensitiveContains(searchText) {
return false
}
return true
}
return filtered.joined(separator: "\n")
}
var body: some View {
NavigationStack {
ZStack {
Theme.Colors.terminalBackground
.ignoresSafeArea()
VStack(spacing: 0) {
// Filters toolbar
filtersToolbar
// Search bar
searchBar
// Logs content
if isLoading {
ProgressView("Loading logs...")
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = error {
VStack {
Text("Error loading logs")
.font(.headline)
.foregroundColor(Theme.Colors.errorAccent)
Text(error)
.font(.subheadline)
.foregroundColor(Theme.Colors.terminalForeground)
.multilineTextAlignment(.center)
} else if presentedError != nil {
ContentUnavailableView {
Label("Failed to Load Logs", systemImage: "exclamationmark.triangle")
} description: {
Text("The logs could not be loaded. Please try again.")
} actions: {
Button("Retry") {
Task {
await loadLogs()
@ -126,19 +124,19 @@ struct SystemLogsView: View {
}
.foregroundColor(Theme.Colors.primaryAccent)
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button(action: downloadLogs) {
Label("Download", systemImage: "square.and.arrow.down")
}
Button(action: { showingClearConfirmation = true }) {
Button(action: { showingClearConfirmation = true }, label: {
Label("Clear Logs", systemImage: "trash")
}
})
Toggle("Auto-scroll", isOn: $autoScroll)
if let info = logsInfo {
Section {
Label(formatFileSize(info.size), systemImage: "doc")
@ -169,22 +167,23 @@ struct SystemLogsView: View {
} message: {
Text("Are you sure you want to clear all system logs? This action cannot be undone.")
}
.errorAlert(item: $presentedError)
}
private var filtersToolbar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
// Level filter
Menu {
ForEach(LogLevel.allCases, id: \.self) { level in
Button(action: { selectedLevel = level }) {
Button(action: { selectedLevel = level }, label: {
HStack {
Text(level.displayName)
if selectedLevel == level {
Image(systemName: "checkmark")
}
}
}
})
}
} label: {
HStack(spacing: 4) {
@ -197,14 +196,14 @@ struct SystemLogsView: View {
.background(Theme.Colors.cardBackground)
.cornerRadius(6)
}
// Source toggles
Toggle("Client", isOn: $showClientLogs)
.toggleStyle(ChipToggleStyle())
Toggle("Server", isOn: $showServerLogs)
.toggleStyle(ChipToggleStyle())
Spacer()
}
.padding(.horizontal)
@ -212,31 +211,31 @@ struct SystemLogsView: View {
.padding(.vertical, 8)
.background(Theme.Colors.cardBackground)
}
private var searchBar: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
TextField("Search logs...", text: $searchText)
.textFieldStyle(PlainTextFieldStyle())
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.terminalForeground)
.autocapitalization(.none)
.disableAutocorrection(true)
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
Button(action: { searchText = "" }, label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
}
})
}
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Theme.Colors.terminalDarkGray)
}
private var logsContent: some View {
ScrollViewReader { proxy in
ScrollView {
@ -258,42 +257,42 @@ struct SystemLogsView: View {
}
}
}
private func loadLogs() async {
isLoading = true
error = nil
presentedError = nil
do {
// Load logs content
logs = try await APIClient.shared.getLogsRaw()
// Load logs info
logsInfo = try await APIClient.shared.getLogsInfo()
isLoading = false
} catch {
self.error = error.localizedDescription
presentedError = IdentifiableError(error: error)
isLoading = false
}
}
private func clearLogs() async {
do {
try await APIClient.shared.clearLogs()
logs = ""
await loadLogs()
} catch {
self.error = error.localizedDescription
presentedError = IdentifiableError(error: error)
}
}
private func downloadLogs() {
// Create activity controller with logs
let activityVC = UIActivityViewController(
activityItems: [logs],
applicationActivities: nil
)
// Present it
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
@ -301,7 +300,7 @@ struct SystemLogsView: View {
rootVC.present(activityVC, animated: true)
}
}
private func startAutoRefresh() {
refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { _ in
Task {
@ -309,12 +308,12 @@ struct SystemLogsView: View {
}
}
}
private func stopAutoRefresh() {
refreshTimer?.invalidate()
refreshTimer = nil
}
private func formatFileSize(_ size: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .binary
@ -325,7 +324,7 @@ struct SystemLogsView: View {
/// Custom toggle style for filter chips
struct ChipToggleStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.isOn.toggle() }) {
Button(action: { configuration.isOn.toggle() }, label: {
HStack(spacing: 4) {
if configuration.isOn {
Image(systemName: "checkmark")
@ -339,7 +338,7 @@ struct ChipToggleStyle: ToggleStyle {
.background(configuration.isOn ? Theme.Colors.primaryAccent.opacity(0.2) : Theme.Colors.cardBackground)
.foregroundColor(configuration.isOn ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground)
.cornerRadius(6)
}
})
.buttonStyle(PlainButtonStyle())
}
}
}

View file

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

View file

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

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

View file

@ -6,7 +6,8 @@ import SwiftUI
/// of how the terminal text will appear.
struct FontSizeSheet: View {
@Binding var fontSize: CGFloat
@Environment(\.dismiss) var dismiss
@Environment(\.dismiss)
var dismiss
let fontSizes: [CGFloat] = [10, 12, 14, 16, 18, 20, 22, 24, 28, 32]

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

View file

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

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
var viewModel: TerminalViewModel
@State private var isAutoScrollEnabled = true
@AppStorage("enableURLDetection") private var enableURLDetection = true
@AppStorage("enableURLDetection")
private var enableURLDetection = true
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
let terminal = SwiftTerm.TerminalView()
@ -120,7 +121,7 @@ struct TerminalHostingView: UIViewRepresentable {
// Use system monospaced font which has better compatibility with SwiftTerm
// The custom SF Mono font seems to have rendering issues
let font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
// SwiftTerm uses the font property directly
terminal.font = font
}
@ -179,8 +180,11 @@ struct TerminalHostingView: UIViewRepresentable {
/// Update terminal buffer from binary buffer data using optimized ANSI sequences
func updateBuffer(from snapshot: BufferSnapshot) {
guard let terminal else { return }
logger.verbose("updateBuffer called with snapshot: \(snapshot.cols)x\(snapshot.rows), cursor: (\(snapshot.cursorX),\(snapshot.cursorY))")
logger
.verbose(
"updateBuffer called with snapshot: \(snapshot.cols)x\(snapshot.rows), cursor: (\(snapshot.cursorX),\(snapshot.cursorY))"
)
// Update terminal dimensions if needed
let currentCols = terminal.getTerminal().cols
@ -207,10 +211,14 @@ struct TerminalHostingView: UIViewRepresentable {
ansiData = convertBufferToOptimizedANSI(snapshot, clearScreen: isFirstUpdate)
isFirstUpdate = false
logger.verbose("Full redraw performed")
} else {
} else if let previous = previousSnapshot {
// Incremental update
ansiData = generateIncrementalUpdate(from: previousSnapshot!, to: snapshot)
ansiData = generateIncrementalUpdate(from: previous, to: snapshot)
logger.verbose("Incremental update performed")
} else {
// Fallback to full redraw if somehow previousSnapshot is nil
ansiData = convertBufferToOptimizedANSI(snapshot, clearScreen: false)
logger.verbose("Fallback full redraw performed")
}
// Store current snapshot for next update
@ -322,10 +330,10 @@ struct TerminalHostingView: UIViewRepresentable {
if let fg = cell.fg {
if fg & 0xFF00_0000 != 0 {
// RGB color
let r = (fg >> 16) & 0xFF
let g = (fg >> 8) & 0xFF
let b = fg & 0xFF
output += "\u{001B}[38;2;\(r);\(g);\(b)m"
let red = (fg >> 16) & 0xFF
let green = (fg >> 8) & 0xFF
let blue = fg & 0xFF
output += "\u{001B}[38;2;\(red);\(green);\(blue)m"
} else if fg <= 255 {
// Palette color
output += "\u{001B}[38;5;\(fg)m"
@ -341,10 +349,10 @@ struct TerminalHostingView: UIViewRepresentable {
if let bg = cell.bg {
if bg & 0xFF00_0000 != 0 {
// RGB color
let r = (bg >> 16) & 0xFF
let g = (bg >> 8) & 0xFF
let b = bg & 0xFF
output += "\u{001B}[48;2;\(r);\(g);\(b)m"
let red = (bg >> 16) & 0xFF
let green = (bg >> 8) & 0xFF
let blue = bg & 0xFF
output += "\u{001B}[48;2;\(red);\(green);\(blue)m"
} else if bg <= 255 {
// Palette color
output += "\u{001B}[48;5;\(bg)m"
@ -552,10 +560,10 @@ struct TerminalHostingView: UIViewRepresentable {
if let color = new {
if color & 0xFF00_0000 != 0 {
// RGB color
let r = (color >> 16) & 0xFF
let g = (color >> 8) & 0xFF
let b = color & 0xFF
output += "\u{001B}[\(isBackground ? 48 : 38);2;\(r);\(g);\(b)m"
let red = (color >> 16) & 0xFF
let green = (color >> 8) & 0xFF
let blue = color & 0xFF
output += "\u{001B}[\(isBackground ? 48 : 38);2;\(red);\(green);\(blue)m"
} else if color <= 255 {
// Palette color
output += "\u{001B}[\(isBackground ? 48 : 38);5;\(color)m"
@ -591,14 +599,14 @@ struct TerminalHostingView: UIViewRepresentable {
}
}
}
func getBufferContent() -> String? {
guard let terminal else { return nil }
// Get the terminal buffer content
let terminalInstance = terminal.getTerminal()
var content = ""
// Read all lines from the terminal buffer
for row in 0..<terminalInstance.rows {
if let line = terminalInstance.getLine(row: row) {
@ -613,7 +621,7 @@ struct TerminalHostingView: UIViewRepresentable {
content += lineText.trimmingCharacters(in: .whitespaces) + "\n"
}
}
return content.trimmingCharacters(in: .whitespacesAndNewlines)
}
@ -646,13 +654,13 @@ struct TerminalHostingView: UIViewRepresentable {
terminal.feed(text: "\u{001b}[B")
}
}
func setMaxWidth(_ maxWidth: Int) {
// Store the max width preference for terminal rendering
// When maxWidth is 0, it means unlimited
// This could be used to constrain terminal rendering in the future
// 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) {
@ -684,7 +692,7 @@ struct TerminalHostingView: UIViewRepresentable {
// If we have buffer data, we can provide additional context
if previousSnapshot != nil {
// 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.
struct TerminalThemeSheet: View {
@Binding var selectedTheme: TerminalTheme
@Environment(\.dismiss) var dismiss
@Environment(\.dismiss)
var dismiss
var body: some View {
NavigationStack {
@ -29,7 +30,7 @@ struct TerminalThemeSheet: View {
HapticFeedback.impact(.light)
// Save to UserDefaults
TerminalTheme.selected = theme
}) {
}, label: {
HStack(spacing: Theme.Spacing.medium) {
// Color preview
HStack(spacing: 2) {
@ -86,7 +87,7 @@ struct TerminalThemeSheet: View {
lineWidth: 1
)
)
}
})
.buttonStyle(PlainButtonStyle())
}
}

View file

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

View file

@ -2,13 +2,16 @@ import Observation
import SwiftTerm
import SwiftUI
private let logger = Logger(category: "TerminalView")
/// Interactive terminal view for a session.
///
/// Displays a full terminal emulator using SwiftTerm with support for
/// input, output, recording, and font size adjustment.
struct TerminalView: View {
let session: Session
@Environment(\.dismiss) var dismiss
@Environment(\.dismiss)
var dismiss
@State private var viewModel: TerminalViewModel
@State private var fontSize: CGFloat = 14
@State private var showingFontSizeSheet = false
@ -26,6 +29,8 @@ struct TerminalView: View {
@State private var exportedFileURL: URL?
@State private var showingWidthSelector = false
@State private var currentTerminalWidth: TerminalWidth = .unlimited
@State private var showingFullscreenInput = false
@State private var showingCtrlKeyGrid = false
@FocusState private var isInputFocused: Bool
init(session: Session) {
@ -80,13 +85,23 @@ struct TerminalView: View {
onSelect: { _ in
showingFileBrowser = false
},
onInsertPath: { [weak viewModel] path, isDirectory in
onInsertPath: { [weak viewModel] path, _ in
// Insert the path into the terminal
viewModel?.sendInput(path)
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(
DragGesture()
.onEnded { value in
@ -96,16 +111,20 @@ struct TerminalView: View {
}
}
)
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
withAnimation(Theme.Animation.standard) {
keyboardHeight = keyboardFrame.height
.task {
for await notification in NotificationCenter.default.notifications(named: UIResponder.keyboardWillShowNotification) {
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
withAnimation(Theme.Animation.standard) {
keyboardHeight = keyboardFrame.height
}
}
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
withAnimation(Theme.Animation.standard) {
keyboardHeight = 0
.task {
for await _ in NotificationCenter.default.notifications(named: UIResponder.keyboardWillHideNotification) {
withAnimation(Theme.Animation.standard) {
keyboardHeight = 0
}
}
}
.onChange(of: selectedTerminalWidth) { _, newValue in
@ -146,31 +165,31 @@ struct TerminalView: View {
}
}
}
// MARK: - Export Functions
private func exportTerminalBuffer() {
guard let bufferContent = viewModel.getBufferContent() else { return }
let fileName = "\(session.displayName)_\(Date().timeIntervalSince1970).txt"
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
do {
try bufferContent.write(to: tempURL, atomically: true, encoding: .utf8)
exportedFileURL = tempURL
showingExportSheet = true
} catch {
print("Failed to export terminal buffer: \(error)")
logger.error("Failed to export terminal buffer: \(error)")
}
}
// MARK: - View Components
private var mainContent: some View {
ZStack {
selectedTheme.background
.ignoresSafeArea()
VStack(spacing: 0) {
if viewModel.isConnecting {
loadingView
@ -182,7 +201,7 @@ struct TerminalView: View {
}
}
}
private var navigationToolbarItems: some ToolbarContent {
Group {
ToolbarItem(placement: .navigationBarLeading) {
@ -191,22 +210,24 @@ struct TerminalView: View {
}
.foregroundColor(Theme.Colors.primaryAccent)
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
QuickFontSizeButtons(fontSize: $fontSize)
.fixedSize()
fileBrowserButton
widthSelectorButton
menuButton
}
}
}
private var bottomToolbarItems: some ToolbarContent {
ToolbarItemGroup(placement: .bottomBar) {
terminalSizeIndicator
Spacer()
}
}
private var recordingIndicator: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
if viewModel.castRecorder.isRecording {
@ -214,22 +235,22 @@ struct TerminalView: View {
}
}
}
// MARK: - Toolbar Components
private var fileBrowserButton: some View {
Button(action: {
Button(action: {
HapticFeedback.impact(.light)
showingFileBrowser = true
}) {
showingFileBrowser = true
}, label: {
Image(systemName: "folder")
.font(.system(size: 16))
.foregroundColor(Theme.Colors.primaryAccent)
}
})
}
private var widthSelectorButton: some View {
Button(action: { showingWidthSelector = true }) {
Button(action: { showingWidthSelector = true }, label: {
HStack(spacing: 2) {
Image(systemName: "arrow.left.and.right")
.font(.system(size: 12))
@ -245,7 +266,7 @@ struct TerminalView: View {
RoundedRectangle(cornerRadius: 6)
.stroke(Theme.Colors.primaryAccent.opacity(0.3), lineWidth: 1)
)
}
})
.foregroundColor(Theme.Colors.primaryAccent)
.popover(isPresented: $showingWidthSelector, arrowEdge: .top) {
WidthSelectorPopover(
@ -254,7 +275,7 @@ struct TerminalView: View {
)
}
}
private var menuButton: some View {
Menu {
terminalMenuItems
@ -263,79 +284,89 @@ struct TerminalView: View {
.foregroundColor(Theme.Colors.primaryAccent)
}
}
@ViewBuilder
private var terminalMenuItems: some View {
Button(action: { viewModel.clearTerminal() }, label: {
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 {
Button(action: {
Button(action: {
fontSize = max(8, fontSize - 1)
HapticFeedback.impact(.light)
}, label: {
Label("Decrease", systemImage: "minus")
})
.disabled(fontSize <= 8)
Button(action: {
Button(action: {
fontSize = min(32, fontSize + 1)
HapticFeedback.impact(.light)
}, label: {
Label("Increase", systemImage: "plus")
})
.disabled(fontSize >= 32)
Button(action: {
Button(action: {
fontSize = 14
HapticFeedback.impact(.light)
}, label: {
Label("Reset to Default", systemImage: "arrow.counterclockwise")
})
.disabled(fontSize == 14)
Divider()
Button(action: { showingFontSizeSheet = true }, label: {
Label("More Options...", systemImage: "slider.horizontal.3")
})
} label: {
Label("Font Size (\(Int(fontSize))pt)", systemImage: "textformat.size")
}
Button(action: { showingTerminalWidthSheet = true }, label: {
Label("Terminal Width", systemImage: "arrow.left.and.right")
})
Button(action: { viewModel.toggleFitToWidth() }, label: {
Label(
viewModel.fitToWidth ? "Fixed Width" : "Fit to Width",
systemImage: viewModel.fitToWidth ? "arrow.left.and.right.square" : "arrow.left.and.right.square.fill"
)
})
Button(action: { showingTerminalThemeSheet = true }, label: {
Label("Theme", systemImage: "paintbrush")
})
Button(action: { viewModel.copyBuffer() }, label: {
Label("Copy All", systemImage: "square.on.square")
})
Button(action: { exportTerminalBuffer() }, label: {
Label("Export as Text", systemImage: "square.and.arrow.up")
})
Divider()
recordingMenuItems
Divider()
debugMenuItems
}
@ViewBuilder
private var recordingMenuItems: some View {
if viewModel.castRecorder.isRecording {
@ -351,13 +382,13 @@ struct TerminalView: View {
Label("Start Recording", systemImage: "record.circle")
})
}
Button(action: { showingRecordingSheet = true }, label: {
Label("Export Recording", systemImage: "square.and.arrow.up")
})
.disabled(viewModel.castRecorder.events.isEmpty)
}
@ViewBuilder
private var debugMenuItems: some View {
Menu {
@ -366,20 +397,20 @@ struct TerminalView: View {
selectedRenderer = renderer
TerminalRenderer.selected = renderer
viewModel.terminalViewId = UUID() // Force recreate terminal view
}) {
}, label: {
HStack {
Text(renderer.displayName)
if renderer == selectedRenderer {
Image(systemName: "checkmark")
}
}
}
})
}
} label: {
Label("Terminal Renderer", systemImage: "gearshape.2")
}
}
@ViewBuilder
private var terminalSizeIndicator: some View {
if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 {
@ -388,16 +419,15 @@ struct TerminalView: View {
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
}
}
private var recordingView: some View {
HStack(spacing: 4) {
Circle()
.fill(Color.red)
.fill(Theme.Colors.errorAccent)
.frame(width: 8, height: 8)
.overlay(
Circle()
.fill(Color.red.opacity(0.3))
.fill(Theme.Colors.errorAccent.opacity(0.3))
.frame(width: 16, height: 16)
.scaleEffect(viewModel.recordingPulse ? 1.5 : 1.0)
.animation(
@ -407,7 +437,7 @@ struct TerminalView: View {
)
Text("REC")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.red)
.foregroundColor(Theme.Colors.errorAccent)
}
.onAppear {
viewModel.recordingPulse = true
@ -491,12 +521,17 @@ struct TerminalView: View {
.id(viewModel.terminalViewId)
.background(selectedTheme.background)
.focused($isInputFocused)
.scrollToBottomOverlay(
isVisible: showScrollToBottom,
action: {
viewModel.scrollToBottom()
showScrollToBottom = false
}
.overlay(
ScrollToBottomButton(
isVisible: showScrollToBottom,
action: {
viewModel.scrollToBottom()
showScrollToBottom = false
}
)
.padding(.bottom, Theme.Spacing.large)
.padding(.leading, Theme.Spacing.large),
alignment: .bottomLeading
)
// Keyboard toolbar
@ -539,7 +574,7 @@ class TerminalViewModel {
var bufferWebSocketClient: BufferWebSocketClient?
private var connectionStatusTask: Task<Void, Never>?
private var connectionErrorTask: Task<Void, Never>?
weak var terminalCoordinator: TerminalHostingView.Coordinator?
weak var terminalCoordinator: AnyObject? // Can be TerminalHostingView.Coordinator
init(session: Session) {
self.session = session
@ -630,18 +665,20 @@ class TerminalViewModel {
// Initialize terminal with dimensions from header
terminalCols = header.width
terminalRows = header.height
print("Snapshot header: \(header.width)x\(header.height)")
logger.debug("Snapshot header: \(header.width)x\(header.height)")
}
// Feed all output events to the terminal
for event in snapshot.events {
if event.type == .output {
// Feed the actual terminal output data
terminalCoordinator?.feedData(event.data)
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.feedData(event.data)
}
}
}
} catch {
print("Failed to load terminal snapshot: \(error)")
logger.error("Failed to load terminal snapshot: \(error)")
}
}
@ -659,22 +696,22 @@ class TerminalViewModel {
switch event {
case .header(let width, let height):
// Initial terminal setup
print("Terminal initialized: \(width)x\(height)")
logger.info("Terminal initialized: \(width)x\(height)")
terminalCols = width
terminalRows = height
// The terminal will be resized when created
case .output(_, let data):
// Feed output data directly to the terminal
if let coordinator = terminalCoordinator {
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.feedData(data)
} else {
// Queue the data to be fed once coordinator is ready
print("Warning: Terminal coordinator not ready, queueing data")
logger.warning("Terminal coordinator not ready, queueing data")
Task {
// Wait a bit for coordinator to be initialized
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
if let coordinator = self.terminalCoordinator {
if let coordinator = self.terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.feedData(data)
}
}
@ -691,7 +728,7 @@ class TerminalViewModel {
// Update terminal dimensions
terminalCols = cols
terminalRows = rows
print("Terminal resize: \(cols)x\(rows)")
logger.info("Terminal resize: \(cols)x\(rows)")
// Record resize event
castRecorder.recordResize(cols: cols, rows: rows)
}
@ -706,7 +743,7 @@ class TerminalViewModel {
if castRecorder.isRecording {
stopRecording()
}
// Load final snapshot for exited session
Task { @MainActor in
// Give the server a moment to finalize the snapshot
@ -716,7 +753,7 @@ class TerminalViewModel {
case .bufferUpdate(let snapshot):
// Update terminal buffer directly
if let coordinator = terminalCoordinator {
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.updateBuffer(from: TerminalHostingView.BufferSnapshot(
cols: snapshot.cols,
rows: snapshot.rows,
@ -737,7 +774,7 @@ class TerminalViewModel {
))
} else {
// Fallback: buffer updates not available yet
print("Warning: Direct buffer update not available")
logger.warning("Direct buffer update not available")
}
case .bell:
@ -755,7 +792,7 @@ class TerminalViewModel {
do {
try await SessionService.shared.sendInput(to: session.id, text: text)
} catch {
print("Failed to send input: \(error)")
logger.error("Failed to send input: \(error)")
}
}
}
@ -767,7 +804,7 @@ class TerminalViewModel {
// If resize succeeded, ensure the flag is cleared
isResizeBlockedByServer = false
} catch {
print("Failed to resize terminal: \(error)")
logger.error("Failed to resize terminal: \(error)")
// Check if the error is specifically about resize being disabled
if case APIError.resizeDisabledByServer = error {
isResizeBlockedByServer = true
@ -786,10 +823,10 @@ class TerminalViewModel {
// Terminal copy is handled by SwiftTerm's built-in functionality
HapticFeedback.notification(.success)
}
func getBufferContent() -> String? {
// Get the current terminal buffer content
if let coordinator = terminalCoordinator {
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
return coordinator.getBufferContent()
}
return nil
@ -810,7 +847,7 @@ class TerminalViewModel {
@MainActor
private func handleTerminalAlert(title: String?, message: String) {
// Log the alert
print("[Terminal Alert] \(title ?? "Alert"): \(message)")
logger.info("Terminal Alert - \(title ?? "Alert"): \(message)")
// Show as a system notification if app is in background
// For now, just provide haptic feedback
@ -822,7 +859,9 @@ class TerminalViewModel {
isAutoScrollEnabled = true
isAtBottom = true
// The actual scrolling is handled by the terminal coordinator
terminalCoordinator?.scrollToBottom()
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.scrollToBottom()
}
}
func updateScrollState(isAtBottom: Bool) {
@ -845,20 +884,22 @@ class TerminalViewModel {
resize(cols: optimalCols, rows: terminalRows)
}
}
func setMaxWidth(_ maxWidth: Int) {
// Store the max width preference
// When maxWidth is 0, it means unlimited
let targetWidth = maxWidth == 0 ? nil : maxWidth
if let width = targetWidth, width != terminalCols {
// Maintain aspect ratio when changing width
let aspectRatio = Double(terminalRows) / Double(terminalCols)
let newHeight = Int(Double(width) * aspectRatio)
resize(cols: width, rows: newHeight)
}
// Update the terminal coordinator if using constrained width
terminalCoordinator?.setMaxWidth(maxWidth)
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.setMaxWidth(maxWidth)
}
}
}

View file

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

View file

@ -6,7 +6,7 @@ struct WidthSelectorPopover: View {
@Binding var isPresented: Bool
@State private var customWidth: String = ""
@State private var showCustomInput = false
var body: some View {
NavigationStack {
List {
@ -14,20 +14,19 @@ struct WidthSelectorPopover: View {
ForEach(TerminalWidth.allCases, id: \.value) { width in
WidthPresetRow(
width: width,
isSelected: currentWidth.value == width.value,
onSelect: {
currentWidth = width
HapticFeedback.impact(.light)
isPresented = false
}
)
isSelected: currentWidth.value == width.value
) {
currentWidth = width
HapticFeedback.impact(.light)
isPresented = false
}
}
}
Section {
Button(action: {
showCustomInput = true
}) {
}, label: {
HStack {
Image(systemName: "square.and.pencil")
.font(.system(size: 16))
@ -38,9 +37,9 @@ struct WidthSelectorPopover: View {
Spacer()
}
.padding(.vertical, 4)
}
})
}
// Show recent custom widths if any
let customWidths = TerminalWidthManager.shared.customWidths
if !customWidths.isEmpty {
@ -51,13 +50,12 @@ struct WidthSelectorPopover: View {
ForEach(customWidths, id: \.self) { width in
WidthPresetRow(
width: .custom(width),
isSelected: currentWidth.value == width && !currentWidth.isPreset,
onSelect: {
currentWidth = .custom(width)
HapticFeedback.impact(.light)
isPresented = false
}
)
isSelected: currentWidth.value == width && !currentWidth.isPreset
) {
currentWidth = .custom(width)
HapticFeedback.impact(.light)
isPresented = false
}
}
}
}
@ -78,17 +76,16 @@ struct WidthSelectorPopover: View {
.frame(width: 320, height: 400)
.sheet(isPresented: $showCustomInput) {
CustomWidthSheet(
customWidth: $customWidth,
onSave: { width in
if let intWidth = Int(width), intWidth >= 20 && intWidth <= 500 {
currentWidth = .custom(intWidth)
TerminalWidthManager.shared.addCustomWidth(intWidth)
HapticFeedback.notification(.success)
showCustomInput = false
isPresented = false
}
customWidth: $customWidth
) { width in
if let intWidth = Int(width), intWidth >= 20 && intWidth <= 500 {
currentWidth = .custom(intWidth)
TerminalWidthManager.shared.addCustomWidth(intWidth)
HapticFeedback.notification(.success)
showCustomInput = false
isPresented = false
}
)
}
}
}
}
@ -98,7 +95,7 @@ private struct WidthPresetRow: View {
let width: TerminalWidth
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack {
@ -108,21 +105,21 @@ private struct WidthPresetRow: View {
.font(Theme.Typography.terminalSystem(size: 16))
.fontWeight(.medium)
.foregroundColor(Theme.Colors.terminalForeground)
if width.value > 0 {
Text("columns")
.font(.caption)
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
}
}
Text(width.description)
.font(.caption)
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 20))
@ -139,9 +136,10 @@ private struct WidthPresetRow: View {
private struct CustomWidthSheet: View {
@Binding var customWidth: String
let onSave: (String) -> Void
@Environment(\.dismiss) var dismiss
@Environment(\.dismiss)
var dismiss
@FocusState private var isFocused: Bool
var body: some View {
NavigationStack {
VStack(spacing: Theme.Spacing.large) {
@ -150,7 +148,7 @@ private struct CustomWidthSheet: View {
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal)
HStack {
TextField("Width", text: $customWidth)
.font(Theme.Typography.terminalSystem(size: 24))
@ -162,12 +160,12 @@ private struct CustomWidthSheet: View {
.padding()
.background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.medium)
Text("columns")
.font(.body)
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
}
Spacer()
}
.padding(.top, Theme.Spacing.extraLarge)
@ -180,7 +178,7 @@ private struct CustomWidthSheet: View {
}
.foregroundColor(Theme.Colors.primaryAccent)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
onSave(customWidth)
@ -195,4 +193,4 @@ private struct CustomWidthSheet: View {
isFocused = true
}
}
}
}

View file

@ -9,56 +9,56 @@ struct XtermWebView: UIViewRepresentable {
let onInput: (String) -> Void
let onResize: (Int, Int) -> Void
var viewModel: TerminalViewModel
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.allowsInlineMediaPlayback = true
configuration.userContentController = WKUserContentController()
// Add message handlers
configuration.userContentController.add(context.coordinator, name: "terminalInput")
configuration.userContentController.add(context.coordinator, name: "terminalResize")
configuration.userContentController.add(context.coordinator, name: "terminalReady")
configuration.userContentController.add(context.coordinator, name: "terminalLog")
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.isOpaque = false
webView.backgroundColor = UIColor(theme.background)
webView.scrollView.isScrollEnabled = false
context.coordinator.webView = webView
context.coordinator.loadTerminal()
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
// Update font size
context.coordinator.updateFontSize(fontSize)
// Update theme
context.coordinator.updateTheme(theme)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
let parent: XtermWebView
weak var webView: WKWebView?
private var bufferWebSocketClient: BufferWebSocketClient?
private let logger = Logger(category: "XtermWebView")
private var sseClient: SSEClient?
init(_ parent: XtermWebView) {
self.parent = parent
super.init()
}
func loadTerminal() {
guard let webView = webView else { return }
guard let webView else { return }
let html = """
<!DOCTYPE html>
<html>
@ -66,14 +66,14 @@ struct XtermWebView: UIViewRepresentable {
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #000;
body {
background: #000;
overflow: hidden;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
#terminal {
width: 100vw;
#terminal {
width: 100vw;
height: 100vh;
}
.xterm { height: 100%; }
@ -91,11 +91,11 @@ struct XtermWebView: UIViewRepresentable {
let fitAddon;
let buffer = [];
let isReady = false;
function log(message) {
window.webkit.messageHandlers.terminalLog.postMessage(message);
}
function initTerminal() {
term = new Terminal({
fontSize: \(parent.fontSize),
@ -127,15 +127,15 @@ struct XtermWebView: UIViewRepresentable {
cursorBlink: true,
scrollback: 10000
});
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(webLinksAddon);
term.open(document.getElementById('terminal'));
// Fit terminal to container
setTimeout(() => {
fitAddon.fit();
@ -147,28 +147,28 @@ struct XtermWebView: UIViewRepresentable {
});
}
}, 0);
// Handle input
term.onData(data => {
window.webkit.messageHandlers.terminalInput.postMessage(data);
});
// Handle resize
term.onResize(({ cols, rows }) => {
window.webkit.messageHandlers.terminalResize.postMessage({ cols, rows });
});
// Process buffered data
isReady = true;
buffer.forEach(data => writeToTerminal(data));
buffer = [];
// Notify ready
window.webkit.messageHandlers.terminalReady.postMessage({});
log('Terminal initialized');
}
function writeToTerminal(data) {
if (!isReady) {
buffer.push(data);
@ -176,38 +176,38 @@ struct XtermWebView: UIViewRepresentable {
}
term.write(data);
}
function updateFontSize(size) {
if (term) {
term.options.fontSize = size;
fitAddon.fit();
}
}
function updateTheme(theme) {
if (term && theme) {
term.options.theme = theme;
}
}
function scrollToBottom() {
if (term) {
term.scrollToBottom();
}
}
function clear() {
if (term) {
term.clear();
}
}
function resize() {
if (fitAddon) {
fitAddon.fit();
}
}
// Expose functions to native
window.xtermAPI = {
writeToTerminal,
@ -217,10 +217,10 @@ struct XtermWebView: UIViewRepresentable {
clear,
resize
};
// Initialize terminal when page loads
window.addEventListener('load', initTerminal);
// Handle window resize
window.addEventListener('resize', () => {
if (fitAddon) {
@ -233,52 +233,55 @@ struct XtermWebView: UIViewRepresentable {
</body>
</html>
"""
webView.loadHTMLString(html, baseURL: nil)
webView.navigationDelegate = self
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
switch message.name {
case "terminalInput":
if let data = message.body as? String {
parent.onInput(data)
}
case "terminalResize":
if let dict = message.body as? [String: Any],
let cols = dict["cols"] as? Int,
let rows = dict["rows"] as? Int {
parent.onResize(cols, rows)
}
case "terminalReady":
setupDataStreaming()
case "terminalLog":
if let log = message.body as? String {
logger.debug(log)
}
default:
break
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
logger.info("Page loaded")
}
private func setupDataStreaming() {
// Subscribe to WebSocket buffer updates
if bufferWebSocketClient == nil {
bufferWebSocketClient = parent.viewModel.bufferWebSocketClient
}
bufferWebSocketClient?.subscribe(to: parent.session.id) { [weak self] event in
self?.handleWebSocketEvent(event)
}
// Also set up SSE as fallback
if let streamURL = APIClient.shared.streamURL(for: parent.session.id) {
sseClient = SSEClient(url: streamURL)
@ -286,29 +289,29 @@ struct XtermWebView: UIViewRepresentable {
sseClient?.start()
}
}
private func handleWebSocketEvent(_ event: TerminalWebSocketEvent) {
switch event {
case .bufferUpdate(let snapshot):
// Convert buffer snapshot to terminal output
renderBufferSnapshot(snapshot)
case .output(_, let data):
writeToTerminal(data)
case .resize(_, _):
case .resize:
// Handle resize if needed
break
case .bell:
// Could play a sound or visual bell
break
default:
break
}
}
private func renderBufferSnapshot(_ snapshot: BufferSnapshot) {
// For now, we'll just write the text content
// In a full implementation, we'd convert the buffer cells to ANSI sequences
@ -321,25 +324,25 @@ struct XtermWebView: UIViewRepresentable {
}
writeToTerminal(output)
}
private func writeToTerminal(_ data: String) {
let escaped = data
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
webView?.evaluateJavaScript("window.xtermAPI.writeToTerminal('\(escaped)')") { _, error in
if let error = error {
if let error {
self.logger.error("Error writing to terminal: \(error)")
}
}
}
func updateFontSize(_ size: CGFloat) {
webView?.evaluateJavaScript("window.xtermAPI.updateFontSize(\(size))")
}
func updateTheme(_ theme: TerminalTheme) {
// Convert theme to xterm.js format
let themeJS = """
@ -352,11 +355,11 @@ struct XtermWebView: UIViewRepresentable {
"""
webView?.evaluateJavaScript("window.xtermAPI.updateTheme(\(themeJS))")
}
func scrollToBottom() {
webView?.evaluateJavaScript("window.xtermAPI.scrollToBottom()")
}
func clear() {
webView?.evaluateJavaScript("window.xtermAPI.clear()")
}
@ -364,25 +367,26 @@ struct XtermWebView: UIViewRepresentable {
}
// MARK: - SSEClientDelegate
@MainActor
extension XtermWebView.Coordinator: SSEClientDelegate {
nonisolated func sseClient(_ client: SSEClient, didReceiveEvent event: SSEClient.SSEEvent) {
Task { @MainActor in
switch event {
case .terminalOutput(_, let type, let data):
if type == "o" { // output
writeToTerminal(data)
switch event {
case .terminalOutput(_, let type, let data):
if type == "o" { // output
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 {
var hex: String {
let uiColor = UIColor(self)
@ -390,12 +394,14 @@ extension Color {
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return String(format: "#%02X%02X%02X",
Int(red * 255),
Int(green * 255),
Int(blue * 255))
return String(
format: "#%02X%02X%02X",
Int(red * 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()
}