From 5b57df02145177d08a09c02496ff4a0c428b975e Mon Sep 17 00:00:00 2001 From: Feror Date: Fri, 29 May 2026 15:34:15 +0200 Subject: [PATCH] Milestone 0 completed. --- Docs/Architecture.md | 7 +++ Makefile | 15 ++++++ Package.swift | 5 +- README | 8 +++ Scripts/format.sh | 12 +++++ Scripts/lint.sh | 24 +++++++++ Sources/SaplingApp/AppDependencies.swift | 60 ++++++++++++++++++++++ Sources/SaplingApp/SaplingApp.swift | 43 +++++++++++++--- Sources/SaplingLogging/SaplingLogger.swift | 39 ++++++++++++++ Sources/SaplingStorage/Configuration.swift | 39 +++++++++++++- Sources/SaplingUI/SettingsView.swift | 47 +++++++++++++++++ roadmap.md | 2 +- 12 files changed, 290 insertions(+), 11 deletions(-) create mode 100644 Makefile create mode 100644 Scripts/format.sh create mode 100644 Scripts/lint.sh create mode 100644 Sources/SaplingApp/AppDependencies.swift create mode 100644 Sources/SaplingLogging/SaplingLogger.swift create mode 100644 Sources/SaplingUI/SettingsView.swift diff --git a/Docs/Architecture.md b/Docs/Architecture.md index 6f2bcb9..038988d 100644 --- a/Docs/Architecture.md +++ b/Docs/Architecture.md @@ -11,6 +11,7 @@ Sapling is organized as a Swift Package with an executable app target and focuse - `SaplingEditor`: Hybrid Markdown editor state and SwiftUI editing surface. - `SaplingRenderer`: Markdown parsing/rendering primitives for headings, emphasis, code blocks, task lists, and images. - `SaplingStorage`: Configuration and workspace metadata persistence contracts. +- `SaplingLogging`: Thin logging facade over `OSLog`. - `SaplingUI`: Shared SwiftUI sidebar, inspector, and empty-state components. ## Architectural Decisions @@ -23,6 +24,12 @@ The editor follows an MVVM shape. `HybridMarkdownEditorViewModel` owns document Storage is abstracted behind small protocols. The current in-memory stores make the application buildable and testable; `JSONWorkspaceMetadataStore` establishes the first production path for workspace metadata without forcing a database decision. +Dependency injection starts in `SaplingApp` through `AppDependencies`. The composition root owns concrete providers and passes protocols into application state. This keeps feature modules testable and prevents UI code from constructing infrastructure ad hoc. + +Settings are represented by `SaplingConfiguration`, persisted by `ConfigurationStore`, and exposed through the SwiftUI `Settings` scene. Logging flows through `SaplingLogger` so feature code uses stable categories without depending directly on logger construction details. + +The development workflow is captured in `Makefile` and `Scripts/`. `make validate` runs formatting, linting, build, and tests; this is the Milestone 0 gate before updating roadmap status. + ## TODO - TODO: Replace sample data composition with real workspace opening and file loading. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5320c05 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: build test format lint validate + +build: + swift build + +test: + swift test + +format: + sh Scripts/format.sh + +lint: + sh Scripts/lint.sh + +validate: format lint build test diff --git a/Package.swift b/Package.swift index 6165f7b..452e602 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,7 @@ let package = Package( .library(name: "SaplingEditor", targets: ["SaplingEditor"]), .library(name: "SaplingRenderer", targets: ["SaplingRenderer"]), .library(name: "SaplingStorage", targets: ["SaplingStorage"]), + .library(name: "SaplingLogging", targets: ["SaplingLogging"]), .library(name: "SaplingUI", targets: ["SaplingUI"]) ], targets: [ @@ -28,6 +29,7 @@ let package = Package( "SaplingEditor", "SaplingRenderer", "SaplingStorage", + "SaplingLogging", "SaplingUI" ] ), @@ -52,9 +54,10 @@ let package = Package( name: "SaplingStorage", dependencies: ["SaplingCore"] ), + .target(name: "SaplingLogging"), .target( name: "SaplingUI", - dependencies: ["SaplingCore", "SaplingWorkspace", "SaplingGit", "SaplingEditor"] + dependencies: ["SaplingCore", "SaplingWorkspace", "SaplingGit", "SaplingEditor", "SaplingStorage"] ), .testTarget( name: "SaplingCoreTests", diff --git a/README b/README index 0e54d13..65c4ef5 100644 --- a/README +++ b/README @@ -189,6 +189,14 @@ Test: swift test ``` +Validate the Milestone 0 development workflow: + +```sh +make validate +``` + +The validation target runs formatting, linting, build, and tests. If `swift-format` is installed, `make format` formats Swift sources in place; otherwise it falls back to the repository lint checks. + The initial app shell launches with a sample workspace tree, a hybrid Markdown editor, a Git/project inspector, and a mock Git provider. The architecture is intentionally protocol-first so the macOS implementation can use system Git while future iOS support can use an embedded Git implementation behind the same interfaces. See [Docs/Architecture.md](Docs/Architecture.md) for module responsibilities, architectural decisions, and TODO markers. diff --git a/Scripts/format.sh b/Scripts/format.sh new file mode 100644 index 0000000..72e1ca6 --- /dev/null +++ b/Scripts/format.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT_DIR" + +if command -v swift-format >/dev/null 2>&1; then + swift-format --in-place --recursive Package.swift Sources Tests +else + echo "swift-format not found; checking formatting invariants only." + sh Scripts/lint.sh +fi diff --git a/Scripts/lint.sh b/Scripts/lint.sh new file mode 100644 index 0000000..693176b --- /dev/null +++ b/Scripts/lint.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT_DIR" + +FILES=$(find Package.swift Sources Tests -type f -name '*.swift' | sort) + +if [ -z "$FILES" ]; then + echo "No Swift files found." + exit 0 +fi + +if grep -n '[[:blank:]]$' $FILES; then + echo "Trailing whitespace found." + exit 1 +fi + +if grep -n "$(printf '\t')" $FILES; then + echo "Tabs found; use spaces for Swift source indentation." + exit 1 +fi + +echo "Lint passed." diff --git a/Sources/SaplingApp/AppDependencies.swift b/Sources/SaplingApp/AppDependencies.swift new file mode 100644 index 0000000..1817058 --- /dev/null +++ b/Sources/SaplingApp/AppDependencies.swift @@ -0,0 +1,60 @@ +import Foundation +import SaplingGit +import SaplingLogging +import SaplingStorage +import SaplingWorkspace + +struct AppDependencies { + let gitProvider: any GitProvider + let configurationStore: any ConfigurationStore + let metadataStore: any WorkspaceMetadataStore + let workspaceManager: any WorkspaceManaging + let logger: SaplingLogger + + static func live() -> AppDependencies { + let gitProvider = MockGitProvider() + let metadataStore = InMemoryWorkspaceMetadataStore() + let configurationStore = JSONConfigurationStore( + fileURL: supportDirectory().appendingPathComponent("Configuration.json") + ) + let workspaceManager = LocalWorkspaceManager( + gitProvider: gitProvider, + metadataStore: metadataStore + ) + + return AppDependencies( + gitProvider: gitProvider, + configurationStore: configurationStore, + metadataStore: metadataStore, + workspaceManager: workspaceManager, + logger: SaplingLogger() + ) + } + + static func preview() -> AppDependencies { + let gitProvider = MockGitProvider() + let metadataStore = InMemoryWorkspaceMetadataStore() + let configurationStore = InMemoryConfigurationStore() + let workspaceManager = LocalWorkspaceManager( + gitProvider: gitProvider, + metadataStore: metadataStore + ) + + return AppDependencies( + gitProvider: gitProvider, + configurationStore: configurationStore, + metadataStore: metadataStore, + workspaceManager: workspaceManager, + logger: SaplingLogger(subsystem: "app.sapling.Sapling.preview") + ) + } + + private static func supportDirectory() -> URL { + let baseURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + + return baseURL.appendingPathComponent("Sapling", isDirectory: true) + } +} diff --git a/Sources/SaplingApp/SaplingApp.swift b/Sources/SaplingApp/SaplingApp.swift index fba7d68..db5ef35 100644 --- a/Sources/SaplingApp/SaplingApp.swift +++ b/Sources/SaplingApp/SaplingApp.swift @@ -3,13 +3,14 @@ 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() + @StateObject private var model = SaplingAppModel(dependencies: .live()) var body: some Scene { WindowGroup { @@ -19,6 +20,15 @@ struct SaplingApplication: App { .windowStyle(.titleBar) .windowToolbarStyle(.unified) #endif + + #if os(macOS) + Settings { + SettingsView( + configuration: $model.configuration, + onSave: model.save(configuration:) + ) + } + #endif } } @@ -29,23 +39,27 @@ private final class SaplingAppModel: ObservableObject { @Published var selectedDocument: MarkdownDocument? @Published var editorViewModel: HybridMarkdownEditorViewModel? @Published var gitStatuses: [GitFileStatus] = [] + @Published var configuration: SaplingConfiguration private let gitProvider: any GitProvider + private let configurationStore: any ConfigurationStore private let workspaceManager: any WorkspaceManaging + private let logger: SaplingLogger - init() { - let gitProvider = MockGitProvider() - self.gitProvider = gitProvider - self.workspaceManager = LocalWorkspaceManager( - gitProvider: gitProvider, - metadataStore: InMemoryWorkspaceMetadataStore() - ) + 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() } @@ -67,11 +81,21 @@ private final class SaplingAppModel: ObservableObject { 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 = [] @@ -81,16 +105,19 @@ private final class SaplingAppModel: ObservableObject { 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) } } diff --git a/Sources/SaplingLogging/SaplingLogger.swift b/Sources/SaplingLogging/SaplingLogger.swift new file mode 100644 index 0000000..f872ba0 --- /dev/null +++ b/Sources/SaplingLogging/SaplingLogger.swift @@ -0,0 +1,39 @@ +import Foundation +import OSLog + +public struct SaplingLogger: Sendable { + public enum Category: String, Sendable { + case app + case workspace + case git + case editor + case storage + case rendering + } + + private let subsystem: String + + public init(subsystem: String = "app.sapling.Sapling") { + self.subsystem = subsystem + } + + public func debug(_ message: String, category: Category) { + logger(for: category).debug("\(message, privacy: .public)") + } + + public func info(_ message: String, category: Category) { + logger(for: category).info("\(message, privacy: .public)") + } + + public func warning(_ message: String, category: Category) { + logger(for: category).warning("\(message, privacy: .public)") + } + + public func error(_ message: String, category: Category) { + logger(for: category).error("\(message, privacy: .public)") + } + + private func logger(for category: Category) -> Logger { + Logger(subsystem: subsystem, category: category.rawValue) + } +} diff --git a/Sources/SaplingStorage/Configuration.swift b/Sources/SaplingStorage/Configuration.swift index 7a090f5..8405b5e 100644 --- a/Sources/SaplingStorage/Configuration.swift +++ b/Sources/SaplingStorage/Configuration.swift @@ -4,15 +4,21 @@ public struct SaplingConfiguration: Hashable, Codable, Sendable { public var recentWorkspaceURLs: [URL] public var defaultBranchName: String public var autosavesDrafts: Bool + public var showsHiddenFiles: Bool + public var preferredEditorFontSize: Double public init( recentWorkspaceURLs: [URL] = [], defaultBranchName: String = "main", - autosavesDrafts: Bool = true + autosavesDrafts: Bool = true, + showsHiddenFiles: Bool = false, + preferredEditorFontSize: Double = 15 ) { self.recentWorkspaceURLs = recentWorkspaceURLs self.defaultBranchName = defaultBranchName self.autosavesDrafts = autosavesDrafts + self.showsHiddenFiles = showsHiddenFiles + self.preferredEditorFontSize = preferredEditorFontSize } } @@ -36,3 +42,34 @@ public final class InMemoryConfigurationStore: ConfigurationStore, @unchecked Se self.configuration = configuration } } + +public final class JSONConfigurationStore: ConfigurationStore, @unchecked Sendable { + private let fileURL: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + public init(fileURL: URL) { + self.fileURL = fileURL + self.encoder = JSONEncoder() + self.decoder = JSONDecoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + + public func loadConfiguration() throws -> SaplingConfiguration { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return SaplingConfiguration() + } + + let data = try Data(contentsOf: fileURL) + return try decoder.decode(SaplingConfiguration.self, from: data) + } + + public func saveConfiguration(_ configuration: SaplingConfiguration) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try encoder.encode(configuration) + try data.write(to: fileURL, options: [.atomic]) + } +} diff --git a/Sources/SaplingUI/SettingsView.swift b/Sources/SaplingUI/SettingsView.swift new file mode 100644 index 0000000..89bb787 --- /dev/null +++ b/Sources/SaplingUI/SettingsView.swift @@ -0,0 +1,47 @@ +import SwiftUI +import SaplingStorage + +public struct SettingsView: View { + @Binding private var configuration: SaplingConfiguration + private let onSave: (SaplingConfiguration) -> Void + + public init( + configuration: Binding, + onSave: @escaping (SaplingConfiguration) -> Void + ) { + self._configuration = configuration + self.onSave = onSave + } + + public var body: some View { + Form { + Section("General") { + TextField("Default branch", text: binding(\.defaultBranchName)) + Toggle("Autosave drafts", isOn: binding(\.autosavesDrafts)) + Toggle("Show hidden files", isOn: binding(\.showsHiddenFiles)) + } + + Section("Editor") { + HStack { + Slider(value: binding(\.preferredEditorFontSize), in: 11...24, step: 1) + Text("\(Int(configuration.preferredEditorFontSize)) pt") + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + } + .formStyle(.grouped) + .frame(minWidth: 420, minHeight: 260) + .padding() + } + + private func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { configuration[keyPath: keyPath] }, + set: { newValue in + configuration[keyPath: keyPath] = newValue + onSave(configuration) + } + ) + } +} diff --git a/roadmap.md b/roadmap.md index 5eef8b2..955da44 100644 --- a/roadmap.md +++ b/roadmap.md @@ -27,7 +27,7 @@ without relying on proprietary services. # Milestone 0 — Foundation -Status: In Progress +Status: Complete Goal: