2026-05-29 15:19:33 +02:00
|
|
|
import SwiftUI
|
2026-05-29 17:58:23 +02:00
|
|
|
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
|
|
|
|
|
|
2026-05-29 19:47:40 +02:00
|
|
|
#if os(macOS)
|
|
|
|
|
import AppKit
|
|
|
|
|
#endif
|
|
|
|
|
|
2026-05-29 15:19:33 +02:00
|
|
|
@main
|
|
|
|
|
struct SaplingApplication: App {
|
2026-05-29 19:47:40 +02:00
|
|
|
#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)
|
|
|
|
|
}
|
2026-06-02 14:43:19 +02:00
|
|
|
.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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 19:47:40 +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 {
|
2026-06-02 14:43:19 +02:00
|
|
|
@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
|
2026-05-29 17:58:23 +02:00
|
|
|
@Published var editorErrorMessage: String?
|
2026-06-02 14:43:19 +02:00
|
|
|
@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
|
2026-06-02 14:43:19 +02:00
|
|
|
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
|
2026-06-02 14:43:19 +02:00
|
|
|
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
|
|
|
|
2026-06-02 14:43:19 +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)
|
|
|
|
|
|
2026-06-02 14:43:19 +02:00
|
|
|
if let lastOpenedWorkspaceURL = configuration.lastOpenedWorkspaceURL {
|
|
|
|
|
Task {
|
|
|
|
|
await openWorkspace(at: lastOpenedWorkspaceURL)
|
|
|
|
|
}
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 14:43:19 +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
|
|
|
|
2026-06-02 14:43:19 +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)
|
2026-05-29 17:58:23 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 14:43:19 +02:00
|
|
|
func select(file: WorkspaceFile) {
|
|
|
|
|
guard file.kind == .markdown else { return }
|
|
|
|
|
workspaceSelection = .file(file.url)
|
|
|
|
|
openDocument(at: file.url)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 17:58:23 +02:00
|
|
|
func openDocument(at url: URL) {
|
|
|
|
|
let didStartAccessing = url.startAccessingSecurityScopedResource()
|
|
|
|
|
defer {
|
|
|
|
|
if didStartAccessing {
|
|
|
|
|
url.stopAccessingSecurityScopedResource()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
do {
|
2026-06-02 14:43:19 +02:00
|
|
|
let session = try documentSessionStore.openDocument(at: url)
|
|
|
|
|
activate(session)
|
2026-05-29 17:58:23 +02:00
|
|
|
editorErrorMessage = nil
|
2026-06-02 14:43:19 +02:00
|
|
|
logger.info("Opened document: \(session.viewModel.document.title)", category: .editor)
|
2026-05-29 17:58:23 +02:00
|
|
|
} 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
|
2026-06-02 14:43:19 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 14:43:19 +02:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-06-02 14:43:19 +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
|
2026-05-29 17:58:23 +02:00
|
|
|
@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,
|
2026-06-02 14:43:19 +02:00
|
|
|
selection: $model.workspaceSelection,
|
2026-05-29 15:19:33 +02:00
|
|
|
onSelectFile: model.select(file:),
|
2026-06-02 14:43:19 +02:00
|
|
|
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()
|
|
|
|
|
)
|
2026-05-29 17:58:23 +02:00
|
|
|
.navigationTitle(navigationTitle(for: editorViewModel))
|
2026-05-29 15:19:33 +02:00
|
|
|
} else {
|
|
|
|
|
EmptyEditorPlaceholder()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-29 17:58:23 +02:00
|
|
|
.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])
|
|
|
|
|
|
2026-06-02 14:43:19 +02:00
|
|
|
Button {
|
|
|
|
|
model.presentWorkspaceImporter()
|
|
|
|
|
} label: {
|
|
|
|
|
Label("Open Workspace", systemImage: "folder.badge.plus")
|
|
|
|
|
}
|
|
|
|
|
.help("Open Workspace...")
|
|
|
|
|
|
2026-05-29 17:58:23 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-02 14:43:19 +02:00
|
|
|
.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)"
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-29 17:58:23 +02:00
|
|
|
.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
|
|
|
|
|
}
|
2026-05-29 17:58:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension UTType {
|
|
|
|
|
static var saplingMarkdown: UTType {
|
|
|
|
|
UTType(filenameExtension: "md") ?? .plainText
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
|
|
|
|
}
|