Sapling/Sources/SaplingApp/SaplingApp.swift

309 lines
10 KiB
Swift
Raw 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 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 isImportingWorkspace = 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() {
isImportingWorkspace = true
}
func openWorkspace(at url: URL) async {
let didStartAccessing = url.startAccessingSecurityScopedResource()
defer {
if didStartAccessing {
url.stopAccessingSecurityScopedResource()
}
}
2026-05-29 15:19:33 +02:00
do {
let workspace = try await workspaceManager.openWorkspace(at: url)
self.workspace = workspace
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 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 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,
selection: $model.workspaceSelection,
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: $model.isImportingWorkspace,
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
Task {
await model.openWorkspace(at: url)
}
case .failure(let error):
model.editorErrorMessage = "Unable to open workspace: \(error.localizedDescription)"
}
}
.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
}
}