Sapling/Sources/SaplingApp/SaplingApp.swift

387 lines
13 KiB
Swift
Raw Permalink Normal View History

2026-05-29 15:19:33 +02:00
import SwiftUI
import UniformTypeIdentifiers
2026-05-29 15:19:33 +02:00
import SaplingCore
import SaplingWorkspace
import SaplingStorage
2026-05-29 15:34:15 +02:00
import SaplingLogging
2026-05-29 15:19:33 +02:00
import SaplingEditor
import SaplingRenderer
import SaplingUI
#if os(macOS)
import AppKit
#endif
2026-05-29 15:19:33 +02:00
@main
struct SaplingApplication: App {
#if os(macOS)
@NSApplicationDelegateAdaptor(SaplingAppDelegate.self) private var appDelegate
#endif
2026-05-29 15:34:15 +02:00
@StateObject private var model = SaplingAppModel(dependencies: .live())
2026-05-29 15:19:33 +02:00
var body: some Scene {
WindowGroup {
MainWindow(model: model)
}
.commands {
CommandGroup(after: .newItem) {
Button("Open Workspace...") {
model.presentWorkspaceImporter()
}
.keyboardShortcut("o", modifiers: [.command, .shift])
}
}
2026-05-29 15:19:33 +02:00
#if os(macOS)
.windowStyle(.titleBar)
.windowToolbarStyle(.unified)
#endif
2026-05-29 15:34:15 +02:00
#if os(macOS)
Settings {
SettingsView(
configuration: $model.configuration,
onSave: model.save(configuration:)
)
}
#endif
2026-05-29 15:19:33 +02:00
}
}
#if os(macOS)
private final class SaplingAppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
}
}
#endif
2026-05-29 15:19:33 +02:00
@MainActor
private final class SaplingAppModel: ObservableObject {
@Published var workspace: Workspace?
@Published var workspaceChildren: [URL: [WorkspaceItem]] = [:]
@Published var loadingTreeItemURLs: Set<URL> = []
@Published var workspaceTreeRevision = 0
@Published var workspaceSelection: WorkspaceTreeSelection?
2026-05-29 15:19:33 +02:00
@Published var selectedProject: Project?
@Published var selectedDocument: MarkdownDocument?
@Published var editorViewModel: HybridMarkdownEditorViewModel?
2026-05-29 15:34:15 +02:00
@Published var configuration: SaplingConfiguration
@Published var editorErrorMessage: String?
@Published var isLoadingWorkspace = false
2026-05-29 15:19:33 +02:00
2026-05-29 15:34:15 +02:00
private let configurationStore: any ConfigurationStore
2026-05-29 15:19:33 +02:00
private let workspaceManager: any WorkspaceManaging
private let documentSessionStore: DocumentSessionStore
2026-05-29 15:34:15 +02:00
private let logger: SaplingLogger
2026-05-29 15:19:33 +02:00
2026-05-29 15:34:15 +02:00
init(dependencies: AppDependencies) {
self.configurationStore = dependencies.configurationStore
self.workspaceManager = dependencies.workspaceManager
self.documentSessionStore = DocumentSessionStore()
2026-05-29 15:34:15 +02:00
self.logger = dependencies.logger
self.configuration = (try? dependencies.configurationStore.loadConfiguration()) ?? SaplingConfiguration()
2026-05-29 15:19:33 +02:00
self.workspace = nil
2026-05-29 15:19:33 +02:00
2026-05-29 15:34:15 +02:00
logger.info("Sapling application model initialized", category: .app)
if let lastOpenedWorkspaceURL = configuration.lastOpenedWorkspaceURL {
Task {
await openWorkspace(at: lastOpenedWorkspaceURL)
}
2026-05-29 15:19:33 +02:00
}
}
func presentWorkspaceImporter() {
#if os(macOS)
let panel = NSOpenPanel()
panel.title = "Open Workspace"
panel.message = "Choose a folder to use as your Sapling workspace."
panel.prompt = "Open"
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.canCreateDirectories = false
panel.resolvesAliases = true
guard panel.runModal() == .OK, let url = panel.url else { return }
Task {
await openWorkspace(at: url)
}
#else
editorErrorMessage = "Workspace selection is currently available on macOS."
#endif
}
func openWorkspace(at url: URL) async {
isLoadingWorkspace = true
let didStartAccessing = url.startAccessingSecurityScopedResource()
defer {
isLoadingWorkspace = false
if didStartAccessing {
url.stopAccessingSecurityScopedResource()
}
}
2026-05-29 15:19:33 +02:00
do {
let workspaceManager = self.workspaceManager
let workspace = try await Task.detached {
try await workspaceManager.openWorkspace(at: url)
}.value
self.workspace = workspace
workspaceChildren = [:]
loadingTreeItemURLs = []
invalidateWorkspaceTree()
workspaceSelection = nil
selectedProject = nil
selectedDocument = nil
editorViewModel = nil
documentSessionStore.closeAll()
persistLastOpenedWorkspace(url)
editorErrorMessage = nil
logger.info("Opened workspace: \(workspace.name)", category: .workspace)
} catch {
editorErrorMessage = "Unable to open workspace \(url.lastPathComponent): \(error.localizedDescription)"
logger.error("Failed to open workspace: \(error)", category: .workspace)
}
}
func children(for item: WorkspaceItem) -> [WorkspaceItem] {
guard let url = item.containerURL?.standardizedFileURL else {
return item.children ?? []
}
return workspaceChildren[url] ?? item.children ?? []
}
func isLoadingChildren(for item: WorkspaceItem) -> Bool {
guard let url = item.containerURL?.standardizedFileURL else { return false }
return loadingTreeItemURLs.contains(url)
}
func loadChildrenIfNeeded(for item: WorkspaceItem) {
guard let url = item.containerURL?.standardizedFileURL else { return }
guard workspaceChildren[url] == nil, !loadingTreeItemURLs.contains(url) else { return }
var loadingURLs = loadingTreeItemURLs
loadingURLs.insert(url)
loadingTreeItemURLs = loadingURLs
invalidateWorkspaceTree()
let workspaceManager = self.workspaceManager
Task {
do {
let children = try await Task.detached {
try await workspaceManager.loadItems(in: url)
}.value
var loadedChildren = workspaceChildren
loadedChildren[url] = children
workspaceChildren = loadedChildren
invalidateWorkspaceTree()
} catch {
editorErrorMessage = "Unable to load \(item.displayName): \(error.localizedDescription)"
logger.error("Failed to load workspace tree item: \(error)", category: .workspace)
}
var loadingURLs = loadingTreeItemURLs
loadingURLs.remove(url)
loadingTreeItemURLs = loadingURLs
invalidateWorkspaceTree()
}
}
func select(file: WorkspaceFile) {
guard file.kind == .markdown else { return }
workspaceSelection = .file(file.url)
openDocument(at: file.url)
}
func openDocument(at url: URL) {
let didStartAccessing = url.startAccessingSecurityScopedResource()
defer {
if didStartAccessing {
url.stopAccessingSecurityScopedResource()
}
}
do {
let session = try documentSessionStore.openDocument(at: url)
activate(session)
editorErrorMessage = nil
logger.info("Opened document: \(session.viewModel.document.title)", category: .editor)
} catch {
editorErrorMessage = "Unable to open \(url.lastPathComponent): \(error.localizedDescription)"
logger.error("Failed to open document: \(error)", category: .editor)
}
}
func saveSelectedDocument() {
guard let editorViewModel else { return }
do {
try editorViewModel.save()
selectedDocument = editorViewModel.document
editorErrorMessage = nil
logger.info("Saved document: \(editorViewModel.document.title)", category: .editor)
} catch {
editorErrorMessage = "Unable to save \(editorViewModel.document.title): \(error.localizedDescription)"
logger.error("Failed to save document: \(error)", category: .editor)
2026-05-29 15:19:33 +02:00
}
}
func select(project: Project) {
selectedProject = project
workspaceSelection = .project(project.repositoryURL)
2026-05-29 15:34:15 +02:00
logger.info("Selected project: \(project.name)", category: .workspace)
2026-05-29 15:19:33 +02:00
}
2026-05-29 15:34:15 +02:00
func save(configuration: SaplingConfiguration) {
do {
try configurationStore.saveConfiguration(configuration)
logger.debug("Saved configuration", category: .storage)
} catch {
logger.error("Failed to save configuration: \(error)", category: .storage)
}
}
private func activate(_ session: MarkdownDocumentSession) {
selectedDocument = session.viewModel.document
editorViewModel = session.viewModel
logger.info("Selected document: \(session.viewModel.document.title)", category: .editor)
2026-05-29 15:19:33 +02:00
}
private func persistLastOpenedWorkspace(_ url: URL) {
configuration.lastOpenedWorkspaceURL = url
configuration.recentWorkspaceURLs.removeAll { $0.standardizedFileURL == url.standardizedFileURL }
configuration.recentWorkspaceURLs.insert(url, at: 0)
configuration.recentWorkspaceURLs = Array(configuration.recentWorkspaceURLs.prefix(10))
save(configuration: configuration)
2026-05-29 15:19:33 +02:00
}
private func invalidateWorkspaceTree() {
workspaceTreeRevision &+= 1
}
2026-05-29 15:19:33 +02:00
}
private struct MainWindow: View {
@ObservedObject var model: SaplingAppModel
@State private var isImportingMarkdown = false
2026-05-29 19:17:34 +02:00
@State private var columnVisibility: NavigationSplitViewVisibility = .all
2026-05-29 15:19:33 +02:00
var body: some View {
2026-05-29 19:17:34 +02:00
NavigationSplitView(columnVisibility: $columnVisibility) {
2026-05-29 15:19:33 +02:00
WorkspaceTreeView(
workspace: model.workspace,
isLoadingWorkspace: model.isLoadingWorkspace,
treeContentRevision: model.workspaceTreeRevision,
selection: $model.workspaceSelection,
childrenFor: model.children(for:),
isLoadingChildren: model.isLoadingChildren(for:),
onExpandItem: model.loadChildrenIfNeeded(for:),
2026-05-29 15:19:33 +02:00
onSelectFile: model.select(file:),
onSelectProject: model.select(project:),
onOpenWorkspace: model.presentWorkspaceImporter
2026-05-29 15:19:33 +02:00
)
2026-05-29 19:17:34 +02:00
.navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 260)
} detail: {
2026-05-29 15:19:33 +02:00
if let editorViewModel = model.editorViewModel {
HybridMarkdownEditor(
viewModel: editorViewModel,
renderer: MarkdownRenderer()
)
.navigationTitle(navigationTitle(for: editorViewModel))
2026-05-29 15:19:33 +02:00
} else {
EmptyEditorPlaceholder()
}
}
.toolbar {
ToolbarItemGroup {
2026-05-29 19:17:34 +02:00
Button {
toggleSidebar()
} label: {
Label("Toggle Sidebar", systemImage: "sidebar.leading")
}
.help("Toggle Sidebar")
.keyboardShortcut("0", modifiers: [.command, .option])
Button {
model.presentWorkspaceImporter()
} label: {
Label("Open Workspace", systemImage: "folder.badge.plus")
}
.help("Open Workspace...")
Button {
isImportingMarkdown = true
} label: {
Label("Open Markdown", systemImage: "doc.badge.plus")
}
.help("Open Markdown")
Button {
model.saveSelectedDocument()
} label: {
Label("Save", systemImage: "square.and.arrow.down")
}
.help("Save")
.disabled(model.editorViewModel?.hasUnsavedChanges != true)
.keyboardShortcut("s", modifiers: .command)
}
}
.fileImporter(
isPresented: $isImportingMarkdown,
allowedContentTypes: [.saplingMarkdown, .plainText],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
model.openDocument(at: url)
case .failure(let error):
model.editorErrorMessage = "Unable to open document: \(error.localizedDescription)"
}
}
.alert(
"Editor Error",
isPresented: Binding(
get: { model.editorErrorMessage != nil },
set: { if !$0 { model.editorErrorMessage = nil } }
)
) {
Button("OK", role: .cancel) {
model.editorErrorMessage = nil
}
} message: {
Text(model.editorErrorMessage ?? "")
}
}
private func navigationTitle(for viewModel: HybridMarkdownEditorViewModel) -> String {
viewModel.hasUnsavedChanges ? "\(viewModel.document.title) *" : viewModel.document.title
}
2026-05-29 19:17:34 +02:00
private func toggleSidebar() {
columnVisibility = columnVisibility == .all ? .detailOnly : .all
}
}
private extension UTType {
static var saplingMarkdown: UTType {
UTType(filenameExtension: "md") ?? .plainText
2026-05-29 15:19:33 +02:00
}
}
private extension WorkspaceItem {
var containerURL: URL? {
switch self {
case .folder(let folder):
return folder.url
case .project(let project):
return project.repositoryURL
case .file, .subproject:
return nil
}
}
}