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