Milestone 0 completed.

This commit is contained in:
Feror 2026-05-29 15:34:15 +02:00
parent 24ed7f2655
commit 5b57df0214
12 changed files with 290 additions and 11 deletions

View file

@ -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.

15
Makefile Normal file
View file

@ -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

View file

@ -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",

8
README
View file

@ -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.

12
Scripts/format.sh Normal file
View file

@ -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

24
Scripts/lint.sh Normal file
View file

@ -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."

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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])
}
}

View file

@ -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<SaplingConfiguration>,
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<Value>(_ keyPath: WritableKeyPath<SaplingConfiguration, Value>) -> Binding<Value> {
Binding(
get: { configuration[keyPath: keyPath] },
set: { newValue in
configuration[keyPath: keyPath] = newValue
onSave(configuration)
}
)
}
}

View file

@ -27,7 +27,7 @@ without relying on proprietary services.
# Milestone 0 — Foundation
Status: In Progress
Status: Complete
Goal: