Compare commits
13 commits
7d4538d8c1
...
bfedd11186
| Author | SHA1 | Date | |
|---|---|---|---|
| bfedd11186 | |||
| 3b9b165464 | |||
| 771f3368b4 | |||
| 1801f44f86 | |||
| ab545ef18b | |||
| 4d7a0d04c2 | |||
| a0ca8bf89c | |||
| 373a05b265 | |||
| 1eb9ede960 | |||
| 1d7840e45c | |||
| 3d3d1aee28 | |||
| 1ca75c60bd | |||
| d05b211ded |
17 changed files with 976 additions and 153 deletions
|
|
@ -6,7 +6,7 @@ Sapling is organized as a Swift Package with an executable app target and focuse
|
|||
|
||||
- `SaplingApp`: SwiftUI application entry point and dependency composition.
|
||||
- `SaplingCore`: Domain models, sample data, and business rules.
|
||||
- `SaplingWorkspace`: Workspace opening and file tree management.
|
||||
- `SaplingWorkspace`: Filesystem-backed workspace opening and file tree discovery.
|
||||
- `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.
|
||||
|
|
@ -18,21 +18,22 @@ Sapling is organized as a Swift Package with an executable app target and focuse
|
|||
|
||||
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.
|
||||
Workspaces are local containers and are not versioned. Projects are Git repositories, while subprojects model Git submodules. Workspace contents are derived lazily from the filesystem rather than a Sapling-owned manifest or database. 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.
|
||||
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. `DocumentSessionStore` keeps open document sessions separate from the workspace tree so opening the same file activates the existing editor state instead of creating a duplicate. 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.
|
||||
Storage is abstracted behind small protocols. `SaplingConfiguration` persists application settings such as the last opened workspace path. Workspace contents are not persisted; they are rebuilt from the filesystem.
|
||||
|
||||
Dependency injection starts in `SaplingApp` through `AppDependencies`. The composition root owns concrete providers and passes protocols into application state. This keeps feature modules testable and prevents UI code from constructing infrastructure ad hoc.
|
||||
|
||||
Settings are represented by `SaplingConfiguration`, persisted by `ConfigurationStore`, and exposed through the SwiftUI `Settings` scene. Logging flows through `SaplingLogger` so feature code uses stable categories without depending directly on logger construction details.
|
||||
|
||||
See `Docs/Workspace.md` for the Milestone 4.1 workspace scanning and document session model.
|
||||
|
||||
The development workflow is captured in `Makefile` and `Scripts/`. `make validate` runs formatting, linting, build, and tests; this is the Milestone 0 gate before updating roadmap status.
|
||||
|
||||
## TODO
|
||||
|
||||
- TODO: Replace sample data composition with real workspace opening and file loading.
|
||||
- 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.
|
||||
|
|
|
|||
83
Docs/Workspace.md
Normal file
83
Docs/Workspace.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Workspace Architecture
|
||||
|
||||
Milestone 4.1 introduces Sapling's first filesystem-backed workspace experience.
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
A workspace is a folder on disk.
|
||||
|
||||
Sapling does not create workspace manifests, project registries, content databases, or any other authoritative copy of workspace contents. The application may persist UI and application settings, such as the last opened workspace path, but the filesystem remains the source of truth for folders, files, and projects.
|
||||
|
||||
Workspace contents are discovered from the selected root directory and kept as an in-memory presentation tree. That tree can be rebuilt from the filesystem.
|
||||
|
||||
## Filesystem Scanning
|
||||
|
||||
`LocalWorkspaceManager` scans one directory level at a time and derives:
|
||||
|
||||
- folders from directories
|
||||
- files from regular filesystem items
|
||||
- projects from directories containing `.git`
|
||||
|
||||
Hidden files and `.git` internals are excluded from the visible tree. Project directories remain browsable tree nodes, but they are visually distinguished from ordinary folders.
|
||||
|
||||
The scanner performs no Git operations. Repository detection is based only on filesystem metadata.
|
||||
|
||||
## Lazy Discovery
|
||||
|
||||
Workspace opening loads only the root directory level. Folder and project children are loaded when the user expands that tree node.
|
||||
|
||||
```text
|
||||
Open Workspace
|
||||
-> scan root directory only
|
||||
-> display root items
|
||||
-> expand folder
|
||||
-> scan that folder only
|
||||
```
|
||||
|
||||
Collapsed folders do no filesystem work. Previously, eager recursive scanning made initial workspace opening and large folder expansion feel slow because large portions of the workspace were discovered before the user asked to see them. The lazy strategy keeps initial display bounded by the size of the root directory and moves deeper discovery to explicit expansion.
|
||||
|
||||
The application model stores loaded children by standardized folder URL. This cache is UI state, not authoritative workspace metadata. If the tree needs to be refreshed, it can be rebuilt from the filesystem.
|
||||
|
||||
Filesystem scanning is dispatched away from the main actor so the UI can show loading feedback immediately.
|
||||
|
||||
## Workspace Tree
|
||||
|
||||
The workspace tree answers:
|
||||
|
||||
> What exists?
|
||||
|
||||
It represents the current filesystem hierarchy and should not own editor state, cursor position, scroll position, unsaved changes, or tab state.
|
||||
|
||||
The sidebar uses the derived tree to display folders, Markdown files, other files, and Git-backed projects. Folder and project nodes are collapsible so large hierarchies can be browsed incrementally.
|
||||
|
||||
Expanding a folder shows a lightweight loading row while that directory is scanned. Tree insertion animations are disabled because large animated insertions made expansion feel slower and visually noisy.
|
||||
|
||||
Rows are sorted with folders and projects first, then files. Each group uses localized alphabetical ordering. Git repositories participate in folder ordering but keep project styling.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Docs
|
||||
Sapling
|
||||
Tests
|
||||
README.md
|
||||
ROADMAP.md
|
||||
```
|
||||
|
||||
## Document Sessions
|
||||
|
||||
Document sessions answer:
|
||||
|
||||
> What is open?
|
||||
|
||||
`DocumentSessionStore` owns open Markdown document sessions independently from the workspace tree. Each session owns its `HybridMarkdownEditorViewModel`, which contains editor state for the document.
|
||||
|
||||
A file may only have one document session within the active workspace. Opening an already-open Markdown file activates the existing session instead of creating a duplicate editor state.
|
||||
|
||||
Opening a new workspace clears document sessions from the previous workspace.
|
||||
|
||||
## Persisted State
|
||||
|
||||
Sapling persists the last opened workspace URL in `SaplingConfiguration`.
|
||||
|
||||
It does not persist workspace contents. Files, folders, and projects are always rediscovered from disk.
|
||||
|
|
@ -26,7 +26,6 @@ let package = Package(
|
|||
dependencies: [
|
||||
"SaplingCore",
|
||||
"SaplingWorkspace",
|
||||
"SaplingGit",
|
||||
"SaplingEditor",
|
||||
"SaplingRenderer",
|
||||
"SaplingStorage",
|
||||
|
|
@ -44,7 +43,7 @@ let package = Package(
|
|||
.target(name: "SaplingCore"),
|
||||
.target(
|
||||
name: "SaplingWorkspace",
|
||||
dependencies: ["SaplingCore", "SaplingGit", "SaplingStorage"]
|
||||
dependencies: ["SaplingCore"]
|
||||
),
|
||||
.target(
|
||||
name: "SaplingGit",
|
||||
|
|
@ -65,7 +64,7 @@ let package = Package(
|
|||
.target(name: "SaplingLogging"),
|
||||
.target(
|
||||
name: "SaplingUI",
|
||||
dependencies: ["SaplingCore", "SaplingWorkspace", "SaplingGit", "SaplingEditor", "SaplingStorage"]
|
||||
dependencies: ["SaplingCore", "SaplingWorkspace", "SaplingEditor", "SaplingStorage"]
|
||||
),
|
||||
.testTarget(
|
||||
name: "SaplingCoreTests",
|
||||
|
|
@ -74,6 +73,10 @@ let package = Package(
|
|||
.testTarget(
|
||||
name: "SaplingEditorTests",
|
||||
dependencies: ["SaplingCore", "SaplingEditor"]
|
||||
),
|
||||
.testTarget(
|
||||
name: "SaplingWorkspaceTests",
|
||||
dependencies: ["SaplingCore", "SaplingWorkspace"]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
|||
2
README
2
README
|
|
@ -197,6 +197,6 @@ make validate
|
|||
|
||||
The validation target runs formatting, linting, build, and tests. If `swift-format` is installed, `make format` formats Swift sources in place; otherwise it falls back to the repository lint checks.
|
||||
|
||||
The initial app shell launches with a sample workspace tree, a hybrid Markdown editor, a Git/project inspector, and a mock Git provider. The architecture is intentionally protocol-first so the macOS implementation can use system Git while future iOS support can use an embedded Git implementation behind the same interfaces.
|
||||
The app shell can open a local folder as a workspace, scan its filesystem hierarchy, visually distinguish Git repositories as projects, and open Markdown files through reusable document sessions. 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.
|
||||
|
|
|
|||
|
|
@ -1,49 +1,32 @@
|
|||
import Foundation
|
||||
import SaplingGit
|
||||
import SaplingLogging
|
||||
import SaplingStorage
|
||||
import SaplingWorkspace
|
||||
|
||||
struct AppDependencies {
|
||||
let gitProvider: any GitProvider
|
||||
let configurationStore: any ConfigurationStore
|
||||
let metadataStore: any WorkspaceMetadataStore
|
||||
let workspaceManager: any WorkspaceManaging
|
||||
let logger: SaplingLogger
|
||||
|
||||
static func live() -> AppDependencies {
|
||||
let gitProvider = MockGitProvider()
|
||||
let metadataStore = InMemoryWorkspaceMetadataStore()
|
||||
let configurationStore = JSONConfigurationStore(
|
||||
fileURL: supportDirectory().appendingPathComponent("Configuration.json")
|
||||
)
|
||||
let workspaceManager = LocalWorkspaceManager(
|
||||
gitProvider: gitProvider,
|
||||
metadataStore: metadataStore
|
||||
)
|
||||
let workspaceManager = LocalWorkspaceManager()
|
||||
|
||||
return AppDependencies(
|
||||
gitProvider: gitProvider,
|
||||
configurationStore: configurationStore,
|
||||
metadataStore: metadataStore,
|
||||
workspaceManager: workspaceManager,
|
||||
logger: SaplingLogger()
|
||||
)
|
||||
}
|
||||
|
||||
static func preview() -> AppDependencies {
|
||||
let gitProvider = MockGitProvider()
|
||||
let metadataStore = InMemoryWorkspaceMetadataStore()
|
||||
let configurationStore = InMemoryConfigurationStore()
|
||||
let workspaceManager = LocalWorkspaceManager(
|
||||
gitProvider: gitProvider,
|
||||
metadataStore: metadataStore
|
||||
)
|
||||
let workspaceManager = LocalWorkspaceManager()
|
||||
|
||||
return AppDependencies(
|
||||
gitProvider: gitProvider,
|
||||
configurationStore: configurationStore,
|
||||
metadataStore: metadataStore,
|
||||
workspaceManager: workspaceManager,
|
||||
logger: SaplingLogger(subsystem: "app.sapling.Sapling.preview")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import SwiftUI
|
|||
import UniformTypeIdentifiers
|
||||
import SaplingCore
|
||||
import SaplingWorkspace
|
||||
import SaplingGit
|
||||
import SaplingStorage
|
||||
import SaplingLogging
|
||||
import SaplingEditor
|
||||
|
|
@ -25,6 +24,14 @@ struct SaplingApplication: App {
|
|||
WindowGroup {
|
||||
MainWindow(model: model)
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(after: .newItem) {
|
||||
Button("Open Workspace...") {
|
||||
model.presentWorkspaceImporter()
|
||||
}
|
||||
.keyboardShortcut("o", modifiers: [.command, .shift])
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.windowStyle(.titleBar)
|
||||
.windowToolbarStyle(.unified)
|
||||
|
|
@ -52,46 +59,142 @@ private final class SaplingAppDelegate: NSObject, NSApplicationDelegate {
|
|||
|
||||
@MainActor
|
||||
private final class SaplingAppModel: ObservableObject {
|
||||
@Published var workspace: Workspace
|
||||
@Published var workspace: Workspace?
|
||||
@Published var workspaceChildren: [URL: [WorkspaceItem]] = [:]
|
||||
@Published var loadingTreeItemURLs: Set<URL> = []
|
||||
@Published var workspaceTreeRevision = 0
|
||||
@Published var workspaceSelection: WorkspaceTreeSelection?
|
||||
@Published var selectedProject: Project?
|
||||
@Published var selectedDocument: MarkdownDocument?
|
||||
@Published var editorViewModel: HybridMarkdownEditorViewModel?
|
||||
@Published var gitStatuses: [GitFileStatus] = []
|
||||
@Published var configuration: SaplingConfiguration
|
||||
@Published var editorErrorMessage: String?
|
||||
@Published var isLoadingWorkspace = false
|
||||
|
||||
private let gitProvider: any GitProvider
|
||||
private let configurationStore: any ConfigurationStore
|
||||
private let workspaceManager: any WorkspaceManaging
|
||||
private let documentSessionStore: DocumentSessionStore
|
||||
private let logger: SaplingLogger
|
||||
|
||||
init(dependencies: AppDependencies) {
|
||||
self.gitProvider = dependencies.gitProvider
|
||||
self.configurationStore = dependencies.configurationStore
|
||||
self.workspaceManager = dependencies.workspaceManager
|
||||
self.documentSessionStore = DocumentSessionStore()
|
||||
self.logger = dependencies.logger
|
||||
self.configuration = (try? dependencies.configurationStore.loadConfiguration()) ?? SaplingConfiguration()
|
||||
|
||||
let workspace = workspaceManager.sampleWorkspace()
|
||||
self.workspace = workspace
|
||||
self.selectedProject = workspace.firstProject
|
||||
setSelectedDocument(SaplingSampleData.document)
|
||||
self.workspace = nil
|
||||
|
||||
logger.info("Sapling application model initialized", category: .app)
|
||||
|
||||
if let lastOpenedWorkspaceURL = configuration.lastOpenedWorkspaceURL {
|
||||
Task {
|
||||
await openWorkspace(at: lastOpenedWorkspaceURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func presentWorkspaceImporter() {
|
||||
#if os(macOS)
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Open Workspace"
|
||||
panel.message = "Choose a folder to use as your Sapling workspace."
|
||||
panel.prompt = "Open"
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canCreateDirectories = false
|
||||
panel.resolvesAliases = true
|
||||
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
|
||||
Task {
|
||||
await refreshGitStatus()
|
||||
await openWorkspace(at: url)
|
||||
}
|
||||
#else
|
||||
editorErrorMessage = "Workspace selection is currently available on macOS."
|
||||
#endif
|
||||
}
|
||||
|
||||
func openWorkspace(at url: URL) async {
|
||||
isLoadingWorkspace = true
|
||||
let didStartAccessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
isLoadingWorkspace = false
|
||||
if didStartAccessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let workspaceManager = self.workspaceManager
|
||||
let workspace = try await Task.detached {
|
||||
try await workspaceManager.openWorkspace(at: url)
|
||||
}.value
|
||||
self.workspace = workspace
|
||||
workspaceChildren = [:]
|
||||
loadingTreeItemURLs = []
|
||||
invalidateWorkspaceTree()
|
||||
workspaceSelection = nil
|
||||
selectedProject = nil
|
||||
selectedDocument = nil
|
||||
editorViewModel = nil
|
||||
documentSessionStore.closeAll()
|
||||
persistLastOpenedWorkspace(url)
|
||||
editorErrorMessage = nil
|
||||
logger.info("Opened workspace: \(workspace.name)", category: .workspace)
|
||||
} catch {
|
||||
editorErrorMessage = "Unable to open workspace \(url.lastPathComponent): \(error.localizedDescription)"
|
||||
logger.error("Failed to open workspace: \(error)", category: .workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func children(for item: WorkspaceItem) -> [WorkspaceItem] {
|
||||
guard let url = item.containerURL?.standardizedFileURL else {
|
||||
return item.children ?? []
|
||||
}
|
||||
|
||||
return workspaceChildren[url] ?? item.children ?? []
|
||||
}
|
||||
|
||||
func isLoadingChildren(for item: WorkspaceItem) -> Bool {
|
||||
guard let url = item.containerURL?.standardizedFileURL else { return false }
|
||||
return loadingTreeItemURLs.contains(url)
|
||||
}
|
||||
|
||||
func loadChildrenIfNeeded(for item: WorkspaceItem) {
|
||||
guard let url = item.containerURL?.standardizedFileURL else { return }
|
||||
guard workspaceChildren[url] == nil, !loadingTreeItemURLs.contains(url) else { return }
|
||||
|
||||
var loadingURLs = loadingTreeItemURLs
|
||||
loadingURLs.insert(url)
|
||||
loadingTreeItemURLs = loadingURLs
|
||||
invalidateWorkspaceTree()
|
||||
let workspaceManager = self.workspaceManager
|
||||
Task {
|
||||
do {
|
||||
let children = try await Task.detached {
|
||||
try await workspaceManager.loadItems(in: url)
|
||||
}.value
|
||||
var loadedChildren = workspaceChildren
|
||||
loadedChildren[url] = children
|
||||
workspaceChildren = loadedChildren
|
||||
invalidateWorkspaceTree()
|
||||
} catch {
|
||||
editorErrorMessage = "Unable to load \(item.displayName): \(error.localizedDescription)"
|
||||
logger.error("Failed to load workspace tree item: \(error)", category: .workspace)
|
||||
}
|
||||
var loadingURLs = loadingTreeItemURLs
|
||||
loadingURLs.remove(url)
|
||||
loadingTreeItemURLs = loadingURLs
|
||||
invalidateWorkspaceTree()
|
||||
}
|
||||
}
|
||||
|
||||
func select(file: WorkspaceFile) {
|
||||
guard file.kind == .markdown else { return }
|
||||
|
||||
if file.url == SaplingSampleData.document.url {
|
||||
setSelectedDocument(SaplingSampleData.document)
|
||||
} else {
|
||||
openDocument(at: file.url)
|
||||
}
|
||||
workspaceSelection = .file(file.url)
|
||||
openDocument(at: file.url)
|
||||
}
|
||||
|
||||
func openDocument(at url: URL) {
|
||||
|
|
@ -103,10 +206,10 @@ private final class SaplingAppModel: ObservableObject {
|
|||
}
|
||||
|
||||
do {
|
||||
let document = try HybridMarkdownEditorViewModel.loadDocument(at: url)
|
||||
setSelectedDocument(document)
|
||||
let session = try documentSessionStore.openDocument(at: url)
|
||||
activate(session)
|
||||
editorErrorMessage = nil
|
||||
logger.info("Opened document: \(document.title)", category: .editor)
|
||||
logger.info("Opened document: \(session.viewModel.document.title)", category: .editor)
|
||||
} catch {
|
||||
editorErrorMessage = "Unable to open \(url.lastPathComponent): \(error.localizedDescription)"
|
||||
logger.error("Failed to open document: \(error)", category: .editor)
|
||||
|
|
@ -129,10 +232,8 @@ private final class SaplingAppModel: ObservableObject {
|
|||
|
||||
func select(project: Project) {
|
||||
selectedProject = project
|
||||
workspaceSelection = .project(project.repositoryURL)
|
||||
logger.info("Selected project: \(project.name)", category: .workspace)
|
||||
Task {
|
||||
await refreshGitStatus()
|
||||
}
|
||||
}
|
||||
|
||||
func save(configuration: SaplingConfiguration) {
|
||||
|
|
@ -144,28 +245,22 @@ private final class SaplingAppModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func refreshGitStatus() async {
|
||||
guard let selectedProject else {
|
||||
gitStatuses = []
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let repository = gitProvider.repository(at: selectedProject.repositoryURL)
|
||||
gitStatuses = try await repository.status()
|
||||
logger.debug("Loaded \(gitStatuses.count) Git status entries", category: .git)
|
||||
} catch {
|
||||
gitStatuses = [
|
||||
GitFileStatus(path: "Unable to load status: \(error)", state: .conflicted)
|
||||
]
|
||||
logger.error("Failed to refresh Git status: \(error)", category: .git)
|
||||
}
|
||||
private func activate(_ session: MarkdownDocumentSession) {
|
||||
selectedDocument = session.viewModel.document
|
||||
editorViewModel = session.viewModel
|
||||
logger.info("Selected document: \(session.viewModel.document.title)", category: .editor)
|
||||
}
|
||||
|
||||
private func setSelectedDocument(_ document: MarkdownDocument) {
|
||||
selectedDocument = document
|
||||
editorViewModel = HybridMarkdownEditorViewModel(document: document)
|
||||
logger.info("Selected document: \(document.title)", category: .editor)
|
||||
private func persistLastOpenedWorkspace(_ url: URL) {
|
||||
configuration.lastOpenedWorkspaceURL = url
|
||||
configuration.recentWorkspaceURLs.removeAll { $0.standardizedFileURL == url.standardizedFileURL }
|
||||
configuration.recentWorkspaceURLs.insert(url, at: 0)
|
||||
configuration.recentWorkspaceURLs = Array(configuration.recentWorkspaceURLs.prefix(10))
|
||||
save(configuration: configuration)
|
||||
}
|
||||
|
||||
private func invalidateWorkspaceTree() {
|
||||
workspaceTreeRevision &+= 1
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -178,8 +273,15 @@ private struct MainWindow: View {
|
|||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
WorkspaceTreeView(
|
||||
workspace: model.workspace,
|
||||
isLoadingWorkspace: model.isLoadingWorkspace,
|
||||
treeContentRevision: model.workspaceTreeRevision,
|
||||
selection: $model.workspaceSelection,
|
||||
childrenFor: model.children(for:),
|
||||
isLoadingChildren: model.isLoadingChildren(for:),
|
||||
onExpandItem: model.loadChildrenIfNeeded(for:),
|
||||
onSelectFile: model.select(file:),
|
||||
onSelectProject: model.select(project:)
|
||||
onSelectProject: model.select(project:),
|
||||
onOpenWorkspace: model.presentWorkspaceImporter
|
||||
)
|
||||
.navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 260)
|
||||
} detail: {
|
||||
|
|
@ -203,6 +305,13 @@ private struct MainWindow: View {
|
|||
.help("Toggle Sidebar")
|
||||
.keyboardShortcut("0", modifiers: [.command, .option])
|
||||
|
||||
Button {
|
||||
model.presentWorkspaceImporter()
|
||||
} label: {
|
||||
Label("Open Workspace", systemImage: "folder.badge.plus")
|
||||
}
|
||||
.help("Open Workspace...")
|
||||
|
||||
Button {
|
||||
isImportingMarkdown = true
|
||||
} label: {
|
||||
|
|
@ -263,29 +372,13 @@ private extension UTType {
|
|||
}
|
||||
}
|
||||
|
||||
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? {
|
||||
var containerURL: URL? {
|
||||
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
|
||||
return folder.url
|
||||
case .project(let project):
|
||||
return project.repositoryURL
|
||||
case .file, .subproject:
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,17 @@ public indirect enum WorkspaceItem: Identifiable, Hashable, Codable, Sendable {
|
|||
case .subproject(let subproject): subproject.name
|
||||
}
|
||||
}
|
||||
|
||||
public var children: [WorkspaceItem]? {
|
||||
switch self {
|
||||
case .folder(let folder):
|
||||
return folder.children
|
||||
case .project(let project):
|
||||
return project.children
|
||||
case .file, .subproject:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct WorkspaceFolder: Identifiable, Hashable, Codable, Sendable {
|
||||
|
|
@ -92,6 +103,7 @@ public struct Project: Identifiable, Hashable, Codable, Sendable {
|
|||
public var id: UUID
|
||||
public var name: String
|
||||
public var repositoryURL: URL
|
||||
public var children: [WorkspaceItem]
|
||||
public var gitRepository: GitRepository
|
||||
public var remotes: [GitRemote]
|
||||
public var branches: [GitBranch]
|
||||
|
|
@ -102,6 +114,7 @@ public struct Project: Identifiable, Hashable, Codable, Sendable {
|
|||
id: UUID = UUID(),
|
||||
name: String,
|
||||
repositoryURL: URL,
|
||||
children: [WorkspaceItem] = [],
|
||||
gitRepository: GitRepository,
|
||||
remotes: [GitRemote] = [],
|
||||
branches: [GitBranch] = [],
|
||||
|
|
@ -111,6 +124,7 @@ public struct Project: Identifiable, Hashable, Codable, Sendable {
|
|||
self.id = id
|
||||
self.name = name
|
||||
self.repositoryURL = repositoryURL
|
||||
self.children = children
|
||||
self.gitRepository = gitRepository
|
||||
self.remotes = remotes
|
||||
self.branches = branches
|
||||
|
|
|
|||
77
Sources/SaplingEditor/DocumentSessionStore.swift
Normal file
77
Sources/SaplingEditor/DocumentSessionStore.swift
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import Foundation
|
||||
import SaplingCore
|
||||
|
||||
@MainActor
|
||||
public final class MarkdownDocumentSession: Identifiable, ObservableObject {
|
||||
public let id: UUID
|
||||
public let documentURL: URL
|
||||
public let viewModel: HybridMarkdownEditorViewModel
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
documentURL: URL,
|
||||
viewModel: HybridMarkdownEditorViewModel
|
||||
) {
|
||||
self.id = id
|
||||
self.documentURL = documentURL
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class DocumentSessionStore: ObservableObject {
|
||||
@Published public private(set) var sessions: [MarkdownDocumentSession]
|
||||
@Published public private(set) var activeSession: MarkdownDocumentSession?
|
||||
|
||||
public init(sessions: [MarkdownDocumentSession] = []) {
|
||||
self.sessions = sessions
|
||||
self.activeSession = sessions.first
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func openDocument(
|
||||
at url: URL,
|
||||
loadDocument: @MainActor (URL) throws -> MarkdownDocument = HybridMarkdownEditorViewModel.loadDocument(at:)
|
||||
) throws -> MarkdownDocumentSession {
|
||||
let key = sessionKey(for: url)
|
||||
if let existingSession = sessions.first(where: { sessionKey(for: $0.documentURL) == key }) {
|
||||
activeSession = existingSession
|
||||
return existingSession
|
||||
}
|
||||
|
||||
let document = try loadDocument(url)
|
||||
let session = MarkdownDocumentSession(
|
||||
documentURL: url,
|
||||
viewModel: HybridMarkdownEditorViewModel(document: document)
|
||||
)
|
||||
sessions.append(session)
|
||||
activeSession = session
|
||||
return session
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func activateDocument(at url: URL) -> Bool {
|
||||
let key = sessionKey(for: url)
|
||||
guard let session = sessions.first(where: { sessionKey(for: $0.documentURL) == key }) else {
|
||||
return false
|
||||
}
|
||||
|
||||
activeSession = session
|
||||
return true
|
||||
}
|
||||
|
||||
public func updateActiveSessionFromViewModel() {
|
||||
guard let activeSession else { return }
|
||||
objectWillChange.send()
|
||||
activeSession.objectWillChange.send()
|
||||
}
|
||||
|
||||
public func closeAll() {
|
||||
sessions = []
|
||||
activeSession = nil
|
||||
}
|
||||
|
||||
private func sessionKey(for url: URL) -> String {
|
||||
url.standardizedFileURL.path
|
||||
}
|
||||
}
|
||||
|
|
@ -209,7 +209,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
coordinator?.applyHybridAttributes(to: textView, cause: .focusChange)
|
||||
}
|
||||
textView.onUserEditingInteraction = { [weak coordinator = context.coordinator] textView, interactionKind in
|
||||
coordinator?.activateEditingPresentation(in: textView, interactionKind: interactionKind)
|
||||
coordinator?.recordUserEditingInteraction(interactionKind)
|
||||
}
|
||||
textView.isRichText = false
|
||||
textView.isEditable = true
|
||||
|
|
@ -317,6 +317,9 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
guard parent.selection != newSelection else { return }
|
||||
let interactionKind = pendingSelectionInteractionKind ?? lastUserInteractionKind
|
||||
pendingSelectionInteractionKind = nil
|
||||
if interactionKind != .programmatic {
|
||||
hasUserActivatedEditing = true
|
||||
}
|
||||
applyHybridAttributes(to: textView, cause: .selectionChange(interactionKind))
|
||||
parent.selection = newSelection
|
||||
}
|
||||
|
|
@ -417,6 +420,10 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
applyHybridAttributes(to: textView, cause: .editingActivation(interactionKind))
|
||||
}
|
||||
|
||||
func recordUserEditingInteraction(_ interactionKind: EditorInteractionKind) {
|
||||
lastUserInteractionKind = interactionKind
|
||||
}
|
||||
|
||||
func setSelection(
|
||||
_ range: NSRange,
|
||||
in textView: NSTextView,
|
||||
|
|
@ -689,6 +696,13 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
return false
|
||||
}
|
||||
|
||||
if anchor.visibleOrigin.y <= 0 {
|
||||
return scrollVisibleOrigin(
|
||||
NSPoint(x: anchor.visibleOrigin.x, y: 0),
|
||||
in: textView
|
||||
)
|
||||
}
|
||||
|
||||
return scrollVisibleOrigin(
|
||||
NSPoint(
|
||||
x: anchor.visibleOrigin.x,
|
||||
|
|
@ -1116,7 +1130,7 @@ public final class HybridMarkdownLiveEditorHarness {
|
|||
coordinator?.applyHybridAttributes(to: textView, cause: .focusChange)
|
||||
}
|
||||
self.textView.onUserEditingInteraction = { [weak coordinator] textView, interactionKind in
|
||||
coordinator?.activateEditingPresentation(in: textView, interactionKind: interactionKind)
|
||||
coordinator?.recordUserEditingInteraction(interactionKind)
|
||||
}
|
||||
self.textView.string = source
|
||||
self.textView.delegate = coordinator
|
||||
|
|
@ -1179,7 +1193,11 @@ public final class HybridMarkdownLiveEditorHarness {
|
|||
}
|
||||
|
||||
private func setSelection(_ range: NSRange, interactionKind: EditorInteractionKind) {
|
||||
coordinator.activateEditingPresentation(in: textView, interactionKind: interactionKind)
|
||||
if interactionKind == .programmatic {
|
||||
coordinator.activateEditingPresentation(in: textView, interactionKind: interactionKind)
|
||||
} else {
|
||||
coordinator.recordUserEditingInteraction(interactionKind)
|
||||
}
|
||||
coordinator.setSelection(range, in: textView, interactionKind: interactionKind)
|
||||
coordinator.textViewDidChangeSelection(Notification(name: NSTextView.didChangeSelectionNotification, object: textView))
|
||||
syncState()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
public struct SaplingConfiguration: Hashable, Codable, Sendable {
|
||||
public var lastOpenedWorkspaceURL: URL?
|
||||
public var recentWorkspaceURLs: [URL]
|
||||
public var defaultBranchName: String
|
||||
public var autosavesDrafts: Bool
|
||||
|
|
@ -8,12 +9,14 @@ public struct SaplingConfiguration: Hashable, Codable, Sendable {
|
|||
public var preferredEditorFontSize: Double
|
||||
|
||||
public init(
|
||||
lastOpenedWorkspaceURL: URL? = nil,
|
||||
recentWorkspaceURLs: [URL] = [],
|
||||
defaultBranchName: String = "main",
|
||||
autosavesDrafts: Bool = true,
|
||||
showsHiddenFiles: Bool = false,
|
||||
preferredEditorFontSize: Double = 15
|
||||
) {
|
||||
self.lastOpenedWorkspaceURL = lastOpenedWorkspaceURL
|
||||
self.recentWorkspaceURLs = recentWorkspaceURLs
|
||||
self.defaultBranchName = defaultBranchName
|
||||
self.autosavesDrafts = autosavesDrafts
|
||||
|
|
|
|||
|
|
@ -1,75 +1,189 @@
|
|||
import SwiftUI
|
||||
import SaplingCore
|
||||
|
||||
public enum WorkspaceTreeSelection: Hashable, Sendable {
|
||||
case folder(URL)
|
||||
case file(URL)
|
||||
case project(URL)
|
||||
case subproject(URL)
|
||||
}
|
||||
|
||||
public struct WorkspaceTreeView: View {
|
||||
private let workspace: Workspace
|
||||
private let workspace: Workspace?
|
||||
private let isLoadingWorkspace: Bool
|
||||
private let treeContentRevision: Int
|
||||
@Binding private var selection: WorkspaceTreeSelection?
|
||||
private let childrenFor: (WorkspaceItem) -> [WorkspaceItem]
|
||||
private let isLoadingChildren: (WorkspaceItem) -> Bool
|
||||
private let onExpandItem: (WorkspaceItem) -> Void
|
||||
private let onSelectFile: (WorkspaceFile) -> Void
|
||||
private let onSelectProject: (Project) -> Void
|
||||
private let onOpenWorkspace: () -> Void
|
||||
|
||||
public init(
|
||||
workspace: Workspace,
|
||||
workspace: Workspace?,
|
||||
isLoadingWorkspace: Bool = false,
|
||||
treeContentRevision: Int = 0,
|
||||
selection: Binding<WorkspaceTreeSelection?>,
|
||||
childrenFor: @escaping (WorkspaceItem) -> [WorkspaceItem] = { $0.children ?? [] },
|
||||
isLoadingChildren: @escaping (WorkspaceItem) -> Bool = { _ in false },
|
||||
onExpandItem: @escaping (WorkspaceItem) -> Void = { _ in },
|
||||
onSelectFile: @escaping (WorkspaceFile) -> Void,
|
||||
onSelectProject: @escaping (Project) -> Void
|
||||
onSelectProject: @escaping (Project) -> Void,
|
||||
onOpenWorkspace: @escaping () -> Void
|
||||
) {
|
||||
self.workspace = workspace
|
||||
self.isLoadingWorkspace = isLoadingWorkspace
|
||||
self.treeContentRevision = treeContentRevision
|
||||
self._selection = selection
|
||||
self.childrenFor = childrenFor
|
||||
self.isLoadingChildren = isLoadingChildren
|
||||
self.onExpandItem = onExpandItem
|
||||
self.onSelectFile = onSelectFile
|
||||
self.onSelectProject = onSelectProject
|
||||
self.onOpenWorkspace = onOpenWorkspace
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
List {
|
||||
Section(workspace.name) {
|
||||
ForEach(workspace.items) { item in
|
||||
WorkspaceItemRow(
|
||||
item: item,
|
||||
onSelectFile: onSelectFile,
|
||||
onSelectProject: onSelectProject
|
||||
)
|
||||
Group {
|
||||
if let workspace {
|
||||
List(selection: $selection) {
|
||||
if isLoadingWorkspace {
|
||||
Label("Scanning Workspace...", systemImage: "progress.indicator")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section(workspace.name) {
|
||||
ForEach(workspace.items, id: \.stableTreeID) { item in
|
||||
WorkspaceItemRow(
|
||||
item: item,
|
||||
treeContentRevision: treeContentRevision,
|
||||
selection: $selection,
|
||||
childrenFor: childrenFor,
|
||||
isLoadingChildren: isLoadingChildren,
|
||||
onExpandItem: onExpandItem,
|
||||
onSelectFile: onSelectFile,
|
||||
onSelectProject: onSelectProject
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
} else {
|
||||
ContentUnavailableView {
|
||||
Label(isLoadingWorkspace ? "Loading Workspace" : "No Workspace", systemImage: "folder")
|
||||
} description: {
|
||||
Text(isLoadingWorkspace ? "Scanning Workspace..." : "Choose a folder to browse Markdown files.")
|
||||
} actions: {
|
||||
if isLoadingWorkspace {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button("Open Workspace...", action: onOpenWorkspace)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.navigationTitle("Workspace")
|
||||
.transaction { transaction in
|
||||
transaction.animation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WorkspaceItemRow: View {
|
||||
let item: WorkspaceItem
|
||||
let treeContentRevision: Int
|
||||
@Binding var selection: WorkspaceTreeSelection?
|
||||
let childrenFor: (WorkspaceItem) -> [WorkspaceItem]
|
||||
let isLoadingChildren: (WorkspaceItem) -> Bool
|
||||
let onExpandItem: (WorkspaceItem) -> Void
|
||||
let onSelectFile: (WorkspaceFile) -> Void
|
||||
let onSelectProject: (Project) -> Void
|
||||
@State private var isExpanded = false
|
||||
|
||||
var body: some View {
|
||||
switch item {
|
||||
case .folder(let folder):
|
||||
DisclosureGroup {
|
||||
ForEach(folder.children) { child in
|
||||
DisclosureGroup(isExpanded: expansionBinding) {
|
||||
children
|
||||
} label: {
|
||||
Label(folder.name, systemImage: "folder")
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selection = .folder(folder.url)
|
||||
}
|
||||
}
|
||||
.tag(WorkspaceTreeSelection.folder(folder.url))
|
||||
case .file(let file):
|
||||
Button {
|
||||
selection = .file(file.url)
|
||||
if file.kind == .markdown {
|
||||
onSelectFile(file)
|
||||
}
|
||||
} label: {
|
||||
Label(file.name, systemImage: iconName(for: file.kind))
|
||||
.foregroundStyle(file.kind == .markdown ? .primary : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tag(WorkspaceTreeSelection.file(file.url))
|
||||
case .project(let project):
|
||||
DisclosureGroup(isExpanded: expansionBinding) {
|
||||
children
|
||||
} label: {
|
||||
Label(project.name, systemImage: "leaf")
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selection = .project(project.repositoryURL)
|
||||
onSelectProject(project)
|
||||
}
|
||||
}
|
||||
.tag(WorkspaceTreeSelection.project(project.repositoryURL))
|
||||
case .subproject(let subproject):
|
||||
Label(subproject.name, systemImage: "rectangle.connected.to.line.below")
|
||||
.foregroundStyle(.secondary)
|
||||
.tag(WorkspaceTreeSelection.subproject(subproject.repositoryURL))
|
||||
}
|
||||
}
|
||||
|
||||
private var expansionBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { isExpanded },
|
||||
set: { newValue in
|
||||
var transaction = Transaction()
|
||||
transaction.animation = nil
|
||||
withTransaction(transaction) {
|
||||
isExpanded = newValue
|
||||
}
|
||||
if newValue {
|
||||
onExpandItem(item)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var children: some View {
|
||||
Group {
|
||||
if isLoadingChildren(item) {
|
||||
Label("Loading...", systemImage: "progress.indicator")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(childrenFor(item), id: \.stableTreeID) { child in
|
||||
WorkspaceItemRow(
|
||||
item: child,
|
||||
treeContentRevision: treeContentRevision,
|
||||
selection: $selection,
|
||||
childrenFor: childrenFor,
|
||||
isLoadingChildren: isLoadingChildren,
|
||||
onExpandItem: onExpandItem,
|
||||
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)
|
||||
}
|
||||
.id(WorkspaceChildrenContentID(itemURL: item.stableTreeID, revision: treeContentRevision))
|
||||
}
|
||||
|
||||
private func iconName(for kind: WorkspaceFileKind) -> String {
|
||||
|
|
@ -80,3 +194,23 @@ private struct WorkspaceItemRow: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WorkspaceChildrenContentID: Hashable {
|
||||
var itemURL: URL
|
||||
var revision: Int
|
||||
}
|
||||
|
||||
private extension WorkspaceItem {
|
||||
var stableTreeID: URL {
|
||||
switch self {
|
||||
case .folder(let folder):
|
||||
return folder.url.standardizedFileURL
|
||||
case .file(let file):
|
||||
return file.url.standardizedFileURL
|
||||
case .project(let project):
|
||||
return project.repositoryURL.standardizedFileURL
|
||||
case .subproject(let subproject):
|
||||
return subproject.repositoryURL.standardizedFileURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +1,55 @@
|
|||
import Foundation
|
||||
import SaplingCore
|
||||
import SaplingGit
|
||||
import SaplingStorage
|
||||
|
||||
public protocol WorkspaceManaging: Sendable {
|
||||
func openWorkspace(at url: URL) async throws -> Workspace
|
||||
func loadItems(in directoryURL: URL) async throws -> [WorkspaceItem]
|
||||
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
|
||||
public init(fileManager: FileManager = .default) {
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
public func openWorkspace(at url: URL) async throws -> Workspace {
|
||||
let items = try scanItems(at: url, relativeTo: url)
|
||||
let items = try scanItems(at: 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 loadItems(in directoryURL: URL) async throws -> [WorkspaceItem] {
|
||||
try scanItems(at: directoryURL)
|
||||
}
|
||||
|
||||
public func sampleWorkspace() -> Workspace {
|
||||
SaplingSampleData.workspace
|
||||
}
|
||||
|
||||
private func scanItems(at url: URL, relativeTo rootURL: URL) throws -> [WorkspaceItem] {
|
||||
private func scanItems(at url: URL) throws -> [WorkspaceItem] {
|
||||
guard let children = try? fileManager.contentsOfDirectory(
|
||||
at: url,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
options: directoryOptions
|
||||
) 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 {
|
||||
if isGitRepository(at: childURL) {
|
||||
return .project(project(at: childURL))
|
||||
}
|
||||
|
||||
return .folder(
|
||||
WorkspaceFolder(
|
||||
name: childURL.lastPathComponent,
|
||||
url: childURL,
|
||||
children: try scanItems(at: childURL, relativeTo: rootURL)
|
||||
url: childURL
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -70,6 +62,11 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
|
|||
)
|
||||
)
|
||||
}
|
||||
.sorted(by: workspaceItemSort)
|
||||
}
|
||||
|
||||
private var directoryOptions: FileManager.DirectoryEnumerationOptions {
|
||||
[.skipsHiddenFiles, .skipsPackageDescendants]
|
||||
}
|
||||
|
||||
private func isGitRepository(at url: URL) -> Bool {
|
||||
|
|
@ -89,6 +86,14 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
|
|||
)
|
||||
}
|
||||
|
||||
private func workspaceItemSort(_ lhs: WorkspaceItem, _ rhs: WorkspaceItem) -> Bool {
|
||||
if lhs.sortGroup != rhs.sortGroup {
|
||||
return lhs.sortGroup < rhs.sortGroup
|
||||
}
|
||||
|
||||
return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending
|
||||
}
|
||||
|
||||
private func fileKind(for url: URL) -> WorkspaceFileKind {
|
||||
switch url.pathExtension.lowercased() {
|
||||
case "md", "markdown":
|
||||
|
|
@ -100,3 +105,14 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension WorkspaceItem {
|
||||
var sortGroup: Int {
|
||||
switch self {
|
||||
case .folder, .project, .subproject:
|
||||
return 0
|
||||
case .file:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
Tests/SaplingEditorTests/DocumentSessionStoreTests.swift
Normal file
67
Tests/SaplingEditorTests/DocumentSessionStoreTests.swift
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import Foundation
|
||||
import SaplingCore
|
||||
import SaplingEditor
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class DocumentSessionStoreTests: XCTestCase {
|
||||
func testOpeningSameFileReusesExistingSession() throws {
|
||||
let url = URL(fileURLWithPath: "/tmp/Sapling/Notes.md")
|
||||
var loadCount = 0
|
||||
let store = DocumentSessionStore()
|
||||
|
||||
let first = try store.openDocument(at: url) { url in
|
||||
loadCount += 1
|
||||
return MarkdownDocument(url: url, title: "Notes", content: "# Notes")
|
||||
}
|
||||
|
||||
let second = try store.openDocument(at: url.standardizedFileURL) { url in
|
||||
loadCount += 1
|
||||
return MarkdownDocument(url: url, title: "Notes", content: "# Reloaded")
|
||||
}
|
||||
|
||||
XCTAssertIdentical(first, second)
|
||||
XCTAssertIdentical(store.activeSession, first)
|
||||
XCTAssertEqual(store.sessions.count, 1)
|
||||
XCTAssertEqual(loadCount, 1)
|
||||
XCTAssertEqual(first.viewModel.document.content, "# Notes")
|
||||
}
|
||||
|
||||
func testOpeningDifferentFilesCreatesSeparateSessionsAndActivatesLatest() throws {
|
||||
let firstURL = URL(fileURLWithPath: "/tmp/Sapling/One.md")
|
||||
let secondURL = URL(fileURLWithPath: "/tmp/Sapling/Two.md")
|
||||
let store = DocumentSessionStore()
|
||||
|
||||
let first = try store.openDocument(at: firstURL) { url in
|
||||
MarkdownDocument(url: url, title: "One", content: "# One")
|
||||
}
|
||||
let second = try store.openDocument(at: secondURL) { url in
|
||||
MarkdownDocument(url: url, title: "Two", content: "# Two")
|
||||
}
|
||||
|
||||
XCTAssertEqual(store.sessions.count, 2)
|
||||
XCTAssertIdentical(store.activeSession, second)
|
||||
|
||||
XCTAssertTrue(store.activateDocument(at: firstURL))
|
||||
XCTAssertIdentical(store.activeSession, first)
|
||||
}
|
||||
|
||||
func testActivateMissingDocumentReturnsFalse() {
|
||||
let store = DocumentSessionStore()
|
||||
|
||||
XCTAssertFalse(store.activateDocument(at: URL(fileURLWithPath: "/tmp/missing.md")))
|
||||
XCTAssertNil(store.activeSession)
|
||||
}
|
||||
|
||||
func testCloseAllRemovesSessionsAndActiveDocument() throws {
|
||||
let store = DocumentSessionStore()
|
||||
try store.openDocument(at: URL(fileURLWithPath: "/tmp/Sapling/Notes.md")) { url in
|
||||
MarkdownDocument(url: url, title: "Notes", content: "# Notes")
|
||||
}
|
||||
|
||||
store.closeAll()
|
||||
|
||||
XCTAssertTrue(store.sessions.isEmpty)
|
||||
XCTAssertNil(store.activeSession)
|
||||
}
|
||||
}
|
||||
|
|
@ -38,12 +38,11 @@ final class EditorLargeDocumentValidationTests: XCTestCase {
|
|||
previousActiveLineIndex: activeLineIndex,
|
||||
currentActiveLineIndex: activeLineIndex
|
||||
)
|
||||
let updatedLineIndex = DocumentLineIndex(source: updatedSource)
|
||||
let dirtyRenderMeasurement = elapsedTime {
|
||||
let lines = EditorActiveLineTracker.lines(from: updatedSource, activeLineIndex: activeLineIndex)
|
||||
let dirty = Set(plan.dirtyLineIndexes)
|
||||
let renderer = HybridMarkdownLineRenderer()
|
||||
_ = lines
|
||||
.filter { dirty.contains($0.index) }
|
||||
_ = plan.dirtyLineIndexes
|
||||
.compactMap { updatedLineIndex.editorLine(at: $0, activeLineIndex: activeLineIndex) }
|
||||
.map(renderer.renderPlan(for:))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ final class HybridMarkdownLiveEditorHarnessTests: XCTestCase {
|
|||
XCTAssertTrue(harness.headingMarkerIsHidden())
|
||||
}
|
||||
|
||||
func testClickAtTopOfDocumentDoesNotScrollViewportDown() {
|
||||
let source = (["# Heading", "Opening paragraph"] + (1...80).map { "Line \($0)" })
|
||||
.joined(separator: "\n")
|
||||
let harness = HybridMarkdownLiveEditorHarness(source: source)
|
||||
harness.simulateLaunchFirstResponder()
|
||||
harness.scrollViewport(toY: 0)
|
||||
|
||||
let paragraphLocation = (source as NSString).range(of: "Opening").location
|
||||
harness.setSelectionByMouse(NSRange(location: paragraphLocation, length: 0))
|
||||
|
||||
XCTAssertEqual(harness.viewportOrigin().y, 0, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testLiveParagraphGeometryReturnsAfterClickAndFocusAway() throws {
|
||||
let source = """
|
||||
# Heading
|
||||
|
|
|
|||
199
Tests/SaplingWorkspaceTests/WorkspaceManagerTests.swift
Normal file
199
Tests/SaplingWorkspaceTests/WorkspaceManagerTests.swift
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import Foundation
|
||||
import SaplingCore
|
||||
import SaplingWorkspace
|
||||
import XCTest
|
||||
|
||||
final class WorkspaceManagerTests: XCTestCase {
|
||||
private var temporaryRoots: [URL] = []
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
for url in temporaryRoots {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
temporaryRoots = []
|
||||
}
|
||||
|
||||
func testOpenWorkspaceBuildsRootLevelOnly() async throws {
|
||||
let rootURL = try makeTemporaryWorkspace()
|
||||
try createDirectory("Notes", in: rootURL)
|
||||
try writeFile("Notes/Index.md", in: rootURL, contents: "# Notes")
|
||||
try writeFile("Notes/todo.txt", in: rootURL, contents: "todo")
|
||||
try createDirectory("Research/Archive", in: rootURL)
|
||||
try writeFile("Research/Archive/Paper.markdown", in: rootURL, contents: "# Paper")
|
||||
|
||||
let workspace = try await LocalWorkspaceManager().openWorkspace(at: rootURL)
|
||||
|
||||
XCTAssertEqual(workspace.rootURL, rootURL)
|
||||
XCTAssertEqual(workspace.items.map(\.displayName), ["Notes", "Research"])
|
||||
|
||||
let notes = try XCTUnwrap(workspace.folder(named: "Notes"))
|
||||
XCTAssertTrue(notes.children.isEmpty)
|
||||
|
||||
let notesChildren = try await LocalWorkspaceManager().loadItems(in: notes.url)
|
||||
XCTAssertEqual(notesChildren.map(\.displayName), ["Index.md", "todo.txt"])
|
||||
XCTAssertEqual(try XCTUnwrap(notesChildren.file(named: "Index.md")).kind, .markdown)
|
||||
XCTAssertEqual(try XCTUnwrap(notesChildren.file(named: "todo.txt")).kind, .other)
|
||||
|
||||
let research = try XCTUnwrap(workspace.folder(named: "Research"))
|
||||
let researchChildren = try await LocalWorkspaceManager().loadItems(in: research.url)
|
||||
XCTAssertEqual(researchChildren.map(\.displayName), ["Archive"])
|
||||
let archive = try XCTUnwrap(researchChildren.folder(named: "Archive"))
|
||||
let archiveChildren = try await LocalWorkspaceManager().loadItems(in: archive.url)
|
||||
XCTAssertEqual(try XCTUnwrap(archiveChildren.file(named: "Paper.markdown")).kind, .markdown)
|
||||
}
|
||||
|
||||
func testGitRepositoriesAreDetectedAsProjectsWithChildren() async throws {
|
||||
let rootURL = try makeTemporaryWorkspace()
|
||||
try createGitRepository("Sapling", in: rootURL)
|
||||
try writeFile("Sapling/README.md", in: rootURL, contents: "# Sapling")
|
||||
try createGitRepository("Research", in: rootURL)
|
||||
try writeFile("Research/Notes.md", in: rootURL, contents: "# Research")
|
||||
try createDirectory("Ordinary", in: rootURL)
|
||||
|
||||
let workspace = try await LocalWorkspaceManager().openWorkspace(at: rootURL)
|
||||
|
||||
XCTAssertEqual(workspace.items.map(\.displayName), ["Ordinary", "Research", "Sapling"])
|
||||
let research = try XCTUnwrap(workspace.project(named: "Research"))
|
||||
let sapling = try XCTUnwrap(workspace.project(named: "Sapling"))
|
||||
XCTAssertEqual(research.gitRepository.statusSummary, .unknown)
|
||||
XCTAssertEqual(sapling.gitRepository.rootURL.lastPathComponent, "Sapling")
|
||||
XCTAssertTrue(research.children.isEmpty)
|
||||
let researchChildren = try await LocalWorkspaceManager().loadItems(in: research.repositoryURL)
|
||||
XCTAssertEqual(researchChildren.map(\.displayName), ["Notes.md"])
|
||||
XCTAssertNil(research.folder(named: ".git"))
|
||||
}
|
||||
|
||||
func testHiddenFilesAndGitInternalsAreExcluded() async throws {
|
||||
let rootURL = try makeTemporaryWorkspace()
|
||||
try writeFile(".hidden.md", in: rootURL, contents: "# Hidden")
|
||||
try createGitRepository("Project", in: rootURL)
|
||||
try writeFile("Project/.git/config", in: rootURL, contents: "[core]")
|
||||
try writeFile("Project/Visible.md", in: rootURL, contents: "# Visible")
|
||||
|
||||
let workspace = try await LocalWorkspaceManager().openWorkspace(at: rootURL)
|
||||
|
||||
XCTAssertEqual(workspace.items.map(\.displayName), ["Project"])
|
||||
let project = try XCTUnwrap(workspace.project(named: "Project"))
|
||||
XCTAssertTrue(project.children.isEmpty)
|
||||
let projectChildren = try await LocalWorkspaceManager().loadItems(in: project.repositoryURL)
|
||||
XCTAssertEqual(projectChildren.map(\.displayName), ["Visible.md"])
|
||||
}
|
||||
|
||||
func testLargeFolderHierarchyScansWithinReasonableTime() async throws {
|
||||
let rootURL = try makeTemporaryWorkspace()
|
||||
for index in 0..<250 {
|
||||
let folderPath = String(format: "Folder-%03d/Nested", index)
|
||||
try createDirectory(folderPath, in: rootURL)
|
||||
try writeFile("\(folderPath)/Note-\(index).md", in: rootURL, contents: "# \(index)")
|
||||
}
|
||||
try createGitRepository("Project-000", in: rootURL)
|
||||
try writeFile("Project-000/README.md", in: rootURL, contents: "# Project")
|
||||
|
||||
let start = Date()
|
||||
let workspace = try await LocalWorkspaceManager().openWorkspace(at: rootURL)
|
||||
let duration = Date().timeIntervalSince(start)
|
||||
|
||||
XCTAssertEqual(workspace.items.count, 251)
|
||||
XCTAssertNotNil(workspace.project(named: "Project-000"))
|
||||
XCTAssertLessThan(duration, 2.0)
|
||||
}
|
||||
|
||||
func testFoldersAndProjectsSortBeforeFilesAlphabetically() async throws {
|
||||
let rootURL = try makeTemporaryWorkspace()
|
||||
try writeFile("ROADMAP.md", in: rootURL, contents: "# Roadmap")
|
||||
try createDirectory("Tests", in: rootURL)
|
||||
try writeFile("README.md", in: rootURL, contents: "# Readme")
|
||||
try createGitRepository("Sapling", in: rootURL)
|
||||
try createDirectory("Docs", in: rootURL)
|
||||
|
||||
let workspace = try await LocalWorkspaceManager().openWorkspace(at: rootURL)
|
||||
|
||||
XCTAssertEqual(workspace.items.map(\.displayName), ["Docs", "Sapling", "Tests", "README.md", "ROADMAP.md"])
|
||||
}
|
||||
|
||||
private func makeTemporaryWorkspace() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("SaplingWorkspaceTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
temporaryRoots.append(url)
|
||||
return url
|
||||
}
|
||||
|
||||
private func createDirectory(_ path: String, in rootURL: URL) throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: rootURL.appendingPathComponent(path, isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
}
|
||||
|
||||
private func createGitRepository(_ path: String, in rootURL: URL) throws {
|
||||
try createDirectory(path, in: rootURL)
|
||||
try createDirectory("\(path)/.git", in: rootURL)
|
||||
}
|
||||
|
||||
private func writeFile(_ path: String, in rootURL: URL, contents: String) throws {
|
||||
let url = rootURL.appendingPathComponent(path)
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Workspace {
|
||||
func folder(named name: String) -> WorkspaceFolder? {
|
||||
items.compactMap { item -> WorkspaceFolder? in
|
||||
guard case .folder(let folder) = item, folder.name == name else { return nil }
|
||||
return folder
|
||||
}.first
|
||||
}
|
||||
|
||||
func project(named name: String) -> Project? {
|
||||
items.compactMap { item -> Project? in
|
||||
guard case .project(let project) = item, project.name == name else { return nil }
|
||||
return project
|
||||
}.first
|
||||
}
|
||||
}
|
||||
|
||||
private extension WorkspaceFolder {
|
||||
func folder(named name: String) -> WorkspaceFolder? {
|
||||
children.compactMap { item -> WorkspaceFolder? in
|
||||
guard case .folder(let folder) = item, folder.name == name else { return nil }
|
||||
return folder
|
||||
}.first
|
||||
}
|
||||
|
||||
func file(named name: String) -> WorkspaceFile? {
|
||||
children.compactMap { item -> WorkspaceFile? in
|
||||
guard case .file(let file) = item, file.name == name else { return nil }
|
||||
return file
|
||||
}.first
|
||||
}
|
||||
}
|
||||
|
||||
private extension Project {
|
||||
func folder(named name: String) -> WorkspaceFolder? {
|
||||
children.compactMap { item -> WorkspaceFolder? in
|
||||
guard case .folder(let folder) = item, folder.name == name else { return nil }
|
||||
return folder
|
||||
}.first
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array where Element == WorkspaceItem {
|
||||
func folder(named name: String) -> WorkspaceFolder? {
|
||||
compactMap { item -> WorkspaceFolder? in
|
||||
guard case .folder(let folder) = item, folder.name == name else { return nil }
|
||||
return folder
|
||||
}.first
|
||||
}
|
||||
|
||||
func file(named name: String) -> WorkspaceFile? {
|
||||
compactMap { item -> WorkspaceFile? in
|
||||
guard case .file(let file) = item, file.name == name else { return nil }
|
||||
return file
|
||||
}.first
|
||||
}
|
||||
}
|
||||
120
decisions.md
120
decisions.md
|
|
@ -778,3 +778,123 @@ Users should own their notes, projects, attachments, and repositories without de
|
|||
A workspace should remain a normal directory that can be understood and manipulated using standard operating system tools.
|
||||
|
||||
Sapling should adapt to the filesystem rather than requiring the filesystem to adapt to Sapling.
|
||||
|
||||
---
|
||||
|
||||
# D-016 — Workspace Tree and Document Sessions Are Separate Concepts
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-06-02
|
||||
|
||||
## Context
|
||||
|
||||
A filesystem-backed workspace naturally describes what exists on disk.
|
||||
|
||||
However, the user interface must also represent documents that are currently open for editing.
|
||||
|
||||
These concerns are related but distinct.
|
||||
|
||||
Historically many editors conflate:
|
||||
|
||||
* selected file
|
||||
* open file
|
||||
* visible editor
|
||||
|
||||
This makes future features such as tabs, split views, and multiple windows difficult to implement.
|
||||
|
||||
## Decision
|
||||
|
||||
Sapling separates:
|
||||
|
||||
### Workspace Tree
|
||||
|
||||
Represents:
|
||||
|
||||
* folders
|
||||
* projects
|
||||
* files
|
||||
|
||||
derived from the filesystem.
|
||||
|
||||
The workspace tree answers:
|
||||
|
||||
"What exists?"
|
||||
|
||||
### Document Sessions
|
||||
|
||||
Represents:
|
||||
|
||||
* open documents
|
||||
* editor state
|
||||
* cursor position
|
||||
* scroll position
|
||||
* unsaved state
|
||||
|
||||
The document session answers:
|
||||
|
||||
"What is currently being edited?"
|
||||
|
||||
## Principles
|
||||
|
||||
Selecting a file does not imply ownership of editor state.
|
||||
|
||||
A document may exist in:
|
||||
|
||||
* the workspace tree
|
||||
* a tab
|
||||
* a split view
|
||||
* a separate window
|
||||
|
||||
without changing its identity.
|
||||
|
||||
Document state belongs to the document session.
|
||||
|
||||
Not the workspace tree.
|
||||
|
||||
## Tab Model
|
||||
|
||||
Tabs are a presentation of document sessions.
|
||||
|
||||
A file may only have one document session within a workspace.
|
||||
|
||||
Opening an already-open document should activate its existing session rather than creating a duplicate.
|
||||
|
||||
## Future Compatibility
|
||||
|
||||
This decision intentionally supports:
|
||||
|
||||
* tabs
|
||||
* split views
|
||||
* multiple windows
|
||||
* session restoration
|
||||
|
||||
without requiring changes to the workspace model.
|
||||
|
||||
These features are presentation concerns built on top of document sessions rather than filesystem discovery.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
* Clear separation of responsibilities.
|
||||
* Simplifies workspace discovery.
|
||||
* Enables future UI layouts.
|
||||
* Prevents duplicated editor state.
|
||||
|
||||
### Negative
|
||||
|
||||
* Introduces an additional abstraction layer.
|
||||
* Requires explicit session management.
|
||||
|
||||
## Rationale
|
||||
|
||||
The filesystem should describe what exists.
|
||||
|
||||
Document sessions should describe what is being edited.
|
||||
|
||||
Keeping these concerns separate allows Sapling to evolve its user interface without revisiting the workspace architecture.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue