import SwiftUI import UniformTypeIdentifiers import SaplingCore import SaplingWorkspace import SaplingStorage import SaplingLogging import SaplingEditor import SaplingRenderer import SaplingUI #if os(macOS) import AppKit #endif @main struct SaplingApplication: App { #if os(macOS) @NSApplicationDelegateAdaptor(SaplingAppDelegate.self) private var appDelegate #endif @StateObject private var model = SaplingAppModel(dependencies: .live()) var body: some Scene { WindowGroup { MainWindow(model: model) } .commands { CommandGroup(after: .newItem) { Button("Open Workspace...") { model.presentWorkspaceImporter() } .keyboardShortcut("o", modifiers: [.command, .shift]) } } #if os(macOS) .windowStyle(.titleBar) .windowToolbarStyle(.unified) #endif #if os(macOS) Settings { SettingsView( configuration: $model.configuration, onSave: model.save(configuration:) ) } #endif } } #if os(macOS) private final class SaplingAppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) } } #endif @MainActor private final class SaplingAppModel: ObservableObject { @Published var workspace: Workspace? @Published var workspaceSelection: WorkspaceTreeSelection? @Published var selectedProject: Project? @Published var selectedDocument: MarkdownDocument? @Published var editorViewModel: HybridMarkdownEditorViewModel? @Published var configuration: SaplingConfiguration @Published var editorErrorMessage: String? private let configurationStore: any ConfigurationStore private let workspaceManager: any WorkspaceManaging private let documentSessionStore: DocumentSessionStore private let logger: SaplingLogger init(dependencies: AppDependencies) { self.configurationStore = dependencies.configurationStore self.workspaceManager = dependencies.workspaceManager self.documentSessionStore = DocumentSessionStore() self.logger = dependencies.logger self.configuration = (try? dependencies.configurationStore.loadConfiguration()) ?? SaplingConfiguration() self.workspace = nil logger.info("Sapling application model initialized", category: .app) if let lastOpenedWorkspaceURL = configuration.lastOpenedWorkspaceURL { Task { await openWorkspace(at: lastOpenedWorkspaceURL) } } } 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 { let didStartAccessing = url.startAccessingSecurityScopedResource() defer { if didStartAccessing { url.stopAccessingSecurityScopedResource() } } 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) } } func select(project: Project) { selectedProject = project workspaceSelection = .project(project.repositoryURL) logger.info("Selected project: \(project.name)", category: .workspace) } 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) } 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) } } private struct MainWindow: View { @ObservedObject var model: SaplingAppModel @State private var isImportingMarkdown = false @State private var columnVisibility: NavigationSplitViewVisibility = .all var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { WorkspaceTreeView( workspace: model.workspace, selection: $model.workspaceSelection, onSelectFile: model.select(file:), onSelectProject: model.select(project:), onOpenWorkspace: model.presentWorkspaceImporter ) .navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 260) } detail: { if let editorViewModel = model.editorViewModel { HybridMarkdownEditor( viewModel: editorViewModel, renderer: MarkdownRenderer() ) .navigationTitle(navigationTitle(for: editorViewModel)) } else { EmptyEditorPlaceholder() } } .toolbar { ToolbarItemGroup { 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 } private func toggleSidebar() { columnVisibility = columnVisibility == .all ? .detailOnly : .all } } private extension UTType { static var saplingMarkdown: UTType { UTType(filenameExtension: "md") ?? .plainText } }