mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-26 15:07:39 +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
|
||||
|
||||
struct Session: Codable, Identifiable {
|
||||
struct Session: Codable, Identifiable, Equatable {
|
||||
let id: String
|
||||
let command: String
|
||||
let workingDir: String
|
||||
|
|
@ -8,12 +8,27 @@ struct Session: Codable, Identifiable {
|
|||
let status: SessionStatus
|
||||
let exitCode: Int?
|
||||
let startedAt: String
|
||||
let lastModified: String
|
||||
let lastModified: String?
|
||||
let pid: Int?
|
||||
let waiting: Bool?
|
||||
let width: 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 {
|
||||
name ?? command
|
||||
}
|
||||
|
|
@ -24,18 +39,41 @@ struct Session: Codable, Identifiable {
|
|||
|
||||
var formattedStartTime: String {
|
||||
// Parse and format the startedAt string
|
||||
let formatter = ISO8601DateFormatter()
|
||||
if let date = formatter.date(from: startedAt) {
|
||||
// Try ISO8601 first
|
||||
let iso8601Formatter = ISO8601DateFormatter()
|
||||
if let date = iso8601Formatter.date(from: startedAt) {
|
||||
let displayFormatter = DateFormatter()
|
||||
displayFormatter.dateStyle = .none
|
||||
displayFormatter.timeStyle = .short
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
enum SessionStatus: String, Codable {
|
||||
case starting
|
||||
case running
|
||||
case exited
|
||||
}
|
||||
|
|
@ -48,11 +86,11 @@ struct SessionCreateData: Codable {
|
|||
let cols: 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.workingDir = workingDir
|
||||
self.name = name
|
||||
self.spawn_terminal = false
|
||||
self.spawn_terminal = spawnTerminal
|
||||
self.cols = cols
|
||||
self.rows = rows
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,51 @@
|
|||
import Foundation
|
||||
|
||||
struct TerminalEvent {
|
||||
let timestamp: Double
|
||||
let type: EventType
|
||||
let data: String
|
||||
|
||||
enum EventType: String {
|
||||
case output = "o"
|
||||
case input = "i"
|
||||
case resize = "r"
|
||||
case marker = "m"
|
||||
}
|
||||
enum TerminalEvent {
|
||||
case header(AsciinemaHeader)
|
||||
case output(timestamp: Double, data: String)
|
||||
case resize(timestamp: Double, dimensions: String)
|
||||
case exit(code: Int, sessionId: String)
|
||||
|
||||
init?(from line: String) {
|
||||
// Parse Asciinema v2 format: [timestamp, "type", "data"]
|
||||
guard let data = line.data(using: .utf8),
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||
array.count >= 3,
|
||||
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,
|
||||
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 typeString = array[1] as? String,
|
||||
let type = EventType(rawValue: typeString),
|
||||
let eventData = array[2] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.timestamp = timestamp
|
||||
self.type = type
|
||||
self.data = eventData
|
||||
switch typeString {
|
||||
case "o":
|
||||
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
|
||||
|
||||
enum SpecialKey: String {
|
||||
case arrowUp = "arrow_up"
|
||||
case arrowDown = "arrow_down"
|
||||
case arrowLeft = "arrow_left"
|
||||
case arrowRight = "arrow_right"
|
||||
case escape = "escape"
|
||||
case enter = "enter"
|
||||
case ctrlEnter = "ctrl_enter"
|
||||
case shiftEnter = "shift_enter"
|
||||
// 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}"
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -17,8 +17,44 @@ enum APIError: LocalizedError {
|
|||
case .decodingError(let error):
|
||||
return "Failed to decode response: \(error.localizedDescription)"
|
||||
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):
|
||||
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
|
||||
case .noServerConfigured:
|
||||
return "No server configured"
|
||||
|
|
@ -36,6 +72,7 @@ protocol APIClientProtocol {
|
|||
func resizeTerminal(sessionId: String, cols: Int, rows: Int) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class APIClient: APIClientProtocol {
|
||||
static let shared = APIClient()
|
||||
private let session = URLSession.shared
|
||||
|
|
@ -73,24 +110,56 @@ class APIClient: APIClientProtocol {
|
|||
|
||||
func createSession(_ data: SessionCreateData) async throws -> String {
|
||||
guard let baseURL = 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")
|
||||
request.httpBody = try encoder.encode(data)
|
||||
|
||||
let (responseData, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
struct CreateResponse: Codable {
|
||||
let sessionId: String
|
||||
do {
|
||||
request.httpBody = try encoder.encode(data)
|
||||
if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) {
|
||||
print("[APIClient] Request body: \(bodyString)")
|
||||
}
|
||||
} catch {
|
||||
print("[APIClient] Failed to encode session data: \(error)")
|
||||
throw error
|
||||
}
|
||||
|
||||
let createResponse = try decoder.decode(CreateResponse.self, from: responseData)
|
||||
return createResponse.sessionId
|
||||
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
|
||||
} 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 {
|
||||
|
|
@ -131,12 +200,22 @@ class APIClient: APIClientProtocol {
|
|||
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]
|
||||
}
|
||||
|
||||
let cleanupResponse = try decoder.decode(CleanupResponse.self, from: data)
|
||||
return cleanupResponse.cleanedSessions
|
||||
do {
|
||||
let cleanupResponse = try decoder.decode(CleanupResponse.self, from: data)
|
||||
return cleanupResponse.cleanedSessions
|
||||
} catch {
|
||||
// If decoding fails, return empty array
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal I/O
|
||||
|
|
@ -182,14 +261,21 @@ class APIClient: APIClientProtocol {
|
|||
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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
class SSEClient: NSObject {
|
||||
final class SSEClient: NSObject, @unchecked Sendable {
|
||||
private var eventSource: URLSessionDataTask?
|
||||
private var session: URLSession?
|
||||
private var streamContinuation: AsyncStream<TerminalEvent>.Continuation?
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
@MainActor
|
||||
class SessionService {
|
||||
static let shared = SessionService()
|
||||
private let apiClient = APIClient.shared
|
||||
|
|
@ -11,7 +12,12 @@ class SessionService {
|
|||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -84,20 +84,20 @@ struct Theme {
|
|||
}
|
||||
|
||||
// MARK: - Shadows
|
||||
struct Shadow {
|
||||
static let card = SwiftUI.Shadow(
|
||||
color: Color.black.opacity(0.3),
|
||||
radius: 8,
|
||||
x: 0,
|
||||
y: 2
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
static let button = SwiftUI.Shadow(
|
||||
color: Color.black.opacity(0.2),
|
||||
radius: 4,
|
||||
x: 0,
|
||||
y: 1
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +163,7 @@ extension View {
|
|||
}
|
||||
|
||||
// MARK: - Haptic Feedback
|
||||
@MainActor
|
||||
struct HapticFeedback {
|
||||
static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
|
||||
let generator = UIImpactFeedbackGenerator(style: style)
|
||||
|
|
|
|||
|
|
@ -91,8 +91,8 @@ struct ConnectionView: View {
|
|||
}
|
||||
|
||||
class ConnectionViewModel: ObservableObject {
|
||||
@Published var host: String = ""
|
||||
@Published var port: String = "3000"
|
||||
@Published var host: String = "127.0.0.1"
|
||||
@Published var port: String = "4020"
|
||||
@Published var name: String = ""
|
||||
@Published var isConnecting: Bool = false
|
||||
@Published var errorMessage: String?
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ struct ServerConfigForm: View {
|
|||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(recentServers.prefix(3), id: \Self.host) { server in
|
||||
ForEach(recentServers.prefix(3), id: \.host) { server in
|
||||
Button(action: {
|
||||
host = server.host
|
||||
port = String(server.port)
|
||||
|
|
|
|||
|
|
@ -8,80 +8,120 @@ struct SessionCardView: View {
|
|||
|
||||
@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 {
|
||||
Button(action: onTap) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
// Header
|
||||
// Header with session ID/name and kill button
|
||||
HStack {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
Image(systemName: "terminal")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(session.isRunning ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground.opacity(0.5))
|
||||
|
||||
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))
|
||||
Text(session.name ?? String(session.id.prefix(8)))
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
.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
|
||||
HStack(spacing: Theme.Spacing.lg) {
|
||||
if let pid = session.pid {
|
||||
HStack(spacing: 4) {
|
||||
Text("PID")
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||
Text(String(pid))
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
.foregroundColor(Theme.Colors.successAccent)
|
||||
}
|
||||
}
|
||||
|
||||
if let exitCode = session.exitCode {
|
||||
HStack(spacing: 4) {
|
||||
Text("EXIT")
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||
Text(String(exitCode))
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
.foregroundColor(exitCode == 0 ? Theme.Colors.successAccent : Theme.Colors.errorAccent)
|
||||
// Terminal content area showing command and working directory
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.fill(Theme.Colors.terminalBackground)
|
||||
.frame(height: 120)
|
||||
.overlay(
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
if session.isRunning {
|
||||
// Show command and working directory info
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
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)
|
||||
}
|
||||
|
||||
Text(displayWorkingDir)
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(Theme.Spacing.sm)
|
||||
|
||||
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()
|
||||
|
||||
Text(session.formattedStartTime)
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||
// PID info
|
||||
if session.isRunning, let pid = session.pid {
|
||||
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(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
|
||||
.fill(Theme.Colors.cardBackground)
|
||||
.shadow(color: Color.black.opacity(0.2), radius: isPressed ? 2 : 6, y: isPressed ? 1 : 3)
|
||||
)
|
||||
.overlay(
|
||||
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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
@State private var command = "zsh"
|
||||
@State private var workingDirectory = ""
|
||||
@State private var workingDirectory = "~/"
|
||||
@State private var sessionName = ""
|
||||
@State private var isCreating = false
|
||||
@State private var errorMessage: String?
|
||||
|
|
@ -59,7 +59,7 @@ struct SessionCreateView: View {
|
|||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
||||
TextField(NSHomeDirectory(), text: $workingDirectory)
|
||||
TextField("~/", text: $workingDirectory)
|
||||
.textFieldStyle(TerminalTextFieldStyle())
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
|
|
@ -94,6 +94,52 @@ struct SessionCreateView: View {
|
|||
}
|
||||
.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
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
Text("QUICK START")
|
||||
|
|
@ -190,6 +236,10 @@ struct SessionCreateView: View {
|
|||
["zsh", "bash", "python3", "node", "npm run dev", "irb"]
|
||||
}
|
||||
|
||||
private var commonDirectories: [String] {
|
||||
["~/", "~/Desktop", "~/Documents", "~/Downloads", "~/Projects", "/tmp"]
|
||||
}
|
||||
|
||||
private func commandIcon(for command: String) -> String {
|
||||
switch command {
|
||||
case "zsh", "bash":
|
||||
|
|
@ -215,7 +265,8 @@ struct SessionCreateView: View {
|
|||
if let lastDir = UserDefaults.standard.string(forKey: "lastWorkingDir") {
|
||||
workingDirectory = lastDir
|
||||
} else {
|
||||
workingDirectory = NSHomeDirectory()
|
||||
// Default to home directory on the server
|
||||
workingDirectory = "~/"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -231,17 +282,33 @@ struct SessionCreateView: View {
|
|||
do {
|
||||
let sessionData = SessionCreateData(
|
||||
command: command,
|
||||
workingDir: workingDirectory.isEmpty ? NSHomeDirectory() : workingDirectory,
|
||||
workingDir: workingDirectory.isEmpty ? "~/" : workingDirectory,
|
||||
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)
|
||||
|
||||
print("[SessionCreate] Session created successfully with ID: \(sessionId)")
|
||||
|
||||
await MainActor.run {
|
||||
onCreated(sessionId)
|
||||
isPresented = false
|
||||
}
|
||||
} catch {
|
||||
print("[SessionCreate] Failed to create session:")
|
||||
print(" Error: \(error)")
|
||||
if let apiError = error as? APIError {
|
||||
print(" API Error: \(apiError)")
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
isCreating = false
|
||||
|
|
|
|||
|
|
@ -125,7 +125,50 @@ struct SessionListView: View {
|
|||
|
||||
private var sessionList: some View {
|
||||
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
|
||||
if viewModel.sessions.contains(where: { !$0.isRunning }) {
|
||||
Button(action: {
|
||||
|
|
@ -158,28 +201,32 @@ struct SessionListView: View {
|
|||
))
|
||||
}
|
||||
|
||||
ForEach(viewModel.sessions) { session in
|
||||
SessionCardView(session: session) {
|
||||
HapticFeedback.selection()
|
||||
selectedSession = session
|
||||
} onKill: {
|
||||
HapticFeedback.impact(.medium)
|
||||
Task {
|
||||
await viewModel.killSession(session.id)
|
||||
}
|
||||
} onCleanup: {
|
||||
HapticFeedback.impact(.medium)
|
||||
Task {
|
||||
await viewModel.cleanupSession(session.id)
|
||||
ForEach(viewModel.sessions) { session in
|
||||
SessionCardView(session: session) {
|
||||
HapticFeedback.selection()
|
||||
if session.isRunning {
|
||||
selectedSession = session
|
||||
}
|
||||
} onKill: {
|
||||
HapticFeedback.impact(.medium)
|
||||
Task {
|
||||
await viewModel.killSession(session.id)
|
||||
}
|
||||
} onCleanup: {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -247,7 +294,7 @@ class SessionListViewModel: ObservableObject {
|
|||
|
||||
func cleanupAllExited() async {
|
||||
do {
|
||||
let cleaned = try await sessionService.cleanupAllExitedSessions()
|
||||
_ = try await sessionService.cleanupAllExitedSessions()
|
||||
await loadSessions()
|
||||
HapticFeedback.notification(.success)
|
||||
} catch {
|
||||
|
|
@ -255,4 +302,16 @@ class SessionListViewModel: ObservableObject {
|
|||
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
|
||||
let onInput: (String) -> Void
|
||||
let onResize: (Int, Int) -> Void
|
||||
@ObservedObject var viewModel: TerminalViewModel
|
||||
|
||||
func makeUIView(context: Context) -> TerminalView {
|
||||
let terminal = TerminalView()
|
||||
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 font
|
||||
updateFont(terminal, size: fontSize)
|
||||
|
||||
// Configure colors
|
||||
configureColors(terminal)
|
||||
|
||||
// Set up delegates
|
||||
terminal.delegate = context.coordinator
|
||||
// SwiftTerm's TerminalView uses terminalDelegate, not delegate
|
||||
terminal.terminalDelegate = context.coordinator
|
||||
|
||||
// Configure terminal options
|
||||
terminal.allowMouseReporting = false
|
||||
terminal.optionAsMetaKey = true
|
||||
|
||||
// Configure font
|
||||
updateFont(terminal, size: fontSize)
|
||||
|
||||
// Start with default size
|
||||
let cols = Int(UIScreen.main.bounds.width / 9) // Approximate char width
|
||||
let rows = 24
|
||||
|
|
@ -36,100 +35,108 @@ struct TerminalHostingView: UIViewRepresentable {
|
|||
return terminal
|
||||
}
|
||||
|
||||
func updateUIView(_ terminal: TerminalView, context: Context) {
|
||||
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, onResize: onResize)
|
||||
Coordinator(
|
||||
onInput: onInput,
|
||||
onResize: onResize,
|
||||
viewModel: viewModel
|
||||
)
|
||||
}
|
||||
|
||||
private func updateFont(_ terminal: TerminalView, size: CGFloat) {
|
||||
if let font = UIFont(name: Theme.Typography.terminalFont, size: size) {
|
||||
terminal.font = font
|
||||
} else if let font = UIFont(name: Theme.Typography.terminalFontFallback, size: size) {
|
||||
terminal.font = font
|
||||
private func updateFont(_ terminal: SwiftTerm.TerminalView, size: CGFloat) {
|
||||
let font: UIFont
|
||||
if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) {
|
||||
font = customFont
|
||||
} else if let fallbackFont = UIFont(name: Theme.Typography.terminalFontFallback, size: size) {
|
||||
font = fallbackFont
|
||||
} 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) {
|
||||
// ANSI colors
|
||||
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 {
|
||||
@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) {
|
||||
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 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) {
|
||||
onInput(string)
|
||||
}
|
||||
}
|
||||
|
||||
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
|
||||
func sizeChanged(source: SwiftTerm.TerminalView, newCols: Int, newRows: Int) {
|
||||
onResize(newCols, newRows)
|
||||
}
|
||||
|
||||
func scrolled(source: TerminalView, position: Double) {
|
||||
func scrolled(source: SwiftTerm.TerminalView, position: Double) {
|
||||
// Handle scroll if needed
|
||||
}
|
||||
|
||||
func setTerminalTitle(source: TerminalView, title: String) {
|
||||
func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) {
|
||||
// Handle title change if needed
|
||||
}
|
||||
|
||||
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
|
||||
func hostCurrentDirectoryUpdate(source: SwiftTerm.TerminalView, directory: String?) {
|
||||
// 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
|
||||
if let url = URL(string: link) {
|
||||
UIApplication.shared.open(url)
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal Control Methods
|
||||
static func feed(_ terminal: TerminalView?, data: String) {
|
||||
guard let terminal = terminal else { return }
|
||||
let bytes = [UInt8](data.utf8)
|
||||
terminal.feed(byteArray: bytes)
|
||||
}
|
||||
|
||||
static func clear(_ terminal: TerminalView?) {
|
||||
terminal?.terminal.resetToInitialState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add conformance with proper isolation
|
||||
extension TerminalHostingView.Coordinator: @preconcurrency SwiftTerm.TerminalViewDelegate {}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import SwiftUI
|
||||
import SwiftTerm
|
||||
import Combine
|
||||
import SwiftTerm
|
||||
|
||||
struct TerminalView: View {
|
||||
let session: Session
|
||||
|
|
@ -130,8 +130,7 @@ struct TerminalView: View {
|
|||
private var terminalContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Terminal hosting view
|
||||
TerminalContainerView(
|
||||
terminal: viewModel.terminal,
|
||||
TerminalHostingView(
|
||||
session: session,
|
||||
fontSize: $fontSize,
|
||||
onInput: { text in
|
||||
|
|
@ -139,8 +138,10 @@ struct TerminalView: View {
|
|||
},
|
||||
onResize: { cols, rows in
|
||||
viewModel.resize(cols: cols, rows: rows)
|
||||
}
|
||||
},
|
||||
viewModel: viewModel
|
||||
)
|
||||
.id(viewModel.terminalViewId)
|
||||
.background(Theme.Colors.terminalBackground)
|
||||
.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
|
||||
class TerminalViewModel: ObservableObject {
|
||||
@Published var isConnecting = true
|
||||
@Published var isConnected = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var terminalViewId = UUID()
|
||||
|
||||
let session: Session
|
||||
var terminal: TerminalView?
|
||||
private var sseClient: SSEClient?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
weak var terminalCoordinator: TerminalHostingView.Coordinator?
|
||||
|
||||
init(session: Session) {
|
||||
self.session = session
|
||||
|
|
@ -248,20 +179,7 @@ class TerminalViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
private func setupTerminal() {
|
||||
let terminal = TerminalView()
|
||||
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)
|
||||
// Terminal setup now handled by SimpleTerminalView
|
||||
}
|
||||
|
||||
func connect() {
|
||||
|
|
@ -274,11 +192,18 @@ class TerminalViewModel: ObservableObject {
|
|||
return
|
||||
}
|
||||
|
||||
// Load existing terminal snapshot first if session is already running
|
||||
if session.isRunning {
|
||||
Task {
|
||||
await loadSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
sseClient = SSEClient()
|
||||
|
||||
Task {
|
||||
for await event in sseClient!.connect(to: streamURL) {
|
||||
await handleTerminalEvent(event)
|
||||
handleTerminalEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,6 +211,21 @@ class TerminalViewModel: ObservableObject {
|
|||
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() {
|
||||
sseClient?.disconnect()
|
||||
sseClient = nil
|
||||
|
|
@ -294,15 +234,32 @@ class TerminalViewModel: ObservableObject {
|
|||
|
||||
@MainActor
|
||||
private func handleTerminalEvent(_ event: TerminalEvent) {
|
||||
switch event.type {
|
||||
case .output:
|
||||
let bytes = [UInt8](event.data.utf8)
|
||||
terminal?.feed(byteArray: bytes)
|
||||
case .resize:
|
||||
// Handle resize if needed
|
||||
break
|
||||
default:
|
||||
break
|
||||
switch event {
|
||||
case .header(let header):
|
||||
// Initial terminal setup
|
||||
print("Terminal initialized: \(header.width)x\(header.height)")
|
||||
// The terminal will be resized when created
|
||||
|
||||
case .output(_, let data):
|
||||
// Feed output data directly to the terminal
|
||||
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) {
|
||||
terminal?.resize(cols: cols, rows: rows)
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await SessionService.shared.resizeTerminal(sessionId: session.id, cols: cols, rows: rows)
|
||||
|
|
@ -329,42 +284,13 @@ class TerminalViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
func clearTerminal() {
|
||||
terminal?.terminal.resetToInitialState()
|
||||
// Reset the terminal by recreating it
|
||||
terminalViewId = UUID()
|
||||
HapticFeedback.impact(.medium)
|
||||
}
|
||||
|
||||
func copyBuffer() {
|
||||
if let text = terminal?.getTerminalText() {
|
||||
UIPasteboard.general.string = text
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Terminal copy is handled by SwiftTerm's built-in functionality
|
||||
HapticFeedback.notification(.success)
|
||||
}
|
||||
}
|
||||
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