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 SaplingGit
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
@main
|
|
|
|
|
struct SaplingApplication: App {
|
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)
|
|
|
|
|
}
|
|
|
|
|
#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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@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] = []
|
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-05-29 15:19:33 +02:00
|
|
|
|
|
|
|
|
private let gitProvider: any GitProvider
|
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-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.gitProvider = dependencies.gitProvider
|
|
|
|
|
self.configurationStore = dependencies.configurationStore
|
|
|
|
|
self.workspaceManager = dependencies.workspaceManager
|
|
|
|
|
self.logger = dependencies.logger
|
|
|
|
|
self.configuration = (try? dependencies.configurationStore.loadConfiguration()) ?? SaplingConfiguration()
|
2026-05-29 15:19:33 +02:00
|
|
|
|
|
|
|
|
let workspace = workspaceManager.sampleWorkspace()
|
|
|
|
|
self.workspace = workspace
|
|
|
|
|
self.selectedProject = workspace.firstProject
|
|
|
|
|
setSelectedDocument(SaplingSampleData.document)
|
|
|
|
|
|
2026-05-29 15:34:15 +02:00
|
|
|
logger.info("Sapling application model initialized", category: .app)
|
|
|
|
|
|
2026-05-29 15:19:33 +02:00
|
|
|
Task {
|
|
|
|
|
await refreshGitStatus()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func select(file: WorkspaceFile) {
|
|
|
|
|
guard file.kind == .markdown else { return }
|
|
|
|
|
|
|
|
|
|
if file.url == SaplingSampleData.document.url {
|
|
|
|
|
setSelectedDocument(SaplingSampleData.document)
|
|
|
|
|
} else {
|
2026-05-29 17:58:23 +02:00
|
|
|
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)
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func select(project: Project) {
|
|
|
|
|
selectedProject = project
|
2026-05-29 15:34:15 +02:00
|
|
|
logger.info("Selected project: \(project.name)", category: .workspace)
|
2026-05-29 15:19:33 +02:00
|
|
|
Task {
|
|
|
|
|
await refreshGitStatus()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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-05-29 15:19:33 +02:00
|
|
|
private func refreshGitStatus() async {
|
|
|
|
|
guard let selectedProject else {
|
|
|
|
|
gitStatuses = []
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let repository = gitProvider.repository(at: selectedProject.repositoryURL)
|
|
|
|
|
gitStatuses = try await repository.status()
|
2026-05-29 15:34:15 +02:00
|
|
|
logger.debug("Loaded \(gitStatuses.count) Git status entries", category: .git)
|
2026-05-29 15:19:33 +02:00
|
|
|
} catch {
|
|
|
|
|
gitStatuses = [
|
|
|
|
|
GitFileStatus(path: "Unable to load status: \(error)", state: .conflicted)
|
|
|
|
|
]
|
2026-05-29 15:34:15 +02:00
|
|
|
logger.error("Failed to refresh Git status: \(error)", category: .git)
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func setSelectedDocument(_ document: MarkdownDocument) {
|
|
|
|
|
selectedDocument = document
|
|
|
|
|
editorViewModel = HybridMarkdownEditorViewModel(document: document)
|
2026-05-29 15:34:15 +02:00
|
|
|
logger.info("Selected document: \(document.title)", category: .editor)
|
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,
|
|
|
|
|
onSelectFile: model.select(file:),
|
|
|
|
|
onSelectProject: model.select(project:)
|
|
|
|
|
)
|
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-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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|