mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-06-28 05:29:29 +00:00
lint+format
This commit is contained in:
parent
8a11508d61
commit
610e3c0c43
27 changed files with 1118 additions and 961 deletions
39
ios/.swiftlint.yml
Normal file
39
ios/.swiftlint.yml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# SwiftLint configuration for VibeTunnel iOS
|
||||
|
||||
# Adjust file length thresholds
|
||||
file_length:
|
||||
warning: 800
|
||||
error: 1000
|
||||
ignore_comment_only_lines: true
|
||||
|
||||
# Adjust type body length thresholds
|
||||
type_body_length:
|
||||
warning: 500
|
||||
error: 800
|
||||
|
||||
# Keep other rules at their defaults
|
||||
line_length:
|
||||
warning: 120
|
||||
error: 200
|
||||
ignores_urls: true
|
||||
ignores_function_declarations: true
|
||||
ignores_comments: true
|
||||
|
||||
# Opt-in rules
|
||||
opt_in_rules:
|
||||
- empty_count
|
||||
- empty_string
|
||||
- first_where
|
||||
- force_unwrapping
|
||||
- implicitly_unwrapped_optional
|
||||
- last_where
|
||||
- reduce_boolean
|
||||
- reduce_into
|
||||
- yoda_condition
|
||||
|
||||
# Excluded paths
|
||||
excluded:
|
||||
- .build
|
||||
- Package.swift
|
||||
- VibeTunnel.xcodeproj
|
||||
- VibeTunnelTests
|
||||
|
|
@ -9,17 +9,18 @@ let package = Package(
|
|||
products: [
|
||||
.library(
|
||||
name: "VibeTunnelDependencies",
|
||||
targets: ["VibeTunnelDependencies"])
|
||||
targets: ["VibeTunnelDependencies"]
|
||||
)
|
||||
],
|
||||
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")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "VibeTunnelDependencies",
|
||||
dependencies: [
|
||||
.product(name: "SwiftTerm", package: "SwiftTerm"),
|
||||
.product(name: "SwiftTerm", package: "SwiftTerm")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ struct ContentView: View {
|
|||
@State private var showingFilePicker = false
|
||||
@State private var showingCastPlayer = false
|
||||
@State private var selectedCastFile: URL?
|
||||
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if connectionManager.isConnected, connectionManager.serverConfig != nil {
|
||||
|
|
@ -29,4 +29,4 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import SwiftUI
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct VibeTunnelApp: App {
|
||||
@State private var connectionManager = ConnectionManager()
|
||||
@State private var navigationManager = NavigationManager()
|
||||
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
|
@ -16,11 +16,11 @@ struct VibeTunnelApp: App {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func handleURL(_ url: URL) {
|
||||
// Handle vibetunnel://session/{sessionId} URLs
|
||||
guard url.scheme == "vibetunnel" else { return }
|
||||
|
||||
|
||||
if url.host == "session",
|
||||
let sessionId = url.pathComponents.last,
|
||||
!sessionId.isEmpty {
|
||||
|
|
@ -33,25 +33,25 @@ struct VibeTunnelApp: App {
|
|||
class ConnectionManager {
|
||||
var isConnected: Bool = false
|
||||
var serverConfig: ServerConfig?
|
||||
|
||||
|
||||
init() {
|
||||
loadSavedConnection()
|
||||
}
|
||||
|
||||
|
||||
private func loadSavedConnection() {
|
||||
if let data = UserDefaults.standard.data(forKey: "savedServerConfig"),
|
||||
let config = try? JSONDecoder().decode(ServerConfig.self, from: data) {
|
||||
self.serverConfig = config
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func saveConnection(_ config: ServerConfig) {
|
||||
if let data = try? JSONEncoder().encode(config) {
|
||||
UserDefaults.standard.set(data, forKey: "savedServerConfig")
|
||||
self.serverConfig = config
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func disconnect() {
|
||||
isConnected = false
|
||||
}
|
||||
|
|
@ -61,14 +61,14 @@ class ConnectionManager {
|
|||
class NavigationManager {
|
||||
var selectedSessionId: String?
|
||||
var shouldNavigateToSession: Bool = false
|
||||
|
||||
|
||||
func navigateToSession(_ sessionId: String) {
|
||||
selectedSessionId = sessionId
|
||||
shouldNavigateToSession = true
|
||||
}
|
||||
|
||||
|
||||
func clearNavigation() {
|
||||
selectedSessionId = nil
|
||||
shouldNavigateToSession = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
import Foundation
|
||||
import Observation
|
||||
|
||||
// Asciinema cast v2 format support
|
||||
/// Cast file theme configuration
|
||||
struct CastTheme: Codable {
|
||||
let foreground: String?
|
||||
let background: String?
|
||||
let palette: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case foreground = "fg"
|
||||
case background = "bg"
|
||||
case palette
|
||||
}
|
||||
}
|
||||
|
||||
/// Asciinema cast v2 format support
|
||||
struct CastFile: Codable {
|
||||
let version: Int
|
||||
let width: Int
|
||||
|
|
@ -10,12 +23,6 @@ struct CastFile: Codable {
|
|||
let title: String?
|
||||
let env: [String: String]?
|
||||
let theme: CastTheme?
|
||||
|
||||
struct CastTheme: Codable {
|
||||
let fg: String?
|
||||
let bg: String?
|
||||
let palette: String?
|
||||
}
|
||||
}
|
||||
|
||||
struct CastEvent: Codable {
|
||||
|
|
@ -24,72 +31,72 @@ struct CastEvent: Codable {
|
|||
let data: String
|
||||
}
|
||||
|
||||
// Cast file recorder for terminal sessions
|
||||
/// Cast file recorder for terminal sessions
|
||||
@MainActor
|
||||
@Observable
|
||||
class CastRecorder {
|
||||
var isRecording = false
|
||||
var recordingStartTime: Date?
|
||||
var events: [CastEvent] = []
|
||||
|
||||
|
||||
private let sessionId: String
|
||||
private let width: Int
|
||||
private let height: Int
|
||||
private var startTime: TimeInterval = 0
|
||||
|
||||
|
||||
init(sessionId: String, width: Int = 80, height: Int = 24) {
|
||||
self.sessionId = sessionId
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
|
||||
|
||||
func startRecording() {
|
||||
guard !isRecording else { return }
|
||||
|
||||
|
||||
isRecording = true
|
||||
recordingStartTime = Date()
|
||||
startTime = Date().timeIntervalSince1970
|
||||
events.removeAll()
|
||||
}
|
||||
|
||||
|
||||
func stopRecording() {
|
||||
guard isRecording else { return }
|
||||
|
||||
|
||||
isRecording = false
|
||||
recordingStartTime = nil
|
||||
}
|
||||
|
||||
|
||||
func recordOutput(_ data: String) {
|
||||
guard isRecording else { return }
|
||||
|
||||
|
||||
let currentTime = Date().timeIntervalSince1970
|
||||
let relativeTime = currentTime - startTime
|
||||
|
||||
|
||||
let event = CastEvent(
|
||||
time: relativeTime,
|
||||
type: "o", // output
|
||||
data: data
|
||||
)
|
||||
|
||||
|
||||
events.append(event)
|
||||
}
|
||||
|
||||
|
||||
func recordResize(cols: Int, rows: Int) {
|
||||
guard isRecording else { return }
|
||||
|
||||
|
||||
let currentTime = Date().timeIntervalSince1970
|
||||
let relativeTime = currentTime - startTime
|
||||
|
||||
|
||||
let resizeData = "\(cols)x\(rows)"
|
||||
let event = CastEvent(
|
||||
time: relativeTime,
|
||||
type: "r", // resize
|
||||
data: resizeData
|
||||
)
|
||||
|
||||
|
||||
events.append(event)
|
||||
}
|
||||
|
||||
|
||||
func exportCastFile() -> Data? {
|
||||
// Create header
|
||||
let header = CastFile(
|
||||
|
|
@ -101,75 +108,78 @@ class CastRecorder {
|
|||
env: ["TERM": "xterm-256color", "SHELL": "/bin/zsh"],
|
||||
theme: nil
|
||||
)
|
||||
|
||||
|
||||
guard let headerData = try? JSONEncoder().encode(header),
|
||||
let headerString = String(data: headerData, encoding: .utf8) else {
|
||||
let headerString = String(data: headerData, encoding: .utf8)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Build the cast file content
|
||||
var castContent = headerString + "\n"
|
||||
|
||||
|
||||
// Add all events
|
||||
for event in events {
|
||||
// Cast events are encoded as arrays [time, type, data]
|
||||
let eventArray: [Any] = [event.time, event.type, event.data]
|
||||
|
||||
|
||||
if let jsonData = try? JSONSerialization.data(withJSONObject: eventArray),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
castContent += jsonString + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return castContent.data(using: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
// Cast file player for imported recordings
|
||||
/// Cast file player for imported recordings
|
||||
class CastPlayer {
|
||||
let header: CastFile
|
||||
let events: [CastEvent]
|
||||
|
||||
|
||||
init?(data: Data) {
|
||||
guard let content = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
guard !lines.isEmpty else { return nil }
|
||||
|
||||
|
||||
// Parse header (first line)
|
||||
guard let headerData = lines[0].data(using: .utf8),
|
||||
let header = try? JSONDecoder().decode(CastFile.self, from: headerData) else {
|
||||
let header = try? JSONDecoder().decode(CastFile.self, from: headerData)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Parse events (remaining lines)
|
||||
var parsedEvents: [CastEvent] = []
|
||||
for i in 1..<lines.count {
|
||||
let line = lines[i].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !line.isEmpty,
|
||||
let lineData = line.data(using: .utf8),
|
||||
let array = try? JSONSerialization.jsonObject(with: lineData) as? [Any],
|
||||
array.count >= 3,
|
||||
let time = array[0] as? Double,
|
||||
let type = array[1] as? String,
|
||||
let data = array[2] as? String else {
|
||||
let data = array[2] as? String
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
let event = CastEvent(time: time, type: type, data: data)
|
||||
parsedEvents.append(event)
|
||||
}
|
||||
|
||||
|
||||
self.header = header
|
||||
self.events = parsedEvents
|
||||
}
|
||||
|
||||
|
||||
var duration: TimeInterval {
|
||||
events.last?.time ?? 0
|
||||
}
|
||||
|
||||
|
||||
func play(onEvent: @escaping @Sendable (CastEvent) -> Void, completion: @escaping @Sendable () -> Void) {
|
||||
let eventsToPlay = self.events
|
||||
Task { @Sendable in
|
||||
|
|
@ -178,15 +188,15 @@ class CastPlayer {
|
|||
if event.time > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(event.time * 1_000_000_000))
|
||||
}
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
onEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ struct FileEntry: Codable, Identifiable {
|
|||
let size: Int64
|
||||
let mode: String
|
||||
let modTime: Date
|
||||
|
||||
|
||||
var id: String { path }
|
||||
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case path
|
||||
|
|
@ -18,7 +18,7 @@ struct FileEntry: Codable, Identifiable {
|
|||
case mode
|
||||
case modTime = "mod_time"
|
||||
}
|
||||
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
|
|
@ -26,7 +26,7 @@ struct FileEntry: Codable, Identifiable {
|
|||
isDir = try container.decode(Bool.self, forKey: .isDir)
|
||||
size = try container.decode(Int64.self, forKey: .size)
|
||||
mode = try container.decode(String.self, forKey: .mode)
|
||||
|
||||
|
||||
// Decode mod_time string as Date
|
||||
let modTimeString = try container.decode(String.self, forKey: .modTime)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
|
|
@ -39,17 +39,21 @@ struct FileEntry: Codable, Identifiable {
|
|||
if let date = formatter.date(from: modTimeString) {
|
||||
modTime = date
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .modTime, in: container, debugDescription: "Invalid date format")
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .modTime,
|
||||
in: container,
|
||||
debugDescription: "Invalid date format"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var formattedSize: String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .binary
|
||||
return formatter.string(fromByteCount: size)
|
||||
}
|
||||
|
||||
|
||||
var formattedDate: String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
|
|
@ -60,4 +64,4 @@ struct FileEntry: Codable, Identifiable {
|
|||
struct DirectoryListing: Codable {
|
||||
let absolutePath: String
|
||||
let files: [FileEntry]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,24 +5,29 @@ struct ServerConfig: Codable, Equatable {
|
|||
let port: Int
|
||||
let name: String?
|
||||
let password: String?
|
||||
|
||||
|
||||
var baseURL: URL {
|
||||
URL(string: "http://\(host):\(port)")!
|
||||
// This should always succeed with valid host and port
|
||||
// Fallback ensures we always have a valid URL
|
||||
URL(string: "http://\(host):\(port)") ?? URL(fileURLWithPath: "/")
|
||||
}
|
||||
|
||||
|
||||
var displayName: String {
|
||||
name ?? "\(host):\(port)"
|
||||
}
|
||||
|
||||
|
||||
var requiresAuthentication: Bool {
|
||||
password != nil && !password!.isEmpty
|
||||
if let password {
|
||||
return !password.isEmpty
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
var authorizationHeader: String? {
|
||||
guard let password = password, !password.isEmpty else { return nil }
|
||||
guard let password, !password.isEmpty else { return nil }
|
||||
let credentials = "admin:\(password)"
|
||||
guard let data = credentials.data(using: .utf8) else { return nil }
|
||||
let base64 = data.base64EncodedString()
|
||||
return "Basic \(base64)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ struct Session: Codable, Identifiable, Equatable {
|
|||
let waiting: Bool?
|
||||
let width: Int?
|
||||
let height: Int?
|
||||
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case command = "cmdline"
|
||||
|
|
@ -28,15 +28,15 @@ struct Session: Codable, Identifiable, Equatable {
|
|||
case width
|
||||
case height
|
||||
}
|
||||
|
||||
|
||||
var displayName: String {
|
||||
name ?? command
|
||||
}
|
||||
|
||||
|
||||
var isRunning: Bool {
|
||||
status == .running
|
||||
}
|
||||
|
||||
|
||||
var formattedStartTime: String {
|
||||
// Parse and format the startedAt string
|
||||
// Try ISO8601 first
|
||||
|
|
@ -47,7 +47,7 @@ struct Session: Codable, Identifiable, Equatable {
|
|||
displayFormatter.timeStyle = .short
|
||||
return displayFormatter.string(from: date)
|
||||
}
|
||||
|
||||
|
||||
// Try RFC3339 format (what Go uses)
|
||||
let rfc3339Formatter = DateFormatter()
|
||||
rfc3339Formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
|
|
@ -58,7 +58,7 @@ struct Session: Codable, Identifiable, Equatable {
|
|||
displayFormatter.timeStyle = .short
|
||||
return displayFormatter.string(from: date)
|
||||
}
|
||||
|
||||
|
||||
// Try without fractional seconds
|
||||
rfc3339Formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
|
||||
if let date = rfc3339Formatter.date(from: startedAt) {
|
||||
|
|
@ -67,7 +67,7 @@ struct Session: Codable, Identifiable, Equatable {
|
|||
displayFormatter.timeStyle = .short
|
||||
return displayFormatter.string(from: date)
|
||||
}
|
||||
|
||||
|
||||
return startedAt
|
||||
}
|
||||
}
|
||||
|
|
@ -82,16 +82,32 @@ struct SessionCreateData: Codable {
|
|||
let command: [String]
|
||||
let workingDir: String
|
||||
let name: String?
|
||||
let spawn_terminal: Bool?
|
||||
let spawnTerminal: Bool?
|
||||
let cols: Int?
|
||||
let rows: Int?
|
||||
|
||||
init(command: String = "zsh", workingDir: String, name: String? = nil, spawnTerminal: Bool = false, cols: Int = 120, rows: Int = 30) {
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case command
|
||||
case workingDir
|
||||
case name
|
||||
case spawnTerminal = "spawn_terminal"
|
||||
case cols
|
||||
case rows
|
||||
}
|
||||
|
||||
init(
|
||||
command: String = "zsh",
|
||||
workingDir: String,
|
||||
name: String? = nil,
|
||||
spawnTerminal: Bool = false,
|
||||
cols: Int = 120,
|
||||
rows: Int = 30
|
||||
) {
|
||||
self.command = [command]
|
||||
self.workingDir = workingDir
|
||||
self.name = name
|
||||
self.spawn_terminal = spawnTerminal
|
||||
self.spawnTerminal = spawnTerminal
|
||||
self.cols = cols
|
||||
self.rows = rows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,21 +5,21 @@ enum TerminalEvent {
|
|||
case output(timestamp: Double, data: String)
|
||||
case resize(timestamp: Double, dimensions: String)
|
||||
case exit(code: Int, sessionId: String)
|
||||
|
||||
|
||||
init?(from line: String) {
|
||||
guard let data = line.data(using: .utf8) else { return nil }
|
||||
|
||||
|
||||
// Try to parse as header first
|
||||
if let header = try? JSONDecoder().decode(AsciinemaHeader.self, from: data) {
|
||||
self = .header(header)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Try to parse as array event
|
||||
guard let array = try? JSONSerialization.jsonObject(with: data) as? [Any] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Check for exit event: ["exit", exitCode, sessionId]
|
||||
if array.count == 3,
|
||||
let exitString = array[0] as? String,
|
||||
|
|
@ -29,15 +29,16 @@ enum TerminalEvent {
|
|||
self = .exit(code: exitCode, sessionId: sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Parse normal events: [timestamp, "type", "data"]
|
||||
guard array.count >= 3,
|
||||
let timestamp = array[0] as? Double,
|
||||
let typeString = array[1] as? String,
|
||||
let eventData = array[2] as? String else {
|
||||
let eventData = array[2] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
switch typeString {
|
||||
case "o":
|
||||
self = .output(timestamp: timestamp, data: eventData)
|
||||
|
|
@ -61,19 +62,19 @@ struct AsciinemaHeader: Codable {
|
|||
|
||||
struct TerminalInput: Codable {
|
||||
let text: String
|
||||
|
||||
|
||||
enum SpecialKey: String {
|
||||
// Arrow keys use ANSI escape sequences
|
||||
case arrowUp = "\u{001B}[A"
|
||||
case arrowDown = "\u{001B}[B"
|
||||
case arrowRight = "\u{001B}[C"
|
||||
case arrowLeft = "\u{001B}[D"
|
||||
|
||||
|
||||
// Special keys
|
||||
case escape = "\u{001B}"
|
||||
case enter = "\r"
|
||||
case tab = "\t"
|
||||
|
||||
|
||||
// Control keys
|
||||
case ctrlC = "\u{0003}"
|
||||
case ctrlD = "\u{0004}"
|
||||
|
|
@ -81,16 +82,16 @@ struct TerminalInput: Codable {
|
|||
case ctrlL = "\u{000C}"
|
||||
case ctrlA = "\u{0001}"
|
||||
case ctrlE = "\u{0005}"
|
||||
|
||||
|
||||
// For compatibility with web frontend
|
||||
case ctrlEnter = "ctrl_enter"
|
||||
case shiftEnter = "shift_enter"
|
||||
}
|
||||
|
||||
|
||||
init(specialKey: SpecialKey) {
|
||||
self.text = specialKey.rawValue
|
||||
}
|
||||
|
||||
|
||||
init(text: String) {
|
||||
self.text = text
|
||||
}
|
||||
|
|
@ -99,4 +100,4 @@ struct TerminalInput: Codable {
|
|||
struct TerminalResize: Codable {
|
||||
let cols: Int
|
||||
let rows: Int
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ struct TerminalSnapshot: Codable {
|
|||
let sessionId: String
|
||||
let header: AsciinemaHeader?
|
||||
let events: [AsciinemaEvent]
|
||||
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case sessionId = "session_id"
|
||||
case header
|
||||
|
|
@ -16,7 +16,7 @@ struct AsciinemaEvent: Codable {
|
|||
let time: Double
|
||||
let type: EventType
|
||||
let data: String
|
||||
|
||||
|
||||
enum EventType: String, Codable {
|
||||
case output = "o"
|
||||
case input = "i"
|
||||
|
|
@ -26,22 +26,22 @@ struct AsciinemaEvent: Codable {
|
|||
}
|
||||
|
||||
extension TerminalSnapshot {
|
||||
// Get the last few lines of terminal output for preview
|
||||
/// Get the last few lines of terminal output for preview
|
||||
var outputPreview: String {
|
||||
// Combine all output events
|
||||
let outputEvents = events.filter { $0.type == .output }
|
||||
let combinedOutput = outputEvents.map { $0.data }.joined()
|
||||
|
||||
let combinedOutput = outputEvents.map(\.data).joined()
|
||||
|
||||
// Split into lines and get last few non-empty lines
|
||||
let lines = combinedOutput.components(separatedBy: .newlines)
|
||||
let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
|
||||
|
||||
// Take last 3-5 lines for preview
|
||||
let previewLines = Array(nonEmptyLines.suffix(4))
|
||||
return previewLines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// Get a cleaned version without ANSI escape codes (basic implementation)
|
||||
|
||||
/// Get a cleaned version without ANSI escape codes (basic implementation)
|
||||
var cleanOutputPreview: String {
|
||||
let output = outputPreview
|
||||
// Remove common ANSI escape sequences (this is a simplified version)
|
||||
|
|
@ -51,4 +51,4 @@ extension TerminalSnapshot {
|
|||
let cleaned = regex?.stringByReplacingMatches(in: output, options: [], range: range, withTemplate: "") ?? output
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ enum APIError: LocalizedError {
|
|||
case networkError(Error)
|
||||
case noServerConfigured
|
||||
case invalidResponse
|
||||
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
|
|
@ -18,7 +18,7 @@ enum APIError: LocalizedError {
|
|||
case .decodingError(let error):
|
||||
return "Failed to decode response: \(error.localizedDescription)"
|
||||
case .serverError(let code, let message):
|
||||
if let message = message {
|
||||
if let message {
|
||||
return message
|
||||
}
|
||||
switch code {
|
||||
|
|
@ -81,50 +81,51 @@ class APIClient: APIClientProtocol {
|
|||
private let session = URLSession.shared
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
|
||||
|
||||
private var baseURL: URL? {
|
||||
guard let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
|
||||
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config) else {
|
||||
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return serverConfig.baseURL
|
||||
}
|
||||
|
||||
|
||||
private init() {}
|
||||
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
|
||||
func getSessions() async throws -> [Session] {
|
||||
guard let baseURL = baseURL else {
|
||||
guard let baseURL else {
|
||||
throw APIError.noServerConfigured
|
||||
}
|
||||
|
||||
|
||||
let url = baseURL.appendingPathComponent("api/sessions")
|
||||
let (data, response) = try await session.data(from: url)
|
||||
|
||||
|
||||
try validateResponse(response)
|
||||
|
||||
|
||||
do {
|
||||
return try decoder.decode([Session].self, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func createSession(_ data: SessionCreateData) async throws -> String {
|
||||
guard let baseURL = baseURL else {
|
||||
guard let baseURL else {
|
||||
print("[APIClient] No server configured")
|
||||
throw APIError.noServerConfigured
|
||||
}
|
||||
|
||||
|
||||
let url = baseURL.appendingPathComponent("api/sessions")
|
||||
print("[APIClient] Creating session at URL: \(url)")
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
addAuthenticationIfNeeded(&request)
|
||||
|
||||
|
||||
do {
|
||||
request.httpBody = try encoder.encode(data)
|
||||
if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) {
|
||||
|
|
@ -134,26 +135,26 @@ class APIClient: APIClientProtocol {
|
|||
print("[APIClient] Failed to encode session data: \(error)")
|
||||
throw error
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
let (responseData, response) = try await session.data(for: request)
|
||||
|
||||
|
||||
print("[APIClient] Response received")
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
print("[APIClient] Status code: \(httpResponse.statusCode)")
|
||||
print("[APIClient] Headers: \(httpResponse.allHeaderFields)")
|
||||
}
|
||||
|
||||
|
||||
if let responseString = String(data: responseData, encoding: .utf8) {
|
||||
print("[APIClient] Response body: \(responseString)")
|
||||
}
|
||||
|
||||
|
||||
try validateResponse(response)
|
||||
|
||||
|
||||
struct CreateResponse: Codable {
|
||||
let sessionId: String
|
||||
}
|
||||
|
||||
|
||||
let createResponse = try decoder.decode(CreateResponse.self, from: responseData)
|
||||
print("[APIClient] Session created with ID: \(createResponse.sessionId)")
|
||||
return createResponse.sessionId
|
||||
|
|
@ -165,57 +166,57 @@ class APIClient: APIClientProtocol {
|
|||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func killSession(_ sessionId: String) async throws {
|
||||
guard let baseURL = baseURL else {
|
||||
guard let baseURL else {
|
||||
throw APIError.noServerConfigured
|
||||
}
|
||||
|
||||
|
||||
let url = baseURL.appendingPathComponent("api/sessions/\(sessionId)")
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "DELETE"
|
||||
addAuthenticationIfNeeded(&request)
|
||||
|
||||
|
||||
let (_, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
}
|
||||
|
||||
|
||||
func cleanupSession(_ sessionId: String) async throws {
|
||||
guard let baseURL = baseURL else {
|
||||
guard let baseURL else {
|
||||
throw APIError.noServerConfigured
|
||||
}
|
||||
|
||||
|
||||
let url = baseURL.appendingPathComponent("api/sessions/\(sessionId)/cleanup")
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "DELETE"
|
||||
addAuthenticationIfNeeded(&request)
|
||||
|
||||
|
||||
let (_, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
}
|
||||
|
||||
|
||||
func cleanupAllExitedSessions() async throws -> [String] {
|
||||
guard let baseURL = baseURL else {
|
||||
guard let baseURL else {
|
||||
throw APIError.noServerConfigured
|
||||
}
|
||||
|
||||
|
||||
let url = baseURL.appendingPathComponent("api/cleanup-exited")
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
addAuthenticationIfNeeded(&request)
|
||||
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
|
||||
// Handle empty response (204 No Content) from Go server
|
||||
if data.isEmpty {
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
struct CleanupResponse: Codable {
|
||||
let cleanedSessions: [String]
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
let cleanupResponse = try decoder.decode(CleanupResponse.self, from: data)
|
||||
return cleanupResponse.cleanedSessions
|
||||
|
|
@ -224,116 +225,121 @@ class APIClient: APIClientProtocol {
|
|||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Terminal I/O
|
||||
|
||||
|
||||
func sendInput(sessionId: String, text: String) async throws {
|
||||
guard let baseURL = baseURL else {
|
||||
guard let baseURL else {
|
||||
throw APIError.noServerConfigured
|
||||
}
|
||||
|
||||
|
||||
let url = baseURL.appendingPathComponent("api/sessions/\(sessionId)/input")
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
addAuthenticationIfNeeded(&request)
|
||||
|
||||
|
||||
let input = TerminalInput(text: text)
|
||||
request.httpBody = try encoder.encode(input)
|
||||
|
||||
|
||||
let (_, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
}
|
||||
|
||||
|
||||
func resizeTerminal(sessionId: String, cols: Int, rows: Int) async throws {
|
||||
guard let baseURL = baseURL else {
|
||||
guard let baseURL else {
|
||||
throw APIError.noServerConfigured
|
||||
}
|
||||
|
||||
|
||||
let url = baseURL.appendingPathComponent("api/sessions/\(sessionId)/resize")
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
addAuthenticationIfNeeded(&request)
|
||||
|
||||
|
||||
let resize = TerminalResize(cols: cols, rows: rows)
|
||||
request.httpBody = try encoder.encode(resize)
|
||||
|
||||
|
||||
let (_, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SSE Stream URL
|
||||
|
||||
|
||||
func streamURL(for sessionId: String) -> URL? {
|
||||
guard let baseURL = baseURL else { return nil }
|
||||
guard let baseURL else { return nil }
|
||||
return baseURL.appendingPathComponent("api/sessions/\(sessionId)/stream")
|
||||
}
|
||||
|
||||
|
||||
func snapshotURL(for sessionId: String) -> URL? {
|
||||
guard let baseURL = baseURL else { return nil }
|
||||
guard let baseURL else { return nil }
|
||||
return baseURL.appendingPathComponent("api/sessions/\(sessionId)/snapshot")
|
||||
}
|
||||
|
||||
|
||||
func getSessionSnapshot(sessionId: String) async throws -> TerminalSnapshot {
|
||||
guard let baseURL = baseURL else {
|
||||
guard let baseURL else {
|
||||
throw APIError.noServerConfigured
|
||||
}
|
||||
|
||||
|
||||
let url = baseURL.appendingPathComponent("api/sessions/\(sessionId)/snapshot")
|
||||
let (data, response) = try await session.data(from: url)
|
||||
|
||||
|
||||
try validateResponse(response)
|
||||
|
||||
|
||||
do {
|
||||
return try decoder.decode(TerminalSnapshot.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
|
||||
private func validateResponse(_ response: URLResponse) throws {
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
print("[APIClient] Invalid response type (not HTTP)")
|
||||
throw APIError.networkError(URLError(.badServerResponse))
|
||||
}
|
||||
|
||||
|
||||
guard 200..<300 ~= httpResponse.statusCode else {
|
||||
print("[APIClient] Server error: HTTP \(httpResponse.statusCode)")
|
||||
throw APIError.serverError(httpResponse.statusCode, nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func addAuthenticationIfNeeded(_ request: inout URLRequest) {
|
||||
// For now, we don't have authentication configured in the iOS app
|
||||
// This is a placeholder for future authentication support
|
||||
// The server might be running without password protection
|
||||
}
|
||||
|
||||
|
||||
// MARK: - File System Operations
|
||||
|
||||
|
||||
func browseDirectory(path: String) async throws -> (absolutePath: String, files: [FileEntry]) {
|
||||
guard let baseURL = baseURL else {
|
||||
guard let baseURL else {
|
||||
throw APIError.noServerConfigured
|
||||
}
|
||||
|
||||
var components = URLComponents(url: baseURL.appendingPathComponent("api/fs/browse"), resolvingAgainstBaseURL: false)!
|
||||
|
||||
guard var components = URLComponents(
|
||||
url: baseURL.appendingPathComponent("api/fs/browse"),
|
||||
resolvingAgainstBaseURL: false
|
||||
) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
components.queryItems = [URLQueryItem(name: "path", value: path)]
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
|
||||
|
||||
// Add authentication header if needed
|
||||
addAuthenticationIfNeeded(&request)
|
||||
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
|
||||
// Log response for debugging
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
print("[APIClient] Browse directory response: \(httpResponse.statusCode)")
|
||||
|
|
@ -343,38 +349,38 @@ class APIClient: APIClientProtocol {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
try validateResponse(response)
|
||||
|
||||
|
||||
// Decode the response which includes absolutePath and files
|
||||
struct BrowseResponse: Codable {
|
||||
let absolutePath: String
|
||||
let files: [FileEntry]
|
||||
}
|
||||
|
||||
|
||||
let browseResponse = try decoder.decode(BrowseResponse.self, from: data)
|
||||
return (absolutePath: browseResponse.absolutePath, files: browseResponse.files)
|
||||
}
|
||||
|
||||
|
||||
func createDirectory(path: String) async throws {
|
||||
guard let baseURL = baseURL else {
|
||||
guard let baseURL else {
|
||||
throw APIError.noServerConfigured
|
||||
}
|
||||
|
||||
|
||||
let url = baseURL.appendingPathComponent("api/mkdir")
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
addAuthenticationIfNeeded(&request)
|
||||
|
||||
|
||||
struct CreateDirectoryRequest: Codable {
|
||||
let path: String
|
||||
}
|
||||
|
||||
|
||||
let requestBody = CreateDirectoryRequest(path: path)
|
||||
request.httpBody = try encoder.encode(requestBody)
|
||||
|
||||
|
||||
let (_, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
// Terminal event types that match the server's output
|
||||
/// Terminal event types that match the server's output
|
||||
enum TerminalWebSocketEvent {
|
||||
case header(width: Int, height: Int)
|
||||
case output(timestamp: Double, data: String)
|
||||
|
|
@ -18,72 +18,73 @@ enum WebSocketError: Error {
|
|||
|
||||
@MainActor
|
||||
class BufferWebSocketClient: NSObject {
|
||||
// Magic byte for binary messages
|
||||
private static let BUFFER_MAGIC_BYTE: UInt8 = 0xbf
|
||||
|
||||
/// Magic byte for binary messages
|
||||
private static let bufferMagicByte: UInt8 = 0xBF
|
||||
|
||||
private var webSocketTask: URLSessionWebSocketTask?
|
||||
private let session = URLSession(configuration: .default)
|
||||
private var subscriptions = [String: ((TerminalWebSocketEvent) -> Void)]()
|
||||
private var subscriptions = [String: (TerminalWebSocketEvent) -> Void]()
|
||||
private var reconnectTimer: Timer?
|
||||
private var reconnectAttempts = 0
|
||||
private var isConnecting = false
|
||||
private var pingTimer: Timer?
|
||||
|
||||
|
||||
// Published events
|
||||
@Published private(set) var isConnected = false
|
||||
@Published private(set) var connectionError: Error?
|
||||
|
||||
|
||||
private var baseURL: URL? {
|
||||
guard let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
|
||||
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config) else {
|
||||
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return serverConfig.baseURL
|
||||
}
|
||||
|
||||
|
||||
func connect() {
|
||||
guard !isConnecting else { return }
|
||||
guard let baseURL = baseURL else {
|
||||
guard let baseURL else {
|
||||
connectionError = WebSocketError.invalidURL
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
isConnecting = true
|
||||
connectionError = nil
|
||||
|
||||
|
||||
// Convert HTTP URL to WebSocket URL
|
||||
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
|
||||
components?.scheme = baseURL.scheme == "https" ? "wss" : "ws"
|
||||
components?.path = "/buffers"
|
||||
|
||||
|
||||
guard let wsURL = components?.url else {
|
||||
connectionError = WebSocketError.invalidURL
|
||||
isConnecting = false
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
print("[BufferWebSocket] Connecting to \(wsURL)")
|
||||
|
||||
|
||||
// Cancel existing task if any
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
|
||||
|
||||
// Create request with authentication
|
||||
var request = URLRequest(url: wsURL)
|
||||
|
||||
|
||||
// Add authentication header if needed
|
||||
if let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
|
||||
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config),
|
||||
let authHeader = serverConfig.authorizationHeader {
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
|
||||
// Create new WebSocket task
|
||||
webSocketTask = session.webSocketTask(with: request)
|
||||
webSocketTask?.resume()
|
||||
|
||||
|
||||
// Start receiving messages
|
||||
receiveMessage()
|
||||
|
||||
|
||||
// Send initial ping to establish connection
|
||||
Task {
|
||||
do {
|
||||
|
|
@ -92,7 +93,7 @@ class BufferWebSocketClient: NSObject {
|
|||
isConnecting = false
|
||||
reconnectAttempts = 0
|
||||
startPingTimer()
|
||||
|
||||
|
||||
// Re-subscribe to all sessions
|
||||
for sessionId in subscriptions.keys {
|
||||
try await subscribe(to: sessionId)
|
||||
|
|
@ -105,18 +106,18 @@ class BufferWebSocketClient: NSObject {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func receiveMessage() {
|
||||
webSocketTask?.receive { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
|
||||
guard let self else { return }
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
Task { @MainActor in
|
||||
self.handleMessage(message)
|
||||
self.receiveMessage() // Continue receiving
|
||||
}
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
print("[BufferWebSocket] Receive error: \(error)")
|
||||
Task { @MainActor in
|
||||
|
|
@ -125,26 +126,27 @@ class BufferWebSocketClient: NSObject {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
|
||||
switch message {
|
||||
case .data(let data):
|
||||
handleBinaryMessage(data)
|
||||
|
||||
|
||||
case .string(let text):
|
||||
handleTextMessage(text)
|
||||
|
||||
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func handleTextMessage(_ text: String) {
|
||||
guard let data = text.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if let type = json["type"] as? String {
|
||||
switch type {
|
||||
case "ping":
|
||||
|
|
@ -152,83 +154,82 @@ class BufferWebSocketClient: NSObject {
|
|||
Task {
|
||||
try? await sendMessage(["type": "pong"])
|
||||
}
|
||||
|
||||
|
||||
case "error":
|
||||
if let message = json["message"] as? String {
|
||||
print("[BufferWebSocket] Server error: \(message)")
|
||||
}
|
||||
|
||||
|
||||
default:
|
||||
print("[BufferWebSocket] Unknown message type: \(type)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func handleBinaryMessage(_ data: Data) {
|
||||
guard data.count > 5 else { return }
|
||||
|
||||
|
||||
var offset = 0
|
||||
|
||||
|
||||
// Check magic byte
|
||||
let magic = data[offset]
|
||||
offset += 1
|
||||
|
||||
guard magic == Self.BUFFER_MAGIC_BYTE else {
|
||||
|
||||
guard magic == Self.bufferMagicByte else {
|
||||
print("[BufferWebSocket] Invalid magic byte: \(magic)")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Read session ID length (4 bytes, little endian)
|
||||
let sessionIdLength = data.withUnsafeBytes { bytes in
|
||||
bytes.loadUnaligned(fromByteOffset: offset, as: UInt32.self).littleEndian
|
||||
}
|
||||
offset += 4
|
||||
|
||||
|
||||
// Read session ID
|
||||
guard data.count >= offset + Int(sessionIdLength) else { return }
|
||||
let sessionIdData = data.subdata(in: offset..<(offset + Int(sessionIdLength)))
|
||||
guard let sessionId = String(data: sessionIdData, encoding: .utf8) else { return }
|
||||
offset += Int(sessionIdLength)
|
||||
|
||||
|
||||
// Remaining data is the message payload
|
||||
let messageData = data.subdata(in: offset..<data.count)
|
||||
|
||||
|
||||
// Decode terminal event
|
||||
if let event = decodeTerminalEvent(from: messageData),
|
||||
let handler = subscriptions[sessionId] {
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func decodeTerminalEvent(from data: Data) -> TerminalWebSocketEvent? {
|
||||
// Decode the JSON payload from the binary message
|
||||
do {
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = json["type"] as? String {
|
||||
|
||||
switch type {
|
||||
case "header":
|
||||
if let width = json["width"] as? Int,
|
||||
let height = json["height"] as? Int {
|
||||
return .header(width: width, height: height)
|
||||
}
|
||||
|
||||
|
||||
case "output":
|
||||
if let timestamp = json["timestamp"] as? Double,
|
||||
let outputData = json["data"] as? String {
|
||||
return .output(timestamp: timestamp, data: outputData)
|
||||
}
|
||||
|
||||
|
||||
case "resize":
|
||||
if let timestamp = json["timestamp"] as? Double,
|
||||
let dimensions = json["dimensions"] as? String {
|
||||
return .resize(timestamp: timestamp, dimensions: dimensions)
|
||||
}
|
||||
|
||||
|
||||
case "exit":
|
||||
let code = json["code"] as? Int ?? 0
|
||||
return .exit(code: code)
|
||||
|
||||
|
||||
default:
|
||||
print("[BufferWebSocket] Unknown message type: \(type)")
|
||||
}
|
||||
|
|
@ -238,74 +239,74 @@ class BufferWebSocketClient: NSObject {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func subscribe(to sessionId: String, handler: @escaping (TerminalWebSocketEvent) -> Void) {
|
||||
subscriptions[sessionId] = handler
|
||||
|
||||
|
||||
Task {
|
||||
try? await subscribe(to: sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func subscribe(to sessionId: String) async throws {
|
||||
try await sendMessage(["type": "subscribe", "sessionId": sessionId])
|
||||
}
|
||||
|
||||
|
||||
func unsubscribe(from sessionId: String) {
|
||||
subscriptions.removeValue(forKey: sessionId)
|
||||
|
||||
|
||||
Task {
|
||||
try? await sendMessage(["type": "unsubscribe", "sessionId": sessionId])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func sendMessage(_ message: [String: Any]) async throws {
|
||||
guard let webSocketTask = webSocketTask else {
|
||||
guard let webSocketTask else {
|
||||
throw WebSocketError.connectionFailed
|
||||
}
|
||||
|
||||
|
||||
let data = try JSONSerialization.data(withJSONObject: message)
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
throw WebSocketError.invalidData
|
||||
}
|
||||
|
||||
|
||||
try await webSocketTask.send(.string(string))
|
||||
}
|
||||
|
||||
|
||||
private func sendPing() async throws {
|
||||
try await sendMessage(["type": "ping"])
|
||||
}
|
||||
|
||||
|
||||
private func startPingTimer() {
|
||||
stopPingTimer()
|
||||
|
||||
|
||||
pingTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { _ in
|
||||
Task { [weak self] in
|
||||
try? await self?.sendPing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func stopPingTimer() {
|
||||
pingTimer?.invalidate()
|
||||
pingTimer = nil
|
||||
}
|
||||
|
||||
|
||||
private func handleDisconnection() {
|
||||
isConnected = false
|
||||
webSocketTask = nil
|
||||
stopPingTimer()
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
|
||||
private func scheduleReconnect() {
|
||||
guard reconnectTimer == nil else { return }
|
||||
|
||||
|
||||
let delay = min(pow(2.0, Double(reconnectAttempts)), 30.0)
|
||||
reconnectAttempts += 1
|
||||
|
||||
|
||||
print("[BufferWebSocket] Reconnecting in \(delay)s (attempt \(reconnectAttempts))")
|
||||
|
||||
|
||||
reconnectTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.reconnectTimer = nil
|
||||
|
|
@ -313,22 +314,22 @@ class BufferWebSocketClient: NSObject {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func disconnect() {
|
||||
reconnectTimer?.invalidate()
|
||||
reconnectTimer = nil
|
||||
stopPingTimer()
|
||||
|
||||
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
webSocketTask = nil
|
||||
|
||||
|
||||
subscriptions.removeAll()
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
// Cancel the WebSocket task
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
// Timers will be cleaned up automatically when the object is deallocated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import Foundation
|
|||
class SessionService {
|
||||
static let shared = SessionService()
|
||||
private let apiClient = APIClient.shared
|
||||
|
||||
|
||||
private init() {}
|
||||
|
||||
|
||||
func getSessions() async throws -> [Session] {
|
||||
return try await apiClient.getSessions()
|
||||
try await apiClient.getSessions()
|
||||
}
|
||||
|
||||
|
||||
func createSession(_ data: SessionCreateData) async throws -> String {
|
||||
do {
|
||||
return try await apiClient.createSession(data)
|
||||
|
|
@ -19,24 +19,24 @@ class SessionService {
|
|||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func killSession(_ sessionId: String) async throws {
|
||||
try await apiClient.killSession(sessionId)
|
||||
}
|
||||
|
||||
|
||||
func cleanupSession(_ sessionId: String) async throws {
|
||||
try await apiClient.cleanupSession(sessionId)
|
||||
}
|
||||
|
||||
|
||||
func cleanupAllExitedSessions() async throws -> [String] {
|
||||
return try await apiClient.cleanupAllExitedSessions()
|
||||
try await apiClient.cleanupAllExitedSessions()
|
||||
}
|
||||
|
||||
|
||||
func sendInput(to sessionId: String, text: String) async throws {
|
||||
try await apiClient.sendInput(sessionId: sessionId, text: text)
|
||||
}
|
||||
|
||||
|
||||
func resizeTerminal(sessionId: String, cols: Int, rows: Int) async throws {
|
||||
try await apiClient.resizeTerminal(sessionId: sessionId, cols: cols, rows: rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,33 @@
|
|||
import SwiftUI
|
||||
|
||||
struct Theme {
|
||||
enum Theme {
|
||||
// MARK: - Colors
|
||||
struct Colors {
|
||||
|
||||
enum Colors {
|
||||
// Terminal-inspired colors
|
||||
static let terminalBackground = Color(hex: "0A0E14")
|
||||
static let terminalForeground = Color(hex: "B3B1AD")
|
||||
static let terminalSelection = Color(hex: "273747")
|
||||
|
||||
|
||||
// Accent colors
|
||||
static let primaryAccent = Color(hex: "39BAE6")
|
||||
static let secondaryAccent = Color(hex: "59C2FF")
|
||||
static let successAccent = Color(hex: "AAD94C")
|
||||
static let warningAccent = Color(hex: "FFB454")
|
||||
static let errorAccent = Color(hex: "F07178")
|
||||
|
||||
|
||||
// UI colors
|
||||
static let cardBackground = Color(hex: "0D1117")
|
||||
static let cardBorder = Color(hex: "1C2128")
|
||||
static let headerBackground = Color(hex: "010409")
|
||||
static let overlayBackground = Color.black.opacity(0.7)
|
||||
|
||||
|
||||
// Additional UI colors for FileBrowser
|
||||
static let terminalAccent = primaryAccent
|
||||
static let terminalGray = Color(hex: "8B949E")
|
||||
static let terminalDarkGray = Color(hex: "161B22")
|
||||
static let terminalWhite = Color.white
|
||||
|
||||
|
||||
// Terminal ANSI colors
|
||||
static let ansiBlack = Color(hex: "01060E")
|
||||
static let ansiRed = Color(hex: "EA6C73")
|
||||
|
|
@ -36,7 +37,7 @@ struct Theme {
|
|||
static let ansiMagenta = Color(hex: "FAE994")
|
||||
static let ansiCyan = Color(hex: "90E1C6")
|
||||
static let ansiWhite = Color(hex: "C7C7C7")
|
||||
|
||||
|
||||
// Bright ANSI colors
|
||||
static let ansiBrightBlack = Color(hex: "686868")
|
||||
static let ansiBrightRed = Color(hex: "F07178")
|
||||
|
|
@ -47,95 +48,100 @@ struct Theme {
|
|||
static let ansiBrightCyan = Color(hex: "95E6CB")
|
||||
static let ansiBrightWhite = Color(hex: "FFFFFF")
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Typography
|
||||
struct Typography {
|
||||
|
||||
enum Typography {
|
||||
static let terminalFont = "SF Mono"
|
||||
static let terminalFontFallback = "Menlo"
|
||||
static let uiFont = "SF Pro Display"
|
||||
|
||||
|
||||
static func terminal(size: CGFloat) -> Font {
|
||||
return Font.custom(terminalFont, size: size)
|
||||
Font.custom(terminalFont, size: size)
|
||||
.monospaced()
|
||||
}
|
||||
|
||||
|
||||
static func terminalSystem(size: CGFloat) -> Font {
|
||||
return Font.system(size: size, design: .monospaced)
|
||||
Font.system(size: size, design: .monospaced)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Spacing
|
||||
struct Spacing {
|
||||
static let xs: CGFloat = 4
|
||||
static let sm: CGFloat = 8
|
||||
static let md: CGFloat = 12
|
||||
static let lg: CGFloat = 16
|
||||
static let xl: CGFloat = 24
|
||||
static let xxl: CGFloat = 32
|
||||
|
||||
enum Spacing {
|
||||
static let extraSmall: CGFloat = 4
|
||||
static let small: CGFloat = 8
|
||||
static let medium: CGFloat = 12
|
||||
static let large: CGFloat = 16
|
||||
static let extraLarge: CGFloat = 24
|
||||
static let extraExtraLarge: CGFloat = 32
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Corner Radius
|
||||
struct CornerRadius {
|
||||
|
||||
enum CornerRadius {
|
||||
static let small: CGFloat = 6
|
||||
static let medium: CGFloat = 10
|
||||
static let large: CGFloat = 16
|
||||
static let card: CGFloat = 12
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Animation
|
||||
struct Animation {
|
||||
|
||||
enum Animation {
|
||||
static let quick = SwiftUI.Animation.easeInOut(duration: 0.2)
|
||||
static let standard = SwiftUI.Animation.easeInOut(duration: 0.3)
|
||||
static let smooth = SwiftUI.Animation.spring(response: 0.4, dampingFraction: 0.8)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Shadows
|
||||
struct Shadows {
|
||||
struct Card {
|
||||
static let color = Color.black.opacity(0.3)
|
||||
static let radius: CGFloat = 8
|
||||
static let x: CGFloat = 0
|
||||
static let y: CGFloat = 2
|
||||
}
|
||||
|
||||
struct Button {
|
||||
static let color = Color.black.opacity(0.2)
|
||||
static let radius: CGFloat = 4
|
||||
static let x: CGFloat = 0
|
||||
static let y: CGFloat = 1
|
||||
}
|
||||
|
||||
enum CardShadow {
|
||||
static let color = Color.black.opacity(0.3)
|
||||
static let radius: CGFloat = 8
|
||||
static let xOffset: CGFloat = 0
|
||||
static let yOffset: CGFloat = 2
|
||||
}
|
||||
|
||||
enum ButtonShadow {
|
||||
static let color = Color.black.opacity(0.2)
|
||||
static let radius: CGFloat = 4
|
||||
static let xOffset: CGFloat = 0
|
||||
static let yOffset: CGFloat = 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Color Extension
|
||||
|
||||
extension Color {
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let a, r, g, b: UInt64
|
||||
let alpha, red, green, blue: UInt64
|
||||
switch hex.count {
|
||||
case 3: // RGB (12-bit)
|
||||
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||
(alpha, red, green, blue) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||
case 6: // RGB (24-bit)
|
||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
(alpha, red, green, blue) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
case 8: // ARGB (32-bit)
|
||||
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||
(alpha, red, green, blue) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(a, r, g, b) = (255, 0, 0, 0)
|
||||
(alpha, red, green, blue) = (255, 0, 0, 0)
|
||||
}
|
||||
|
||||
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double(r) / 255,
|
||||
green: Double(g) / 255,
|
||||
blue: Double(b) / 255,
|
||||
opacity: Double(a) / 255
|
||||
red: Double(red) / 255,
|
||||
green: Double(green) / 255,
|
||||
blue: Double(blue) / 255,
|
||||
opacity: Double(alpha) / 255
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View Modifiers
|
||||
|
||||
extension View {
|
||||
func terminalCard() -> some View {
|
||||
self
|
||||
|
|
@ -145,20 +151,25 @@ extension View {
|
|||
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
|
||||
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.3), radius: 8, x: 0, y: 2)
|
||||
.shadow(
|
||||
color: Theme.CardShadow.color,
|
||||
radius: Theme.CardShadow.radius,
|
||||
x: Theme.CardShadow.xOffset,
|
||||
y: Theme.CardShadow.yOffset
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
func glowEffect(color: Color = Theme.Colors.primaryAccent) -> some View {
|
||||
self
|
||||
.shadow(color: color.opacity(0.5), radius: 10)
|
||||
.shadow(color: color.opacity(0.3), radius: 20)
|
||||
}
|
||||
|
||||
|
||||
func terminalButton() -> some View {
|
||||
self
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
.padding(.horizontal, Theme.Spacing.lg)
|
||||
.padding(.vertical, Theme.Spacing.md)
|
||||
.padding(.horizontal, Theme.Spacing.large)
|
||||
.padding(.vertical, Theme.Spacing.medium)
|
||||
.background(Theme.Colors.primaryAccent.opacity(0.1))
|
||||
.cornerRadius(Theme.CornerRadius.medium)
|
||||
.overlay(
|
||||
|
|
@ -169,20 +180,21 @@ extension View {
|
|||
}
|
||||
|
||||
// MARK: - Haptic Feedback
|
||||
|
||||
@MainActor
|
||||
struct HapticFeedback {
|
||||
static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
|
||||
let generator = UIImpactFeedbackGenerator(style: style)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
|
||||
|
||||
static func selection() {
|
||||
let generator = UISelectionFeedbackGenerator()
|
||||
generator.selectionChanged()
|
||||
}
|
||||
|
||||
|
||||
static func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) {
|
||||
let generator = UINotificationFeedbackGenerator()
|
||||
generator.notificationOccurred(type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ import SwiftUI
|
|||
struct LoadingView: View {
|
||||
let message: String
|
||||
@State private var isAnimating = false
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
VStack(spacing: Theme.Spacing.large) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Theme.Colors.cardBorder, lineWidth: 3)
|
||||
.frame(width: 50, height: 50)
|
||||
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: 0.2)
|
||||
.stroke(Theme.Colors.primaryAccent, lineWidth: 3)
|
||||
|
|
@ -22,7 +22,7 @@ struct LoadingView: View {
|
|||
value: isAnimating
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Text(message)
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
|
|
@ -31,4 +31,4 @@ struct LoadingView: View {
|
|||
isAnimating = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
import SwiftUI
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectionView: View {
|
||||
@Environment(ConnectionManager.self) var connectionManager
|
||||
@State private var viewModel = ConnectionViewModel()
|
||||
@State private var logoScale: CGFloat = 0.8
|
||||
@State private var contentOpacity: Double = 0
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
// Background
|
||||
Theme.Colors.terminalBackground
|
||||
.ignoresSafeArea()
|
||||
|
||||
|
||||
// Content
|
||||
VStack(spacing: Theme.Spacing.xxl) {
|
||||
VStack(spacing: Theme.Spacing.extraExtraLarge) {
|
||||
// Logo and Title
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
VStack(spacing: Theme.Spacing.large) {
|
||||
ZStack {
|
||||
// Glow effect
|
||||
Image(systemName: "terminal.fill")
|
||||
|
|
@ -25,7 +25,7 @@ struct ConnectionView: View {
|
|||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
.blur(radius: 20)
|
||||
.opacity(0.5)
|
||||
|
||||
|
||||
// Main icon
|
||||
Image(systemName: "terminal.fill")
|
||||
.font(.system(size: 80))
|
||||
|
|
@ -38,12 +38,12 @@ struct ConnectionView: View {
|
|||
logoScale = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
|
||||
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))
|
||||
|
|
@ -51,7 +51,7 @@ struct ConnectionView: View {
|
|||
}
|
||||
}
|
||||
.padding(.top, 60)
|
||||
|
||||
|
||||
// Connection Form
|
||||
ServerConfigForm(
|
||||
host: $viewModel.host,
|
||||
|
|
@ -68,7 +68,7 @@ struct ConnectionView: View {
|
|||
contentOpacity = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
|
|
@ -81,7 +81,7 @@ struct ConnectionView: View {
|
|||
viewModel.loadLastConnection()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func connectToServer() {
|
||||
Task {
|
||||
await viewModel.testConnection { config in
|
||||
|
|
@ -100,7 +100,7 @@ class ConnectionViewModel {
|
|||
var password: String = ""
|
||||
var isConnecting: Bool = false
|
||||
var errorMessage: String?
|
||||
|
||||
|
||||
func loadLastConnection() {
|
||||
if let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
|
||||
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config) {
|
||||
|
|
@ -110,30 +110,30 @@ class ConnectionViewModel {
|
|||
self.password = serverConfig.password ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func testConnection(onSuccess: @escaping (ServerConfig) -> Void) async {
|
||||
errorMessage = nil
|
||||
|
||||
|
||||
guard !host.isEmpty else {
|
||||
errorMessage = "Please enter a server address"
|
||||
return
|
||||
}
|
||||
|
||||
guard let portNumber = Int(port), portNumber > 0, portNumber <= 65535 else {
|
||||
|
||||
guard let portNumber = Int(port), portNumber > 0, portNumber <= 65_535 else {
|
||||
errorMessage = "Please enter a valid port number"
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
isConnecting = true
|
||||
|
||||
|
||||
let config = ServerConfig(
|
||||
host: host,
|
||||
port: portNumber,
|
||||
name: name.isEmpty ? nil : name,
|
||||
password: password.isEmpty ? nil : password
|
||||
)
|
||||
|
||||
|
||||
do {
|
||||
// Test connection by fetching sessions
|
||||
let url = config.baseURL.appendingPathComponent("api/sessions")
|
||||
|
|
@ -142,7 +142,7 @@ class ConnectionViewModel {
|
|||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 {
|
||||
onSuccess(config)
|
||||
|
|
@ -152,7 +152,7 @@ class ConnectionViewModel {
|
|||
} catch {
|
||||
errorMessage = "Connection failed: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
|
||||
isConnecting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,24 +8,27 @@ struct ServerConfigForm: View {
|
|||
let isConnecting: Bool
|
||||
let errorMessage: String?
|
||||
let onConnect: () -> Void
|
||||
|
||||
|
||||
@FocusState private var focusedField: Field?
|
||||
@State private var recentServers: [ServerConfig] = []
|
||||
|
||||
|
||||
enum Field {
|
||||
case host, port, name, password
|
||||
case host
|
||||
case port
|
||||
case name
|
||||
case password
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Theme.Spacing.xl) {
|
||||
VStack(spacing: Theme.Spacing.extraLarge) {
|
||||
// Input Fields
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
VStack(spacing: Theme.Spacing.large) {
|
||||
// Host/IP Field
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Label("Server Address", systemImage: "network")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
||||
|
||||
TextField("192.168.1.100 or localhost", text: $host)
|
||||
.textFieldStyle(TerminalTextFieldStyle())
|
||||
.autocapitalization(.none)
|
||||
|
|
@ -36,13 +39,13 @@ struct ServerConfigForm: View {
|
|||
focusedField = .port
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Port Field
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Label("Port", systemImage: "number.circle")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
||||
|
||||
TextField("3000", text: $port)
|
||||
.textFieldStyle(TerminalTextFieldStyle())
|
||||
.keyboardType(.numberPad)
|
||||
|
|
@ -52,13 +55,13 @@ struct ServerConfigForm: View {
|
|||
focusedField = .name
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Name Field (Optional)
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Label("Connection Name (Optional)", systemImage: "tag")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
||||
|
||||
TextField("My Mac", text: $name)
|
||||
.textFieldStyle(TerminalTextFieldStyle())
|
||||
.focused($focusedField, equals: .name)
|
||||
|
|
@ -67,13 +70,13 @@ struct ServerConfigForm: View {
|
|||
focusedField = .password
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Password Field (Optional)
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Label("Password (Optional)", systemImage: "lock")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
||||
|
||||
SecureField("Enter password if required", text: $password)
|
||||
.textFieldStyle(TerminalTextFieldStyle())
|
||||
.focused($focusedField, equals: .password)
|
||||
|
|
@ -85,10 +88,10 @@ struct ServerConfigForm: View {
|
|||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
|
||||
// Error Message
|
||||
if let errorMessage = errorMessage {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
if let errorMessage {
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
Text(errorMessage)
|
||||
|
|
@ -101,14 +104,14 @@ struct ServerConfigForm: View {
|
|||
removal: .scale.combined(with: .opacity)
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
// Connect Button
|
||||
Button(action: {
|
||||
HapticFeedback.impact(.medium)
|
||||
onConnect()
|
||||
}) {
|
||||
}, label: {
|
||||
if isConnecting {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.terminalBackground))
|
||||
.scaleEffect(0.8)
|
||||
|
|
@ -117,7 +120,7 @@ struct ServerConfigForm: View {
|
|||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
Image(systemName: "bolt.fill")
|
||||
Text("Connect")
|
||||
}
|
||||
|
|
@ -125,9 +128,9 @@ struct ServerConfigForm: View {
|
|||
.fontWeight(.semibold)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
})
|
||||
.foregroundColor(isConnecting ? Theme.Colors.terminalForeground : Theme.Colors.primaryAccent)
|
||||
.padding(.vertical, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.medium)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(isConnecting ? Theme.Colors.cardBackground : Theme.Colors.terminalBackground)
|
||||
|
|
@ -141,17 +144,17 @@ struct ServerConfigForm: View {
|
|||
.padding(.horizontal)
|
||||
.scaleEffect(isConnecting ? 0.98 : 1.0)
|
||||
.animation(Theme.Animation.quick, value: isConnecting)
|
||||
|
||||
|
||||
// Recent Servers (if any)
|
||||
if !recentServers.isEmpty {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Text("Recent Connections")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
.padding(.horizontal)
|
||||
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
ForEach(recentServers.prefix(3), id: \.host) { server in
|
||||
Button(action: {
|
||||
host = server.host
|
||||
|
|
@ -159,7 +162,7 @@ struct ServerConfigForm: View {
|
|||
name = server.name ?? ""
|
||||
password = server.password ?? ""
|
||||
HapticFeedback.selection()
|
||||
}) {
|
||||
}, label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(server.displayName)
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
|
|
@ -169,13 +172,13 @@ struct ServerConfigForm: View {
|
|||
.opacity(0.7)
|
||||
}
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.padding(.horizontal, Theme.Spacing.medium)
|
||||
.padding(.vertical, Theme.Spacing.small)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
|
@ -190,7 +193,7 @@ struct ServerConfigForm: View {
|
|||
loadRecentServers()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func loadRecentServers() {
|
||||
// Load recent servers from UserDefaults
|
||||
if let data = UserDefaults.standard.data(forKey: "recentServers"),
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
import SwiftUI
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct FileBrowserView: View {
|
||||
@State private var viewModel = FileBrowserViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
|
||||
let onSelect: (String) -> Void
|
||||
let initialPath: String
|
||||
|
||||
|
||||
init(initialPath: String = "~", onSelect: @escaping (String) -> Void) {
|
||||
self.initialPath = initialPath
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
// Background
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Current path display
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "folder.fill")
|
||||
.foregroundColor(Theme.Colors.terminalAccent)
|
||||
.font(.system(size: 16))
|
||||
|
||||
|
||||
Text(viewModel.currentPath)
|
||||
.font(.custom("SF Mono", size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalGray)
|
||||
|
|
@ -36,7 +36,7 @@ struct FileBrowserView: View {
|
|||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
.background(Theme.Colors.terminalDarkGray)
|
||||
|
||||
|
||||
// File list
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
|
|
@ -52,7 +52,7 @@ struct FileBrowserView: View {
|
|||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
|
||||
// Directories first, then files
|
||||
ForEach(viewModel.sortedEntries) { entry in
|
||||
FileBrowserRow(
|
||||
|
|
@ -77,7 +77,7 @@ struct FileBrowserView: View {
|
|||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.terminalAccent))
|
||||
.scaleEffect(1.2)
|
||||
|
||||
|
||||
Text("Loading...")
|
||||
.font(.custom("SF Mono", size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalGray)
|
||||
|
|
@ -86,11 +86,11 @@ struct FileBrowserView: View {
|
|||
.background(Color.black.opacity(0.8))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Bottom toolbar
|
||||
HStack(spacing: 20) {
|
||||
// Cancel button
|
||||
Button(action: { dismiss() }) {
|
||||
Button(action: { dismiss() }, label: {
|
||||
Text("cancel")
|
||||
.font(.custom("SF Mono", size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalGray)
|
||||
|
|
@ -101,13 +101,13 @@ struct FileBrowserView: View {
|
|||
.stroke(Theme.Colors.terminalGray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
})
|
||||
.buttonStyle(TerminalButtonStyle())
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
// Create folder button
|
||||
Button(action: { viewModel.showCreateFolder = true }) {
|
||||
Button(action: { viewModel.showCreateFolder = true }, label: {
|
||||
Label("new folder", systemImage: "folder.badge.plus")
|
||||
.font(.custom("SF Mono", size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalAccent)
|
||||
|
|
@ -118,14 +118,14 @@ struct FileBrowserView: View {
|
|||
.stroke(Theme.Colors.terminalAccent.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
})
|
||||
.buttonStyle(TerminalButtonStyle())
|
||||
|
||||
|
||||
// Select button
|
||||
Button(action: {
|
||||
Button(action: {
|
||||
onSelect(viewModel.currentPath)
|
||||
dismiss()
|
||||
}) {
|
||||
}, label: {
|
||||
Text("select")
|
||||
.font(.custom("SF Mono", size: 14))
|
||||
.foregroundColor(.black)
|
||||
|
|
@ -141,7 +141,7 @@ struct FileBrowserView: View {
|
|||
.blur(radius: 10)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
})
|
||||
.buttonStyle(TerminalButtonStyle())
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
|
@ -154,11 +154,11 @@ struct FileBrowserView: View {
|
|||
TextField("Folder name", text: $viewModel.newFolderName)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
|
||||
Button("Cancel", role: .cancel) {
|
||||
viewModel.newFolderName = ""
|
||||
}
|
||||
|
||||
|
||||
Button("Create") {
|
||||
viewModel.createFolder()
|
||||
}
|
||||
|
|
@ -167,7 +167,7 @@ struct FileBrowserView: View {
|
|||
Text("Enter a name for the new folder")
|
||||
}
|
||||
.alert("Error", isPresented: $viewModel.showError, presenting: viewModel.errorMessage) { _ in
|
||||
Button("OK") { }
|
||||
Button("OK") {}
|
||||
} message: { error in
|
||||
Text(error)
|
||||
}
|
||||
|
|
@ -186,8 +186,15 @@ struct FileBrowserRow: View {
|
|||
let size: String?
|
||||
let modifiedTime: String?
|
||||
let onTap: () -> Void
|
||||
|
||||
init(name: String, isDirectory: Bool, isParent: Bool = false, size: String? = nil, modifiedTime: String? = nil, onTap: @escaping () -> Void) {
|
||||
|
||||
init(
|
||||
name: String,
|
||||
isDirectory: Bool,
|
||||
isParent: Bool = false,
|
||||
size: String? = nil,
|
||||
modifiedTime: String? = nil,
|
||||
onTap: @escaping () -> Void
|
||||
) {
|
||||
self.name = name
|
||||
self.isDirectory = isDirectory
|
||||
self.isParent = isParent
|
||||
|
|
@ -195,7 +202,7 @@ struct FileBrowserRow: View {
|
|||
self.modifiedTime = modifiedTime
|
||||
self.onTap = onTap
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 12) {
|
||||
|
|
@ -204,33 +211,35 @@ struct FileBrowserRow: View {
|
|||
.foregroundColor(isDirectory ? Theme.Colors.terminalAccent : Theme.Colors.terminalGray.opacity(0.6))
|
||||
.font(.system(size: 16))
|
||||
.frame(width: 24)
|
||||
|
||||
|
||||
// Name
|
||||
Text(name)
|
||||
.font(.custom("SF Mono", size: 14))
|
||||
.foregroundColor(isParent ? Theme.Colors.terminalAccent : (isDirectory ? Theme.Colors.terminalWhite : Theme.Colors.terminalGray))
|
||||
.foregroundColor(isParent ? Theme.Colors
|
||||
.terminalAccent : (isDirectory ? Theme.Colors.terminalWhite : Theme.Colors.terminalGray)
|
||||
)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
// Details
|
||||
if !isParent {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
if let size = size {
|
||||
if let size {
|
||||
Text(size)
|
||||
.font(.custom("SF Mono", size: 11))
|
||||
.foregroundColor(Theme.Colors.terminalGray.opacity(0.6))
|
||||
}
|
||||
|
||||
if let modifiedTime = modifiedTime {
|
||||
|
||||
if let modifiedTime {
|
||||
Text(modifiedTime)
|
||||
.font(.custom("SF Mono", size: 11))
|
||||
.foregroundColor(Theme.Colors.terminalGray.opacity(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Chevron for directories
|
||||
if isDirectory && !isParent {
|
||||
Image(systemName: "chevron.right")
|
||||
|
|
@ -269,9 +278,9 @@ class FileBrowserViewModel {
|
|||
var newFolderName = ""
|
||||
var showError = false
|
||||
var errorMessage: String?
|
||||
|
||||
|
||||
private let apiClient = APIClient.shared
|
||||
|
||||
|
||||
var sortedEntries: [FileEntry] {
|
||||
entries.sorted { entry1, entry2 in
|
||||
// Directories come first
|
||||
|
|
@ -282,22 +291,22 @@ class FileBrowserViewModel {
|
|||
return entry1.name.localizedCaseInsensitiveCompare(entry2.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var canGoUp: Bool {
|
||||
currentPath != "/" && currentPath != "~"
|
||||
}
|
||||
|
||||
|
||||
func loadDirectory(path: String) {
|
||||
Task {
|
||||
await loadDirectoryAsync(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
private func loadDirectoryAsync(path: String) async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
|
||||
do {
|
||||
let result = try await apiClient.browseDirectory(path: path)
|
||||
// Use the absolute path returned by the server
|
||||
|
|
@ -311,26 +320,26 @@ class FileBrowserViewModel {
|
|||
showError = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func navigate(to path: String) {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
loadDirectory(path: path)
|
||||
}
|
||||
|
||||
|
||||
func navigateToParent() {
|
||||
let parentPath = URL(fileURLWithPath: currentPath).deletingLastPathComponent().path
|
||||
navigate(to: parentPath)
|
||||
}
|
||||
|
||||
|
||||
func createFolder() {
|
||||
let folderName = newFolderName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !folderName.isEmpty else { return }
|
||||
|
||||
|
||||
Task {
|
||||
await createFolderAsync(name: folderName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
private func createFolderAsync(name: String) async {
|
||||
do {
|
||||
|
|
@ -353,4 +362,4 @@ class FileBrowserViewModel {
|
|||
FileBrowserView { path in
|
||||
print("Selected path: \(path)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ struct SessionCardView: View {
|
|||
let onTap: () -> Void
|
||||
let onKill: () -> Void
|
||||
let onCleanup: () -> Void
|
||||
|
||||
|
||||
@State private var isPressed = false
|
||||
@State private var terminalSnapshot: TerminalSnapshot?
|
||||
@State private var isLoadingSnapshot = false
|
||||
@State private var isKilling = false
|
||||
@State private var opacity: Double = 1.0
|
||||
@State private var scale: CGFloat = 1.0
|
||||
|
||||
|
||||
private var displayWorkingDir: String {
|
||||
// Convert absolute paths back to ~ notation for display
|
||||
let homePrefix = "/Users/"
|
||||
|
|
@ -23,10 +23,10 @@ struct SessionCardView: View {
|
|||
}
|
||||
return session.workingDir
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
|
||||
// Header with session ID/name and kill button
|
||||
HStack {
|
||||
Text(session.name ?? String(session.id.prefix(8)))
|
||||
|
|
@ -34,9 +34,9 @@ struct SessionCardView: View {
|
|||
.fontWeight(.medium)
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
.lineLimit(1)
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
Button(action: {
|
||||
HapticFeedback.impact(.medium)
|
||||
if session.isRunning {
|
||||
|
|
@ -44,26 +44,26 @@ struct SessionCardView: View {
|
|||
} else {
|
||||
animateCleanup()
|
||||
}
|
||||
}) {
|
||||
}, label: {
|
||||
Text(session.isRunning ? "kill" : "clean")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
.padding(.horizontal, Theme.Spacing.sm)
|
||||
.padding(.horizontal, Theme.Spacing.small)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
|
||||
// Terminal content area showing command and terminal output preview
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.fill(Theme.Colors.terminalBackground)
|
||||
.frame(height: 120)
|
||||
.overlay(
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
if session.isRunning {
|
||||
if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty {
|
||||
// Show terminal output preview
|
||||
|
|
@ -75,7 +75,7 @@ struct SessionCardView: View {
|
|||
.lineLimit(nil)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(Theme.Spacing.sm)
|
||||
.padding(Theme.Spacing.small)
|
||||
} else {
|
||||
// Show command and working directory info as fallback
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
|
|
@ -87,26 +87,28 @@ struct SessionCardView: View {
|
|||
.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)
|
||||
|
||||
|
||||
if isLoadingSnapshot {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
||||
.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.xs)
|
||||
.padding(.top, Theme.Spacing.extraSmall)
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.sm)
|
||||
|
||||
.padding(Theme.Spacing.small)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
|
|
@ -125,7 +127,7 @@ struct SessionCardView: View {
|
|||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.sm)
|
||||
.padding(Theme.Spacing.small)
|
||||
} else {
|
||||
Text("Session exited")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
|
|
@ -135,21 +137,25 @@ struct SessionCardView: View {
|
|||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
// Status bar at bottom
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
// Status indicator
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.3))
|
||||
.fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground
|
||||
.opacity(0.3)
|
||||
)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(session.isRunning ? "running" : "exited")
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
.foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.5))
|
||||
.foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors
|
||||
.terminalForeground.opacity(0.5)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
// PID info
|
||||
if session.isRunning, let pid = session.pid {
|
||||
Text("PID: \(pid)")
|
||||
|
|
@ -162,7 +168,7 @@ struct SessionCardView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.padding(Theme.Spacing.medium)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
|
||||
.fill(Theme.Colors.cardBackground)
|
||||
|
|
@ -200,10 +206,10 @@ struct SessionCardView: View {
|
|||
loadSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func loadSnapshot() {
|
||||
guard terminalSnapshot == nil else { return }
|
||||
|
||||
|
||||
isLoadingSnapshot = true
|
||||
Task {
|
||||
do {
|
||||
|
|
@ -220,16 +226,16 @@ struct SessionCardView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func animateKill() {
|
||||
guard !isKilling else { return }
|
||||
isKilling = true
|
||||
|
||||
|
||||
// Shake animation
|
||||
withAnimation(.linear(duration: 0.05).repeatCount(4, autoreverses: true)) {
|
||||
scale = 0.97
|
||||
}
|
||||
|
||||
|
||||
// Fade out after shake
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
|
|
@ -237,7 +243,7 @@ struct SessionCardView: View {
|
|||
scale = 0.95
|
||||
}
|
||||
onKill()
|
||||
|
||||
|
||||
// Reset after a delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
isKilling = false
|
||||
|
|
@ -248,16 +254,16 @@ struct SessionCardView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func animateCleanup() {
|
||||
// Shrink and fade animation for cleanup
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
scale = 0.8
|
||||
opacity = 0
|
||||
}
|
||||
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
onCleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import SwiftUI
|
||||
|
||||
// Custom text field style for terminal-like appearance
|
||||
/// Custom text field style for terminal-like appearance
|
||||
struct TerminalTextFieldStyle: TextFieldStyle {
|
||||
func _body(configuration: TextField<Self._Label>) -> some View {
|
||||
func makeBody(configuration: TextField<Self._Label>) -> some View {
|
||||
configuration
|
||||
.font(Theme.Typography.terminalSystem(size: 16))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
|
@ -21,60 +21,62 @@ struct TerminalTextFieldStyle: TextFieldStyle {
|
|||
struct SessionCreateView: View {
|
||||
@Binding var isPresented: Bool
|
||||
let onCreated: (String) -> Void
|
||||
|
||||
|
||||
@State private var command = "claude"
|
||||
@State private var workingDirectory = "~"
|
||||
@State private var sessionName = ""
|
||||
@State private var isCreating = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showFileBrowser = false
|
||||
|
||||
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
|
||||
enum Field {
|
||||
case command, workingDir, name
|
||||
case command
|
||||
case workingDir
|
||||
case name
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Theme.Colors.terminalBackground
|
||||
.ignoresSafeArea()
|
||||
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
VStack(spacing: Theme.Spacing.large) {
|
||||
// Configuration Fields
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
VStack(spacing: Theme.Spacing.large) {
|
||||
// Command Field
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Label("Command", systemImage: "terminal")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
||||
|
||||
TextField("zsh", text: $command)
|
||||
.textFieldStyle(TerminalTextFieldStyle())
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($focusedField, equals: .command)
|
||||
}
|
||||
|
||||
|
||||
// Working Directory
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Label("Working Directory", systemImage: "folder")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
TextField("~", text: $workingDirectory)
|
||||
.textFieldStyle(TerminalTextFieldStyle())
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($focusedField, equals: .workingDir)
|
||||
|
||||
|
||||
Button(action: {
|
||||
HapticFeedback.impact(.light)
|
||||
showFileBrowser = true
|
||||
}) {
|
||||
}, label: {
|
||||
Image(systemName: "folder")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
|
@ -87,25 +89,25 @@ struct SessionCreateView: View {
|
|||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(Theme.Colors.cardBorder.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Session Name
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Label("Session Name (Optional)", systemImage: "tag")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
||||
|
||||
TextField("My Session", text: $sessionName)
|
||||
.textFieldStyle(TerminalTextFieldStyle())
|
||||
.focused($focusedField, equals: .name)
|
||||
}
|
||||
|
||||
|
||||
// Error Message
|
||||
if let error = errorMessage {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 14))
|
||||
Text(error)
|
||||
|
|
@ -113,8 +115,8 @@ struct SessionCreateView: View {
|
|||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.foregroundColor(Theme.Colors.errorAccent)
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.padding(.horizontal, Theme.Spacing.medium)
|
||||
.padding(.vertical, Theme.Spacing.small)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
|
|
@ -131,67 +133,75 @@ struct SessionCreateView: View {
|
|||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, Theme.Spacing.lg)
|
||||
|
||||
.padding(.top, Theme.Spacing.large)
|
||||
|
||||
// Quick Directories
|
||||
if focusedField == .workingDir {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Text("COMMON DIRECTORIES")
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||
.tracking(1)
|
||||
.padding(.horizontal)
|
||||
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(commonDirectories, id: \.self) { dir in
|
||||
Button(action: {
|
||||
workingDirectory = dir
|
||||
HapticFeedback.selection()
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "folder.fill")
|
||||
.font(.system(size: 12))
|
||||
Text(dir)
|
||||
.font(Theme.Typography.terminalSystem(size: 13))
|
||||
}
|
||||
.foregroundColor(workingDirectory == dir ? Theme.Colors.terminalBackground : Theme.Colors.terminalForeground)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.fill(workingDirectory == dir ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder.opacity(0.1))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(workingDirectory == dir ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
ForEach(commonDirectories, id: \.self) { dir in
|
||||
Button(action: {
|
||||
workingDirectory = dir
|
||||
HapticFeedback.selection()
|
||||
}, label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "folder.fill")
|
||||
.font(.system(size: 12))
|
||||
Text(dir)
|
||||
.font(Theme.Typography.terminalSystem(size: 13))
|
||||
}
|
||||
.foregroundColor(workingDirectory == dir ? Theme.Colors
|
||||
.terminalBackground : Theme.Colors.terminalForeground
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.fill(workingDirectory == dir ? Theme.Colors
|
||||
.primaryAccent : Theme.Colors.cardBorder.opacity(0.1)
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(
|
||||
workingDirectory == dir ? Theme.Colors.primaryAccent : Theme
|
||||
.Colors.cardBorder.opacity(0.3),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Quick Start Commands
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Text("QUICK START")
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||
.tracking(1)
|
||||
.padding(.horizontal)
|
||||
|
||||
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
], spacing: Theme.Spacing.sm) {
|
||||
], spacing: Theme.Spacing.small) {
|
||||
ForEach(recentCommands, id: \.self) { cmd in
|
||||
Button(action: {
|
||||
command = cmd
|
||||
HapticFeedback.selection()
|
||||
}) {
|
||||
}, label: {
|
||||
HStack {
|
||||
Image(systemName: commandIcon(for: cmd))
|
||||
.font(.system(size: 14))
|
||||
|
|
@ -199,18 +209,26 @@ struct SessionCreateView: View {
|
|||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
Spacer()
|
||||
}
|
||||
.foregroundColor(command == cmd ? Theme.Colors.terminalBackground : Theme.Colors.terminalForeground)
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.foregroundColor(command == cmd ? Theme.Colors.terminalBackground : Theme.Colors
|
||||
.terminalForeground
|
||||
)
|
||||
.padding(.horizontal, Theme.Spacing.medium)
|
||||
.padding(.vertical, Theme.Spacing.small)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.fill(command == cmd ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder.opacity(0.3))
|
||||
.fill(command == cmd ? Theme.Colors.primaryAccent : Theme.Colors
|
||||
.cardBorder.opacity(0.3)
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(command == cmd ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder, lineWidth: 1)
|
||||
.stroke(
|
||||
command == cmd ? Theme.Colors.primaryAccent : Theme.Colors
|
||||
.cardBorder,
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.scaleEffect(command == cmd ? 0.95 : 1.0)
|
||||
.animation(Theme.Animation.quick, value: command == cmd)
|
||||
|
|
@ -218,7 +236,7 @@ struct SessionCreateView: View {
|
|||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
}
|
||||
|
|
@ -231,31 +249,31 @@ struct SessionCreateView: View {
|
|||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.background(Theme.Colors.terminalBackground.opacity(0.5))
|
||||
|
||||
|
||||
// Content
|
||||
HStack {
|
||||
Button(action: {
|
||||
HapticFeedback.impact(.light)
|
||||
isPresented = false
|
||||
}) {
|
||||
}, label: {
|
||||
Text("Cancel")
|
||||
.font(.system(size: 17))
|
||||
.foregroundColor(Theme.Colors.errorAccent)
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
Text("New Session")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
Button(action: {
|
||||
HapticFeedback.impact(.medium)
|
||||
createSession()
|
||||
}) {
|
||||
}, label: {
|
||||
if isCreating {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
||||
|
|
@ -263,9 +281,11 @@ struct SessionCreateView: View {
|
|||
} else {
|
||||
Text("Create")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(command.isEmpty ? Theme.Colors.primaryAccent.opacity(0.5) : Theme.Colors.primaryAccent)
|
||||
.foregroundColor(command.isEmpty ? Theme.Colors.primaryAccent.opacity(0.5) : Theme
|
||||
.Colors.primaryAccent
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.disabled(isCreating || command.isEmpty)
|
||||
}
|
||||
|
|
@ -294,34 +314,34 @@ struct SessionCreateView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var recentCommands: [String] {
|
||||
["claude", "zsh", "bash", "python3", "node", "npm run dev"]
|
||||
}
|
||||
|
||||
|
||||
private var commonDirectories: [String] {
|
||||
["~", "~/Desktop", "~/Documents", "~/Downloads", "~/Projects", "/tmp"]
|
||||
}
|
||||
|
||||
|
||||
private func commandIcon(for command: String) -> String {
|
||||
switch command {
|
||||
case "claude":
|
||||
return "sparkle"
|
||||
"sparkle"
|
||||
case "zsh", "bash":
|
||||
return "terminal"
|
||||
"terminal"
|
||||
case "python3":
|
||||
return "chevron.left.forwardslash.chevron.right"
|
||||
"chevron.left.forwardslash.chevron.right"
|
||||
case "node":
|
||||
return "server.rack"
|
||||
"server.rack"
|
||||
case "npm run dev":
|
||||
return "play.circle"
|
||||
"play.circle"
|
||||
case "irb":
|
||||
return "diamond"
|
||||
"diamond"
|
||||
default:
|
||||
return "terminal"
|
||||
"terminal"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func loadDefaults() {
|
||||
// Load last used values
|
||||
if let lastCommand = UserDefaults.standard.string(forKey: "lastCommand") {
|
||||
|
|
@ -334,15 +354,15 @@ struct SessionCreateView: View {
|
|||
workingDirectory = "~"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func createSession() {
|
||||
isCreating = true
|
||||
errorMessage = nil
|
||||
|
||||
|
||||
// Save preferences
|
||||
UserDefaults.standard.set(command, forKey: "lastCommand")
|
||||
UserDefaults.standard.set(workingDirectory, forKey: "lastWorkingDir")
|
||||
|
||||
|
||||
Task {
|
||||
do {
|
||||
let sessionData = SessionCreateData(
|
||||
|
|
@ -350,7 +370,7 @@ struct SessionCreateView: View {
|
|||
workingDir: workingDirectory.isEmpty ? "~" : workingDirectory,
|
||||
name: sessionName.isEmpty ? nil : sessionName
|
||||
)
|
||||
|
||||
|
||||
// Log the request for debugging
|
||||
print("[SessionCreate] Creating session with data:")
|
||||
print(" Command: \(sessionData.command)")
|
||||
|
|
@ -358,11 +378,11 @@ struct SessionCreateView: View {
|
|||
print(" Name: \(sessionData.name ?? "nil")")
|
||||
print(" Spawn Terminal: \(sessionData.spawn_terminal ?? false)")
|
||||
print(" Cols: \(sessionData.cols ?? 0), Rows: \(sessionData.rows ?? 0)")
|
||||
|
||||
|
||||
let sessionId = try await SessionService.shared.createSession(sessionData)
|
||||
|
||||
|
||||
print("[SessionCreate] Session created successfully with ID: \(sessionId)")
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
onCreated(sessionId)
|
||||
isPresented = false
|
||||
|
|
@ -373,7 +393,7 @@ struct SessionCreateView: View {
|
|||
if let apiError = error as? APIError {
|
||||
print(" API Error: \(apiError)")
|
||||
}
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
isCreating = false
|
||||
|
|
@ -381,4 +401,4 @@ struct SessionCreateView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import SwiftUI
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct SessionListView: View {
|
||||
@Environment(ConnectionManager.self) var connectionManager
|
||||
|
|
@ -8,14 +8,14 @@ struct SessionListView: View {
|
|||
@State private var showingCreateSession = false
|
||||
@State private var selectedSession: Session?
|
||||
@State private var showExitedSessions = true
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
// Background
|
||||
Theme.Colors.terminalBackground
|
||||
.ignoresSafeArea()
|
||||
|
||||
|
||||
if viewModel.isLoading && viewModel.sessions.isEmpty {
|
||||
ProgressView("Loading sessions...")
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
||||
|
|
@ -35,24 +35,24 @@ struct SessionListView: View {
|
|||
Button(action: {
|
||||
HapticFeedback.impact(.medium)
|
||||
connectionManager.disconnect()
|
||||
}) {
|
||||
}, label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "xmark.circle")
|
||||
Text("Disconnect")
|
||||
}
|
||||
.foregroundColor(Theme.Colors.errorAccent)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: {
|
||||
HapticFeedback.impact(.light)
|
||||
showingCreateSession = true
|
||||
}) {
|
||||
}, label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingCreateSession) {
|
||||
|
|
@ -89,58 +89,58 @@ struct SessionListView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: Theme.Spacing.xl) {
|
||||
VStack(spacing: Theme.Spacing.extraLarge) {
|
||||
ZStack {
|
||||
Image(systemName: "terminal")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
.blur(radius: 20)
|
||||
.opacity(0.3)
|
||||
|
||||
|
||||
Image(systemName: "terminal")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
|
||||
VStack(spacing: Theme.Spacing.small) {
|
||||
Text("No Sessions")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
|
||||
Text("Create a new terminal session to get started")
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
|
||||
Button(action: {
|
||||
HapticFeedback.impact(.medium)
|
||||
showingCreateSession = true
|
||||
}) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
}, label: {
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
Image(systemName: "plus.circle")
|
||||
Text("Create Session")
|
||||
}
|
||||
.font(Theme.Typography.terminalSystem(size: 16))
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
})
|
||||
.terminalButton()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
|
||||
private var sessionList: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
VStack(spacing: Theme.Spacing.large) {
|
||||
// Header with session count and kill all button
|
||||
HStack {
|
||||
let runningCount = viewModel.sessions.filter { $0.isRunning }.count
|
||||
let exitedCount = viewModel.sessions.filter { !$0.isRunning }.count
|
||||
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
let runningCount = viewModel.sessions.count(where: { $0.isRunning })
|
||||
let exitedCount = viewModel.sessions.count(where: { !$0.isRunning })
|
||||
|
||||
HStack(spacing: Theme.Spacing.medium) {
|
||||
if runningCount > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Text("Running:")
|
||||
|
|
@ -150,7 +150,7 @@ struct SessionListView: View {
|
|||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if exitedCount > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Text("Exited:")
|
||||
|
|
@ -160,16 +160,16 @@ struct SessionListView: View {
|
|||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if runningCount == 0 && exitedCount == 0 {
|
||||
Text("No Sessions")
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
}
|
||||
}
|
||||
.font(Theme.Typography.terminalSystem(size: 16))
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
// Toggle to show/hide exited sessions
|
||||
if exitedCount > 0 {
|
||||
Button(action: {
|
||||
|
|
@ -177,7 +177,7 @@ struct SessionListView: View {
|
|||
withAnimation(Theme.Animation.smooth) {
|
||||
showExitedSessions.toggle()
|
||||
}
|
||||
}) {
|
||||
}, label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: showExitedSessions ? "eye.slash" : "eye")
|
||||
.font(.caption)
|
||||
|
|
@ -185,31 +185,31 @@ struct SessionListView: View {
|
|||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
}
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
.padding(.horizontal, Theme.Spacing.sm)
|
||||
.padding(.horizontal, Theme.Spacing.small)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.fill(Theme.Colors.terminalForeground.opacity(0.1))
|
||||
)
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
if viewModel.sessions.contains(where: { $0.isRunning }) {
|
||||
|
||||
if viewModel.sessions.contains(where: \.isRunning) {
|
||||
Button(action: {
|
||||
HapticFeedback.impact(.medium)
|
||||
Task {
|
||||
await viewModel.killAllSessions()
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
}, label: {
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
Image(systemName: "stop.circle")
|
||||
Text("Kill All")
|
||||
}
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.errorAccent)
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.padding(.horizontal, Theme.Spacing.medium)
|
||||
.padding(.vertical, Theme.Spacing.small)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.fill(Theme.Colors.errorAccent.opacity(0.1))
|
||||
|
|
@ -218,49 +218,49 @@ struct SessionListView: View {
|
|||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(Theme.Colors.errorAccent.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
|
||||
// Sessions grid
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible(), spacing: Theme.Spacing.md),
|
||||
GridItem(.flexible(), spacing: Theme.Spacing.md)
|
||||
], spacing: Theme.Spacing.md) {
|
||||
// Clean up all button if there are exited sessions
|
||||
if showExitedSessions && viewModel.sessions.contains(where: { !$0.isRunning }) {
|
||||
Button(action: {
|
||||
HapticFeedback.impact(.medium)
|
||||
Task {
|
||||
await viewModel.cleanupAllExited()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
Text("Clean Up All Exited")
|
||||
Spacer()
|
||||
}
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.warningAccent)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
|
||||
.fill(Theme.Colors.warningAccent.opacity(0.1))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
|
||||
.stroke(Theme.Colors.warningAccent.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
GridItem(.flexible(), spacing: Theme.Spacing.medium),
|
||||
GridItem(.flexible(), spacing: Theme.Spacing.medium)
|
||||
], spacing: Theme.Spacing.medium) {
|
||||
// Clean up all button if there are exited sessions
|
||||
if showExitedSessions && viewModel.sessions.contains(where: { !$0.isRunning }) {
|
||||
Button(action: {
|
||||
HapticFeedback.impact(.medium)
|
||||
Task {
|
||||
await viewModel.cleanupAllExited()
|
||||
}
|
||||
}, label: {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
Text("Clean Up All Exited")
|
||||
Spacer()
|
||||
}
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.warningAccent)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
|
||||
.fill(Theme.Colors.warningAccent.opacity(0.1))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
|
||||
.stroke(Theme.Colors.warningAccent.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.transition(.asymmetric(
|
||||
insertion: .scale.combined(with: .opacity),
|
||||
removal: .scale.combined(with: .opacity)
|
||||
))
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.transition(.asymmetric(
|
||||
insertion: .scale.combined(with: .opacity),
|
||||
removal: .scale.combined(with: .opacity)
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
ForEach(viewModel.sessions.filter { showExitedSessions || $0.isRunning }) { session in
|
||||
SessionCardView(session: session) {
|
||||
HapticFeedback.selection()
|
||||
|
|
@ -298,15 +298,15 @@ class SessionListViewModel {
|
|||
var sessions: [Session] = []
|
||||
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
|
||||
|
|
@ -316,27 +316,27 @@ class SessionListViewModel {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func stopAutoRefresh() {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
}
|
||||
|
||||
|
||||
func loadSessions() async {
|
||||
if sessions.isEmpty {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
sessions = try await sessionService.getSessions()
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
|
||||
func killSession(_ sessionId: String) async {
|
||||
do {
|
||||
try await sessionService.killSession(sessionId)
|
||||
|
|
@ -345,7 +345,7 @@ class SessionListViewModel {
|
|||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func cleanupSession(_ sessionId: String) async {
|
||||
do {
|
||||
try await sessionService.cleanupSession(sessionId)
|
||||
|
|
@ -354,7 +354,7 @@ class SessionListViewModel {
|
|||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func cleanupAllExited() async {
|
||||
do {
|
||||
_ = try await sessionService.cleanupAllExitedSessions()
|
||||
|
|
@ -365,9 +365,9 @@ class SessionListViewModel {
|
|||
HapticFeedback.notification(.error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func killAllSessions() async {
|
||||
let runningSessions = sessions.filter { $0.isRunning }
|
||||
let runningSessions = sessions.filter(\.isRunning)
|
||||
for session in runningSessions {
|
||||
do {
|
||||
try await sessionService.killSession(session.id)
|
||||
|
|
@ -377,4 +377,4 @@ class SessionListViewModel {
|
|||
}
|
||||
await loadSessions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import SwiftUI
|
||||
import Observation
|
||||
import SwiftTerm
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct CastPlayerView: View {
|
||||
|
|
@ -11,13 +11,13 @@ struct CastPlayerView: View {
|
|||
@State private var isPlaying = false
|
||||
@State private var currentTime: TimeInterval = 0
|
||||
@State private var playbackSpeed: Double = 1.0
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Theme.Colors.terminalBackground
|
||||
.ignoresSafeArea()
|
||||
|
||||
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.isLoading {
|
||||
loadingView
|
||||
|
|
@ -45,30 +45,30 @@ struct CastPlayerView: View {
|
|||
viewModel.loadCastFile(from: castFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
VStack(spacing: Theme.Spacing.large) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
||||
.scaleEffect(1.5)
|
||||
|
||||
|
||||
Text("Loading recording...")
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
|
||||
private func errorView(_ error: String) -> some View {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
VStack(spacing: Theme.Spacing.large) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(Theme.Colors.errorAccent)
|
||||
|
||||
|
||||
Text("Failed to load recording")
|
||||
.font(.headline)
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
|
||||
Text(error)
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
|
|
@ -77,17 +77,17 @@ struct CastPlayerView: View {
|
|||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
|
||||
private var playerContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Terminal display
|
||||
CastTerminalView(fontSize: $fontSize, viewModel: viewModel)
|
||||
.background(Theme.Colors.terminalBackground)
|
||||
|
||||
|
||||
// Playback controls
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
VStack(spacing: Theme.Spacing.medium) {
|
||||
// Progress bar
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
VStack(spacing: Theme.Spacing.extraSmall) {
|
||||
Slider(value: $currentTime, in: 0...viewModel.duration) { editing in
|
||||
if !editing && isPlaying {
|
||||
// Resume playback from new position
|
||||
|
|
@ -95,7 +95,7 @@ struct CastPlayerView: View {
|
|||
}
|
||||
}
|
||||
.accentColor(Theme.Colors.primaryAccent)
|
||||
|
||||
|
||||
HStack {
|
||||
Text(formatTime(currentTime))
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
|
|
@ -105,9 +105,9 @@ struct CastPlayerView: View {
|
|||
}
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
}
|
||||
|
||||
|
||||
// Control buttons
|
||||
HStack(spacing: Theme.Spacing.xl) {
|
||||
HStack(spacing: Theme.Spacing.extraLarge) {
|
||||
// Speed control
|
||||
Menu {
|
||||
Button("0.5x") { playbackSpeed = 0.5 }
|
||||
|
|
@ -118,21 +118,21 @@ struct CastPlayerView: View {
|
|||
Text("\(playbackSpeed, specifier: "%.1f")x")
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
.padding(.horizontal, Theme.Spacing.sm)
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
.padding(.horizontal, Theme.Spacing.small)
|
||||
.padding(.vertical, Theme.Spacing.extraSmall)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(Theme.Colors.primaryAccent, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Play/Pause
|
||||
Button(action: togglePlayback) {
|
||||
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 44))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
|
||||
|
||||
// Restart
|
||||
Button(action: restart) {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
|
|
@ -150,7 +150,7 @@ struct CastPlayerView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func togglePlayback() {
|
||||
if isPlaying {
|
||||
viewModel.pause()
|
||||
|
|
@ -159,7 +159,7 @@ struct CastPlayerView: View {
|
|||
}
|
||||
isPlaying.toggle()
|
||||
}
|
||||
|
||||
|
||||
private func restart() {
|
||||
viewModel.restart()
|
||||
currentTime = 0
|
||||
|
|
@ -167,7 +167,7 @@ struct CastPlayerView: View {
|
|||
viewModel.play(speed: playbackSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func formatTime(_ seconds: TimeInterval) -> String {
|
||||
let minutes = Int(seconds) / 60
|
||||
let remainingSeconds = Int(seconds) % 60
|
||||
|
|
@ -175,75 +175,74 @@ struct CastPlayerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// Simple terminal view for cast playback
|
||||
/// Simple terminal view for cast playback
|
||||
struct CastTerminalView: UIViewRepresentable {
|
||||
@Binding var fontSize: CGFloat
|
||||
let viewModel: CastPlayerViewModel
|
||||
|
||||
|
||||
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
|
||||
let terminal = SwiftTerm.TerminalView()
|
||||
|
||||
|
||||
terminal.backgroundColor = UIColor(Theme.Colors.terminalBackground)
|
||||
terminal.nativeForegroundColor = UIColor(Theme.Colors.terminalForeground)
|
||||
terminal.nativeBackgroundColor = UIColor(Theme.Colors.terminalBackground)
|
||||
|
||||
|
||||
terminal.allowMouseReporting = false
|
||||
// TODO: Check SwiftTerm API for link detection
|
||||
// terminal.linkRecognizer = .autodetect
|
||||
|
||||
// SwiftTerm doesn't have built-in link detection API
|
||||
// URL detection would need to be implemented manually
|
||||
|
||||
updateFont(terminal, size: fontSize)
|
||||
|
||||
|
||||
// Set initial size from cast file if available
|
||||
if let header = viewModel.header {
|
||||
terminal.resize(cols: Int(header.width), rows: Int(header.height))
|
||||
} else {
|
||||
terminal.resize(cols: 80, rows: 24)
|
||||
}
|
||||
|
||||
|
||||
context.coordinator.terminal = terminal
|
||||
return terminal
|
||||
}
|
||||
|
||||
|
||||
func updateUIView(_ terminal: SwiftTerm.TerminalView, context: Context) {
|
||||
updateFont(terminal, size: fontSize)
|
||||
}
|
||||
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
|
||||
private func updateFont(_ terminal: SwiftTerm.TerminalView, size: CGFloat) {
|
||||
let font: UIFont
|
||||
if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) {
|
||||
font = customFont
|
||||
let font: UIFont = if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) {
|
||||
customFont
|
||||
} else if let fallbackFont = UIFont(name: Theme.Typography.terminalFontFallback, size: size) {
|
||||
font = fallbackFont
|
||||
fallbackFont
|
||||
} else {
|
||||
font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
}
|
||||
terminal.font = font
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
class Coordinator: NSObject {
|
||||
weak var terminal: SwiftTerm.TerminalView?
|
||||
let viewModel: CastPlayerViewModel
|
||||
|
||||
|
||||
init(viewModel: CastPlayerViewModel) {
|
||||
self.viewModel = viewModel
|
||||
super.init()
|
||||
|
||||
|
||||
// Set up terminal output handler
|
||||
viewModel.onTerminalOutput = { [weak self] data in
|
||||
Task { @MainActor in
|
||||
self?.terminal?.feed(text: data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
viewModel.onTerminalClear = { [weak self] in
|
||||
Task { @MainActor in
|
||||
// TODO: Check SwiftTerm API for clearing terminal
|
||||
// For now, we'll feed a clear screen sequence
|
||||
// SwiftTerm uses standard ANSI escape sequences for clearing
|
||||
// This is the correct approach for clearing the terminal
|
||||
self?.terminal?.feed(text: "\u{001B}[2J\u{001B}[H")
|
||||
}
|
||||
}
|
||||
|
|
@ -258,27 +257,27 @@ class CastPlayerViewModel {
|
|||
var errorMessage: String?
|
||||
var currentTime: TimeInterval = 0
|
||||
var isSeeking = false
|
||||
|
||||
|
||||
var player: CastPlayer?
|
||||
var header: CastFile? { player?.header }
|
||||
var duration: TimeInterval { player?.duration ?? 0 }
|
||||
|
||||
|
||||
var onTerminalOutput: ((String) -> Void)?
|
||||
var onTerminalClear: (() -> Void)?
|
||||
|
||||
|
||||
private var playbackTask: Task<Void, Never>?
|
||||
|
||||
|
||||
func loadCastFile(from url: URL) {
|
||||
Task {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
|
||||
|
||||
guard let player = CastPlayer(data: data) else {
|
||||
errorMessage = "Invalid cast file format"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
self.player = player
|
||||
isLoading = false
|
||||
} catch {
|
||||
|
|
@ -287,17 +286,17 @@ class CastPlayerViewModel {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func play(speed: Double = 1.0) {
|
||||
playbackTask?.cancel()
|
||||
|
||||
|
||||
playbackTask = Task {
|
||||
guard let player = player else { return }
|
||||
|
||||
guard let player else { return }
|
||||
|
||||
player.play(from: currentTime, speed: speed) { [weak self] event in
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
|
||||
guard let self else { return }
|
||||
|
||||
switch event.type {
|
||||
case "o":
|
||||
self.onTerminalOutput?(event.data)
|
||||
|
|
@ -307,7 +306,7 @@ class CastPlayerViewModel {
|
|||
default:
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
self.currentTime = event.time
|
||||
}
|
||||
} completion: {
|
||||
|
|
@ -315,30 +314,30 @@ class CastPlayerViewModel {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func pause() {
|
||||
playbackTask?.cancel()
|
||||
}
|
||||
|
||||
|
||||
func seekTo(time: TimeInterval) {
|
||||
isSeeking = true
|
||||
currentTime = time
|
||||
|
||||
|
||||
// Clear terminal and replay up to the seek point
|
||||
onTerminalClear?()
|
||||
|
||||
guard let player = player else { return }
|
||||
|
||||
|
||||
guard let player else { return }
|
||||
|
||||
// Replay all events up to the seek time instantly
|
||||
for event in player.events where event.time <= time {
|
||||
if event.type == "o" {
|
||||
onTerminalOutput?(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
isSeeking = false
|
||||
}
|
||||
|
||||
|
||||
func restart() {
|
||||
playbackTask?.cancel()
|
||||
currentTime = 0
|
||||
|
|
@ -346,33 +345,38 @@ class CastPlayerViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
// Extension to CastPlayer for playback from specific time
|
||||
/// Extension to CastPlayer for playback from specific time
|
||||
extension CastPlayer {
|
||||
func play(from startTime: TimeInterval = 0, speed: Double = 1.0, onEvent: @escaping @Sendable (CastEvent) -> Void, completion: @escaping @Sendable () -> Void) {
|
||||
func play(
|
||||
from startTime: TimeInterval = 0,
|
||||
speed: Double = 1.0,
|
||||
onEvent: @escaping @Sendable (CastEvent) -> Void,
|
||||
completion: @escaping @Sendable () -> Void
|
||||
) {
|
||||
let eventsToPlay = events.filter { $0.time > startTime }
|
||||
Task { @Sendable in
|
||||
var lastEventTime = startTime
|
||||
|
||||
|
||||
for event in eventsToPlay {
|
||||
// Calculate wait time adjusted for playback speed
|
||||
let waitTime = (event.time - lastEventTime) / speed
|
||||
if waitTime > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
|
||||
}
|
||||
|
||||
|
||||
// Check if task was cancelled
|
||||
if Task.isCancelled { break }
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
onEvent(event)
|
||||
}
|
||||
|
||||
|
||||
lastEventTime = event.time
|
||||
}
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@ import SwiftUI
|
|||
struct FontSizeSheet: View {
|
||||
@Binding var fontSize: CGFloat
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
|
||||
let fontSizes: [CGFloat] = [10, 12, 14, 16, 18, 20, 22, 24, 28, 32]
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Font size preview
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
VStack(spacing: Theme.Spacing.large) {
|
||||
Text("Font Size Preview")
|
||||
.font(.caption)
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
|
||||
|
||||
Text("VibeTunnel:~ $ echo 'Hello, World!'")
|
||||
.font(Theme.Typography.terminal(size: fontSize))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
|
@ -28,16 +28,16 @@ struct FontSizeSheet: View {
|
|||
)
|
||||
}
|
||||
.padding()
|
||||
|
||||
|
||||
// Font size slider
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
|
||||
HStack {
|
||||
Text("Size: \(Int(fontSize))pt")
|
||||
.font(.headline)
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
Button("Reset") {
|
||||
withAnimation(Theme.Animation.quick) {
|
||||
fontSize = 14
|
||||
|
|
@ -47,43 +47,53 @@ struct FontSizeSheet: View {
|
|||
.font(.caption)
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
|
||||
|
||||
Slider(value: $fontSize, in: 10...32, step: 1) { _ in
|
||||
HapticFeedback.selection()
|
||||
}
|
||||
.accentColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
.padding()
|
||||
|
||||
|
||||
Divider()
|
||||
.background(Theme.Colors.cardBorder)
|
||||
|
||||
|
||||
// Quick selection grid
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
|
||||
Text("Quick Selection")
|
||||
.font(.caption)
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: Theme.Spacing.sm) {
|
||||
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible()), count: 5),
|
||||
spacing: Theme.Spacing.small
|
||||
) {
|
||||
ForEach(fontSizes, id: \.self) { size in
|
||||
Button(action: {
|
||||
fontSize = size
|
||||
HapticFeedback.impact(.light)
|
||||
}) {
|
||||
}, label: {
|
||||
Text("\(Int(size))")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(fontSize == size ? Theme.Colors.terminalBackground : Theme.Colors.terminalForeground)
|
||||
.foregroundColor(fontSize == size ? Theme.Colors.terminalBackground : Theme.Colors
|
||||
.terminalForeground
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.padding(.vertical, Theme.Spacing.small)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.fill(fontSize == size ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder.opacity(0.3))
|
||||
.fill(fontSize == size ? Theme.Colors.primaryAccent : Theme.Colors
|
||||
.cardBorder.opacity(0.3)
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(fontSize == size ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder, lineWidth: 1)
|
||||
.stroke(
|
||||
fontSize == size ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder,
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.scaleEffect(fontSize == size ? 0.95 : 1.0)
|
||||
.animation(Theme.Animation.quick, value: fontSize == size)
|
||||
|
|
@ -91,7 +101,7 @@ struct FontSizeSheet: View {
|
|||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(Theme.Colors.cardBackground)
|
||||
|
|
@ -108,4 +118,4 @@ struct FontSizeSheet: View {
|
|||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,39 +8,39 @@ struct RecordingExportSheet: View {
|
|||
@State private var isExporting = false
|
||||
@State private var showingShareSheet = false
|
||||
@State private var exportedFileURL: URL?
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: Theme.Spacing.xl) {
|
||||
VStack(spacing: Theme.Spacing.extraLarge) {
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.Colors.primaryAccent.opacity(0.1))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
|
||||
Image(systemName: "record.circle.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
.padding(.top, Theme.Spacing.xl)
|
||||
|
||||
.padding(.top, Theme.Spacing.extraLarge)
|
||||
|
||||
// Info
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
VStack(spacing: Theme.Spacing.small) {
|
||||
Text("Recording Export")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
|
||||
if recorder.isRecording {
|
||||
Text("Recording in progress...")
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
} else if !recorder.events.isEmpty {
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
VStack(spacing: Theme.Spacing.extraSmall) {
|
||||
Text("\(recorder.events.count) events recorded")
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
|
||||
|
||||
if let duration = recorder.events.last?.time {
|
||||
Text("Duration: \(formatDuration(duration))")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
|
|
@ -53,9 +53,9 @@ struct RecordingExportSheet: View {
|
|||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
// Export button
|
||||
if !recorder.events.isEmpty {
|
||||
Button(action: exportRecording) {
|
||||
|
|
@ -64,7 +64,7 @@ struct RecordingExportSheet: View {
|
|||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.terminalBackground))
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
Text("Export as .cast file")
|
||||
}
|
||||
|
|
@ -74,7 +74,7 @@ struct RecordingExportSheet: View {
|
|||
.fontWeight(.medium)
|
||||
.foregroundColor(Theme.Colors.terminalBackground)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.medium)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(Theme.Colors.primaryAccent)
|
||||
|
|
@ -82,7 +82,7 @@ struct RecordingExportSheet: View {
|
|||
.disabled(isExporting)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(Theme.Colors.terminalBackground)
|
||||
|
|
@ -102,19 +102,20 @@ struct RecordingExportSheet: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func exportRecording() {
|
||||
isExporting = true
|
||||
|
||||
|
||||
Task {
|
||||
if let castData = recorder.exportCastFile() {
|
||||
// Create temporary file
|
||||
let fileName = "\(sessionName.replacingOccurrences(of: " ", with: "_"))_\(Date().timeIntervalSince1970).cast"
|
||||
let fileName =
|
||||
"\(sessionName.replacingOccurrences(of: " ", with: "_"))_\(Date().timeIntervalSince1970).cast"
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
|
||||
|
||||
|
||||
do {
|
||||
try castData.write(to: tempURL)
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
exportedFileURL = tempURL
|
||||
isExporting = false
|
||||
|
|
@ -133,7 +134,7 @@ struct RecordingExportSheet: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func formatDuration(_ seconds: TimeInterval) -> String {
|
||||
let minutes = Int(seconds) / 60
|
||||
let remainingSeconds = Int(seconds) % 60
|
||||
|
|
@ -143,11 +144,11 @@ struct RecordingExportSheet: View {
|
|||
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
return controller
|
||||
}
|
||||
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import SwiftUI
|
||||
import SwiftTerm
|
||||
import SwiftUI
|
||||
|
||||
struct TerminalHostingView: UIViewRepresentable {
|
||||
let session: Session
|
||||
|
|
@ -8,45 +8,45 @@ struct TerminalHostingView: UIViewRepresentable {
|
|||
let onResize: (Int, Int) -> Void
|
||||
var viewModel: TerminalViewModel
|
||||
@State private var isAutoScrollEnabled = true
|
||||
|
||||
|
||||
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
|
||||
let terminal = SwiftTerm.TerminalView()
|
||||
|
||||
|
||||
// Configure terminal appearance
|
||||
terminal.backgroundColor = UIColor(Theme.Colors.terminalBackground)
|
||||
terminal.nativeForegroundColor = UIColor(Theme.Colors.terminalForeground)
|
||||
terminal.nativeBackgroundColor = UIColor(Theme.Colors.terminalBackground)
|
||||
|
||||
|
||||
// Set up delegates
|
||||
// SwiftTerm's TerminalView uses terminalDelegate, not delegate
|
||||
terminal.terminalDelegate = context.coordinator
|
||||
|
||||
|
||||
// Configure terminal options
|
||||
terminal.allowMouseReporting = false
|
||||
terminal.optionAsMetaKey = true
|
||||
|
||||
|
||||
// Enable URL detection
|
||||
// TODO: Check SwiftTerm API for link detection
|
||||
// terminal.linkRecognizer = .autodetect
|
||||
|
||||
// SwiftTerm doesn't have built-in link detection API
|
||||
// URL detection would need to be implemented manually
|
||||
|
||||
// Configure font
|
||||
updateFont(terminal, size: fontSize)
|
||||
|
||||
|
||||
// Start with default size
|
||||
let cols = Int(UIScreen.main.bounds.width / 9) // Approximate char width
|
||||
let rows = 24
|
||||
terminal.resize(cols: cols, rows: rows)
|
||||
|
||||
|
||||
return terminal
|
||||
}
|
||||
|
||||
|
||||
func updateUIView(_ terminal: SwiftTerm.TerminalView, context: Context) {
|
||||
updateFont(terminal, size: fontSize)
|
||||
|
||||
|
||||
// Update terminal content from viewModel
|
||||
context.coordinator.terminal = terminal
|
||||
}
|
||||
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(
|
||||
onInput: onInput,
|
||||
|
|
@ -54,108 +54,106 @@ struct TerminalHostingView: UIViewRepresentable {
|
|||
viewModel: viewModel
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private func updateFont(_ terminal: SwiftTerm.TerminalView, size: CGFloat) {
|
||||
let font: UIFont
|
||||
if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) {
|
||||
font = customFont
|
||||
let font: UIFont = if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) {
|
||||
customFont
|
||||
} else if let fallbackFont = UIFont(name: Theme.Typography.terminalFontFallback, size: size) {
|
||||
font = fallbackFont
|
||||
fallbackFont
|
||||
} else {
|
||||
font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
}
|
||||
// SwiftTerm uses the font property directly
|
||||
terminal.font = font
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
class Coordinator: NSObject {
|
||||
let onInput: (String) -> Void
|
||||
let onResize: (Int, Int) -> Void
|
||||
let viewModel: TerminalViewModel
|
||||
weak var terminal: SwiftTerm.TerminalView?
|
||||
|
||||
init(onInput: @escaping (String) -> Void,
|
||||
onResize: @escaping (Int, Int) -> Void,
|
||||
viewModel: TerminalViewModel) {
|
||||
|
||||
init(
|
||||
onInput: @escaping (String) -> Void,
|
||||
onResize: @escaping (Int, Int) -> Void,
|
||||
viewModel: TerminalViewModel
|
||||
) {
|
||||
self.onInput = onInput
|
||||
self.onResize = onResize
|
||||
self.viewModel = viewModel
|
||||
super.init()
|
||||
|
||||
|
||||
// Set the coordinator reference on the viewModel
|
||||
Task { @MainActor in
|
||||
viewModel.terminalCoordinator = self
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func feedData(_ data: String) {
|
||||
Task { @MainActor in
|
||||
guard let terminal = terminal else { return }
|
||||
|
||||
guard let terminal else { return }
|
||||
|
||||
// Store current scroll position before feeding data
|
||||
let wasAtBottom = viewModel.isAutoScrollEnabled
|
||||
|
||||
|
||||
// Feed the output to the terminal
|
||||
terminal.feed(text: data)
|
||||
|
||||
|
||||
// Auto-scroll to bottom if enabled
|
||||
if wasAtBottom {
|
||||
// SwiftTerm automatically scrolls when feeding data at bottom
|
||||
// TODO: Check SwiftTerm API for explicit scrolling if needed
|
||||
// terminal.scrollToBottom()
|
||||
// No explicit API needed for auto-scrolling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - TerminalViewDelegate
|
||||
|
||||
|
||||
func send(source: SwiftTerm.TerminalView, data: ArraySlice<UInt8>) {
|
||||
if let string = String(bytes: data, encoding: .utf8) {
|
||||
onInput(string)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func sizeChanged(source: SwiftTerm.TerminalView, newCols: Int, newRows: Int) {
|
||||
onResize(newCols, newRows)
|
||||
}
|
||||
|
||||
|
||||
func scrolled(source: SwiftTerm.TerminalView, position: Double) {
|
||||
// TODO: Implement scroll position tracking with SwiftTerm API
|
||||
// The current implementation needs to be updated for the actual SwiftTerm API
|
||||
/*
|
||||
// Check if user manually scrolled away from bottom
|
||||
if let terminal = terminal {
|
||||
let buffer = terminal.buffer
|
||||
let totalRows = buffer.lines.count
|
||||
let viewportHeight = terminal.rows
|
||||
let maxScroll = Double(max(0, totalRows - viewportHeight))
|
||||
|
||||
// If user scrolled away from bottom (with some tolerance)
|
||||
let isAtBottom = position >= maxScroll - 5
|
||||
|
||||
Task { @MainActor in
|
||||
if !isAtBottom && viewModel.isAutoScrollEnabled {
|
||||
// User manually scrolled up - disable auto-scroll
|
||||
viewModel.isAutoScrollEnabled = false
|
||||
} else if isAtBottom && !viewModel.isAutoScrollEnabled {
|
||||
// User scrolled back to bottom - re-enable auto-scroll
|
||||
viewModel.isAutoScrollEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
// SwiftTerm doesn't expose detailed scroll position tracking
|
||||
// The position parameter represents the relative scroll position
|
||||
// // Check if user manually scrolled away from bottom
|
||||
// if let terminal = terminal {
|
||||
// let buffer = terminal.buffer
|
||||
// let totalRows = buffer.lines.count
|
||||
// let viewportHeight = terminal.rows
|
||||
// let maxScroll = Double(max(0, totalRows - viewportHeight))
|
||||
//
|
||||
// // If user scrolled away from bottom (with some tolerance)
|
||||
// let isAtBottom = position >= maxScroll - 5
|
||||
//
|
||||
// Task { @MainActor in
|
||||
// if !isAtBottom && viewModel.isAutoScrollEnabled {
|
||||
// // User manually scrolled up - disable auto-scroll
|
||||
// viewModel.isAutoScrollEnabled = false
|
||||
// } else if isAtBottom && !viewModel.isAutoScrollEnabled {
|
||||
// // User scrolled back to bottom - re-enable auto-scroll
|
||||
// viewModel.isAutoScrollEnabled = true
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) {
|
||||
// Handle title change if needed
|
||||
}
|
||||
|
||||
|
||||
func hostCurrentDirectoryUpdate(source: SwiftTerm.TerminalView, directory: String?) {
|
||||
// Handle directory update if needed
|
||||
}
|
||||
|
||||
func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String : String]) {
|
||||
|
||||
func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String: String]) {
|
||||
// Open URL
|
||||
if let url = URL(string: link) {
|
||||
DispatchQueue.main.async {
|
||||
|
|
@ -163,19 +161,19 @@ struct TerminalHostingView: UIViewRepresentable {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func clipboardCopy(source: SwiftTerm.TerminalView, content: Data) {
|
||||
// Handle clipboard copy
|
||||
if let string = String(data: content, encoding: .utf8) {
|
||||
UIPasteboard.general.string = string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func rangeChanged(source: SwiftTerm.TerminalView, startY: Int, endY: Int) {
|
||||
// Handle range change if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add conformance with proper isolation
|
||||
extension TerminalHostingView.Coordinator: @preconcurrency SwiftTerm.TerminalViewDelegate {}
|
||||
/// Add conformance with proper isolation
|
||||
extension TerminalHostingView.Coordinator: @preconcurrency SwiftTerm.TerminalViewDelegate {}
|
||||
|
|
|
|||
|
|
@ -5,34 +5,36 @@ struct TerminalToolbar: View {
|
|||
let onDismissKeyboard: () -> Void
|
||||
let onRawInput: ((String) -> Void)?
|
||||
@State private var showMoreKeys = false
|
||||
|
||||
init(onSpecialKey: @escaping (TerminalInput.SpecialKey) -> Void,
|
||||
onDismissKeyboard: @escaping () -> Void,
|
||||
onRawInput: ((String) -> Void)? = nil) {
|
||||
|
||||
init(
|
||||
onSpecialKey: @escaping (TerminalInput.SpecialKey) -> Void,
|
||||
onDismissKeyboard: @escaping () -> Void,
|
||||
onRawInput: ((String) -> Void)? = nil
|
||||
) {
|
||||
self.onSpecialKey = onSpecialKey
|
||||
self.onDismissKeyboard = onDismissKeyboard
|
||||
self.onRawInput = onRawInput
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
.background(Theme.Colors.cardBorder)
|
||||
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
|
||||
HStack(spacing: Theme.Spacing.extraSmall) {
|
||||
// Tab key
|
||||
ToolbarButton(label: "TAB", systemImage: "arrow.right.to.line.compact") {
|
||||
HapticFeedback.impact(.light)
|
||||
onSpecialKey(.tab)
|
||||
}
|
||||
|
||||
|
||||
// Arrow keys
|
||||
HStack(spacing: 2) {
|
||||
ToolbarButton(label: "←", width: 35) {
|
||||
HapticFeedback.impact(.light)
|
||||
onSpecialKey(.arrowLeft)
|
||||
}
|
||||
|
||||
|
||||
VStack(spacing: 2) {
|
||||
ToolbarButton(label: "↑", width: 35, height: 20) {
|
||||
HapticFeedback.impact(.light)
|
||||
|
|
@ -43,19 +45,19 @@ struct TerminalToolbar: View {
|
|||
onSpecialKey(.arrowDown)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ToolbarButton(label: "→", width: 35) {
|
||||
HapticFeedback.impact(.light)
|
||||
onSpecialKey(.arrowRight)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ESC key
|
||||
ToolbarButton(label: "ESC") {
|
||||
HapticFeedback.impact(.light)
|
||||
onSpecialKey(.escape)
|
||||
}
|
||||
|
||||
|
||||
// More keys toggle
|
||||
ToolbarButton(
|
||||
label: "•••",
|
||||
|
|
@ -66,88 +68,88 @@ struct TerminalToolbar: View {
|
|||
showMoreKeys.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
// Dismiss keyboard
|
||||
ToolbarButton(systemImage: "keyboard.chevron.compact.down") {
|
||||
HapticFeedback.impact(.light)
|
||||
onDismissKeyboard()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.sm)
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
.padding(.horizontal, Theme.Spacing.small)
|
||||
.padding(.vertical, Theme.Spacing.extraSmall)
|
||||
.background(Theme.Colors.cardBackground)
|
||||
|
||||
|
||||
// Extended toolbar
|
||||
if showMoreKeys {
|
||||
Divider()
|
||||
.background(Theme.Colors.cardBorder)
|
||||
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
|
||||
VStack(spacing: Theme.Spacing.extraSmall) {
|
||||
// First row of control keys
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
HStack(spacing: Theme.Spacing.extraSmall) {
|
||||
ToolbarButton(label: "CTRL+A") {
|
||||
HapticFeedback.impact(.medium)
|
||||
onSpecialKey(.ctrlA)
|
||||
}
|
||||
|
||||
|
||||
ToolbarButton(label: "CTRL+C") {
|
||||
HapticFeedback.impact(.medium)
|
||||
onSpecialKey(.ctrlC)
|
||||
}
|
||||
|
||||
|
||||
ToolbarButton(label: "CTRL+D") {
|
||||
HapticFeedback.impact(.medium)
|
||||
onSpecialKey(.ctrlD)
|
||||
}
|
||||
|
||||
|
||||
ToolbarButton(label: "CTRL+E") {
|
||||
HapticFeedback.impact(.medium)
|
||||
onSpecialKey(.ctrlE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Second row of control keys
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
HStack(spacing: Theme.Spacing.extraSmall) {
|
||||
ToolbarButton(label: "CTRL+L") {
|
||||
HapticFeedback.impact(.medium)
|
||||
onSpecialKey(.ctrlL)
|
||||
}
|
||||
|
||||
|
||||
ToolbarButton(label: "CTRL+Z") {
|
||||
HapticFeedback.impact(.medium)
|
||||
onSpecialKey(.ctrlZ)
|
||||
}
|
||||
|
||||
|
||||
ToolbarButton(label: "ENTER") {
|
||||
HapticFeedback.impact(.light)
|
||||
onSpecialKey(.enter)
|
||||
}
|
||||
|
||||
|
||||
ToolbarButton(label: "HOME") {
|
||||
HapticFeedback.impact(.light)
|
||||
// Send Ctrl+A for home
|
||||
onSpecialKey(.ctrlA)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Third row - custom Ctrl key input
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
HStack(spacing: Theme.Spacing.extraSmall) {
|
||||
Text("CTRL +")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
.padding(.leading, Theme.Spacing.sm)
|
||||
|
||||
.padding(.leading, Theme.Spacing.small)
|
||||
|
||||
ForEach(["K", "U", "W", "R", "T"], id: \.self) { letter in
|
||||
ToolbarButton(label: letter, width: 44) {
|
||||
HapticFeedback.impact(.medium)
|
||||
// Send the control character for the letter
|
||||
if let charCode = letter.first?.asciiValue {
|
||||
let controlCharCode = Int(charCode - 64) // A=1, B=2, etc.
|
||||
let controlChar = String(UnicodeScalar(controlCharCode)!)
|
||||
let controlChar = UnicodeScalar(controlCharCode).map(String.init) ?? ""
|
||||
// Use raw input if available, otherwise fall back to sending as text
|
||||
if let onRawInput = onRawInput {
|
||||
if let onRawInput {
|
||||
onRawInput(controlChar)
|
||||
} else {
|
||||
// Fallback - just send Ctrl+C
|
||||
|
|
@ -156,12 +158,12 @@ struct TerminalToolbar: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.sm)
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
.padding(.horizontal, Theme.Spacing.small)
|
||||
.padding(.vertical, Theme.Spacing.extraSmall)
|
||||
.background(Theme.Colors.cardBackground)
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .top).combined(with: .opacity),
|
||||
|
|
@ -180,7 +182,7 @@ struct ToolbarButton: View {
|
|||
let height: CGFloat?
|
||||
let isActive: Bool
|
||||
let action: () -> Void
|
||||
|
||||
|
||||
init(
|
||||
label: String? = nil,
|
||||
systemImage: String? = nil,
|
||||
|
|
@ -196,15 +198,15 @@ struct ToolbarButton: View {
|
|||
self.isActive = isActive
|
||||
self.action = action
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Group {
|
||||
if let label = label {
|
||||
if let label {
|
||||
Text(label)
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.fontWeight(.medium)
|
||||
} else if let systemImage = systemImage {
|
||||
} else if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
|
|
@ -228,4 +230,4 @@ struct ToolbarButton: View {
|
|||
.scaleEffect(isActive ? 0.95 : 1.0)
|
||||
.animation(Theme.Animation.quick, value: isActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import SwiftUI
|
||||
import Observation
|
||||
import SwiftTerm
|
||||
import SwiftUI
|
||||
|
||||
struct TerminalView: View {
|
||||
let session: Session
|
||||
|
|
@ -11,19 +11,19 @@ struct TerminalView: View {
|
|||
@State private var showingRecordingSheet = false
|
||||
@State private var keyboardHeight: CGFloat = 0
|
||||
@FocusState private var isInputFocused: Bool
|
||||
|
||||
|
||||
init(session: Session) {
|
||||
self.session = session
|
||||
self._viewModel = State(initialValue: TerminalViewModel(session: session))
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
// Background
|
||||
Theme.Colors.terminalBackground
|
||||
.ignoresSafeArea()
|
||||
|
||||
|
||||
// Terminal content
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.isConnecting {
|
||||
|
|
@ -47,40 +47,40 @@ struct TerminalView: View {
|
|||
}
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Menu {
|
||||
Button(action: { viewModel.clearTerminal() }) {
|
||||
Button(action: { viewModel.clearTerminal() }, label: {
|
||||
Label("Clear", systemImage: "clear")
|
||||
}
|
||||
|
||||
Button(action: { showingFontSizeSheet = true }) {
|
||||
})
|
||||
|
||||
Button(action: { showingFontSizeSheet = true }, label: {
|
||||
Label("Font Size", systemImage: "textformat.size")
|
||||
}
|
||||
|
||||
Button(action: { viewModel.copyBuffer() }) {
|
||||
})
|
||||
|
||||
Button(action: { viewModel.copyBuffer() }, label: {
|
||||
Label("Copy All", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
if viewModel.castRecorder.isRecording {
|
||||
Button(action: {
|
||||
Button(action: {
|
||||
viewModel.stopRecording()
|
||||
showingRecordingSheet = true
|
||||
}) {
|
||||
}, label: {
|
||||
Label("Stop Recording", systemImage: "stop.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Button(action: { viewModel.startRecording() }) {
|
||||
Button(action: { viewModel.startRecording() }, label: {
|
||||
Label("Start Recording", systemImage: "record.circle")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Button(action: { showingRecordingSheet = true }) {
|
||||
|
||||
Button(action: { showingRecordingSheet = true }, label: {
|
||||
Label("Export Recording", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
})
|
||||
.disabled(viewModel.castRecorder.events.isEmpty)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
|
|
@ -97,7 +97,7 @@ struct TerminalView: View {
|
|||
.toolbar {
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 {
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
HStack(spacing: Theme.Spacing.extraSmall) {
|
||||
Image(systemName: "rectangle.split.3x1")
|
||||
.font(.caption)
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||
|
|
@ -106,22 +106,26 @@ struct TerminalView: View {
|
|||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
// Session status
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.3))
|
||||
.fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground
|
||||
.opacity(0.3)
|
||||
)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(session.isRunning ? "Running" : "Exited")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.5))
|
||||
.foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors
|
||||
.terminalForeground.opacity(0.5)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if let pid = session.pid {
|
||||
Spacer()
|
||||
|
||||
|
||||
Text("PID: \(pid)")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||
|
|
@ -131,7 +135,7 @@ struct TerminalView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Recording indicator
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if viewModel.castRecorder.isRecording {
|
||||
|
|
@ -144,7 +148,10 @@ struct TerminalView: View {
|
|||
.fill(Color.red.opacity(0.3))
|
||||
.frame(width: 16, height: 16)
|
||||
.scaleEffect(viewModel.recordingPulse ? 1.5 : 1.0)
|
||||
.animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: viewModel.recordingPulse)
|
||||
.animation(
|
||||
.easeInOut(duration: 1.0).repeatForever(autoreverses: true),
|
||||
value: viewModel.recordingPulse
|
||||
)
|
||||
)
|
||||
Text("REC")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
|
|
@ -165,7 +172,9 @@ struct TerminalView: View {
|
|||
.onDisappear {
|
||||
viewModel.disconnect()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in
|
||||
.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
|
||||
|
|
@ -178,36 +187,36 @@ struct TerminalView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
VStack(spacing: Theme.Spacing.large) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
||||
.scaleEffect(1.5)
|
||||
|
||||
|
||||
Text("Connecting to session...")
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
|
||||
private func errorView(_ error: String) -> some View {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
VStack(spacing: Theme.Spacing.large) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(Theme.Colors.errorAccent)
|
||||
|
||||
|
||||
Text("Connection Error")
|
||||
.font(.headline)
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
|
||||
Text(error)
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
|
||||
Button("Retry") {
|
||||
viewModel.connect()
|
||||
}
|
||||
|
|
@ -215,7 +224,7 @@ struct TerminalView: View {
|
|||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
|
||||
private var terminalContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Terminal hosting view
|
||||
|
|
@ -235,7 +244,7 @@ struct TerminalView: View {
|
|||
.id(viewModel.terminalViewId)
|
||||
.background(Theme.Colors.terminalBackground)
|
||||
.focused($isInputFocused)
|
||||
|
||||
|
||||
// Keyboard toolbar
|
||||
if keyboardHeight > 0 {
|
||||
TerminalToolbar(
|
||||
|
|
@ -266,51 +275,51 @@ class TerminalViewModel {
|
|||
var terminalRows: Int = 0
|
||||
var isAutoScrollEnabled = true
|
||||
var recordingPulse = false
|
||||
|
||||
|
||||
let session: Session
|
||||
let castRecorder: CastRecorder
|
||||
private var bufferWebSocketClient: BufferWebSocketClient?
|
||||
private var connectionStatusTask: Task<Void, Never>?
|
||||
private var connectionErrorTask: Task<Void, Never>?
|
||||
weak var terminalCoordinator: TerminalHostingView.Coordinator?
|
||||
|
||||
|
||||
init(session: Session) {
|
||||
self.session = session
|
||||
self.castRecorder = CastRecorder(sessionId: session.id, width: 80, height: 24)
|
||||
setupTerminal()
|
||||
}
|
||||
|
||||
|
||||
private func setupTerminal() {
|
||||
// Terminal setup now handled by SimpleTerminalView
|
||||
}
|
||||
|
||||
|
||||
func startRecording() {
|
||||
castRecorder.startRecording()
|
||||
}
|
||||
|
||||
|
||||
func stopRecording() {
|
||||
castRecorder.stopRecording()
|
||||
}
|
||||
|
||||
|
||||
func connect() {
|
||||
isConnecting = true
|
||||
errorMessage = nil
|
||||
|
||||
|
||||
// Create WebSocket client if needed
|
||||
if bufferWebSocketClient == nil {
|
||||
bufferWebSocketClient = BufferWebSocketClient()
|
||||
}
|
||||
|
||||
|
||||
// Connect to WebSocket
|
||||
bufferWebSocketClient?.connect()
|
||||
|
||||
|
||||
// Subscribe to terminal events
|
||||
bufferWebSocketClient?.subscribe(to: session.id) { [weak self] event in
|
||||
Task { @MainActor in
|
||||
self?.handleWebSocketEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Monitor connection status
|
||||
connectionStatusTask?.cancel()
|
||||
connectionStatusTask = Task { [weak self] in
|
||||
|
|
@ -329,7 +338,7 @@ class TerminalViewModel {
|
|||
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Monitor connection errors
|
||||
connectionErrorTask?.cancel()
|
||||
connectionErrorTask = Task { [weak self] in
|
||||
|
|
@ -345,11 +354,11 @@ class TerminalViewModel {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
private func loadSnapshot() async {
|
||||
guard let snapshotURL = APIClient.shared.snapshotURL(for: session.id) else { return }
|
||||
|
||||
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: snapshotURL)
|
||||
if let snapshot = String(data: data, encoding: .utf8) {
|
||||
|
|
@ -360,7 +369,7 @@ class TerminalViewModel {
|
|||
print("Failed to load terminal snapshot: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func disconnect() {
|
||||
connectionStatusTask?.cancel()
|
||||
connectionErrorTask?.cancel()
|
||||
|
|
@ -369,7 +378,7 @@ class TerminalViewModel {
|
|||
bufferWebSocketClient = nil
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
private func handleWebSocketEvent(_ event: TerminalWebSocketEvent) {
|
||||
switch event {
|
||||
|
|
@ -379,13 +388,13 @@ class TerminalViewModel {
|
|||
terminalCols = width
|
||||
terminalRows = height
|
||||
// The terminal will be resized when created
|
||||
|
||||
|
||||
case .output(_, let data):
|
||||
// Feed output data directly to the terminal
|
||||
terminalCoordinator?.feedData(data)
|
||||
// Record output if recording
|
||||
castRecorder.recordOutput(data)
|
||||
|
||||
|
||||
case .resize(_, let dimensions):
|
||||
// Parse dimensions like "120x30"
|
||||
let parts = dimensions.split(separator: "x")
|
||||
|
|
@ -399,7 +408,7 @@ class TerminalViewModel {
|
|||
// Record resize event
|
||||
castRecorder.recordResize(cols: cols, rows: rows)
|
||||
}
|
||||
|
||||
|
||||
case .exit(let code):
|
||||
// Session has exited
|
||||
isConnected = false
|
||||
|
|
@ -412,7 +421,7 @@ class TerminalViewModel {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func sendInput(_ text: String) {
|
||||
Task {
|
||||
do {
|
||||
|
|
@ -422,7 +431,7 @@ class TerminalViewModel {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func resize(cols: Int, rows: Int) {
|
||||
Task {
|
||||
do {
|
||||
|
|
@ -432,15 +441,15 @@ class TerminalViewModel {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func clearTerminal() {
|
||||
// Reset the terminal by recreating it
|
||||
terminalViewId = UUID()
|
||||
HapticFeedback.impact(.medium)
|
||||
}
|
||||
|
||||
|
||||
func copyBuffer() {
|
||||
// Terminal copy is handled by SwiftTerm's built-in functionality
|
||||
HapticFeedback.notification(.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue