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 @MainActor
private final class SaplingAppModel: ObservableObject { private final class SaplingAppModel: ObservableObject {
@Published var workspace: Workspace? @Published var workspace: Workspace?
@Published var workspaceChildren: [URL: [WorkspaceItem]] = [:]
@Published var loadingTreeItemURLs: Set<URL> = []
@Published var workspaceSelection: WorkspaceTreeSelection? @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 configuration: SaplingConfiguration @Published var configuration: SaplingConfiguration
@Published var editorErrorMessage: String? @Published var editorErrorMessage: String?
@Published var isLoadingWorkspace = false
private let configurationStore: any ConfigurationStore private let configurationStore: any ConfigurationStore
private let workspaceManager: any WorkspaceManaging private let workspaceManager: any WorkspaceManaging
@ -113,16 +116,23 @@ private final class SaplingAppModel: ObservableObject {
} }
func openWorkspace(at url: URL) async { func openWorkspace(at url: URL) async {
isLoadingWorkspace = true
let didStartAccessing = url.startAccessingSecurityScopedResource() let didStartAccessing = url.startAccessingSecurityScopedResource()
defer { defer {
isLoadingWorkspace = false
if didStartAccessing { if didStartAccessing {
url.stopAccessingSecurityScopedResource() url.stopAccessingSecurityScopedResource()
} }
} }
do { 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 self.workspace = workspace
workspaceChildren = [:]
loadingTreeItemURLs = []
workspaceSelection = nil workspaceSelection = nil
selectedProject = nil selectedProject = nil
selectedDocument = 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) { func select(file: WorkspaceFile) {
guard file.kind == .markdown else { return } guard file.kind == .markdown else { return }
workspaceSelection = .file(file.url) workspaceSelection = .file(file.url)
@ -215,7 +258,11 @@ private struct MainWindow: View {
NavigationSplitView(columnVisibility: $columnVisibility) { NavigationSplitView(columnVisibility: $columnVisibility) {
WorkspaceTreeView( WorkspaceTreeView(
workspace: model.workspace, workspace: model.workspace,
isLoadingWorkspace: model.isLoadingWorkspace,
selection: $model.workspaceSelection, selection: $model.workspaceSelection,
childrenFor: model.children(for:),
isLoadingChildren: model.isLoadingChildren(for:),
onExpandItem: model.loadChildrenIfNeeded(for:),
onSelectFile: model.select(file:), onSelectFile: model.select(file:),
onSelectProject: model.select(project:), onSelectProject: model.select(project:),
onOpenWorkspace: model.presentWorkspaceImporter onOpenWorkspace: model.presentWorkspaceImporter
@ -308,3 +355,16 @@ private extension UTType {
UTType(filenameExtension: "md") ?? .plainText 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 { public struct WorkspaceTreeView: View {
private let workspace: Workspace? private let workspace: Workspace?
private let isLoadingWorkspace: Bool
@Binding private var selection: WorkspaceTreeSelection? @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 onSelectFile: (WorkspaceFile) -> Void
private let onSelectProject: (Project) -> Void private let onSelectProject: (Project) -> Void
private let onOpenWorkspace: () -> Void private let onOpenWorkspace: () -> Void
public init( public init(
workspace: Workspace?, workspace: Workspace?,
isLoadingWorkspace: Bool = false,
selection: Binding<WorkspaceTreeSelection?>, selection: Binding<WorkspaceTreeSelection?>,
childrenFor: @escaping (WorkspaceItem) -> [WorkspaceItem] = { $0.children ?? [] },
isLoadingChildren: @escaping (WorkspaceItem) -> Bool = { _ in false },
onExpandItem: @escaping (WorkspaceItem) -> Void = { _ in },
onSelectFile: @escaping (WorkspaceFile) -> Void, onSelectFile: @escaping (WorkspaceFile) -> Void,
onSelectProject: @escaping (Project) -> Void, onSelectProject: @escaping (Project) -> Void,
onOpenWorkspace: @escaping () -> Void onOpenWorkspace: @escaping () -> Void
) { ) {
self.workspace = workspace self.workspace = workspace
self.isLoadingWorkspace = isLoadingWorkspace
self._selection = selection self._selection = selection
self.childrenFor = childrenFor
self.isLoadingChildren = isLoadingChildren
self.onExpandItem = onExpandItem
self.onSelectFile = onSelectFile self.onSelectFile = onSelectFile
self.onSelectProject = onSelectProject self.onSelectProject = onSelectProject
self.onOpenWorkspace = onOpenWorkspace self.onOpenWorkspace = onOpenWorkspace
@ -33,11 +45,18 @@ public struct WorkspaceTreeView: View {
Group { Group {
if let workspace { if let workspace {
List(selection: $selection) { List(selection: $selection) {
if isLoadingWorkspace {
Label("Scanning Workspace...", systemImage: "progress.indicator")
.foregroundStyle(.secondary)
}
Section(workspace.name) { Section(workspace.name) {
ForEach(workspace.items, id: \.stableTreeID) { item in ForEach(workspace.items, id: \.stableTreeID) { item in
WorkspaceItemRow( WorkspaceItemRow(
item: item, item: item,
selection: $selection, selection: $selection,
childrenFor: childrenFor,
isLoadingChildren: isLoadingChildren,
onExpandItem: onExpandItem,
onSelectFile: onSelectFile, onSelectFile: onSelectFile,
onSelectProject: onSelectProject onSelectProject: onSelectProject
) )
@ -47,36 +66,40 @@ public struct WorkspaceTreeView: View {
.listStyle(.sidebar) .listStyle(.sidebar)
} else { } else {
ContentUnavailableView { ContentUnavailableView {
Label("No Workspace", systemImage: "folder") Label(isLoadingWorkspace ? "Loading Workspace" : "No Workspace", systemImage: "folder")
} description: { } description: {
Text("Choose a folder to browse Markdown files.") Text(isLoadingWorkspace ? "Scanning Workspace..." : "Choose a folder to browse Markdown files.")
} actions: { } actions: {
if isLoadingWorkspace {
ProgressView()
} else {
Button("Open Workspace...", action: onOpenWorkspace) Button("Open Workspace...", action: onOpenWorkspace)
} }
} }
} }
}
.navigationTitle("Workspace") .navigationTitle("Workspace")
.transaction { transaction in
transaction.animation = nil
}
} }
} }
private struct WorkspaceItemRow: View { private struct WorkspaceItemRow: View {
let item: WorkspaceItem let item: WorkspaceItem
@Binding var selection: WorkspaceTreeSelection? @Binding var selection: WorkspaceTreeSelection?
let childrenFor: (WorkspaceItem) -> [WorkspaceItem]
let isLoadingChildren: (WorkspaceItem) -> Bool
let onExpandItem: (WorkspaceItem) -> Void
let onSelectFile: (WorkspaceFile) -> Void let onSelectFile: (WorkspaceFile) -> Void
let onSelectProject: (Project) -> Void let onSelectProject: (Project) -> Void
@State private var isExpanded = false
var body: some View { var body: some View {
switch item { switch item {
case .folder(let folder): case .folder(let folder):
DisclosureGroup { DisclosureGroup(isExpanded: expansionBinding) {
ForEach(folder.children, id: \.stableTreeID) { child in children
WorkspaceItemRow(
item: child,
selection: $selection,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
}
} label: { } label: {
Label(folder.name, systemImage: "folder") Label(folder.name, systemImage: "folder")
.contentShape(Rectangle()) .contentShape(Rectangle())
@ -87,8 +110,8 @@ private struct WorkspaceItemRow: View {
.tag(WorkspaceTreeSelection.folder(folder.url)) .tag(WorkspaceTreeSelection.folder(folder.url))
case .file(let file): case .file(let file):
Button { Button {
if file.kind == .markdown {
selection = .file(file.url) selection = .file(file.url)
if file.kind == .markdown {
onSelectFile(file) onSelectFile(file)
} }
} label: { } label: {
@ -96,18 +119,10 @@ private struct WorkspaceItemRow: View {
.foregroundStyle(file.kind == .markdown ? .primary : .secondary) .foregroundStyle(file.kind == .markdown ? .primary : .secondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(file.kind != .markdown)
.tag(WorkspaceTreeSelection.file(file.url)) .tag(WorkspaceTreeSelection.file(file.url))
case .project(let project): case .project(let project):
DisclosureGroup { DisclosureGroup(isExpanded: expansionBinding) {
ForEach(project.children, id: \.stableTreeID) { child in children
WorkspaceItemRow(
item: child,
selection: $selection,
onSelectFile: onSelectFile,
onSelectProject: onSelectProject
)
}
} label: { } label: {
Label(project.name, systemImage: "leaf") Label(project.name, systemImage: "leaf")
.fontWeight(.medium) .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 { private func iconName(for kind: WorkspaceFileKind) -> String {
switch kind { switch kind {
case .markdown: "doc.plaintext" case .markdown: "doc.plaintext"

View file

@ -3,6 +3,7 @@ import SaplingCore
public protocol WorkspaceManaging: Sendable { public protocol WorkspaceManaging: Sendable {
func openWorkspace(at url: URL) async throws -> Workspace func openWorkspace(at url: URL) async throws -> Workspace
func loadItems(in directoryURL: URL) async throws -> [WorkspaceItem]
func sampleWorkspace() -> Workspace func sampleWorkspace() -> Workspace
} }
@ -14,17 +15,21 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
} }
public func openWorkspace(at url: URL) async throws -> Workspace { 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) let workspace = Workspace(name: url.lastPathComponent, rootURL: url, items: items)
try SaplingRules.validateWorkspace(workspace) try SaplingRules.validateWorkspace(workspace)
return workspace return workspace
} }
public func loadItems(in directoryURL: URL) async throws -> [WorkspaceItem] {
try scanItems(at: directoryURL)
}
public func sampleWorkspace() -> Workspace { public func sampleWorkspace() -> Workspace {
SaplingSampleData.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( guard let children = try? fileManager.contentsOfDirectory(
at: url, at: url,
includingPropertiesForKeys: [.isDirectoryKey], includingPropertiesForKeys: [.isDirectoryKey],
@ -34,19 +39,17 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
} }
return try children return try children
.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }
.map { childURL in .map { childURL in
let values = try childURL.resourceValues(forKeys: [.isDirectoryKey]) let values = try childURL.resourceValues(forKeys: [.isDirectoryKey])
if values.isDirectory == true { if values.isDirectory == true {
if isGitRepository(at: childURL) { if isGitRepository(at: childURL) {
return .project(try project(at: childURL, relativeTo: rootURL)) return .project(project(at: childURL))
} }
return .folder( return .folder(
WorkspaceFolder( WorkspaceFolder(
name: childURL.lastPathComponent, name: childURL.lastPathComponent,
url: childURL, url: childURL
children: try scanItems(at: childURL, relativeTo: rootURL)
) )
) )
} }
@ -59,6 +62,7 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
) )
) )
} }
.sorted(by: workspaceItemSort)
} }
private var directoryOptions: FileManager.DirectoryEnumerationOptions { private var directoryOptions: FileManager.DirectoryEnumerationOptions {
@ -69,7 +73,7 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
fileManager.fileExists(atPath: url.appendingPathComponent(".git").path) 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( let repository = GitRepository(
name: url.lastPathComponent, name: url.lastPathComponent,
rootURL: url, rootURL: url,
@ -78,11 +82,18 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
return Project( return Project(
name: url.lastPathComponent, name: url.lastPathComponent,
repositoryURL: url, repositoryURL: url,
children: try scanItems(at: url, relativeTo: rootURL),
gitRepository: repository 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 { private func fileKind(for url: URL) -> WorkspaceFileKind {
switch url.pathExtension.lowercased() { switch url.pathExtension.lowercased() {
case "md", "markdown": 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 = [] temporaryRoots = []
} }
func testOpenWorkspaceBuildsFilesystemTree() async throws { func testOpenWorkspaceBuildsRootLevelOnly() async throws {
let rootURL = try makeTemporaryWorkspace() let rootURL = try makeTemporaryWorkspace()
try createDirectory("Notes", in: rootURL) try createDirectory("Notes", in: rootURL)
try writeFile("Notes/Index.md", in: rootURL, contents: "# Notes") try writeFile("Notes/Index.md", in: rootURL, contents: "# Notes")
@ -27,12 +27,19 @@ final class WorkspaceManagerTests: XCTestCase {
XCTAssertEqual(workspace.items.map(\.displayName), ["Notes", "Research"]) XCTAssertEqual(workspace.items.map(\.displayName), ["Notes", "Research"])
let notes = try XCTUnwrap(workspace.folder(named: "Notes")) let notes = try XCTUnwrap(workspace.folder(named: "Notes"))
XCTAssertEqual(notes.children.map(\.displayName), ["Index.md", "todo.txt"]) XCTAssertTrue(notes.children.isEmpty)
XCTAssertEqual(try XCTUnwrap(notes.file(named: "Index.md")).kind, .markdown)
XCTAssertEqual(try XCTUnwrap(notes.file(named: "todo.txt")).kind, .other)
let archive = try XCTUnwrap(workspace.folder(named: "Research")?.folder(named: "Archive")) let notesChildren = try await LocalWorkspaceManager().loadItems(in: notes.url)
XCTAssertEqual(try XCTUnwrap(archive.file(named: "Paper.markdown")).kind, .markdown) 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 { func testGitRepositoriesAreDetectedAsProjectsWithChildren() async throws {
@ -50,7 +57,9 @@ final class WorkspaceManagerTests: XCTestCase {
let sapling = try XCTUnwrap(workspace.project(named: "Sapling")) let sapling = try XCTUnwrap(workspace.project(named: "Sapling"))
XCTAssertEqual(research.gitRepository.statusSummary, .unknown) XCTAssertEqual(research.gitRepository.statusSummary, .unknown)
XCTAssertEqual(sapling.gitRepository.rootURL.lastPathComponent, "Sapling") 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")) XCTAssertNil(research.folder(named: ".git"))
} }
@ -65,7 +74,9 @@ final class WorkspaceManagerTests: XCTestCase {
XCTAssertEqual(workspace.items.map(\.displayName), ["Project"]) XCTAssertEqual(workspace.items.map(\.displayName), ["Project"])
let project = try XCTUnwrap(workspace.project(named: "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 { func testLargeFolderHierarchyScansWithinReasonableTime() async throws {
@ -87,6 +98,19 @@ final class WorkspaceManagerTests: XCTestCase {
XCTAssertLessThan(duration, 2.0) 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 { 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)
@ -157,3 +181,19 @@ private extension Project {
}.first }.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
}
}