mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
towards featire parity on iOS
This commit is contained in:
parent
70f5bf2c18
commit
eee508c36d
16 changed files with 1382 additions and 97 deletions
|
|
@ -10,16 +10,33 @@ struct ContentView: View {
|
||||||
@State private var showingFilePicker = false
|
@State private var showingFilePicker = false
|
||||||
@State private var showingCastPlayer = false
|
@State private var showingCastPlayer = false
|
||||||
@State private var selectedCastFile: URL?
|
@State private var selectedCastFile: URL?
|
||||||
|
@State private var isValidatingConnection = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if connectionManager.isConnected, connectionManager.serverConfig != nil {
|
if isValidatingConnection && connectionManager.isConnected {
|
||||||
|
// Show loading while validating restored connection
|
||||||
|
VStack(spacing: Theme.Spacing.large) {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
||||||
|
.scaleEffect(1.5)
|
||||||
|
|
||||||
|
Text("Restoring connection...")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Theme.Colors.terminalBackground)
|
||||||
|
} else if connectionManager.isConnected, connectionManager.serverConfig != nil {
|
||||||
SessionListView()
|
SessionListView()
|
||||||
} else {
|
} else {
|
||||||
ConnectionView()
|
ConnectionView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.default, value: connectionManager.isConnected)
|
.animation(.default, value: connectionManager.isConnected)
|
||||||
|
.onAppear {
|
||||||
|
validateRestoredConnection()
|
||||||
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
// Handle cast file opening
|
// Handle cast file opening
|
||||||
if url.pathExtension == "cast" {
|
if url.pathExtension == "cast" {
|
||||||
|
|
@ -33,4 +50,30 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func validateRestoredConnection() {
|
||||||
|
guard connectionManager.isConnected,
|
||||||
|
let config = connectionManager.serverConfig else {
|
||||||
|
isValidatingConnection = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the restored connection
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
// Try to fetch sessions to validate connection
|
||||||
|
_ = try await APIClient.shared.getSessions()
|
||||||
|
// Connection is valid
|
||||||
|
await MainActor.run {
|
||||||
|
isValidatingConnection = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Connection failed, reset state
|
||||||
|
await MainActor.run {
|
||||||
|
connectionManager.disconnect()
|
||||||
|
isValidatingConnection = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,17 @@ struct VibeTunnelApp: App {
|
||||||
/// connection-related operations.
|
/// connection-related operations.
|
||||||
@Observable
|
@Observable
|
||||||
class ConnectionManager {
|
class ConnectionManager {
|
||||||
var isConnected: Bool = false
|
var isConnected: Bool = false {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(isConnected, forKey: "connectionState")
|
||||||
|
}
|
||||||
|
}
|
||||||
var serverConfig: ServerConfig?
|
var serverConfig: ServerConfig?
|
||||||
|
var lastConnectionTime: Date?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
loadSavedConnection()
|
loadSavedConnection()
|
||||||
|
restoreConnectionState()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadSavedConnection() {
|
private func loadSavedConnection() {
|
||||||
|
|
@ -52,16 +58,40 @@ class ConnectionManager {
|
||||||
self.serverConfig = config
|
self.serverConfig = config
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func restoreConnectionState() {
|
||||||
|
// Restore connection state if app was terminated while connected
|
||||||
|
let wasConnected = UserDefaults.standard.bool(forKey: "connectionState")
|
||||||
|
if let lastConnectionData = UserDefaults.standard.object(forKey: "lastConnectionTime") as? Date {
|
||||||
|
lastConnectionTime = lastConnectionData
|
||||||
|
|
||||||
|
// Only restore connection if it was within the last hour
|
||||||
|
let timeSinceLastConnection = Date().timeIntervalSince(lastConnectionData)
|
||||||
|
if wasConnected && timeSinceLastConnection < 3600 && serverConfig != nil {
|
||||||
|
// Attempt to restore connection
|
||||||
|
isConnected = true
|
||||||
|
} else {
|
||||||
|
// Clear stale connection state
|
||||||
|
isConnected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func saveConnection(_ config: ServerConfig) {
|
func saveConnection(_ config: ServerConfig) {
|
||||||
if let data = try? JSONEncoder().encode(config) {
|
if let data = try? JSONEncoder().encode(config) {
|
||||||
UserDefaults.standard.set(data, forKey: "savedServerConfig")
|
UserDefaults.standard.set(data, forKey: "savedServerConfig")
|
||||||
self.serverConfig = config
|
self.serverConfig = config
|
||||||
|
|
||||||
|
// Save connection timestamp
|
||||||
|
lastConnectionTime = Date()
|
||||||
|
UserDefaults.standard.set(lastConnectionTime, forKey: "lastConnectionTime")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
isConnected = false
|
isConnected = false
|
||||||
|
UserDefaults.standard.removeObject(forKey: "connectionState")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "lastConnectionTime")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,24 @@ struct FileEntry: Codable, Identifiable {
|
||||||
let modTime: Date
|
let modTime: Date
|
||||||
|
|
||||||
var id: String { path }
|
var id: String { path }
|
||||||
|
|
||||||
|
/// Creates a new FileEntry with the given parameters.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: The file name
|
||||||
|
/// - path: The full path to the file
|
||||||
|
/// - isDir: Whether this entry represents a directory
|
||||||
|
/// - size: The file size in bytes
|
||||||
|
/// - mode: The file permissions mode string
|
||||||
|
/// - modTime: The modification time
|
||||||
|
init(name: String, path: String, isDir: Bool, size: Int64, mode: String, modTime: Date) {
|
||||||
|
self.name = name
|
||||||
|
self.path = path
|
||||||
|
self.isDir = isDir
|
||||||
|
self.size = size
|
||||||
|
self.mode = mode
|
||||||
|
self.modTime = modTime
|
||||||
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
|
|
|
||||||
202
ios/VibeTunnel/Models/TerminalTheme.swift
Normal file
202
ios/VibeTunnel/Models/TerminalTheme.swift
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Terminal color theme definition.
|
||||||
|
struct TerminalTheme: Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
|
||||||
|
// Basic colors
|
||||||
|
let background: Color
|
||||||
|
let foreground: Color
|
||||||
|
let selection: Color
|
||||||
|
let cursor: Color
|
||||||
|
|
||||||
|
// ANSI colors (0-7)
|
||||||
|
let black: Color
|
||||||
|
let red: Color
|
||||||
|
let green: Color
|
||||||
|
let yellow: Color
|
||||||
|
let blue: Color
|
||||||
|
let magenta: Color
|
||||||
|
let cyan: Color
|
||||||
|
let white: Color
|
||||||
|
|
||||||
|
// Bright ANSI colors (8-15)
|
||||||
|
let brightBlack: Color
|
||||||
|
let brightRed: Color
|
||||||
|
let brightGreen: Color
|
||||||
|
let brightYellow: Color
|
||||||
|
let brightBlue: Color
|
||||||
|
let brightMagenta: Color
|
||||||
|
let brightCyan: Color
|
||||||
|
let brightWhite: Color
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Predefined Themes
|
||||||
|
|
||||||
|
extension TerminalTheme {
|
||||||
|
/// VibeTunnel's default dark theme
|
||||||
|
static let vibeTunnel = TerminalTheme(
|
||||||
|
id: "vibetunnel",
|
||||||
|
name: "VibeTunnel",
|
||||||
|
description: "Default VibeTunnel theme with blue accents",
|
||||||
|
background: Theme.Colors.terminalBackground,
|
||||||
|
foreground: Theme.Colors.terminalForeground,
|
||||||
|
selection: Theme.Colors.terminalSelection,
|
||||||
|
cursor: Theme.Colors.primaryAccent,
|
||||||
|
black: Theme.Colors.ansiBlack,
|
||||||
|
red: Theme.Colors.ansiRed,
|
||||||
|
green: Theme.Colors.ansiGreen,
|
||||||
|
yellow: Theme.Colors.ansiYellow,
|
||||||
|
blue: Theme.Colors.ansiBlue,
|
||||||
|
magenta: Theme.Colors.ansiMagenta,
|
||||||
|
cyan: Theme.Colors.ansiCyan,
|
||||||
|
white: Theme.Colors.ansiWhite,
|
||||||
|
brightBlack: Theme.Colors.ansiBrightBlack,
|
||||||
|
brightRed: Theme.Colors.ansiBrightRed,
|
||||||
|
brightGreen: Theme.Colors.ansiBrightGreen,
|
||||||
|
brightYellow: Theme.Colors.ansiBrightYellow,
|
||||||
|
brightBlue: Theme.Colors.ansiBrightBlue,
|
||||||
|
brightMagenta: Theme.Colors.ansiBrightMagenta,
|
||||||
|
brightCyan: Theme.Colors.ansiBrightCyan,
|
||||||
|
brightWhite: Theme.Colors.ansiBrightWhite
|
||||||
|
)
|
||||||
|
|
||||||
|
/// VS Code Dark theme
|
||||||
|
static let vsCodeDark = TerminalTheme(
|
||||||
|
id: "vscode-dark",
|
||||||
|
name: "VS Code Dark",
|
||||||
|
description: "Popular dark theme from Visual Studio Code",
|
||||||
|
background: Color(hex: "1E1E1E"),
|
||||||
|
foreground: Color(hex: "D4D4D4"),
|
||||||
|
selection: Color(hex: "264F78"),
|
||||||
|
cursor: Color(hex: "AEAFAD"),
|
||||||
|
black: Color(hex: "000000"),
|
||||||
|
red: Color(hex: "CD3131"),
|
||||||
|
green: Color(hex: "0DBC79"),
|
||||||
|
yellow: Color(hex: "E5E510"),
|
||||||
|
blue: Color(hex: "2472C8"),
|
||||||
|
magenta: Color(hex: "BC3FBC"),
|
||||||
|
cyan: Color(hex: "11A8CD"),
|
||||||
|
white: Color(hex: "E5E5E5"),
|
||||||
|
brightBlack: Color(hex: "666666"),
|
||||||
|
brightRed: Color(hex: "F14C4C"),
|
||||||
|
brightGreen: Color(hex: "23D18B"),
|
||||||
|
brightYellow: Color(hex: "F5F543"),
|
||||||
|
brightBlue: Color(hex: "3B8EEA"),
|
||||||
|
brightMagenta: Color(hex: "D670D6"),
|
||||||
|
brightCyan: Color(hex: "29B8DB"),
|
||||||
|
brightWhite: Color(hex: "FFFFFF")
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Solarized Dark theme
|
||||||
|
static let solarizedDark = TerminalTheme(
|
||||||
|
id: "solarized-dark",
|
||||||
|
name: "Solarized Dark",
|
||||||
|
description: "Precision colors for machines and people",
|
||||||
|
background: Color(hex: "002B36"),
|
||||||
|
foreground: Color(hex: "839496"),
|
||||||
|
selection: Color(hex: "073642"),
|
||||||
|
cursor: Color(hex: "839496"),
|
||||||
|
black: Color(hex: "073642"),
|
||||||
|
red: Color(hex: "DC322F"),
|
||||||
|
green: Color(hex: "859900"),
|
||||||
|
yellow: Color(hex: "B58900"),
|
||||||
|
blue: Color(hex: "268BD2"),
|
||||||
|
magenta: Color(hex: "D33682"),
|
||||||
|
cyan: Color(hex: "2AA198"),
|
||||||
|
white: Color(hex: "EEE8D5"),
|
||||||
|
brightBlack: Color(hex: "002B36"),
|
||||||
|
brightRed: Color(hex: "CB4B16"),
|
||||||
|
brightGreen: Color(hex: "586E75"),
|
||||||
|
brightYellow: Color(hex: "657B83"),
|
||||||
|
brightBlue: Color(hex: "839496"),
|
||||||
|
brightMagenta: Color(hex: "6C71C4"),
|
||||||
|
brightCyan: Color(hex: "93A1A1"),
|
||||||
|
brightWhite: Color(hex: "FDF6E3")
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Dracula theme
|
||||||
|
static let dracula = TerminalTheme(
|
||||||
|
id: "dracula",
|
||||||
|
name: "Dracula",
|
||||||
|
description: "Dark theme for developers",
|
||||||
|
background: Color(hex: "282A36"),
|
||||||
|
foreground: Color(hex: "F8F8F2"),
|
||||||
|
selection: Color(hex: "44475A"),
|
||||||
|
cursor: Color(hex: "F8F8F2"),
|
||||||
|
black: Color(hex: "21222C"),
|
||||||
|
red: Color(hex: "FF5555"),
|
||||||
|
green: Color(hex: "50FA7B"),
|
||||||
|
yellow: Color(hex: "F1FA8C"),
|
||||||
|
blue: Color(hex: "BD93F9"),
|
||||||
|
magenta: Color(hex: "FF79C6"),
|
||||||
|
cyan: Color(hex: "8BE9FD"),
|
||||||
|
white: Color(hex: "F8F8F2"),
|
||||||
|
brightBlack: Color(hex: "6272A4"),
|
||||||
|
brightRed: Color(hex: "FF6E6E"),
|
||||||
|
brightGreen: Color(hex: "69FF94"),
|
||||||
|
brightYellow: Color(hex: "FFFFA5"),
|
||||||
|
brightBlue: Color(hex: "D6ACFF"),
|
||||||
|
brightMagenta: Color(hex: "FF92DF"),
|
||||||
|
brightCyan: Color(hex: "A4FFFF"),
|
||||||
|
brightWhite: Color(hex: "FFFFFF")
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Nord theme
|
||||||
|
static let nord = TerminalTheme(
|
||||||
|
id: "nord",
|
||||||
|
name: "Nord",
|
||||||
|
description: "An arctic, north-bluish color palette",
|
||||||
|
background: Color(hex: "2E3440"),
|
||||||
|
foreground: Color(hex: "D8DEE9"),
|
||||||
|
selection: Color(hex: "434C5E"),
|
||||||
|
cursor: Color(hex: "D8DEE9"),
|
||||||
|
black: Color(hex: "3B4252"),
|
||||||
|
red: Color(hex: "BF616A"),
|
||||||
|
green: Color(hex: "A3BE8C"),
|
||||||
|
yellow: Color(hex: "EBCB8B"),
|
||||||
|
blue: Color(hex: "81A1C1"),
|
||||||
|
magenta: Color(hex: "B48EAD"),
|
||||||
|
cyan: Color(hex: "88C0D0"),
|
||||||
|
white: Color(hex: "E5E9F0"),
|
||||||
|
brightBlack: Color(hex: "4C566A"),
|
||||||
|
brightRed: Color(hex: "BF616A"),
|
||||||
|
brightGreen: Color(hex: "A3BE8C"),
|
||||||
|
brightYellow: Color(hex: "EBCB8B"),
|
||||||
|
brightBlue: Color(hex: "81A1C1"),
|
||||||
|
brightMagenta: Color(hex: "B48EAD"),
|
||||||
|
brightCyan: Color(hex: "8FBCBB"),
|
||||||
|
brightWhite: Color(hex: "ECEFF4")
|
||||||
|
)
|
||||||
|
|
||||||
|
/// All available themes
|
||||||
|
static let allThemes: [TerminalTheme] = [
|
||||||
|
.vibeTunnel,
|
||||||
|
.vsCodeDark,
|
||||||
|
.solarizedDark,
|
||||||
|
.dracula,
|
||||||
|
.nord
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UserDefaults Storage
|
||||||
|
|
||||||
|
extension TerminalTheme {
|
||||||
|
private static let selectedThemeKey = "selectedTerminalTheme"
|
||||||
|
|
||||||
|
/// Get the currently selected theme from UserDefaults
|
||||||
|
static var selected: TerminalTheme {
|
||||||
|
get {
|
||||||
|
guard let themeId = UserDefaults.standard.string(forKey: selectedThemeKey),
|
||||||
|
let theme = allThemes.first(where: { $0.id == themeId }) else {
|
||||||
|
return .vibeTunnel
|
||||||
|
}
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
UserDefaults.standard.set(newValue.id, forKey: selectedThemeKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -73,6 +73,7 @@ protocol APIClientProtocol {
|
||||||
func killSession(_ sessionId: String) async throws
|
func killSession(_ sessionId: String) async throws
|
||||||
func cleanupSession(_ sessionId: String) async throws
|
func cleanupSession(_ sessionId: String) async throws
|
||||||
func cleanupAllExitedSessions() async throws -> [String]
|
func cleanupAllExitedSessions() async throws -> [String]
|
||||||
|
func killAllSessions() async throws
|
||||||
func sendInput(sessionId: String, text: String) async throws
|
func sendInput(sessionId: String, text: String) async throws
|
||||||
func resizeTerminal(sessionId: String, cols: Int, rows: Int) async throws
|
func resizeTerminal(sessionId: String, cols: Int, rows: Int) async throws
|
||||||
}
|
}
|
||||||
|
|
@ -232,6 +233,23 @@ class APIClient: APIClientProtocol {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func killAllSessions() async throws {
|
||||||
|
// First get all sessions
|
||||||
|
let sessions = try await getSessions()
|
||||||
|
|
||||||
|
// Filter running sessions
|
||||||
|
let runningSessions = sessions.filter { $0.isRunning }
|
||||||
|
|
||||||
|
// Kill each running session concurrently
|
||||||
|
await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
|
for session in runningSessions {
|
||||||
|
group.addTask { [weak self] in
|
||||||
|
try await self?.killSession(session.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Terminal I/O
|
// MARK: - Terminal I/O
|
||||||
|
|
||||||
|
|
@ -315,9 +333,10 @@ class APIClient: APIClientProtocol {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addAuthenticationIfNeeded(_ request: inout URLRequest) {
|
private func addAuthenticationIfNeeded(_ request: inout URLRequest) {
|
||||||
// For now, we don't have authentication configured in the iOS app
|
// Add authorization header from server config
|
||||||
// This is a placeholder for future authentication support
|
if let authHeader = ConnectionManager.shared.currentServerConfig?.authorizationHeader {
|
||||||
// The server might be running without password protection
|
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - File System Operations
|
// MARK: - File System Operations
|
||||||
|
|
@ -390,4 +409,83 @@ class APIClient: APIClientProtocol {
|
||||||
let (_, response) = try await session.data(for: request)
|
let (_, response) = try await session.data(for: request)
|
||||||
try validateResponse(response)
|
try validateResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - File Operations
|
||||||
|
|
||||||
|
/// Read a file's content
|
||||||
|
func readFile(path: String) async throws -> String {
|
||||||
|
guard let baseURL else { throw APIError.noServerConfigured }
|
||||||
|
|
||||||
|
let url = baseURL.appendingPathComponent("api/files/read")
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
addAuthenticationIfNeeded(&request)
|
||||||
|
|
||||||
|
struct ReadFileRequest: Codable {
|
||||||
|
let path: String
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestBody = ReadFileRequest(path: path)
|
||||||
|
request.httpBody = try encoder.encode(requestBody)
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
try validateResponse(response)
|
||||||
|
|
||||||
|
struct ReadFileResponse: Codable {
|
||||||
|
let content: String
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileResponse = try decoder.decode(ReadFileResponse.self, from: data)
|
||||||
|
return fileResponse.content
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new file with content
|
||||||
|
func createFile(path: String, content: String) async throws {
|
||||||
|
guard let baseURL else { throw APIError.noServerConfigured }
|
||||||
|
|
||||||
|
let url = baseURL.appendingPathComponent("api/files/write")
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
addAuthenticationIfNeeded(&request)
|
||||||
|
|
||||||
|
struct WriteFileRequest: Codable {
|
||||||
|
let path: String
|
||||||
|
let content: String
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestBody = WriteFileRequest(path: path, content: content)
|
||||||
|
request.httpBody = try encoder.encode(requestBody)
|
||||||
|
|
||||||
|
let (_, response) = try await session.data(for: request)
|
||||||
|
try validateResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing file's content
|
||||||
|
func updateFile(path: String, content: String) async throws {
|
||||||
|
// For VibeTunnel, write operation handles both create and update
|
||||||
|
try await createFile(path: path, content: content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a file
|
||||||
|
func deleteFile(path: String) async throws {
|
||||||
|
guard let baseURL else { throw APIError.noServerConfigured }
|
||||||
|
|
||||||
|
let url = baseURL.appendingPathComponent("api/files/delete")
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
addAuthenticationIfNeeded(&request)
|
||||||
|
|
||||||
|
struct DeleteFileRequest: Codable {
|
||||||
|
let path: String
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestBody = DeleteFileRequest(path: path)
|
||||||
|
request.httpBody = try encoder.encode(requestBody)
|
||||||
|
|
||||||
|
let (_, response) = try await session.data(for: request)
|
||||||
|
try validateResponse(response)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ class SessionService {
|
||||||
func cleanupAllExitedSessions() async throws -> [String] {
|
func cleanupAllExitedSessions() async throws -> [String] {
|
||||||
try await apiClient.cleanupAllExitedSessions()
|
try await apiClient.cleanupAllExitedSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func killAllSessions() async throws {
|
||||||
|
try await apiClient.killAllSessions()
|
||||||
|
}
|
||||||
|
|
||||||
func sendInput(to sessionId: String, text: String) async throws {
|
func sendInput(to sessionId: String, text: String) async throws {
|
||||||
try await apiClient.sendInput(sessionId: sessionId, text: text)
|
try await apiClient.sendInput(sessionId: sessionId, text: text)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ struct ConnectionView: View {
|
||||||
port: $viewModel.port,
|
port: $viewModel.port,
|
||||||
name: $viewModel.name,
|
name: $viewModel.name,
|
||||||
password: $viewModel.password,
|
password: $viewModel.password,
|
||||||
|
authType: $viewModel.authType,
|
||||||
|
bearerToken: $viewModel.bearerToken,
|
||||||
isConnecting: viewModel.isConnecting,
|
isConnecting: viewModel.isConnecting,
|
||||||
errorMessage: viewModel.errorMessage,
|
errorMessage: viewModel.errorMessage,
|
||||||
onConnect: connectToServer
|
onConnect: connectToServer
|
||||||
|
|
@ -103,6 +105,8 @@ class ConnectionViewModel {
|
||||||
var port: String = "4020"
|
var port: String = "4020"
|
||||||
var name: String = ""
|
var name: String = ""
|
||||||
var password: String = ""
|
var password: String = ""
|
||||||
|
var authType: AuthType = .none
|
||||||
|
var bearerToken: String = ""
|
||||||
var isConnecting: Bool = false
|
var isConnecting: Bool = false
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
|
|
@ -114,6 +118,8 @@ class ConnectionViewModel {
|
||||||
self.port = String(serverConfig.port)
|
self.port = String(serverConfig.port)
|
||||||
self.name = serverConfig.name ?? ""
|
self.name = serverConfig.name ?? ""
|
||||||
self.password = serverConfig.password ?? ""
|
self.password = serverConfig.password ?? ""
|
||||||
|
self.authType = serverConfig.authType
|
||||||
|
self.bearerToken = serverConfig.bearerToken ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +143,9 @@ class ConnectionViewModel {
|
||||||
host: host,
|
host: host,
|
||||||
port: portNumber,
|
port: portNumber,
|
||||||
name: name.isEmpty ? nil : name,
|
name: name.isEmpty ? nil : name,
|
||||||
password: password.isEmpty ? nil : password
|
password: password.isEmpty ? nil : password,
|
||||||
|
authType: authType,
|
||||||
|
bearerToken: bearerToken.isEmpty ? nil : bearerToken
|
||||||
)
|
)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ struct ServerConfigForm: View {
|
||||||
@Binding var port: String
|
@Binding var port: String
|
||||||
@Binding var name: String
|
@Binding var name: String
|
||||||
@Binding var password: String
|
@Binding var password: String
|
||||||
|
@Binding var authType: AuthType
|
||||||
|
@Binding var bearerToken: String
|
||||||
let isConnecting: Bool
|
let isConnecting: Bool
|
||||||
let errorMessage: String?
|
let errorMessage: String?
|
||||||
let onConnect: () -> Void
|
let onConnect: () -> Void
|
||||||
|
|
@ -21,6 +23,7 @@ struct ServerConfigForm: View {
|
||||||
case port
|
case port
|
||||||
case name
|
case name
|
||||||
case password
|
case password
|
||||||
|
case bearerToken
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -75,20 +78,66 @@ struct ServerConfigForm: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password Field (Optional)
|
// Authentication Type
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||||
Label("Password (Optional)", systemImage: "lock")
|
Label("Authentication", systemImage: "lock.shield")
|
||||||
.font(Theme.Typography.terminalSystem(size: 12))
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
|
||||||
SecureField("Enter password if required", text: $password)
|
Picker("Auth Type", selection: $authType) {
|
||||||
.textFieldStyle(TerminalTextFieldStyle())
|
ForEach(AuthType.allCases, id: \.self) { type in
|
||||||
.focused($focusedField, equals: .password)
|
Text(type.displayName).tag(type)
|
||||||
.submitLabel(.done)
|
|
||||||
.onSubmit {
|
|
||||||
focusedField = nil
|
|
||||||
onConnect()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||||
|
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password Field (for Basic Auth)
|
||||||
|
if authType == .basic {
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||||
|
Label("Password", systemImage: "lock")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
|
||||||
|
SecureField("Enter password", text: $password)
|
||||||
|
.textFieldStyle(TerminalTextFieldStyle())
|
||||||
|
.focused($focusedField, equals: .password)
|
||||||
|
.submitLabel(.done)
|
||||||
|
.onSubmit {
|
||||||
|
focusedField = nil
|
||||||
|
onConnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bearer Token Field
|
||||||
|
if authType == .bearer {
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||||
|
Label("Bearer Token", systemImage: "key")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
|
||||||
|
SecureField("Enter bearer token", text: $bearerToken)
|
||||||
|
.textFieldStyle(TerminalTextFieldStyle())
|
||||||
|
.focused($focusedField, equals: .bearerToken)
|
||||||
|
.submitLabel(.done)
|
||||||
|
.onSubmit {
|
||||||
|
focusedField = nil
|
||||||
|
onConnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
@ -165,6 +214,8 @@ struct ServerConfigForm: View {
|
||||||
port = String(server.port)
|
port = String(server.port)
|
||||||
name = server.name ?? ""
|
name = server.name ?? ""
|
||||||
password = server.password ?? ""
|
password = server.password ?? ""
|
||||||
|
authType = server.authType
|
||||||
|
bearerToken = server.bearerToken ?? ""
|
||||||
HapticFeedback.selection()
|
HapticFeedback.selection()
|
||||||
}, label: {
|
}, label: {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,24 @@ import SwiftUI
|
||||||
struct FileBrowserView: View {
|
struct FileBrowserView: View {
|
||||||
@State private var viewModel = FileBrowserViewModel()
|
@State private var viewModel = FileBrowserViewModel()
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var showingFileEditor = false
|
||||||
|
@State private var showingNewFileAlert = false
|
||||||
|
@State private var newFileName = ""
|
||||||
|
@State private var selectedFile: FileEntry?
|
||||||
|
@State private var showingDeleteAlert = false
|
||||||
|
|
||||||
let onSelect: (String) -> Void
|
let onSelect: (String) -> Void
|
||||||
let initialPath: String
|
let initialPath: String
|
||||||
|
let mode: FileBrowserMode
|
||||||
|
|
||||||
init(initialPath: String = "~", onSelect: @escaping (String) -> Void) {
|
enum FileBrowserMode {
|
||||||
|
case selectDirectory
|
||||||
|
case browseFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
init(initialPath: String = "~", mode: FileBrowserMode = .selectDirectory, onSelect: @escaping (String) -> Void) {
|
||||||
self.initialPath = initialPath
|
self.initialPath = initialPath
|
||||||
|
self.mode = mode
|
||||||
self.onSelect = onSelect
|
self.onSelect = onSelect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,10 +79,31 @@ struct FileBrowserView: View {
|
||||||
onTap: {
|
onTap: {
|
||||||
if entry.isDir {
|
if entry.isDir {
|
||||||
viewModel.navigate(to: entry.path)
|
viewModel.navigate(to: entry.path)
|
||||||
|
} else if mode == .browseFiles {
|
||||||
|
// Open file editor for files in browse mode
|
||||||
|
selectedFile = entry
|
||||||
|
showingFileEditor = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
|
.contextMenu {
|
||||||
|
if mode == .browseFiles && !entry.isDir {
|
||||||
|
Button(action: {
|
||||||
|
selectedFile = entry
|
||||||
|
showingFileEditor = true
|
||||||
|
}) {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(role: .destructive, action: {
|
||||||
|
selectedFile = entry
|
||||||
|
showingDeleteAlert = true
|
||||||
|
}) {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
@ -124,29 +157,48 @@ struct FileBrowserView: View {
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
})
|
})
|
||||||
.buttonStyle(TerminalButtonStyle())
|
.buttonStyle(TerminalButtonStyle())
|
||||||
|
|
||||||
|
// Create file button (only in browse mode)
|
||||||
|
if mode == .browseFiles {
|
||||||
|
Button(action: { showingNewFileAlert = true }, label: {
|
||||||
|
Label("new file", systemImage: "doc.badge.plus")
|
||||||
|
.font(.custom("SF Mono", size: 14))
|
||||||
|
.foregroundColor(Theme.Colors.terminalAccent)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(Theme.Colors.terminalAccent.opacity(0.5), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
})
|
||||||
|
.buttonStyle(TerminalButtonStyle())
|
||||||
|
}
|
||||||
|
|
||||||
// Select button
|
// Select button (only in selectDirectory mode)
|
||||||
Button(action: {
|
if mode == .selectDirectory {
|
||||||
onSelect(viewModel.currentPath)
|
Button(action: {
|
||||||
dismiss()
|
onSelect(viewModel.currentPath)
|
||||||
}, label: {
|
dismiss()
|
||||||
Text("select")
|
}, label: {
|
||||||
.font(.custom("SF Mono", size: 14))
|
Text("select")
|
||||||
.foregroundColor(.black)
|
.font(.custom("SF Mono", size: 14))
|
||||||
.padding(.horizontal, 24)
|
.foregroundColor(.black)
|
||||||
.padding(.vertical, 10)
|
.padding(.horizontal, 24)
|
||||||
.background(
|
.padding(.vertical, 10)
|
||||||
RoundedRectangle(cornerRadius: 8)
|
.background(
|
||||||
.fill(Theme.Colors.terminalAccent)
|
RoundedRectangle(cornerRadius: 8)
|
||||||
)
|
.fill(Theme.Colors.terminalAccent)
|
||||||
.overlay(
|
)
|
||||||
RoundedRectangle(cornerRadius: 8)
|
.overlay(
|
||||||
.fill(Theme.Colors.terminalAccent.opacity(0.3))
|
RoundedRectangle(cornerRadius: 8)
|
||||||
.blur(radius: 10)
|
.fill(Theme.Colors.terminalAccent.opacity(0.3))
|
||||||
)
|
.blur(radius: 10)
|
||||||
.contentShape(Rectangle())
|
)
|
||||||
})
|
.contentShape(Rectangle())
|
||||||
.buttonStyle(TerminalButtonStyle())
|
})
|
||||||
|
.buttonStyle(TerminalButtonStyle())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 16)
|
||||||
|
|
@ -175,6 +227,54 @@ struct FileBrowserView: View {
|
||||||
} message: { error in
|
} message: { error in
|
||||||
Text(error)
|
Text(error)
|
||||||
}
|
}
|
||||||
|
.alert("Create File", isPresented: $showingNewFileAlert) {
|
||||||
|
TextField("File name", text: $newFileName)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
newFileName = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Create") {
|
||||||
|
let path = viewModel.currentPath + "/" + newFileName
|
||||||
|
selectedFile = FileEntry(
|
||||||
|
name: newFileName,
|
||||||
|
path: path,
|
||||||
|
isDir: false,
|
||||||
|
size: 0,
|
||||||
|
mode: "0644",
|
||||||
|
modTime: Date()
|
||||||
|
)
|
||||||
|
showingFileEditor = true
|
||||||
|
newFileName = ""
|
||||||
|
}
|
||||||
|
.disabled(newFileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
} message: {
|
||||||
|
Text("Enter a name for the new file")
|
||||||
|
}
|
||||||
|
.alert("Delete File", isPresented: $showingDeleteAlert, presenting: selectedFile) { file in
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await viewModel.deleteFile(path: file.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} message: { file in
|
||||||
|
Text("Are you sure you want to delete '\(file.name)'? This action cannot be undone.")
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingFileEditor) {
|
||||||
|
if let file = selectedFile {
|
||||||
|
FileEditorView(
|
||||||
|
path: file.path,
|
||||||
|
isNewFile: !viewModel.entries.contains(where: { $0.path == file.path })
|
||||||
|
)
|
||||||
|
.onDisappear {
|
||||||
|
// Reload directory to show any new files
|
||||||
|
viewModel.loadDirectory(path: viewModel.currentPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|
@ -369,6 +469,20 @@ class FileBrowserViewModel {
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteFile(path: String) async {
|
||||||
|
do {
|
||||||
|
try await apiClient.deleteFile(path: path)
|
||||||
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||||
|
// Reload directory to reflect deletion
|
||||||
|
await loadDirectoryAsync(path: currentPath)
|
||||||
|
} catch {
|
||||||
|
print("[FileBrowser] Failed to delete file: \(error)")
|
||||||
|
errorMessage = "Failed to delete file: \(error.localizedDescription)"
|
||||||
|
showError = true
|
||||||
|
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|
|
||||||
191
ios/VibeTunnel/Views/FileEditorView.swift
Normal file
191
ios/VibeTunnel/Views/FileEditorView.swift
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// File editor view for creating and editing text files.
|
||||||
|
struct FileEditorView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var viewModel: FileEditorViewModel
|
||||||
|
@State private var showingSaveAlert = false
|
||||||
|
@State private var showingDiscardAlert = false
|
||||||
|
@FocusState private var isTextEditorFocused: Bool
|
||||||
|
|
||||||
|
init(path: String, isNewFile: Bool = false, initialContent: String = "") {
|
||||||
|
self._viewModel = State(initialValue: FileEditorViewModel(
|
||||||
|
path: path,
|
||||||
|
isNewFile: isNewFile,
|
||||||
|
initialContent: initialContent
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ZStack {
|
||||||
|
Theme.Colors.terminalBackground
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Editor
|
||||||
|
ScrollView {
|
||||||
|
TextEditor(text: $viewModel.content)
|
||||||
|
.font(Theme.Typography.terminal(size: 14))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.padding()
|
||||||
|
.focused($isTextEditorFocused)
|
||||||
|
}
|
||||||
|
.background(Theme.Colors.terminalBackground)
|
||||||
|
|
||||||
|
// Status bar
|
||||||
|
HStack(spacing: Theme.Spacing.medium) {
|
||||||
|
if viewModel.hasChanges {
|
||||||
|
Label("Modified", systemImage: "pencil.circle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Theme.Colors.warningAccent)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(viewModel.lineCount) lines")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||||
|
|
||||||
|
Text("•")
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.3))
|
||||||
|
|
||||||
|
Text("\(viewModel.content.count) chars")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, Theme.Spacing.small)
|
||||||
|
.background(Theme.Colors.cardBackground)
|
||||||
|
.overlay(
|
||||||
|
Rectangle()
|
||||||
|
.fill(Theme.Colors.cardBorder)
|
||||||
|
.frame(height: 1),
|
||||||
|
alignment: .top
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(viewModel.filename)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Cancel") {
|
||||||
|
if viewModel.hasChanges {
|
||||||
|
showingDiscardAlert = true
|
||||||
|
} else {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Save") {
|
||||||
|
Task {
|
||||||
|
await viewModel.save()
|
||||||
|
if !viewModel.showError {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(Theme.Colors.successAccent)
|
||||||
|
.disabled(!viewModel.hasChanges && !viewModel.isNewFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Discard Changes?", isPresented: $showingDiscardAlert) {
|
||||||
|
Button("Discard", role: .destructive) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("You have unsaved changes. Are you sure you want to discard them?")
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: $viewModel.showError, presenting: viewModel.errorMessage) { _ in
|
||||||
|
Button("OK") {}
|
||||||
|
} message: { error in
|
||||||
|
Text(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
.onAppear {
|
||||||
|
if !viewModel.isNewFile {
|
||||||
|
Task {
|
||||||
|
await viewModel.loadFile()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isTextEditorFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View model for file editing operations.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
class FileEditorViewModel {
|
||||||
|
var content = ""
|
||||||
|
var originalContent = ""
|
||||||
|
var isLoading = false
|
||||||
|
var showError = false
|
||||||
|
var errorMessage: String?
|
||||||
|
|
||||||
|
let path: String
|
||||||
|
let isNewFile: Bool
|
||||||
|
|
||||||
|
var filename: String {
|
||||||
|
if isNewFile {
|
||||||
|
return "New File"
|
||||||
|
}
|
||||||
|
return URL(fileURLWithPath: path).lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasChanges: Bool {
|
||||||
|
content != originalContent
|
||||||
|
}
|
||||||
|
|
||||||
|
var lineCount: Int {
|
||||||
|
content.isEmpty ? 1 : content.components(separatedBy: .newlines).count
|
||||||
|
}
|
||||||
|
|
||||||
|
init(path: String, isNewFile: Bool, initialContent: String = "") {
|
||||||
|
self.path = path
|
||||||
|
self.isNewFile = isNewFile
|
||||||
|
self.content = initialContent
|
||||||
|
self.originalContent = initialContent
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadFile() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let fileContent = try await APIClient.shared.readFile(path: path)
|
||||||
|
content = fileContent
|
||||||
|
originalContent = fileContent
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to load file: \(error.localizedDescription)"
|
||||||
|
showError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() async {
|
||||||
|
do {
|
||||||
|
if isNewFile {
|
||||||
|
try await APIClient.shared.createFile(path: path, content: content)
|
||||||
|
} else {
|
||||||
|
try await APIClient.shared.updateFile(path: path, content: content)
|
||||||
|
}
|
||||||
|
originalContent = content
|
||||||
|
HapticFeedback.notification(.success)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to save file: \(error.localizedDescription)"
|
||||||
|
showError = true
|
||||||
|
HapticFeedback.notification(.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
FileEditorView(path: "/tmp/test.txt", isNewFile: true)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import SwiftUI
|
||||||
/// Applies terminal-themed styling to text fields including
|
/// Applies terminal-themed styling to text fields including
|
||||||
/// monospace font, dark background, and subtle border.
|
/// monospace font, dark background, and subtle border.
|
||||||
struct TerminalTextFieldStyle: TextFieldStyle {
|
struct TerminalTextFieldStyle: TextFieldStyle {
|
||||||
|
// swiftlint:disable:next identifier_name
|
||||||
func _body(configuration: TextField<Self._Label>) -> some View {
|
func _body(configuration: TextField<Self._Label>) -> some View {
|
||||||
configuration
|
configuration
|
||||||
.font(Theme.Typography.terminalSystem(size: 16))
|
.font(Theme.Typography.terminalSystem(size: 16))
|
||||||
|
|
@ -29,7 +30,7 @@ struct SessionCreateView: View {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
let onCreated: (String) -> Void
|
let onCreated: (String) -> Void
|
||||||
|
|
||||||
@State private var command = "claude"
|
@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
|
||||||
|
|
@ -37,6 +38,7 @@ struct SessionCreateView: View {
|
||||||
@State private var showFileBrowser = false
|
@State private var showFileBrowser = false
|
||||||
|
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
enum Field {
|
enum Field {
|
||||||
case command
|
case command
|
||||||
|
|
@ -57,7 +59,7 @@ struct SessionCreateView: View {
|
||||||
// Command Field
|
// Command Field
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||||
Label("Command", systemImage: "terminal")
|
Label("Command", systemImage: "terminal")
|
||||||
.font(Theme.Typography.terminalSystem(size: 12))
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
|
||||||
TextField("zsh", text: $command)
|
TextField("zsh", text: $command)
|
||||||
|
|
@ -70,7 +72,7 @@ struct SessionCreateView: View {
|
||||||
// Working Directory
|
// Working Directory
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||||
Label("Working Directory", systemImage: "folder")
|
Label("Working Directory", systemImage: "folder")
|
||||||
.font(Theme.Typography.terminalSystem(size: 12))
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
|
||||||
HStack(spacing: Theme.Spacing.small) {
|
HStack(spacing: Theme.Spacing.small) {
|
||||||
|
|
@ -89,12 +91,8 @@ struct SessionCreateView: View {
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
.fill(Theme.Colors.cardBorder.opacity(0.1))
|
.fill(Theme.Colors.primaryAccent)
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
|
||||||
.stroke(Theme.Colors.cardBorder.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
|
@ -104,7 +102,7 @@ struct SessionCreateView: View {
|
||||||
// Session Name
|
// Session Name
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||||
Label("Session Name (Optional)", systemImage: "tag")
|
Label("Session Name (Optional)", systemImage: "tag")
|
||||||
.font(Theme.Typography.terminalSystem(size: 12))
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
|
||||||
TextField("My Session", text: $sessionName)
|
TextField("My Session", text: $sessionName)
|
||||||
|
|
@ -193,52 +191,51 @@ struct SessionCreateView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick Start Commands
|
// Quick Start Commands
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
|
||||||
Text("QUICK START")
|
Text("QUICK START")
|
||||||
.font(Theme.Typography.terminalSystem(size: 10))
|
.font(Theme.Typography.terminalSystem(size: 11))
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.4))
|
||||||
.tracking(1)
|
.tracking(0.5)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
LazyVGrid(columns: [
|
LazyVGrid(columns: [
|
||||||
GridItem(.flexible()),
|
GridItem(.flexible()),
|
||||||
GridItem(.flexible())
|
GridItem(.flexible())
|
||||||
], spacing: Theme.Spacing.small) {
|
], spacing: Theme.Spacing.small) {
|
||||||
ForEach(recentCommands, id: \.self) { cmd in
|
ForEach(quickStartCommands, id: \.title) { item in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
command = cmd
|
command = item.command
|
||||||
HapticFeedback.selection()
|
HapticFeedback.selection()
|
||||||
}, label: {
|
}, label: {
|
||||||
HStack {
|
HStack(spacing: Theme.Spacing.small) {
|
||||||
Image(systemName: commandIcon(for: cmd))
|
Image(systemName: item.icon)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 16))
|
||||||
Text(cmd)
|
.frame(width: 20)
|
||||||
.font(Theme.Typography.terminalSystem(size: 14))
|
Text(item.title)
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 15))
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.foregroundColor(command == cmd ? Theme.Colors.terminalBackground : Theme.Colors
|
.foregroundColor(command == item.command ? Theme.Colors.terminalBackground : Theme.Colors
|
||||||
.terminalForeground
|
.terminalForeground
|
||||||
)
|
)
|
||||||
.padding(.horizontal, Theme.Spacing.medium)
|
.padding(.horizontal, Theme.Spacing.medium)
|
||||||
.padding(.vertical, Theme.Spacing.small)
|
.padding(.vertical, 14)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
.fill(command == cmd ? Theme.Colors.primaryAccent : Theme.Colors
|
.fill(command == item.command ? Theme.Colors.primaryAccent : Theme.Colors
|
||||||
.cardBorder.opacity(0.3)
|
.cardBackground
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
.stroke(
|
.stroke(
|
||||||
command == cmd ? Theme.Colors.primaryAccent : Theme.Colors
|
command == item.command ? Theme.Colors.primaryAccent : Theme.Colors
|
||||||
.cardBorder,
|
.cardBorder.opacity(0.3),
|
||||||
lineWidth: 1
|
lineWidth: 1
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
.scaleEffect(command == cmd ? 0.95 : 1.0)
|
|
||||||
.animation(Theme.Animation.quick, value: command == cmd)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
@ -246,6 +243,8 @@ struct SessionCreateView: View {
|
||||||
|
|
||||||
Spacer(minLength: 40)
|
Spacer(minLength: 40)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: horizontalSizeClass == .regular ? 600 : .infinity)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
|
|
@ -296,8 +295,8 @@ struct SessionCreateView: View {
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
.disabled(isCreating || command.isEmpty)
|
.disabled(isCreating || command.isEmpty)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
.frame(height: 56) // Fixed height for the header
|
.frame(height: 56) // Fixed height for the header
|
||||||
.overlay(
|
.overlay(
|
||||||
|
|
@ -322,8 +321,21 @@ struct SessionCreateView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var recentCommands: [String] {
|
private struct QuickStartItem {
|
||||||
["claude", "zsh", "bash", "python3", "node", "npm run dev"]
|
let title: String
|
||||||
|
let command: String
|
||||||
|
let icon: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private var quickStartCommands: [QuickStartItem] {
|
||||||
|
[
|
||||||
|
QuickStartItem(title: "claude", command: "claude", icon: "sparkle"),
|
||||||
|
QuickStartItem(title: "zsh", command: "zsh", icon: "terminal"),
|
||||||
|
QuickStartItem(title: "bash", command: "bash", icon: "terminal.fill"),
|
||||||
|
QuickStartItem(title: "python3", command: "python3", icon: "chevron.left.forwardslash.chevron.right"),
|
||||||
|
QuickStartItem(title: "node", command: "node", icon: "server.rack"),
|
||||||
|
QuickStartItem(title: "npm run dev", command: "npm run dev", icon: "play.circle")
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private var commonDirectories: [String] {
|
private var commonDirectories: [String] {
|
||||||
|
|
@ -353,6 +365,9 @@ struct SessionCreateView: View {
|
||||||
// Load last used values
|
// Load last used values
|
||||||
if let lastCommand = UserDefaults.standard.string(forKey: "lastCommand") {
|
if let lastCommand = UserDefaults.standard.string(forKey: "lastCommand") {
|
||||||
command = lastCommand
|
command = lastCommand
|
||||||
|
} else {
|
||||||
|
// Default to zsh
|
||||||
|
command = "zsh"
|
||||||
}
|
}
|
||||||
if let lastDir = UserDefaults.standard.string(forKey: "lastWorkingDir") {
|
if let lastDir = UserDefaults.standard.string(forKey: "lastWorkingDir") {
|
||||||
workingDirectory = lastDir
|
workingDirectory = lastDir
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,36 @@ struct SessionListView: View {
|
||||||
@State private var showingCreateSession = false
|
@State private var showingCreateSession = false
|
||||||
@State private var selectedSession: Session?
|
@State private var selectedSession: Session?
|
||||||
@State private var showExitedSessions = true
|
@State private var showExitedSessions = true
|
||||||
|
@State private var showingFileBrowser = false
|
||||||
|
@State private var searchText = ""
|
||||||
|
|
||||||
|
var filteredSessions: [Session] {
|
||||||
|
let sessions = viewModel.sessions.filter { showExitedSessions || $0.isRunning }
|
||||||
|
|
||||||
|
if searchText.isEmpty {
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions.filter { session in
|
||||||
|
// Search in session name
|
||||||
|
if let name = session.name, name.localizedCaseInsensitiveContains(searchText) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Search in command
|
||||||
|
if session.command.localizedCaseInsensitiveContains(searchText) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Search in working directory
|
||||||
|
if session.cwd.localizedCaseInsensitiveContains(searchText) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Search in PID
|
||||||
|
if let pid = session.pid, String(pid).contains(searchText) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
|
@ -26,6 +56,8 @@ struct SessionListView: View {
|
||||||
.font(Theme.Typography.terminalSystem(size: 14))
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
.foregroundColor(Theme.Colors.terminalForeground)
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if filteredSessions.isEmpty && !searchText.isEmpty {
|
||||||
|
noSearchResultsView
|
||||||
} else if viewModel.sessions.isEmpty {
|
} else if viewModel.sessions.isEmpty {
|
||||||
emptyStateView
|
emptyStateView
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -49,14 +81,25 @@ struct SessionListView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: {
|
HStack(spacing: Theme.Spacing.medium) {
|
||||||
HapticFeedback.impact(.light)
|
Button(action: {
|
||||||
showingCreateSession = true
|
HapticFeedback.impact(.light)
|
||||||
}, label: {
|
showingFileBrowser = true
|
||||||
Image(systemName: "plus.circle.fill")
|
}, label: {
|
||||||
.font(.title3)
|
Image(systemName: "folder.fill")
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.font(.title3)
|
||||||
})
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
})
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
HapticFeedback.impact(.light)
|
||||||
|
showingCreateSession = true
|
||||||
|
}, label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingCreateSession) {
|
.sheet(isPresented: $showingCreateSession) {
|
||||||
|
|
@ -73,9 +116,15 @@ struct SessionListView: View {
|
||||||
.sheet(item: $selectedSession) { session in
|
.sheet(item: $selectedSession) { session in
|
||||||
TerminalView(session: session)
|
TerminalView(session: session)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingFileBrowser) {
|
||||||
|
FileBrowserView(mode: .browseFiles) { path in
|
||||||
|
// For browse mode, we don't need to handle path selection
|
||||||
|
}
|
||||||
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.loadSessions()
|
await viewModel.loadSessions()
|
||||||
}
|
}
|
||||||
|
.searchable(text: $searchText, prompt: "Search sessions")
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.startAutoRefresh()
|
viewModel.startAutoRefresh()
|
||||||
}
|
}
|
||||||
|
|
@ -136,6 +185,32 @@ struct SessionListView: View {
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var noSearchResultsView: some View {
|
||||||
|
VStack(spacing: Theme.Spacing.extraLarge) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.3))
|
||||||
|
|
||||||
|
VStack(spacing: Theme.Spacing.small) {
|
||||||
|
Text("No sessions found")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
|
||||||
|
Text("Try searching with different keywords")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: { searchText = "" }) {
|
||||||
|
Label("Clear Search", systemImage: "xmark.circle.fill")
|
||||||
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
|
}
|
||||||
|
.terminalButton()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
private var sessionList: some View {
|
private var sessionList: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|
@ -156,7 +231,7 @@ struct SessionListView: View {
|
||||||
GridItem(.flexible(), spacing: Theme.Spacing.medium),
|
GridItem(.flexible(), spacing: Theme.Spacing.medium),
|
||||||
GridItem(.flexible(), spacing: Theme.Spacing.medium)
|
GridItem(.flexible(), spacing: Theme.Spacing.medium)
|
||||||
], spacing: Theme.Spacing.medium) {
|
], spacing: Theme.Spacing.medium) {
|
||||||
if showExitedSessions && viewModel.sessions.contains(where: { !$0.isRunning }) {
|
if showExitedSessions && filteredSessions.contains(where: { !$0.isRunning }) {
|
||||||
CleanupAllButton {
|
CleanupAllButton {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.cleanupAllExited()
|
await viewModel.cleanupAllExited()
|
||||||
|
|
@ -164,7 +239,7 @@ struct SessionListView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(viewModel.sessions.filter { showExitedSessions || $0.isRunning }) { session in
|
ForEach(filteredSessions) { session in
|
||||||
SessionCardView(session: session) {
|
SessionCardView(session: session) {
|
||||||
HapticFeedback.selection()
|
HapticFeedback.selection()
|
||||||
if session.isRunning {
|
if session.isRunning {
|
||||||
|
|
@ -271,15 +346,14 @@ class SessionListViewModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
func killAllSessions() async {
|
func killAllSessions() async {
|
||||||
let runningSessions = sessions.filter(\.isRunning)
|
do {
|
||||||
for session in runningSessions {
|
try await sessionService.killAllSessions()
|
||||||
do {
|
await loadSessions()
|
||||||
try await sessionService.killSession(session.id)
|
HapticFeedback.notification(.success)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
}
|
HapticFeedback.notification(.error)
|
||||||
}
|
}
|
||||||
await loadSessions()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import SwiftUI
|
||||||
struct TerminalHostingView: UIViewRepresentable {
|
struct TerminalHostingView: UIViewRepresentable {
|
||||||
let session: Session
|
let session: Session
|
||||||
@Binding var fontSize: CGFloat
|
@Binding var fontSize: CGFloat
|
||||||
|
let theme: TerminalTheme
|
||||||
let onInput: (String) -> Void
|
let onInput: (String) -> Void
|
||||||
let onResize: (Int, Int) -> Void
|
let onResize: (Int, Int) -> Void
|
||||||
var viewModel: TerminalViewModel
|
var viewModel: TerminalViewModel
|
||||||
|
|
@ -16,10 +17,36 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
|
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
|
||||||
let terminal = SwiftTerm.TerminalView()
|
let terminal = SwiftTerm.TerminalView()
|
||||||
|
|
||||||
// Configure terminal appearance
|
// Configure terminal appearance with theme
|
||||||
terminal.backgroundColor = UIColor(Theme.Colors.terminalBackground)
|
terminal.backgroundColor = UIColor(theme.background)
|
||||||
terminal.nativeForegroundColor = UIColor(Theme.Colors.terminalForeground)
|
terminal.nativeForegroundColor = UIColor(theme.foreground)
|
||||||
terminal.nativeBackgroundColor = UIColor(Theme.Colors.terminalBackground)
|
terminal.nativeBackgroundColor = UIColor(theme.background)
|
||||||
|
|
||||||
|
// Set ANSI colors from theme
|
||||||
|
terminal.installColors([
|
||||||
|
UIColor(theme.black), // 0 - Black
|
||||||
|
UIColor(theme.red), // 1 - Red
|
||||||
|
UIColor(theme.green), // 2 - Green
|
||||||
|
UIColor(theme.yellow), // 3 - Yellow
|
||||||
|
UIColor(theme.blue), // 4 - Blue
|
||||||
|
UIColor(theme.magenta), // 5 - Magenta
|
||||||
|
UIColor(theme.cyan), // 6 - Cyan
|
||||||
|
UIColor(theme.white), // 7 - White
|
||||||
|
UIColor(theme.brightBlack), // 8 - Bright Black
|
||||||
|
UIColor(theme.brightRed), // 9 - Bright Red
|
||||||
|
UIColor(theme.brightGreen), // 10 - Bright Green
|
||||||
|
UIColor(theme.brightYellow), // 11 - Bright Yellow
|
||||||
|
UIColor(theme.brightBlue), // 12 - Bright Blue
|
||||||
|
UIColor(theme.brightMagenta), // 13 - Bright Magenta
|
||||||
|
UIColor(theme.brightCyan), // 14 - Bright Cyan
|
||||||
|
UIColor(theme.brightWhite) // 15 - Bright White
|
||||||
|
])
|
||||||
|
|
||||||
|
// Set cursor color
|
||||||
|
terminal.caretColor = UIColor(theme.cursor)
|
||||||
|
|
||||||
|
// Set selection color
|
||||||
|
terminal.selectedTextBackgroundColor = UIColor(theme.selection)
|
||||||
|
|
||||||
// Set up delegates
|
// Set up delegates
|
||||||
// SwiftTerm's TerminalView uses terminalDelegate, not delegate
|
// SwiftTerm's TerminalView uses terminalDelegate, not delegate
|
||||||
|
|
@ -46,6 +73,33 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
|
|
||||||
func updateUIView(_ terminal: SwiftTerm.TerminalView, context: Context) {
|
func updateUIView(_ terminal: SwiftTerm.TerminalView, context: Context) {
|
||||||
updateFont(terminal, size: fontSize)
|
updateFont(terminal, size: fontSize)
|
||||||
|
|
||||||
|
// Update theme colors
|
||||||
|
terminal.backgroundColor = UIColor(theme.background)
|
||||||
|
terminal.nativeForegroundColor = UIColor(theme.foreground)
|
||||||
|
terminal.nativeBackgroundColor = UIColor(theme.background)
|
||||||
|
terminal.caretColor = UIColor(theme.cursor)
|
||||||
|
terminal.selectedTextBackgroundColor = UIColor(theme.selection)
|
||||||
|
|
||||||
|
// Update ANSI colors
|
||||||
|
terminal.installColors([
|
||||||
|
UIColor(theme.black), // 0 - Black
|
||||||
|
UIColor(theme.red), // 1 - Red
|
||||||
|
UIColor(theme.green), // 2 - Green
|
||||||
|
UIColor(theme.yellow), // 3 - Yellow
|
||||||
|
UIColor(theme.blue), // 4 - Blue
|
||||||
|
UIColor(theme.magenta), // 5 - Magenta
|
||||||
|
UIColor(theme.cyan), // 6 - Cyan
|
||||||
|
UIColor(theme.white), // 7 - White
|
||||||
|
UIColor(theme.brightBlack), // 8 - Bright Black
|
||||||
|
UIColor(theme.brightRed), // 9 - Bright Red
|
||||||
|
UIColor(theme.brightGreen), // 10 - Bright Green
|
||||||
|
UIColor(theme.brightYellow), // 11 - Bright Yellow
|
||||||
|
UIColor(theme.brightBlue), // 12 - Bright Blue
|
||||||
|
UIColor(theme.brightMagenta), // 13 - Bright Magenta
|
||||||
|
UIColor(theme.brightCyan), // 14 - Bright Cyan
|
||||||
|
UIColor(theme.brightWhite) // 15 - Bright White
|
||||||
|
])
|
||||||
|
|
||||||
// Update terminal content from viewModel
|
// Update terminal content from viewModel
|
||||||
context.coordinator.terminal = terminal
|
context.coordinator.terminal = terminal
|
||||||
|
|
|
||||||
166
ios/VibeTunnel/Views/Terminal/TerminalThemeSheet.swift
Normal file
166
ios/VibeTunnel/Views/Terminal/TerminalThemeSheet.swift
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Sheet for selecting terminal color themes.
|
||||||
|
struct TerminalThemeSheet: View {
|
||||||
|
@Binding var selectedTheme: TerminalTheme
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: Theme.Spacing.large) {
|
||||||
|
// Current theme preview
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||||
|
Text("Preview")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||||
|
|
||||||
|
TerminalThemePreview(theme: selectedTheme)
|
||||||
|
.frame(height: 120)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top)
|
||||||
|
|
||||||
|
// Theme list
|
||||||
|
VStack(spacing: Theme.Spacing.medium) {
|
||||||
|
ForEach(TerminalTheme.allThemes) { theme in
|
||||||
|
Button(action: {
|
||||||
|
selectedTheme = theme
|
||||||
|
HapticFeedback.impact(.light)
|
||||||
|
// Save to UserDefaults
|
||||||
|
TerminalTheme.selected = theme
|
||||||
|
}) {
|
||||||
|
HStack(spacing: Theme.Spacing.medium) {
|
||||||
|
// Color preview
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
ForEach([theme.red, theme.green, theme.yellow, theme.blue], id: \.self) { color in
|
||||||
|
Rectangle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 8, height: 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cornerRadius(4)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Theme info
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.extraSmall) {
|
||||||
|
Text(theme.name)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
|
||||||
|
Text(theme.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Selection indicator
|
||||||
|
if selectedTheme.id == theme.id {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(Theme.Colors.successAccent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
.fill(selectedTheme.id == theme.id
|
||||||
|
? Theme.Colors.primaryAccent.opacity(0.1)
|
||||||
|
: Theme.Colors.cardBorder.opacity(0.1))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
.stroke(selectedTheme.id == theme.id
|
||||||
|
? Theme.Colors.primaryAccent
|
||||||
|
: Theme.Colors.cardBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Spacer(minLength: Theme.Spacing.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Theme.Colors.cardBackground)
|
||||||
|
.navigationTitle("Terminal Theme")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Done") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preview of a terminal theme showing sample text with colors.
|
||||||
|
struct TerminalThemePreview: View {
|
||||||
|
let theme: TerminalTheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
// Terminal prompt with colors
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text("user")
|
||||||
|
.foregroundColor(theme.green)
|
||||||
|
Text("@")
|
||||||
|
.foregroundColor(theme.foreground)
|
||||||
|
Text("vibetunnel")
|
||||||
|
.foregroundColor(theme.blue)
|
||||||
|
Text(":")
|
||||||
|
.foregroundColor(theme.foreground)
|
||||||
|
Text("~/projects")
|
||||||
|
.foregroundColor(theme.cyan)
|
||||||
|
Text(" $ ")
|
||||||
|
.foregroundColor(theme.foreground)
|
||||||
|
}
|
||||||
|
.font(Theme.Typography.terminal(size: 12))
|
||||||
|
|
||||||
|
// Sample command
|
||||||
|
Text("git status")
|
||||||
|
.foregroundColor(theme.foreground)
|
||||||
|
.font(Theme.Typography.terminal(size: 12))
|
||||||
|
|
||||||
|
// Sample output with different colors
|
||||||
|
Text("On branch ")
|
||||||
|
.foregroundColor(theme.foreground) +
|
||||||
|
Text("main")
|
||||||
|
.foregroundColor(theme.green)
|
||||||
|
|
||||||
|
Text("Changes not staged for commit:")
|
||||||
|
.foregroundColor(theme.red)
|
||||||
|
.font(Theme.Typography.terminal(size: 12))
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text(" modified: ")
|
||||||
|
.foregroundColor(theme.red)
|
||||||
|
Text("file.swift")
|
||||||
|
.foregroundColor(theme.foreground)
|
||||||
|
}
|
||||||
|
.font(Theme.Typography.terminal(size: 12))
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(theme.background)
|
||||||
|
.cornerRadius(Theme.CornerRadius.medium)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
TerminalThemeSheet(selectedTheme: .constant(TerminalTheme.vibeTunnel))
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,10 @@ struct TerminalView: View {
|
||||||
@State private var fontSize: CGFloat = 14
|
@State private var fontSize: CGFloat = 14
|
||||||
@State private var showingFontSizeSheet = false
|
@State private var showingFontSizeSheet = false
|
||||||
@State private var showingRecordingSheet = false
|
@State private var showingRecordingSheet = false
|
||||||
|
@State private var showingTerminalWidthSheet = false
|
||||||
|
@State private var showingTerminalThemeSheet = false
|
||||||
|
@State private var selectedTerminalWidth: Int?
|
||||||
|
@State private var selectedTheme = TerminalTheme.selected
|
||||||
@State private var keyboardHeight: CGFloat = 0
|
@State private var keyboardHeight: CGFloat = 0
|
||||||
@FocusState private var isInputFocused: Bool
|
@FocusState private var isInputFocused: Bool
|
||||||
|
|
||||||
|
|
@ -25,7 +29,7 @@ struct TerminalView: View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Background
|
// Background
|
||||||
Theme.Colors.terminalBackground
|
selectedTheme.background
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
// Terminal content
|
// Terminal content
|
||||||
|
|
@ -62,6 +66,14 @@ struct TerminalView: View {
|
||||||
Label("Font Size", systemImage: "textformat.size")
|
Label("Font Size", systemImage: "textformat.size")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Button(action: { showingTerminalWidthSheet = true }, label: {
|
||||||
|
Label("Terminal Width", systemImage: "arrow.left.and.right")
|
||||||
|
})
|
||||||
|
|
||||||
|
Button(action: { showingTerminalThemeSheet = true }, label: {
|
||||||
|
Label("Theme", systemImage: "paintbrush")
|
||||||
|
})
|
||||||
|
|
||||||
Button(action: { viewModel.copyBuffer() }, label: {
|
Button(action: { viewModel.copyBuffer() }, label: {
|
||||||
Label("Copy All", systemImage: "doc.on.doc")
|
Label("Copy All", systemImage: "doc.on.doc")
|
||||||
})
|
})
|
||||||
|
|
@ -98,6 +110,15 @@ struct TerminalView: View {
|
||||||
.sheet(isPresented: $showingRecordingSheet) {
|
.sheet(isPresented: $showingRecordingSheet) {
|
||||||
RecordingExportSheet(recorder: viewModel.castRecorder, sessionName: session.displayName)
|
RecordingExportSheet(recorder: viewModel.castRecorder, sessionName: session.displayName)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingTerminalWidthSheet) {
|
||||||
|
TerminalWidthSheet(selectedWidth: $selectedTerminalWidth)
|
||||||
|
.onAppear {
|
||||||
|
selectedTerminalWidth = viewModel.terminalCols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingTerminalThemeSheet) {
|
||||||
|
TerminalThemeSheet(selectedTheme: $selectedTheme)
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .bottomBar) {
|
ToolbarItemGroup(placement: .bottomBar) {
|
||||||
if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 {
|
if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 {
|
||||||
|
|
@ -190,6 +211,14 @@ struct TerminalView: View {
|
||||||
keyboardHeight = 0
|
keyboardHeight = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: selectedTerminalWidth) { oldValue, newValue in
|
||||||
|
if let width = newValue, width != viewModel.terminalCols {
|
||||||
|
// Calculate appropriate height based on aspect ratio
|
||||||
|
let aspectRatio = Double(viewModel.terminalRows) / Double(viewModel.terminalCols)
|
||||||
|
let newHeight = Int(Double(width) * aspectRatio)
|
||||||
|
viewModel.resize(cols: width, rows: newHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var loadingView: some View {
|
private var loadingView: some View {
|
||||||
|
|
@ -235,6 +264,7 @@ struct TerminalView: View {
|
||||||
TerminalHostingView(
|
TerminalHostingView(
|
||||||
session: session,
|
session: session,
|
||||||
fontSize: $fontSize,
|
fontSize: $fontSize,
|
||||||
|
theme: selectedTheme,
|
||||||
onInput: { text in
|
onInput: { text in
|
||||||
viewModel.sendInput(text)
|
viewModel.sendInput(text)
|
||||||
},
|
},
|
||||||
|
|
@ -246,7 +276,7 @@ struct TerminalView: View {
|
||||||
viewModel: viewModel
|
viewModel: viewModel
|
||||||
)
|
)
|
||||||
.id(viewModel.terminalViewId)
|
.id(viewModel.terminalViewId)
|
||||||
.background(Theme.Colors.terminalBackground)
|
.background(selectedTheme.background)
|
||||||
.focused($isInputFocused)
|
.focused($isInputFocused)
|
||||||
|
|
||||||
// Keyboard toolbar
|
// Keyboard toolbar
|
||||||
|
|
|
||||||
187
ios/VibeTunnel/Views/Terminal/TerminalWidthSheet.swift
Normal file
187
ios/VibeTunnel/Views/Terminal/TerminalWidthSheet.swift
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Sheet for selecting terminal width presets.
|
||||||
|
///
|
||||||
|
/// Provides common terminal width options (80, 100, 120, 132, 160 columns)
|
||||||
|
/// with descriptions of their typical use cases.
|
||||||
|
struct TerminalWidthSheet: View {
|
||||||
|
@Binding var selectedWidth: Int?
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
struct WidthPreset {
|
||||||
|
let columns: Int
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let icon: String
|
||||||
|
}
|
||||||
|
|
||||||
|
let widthPresets: [WidthPreset] = [
|
||||||
|
WidthPreset(
|
||||||
|
columns: 80,
|
||||||
|
name: "Classic",
|
||||||
|
description: "Traditional terminal width, ideal for legacy apps",
|
||||||
|
icon: "rectangle.split.3x1"
|
||||||
|
),
|
||||||
|
WidthPreset(
|
||||||
|
columns: 100,
|
||||||
|
name: "Comfortable",
|
||||||
|
description: "Good balance for modern development",
|
||||||
|
icon: "rectangle.split.3x1.fill"
|
||||||
|
),
|
||||||
|
WidthPreset(
|
||||||
|
columns: 120,
|
||||||
|
name: "Standard",
|
||||||
|
description: "Common IDE and editor width",
|
||||||
|
icon: "rectangle.3.offgrid"
|
||||||
|
),
|
||||||
|
WidthPreset(
|
||||||
|
columns: 132,
|
||||||
|
name: "Wide",
|
||||||
|
description: "DEC VT100 wide mode, great for logs",
|
||||||
|
icon: "rectangle.3.offgrid.fill"
|
||||||
|
),
|
||||||
|
WidthPreset(
|
||||||
|
columns: 160,
|
||||||
|
name: "Ultra Wide",
|
||||||
|
description: "Maximum visibility for complex output",
|
||||||
|
icon: "rectangle.grid.3x2"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: Theme.Spacing.large) {
|
||||||
|
// Info header
|
||||||
|
HStack(spacing: Theme.Spacing.small) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
|
||||||
|
Text("Terminal width determines how many characters fit on each line")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top)
|
||||||
|
|
||||||
|
// Width presets
|
||||||
|
VStack(spacing: Theme.Spacing.medium) {
|
||||||
|
ForEach(widthPresets, id: \.columns) { preset in
|
||||||
|
Button(action: {
|
||||||
|
selectedWidth = preset.columns
|
||||||
|
HapticFeedback.impact(.light)
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
HStack(spacing: Theme.Spacing.medium) {
|
||||||
|
// Icon
|
||||||
|
Image(systemName: preset.icon)
|
||||||
|
.font(.system(size: 24))
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
.frame(width: 40)
|
||||||
|
|
||||||
|
// Text content
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.extraSmall) {
|
||||||
|
HStack {
|
||||||
|
Text(preset.name)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
|
||||||
|
Text("\(preset.columns) columns")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(preset.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Selection indicator
|
||||||
|
if selectedWidth == preset.columns {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(Theme.Colors.successAccent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
.fill(selectedWidth == preset.columns
|
||||||
|
? Theme.Colors.primaryAccent.opacity(0.1)
|
||||||
|
: Theme.Colors.cardBorder.opacity(0.1))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
.stroke(selectedWidth == preset.columns
|
||||||
|
? Theme.Colors.primaryAccent
|
||||||
|
: Theme.Colors.cardBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Custom width option
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||||
|
Text("Custom Width")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
// For now, just use the current width
|
||||||
|
selectedWidth = nil
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||||
|
|
||||||
|
Text("Use current terminal width")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
.fill(Theme.Colors.cardBorder.opacity(0.1))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: Theme.Spacing.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Theme.Colors.cardBackground)
|
||||||
|
.navigationTitle("Terminal Width")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
TerminalWidthSheet(selectedWidth: .constant(80))
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue