towards featire parity on iOS

This commit is contained in:
Peter Steinberger 2025-06-20 16:01:04 +02:00
parent 70f5bf2c18
commit eee508c36d
16 changed files with 1382 additions and 97 deletions

View file

@ -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
}
}
}
}
}

View file

@ -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")
}
}

View file

@ -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

View 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)
}
}
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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 {

View file

@ -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) {

View file

@ -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 {

View 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)
}

View file

@ -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

View file

@ -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()
}
}

View file

@ -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

View 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))
}

View file

@ -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

View 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))
}