Compare commits

...

13 commits

17 changed files with 976 additions and 153 deletions

View file

@ -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
View 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.

View file

@ -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
View file

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

View file

@ -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")
)

View file

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

View file

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

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

View file

@ -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()

View file

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

View file

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

View file

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

View 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)
}
}

View file

@ -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:))
}

View file

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

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

View file

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