feat(workspace): add workspace sidebar experience

This commit is contained in:
Feror 2026-06-02 14:43:19 +02:00
parent 3d3d1aee28
commit 1d7840e45c
10 changed files with 255 additions and 109 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 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.

51
Docs/Workspace.md Normal file
View file

@ -0,0 +1,51 @@
# 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 by scanning the selected root directory and building an in-memory tree. That tree can be rebuilt at any time from the filesystem.
## Filesystem Scanning
`LocalWorkspaceManager` recursively scans the workspace root 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.
## 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.
## 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",
@ -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",

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,24 +1,20 @@
import Foundation
import SaplingGit
import SaplingLogging
import SaplingStorage
import SaplingWorkspace
struct AppDependencies {
let gitProvider: any GitProvider
let configurationStore: any ConfigurationStore
let workspaceManager: any WorkspaceManaging
let logger: SaplingLogger
static func live() -> AppDependencies {
let gitProvider = MockGitProvider()
let configurationStore = JSONConfigurationStore(
fileURL: supportDirectory().appendingPathComponent("Configuration.json")
)
let workspaceManager = LocalWorkspaceManager()
return AppDependencies(
gitProvider: gitProvider,
configurationStore: configurationStore,
workspaceManager: workspaceManager,
logger: SaplingLogger()
@ -26,12 +22,10 @@ struct AppDependencies {
}
static func preview() -> AppDependencies {
let gitProvider = MockGitProvider()
let configurationStore = InMemoryConfigurationStore()
let workspaceManager = LocalWorkspaceManager()
return AppDependencies(
gitProvider: gitProvider,
configurationStore: configurationStore,
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,71 @@ private final class SaplingAppDelegate: NSObject, NSApplicationDelegate {
@MainActor
private final class SaplingAppModel: ObservableObject {
@Published var workspace: Workspace
@Published var workspace: Workspace?
@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 isImportingWorkspace = 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)
Task {
await refreshGitStatus()
if let lastOpenedWorkspaceURL = configuration.lastOpenedWorkspaceURL {
Task {
await openWorkspace(at: lastOpenedWorkspaceURL)
}
}
}
func presentWorkspaceImporter() {
isImportingWorkspace = true
}
func openWorkspace(at url: URL) async {
let didStartAccessing = url.startAccessingSecurityScopedResource()
defer {
if didStartAccessing {
url.stopAccessingSecurityScopedResource()
}
}
do {
let workspace = try await workspaceManager.openWorkspace(at: url)
self.workspace = workspace
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 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 +135,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 +161,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 +174,18 @@ 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)
}
}
@ -178,8 +198,10 @@ private struct MainWindow: View {
NavigationSplitView(columnVisibility: $columnVisibility) {
WorkspaceTreeView(
workspace: model.workspace,
selection: $model.workspaceSelection,
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 +225,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: {
@ -220,6 +249,21 @@ private struct MainWindow: View {
.keyboardShortcut("s", modifiers: .command)
}
}
.fileImporter(
isPresented: $model.isImportingWorkspace,
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
Task {
await model.openWorkspace(at: url)
}
case .failure(let error):
model.editorErrorMessage = "Unable to open workspace: \(error.localizedDescription)"
}
}
.fileImporter(
isPresented: $isImportingMarkdown,
allowedContentTypes: [.saplingMarkdown, .plainText],
@ -262,32 +306,3 @@ private extension UTType {
UTType(filenameExtension: "md") ?? .plainText
}
}
private extension Workspace {
var firstProject: Project? {
for item in items {
if let project = item.firstProject {
return project
}
}
return nil
}
}
private extension WorkspaceItem {
var firstProject: Project? {
switch self {
case .project(let project):
return project
case .folder(let folder):
for child in folder.children {
if let project = child.firstProject {
return project
}
}
return nil
case .file, .subproject:
return nil
}
}
}

View file

@ -66,6 +66,11 @@ public final class DocumentSessionStore: ObservableObject {
activeSession.objectWillChange.send()
}
public func closeAll() {
sessions = []
activeSession = nil
}
private func sessionKey(for url: URL) -> String {
url.standardizedFileURL.path
}

View file

@ -1,40 +1,67 @@
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?
@Binding private var selection: WorkspaceTreeSelection?
private let onSelectFile: (WorkspaceFile) -> Void
private let onSelectProject: (Project) -> Void
private let onOpenWorkspace: () -> Void
public init(
workspace: Workspace,
workspace: Workspace?,
selection: Binding<WorkspaceTreeSelection?>,
onSelectFile: @escaping (WorkspaceFile) -> Void,
onSelectProject: @escaping (Project) -> Void
onSelectProject: @escaping (Project) -> Void,
onOpenWorkspace: @escaping () -> Void
) {
self.workspace = workspace
self._selection = selection
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) {
Section(workspace.name) {
ForEach(workspace.items) { item in
WorkspaceItemRow(
item: item,
selection: $selection,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
}
}
}
.listStyle(.sidebar)
} else {
ContentUnavailableView {
Label("No Workspace", systemImage: "folder")
} description: {
Text("Choose a folder to browse Markdown files.")
} actions: {
Button("Open Workspace...", action: onOpenWorkspace)
}
}
}
.listStyle(.sidebar)
.navigationTitle("Workspace")
}
}
private struct WorkspaceItemRow: View {
let item: WorkspaceItem
@Binding var selection: WorkspaceTreeSelection?
let onSelectFile: (WorkspaceFile) -> Void
let onSelectProject: (Project) -> Void
@ -45,30 +72,53 @@ private struct WorkspaceItemRow: View {
ForEach(folder.children) { child in
WorkspaceItemRow(
item: child,
selection: $selection,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
}
} label: {
Label(folder.name, systemImage: "folder")
.contentShape(Rectangle())
.onTapGesture {
selection = .folder(folder.url)
}
}
.tag(WorkspaceTreeSelection.folder(folder.url))
case .file(let file):
Button {
onSelectFile(file)
} label: {
Label(file.name, systemImage: iconName(for: file.kind))
}
.buttonStyle(.plain)
Label(file.name, systemImage: iconName(for: file.kind))
.foregroundStyle(file.kind == .markdown ? .primary : .secondary)
.contentShape(Rectangle())
.onTapGesture {
selection = .file(file.url)
onSelectFile(file)
}
.tag(WorkspaceTreeSelection.file(file.url))
case .project(let project):
Button {
onSelectProject(project)
DisclosureGroup {
ForEach(project.children) { child in
WorkspaceItemRow(
item: child,
selection: $selection,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
}
} label: {
Label(project.name, systemImage: "point.3.connected.trianglepath.dotted")
Label(project.name, systemImage: "leaf")
.fontWeight(.medium)
.foregroundStyle(.primary)
.contentShape(Rectangle())
.onTapGesture {
selection = .project(project.repositoryURL)
onSelectProject(project)
}
}
.buttonStyle(.plain)
.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))
}
}

View file

@ -52,4 +52,16 @@ final class DocumentSessionStoreTests: XCTestCase {
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

@ -68,6 +68,25 @@ final class WorkspaceManagerTests: XCTestCase {
XCTAssertEqual(project.children.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)
}
private func makeTemporaryWorkspace() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("SaplingWorkspaceTests-\(UUID().uuidString)", isDirectory: true)