perf(workspace): lazily load tree folders

This commit is contained in:
Feror 2026-06-02 15:24:36 +02:00
parent 4d7a0d04c2
commit ab545ef18b
4 changed files with 213 additions and 40 deletions

View file

@ -60,12 +60,15 @@ private final class SaplingAppDelegate: NSObject, NSApplicationDelegate {
@MainActor
private final class SaplingAppModel: ObservableObject {
@Published var workspace: Workspace?
@Published var workspaceChildren: [URL: [WorkspaceItem]] = [:]
@Published var loadingTreeItemURLs: Set<URL> = []
@Published var workspaceSelection: WorkspaceTreeSelection?
@Published var selectedProject: Project?
@Published var selectedDocument: MarkdownDocument?
@Published var editorViewModel: HybridMarkdownEditorViewModel?
@Published var configuration: SaplingConfiguration
@Published var editorErrorMessage: String?
@Published var isLoadingWorkspace = false
private let configurationStore: any ConfigurationStore
private let workspaceManager: any WorkspaceManaging
@ -113,16 +116,23 @@ private final class SaplingAppModel: ObservableObject {
}
func openWorkspace(at url: URL) async {
isLoadingWorkspace = true
let didStartAccessing = url.startAccessingSecurityScopedResource()
defer {
isLoadingWorkspace = false
if didStartAccessing {
url.stopAccessingSecurityScopedResource()
}
}
do {
let workspace = try await workspaceManager.openWorkspace(at: url)
let workspaceManager = self.workspaceManager
let workspace = try await Task.detached {
try await workspaceManager.openWorkspace(at: url)
}.value
self.workspace = workspace
workspaceChildren = [:]
loadingTreeItemURLs = []
workspaceSelection = nil
selectedProject = nil
selectedDocument = nil
@ -137,6 +147,39 @@ private final class SaplingAppModel: ObservableObject {
}
}
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 }
loadingTreeItemURLs.insert(url)
let workspaceManager = self.workspaceManager
Task {
do {
let children = try await Task.detached {
try await workspaceManager.loadItems(in: url)
}.value
workspaceChildren[url] = children
} catch {
editorErrorMessage = "Unable to load \(item.displayName): \(error.localizedDescription)"
logger.error("Failed to load workspace tree item: \(error)", category: .workspace)
}
loadingTreeItemURLs.remove(url)
}
}
func select(file: WorkspaceFile) {
guard file.kind == .markdown else { return }
workspaceSelection = .file(file.url)
@ -215,7 +258,11 @@ private struct MainWindow: View {
NavigationSplitView(columnVisibility: $columnVisibility) {
WorkspaceTreeView(
workspace: model.workspace,
isLoadingWorkspace: model.isLoadingWorkspace,
selection: $model.workspaceSelection,
childrenFor: model.children(for:),
isLoadingChildren: model.isLoadingChildren(for:),
onExpandItem: model.loadChildrenIfNeeded(for:),
onSelectFile: model.select(file:),
onSelectProject: model.select(project:),
onOpenWorkspace: model.presentWorkspaceImporter
@ -308,3 +355,16 @@ private extension UTType {
UTType(filenameExtension: "md") ?? .plainText
}
}
private extension WorkspaceItem {
var containerURL: URL? {
switch self {
case .folder(let folder):
return folder.url
case .project(let project):
return project.repositoryURL
case .file, .subproject:
return nil
}
}
}

View file

@ -10,20 +10,32 @@ public enum WorkspaceTreeSelection: Hashable, Sendable {
public struct WorkspaceTreeView: View {
private let workspace: Workspace?
private let isLoadingWorkspace: Bool
@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?,
isLoadingWorkspace: Bool = false,
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,
onOpenWorkspace: @escaping () -> Void
) {
self.workspace = workspace
self.isLoadingWorkspace = isLoadingWorkspace
self._selection = selection
self.childrenFor = childrenFor
self.isLoadingChildren = isLoadingChildren
self.onExpandItem = onExpandItem
self.onSelectFile = onSelectFile
self.onSelectProject = onSelectProject
self.onOpenWorkspace = onOpenWorkspace
@ -33,11 +45,18 @@ public struct WorkspaceTreeView: View {
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,
selection: $selection,
childrenFor: childrenFor,
isLoadingChildren: isLoadingChildren,
onExpandItem: onExpandItem,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
@ -47,36 +66,40 @@ public struct WorkspaceTreeView: View {
.listStyle(.sidebar)
} else {
ContentUnavailableView {
Label("No Workspace", systemImage: "folder")
Label(isLoadingWorkspace ? "Loading Workspace" : "No Workspace", systemImage: "folder")
} description: {
Text("Choose a folder to browse Markdown files.")
Text(isLoadingWorkspace ? "Scanning Workspace..." : "Choose a folder to browse Markdown files.")
} actions: {
Button("Open Workspace...", action: onOpenWorkspace)
if isLoadingWorkspace {
ProgressView()
} else {
Button("Open Workspace...", action: onOpenWorkspace)
}
}
}
}
.navigationTitle("Workspace")
.transaction { transaction in
transaction.animation = nil
}
}
}
private struct WorkspaceItemRow: View {
let item: WorkspaceItem
@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, id: \.stableTreeID) { child in
WorkspaceItemRow(
item: child,
selection: $selection,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
}
DisclosureGroup(isExpanded: expansionBinding) {
children
} label: {
Label(folder.name, systemImage: "folder")
.contentShape(Rectangle())
@ -87,8 +110,8 @@ private struct WorkspaceItemRow: View {
.tag(WorkspaceTreeSelection.folder(folder.url))
case .file(let file):
Button {
selection = .file(file.url)
if file.kind == .markdown {
selection = .file(file.url)
onSelectFile(file)
}
} label: {
@ -96,18 +119,10 @@ private struct WorkspaceItemRow: View {
.foregroundStyle(file.kind == .markdown ? .primary : .secondary)
}
.buttonStyle(.plain)
.disabled(file.kind != .markdown)
.tag(WorkspaceTreeSelection.file(file.url))
case .project(let project):
DisclosureGroup {
ForEach(project.children, id: \.stableTreeID) { child in
WorkspaceItemRow(
item: child,
selection: $selection,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
}
DisclosureGroup(isExpanded: expansionBinding) {
children
} label: {
Label(project.name, systemImage: "leaf")
.fontWeight(.medium)
@ -126,6 +141,42 @@ private struct WorkspaceItemRow: View {
}
}
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 {
if isLoadingChildren(item) {
Label("Loading...", systemImage: "progress.indicator")
.foregroundStyle(.secondary)
} else {
ForEach(childrenFor(item), id: \.stableTreeID) { child in
WorkspaceItemRow(
item: child,
selection: $selection,
childrenFor: childrenFor,
isLoadingChildren: isLoadingChildren,
onExpandItem: onExpandItem,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
}
}
}
private func iconName(for kind: WorkspaceFileKind) -> String {
switch kind {
case .markdown: "doc.plaintext"

View file

@ -3,6 +3,7 @@ import SaplingCore
public protocol WorkspaceManaging: Sendable {
func openWorkspace(at url: URL) async throws -> Workspace
func loadItems(in directoryURL: URL) async throws -> [WorkspaceItem]
func sampleWorkspace() -> Workspace
}
@ -14,17 +15,21 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
}
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)
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],
@ -34,19 +39,17 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
}
return try children
.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }
.map { childURL in
let values = try childURL.resourceValues(forKeys: [.isDirectoryKey])
if values.isDirectory == true {
if isGitRepository(at: childURL) {
return .project(try project(at: childURL, relativeTo: rootURL))
return .project(project(at: childURL))
}
return .folder(
WorkspaceFolder(
name: childURL.lastPathComponent,
url: childURL,
children: try scanItems(at: childURL, relativeTo: rootURL)
url: childURL
)
)
}
@ -59,6 +62,7 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
)
)
}
.sorted(by: workspaceItemSort)
}
private var directoryOptions: FileManager.DirectoryEnumerationOptions {
@ -69,7 +73,7 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
fileManager.fileExists(atPath: url.appendingPathComponent(".git").path)
}
private func project(at url: URL, relativeTo rootURL: URL) throws -> Project {
private func project(at url: URL) -> Project {
let repository = GitRepository(
name: url.lastPathComponent,
rootURL: url,
@ -78,11 +82,18 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
return Project(
name: url.lastPathComponent,
repositoryURL: url,
children: try scanItems(at: url, relativeTo: rootURL),
gitRepository: repository
)
}
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":
@ -94,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

@ -13,7 +13,7 @@ final class WorkspaceManagerTests: XCTestCase {
temporaryRoots = []
}
func testOpenWorkspaceBuildsFilesystemTree() async throws {
func testOpenWorkspaceBuildsRootLevelOnly() async throws {
let rootURL = try makeTemporaryWorkspace()
try createDirectory("Notes", in: rootURL)
try writeFile("Notes/Index.md", in: rootURL, contents: "# Notes")
@ -27,12 +27,19 @@ final class WorkspaceManagerTests: XCTestCase {
XCTAssertEqual(workspace.items.map(\.displayName), ["Notes", "Research"])
let notes = try XCTUnwrap(workspace.folder(named: "Notes"))
XCTAssertEqual(notes.children.map(\.displayName), ["Index.md", "todo.txt"])
XCTAssertEqual(try XCTUnwrap(notes.file(named: "Index.md")).kind, .markdown)
XCTAssertEqual(try XCTUnwrap(notes.file(named: "todo.txt")).kind, .other)
XCTAssertTrue(notes.children.isEmpty)
let archive = try XCTUnwrap(workspace.folder(named: "Research")?.folder(named: "Archive"))
XCTAssertEqual(try XCTUnwrap(archive.file(named: "Paper.markdown")).kind, .markdown)
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 {
@ -50,7 +57,9 @@ final class WorkspaceManagerTests: XCTestCase {
let sapling = try XCTUnwrap(workspace.project(named: "Sapling"))
XCTAssertEqual(research.gitRepository.statusSummary, .unknown)
XCTAssertEqual(sapling.gitRepository.rootURL.lastPathComponent, "Sapling")
XCTAssertEqual(research.children.map(\.displayName), ["Notes.md"])
XCTAssertTrue(research.children.isEmpty)
let researchChildren = try await LocalWorkspaceManager().loadItems(in: research.repositoryURL)
XCTAssertEqual(researchChildren.map(\.displayName), ["Notes.md"])
XCTAssertNil(research.folder(named: ".git"))
}
@ -65,7 +74,9 @@ final class WorkspaceManagerTests: XCTestCase {
XCTAssertEqual(workspace.items.map(\.displayName), ["Project"])
let project = try XCTUnwrap(workspace.project(named: "Project"))
XCTAssertEqual(project.children.map(\.displayName), ["Visible.md"])
XCTAssertTrue(project.children.isEmpty)
let projectChildren = try await LocalWorkspaceManager().loadItems(in: project.repositoryURL)
XCTAssertEqual(projectChildren.map(\.displayName), ["Visible.md"])
}
func testLargeFolderHierarchyScansWithinReasonableTime() async throws {
@ -87,6 +98,19 @@ final class WorkspaceManagerTests: XCTestCase {
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)
@ -157,3 +181,19 @@ private extension Project {
}.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
}
}