Added the initial project scaffolding

This commit is contained in:
Feror 2026-05-29 15:19:33 +02:00
parent 48818f27b3
commit c12816c09c
24 changed files with 7586 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.DS_Store
.build/
DerivedData/
*.xcodeproj/project.xcworkspace/xcuserdata/
*.xcworkspace/xcuserdata/
*.xcuserstate

5695
Assets/SaplingIcon.ai Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 243.71 120.98">
<defs>
<style>
.cls-1 {
fill: #36803e;
fill-rule: evenodd;
}
</style>
</defs>
<path class="cls-1" d="M112.25,69.42v-.44l66.24-26.12-60.77,34.83c9.44,9.35,21.96,14.93,35.22,15.69,27.57,1.82,53-10.45,69.99-31.73,3.24-4.14,6.08-8.57,8.47-13.25,2.99-5.71,13.69-24.93,12.14-29.53-2.26-6.7-33.72-11.32-40.92-12.3-37.41-5.18-83.1,1.03-93.26,45-.48,2.15-.63,4.35-.44,6.55l-2.1,1.39-1.98-1.45c.39-1.52.57-3.09.54-4.66C104.46,15.45,67.87,2.62,36.21.51,30.12.12,3.43-1.39.45,3.75c-2.06,3.51,3.51,21.14,5,26.36,1.19,4.25,2.77,8.38,4.74,12.34,10.06,20.53,29.63,34.75,52.26,37.98,11.08,1.62,22.36-.86,31.73-6.98l-44.17-38.91,47.6,31.04v.34c3.79,13.27,3.13,32.49-.54,48.55l19.06,6.53c-3.97-14.88-8.75-36.75-3.97-51.57h.08Z"/>
</svg>

After

Width:  |  Height:  |  Size: 900 B

View file

@ -0,0 +1,52 @@
{
"fill" : {
"automatic-gradient" : "display-p3:0.91062,0.96783,0.95963,1.00000"
},
"groups" : [
{
"layers" : [
{
"fill" : {
"linear-gradient" : [
"display-p3:0.26402,0.64347,0.22558,1.00000",
"display-p3:0.07838,0.50000,0.03567,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.48025095532039985,
"y" : -0.09310788690476185
},
"stop" : {
"x" : 0.409704218106996,
"y" : 0.8235587797619051
}
}
},
"image-name" : "SaplingIcon.svg",
"name" : "SaplingIcon",
"position" : {
"scale" : 3.5,
"translation-in-points" : [
20,
35
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

12
Assets/SaplingIcon.svg Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 243.71 120.98">
<defs>
<style>
.cls-1 {
fill: #36803e;
fill-rule: evenodd;
}
</style>
</defs>
<path class="cls-1" d="M112.25,69.42v-.44l66.24-26.12-60.77,34.83c9.44,9.35,21.96,14.93,35.22,15.69,27.57,1.82,53-10.45,69.99-31.73,3.24-4.14,6.08-8.57,8.47-13.25,2.99-5.71,13.69-24.93,12.14-29.53-2.26-6.7-33.72-11.32-40.92-12.3-37.41-5.18-83.1,1.03-93.26,45-.48,2.15-.63,4.35-.44,6.55l-2.1,1.39-1.98-1.45c.39-1.52.57-3.09.54-4.66C104.46,15.45,67.87,2.62,36.21.51,30.12.12,3.43-1.39.45,3.75c-2.06,3.51,3.51,21.14,5,26.36,1.19,4.25,2.77,8.38,4.74,12.34,10.06,20.53,29.63,34.75,52.26,37.98,11.08,1.62,22.36-.86,31.73-6.98l-44.17-38.91,47.6,31.04v.34c3.79,13.27,3.13,32.49-.54,48.55l19.06,6.53c-3.97-14.88-8.75-36.75-3.97-51.57h.08Z"/>
</svg>

After

Width:  |  Height:  |  Size: 900 B

33
Docs/Architecture.md Normal file
View file

@ -0,0 +1,33 @@
# Sapling Architecture
Sapling is organized as a Swift Package with an executable app target and focused library targets. The app shell depends on feature modules, feature modules depend on protocols and domain models, and `SaplingCore` has no dependency on UI or platform-specific services.
## Targets
- `SaplingApp`: SwiftUI application entry point and dependency composition.
- `SaplingCore`: Domain models, sample data, and business rules.
- `SaplingWorkspace`: Workspace opening and file tree management.
- `SaplingGit`: Protocol-first Git abstraction plus macOS, embedded, and mock providers.
- `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.
- `SaplingUI`: Shared SwiftUI sidebar, inspector, and empty-state components.
## Architectural Decisions
The Git layer is protocol-first because macOS and iOS need different implementations. `MacGitProvider` uses the system `git` binary behind the `GitProvider` and `Repository` protocols. `EmbeddedGitProvider` is intentionally stubbed as the future iOS-compatible implementation point. `MockGitProvider` is used by the initial app shell and previews/tests so UI work is not coupled to local repositories.
Workspaces are local containers and are not versioned. Projects are Git repositories, while subprojects model Git submodules. This distinction is represented in `SaplingCore` so business rules remain shared across macOS and future iOS targets.
The editor follows an MVVM shape. `HybridMarkdownEditorViewModel` owns document editing state, while `HybridMarkdownEditor` renders the active line as source and inactive lines as rendered Markdown. The renderer is injected through `MarkdownRendering` so the current lightweight parser can later be replaced with a richer Markdown/LaTeX/Mermaid pipeline.
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.
## TODO
- TODO: Replace sample data composition with real workspace opening and file loading.
- TODO: Add security-scoped bookmark handling for sandboxed macOS distribution.
- TODO: Expand `MarkdownRenderer` to support full Markdown block parsing, LaTeX, Mermaid, and attachment previews.
- TODO: Replace the placeholder source-line editor with a text layout engine that preserves cursor position across rendered/source transitions.
- TODO: Complete submodule removal as a transactional operation across `.gitmodules`, index state, and module metadata.
- TODO: Implement `EmbeddedGitProvider` with an iOS-compatible Git engine.

64
Package.swift Normal file
View file

@ -0,0 +1,64 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "Sapling",
platforms: [
.macOS(.v14),
.iOS(.v17)
],
products: [
.executable(name: "SaplingApp", targets: ["SaplingApp"]),
.library(name: "SaplingCore", targets: ["SaplingCore"]),
.library(name: "SaplingWorkspace", targets: ["SaplingWorkspace"]),
.library(name: "SaplingGit", targets: ["SaplingGit"]),
.library(name: "SaplingEditor", targets: ["SaplingEditor"]),
.library(name: "SaplingRenderer", targets: ["SaplingRenderer"]),
.library(name: "SaplingStorage", targets: ["SaplingStorage"]),
.library(name: "SaplingUI", targets: ["SaplingUI"])
],
targets: [
.executableTarget(
name: "SaplingApp",
dependencies: [
"SaplingCore",
"SaplingWorkspace",
"SaplingGit",
"SaplingEditor",
"SaplingRenderer",
"SaplingStorage",
"SaplingUI"
]
),
.target(name: "SaplingCore"),
.target(
name: "SaplingWorkspace",
dependencies: ["SaplingCore", "SaplingGit", "SaplingStorage"]
),
.target(
name: "SaplingGit",
dependencies: ["SaplingCore"]
),
.target(
name: "SaplingEditor",
dependencies: ["SaplingCore", "SaplingRenderer"]
),
.target(
name: "SaplingRenderer",
dependencies: ["SaplingCore"]
),
.target(
name: "SaplingStorage",
dependencies: ["SaplingCore"]
),
.target(
name: "SaplingUI",
dependencies: ["SaplingCore", "SaplingWorkspace", "SaplingGit", "SaplingEditor"]
),
.testTarget(
name: "SaplingCoreTests",
dependencies: ["SaplingCore"]
)
]
)

40
README
View file

@ -152,3 +152,43 @@ Will share as much code as possible with the macOS application while providing a
## Status
Sapling is currently in active development.
## Project Layout
Sapling currently uses Swift Package Manager as the source-of-truth project layout. Open `Package.swift` in Xcode for app development or run SwiftPM commands from the repository root.
```text
Sapling/
├── Package.swift
├── Sources/
│ ├── SaplingApp/ # SwiftUI app entry point and composition root
│ ├── SaplingCore/ # Shared domain models and business rules
│ ├── SaplingWorkspace/ # Workspace and file tree management
│ ├── SaplingGit/ # Git protocols, macOS provider, embedded stub, mock provider
│ ├── SaplingEditor/ # Hybrid Markdown editor MVVM surface
│ ├── SaplingRenderer/ # Markdown rendering abstractions
│ ├── SaplingStorage/ # Configuration and metadata persistence
│ └── SaplingUI/ # Shared SwiftUI components
├── Tests/
│ └── SaplingCoreTests/
└── Docs/
└── Architecture.md
```
## Development
Build:
```sh
swift build
```
Test:
```sh
swift test
```
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.

View file

@ -0,0 +1,156 @@
import SwiftUI
import SaplingCore
import SaplingWorkspace
import SaplingGit
import SaplingStorage
import SaplingEditor
import SaplingRenderer
import SaplingUI
@main
struct SaplingApplication: App {
@StateObject private var model = SaplingAppModel()
var body: some Scene {
WindowGroup {
MainWindow(model: model)
}
#if os(macOS)
.windowStyle(.titleBar)
.windowToolbarStyle(.unified)
#endif
}
}
@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] = []
private let gitProvider: any GitProvider
private let workspaceManager: any WorkspaceManaging
init() {
let gitProvider = MockGitProvider()
self.gitProvider = gitProvider
self.workspaceManager = LocalWorkspaceManager(
gitProvider: gitProvider,
metadataStore: InMemoryWorkspaceMetadataStore()
)
let workspace = workspaceManager.sampleWorkspace()
self.workspace = workspace
self.selectedProject = workspace.firstProject
setSelectedDocument(SaplingSampleData.document)
Task {
await refreshGitStatus()
}
}
func select(file: WorkspaceFile) {
guard file.kind == .markdown else { return }
if file.url == SaplingSampleData.document.url {
setSelectedDocument(SaplingSampleData.document)
} else {
setSelectedDocument(MarkdownDocument(
url: file.url,
title: file.name,
content: "# \(file.name)\n\nTODO: Load document contents from disk."
))
}
}
func select(project: Project) {
selectedProject = project
Task {
await refreshGitStatus()
}
}
private func refreshGitStatus() async {
guard let selectedProject else {
gitStatuses = []
return
}
do {
let repository = gitProvider.repository(at: selectedProject.repositoryURL)
gitStatuses = try await repository.status()
} catch {
gitStatuses = [
GitFileStatus(path: "Unable to load status: \(error)", state: .conflicted)
]
}
}
private func setSelectedDocument(_ document: MarkdownDocument) {
selectedDocument = document
editorViewModel = HybridMarkdownEditorViewModel(document: document)
}
}
private struct MainWindow: View {
@ObservedObject var model: SaplingAppModel
var body: some View {
NavigationSplitView {
WorkspaceTreeView(
workspace: model.workspace,
onSelectFile: model.select(file:),
onSelectProject: model.select(project:)
)
.frame(minWidth: 220)
} content: {
if let editorViewModel = model.editorViewModel {
HybridMarkdownEditor(
viewModel: editorViewModel,
renderer: MarkdownRenderer()
)
.navigationTitle(editorViewModel.document.title)
} else {
EmptyEditorPlaceholder()
}
} detail: {
SaplingInspectorView(
project: model.selectedProject,
document: model.selectedDocument,
statuses: model.gitStatuses
)
.frame(minWidth: 260)
}
}
}
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
}
}
}

View file

@ -0,0 +1,125 @@
import Foundation
public enum SaplingDomainError: Error, Equatable, Sendable {
case workspaceCannotBeInsideProject
case projectMustBeGitRepository
case subprojectPathCannotBeEmpty
}
public enum SaplingRules {
public static func validateWorkspace(_ workspace: Workspace) throws {
for item in workspace.items {
try validateWorkspaceItem(item)
}
}
public static func validateProject(_ project: Project) throws {
guard project.gitRepository.rootURL == project.repositoryURL else {
throw SaplingDomainError.projectMustBeGitRepository
}
}
public static func validateSubproject(_ subproject: Subproject) throws {
guard !subproject.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw SaplingDomainError.subprojectPathCannotBeEmpty
}
}
private static func validateWorkspaceItem(_ item: WorkspaceItem) throws {
switch item {
case .folder(let folder):
for child in folder.children {
try validateWorkspaceItem(child)
}
case .project(let project):
try validateProject(project)
case .subproject(let subproject):
try validateSubproject(subproject)
case .file:
break
}
}
}
public enum SaplingSampleData {
public static let rootURL = URL(fileURLWithPath: "/tmp/SaplingSample")
public static var document: MarkdownDocument {
MarkdownDocument(
url: rootURL.appendingPathComponent("Research/README.md"),
title: "README",
content: """
# Sapling
Sapling is a **Git-native** Markdown workspace.
- [x] Model local workspaces
- [ ] Render inactive Markdown lines
```swift
let workspace = Workspace(name: "Research", rootURL: url)
```
![Architecture](attachments/architecture.png)
"""
)
}
public static var workspace: Workspace {
let repositoryURL = rootURL.appendingPathComponent("Research")
let branch = GitBranch(name: "main", isCurrent: true, upstreamName: "origin/main")
let remote = GitRemote(
name: "origin",
url: URL(string: "https://example.com/sapling/research.git")!
)
let repository = GitRepository(
name: "Research",
rootURL: repositoryURL,
currentBranch: branch,
remotes: [remote],
statusSummary: .dirty
)
let subproject = Subproject(
name: "Shared Assets",
path: "Assets/Shared",
repositoryURL: repositoryURL.appendingPathComponent("Assets/Shared"),
remoteURL: URL(string: "https://example.com/sapling/shared-assets.git")
)
let project = Project(
name: "Research",
repositoryURL: repositoryURL,
gitRepository: repository,
remotes: [remote],
branches: [
branch,
GitBranch(name: "drafts/editor-prototype")
],
subprojects: [subproject],
usesGitLFS: true
)
return Workspace(
name: "Sapling Sample",
rootURL: rootURL,
items: [
.folder(
WorkspaceFolder(
name: "Inbox",
url: rootURL.appendingPathComponent("Inbox"),
children: [
.file(
WorkspaceFile(
name: "Scratch.md",
url: rootURL.appendingPathComponent("Inbox/Scratch.md"),
kind: .markdown
)
)
]
)
),
.project(project),
.subproject(subproject)
]
)
}
}

View file

@ -0,0 +1,313 @@
import Foundation
public struct Workspace: Identifiable, Hashable, Codable, Sendable {
public var id: UUID
public var name: String
public var rootURL: URL
public var items: [WorkspaceItem]
public init(
id: UUID = UUID(),
name: String,
rootURL: URL,
items: [WorkspaceItem] = []
) {
self.id = id
self.name = name
self.rootURL = rootURL
self.items = items
}
}
public indirect enum WorkspaceItem: Identifiable, Hashable, Codable, Sendable {
case folder(WorkspaceFolder)
case file(WorkspaceFile)
case project(Project)
case subproject(Subproject)
public var id: UUID {
switch self {
case .folder(let folder): folder.id
case .file(let file): file.id
case .project(let project): project.id
case .subproject(let subproject): subproject.id
}
}
public var displayName: String {
switch self {
case .folder(let folder): folder.name
case .file(let file): file.name
case .project(let project): project.name
case .subproject(let subproject): subproject.name
}
}
}
public struct WorkspaceFolder: Identifiable, Hashable, Codable, Sendable {
public var id: UUID
public var name: String
public var url: URL
public var children: [WorkspaceItem]
public init(
id: UUID = UUID(),
name: String,
url: URL,
children: [WorkspaceItem] = []
) {
self.id = id
self.name = name
self.url = url
self.children = children
}
}
public struct WorkspaceFile: Identifiable, Hashable, Codable, Sendable {
public var id: UUID
public var name: String
public var url: URL
public var kind: WorkspaceFileKind
public init(
id: UUID = UUID(),
name: String,
url: URL,
kind: WorkspaceFileKind
) {
self.id = id
self.name = name
self.url = url
self.kind = kind
}
}
public enum WorkspaceFileKind: String, Hashable, Codable, Sendable {
case markdown
case attachment
case other
}
public struct Project: Identifiable, Hashable, Codable, Sendable {
public var id: UUID
public var name: String
public var repositoryURL: URL
public var gitRepository: GitRepository
public var remotes: [GitRemote]
public var branches: [GitBranch]
public var subprojects: [Subproject]
public var usesGitLFS: Bool
public init(
id: UUID = UUID(),
name: String,
repositoryURL: URL,
gitRepository: GitRepository,
remotes: [GitRemote] = [],
branches: [GitBranch] = [],
subprojects: [Subproject] = [],
usesGitLFS: Bool = false
) {
self.id = id
self.name = name
self.repositoryURL = repositoryURL
self.gitRepository = gitRepository
self.remotes = remotes
self.branches = branches
self.subprojects = subprojects
self.usesGitLFS = usesGitLFS
}
}
public struct Subproject: Identifiable, Hashable, Codable, Sendable {
public var id: UUID
public var name: String
public var path: String
public var repositoryURL: URL
public var remoteURL: URL?
public init(
id: UUID = UUID(),
name: String,
path: String,
repositoryURL: URL,
remoteURL: URL? = nil
) {
self.id = id
self.name = name
self.path = path
self.repositoryURL = repositoryURL
self.remoteURL = remoteURL
}
}
public struct MarkdownDocument: Identifiable, Hashable, Codable, Sendable {
public var id: UUID
public var url: URL
public var title: String
public var content: String
public var attachments: [Attachment]
public init(
id: UUID = UUID(),
url: URL,
title: String,
content: String,
attachments: [Attachment] = []
) {
self.id = id
self.url = url
self.title = title
self.content = content
self.attachments = attachments
}
}
public struct Attachment: Identifiable, Hashable, Codable, Sendable {
public var id: UUID
public var filename: String
public var url: URL
public var kind: AttachmentKind
public var isGitLFSManaged: Bool
public init(
id: UUID = UUID(),
filename: String,
url: URL,
kind: AttachmentKind,
isGitLFSManaged: Bool = false
) {
self.id = id
self.filename = filename
self.url = url
self.kind = kind
self.isGitLFSManaged = isGitLFSManaged
}
}
public enum AttachmentKind: String, Hashable, Codable, Sendable {
case image
case pdf
case audio
case video
case binary
}
public struct GitRepository: Identifiable, Hashable, Codable, Sendable {
public var id: UUID
public var name: String
public var rootURL: URL
public var currentBranch: GitBranch?
public var remotes: [GitRemote]
public var statusSummary: GitStatusSummary
public init(
id: UUID = UUID(),
name: String,
rootURL: URL,
currentBranch: GitBranch? = nil,
remotes: [GitRemote] = [],
statusSummary: GitStatusSummary = .clean
) {
self.id = id
self.name = name
self.rootURL = rootURL
self.currentBranch = currentBranch
self.remotes = remotes
self.statusSummary = statusSummary
}
}
public struct GitRemote: Identifiable, Hashable, Codable, Sendable {
public var id: UUID
public var name: String
public var url: URL
public var fetchURL: URL?
public var pushURL: URL?
public init(
id: UUID = UUID(),
name: String,
url: URL,
fetchURL: URL? = nil,
pushURL: URL? = nil
) {
self.id = id
self.name = name
self.url = url
self.fetchURL = fetchURL
self.pushURL = pushURL
}
}
public struct GitBranch: Identifiable, Hashable, Codable, Sendable {
public var id: UUID
public var name: String
public var isCurrent: Bool
public var upstreamName: String?
public init(
id: UUID = UUID(),
name: String,
isCurrent: Bool = false,
upstreamName: String? = nil
) {
self.id = id
self.name = name
self.isCurrent = isCurrent
self.upstreamName = upstreamName
}
}
public struct GitCommit: Identifiable, Hashable, Codable, Sendable {
public var id: String
public var shortHash: String
public var authorName: String
public var authorEmail: String
public var message: String
public var authoredAt: Date
public init(
id: String,
shortHash: String,
authorName: String,
authorEmail: String,
message: String,
authoredAt: Date
) {
self.id = id
self.shortHash = shortHash
self.authorName = authorName
self.authorEmail = authorEmail
self.message = message
self.authoredAt = authoredAt
}
}
public struct GitFileStatus: Identifiable, Hashable, Codable, Sendable {
public var id: UUID
public var path: String
public var state: GitFileState
public init(id: UUID = UUID(), path: String, state: GitFileState) {
self.id = id
self.path = path
self.state = state
}
}
public enum GitFileState: String, Hashable, Codable, Sendable {
case untracked
case added
case modified
case deleted
case renamed
case conflicted
}
public enum GitStatusSummary: String, Hashable, Codable, Sendable {
case clean
case dirty
case conflicted
case unknown
}

View file

@ -0,0 +1,160 @@
import SwiftUI
import SaplingCore
import SaplingRenderer
@MainActor
public final class HybridMarkdownEditorViewModel: ObservableObject {
@Published public var document: MarkdownDocument
@Published public var activeLineIndex: Int
public init(document: MarkdownDocument, activeLineIndex: Int = 0) {
self.document = document
self.activeLineIndex = activeLineIndex
}
public var lines: [String] {
document.content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
}
public func bindingForActiveLine() -> Binding<String> {
Binding(
get: { [weak self] in
guard let self else { return "" }
let lines = self.lines
guard lines.indices.contains(self.activeLineIndex) else { return "" }
return lines[self.activeLineIndex]
},
set: { [weak self] newValue in
self?.replaceActiveLine(with: newValue)
}
)
}
public func activateLine(at index: Int) {
activeLineIndex = index
}
private func replaceActiveLine(with newValue: String) {
var updatedLines = lines
if updatedLines.indices.contains(activeLineIndex) {
updatedLines[activeLineIndex] = newValue
} else {
updatedLines.append(newValue)
activeLineIndex = updatedLines.endIndex - 1
}
document.content = updatedLines.joined(separator: "\n")
}
}
public struct HybridMarkdownEditor: View {
@ObservedObject private var viewModel: HybridMarkdownEditorViewModel
private let renderer: any MarkdownRendering
public init(
viewModel: HybridMarkdownEditorViewModel,
renderer: any MarkdownRendering = MarkdownRenderer()
) {
self.viewModel = viewModel
self.renderer = renderer
}
public var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 4) {
ForEach(Array(viewModel.lines.enumerated()), id: \.offset) { index, line in
if index == viewModel.activeLineIndex {
TextEditor(text: viewModel.bindingForActiveLine())
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.frame(minHeight: 34)
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 6))
} else {
RenderedMarkdownLine(line: line, renderer: renderer)
.contentShape(Rectangle())
.onTapGesture {
viewModel.activateLine(at: index)
}
}
}
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(platformTextBackground)
}
}
private struct RenderedMarkdownLine: View {
let line: String
let renderer: any MarkdownRendering
var body: some View {
let block = renderer.blocks(for: line).first ?? .blank(id: UUID())
blockView(block)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.padding(.vertical, 3)
}
@ViewBuilder
private func blockView(_ block: RenderedMarkdownBlock) -> some View {
switch block {
case .heading(_, let level, let text):
Text(text)
.font(headingFont(level: level))
.fontWeight(.semibold)
case .paragraph(_, let text):
Text(text)
.font(.body)
case .codeBlock(_, let language, let code):
VStack(alignment: .leading, spacing: 6) {
if let language {
Text(language.uppercased())
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(code)
.font(.system(.body, design: .monospaced))
}
.padding(10)
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 6))
case .task(_, let checked, let text):
HStack(spacing: 8) {
Image(systemName: checked ? "checkmark.square.fill" : "square")
.foregroundStyle(checked ? .green : .secondary)
Text(text)
}
case .image(_, let altText, let source):
HStack(spacing: 8) {
Image(systemName: "photo")
VStack(alignment: .leading, spacing: 2) {
Text(altText.isEmpty ? "Image" : altText)
Text(source)
.font(.caption)
.foregroundStyle(.secondary)
}
}
case .blank:
Spacer(minLength: 20)
}
}
private func headingFont(level: Int) -> Font {
switch level {
case 1: .largeTitle
case 2: .title
case 3: .title2
default: .headline
}
}
}
private var platformTextBackground: Color {
#if os(macOS)
Color(nsColor: .textBackgroundColor)
#else
Color(uiColor: .systemBackground)
#endif
}

View file

@ -0,0 +1,41 @@
import Foundation
import SaplingCore
public final class EmbeddedGitProvider: GitProvider {
public let displayName = "Embedded Git"
public init() {}
public func initRepository(at path: URL) async throws -> GitRepository {
throw GitProviderError.notImplemented("Embedded Git init is reserved for the future iOS provider.")
}
public func clone(remoteURL: URL, to destinationURL: URL) async throws -> GitRepository {
throw GitProviderError.notImplemented("Embedded Git clone is reserved for the future iOS provider.")
}
public func repository(at path: URL) -> any Repository {
EmbeddedRepository(rootURL: path)
}
}
private struct EmbeddedRepository: Repository {
let rootURL: URL
func status() async throws -> [GitFileStatus] { throw unsupported() }
func add(paths: [String]) async throws { throw unsupported() }
func commit(message: String) async throws -> GitCommit { throw unsupported() }
func push(remote: String?, branch: String?) async throws { throw unsupported() }
func pull(remote: String?, branch: String?) async throws { throw unsupported() }
func createBranch(named name: String) async throws -> GitBranch { throw unsupported() }
func switchBranch(named name: String) async throws -> GitBranch { throw unsupported() }
func merge(branch name: String) async throws { throw unsupported() }
func addSubmodule(remoteURL: URL, path: String) async throws -> Subproject { throw unsupported() }
func removeSubmodule(path: String) async throws { throw unsupported() }
func updateSubmodules(initSubmodules: Bool, recursive: Bool) async throws { throw unsupported() }
func syncSubmodules(recursive: Bool) async throws { throw unsupported() }
private func unsupported() -> GitProviderError {
.notImplemented("Embedded Git repository operations are intentionally stubbed for iOS.")
}
}

View file

@ -0,0 +1,55 @@
import Foundation
import SaplingCore
public protocol GitProvider: Sendable {
var displayName: String { get }
func initRepository(at path: URL) async throws -> GitRepository
func clone(remoteURL: URL, to destinationURL: URL) async throws -> GitRepository
func repository(at path: URL) -> any Repository
}
public protocol Repository: Sendable {
var rootURL: URL { get }
func status() async throws -> [GitFileStatus]
func add(paths: [String]) async throws
func commit(message: String) async throws -> GitCommit
func push(remote: String?, branch: String?) async throws
func pull(remote: String?, branch: String?) async throws
func createBranch(named name: String) async throws -> GitBranch
func switchBranch(named name: String) async throws -> GitBranch
func merge(branch name: String) async throws
func addSubmodule(remoteURL: URL, path: String) async throws -> Subproject
func removeSubmodule(path: String) async throws
func updateSubmodules(initSubmodules: Bool, recursive: Bool) async throws
func syncSubmodules(recursive: Bool) async throws
}
public protocol Branch: Sendable {
var name: String { get }
var isCurrent: Bool { get }
var upstreamName: String? { get }
}
public protocol Commit: Sendable {
var id: String { get }
var message: String { get }
var authoredAt: Date { get }
}
public protocol Remote: Sendable {
var name: String { get }
var url: URL { get }
}
extension GitBranch: Branch {}
extension GitCommit: Commit {}
extension GitRemote: Remote {}
public enum GitProviderError: Error, Equatable, Sendable {
case unsupportedOnCurrentPlatform
case commandFailed(command: String, exitCode: Int32, output: String)
case invalidRepository(URL)
case notImplemented(String)
}

View file

@ -0,0 +1,179 @@
import Foundation
import SaplingCore
public final class MacGitProvider: GitProvider, @unchecked Sendable {
public let displayName = "System Git"
public init() {}
public func initRepository(at path: URL) async throws -> GitRepository {
#if os(macOS)
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
try runGit(["init"], in: path)
return GitRepository(name: path.lastPathComponent, rootURL: path)
#else
throw GitProviderError.unsupportedOnCurrentPlatform
#endif
}
public func clone(remoteURL: URL, to destinationURL: URL) async throws -> GitRepository {
#if os(macOS)
try runGit(["clone", remoteURL.absoluteString, destinationURL.path], in: destinationURL.deletingLastPathComponent())
return GitRepository(name: destinationURL.lastPathComponent, rootURL: destinationURL)
#else
throw GitProviderError.unsupportedOnCurrentPlatform
#endif
}
public func repository(at path: URL) -> any Repository {
MacGitRepository(rootURL: path) { [self] arguments, directory in
try runGit(arguments, in: directory)
}
}
#if os(macOS)
@discardableResult
private func runGit(_ arguments: [String], in directory: URL) throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["git"] + arguments
process.currentDirectoryURL = directory
let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
try process.run()
process.waitUntilExit()
let outputData = stdout.fileHandleForReading.readDataToEndOfFile()
let errorData = stderr.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData + errorData, encoding: .utf8) ?? ""
guard process.terminationStatus == 0 else {
throw GitProviderError.commandFailed(
command: "git \(arguments.joined(separator: " "))",
exitCode: process.terminationStatus,
output: output
)
}
return output
}
#else
private func runGit(_ arguments: [String], in directory: URL) throws -> String {
throw GitProviderError.unsupportedOnCurrentPlatform
}
#endif
}
private struct MacGitRepository: Repository {
let rootURL: URL
let runner: @Sendable (_ arguments: [String], _ directory: URL) throws -> String
func status() async throws -> [GitFileStatus] {
let output = try runner(["status", "--porcelain"], rootURL)
return output
.split(separator: "\n")
.compactMap(parseStatusLine)
}
func add(paths: [String]) async throws {
_ = try runner(["add"] + paths, rootURL)
}
func commit(message: String) async throws -> GitCommit {
_ = try runner(["commit", "-m", message], rootURL)
let hash = try runner(["rev-parse", "HEAD"], rootURL).trimmingCharacters(in: .whitespacesAndNewlines)
return GitCommit(
id: hash,
shortHash: String(hash.prefix(7)),
authorName: "",
authorEmail: "",
message: message,
authoredAt: Date()
)
}
func push(remote: String?, branch: String?) async throws {
_ = try runner(compactArguments(["push", remote, branch]), rootURL)
}
func pull(remote: String?, branch: String?) async throws {
_ = try runner(compactArguments(["pull", remote, branch]), rootURL)
}
func createBranch(named name: String) async throws -> GitBranch {
_ = try runner(["branch", name], rootURL)
return GitBranch(name: name)
}
func switchBranch(named name: String) async throws -> GitBranch {
_ = try runner(["switch", name], rootURL)
return GitBranch(name: name, isCurrent: true)
}
func merge(branch name: String) async throws {
_ = try runner(["merge", name], rootURL)
}
func addSubmodule(remoteURL: URL, path: String) async throws -> Subproject {
_ = try runner(["submodule", "add", remoteURL.absoluteString, path], rootURL)
return Subproject(
name: URL(fileURLWithPath: path).lastPathComponent,
path: path,
repositoryURL: rootURL.appendingPathComponent(path),
remoteURL: remoteURL
)
}
func removeSubmodule(path: String) async throws {
// TODO: Coordinate .gitmodules edits, deinit, and metadata cleanup in a transaction.
_ = try runner(["submodule", "deinit", "-f", path], rootURL)
_ = try runner(["rm", "-f", path], rootURL)
}
func updateSubmodules(initSubmodules: Bool, recursive: Bool) async throws {
var arguments = ["submodule", "update"]
if initSubmodules { arguments.append("--init") }
if recursive { arguments.append("--recursive") }
_ = try runner(arguments, rootURL)
}
func syncSubmodules(recursive: Bool) async throws {
var arguments = ["submodule", "sync"]
if recursive { arguments.append("--recursive") }
_ = try runner(arguments, rootURL)
}
private func parseStatusLine(_ line: Substring) -> GitFileStatus? {
guard line.count >= 4 else { return nil }
let code = String(line.prefix(2))
let path = String(line.dropFirst(3))
let state: GitFileState
if code.contains("U") {
state = .conflicted
} else if code.contains("A") {
state = .added
} else if code.contains("D") {
state = .deleted
} else if code.contains("R") {
state = .renamed
} else if code == "??" {
state = .untracked
} else {
state = .modified
}
return GitFileStatus(path: path, state: state)
}
private func compactArguments(_ values: [String?]) -> [String] {
values.compactMap { value in
guard let value, !value.isEmpty else { return nil }
return value
}
}
}

View file

@ -0,0 +1,70 @@
import Foundation
import SaplingCore
public final class MockGitProvider: GitProvider {
public let displayName = "Mock Git"
private let statuses: [GitFileStatus]
public init(
statuses: [GitFileStatus] = [
GitFileStatus(path: "README.md", state: .modified),
GitFileStatus(path: "attachments/architecture.png", state: .untracked)
]
) {
self.statuses = statuses
}
public func initRepository(at path: URL) async throws -> GitRepository {
GitRepository(name: path.lastPathComponent, rootURL: path)
}
public func clone(remoteURL: URL, to destinationURL: URL) async throws -> GitRepository {
GitRepository(
name: destinationURL.lastPathComponent,
rootURL: destinationURL,
remotes: [GitRemote(name: "origin", url: remoteURL)]
)
}
public func repository(at path: URL) -> any Repository {
MockRepository(rootURL: path, statuses: statuses)
}
}
private struct MockRepository: Repository {
let rootURL: URL
let statuses: [GitFileStatus]
func status() async throws -> [GitFileStatus] { statuses }
func add(paths: [String]) async throws {}
func commit(message: String) async throws -> GitCommit {
GitCommit(
id: UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased(),
shortHash: "mocked",
authorName: "Sapling",
authorEmail: "sapling@example.com",
message: message,
authoredAt: Date()
)
}
func push(remote: String?, branch: String?) async throws {}
func pull(remote: String?, branch: String?) async throws {}
func createBranch(named name: String) async throws -> GitBranch { GitBranch(name: name) }
func switchBranch(named name: String) async throws -> GitBranch { GitBranch(name: name, isCurrent: true) }
func merge(branch name: String) async throws {}
func addSubmodule(remoteURL: URL, path: String) async throws -> Subproject {
Subproject(
name: URL(fileURLWithPath: path).lastPathComponent,
path: path,
repositoryURL: rootURL.appendingPathComponent(path),
remoteURL: remoteURL
)
}
func removeSubmodule(path: String) async throws {}
func updateSubmodules(initSubmodules: Bool, recursive: Bool) async throws {}
func syncSubmodules(recursive: Bool) async throws {}
}

View file

@ -0,0 +1,124 @@
import Foundation
public protocol MarkdownRendering: Sendable {
func blocks(for markdown: String) -> [RenderedMarkdownBlock]
func inlineMarkdown(for source: String) -> AttributedString
}
public enum RenderedMarkdownBlock: Identifiable, Hashable, Sendable {
case heading(id: UUID, level: Int, text: String)
case paragraph(id: UUID, text: AttributedString)
case codeBlock(id: UUID, language: String?, code: String)
case task(id: UUID, checked: Bool, text: AttributedString)
case image(id: UUID, altText: String, source: String)
case blank(id: UUID)
public var id: UUID {
switch self {
case .heading(let id, _, _): id
case .paragraph(let id, _): id
case .codeBlock(let id, _, _): id
case .task(let id, _, _): id
case .image(let id, _, _): id
case .blank(let id): id
}
}
}
public struct MarkdownRenderer: MarkdownRendering {
public init() {}
public func blocks(for markdown: String) -> [RenderedMarkdownBlock] {
var blocks: [RenderedMarkdownBlock] = []
var codeLines: [String] = []
var codeLanguage: String?
var isInCodeBlock = false
for line in markdown.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
if line.hasPrefix("```") {
if isInCodeBlock {
blocks.append(.codeBlock(id: UUID(), language: codeLanguage, code: codeLines.joined(separator: "\n")))
codeLines.removeAll()
codeLanguage = nil
isInCodeBlock = false
} else {
isInCodeBlock = true
codeLanguage = String(line.dropFirst(3)).nilIfEmpty
}
continue
}
if isInCodeBlock {
codeLines.append(line)
continue
}
blocks.append(block(for: line))
}
if isInCodeBlock {
blocks.append(.codeBlock(id: UUID(), language: codeLanguage, code: codeLines.joined(separator: "\n")))
}
return blocks
}
public func inlineMarkdown(for source: String) -> AttributedString {
(try? AttributedString(markdown: source)) ?? AttributedString(source)
}
private func block(for line: String) -> RenderedMarkdownBlock {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else {
return .blank(id: UUID())
}
if let heading = parseHeading(trimmed) {
return .heading(id: UUID(), level: heading.level, text: heading.text)
}
if let task = parseTask(trimmed) {
return .task(id: UUID(), checked: task.checked, text: inlineMarkdown(for: task.text))
}
if let image = parseImage(trimmed) {
return .image(id: UUID(), altText: image.altText, source: image.source)
}
return .paragraph(id: UUID(), text: inlineMarkdown(for: line))
}
private func parseHeading(_ line: String) -> (level: Int, text: String)? {
let markerCount = line.prefix { $0 == "#" }.count
guard (1...6).contains(markerCount), line.dropFirst(markerCount).first == " " else {
return nil
}
return (markerCount, String(line.dropFirst(markerCount + 1)))
}
private func parseTask(_ line: String) -> (checked: Bool, text: String)? {
if line.hasPrefix("- [x] ") || line.hasPrefix("- [X] ") {
return (true, String(line.dropFirst(6)))
}
if line.hasPrefix("- [ ] ") {
return (false, String(line.dropFirst(6)))
}
return nil
}
private func parseImage(_ line: String) -> (altText: String, source: String)? {
guard line.hasPrefix("!["), let closeAlt = line.firstIndex(of: "]") else { return nil }
let afterAlt = line[line.index(after: closeAlt)...]
guard afterAlt.hasPrefix("("), afterAlt.hasSuffix(")") else { return nil }
let alt = String(line[line.index(line.startIndex, offsetBy: 2)..<closeAlt])
let source = String(afterAlt.dropFirst().dropLast())
return (alt, source)
}
}
private extension String {
var nilIfEmpty: String? {
isEmpty ? nil : self
}
}

View file

@ -0,0 +1,38 @@
import Foundation
public struct SaplingConfiguration: Hashable, Codable, Sendable {
public var recentWorkspaceURLs: [URL]
public var defaultBranchName: String
public var autosavesDrafts: Bool
public init(
recentWorkspaceURLs: [URL] = [],
defaultBranchName: String = "main",
autosavesDrafts: Bool = true
) {
self.recentWorkspaceURLs = recentWorkspaceURLs
self.defaultBranchName = defaultBranchName
self.autosavesDrafts = autosavesDrafts
}
}
public protocol ConfigurationStore: Sendable {
func loadConfiguration() throws -> SaplingConfiguration
func saveConfiguration(_ configuration: SaplingConfiguration) throws
}
public final class InMemoryConfigurationStore: ConfigurationStore, @unchecked Sendable {
private var configuration: SaplingConfiguration
public init(configuration: SaplingConfiguration = SaplingConfiguration()) {
self.configuration = configuration
}
public func loadConfiguration() throws -> SaplingConfiguration {
configuration
}
public func saveConfiguration(_ configuration: SaplingConfiguration) throws {
self.configuration = configuration
}
}

View file

@ -0,0 +1,72 @@
import Foundation
import SaplingCore
public struct WorkspaceMetadata: Hashable, Codable, Sendable {
public var workspaceID: UUID
public var lastOpenedAt: Date
public var selectedDocumentURL: URL?
public var expandedItemIDs: Set<UUID>
public init(
workspaceID: UUID,
lastOpenedAt: Date = Date(),
selectedDocumentURL: URL? = nil,
expandedItemIDs: Set<UUID> = []
) {
self.workspaceID = workspaceID
self.lastOpenedAt = lastOpenedAt
self.selectedDocumentURL = selectedDocumentURL
self.expandedItemIDs = expandedItemIDs
}
}
public protocol WorkspaceMetadataStore: Sendable {
func loadMetadata(for workspaceID: UUID) throws -> WorkspaceMetadata?
func saveMetadata(_ metadata: WorkspaceMetadata) throws
}
public final class InMemoryWorkspaceMetadataStore: WorkspaceMetadataStore, @unchecked Sendable {
private var metadataByWorkspaceID: [UUID: WorkspaceMetadata]
public init(metadataByWorkspaceID: [UUID: WorkspaceMetadata] = [:]) {
self.metadataByWorkspaceID = metadataByWorkspaceID
}
public func loadMetadata(for workspaceID: UUID) throws -> WorkspaceMetadata? {
metadataByWorkspaceID[workspaceID]
}
public func saveMetadata(_ metadata: WorkspaceMetadata) throws {
metadataByWorkspaceID[metadata.workspaceID] = metadata
}
}
public final class JSONWorkspaceMetadataStore: WorkspaceMetadataStore {
private let metadataDirectory: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(metadataDirectory: URL) {
self.metadataDirectory = metadataDirectory
self.encoder = JSONEncoder()
self.decoder = JSONDecoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
}
public func loadMetadata(for workspaceID: UUID) throws -> WorkspaceMetadata? {
let url = fileURL(for: workspaceID)
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try Data(contentsOf: url)
return try decoder.decode(WorkspaceMetadata.self, from: data)
}
public func saveMetadata(_ metadata: WorkspaceMetadata) throws {
try FileManager.default.createDirectory(at: metadataDirectory, withIntermediateDirectories: true)
let data = try encoder.encode(metadata)
try data.write(to: fileURL(for: metadata.workspaceID), options: [.atomic])
}
private func fileURL(for workspaceID: UUID) -> URL {
metadataDirectory.appendingPathComponent("\(workspaceID.uuidString).json")
}
}

View file

@ -0,0 +1,14 @@
import SwiftUI
public struct EmptyEditorPlaceholder: View {
public init() {}
public var body: some View {
ContentUnavailableView(
"Select a Markdown file",
systemImage: "doc.text.magnifyingglass",
description: Text("Open a file from the workspace tree to begin editing.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

View file

@ -0,0 +1,121 @@
import SwiftUI
import SaplingCore
public struct SaplingInspectorView: View {
private let project: Project?
private let document: MarkdownDocument?
private let statuses: [GitFileStatus]
public init(
project: Project?,
document: MarkdownDocument?,
statuses: [GitFileStatus]
) {
self.project = project
self.document = document
self.statuses = statuses
}
public var body: some View {
List {
projectSection
gitStatusSection
outlineSection
}
.listStyle(.inset)
.navigationTitle("Inspector")
}
@ViewBuilder
private var projectSection: some View {
Section("Project") {
if let project {
LabeledContent("Name", value: project.name)
LabeledContent("Branch", value: project.gitRepository.currentBranch?.name ?? "Unknown")
LabeledContent("Remotes", value: "\(project.remotes.count)")
LabeledContent("LFS", value: project.usesGitLFS ? "Enabled" : "Disabled")
LabeledContent("Subprojects", value: "\(project.subprojects.count)")
} else {
Text("No project selected")
.foregroundStyle(.secondary)
}
}
}
private var gitStatusSection: some View {
Section("Git Status") {
if statuses.isEmpty {
Label("Clean", systemImage: "checkmark.circle")
.foregroundStyle(.green)
} else {
ForEach(statuses) { status in
HStack {
Image(systemName: iconName(for: status.state))
.foregroundStyle(color(for: status.state))
Text(status.path)
.lineLimit(1)
Spacer()
Text(status.state.rawValue)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
private var outlineSection: some View {
Section("Outline") {
if let document {
let headings = outlineHeadings(in: document.content)
if headings.isEmpty {
Text("No headings")
.foregroundStyle(.secondary)
} else {
ForEach(headings, id: \.self) { heading in
Text(heading)
.lineLimit(1)
}
}
} else {
Text("No document selected")
.foregroundStyle(.secondary)
}
}
}
private func outlineHeadings(in markdown: String) -> [String] {
markdown
.split(separator: "\n")
.map(String.init)
.compactMap { line in
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("#") else { return nil }
let hashes = trimmed.prefix { $0 == "#" }.count
guard hashes <= 6, trimmed.dropFirst(hashes).first == " " else { return nil }
return String(trimmed.dropFirst(hashes + 1))
}
}
private func iconName(for state: GitFileState) -> String {
switch state {
case .untracked: "questionmark.circle"
case .added: "plus.circle"
case .modified: "pencil.circle"
case .deleted: "minus.circle"
case .renamed: "arrow.triangle.2.circlepath"
case .conflicted: "exclamationmark.triangle"
}
}
private func color(for state: GitFileState) -> Color {
switch state {
case .untracked: .secondary
case .added: .green
case .modified: .orange
case .deleted: .red
case .renamed: .blue
case .conflicted: .red
}
}
}

View file

@ -0,0 +1,82 @@
import SwiftUI
import SaplingCore
public struct WorkspaceTreeView: View {
private let workspace: Workspace
private let onSelectFile: (WorkspaceFile) -> Void
private let onSelectProject: (Project) -> Void
public init(
workspace: Workspace,
onSelectFile: @escaping (WorkspaceFile) -> Void,
onSelectProject: @escaping (Project) -> Void
) {
self.workspace = workspace
self.onSelectFile = onSelectFile
self.onSelectProject = onSelectProject
}
public var body: some View {
List {
Section(workspace.name) {
ForEach(workspace.items) { item in
WorkspaceItemRow(
item: item,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
}
}
}
.listStyle(.sidebar)
.navigationTitle("Workspace")
}
}
private struct WorkspaceItemRow: View {
let item: WorkspaceItem
let onSelectFile: (WorkspaceFile) -> Void
let onSelectProject: (Project) -> Void
var body: some View {
switch item {
case .folder(let folder):
DisclosureGroup {
ForEach(folder.children) { child in
WorkspaceItemRow(
item: child,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
}
} label: {
Label(folder.name, systemImage: "folder")
}
case .file(let file):
Button {
onSelectFile(file)
} label: {
Label(file.name, systemImage: iconName(for: file.kind))
}
.buttonStyle(.plain)
case .project(let project):
Button {
onSelectProject(project)
} label: {
Label(project.name, systemImage: "point.3.connected.trianglepath.dotted")
}
.buttonStyle(.plain)
case .subproject(let subproject):
Label(subproject.name, systemImage: "rectangle.connected.to.line.below")
.foregroundStyle(.secondary)
}
}
private func iconName(for kind: WorkspaceFileKind) -> String {
switch kind {
case .markdown: "doc.plaintext"
case .attachment: "paperclip"
case .other: "doc"
}
}
}

View file

@ -0,0 +1,102 @@
import Foundation
import SaplingCore
import SaplingGit
import SaplingStorage
public protocol WorkspaceManaging: Sendable {
func openWorkspace(at url: URL) async throws -> Workspace
func sampleWorkspace() -> Workspace
}
public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable {
private let gitProvider: any GitProvider
private let metadataStore: any WorkspaceMetadataStore
private let fileManager: FileManager
public init(
gitProvider: any GitProvider,
metadataStore: any WorkspaceMetadataStore,
fileManager: FileManager = .default
) {
self.gitProvider = gitProvider
self.metadataStore = metadataStore
self.fileManager = fileManager
}
public func openWorkspace(at url: URL) async throws -> Workspace {
let items = try scanItems(at: url, relativeTo: url)
let workspace = Workspace(name: url.lastPathComponent, rootURL: url, items: items)
try SaplingRules.validateWorkspace(workspace)
try metadataStore.saveMetadata(WorkspaceMetadata(workspaceID: workspace.id))
return workspace
}
public func sampleWorkspace() -> Workspace {
SaplingSampleData.workspace
}
private func scanItems(at url: URL, relativeTo rootURL: URL) throws -> [WorkspaceItem] {
guard let children = try? fileManager.contentsOfDirectory(
at: url,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return try children
.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }
.map { childURL in
if isGitRepository(at: childURL) {
return .project(project(at: childURL))
}
let values = try childURL.resourceValues(forKeys: [.isDirectoryKey])
if values.isDirectory == true {
return .folder(
WorkspaceFolder(
name: childURL.lastPathComponent,
url: childURL,
children: try scanItems(at: childURL, relativeTo: rootURL)
)
)
}
return .file(
WorkspaceFile(
name: childURL.lastPathComponent,
url: childURL,
kind: fileKind(for: childURL)
)
)
}
}
private func isGitRepository(at url: URL) -> Bool {
fileManager.fileExists(atPath: url.appendingPathComponent(".git").path)
}
private func project(at url: URL) -> Project {
let repository = GitRepository(
name: url.lastPathComponent,
rootURL: url,
statusSummary: .unknown
)
return Project(
name: url.lastPathComponent,
repositoryURL: url,
gitRepository: repository
)
}
private func fileKind(for url: URL) -> WorkspaceFileKind {
switch url.pathExtension.lowercased() {
case "md", "markdown":
return .markdown
case "png", "jpg", "jpeg", "gif", "webp", "pdf", "mp3", "mp4":
return .attachment
default:
return .other
}
}
}

View file

@ -0,0 +1,20 @@
import XCTest
@testable import SaplingCore
final class SaplingRulesTests: XCTestCase {
func testSampleWorkspacePassesValidation() throws {
try SaplingRules.validateWorkspace(SaplingSampleData.workspace)
}
func testRejectsEmptySubprojectPath() {
let subproject = Subproject(
name: "Broken",
path: "",
repositoryURL: URL(fileURLWithPath: "/tmp/Broken")
)
XCTAssertThrowsError(try SaplingRules.validateSubproject(subproject)) { error in
XCTAssertEqual(error as? SaplingDomainError, .subprojectPathCannotBeEmpty)
}
}
}