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. - `SaplingApp`: SwiftUI application entry point and dependency composition.
- `SaplingCore`: Domain models, sample data, and business rules. - `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. - `SaplingGit`: Protocol-first Git abstraction plus macOS, embedded, and mock providers.
- `SaplingEditor`: Hybrid Markdown editor state and SwiftUI editing surface. - `SaplingEditor`: Hybrid Markdown editor state and SwiftUI editing surface.
- `SaplingRenderer`: Markdown parsing/rendering primitives for headings, emphasis, code blocks, task lists, and images. - `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. 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. 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. 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. 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
- TODO: Replace sample data composition with real workspace opening and file loading.
- TODO: Add security-scoped bookmark handling for sandboxed macOS distribution. - 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: Expand `MarkdownRenderer` to support full Markdown block parsing, LaTeX, Mermaid, and attachment previews.
- TODO: Replace the placeholder source-line editor with a text layout engine that preserves cursor position across rendered/source transitions. - TODO: 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: [ dependencies: [
"SaplingCore", "SaplingCore",
"SaplingWorkspace", "SaplingWorkspace",
"SaplingGit",
"SaplingEditor", "SaplingEditor",
"SaplingRenderer", "SaplingRenderer",
"SaplingStorage", "SaplingStorage",
@ -65,7 +64,7 @@ let package = Package(
.target(name: "SaplingLogging"), .target(name: "SaplingLogging"),
.target( .target(
name: "SaplingUI", name: "SaplingUI",
dependencies: ["SaplingCore", "SaplingWorkspace", "SaplingGit", "SaplingEditor", "SaplingStorage"] dependencies: ["SaplingCore", "SaplingWorkspace", "SaplingEditor", "SaplingStorage"]
), ),
.testTarget( .testTarget(
name: "SaplingCoreTests", 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 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. See [Docs/Architecture.md](Docs/Architecture.md) for module responsibilities, architectural decisions, and TODO markers.

View file

@ -1,24 +1,20 @@
import Foundation import Foundation
import SaplingGit
import SaplingLogging import SaplingLogging
import SaplingStorage import SaplingStorage
import SaplingWorkspace import SaplingWorkspace
struct AppDependencies { struct AppDependencies {
let gitProvider: any GitProvider
let configurationStore: any ConfigurationStore let configurationStore: any ConfigurationStore
let workspaceManager: any WorkspaceManaging let workspaceManager: any WorkspaceManaging
let logger: SaplingLogger let logger: SaplingLogger
static func live() -> AppDependencies { static func live() -> AppDependencies {
let gitProvider = MockGitProvider()
let configurationStore = JSONConfigurationStore( let configurationStore = JSONConfigurationStore(
fileURL: supportDirectory().appendingPathComponent("Configuration.json") fileURL: supportDirectory().appendingPathComponent("Configuration.json")
) )
let workspaceManager = LocalWorkspaceManager() let workspaceManager = LocalWorkspaceManager()
return AppDependencies( return AppDependencies(
gitProvider: gitProvider,
configurationStore: configurationStore, configurationStore: configurationStore,
workspaceManager: workspaceManager, workspaceManager: workspaceManager,
logger: SaplingLogger() logger: SaplingLogger()
@ -26,12 +22,10 @@ struct AppDependencies {
} }
static func preview() -> AppDependencies { static func preview() -> AppDependencies {
let gitProvider = MockGitProvider()
let configurationStore = InMemoryConfigurationStore() let configurationStore = InMemoryConfigurationStore()
let workspaceManager = LocalWorkspaceManager() let workspaceManager = LocalWorkspaceManager()
return AppDependencies( return AppDependencies(
gitProvider: gitProvider,
configurationStore: configurationStore, configurationStore: configurationStore,
workspaceManager: workspaceManager, workspaceManager: workspaceManager,
logger: SaplingLogger(subsystem: "app.sapling.Sapling.preview") logger: SaplingLogger(subsystem: "app.sapling.Sapling.preview")

View file

@ -2,7 +2,6 @@ import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
import SaplingCore import SaplingCore
import SaplingWorkspace import SaplingWorkspace
import SaplingGit
import SaplingStorage import SaplingStorage
import SaplingLogging import SaplingLogging
import SaplingEditor import SaplingEditor
@ -25,6 +24,14 @@ struct SaplingApplication: App {
WindowGroup { WindowGroup {
MainWindow(model: model) MainWindow(model: model)
} }
.commands {
CommandGroup(after: .newItem) {
Button("Open Workspace...") {
model.presentWorkspaceImporter()
}
.keyboardShortcut("o", modifiers: [.command, .shift])
}
}
#if os(macOS) #if os(macOS)
.windowStyle(.titleBar) .windowStyle(.titleBar)
.windowToolbarStyle(.unified) .windowToolbarStyle(.unified)
@ -52,46 +59,71 @@ private final class SaplingAppDelegate: NSObject, NSApplicationDelegate {
@MainActor @MainActor
private final class SaplingAppModel: ObservableObject { private final class SaplingAppModel: ObservableObject {
@Published var workspace: Workspace @Published var workspace: Workspace?
@Published var workspaceSelection: WorkspaceTreeSelection?
@Published var selectedProject: Project? @Published var selectedProject: Project?
@Published var selectedDocument: MarkdownDocument? @Published var selectedDocument: MarkdownDocument?
@Published var editorViewModel: HybridMarkdownEditorViewModel? @Published var editorViewModel: HybridMarkdownEditorViewModel?
@Published var gitStatuses: [GitFileStatus] = []
@Published var configuration: SaplingConfiguration @Published var configuration: SaplingConfiguration
@Published var editorErrorMessage: String? @Published var editorErrorMessage: String?
@Published var isImportingWorkspace = false
private let gitProvider: any GitProvider
private let configurationStore: any ConfigurationStore private let configurationStore: any ConfigurationStore
private let workspaceManager: any WorkspaceManaging private let workspaceManager: any WorkspaceManaging
private let documentSessionStore: DocumentSessionStore
private let logger: SaplingLogger private let logger: SaplingLogger
init(dependencies: AppDependencies) { init(dependencies: AppDependencies) {
self.gitProvider = dependencies.gitProvider
self.configurationStore = dependencies.configurationStore self.configurationStore = dependencies.configurationStore
self.workspaceManager = dependencies.workspaceManager self.workspaceManager = dependencies.workspaceManager
self.documentSessionStore = DocumentSessionStore()
self.logger = dependencies.logger self.logger = dependencies.logger
self.configuration = (try? dependencies.configurationStore.loadConfiguration()) ?? SaplingConfiguration() self.configuration = (try? dependencies.configurationStore.loadConfiguration()) ?? SaplingConfiguration()
let workspace = workspaceManager.sampleWorkspace() self.workspace = nil
self.workspace = workspace
self.selectedProject = workspace.firstProject
setSelectedDocument(SaplingSampleData.document)
logger.info("Sapling application model initialized", category: .app) logger.info("Sapling application model initialized", category: .app)
Task { if let lastOpenedWorkspaceURL = configuration.lastOpenedWorkspaceURL {
await refreshGitStatus() 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) { func select(file: WorkspaceFile) {
guard file.kind == .markdown else { return } guard file.kind == .markdown else { return }
workspaceSelection = .file(file.url)
if file.url == SaplingSampleData.document.url { openDocument(at: file.url)
setSelectedDocument(SaplingSampleData.document)
} else {
openDocument(at: file.url)
}
} }
func openDocument(at url: URL) { func openDocument(at url: URL) {
@ -103,10 +135,10 @@ private final class SaplingAppModel: ObservableObject {
} }
do { do {
let document = try HybridMarkdownEditorViewModel.loadDocument(at: url) let session = try documentSessionStore.openDocument(at: url)
setSelectedDocument(document) activate(session)
editorErrorMessage = nil editorErrorMessage = nil
logger.info("Opened document: \(document.title)", category: .editor) logger.info("Opened document: \(session.viewModel.document.title)", category: .editor)
} catch { } catch {
editorErrorMessage = "Unable to open \(url.lastPathComponent): \(error.localizedDescription)" editorErrorMessage = "Unable to open \(url.lastPathComponent): \(error.localizedDescription)"
logger.error("Failed to open document: \(error)", category: .editor) logger.error("Failed to open document: \(error)", category: .editor)
@ -129,10 +161,8 @@ private final class SaplingAppModel: ObservableObject {
func select(project: Project) { func select(project: Project) {
selectedProject = project selectedProject = project
workspaceSelection = .project(project.repositoryURL)
logger.info("Selected project: \(project.name)", category: .workspace) logger.info("Selected project: \(project.name)", category: .workspace)
Task {
await refreshGitStatus()
}
} }
func save(configuration: SaplingConfiguration) { func save(configuration: SaplingConfiguration) {
@ -144,28 +174,18 @@ private final class SaplingAppModel: ObservableObject {
} }
} }
private func refreshGitStatus() async { private func activate(_ session: MarkdownDocumentSession) {
guard let selectedProject else { selectedDocument = session.viewModel.document
gitStatuses = [] editorViewModel = session.viewModel
return logger.info("Selected document: \(session.viewModel.document.title)", category: .editor)
}
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 setSelectedDocument(_ document: MarkdownDocument) { private func persistLastOpenedWorkspace(_ url: URL) {
selectedDocument = document configuration.lastOpenedWorkspaceURL = url
editorViewModel = HybridMarkdownEditorViewModel(document: document) configuration.recentWorkspaceURLs.removeAll { $0.standardizedFileURL == url.standardizedFileURL }
logger.info("Selected document: \(document.title)", category: .editor) 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) { NavigationSplitView(columnVisibility: $columnVisibility) {
WorkspaceTreeView( WorkspaceTreeView(
workspace: model.workspace, workspace: model.workspace,
selection: $model.workspaceSelection,
onSelectFile: model.select(file:), onSelectFile: model.select(file:),
onSelectProject: model.select(project:) onSelectProject: model.select(project:),
onOpenWorkspace: model.presentWorkspaceImporter
) )
.navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 260) .navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 260)
} detail: { } detail: {
@ -203,6 +225,13 @@ private struct MainWindow: View {
.help("Toggle Sidebar") .help("Toggle Sidebar")
.keyboardShortcut("0", modifiers: [.command, .option]) .keyboardShortcut("0", modifiers: [.command, .option])
Button {
model.presentWorkspaceImporter()
} label: {
Label("Open Workspace", systemImage: "folder.badge.plus")
}
.help("Open Workspace...")
Button { Button {
isImportingMarkdown = true isImportingMarkdown = true
} label: { } label: {
@ -220,6 +249,21 @@ private struct MainWindow: View {
.keyboardShortcut("s", modifiers: .command) .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( .fileImporter(
isPresented: $isImportingMarkdown, isPresented: $isImportingMarkdown,
allowedContentTypes: [.saplingMarkdown, .plainText], allowedContentTypes: [.saplingMarkdown, .plainText],
@ -262,32 +306,3 @@ private extension UTType {
UTType(filenameExtension: "md") ?? .plainText 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() activeSession.objectWillChange.send()
} }
public func closeAll() {
sessions = []
activeSession = nil
}
private func sessionKey(for url: URL) -> String { private func sessionKey(for url: URL) -> String {
url.standardizedFileURL.path url.standardizedFileURL.path
} }

View file

@ -1,40 +1,67 @@
import SwiftUI import SwiftUI
import SaplingCore import SaplingCore
public enum WorkspaceTreeSelection: Hashable, Sendable {
case folder(URL)
case file(URL)
case project(URL)
case subproject(URL)
}
public struct WorkspaceTreeView: View { public struct WorkspaceTreeView: View {
private let workspace: Workspace private let workspace: Workspace?
@Binding private var selection: WorkspaceTreeSelection?
private let onSelectFile: (WorkspaceFile) -> Void private let onSelectFile: (WorkspaceFile) -> Void
private let onSelectProject: (Project) -> Void private let onSelectProject: (Project) -> Void
private let onOpenWorkspace: () -> Void
public init( public init(
workspace: Workspace, workspace: Workspace?,
selection: Binding<WorkspaceTreeSelection?>,
onSelectFile: @escaping (WorkspaceFile) -> Void, onSelectFile: @escaping (WorkspaceFile) -> Void,
onSelectProject: @escaping (Project) -> Void onSelectProject: @escaping (Project) -> Void,
onOpenWorkspace: @escaping () -> Void
) { ) {
self.workspace = workspace self.workspace = workspace
self._selection = selection
self.onSelectFile = onSelectFile self.onSelectFile = onSelectFile
self.onSelectProject = onSelectProject self.onSelectProject = onSelectProject
self.onOpenWorkspace = onOpenWorkspace
} }
public var body: some View { public var body: some View {
List { Group {
Section(workspace.name) { if let workspace {
ForEach(workspace.items) { item in List(selection: $selection) {
WorkspaceItemRow( Section(workspace.name) {
item: item, ForEach(workspace.items) { item in
onSelectFile: onSelectFile, WorkspaceItemRow(
onSelectProject: onSelectProject 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") .navigationTitle("Workspace")
} }
} }
private struct WorkspaceItemRow: View { private struct WorkspaceItemRow: View {
let item: WorkspaceItem let item: WorkspaceItem
@Binding var selection: WorkspaceTreeSelection?
let onSelectFile: (WorkspaceFile) -> Void let onSelectFile: (WorkspaceFile) -> Void
let onSelectProject: (Project) -> Void let onSelectProject: (Project) -> Void
@ -45,30 +72,53 @@ private struct WorkspaceItemRow: View {
ForEach(folder.children) { child in ForEach(folder.children) { child in
WorkspaceItemRow( WorkspaceItemRow(
item: child, item: child,
selection: $selection,
onSelectFile: onSelectFile, onSelectFile: onSelectFile,
onSelectProject: onSelectProject onSelectProject: onSelectProject
) )
} }
} label: { } label: {
Label(folder.name, systemImage: "folder") Label(folder.name, systemImage: "folder")
.contentShape(Rectangle())
.onTapGesture {
selection = .folder(folder.url)
}
} }
.tag(WorkspaceTreeSelection.folder(folder.url))
case .file(let file): case .file(let file):
Button { Label(file.name, systemImage: iconName(for: file.kind))
onSelectFile(file) .foregroundStyle(file.kind == .markdown ? .primary : .secondary)
} label: { .contentShape(Rectangle())
Label(file.name, systemImage: iconName(for: file.kind)) .onTapGesture {
} selection = .file(file.url)
.buttonStyle(.plain) onSelectFile(file)
}
.tag(WorkspaceTreeSelection.file(file.url))
case .project(let project): case .project(let project):
Button { DisclosureGroup {
onSelectProject(project) ForEach(project.children) { child in
WorkspaceItemRow(
item: child,
selection: $selection,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
}
} label: { } 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): case .subproject(let subproject):
Label(subproject.name, systemImage: "rectangle.connected.to.line.below") Label(subproject.name, systemImage: "rectangle.connected.to.line.below")
.foregroundStyle(.secondary) .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"))) XCTAssertFalse(store.activateDocument(at: URL(fileURLWithPath: "/tmp/missing.md")))
XCTAssertNil(store.activeSession) 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"]) 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 { private func makeTemporaryWorkspace() throws -> URL {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("SaplingWorkspaceTests-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("SaplingWorkspaceTests-\(UUID().uuidString)", isDirectory: true)