Added the initial project scaffolding
This commit is contained in:
parent
48818f27b3
commit
c12816c09c
24 changed files with 7586 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.DS_Store
|
||||||
|
.build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcodeproj/project.xcworkspace/xcuserdata/
|
||||||
|
*.xcworkspace/xcuserdata/
|
||||||
|
*.xcuserstate
|
||||||
5695
Assets/SaplingIcon.ai
Normal file
5695
Assets/SaplingIcon.ai
Normal file
File diff suppressed because one or more lines are too long
12
Assets/SaplingIcon.icon/Assets/SaplingIcon.svg
Normal file
12
Assets/SaplingIcon.icon/Assets/SaplingIcon.svg
Normal 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 |
52
Assets/SaplingIcon.icon/icon.json
Normal file
52
Assets/SaplingIcon.icon/icon.json
Normal 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
12
Assets/SaplingIcon.svg
Normal 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
33
Docs/Architecture.md
Normal 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
64
Package.swift
Normal 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
40
README
|
|
@ -152,3 +152,43 @@ Will share as much code as possible with the macOS application while providing a
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Sapling is currently in active development.
|
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.
|
||||||
|
|
|
||||||
156
Sources/SaplingApp/SaplingApp.swift
Normal file
156
Sources/SaplingApp/SaplingApp.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
Sources/SaplingCore/BusinessRules.swift
Normal file
125
Sources/SaplingCore/BusinessRules.swift
Normal 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)
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
313
Sources/SaplingCore/Models.swift
Normal file
313
Sources/SaplingCore/Models.swift
Normal 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
|
||||||
|
}
|
||||||
160
Sources/SaplingEditor/HybridMarkdownEditor.swift
Normal file
160
Sources/SaplingEditor/HybridMarkdownEditor.swift
Normal 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
|
||||||
|
}
|
||||||
41
Sources/SaplingGit/EmbeddedGitProvider.swift
Normal file
41
Sources/SaplingGit/EmbeddedGitProvider.swift
Normal 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Sources/SaplingGit/GitProtocols.swift
Normal file
55
Sources/SaplingGit/GitProtocols.swift
Normal 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)
|
||||||
|
}
|
||||||
179
Sources/SaplingGit/MacGitProvider.swift
Normal file
179
Sources/SaplingGit/MacGitProvider.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Sources/SaplingGit/MockGitProvider.swift
Normal file
70
Sources/SaplingGit/MockGitProvider.swift
Normal 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 {}
|
||||||
|
}
|
||||||
124
Sources/SaplingRenderer/MarkdownRenderer.swift
Normal file
124
Sources/SaplingRenderer/MarkdownRenderer.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Sources/SaplingStorage/Configuration.swift
Normal file
38
Sources/SaplingStorage/Configuration.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
72
Sources/SaplingStorage/WorkspaceMetadataStore.swift
Normal file
72
Sources/SaplingStorage/WorkspaceMetadataStore.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Sources/SaplingUI/EmptyEditorPlaceholder.swift
Normal file
14
Sources/SaplingUI/EmptyEditorPlaceholder.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
121
Sources/SaplingUI/SaplingInspectorView.swift
Normal file
121
Sources/SaplingUI/SaplingInspectorView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
Sources/SaplingUI/WorkspaceTreeView.swift
Normal file
82
Sources/SaplingUI/WorkspaceTreeView.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
Sources/SaplingWorkspace/WorkspaceManager.swift
Normal file
102
Sources/SaplingWorkspace/WorkspaceManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Tests/SaplingCoreTests/SaplingRulesTests.swift
Normal file
20
Tests/SaplingCoreTests/SaplingRulesTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue