feat(workspace): add workspace sidebar experience
This commit is contained in:
parent
3d3d1aee28
commit
1d7840e45c
10 changed files with 255 additions and 109 deletions
|
|
@ -6,7 +6,7 @@ Sapling is organized as a Swift Package with an executable app target and focuse
|
|||
|
||||
- `SaplingApp`: SwiftUI application entry point and dependency composition.
|
||||
- `SaplingCore`: Domain models, sample data, and business rules.
|
||||
- `SaplingWorkspace`: Workspace opening and file tree management.
|
||||
- `SaplingWorkspace`: Filesystem-backed workspace opening and file tree discovery.
|
||||
- `SaplingGit`: Protocol-first Git abstraction plus macOS, embedded, and mock providers.
|
||||
- `SaplingEditor`: Hybrid Markdown editor state and SwiftUI editing surface.
|
||||
- `SaplingRenderer`: Markdown parsing/rendering primitives for headings, emphasis, code blocks, task lists, and images.
|
||||
|
|
@ -18,21 +18,22 @@ Sapling is organized as a Swift Package with an executable app target and focuse
|
|||
|
||||
The Git layer is protocol-first because macOS and iOS need different implementations. `MacGitProvider` uses the system `git` binary behind the `GitProvider` and `Repository` protocols. `EmbeddedGitProvider` is intentionally stubbed as the future iOS-compatible implementation point. `MockGitProvider` is used by the initial app shell and previews/tests so UI work is not coupled to local repositories.
|
||||
|
||||
Workspaces are local containers and are not versioned. Projects are Git repositories, while subprojects model Git submodules. This distinction is represented in `SaplingCore` so business rules remain shared across macOS and future iOS targets.
|
||||
Workspaces are local containers and are not versioned. Projects are Git repositories, while subprojects model Git submodules. Workspace contents are derived 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
51
Docs/Workspace.md
Normal 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.
|
||||
|
|
@ -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
2
README
|
|
@ -197,6 +197,6 @@ make validate
|
|||
|
||||
The validation target runs formatting, linting, build, and tests. If `swift-format` is installed, `make format` formats Swift sources in place; otherwise it falls back to the repository lint checks.
|
||||
|
||||
The initial app shell launches with a sample workspace tree, a hybrid Markdown editor, a Git/project inspector, and a mock Git provider. The architecture is intentionally protocol-first so the macOS implementation can use system Git while future iOS support can use an embedded Git implementation behind the same interfaces.
|
||||
The app shell can open a local folder as a workspace, scan its filesystem hierarchy, visually distinguish Git repositories as projects, and open Markdown files through reusable document sessions. The architecture is intentionally protocol-first so the macOS implementation can use system Git while future iOS support can use an embedded Git implementation behind the same interfaces.
|
||||
|
||||
See [Docs/Architecture.md](Docs/Architecture.md) for module responsibilities, architectural decisions, and TODO markers.
|
||||
|
|
|
|||
|
|
@ -1,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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue