mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
feat(ios): Fix iOS build and integrate SwiftTerm for terminal emulation
Major improvements to the iOS VibeTunnel app: SwiftTerm Integration: - Properly integrated SwiftTerm 1.2.5 for terminal emulation - Fixed naming conflicts and API compatibility issues - Added proper actor isolation with @preconcurrency - Terminal data now feeds correctly via SSE streams Session Management: - Fixed session model to match Go server's JSON response format - Added CodingKeys mapping for field name differences (cmdline, cwd, etc.) - Support for "starting" session status - Enhanced date parsing for both ISO8601 and RFC3339 formats Working Directory Fixes: - Changed default working directory from iOS sandbox to server paths - Now uses ~/ as default (matching web frontend) - Added common server directories as quick options - Server expands ~ to actual home directory Terminal Streaming: - Implemented Asciinema cast v2 format parsing - SSE client properly handles output, resize, and exit events - Added terminal snapshot loading for existing sessions - Fixed special key handling with proper ANSI escape sequences UI Improvements: - Updated session list to grid layout (2 columns) - Added session count and "Kill All" functionality - Redesigned session cards with inline kill/clean buttons - Shows command and working directory in session preview Error Handling: - Added comprehensive logging for debugging - Enhanced error messages for better user experience - Detailed API request/response logging - Network error descriptions (connection refused, timeout, etc.) Server Configuration: - Set default server address to 127.0.0.1:4020 - Sessions now created with spawn_terminal: false for PTY mode - Both web and iOS clients can share terminal sessions The iOS app now provides a full terminal experience using SwiftTerm, mirroring the web frontend's functionality with proper server integration.
This commit is contained in:
parent
d68d5e5dae
commit
8ddde1d6c2
14 changed files with 767 additions and 374 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Session: Codable, Identifiable {
|
struct Session: Codable, Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
let command: String
|
let command: String
|
||||||
let workingDir: String
|
let workingDir: String
|
||||||
|
|
@ -8,12 +8,27 @@ struct Session: Codable, Identifiable {
|
||||||
let status: SessionStatus
|
let status: SessionStatus
|
||||||
let exitCode: Int?
|
let exitCode: Int?
|
||||||
let startedAt: String
|
let startedAt: String
|
||||||
let lastModified: String
|
let lastModified: String?
|
||||||
let pid: Int?
|
let pid: Int?
|
||||||
let waiting: Bool?
|
let waiting: Bool?
|
||||||
let width: Int?
|
let width: Int?
|
||||||
let height: Int?
|
let height: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case command = "cmdline"
|
||||||
|
case workingDir = "cwd"
|
||||||
|
case name
|
||||||
|
case status
|
||||||
|
case exitCode = "exit_code"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case lastModified = "last_modified"
|
||||||
|
case pid
|
||||||
|
case waiting
|
||||||
|
case width
|
||||||
|
case height
|
||||||
|
}
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
name ?? command
|
name ?? command
|
||||||
}
|
}
|
||||||
|
|
@ -24,18 +39,41 @@ struct Session: Codable, Identifiable {
|
||||||
|
|
||||||
var formattedStartTime: String {
|
var formattedStartTime: String {
|
||||||
// Parse and format the startedAt string
|
// Parse and format the startedAt string
|
||||||
let formatter = ISO8601DateFormatter()
|
// Try ISO8601 first
|
||||||
if let date = formatter.date(from: startedAt) {
|
let iso8601Formatter = ISO8601DateFormatter()
|
||||||
|
if let date = iso8601Formatter.date(from: startedAt) {
|
||||||
let displayFormatter = DateFormatter()
|
let displayFormatter = DateFormatter()
|
||||||
displayFormatter.dateStyle = .none
|
displayFormatter.dateStyle = .none
|
||||||
displayFormatter.timeStyle = .short
|
displayFormatter.timeStyle = .short
|
||||||
return displayFormatter.string(from: date)
|
return displayFormatter.string(from: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try RFC3339 format (what Go uses)
|
||||||
|
let rfc3339Formatter = DateFormatter()
|
||||||
|
rfc3339Formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
rfc3339Formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX"
|
||||||
|
if let date = rfc3339Formatter.date(from: startedAt) {
|
||||||
|
let displayFormatter = DateFormatter()
|
||||||
|
displayFormatter.dateStyle = .none
|
||||||
|
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) {
|
||||||
|
let displayFormatter = DateFormatter()
|
||||||
|
displayFormatter.dateStyle = .none
|
||||||
|
displayFormatter.timeStyle = .short
|
||||||
|
return displayFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
return startedAt
|
return startedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SessionStatus: String, Codable {
|
enum SessionStatus: String, Codable {
|
||||||
|
case starting
|
||||||
case running
|
case running
|
||||||
case exited
|
case exited
|
||||||
}
|
}
|
||||||
|
|
@ -48,11 +86,11 @@ struct SessionCreateData: Codable {
|
||||||
let cols: Int?
|
let cols: Int?
|
||||||
let rows: Int?
|
let rows: Int?
|
||||||
|
|
||||||
init(command: String = "zsh", workingDir: String, name: String? = nil, cols: Int = 80, rows: Int = 24) {
|
init(command: String = "zsh", workingDir: String, name: String? = nil, spawnTerminal: Bool = false, cols: Int = 120, rows: Int = 30) {
|
||||||
self.command = [command]
|
self.command = [command]
|
||||||
self.workingDir = workingDir
|
self.workingDir = workingDir
|
||||||
self.name = name
|
self.name = name
|
||||||
self.spawn_terminal = false
|
self.spawn_terminal = spawnTerminal
|
||||||
self.cols = cols
|
self.cols = cols
|
||||||
self.rows = rows
|
self.rows = rows
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,51 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct TerminalEvent {
|
enum TerminalEvent {
|
||||||
let timestamp: Double
|
case header(AsciinemaHeader)
|
||||||
let type: EventType
|
case output(timestamp: Double, data: String)
|
||||||
let data: String
|
case resize(timestamp: Double, dimensions: String)
|
||||||
|
case exit(code: Int, sessionId: String)
|
||||||
enum EventType: String {
|
|
||||||
case output = "o"
|
|
||||||
case input = "i"
|
|
||||||
case resize = "r"
|
|
||||||
case marker = "m"
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(from line: String) {
|
init?(from line: String) {
|
||||||
// Parse Asciinema v2 format: [timestamp, "type", "data"]
|
guard let data = line.data(using: .utf8) else { return nil }
|
||||||
guard let data = line.data(using: .utf8),
|
|
||||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
// Try to parse as header first
|
||||||
array.count >= 3,
|
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,
|
||||||
|
exitString == "exit",
|
||||||
|
let exitCode = array[1] as? Int,
|
||||||
|
let sessionId = array[2] as? String {
|
||||||
|
self = .exit(code: exitCode, sessionId: sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse normal events: [timestamp, "type", "data"]
|
||||||
|
guard array.count >= 3,
|
||||||
let timestamp = array[0] as? Double,
|
let timestamp = array[0] as? Double,
|
||||||
let typeString = array[1] as? String,
|
let typeString = array[1] as? String,
|
||||||
let type = EventType(rawValue: typeString),
|
|
||||||
let eventData = array[2] as? String else {
|
let eventData = array[2] as? String else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
self.timestamp = timestamp
|
switch typeString {
|
||||||
self.type = type
|
case "o":
|
||||||
self.data = eventData
|
self = .output(timestamp: timestamp, data: eventData)
|
||||||
|
case "r":
|
||||||
|
self = .resize(timestamp: timestamp, dimensions: eventData)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,18 +61,28 @@ struct TerminalInput: Codable {
|
||||||
let text: String
|
let text: String
|
||||||
|
|
||||||
enum SpecialKey: String {
|
enum SpecialKey: String {
|
||||||
case arrowUp = "arrow_up"
|
// Arrow keys use ANSI escape sequences
|
||||||
case arrowDown = "arrow_down"
|
case arrowUp = "\u{001B}[A"
|
||||||
case arrowLeft = "arrow_left"
|
case arrowDown = "\u{001B}[B"
|
||||||
case arrowRight = "arrow_right"
|
case arrowRight = "\u{001B}[C"
|
||||||
case escape = "escape"
|
case arrowLeft = "\u{001B}[D"
|
||||||
case enter = "enter"
|
|
||||||
case ctrlEnter = "ctrl_enter"
|
// Special keys
|
||||||
case shiftEnter = "shift_enter"
|
case escape = "\u{001B}"
|
||||||
|
case enter = "\r"
|
||||||
case tab = "\t"
|
case tab = "\t"
|
||||||
|
|
||||||
|
// Control keys
|
||||||
case ctrlC = "\u{0003}"
|
case ctrlC = "\u{0003}"
|
||||||
case ctrlD = "\u{0004}"
|
case ctrlD = "\u{0004}"
|
||||||
case ctrlZ = "\u{001A}"
|
case ctrlZ = "\u{001A}"
|
||||||
|
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) {
|
init(specialKey: SpecialKey) {
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,44 @@ enum APIError: LocalizedError {
|
||||||
case .decodingError(let error):
|
case .decodingError(let error):
|
||||||
return "Failed to decode response: \(error.localizedDescription)"
|
return "Failed to decode response: \(error.localizedDescription)"
|
||||||
case .serverError(let code, let message):
|
case .serverError(let code, let message):
|
||||||
return message ?? "Server error: \(code)"
|
if let message = message {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
switch code {
|
||||||
|
case 400:
|
||||||
|
return "Bad request - check your input"
|
||||||
|
case 401:
|
||||||
|
return "Unauthorized - authentication required"
|
||||||
|
case 403:
|
||||||
|
return "Forbidden - access denied"
|
||||||
|
case 404:
|
||||||
|
return "Not found - endpoint doesn't exist"
|
||||||
|
case 500:
|
||||||
|
return "Server error - internal server error"
|
||||||
|
case 502:
|
||||||
|
return "Bad gateway - server is down"
|
||||||
|
case 503:
|
||||||
|
return "Service unavailable"
|
||||||
|
default:
|
||||||
|
return "Server error: \(code)"
|
||||||
|
}
|
||||||
case .networkError(let error):
|
case .networkError(let error):
|
||||||
|
if let urlError = error as? URLError {
|
||||||
|
switch urlError.code {
|
||||||
|
case .notConnectedToInternet:
|
||||||
|
return "No internet connection"
|
||||||
|
case .cannotFindHost:
|
||||||
|
return "Cannot find server - check the address"
|
||||||
|
case .cannotConnectToHost:
|
||||||
|
return "Cannot connect to server - is it running?"
|
||||||
|
case .timedOut:
|
||||||
|
return "Connection timed out"
|
||||||
|
case .networkConnectionLost:
|
||||||
|
return "Network connection lost"
|
||||||
|
default:
|
||||||
|
return urlError.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
return error.localizedDescription
|
return error.localizedDescription
|
||||||
case .noServerConfigured:
|
case .noServerConfigured:
|
||||||
return "No server configured"
|
return "No server configured"
|
||||||
|
|
@ -36,6 +72,7 @@ protocol APIClientProtocol {
|
||||||
func resizeTerminal(sessionId: String, cols: Int, rows: Int) async throws
|
func resizeTerminal(sessionId: String, cols: Int, rows: Int) async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class APIClient: APIClientProtocol {
|
class APIClient: APIClientProtocol {
|
||||||
static let shared = APIClient()
|
static let shared = APIClient()
|
||||||
private let session = URLSession.shared
|
private let session = URLSession.shared
|
||||||
|
|
@ -73,24 +110,56 @@ class APIClient: APIClientProtocol {
|
||||||
|
|
||||||
func createSession(_ data: SessionCreateData) async throws -> String {
|
func createSession(_ data: SessionCreateData) async throws -> String {
|
||||||
guard let baseURL = baseURL else {
|
guard let baseURL = baseURL else {
|
||||||
|
print("[APIClient] No server configured")
|
||||||
throw APIError.noServerConfigured
|
throw APIError.noServerConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = baseURL.appendingPathComponent("api/sessions")
|
let url = baseURL.appendingPathComponent("api/sessions")
|
||||||
|
print("[APIClient] Creating session at URL: \(url)")
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
request.httpBody = try encoder.encode(data)
|
|
||||||
|
|
||||||
let (responseData, response) = try await session.data(for: request)
|
do {
|
||||||
try validateResponse(response)
|
request.httpBody = try encoder.encode(data)
|
||||||
|
if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) {
|
||||||
struct CreateResponse: Codable {
|
print("[APIClient] Request body: \(bodyString)")
|
||||||
let sessionId: String
|
}
|
||||||
|
} catch {
|
||||||
|
print("[APIClient] Failed to encode session data: \(error)")
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
let createResponse = try decoder.decode(CreateResponse.self, from: responseData)
|
do {
|
||||||
return createResponse.sessionId
|
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
|
||||||
|
} catch {
|
||||||
|
print("[APIClient] Request failed: \(error)")
|
||||||
|
if let urlError = error as? URLError {
|
||||||
|
print("[APIClient] URL Error code: \(urlError.code), description: \(urlError.localizedDescription)")
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func killSession(_ sessionId: String) async throws {
|
func killSession(_ sessionId: String) async throws {
|
||||||
|
|
@ -131,12 +200,22 @@ class APIClient: APIClientProtocol {
|
||||||
let (data, response) = try await session.data(for: request)
|
let (data, response) = try await session.data(for: request)
|
||||||
try validateResponse(response)
|
try validateResponse(response)
|
||||||
|
|
||||||
|
// Handle empty response (204 No Content) from Go server
|
||||||
|
if data.isEmpty {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
struct CleanupResponse: Codable {
|
struct CleanupResponse: Codable {
|
||||||
let cleanedSessions: [String]
|
let cleanedSessions: [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
let cleanupResponse = try decoder.decode(CleanupResponse.self, from: data)
|
do {
|
||||||
return cleanupResponse.cleanedSessions
|
let cleanupResponse = try decoder.decode(CleanupResponse.self, from: data)
|
||||||
|
return cleanupResponse.cleanedSessions
|
||||||
|
} catch {
|
||||||
|
// If decoding fails, return empty array
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Terminal I/O
|
// MARK: - Terminal I/O
|
||||||
|
|
@ -182,14 +261,21 @@ class APIClient: APIClientProtocol {
|
||||||
return baseURL.appendingPathComponent("api/sessions/\(sessionId)/stream")
|
return baseURL.appendingPathComponent("api/sessions/\(sessionId)/stream")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func snapshotURL(for sessionId: String) -> URL? {
|
||||||
|
guard let baseURL = baseURL else { return nil }
|
||||||
|
return baseURL.appendingPathComponent("api/sessions/\(sessionId)/snapshot")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func validateResponse(_ response: URLResponse) throws {
|
private func validateResponse(_ response: URLResponse) throws {
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
print("[APIClient] Invalid response type (not HTTP)")
|
||||||
throw APIError.networkError(URLError(.badServerResponse))
|
throw APIError.networkError(URLError(.badServerResponse))
|
||||||
}
|
}
|
||||||
|
|
||||||
guard 200..<300 ~= httpResponse.statusCode else {
|
guard 200..<300 ~= httpResponse.statusCode else {
|
||||||
|
print("[APIClient] Server error: HTTP \(httpResponse.statusCode)")
|
||||||
throw APIError.serverError(httpResponse.statusCode, nil)
|
throw APIError.serverError(httpResponse.statusCode, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class SSEClient: NSObject {
|
final class SSEClient: NSObject, @unchecked Sendable {
|
||||||
private var eventSource: URLSessionDataTask?
|
private var eventSource: URLSessionDataTask?
|
||||||
private var session: URLSession?
|
private var session: URLSession?
|
||||||
private var streamContinuation: AsyncStream<TerminalEvent>.Continuation?
|
private var streamContinuation: AsyncStream<TerminalEvent>.Continuation?
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class SessionService {
|
class SessionService {
|
||||||
static let shared = SessionService()
|
static let shared = SessionService()
|
||||||
private let apiClient = APIClient.shared
|
private let apiClient = APIClient.shared
|
||||||
|
|
@ -11,7 +12,12 @@ class SessionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func createSession(_ data: SessionCreateData) async throws -> String {
|
func createSession(_ data: SessionCreateData) async throws -> String {
|
||||||
return try await apiClient.createSession(data)
|
do {
|
||||||
|
return try await apiClient.createSession(data)
|
||||||
|
} catch {
|
||||||
|
print("[SessionService] Failed to create session: \(error)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func killSession(_ sessionId: String) async throws {
|
func killSession(_ sessionId: String) async throws {
|
||||||
|
|
|
||||||
|
|
@ -84,20 +84,20 @@ struct Theme {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shadows
|
// MARK: - Shadows
|
||||||
struct Shadow {
|
struct Shadows {
|
||||||
static let card = SwiftUI.Shadow(
|
struct Card {
|
||||||
color: Color.black.opacity(0.3),
|
static let color = Color.black.opacity(0.3)
|
||||||
radius: 8,
|
static let radius: CGFloat = 8
|
||||||
x: 0,
|
static let x: CGFloat = 0
|
||||||
y: 2
|
static let y: CGFloat = 2
|
||||||
)
|
}
|
||||||
|
|
||||||
static let button = SwiftUI.Shadow(
|
struct Button {
|
||||||
color: Color.black.opacity(0.2),
|
static let color = Color.black.opacity(0.2)
|
||||||
radius: 4,
|
static let radius: CGFloat = 4
|
||||||
x: 0,
|
static let x: CGFloat = 0
|
||||||
y: 1
|
static let y: CGFloat = 1
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,6 +163,7 @@ extension View {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Haptic Feedback
|
// MARK: - Haptic Feedback
|
||||||
|
@MainActor
|
||||||
struct HapticFeedback {
|
struct HapticFeedback {
|
||||||
static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
|
static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
|
||||||
let generator = UIImpactFeedbackGenerator(style: style)
|
let generator = UIImpactFeedbackGenerator(style: style)
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,8 @@ struct ConnectionView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ConnectionViewModel: ObservableObject {
|
class ConnectionViewModel: ObservableObject {
|
||||||
@Published var host: String = ""
|
@Published var host: String = "127.0.0.1"
|
||||||
@Published var port: String = "3000"
|
@Published var port: String = "4020"
|
||||||
@Published var name: String = ""
|
@Published var name: String = ""
|
||||||
@Published var isConnecting: Bool = false
|
@Published var isConnecting: Bool = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ struct ServerConfigForm: View {
|
||||||
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: Theme.Spacing.sm) {
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
ForEach(recentServers.prefix(3), id: \Self.host) { server in
|
ForEach(recentServers.prefix(3), id: \.host) { server in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
host = server.host
|
host = server.host
|
||||||
port = String(server.port)
|
port = String(server.port)
|
||||||
|
|
|
||||||
|
|
@ -8,80 +8,120 @@ struct SessionCardView: View {
|
||||||
|
|
||||||
@State private var isPressed = false
|
@State private var isPressed = false
|
||||||
|
|
||||||
|
private var displayWorkingDir: String {
|
||||||
|
// Convert absolute paths back to ~ notation for display
|
||||||
|
let homePrefix = "/Users/"
|
||||||
|
if session.workingDir.hasPrefix(homePrefix),
|
||||||
|
let userEndIndex = session.workingDir[homePrefix.endIndex...].firstIndex(of: "/") {
|
||||||
|
let restOfPath = String(session.workingDir[userEndIndex...])
|
||||||
|
return "~\(restOfPath)"
|
||||||
|
}
|
||||||
|
return session.workingDir
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onTap) {
|
Button(action: onTap) {
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||||
// Header
|
// Header with session ID/name and kill button
|
||||||
HStack {
|
HStack {
|
||||||
HStack(spacing: Theme.Spacing.sm) {
|
Text(session.name ?? String(session.id.prefix(8)))
|
||||||
Image(systemName: "terminal")
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
.font(.system(size: 16))
|
.fontWeight(.medium)
|
||||||
.foregroundColor(session.isRunning ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground.opacity(0.5))
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
|
||||||
Text(session.displayName)
|
|
||||||
.font(Theme.Typography.terminalSystem(size: 16))
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(Theme.Colors.terminalForeground)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
statusBadge
|
|
||||||
}
|
|
||||||
|
|
||||||
// Working Directory
|
|
||||||
HStack(spacing: Theme.Spacing.sm) {
|
|
||||||
Image(systemName: "folder")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(Theme.Colors.primaryAccent.opacity(0.7))
|
|
||||||
|
|
||||||
Text(session.workingDir)
|
|
||||||
.font(Theme.Typography.terminalSystem(size: 12))
|
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
HapticFeedback.impact(.medium)
|
||||||
|
if session.isRunning {
|
||||||
|
onKill()
|
||||||
|
} else {
|
||||||
|
onCleanup()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(session.isRunning ? "kill" : "clean")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
.padding(.horizontal, Theme.Spacing.sm)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||||
|
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info Row
|
// Terminal content area showing command and working directory
|
||||||
HStack(spacing: Theme.Spacing.lg) {
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||||
if let pid = session.pid {
|
.fill(Theme.Colors.terminalBackground)
|
||||||
HStack(spacing: 4) {
|
.frame(height: 120)
|
||||||
Text("PID")
|
.overlay(
|
||||||
.font(Theme.Typography.terminalSystem(size: 10))
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
if session.isRunning {
|
||||||
Text(String(pid))
|
// Show command and working directory info
|
||||||
.font(Theme.Typography.terminalSystem(size: 10))
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
.foregroundColor(Theme.Colors.successAccent)
|
HStack(spacing: 4) {
|
||||||
}
|
Text("$")
|
||||||
}
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
Text(session.command)
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
}
|
||||||
|
|
||||||
if let exitCode = session.exitCode {
|
Text(displayWorkingDir)
|
||||||
HStack(spacing: 4) {
|
.font(Theme.Typography.terminalSystem(size: 10))
|
||||||
Text("EXIT")
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
|
||||||
.font(Theme.Typography.terminalSystem(size: 10))
|
.lineLimit(1)
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
}
|
||||||
Text(String(exitCode))
|
.padding(Theme.Spacing.sm)
|
||||||
.font(Theme.Typography.terminalSystem(size: 10))
|
|
||||||
.foregroundColor(exitCode == 0 ? Theme.Colors.successAccent : Theme.Colors.errorAccent)
|
Spacer()
|
||||||
|
} else {
|
||||||
|
Text("Session exited")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status bar at bottom
|
||||||
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
|
// Status indicator
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.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))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text(session.formattedStartTime)
|
// PID info
|
||||||
.font(Theme.Typography.terminalSystem(size: 10))
|
if session.isRunning, let pid = session.pid {
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
Text("PID: \(pid)")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 10))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||||
|
.onTapGesture {
|
||||||
|
UIPasteboard.general.string = String(pid)
|
||||||
|
HapticFeedback.notification(.success)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(Theme.Spacing.lg)
|
.padding(Theme.Spacing.md)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
|
||||||
.fill(Theme.Colors.cardBackground)
|
.fill(Theme.Colors.cardBackground)
|
||||||
.shadow(color: Color.black.opacity(0.2), radius: isPressed ? 2 : 6, y: isPressed ? 1 : 3)
|
|
||||||
)
|
)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
|
||||||
.stroke(session.isRunning ? Theme.Colors.primaryAccent.opacity(0.3) : Theme.Colors.cardBorder, lineWidth: 1)
|
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
|
||||||
)
|
)
|
||||||
.scaleEffect(isPressed ? 0.98 : 1.0)
|
.scaleEffect(isPressed ? 0.98 : 1.0)
|
||||||
}
|
}
|
||||||
|
|
@ -108,35 +148,4 @@ struct SessionCardView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusBadge: some View {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Circle()
|
|
||||||
.fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.3))
|
|
||||||
.frame(width: 8, height: 8)
|
|
||||||
.overlay(
|
|
||||||
Circle()
|
|
||||||
.fill(session.isRunning ? Theme.Colors.successAccent : .clear)
|
|
||||||
.frame(width: 16, height: 16)
|
|
||||||
.blur(radius: 6)
|
|
||||||
.opacity(session.isRunning ? 0.5 : 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(session.status.rawValue.uppercased())
|
|
||||||
.font(Theme.Typography.terminalSystem(size: 10))
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.5))
|
|
||||||
.tracking(1)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, Theme.Spacing.sm)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(
|
|
||||||
Capsule()
|
|
||||||
.fill(session.isRunning ? Theme.Colors.successAccent.opacity(0.1) : Theme.Colors.cardBorder.opacity(0.3))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
Capsule()
|
|
||||||
.stroke(session.isRunning ? Theme.Colors.successAccent.opacity(0.3) : Theme.Colors.cardBorder, lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ struct SessionCreateView: View {
|
||||||
let onCreated: (String) -> Void
|
let onCreated: (String) -> Void
|
||||||
|
|
||||||
@State private var command = "zsh"
|
@State private var command = "zsh"
|
||||||
@State private var workingDirectory = ""
|
@State private var workingDirectory = "~/"
|
||||||
@State private var sessionName = ""
|
@State private var sessionName = ""
|
||||||
@State private var isCreating = false
|
@State private var isCreating = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
|
@ -59,7 +59,7 @@ struct SessionCreateView: View {
|
||||||
.font(Theme.Typography.terminalSystem(size: 12))
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
|
||||||
TextField(NSHomeDirectory(), text: $workingDirectory)
|
TextField("~/", text: $workingDirectory)
|
||||||
.textFieldStyle(TerminalTextFieldStyle())
|
.textFieldStyle(TerminalTextFieldStyle())
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
|
|
@ -94,6 +94,52 @@ struct SessionCreateView: View {
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Quick Directories
|
||||||
|
if focusedField == .workingDir {
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||||
|
Text("COMMON DIRECTORIES")
|
||||||
|
.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) {
|
||||||
|
ForEach(commonDirectories, id: \.self) { dir in
|
||||||
|
Button(action: {
|
||||||
|
workingDirectory = dir
|
||||||
|
HapticFeedback.selection()
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "folder")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
Text(dir)
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.foregroundColor(workingDirectory == dir ? Theme.Colors.terminalBackground : Theme.Colors.terminalForeground)
|
||||||
|
.padding(.horizontal, Theme.Spacing.md)
|
||||||
|
.padding(.vertical, Theme.Spacing.sm)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||||
|
.fill(workingDirectory == dir ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder.opacity(0.3))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||||
|
.stroke(workingDirectory == dir ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.scaleEffect(workingDirectory == dir ? 0.95 : 1.0)
|
||||||
|
.animation(Theme.Animation.quick, value: workingDirectory == dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Quick Start Commands
|
// Quick Start Commands
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||||
Text("QUICK START")
|
Text("QUICK START")
|
||||||
|
|
@ -190,6 +236,10 @@ struct SessionCreateView: View {
|
||||||
["zsh", "bash", "python3", "node", "npm run dev", "irb"]
|
["zsh", "bash", "python3", "node", "npm run dev", "irb"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var commonDirectories: [String] {
|
||||||
|
["~/", "~/Desktop", "~/Documents", "~/Downloads", "~/Projects", "/tmp"]
|
||||||
|
}
|
||||||
|
|
||||||
private func commandIcon(for command: String) -> String {
|
private func commandIcon(for command: String) -> String {
|
||||||
switch command {
|
switch command {
|
||||||
case "zsh", "bash":
|
case "zsh", "bash":
|
||||||
|
|
@ -215,7 +265,8 @@ struct SessionCreateView: View {
|
||||||
if let lastDir = UserDefaults.standard.string(forKey: "lastWorkingDir") {
|
if let lastDir = UserDefaults.standard.string(forKey: "lastWorkingDir") {
|
||||||
workingDirectory = lastDir
|
workingDirectory = lastDir
|
||||||
} else {
|
} else {
|
||||||
workingDirectory = NSHomeDirectory()
|
// Default to home directory on the server
|
||||||
|
workingDirectory = "~/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,17 +282,33 @@ struct SessionCreateView: View {
|
||||||
do {
|
do {
|
||||||
let sessionData = SessionCreateData(
|
let sessionData = SessionCreateData(
|
||||||
command: command,
|
command: command,
|
||||||
workingDir: workingDirectory.isEmpty ? NSHomeDirectory() : workingDirectory,
|
workingDir: workingDirectory.isEmpty ? "~/" : workingDirectory,
|
||||||
name: sessionName.isEmpty ? nil : sessionName
|
name: sessionName.isEmpty ? nil : sessionName
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Log the request for debugging
|
||||||
|
print("[SessionCreate] Creating session with data:")
|
||||||
|
print(" Command: \(sessionData.command)")
|
||||||
|
print(" Working Dir: \(sessionData.workingDir)")
|
||||||
|
print(" Name: \(sessionData.name ?? "nil")")
|
||||||
|
print(" Spawn Terminal: \(sessionData.spawn_terminal ?? false)")
|
||||||
|
print(" Cols: \(sessionData.cols ?? 0), Rows: \(sessionData.rows ?? 0)")
|
||||||
|
|
||||||
let sessionId = try await SessionService.shared.createSession(sessionData)
|
let sessionId = try await SessionService.shared.createSession(sessionData)
|
||||||
|
|
||||||
|
print("[SessionCreate] Session created successfully with ID: \(sessionId)")
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
onCreated(sessionId)
|
onCreated(sessionId)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
print("[SessionCreate] Failed to create session:")
|
||||||
|
print(" Error: \(error)")
|
||||||
|
if let apiError = error as? APIError {
|
||||||
|
print(" API Error: \(apiError)")
|
||||||
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
isCreating = false
|
isCreating = false
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,50 @@ struct SessionListView: View {
|
||||||
|
|
||||||
private var sessionList: some View {
|
private var sessionList: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: Theme.Spacing.md) {
|
VStack(spacing: Theme.Spacing.lg) {
|
||||||
|
// Header with session count and kill all button
|
||||||
|
HStack {
|
||||||
|
Text("\(viewModel.sessions.count) Session\(viewModel.sessions.count == 1 ? "" : "s")")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 16))
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if viewModel.sessions.contains(where: { $0.isRunning }) {
|
||||||
|
Button(action: {
|
||||||
|
HapticFeedback.impact(.medium)
|
||||||
|
Task {
|
||||||
|
await viewModel.killAllSessions()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
|
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)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||||
|
.fill(Theme.Colors.errorAccent.opacity(0.1))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
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
|
// Clean up all button if there are exited sessions
|
||||||
if viewModel.sessions.contains(where: { !$0.isRunning }) {
|
if viewModel.sessions.contains(where: { !$0.isRunning }) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
|
|
@ -158,28 +201,32 @@ struct SessionListView: View {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(viewModel.sessions) { session in
|
ForEach(viewModel.sessions) { session in
|
||||||
SessionCardView(session: session) {
|
SessionCardView(session: session) {
|
||||||
HapticFeedback.selection()
|
HapticFeedback.selection()
|
||||||
selectedSession = session
|
if session.isRunning {
|
||||||
} onKill: {
|
selectedSession = session
|
||||||
HapticFeedback.impact(.medium)
|
}
|
||||||
Task {
|
} onKill: {
|
||||||
await viewModel.killSession(session.id)
|
HapticFeedback.impact(.medium)
|
||||||
}
|
Task {
|
||||||
} onCleanup: {
|
await viewModel.killSession(session.id)
|
||||||
HapticFeedback.impact(.medium)
|
}
|
||||||
Task {
|
} onCleanup: {
|
||||||
await viewModel.cleanupSession(session.id)
|
HapticFeedback.impact(.medium)
|
||||||
|
Task {
|
||||||
|
await viewModel.cleanupSession(session.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale(scale: 0.8).combined(with: .opacity),
|
||||||
|
removal: .scale(scale: 0.8).combined(with: .opacity)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
.transition(.asymmetric(
|
|
||||||
insertion: .scale(scale: 0.8).combined(with: .opacity),
|
|
||||||
removal: .scale(scale: 0.8).combined(with: .opacity)
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(.vertical)
|
||||||
.animation(Theme.Animation.smooth, value: viewModel.sessions)
|
.animation(Theme.Animation.smooth, value: viewModel.sessions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +294,7 @@ class SessionListViewModel: ObservableObject {
|
||||||
|
|
||||||
func cleanupAllExited() async {
|
func cleanupAllExited() async {
|
||||||
do {
|
do {
|
||||||
let cleaned = try await sessionService.cleanupAllExitedSessions()
|
_ = try await sessionService.cleanupAllExitedSessions()
|
||||||
await loadSessions()
|
await loadSessions()
|
||||||
HapticFeedback.notification(.success)
|
HapticFeedback.notification(.success)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -255,4 +302,16 @@ class SessionListViewModel: ObservableObject {
|
||||||
HapticFeedback.notification(.error)
|
HapticFeedback.notification(.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func killAllSessions() async {
|
||||||
|
let runningSessions = sessions.filter { $0.isRunning }
|
||||||
|
for session in runningSessions {
|
||||||
|
do {
|
||||||
|
try await sessionService.killSession(session.id)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadSessions()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,28 +6,27 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
@Binding var fontSize: CGFloat
|
@Binding var fontSize: CGFloat
|
||||||
let onInput: (String) -> Void
|
let onInput: (String) -> Void
|
||||||
let onResize: (Int, Int) -> Void
|
let onResize: (Int, Int) -> Void
|
||||||
|
@ObservedObject var viewModel: TerminalViewModel
|
||||||
|
|
||||||
func makeUIView(context: Context) -> TerminalView {
|
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
|
||||||
let terminal = TerminalView()
|
let terminal = SwiftTerm.TerminalView()
|
||||||
|
|
||||||
// Configure terminal appearance
|
// Configure terminal appearance
|
||||||
terminal.backgroundColor = UIColor(Theme.Colors.terminalBackground)
|
terminal.backgroundColor = UIColor(Theme.Colors.terminalBackground)
|
||||||
terminal.nativeForegroundColor = UIColor(Theme.Colors.terminalForeground)
|
terminal.nativeForegroundColor = UIColor(Theme.Colors.terminalForeground)
|
||||||
terminal.nativeBackgroundColor = UIColor(Theme.Colors.terminalBackground)
|
terminal.nativeBackgroundColor = UIColor(Theme.Colors.terminalBackground)
|
||||||
|
|
||||||
// Set up font
|
|
||||||
updateFont(terminal, size: fontSize)
|
|
||||||
|
|
||||||
// Configure colors
|
|
||||||
configureColors(terminal)
|
|
||||||
|
|
||||||
// Set up delegates
|
// Set up delegates
|
||||||
terminal.delegate = context.coordinator
|
// SwiftTerm's TerminalView uses terminalDelegate, not delegate
|
||||||
|
terminal.terminalDelegate = context.coordinator
|
||||||
|
|
||||||
// Configure terminal options
|
// Configure terminal options
|
||||||
terminal.allowMouseReporting = false
|
terminal.allowMouseReporting = false
|
||||||
terminal.optionAsMetaKey = true
|
terminal.optionAsMetaKey = true
|
||||||
|
|
||||||
|
// Configure font
|
||||||
|
updateFont(terminal, size: fontSize)
|
||||||
|
|
||||||
// Start with default size
|
// Start with default size
|
||||||
let cols = Int(UIScreen.main.bounds.width / 9) // Approximate char width
|
let cols = Int(UIScreen.main.bounds.width / 9) // Approximate char width
|
||||||
let rows = 24
|
let rows = 24
|
||||||
|
|
@ -36,100 +35,108 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
return terminal
|
return terminal
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ terminal: TerminalView, context: Context) {
|
func updateUIView(_ terminal: SwiftTerm.TerminalView, context: Context) {
|
||||||
updateFont(terminal, size: fontSize)
|
updateFont(terminal, size: fontSize)
|
||||||
|
|
||||||
|
// Update terminal content from viewModel
|
||||||
|
context.coordinator.terminal = terminal
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(onInput: onInput, onResize: onResize)
|
Coordinator(
|
||||||
|
onInput: onInput,
|
||||||
|
onResize: onResize,
|
||||||
|
viewModel: viewModel
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateFont(_ terminal: TerminalView, size: CGFloat) {
|
private func updateFont(_ terminal: SwiftTerm.TerminalView, size: CGFloat) {
|
||||||
if let font = UIFont(name: Theme.Typography.terminalFont, size: size) {
|
let font: UIFont
|
||||||
terminal.font = font
|
if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) {
|
||||||
} else if let font = UIFont(name: Theme.Typography.terminalFontFallback, size: size) {
|
font = customFont
|
||||||
terminal.font = font
|
} else if let fallbackFont = UIFont(name: Theme.Typography.terminalFontFallback, size: size) {
|
||||||
|
font = fallbackFont
|
||||||
} else {
|
} else {
|
||||||
terminal.font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||||
}
|
}
|
||||||
|
// SwiftTerm uses the font property directly
|
||||||
|
terminal.font = font
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureColors(_ terminal: TerminalView) {
|
@MainActor
|
||||||
// ANSI colors
|
class Coordinator: NSObject {
|
||||||
terminal.setColor(index: 0, color: UIColor(Theme.Colors.ansiBlack))
|
|
||||||
terminal.setColor(index: 1, color: UIColor(Theme.Colors.ansiRed))
|
|
||||||
terminal.setColor(index: 2, color: UIColor(Theme.Colors.ansiGreen))
|
|
||||||
terminal.setColor(index: 3, color: UIColor(Theme.Colors.ansiYellow))
|
|
||||||
terminal.setColor(index: 4, color: UIColor(Theme.Colors.ansiBlue))
|
|
||||||
terminal.setColor(index: 5, color: UIColor(Theme.Colors.ansiMagenta))
|
|
||||||
terminal.setColor(index: 6, color: UIColor(Theme.Colors.ansiCyan))
|
|
||||||
terminal.setColor(index: 7, color: UIColor(Theme.Colors.ansiWhite))
|
|
||||||
|
|
||||||
// Bright ANSI colors
|
|
||||||
terminal.setColor(index: 8, color: UIColor(Theme.Colors.ansiBrightBlack))
|
|
||||||
terminal.setColor(index: 9, color: UIColor(Theme.Colors.ansiBrightRed))
|
|
||||||
terminal.setColor(index: 10, color: UIColor(Theme.Colors.ansiBrightGreen))
|
|
||||||
terminal.setColor(index: 11, color: UIColor(Theme.Colors.ansiBrightYellow))
|
|
||||||
terminal.setColor(index: 12, color: UIColor(Theme.Colors.ansiBrightBlue))
|
|
||||||
terminal.setColor(index: 13, color: UIColor(Theme.Colors.ansiBrightMagenta))
|
|
||||||
terminal.setColor(index: 14, color: UIColor(Theme.Colors.ansiBrightCyan))
|
|
||||||
terminal.setColor(index: 15, color: UIColor(Theme.Colors.ansiBrightWhite))
|
|
||||||
|
|
||||||
// Cursor
|
|
||||||
terminal.caretColor = UIColor(Theme.Colors.primaryAccent)
|
|
||||||
terminal.caretTextColor = UIColor(Theme.Colors.terminalBackground)
|
|
||||||
|
|
||||||
// Selection
|
|
||||||
terminal.selectedTextBackgroundColor = UIColor(Theme.Colors.terminalSelection)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Coordinator: NSObject, TerminalViewDelegate {
|
|
||||||
let onInput: (String) -> Void
|
let onInput: (String) -> Void
|
||||||
let onResize: (Int, Int) -> Void
|
let onResize: (Int, Int) -> Void
|
||||||
|
let viewModel: TerminalViewModel
|
||||||
|
weak var terminal: SwiftTerm.TerminalView?
|
||||||
|
|
||||||
init(onInput: @escaping (String) -> Void, onResize: @escaping (Int, Int) -> Void) {
|
init(onInput: @escaping (String) -> Void,
|
||||||
|
onResize: @escaping (Int, Int) -> Void,
|
||||||
|
viewModel: TerminalViewModel) {
|
||||||
self.onInput = onInput
|
self.onInput = onInput
|
||||||
self.onResize = onResize
|
self.onResize = onResize
|
||||||
|
self.viewModel = viewModel
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
// Set the coordinator reference on the viewModel
|
||||||
|
Task { @MainActor in
|
||||||
|
viewModel.terminalCoordinator = self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func send(source: TerminalView, data: ArraySlice<UInt8>) {
|
func feedData(_ data: String) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let terminal = terminal else { return }
|
||||||
|
// Feed the output to the terminal
|
||||||
|
terminal.feed(text: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TerminalViewDelegate
|
||||||
|
|
||||||
|
func send(source: SwiftTerm.TerminalView, data: ArraySlice<UInt8>) {
|
||||||
if let string = String(bytes: data, encoding: .utf8) {
|
if let string = String(bytes: data, encoding: .utf8) {
|
||||||
onInput(string)
|
onInput(string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
|
func sizeChanged(source: SwiftTerm.TerminalView, newCols: Int, newRows: Int) {
|
||||||
onResize(newCols, newRows)
|
onResize(newCols, newRows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrolled(source: TerminalView, position: Double) {
|
func scrolled(source: SwiftTerm.TerminalView, position: Double) {
|
||||||
// Handle scroll if needed
|
// Handle scroll if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
func setTerminalTitle(source: TerminalView, title: String) {
|
func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) {
|
||||||
// Handle title change if needed
|
// Handle title change if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
|
func hostCurrentDirectoryUpdate(source: SwiftTerm.TerminalView, directory: String?) {
|
||||||
// Handle directory update if needed
|
// Handle directory update if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestOpenLink(source: TerminalView, link: String, params: [String : String]) {
|
func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String : String]) {
|
||||||
// Open URL
|
// Open URL
|
||||||
if let url = URL(string: link) {
|
if let url = URL(string: link) {
|
||||||
UIApplication.shared.open(url)
|
DispatchQueue.main.async {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Terminal Control Methods
|
func clipboardCopy(source: SwiftTerm.TerminalView, content: Data) {
|
||||||
static func feed(_ terminal: TerminalView?, data: String) {
|
// Handle clipboard copy
|
||||||
guard let terminal = terminal else { return }
|
if let string = String(data: content, encoding: .utf8) {
|
||||||
let bytes = [UInt8](data.utf8)
|
UIPasteboard.general.string = string
|
||||||
terminal.feed(byteArray: bytes)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func clear(_ terminal: TerminalView?) {
|
func rangeChanged(source: SwiftTerm.TerminalView, startY: Int, endY: Int) {
|
||||||
terminal?.terminal.resetToInitialState()
|
// Handle range change if needed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add conformance with proper isolation
|
||||||
|
extension TerminalHostingView.Coordinator: @preconcurrency SwiftTerm.TerminalViewDelegate {}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftTerm
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import SwiftTerm
|
||||||
|
|
||||||
struct TerminalView: View {
|
struct TerminalView: View {
|
||||||
let session: Session
|
let session: Session
|
||||||
|
|
@ -130,8 +130,7 @@ struct TerminalView: View {
|
||||||
private var terminalContent: some View {
|
private var terminalContent: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Terminal hosting view
|
// Terminal hosting view
|
||||||
TerminalContainerView(
|
TerminalHostingView(
|
||||||
terminal: viewModel.terminal,
|
|
||||||
session: session,
|
session: session,
|
||||||
fontSize: $fontSize,
|
fontSize: $fontSize,
|
||||||
onInput: { text in
|
onInput: { text in
|
||||||
|
|
@ -139,8 +138,10 @@ struct TerminalView: View {
|
||||||
},
|
},
|
||||||
onResize: { cols, rows in
|
onResize: { cols, rows in
|
||||||
viewModel.resize(cols: cols, rows: rows)
|
viewModel.resize(cols: cols, rows: rows)
|
||||||
}
|
},
|
||||||
|
viewModel: viewModel
|
||||||
)
|
)
|
||||||
|
.id(viewModel.terminalViewId)
|
||||||
.background(Theme.Colors.terminalBackground)
|
.background(Theme.Colors.terminalBackground)
|
||||||
.focused($isInputFocused)
|
.focused($isInputFocused)
|
||||||
|
|
||||||
|
|
@ -160,87 +161,17 @@ struct TerminalView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal container to manage SwiftTerm lifecycle
|
|
||||||
struct TerminalContainerView: UIViewControllerRepresentable {
|
|
||||||
let terminal: TerminalView?
|
|
||||||
let session: Session
|
|
||||||
@Binding var fontSize: CGFloat
|
|
||||||
let onInput: (String) -> Void
|
|
||||||
let onResize: (Int, Int) -> Void
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> TerminalHostingController {
|
|
||||||
let controller = TerminalHostingController()
|
|
||||||
controller.session = session
|
|
||||||
controller.fontSize = fontSize
|
|
||||||
controller.onInput = onInput
|
|
||||||
controller.onResize = onResize
|
|
||||||
controller.terminalView = terminal
|
|
||||||
return controller
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ controller: TerminalHostingController, context: Context) {
|
|
||||||
controller.fontSize = fontSize
|
|
||||||
controller.updateTerminal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TerminalHostingController: UIViewController {
|
|
||||||
var terminalView: TerminalView?
|
|
||||||
var session: Session?
|
|
||||||
var fontSize: CGFloat = 14
|
|
||||||
var onInput: ((String) -> Void)?
|
|
||||||
var onResize: ((Int, Int) -> Void)?
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
view.backgroundColor = UIColor(Theme.Colors.terminalBackground)
|
|
||||||
setupTerminal()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillLayoutSubviews() {
|
|
||||||
super.viewWillLayoutSubviews()
|
|
||||||
terminalView?.frame = view.bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupTerminal() {
|
|
||||||
guard let terminal = terminalView else { return }
|
|
||||||
|
|
||||||
terminal.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
view.addSubview(terminal)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
terminal.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
terminal.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
terminal.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
terminal.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
|
||||||
])
|
|
||||||
|
|
||||||
// Make terminal first responder for keyboard input
|
|
||||||
terminal.becomeFirstResponder()
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateTerminal() {
|
|
||||||
// Update font size if needed
|
|
||||||
if let terminal = terminalView {
|
|
||||||
if let font = UIFont(name: Theme.Typography.terminalFont, size: fontSize) {
|
|
||||||
terminal.font = font
|
|
||||||
} else {
|
|
||||||
terminal.font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class TerminalViewModel: ObservableObject {
|
class TerminalViewModel: ObservableObject {
|
||||||
@Published var isConnecting = true
|
@Published var isConnecting = true
|
||||||
@Published var isConnected = false
|
@Published var isConnected = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
@Published var terminalViewId = UUID()
|
||||||
|
|
||||||
let session: Session
|
let session: Session
|
||||||
var terminal: TerminalView?
|
|
||||||
private var sseClient: SSEClient?
|
private var sseClient: SSEClient?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
var cancellables = Set<AnyCancellable>()
|
||||||
|
weak var terminalCoordinator: TerminalHostingView.Coordinator?
|
||||||
|
|
||||||
init(session: Session) {
|
init(session: Session) {
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
@ -248,20 +179,7 @@ class TerminalViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupTerminal() {
|
private func setupTerminal() {
|
||||||
let terminal = TerminalView()
|
// Terminal setup now handled by SimpleTerminalView
|
||||||
terminal.delegate = self
|
|
||||||
self.terminal = terminal
|
|
||||||
|
|
||||||
// Configure appearance
|
|
||||||
terminal.backgroundColor = UIColor(Theme.Colors.terminalBackground)
|
|
||||||
terminal.nativeForegroundColor = UIColor(Theme.Colors.terminalForeground)
|
|
||||||
terminal.nativeBackgroundColor = UIColor(Theme.Colors.terminalBackground)
|
|
||||||
terminal.caretColor = UIColor(Theme.Colors.primaryAccent)
|
|
||||||
terminal.caretTextColor = UIColor(Theme.Colors.terminalBackground)
|
|
||||||
terminal.selectedTextBackgroundColor = UIColor(Theme.Colors.terminalSelection)
|
|
||||||
|
|
||||||
// Set initial size
|
|
||||||
terminal.resize(cols: 80, rows: 24)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func connect() {
|
func connect() {
|
||||||
|
|
@ -274,11 +192,18 @@ class TerminalViewModel: ObservableObject {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load existing terminal snapshot first if session is already running
|
||||||
|
if session.isRunning {
|
||||||
|
Task {
|
||||||
|
await loadSnapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sseClient = SSEClient()
|
sseClient = SSEClient()
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
for await event in sseClient!.connect(to: streamURL) {
|
for await event in sseClient!.connect(to: streamURL) {
|
||||||
await handleTerminalEvent(event)
|
handleTerminalEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -286,6 +211,21 @@ class TerminalViewModel: ObservableObject {
|
||||||
isConnected = true
|
isConnected = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
// Feed the snapshot to the terminal
|
||||||
|
terminalCoordinator?.feedData(snapshot)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to load terminal snapshot: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
sseClient?.disconnect()
|
sseClient?.disconnect()
|
||||||
sseClient = nil
|
sseClient = nil
|
||||||
|
|
@ -294,15 +234,32 @@ class TerminalViewModel: ObservableObject {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handleTerminalEvent(_ event: TerminalEvent) {
|
private func handleTerminalEvent(_ event: TerminalEvent) {
|
||||||
switch event.type {
|
switch event {
|
||||||
case .output:
|
case .header(let header):
|
||||||
let bytes = [UInt8](event.data.utf8)
|
// Initial terminal setup
|
||||||
terminal?.feed(byteArray: bytes)
|
print("Terminal initialized: \(header.width)x\(header.height)")
|
||||||
case .resize:
|
// The terminal will be resized when created
|
||||||
// Handle resize if needed
|
|
||||||
break
|
case .output(_, let data):
|
||||||
default:
|
// Feed output data directly to the terminal
|
||||||
break
|
terminalCoordinator?.feedData(data)
|
||||||
|
|
||||||
|
case .resize(_, let dimensions):
|
||||||
|
// Parse dimensions like "120x30"
|
||||||
|
let parts = dimensions.split(separator: "x")
|
||||||
|
if parts.count == 2,
|
||||||
|
let cols = Int(parts[0]),
|
||||||
|
let rows = Int(parts[1]) {
|
||||||
|
// Handle resize if needed
|
||||||
|
print("Terminal resize: \(cols)x\(rows)")
|
||||||
|
}
|
||||||
|
|
||||||
|
case .exit(let code, _):
|
||||||
|
// Session has exited
|
||||||
|
isConnected = false
|
||||||
|
if code != 0 {
|
||||||
|
errorMessage = "Session exited with code \(code)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,8 +274,6 @@ class TerminalViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func resize(cols: Int, rows: Int) {
|
func resize(cols: Int, rows: Int) {
|
||||||
terminal?.resize(cols: cols, rows: rows)
|
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await SessionService.shared.resizeTerminal(sessionId: session.id, cols: cols, rows: rows)
|
try await SessionService.shared.resizeTerminal(sessionId: session.id, cols: cols, rows: rows)
|
||||||
|
|
@ -329,42 +284,13 @@ class TerminalViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearTerminal() {
|
func clearTerminal() {
|
||||||
terminal?.terminal.resetToInitialState()
|
// Reset the terminal by recreating it
|
||||||
|
terminalViewId = UUID()
|
||||||
HapticFeedback.impact(.medium)
|
HapticFeedback.impact(.medium)
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyBuffer() {
|
func copyBuffer() {
|
||||||
if let text = terminal?.getTerminalText() {
|
// Terminal copy is handled by SwiftTerm's built-in functionality
|
||||||
UIPasteboard.general.string = text
|
HapticFeedback.notification(.success)
|
||||||
HapticFeedback.notification(.success)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TerminalViewModel: TerminalViewDelegate {
|
|
||||||
nonisolated func send(source: TerminalView, data: ArraySlice<UInt8>) {
|
|
||||||
if let string = String(bytes: data, encoding: .utf8) {
|
|
||||||
Task { @MainActor in
|
|
||||||
sendInput(string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
|
|
||||||
Task { @MainActor in
|
|
||||||
resize(cols: newCols, rows: newRows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated func scrolled(source: TerminalView, position: Double) {}
|
|
||||||
nonisolated func setTerminalTitle(source: TerminalView, title: String) {}
|
|
||||||
nonisolated func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
|
|
||||||
|
|
||||||
nonisolated func requestOpenLink(source: TerminalView, link: String, params: [String : String]) {
|
|
||||||
if let url = URL(string: link) {
|
|
||||||
Task { @MainActor in
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
165
ios/docs/modern-swift.md
Normal file
165
ios/docs/modern-swift.md
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
# Modern Swift Development
|
||||||
|
|
||||||
|
Write idiomatic SwiftUI code following Apple's latest architectural recommendations and best practices.
|
||||||
|
|
||||||
|
## Core Philosophy
|
||||||
|
|
||||||
|
- SwiftUI is the default UI paradigm for Apple platforms - embrace its declarative nature
|
||||||
|
- Avoid legacy UIKit patterns and unnecessary abstractions
|
||||||
|
- Focus on simplicity, clarity, and native data flow
|
||||||
|
- Let SwiftUI handle the complexity - don't fight the framework
|
||||||
|
|
||||||
|
## Architecture Guidelines
|
||||||
|
|
||||||
|
### 1. Embrace Native State Management
|
||||||
|
|
||||||
|
Use SwiftUI's built-in property wrappers appropriately:
|
||||||
|
- `@State` - Local, ephemeral view state
|
||||||
|
- `@Binding` - Two-way data flow between views
|
||||||
|
- `@Observable` - Shared state (iOS 17+)
|
||||||
|
- `@ObservableObject` - Legacy shared state (pre-iOS 17)
|
||||||
|
- `@Environment` - Dependency injection for app-wide concerns
|
||||||
|
|
||||||
|
### 2. State Ownership Principles
|
||||||
|
|
||||||
|
- Views own their local state unless sharing is required
|
||||||
|
- State flows down, actions flow up
|
||||||
|
- Keep state as close to where it's used as possible
|
||||||
|
- Extract shared state only when multiple views need it
|
||||||
|
|
||||||
|
### 3. Modern Async Patterns
|
||||||
|
|
||||||
|
- Use `async/await` as the default for asynchronous operations
|
||||||
|
- Leverage `.task` modifier for lifecycle-aware async work
|
||||||
|
- Avoid Combine unless absolutely necessary
|
||||||
|
- Handle errors gracefully with try/catch
|
||||||
|
|
||||||
|
### 4. View Composition
|
||||||
|
|
||||||
|
- Build UI with small, focused views
|
||||||
|
- Extract reusable components naturally
|
||||||
|
- Use view modifiers to encapsulate common styling
|
||||||
|
- Prefer composition over inheritance
|
||||||
|
|
||||||
|
### 5. Code Organization
|
||||||
|
|
||||||
|
- Organize by feature, not by type (avoid Views/, Models/, ViewModels/ folders)
|
||||||
|
- Keep related code together in the same file when appropriate
|
||||||
|
- Use extensions to organize large files
|
||||||
|
- Follow Swift naming conventions consistently
|
||||||
|
|
||||||
|
## Implementation Patterns
|
||||||
|
|
||||||
|
### Simple State Example
|
||||||
|
```swift
|
||||||
|
struct CounterView: View {
|
||||||
|
@State private var count = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
Text("Count: \(count)")
|
||||||
|
Button("Increment") {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared State with @Observable
|
||||||
|
```swift
|
||||||
|
@Observable
|
||||||
|
class UserSession {
|
||||||
|
var isAuthenticated = false
|
||||||
|
var currentUser: User?
|
||||||
|
|
||||||
|
func signIn(user: User) {
|
||||||
|
currentUser = user
|
||||||
|
isAuthenticated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MyApp: App {
|
||||||
|
@State private var session = UserSession()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environment(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async Data Loading
|
||||||
|
```swift
|
||||||
|
struct ProfileView: View {
|
||||||
|
@State private var profile: Profile?
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var error: Error?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
} else if let profile {
|
||||||
|
ProfileContent(profile: profile)
|
||||||
|
} else if let error {
|
||||||
|
ErrorView(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadProfile()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadProfile() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
profile = try await ProfileService.fetch()
|
||||||
|
} catch {
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### DO:
|
||||||
|
- Write self-contained views when possible
|
||||||
|
- Use property wrappers as intended by Apple
|
||||||
|
- Test logic in isolation, preview UI visually
|
||||||
|
- Handle loading and error states explicitly
|
||||||
|
- Keep views focused on presentation
|
||||||
|
- Use Swift's type system for safety
|
||||||
|
|
||||||
|
### DON'T:
|
||||||
|
- Create ViewModels for every view
|
||||||
|
- Move state out of views unnecessarily
|
||||||
|
- Add abstraction layers without clear benefit
|
||||||
|
- Use Combine for simple async operations
|
||||||
|
- Fight SwiftUI's update mechanism
|
||||||
|
- Overcomplicate simple features
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Unit test business logic and data transformations
|
||||||
|
- Use SwiftUI Previews for visual testing
|
||||||
|
- Test @Observable classes independently
|
||||||
|
- Keep tests simple and focused
|
||||||
|
- Don't sacrifice code clarity for testability
|
||||||
|
|
||||||
|
## Modern Swift Features
|
||||||
|
|
||||||
|
- Use Swift Concurrency (async/await, actors)
|
||||||
|
- Leverage Swift 6 data race safety when available
|
||||||
|
- Utilize property wrappers effectively
|
||||||
|
- Embrace value types where appropriate
|
||||||
|
- Use protocols for abstraction, not just for testing
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Write SwiftUI code that looks and feels like SwiftUI. The framework has matured significantly - trust its patterns and tools. Focus on solving user problems rather than implementing architectural patterns from other platforms.
|
||||||
Loading…
Reference in a new issue