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