Sapling/Sources/SaplingApp/SaplingApp.swift

294 lines
9.3 KiB
Swift
Raw Normal View History

2026-05-29 15:19:33 +02:00
import SwiftUI
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
#if os(macOS)
import AppKit
#endif
2026-05-29 15:19:33 +02:00
@main
struct SaplingApplication: App {
#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)
}
#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
}
}
#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 {
@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
@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 {
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
@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()
)
.navigationTitle(navigationTitle(for: editorViewModel))
2026-05-29 15:19:33 +02:00
} else {
EmptyEditorPlaceholder()
}
}
.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])
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
}
}
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
}
}
}