vibetunnel/ios/VibeTunnel/Views/FileBrowserView.swift
Peter Steinberger baaaa5a033 fix: CI and linting issues across all platforms
- Fix code signing in Mac and iOS test workflows
- Fix all SwiftFormat and SwiftLint issues
- Fix ESLint issues in web code
- Remove force casts and unwrapping in Swift code
- Update build scripts to use correct file paths
2025-06-23 19:40:53 +02:00

830 lines
32 KiB
Swift

import Observation
import QuickLook
import SwiftUI
/// File browser for navigating the server's file system.
///
/// Provides a hierarchical view of directories and files with
/// navigation, selection, and directory creation capabilities.
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
@StateObject private var quickLookManager = QuickLookManager.shared
@State private var showingQuickLook = false
@State private var showingFilePreview = false
@State private var previewPath: String?
let onSelect: (String) -> Void
let initialPath: String
let mode: FileBrowserMode
let onInsertPath: ((String, Bool) -> Void)? // Path and isDirectory
enum FileBrowserMode {
case selectDirectory
case browseFiles
case insertPath // New mode for inserting paths into terminal
}
init(
initialPath: String = "~",
mode: FileBrowserMode = .selectDirectory,
onSelect: @escaping (String) -> Void,
onInsertPath: ((String, Bool) -> Void)? = nil
) {
self.initialPath = initialPath
self.mode = mode
self.onSelect = onSelect
self.onInsertPath = onInsertPath
}
private var navigationHeader: some View {
HStack(spacing: 16) {
// Back button
if viewModel.canGoUp {
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
viewModel.navigateToParent()
} label: {
HStack(spacing: 6) {
Image(systemName: "chevron.left")
.font(.system(size: 14, weight: .semibold))
Text("Back")
.font(.custom("SF Mono", size: 14))
}
.foregroundColor(Theme.Colors.terminalAccent)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Theme.Colors.terminalAccent.opacity(0.1))
)
}
.buttonStyle(TerminalButtonStyle())
}
// Current path display
HStack(spacing: 8) {
Image(systemName: "folder.fill")
.foregroundColor(Theme.Colors.terminalAccent)
.font(.system(size: 16))
Text(viewModel.displayPath)
.font(.custom("SF Mono", size: 14))
.foregroundColor(Theme.Colors.terminalGray)
.lineLimit(1)
.truncationMode(.middle)
// Git branch indicator
if let gitStatus = viewModel.gitStatus, gitStatus.isGitRepo, let branch = gitStatus.branch {
Text("📍 \(branch)")
.font(.custom("SF Mono", size: 12))
.foregroundColor(Theme.Colors.terminalGray.opacity(0.8))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.background(Theme.Colors.terminalDarkGray)
}
private var filterToolbar: some View {
HStack(spacing: 12) {
// Git filter toggle
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
viewModel.gitFilter = viewModel.gitFilter == .all ? .changed : .all
viewModel.loadDirectory(path: viewModel.currentPath)
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.branch")
.font(.system(size: 12))
Text(viewModel.gitFilter == .changed ? "Git Changes" : "All Files")
.font(.custom("SF Mono", size: 12))
}
.foregroundColor(viewModel.gitFilter == .changed ? Theme.Colors.successAccent : Theme.Colors
.terminalGray
)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(viewModel.gitFilter == .changed ? Theme.Colors.successAccent.opacity(0.2) : Theme.Colors
.terminalGray.opacity(0.1)
)
)
}
.buttonStyle(TerminalButtonStyle())
// Hidden files toggle
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
viewModel.showHidden.toggle()
viewModel.loadDirectory(path: viewModel.currentPath)
} label: {
HStack(spacing: 4) {
Image(systemName: viewModel.showHidden ? "eye" : "eye.slash")
.font(.system(size: 12))
Text("Hidden")
.font(.custom("SF Mono", size: 12))
}
.foregroundColor(viewModel.showHidden ? Theme.Colors.terminalAccent : Theme.Colors.terminalGray)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(viewModel.showHidden ? Theme.Colors.terminalAccent.opacity(0.2) : Theme.Colors
.terminalGray.opacity(0.1)
)
)
}
.buttonStyle(TerminalButtonStyle())
Spacer()
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Theme.Colors.terminalDarkGray.opacity(0.5))
}
var body: some View {
NavigationStack {
ZStack {
// Background
Theme.Colors.terminalBackground.ignoresSafeArea()
VStack(spacing: 0) {
navigationHeader
filterToolbar
// File list
ScrollView {
LazyVStack(spacing: 0) {
// Directories first, then files
ForEach(viewModel.sortedEntries) { entry in
FileBrowserRow(
name: entry.name,
isDirectory: entry.isDir,
size: entry.isDir ? nil : entry.formattedSize,
modifiedTime: entry.formattedDate,
gitStatus: entry.gitStatus
) {
if entry.isDir && mode != .insertPath {
viewModel.navigate(to: entry.path)
} else if mode == .browseFiles {
// Preview file with our custom preview
previewPath = entry.path
showingFilePreview = true
} else if mode == .insertPath {
// Insert the path into terminal
insertPath(entry.path, isDirectory: entry.isDir)
}
}
.transition(.opacity)
// Context menu disabled - file operations not implemented in backend
// .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)
}
.overlay(alignment: .center) {
if viewModel.isLoading {
VStack(spacing: 16) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.terminalAccent))
.scaleEffect(1.2)
Text("Loading...")
.font(.custom("SF Mono", size: 14))
.foregroundColor(Theme.Colors.terminalGray)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.Colors.terminalBackground.opacity(0.8))
}
}
// Bottom toolbar
HStack(spacing: 20) {
// Cancel button
Button(action: { dismiss() }, label: {
Text("cancel")
.font(.custom("SF Mono", size: 14))
.foregroundColor(Theme.Colors.terminalGray)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 8)
.stroke(Theme.Colors.terminalGray.opacity(0.3), lineWidth: 1)
)
.contentShape(Rectangle())
})
.buttonStyle(TerminalButtonStyle())
Spacer()
// Create folder button
Button(action: { viewModel.showCreateFolder = true }, label: {
Label("new folder", systemImage: "folder.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())
// Create file button (disabled - not implemented in backend)
// Uncomment when file operations are implemented
// 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 (only in selectDirectory mode)
if mode == .selectDirectory {
Button(action: {
onSelect(viewModel.currentPath)
dismiss()
}, label: {
Text("select")
.font(.custom("SF Mono", size: 14))
.foregroundColor(Theme.Colors.terminalBackground)
.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)
.background(Theme.Colors.terminalDarkGray)
}
}
.toolbar(.hidden, for: .navigationBar)
.alert("Create Folder", isPresented: $viewModel.showCreateFolder) {
TextField("Folder name", text: $viewModel.newFolderName)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Cancel", role: .cancel) {
viewModel.newFolderName = ""
}
Button("Create") {
viewModel.createFolder()
}
.disabled(viewModel.newFolderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} message: {
Text("Enter a name for the new folder")
}
.alert("Error", isPresented: $viewModel.showError, presenting: viewModel.errorMessage) { _ in
Button("OK") {}
} 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 { $0.path == file.path }
)
.onDisappear {
// Reload directory to show any new files
viewModel.loadDirectory(path: viewModel.currentPath)
}
}
}
.fullScreenCover(isPresented: $quickLookManager.isPresenting) {
QuickLookWrapper(quickLookManager: quickLookManager)
.ignoresSafeArea()
}
.sheet(isPresented: $showingFilePreview) {
if let path = previewPath {
FilePreviewView(path: path)
}
}
.overlay {
if quickLookManager.isDownloading {
ZStack {
Color.black.opacity(0.8)
.ignoresSafeArea()
VStack(spacing: 20) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.terminalAccent))
.scaleEffect(1.5)
Text("Downloading file...")
.font(.custom("SF Mono", size: 16))
.foregroundColor(Theme.Colors.terminalWhite)
if quickLookManager.downloadProgress > 0 {
ProgressView(value: quickLookManager.downloadProgress)
.progressViewStyle(LinearProgressViewStyle(tint: Theme.Colors.terminalAccent))
.frame(width: 200)
}
}
.padding(40)
.background(Theme.Colors.terminalDarkGray)
.cornerRadius(12)
}
}
}
}
.preferredColorScheme(.dark)
.onAppear {
viewModel.loadDirectory(path: initialPath)
}
}
// MARK: - Helper Methods
private func insertPath(_ path: String, isDirectory: Bool) {
// Escape the path if it contains spaces
let escapedPath = path.contains(" ") ? "\"\(path)\"" : path
// Call the insertion handler
onInsertPath?(escapedPath, isDirectory)
// Provide haptic feedback
HapticFeedback.impact(.light)
// Dismiss the file browser
dismiss()
}
}
/// Row component for displaying file or directory information.
///
/// Shows file/directory icon, name, size, and modification time
/// with appropriate styling for directories and parent navigation.
struct FileBrowserRow: View {
let name: String
let isDirectory: Bool
let isParent: Bool
let size: String?
let modifiedTime: String?
let gitStatus: GitFileStatus?
let onTap: () -> Void
init(
name: String,
isDirectory: Bool,
isParent: Bool = false,
size: String? = nil,
modifiedTime: String? = nil,
gitStatus: GitFileStatus? = nil,
onTap: @escaping () -> Void
) {
self.name = name
self.isDirectory = isDirectory
self.isParent = isParent
self.size = size
self.modifiedTime = modifiedTime
self.gitStatus = gitStatus
self.onTap = onTap
}
var iconName: String {
if isDirectory {
return "folder.fill"
}
// Get file extension
let ext = name.split(separator: ".").last?.lowercased() ?? ""
switch ext {
case "js", "jsx", "ts", "tsx", "mjs", "cjs":
return "doc.text.fill"
case "json", "yaml", "yml", "toml":
return "doc.text.fill"
case "md", "markdown", "txt", "log":
return "doc.plaintext.fill"
case "html", "htm", "xml":
return "globe"
case "css", "scss", "sass", "less":
return "paintbrush.fill"
case "png", "jpg", "jpeg", "gif", "svg", "ico", "webp":
return "photo.fill"
case "pdf":
return "doc.richtext.fill"
case "zip", "tar", "gz", "bz2", "xz", "7z", "rar":
return "archivebox.fill"
case "mp4", "mov", "avi", "mkv", "webm":
return "play.rectangle.fill"
case "mp3", "wav", "flac", "aac", "ogg":
return "music.note"
case "sh", "bash", "zsh", "fish":
return "terminal.fill"
case "py", "pyc", "pyo":
return "doc.text.fill"
case "swift":
return "swift"
case "c", "cpp", "cc", "h", "hpp":
return "chevron.left.forwardslash.chevron.right"
case "go":
return "doc.text.fill"
case "rs":
return "doc.text.fill"
case "java", "class", "jar":
return "cup.and.saucer.fill"
default:
return "doc.fill"
}
}
var iconColor: Color {
if isDirectory {
return Theme.Colors.terminalAccent
}
let ext = name.split(separator: ".").last?.lowercased() ?? ""
switch ext {
case "js", "jsx", "mjs", "cjs":
return Theme.Colors.fileTypeJS
case "ts", "tsx":
return Theme.Colors.fileTypeTS
case "json":
return Theme.Colors.fileTypeJSON
case "html", "htm":
return Theme.Colors.fileTypeJSON
case "css", "scss", "sass", "less":
return Theme.Colors.fileTypeCSS
case "md", "markdown":
return Theme.Colors.terminalGray
case "png", "jpg", "jpeg", "gif", "svg", "ico", "webp":
return Theme.Colors.fileTypeImage
case "swift":
return Theme.Colors.fileTypeJSON
case "py":
return Theme.Colors.fileTypePython
case "go":
return Theme.Colors.fileTypeGo
case "rs":
return Theme.Colors.fileTypeJSON
case "sh", "bash", "zsh", "fish":
return Theme.Colors.fileTypeImage
default:
return Theme.Colors.terminalGray.opacity(0.6)
}
}
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
// Icon
Image(systemName: iconName)
.foregroundColor(iconColor)
.font(.system(size: 16))
.frame(width: 24)
// Name
Text(name)
.font(.custom("SF Mono", size: 14))
.foregroundColor(isParent ? Theme.Colors
.terminalAccent : (isDirectory ? Theme.Colors.terminalWhite : Theme.Colors.terminalGray)
)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
// Git status indicator
if let gitStatus, gitStatus != .unchanged {
GitStatusBadge(status: gitStatus)
.padding(.trailing, 8)
}
// Details
if !isParent {
VStack(alignment: .trailing, spacing: 2) {
if let size {
Text(size)
.font(.custom("SF Mono", size: 11))
.foregroundColor(Theme.Colors.terminalGray.opacity(0.6))
}
if let modifiedTime {
Text(modifiedTime)
.font(.custom("SF Mono", size: 11))
.foregroundColor(Theme.Colors.terminalGray.opacity(0.5))
}
}
}
// Chevron for directories
if isDirectory && !isParent {
Image(systemName: "chevron.right")
.foregroundColor(Theme.Colors.terminalGray.opacity(0.4))
.font(.system(size: 12, weight: .medium))
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
.background(
Theme.Colors.terminalGray.opacity(0.05)
.opacity(isDirectory ? 1 : 0)
)
.contextMenu {
if !isParent {
Button {
UIPasteboard.general.string = name
UINotificationFeedbackGenerator().notificationOccurred(.success)
} label: {
Label("Copy Name", systemImage: "doc.on.doc")
}
Button {
UIPasteboard.general.string = isDirectory ? "\(name)/" : name
UINotificationFeedbackGenerator().notificationOccurred(.success)
} label: {
Label("Copy Path", systemImage: "doc.on.doc.fill")
}
}
}
}
}
/// Button style with terminal-themed press effects.
///
/// Provides subtle scale and opacity animations on press
/// for a responsive terminal-like interaction feel.
struct TerminalButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.opacity(configuration.isPressed ? 0.8 : 1.0)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
/// View model for file browser navigation and operations.
@MainActor
@Observable
class FileBrowserViewModel {
var currentPath = "~"
var entries: [FileEntry] = []
var isLoading = false
var showCreateFolder = false
var newFolderName = ""
var showError = false
var errorMessage: String?
var gitStatus: GitStatus?
var showHidden = false
var gitFilter: GitFilterOption = .all
enum GitFilterOption: String {
case all = "all"
case changed = "changed"
}
private let apiClient = APIClient.shared
var sortedEntries: [FileEntry] {
entries.sorted { entry1, entry2 in
// Directories come first
if entry1.isDir != entry2.isDir {
return entry1.isDir
}
// Then sort by name
return entry1.name.localizedCaseInsensitiveCompare(entry2.name) == .orderedAscending
}
}
var canGoUp: Bool {
currentPath != "/" && currentPath != "~"
}
var displayPath: String {
// Show a more user-friendly path
if currentPath == "/" {
return "/"
} else if currentPath.hasPrefix("/Users/") {
// Extract username from path like /Users/username/...
let components = currentPath.components(separatedBy: "/")
if components.count > 2 {
let username = components[2]
let homePath = "/Users/\(username)"
if currentPath == homePath || currentPath.hasPrefix(homePath + "/") {
return currentPath.replacingOccurrences(of: homePath, with: "~")
}
}
}
return currentPath
}
func loadDirectory(path: String) {
Task {
await loadDirectoryAsync(path: path)
}
}
@MainActor
private func loadDirectoryAsync(path: String) async {
isLoading = true
defer { isLoading = false }
do {
let result = try await apiClient.browseDirectory(
path: path,
showHidden: showHidden,
gitFilter: gitFilter.rawValue
)
// Use the absolute path returned by the server
currentPath = result.absolutePath
gitStatus = result.gitStatus
withAnimation(.easeInOut(duration: 0.2)) {
entries = result.files
}
} catch {
// Failed to load directory: \(error)
errorMessage = "Failed to load directory: \(error.localizedDescription)"
showError = true
}
}
func navigate(to path: String) {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
loadDirectory(path: path)
}
func navigateToParent() {
let parentPath = URL(fileURLWithPath: currentPath).deletingLastPathComponent().path
navigate(to: parentPath)
}
func createFolder() {
let folderName = newFolderName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !folderName.isEmpty else { return }
Task {
await createFolderAsync(name: folderName)
}
}
@MainActor
private func createFolderAsync(name: String) async {
do {
let fullPath = currentPath + "/" + name
try await apiClient.createDirectory(path: fullPath)
UINotificationFeedbackGenerator().notificationOccurred(.success)
newFolderName = ""
// Reload directory to show new folder
await loadDirectoryAsync(path: currentPath)
} catch {
// Failed to create folder: \(error)
errorMessage = "Failed to create folder: \(error.localizedDescription)"
showError = true
UINotificationFeedbackGenerator().notificationOccurred(.error)
}
}
func deleteFile(path: String) async {
// File deletion is not yet implemented in the backend
errorMessage = "File deletion is not available in the current server version"
showError = true
UINotificationFeedbackGenerator().notificationOccurred(.error)
}
func previewFile(_ file: FileEntry) async {
do {
try await QuickLookManager.shared.previewFile(file, apiClient: apiClient)
} catch {
await MainActor.run {
errorMessage = "Failed to preview file: \(error.localizedDescription)"
showError = true
}
}
}
}
/// Git status badge component for displaying file status
struct GitStatusBadge: View {
let status: GitFileStatus
var label: String {
switch status {
case .modified: "M"
case .added: "A"
case .deleted: "D"
case .untracked: "?"
case .unchanged: ""
}
}
var color: Color {
switch status {
case .modified: .yellow
case .added: .green
case .deleted: .red
case .untracked: .gray
case .unchanged: .clear
}
}
var body: some View {
if status != .unchanged {
Text(label)
.font(.custom("SF Mono", size: 10))
.fontWeight(.bold)
.foregroundColor(color)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.2))
.cornerRadius(4)
}
}
}
#Preview {
FileBrowserView { _ in
// Selected path
}
}