mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-08 11:45:58 +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 showingCastPlayer = false
|
||||
@State private var selectedCastFile: URL?
|
||||
@State private var isValidatingConnection = true
|
||||
|
||||
var body: some View {
|
||||
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()
|
||||
} else {
|
||||
ConnectionView()
|
||||
}
|
||||
}
|
||||
.animation(.default, value: connectionManager.isConnected)
|
||||
.onAppear {
|
||||
validateRestoredConnection()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
// Handle cast file opening
|
||||
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.
|
||||
@Observable
|
||||
class ConnectionManager {
|
||||
var isConnected: Bool = false
|
||||
var isConnected: Bool = false {
|
||||
didSet {
|
||||
UserDefaults.standard.set(isConnected, forKey: "connectionState")
|
||||
}
|
||||
}
|
||||
var serverConfig: ServerConfig?
|
||||
var lastConnectionTime: Date?
|
||||
|
||||
init() {
|
||||
loadSavedConnection()
|
||||
restoreConnectionState()
|
||||
}
|
||||
|
||||
private func loadSavedConnection() {
|
||||
|
|
@ -52,16 +58,40 @@ class ConnectionManager {
|
|||
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) {
|
||||
if let data = try? JSONEncoder().encode(config) {
|
||||
UserDefaults.standard.set(data, forKey: "savedServerConfig")
|
||||
self.serverConfig = config
|
||||
|
||||
// Save connection timestamp
|
||||
lastConnectionTime = Date()
|
||||
UserDefaults.standard.set(lastConnectionTime, forKey: "lastConnectionTime")
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
isConnected = false
|
||||
UserDefaults.standard.removeObject(forKey: "connectionState")
|
||||
UserDefaults.standard.removeObject(forKey: "lastConnectionTime")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,24 @@ struct FileEntry: Codable, Identifiable {
|
|||
let modTime: Date
|
||||
|
||||
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 {
|
||||
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 cleanupSession(_ sessionId: String) async throws
|
||||
func cleanupAllExitedSessions() async throws -> [String]
|
||||
func killAllSessions() async throws
|
||||
func sendInput(sessionId: String, text: String) async throws
|
||||
func resizeTerminal(sessionId: String, cols: Int, rows: Int) async throws
|
||||
}
|
||||
|
|
@ -232,6 +233,23 @@ class APIClient: APIClientProtocol {
|
|||
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
|
||||
|
||||
|
|
@ -315,9 +333,10 @@ class APIClient: APIClientProtocol {
|
|||
}
|
||||
|
||||
private func addAuthenticationIfNeeded(_ request: inout URLRequest) {
|
||||
// For now, we don't have authentication configured in the iOS app
|
||||
// This is a placeholder for future authentication support
|
||||
// The server might be running without password protection
|
||||
// Add authorization header from server config
|
||||
if let authHeader = ConnectionManager.shared.currentServerConfig?.authorizationHeader {
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File System Operations
|
||||
|
|
@ -390,4 +409,83 @@ class APIClient: APIClientProtocol {
|
|||
let (_, response) = try await session.data(for: request)
|
||||
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] {
|
||||
try await apiClient.cleanupAllExitedSessions()
|
||||
}
|
||||
|
||||
func killAllSessions() async throws {
|
||||
try await apiClient.killAllSessions()
|
||||
}
|
||||
|
||||
func sendInput(to sessionId: String, text: String) async throws {
|
||||
try await apiClient.sendInput(sessionId: sessionId, text: text)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ struct ConnectionView: View {
|
|||
port: $viewModel.port,
|
||||
name: $viewModel.name,
|
||||
password: $viewModel.password,
|
||||
authType: $viewModel.authType,
|
||||
bearerToken: $viewModel.bearerToken,
|
||||
isConnecting: viewModel.isConnecting,
|
||||
errorMessage: viewModel.errorMessage,
|
||||
onConnect: connectToServer
|
||||
|
|
@ -103,6 +105,8 @@ class ConnectionViewModel {
|
|||
var port: String = "4020"
|
||||
var name: String = ""
|
||||
var password: String = ""
|
||||
var authType: AuthType = .none
|
||||
var bearerToken: String = ""
|
||||
var isConnecting: Bool = false
|
||||
var errorMessage: String?
|
||||
|
||||
|
|
@ -114,6 +118,8 @@ class ConnectionViewModel {
|
|||
self.port = String(serverConfig.port)
|
||||
self.name = serverConfig.name ?? ""
|
||||
self.password = serverConfig.password ?? ""
|
||||
self.authType = serverConfig.authType
|
||||
self.bearerToken = serverConfig.bearerToken ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,7 +143,9 @@ class ConnectionViewModel {
|
|||
host: host,
|
||||
port: portNumber,
|
||||
name: name.isEmpty ? nil : name,
|
||||
password: password.isEmpty ? nil : password
|
||||
password: password.isEmpty ? nil : password,
|
||||
authType: authType,
|
||||
bearerToken: bearerToken.isEmpty ? nil : bearerToken
|
||||
)
|
||||
|
||||
do {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ struct ServerConfigForm: View {
|
|||
@Binding var port: String
|
||||
@Binding var name: String
|
||||
@Binding var password: String
|
||||
@Binding var authType: AuthType
|
||||
@Binding var bearerToken: String
|
||||
let isConnecting: Bool
|
||||
let errorMessage: String?
|
||||
let onConnect: () -> Void
|
||||
|
|
@ -21,6 +23,7 @@ struct ServerConfigForm: View {
|
|||
case port
|
||||
case name
|
||||
case password
|
||||
case bearerToken
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -75,20 +78,66 @@ struct ServerConfigForm: View {
|
|||
}
|
||||
}
|
||||
|
||||
// Password Field (Optional)
|
||||
// Authentication Type
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Label("Password (Optional)", systemImage: "lock")
|
||||
Label("Authentication", systemImage: "lock.shield")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
||||
SecureField("Enter password if required", text: $password)
|
||||
.textFieldStyle(TerminalTextFieldStyle())
|
||||
.focused($focusedField, equals: .password)
|
||||
.submitLabel(.done)
|
||||
.onSubmit {
|
||||
focusedField = nil
|
||||
onConnect()
|
||||
Picker("Auth Type", selection: $authType) {
|
||||
ForEach(AuthType.allCases, id: \.self) { type in
|
||||
Text(type.displayName).tag(type)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
|
|
@ -165,6 +214,8 @@ struct ServerConfigForm: View {
|
|||
port = String(server.port)
|
||||
name = server.name ?? ""
|
||||
password = server.password ?? ""
|
||||
authType = server.authType
|
||||
bearerToken = server.bearerToken ?? ""
|
||||
HapticFeedback.selection()
|
||||
}, label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,24 @@ import SwiftUI
|
|||
struct FileBrowserView: View {
|
||||
@State private var viewModel = FileBrowserViewModel()
|
||||
@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 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.mode = mode
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
|
||||
|
|
@ -67,10 +79,31 @@ struct FileBrowserView: View {
|
|||
onTap: {
|
||||
if entry.isDir {
|
||||
viewModel.navigate(to: entry.path)
|
||||
} else if mode == .browseFiles {
|
||||
// Open file editor for files in browse mode
|
||||
selectedFile = entry
|
||||
showingFileEditor = true
|
||||
}
|
||||
}
|
||||
)
|
||||
.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)
|
||||
|
|
@ -124,29 +157,48 @@ struct FileBrowserView: View {
|
|||
.contentShape(Rectangle())
|
||||
})
|
||||
.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
|
||||
Button(action: {
|
||||
onSelect(viewModel.currentPath)
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("select")
|
||||
.font(.custom("SF Mono", size: 14))
|
||||
.foregroundColor(.black)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Theme.Colors.terminalAccent)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Theme.Colors.terminalAccent.opacity(0.3))
|
||||
.blur(radius: 10)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
})
|
||||
.buttonStyle(TerminalButtonStyle())
|
||||
// Select button (only in selectDirectory mode)
|
||||
if mode == .selectDirectory {
|
||||
Button(action: {
|
||||
onSelect(viewModel.currentPath)
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("select")
|
||||
.font(.custom("SF Mono", size: 14))
|
||||
.foregroundColor(.black)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Theme.Colors.terminalAccent)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Theme.Colors.terminalAccent.opacity(0.3))
|
||||
.blur(radius: 10)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
})
|
||||
.buttonStyle(TerminalButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
|
|
@ -175,6 +227,54 @@ struct FileBrowserView: View {
|
|||
} message: { error in
|
||||
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)
|
||||
.onAppear {
|
||||
|
|
@ -369,6 +469,20 @@ class FileBrowserViewModel {
|
|||
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 {
|
||||
|
|
|
|||
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
|
||||
/// monospace font, dark background, and subtle border.
|
||||
struct TerminalTextFieldStyle: TextFieldStyle {
|
||||
// swiftlint:disable:next identifier_name
|
||||
func _body(configuration: TextField<Self._Label>) -> some View {
|
||||
configuration
|
||||
.font(Theme.Typography.terminalSystem(size: 16))
|
||||
|
|
@ -29,7 +30,7 @@ struct SessionCreateView: View {
|
|||
@Binding var isPresented: Bool
|
||||
let onCreated: (String) -> Void
|
||||
|
||||
@State private var command = "claude"
|
||||
@State private var command = "zsh"
|
||||
@State private var workingDirectory = "~"
|
||||
@State private var sessionName = ""
|
||||
@State private var isCreating = false
|
||||
|
|
@ -37,6 +38,7 @@ struct SessionCreateView: View {
|
|||
@State private var showFileBrowser = false
|
||||
|
||||
@FocusState private var focusedField: Field?
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
enum Field {
|
||||
case command
|
||||
|
|
@ -57,7 +59,7 @@ struct SessionCreateView: View {
|
|||
// Command Field
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Label("Command", systemImage: "terminal")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
||||
TextField("zsh", text: $command)
|
||||
|
|
@ -70,7 +72,7 @@ struct SessionCreateView: View {
|
|||
// Working Directory
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Label("Working Directory", systemImage: "folder")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
|
|
@ -89,12 +91,8 @@ struct SessionCreateView: View {
|
|||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.fill(Theme.Colors.cardBorder.opacity(0.1))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(Theme.Colors.cardBorder.opacity(0.3), lineWidth: 1)
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(Theme.Colors.primaryAccent)
|
||||
)
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
|
@ -104,7 +102,7 @@ struct SessionCreateView: View {
|
|||
// Session Name
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Label("Session Name (Optional)", systemImage: "tag")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
||||
TextField("My Session", text: $sessionName)
|
||||
|
|
@ -193,52 +191,51 @@ struct SessionCreateView: View {
|
|||
}
|
||||
|
||||
// Quick Start Commands
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
|
||||
Text("QUICK START")
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||
.tracking(1)
|
||||
.font(Theme.Typography.terminalSystem(size: 11))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.4))
|
||||
.tracking(0.5)
|
||||
.padding(.horizontal)
|
||||
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
], spacing: Theme.Spacing.small) {
|
||||
ForEach(recentCommands, id: \.self) { cmd in
|
||||
ForEach(quickStartCommands, id: \.title) { item in
|
||||
Button(action: {
|
||||
command = cmd
|
||||
command = item.command
|
||||
HapticFeedback.selection()
|
||||
}, label: {
|
||||
HStack {
|
||||
Image(systemName: commandIcon(for: cmd))
|
||||
.font(.system(size: 14))
|
||||
Text(cmd)
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
Image(systemName: item.icon)
|
||||
.font(.system(size: 16))
|
||||
.frame(width: 20)
|
||||
Text(item.title)
|
||||
.font(Theme.Typography.terminalSystem(size: 15))
|
||||
Spacer()
|
||||
}
|
||||
.foregroundColor(command == cmd ? Theme.Colors.terminalBackground : Theme.Colors
|
||||
.foregroundColor(command == item.command ? Theme.Colors.terminalBackground : Theme.Colors
|
||||
.terminalForeground
|
||||
)
|
||||
.padding(.horizontal, Theme.Spacing.medium)
|
||||
.padding(.vertical, Theme.Spacing.small)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.fill(command == cmd ? Theme.Colors.primaryAccent : Theme.Colors
|
||||
.cardBorder.opacity(0.3)
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(command == item.command ? Theme.Colors.primaryAccent : Theme.Colors
|
||||
.cardBackground
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(
|
||||
command == cmd ? Theme.Colors.primaryAccent : Theme.Colors
|
||||
.cardBorder,
|
||||
command == item.command ? Theme.Colors.primaryAccent : Theme.Colors
|
||||
.cardBorder.opacity(0.3),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.scaleEffect(command == cmd ? 0.95 : 1.0)
|
||||
.animation(Theme.Animation.quick, value: command == cmd)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
|
@ -246,6 +243,8 @@ struct SessionCreateView: View {
|
|||
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
.frame(maxWidth: horizontalSizeClass == .regular ? 600 : .infinity)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
|
|
@ -296,8 +295,8 @@ struct SessionCreateView: View {
|
|||
.buttonStyle(PlainButtonStyle())
|
||||
.disabled(isCreating || command.isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.frame(height: 56) // Fixed height for the header
|
||||
.overlay(
|
||||
|
|
@ -322,8 +321,21 @@ struct SessionCreateView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var recentCommands: [String] {
|
||||
["claude", "zsh", "bash", "python3", "node", "npm run dev"]
|
||||
private struct QuickStartItem {
|
||||
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] {
|
||||
|
|
@ -353,6 +365,9 @@ struct SessionCreateView: View {
|
|||
// Load last used values
|
||||
if let lastCommand = UserDefaults.standard.string(forKey: "lastCommand") {
|
||||
command = lastCommand
|
||||
} else {
|
||||
// Default to zsh
|
||||
command = "zsh"
|
||||
}
|
||||
if let lastDir = UserDefaults.standard.string(forKey: "lastWorkingDir") {
|
||||
workingDirectory = lastDir
|
||||
|
|
|
|||
|
|
@ -12,6 +12,36 @@ struct SessionListView: View {
|
|||
@State private var showingCreateSession = false
|
||||
@State private var selectedSession: Session?
|
||||
@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 {
|
||||
NavigationStack {
|
||||
|
|
@ -26,6 +56,8 @@ struct SessionListView: View {
|
|||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if filteredSessions.isEmpty && !searchText.isEmpty {
|
||||
noSearchResultsView
|
||||
} else if viewModel.sessions.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
|
|
@ -49,14 +81,25 @@ struct SessionListView: View {
|
|||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: {
|
||||
HapticFeedback.impact(.light)
|
||||
showingCreateSession = true
|
||||
}, label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
})
|
||||
HStack(spacing: Theme.Spacing.medium) {
|
||||
Button(action: {
|
||||
HapticFeedback.impact(.light)
|
||||
showingFileBrowser = true
|
||||
}, label: {
|
||||
Image(systemName: "folder.fill")
|
||||
.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) {
|
||||
|
|
@ -73,9 +116,15 @@ struct SessionListView: View {
|
|||
.sheet(item: $selectedSession) { session in
|
||||
TerminalView(session: session)
|
||||
}
|
||||
.sheet(isPresented: $showingFileBrowser) {
|
||||
FileBrowserView(mode: .browseFiles) { path in
|
||||
// For browse mode, we don't need to handle path selection
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.loadSessions()
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search sessions")
|
||||
.onAppear {
|
||||
viewModel.startAutoRefresh()
|
||||
}
|
||||
|
|
@ -136,6 +185,32 @@ struct SessionListView: View {
|
|||
}
|
||||
.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 {
|
||||
ScrollView {
|
||||
|
|
@ -156,7 +231,7 @@ struct SessionListView: View {
|
|||
GridItem(.flexible(), spacing: Theme.Spacing.medium),
|
||||
GridItem(.flexible(), spacing: Theme.Spacing.medium)
|
||||
], spacing: Theme.Spacing.medium) {
|
||||
if showExitedSessions && viewModel.sessions.contains(where: { !$0.isRunning }) {
|
||||
if showExitedSessions && filteredSessions.contains(where: { !$0.isRunning }) {
|
||||
CleanupAllButton {
|
||||
Task {
|
||||
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) {
|
||||
HapticFeedback.selection()
|
||||
if session.isRunning {
|
||||
|
|
@ -271,15 +346,14 @@ class SessionListViewModel {
|
|||
}
|
||||
|
||||
func killAllSessions() async {
|
||||
let runningSessions = sessions.filter(\.isRunning)
|
||||
for session in runningSessions {
|
||||
do {
|
||||
try await sessionService.killSession(session.id)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
do {
|
||||
try await sessionService.killAllSessions()
|
||||
await loadSessions()
|
||||
HapticFeedback.notification(.success)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
HapticFeedback.notification(.error)
|
||||
}
|
||||
await loadSessions()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import SwiftUI
|
|||
struct TerminalHostingView: UIViewRepresentable {
|
||||
let session: Session
|
||||
@Binding var fontSize: CGFloat
|
||||
let theme: TerminalTheme
|
||||
let onInput: (String) -> Void
|
||||
let onResize: (Int, Int) -> Void
|
||||
var viewModel: TerminalViewModel
|
||||
|
|
@ -16,10 +17,36 @@ struct TerminalHostingView: UIViewRepresentable {
|
|||
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)
|
||||
// Configure terminal appearance with theme
|
||||
terminal.backgroundColor = UIColor(theme.background)
|
||||
terminal.nativeForegroundColor = UIColor(theme.foreground)
|
||||
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
|
||||
// SwiftTerm's TerminalView uses terminalDelegate, not delegate
|
||||
|
|
@ -46,6 +73,33 @@ struct TerminalHostingView: UIViewRepresentable {
|
|||
|
||||
func updateUIView(_ terminal: SwiftTerm.TerminalView, context: Context) {
|
||||
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
|
||||
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 showingFontSizeSheet = 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
|
||||
@FocusState private var isInputFocused: Bool
|
||||
|
||||
|
|
@ -25,7 +29,7 @@ struct TerminalView: View {
|
|||
NavigationStack {
|
||||
ZStack {
|
||||
// Background
|
||||
Theme.Colors.terminalBackground
|
||||
selectedTheme.background
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Terminal content
|
||||
|
|
@ -62,6 +66,14 @@ struct TerminalView: View {
|
|||
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: {
|
||||
Label("Copy All", systemImage: "doc.on.doc")
|
||||
})
|
||||
|
|
@ -98,6 +110,15 @@ struct TerminalView: View {
|
|||
.sheet(isPresented: $showingRecordingSheet) {
|
||||
RecordingExportSheet(recorder: viewModel.castRecorder, sessionName: session.displayName)
|
||||
}
|
||||
.sheet(isPresented: $showingTerminalWidthSheet) {
|
||||
TerminalWidthSheet(selectedWidth: $selectedTerminalWidth)
|
||||
.onAppear {
|
||||
selectedTerminalWidth = viewModel.terminalCols
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingTerminalThemeSheet) {
|
||||
TerminalThemeSheet(selectedTheme: $selectedTheme)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 {
|
||||
|
|
@ -190,6 +211,14 @@ struct TerminalView: View {
|
|||
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 {
|
||||
|
|
@ -235,6 +264,7 @@ struct TerminalView: View {
|
|||
TerminalHostingView(
|
||||
session: session,
|
||||
fontSize: $fontSize,
|
||||
theme: selectedTheme,
|
||||
onInput: { text in
|
||||
viewModel.sendInput(text)
|
||||
},
|
||||
|
|
@ -246,7 +276,7 @@ struct TerminalView: View {
|
|||
viewModel: viewModel
|
||||
)
|
||||
.id(viewModel.terminalViewId)
|
||||
.background(Theme.Colors.terminalBackground)
|
||||
.background(selectedTheme.background)
|
||||
.focused($isInputFocused)
|
||||
|
||||
// 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