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
|
@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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue