From 1e157783642e21a140a594e81ac0237366159cdd Mon Sep 17 00:00:00 2001 From: Feror Date: Fri, 29 May 2026 17:58:23 +0200 Subject: [PATCH] feat(editor): implement document loading and saving --- Sources/SaplingApp/SaplingApp.swift | 101 ++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 7 deletions(-) diff --git a/Sources/SaplingApp/SaplingApp.swift b/Sources/SaplingApp/SaplingApp.swift index db5ef35..46d9250 100644 --- a/Sources/SaplingApp/SaplingApp.swift +++ b/Sources/SaplingApp/SaplingApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers import SaplingCore import SaplingWorkspace import SaplingGit @@ -40,6 +41,7 @@ private final class SaplingAppModel: ObservableObject { @Published var editorViewModel: HybridMarkdownEditorViewModel? @Published var gitStatuses: [GitFileStatus] = [] @Published var configuration: SaplingConfiguration + @Published var editorErrorMessage: String? private let gitProvider: any GitProvider private let configurationStore: any ConfigurationStore @@ -71,11 +73,40 @@ private final class SaplingAppModel: ObservableObject { if file.url == SaplingSampleData.document.url { setSelectedDocument(SaplingSampleData.document) } else { - setSelectedDocument(MarkdownDocument( - url: file.url, - title: file.name, - content: "# \(file.name)\n\nTODO: Load document contents from disk." - )) + openDocument(at: file.url) + } + } + + func openDocument(at url: URL) { + let didStartAccessing = url.startAccessingSecurityScopedResource() + defer { + if didStartAccessing { + url.stopAccessingSecurityScopedResource() + } + } + + do { + let document = try HybridMarkdownEditorViewModel.loadDocument(at: url) + setSelectedDocument(document) + editorErrorMessage = nil + logger.info("Opened document: \(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) } } @@ -123,6 +154,7 @@ private final class SaplingAppModel: ObservableObject { private struct MainWindow: View { @ObservedObject var model: SaplingAppModel + @State private var isImportingMarkdown = false var body: some View { NavigationSplitView { @@ -138,18 +170,73 @@ private struct MainWindow: View { viewModel: editorViewModel, renderer: MarkdownRenderer() ) - .navigationTitle(editorViewModel.document.title) + .navigationTitle(navigationTitle(for: editorViewModel)) } else { EmptyEditorPlaceholder() } } detail: { SaplingInspectorView( project: model.selectedProject, - document: model.selectedDocument, + document: model.editorViewModel?.document ?? model.selectedDocument, statuses: model.gitStatuses ) .frame(minWidth: 260) } + .toolbar { + ToolbarItemGroup { + 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 + } +} + +private extension UTType { + static var saplingMarkdown: UTType { + UTType(filenameExtension: "md") ?? .plainText } }