Sapling/Sources/SaplingUI/WorkspaceTreeView.swift

203 lines
7.3 KiB
Swift
Raw Normal View History

2026-05-29 15:19:33 +02:00
import SwiftUI
import SaplingCore
public enum WorkspaceTreeSelection: Hashable, Sendable {
case folder(URL)
case file(URL)
case project(URL)
case subproject(URL)
}
2026-05-29 15:19:33 +02:00
public struct WorkspaceTreeView: View {
private let workspace: Workspace?
private let isLoadingWorkspace: Bool
@Binding private var selection: WorkspaceTreeSelection?
private let childrenFor: (WorkspaceItem) -> [WorkspaceItem]
private let isLoadingChildren: (WorkspaceItem) -> Bool
private let onExpandItem: (WorkspaceItem) -> Void
2026-05-29 15:19:33 +02:00
private let onSelectFile: (WorkspaceFile) -> Void
private let onSelectProject: (Project) -> Void
private let onOpenWorkspace: () -> Void
2026-05-29 15:19:33 +02:00
public init(
workspace: Workspace?,
isLoadingWorkspace: Bool = false,
selection: Binding<WorkspaceTreeSelection?>,
childrenFor: @escaping (WorkspaceItem) -> [WorkspaceItem] = { $0.children ?? [] },
isLoadingChildren: @escaping (WorkspaceItem) -> Bool = { _ in false },
onExpandItem: @escaping (WorkspaceItem) -> Void = { _ in },
2026-05-29 15:19:33 +02:00
onSelectFile: @escaping (WorkspaceFile) -> Void,
onSelectProject: @escaping (Project) -> Void,
onOpenWorkspace: @escaping () -> Void
2026-05-29 15:19:33 +02:00
) {
self.workspace = workspace
self.isLoadingWorkspace = isLoadingWorkspace
self._selection = selection
self.childrenFor = childrenFor
self.isLoadingChildren = isLoadingChildren
self.onExpandItem = onExpandItem
2026-05-29 15:19:33 +02:00
self.onSelectFile = onSelectFile
self.onSelectProject = onSelectProject
self.onOpenWorkspace = onOpenWorkspace
2026-05-29 15:19:33 +02:00
}
public var body: some View {
Group {
if let workspace {
List(selection: $selection) {
if isLoadingWorkspace {
Label("Scanning Workspace...", systemImage: "progress.indicator")
.foregroundStyle(.secondary)
}
Section(workspace.name) {
ForEach(workspace.items, id: \.stableTreeID) { item in
WorkspaceItemRow(
item: item,
selection: $selection,
childrenFor: childrenFor,
isLoadingChildren: isLoadingChildren,
onExpandItem: onExpandItem,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
}
}
}
.listStyle(.sidebar)
} else {
ContentUnavailableView {
Label(isLoadingWorkspace ? "Loading Workspace" : "No Workspace", systemImage: "folder")
} description: {
Text(isLoadingWorkspace ? "Scanning Workspace..." : "Choose a folder to browse Markdown files.")
} actions: {
if isLoadingWorkspace {
ProgressView()
} else {
Button("Open Workspace...", action: onOpenWorkspace)
}
2026-05-29 15:19:33 +02:00
}
}
}
.navigationTitle("Workspace")
.transaction { transaction in
transaction.animation = nil
}
2026-05-29 15:19:33 +02:00
}
}
private struct WorkspaceItemRow: View {
let item: WorkspaceItem
@Binding var selection: WorkspaceTreeSelection?
let childrenFor: (WorkspaceItem) -> [WorkspaceItem]
let isLoadingChildren: (WorkspaceItem) -> Bool
let onExpandItem: (WorkspaceItem) -> Void
2026-05-29 15:19:33 +02:00
let onSelectFile: (WorkspaceFile) -> Void
let onSelectProject: (Project) -> Void
@State private var isExpanded = false
2026-05-29 15:19:33 +02:00
var body: some View {
switch item {
case .folder(let folder):
DisclosureGroup(isExpanded: expansionBinding) {
children
2026-05-29 15:19:33 +02:00
} label: {
Label(folder.name, systemImage: "folder")
.contentShape(Rectangle())
.onTapGesture {
selection = .folder(folder.url)
}
2026-05-29 15:19:33 +02:00
}
.tag(WorkspaceTreeSelection.folder(folder.url))
2026-05-29 15:19:33 +02:00
case .file(let file):
Button {
selection = .file(file.url)
if file.kind == .markdown {
onSelectFile(file)
}
} label: {
Label(file.name, systemImage: iconName(for: file.kind))
.foregroundStyle(file.kind == .markdown ? .primary : .secondary)
}
.buttonStyle(.plain)
.tag(WorkspaceTreeSelection.file(file.url))
2026-05-29 15:19:33 +02:00
case .project(let project):
DisclosureGroup(isExpanded: expansionBinding) {
children
2026-05-29 15:19:33 +02:00
} label: {
Label(project.name, systemImage: "leaf")
.fontWeight(.medium)
.foregroundStyle(.primary)
.contentShape(Rectangle())
.onTapGesture {
selection = .project(project.repositoryURL)
onSelectProject(project)
}
2026-05-29 15:19:33 +02:00
}
.tag(WorkspaceTreeSelection.project(project.repositoryURL))
2026-05-29 15:19:33 +02:00
case .subproject(let subproject):
Label(subproject.name, systemImage: "rectangle.connected.to.line.below")
.foregroundStyle(.secondary)
.tag(WorkspaceTreeSelection.subproject(subproject.repositoryURL))
2026-05-29 15:19:33 +02:00
}
}
private var expansionBinding: Binding<Bool> {
Binding(
get: { isExpanded },
set: { newValue in
var transaction = Transaction()
transaction.animation = nil
withTransaction(transaction) {
isExpanded = newValue
}
if newValue {
onExpandItem(item)
}
}
)
}
@ViewBuilder
private var children: some View {
if isLoadingChildren(item) {
Label("Loading...", systemImage: "progress.indicator")
.foregroundStyle(.secondary)
} else {
ForEach(childrenFor(item), id: \.stableTreeID) { child in
WorkspaceItemRow(
item: child,
selection: $selection,
childrenFor: childrenFor,
isLoadingChildren: isLoadingChildren,
onExpandItem: onExpandItem,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
}
}
}
2026-05-29 15:19:33 +02:00
private func iconName(for kind: WorkspaceFileKind) -> String {
switch kind {
case .markdown: "doc.plaintext"
case .attachment: "paperclip"
case .other: "doc"
}
}
}
private extension WorkspaceItem {
var stableTreeID: URL {
switch self {
case .folder(let folder):
return folder.url.standardizedFileURL
case .file(let file):
return file.url.standardizedFileURL
case .project(let project):
return project.repositoryURL.standardizedFileURL
case .subproject(let subproject):
return subproject.repositoryURL.standardizedFileURL
}
}
}