From eee508c36df3005ffbab09aaacfc9a0344796ddb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Jun 2025 16:01:04 +0200 Subject: [PATCH] towards featire parity on iOS --- ios/VibeTunnel/App/ContentView.swift | 45 +++- ios/VibeTunnel/App/VibeTunnelApp.swift | 32 ++- ios/VibeTunnel/Models/FileEntry.swift | 18 ++ ios/VibeTunnel/Models/TerminalTheme.swift | 202 ++++++++++++++++++ ios/VibeTunnel/Services/APIClient.swift | 104 ++++++++- ios/VibeTunnel/Services/SessionService.swift | 4 + .../Views/Connection/ConnectionView.swift | 10 +- .../Views/Connection/ServerConfigForm.swift | 69 +++++- ios/VibeTunnel/Views/FileBrowserView.swift | 160 ++++++++++++-- ios/VibeTunnel/Views/FileEditorView.swift | 191 +++++++++++++++++ .../Views/Sessions/SessionCreateView.swift | 85 +++++--- .../Views/Sessions/SessionListView.swift | 110 ++++++++-- .../Views/Terminal/TerminalHostingView.swift | 62 +++++- .../Views/Terminal/TerminalThemeSheet.swift | 166 ++++++++++++++ .../Views/Terminal/TerminalView.swift | 34 ++- .../Views/Terminal/TerminalWidthSheet.swift | 187 ++++++++++++++++ 16 files changed, 1382 insertions(+), 97 deletions(-) create mode 100644 ios/VibeTunnel/Models/TerminalTheme.swift create mode 100644 ios/VibeTunnel/Views/FileEditorView.swift create mode 100644 ios/VibeTunnel/Views/Terminal/TerminalThemeSheet.swift create mode 100644 ios/VibeTunnel/Views/Terminal/TerminalWidthSheet.swift diff --git a/ios/VibeTunnel/App/ContentView.swift b/ios/VibeTunnel/App/ContentView.swift index ef72c03d..f2b78e70 100644 --- a/ios/VibeTunnel/App/ContentView.swift +++ b/ios/VibeTunnel/App/ContentView.swift @@ -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 + } + } + } + } } diff --git a/ios/VibeTunnel/App/VibeTunnelApp.swift b/ios/VibeTunnel/App/VibeTunnelApp.swift index 6083c97c..ae5f4cc3 100644 --- a/ios/VibeTunnel/App/VibeTunnelApp.swift +++ b/ios/VibeTunnel/App/VibeTunnelApp.swift @@ -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") } } diff --git a/ios/VibeTunnel/Models/FileEntry.swift b/ios/VibeTunnel/Models/FileEntry.swift index 75912051..7ec3a30c 100644 --- a/ios/VibeTunnel/Models/FileEntry.swift +++ b/ios/VibeTunnel/Models/FileEntry.swift @@ -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 diff --git a/ios/VibeTunnel/Models/TerminalTheme.swift b/ios/VibeTunnel/Models/TerminalTheme.swift new file mode 100644 index 00000000..7fd754c2 --- /dev/null +++ b/ios/VibeTunnel/Models/TerminalTheme.swift @@ -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) + } + } +} \ No newline at end of file diff --git a/ios/VibeTunnel/Services/APIClient.swift b/ios/VibeTunnel/Services/APIClient.swift index 7bbc6346..eddc8352 100644 --- a/ios/VibeTunnel/Services/APIClient.swift +++ b/ios/VibeTunnel/Services/APIClient.swift @@ -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) + } } diff --git a/ios/VibeTunnel/Services/SessionService.swift b/ios/VibeTunnel/Services/SessionService.swift index 69bd4128..0591a0e1 100644 --- a/ios/VibeTunnel/Services/SessionService.swift +++ b/ios/VibeTunnel/Services/SessionService.swift @@ -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) diff --git a/ios/VibeTunnel/Views/Connection/ConnectionView.swift b/ios/VibeTunnel/Views/Connection/ConnectionView.swift index f8b7c3e8..c1e61799 100644 --- a/ios/VibeTunnel/Views/Connection/ConnectionView.swift +++ b/ios/VibeTunnel/Views/Connection/ConnectionView.swift @@ -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 { diff --git a/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift b/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift index 159473d6..a58a146d 100644 --- a/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift +++ b/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift @@ -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) { diff --git a/ios/VibeTunnel/Views/FileBrowserView.swift b/ios/VibeTunnel/Views/FileBrowserView.swift index 073602fe..affec89b 100644 --- a/ios/VibeTunnel/Views/FileBrowserView.swift +++ b/ios/VibeTunnel/Views/FileBrowserView.swift @@ -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 { diff --git a/ios/VibeTunnel/Views/FileEditorView.swift b/ios/VibeTunnel/Views/FileEditorView.swift new file mode 100644 index 00000000..1a34af35 --- /dev/null +++ b/ios/VibeTunnel/Views/FileEditorView.swift @@ -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) +} \ No newline at end of file diff --git a/ios/VibeTunnel/Views/Sessions/SessionCreateView.swift b/ios/VibeTunnel/Views/Sessions/SessionCreateView.swift index cc33fcd2..fadd66db 100644 --- a/ios/VibeTunnel/Views/Sessions/SessionCreateView.swift +++ b/ios/VibeTunnel/Views/Sessions/SessionCreateView.swift @@ -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) -> 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 diff --git a/ios/VibeTunnel/Views/Sessions/SessionListView.swift b/ios/VibeTunnel/Views/Sessions/SessionListView.swift index 4d4e586f..d4ac8353 100644 --- a/ios/VibeTunnel/Views/Sessions/SessionListView.swift +++ b/ios/VibeTunnel/Views/Sessions/SessionListView.swift @@ -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() } } diff --git a/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift b/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift index d95a0750..48284650 100644 --- a/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift +++ b/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift @@ -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 diff --git a/ios/VibeTunnel/Views/Terminal/TerminalThemeSheet.swift b/ios/VibeTunnel/Views/Terminal/TerminalThemeSheet.swift new file mode 100644 index 00000000..bd5ea611 --- /dev/null +++ b/ios/VibeTunnel/Views/Terminal/TerminalThemeSheet.swift @@ -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)) +} \ No newline at end of file diff --git a/ios/VibeTunnel/Views/Terminal/TerminalView.swift b/ios/VibeTunnel/Views/Terminal/TerminalView.swift index 25b492b0..ecd64a09 100644 --- a/ios/VibeTunnel/Views/Terminal/TerminalView.swift +++ b/ios/VibeTunnel/Views/Terminal/TerminalView.swift @@ -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 diff --git a/ios/VibeTunnel/Views/Terminal/TerminalWidthSheet.swift b/ios/VibeTunnel/Views/Terminal/TerminalWidthSheet.swift new file mode 100644 index 00000000..330e4eae --- /dev/null +++ b/ios/VibeTunnel/Views/Terminal/TerminalWidthSheet.swift @@ -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)) +} \ No newline at end of file