From ab545ef18b9ce971264ab5a88c2094437538743c Mon Sep 17 00:00:00 2001 From: Feror Date: Tue, 2 Jun 2026 15:24:36 +0200 Subject: [PATCH] perf(workspace): lazily load tree folders --- Sources/SaplingApp/SaplingApp.swift | 62 +++++++++++- Sources/SaplingUI/WorkspaceTreeView.swift | 97 ++++++++++++++----- .../SaplingWorkspace/WorkspaceManager.swift | 38 ++++++-- .../WorkspaceManagerTests.swift | 56 +++++++++-- 4 files changed, 213 insertions(+), 40 deletions(-) diff --git a/Sources/SaplingApp/SaplingApp.swift b/Sources/SaplingApp/SaplingApp.swift index f8dd671..06cc216 100644 --- a/Sources/SaplingApp/SaplingApp.swift +++ b/Sources/SaplingApp/SaplingApp.swift @@ -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 = [] @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 + } + } +} diff --git a/Sources/SaplingUI/WorkspaceTreeView.swift b/Sources/SaplingUI/WorkspaceTreeView.swift index a95cc4d..dbcca1a 100644 --- a/Sources/SaplingUI/WorkspaceTreeView.swift +++ b/Sources/SaplingUI/WorkspaceTreeView.swift @@ -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, + 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 { + 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" diff --git a/Sources/SaplingWorkspace/WorkspaceManager.swift b/Sources/SaplingWorkspace/WorkspaceManager.swift index df3f234..451f36c 100644 --- a/Sources/SaplingWorkspace/WorkspaceManager.swift +++ b/Sources/SaplingWorkspace/WorkspaceManager.swift @@ -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 + } + } +} diff --git a/Tests/SaplingWorkspaceTests/WorkspaceManagerTests.swift b/Tests/SaplingWorkspaceTests/WorkspaceManagerTests.swift index 026aa38..6544881 100644 --- a/Tests/SaplingWorkspaceTests/WorkspaceManagerTests.swift +++ b/Tests/SaplingWorkspaceTests/WorkspaceManagerTests.swift @@ -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 + } +}