import SwiftUI import UniformTypeIdentifiers import SaplingCore import SaplingWorkspace import SaplingGit import SaplingStorage import SaplingLogging import SaplingEditor import SaplingRenderer import SaplingUI @main struct SaplingApplication: App { @StateObject private var model = SaplingAppModel(dependencies: .live()) var body: some Scene { WindowGroup { MainWindow(model: model) } #if os(macOS) .windowStyle(.titleBar) .windowToolbarStyle(.unified) #endif #if os(macOS) Settings { SettingsView( configuration: $model.configuration, onSave: model.save(configuration:) ) } #endif } } @MainActor private final class SaplingAppModel: ObservableObject { @Published var workspace: Workspace @Published var selectedProject: Project? @Published var selectedDocument: MarkdownDocument? @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 private let workspaceManager: any WorkspaceManaging private let logger: SaplingLogger init(dependencies: AppDependencies) { self.gitProvider = dependencies.gitProvider self.configurationStore = dependencies.configurationStore self.workspaceManager = dependencies.workspaceManager self.logger = dependencies.logger self.configuration = (try? dependencies.configurationStore.loadConfiguration()) ?? SaplingConfiguration() let workspace = workspaceManager.sampleWorkspace() self.workspace = workspace self.selectedProject = workspace.firstProject setSelectedDocument(SaplingSampleData.document) logger.info("Sapling application model initialized", category: .app) Task { await refreshGitStatus() } } func select(file: WorkspaceFile) { guard file.kind == .markdown else { return } if file.url == SaplingSampleData.document.url { setSelectedDocument(SaplingSampleData.document) } else { 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) } } func select(project: Project) { selectedProject = project logger.info("Selected project: \(project.name)", category: .workspace) Task { await refreshGitStatus() } } 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 refreshGitStatus() async { guard let selectedProject else { gitStatuses = [] return } do { let repository = gitProvider.repository(at: selectedProject.repositoryURL) gitStatuses = try await repository.status() logger.debug("Loaded \(gitStatuses.count) Git status entries", category: .git) } catch { gitStatuses = [ GitFileStatus(path: "Unable to load status: \(error)", state: .conflicted) ] logger.error("Failed to refresh Git status: \(error)", category: .git) } } private func setSelectedDocument(_ document: MarkdownDocument) { selectedDocument = document editorViewModel = HybridMarkdownEditorViewModel(document: document) logger.info("Selected document: \(document.title)", category: .editor) } } private struct MainWindow: View { @ObservedObject var model: SaplingAppModel @State private var isImportingMarkdown = false var body: some View { NavigationSplitView { WorkspaceTreeView( workspace: model.workspace, onSelectFile: model.select(file:), onSelectProject: model.select(project:) ) .frame(minWidth: 220) } content: { if let editorViewModel = model.editorViewModel { HybridMarkdownEditor( viewModel: editorViewModel, renderer: MarkdownRenderer() ) .navigationTitle(navigationTitle(for: editorViewModel)) } else { EmptyEditorPlaceholder() } } detail: { SaplingInspectorView( project: model.selectedProject, 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 } } private extension Workspace { var firstProject: Project? { for item in items { if let project = item.firstProject { return project } } return nil } } private extension WorkspaceItem { var firstProject: Project? { switch self { case .project(let project): return project case .folder(let folder): for child in folder.children { if let project = child.firstProject { return project } } return nil case .file, .subproject: return nil } } }