lint+format

This commit is contained in:
Peter Steinberger 2025-06-20 11:32:32 +02:00
parent 8a11508d61
commit 610e3c0c43
27 changed files with 1118 additions and 961 deletions

39
ios/.swiftlint.yml Normal file
View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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