mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
lots of work on iOS
This commit is contained in:
parent
9ee544b28b
commit
7531e6f12b
60 changed files with 4086 additions and 943 deletions
|
|
@ -17,7 +17,7 @@ excluded:
|
||||||
- ../mac/build
|
- ../mac/build
|
||||||
- ../ios/build
|
- ../ios/build
|
||||||
- Package.swift
|
- Package.swift
|
||||||
- *.xcodeproj
|
- "*.xcodeproj"
|
||||||
|
|
||||||
# Rule configuration
|
# Rule configuration
|
||||||
opt_in_rules:
|
opt_in_rules:
|
||||||
|
|
@ -128,9 +128,6 @@ custom_rules:
|
||||||
regex: '\bprint\('
|
regex: '\bprint\('
|
||||||
message: "Use proper logging instead of print statements"
|
message: "Use proper logging instead of print statements"
|
||||||
severity: warning
|
severity: warning
|
||||||
excluded:
|
|
||||||
- "*/Tests/*"
|
|
||||||
- "*/UITests/*"
|
|
||||||
|
|
||||||
analyzer_rules:
|
analyzer_rules:
|
||||||
- unused_import
|
- unused_import
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,15 @@ let package = Package(
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0")
|
.package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0"),
|
||||||
|
.package(url: "https://github.com/mhdhejazi/Dynamic.git", from: "1.2.0")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "VibeTunnelDependencies",
|
name: "VibeTunnelDependencies",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "SwiftTerm", package: "SwiftTerm")
|
.product(name: "SwiftTerm", package: "SwiftTerm"),
|
||||||
|
.product(name: "Dynamic", package: "Dynamic")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,10 @@
|
||||||
78868B612DFF808300B22C15 /* Exceptions for "VibeTunnel" folder in "VibeTunnel" target */ = {
|
78868B612DFF808300B22C15 /* Exceptions for "VibeTunnel" folder in "VibeTunnel" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
|
Local.xcconfig,
|
||||||
Resources/Info.plist,
|
Resources/Info.plist,
|
||||||
|
Shared.xcconfig,
|
||||||
|
version.xcconfig,
|
||||||
);
|
);
|
||||||
target = 788687F02DFF4FCB00B22C15 /* VibeTunnel */;
|
target = 788687F02DFF4FCB00B22C15 /* VibeTunnel */;
|
||||||
};
|
};
|
||||||
|
|
@ -219,8 +222,11 @@
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastSwiftUpdateCheck = 1600;
|
LastSwiftUpdateCheck = 1600;
|
||||||
LastUpgradeCheck = 1600;
|
LastUpgradeCheck = 2600;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
|
04469FB37E8A42F9D06BF670 = {
|
||||||
|
TestTargetID = 788687F02DFF4FCB00B22C15;
|
||||||
|
};
|
||||||
788687F02DFF4FCB00B22C15 = {
|
788687F02DFF4FCB00B22C15 = {
|
||||||
CreatedOnToolsVersion = 16.0;
|
CreatedOnToolsVersion = 16.0;
|
||||||
};
|
};
|
||||||
|
|
@ -399,6 +405,7 @@
|
||||||
6BD919EBC6E8FC8AE5C0AD08 /* Release */ = {
|
6BD919EBC6E8FC8AE5C0AD08 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
ENABLE_TESTING_FRAMEWORKS = YES;
|
ENABLE_TESTING_FRAMEWORKS = YES;
|
||||||
|
|
@ -408,6 +415,7 @@
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 6.0;
|
SWIFT_VERSION = 6.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VibeTunnel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VibeTunnel";
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
|
|
@ -492,6 +500,7 @@
|
||||||
AB5CCE958CDF40666B1D5C7F /* Debug */ = {
|
AB5CCE958CDF40666B1D5C7F /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
ENABLE_TESTING_FRAMEWORKS = YES;
|
ENABLE_TESTING_FRAMEWORKS = YES;
|
||||||
|
|
@ -501,6 +510,7 @@
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 6.0;
|
SWIFT_VERSION = 6.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VibeTunnel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VibeTunnel";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
|
@ -508,6 +518,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
|
@ -561,6 +572,7 @@
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
};
|
};
|
||||||
|
|
@ -570,6 +582,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
|
@ -616,6 +629,7 @@
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,15 @@ import UniformTypeIdentifiers
|
||||||
/// Displays either the connection view or session list based on
|
/// Displays either the connection view or session list based on
|
||||||
/// connection state, and handles opening cast files.
|
/// connection state, and handles opening cast files.
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(ConnectionManager.self) var connectionManager
|
@Environment(ConnectionManager.self)
|
||||||
|
var connectionManager
|
||||||
@State private var showingFilePicker = false
|
@State private var showingFilePicker = false
|
||||||
@State private var showingCastPlayer = false
|
@State private var showingCastPlayer = false
|
||||||
@State private var selectedCastFile: URL?
|
@State private var selectedCastFile: URL?
|
||||||
@State private var isValidatingConnection = true
|
@State private var isValidatingConnection = true
|
||||||
|
@State private var showingWelcome = false
|
||||||
|
@AppStorage("welcomeCompleted")
|
||||||
|
private var welcomeCompleted = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
|
|
@ -30,12 +34,20 @@ struct ContentView: View {
|
||||||
} else if connectionManager.isConnected, connectionManager.serverConfig != nil {
|
} else if connectionManager.isConnected, connectionManager.serverConfig != nil {
|
||||||
SessionListView()
|
SessionListView()
|
||||||
} else {
|
} else {
|
||||||
ConnectionView()
|
EnhancedConnectionView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.default, value: connectionManager.isConnected)
|
.animation(.default, value: connectionManager.isConnected)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
validateRestoredConnection()
|
validateRestoredConnection()
|
||||||
|
|
||||||
|
// Show welcome on first launch
|
||||||
|
if !welcomeCompleted {
|
||||||
|
showingWelcome = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $showingWelcome) {
|
||||||
|
WelcomeView()
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
// Handle cast file opening
|
// Handle cast file opening
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ struct VibeTunnelApp: App {
|
||||||
@State private var connectionManager = ConnectionManager()
|
@State private var connectionManager = ConnectionManager()
|
||||||
@State private var navigationManager = NavigationManager()
|
@State private var navigationManager = NavigationManager()
|
||||||
@State private var networkMonitor = NetworkMonitor.shared
|
@State private var networkMonitor = NetworkMonitor.shared
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Configure app logging level
|
// Configure app logging level
|
||||||
AppConfig.configureLogging()
|
AppConfig.configureLogging()
|
||||||
|
|
@ -26,8 +26,18 @@ struct VibeTunnelApp: App {
|
||||||
// Initialize network monitoring
|
// Initialize network monitoring
|
||||||
_ = networkMonitor
|
_ = networkMonitor
|
||||||
}
|
}
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
.macCatalystWindowStyle(getStoredWindowStyle())
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
private func getStoredWindowStyle() -> MacWindowStyle {
|
||||||
|
let styleRaw = UserDefaults.standard.string(forKey: "macWindowStyle") ?? "standard"
|
||||||
|
return styleRaw == "inline" ? .inline : .standard
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private func handleURL(_ url: URL) {
|
private func handleURL(_ url: URL) {
|
||||||
// Handle vibetunnel://session/{sessionId} URLs
|
// Handle vibetunnel://session/{sessionId} URLs
|
||||||
|
|
@ -112,8 +122,7 @@ class ConnectionManager {
|
||||||
|
|
||||||
/// Make ConnectionManager accessible globally for APIClient
|
/// Make ConnectionManager accessible globally for APIClient
|
||||||
extension ConnectionManager {
|
extension ConnectionManager {
|
||||||
@MainActor
|
@MainActor static let shared = ConnectionManager()
|
||||||
static let shared = ConnectionManager()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manages app-wide navigation state.
|
/// Manages app-wide navigation state.
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// App configuration for VibeTunnel
|
/// App configuration for VibeTunnel
|
||||||
struct AppConfig {
|
enum AppConfig {
|
||||||
/// Set the logging level for the app
|
/// Set the logging level for the app
|
||||||
/// Change this to control verbosity of logs
|
/// Change this to control verbosity of logs
|
||||||
static func configureLogging() {
|
static func configureLogging() {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// In debug builds, you can change this to .verbose to see all logs
|
// In debug builds, default to info level to reduce noise
|
||||||
Logger.globalLevel = .info // Change to .verbose for detailed logging
|
// Change to .verbose only when debugging binary protocol issues
|
||||||
|
Logger.globalLevel = .info
|
||||||
#else
|
#else
|
||||||
// In release builds, only show warnings and errors
|
// In release builds, only show warnings and errors
|
||||||
Logger.globalLevel = .warning
|
Logger.globalLevel = .warning
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,16 @@ struct FileEntry: Codable, Identifiable {
|
||||||
/// - modTime: The modification time
|
/// - modTime: The modification time
|
||||||
/// - isGitTracked: Whether the file is in a git repository
|
/// - isGitTracked: Whether the file is in a git repository
|
||||||
/// - gitStatus: The git status of the file
|
/// - gitStatus: The git status of the file
|
||||||
init(name: String, path: String, isDir: Bool, size: Int64, mode: String, modTime: Date, isGitTracked: Bool? = nil, gitStatus: GitFileStatus? = nil) {
|
init(
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
isDir: Bool,
|
||||||
|
size: Int64,
|
||||||
|
mode: String,
|
||||||
|
modTime: Date,
|
||||||
|
isGitTracked: Bool? = nil,
|
||||||
|
gitStatus: GitFileStatus? = nil
|
||||||
|
) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.path = path
|
self.path = path
|
||||||
self.isDir = isDir
|
self.isDir = isDir
|
||||||
|
|
@ -134,13 +143,13 @@ struct GitStatus: Codable {
|
||||||
struct DirectoryListing: Codable {
|
struct DirectoryListing: Codable {
|
||||||
/// The absolute path of the directory being listed.
|
/// The absolute path of the directory being listed.
|
||||||
let absolutePath: String
|
let absolutePath: String
|
||||||
|
|
||||||
/// Array of file and subdirectory entries in this directory.
|
/// Array of file and subdirectory entries in this directory.
|
||||||
let files: [FileEntry]
|
let files: [FileEntry]
|
||||||
|
|
||||||
/// Git status information for the directory
|
/// Git status information for the directory
|
||||||
let gitStatus: GitStatus?
|
let gitStatus: GitStatus?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case absolutePath = "fullPath"
|
case absolutePath = "fullPath"
|
||||||
case files
|
case files
|
||||||
|
|
|
||||||
132
ios/VibeTunnel/Models/ServerProfile.swift
Normal file
132
ios/VibeTunnel/Models/ServerProfile.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import Foundation
|
||||||
/// and terminal dimensions.
|
/// and terminal dimensions.
|
||||||
struct Session: Codable, Identifiable, Equatable, Hashable {
|
struct Session: Codable, Identifiable, Equatable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let command: [String] // Changed from String to [String] to match server
|
let command: [String] // Changed from String to [String] to match server
|
||||||
let workingDir: String
|
let workingDir: String
|
||||||
let name: String?
|
let name: String?
|
||||||
let status: SessionStatus
|
let status: SessionStatus
|
||||||
|
|
@ -15,12 +15,12 @@ struct Session: Codable, Identifiable, Equatable, Hashable {
|
||||||
let startedAt: String
|
let startedAt: String
|
||||||
let lastModified: String?
|
let lastModified: String?
|
||||||
let pid: Int?
|
let pid: Int?
|
||||||
|
|
||||||
// Terminal dimensions
|
// Terminal dimensions
|
||||||
let width: Int?
|
let width: Int?
|
||||||
let height: Int?
|
let height: Int?
|
||||||
let waiting: Bool?
|
let waiting: Bool?
|
||||||
|
|
||||||
// Optional fields from HQ mode
|
// Optional fields from HQ mode
|
||||||
let source: String?
|
let source: String?
|
||||||
let remoteId: String?
|
let remoteId: String?
|
||||||
|
|
@ -50,7 +50,7 @@ struct Session: Codable, Identifiable, Equatable, Hashable {
|
||||||
///
|
///
|
||||||
/// Returns the custom name if not empty, otherwise the command.
|
/// Returns the custom name if not empty, otherwise the command.
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
if let name = name, !name.isEmpty {
|
if let name, !name.isEmpty {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
return command.joined(separator: " ")
|
return command.joined(separator: " ")
|
||||||
|
|
|
||||||
|
|
@ -4,30 +4,30 @@ import Foundation
|
||||||
enum TerminalRenderer: String, CaseIterable, Codable {
|
enum TerminalRenderer: String, CaseIterable, Codable {
|
||||||
case swiftTerm = "SwiftTerm"
|
case swiftTerm = "SwiftTerm"
|
||||||
case xterm = "xterm.js"
|
case xterm = "xterm.js"
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .swiftTerm:
|
case .swiftTerm:
|
||||||
return "SwiftTerm (Native)"
|
"SwiftTerm (Native)"
|
||||||
case .xterm:
|
case .xterm:
|
||||||
return "xterm.js (WebView)"
|
"xterm.js (WebView)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .swiftTerm:
|
case .swiftTerm:
|
||||||
return "Native Swift terminal emulator with best performance"
|
"Native Swift terminal emulator with best performance"
|
||||||
case .xterm:
|
case .xterm:
|
||||||
return "JavaScript-based terminal, identical to web version"
|
"JavaScript-based terminal, identical to web version"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The currently selected renderer (persisted in UserDefaults)
|
/// The currently selected renderer (persisted in UserDefaults)
|
||||||
static var selected: TerminalRenderer {
|
static var selected: Self {
|
||||||
get {
|
get {
|
||||||
if let rawValue = UserDefaults.standard.string(forKey: "selectedTerminalRenderer"),
|
if let rawValue = UserDefaults.standard.string(forKey: "selectedTerminalRenderer"),
|
||||||
let renderer = TerminalRenderer(rawValue: rawValue) {
|
let renderer = Self(rawValue: rawValue) {
|
||||||
return renderer
|
return renderer
|
||||||
}
|
}
|
||||||
return .swiftTerm // Default
|
return .swiftTerm // Default
|
||||||
|
|
@ -36,4 +36,4 @@ enum TerminalRenderer: String, CaseIterable, Codable {
|
||||||
UserDefaults.standard.set(newValue.rawValue, forKey: "selectedTerminalRenderer")
|
UserDefaults.standard.set(newValue.rawValue, forKey: "selectedTerminalRenderer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,64 +9,64 @@ enum TerminalWidth: CaseIterable, Equatable {
|
||||||
case mainframe132
|
case mainframe132
|
||||||
case ultraWide160
|
case ultraWide160
|
||||||
case custom(Int)
|
case custom(Int)
|
||||||
|
|
||||||
var value: Int {
|
var value: Int {
|
||||||
switch self {
|
switch self {
|
||||||
case .unlimited: return 0
|
case .unlimited: 0
|
||||||
case .classic80: return 80
|
case .classic80: 80
|
||||||
case .modern100: return 100
|
case .modern100: 100
|
||||||
case .wide120: return 120
|
case .wide120: 120
|
||||||
case .mainframe132: return 132
|
case .mainframe132: 132
|
||||||
case .ultraWide160: return 160
|
case .ultraWide160: 160
|
||||||
case .custom(let width): return width
|
case .custom(let width): width
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .unlimited: return "∞"
|
case .unlimited: "∞"
|
||||||
case .classic80: return "80"
|
case .classic80: "80"
|
||||||
case .modern100: return "100"
|
case .modern100: "100"
|
||||||
case .wide120: return "120"
|
case .wide120: "120"
|
||||||
case .mainframe132: return "132"
|
case .mainframe132: "132"
|
||||||
case .ultraWide160: return "160"
|
case .ultraWide160: "160"
|
||||||
case .custom(let width): return "\(width)"
|
case .custom(let width): "\(width)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .unlimited: return "Unlimited"
|
case .unlimited: "Unlimited"
|
||||||
case .classic80: return "Classic terminal"
|
case .classic80: "Classic terminal"
|
||||||
case .modern100: return "Modern standard"
|
case .modern100: "Modern standard"
|
||||||
case .wide120: return "Wide terminal"
|
case .wide120: "Wide terminal"
|
||||||
case .mainframe132: return "Mainframe width"
|
case .mainframe132: "Mainframe width"
|
||||||
case .ultraWide160: return "Ultra-wide"
|
case .ultraWide160: "Ultra-wide"
|
||||||
case .custom: return "Custom width"
|
case .custom: "Custom width"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static var allCases: [TerminalWidth] {
|
static var allCases: [Self] {
|
||||||
[.unlimited, .classic80, .modern100, .wide120, .mainframe132, .ultraWide160]
|
[.unlimited, .classic80, .modern100, .wide120, .mainframe132, .ultraWide160]
|
||||||
}
|
}
|
||||||
|
|
||||||
static func from(value: Int) -> TerminalWidth {
|
static func from(value: Int) -> Self {
|
||||||
switch value {
|
switch value {
|
||||||
case 0: return .unlimited
|
case 0: .unlimited
|
||||||
case 80: return .classic80
|
case 80: .classic80
|
||||||
case 100: return .modern100
|
case 100: .modern100
|
||||||
case 120: return .wide120
|
case 120: .wide120
|
||||||
case 132: return .mainframe132
|
case 132: .mainframe132
|
||||||
case 160: return .ultraWide160
|
case 160: .ultraWide160
|
||||||
default: return .custom(value)
|
default: .custom(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if this is a standard preset width
|
/// Check if this is a standard preset width
|
||||||
var isPreset: Bool {
|
var isPreset: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .custom: return false
|
case .custom: false
|
||||||
default: return true
|
default: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,12 +75,12 @@ enum TerminalWidth: CaseIterable, Equatable {
|
||||||
@MainActor
|
@MainActor
|
||||||
class TerminalWidthManager {
|
class TerminalWidthManager {
|
||||||
static let shared = TerminalWidthManager()
|
static let shared = TerminalWidthManager()
|
||||||
|
|
||||||
private let defaultWidthKey = "defaultTerminalWidth"
|
private let defaultWidthKey = "defaultTerminalWidth"
|
||||||
private let customWidthsKey = "customTerminalWidths"
|
private let customWidthsKey = "customTerminalWidths"
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
/// Get the default terminal width
|
/// Get the default terminal width
|
||||||
var defaultWidth: Int {
|
var defaultWidth: Int {
|
||||||
get {
|
get {
|
||||||
|
|
@ -90,7 +90,7 @@ class TerminalWidthManager {
|
||||||
UserDefaults.standard.set(newValue, forKey: defaultWidthKey)
|
UserDefaults.standard.set(newValue, forKey: defaultWidthKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get saved custom widths
|
/// Get saved custom widths
|
||||||
var customWidths: [Int] {
|
var customWidths: [Int] {
|
||||||
get {
|
get {
|
||||||
|
|
@ -100,7 +100,7 @@ class TerminalWidthManager {
|
||||||
UserDefaults.standard.set(newValue, forKey: customWidthsKey)
|
UserDefaults.standard.set(newValue, forKey: customWidthsKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a custom width to saved list
|
/// Add a custom width to saved list
|
||||||
func addCustomWidth(_ width: Int) {
|
func addCustomWidth(_ width: Int) {
|
||||||
var widths = customWidths
|
var widths = customWidths
|
||||||
|
|
@ -113,7 +113,7 @@ class TerminalWidthManager {
|
||||||
customWidths = widths
|
customWidths = widths
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all available widths including custom ones
|
/// Get all available widths including custom ones
|
||||||
func allWidths() -> [TerminalWidth] {
|
func allWidths() -> [TerminalWidth] {
|
||||||
var widths = TerminalWidth.allCases
|
var widths = TerminalWidth.allCases
|
||||||
|
|
@ -124,4 +124,4 @@ class TerminalWidthManager {
|
||||||
}
|
}
|
||||||
return widths
|
return widths
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 954 KiB |
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
private let logger = Logger(category: "APIClient")
|
||||||
|
|
||||||
/// Errors that can occur during API operations.
|
/// Errors that can occur during API operations.
|
||||||
enum APIError: LocalizedError {
|
enum APIError: LocalizedError {
|
||||||
case invalidURL
|
case invalidURL
|
||||||
|
|
@ -120,15 +122,15 @@ class APIClient: APIClientProtocol {
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if let jsonString = String(data: data, encoding: .utf8) {
|
if let jsonString = String(data: data, encoding: .utf8) {
|
||||||
print("[APIClient] getSessions response: \(jsonString)")
|
logger.debug("getSessions response: \(jsonString)")
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
return try decoder.decode([Session].self, from: data)
|
return try decoder.decode([Session].self, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
print("[APIClient] Decoding error: \(error)")
|
logger.error("Decoding error: \(error)")
|
||||||
if let decodingError = error as? DecodingError {
|
if let decodingError = error as? DecodingError {
|
||||||
print("[APIClient] Decoding error details: \(decodingError)")
|
logger.error("Decoding error details: \(decodingError)")
|
||||||
}
|
}
|
||||||
throw APIError.decodingError(error)
|
throw APIError.decodingError(error)
|
||||||
}
|
}
|
||||||
|
|
@ -153,12 +155,12 @@ class APIClient: APIClientProtocol {
|
||||||
|
|
||||||
func createSession(_ data: SessionCreateData) async throws -> String {
|
func createSession(_ data: SessionCreateData) async throws -> String {
|
||||||
guard let baseURL else {
|
guard let baseURL else {
|
||||||
print("[APIClient] No server configured")
|
logger.error("No server configured")
|
||||||
throw APIError.noServerConfigured
|
throw APIError.noServerConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = baseURL.appendingPathComponent("api/sessions")
|
let url = baseURL.appendingPathComponent("api/sessions")
|
||||||
print("[APIClient] Creating session at URL: \(url)")
|
logger.debug("Creating session at URL: \(url)")
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
|
|
@ -168,24 +170,24 @@ class APIClient: APIClientProtocol {
|
||||||
do {
|
do {
|
||||||
request.httpBody = try encoder.encode(data)
|
request.httpBody = try encoder.encode(data)
|
||||||
if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) {
|
if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) {
|
||||||
print("[APIClient] Request body: \(bodyString)")
|
logger.debug("Request body: \(bodyString)")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("[APIClient] Failed to encode session data: \(error)")
|
logger.error("Failed to encode session data: \(error)")
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (responseData, response) = try await session.data(for: request)
|
let (responseData, response) = try await session.data(for: request)
|
||||||
|
|
||||||
print("[APIClient] Response received")
|
logger.debug("Response received")
|
||||||
if let httpResponse = response as? HTTPURLResponse {
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
print("[APIClient] Status code: \(httpResponse.statusCode)")
|
logger.debug("Status code: \(httpResponse.statusCode)")
|
||||||
print("[APIClient] Headers: \(httpResponse.allHeaderFields)")
|
logger.debug("Headers: \(httpResponse.allHeaderFields)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let responseString = String(data: responseData, encoding: .utf8) {
|
if let responseString = String(data: responseData, encoding: .utf8) {
|
||||||
print("[APIClient] Response body: \(responseString)")
|
logger.debug("Response body: \(responseString)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the response is an error
|
// Check if the response is an error
|
||||||
|
|
@ -199,7 +201,7 @@ class APIClient: APIClientProtocol {
|
||||||
|
|
||||||
if let errorResponse = try? decoder.decode(ErrorResponse.self, from: responseData) {
|
if let errorResponse = try? decoder.decode(ErrorResponse.self, from: responseData) {
|
||||||
let errorMessage = errorResponse.details ?? errorResponse.error ?? "Unknown error"
|
let errorMessage = errorResponse.details ?? errorResponse.error ?? "Unknown error"
|
||||||
print("[APIClient] Server error: \(errorMessage)")
|
logger.error("Server error: \(errorMessage)")
|
||||||
throw APIError.serverError(httpResponse.statusCode, errorMessage)
|
throw APIError.serverError(httpResponse.statusCode, errorMessage)
|
||||||
} else {
|
} else {
|
||||||
// Fallback to generic error
|
// Fallback to generic error
|
||||||
|
|
@ -212,12 +214,12 @@ class APIClient: APIClientProtocol {
|
||||||
}
|
}
|
||||||
|
|
||||||
let createResponse = try decoder.decode(CreateResponse.self, from: responseData)
|
let createResponse = try decoder.decode(CreateResponse.self, from: responseData)
|
||||||
print("[APIClient] Session created with ID: \(createResponse.sessionId)")
|
logger.info("Session created with ID: \(createResponse.sessionId)")
|
||||||
return createResponse.sessionId
|
return createResponse.sessionId
|
||||||
} catch {
|
} catch {
|
||||||
print("[APIClient] Request failed: \(error)")
|
logger.error("Request failed: \(error)")
|
||||||
if let urlError = error as? URLError {
|
if let urlError = error as? URLError {
|
||||||
print("[APIClient] URL Error code: \(urlError.code), description: \(urlError.localizedDescription)")
|
logger.error("URL Error code: \(urlError.code), description: \(urlError.localizedDescription)")
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
@ -453,12 +455,12 @@ class APIClient: APIClientProtocol {
|
||||||
|
|
||||||
private func validateResponse(_ response: URLResponse) throws {
|
private func validateResponse(_ response: URLResponse) throws {
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
print("[APIClient] Invalid response type (not HTTP)")
|
logger.error("Invalid response type (not HTTP)")
|
||||||
throw APIError.networkError(URLError(.badServerResponse))
|
throw APIError.networkError(URLError(.badServerResponse))
|
||||||
}
|
}
|
||||||
|
|
||||||
guard 200..<300 ~= httpResponse.statusCode else {
|
guard 200..<300 ~= httpResponse.statusCode else {
|
||||||
print("[APIClient] Server error: HTTP \(httpResponse.statusCode)")
|
logger.error("Server error: HTTP \(httpResponse.statusCode)")
|
||||||
throw APIError.serverError(httpResponse.statusCode, nil)
|
throw APIError.serverError(httpResponse.statusCode, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -472,7 +474,12 @@ class APIClient: APIClientProtocol {
|
||||||
|
|
||||||
// MARK: - File System Operations
|
// MARK: - File System Operations
|
||||||
|
|
||||||
func browseDirectory(path: String, showHidden: Bool = false, gitFilter: String = "all") async throws -> DirectoryListing {
|
func browseDirectory(
|
||||||
|
path: String,
|
||||||
|
showHidden: Bool = false,
|
||||||
|
gitFilter: String = "all"
|
||||||
|
)
|
||||||
|
async throws -> DirectoryListing {
|
||||||
guard let baseURL else {
|
guard let baseURL else {
|
||||||
throw APIError.noServerConfigured
|
throw APIError.noServerConfigured
|
||||||
}
|
}
|
||||||
|
|
@ -503,10 +510,10 @@ class APIClient: APIClientProtocol {
|
||||||
|
|
||||||
// Log response for debugging
|
// Log response for debugging
|
||||||
if let httpResponse = response as? HTTPURLResponse {
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
print("[APIClient] Browse directory response: \(httpResponse.statusCode)")
|
logger.debug("Browse directory response: \(httpResponse.statusCode)")
|
||||||
if httpResponse.statusCode >= 400 {
|
if httpResponse.statusCode >= 400 {
|
||||||
if let errorString = String(data: data, encoding: .utf8) {
|
if let errorString = String(data: data, encoding: .utf8) {
|
||||||
print("[APIClient] Error response body: \(errorString)")
|
logger.error("Error response body: \(errorString)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -598,12 +605,12 @@ class APIClient: APIClientProtocol {
|
||||||
|
|
||||||
return try decoder.decode(FileInfo.self, from: data)
|
return try decoder.decode(FileInfo.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func previewFile(path: String) async throws -> FilePreview {
|
func previewFile(path: String) async throws -> FilePreview {
|
||||||
guard let baseURL else {
|
guard let baseURL else {
|
||||||
throw APIError.noServerConfigured
|
throw APIError.noServerConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
guard var components = URLComponents(
|
guard var components = URLComponents(
|
||||||
url: baseURL.appendingPathComponent("api/fs/preview"),
|
url: baseURL.appendingPathComponent("api/fs/preview"),
|
||||||
resolvingAgainstBaseURL: false
|
resolvingAgainstBaseURL: false
|
||||||
|
|
@ -611,26 +618,26 @@ class APIClient: APIClientProtocol {
|
||||||
throw APIError.invalidURL
|
throw APIError.invalidURL
|
||||||
}
|
}
|
||||||
components.queryItems = [URLQueryItem(name: "path", value: path)]
|
components.queryItems = [URLQueryItem(name: "path", value: path)]
|
||||||
|
|
||||||
guard let url = components.url else {
|
guard let url = components.url else {
|
||||||
throw APIError.invalidURL
|
throw APIError.invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
addAuthenticationIfNeeded(&request)
|
addAuthenticationIfNeeded(&request)
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: request)
|
let (data, response) = try await session.data(for: request)
|
||||||
try validateResponse(response)
|
try validateResponse(response)
|
||||||
|
|
||||||
return try decoder.decode(FilePreview.self, from: data)
|
return try decoder.decode(FilePreview.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGitDiff(path: String) async throws -> FileDiff {
|
func getGitDiff(path: String) async throws -> FileDiff {
|
||||||
guard let baseURL else {
|
guard let baseURL else {
|
||||||
throw APIError.noServerConfigured
|
throw APIError.noServerConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
guard var components = URLComponents(
|
guard var components = URLComponents(
|
||||||
url: baseURL.appendingPathComponent("api/fs/diff"),
|
url: baseURL.appendingPathComponent("api/fs/diff"),
|
||||||
resolvingAgainstBaseURL: false
|
resolvingAgainstBaseURL: false
|
||||||
|
|
@ -638,75 +645,76 @@ class APIClient: APIClientProtocol {
|
||||||
throw APIError.invalidURL
|
throw APIError.invalidURL
|
||||||
}
|
}
|
||||||
components.queryItems = [URLQueryItem(name: "path", value: path)]
|
components.queryItems = [URLQueryItem(name: "path", value: path)]
|
||||||
|
|
||||||
guard let url = components.url else {
|
guard let url = components.url else {
|
||||||
throw APIError.invalidURL
|
throw APIError.invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
addAuthenticationIfNeeded(&request)
|
addAuthenticationIfNeeded(&request)
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: request)
|
let (data, response) = try await session.data(for: request)
|
||||||
try validateResponse(response)
|
try validateResponse(response)
|
||||||
|
|
||||||
return try decoder.decode(FileDiff.self, from: data)
|
return try decoder.decode(FileDiff.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - System Logs
|
// MARK: - System Logs
|
||||||
|
|
||||||
func getLogsRaw() async throws -> String {
|
func getLogsRaw() async throws -> String {
|
||||||
guard let baseURL else {
|
guard let baseURL else {
|
||||||
throw APIError.noServerConfigured
|
throw APIError.noServerConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = baseURL.appendingPathComponent("api/logs/raw")
|
let url = baseURL.appendingPathComponent("api/logs/raw")
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
addAuthenticationIfNeeded(&request)
|
addAuthenticationIfNeeded(&request)
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: request)
|
let (data, response) = try await session.data(for: request)
|
||||||
try validateResponse(response)
|
try validateResponse(response)
|
||||||
|
|
||||||
guard let logContent = String(data: data, encoding: .utf8) else {
|
guard let logContent = String(data: data, encoding: .utf8) else {
|
||||||
throw APIError.invalidResponse
|
throw APIError.invalidResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
return logContent
|
return logContent
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLogsInfo() async throws -> LogsInfo {
|
func getLogsInfo() async throws -> LogsInfo {
|
||||||
guard let baseURL else {
|
guard let baseURL else {
|
||||||
throw APIError.noServerConfigured
|
throw APIError.noServerConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = baseURL.appendingPathComponent("api/logs/info")
|
let url = baseURL.appendingPathComponent("api/logs/info")
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
addAuthenticationIfNeeded(&request)
|
addAuthenticationIfNeeded(&request)
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: request)
|
let (data, response) = try await session.data(for: request)
|
||||||
try validateResponse(response)
|
try validateResponse(response)
|
||||||
|
|
||||||
return try decoder.decode(LogsInfo.self, from: data)
|
return try decoder.decode(LogsInfo.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearLogs() async throws {
|
func clearLogs() async throws {
|
||||||
guard let baseURL else {
|
guard let baseURL else {
|
||||||
throw APIError.noServerConfigured
|
throw APIError.noServerConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = baseURL.appendingPathComponent("api/logs/clear")
|
let url = baseURL.appendingPathComponent("api/logs/clear")
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "DELETE"
|
request.httpMethod = "DELETE"
|
||||||
addAuthenticationIfNeeded(&request)
|
addAuthenticationIfNeeded(&request)
|
||||||
|
|
||||||
let (_, response) = try await session.data(for: request)
|
let (_, response) = try await session.data(for: request)
|
||||||
try validateResponse(response)
|
try validateResponse(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - File Preview Types
|
// MARK: - File Preview Types
|
||||||
|
|
||||||
struct FilePreview: Codable {
|
struct FilePreview: Codable {
|
||||||
let type: FilePreviewType
|
let type: FilePreviewType
|
||||||
let content: String?
|
let content: String?
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ enum WebSocketError: Error {
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
class BufferWebSocketClient: NSObject {
|
class BufferWebSocketClient: NSObject {
|
||||||
|
static let shared = BufferWebSocketClient()
|
||||||
|
|
||||||
private let logger = Logger(category: "BufferWebSocket")
|
private let logger = Logger(category: "BufferWebSocket")
|
||||||
/// Magic byte for binary messages
|
/// Magic byte for binary messages
|
||||||
private static let bufferMagicByte: UInt8 = 0xBF
|
private static let bufferMagicByte: UInt8 = 0xBF
|
||||||
|
|
@ -70,7 +72,7 @@ class BufferWebSocketClient: NSObject {
|
||||||
}
|
}
|
||||||
return serverConfig.baseURL
|
return serverConfig.baseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
init(webSocketFactory: WebSocketFactory = DefaultWebSocketFactory()) {
|
init(webSocketFactory: WebSocketFactory = DefaultWebSocketFactory()) {
|
||||||
self.webSocketFactory = webSocketFactory
|
self.webSocketFactory = webSocketFactory
|
||||||
super.init()
|
super.init()
|
||||||
|
|
@ -108,7 +110,7 @@ class BufferWebSocketClient: NSObject {
|
||||||
|
|
||||||
// Build headers
|
// Build headers
|
||||||
var headers: [String: String] = [:]
|
var headers: [String: String] = [:]
|
||||||
|
|
||||||
// Add authentication header if needed
|
// Add authentication header if needed
|
||||||
if let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
|
if let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
|
||||||
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config),
|
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config),
|
||||||
|
|
@ -388,7 +390,10 @@ class BufferWebSocketClient: NSObject {
|
||||||
if offset < data.count {
|
if offset < data.count {
|
||||||
let typeByte = data[offset]
|
let typeByte = data[offset]
|
||||||
logger.verbose("Type byte: 0x\(String(format: "%02X", typeByte))")
|
logger.verbose("Type byte: 0x\(String(format: "%02X", typeByte))")
|
||||||
logger.verbose("Bits: hasExt=\((typeByte & 0x80) != 0), isUni=\((typeByte & 0x40) != 0), hasFg=\((typeByte & 0x20) != 0), hasBg=\((typeByte & 0x10) != 0), charType=\(typeByte & 0x03)")
|
logger
|
||||||
|
.verbose(
|
||||||
|
"Bits: hasExt=\((typeByte & 0x80) != 0), isUni=\((typeByte & 0x40) != 0), hasFg=\((typeByte & 0x20) != 0), hasBg=\((typeByte & 0x10) != 0), charType=\(typeByte & 0x03)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -526,10 +531,10 @@ class BufferWebSocketClient: NSObject {
|
||||||
logger.debug("RGB foreground decode failed: insufficient data")
|
logger.debug("RGB foreground decode failed: insufficient data")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let r = Int(data[currentOffset])
|
let red = Int(data[currentOffset])
|
||||||
let g = Int(data[currentOffset + 1])
|
let green = Int(data[currentOffset + 1])
|
||||||
let b = Int(data[currentOffset + 2])
|
let blue = Int(data[currentOffset + 2])
|
||||||
fg = (r << 16) | (g << 8) | b | 0xFF00_0000 // Add alpha for RGB
|
fg = (red << 16) | (green << 8) | blue | 0xFF00_0000 // Add alpha for RGB
|
||||||
currentOffset += 3
|
currentOffset += 3
|
||||||
} else {
|
} else {
|
||||||
// Palette color (1 byte)
|
// Palette color (1 byte)
|
||||||
|
|
@ -550,10 +555,10 @@ class BufferWebSocketClient: NSObject {
|
||||||
logger.debug("RGB background decode failed: insufficient data")
|
logger.debug("RGB background decode failed: insufficient data")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let r = Int(data[currentOffset])
|
let red = Int(data[currentOffset])
|
||||||
let g = Int(data[currentOffset + 1])
|
let green = Int(data[currentOffset + 1])
|
||||||
let b = Int(data[currentOffset + 2])
|
let blue = Int(data[currentOffset + 2])
|
||||||
bg = (r << 16) | (g << 8) | b | 0xFF00_0000 // Add alpha for RGB
|
bg = (red << 16) | (green << 8) | blue | 0xFF00_0000 // Add alpha for RGB
|
||||||
currentOffset += 3
|
currentOffset += 3
|
||||||
} else {
|
} else {
|
||||||
// Palette color (1 byte)
|
// Palette color (1 byte)
|
||||||
|
|
@ -718,7 +723,7 @@ extension BufferWebSocketClient: WebSocketDelegate {
|
||||||
isConnecting = false
|
isConnecting = false
|
||||||
reconnectAttempts = 0
|
reconnectAttempts = 0
|
||||||
startPingTask()
|
startPingTask()
|
||||||
|
|
||||||
// Re-subscribe to all sessions
|
// Re-subscribe to all sessions
|
||||||
Task {
|
Task {
|
||||||
for sessionId in subscriptions.keys {
|
for sessionId in subscriptions.keys {
|
||||||
|
|
@ -726,18 +731,22 @@ extension BufferWebSocketClient: WebSocketDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func webSocket(_ webSocket: WebSocketProtocol, didReceiveMessage message: WebSocketMessage) {
|
func webSocket(_ webSocket: WebSocketProtocol, didReceiveMessage message: WebSocketMessage) {
|
||||||
handleMessage(message)
|
handleMessage(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func webSocket(_ webSocket: WebSocketProtocol, didFailWithError error: Error) {
|
func webSocket(_ webSocket: WebSocketProtocol, didFailWithError error: Error) {
|
||||||
logger.error("Error: \(error)")
|
logger.error("Error: \(error)")
|
||||||
connectionError = error
|
connectionError = error
|
||||||
handleDisconnection()
|
handleDisconnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
func webSocketDidDisconnect(_ webSocket: WebSocketProtocol, closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
func webSocketDidDisconnect(
|
||||||
|
_ webSocket: WebSocketProtocol,
|
||||||
|
closeCode: URLSessionWebSocketTask.CloseCode,
|
||||||
|
reason: Data?
|
||||||
|
) {
|
||||||
logger.info("Disconnected with code: \(closeCode)")
|
logger.info("Disconnected with code: \(closeCode)")
|
||||||
handleDisconnection()
|
handleDisconnection()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
118
ios/VibeTunnel/Services/KeychainService.swift
Normal file
118
ios/VibeTunnel/Services/KeychainService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
193
ios/VibeTunnel/Services/LivePreviewManager.swift
Normal file
193
ios/VibeTunnel/Services/LivePreviewManager.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,15 +2,18 @@ import Foundation
|
||||||
import Network
|
import Network
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private let logger = Logger(category: "NetworkMonitor")
|
||||||
|
|
||||||
/// Monitors network connectivity and provides offline/online state
|
/// Monitors network connectivity and provides offline/online state
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NetworkMonitor: ObservableObject {
|
@Observable
|
||||||
|
final class NetworkMonitor {
|
||||||
static let shared = NetworkMonitor()
|
static let shared = NetworkMonitor()
|
||||||
|
|
||||||
@Published private(set) var isConnected = true
|
private(set) var isConnected = true
|
||||||
@Published private(set) var connectionType = NWInterface.InterfaceType.other
|
private(set) var connectionType = NWInterface.InterfaceType.other
|
||||||
@Published private(set) var isExpensive = false
|
private(set) var isExpensive = false
|
||||||
@Published private(set) var isConstrained = false
|
private(set) var isConstrained = false
|
||||||
|
|
||||||
private let monitor = NWPathMonitor()
|
private let monitor = NWPathMonitor()
|
||||||
private let queue = DispatchQueue(label: "NetworkMonitor")
|
private let queue = DispatchQueue(label: "NetworkMonitor")
|
||||||
|
|
@ -40,7 +43,7 @@ final class NetworkMonitor: ObservableObject {
|
||||||
|
|
||||||
// Log state changes
|
// Log state changes
|
||||||
if wasConnected != self.isConnected {
|
if wasConnected != self.isConnected {
|
||||||
print("[NetworkMonitor] Connection state changed: \(self.isConnected ? "Online" : "Offline")")
|
logger.info("Connection state changed: \(self.isConnected ? "Online" : "Offline")")
|
||||||
|
|
||||||
// Post notification for other parts of the app
|
// Post notification for other parts of the app
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
|
|
@ -121,7 +124,7 @@ extension Notification.Name {
|
||||||
// MARK: - View Modifier for Offline Banner
|
// MARK: - View Modifier for Offline Banner
|
||||||
|
|
||||||
struct OfflineBanner: ViewModifier {
|
struct OfflineBanner: ViewModifier {
|
||||||
@ObservedObject private var networkMonitor = NetworkMonitor.shared
|
@State private var networkMonitor = NetworkMonitor.shared
|
||||||
@State private var showBanner = false
|
@State private var showBanner = false
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
|
|
@ -132,17 +135,17 @@ struct OfflineBanner: ViewModifier {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "wifi.slash")
|
Image(systemName: "wifi.slash")
|
||||||
.foregroundColor(.white)
|
.foregroundColor(Theme.Colors.terminalBackground)
|
||||||
|
|
||||||
Text("No Internet Connection")
|
Text("No Internet Connection")
|
||||||
.foregroundColor(.white)
|
.foregroundColor(Theme.Colors.terminalBackground)
|
||||||
.font(.footnote.bold())
|
.font(.footnote.bold())
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(Color.red)
|
.background(Theme.Colors.errorAccent)
|
||||||
.animation(.easeInOut(duration: 0.3), value: showBanner)
|
.animation(.easeInOut(duration: 0.3), value: showBanner)
|
||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
|
||||||
|
|
@ -178,32 +181,32 @@ extension View {
|
||||||
// MARK: - Connection Status View
|
// MARK: - Connection Status View
|
||||||
|
|
||||||
struct ConnectionStatusView: View {
|
struct ConnectionStatusView: View {
|
||||||
@ObservedObject private var networkMonitor = NetworkMonitor.shared
|
@State private var networkMonitor = NetworkMonitor.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(networkMonitor.isConnected ? Color.green : Color.red)
|
.fill(networkMonitor.isConnected ? Theme.Colors.successAccent : Theme.Colors.errorAccent)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
|
|
||||||
Text(networkMonitor.isConnected ? "Online" : "Offline")
|
Text(networkMonitor.isConnected ? "Online" : "Offline")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(Theme.Colors.terminalGray)
|
||||||
|
|
||||||
if networkMonitor.isConnected {
|
if networkMonitor.isConnected {
|
||||||
switch networkMonitor.connectionType {
|
switch networkMonitor.connectionType {
|
||||||
case .wifi:
|
case .wifi:
|
||||||
Image(systemName: "wifi")
|
Image(systemName: "wifi")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(Theme.Colors.terminalGray)
|
||||||
case .cellular:
|
case .cellular:
|
||||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(Theme.Colors.terminalGray)
|
||||||
case .wiredEthernet:
|
case .wiredEthernet:
|
||||||
Image(systemName: "cable.connector")
|
Image(systemName: "cable.connector")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(Theme.Colors.terminalGray)
|
||||||
default:
|
default:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
|
|
@ -211,14 +214,14 @@ struct ConnectionStatusView: View {
|
||||||
if networkMonitor.isExpensive {
|
if networkMonitor.isExpensive {
|
||||||
Image(systemName: "dollarsign.circle")
|
Image(systemName: "dollarsign.circle")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(Theme.Colors.warningAccent)
|
||||||
.help("Connection may incur charges")
|
.help("Connection may incur charges")
|
||||||
}
|
}
|
||||||
|
|
||||||
if networkMonitor.isConstrained {
|
if networkMonitor.isConstrained {
|
||||||
Image(systemName: "tortoise")
|
Image(systemName: "tortoise")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(Theme.Colors.warningAccent)
|
||||||
.help("Low Data Mode is enabled")
|
.help("Low Data Mode is enabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
124
ios/VibeTunnel/Services/ReconnectionManager.swift
Normal file
124
ios/VibeTunnel/Services/ReconnectionManager.swift
Normal 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")
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
private let logger = Logger(category: "SSEClient")
|
||||||
|
|
||||||
/// Server-Sent Events (SSE) client for real-time terminal output streaming.
|
/// Server-Sent Events (SSE) client for real-time terminal output streaming.
|
||||||
///
|
///
|
||||||
/// SSEClient handles the text-based streaming protocol used by the VibeTunnel server
|
/// SSEClient handles the text-based streaming protocol used by the VibeTunnel server
|
||||||
|
|
@ -11,53 +13,53 @@ final class SSEClient: NSObject, @unchecked Sendable {
|
||||||
private let url: URL
|
private let url: URL
|
||||||
private var buffer = Data()
|
private var buffer = Data()
|
||||||
weak var delegate: SSEClientDelegate?
|
weak var delegate: SSEClientDelegate?
|
||||||
|
|
||||||
/// Events received from the SSE stream
|
/// Events received from the SSE stream
|
||||||
enum SSEEvent {
|
enum SSEEvent {
|
||||||
case terminalOutput(timestamp: Double, type: String, data: String)
|
case terminalOutput(timestamp: Double, type: String, data: String)
|
||||||
case exit(exitCode: Int, sessionId: String)
|
case exit(exitCode: Int, sessionId: String)
|
||||||
case error(String)
|
case error(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(url: URL) {
|
init(url: URL) {
|
||||||
self.url = url
|
self.url = url
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
let configuration = URLSessionConfiguration.default
|
let configuration = URLSessionConfiguration.default
|
||||||
configuration.timeoutIntervalForRequest = 0 // No timeout for SSE
|
configuration.timeoutIntervalForRequest = 0 // No timeout for SSE
|
||||||
configuration.timeoutIntervalForResource = 0
|
configuration.timeoutIntervalForResource = 0
|
||||||
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||||
|
|
||||||
self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: .main)
|
self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: .main)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func start() {
|
func start() {
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||||
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
|
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
|
||||||
|
|
||||||
// Add authentication if needed
|
// Add authentication if needed
|
||||||
if let authHeader = ConnectionManager.shared.currentServerConfig?.authorizationHeader {
|
if let authHeader = ConnectionManager.shared.currentServerConfig?.authorizationHeader {
|
||||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||||
}
|
}
|
||||||
|
|
||||||
task = session.dataTask(with: request)
|
task = session.dataTask(with: request)
|
||||||
task?.resume()
|
task?.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
task?.cancel()
|
task?.cancel()
|
||||||
task = nil
|
task = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processBuffer() {
|
private func processBuffer() {
|
||||||
// Convert buffer to string
|
// Convert buffer to string
|
||||||
guard let string = String(data: buffer, encoding: .utf8) else { return }
|
guard let string = String(data: buffer, encoding: .utf8) else { return }
|
||||||
|
|
||||||
// Split by double newline (SSE event separator)
|
// Split by double newline (SSE event separator)
|
||||||
let events = string.components(separatedBy: "\n\n")
|
let events = string.components(separatedBy: "\n\n")
|
||||||
|
|
||||||
// Keep the last incomplete event in buffer
|
// Keep the last incomplete event in buffer
|
||||||
if !string.hasSuffix("\n\n") && events.count > 1 {
|
if !string.hasSuffix("\n\n") && events.count > 1 {
|
||||||
if let lastEvent = events.last, let lastEventData = lastEvent.data(using: .utf8) {
|
if let lastEvent = events.last, let lastEventData = lastEvent.data(using: .utf8) {
|
||||||
|
|
@ -66,24 +68,24 @@ final class SSEClient: NSObject, @unchecked Sendable {
|
||||||
} else {
|
} else {
|
||||||
buffer = Data()
|
buffer = Data()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process complete events
|
// Process complete events
|
||||||
for (index, eventString) in events.enumerated() {
|
for (index, eventString) in events.enumerated() {
|
||||||
// Skip the last event if buffer wasn't cleared (it's incomplete)
|
// Skip the last event if buffer wasn't cleared (it's incomplete)
|
||||||
if index == events.count - 1 && !buffer.isEmpty {
|
if index == events.count - 1 && !buffer.isEmpty {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !eventString.isEmpty {
|
if !eventString.isEmpty {
|
||||||
processEvent(eventString)
|
processEvent(eventString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processEvent(_ eventString: String) {
|
private func processEvent(_ eventString: String) {
|
||||||
var eventType: String?
|
var eventType: String?
|
||||||
var eventData: String?
|
var eventData: String?
|
||||||
|
|
||||||
// Parse SSE format
|
// Parse SSE format
|
||||||
let lines = eventString.components(separatedBy: "\n")
|
let lines = eventString.components(separatedBy: "\n")
|
||||||
for line in lines {
|
for line in lines {
|
||||||
|
|
@ -94,21 +96,21 @@ final class SSEClient: NSObject, @unchecked Sendable {
|
||||||
if eventData == nil {
|
if eventData == nil {
|
||||||
eventData = data
|
eventData = data
|
||||||
} else {
|
} else {
|
||||||
eventData! += "\n" + data
|
eventData = (eventData ?? "") + "\n" + data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process based on event type
|
// Process based on event type
|
||||||
if eventType == "message" || eventType == nil, let data = eventData {
|
if eventType == "message" || eventType == nil, let data = eventData {
|
||||||
parseTerminalData(data)
|
parseTerminalData(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func parseTerminalData(_ data: String) {
|
private func parseTerminalData(_ data: String) {
|
||||||
// The data should be a JSON array: [timestamp, type, data] or ['exit', exitCode, sessionId]
|
// The data should be a JSON array: [timestamp, type, data] or ['exit', exitCode, sessionId]
|
||||||
guard let jsonData = data.data(using: .utf8) else { return }
|
guard let jsonData = data.data(using: .utf8) else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
if let array = try JSONSerialization.jsonObject(with: jsonData) as? [Any] {
|
if let array = try JSONSerialization.jsonObject(with: jsonData) as? [Any] {
|
||||||
if array.count >= 3 {
|
if array.count >= 3 {
|
||||||
|
|
@ -122,28 +124,37 @@ final class SSEClient: NSObject, @unchecked Sendable {
|
||||||
else if let timestamp = array[0] as? Double,
|
else if let timestamp = array[0] as? Double,
|
||||||
let type = array[1] as? String,
|
let type = array[1] as? String,
|
||||||
let outputData = array[2] as? String {
|
let outputData = array[2] as? String {
|
||||||
delegate?.sseClient(self, didReceiveEvent: .terminalOutput(timestamp: timestamp, type: type, data: outputData))
|
delegate?.sseClient(
|
||||||
|
self,
|
||||||
|
didReceiveEvent: .terminalOutput(timestamp: timestamp, type: type, data: outputData)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("[SSEClient] Failed to parse event data: \(error)")
|
logger.error("Failed to parse event data: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - URLSessionDataDelegate
|
// MARK: - URLSessionDataDelegate
|
||||||
|
|
||||||
extension SSEClient: URLSessionDataDelegate {
|
extension SSEClient: URLSessionDataDelegate {
|
||||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
dataTask: URLSessionDataTask,
|
||||||
|
didReceive response: URLResponse,
|
||||||
|
completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void
|
||||||
|
) {
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
completionHandler(.cancel)
|
completionHandler(.cancel)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if httpResponse.statusCode == 200 {
|
if httpResponse.statusCode == 200 {
|
||||||
completionHandler(.allow)
|
completionHandler(.allow)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -151,15 +162,19 @@ extension SSEClient: URLSessionDataDelegate {
|
||||||
completionHandler(.cancel)
|
completionHandler(.cancel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||||
buffer.append(data)
|
buffer.append(data)
|
||||||
processBuffer()
|
processBuffer()
|
||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
if let error = error {
|
if let error {
|
||||||
if (error as NSError).code != NSURLErrorCancelled {
|
// Check if this is a URLError directly
|
||||||
|
if let urlError = error as? URLError, urlError.code != .cancelled {
|
||||||
|
delegate?.sseClient(self, didReceiveEvent: .error(error.localizedDescription))
|
||||||
|
} else if (error as? URLError) == nil {
|
||||||
|
// Not a URLError, so it's some other error we should report
|
||||||
delegate?.sseClient(self, didReceiveEvent: .error(error.localizedDescription))
|
delegate?.sseClient(self, didReceiveEvent: .error(error.localizedDescription))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -167,6 +182,7 @@ extension SSEClient: URLSessionDataDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SSEClientDelegate
|
// MARK: - SSEClientDelegate
|
||||||
|
|
||||||
protocol SSEClientDelegate: AnyObject {
|
protocol SSEClientDelegate: AnyObject {
|
||||||
func sseClient(_ client: SSEClient, didReceiveEvent event: SSEClient.SSEEvent)
|
func sseClient(_ client: SSEClient, didReceiveEvent event: SSEClient.SSEEvent)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
private let logger = Logger(category: "SessionService")
|
||||||
|
|
||||||
/// Service layer for managing terminal sessions.
|
/// Service layer for managing terminal sessions.
|
||||||
///
|
///
|
||||||
/// SessionService provides a simplified interface for session-related operations,
|
/// SessionService provides a simplified interface for session-related operations,
|
||||||
|
|
@ -19,7 +21,7 @@ class SessionService {
|
||||||
do {
|
do {
|
||||||
return try await apiClient.createSession(data)
|
return try await apiClient.createSession(data)
|
||||||
} catch {
|
} catch {
|
||||||
print("[SessionService] Failed to create session: \(error)")
|
logger.error("Failed to create session: \(error)")
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,6 @@ protocol WebSocketFactory {
|
||||||
@MainActor
|
@MainActor
|
||||||
class DefaultWebSocketFactory: WebSocketFactory {
|
class DefaultWebSocketFactory: WebSocketFactory {
|
||||||
func createWebSocket() -> WebSocketProtocol {
|
func createWebSocket() -> WebSocketProtocol {
|
||||||
return URLSessionWebSocket()
|
URLSessionWebSocket()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import Foundation
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol WebSocketProtocol: AnyObject {
|
protocol WebSocketProtocol: AnyObject {
|
||||||
var delegate: WebSocketDelegate? { get set }
|
var delegate: WebSocketDelegate? { get set }
|
||||||
|
|
||||||
func connect(to url: URL, with headers: [String: String]) async throws
|
func connect(to url: URL, with headers: [String: String]) async throws
|
||||||
func send(_ message: WebSocketMessage) async throws
|
func send(_ message: WebSocketMessage) async throws
|
||||||
func sendPing() async throws
|
func sendPing() async throws
|
||||||
|
|
@ -23,7 +23,11 @@ protocol WebSocketDelegate: AnyObject {
|
||||||
func webSocketDidConnect(_ webSocket: WebSocketProtocol)
|
func webSocketDidConnect(_ webSocket: WebSocketProtocol)
|
||||||
func webSocket(_ webSocket: WebSocketProtocol, didReceiveMessage message: WebSocketMessage)
|
func webSocket(_ webSocket: WebSocketProtocol, didReceiveMessage message: WebSocketMessage)
|
||||||
func webSocket(_ webSocket: WebSocketProtocol, didFailWithError error: Error)
|
func webSocket(_ webSocket: WebSocketProtocol, didFailWithError error: Error)
|
||||||
func webSocketDidDisconnect(_ webSocket: WebSocketProtocol, closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
|
func webSocketDidDisconnect(
|
||||||
|
_ webSocket: WebSocketProtocol,
|
||||||
|
closeCode: URLSessionWebSocketTask.CloseCode,
|
||||||
|
reason: Data?
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Real implementation of WebSocketProtocol using URLSessionWebSocketTask
|
/// Real implementation of WebSocketProtocol using URLSessionWebSocketTask
|
||||||
|
|
@ -33,23 +37,23 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
|
||||||
private var webSocketTask: URLSessionWebSocketTask?
|
private var webSocketTask: URLSessionWebSocketTask?
|
||||||
private var session: URLSession!
|
private var session: URLSession!
|
||||||
private var isReceiving = false
|
private var isReceiving = false
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
self.session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
self.session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func connect(to url: URL, with headers: [String: String]) async throws {
|
func connect(to url: URL, with headers: [String: String]) async throws {
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
|
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
|
||||||
|
|
||||||
webSocketTask = session.webSocketTask(with: request)
|
webSocketTask = session.webSocketTask(with: request)
|
||||||
webSocketTask?.resume()
|
webSocketTask?.resume()
|
||||||
|
|
||||||
// Start receiving messages
|
// Start receiving messages
|
||||||
isReceiving = true
|
isReceiving = true
|
||||||
receiveNextMessage()
|
receiveNextMessage()
|
||||||
|
|
||||||
// Send initial ping to verify connection
|
// Send initial ping to verify connection
|
||||||
do {
|
do {
|
||||||
try await sendPing()
|
try await sendPing()
|
||||||
|
|
@ -63,12 +67,12 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func send(_ message: WebSocketMessage) async throws {
|
func send(_ message: WebSocketMessage) async throws {
|
||||||
guard let task = webSocketTask else {
|
guard let task = webSocketTask else {
|
||||||
throw WebSocketError.connectionFailed
|
throw WebSocketError.connectionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
switch message {
|
switch message {
|
||||||
case .string(let text):
|
case .string(let text):
|
||||||
try await task.send(.string(text))
|
try await task.send(.string(text))
|
||||||
|
|
@ -76,15 +80,15 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
|
||||||
try await task.send(.data(data))
|
try await task.send(.data(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPing() async throws {
|
func sendPing() async throws {
|
||||||
guard let task = webSocketTask else {
|
guard let task = webSocketTask else {
|
||||||
throw WebSocketError.connectionFailed
|
throw WebSocketError.connectionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
task.sendPing { error in
|
task.sendPing { error in
|
||||||
if let error = error {
|
if let error {
|
||||||
continuation.resume(throwing: error)
|
continuation.resume(throwing: error)
|
||||||
} else {
|
} else {
|
||||||
continuation.resume()
|
continuation.resume()
|
||||||
|
|
@ -92,7 +96,7 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func disconnect(with code: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
func disconnect(with code: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||||
isReceiving = false
|
isReceiving = false
|
||||||
webSocketTask?.cancel(with: code, reason: reason)
|
webSocketTask?.cancel(with: code, reason: reason)
|
||||||
|
|
@ -100,13 +104,13 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
|
||||||
self.delegate?.webSocketDidDisconnect(self, closeCode: code, reason: reason)
|
self.delegate?.webSocketDidDisconnect(self, closeCode: code, reason: reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func receiveNextMessage() {
|
private func receiveNextMessage() {
|
||||||
guard isReceiving, let task = webSocketTask else { return }
|
guard isReceiving, let task = webSocketTask else { return }
|
||||||
|
|
||||||
task.receive { [weak self] result in
|
task.receive { [weak self] result in
|
||||||
guard let self = self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let message):
|
case .success(let message):
|
||||||
let wsMessage: WebSocketMessage
|
let wsMessage: WebSocketMessage
|
||||||
|
|
@ -118,16 +122,16 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
|
||||||
@unknown default:
|
@unknown default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.delegate?.webSocket(self, didReceiveMessage: wsMessage)
|
self.delegate?.webSocket(self, didReceiveMessage: wsMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue receiving
|
// Continue receiving
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.receiveNextMessage()
|
self.receiveNextMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.isReceiving = false
|
self.isReceiving = false
|
||||||
|
|
@ -139,14 +143,23 @@ class URLSessionWebSocket: NSObject, WebSocketProtocol {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension URLSessionWebSocket: URLSessionWebSocketDelegate {
|
extension URLSessionWebSocket: URLSessionWebSocketDelegate {
|
||||||
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
|
nonisolated func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
webSocketTask: URLSessionWebSocketTask,
|
||||||
|
didOpenWithProtocol protocol: String?
|
||||||
|
) {
|
||||||
// Connection opened - already handled in connect()
|
// Connection opened - already handled in connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
nonisolated func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
webSocketTask: URLSessionWebSocketTask,
|
||||||
|
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
||||||
|
reason: Data?
|
||||||
|
) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.isReceiving = false
|
self.isReceiving = false
|
||||||
self.delegate?.webSocketDidDisconnect(self, closeCode: closeCode, reason: reason)
|
self.delegate?.webSocketDidDisconnect(self, closeCode: closeCode, reason: reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
166
ios/VibeTunnel/Utils/ErrorHandling.swift
Normal file
166
ios/VibeTunnel/Utils/ErrorHandling.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,54 +7,54 @@ enum LogLevel: Int {
|
||||||
case info = 2
|
case info = 2
|
||||||
case warning = 3
|
case warning = 3
|
||||||
case error = 4
|
case error = 4
|
||||||
|
|
||||||
var prefix: String {
|
var prefix: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .verbose: return "🔍"
|
case .verbose: "🔍"
|
||||||
case .debug: return "🐛"
|
case .debug: "🐛"
|
||||||
case .info: return "ℹ️"
|
case .info: "ℹ️"
|
||||||
case .warning: return "⚠️"
|
case .warning: "⚠️"
|
||||||
case .error: return "❌"
|
case .error: "❌"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Logger {
|
struct Logger {
|
||||||
private let category: String
|
private let category: String
|
||||||
|
|
||||||
/// Global log level - only messages at this level or higher will be printed
|
// Global log level - only messages at this level or higher will be printed
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
nonisolated(unsafe) static var globalLevel: LogLevel = .info // Default to info level in debug builds
|
nonisolated(unsafe) static var globalLevel: LogLevel = .info // Default to info level in debug builds
|
||||||
#else
|
#else
|
||||||
nonisolated(unsafe) static var globalLevel: LogLevel = .warning // Only warnings and errors in release
|
nonisolated(unsafe) static var globalLevel: LogLevel = .warning // Only warnings and errors in release
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
init(category: String) {
|
init(category: String) {
|
||||||
self.category = category
|
self.category = category
|
||||||
}
|
}
|
||||||
|
|
||||||
func verbose(_ message: String) {
|
func verbose(_ message: String) {
|
||||||
log(message, level: .verbose)
|
log(message, level: .verbose)
|
||||||
}
|
}
|
||||||
|
|
||||||
func debug(_ message: String) {
|
func debug(_ message: String) {
|
||||||
log(message, level: .debug)
|
log(message, level: .debug)
|
||||||
}
|
}
|
||||||
|
|
||||||
func info(_ message: String) {
|
func info(_ message: String) {
|
||||||
log(message, level: .info)
|
log(message, level: .info)
|
||||||
}
|
}
|
||||||
|
|
||||||
func warning(_ message: String) {
|
func warning(_ message: String) {
|
||||||
log(message, level: .warning)
|
log(message, level: .warning)
|
||||||
}
|
}
|
||||||
|
|
||||||
func error(_ message: String) {
|
func error(_ message: String) {
|
||||||
log(message, level: .error)
|
log(message, level: .error)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func log(_ message: String, level: LogLevel) {
|
private func log(_ message: String, level: LogLevel) {
|
||||||
guard level.rawValue >= Logger.globalLevel.rawValue else { return }
|
guard level.rawValue >= Self.globalLevel.rawValue else { return }
|
||||||
print("\(level.prefix) [\(category)] \(message)")
|
print("\(level.prefix) [\(category)] \(message)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
279
ios/VibeTunnel/Utils/MacCatalystWindow.swift
Normal file
279
ios/VibeTunnel/Utils/MacCatalystWindow.swift
Normal 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
|
||||||
|
|
@ -14,26 +14,32 @@ enum Theme {
|
||||||
static let terminalBackground = Color(light: Color(hex: "FFFFFF"), dark: Color(hex: "0A0E14"))
|
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 cardBackground = Color(light: Color(hex: "F8F9FA"), dark: Color(hex: "0D1117"))
|
||||||
static let headerBackground = Color(light: Color(hex: "FFFFFF"), dark: Color(hex: "010409"))
|
static let headerBackground = Color(light: Color(hex: "FFFFFF"), dark: Color(hex: "010409"))
|
||||||
|
|
||||||
// Border colors
|
/// Border colors
|
||||||
static let cardBorder = Color(light: Color(hex: "E1E4E8"), dark: Color(hex: "1C2128"))
|
static let cardBorder = Color(light: Color(hex: "E1E4E8"), dark: Color(hex: "1C2128"))
|
||||||
|
|
||||||
// Text colors
|
/// Text colors
|
||||||
static let terminalForeground = Color(light: Color(hex: "24292E"), dark: Color(hex: "B3B1AD"))
|
static let terminalForeground = Color(light: Color(hex: "24292E"), dark: Color(hex: "B3B1AD"))
|
||||||
|
|
||||||
// Accent colors (same for both modes)
|
// Accent colors (same for both modes)
|
||||||
static let primaryAccent = Color(light: Color(hex: "22C55E"), dark: Color(hex: "00FF88")) // Darker green for light mode
|
static let primaryAccent = Color(hex: "007AFF") // iOS system blue
|
||||||
static let secondaryAccent = Color(hex: "59C2FF")
|
static let secondaryAccent = Color(hex: "59C2FF")
|
||||||
static let successAccent = Color(hex: "AAD94C")
|
static let successAccent = Color(hex: "AAD94C")
|
||||||
static let warningAccent = Color(hex: "FFB454")
|
static let warningAccent = Color(hex: "FFB454")
|
||||||
static let errorAccent = Color(hex: "F07178")
|
static let errorAccent = Color(hex: "F07178")
|
||||||
|
|
||||||
// Selection colors
|
/// Selection colors
|
||||||
static let terminalSelection = Color(light: Color(hex: "E1E4E8"), dark: Color(hex: "273747"))
|
static let terminalSelection = Color(light: Color(hex: "E1E4E8"), dark: Color(hex: "273747"))
|
||||||
|
|
||||||
// Overlay colors
|
/// Overlay colors
|
||||||
static let overlayBackground = Color(light: Color.black.opacity(0.5), dark: Color.black.opacity(0.7))
|
static let overlayBackground = Color(light: Color.black.opacity(0.5), dark: Color.black.opacity(0.7))
|
||||||
|
|
||||||
|
// Additional UI colors
|
||||||
|
static let secondaryText = Color(light: Color(hex: "6E7781"), dark: Color(hex: "8B949E"))
|
||||||
|
static let secondaryBackground = Color(light: Color(hex: "F6F8FA"), dark: Color(hex: "161B22"))
|
||||||
|
static let success = successAccent
|
||||||
|
static let error = errorAccent
|
||||||
|
|
||||||
// Additional UI colors for FileBrowser
|
// Additional UI colors for FileBrowser
|
||||||
static let terminalAccent = primaryAccent
|
static let terminalAccent = primaryAccent
|
||||||
static let terminalGray = Color(light: Color(hex: "586069"), dark: Color(hex: "8B949E"))
|
static let terminalGray = Color(light: Color(hex: "586069"), dark: Color(hex: "8B949E"))
|
||||||
|
|
@ -59,6 +65,15 @@ enum Theme {
|
||||||
static let ansiBrightMagenta = Color(light: Color(hex: "5A32A3"), dark: Color(hex: "FFEE99"))
|
static let ansiBrightMagenta = Color(light: Color(hex: "5A32A3"), dark: Color(hex: "FFEE99"))
|
||||||
static let ansiBrightCyan = Color(light: Color(hex: "0598BC"), dark: Color(hex: "95E6CB"))
|
static let ansiBrightCyan = Color(light: Color(hex: "0598BC"), dark: Color(hex: "95E6CB"))
|
||||||
static let ansiBrightWhite = Color(light: Color(hex: "24292E"), dark: Color(hex: "FFFFFF"))
|
static let ansiBrightWhite = Color(light: Color(hex: "24292E"), dark: Color(hex: "FFFFFF"))
|
||||||
|
|
||||||
|
// File type colors
|
||||||
|
static let fileTypeJS = Color(light: Color(hex: "B08800"), dark: Color(hex: "FFB454"))
|
||||||
|
static let fileTypeTS = Color(light: Color(hex: "0366D6"), dark: Color(hex: "007ACC"))
|
||||||
|
static let fileTypeJSON = Color(light: Color(hex: "E36209"), dark: Color(hex: "FF8C42"))
|
||||||
|
static let fileTypeCSS = Color(light: Color(hex: "563D7C"), dark: Color(hex: "7B68EE"))
|
||||||
|
static let fileTypePython = Color(light: Color(hex: "3776AB"), dark: Color(hex: "4B8BBE"))
|
||||||
|
static let fileTypeGo = Color(light: Color(hex: "00ADD8"), dark: Color(hex: "00ADD8"))
|
||||||
|
static let fileTypeImage = Color(light: Color(hex: "28A745"), dark: Color(hex: "91B362"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Typography
|
// MARK: - Typography
|
||||||
|
|
@ -77,6 +92,18 @@ enum Theme {
|
||||||
static func terminalSystem(size: CGFloat) -> Font {
|
static func terminalSystem(size: CGFloat) -> Font {
|
||||||
Font.system(size: size, design: .monospaced)
|
Font.system(size: size, design: .monospaced)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func terminalSystem(size: CGFloat, weight: Font.Weight) -> Font {
|
||||||
|
Font.system(size: size, weight: weight, design: .monospaced)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func largeTitle() -> Font {
|
||||||
|
Font.largeTitle.weight(.semibold)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func title() -> Font {
|
||||||
|
Font.title2.weight(.medium)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Spacing
|
// MARK: - Spacing
|
||||||
|
|
@ -88,6 +115,7 @@ enum Theme {
|
||||||
static let medium: CGFloat = 12
|
static let medium: CGFloat = 12
|
||||||
static let large: CGFloat = 16
|
static let large: CGFloat = 16
|
||||||
static let extraLarge: CGFloat = 24
|
static let extraLarge: CGFloat = 24
|
||||||
|
static let xlarge: CGFloat = 24 // Alias for extraLarge
|
||||||
static let extraExtraLarge: CGFloat = 32
|
static let extraExtraLarge: CGFloat = 32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,6 +129,13 @@ enum Theme {
|
||||||
static let card: CGFloat = 12
|
static let card: CGFloat = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout
|
||||||
|
|
||||||
|
/// Layout constants
|
||||||
|
enum Layout {
|
||||||
|
static let cornerRadius: CGFloat = 10
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Animation
|
// MARK: - Animation
|
||||||
|
|
||||||
/// Animation presets.
|
/// Animation presets.
|
||||||
|
|
@ -154,15 +189,15 @@ extension Color {
|
||||||
opacity: Double(alpha) / 255
|
opacity: Double(alpha) / 255
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a color that automatically adapts to light/dark mode
|
/// Creates a color that automatically adapts to light/dark mode
|
||||||
init(light: Color, dark: Color) {
|
init(light: Color, dark: Color) {
|
||||||
self.init(UIColor { traitCollection in
|
self.init(UIColor { traitCollection in
|
||||||
switch traitCollection.userInterfaceStyle {
|
switch traitCollection.userInterfaceStyle {
|
||||||
case .dark:
|
case .dark:
|
||||||
return UIColor(dark)
|
UIColor(dark)
|
||||||
default:
|
default:
|
||||||
return UIColor(light)
|
UIColor(light)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -205,14 +240,8 @@ extension View {
|
||||||
.stroke(Theme.Colors.primaryAccent, lineWidth: 1)
|
.stroke(Theme.Colors.primaryAccent, lineWidth: 1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interactive button style with press and hover animations
|
// Removed: interactiveButton - use explicit scaleEffect and animation instead
|
||||||
func interactiveButton(isPressed: Bool = false, isHovered: Bool = false) -> some View {
|
|
||||||
self
|
|
||||||
.scaleEffect(isPressed ? 0.95 : 1.0)
|
|
||||||
.animation(Theme.Animation.quick, value: isPressed)
|
|
||||||
.animation(Theme.Animation.quick, value: isHovered)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Haptic Feedback
|
// MARK: - Haptic Feedback
|
||||||
|
|
@ -268,36 +297,5 @@ struct HapticFeedback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SwiftUI Haptic View Modifiers
|
// Note: Call HapticFeedback methods directly instead of using view modifiers
|
||||||
|
// Example: HapticFeedback.impact(.light) or HapticFeedback.selection()
|
||||||
extension View {
|
|
||||||
/// Provides haptic feedback when the view is tapped
|
|
||||||
func hapticOnTap(_ style: HapticFeedback.ImpactStyle = .light) -> some View {
|
|
||||||
self.onTapGesture {
|
|
||||||
HapticFeedback.impact(style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provides haptic feedback when a value changes
|
|
||||||
func hapticOnChange(of value: some Equatable, style: HapticFeedback.ImpactStyle = .light) -> some View {
|
|
||||||
self.onChange(of: value) { _, _ in
|
|
||||||
HapticFeedback.impact(style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provides selection haptic feedback when a value changes
|
|
||||||
func hapticSelection(on value: some Equatable) -> some View {
|
|
||||||
self.onChange(of: value) { _, _ in
|
|
||||||
HapticFeedback.selection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provides notification haptic feedback
|
|
||||||
func hapticNotification(_ type: HapticFeedback.NotificationType, when condition: Bool) -> some View {
|
|
||||||
self.onChange(of: condition) { _, newValue in
|
|
||||||
if newValue {
|
|
||||||
HapticFeedback.notification(type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
156
ios/VibeTunnel/ViewModels/ServerProfilesViewModel.swift
Normal file
156
ios/VibeTunnel/ViewModels/ServerProfilesViewModel.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,13 +7,13 @@ import SwiftUI
|
||||||
struct LoadingView: View {
|
struct LoadingView: View {
|
||||||
let message: String
|
let message: String
|
||||||
let useUnicodeSpinner: Bool
|
let useUnicodeSpinner: Bool
|
||||||
|
|
||||||
@State private var isAnimating = false
|
@State private var isAnimating = false
|
||||||
@State private var spinnerFrame = 0
|
@State private var spinnerFrame = 0
|
||||||
|
|
||||||
// Unicode spinner frames matching web UI
|
/// Unicode spinner frames matching web UI
|
||||||
private let spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
private let spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||||
|
|
||||||
init(message: String, useUnicodeSpinner: Bool = false) {
|
init(message: String, useUnicodeSpinner: Bool = false) {
|
||||||
self.message = message
|
self.message = message
|
||||||
self.useUnicodeSpinner = useUnicodeSpinner
|
self.useUnicodeSpinner = useUnicodeSpinner
|
||||||
|
|
@ -57,7 +57,7 @@ struct LoadingView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startUnicodeAnimation() {
|
private func startUnicodeAnimation() {
|
||||||
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in
|
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import SwiftUI
|
||||||
struct ConnectionView: View {
|
struct ConnectionView: View {
|
||||||
@Environment(ConnectionManager.self)
|
@Environment(ConnectionManager.self)
|
||||||
var connectionManager
|
var connectionManager
|
||||||
@ObservedObject private var networkMonitor = NetworkMonitor.shared
|
@State private var networkMonitor = NetworkMonitor.shared
|
||||||
@State private var viewModel = ConnectionViewModel()
|
@State private var viewModel = ConnectionViewModel()
|
||||||
@State private var logoScale: CGFloat = 0.8
|
@State private var logoScale: CGFloat = 0.8
|
||||||
@State private var contentOpacity: Double = 0
|
@State private var contentOpacity: Double = 0
|
||||||
|
|
|
||||||
441
ios/VibeTunnel/Views/Connection/EnhancedConnectionView.swift
Normal file
441
ios/VibeTunnel/Views/Connection/EnhancedConnectionView.swift
Normal 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())
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ struct ServerConfigForm: View {
|
||||||
let isConnecting: Bool
|
let isConnecting: Bool
|
||||||
let errorMessage: String?
|
let errorMessage: String?
|
||||||
let onConnect: () -> Void
|
let onConnect: () -> Void
|
||||||
@ObservedObject private var networkMonitor = NetworkMonitor.shared
|
@State private var networkMonitor = NetworkMonitor.shared
|
||||||
|
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
@State private var recentServers: [ServerConfig] = []
|
@State private var recentServers: [ServerConfig] = []
|
||||||
|
|
|
||||||
|
|
@ -4,31 +4,29 @@ import WebKit
|
||||||
/// View for previewing files with syntax highlighting
|
/// View for previewing files with syntax highlighting
|
||||||
struct FilePreviewView: View {
|
struct FilePreviewView: View {
|
||||||
let path: String
|
let path: String
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss)
|
||||||
|
var dismiss
|
||||||
@State private var preview: FilePreview?
|
@State private var preview: FilePreview?
|
||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
@State private var error: String?
|
@State private var presentedError: IdentifiableError?
|
||||||
@State private var showingDiff = false
|
@State private var showingDiff = false
|
||||||
@State private var gitDiff: FileDiff?
|
@State private var gitDiff: FileDiff?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
Theme.Colors.terminalBackground
|
Theme.Colors.terminalBackground
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView("Loading...")
|
ProgressView("Loading...")
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
||||||
} else if let error = error {
|
} else if presentedError != nil {
|
||||||
VStack {
|
ContentUnavailableView {
|
||||||
Text("Error loading file")
|
Label("Failed to Load File", systemImage: "exclamationmark.triangle")
|
||||||
.font(.headline)
|
} description: {
|
||||||
.foregroundColor(Theme.Colors.errorAccent)
|
Text("The file could not be loaded. Please try again.")
|
||||||
Text(error)
|
} actions: {
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(Theme.Colors.terminalForeground)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
Button("Retry") {
|
Button("Retry") {
|
||||||
Task {
|
Task {
|
||||||
await loadPreview()
|
await loadPreview()
|
||||||
|
|
@ -36,7 +34,7 @@ struct FilePreviewView: View {
|
||||||
}
|
}
|
||||||
.terminalButton()
|
.terminalButton()
|
||||||
}
|
}
|
||||||
} else if let preview = preview {
|
} else if let preview {
|
||||||
previewContent(for: preview)
|
previewContent(for: preview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -49,8 +47,8 @@ struct FilePreviewView: View {
|
||||||
}
|
}
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let preview = preview, preview.type == .text {
|
if let preview, preview.type == .text {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Diff") {
|
Button("Diff") {
|
||||||
showingDiff = true
|
showingDiff = true
|
||||||
|
|
@ -74,8 +72,9 @@ struct FilePreviewView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.errorAlert(item: $presentedError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func previewContent(for preview: FilePreview) -> some View {
|
private func previewContent(for preview: FilePreview) -> some View {
|
||||||
switch preview.type {
|
switch preview.type {
|
||||||
|
|
@ -100,11 +99,11 @@ struct FilePreviewView: View {
|
||||||
Image(systemName: "doc.zipper")
|
Image(systemName: "doc.zipper")
|
||||||
.font(.system(size: 64))
|
.font(.system(size: 64))
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||||
|
|
||||||
Text("Binary File")
|
Text("Binary File")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(Theme.Colors.terminalForeground)
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
|
||||||
if let size = preview.size {
|
if let size = preview.size {
|
||||||
Text(formatFileSize(size))
|
Text(formatFileSize(size))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|
@ -113,20 +112,20 @@ struct FilePreviewView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadPreview() async {
|
private func loadPreview() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
presentedError = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
preview = try await APIClient.shared.previewFile(path: path)
|
preview = try await APIClient.shared.previewFile(path: path)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
presentedError = IdentifiableError(error: error)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadDiff() async {
|
private func loadDiff() async {
|
||||||
do {
|
do {
|
||||||
gitDiff = try await APIClient.shared.getGitDiff(path: path)
|
gitDiff = try await APIClient.shared.getGitDiff(path: path)
|
||||||
|
|
@ -134,7 +133,7 @@ struct FilePreviewView: View {
|
||||||
// Silently fail - diff might not be available
|
// Silently fail - diff might not be available
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatFileSize(_ size: Int64) -> String {
|
private func formatFileSize(_ size: Int64) -> String {
|
||||||
let formatter = ByteCountFormatter()
|
let formatter = ByteCountFormatter()
|
||||||
formatter.countStyle = .binary
|
formatter.countStyle = .binary
|
||||||
|
|
@ -146,22 +145,22 @@ struct FilePreviewView: View {
|
||||||
struct SyntaxHighlightedView: UIViewRepresentable {
|
struct SyntaxHighlightedView: UIViewRepresentable {
|
||||||
let content: String
|
let content: String
|
||||||
let language: String
|
let language: String
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
let configuration = WKWebViewConfiguration()
|
let configuration = WKWebViewConfiguration()
|
||||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||||
webView.isOpaque = false
|
webView.isOpaque = false
|
||||||
webView.backgroundColor = UIColor(Theme.Colors.cardBackground)
|
webView.backgroundColor = UIColor(Theme.Colors.cardBackground)
|
||||||
webView.scrollView.backgroundColor = UIColor(Theme.Colors.cardBackground)
|
webView.scrollView.backgroundColor = UIColor(Theme.Colors.cardBackground)
|
||||||
|
|
||||||
loadContent(in: webView)
|
loadContent(in: webView)
|
||||||
return webView
|
return webView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||||
// Content is static, no updates needed
|
// Content is static, no updates needed
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadContent(in webView: WKWebView) {
|
private func loadContent(in webView: WKWebView) {
|
||||||
let escapedContent = content
|
let escapedContent = content
|
||||||
.replacingOccurrences(of: "&", with: "&")
|
.replacingOccurrences(of: "&", with: "&")
|
||||||
|
|
@ -169,7 +168,7 @@ struct SyntaxHighlightedView: UIViewRepresentable {
|
||||||
.replacingOccurrences(of: ">", with: ">")
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
.replacingOccurrences(of: "\"", with: """)
|
.replacingOccurrences(of: "\"", with: """)
|
||||||
.replacingOccurrences(of: "'", with: "'")
|
.replacingOccurrences(of: "'", with: "'")
|
||||||
|
|
||||||
let html = """
|
let html = """
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -210,7 +209,7 @@ struct SyntaxHighlightedView: UIViewRepresentable {
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
webView.loadHTMLString(html, baseURL: nil)
|
webView.loadHTMLString(html, baseURL: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -218,14 +217,15 @@ struct SyntaxHighlightedView: UIViewRepresentable {
|
||||||
/// View for displaying git diffs
|
/// View for displaying git diffs
|
||||||
struct GitDiffView: View {
|
struct GitDiffView: View {
|
||||||
let diff: FileDiff
|
let diff: FileDiff
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss)
|
||||||
|
var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
Theme.Colors.terminalBackground
|
Theme.Colors.terminalBackground
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
DiffWebView(content: diff.diff)
|
DiffWebView(content: diff.diff)
|
||||||
}
|
}
|
||||||
.navigationTitle("Git Diff")
|
.navigationTitle("Git Diff")
|
||||||
|
|
@ -246,27 +246,27 @@ struct GitDiffView: View {
|
||||||
/// WebView for displaying diffs with syntax highlighting
|
/// WebView for displaying diffs with syntax highlighting
|
||||||
struct DiffWebView: UIViewRepresentable {
|
struct DiffWebView: UIViewRepresentable {
|
||||||
let content: String
|
let content: String
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
let configuration = WKWebViewConfiguration()
|
let configuration = WKWebViewConfiguration()
|
||||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||||
webView.isOpaque = false
|
webView.isOpaque = false
|
||||||
webView.backgroundColor = UIColor(Theme.Colors.cardBackground)
|
webView.backgroundColor = UIColor(Theme.Colors.cardBackground)
|
||||||
|
|
||||||
loadDiff(in: webView)
|
loadDiff(in: webView)
|
||||||
return webView
|
return webView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||||
// Content is static
|
// Content is static
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadDiff(in webView: WKWebView) {
|
private func loadDiff(in webView: WKWebView) {
|
||||||
let escapedContent = content
|
let escapedContent = content
|
||||||
.replacingOccurrences(of: "&", with: "&")
|
.replacingOccurrences(of: "&", with: "&")
|
||||||
.replacingOccurrences(of: "<", with: "<")
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
.replacingOccurrences(of: ">", with: ">")
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
|
|
||||||
let html = """
|
let html = """
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -305,7 +305,7 @@ struct DiffWebView: UIViewRepresentable {
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
webView.loadHTMLString(html, baseURL: nil)
|
webView.loadHTMLString(html, baseURL: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,15 @@ struct FileBrowserView: View {
|
||||||
enum FileBrowserMode {
|
enum FileBrowserMode {
|
||||||
case selectDirectory
|
case selectDirectory
|
||||||
case browseFiles
|
case browseFiles
|
||||||
case insertPath // New mode for inserting paths into terminal
|
case insertPath // New mode for inserting paths into terminal
|
||||||
}
|
}
|
||||||
|
|
||||||
init(initialPath: String = "~", mode: FileBrowserMode = .selectDirectory, onSelect: @escaping (String) -> Void, onInsertPath: ((String, Bool) -> Void)? = nil) {
|
init(
|
||||||
|
initialPath: String = "~",
|
||||||
|
mode: FileBrowserMode = .selectDirectory,
|
||||||
|
onSelect: @escaping (String) -> Void,
|
||||||
|
onInsertPath: ((String, Bool) -> Void)? = nil
|
||||||
|
) {
|
||||||
self.initialPath = initialPath
|
self.initialPath = initialPath
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
self.onSelect = onSelect
|
self.onSelect = onSelect
|
||||||
|
|
@ -74,7 +79,7 @@ struct FileBrowserView: View {
|
||||||
.foregroundColor(Theme.Colors.terminalGray)
|
.foregroundColor(Theme.Colors.terminalGray)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
|
|
||||||
// Git branch indicator
|
// Git branch indicator
|
||||||
if let gitStatus = viewModel.gitStatus, gitStatus.isGitRepo, let branch = gitStatus.branch {
|
if let gitStatus = viewModel.gitStatus, gitStatus.isGitRepo, let branch = gitStatus.branch {
|
||||||
Text("📍 \(branch)")
|
Text("📍 \(branch)")
|
||||||
|
|
@ -88,7 +93,7 @@ struct FileBrowserView: View {
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 16)
|
||||||
.background(Theme.Colors.terminalDarkGray)
|
.background(Theme.Colors.terminalDarkGray)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var filterToolbar: some View {
|
private var filterToolbar: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
// Git filter toggle
|
// Git filter toggle
|
||||||
|
|
@ -103,16 +108,20 @@ struct FileBrowserView: View {
|
||||||
Text(viewModel.gitFilter == .changed ? "Git Changes" : "All Files")
|
Text(viewModel.gitFilter == .changed ? "Git Changes" : "All Files")
|
||||||
.font(.custom("SF Mono", size: 12))
|
.font(.custom("SF Mono", size: 12))
|
||||||
}
|
}
|
||||||
.foregroundColor(viewModel.gitFilter == .changed ? Theme.Colors.successAccent : Theme.Colors.terminalGray)
|
.foregroundColor(viewModel.gitFilter == .changed ? Theme.Colors.successAccent : Theme.Colors
|
||||||
|
.terminalGray
|
||||||
|
)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
.fill(viewModel.gitFilter == .changed ? Theme.Colors.successAccent.opacity(0.2) : Theme.Colors.terminalGray.opacity(0.1))
|
.fill(viewModel.gitFilter == .changed ? Theme.Colors.successAccent.opacity(0.2) : Theme.Colors
|
||||||
|
.terminalGray.opacity(0.1)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(TerminalButtonStyle())
|
.buttonStyle(TerminalButtonStyle())
|
||||||
|
|
||||||
// Hidden files toggle
|
// Hidden files toggle
|
||||||
Button {
|
Button {
|
||||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
|
@ -130,23 +139,25 @@ struct FileBrowserView: View {
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
.fill(viewModel.showHidden ? Theme.Colors.terminalAccent.opacity(0.2) : Theme.Colors.terminalGray.opacity(0.1))
|
.fill(viewModel.showHidden ? Theme.Colors.terminalAccent.opacity(0.2) : Theme.Colors
|
||||||
|
.terminalGray.opacity(0.1)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(TerminalButtonStyle())
|
.buttonStyle(TerminalButtonStyle())
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(Theme.Colors.terminalDarkGray.opacity(0.5))
|
.background(Theme.Colors.terminalDarkGray.opacity(0.5))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Background
|
// Background
|
||||||
Color.black.ignoresSafeArea()
|
Theme.Colors.terminalBackground.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
navigationHeader
|
navigationHeader
|
||||||
|
|
@ -210,7 +221,7 @@ struct FileBrowserView: View {
|
||||||
.foregroundColor(Theme.Colors.terminalGray)
|
.foregroundColor(Theme.Colors.terminalGray)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.black.opacity(0.8))
|
.background(Theme.Colors.terminalBackground.opacity(0.8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -274,7 +285,7 @@ struct FileBrowserView: View {
|
||||||
}, label: {
|
}, label: {
|
||||||
Text("select")
|
Text("select")
|
||||||
.font(.custom("SF Mono", size: 14))
|
.font(.custom("SF Mono", size: 14))
|
||||||
.foregroundColor(.black)
|
.foregroundColor(Theme.Colors.terminalBackground)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
|
|
@ -408,19 +419,19 @@ struct FileBrowserView: View {
|
||||||
viewModel.loadDirectory(path: initialPath)
|
viewModel.loadDirectory(path: initialPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
private func insertPath(_ path: String, isDirectory: Bool) {
|
private func insertPath(_ path: String, isDirectory: Bool) {
|
||||||
// Escape the path if it contains spaces
|
// Escape the path if it contains spaces
|
||||||
let escapedPath = path.contains(" ") ? "\"\(path)\"" : path
|
let escapedPath = path.contains(" ") ? "\"\(path)\"" : path
|
||||||
|
|
||||||
// Call the insertion handler
|
// Call the insertion handler
|
||||||
onInsertPath?(escapedPath, isDirectory)
|
onInsertPath?(escapedPath, isDirectory)
|
||||||
|
|
||||||
// Provide haptic feedback
|
// Provide haptic feedback
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
|
|
||||||
// Dismiss the file browser
|
// Dismiss the file browser
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
@ -461,10 +472,10 @@ struct FileBrowserRow: View {
|
||||||
if isDirectory {
|
if isDirectory {
|
||||||
return "folder.fill"
|
return "folder.fill"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file extension
|
// Get file extension
|
||||||
let ext = name.split(separator: ".").last?.lowercased() ?? ""
|
let ext = name.split(separator: ".").last?.lowercased() ?? ""
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case "js", "jsx", "ts", "tsx", "mjs", "cjs":
|
case "js", "jsx", "ts", "tsx", "mjs", "cjs":
|
||||||
return "doc.text.fill"
|
return "doc.text.fill"
|
||||||
|
|
@ -504,44 +515,44 @@ struct FileBrowserRow: View {
|
||||||
return "doc.fill"
|
return "doc.fill"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var iconColor: Color {
|
var iconColor: Color {
|
||||||
if isDirectory {
|
if isDirectory {
|
||||||
return Theme.Colors.terminalAccent
|
return Theme.Colors.terminalAccent
|
||||||
}
|
}
|
||||||
|
|
||||||
let ext = name.split(separator: ".").last?.lowercased() ?? ""
|
let ext = name.split(separator: ".").last?.lowercased() ?? ""
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case "js", "jsx", "mjs", "cjs":
|
case "js", "jsx", "mjs", "cjs":
|
||||||
return .yellow
|
return Theme.Colors.fileTypeJS
|
||||||
case "ts", "tsx":
|
case "ts", "tsx":
|
||||||
return Color(red: 0.0, green: 0.48, blue: 0.78) // TypeScript blue
|
return Theme.Colors.fileTypeTS
|
||||||
case "json":
|
case "json":
|
||||||
return .orange
|
return Theme.Colors.fileTypeJSON
|
||||||
case "html", "htm":
|
case "html", "htm":
|
||||||
return .orange
|
return Theme.Colors.fileTypeJSON
|
||||||
case "css", "scss", "sass", "less":
|
case "css", "scss", "sass", "less":
|
||||||
return Color(red: 0.21, green: 0.46, blue: 0.74) // CSS blue
|
return Theme.Colors.fileTypeCSS
|
||||||
case "md", "markdown":
|
case "md", "markdown":
|
||||||
return .gray
|
return Theme.Colors.terminalGray
|
||||||
case "png", "jpg", "jpeg", "gif", "svg", "ico", "webp":
|
case "png", "jpg", "jpeg", "gif", "svg", "ico", "webp":
|
||||||
return .green
|
return Theme.Colors.fileTypeImage
|
||||||
case "swift":
|
case "swift":
|
||||||
return .orange
|
return Theme.Colors.fileTypeJSON
|
||||||
case "py":
|
case "py":
|
||||||
return Color(red: 0.22, green: 0.49, blue: 0.72) // Python blue
|
return Theme.Colors.fileTypePython
|
||||||
case "go":
|
case "go":
|
||||||
return Color(red: 0.0, green: 0.68, blue: 0.85) // Go cyan
|
return Theme.Colors.fileTypeGo
|
||||||
case "rs":
|
case "rs":
|
||||||
return .orange
|
return Theme.Colors.fileTypeJSON
|
||||||
case "sh", "bash", "zsh", "fish":
|
case "sh", "bash", "zsh", "fish":
|
||||||
return .green
|
return Theme.Colors.fileTypeImage
|
||||||
default:
|
default:
|
||||||
return Theme.Colors.terminalGray.opacity(0.6)
|
return Theme.Colors.terminalGray.opacity(0.6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onTap) {
|
Button(action: onTap) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
|
|
@ -563,7 +574,7 @@ struct FileBrowserRow: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Git status indicator
|
// Git status indicator
|
||||||
if let gitStatus = gitStatus, gitStatus != .unchanged {
|
if let gitStatus, gitStatus != .unchanged {
|
||||||
GitStatusBadge(status: gitStatus)
|
GitStatusBadge(status: gitStatus)
|
||||||
.padding(.trailing, 8)
|
.padding(.trailing, 8)
|
||||||
}
|
}
|
||||||
|
|
@ -609,7 +620,7 @@ struct FileBrowserRow: View {
|
||||||
} label: {
|
} label: {
|
||||||
Label("Copy Name", systemImage: "doc.on.doc")
|
Label("Copy Name", systemImage: "doc.on.doc")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
UIPasteboard.general.string = isDirectory ? "\(name)/" : name
|
UIPasteboard.general.string = isDirectory ? "\(name)/" : name
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||||
|
|
@ -648,7 +659,7 @@ class FileBrowserViewModel {
|
||||||
var gitStatus: GitStatus?
|
var gitStatus: GitStatus?
|
||||||
var showHidden = false
|
var showHidden = false
|
||||||
var gitFilter: GitFilterOption = .all
|
var gitFilter: GitFilterOption = .all
|
||||||
|
|
||||||
enum GitFilterOption: String {
|
enum GitFilterOption: String {
|
||||||
case all = "all"
|
case all = "all"
|
||||||
case changed = "changed"
|
case changed = "changed"
|
||||||
|
|
@ -702,8 +713,8 @@ class FileBrowserViewModel {
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let result = try await apiClient.browseDirectory(
|
let result = try await apiClient.browseDirectory(
|
||||||
path: path,
|
path: path,
|
||||||
showHidden: showHidden,
|
showHidden: showHidden,
|
||||||
gitFilter: gitFilter.rawValue
|
gitFilter: gitFilter.rawValue
|
||||||
)
|
)
|
||||||
// Use the absolute path returned by the server
|
// Use the absolute path returned by the server
|
||||||
|
|
@ -777,27 +788,27 @@ class FileBrowserViewModel {
|
||||||
/// Git status badge component for displaying file status
|
/// Git status badge component for displaying file status
|
||||||
struct GitStatusBadge: View {
|
struct GitStatusBadge: View {
|
||||||
let status: GitFileStatus
|
let status: GitFileStatus
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch status {
|
switch status {
|
||||||
case .modified: return "M"
|
case .modified: "M"
|
||||||
case .added: return "A"
|
case .added: "A"
|
||||||
case .deleted: return "D"
|
case .deleted: "D"
|
||||||
case .untracked: return "?"
|
case .untracked: "?"
|
||||||
case .unchanged: return ""
|
case .unchanged: ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var color: Color {
|
var color: Color {
|
||||||
switch status {
|
switch status {
|
||||||
case .modified: return .yellow
|
case .modified: .yellow
|
||||||
case .added: return .green
|
case .added: .green
|
||||||
case .deleted: return .red
|
case .deleted: .red
|
||||||
case .untracked: return .gray
|
case .untracked: .gray
|
||||||
case .unchanged: return .clear
|
case .unchanged: .clear
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if status != .unchanged {
|
if status != .unchanged {
|
||||||
Text(label)
|
Text(label)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import SwiftUI
|
||||||
|
|
||||||
/// File editor view for creating and editing text files.
|
/// File editor view for creating and editing text files.
|
||||||
struct FileEditorView: View {
|
struct FileEditorView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss)
|
||||||
|
private var dismiss
|
||||||
@State private var viewModel: FileEditorViewModel
|
@State private var viewModel: FileEditorViewModel
|
||||||
@State private var showingSaveAlert = false
|
@State private var showingSaveAlert = false
|
||||||
@State private var showingDiscardAlert = false
|
@State private var showingDiscardAlert = false
|
||||||
|
|
@ -110,13 +111,13 @@ struct FileEditorView: View {
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if !viewModel.isNewFile {
|
|
||||||
Task {
|
|
||||||
await viewModel.loadFile()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isTextEditorFocused = true
|
isTextEditorFocused = true
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
if !viewModel.isNewFile {
|
||||||
|
await viewModel.loadFile()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@ struct QuickLookWrapper: UIViewControllerRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@objc func dismiss() {
|
@objc
|
||||||
|
func dismiss() {
|
||||||
quickLookManager.isPresenting = false
|
quickLookManager.isPresenting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ struct SessionCardView: View {
|
||||||
@State private var scale: CGFloat = 1.0
|
@State private var scale: CGFloat = 1.0
|
||||||
@State private var rotation: Double = 0
|
@State private var rotation: Double = 0
|
||||||
@State private var brightness: Double = 1.0
|
@State private var brightness: Double = 1.0
|
||||||
|
|
||||||
|
@Environment(\.livePreviewSubscription) private var livePreview
|
||||||
|
|
||||||
private var displayWorkingDir: String {
|
private var displayWorkingDir: String {
|
||||||
// Convert absolute paths back to ~ notation for display
|
// Convert absolute paths back to ~ notation for display
|
||||||
|
|
@ -71,95 +73,23 @@ struct SessionCardView: View {
|
||||||
.fill(Theme.Colors.terminalBackground)
|
.fill(Theme.Colors.terminalBackground)
|
||||||
.frame(height: 120)
|
.frame(height: 120)
|
||||||
.overlay(
|
.overlay(
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
Group {
|
||||||
if session.isRunning {
|
if session.isRunning {
|
||||||
if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty {
|
// Show live preview if available
|
||||||
// Show terminal output preview
|
if let bufferSnapshot = livePreview?.latestSnapshot {
|
||||||
ScrollView(.vertical, showsIndicators: false) {
|
CompactTerminalPreview(snapshot: bufferSnapshot)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
.animation(.easeInOut(duration: 0.2), value: bufferSnapshot.cursorY)
|
||||||
// ESC indicator if present
|
} else if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty {
|
||||||
if snapshot.cleanOutputPreview.lowercased().contains("esc to interrupt") {
|
// Show static snapshot as fallback
|
||||||
HStack(spacing: 4) {
|
staticSnapshotView(snapshot)
|
||||||
Image(systemName: "escape")
|
|
||||||
.font(.system(size: 10, weight: .bold))
|
|
||||||
.foregroundColor(Theme.Colors.warningAccent)
|
|
||||||
Text("Press ESC to interrupt")
|
|
||||||
.font(Theme.Typography.terminalSystem(size: 10))
|
|
||||||
.foregroundColor(Theme.Colors.warningAccent)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.fill(Theme.Colors.warningAccent.opacity(0.2))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(snapshot.cleanOutputPreview)
|
|
||||||
.font(Theme.Typography.terminalSystem(size: 10))
|
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.8))
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.lineLimit(nil)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(Theme.Spacing.small)
|
|
||||||
} else {
|
} else {
|
||||||
// Show command and working directory info as fallback
|
// Show command and working directory info as fallback
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
commandInfoView
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text("$")
|
|
||||||
.font(Theme.Typography.terminalSystem(size: 12))
|
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
|
||||||
Text(session.command.joined(separator: " "))
|
|
||||||
.font(Theme.Typography.terminalSystem(size: 12))
|
|
||||||
.foregroundColor(Theme.Colors.terminalForeground)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(displayWorkingDir)
|
|
||||||
.font(Theme.Typography.terminalSystem(size: 10))
|
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
|
|
||||||
.lineLimit(1)
|
|
||||||
.onTapGesture {
|
|
||||||
UIPasteboard.general.string = session.workingDir
|
|
||||||
HapticFeedback.notification(.success)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isLoadingSnapshot {
|
|
||||||
HStack {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors
|
|
||||||
.primaryAccent
|
|
||||||
))
|
|
||||||
.scaleEffect(0.8)
|
|
||||||
Text("Loading output...")
|
|
||||||
.font(Theme.Typography.terminalSystem(size: 10))
|
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
|
||||||
}
|
|
||||||
.padding(.top, Theme.Spacing.extraSmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(Theme.Spacing.small)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// For exited sessions, show last output if available
|
||||||
if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty {
|
if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty {
|
||||||
// Show last output for exited sessions
|
exitedSessionView(snapshot)
|
||||||
ScrollView(.vertical, showsIndicators: false) {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text("Session exited")
|
|
||||||
.font(Theme.Typography.terminalSystem(size: 10))
|
|
||||||
.foregroundColor(Theme.Colors.errorAccent)
|
|
||||||
Text(snapshot.cleanOutputPreview)
|
|
||||||
.font(Theme.Typography.terminalSystem(size: 10))
|
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.lineLimit(nil)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(Theme.Spacing.small)
|
|
||||||
} else {
|
} else {
|
||||||
Text("Session exited")
|
Text("Session exited")
|
||||||
.font(Theme.Typography.terminalSystem(size: 12))
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
|
@ -184,6 +114,25 @@ struct SessionCardView: View {
|
||||||
.foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors
|
.foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors
|
||||||
.terminalForeground.opacity(0.5)
|
.terminalForeground.opacity(0.5)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Live preview indicator
|
||||||
|
if session.isRunning && livePreview?.latestSnapshot != nil {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
.symbolEffect(.pulse)
|
||||||
|
Text("live")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 9))
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(Theme.Colors.primaryAccent.opacity(0.1))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
@ -307,4 +256,93 @@ struct SessionCardView: View {
|
||||||
opacity = 1.0
|
opacity = 1.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - View Components
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var commandInfoView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("$")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
Text(session.command.joined(separator: " "))
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(displayWorkingDir)
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 10))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
|
||||||
|
.lineLimit(1)
|
||||||
|
.onTapGesture {
|
||||||
|
UIPasteboard.general.string = session.workingDir
|
||||||
|
HapticFeedback.notification(.success)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLoadingSnapshot {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
Text("Connecting...")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 10))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||||
|
}
|
||||||
|
.padding(.top, Theme.Spacing.extraSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func staticSnapshotView(_ snapshot: TerminalSnapshot) -> some View {
|
||||||
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
// ESC indicator if present
|
||||||
|
if snapshot.cleanOutputPreview.lowercased().contains("esc to interrupt") {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "escape")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundColor(Theme.Colors.warningAccent)
|
||||||
|
Text("Press ESC to interrupt")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 10))
|
||||||
|
.foregroundColor(Theme.Colors.warningAccent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Theme.Colors.warningAccent.opacity(0.2))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(snapshot.cleanOutputPreview)
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 10))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.8))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.lineLimit(nil)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func exitedSessionView(_ snapshot: TerminalSnapshot) -> some View {
|
||||||
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Session exited")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 10))
|
||||||
|
.foregroundColor(Theme.Colors.errorAccent)
|
||||||
|
Text(snapshot.cleanOutputPreview)
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 10))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.lineLimit(nil)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.small)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private let logger = Logger(category: "SessionCreate")
|
||||||
|
|
||||||
/// Custom text field style for terminal-like appearance.
|
/// Custom text field style for terminal-like appearance.
|
||||||
///
|
///
|
||||||
/// Applies terminal-themed styling to text fields including
|
/// Applies terminal-themed styling to text fields including
|
||||||
|
|
@ -34,11 +36,12 @@ struct SessionCreateView: View {
|
||||||
@State private var workingDirectory = "~/"
|
@State private var workingDirectory = "~/"
|
||||||
@State private var sessionName = ""
|
@State private var sessionName = ""
|
||||||
@State private var isCreating = false
|
@State private var isCreating = false
|
||||||
@State private var errorMessage: String?
|
@State private var presentedError: IdentifiableError?
|
||||||
@State private var showFileBrowser = false
|
@State private var showFileBrowser = false
|
||||||
|
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass)
|
||||||
|
private var horizontalSizeClass
|
||||||
|
|
||||||
enum Field {
|
enum Field {
|
||||||
case command
|
case command
|
||||||
|
|
@ -111,21 +114,12 @@ struct SessionCreateView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error Message
|
// Error Message
|
||||||
if let error = errorMessage {
|
if presentedError != nil {
|
||||||
HStack(spacing: Theme.Spacing.small) {
|
ErrorBanner(
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
message: presentedError?.error.localizedDescription ?? "An error occurred",
|
||||||
.font(.system(size: 14))
|
onDismiss: {
|
||||||
Text(error)
|
presentedError = nil
|
||||||
.font(Theme.Typography.terminalSystem(size: 13))
|
}
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
.foregroundColor(Theme.Colors.errorAccent)
|
|
||||||
.padding(.horizontal, Theme.Spacing.medium)
|
|
||||||
.padding(.vertical, Theme.Spacing.small)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
|
||||||
.fill(Theme.Colors.errorAccent.opacity(0.15))
|
|
||||||
)
|
)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||||
|
|
@ -321,6 +315,7 @@ struct SessionCreateView: View {
|
||||||
HapticFeedback.notification(.success)
|
HapticFeedback.notification(.success)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.errorAlert(item: $presentedError)
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct QuickStartItem {
|
private struct QuickStartItem {
|
||||||
|
|
@ -386,7 +381,7 @@ struct SessionCreateView: View {
|
||||||
|
|
||||||
private func createSession() {
|
private func createSession() {
|
||||||
isCreating = true
|
isCreating = true
|
||||||
errorMessage = nil
|
presentedError = nil
|
||||||
|
|
||||||
// Save preferences matching web localStorage keys
|
// Save preferences matching web localStorage keys
|
||||||
UserDefaults.standard.set(command, forKey: "vibetunnel_last_command")
|
UserDefaults.standard.set(command, forKey: "vibetunnel_last_command")
|
||||||
|
|
@ -401,30 +396,29 @@ struct SessionCreateView: View {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Log the request for debugging
|
// Log the request for debugging
|
||||||
print("[SessionCreate] Creating session with data:")
|
logger.info("Creating session with data:")
|
||||||
print(" Command: \(sessionData.command)")
|
logger.debug(" Command: \(sessionData.command)")
|
||||||
print(" Working Dir: \(sessionData.workingDir)")
|
logger.debug(" Working Dir: \(sessionData.workingDir)")
|
||||||
print(" Name: \(sessionData.name ?? "nil")")
|
logger.debug(" Name: \(sessionData.name ?? "nil")")
|
||||||
print(" Spawn Terminal: \(sessionData.spawnTerminal ?? false)")
|
logger.debug(" Spawn Terminal: \(sessionData.spawnTerminal ?? false)")
|
||||||
print(" Cols: \(sessionData.cols ?? 0), Rows: \(sessionData.rows ?? 0)")
|
logger.debug(" Cols: \(sessionData.cols ?? 0), Rows: \(sessionData.rows ?? 0)")
|
||||||
|
|
||||||
let sessionId = try await SessionService.shared.createSession(sessionData)
|
let sessionId = try await SessionService.shared.createSession(sessionData)
|
||||||
|
|
||||||
print("[SessionCreate] Session created successfully with ID: \(sessionId)")
|
logger.info("Session created successfully with ID: \(sessionId)")
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
onCreated(sessionId)
|
onCreated(sessionId)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("[SessionCreate] Failed to create session:")
|
logger.error("Failed to create session: \(error)")
|
||||||
print(" Error: \(error)")
|
|
||||||
if let apiError = error as? APIError {
|
if let apiError = error as? APIError {
|
||||||
print(" API Error: \(apiError)")
|
logger.error(" API Error: \(apiError)")
|
||||||
}
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
errorMessage = error.localizedDescription
|
presentedError = IdentifiableError(error: error)
|
||||||
isCreating = false
|
isCreating = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ import UniformTypeIdentifiers
|
||||||
/// Shows active and exited sessions with options to create new sessions,
|
/// Shows active and exited sessions with options to create new sessions,
|
||||||
/// manage existing ones, and navigate to terminal views.
|
/// manage existing ones, and navigate to terminal views.
|
||||||
struct SessionListView: View {
|
struct SessionListView: View {
|
||||||
@Environment(ConnectionManager.self) var connectionManager
|
@Environment(ConnectionManager.self)
|
||||||
@Environment(NavigationManager.self) var navigationManager
|
var connectionManager
|
||||||
@ObservedObject private var networkMonitor = NetworkMonitor.shared
|
@Environment(NavigationManager.self)
|
||||||
|
var navigationManager
|
||||||
|
@State private var networkMonitor = NetworkMonitor.shared
|
||||||
@State private var viewModel = SessionListViewModel()
|
@State private var viewModel = SessionListViewModel()
|
||||||
@State private var showingCreateSession = false
|
@State private var showingCreateSession = false
|
||||||
@State private var selectedSession: Session?
|
@State private var selectedSession: Session?
|
||||||
|
|
@ -19,6 +21,8 @@ struct SessionListView: View {
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var showingCastImporter = false
|
@State private var showingCastImporter = false
|
||||||
@State private var importedCastFile: CastFileItem?
|
@State private var importedCastFile: CastFileItem?
|
||||||
|
@State private var presentedError: IdentifiableError?
|
||||||
|
@AppStorage("enableLivePreviews") private var enableLivePreviews = true
|
||||||
|
|
||||||
var filteredSessions: [Session] {
|
var filteredSessions: [Session] {
|
||||||
let sessions = viewModel.sessions.filter { showExitedSessions || $0.isRunning }
|
let sessions = viewModel.sessions.filter { showExitedSessions || $0.isRunning }
|
||||||
|
|
@ -101,16 +105,16 @@ struct SessionListView: View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
showingSettings = true
|
showingSettings = true
|
||||||
}) {
|
}, label: {
|
||||||
Label("Settings", systemImage: "gearshape")
|
Label("Settings", systemImage: "gearshape")
|
||||||
}
|
})
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
showingCastImporter = true
|
showingCastImporter = true
|
||||||
}) {
|
}, label: {
|
||||||
Label("Import Recording", systemImage: "square.and.arrow.down")
|
Label("Import Recording", systemImage: "square.and.arrow.down")
|
||||||
}
|
})
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "ellipsis.circle")
|
Image(systemName: "ellipsis.circle")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
|
|
@ -170,21 +174,27 @@ struct SessionListView: View {
|
||||||
importedCastFile = CastFileItem(url: url)
|
importedCastFile = CastFileItem(url: url)
|
||||||
}
|
}
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
print("Failed to import cast file: \(error)")
|
logger.error("Failed to import cast file: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $importedCastFile) { item in
|
.sheet(item: $importedCastFile) { item in
|
||||||
CastPlayerView(castFileURL: item.url)
|
CastPlayerView(castFileURL: item.url)
|
||||||
}
|
}
|
||||||
|
.errorAlert(item: $presentedError)
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.loadSessions()
|
await viewModel.loadSessions()
|
||||||
}
|
}
|
||||||
.searchable(text: $searchText, prompt: "Search sessions")
|
.searchable(text: $searchText, prompt: "Search sessions")
|
||||||
.onAppear {
|
.task {
|
||||||
viewModel.startAutoRefresh()
|
await viewModel.loadSessions()
|
||||||
}
|
|
||||||
.onDisappear {
|
// Refresh every 3 seconds
|
||||||
viewModel.stopAutoRefresh()
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
|
||||||
|
if !Task.isCancelled {
|
||||||
|
await viewModel.loadSessions()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: navigationManager.shouldNavigateToSession) { _, shouldNavigate in
|
.onChange(of: navigationManager.shouldNavigateToSession) { _, shouldNavigate in
|
||||||
|
|
@ -195,6 +205,12 @@ struct SessionListView: View {
|
||||||
navigationManager.clearNavigation()
|
navigationManager.clearNavigation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.errorMessage) { _, newError in
|
||||||
|
if let error = newError {
|
||||||
|
presentedError = IdentifiableError(error: APIError.serverError(0, error))
|
||||||
|
viewModel.errorMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyStateView: some View {
|
private var emptyStateView: some View {
|
||||||
|
|
@ -256,10 +272,10 @@ struct SessionListView: View {
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: { searchText = "" }) {
|
Button(action: { searchText = "" }, label: {
|
||||||
Label("Clear Search", systemImage: "xmark.circle.fill")
|
Label("Clear Search", systemImage: "xmark.circle.fill")
|
||||||
.font(Theme.Typography.terminalSystem(size: 14))
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
}
|
})
|
||||||
.terminalButton()
|
.terminalButton()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
@ -295,7 +311,6 @@ struct SessionListView: View {
|
||||||
GridItem(.flexible(), spacing: Theme.Spacing.medium),
|
GridItem(.flexible(), spacing: Theme.Spacing.medium),
|
||||||
GridItem(.flexible(), spacing: Theme.Spacing.medium)
|
GridItem(.flexible(), spacing: Theme.Spacing.medium)
|
||||||
], spacing: Theme.Spacing.medium) {
|
], spacing: Theme.Spacing.medium) {
|
||||||
|
|
||||||
ForEach(filteredSessions) { session in
|
ForEach(filteredSessions) { session in
|
||||||
SessionCardView(session: session) {
|
SessionCardView(session: session) {
|
||||||
HapticFeedback.selection()
|
HapticFeedback.selection()
|
||||||
|
|
@ -313,6 +328,7 @@ struct SessionListView: View {
|
||||||
await viewModel.cleanupSession(session.id)
|
await viewModel.cleanupSession(session.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.livePreview(for: session.id, enabled: session.isRunning && enableLivePreviews)
|
||||||
.transition(.asymmetric(
|
.transition(.asymmetric(
|
||||||
insertion: .scale(scale: 0.8).combined(with: .opacity),
|
insertion: .scale(scale: 0.8).combined(with: .opacity),
|
||||||
removal: .scale(scale: 0.8).combined(with: .opacity)
|
removal: .scale(scale: 0.8).combined(with: .opacity)
|
||||||
|
|
@ -372,31 +388,6 @@ struct SessionListView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Error Banner
|
|
||||||
|
|
||||||
struct ErrorBanner: View {
|
|
||||||
let message: String
|
|
||||||
let isOffline: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: isOffline ? "wifi.slash" : "exclamationmark.triangle")
|
|
||||||
.foregroundColor(Theme.Colors.terminalBackground)
|
|
||||||
|
|
||||||
Text(message)
|
|
||||||
.font(Theme.Typography.terminalSystem(size: 14))
|
|
||||||
.foregroundColor(Theme.Colors.terminalBackground)
|
|
||||||
.lineLimit(2)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.background(isOffline ? Color.orange : Theme.Colors.errorAccent)
|
|
||||||
.cornerRadius(Theme.CornerRadius.small)
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// View model for managing session list state and operations.
|
/// View model for managing session list state and operations.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
@ -406,29 +397,8 @@ class SessionListViewModel {
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
private var refreshTask: Task<Void, Never>?
|
|
||||||
private let sessionService = SessionService.shared
|
private let sessionService = SessionService.shared
|
||||||
|
|
||||||
func startAutoRefresh() {
|
|
||||||
refreshTask?.cancel()
|
|
||||||
refreshTask = Task {
|
|
||||||
await loadSessions()
|
|
||||||
|
|
||||||
// Refresh every 3 seconds using modern async approach
|
|
||||||
while !Task.isCancelled {
|
|
||||||
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
|
|
||||||
if !Task.isCancelled {
|
|
||||||
await loadSessions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopAutoRefresh() {
|
|
||||||
refreshTask?.cancel()
|
|
||||||
refreshTask = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadSessions() async {
|
func loadSessions() async {
|
||||||
if sessions.isEmpty {
|
if sessions.isEmpty {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
|
@ -493,8 +463,8 @@ struct SessionHeaderView: View {
|
||||||
let onKillAll: () -> Void
|
let onKillAll: () -> Void
|
||||||
let onCleanupAll: () -> Void
|
let onCleanupAll: () -> Void
|
||||||
|
|
||||||
private var runningCount: Int { sessions.count(where: { $0.isRunning }) }
|
private var runningCount: Int { sessions.count { $0.isRunning }}
|
||||||
private var exitedCount: Int { sessions.count(where: { !$0.isRunning }) }
|
private var exitedCount: Int { sessions.count { !$0.isRunning }}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: Theme.Spacing.medium) {
|
VStack(spacing: Theme.Spacing.medium) {
|
||||||
|
|
@ -505,28 +475,28 @@ struct SessionHeaderView: View {
|
||||||
count: runningCount,
|
count: runningCount,
|
||||||
color: Theme.Colors.successAccent
|
color: Theme.Colors.successAccent
|
||||||
)
|
)
|
||||||
|
|
||||||
SessionCountBadge(
|
SessionCountBadge(
|
||||||
label: "Exited",
|
label: "Exited",
|
||||||
count: exitedCount,
|
count: exitedCount,
|
||||||
color: Theme.Colors.errorAccent
|
color: Theme.Colors.errorAccent
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action buttons
|
// Action buttons
|
||||||
HStack(spacing: Theme.Spacing.medium) {
|
HStack(spacing: Theme.Spacing.medium) {
|
||||||
if exitedCount > 0 {
|
if exitedCount > 0 {
|
||||||
ExitedSessionToggle(showExitedSessions: $showExitedSessions)
|
ExitedSessionToggle(showExitedSessions: $showExitedSessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if showExitedSessions && sessions.contains(where: { !$0.isRunning }) {
|
if showExitedSessions && sessions.contains(where: { !$0.isRunning }) {
|
||||||
CleanupAllHeaderButton(onCleanup: onCleanupAll)
|
CleanupAllHeaderButton(onCleanup: onCleanupAll)
|
||||||
}
|
}
|
||||||
|
|
||||||
if sessions.contains(where: \.isRunning) {
|
if sessions.contains(where: \.isRunning) {
|
||||||
KillAllButton(onKillAll: onKillAll)
|
KillAllButton(onKillAll: onKillAll)
|
||||||
}
|
}
|
||||||
|
|
@ -540,14 +510,14 @@ struct SessionCountBadge: View {
|
||||||
let label: String
|
let label: String
|
||||||
let count: Int
|
let count: Int
|
||||||
let color: Color
|
let color: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(Theme.Typography.terminalSystem(size: 12))
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
|
|
||||||
Text("\(count)")
|
Text("\(count)")
|
||||||
.font(Theme.Typography.terminalSystem(size: 28))
|
.font(Theme.Typography.terminalSystem(size: 28))
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
|
|
@ -683,3 +653,7 @@ struct CastFileItem: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let url: URL
|
let url: URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Logging
|
||||||
|
|
||||||
|
private let logger = Logger(category: "SessionListView")
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@ struct SettingsView: View {
|
||||||
enum SettingsTab: String, CaseIterable {
|
enum SettingsTab: String, CaseIterable {
|
||||||
case general = "General"
|
case general = "General"
|
||||||
case advanced = "Advanced"
|
case advanced = "Advanced"
|
||||||
|
case about = "About"
|
||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .general: "gear"
|
case .general: "gear"
|
||||||
case .advanced: "gearshape.2"
|
case .advanced: "gearshape.2"
|
||||||
|
case .about: "info.circle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,6 +62,8 @@ struct SettingsView: View {
|
||||||
GeneralSettingsView()
|
GeneralSettingsView()
|
||||||
case .advanced:
|
case .advanced:
|
||||||
AdvancedSettingsView()
|
AdvancedSettingsView()
|
||||||
|
case .about:
|
||||||
|
AboutSettingsView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
@ -91,6 +95,8 @@ struct GeneralSettingsView: View {
|
||||||
private var autoScrollEnabled = true
|
private var autoScrollEnabled = true
|
||||||
@AppStorage("enableURLDetection")
|
@AppStorage("enableURLDetection")
|
||||||
private var enableURLDetection = true
|
private var enableURLDetection = true
|
||||||
|
@AppStorage("enableLivePreviews")
|
||||||
|
private var enableLivePreviews = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.large) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.large) {
|
||||||
|
|
@ -166,6 +172,26 @@ struct GeneralSettingsView: View {
|
||||||
.padding()
|
.padding()
|
||||||
.background(Theme.Colors.cardBackground)
|
.background(Theme.Colors.cardBackground)
|
||||||
.cornerRadius(Theme.CornerRadius.card)
|
.cornerRadius(Theme.CornerRadius.card)
|
||||||
|
|
||||||
|
// Live Previews
|
||||||
|
Toggle(isOn: $enableLivePreviews) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Live Session Previews")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
Text("Show real-time terminal output in session cards")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toggleStyle(SwitchToggleStyle(tint: Theme.Colors.primaryAccent))
|
||||||
|
.padding()
|
||||||
|
.background(Theme.Colors.cardBackground)
|
||||||
|
.cornerRadius(Theme.CornerRadius.card)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,6 +207,16 @@ struct AdvancedSettingsView: View {
|
||||||
@AppStorage("debugModeEnabled")
|
@AppStorage("debugModeEnabled")
|
||||||
private var debugModeEnabled = false
|
private var debugModeEnabled = false
|
||||||
@State private var showingSystemLogs = false
|
@State private var showingSystemLogs = false
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
@AppStorage("macWindowStyle")
|
||||||
|
private var macWindowStyleRaw = "standard"
|
||||||
|
@StateObject private var windowManager = MacCatalystWindowManager.shared
|
||||||
|
|
||||||
|
private var macWindowStyle: MacWindowStyle {
|
||||||
|
macWindowStyleRaw == "inline" ? .inline : .standard
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.large) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.large) {
|
||||||
|
|
@ -210,7 +246,7 @@ struct AdvancedSettingsView: View {
|
||||||
.padding()
|
.padding()
|
||||||
.background(Theme.Colors.cardBackground)
|
.background(Theme.Colors.cardBackground)
|
||||||
.cornerRadius(Theme.CornerRadius.card)
|
.cornerRadius(Theme.CornerRadius.card)
|
||||||
|
|
||||||
// View System Logs Button
|
// View System Logs Button
|
||||||
Button(action: { showingSystemLogs = true }) {
|
Button(action: { showingSystemLogs = true }) {
|
||||||
HStack {
|
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
|
// Developer Section
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
|
||||||
Text("Developer")
|
Text("Developer")
|
||||||
|
|
@ -270,6 +345,129 @@ struct AdvancedSettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// About settings tab content
|
||||||
|
struct AboutSettingsView: View {
|
||||||
|
private var appVersion: String {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buildNumber: String {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Theme.Spacing.xlarge) {
|
||||||
|
// App icon and info
|
||||||
|
VStack(spacing: Theme.Spacing.large) {
|
||||||
|
Image("AppIcon")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
.cornerRadius(22)
|
||||||
|
.shadow(color: Theme.Colors.primaryAccent.opacity(0.3), radius: 10, y: 5)
|
||||||
|
|
||||||
|
VStack(spacing: Theme.Spacing.small) {
|
||||||
|
Text("VibeTunnel")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Text("Version \(appVersion) (\(buildNumber))")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
|
.foregroundColor(Theme.Colors.secondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, Theme.Spacing.large)
|
||||||
|
|
||||||
|
// Links section
|
||||||
|
VStack(spacing: Theme.Spacing.medium) {
|
||||||
|
LinkRow(
|
||||||
|
icon: "globe",
|
||||||
|
title: "Website",
|
||||||
|
subtitle: "vibetunnel.sh",
|
||||||
|
url: URL(string: "https://vibetunnel.sh")
|
||||||
|
)
|
||||||
|
|
||||||
|
LinkRow(
|
||||||
|
icon: "doc.text",
|
||||||
|
title: "Documentation",
|
||||||
|
subtitle: "Learn how to use VibeTunnel",
|
||||||
|
url: URL(string: "https://docs.vibetunnel.sh")
|
||||||
|
)
|
||||||
|
|
||||||
|
LinkRow(
|
||||||
|
icon: "exclamationmark.bubble",
|
||||||
|
title: "Report an Issue",
|
||||||
|
subtitle: "Help us improve",
|
||||||
|
url: URL(string: "https://github.com/vibetunnel/vibetunnel/issues")
|
||||||
|
)
|
||||||
|
|
||||||
|
LinkRow(
|
||||||
|
icon: "heart",
|
||||||
|
title: "Rate on App Store",
|
||||||
|
subtitle: "Share your feedback",
|
||||||
|
url: URL(string: "https://apps.apple.com/app/vibetunnel")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credits
|
||||||
|
VStack(spacing: Theme.Spacing.small) {
|
||||||
|
Text("Made with ❤️ by the VibeTunnel team")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
.foregroundColor(Theme.Colors.secondaryText)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text("© 2024 VibeTunnel. All rights reserved.")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 11))
|
||||||
|
.foregroundColor(Theme.Colors.secondaryText.opacity(0.7))
|
||||||
|
}
|
||||||
|
.padding(.top, Theme.Spacing.large)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LinkRow: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let url: URL?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: {
|
||||||
|
if let url = url {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack(spacing: Theme.Spacing.medium) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
.frame(width: 30)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
.foregroundColor(Theme.Colors.secondaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "arrow.up.right.square")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(Theme.Colors.secondaryText.opacity(0.5))
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Theme.Colors.cardBackground)
|
||||||
|
.cornerRadius(Theme.CornerRadius.card)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ import SwiftUI
|
||||||
|
|
||||||
/// System logs viewer with filtering and search capabilities
|
/// System logs viewer with filtering and search capabilities
|
||||||
struct SystemLogsView: View {
|
struct SystemLogsView: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss)
|
||||||
|
var dismiss
|
||||||
@State private var logs = ""
|
@State private var logs = ""
|
||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
@State private var error: String?
|
@State private var presentedError: IdentifiableError?
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var selectedLevel: LogLevel = .all
|
@State private var selectedLevel: LogLevel = .all
|
||||||
@State private var showClientLogs = true
|
@State private var showClientLogs = true
|
||||||
|
|
@ -14,96 +15,93 @@ struct SystemLogsView: View {
|
||||||
@State private var refreshTimer: Timer?
|
@State private var refreshTimer: Timer?
|
||||||
@State private var showingClearConfirmation = false
|
@State private var showingClearConfirmation = false
|
||||||
@State private var logsInfo: LogsInfo?
|
@State private var logsInfo: LogsInfo?
|
||||||
|
|
||||||
enum LogLevel: String, CaseIterable {
|
enum LogLevel: String, CaseIterable {
|
||||||
case all = "All"
|
case all = "All"
|
||||||
case error = "Error"
|
case error = "Error"
|
||||||
case warn = "Warn"
|
case warn = "Warn"
|
||||||
case log = "Log"
|
case log = "Log"
|
||||||
case debug = "Debug"
|
case debug = "Debug"
|
||||||
|
|
||||||
var displayName: String { rawValue }
|
var displayName: String { rawValue }
|
||||||
|
|
||||||
func matches(_ line: String) -> Bool {
|
func matches(_ line: String) -> Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .all:
|
case .all:
|
||||||
return true
|
true
|
||||||
case .error:
|
case .error:
|
||||||
return line.localizedCaseInsensitiveContains("[ERROR]") ||
|
line.localizedCaseInsensitiveContains("[ERROR]") ||
|
||||||
line.localizedCaseInsensitiveContains("error:")
|
line.localizedCaseInsensitiveContains("error:")
|
||||||
case .warn:
|
case .warn:
|
||||||
return line.localizedCaseInsensitiveContains("[WARN]") ||
|
line.localizedCaseInsensitiveContains("[WARN]") ||
|
||||||
line.localizedCaseInsensitiveContains("warning:")
|
line.localizedCaseInsensitiveContains("warning:")
|
||||||
case .log:
|
case .log:
|
||||||
return line.localizedCaseInsensitiveContains("[LOG]") ||
|
line.localizedCaseInsensitiveContains("[LOG]") ||
|
||||||
line.localizedCaseInsensitiveContains("log:")
|
line.localizedCaseInsensitiveContains("log:")
|
||||||
case .debug:
|
case .debug:
|
||||||
return line.localizedCaseInsensitiveContains("[DEBUG]") ||
|
line.localizedCaseInsensitiveContains("[DEBUG]") ||
|
||||||
line.localizedCaseInsensitiveContains("debug:")
|
line.localizedCaseInsensitiveContains("debug:")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var filteredLogs: String {
|
var filteredLogs: String {
|
||||||
let lines = logs.components(separatedBy: .newlines)
|
let lines = logs.components(separatedBy: .newlines)
|
||||||
let filtered = lines.filter { line in
|
let filtered = lines.filter { line in
|
||||||
// Skip empty lines
|
// Skip empty lines
|
||||||
guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
|
guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
|
||||||
|
|
||||||
// Filter by level
|
// Filter by level
|
||||||
if selectedLevel != .all && !selectedLevel.matches(line) {
|
if selectedLevel != .all && !selectedLevel.matches(line) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by source
|
// Filter by source
|
||||||
let isClientLog = line.contains("[Client]") || line.contains("client:")
|
let isClientLog = line.contains("[Client]") || line.contains("client:")
|
||||||
let isServerLog = line.contains("[Server]") || line.contains("server:") || (!isClientLog)
|
let isServerLog = line.contains("[Server]") || line.contains("server:") || !isClientLog
|
||||||
|
|
||||||
if !showClientLogs && isClientLog {
|
if !showClientLogs && isClientLog {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if !showServerLogs && isServerLog {
|
if !showServerLogs && isServerLog {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by search text
|
// Filter by search text
|
||||||
if !searchText.isEmpty && !line.localizedCaseInsensitiveContains(searchText) {
|
if !searchText.isEmpty && !line.localizedCaseInsensitiveContains(searchText) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered.joined(separator: "\n")
|
return filtered.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
Theme.Colors.terminalBackground
|
Theme.Colors.terminalBackground
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Filters toolbar
|
// Filters toolbar
|
||||||
filtersToolbar
|
filtersToolbar
|
||||||
|
|
||||||
// Search bar
|
// Search bar
|
||||||
searchBar
|
searchBar
|
||||||
|
|
||||||
// Logs content
|
// Logs content
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView("Loading logs...")
|
ProgressView("Loading logs...")
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else if let error = error {
|
} else if presentedError != nil {
|
||||||
VStack {
|
ContentUnavailableView {
|
||||||
Text("Error loading logs")
|
Label("Failed to Load Logs", systemImage: "exclamationmark.triangle")
|
||||||
.font(.headline)
|
} description: {
|
||||||
.foregroundColor(Theme.Colors.errorAccent)
|
Text("The logs could not be loaded. Please try again.")
|
||||||
Text(error)
|
} actions: {
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(Theme.Colors.terminalForeground)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
Button("Retry") {
|
Button("Retry") {
|
||||||
Task {
|
Task {
|
||||||
await loadLogs()
|
await loadLogs()
|
||||||
|
|
@ -126,19 +124,19 @@ struct SystemLogsView: View {
|
||||||
}
|
}
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Menu {
|
Menu {
|
||||||
Button(action: downloadLogs) {
|
Button(action: downloadLogs) {
|
||||||
Label("Download", systemImage: "square.and.arrow.down")
|
Label("Download", systemImage: "square.and.arrow.down")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: { showingClearConfirmation = true }) {
|
Button(action: { showingClearConfirmation = true }, label: {
|
||||||
Label("Clear Logs", systemImage: "trash")
|
Label("Clear Logs", systemImage: "trash")
|
||||||
}
|
})
|
||||||
|
|
||||||
Toggle("Auto-scroll", isOn: $autoScroll)
|
Toggle("Auto-scroll", isOn: $autoScroll)
|
||||||
|
|
||||||
if let info = logsInfo {
|
if let info = logsInfo {
|
||||||
Section {
|
Section {
|
||||||
Label(formatFileSize(info.size), systemImage: "doc")
|
Label(formatFileSize(info.size), systemImage: "doc")
|
||||||
|
|
@ -169,22 +167,23 @@ struct SystemLogsView: View {
|
||||||
} message: {
|
} message: {
|
||||||
Text("Are you sure you want to clear all system logs? This action cannot be undone.")
|
Text("Are you sure you want to clear all system logs? This action cannot be undone.")
|
||||||
}
|
}
|
||||||
|
.errorAlert(item: $presentedError)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var filtersToolbar: some View {
|
private var filtersToolbar: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
// Level filter
|
// Level filter
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(LogLevel.allCases, id: \.self) { level in
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
Button(action: { selectedLevel = level }) {
|
Button(action: { selectedLevel = level }, label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(level.displayName)
|
Text(level.displayName)
|
||||||
if selectedLevel == level {
|
if selectedLevel == level {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
|
|
@ -197,14 +196,14 @@ struct SystemLogsView: View {
|
||||||
.background(Theme.Colors.cardBackground)
|
.background(Theme.Colors.cardBackground)
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source toggles
|
// Source toggles
|
||||||
Toggle("Client", isOn: $showClientLogs)
|
Toggle("Client", isOn: $showClientLogs)
|
||||||
.toggleStyle(ChipToggleStyle())
|
.toggleStyle(ChipToggleStyle())
|
||||||
|
|
||||||
Toggle("Server", isOn: $showServerLogs)
|
Toggle("Server", isOn: $showServerLogs)
|
||||||
.toggleStyle(ChipToggleStyle())
|
.toggleStyle(ChipToggleStyle())
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
@ -212,31 +211,31 @@ struct SystemLogsView: View {
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(Theme.Colors.cardBackground)
|
.background(Theme.Colors.cardBackground)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var searchBar: some View {
|
private var searchBar: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||||
|
|
||||||
TextField("Search logs...", text: $searchText)
|
TextField("Search logs...", text: $searchText)
|
||||||
.textFieldStyle(PlainTextFieldStyle())
|
.textFieldStyle(PlainTextFieldStyle())
|
||||||
.font(Theme.Typography.terminalSystem(size: 14))
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
.foregroundColor(Theme.Colors.terminalForeground)
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
|
|
||||||
if !searchText.isEmpty {
|
if !searchText.isEmpty {
|
||||||
Button(action: { searchText = "" }) {
|
Button(action: { searchText = "" }, label: {
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(Theme.Colors.terminalDarkGray)
|
.background(Theme.Colors.terminalDarkGray)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var logsContent: some View {
|
private var logsContent: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|
@ -258,42 +257,42 @@ struct SystemLogsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadLogs() async {
|
private func loadLogs() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
presentedError = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Load logs content
|
// Load logs content
|
||||||
logs = try await APIClient.shared.getLogsRaw()
|
logs = try await APIClient.shared.getLogsRaw()
|
||||||
|
|
||||||
// Load logs info
|
// Load logs info
|
||||||
logsInfo = try await APIClient.shared.getLogsInfo()
|
logsInfo = try await APIClient.shared.getLogsInfo()
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
presentedError = IdentifiableError(error: error)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearLogs() async {
|
private func clearLogs() async {
|
||||||
do {
|
do {
|
||||||
try await APIClient.shared.clearLogs()
|
try await APIClient.shared.clearLogs()
|
||||||
logs = ""
|
logs = ""
|
||||||
await loadLogs()
|
await loadLogs()
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
presentedError = IdentifiableError(error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func downloadLogs() {
|
private func downloadLogs() {
|
||||||
// Create activity controller with logs
|
// Create activity controller with logs
|
||||||
let activityVC = UIActivityViewController(
|
let activityVC = UIActivityViewController(
|
||||||
activityItems: [logs],
|
activityItems: [logs],
|
||||||
applicationActivities: nil
|
applicationActivities: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
// Present it
|
// Present it
|
||||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
let window = windowScene.windows.first,
|
let window = windowScene.windows.first,
|
||||||
|
|
@ -301,7 +300,7 @@ struct SystemLogsView: View {
|
||||||
rootVC.present(activityVC, animated: true)
|
rootVC.present(activityVC, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startAutoRefresh() {
|
private func startAutoRefresh() {
|
||||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { _ in
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { _ in
|
||||||
Task {
|
Task {
|
||||||
|
|
@ -309,12 +308,12 @@ struct SystemLogsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopAutoRefresh() {
|
private func stopAutoRefresh() {
|
||||||
refreshTimer?.invalidate()
|
refreshTimer?.invalidate()
|
||||||
refreshTimer = nil
|
refreshTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatFileSize(_ size: Int64) -> String {
|
private func formatFileSize(_ size: Int64) -> String {
|
||||||
let formatter = ByteCountFormatter()
|
let formatter = ByteCountFormatter()
|
||||||
formatter.countStyle = .binary
|
formatter.countStyle = .binary
|
||||||
|
|
@ -325,7 +324,7 @@ struct SystemLogsView: View {
|
||||||
/// Custom toggle style for filter chips
|
/// Custom toggle style for filter chips
|
||||||
struct ChipToggleStyle: ToggleStyle {
|
struct ChipToggleStyle: ToggleStyle {
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
Button(action: { configuration.isOn.toggle() }) {
|
Button(action: { configuration.isOn.toggle() }, label: {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
if configuration.isOn {
|
if configuration.isOn {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
|
|
@ -339,7 +338,7 @@ struct ChipToggleStyle: ToggleStyle {
|
||||||
.background(configuration.isOn ? Theme.Colors.primaryAccent.opacity(0.2) : Theme.Colors.cardBackground)
|
.background(configuration.isOn ? Theme.Colors.primaryAccent.opacity(0.2) : Theme.Colors.cardBackground)
|
||||||
.foregroundColor(configuration.isOn ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground)
|
.foregroundColor(configuration.isOn ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground)
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
})
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private let logger = Logger(category: "AdvancedKeyboard")
|
||||||
|
|
||||||
/// Advanced keyboard view with special keys and control combinations
|
/// Advanced keyboard view with special keys and control combinations
|
||||||
struct AdvancedKeyboardView: View {
|
struct AdvancedKeyboardView: View {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
|
|
@ -230,8 +232,9 @@ struct CtrlKeyButton: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// Calculate control character (Ctrl+A = 1, Ctrl+B = 2, etc.)
|
// Calculate control character (Ctrl+A = 1, Ctrl+B = 2, etc.)
|
||||||
if let scalar = char.unicodeScalars.first {
|
if let scalar = char.unicodeScalars.first,
|
||||||
let ctrlChar = Character(UnicodeScalar(scalar.value - 64)!)
|
let ctrlScalar = UnicodeScalar(scalar.value - 64) {
|
||||||
|
let ctrlChar = Character(ctrlScalar)
|
||||||
onPress(String(ctrlChar))
|
onPress(String(ctrlChar))
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -292,6 +295,6 @@ struct FunctionKeyButton: View {
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
AdvancedKeyboardView(isPresented: .constant(true)) { input in
|
AdvancedKeyboardView(isPresented: .constant(true)) { input in
|
||||||
print("Input: \(input)")
|
logger.debug("Input: \(input)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ import UniformTypeIdentifiers
|
||||||
/// supporting the Asciinema cast v2 format.
|
/// supporting the Asciinema cast v2 format.
|
||||||
struct CastPlayerView: View {
|
struct CastPlayerView: View {
|
||||||
let castFileURL: URL
|
let castFileURL: URL
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss)
|
||||||
|
var dismiss
|
||||||
@State private var viewModel = CastPlayerViewModel()
|
@State private var viewModel = CastPlayerViewModel()
|
||||||
@State private var fontSize: CGFloat = 14
|
@State private var fontSize: CGFloat = 14
|
||||||
@State private var isPlaying = false
|
@State private var isPlaying = false
|
||||||
|
|
|
||||||
216
ios/VibeTunnel/Views/Terminal/CtrlKeyGrid.swift
Normal file
216
ios/VibeTunnel/Views/Terminal/CtrlKeyGrid.swift
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private let logger = Logger(category: "FileBrowserFAB")
|
||||||
|
|
||||||
/// Floating action button for opening file browser
|
/// Floating action button for opening file browser
|
||||||
struct FileBrowserFAB: View {
|
struct FileBrowserFAB: View {
|
||||||
let isVisible: Bool
|
let isVisible: Bool
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
HapticFeedback.impact(.medium)
|
Task { @MainActor in
|
||||||
|
HapticFeedback.impact(.medium)
|
||||||
|
}
|
||||||
action()
|
action()
|
||||||
}) {
|
}, label: {
|
||||||
Image(systemName: "folder.fill")
|
Image(systemName: "folder.fill")
|
||||||
.font(.system(size: 20, weight: .medium))
|
.font(.system(size: 20, weight: .medium))
|
||||||
.foregroundColor(Theme.Colors.terminalBackground)
|
.foregroundColor(Theme.Colors.terminalBackground)
|
||||||
|
|
@ -23,7 +27,7 @@ struct FileBrowserFAB: View {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
|
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
|
||||||
}
|
})
|
||||||
.opacity(isVisible ? 1 : 0)
|
.opacity(isVisible ? 1 : 0)
|
||||||
.scaleEffect(isVisible ? 1 : 0.8)
|
.scaleEffect(isVisible ? 1 : 0.8)
|
||||||
.animation(Theme.Animation.smooth, value: isVisible)
|
.animation(Theme.Animation.smooth, value: isVisible)
|
||||||
|
|
@ -31,31 +35,22 @@ struct FileBrowserFAB: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extension to add file browser FAB overlay modifier
|
// Note: Use FileBrowserFAB directly with overlay instead of this extension
|
||||||
extension View {
|
// Example:
|
||||||
func fileBrowserFABOverlay(
|
// .overlay(
|
||||||
isVisible: Bool,
|
// FileBrowserFAB(isVisible: showFAB, action: { })
|
||||||
action: @escaping () -> Void
|
// .padding(.bottom, Theme.Spacing.extraLarge)
|
||||||
) -> some View {
|
// .padding(.trailing, Theme.Spacing.large),
|
||||||
self.overlay(
|
// alignment: .bottomTrailing
|
||||||
FileBrowserFAB(
|
// )
|
||||||
isVisible: isVisible,
|
|
||||||
action: action
|
|
||||||
)
|
|
||||||
.padding(.bottom, Theme.Spacing.extraLarge)
|
|
||||||
.padding(.trailing, Theme.Spacing.large),
|
|
||||||
alignment: .bottomTrailing
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ZStack {
|
ZStack {
|
||||||
Theme.Colors.terminalBackground
|
Theme.Colors.terminalBackground
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
FileBrowserFAB(isVisible: true) {
|
FileBrowserFAB(isVisible: true) {
|
||||||
print("Open file browser")
|
logger.debug("Open file browser")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import SwiftUI
|
||||||
/// of how the terminal text will appear.
|
/// of how the terminal text will appear.
|
||||||
struct FontSizeSheet: View {
|
struct FontSizeSheet: View {
|
||||||
@Binding var fontSize: CGFloat
|
@Binding var fontSize: CGFloat
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss)
|
||||||
|
var dismiss
|
||||||
|
|
||||||
let fontSizes: [CGFloat] = [10, 12, 14, 16, 18, 20, 22, 24, 28, 32]
|
let fontSizes: [CGFloat] = [10, 12, 14, 16, 18, 20, 22, 24, 28, 32]
|
||||||
|
|
||||||
|
|
|
||||||
214
ios/VibeTunnel/Views/Terminal/FullscreenTextInput.swift
Normal file
214
ios/VibeTunnel/Views/Terminal/FullscreenTextInput.swift
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
76
ios/VibeTunnel/Views/Terminal/QuickFontSizeButtons.swift
Normal file
76
ios/VibeTunnel/Views/Terminal/QuickFontSizeButtons.swift
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
private let logger = Logger(category: "RecordingExport")
|
||||||
|
|
||||||
/// Sheet for exporting terminal recordings.
|
/// Sheet for exporting terminal recordings.
|
||||||
///
|
///
|
||||||
/// Provides interface for exporting recorded terminal sessions
|
/// Provides interface for exporting recorded terminal sessions
|
||||||
|
|
@ -8,7 +10,8 @@ import UniformTypeIdentifiers
|
||||||
struct RecordingExportSheet: View {
|
struct RecordingExportSheet: View {
|
||||||
var recorder: CastRecorder
|
var recorder: CastRecorder
|
||||||
let sessionName: String
|
let sessionName: String
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss)
|
||||||
|
var dismiss
|
||||||
@State private var isExporting = false
|
@State private var isExporting = false
|
||||||
@State private var showingShareSheet = false
|
@State private var showingShareSheet = false
|
||||||
@State private var exportedFileURL: URL?
|
@State private var exportedFileURL: URL?
|
||||||
|
|
@ -126,7 +129,7 @@ struct RecordingExportSheet: View {
|
||||||
showingShareSheet = true
|
showingShareSheet = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to save cast file: \(error)")
|
logger.error("Failed to save cast file: \(error)")
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
isExporting = false
|
isExporting = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private let logger = Logger(category: "ScrollToBottomButton")
|
||||||
|
|
||||||
/// Floating action button to scroll terminal to bottom
|
/// Floating action button to scroll terminal to bottom
|
||||||
struct ScrollToBottomButton: View {
|
struct ScrollToBottomButton: View {
|
||||||
let isVisible: Bool
|
let isVisible: Bool
|
||||||
|
|
@ -11,7 +13,7 @@ struct ScrollToBottomButton: View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
action()
|
action()
|
||||||
}) {
|
}, label: {
|
||||||
Text("↓")
|
Text("↓")
|
||||||
.font(.system(size: 24, weight: .bold))
|
.font(.system(size: 24, weight: .bold))
|
||||||
.foregroundColor(isHovered ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground)
|
.foregroundColor(isHovered ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground)
|
||||||
|
|
@ -35,7 +37,7 @@ struct ScrollToBottomButton: View {
|
||||||
)
|
)
|
||||||
.scaleEffect(isPressed ? 0.95 : 1.0)
|
.scaleEffect(isPressed ? 0.95 : 1.0)
|
||||||
.offset(y: isHovered && !isPressed ? -1 : 0)
|
.offset(y: isHovered && !isPressed ? -1 : 0)
|
||||||
}
|
})
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
.opacity(isVisible ? 1 : 0)
|
.opacity(isVisible ? 1 : 0)
|
||||||
.scaleEffect(isVisible ? 1 : 0.8)
|
.scaleEffect(isVisible ? 1 : 0.8)
|
||||||
|
|
@ -54,24 +56,14 @@ struct ScrollToBottomButton: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extension to add scroll-to-bottom overlay modifier
|
// Note: Use ScrollToBottomButton directly with overlay instead of this extension
|
||||||
extension View {
|
// Example:
|
||||||
func scrollToBottomOverlay(
|
// .overlay(
|
||||||
isVisible: Bool,
|
// ScrollToBottomButton(isVisible: showButton, action: { })
|
||||||
action: @escaping () -> Void
|
// .padding(.bottom, Theme.Spacing.large)
|
||||||
)
|
// .padding(.leading, Theme.Spacing.large),
|
||||||
-> some View {
|
// alignment: .bottomLeading
|
||||||
self.overlay(
|
// )
|
||||||
ScrollToBottomButton(
|
|
||||||
isVisible: isVisible,
|
|
||||||
action: action
|
|
||||||
)
|
|
||||||
.padding(.bottom, Theme.Spacing.large)
|
|
||||||
.padding(.leading, Theme.Spacing.large),
|
|
||||||
alignment: .bottomLeading
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
|
@ -79,7 +71,7 @@ extension View {
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
ScrollToBottomButton(isVisible: true) {
|
ScrollToBottomButton(isVisible: true) {
|
||||||
print("Scroll to bottom")
|
logger.debug("Scroll to bottom")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
188
ios/VibeTunnel/Views/Terminal/TerminalBufferPreview.swift
Normal file
188
ios/VibeTunnel/Views/Terminal/TerminalBufferPreview.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,8 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
let onResize: (Int, Int) -> Void
|
let onResize: (Int, Int) -> Void
|
||||||
var viewModel: TerminalViewModel
|
var viewModel: TerminalViewModel
|
||||||
@State private var isAutoScrollEnabled = true
|
@State private var isAutoScrollEnabled = true
|
||||||
@AppStorage("enableURLDetection") private var enableURLDetection = true
|
@AppStorage("enableURLDetection")
|
||||||
|
private var enableURLDetection = true
|
||||||
|
|
||||||
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
|
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
|
||||||
let terminal = SwiftTerm.TerminalView()
|
let terminal = SwiftTerm.TerminalView()
|
||||||
|
|
@ -120,7 +121,7 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
// Use system monospaced font which has better compatibility with SwiftTerm
|
// Use system monospaced font which has better compatibility with SwiftTerm
|
||||||
// The custom SF Mono font seems to have rendering issues
|
// The custom SF Mono font seems to have rendering issues
|
||||||
let font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
let font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||||
|
|
||||||
// SwiftTerm uses the font property directly
|
// SwiftTerm uses the font property directly
|
||||||
terminal.font = font
|
terminal.font = font
|
||||||
}
|
}
|
||||||
|
|
@ -179,8 +180,11 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
/// Update terminal buffer from binary buffer data using optimized ANSI sequences
|
/// Update terminal buffer from binary buffer data using optimized ANSI sequences
|
||||||
func updateBuffer(from snapshot: BufferSnapshot) {
|
func updateBuffer(from snapshot: BufferSnapshot) {
|
||||||
guard let terminal else { return }
|
guard let terminal else { return }
|
||||||
|
|
||||||
logger.verbose("updateBuffer called with snapshot: \(snapshot.cols)x\(snapshot.rows), cursor: (\(snapshot.cursorX),\(snapshot.cursorY))")
|
logger
|
||||||
|
.verbose(
|
||||||
|
"updateBuffer called with snapshot: \(snapshot.cols)x\(snapshot.rows), cursor: (\(snapshot.cursorX),\(snapshot.cursorY))"
|
||||||
|
)
|
||||||
|
|
||||||
// Update terminal dimensions if needed
|
// Update terminal dimensions if needed
|
||||||
let currentCols = terminal.getTerminal().cols
|
let currentCols = terminal.getTerminal().cols
|
||||||
|
|
@ -207,10 +211,14 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
ansiData = convertBufferToOptimizedANSI(snapshot, clearScreen: isFirstUpdate)
|
ansiData = convertBufferToOptimizedANSI(snapshot, clearScreen: isFirstUpdate)
|
||||||
isFirstUpdate = false
|
isFirstUpdate = false
|
||||||
logger.verbose("Full redraw performed")
|
logger.verbose("Full redraw performed")
|
||||||
} else {
|
} else if let previous = previousSnapshot {
|
||||||
// Incremental update
|
// Incremental update
|
||||||
ansiData = generateIncrementalUpdate(from: previousSnapshot!, to: snapshot)
|
ansiData = generateIncrementalUpdate(from: previous, to: snapshot)
|
||||||
logger.verbose("Incremental update performed")
|
logger.verbose("Incremental update performed")
|
||||||
|
} else {
|
||||||
|
// Fallback to full redraw if somehow previousSnapshot is nil
|
||||||
|
ansiData = convertBufferToOptimizedANSI(snapshot, clearScreen: false)
|
||||||
|
logger.verbose("Fallback full redraw performed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store current snapshot for next update
|
// Store current snapshot for next update
|
||||||
|
|
@ -322,10 +330,10 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
if let fg = cell.fg {
|
if let fg = cell.fg {
|
||||||
if fg & 0xFF00_0000 != 0 {
|
if fg & 0xFF00_0000 != 0 {
|
||||||
// RGB color
|
// RGB color
|
||||||
let r = (fg >> 16) & 0xFF
|
let red = (fg >> 16) & 0xFF
|
||||||
let g = (fg >> 8) & 0xFF
|
let green = (fg >> 8) & 0xFF
|
||||||
let b = fg & 0xFF
|
let blue = fg & 0xFF
|
||||||
output += "\u{001B}[38;2;\(r);\(g);\(b)m"
|
output += "\u{001B}[38;2;\(red);\(green);\(blue)m"
|
||||||
} else if fg <= 255 {
|
} else if fg <= 255 {
|
||||||
// Palette color
|
// Palette color
|
||||||
output += "\u{001B}[38;5;\(fg)m"
|
output += "\u{001B}[38;5;\(fg)m"
|
||||||
|
|
@ -341,10 +349,10 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
if let bg = cell.bg {
|
if let bg = cell.bg {
|
||||||
if bg & 0xFF00_0000 != 0 {
|
if bg & 0xFF00_0000 != 0 {
|
||||||
// RGB color
|
// RGB color
|
||||||
let r = (bg >> 16) & 0xFF
|
let red = (bg >> 16) & 0xFF
|
||||||
let g = (bg >> 8) & 0xFF
|
let green = (bg >> 8) & 0xFF
|
||||||
let b = bg & 0xFF
|
let blue = bg & 0xFF
|
||||||
output += "\u{001B}[48;2;\(r);\(g);\(b)m"
|
output += "\u{001B}[48;2;\(red);\(green);\(blue)m"
|
||||||
} else if bg <= 255 {
|
} else if bg <= 255 {
|
||||||
// Palette color
|
// Palette color
|
||||||
output += "\u{001B}[48;5;\(bg)m"
|
output += "\u{001B}[48;5;\(bg)m"
|
||||||
|
|
@ -552,10 +560,10 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
if let color = new {
|
if let color = new {
|
||||||
if color & 0xFF00_0000 != 0 {
|
if color & 0xFF00_0000 != 0 {
|
||||||
// RGB color
|
// RGB color
|
||||||
let r = (color >> 16) & 0xFF
|
let red = (color >> 16) & 0xFF
|
||||||
let g = (color >> 8) & 0xFF
|
let green = (color >> 8) & 0xFF
|
||||||
let b = color & 0xFF
|
let blue = color & 0xFF
|
||||||
output += "\u{001B}[\(isBackground ? 48 : 38);2;\(r);\(g);\(b)m"
|
output += "\u{001B}[\(isBackground ? 48 : 38);2;\(red);\(green);\(blue)m"
|
||||||
} else if color <= 255 {
|
} else if color <= 255 {
|
||||||
// Palette color
|
// Palette color
|
||||||
output += "\u{001B}[\(isBackground ? 48 : 38);5;\(color)m"
|
output += "\u{001B}[\(isBackground ? 48 : 38);5;\(color)m"
|
||||||
|
|
@ -591,14 +599,14 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBufferContent() -> String? {
|
func getBufferContent() -> String? {
|
||||||
guard let terminal else { return nil }
|
guard let terminal else { return nil }
|
||||||
|
|
||||||
// Get the terminal buffer content
|
// Get the terminal buffer content
|
||||||
let terminalInstance = terminal.getTerminal()
|
let terminalInstance = terminal.getTerminal()
|
||||||
var content = ""
|
var content = ""
|
||||||
|
|
||||||
// Read all lines from the terminal buffer
|
// Read all lines from the terminal buffer
|
||||||
for row in 0..<terminalInstance.rows {
|
for row in 0..<terminalInstance.rows {
|
||||||
if let line = terminalInstance.getLine(row: row) {
|
if let line = terminalInstance.getLine(row: row) {
|
||||||
|
|
@ -613,7 +621,7 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
content += lineText.trimmingCharacters(in: .whitespaces) + "\n"
|
content += lineText.trimmingCharacters(in: .whitespaces) + "\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return content.trimmingCharacters(in: .whitespacesAndNewlines)
|
return content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -646,13 +654,13 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
terminal.feed(text: "\u{001b}[B")
|
terminal.feed(text: "\u{001b}[B")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setMaxWidth(_ maxWidth: Int) {
|
func setMaxWidth(_ maxWidth: Int) {
|
||||||
// Store the max width preference for terminal rendering
|
// Store the max width preference for terminal rendering
|
||||||
// When maxWidth is 0, it means unlimited
|
// When maxWidth is 0, it means unlimited
|
||||||
// This could be used to constrain terminal rendering in the future
|
// This could be used to constrain terminal rendering in the future
|
||||||
// For now, just log the preference
|
// For now, just log the preference
|
||||||
print("[Terminal] Max width set to: \(maxWidth == 0 ? "unlimited" : "\(maxWidth) columns")")
|
logger.info("Max width set to: \(maxWidth == 0 ? "unlimited" : "\(maxWidth) columns")")
|
||||||
}
|
}
|
||||||
|
|
||||||
func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) {
|
func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) {
|
||||||
|
|
@ -684,7 +692,7 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
// If we have buffer data, we can provide additional context
|
// If we have buffer data, we can provide additional context
|
||||||
if previousSnapshot != nil {
|
if previousSnapshot != nil {
|
||||||
// Log selection range for debugging
|
// Log selection range for debugging
|
||||||
print("[Terminal] Copied \(string.count) characters")
|
logger.debug("Copied \(string.count) characters")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import SwiftUI
|
||||||
/// Sheet for selecting terminal color themes.
|
/// Sheet for selecting terminal color themes.
|
||||||
struct TerminalThemeSheet: View {
|
struct TerminalThemeSheet: View {
|
||||||
@Binding var selectedTheme: TerminalTheme
|
@Binding var selectedTheme: TerminalTheme
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss)
|
||||||
|
var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
|
@ -29,7 +30,7 @@ struct TerminalThemeSheet: View {
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
// Save to UserDefaults
|
// Save to UserDefaults
|
||||||
TerminalTheme.selected = theme
|
TerminalTheme.selected = theme
|
||||||
}) {
|
}, label: {
|
||||||
HStack(spacing: Theme.Spacing.medium) {
|
HStack(spacing: Theme.Spacing.medium) {
|
||||||
// Color preview
|
// Color preview
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
|
|
@ -86,7 +87,7 @@ struct TerminalThemeSheet: View {
|
||||||
lineWidth: 1
|
lineWidth: 1
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -235,8 +235,8 @@ struct ToolbarButton: View {
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||||
.fill(
|
.fill(
|
||||||
isActive ? Theme.Colors.primaryAccent.opacity(0.2) :
|
isActive ? Theme.Colors.primaryAccent.opacity(0.2) :
|
||||||
isPressed ? Theme.Colors.primaryAccent.opacity(0.1) :
|
isPressed ? Theme.Colors.primaryAccent.opacity(0.1) :
|
||||||
Theme.Colors.cardBorder.opacity(0.3)
|
Theme.Colors.cardBorder.opacity(0.3)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.overlay(
|
.overlay(
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@ import Observation
|
||||||
import SwiftTerm
|
import SwiftTerm
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private let logger = Logger(category: "TerminalView")
|
||||||
|
|
||||||
/// Interactive terminal view for a session.
|
/// Interactive terminal view for a session.
|
||||||
///
|
///
|
||||||
/// Displays a full terminal emulator using SwiftTerm with support for
|
/// Displays a full terminal emulator using SwiftTerm with support for
|
||||||
/// input, output, recording, and font size adjustment.
|
/// input, output, recording, and font size adjustment.
|
||||||
struct TerminalView: View {
|
struct TerminalView: View {
|
||||||
let session: Session
|
let session: Session
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss)
|
||||||
|
var dismiss
|
||||||
@State private var viewModel: TerminalViewModel
|
@State private var viewModel: TerminalViewModel
|
||||||
@State private var fontSize: CGFloat = 14
|
@State private var fontSize: CGFloat = 14
|
||||||
@State private var showingFontSizeSheet = false
|
@State private var showingFontSizeSheet = false
|
||||||
|
|
@ -26,6 +29,8 @@ struct TerminalView: View {
|
||||||
@State private var exportedFileURL: URL?
|
@State private var exportedFileURL: URL?
|
||||||
@State private var showingWidthSelector = false
|
@State private var showingWidthSelector = false
|
||||||
@State private var currentTerminalWidth: TerminalWidth = .unlimited
|
@State private var currentTerminalWidth: TerminalWidth = .unlimited
|
||||||
|
@State private var showingFullscreenInput = false
|
||||||
|
@State private var showingCtrlKeyGrid = false
|
||||||
@FocusState private var isInputFocused: Bool
|
@FocusState private var isInputFocused: Bool
|
||||||
|
|
||||||
init(session: Session) {
|
init(session: Session) {
|
||||||
|
|
@ -80,13 +85,23 @@ struct TerminalView: View {
|
||||||
onSelect: { _ in
|
onSelect: { _ in
|
||||||
showingFileBrowser = false
|
showingFileBrowser = false
|
||||||
},
|
},
|
||||||
onInsertPath: { [weak viewModel] path, isDirectory in
|
onInsertPath: { [weak viewModel] path, _ in
|
||||||
// Insert the path into the terminal
|
// Insert the path into the terminal
|
||||||
viewModel?.sendInput(path)
|
viewModel?.sendInput(path)
|
||||||
showingFileBrowser = false
|
showingFileBrowser = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingFullscreenInput) {
|
||||||
|
FullscreenTextInput(isPresented: $showingFullscreenInput) { [weak viewModel] text in
|
||||||
|
viewModel?.sendInput(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingCtrlKeyGrid) {
|
||||||
|
CtrlKeyGrid(isPresented: $showingCtrlKeyGrid) { [weak viewModel] controlChar in
|
||||||
|
viewModel?.sendInput(controlChar)
|
||||||
|
}
|
||||||
|
}
|
||||||
.gesture(
|
.gesture(
|
||||||
DragGesture()
|
DragGesture()
|
||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
|
|
@ -96,16 +111,20 @@ struct TerminalView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in
|
.task {
|
||||||
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
|
for await notification in NotificationCenter.default.notifications(named: UIResponder.keyboardWillShowNotification) {
|
||||||
withAnimation(Theme.Animation.standard) {
|
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
|
||||||
keyboardHeight = keyboardFrame.height
|
withAnimation(Theme.Animation.standard) {
|
||||||
|
keyboardHeight = keyboardFrame.height
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
|
.task {
|
||||||
withAnimation(Theme.Animation.standard) {
|
for await _ in NotificationCenter.default.notifications(named: UIResponder.keyboardWillHideNotification) {
|
||||||
keyboardHeight = 0
|
withAnimation(Theme.Animation.standard) {
|
||||||
|
keyboardHeight = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedTerminalWidth) { _, newValue in
|
.onChange(of: selectedTerminalWidth) { _, newValue in
|
||||||
|
|
@ -146,31 +165,31 @@ struct TerminalView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Export Functions
|
// MARK: - Export Functions
|
||||||
|
|
||||||
private func exportTerminalBuffer() {
|
private func exportTerminalBuffer() {
|
||||||
guard let bufferContent = viewModel.getBufferContent() else { return }
|
guard let bufferContent = viewModel.getBufferContent() else { return }
|
||||||
|
|
||||||
let fileName = "\(session.displayName)_\(Date().timeIntervalSince1970).txt"
|
let fileName = "\(session.displayName)_\(Date().timeIntervalSince1970).txt"
|
||||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
|
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try bufferContent.write(to: tempURL, atomically: true, encoding: .utf8)
|
try bufferContent.write(to: tempURL, atomically: true, encoding: .utf8)
|
||||||
exportedFileURL = tempURL
|
exportedFileURL = tempURL
|
||||||
showingExportSheet = true
|
showingExportSheet = true
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to export terminal buffer: \(error)")
|
logger.error("Failed to export terminal buffer: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - View Components
|
// MARK: - View Components
|
||||||
|
|
||||||
private var mainContent: some View {
|
private var mainContent: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
selectedTheme.background
|
selectedTheme.background
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if viewModel.isConnecting {
|
if viewModel.isConnecting {
|
||||||
loadingView
|
loadingView
|
||||||
|
|
@ -182,7 +201,7 @@ struct TerminalView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var navigationToolbarItems: some ToolbarContent {
|
private var navigationToolbarItems: some ToolbarContent {
|
||||||
Group {
|
Group {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
|
@ -191,22 +210,24 @@ struct TerminalView: View {
|
||||||
}
|
}
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||||
|
QuickFontSizeButtons(fontSize: $fontSize)
|
||||||
|
.fixedSize()
|
||||||
fileBrowserButton
|
fileBrowserButton
|
||||||
widthSelectorButton
|
widthSelectorButton
|
||||||
menuButton
|
menuButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var bottomToolbarItems: some ToolbarContent {
|
private var bottomToolbarItems: some ToolbarContent {
|
||||||
ToolbarItemGroup(placement: .bottomBar) {
|
ToolbarItemGroup(placement: .bottomBar) {
|
||||||
terminalSizeIndicator
|
terminalSizeIndicator
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var recordingIndicator: some ToolbarContent {
|
private var recordingIndicator: some ToolbarContent {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
if viewModel.castRecorder.isRecording {
|
if viewModel.castRecorder.isRecording {
|
||||||
|
|
@ -214,22 +235,22 @@ struct TerminalView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Toolbar Components
|
// MARK: - Toolbar Components
|
||||||
|
|
||||||
private var fileBrowserButton: some View {
|
private var fileBrowserButton: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
showingFileBrowser = true
|
showingFileBrowser = true
|
||||||
}) {
|
}, label: {
|
||||||
Image(systemName: "folder")
|
Image(systemName: "folder")
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 16))
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private var widthSelectorButton: some View {
|
private var widthSelectorButton: some View {
|
||||||
Button(action: { showingWidthSelector = true }) {
|
Button(action: { showingWidthSelector = true }, label: {
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
Image(systemName: "arrow.left.and.right")
|
Image(systemName: "arrow.left.and.right")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
|
|
@ -245,7 +266,7 @@ struct TerminalView: View {
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
.stroke(Theme.Colors.primaryAccent.opacity(0.3), lineWidth: 1)
|
.stroke(Theme.Colors.primaryAccent.opacity(0.3), lineWidth: 1)
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
.popover(isPresented: $showingWidthSelector, arrowEdge: .top) {
|
.popover(isPresented: $showingWidthSelector, arrowEdge: .top) {
|
||||||
WidthSelectorPopover(
|
WidthSelectorPopover(
|
||||||
|
|
@ -254,7 +275,7 @@ struct TerminalView: View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var menuButton: some View {
|
private var menuButton: some View {
|
||||||
Menu {
|
Menu {
|
||||||
terminalMenuItems
|
terminalMenuItems
|
||||||
|
|
@ -263,79 +284,89 @@ struct TerminalView: View {
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var terminalMenuItems: some View {
|
private var terminalMenuItems: some View {
|
||||||
Button(action: { viewModel.clearTerminal() }, label: {
|
Button(action: { viewModel.clearTerminal() }, label: {
|
||||||
Label("Clear", systemImage: "clear")
|
Label("Clear", systemImage: "clear")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Button(action: { showingFullscreenInput = true }, label: {
|
||||||
|
Label("Compose Command", systemImage: "text.viewfinder")
|
||||||
|
})
|
||||||
|
|
||||||
|
Button(action: { showingCtrlKeyGrid = true }, label: {
|
||||||
|
Label("Ctrl Shortcuts", systemImage: "command.square")
|
||||||
|
})
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
fontSize = max(8, fontSize - 1)
|
fontSize = max(8, fontSize - 1)
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
}, label: {
|
}, label: {
|
||||||
Label("Decrease", systemImage: "minus")
|
Label("Decrease", systemImage: "minus")
|
||||||
})
|
})
|
||||||
.disabled(fontSize <= 8)
|
.disabled(fontSize <= 8)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
fontSize = min(32, fontSize + 1)
|
fontSize = min(32, fontSize + 1)
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
}, label: {
|
}, label: {
|
||||||
Label("Increase", systemImage: "plus")
|
Label("Increase", systemImage: "plus")
|
||||||
})
|
})
|
||||||
.disabled(fontSize >= 32)
|
.disabled(fontSize >= 32)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
fontSize = 14
|
fontSize = 14
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
}, label: {
|
}, label: {
|
||||||
Label("Reset to Default", systemImage: "arrow.counterclockwise")
|
Label("Reset to Default", systemImage: "arrow.counterclockwise")
|
||||||
})
|
})
|
||||||
.disabled(fontSize == 14)
|
.disabled(fontSize == 14)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button(action: { showingFontSizeSheet = true }, label: {
|
Button(action: { showingFontSizeSheet = true }, label: {
|
||||||
Label("More Options...", systemImage: "slider.horizontal.3")
|
Label("More Options...", systemImage: "slider.horizontal.3")
|
||||||
})
|
})
|
||||||
} label: {
|
} label: {
|
||||||
Label("Font Size (\(Int(fontSize))pt)", systemImage: "textformat.size")
|
Label("Font Size (\(Int(fontSize))pt)", systemImage: "textformat.size")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: { showingTerminalWidthSheet = true }, label: {
|
Button(action: { showingTerminalWidthSheet = true }, label: {
|
||||||
Label("Terminal Width", systemImage: "arrow.left.and.right")
|
Label("Terminal Width", systemImage: "arrow.left.and.right")
|
||||||
})
|
})
|
||||||
|
|
||||||
Button(action: { viewModel.toggleFitToWidth() }, label: {
|
Button(action: { viewModel.toggleFitToWidth() }, label: {
|
||||||
Label(
|
Label(
|
||||||
viewModel.fitToWidth ? "Fixed Width" : "Fit to Width",
|
viewModel.fitToWidth ? "Fixed Width" : "Fit to Width",
|
||||||
systemImage: viewModel.fitToWidth ? "arrow.left.and.right.square" : "arrow.left.and.right.square.fill"
|
systemImage: viewModel.fitToWidth ? "arrow.left.and.right.square" : "arrow.left.and.right.square.fill"
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
Button(action: { showingTerminalThemeSheet = true }, label: {
|
Button(action: { showingTerminalThemeSheet = true }, label: {
|
||||||
Label("Theme", systemImage: "paintbrush")
|
Label("Theme", systemImage: "paintbrush")
|
||||||
})
|
})
|
||||||
|
|
||||||
Button(action: { viewModel.copyBuffer() }, label: {
|
Button(action: { viewModel.copyBuffer() }, label: {
|
||||||
Label("Copy All", systemImage: "square.on.square")
|
Label("Copy All", systemImage: "square.on.square")
|
||||||
})
|
})
|
||||||
|
|
||||||
Button(action: { exportTerminalBuffer() }, label: {
|
Button(action: { exportTerminalBuffer() }, label: {
|
||||||
Label("Export as Text", systemImage: "square.and.arrow.up")
|
Label("Export as Text", systemImage: "square.and.arrow.up")
|
||||||
})
|
})
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
recordingMenuItems
|
recordingMenuItems
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
debugMenuItems
|
debugMenuItems
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var recordingMenuItems: some View {
|
private var recordingMenuItems: some View {
|
||||||
if viewModel.castRecorder.isRecording {
|
if viewModel.castRecorder.isRecording {
|
||||||
|
|
@ -351,13 +382,13 @@ struct TerminalView: View {
|
||||||
Label("Start Recording", systemImage: "record.circle")
|
Label("Start Recording", systemImage: "record.circle")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: { showingRecordingSheet = true }, label: {
|
Button(action: { showingRecordingSheet = true }, label: {
|
||||||
Label("Export Recording", systemImage: "square.and.arrow.up")
|
Label("Export Recording", systemImage: "square.and.arrow.up")
|
||||||
})
|
})
|
||||||
.disabled(viewModel.castRecorder.events.isEmpty)
|
.disabled(viewModel.castRecorder.events.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var debugMenuItems: some View {
|
private var debugMenuItems: some View {
|
||||||
Menu {
|
Menu {
|
||||||
|
|
@ -366,20 +397,20 @@ struct TerminalView: View {
|
||||||
selectedRenderer = renderer
|
selectedRenderer = renderer
|
||||||
TerminalRenderer.selected = renderer
|
TerminalRenderer.selected = renderer
|
||||||
viewModel.terminalViewId = UUID() // Force recreate terminal view
|
viewModel.terminalViewId = UUID() // Force recreate terminal view
|
||||||
}) {
|
}, label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(renderer.displayName)
|
Text(renderer.displayName)
|
||||||
if renderer == selectedRenderer {
|
if renderer == selectedRenderer {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Terminal Renderer", systemImage: "gearshape.2")
|
Label("Terminal Renderer", systemImage: "gearshape.2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var terminalSizeIndicator: some View {
|
private var terminalSizeIndicator: some View {
|
||||||
if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 {
|
if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 {
|
||||||
|
|
@ -388,16 +419,15 @@ struct TerminalView: View {
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private var recordingView: some View {
|
private var recordingView: some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color.red)
|
.fill(Theme.Colors.errorAccent)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
.overlay(
|
.overlay(
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color.red.opacity(0.3))
|
.fill(Theme.Colors.errorAccent.opacity(0.3))
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
.scaleEffect(viewModel.recordingPulse ? 1.5 : 1.0)
|
.scaleEffect(viewModel.recordingPulse ? 1.5 : 1.0)
|
||||||
.animation(
|
.animation(
|
||||||
|
|
@ -407,7 +437,7 @@ struct TerminalView: View {
|
||||||
)
|
)
|
||||||
Text("REC")
|
Text("REC")
|
||||||
.font(.system(size: 12, weight: .bold))
|
.font(.system(size: 12, weight: .bold))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(Theme.Colors.errorAccent)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.recordingPulse = true
|
viewModel.recordingPulse = true
|
||||||
|
|
@ -491,12 +521,17 @@ struct TerminalView: View {
|
||||||
.id(viewModel.terminalViewId)
|
.id(viewModel.terminalViewId)
|
||||||
.background(selectedTheme.background)
|
.background(selectedTheme.background)
|
||||||
.focused($isInputFocused)
|
.focused($isInputFocused)
|
||||||
.scrollToBottomOverlay(
|
.overlay(
|
||||||
isVisible: showScrollToBottom,
|
ScrollToBottomButton(
|
||||||
action: {
|
isVisible: showScrollToBottom,
|
||||||
viewModel.scrollToBottom()
|
action: {
|
||||||
showScrollToBottom = false
|
viewModel.scrollToBottom()
|
||||||
}
|
showScrollToBottom = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.bottom, Theme.Spacing.large)
|
||||||
|
.padding(.leading, Theme.Spacing.large),
|
||||||
|
alignment: .bottomLeading
|
||||||
)
|
)
|
||||||
|
|
||||||
// Keyboard toolbar
|
// Keyboard toolbar
|
||||||
|
|
@ -539,7 +574,7 @@ class TerminalViewModel {
|
||||||
var bufferWebSocketClient: BufferWebSocketClient?
|
var bufferWebSocketClient: BufferWebSocketClient?
|
||||||
private var connectionStatusTask: Task<Void, Never>?
|
private var connectionStatusTask: Task<Void, Never>?
|
||||||
private var connectionErrorTask: Task<Void, Never>?
|
private var connectionErrorTask: Task<Void, Never>?
|
||||||
weak var terminalCoordinator: TerminalHostingView.Coordinator?
|
weak var terminalCoordinator: AnyObject? // Can be TerminalHostingView.Coordinator
|
||||||
|
|
||||||
init(session: Session) {
|
init(session: Session) {
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
@ -630,18 +665,20 @@ class TerminalViewModel {
|
||||||
// Initialize terminal with dimensions from header
|
// Initialize terminal with dimensions from header
|
||||||
terminalCols = header.width
|
terminalCols = header.width
|
||||||
terminalRows = header.height
|
terminalRows = header.height
|
||||||
print("Snapshot header: \(header.width)x\(header.height)")
|
logger.debug("Snapshot header: \(header.width)x\(header.height)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feed all output events to the terminal
|
// Feed all output events to the terminal
|
||||||
for event in snapshot.events {
|
for event in snapshot.events {
|
||||||
if event.type == .output {
|
if event.type == .output {
|
||||||
// Feed the actual terminal output data
|
// Feed the actual terminal output data
|
||||||
terminalCoordinator?.feedData(event.data)
|
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
|
||||||
|
coordinator.feedData(event.data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to load terminal snapshot: \(error)")
|
logger.error("Failed to load terminal snapshot: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -659,22 +696,22 @@ class TerminalViewModel {
|
||||||
switch event {
|
switch event {
|
||||||
case .header(let width, let height):
|
case .header(let width, let height):
|
||||||
// Initial terminal setup
|
// Initial terminal setup
|
||||||
print("Terminal initialized: \(width)x\(height)")
|
logger.info("Terminal initialized: \(width)x\(height)")
|
||||||
terminalCols = width
|
terminalCols = width
|
||||||
terminalRows = height
|
terminalRows = height
|
||||||
// The terminal will be resized when created
|
// The terminal will be resized when created
|
||||||
|
|
||||||
case .output(_, let data):
|
case .output(_, let data):
|
||||||
// Feed output data directly to the terminal
|
// Feed output data directly to the terminal
|
||||||
if let coordinator = terminalCoordinator {
|
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
|
||||||
coordinator.feedData(data)
|
coordinator.feedData(data)
|
||||||
} else {
|
} else {
|
||||||
// Queue the data to be fed once coordinator is ready
|
// Queue the data to be fed once coordinator is ready
|
||||||
print("Warning: Terminal coordinator not ready, queueing data")
|
logger.warning("Terminal coordinator not ready, queueing data")
|
||||||
Task {
|
Task {
|
||||||
// Wait a bit for coordinator to be initialized
|
// Wait a bit for coordinator to be initialized
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
|
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
|
||||||
if let coordinator = self.terminalCoordinator {
|
if let coordinator = self.terminalCoordinator as? TerminalHostingView.Coordinator {
|
||||||
coordinator.feedData(data)
|
coordinator.feedData(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -691,7 +728,7 @@ class TerminalViewModel {
|
||||||
// Update terminal dimensions
|
// Update terminal dimensions
|
||||||
terminalCols = cols
|
terminalCols = cols
|
||||||
terminalRows = rows
|
terminalRows = rows
|
||||||
print("Terminal resize: \(cols)x\(rows)")
|
logger.info("Terminal resize: \(cols)x\(rows)")
|
||||||
// Record resize event
|
// Record resize event
|
||||||
castRecorder.recordResize(cols: cols, rows: rows)
|
castRecorder.recordResize(cols: cols, rows: rows)
|
||||||
}
|
}
|
||||||
|
|
@ -706,7 +743,7 @@ class TerminalViewModel {
|
||||||
if castRecorder.isRecording {
|
if castRecorder.isRecording {
|
||||||
stopRecording()
|
stopRecording()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load final snapshot for exited session
|
// Load final snapshot for exited session
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// Give the server a moment to finalize the snapshot
|
// Give the server a moment to finalize the snapshot
|
||||||
|
|
@ -716,7 +753,7 @@ class TerminalViewModel {
|
||||||
|
|
||||||
case .bufferUpdate(let snapshot):
|
case .bufferUpdate(let snapshot):
|
||||||
// Update terminal buffer directly
|
// Update terminal buffer directly
|
||||||
if let coordinator = terminalCoordinator {
|
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
|
||||||
coordinator.updateBuffer(from: TerminalHostingView.BufferSnapshot(
|
coordinator.updateBuffer(from: TerminalHostingView.BufferSnapshot(
|
||||||
cols: snapshot.cols,
|
cols: snapshot.cols,
|
||||||
rows: snapshot.rows,
|
rows: snapshot.rows,
|
||||||
|
|
@ -737,7 +774,7 @@ class TerminalViewModel {
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
// Fallback: buffer updates not available yet
|
// Fallback: buffer updates not available yet
|
||||||
print("Warning: Direct buffer update not available")
|
logger.warning("Direct buffer update not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
case .bell:
|
case .bell:
|
||||||
|
|
@ -755,7 +792,7 @@ class TerminalViewModel {
|
||||||
do {
|
do {
|
||||||
try await SessionService.shared.sendInput(to: session.id, text: text)
|
try await SessionService.shared.sendInput(to: session.id, text: text)
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to send input: \(error)")
|
logger.error("Failed to send input: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -767,7 +804,7 @@ class TerminalViewModel {
|
||||||
// If resize succeeded, ensure the flag is cleared
|
// If resize succeeded, ensure the flag is cleared
|
||||||
isResizeBlockedByServer = false
|
isResizeBlockedByServer = false
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to resize terminal: \(error)")
|
logger.error("Failed to resize terminal: \(error)")
|
||||||
// Check if the error is specifically about resize being disabled
|
// Check if the error is specifically about resize being disabled
|
||||||
if case APIError.resizeDisabledByServer = error {
|
if case APIError.resizeDisabledByServer = error {
|
||||||
isResizeBlockedByServer = true
|
isResizeBlockedByServer = true
|
||||||
|
|
@ -786,10 +823,10 @@ class TerminalViewModel {
|
||||||
// Terminal copy is handled by SwiftTerm's built-in functionality
|
// Terminal copy is handled by SwiftTerm's built-in functionality
|
||||||
HapticFeedback.notification(.success)
|
HapticFeedback.notification(.success)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBufferContent() -> String? {
|
func getBufferContent() -> String? {
|
||||||
// Get the current terminal buffer content
|
// Get the current terminal buffer content
|
||||||
if let coordinator = terminalCoordinator {
|
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
|
||||||
return coordinator.getBufferContent()
|
return coordinator.getBufferContent()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -810,7 +847,7 @@ class TerminalViewModel {
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handleTerminalAlert(title: String?, message: String) {
|
private func handleTerminalAlert(title: String?, message: String) {
|
||||||
// Log the alert
|
// Log the alert
|
||||||
print("[Terminal Alert] \(title ?? "Alert"): \(message)")
|
logger.info("Terminal Alert - \(title ?? "Alert"): \(message)")
|
||||||
|
|
||||||
// Show as a system notification if app is in background
|
// Show as a system notification if app is in background
|
||||||
// For now, just provide haptic feedback
|
// For now, just provide haptic feedback
|
||||||
|
|
@ -822,7 +859,9 @@ class TerminalViewModel {
|
||||||
isAutoScrollEnabled = true
|
isAutoScrollEnabled = true
|
||||||
isAtBottom = true
|
isAtBottom = true
|
||||||
// The actual scrolling is handled by the terminal coordinator
|
// The actual scrolling is handled by the terminal coordinator
|
||||||
terminalCoordinator?.scrollToBottom()
|
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
|
||||||
|
coordinator.scrollToBottom()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateScrollState(isAtBottom: Bool) {
|
func updateScrollState(isAtBottom: Bool) {
|
||||||
|
|
@ -845,20 +884,22 @@ class TerminalViewModel {
|
||||||
resize(cols: optimalCols, rows: terminalRows)
|
resize(cols: optimalCols, rows: terminalRows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setMaxWidth(_ maxWidth: Int) {
|
func setMaxWidth(_ maxWidth: Int) {
|
||||||
// Store the max width preference
|
// Store the max width preference
|
||||||
// When maxWidth is 0, it means unlimited
|
// When maxWidth is 0, it means unlimited
|
||||||
let targetWidth = maxWidth == 0 ? nil : maxWidth
|
let targetWidth = maxWidth == 0 ? nil : maxWidth
|
||||||
|
|
||||||
if let width = targetWidth, width != terminalCols {
|
if let width = targetWidth, width != terminalCols {
|
||||||
// Maintain aspect ratio when changing width
|
// Maintain aspect ratio when changing width
|
||||||
let aspectRatio = Double(terminalRows) / Double(terminalCols)
|
let aspectRatio = Double(terminalRows) / Double(terminalCols)
|
||||||
let newHeight = Int(Double(width) * aspectRatio)
|
let newHeight = Int(Double(width) * aspectRatio)
|
||||||
resize(cols: width, rows: newHeight)
|
resize(cols: width, rows: newHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the terminal coordinator if using constrained width
|
// Update the terminal coordinator if using constrained width
|
||||||
terminalCoordinator?.setMaxWidth(maxWidth)
|
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
|
||||||
|
coordinator.setMaxWidth(maxWidth)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import SwiftUI
|
||||||
struct TerminalWidthSheet: View {
|
struct TerminalWidthSheet: View {
|
||||||
@Binding var selectedWidth: Int?
|
@Binding var selectedWidth: Int?
|
||||||
let isResizeBlockedByServer: Bool
|
let isResizeBlockedByServer: Bool
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss)
|
||||||
|
var dismiss
|
||||||
@State private var showCustomInput = false
|
@State private var showCustomInput = false
|
||||||
@State private var customWidthText = ""
|
@State private var customWidthText = ""
|
||||||
@FocusState private var isCustomInputFocused: Bool
|
@FocusState private var isCustomInputFocused: Bool
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ struct WidthSelectorPopover: View {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@State private var customWidth: String = ""
|
@State private var customWidth: String = ""
|
||||||
@State private var showCustomInput = false
|
@State private var showCustomInput = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
|
|
@ -14,20 +14,19 @@ struct WidthSelectorPopover: View {
|
||||||
ForEach(TerminalWidth.allCases, id: \.value) { width in
|
ForEach(TerminalWidth.allCases, id: \.value) { width in
|
||||||
WidthPresetRow(
|
WidthPresetRow(
|
||||||
width: width,
|
width: width,
|
||||||
isSelected: currentWidth.value == width.value,
|
isSelected: currentWidth.value == width.value
|
||||||
onSelect: {
|
) {
|
||||||
currentWidth = width
|
currentWidth = width
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showCustomInput = true
|
showCustomInput = true
|
||||||
}) {
|
}, label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "square.and.pencil")
|
Image(systemName: "square.and.pencil")
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 16))
|
||||||
|
|
@ -38,9 +37,9 @@ struct WidthSelectorPopover: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show recent custom widths if any
|
// Show recent custom widths if any
|
||||||
let customWidths = TerminalWidthManager.shared.customWidths
|
let customWidths = TerminalWidthManager.shared.customWidths
|
||||||
if !customWidths.isEmpty {
|
if !customWidths.isEmpty {
|
||||||
|
|
@ -51,13 +50,12 @@ struct WidthSelectorPopover: View {
|
||||||
ForEach(customWidths, id: \.self) { width in
|
ForEach(customWidths, id: \.self) { width in
|
||||||
WidthPresetRow(
|
WidthPresetRow(
|
||||||
width: .custom(width),
|
width: .custom(width),
|
||||||
isSelected: currentWidth.value == width && !currentWidth.isPreset,
|
isSelected: currentWidth.value == width && !currentWidth.isPreset
|
||||||
onSelect: {
|
) {
|
||||||
currentWidth = .custom(width)
|
currentWidth = .custom(width)
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -78,17 +76,16 @@ struct WidthSelectorPopover: View {
|
||||||
.frame(width: 320, height: 400)
|
.frame(width: 320, height: 400)
|
||||||
.sheet(isPresented: $showCustomInput) {
|
.sheet(isPresented: $showCustomInput) {
|
||||||
CustomWidthSheet(
|
CustomWidthSheet(
|
||||||
customWidth: $customWidth,
|
customWidth: $customWidth
|
||||||
onSave: { width in
|
) { width in
|
||||||
if let intWidth = Int(width), intWidth >= 20 && intWidth <= 500 {
|
if let intWidth = Int(width), intWidth >= 20 && intWidth <= 500 {
|
||||||
currentWidth = .custom(intWidth)
|
currentWidth = .custom(intWidth)
|
||||||
TerminalWidthManager.shared.addCustomWidth(intWidth)
|
TerminalWidthManager.shared.addCustomWidth(intWidth)
|
||||||
HapticFeedback.notification(.success)
|
HapticFeedback.notification(.success)
|
||||||
showCustomInput = false
|
showCustomInput = false
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +95,7 @@ private struct WidthPresetRow: View {
|
||||||
let width: TerminalWidth
|
let width: TerminalWidth
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
let onSelect: () -> Void
|
let onSelect: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onSelect) {
|
Button(action: onSelect) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -108,21 +105,21 @@ private struct WidthPresetRow: View {
|
||||||
.font(Theme.Typography.terminalSystem(size: 16))
|
.font(Theme.Typography.terminalSystem(size: 16))
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(Theme.Colors.terminalForeground)
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
|
||||||
if width.value > 0 {
|
if width.value > 0 {
|
||||||
Text("columns")
|
Text("columns")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(width.description)
|
Text(width.description)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if isSelected {
|
if isSelected {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.system(size: 20))
|
.font(.system(size: 20))
|
||||||
|
|
@ -139,9 +136,10 @@ private struct WidthPresetRow: View {
|
||||||
private struct CustomWidthSheet: View {
|
private struct CustomWidthSheet: View {
|
||||||
@Binding var customWidth: String
|
@Binding var customWidth: String
|
||||||
let onSave: (String) -> Void
|
let onSave: (String) -> Void
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss)
|
||||||
|
var dismiss
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: Theme.Spacing.large) {
|
VStack(spacing: Theme.Spacing.large) {
|
||||||
|
|
@ -150,7 +148,7 @@ private struct CustomWidthSheet: View {
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
TextField("Width", text: $customWidth)
|
TextField("Width", text: $customWidth)
|
||||||
.font(Theme.Typography.terminalSystem(size: 24))
|
.font(Theme.Typography.terminalSystem(size: 24))
|
||||||
|
|
@ -162,12 +160,12 @@ private struct CustomWidthSheet: View {
|
||||||
.padding()
|
.padding()
|
||||||
.background(Theme.Colors.cardBackground)
|
.background(Theme.Colors.cardBackground)
|
||||||
.cornerRadius(Theme.CornerRadius.medium)
|
.cornerRadius(Theme.CornerRadius.medium)
|
||||||
|
|
||||||
Text("columns")
|
Text("columns")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.top, Theme.Spacing.extraLarge)
|
.padding(.top, Theme.Spacing.extraLarge)
|
||||||
|
|
@ -180,7 +178,7 @@ private struct CustomWidthSheet: View {
|
||||||
}
|
}
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
onSave(customWidth)
|
onSave(customWidth)
|
||||||
|
|
@ -195,4 +193,4 @@ private struct CustomWidthSheet: View {
|
||||||
isFocused = true
|
isFocused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,56 +9,56 @@ struct XtermWebView: UIViewRepresentable {
|
||||||
let onInput: (String) -> Void
|
let onInput: (String) -> Void
|
||||||
let onResize: (Int, Int) -> Void
|
let onResize: (Int, Int) -> Void
|
||||||
var viewModel: TerminalViewModel
|
var viewModel: TerminalViewModel
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
let configuration = WKWebViewConfiguration()
|
let configuration = WKWebViewConfiguration()
|
||||||
configuration.allowsInlineMediaPlayback = true
|
configuration.allowsInlineMediaPlayback = true
|
||||||
configuration.userContentController = WKUserContentController()
|
configuration.userContentController = WKUserContentController()
|
||||||
|
|
||||||
// Add message handlers
|
// Add message handlers
|
||||||
configuration.userContentController.add(context.coordinator, name: "terminalInput")
|
configuration.userContentController.add(context.coordinator, name: "terminalInput")
|
||||||
configuration.userContentController.add(context.coordinator, name: "terminalResize")
|
configuration.userContentController.add(context.coordinator, name: "terminalResize")
|
||||||
configuration.userContentController.add(context.coordinator, name: "terminalReady")
|
configuration.userContentController.add(context.coordinator, name: "terminalReady")
|
||||||
configuration.userContentController.add(context.coordinator, name: "terminalLog")
|
configuration.userContentController.add(context.coordinator, name: "terminalLog")
|
||||||
|
|
||||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||||
webView.isOpaque = false
|
webView.isOpaque = false
|
||||||
webView.backgroundColor = UIColor(theme.background)
|
webView.backgroundColor = UIColor(theme.background)
|
||||||
webView.scrollView.isScrollEnabled = false
|
webView.scrollView.isScrollEnabled = false
|
||||||
|
|
||||||
context.coordinator.webView = webView
|
context.coordinator.webView = webView
|
||||||
context.coordinator.loadTerminal()
|
context.coordinator.loadTerminal()
|
||||||
|
|
||||||
return webView
|
return webView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||||
// Update font size
|
// Update font size
|
||||||
context.coordinator.updateFontSize(fontSize)
|
context.coordinator.updateFontSize(fontSize)
|
||||||
|
|
||||||
// Update theme
|
// Update theme
|
||||||
context.coordinator.updateTheme(theme)
|
context.coordinator.updateTheme(theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(self)
|
Coordinator(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
|
class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
|
||||||
let parent: XtermWebView
|
let parent: XtermWebView
|
||||||
weak var webView: WKWebView?
|
weak var webView: WKWebView?
|
||||||
private var bufferWebSocketClient: BufferWebSocketClient?
|
private var bufferWebSocketClient: BufferWebSocketClient?
|
||||||
private let logger = Logger(category: "XtermWebView")
|
private let logger = Logger(category: "XtermWebView")
|
||||||
private var sseClient: SSEClient?
|
private var sseClient: SSEClient?
|
||||||
|
|
||||||
init(_ parent: XtermWebView) {
|
init(_ parent: XtermWebView) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadTerminal() {
|
func loadTerminal() {
|
||||||
guard let webView = webView else { return }
|
guard let webView else { return }
|
||||||
|
|
||||||
let html = """
|
let html = """
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
background: #000;
|
background: #000;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
#terminal {
|
#terminal {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
.xterm { height: 100%; }
|
.xterm { height: 100%; }
|
||||||
|
|
@ -91,11 +91,11 @@ struct XtermWebView: UIViewRepresentable {
|
||||||
let fitAddon;
|
let fitAddon;
|
||||||
let buffer = [];
|
let buffer = [];
|
||||||
let isReady = false;
|
let isReady = false;
|
||||||
|
|
||||||
function log(message) {
|
function log(message) {
|
||||||
window.webkit.messageHandlers.terminalLog.postMessage(message);
|
window.webkit.messageHandlers.terminalLog.postMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTerminal() {
|
function initTerminal() {
|
||||||
term = new Terminal({
|
term = new Terminal({
|
||||||
fontSize: \(parent.fontSize),
|
fontSize: \(parent.fontSize),
|
||||||
|
|
@ -127,15 +127,15 @@ struct XtermWebView: UIViewRepresentable {
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
scrollback: 10000
|
scrollback: 10000
|
||||||
});
|
});
|
||||||
|
|
||||||
fitAddon = new FitAddon.FitAddon();
|
fitAddon = new FitAddon.FitAddon();
|
||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fitAddon);
|
||||||
|
|
||||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||||
term.loadAddon(webLinksAddon);
|
term.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
term.open(document.getElementById('terminal'));
|
term.open(document.getElementById('terminal'));
|
||||||
|
|
||||||
// Fit terminal to container
|
// Fit terminal to container
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
|
|
@ -147,28 +147,28 @@ struct XtermWebView: UIViewRepresentable {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Handle input
|
// Handle input
|
||||||
term.onData(data => {
|
term.onData(data => {
|
||||||
window.webkit.messageHandlers.terminalInput.postMessage(data);
|
window.webkit.messageHandlers.terminalInput.postMessage(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize
|
||||||
term.onResize(({ cols, rows }) => {
|
term.onResize(({ cols, rows }) => {
|
||||||
window.webkit.messageHandlers.terminalResize.postMessage({ cols, rows });
|
window.webkit.messageHandlers.terminalResize.postMessage({ cols, rows });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process buffered data
|
// Process buffered data
|
||||||
isReady = true;
|
isReady = true;
|
||||||
buffer.forEach(data => writeToTerminal(data));
|
buffer.forEach(data => writeToTerminal(data));
|
||||||
buffer = [];
|
buffer = [];
|
||||||
|
|
||||||
// Notify ready
|
// Notify ready
|
||||||
window.webkit.messageHandlers.terminalReady.postMessage({});
|
window.webkit.messageHandlers.terminalReady.postMessage({});
|
||||||
|
|
||||||
log('Terminal initialized');
|
log('Terminal initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeToTerminal(data) {
|
function writeToTerminal(data) {
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
buffer.push(data);
|
buffer.push(data);
|
||||||
|
|
@ -176,38 +176,38 @@ struct XtermWebView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
term.write(data);
|
term.write(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFontSize(size) {
|
function updateFontSize(size) {
|
||||||
if (term) {
|
if (term) {
|
||||||
term.options.fontSize = size;
|
term.options.fontSize = size;
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTheme(theme) {
|
function updateTheme(theme) {
|
||||||
if (term && theme) {
|
if (term && theme) {
|
||||||
term.options.theme = theme;
|
term.options.theme = theme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
if (term) {
|
if (term) {
|
||||||
term.scrollToBottom();
|
term.scrollToBottom();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
if (term) {
|
if (term) {
|
||||||
term.clear();
|
term.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resize() {
|
function resize() {
|
||||||
if (fitAddon) {
|
if (fitAddon) {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose functions to native
|
// Expose functions to native
|
||||||
window.xtermAPI = {
|
window.xtermAPI = {
|
||||||
writeToTerminal,
|
writeToTerminal,
|
||||||
|
|
@ -217,10 +217,10 @@ struct XtermWebView: UIViewRepresentable {
|
||||||
clear,
|
clear,
|
||||||
resize
|
resize
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize terminal when page loads
|
// Initialize terminal when page loads
|
||||||
window.addEventListener('load', initTerminal);
|
window.addEventListener('load', initTerminal);
|
||||||
|
|
||||||
// Handle window resize
|
// Handle window resize
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
if (fitAddon) {
|
if (fitAddon) {
|
||||||
|
|
@ -233,52 +233,55 @@ struct XtermWebView: UIViewRepresentable {
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
webView.loadHTMLString(html, baseURL: nil)
|
webView.loadHTMLString(html, baseURL: nil)
|
||||||
webView.navigationDelegate = self
|
webView.navigationDelegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
func userContentController(
|
||||||
|
_ userContentController: WKUserContentController,
|
||||||
|
didReceive message: WKScriptMessage
|
||||||
|
) {
|
||||||
switch message.name {
|
switch message.name {
|
||||||
case "terminalInput":
|
case "terminalInput":
|
||||||
if let data = message.body as? String {
|
if let data = message.body as? String {
|
||||||
parent.onInput(data)
|
parent.onInput(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "terminalResize":
|
case "terminalResize":
|
||||||
if let dict = message.body as? [String: Any],
|
if let dict = message.body as? [String: Any],
|
||||||
let cols = dict["cols"] as? Int,
|
let cols = dict["cols"] as? Int,
|
||||||
let rows = dict["rows"] as? Int {
|
let rows = dict["rows"] as? Int {
|
||||||
parent.onResize(cols, rows)
|
parent.onResize(cols, rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "terminalReady":
|
case "terminalReady":
|
||||||
setupDataStreaming()
|
setupDataStreaming()
|
||||||
|
|
||||||
case "terminalLog":
|
case "terminalLog":
|
||||||
if let log = message.body as? String {
|
if let log = message.body as? String {
|
||||||
logger.debug(log)
|
logger.debug(log)
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
logger.info("Page loaded")
|
logger.info("Page loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupDataStreaming() {
|
private func setupDataStreaming() {
|
||||||
// Subscribe to WebSocket buffer updates
|
// Subscribe to WebSocket buffer updates
|
||||||
if bufferWebSocketClient == nil {
|
if bufferWebSocketClient == nil {
|
||||||
bufferWebSocketClient = parent.viewModel.bufferWebSocketClient
|
bufferWebSocketClient = parent.viewModel.bufferWebSocketClient
|
||||||
}
|
}
|
||||||
|
|
||||||
bufferWebSocketClient?.subscribe(to: parent.session.id) { [weak self] event in
|
bufferWebSocketClient?.subscribe(to: parent.session.id) { [weak self] event in
|
||||||
self?.handleWebSocketEvent(event)
|
self?.handleWebSocketEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also set up SSE as fallback
|
// Also set up SSE as fallback
|
||||||
if let streamURL = APIClient.shared.streamURL(for: parent.session.id) {
|
if let streamURL = APIClient.shared.streamURL(for: parent.session.id) {
|
||||||
sseClient = SSEClient(url: streamURL)
|
sseClient = SSEClient(url: streamURL)
|
||||||
|
|
@ -286,29 +289,29 @@ struct XtermWebView: UIViewRepresentable {
|
||||||
sseClient?.start()
|
sseClient?.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleWebSocketEvent(_ event: TerminalWebSocketEvent) {
|
private func handleWebSocketEvent(_ event: TerminalWebSocketEvent) {
|
||||||
switch event {
|
switch event {
|
||||||
case .bufferUpdate(let snapshot):
|
case .bufferUpdate(let snapshot):
|
||||||
// Convert buffer snapshot to terminal output
|
// Convert buffer snapshot to terminal output
|
||||||
renderBufferSnapshot(snapshot)
|
renderBufferSnapshot(snapshot)
|
||||||
|
|
||||||
case .output(_, let data):
|
case .output(_, let data):
|
||||||
writeToTerminal(data)
|
writeToTerminal(data)
|
||||||
|
|
||||||
case .resize(_, _):
|
case .resize:
|
||||||
// Handle resize if needed
|
// Handle resize if needed
|
||||||
break
|
break
|
||||||
|
|
||||||
case .bell:
|
case .bell:
|
||||||
// Could play a sound or visual bell
|
// Could play a sound or visual bell
|
||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func renderBufferSnapshot(_ snapshot: BufferSnapshot) {
|
private func renderBufferSnapshot(_ snapshot: BufferSnapshot) {
|
||||||
// For now, we'll just write the text content
|
// For now, we'll just write the text content
|
||||||
// In a full implementation, we'd convert the buffer cells to ANSI sequences
|
// In a full implementation, we'd convert the buffer cells to ANSI sequences
|
||||||
|
|
@ -321,25 +324,25 @@ struct XtermWebView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
writeToTerminal(output)
|
writeToTerminal(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func writeToTerminal(_ data: String) {
|
private func writeToTerminal(_ data: String) {
|
||||||
let escaped = data
|
let escaped = data
|
||||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||||
.replacingOccurrences(of: "'", with: "\\'")
|
.replacingOccurrences(of: "'", with: "\\'")
|
||||||
.replacingOccurrences(of: "\n", with: "\\n")
|
.replacingOccurrences(of: "\n", with: "\\n")
|
||||||
.replacingOccurrences(of: "\r", with: "\\r")
|
.replacingOccurrences(of: "\r", with: "\\r")
|
||||||
|
|
||||||
webView?.evaluateJavaScript("window.xtermAPI.writeToTerminal('\(escaped)')") { _, error in
|
webView?.evaluateJavaScript("window.xtermAPI.writeToTerminal('\(escaped)')") { _, error in
|
||||||
if let error = error {
|
if let error {
|
||||||
self.logger.error("Error writing to terminal: \(error)")
|
self.logger.error("Error writing to terminal: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateFontSize(_ size: CGFloat) {
|
func updateFontSize(_ size: CGFloat) {
|
||||||
webView?.evaluateJavaScript("window.xtermAPI.updateFontSize(\(size))")
|
webView?.evaluateJavaScript("window.xtermAPI.updateFontSize(\(size))")
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateTheme(_ theme: TerminalTheme) {
|
func updateTheme(_ theme: TerminalTheme) {
|
||||||
// Convert theme to xterm.js format
|
// Convert theme to xterm.js format
|
||||||
let themeJS = """
|
let themeJS = """
|
||||||
|
|
@ -352,11 +355,11 @@ struct XtermWebView: UIViewRepresentable {
|
||||||
"""
|
"""
|
||||||
webView?.evaluateJavaScript("window.xtermAPI.updateTheme(\(themeJS))")
|
webView?.evaluateJavaScript("window.xtermAPI.updateTheme(\(themeJS))")
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrollToBottom() {
|
func scrollToBottom() {
|
||||||
webView?.evaluateJavaScript("window.xtermAPI.scrollToBottom()")
|
webView?.evaluateJavaScript("window.xtermAPI.scrollToBottom()")
|
||||||
}
|
}
|
||||||
|
|
||||||
func clear() {
|
func clear() {
|
||||||
webView?.evaluateJavaScript("window.xtermAPI.clear()")
|
webView?.evaluateJavaScript("window.xtermAPI.clear()")
|
||||||
}
|
}
|
||||||
|
|
@ -364,25 +367,26 @@ struct XtermWebView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SSEClientDelegate
|
// MARK: - SSEClientDelegate
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
extension XtermWebView.Coordinator: SSEClientDelegate {
|
extension XtermWebView.Coordinator: SSEClientDelegate {
|
||||||
nonisolated func sseClient(_ client: SSEClient, didReceiveEvent event: SSEClient.SSEEvent) {
|
nonisolated func sseClient(_ client: SSEClient, didReceiveEvent event: SSEClient.SSEEvent) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
switch event {
|
switch event {
|
||||||
case .terminalOutput(_, let type, let data):
|
case .terminalOutput(_, let type, let data):
|
||||||
if type == "o" { // output
|
if type == "o" { // output
|
||||||
writeToTerminal(data)
|
writeToTerminal(data)
|
||||||
|
}
|
||||||
|
case .exit(let exitCode, _):
|
||||||
|
writeToTerminal("\r\n[Process exited with code \(exitCode)]\r\n")
|
||||||
|
case .error(let error):
|
||||||
|
logger.error("SSE error: \(error)")
|
||||||
}
|
}
|
||||||
case .exit(let exitCode, _):
|
|
||||||
writeToTerminal("\r\n[Process exited with code \(exitCode)]\r\n")
|
|
||||||
case .error(let error):
|
|
||||||
logger.error("SSE error: \(error)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper extension for Color to hex
|
/// Helper extension for Color to hex
|
||||||
extension Color {
|
extension Color {
|
||||||
var hex: String {
|
var hex: String {
|
||||||
let uiColor = UIColor(self)
|
let uiColor = UIColor(self)
|
||||||
|
|
@ -390,12 +394,14 @@ extension Color {
|
||||||
var green: CGFloat = 0
|
var green: CGFloat = 0
|
||||||
var blue: CGFloat = 0
|
var blue: CGFloat = 0
|
||||||
var alpha: CGFloat = 0
|
var alpha: CGFloat = 0
|
||||||
|
|
||||||
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||||
|
|
||||||
return String(format: "#%02X%02X%02X",
|
return String(
|
||||||
Int(red * 255),
|
format: "#%02X%02X%02X",
|
||||||
Int(green * 255),
|
Int(red * 255),
|
||||||
Int(blue * 255))
|
Int(green * 255),
|
||||||
|
Int(blue * 255)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
458
ios/VibeTunnel/Views/Welcome/WelcomeView.swift
Normal file
458
ios/VibeTunnel/Views/Welcome/WelcomeView.swift
Normal 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()
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue