PadXcode-iPad/Navigator/GitAwareFileNavigatorView.swift
2026-04-12 22:07:35 -07:00

127 lines
5.3 KiB
Swift

import SwiftUI
import UIKit
struct GitAwareFileNavigatorView: View {
let tree: FileNode?
let fileStatuses: [GitFileStatus]
let onSelect: (FileNode) -> Void
var onCreateFile: ((String) -> Void)? = nil
var onCreateFolder: ((String) -> Void)? = nil
var onRename: ((FileNode) -> Void)? = nil
var onDelete: ((FileNode) -> Void)? = nil
private var statusMap: [String: GitFileStatus] {
Dictionary(uniqueKeysWithValues: fileStatuses.map { ($0.path, $0) })
}
var body: some View {
Group {
if let tree {
List {
GitAwareNodeRow(node: tree, statusMap: statusMap, depth: 0,
onSelect: onSelect, onCreateFile: onCreateFile,
onCreateFolder: onCreateFolder,
onRename: onRename, onDelete: onDelete)
}
.listStyle(.sidebar)
} else {
ContentUnavailableView("No Project Loaded",
systemImage: "folder.badge.questionmark",
description: Text("Check your daemon connection."))
}
}
}
}
struct GitAwareNodeRow: View {
let node: FileNode
let statusMap: [String: GitFileStatus]
let depth: Int
let onSelect: (FileNode) -> Void
var onCreateFile: ((String) -> Void)?
var onCreateFolder: ((String) -> Void)?
var onRename: ((FileNode) -> Void)?
var onDelete: ((FileNode) -> Void)?
@State private var isExpanded = true
@State private var showDeleteAlert = false
private var fileStatus: GitFileStatus? {
statusMap[node.path] ?? statusMap.values.first { node.path.hasSuffix("/" + $0.path) }
}
var body: some View {
if node.isDirectory {
DisclosureGroup(isExpanded: $isExpanded) {
ForEach(node.children ?? []) { child in
GitAwareNodeRow(node: child, statusMap: statusMap, depth: depth + 1,
onSelect: onSelect, onCreateFile: onCreateFile,
onCreateFolder: onCreateFolder,
onRename: onRename, onDelete: onDelete)
}
} label: {
Label(node.name, systemImage: isExpanded ? "folder.fill" : "folder")
.font(.body)
}
.contextMenu { directoryContextMenu }
} else {
Button { onSelect(node) } label: {
HStack(spacing: 6) {
Image(systemName: fileIcon(for: node.name))
.font(.system(size: 12)).foregroundStyle(iconColor(for: node.name)).frame(width: 16)
Text(node.name)
.font(.system(.body, design: .monospaced))
.foregroundStyle(fileStatus == nil ? .primary : fileStatus!.statusSwiftUIColor)
Spacer()
if let s = fileStatus {
Text(badge(s.status))
.font(.system(.caption2, design: .monospaced).bold())
.foregroundStyle(s.statusSwiftUIColor)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contextMenu { fileContextMenu }
.alert("Delete \"\(node.name)\"?", isPresented: $showDeleteAlert) {
Button("Delete", role: .destructive) { onDelete?(node) }
Button("Cancel", role: .cancel) {}
}
}
}
private func badge(_ status: String) -> String {
switch status { case "modified": return "M"; case "added": return "A"
case "deleted": return "D"; case "untracked": return "?"; default: return "" }
}
private func fileIcon(for name: String) -> String {
switch (name as NSString).pathExtension.lowercased() {
case "swift": return "swift"; case "json": return "curlybraces"
case "md": return "doc.text"; case "sh": return "terminal"
case "xcconfig": return "gearshape"; default: return "doc"
}
}
private func iconColor(for name: String) -> Color {
switch (name as NSString).pathExtension.lowercased() {
case "swift": return .orange; case "json": return .yellow
case "md": return .blue; default: return .secondary
}
}
@ViewBuilder private var directoryContextMenu: some View {
Button { onCreateFile?(node.path) } label: { Label("New File", systemImage: "doc.badge.plus") }
Button { onCreateFolder?(node.path) } label: { Label("New Folder", systemImage: "folder.badge.plus") }
Divider()
Button { onRename?(node) } label: { Label("Rename…", systemImage: "pencil") }
Divider()
Button(role: .destructive) { showDeleteAlert = true } label: { Label("Delete", systemImage: "trash") }
}
@ViewBuilder private var fileContextMenu: some View {
Button { onSelect(node) } label: { Label("Open", systemImage: "arrow.up.right.square") }
Button { onRename?(node) } label: { Label("Rename…", systemImage: "pencil") }
Divider()
Button(role: .destructive) { showDeleteAlert = true } label: { Label("Delete", systemImage: "trash") }
}
}