perf(workspace): lazily load tree folders
This commit is contained in:
parent
4d7a0d04c2
commit
ab545ef18b
4 changed files with 213 additions and 40 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue