Milestone 0 completed.
This commit is contained in:
parent
24ed7f2655
commit
5b57df0214
12 changed files with 290 additions and 11 deletions
|
|
@ -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
15
Makefile
Normal 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
|
||||
|
|
@ -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
8
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.
|
||||
|
|
|
|||
12
Scripts/format.sh
Normal file
12
Scripts/format.sh
Normal 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
24
Scripts/lint.sh
Normal 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."
|
||||
60
Sources/SaplingApp/AppDependencies.swift
Normal file
60
Sources/SaplingApp/AppDependencies.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
39
Sources/SaplingLogging/SaplingLogger.swift
Normal file
39
Sources/SaplingLogging/SaplingLogger.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
Sources/SaplingUI/SettingsView.swift
Normal file
47
Sources/SaplingUI/SettingsView.swift
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ without relying on proprietary services.
|
|||
|
||||
# Milestone 0 — Foundation
|
||||
|
||||
Status: In Progress
|
||||
Status: Complete
|
||||
|
||||
Goal:
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue