From bd5f13cd1e442b4787ba65ae46aa514775f71164 Mon Sep 17 00:00:00 2001 From: matchaboar <224671045+matchaboar@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:21:08 -0700 Subject: [PATCH 1/3] perf: scale file browser for large folders Move file-tree and batched Git work off the main actor, publish stable flat row models, and lazily realize sidebar rows with cancellation and signpost coverage. Add focused identity tests plus deterministic benchmark and issue #36 verification guidance. --- Sources/mindle/ContentView.swift | 146 +------- Sources/mindle/DocumentStore.swift | 86 ++--- Sources/mindle/FileBrowserState.swift | 182 ++++++++++ Sources/mindle/FileBrowserView.swift | 227 +++++++++++++ Sources/mindle/FileTree.swift | 152 +++++++++ Sources/mindle/GitFileMetadata.swift | 368 +++++++++++++++++++++ Sources/mindle/MindleApp.swift | 2 +- Sources/mindle/PerformanceTrace.swift | 36 ++ Tests/FileBrowserPerformance.md | 94 ++++++ Tests/harness/FileBrowserStateChecks.swift | 157 +++++++++ Tests/harness/FileTreeChecks.swift | 88 +++++ Tests/harness/GitFileMetadataChecks.swift | 165 +++++++++ Tests/harness/main.swift | 3 + Tests/performance/main.swift | 306 +++++++++++++++++ run-tests.sh | 8 + scripts/generate-file-browser-fixture.sh | 53 +++ scripts/profile-file-browser.sh | 37 +++ 17 files changed, 1922 insertions(+), 188 deletions(-) create mode 100644 Sources/mindle/FileBrowserState.swift create mode 100644 Sources/mindle/FileBrowserView.swift create mode 100644 Sources/mindle/FileTree.swift create mode 100644 Sources/mindle/GitFileMetadata.swift create mode 100644 Sources/mindle/PerformanceTrace.swift create mode 100644 Tests/FileBrowserPerformance.md create mode 100644 Tests/harness/FileBrowserStateChecks.swift create mode 100644 Tests/harness/FileTreeChecks.swift create mode 100644 Tests/harness/GitFileMetadataChecks.swift create mode 100644 Tests/performance/main.swift create mode 100755 scripts/generate-file-browser-fixture.sh create mode 100755 scripts/profile-file-browser.sh diff --git a/Sources/mindle/ContentView.swift b/Sources/mindle/ContentView.swift index f9cad66..4bd91dc 100644 --- a/Sources/mindle/ContentView.swift +++ b/Sources/mindle/ContentView.swift @@ -22,7 +22,12 @@ struct ContentView: View { } HSplitView { if store.showFileBrowser { - FileBrowserSidebar() + FileBrowserSidebar( + browser: store.fileBrowser, + theme: store.theme, + onRefresh: store.refreshFileTree, + onOpen: store.open + ) .frame(minWidth: 200, idealWidth: 260, maxWidth: 400) } ReaderPane() @@ -49,7 +54,7 @@ struct ContentView: View { withAnimation(.easeInOut(duration: 0.18)) { store.showFileBrowser.toggle() } - if store.showFileBrowser && store.fileTree == nil { + if store.showFileBrowser && store.fileBrowser.tree == nil { store.refreshFileTree() } } label: { @@ -146,6 +151,9 @@ struct ContentView: View { } return false } + .onDisappear { + store.fileBrowser.cancelAll() + } } private func themeIcon(_ t: ReaderTheme) -> String { @@ -1284,140 +1292,6 @@ struct AnnotationMessageRow: View { }() } -// MARK: - File browser sidebar - -struct FileBrowserSidebar: View { - @EnvironmentObject var store: DocumentStore - - var body: some View { - let c = store.theme.colors - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 8) { - Image(systemName: "folder") - .foregroundStyle(c.accent) - Text("Files") - .font(.system(size: 13, weight: .semibold, design: .serif)) - .foregroundStyle(c.text) - Spacer() - Button { - store.refreshFileTree() - } label: { - Image(systemName: "arrow.clockwise") - .font(.system(size: 11)) - } - .buttonStyle(.plain) - .foregroundStyle(c.muted) - .help("Refresh file list") - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - - Rectangle().fill(c.rule.opacity(0.4)).frame(height: 0.5) - - if let tree = store.fileTree, let children = tree.children, !children.isEmpty { - ScrollView { - // Non-lazy VStack so the tree's content size stays constant - // when other window state changes (e.g. fileURL flipping - // isCurrent on a row, or the TabBar appearing/disappearing - // as tabs.count crosses the 2-to-1 boundary). LazyVStack - // re-measured rows on those events and could nudge the - // scroll position, making the active row appear to shift - // (#36). The directories Mindle browses are typically - // small enough that eager realization is fine. - VStack(alignment: .leading, spacing: 0) { - ForEach(children) { child in - FileTreeRow(node: child, depth: 0) - } - } - .padding(.vertical, 6) - } - } else { - VStack(spacing: 8) { - Image(systemName: "tray") - .font(.system(size: 28, weight: .ultraLight)) - .foregroundStyle(c.muted.opacity(0.7)) - Text("No markdown files\nin this directory.") - .multilineTextAlignment(.center) - .font(.system(size: 12, design: .serif).italic()) - .foregroundStyle(c.muted) - .padding(.horizontal, 24) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .background(c.sidebar) - } -} - -struct FileTreeRow: View { - let node: FileNode - let depth: Int - @EnvironmentObject var store: DocumentStore - @State private var isExpanded: Bool = true - - var body: some View { - let c = store.theme.colors - if node.isDirectory { - Button { - withAnimation(.easeInOut(duration: 0.12)) { isExpanded.toggle() } - } label: { - HStack(spacing: 6) { - Image(systemName: isExpanded ? "chevron.down" : "chevron.right") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(c.muted) - .frame(width: 10) - Image(systemName: "folder") - .font(.system(size: 11)) - .foregroundStyle(c.muted) - Text(node.name) - .font(.system(size: 12, weight: .medium, design: .serif)) - .foregroundStyle(c.text) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 0) - } - .padding(.leading, CGFloat(depth) * 14 + 8) - .padding(.trailing, 10) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - if isExpanded { - ForEach(node.children ?? []) { child in - FileTreeRow(node: child, depth: depth + 1) - } - } - } else { - let isCurrent = store.fileURL?.standardizedFileURL == node.url.standardizedFileURL - Button { - store.open(url: node.url) - } label: { - HStack(spacing: 6) { - Spacer().frame(width: 10) - Image(systemName: "doc.text") - .font(.system(size: 11)) - .foregroundStyle(isCurrent ? c.accent : c.muted) - Text(node.name) - .font(.system(size: 12, design: .serif)) - .foregroundStyle(c.text) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 0) - } - .padding(.leading, CGFloat(depth) * 14 + 8) - .padding(.trailing, 10) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .background(isCurrent ? c.accent.opacity(0.14) : Color.clear) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - } -} - // MARK: - Tab bar struct TabBar: View { diff --git a/Sources/mindle/DocumentStore.swift b/Sources/mindle/DocumentStore.swift index e74209a..12dd524 100644 --- a/Sources/mindle/DocumentStore.swift +++ b/Sources/mindle/DocumentStore.swift @@ -169,14 +169,6 @@ enum AnnotationStatus: String, Codable { case open, resolved, wontfix } -struct FileNode: Identifiable, Equatable { - var id: URL { url } - let url: URL - let name: String - let isDirectory: Bool - let children: [FileNode]? // nil = leaf file; non-nil = directory -} - /// One open document inside a window. Active-tab state still lives in /// the window-scoped @Published vars (`fileURL`, `rawText`, `annotations`, /// `lastSyncedText`) so all existing features keep working untouched; @@ -300,7 +292,7 @@ final class DocumentStore: ObservableObject { } @Published var showAnnotations: Bool = false @Published var showFileBrowser: Bool = false - @Published var fileTree: FileNode? = nil + let fileBrowser = FileBrowserState() // Tabs (per-window). Empty when no document is open; otherwise the active // tab's state mirrors `fileURL` / `rawText` / `annotations` above. @@ -522,8 +514,12 @@ final class DocumentStore: ObservableObject { // the markdown pipeline (markdown-it, search, highlight, diff) // doesn't try to do anything with the binary PDF bytes. let kind = DocumentKind.kind(for: url) - let text: String = (kind == .pdf) ? "" : try String(contentsOf: url, encoding: .utf8) - finishOpen(url: url, text: text, kind: kind, sourceURL: nil, remoteTarget: nil) + let text: String = try PerformanceTrace.measure("LocalFileRead") { + (kind == .pdf) ? "" : try String(contentsOf: url, encoding: .utf8) + } + PerformanceTrace.measure("FileOpenApply") { + finishOpen(url: url, text: text, kind: kind, sourceURL: nil, remoteTarget: nil) + } } catch { NSSound.beep() } @@ -537,8 +533,8 @@ final class DocumentStore: ObservableObject { // Re-root the file tree only when the new file is outside the current scope. // Clicking a file inside a subfolder of the current root must preserve rooting. let shouldRebuildTree: Bool - if let root = fileTree?.url { - shouldRebuildTree = !Self.isDescendant(url: url, of: root) + if let root = fileBrowser.rootURL { + shouldRebuildTree = !FileTreeBuilder.isDescendant(url, of: root) } else { shouldRebuildTree = true } @@ -571,7 +567,10 @@ final class DocumentStore: ObservableObject { // Capture the sidecar-loaded annotations into the tab snapshot. snapshotActiveTab() - if shouldRebuildTree { refreshFileTree() } + if shouldRebuildTree { + fileBrowser.setRoot(url.deletingLastPathComponent()) + } + syncFileBrowserSelection(for: url) if url.isFileURL && remoteTarget == nil { NSDocumentController.shared.noteNewRecentDocumentURL(url) } @@ -860,6 +859,7 @@ final class DocumentStore: ObservableObject { lastSyncedText = placeholder annotations = [] collaborators = [:] + syncFileBrowserSelection(for: url) closeSearch() focusedAnnotation = nil editingAnnotationID = nil @@ -924,6 +924,7 @@ final class DocumentStore: ObservableObject { lastSyncedText = raw annotations = [] collaborators = [:] + syncFileBrowserSelection(for: url) closeSearch() focusedAnnotation = nil editingAnnotationID = nil @@ -1098,6 +1099,7 @@ final class DocumentStore: ObservableObject { // Last tab closed — back to empty state. activeTabID = nil fileURL = nil + syncFileBrowserSelection(for: nil) activeRemoteTarget = nil rawText = "" lastSyncedText = "" @@ -1124,6 +1126,7 @@ final class DocumentStore: ObservableObject { private func loadTabState(_ tab: DocumentTab) { fileURL = tab.fileURL + syncFileBrowserSelection(for: tab.fileURL) self.activeRemoteTarget = tab.sourceURL.flatMap { SSHTarget(sourceURL: $0) } rawText = tab.rawText lastSyncedText = tab.lastSyncedText @@ -1172,48 +1175,29 @@ final class DocumentStore: ObservableObject { // MARK: - File browser - static let browsableExtensions: Set = ["md", "markdown", "mdown", "mkd", "txt", "pdf"] - func refreshFileTree() { - guard let url = fileURL else { fileTree = nil; return } - fileTree = Self.buildTree(at: url.deletingLastPathComponent()) - } - - private static func isDescendant(url: URL, of ancestor: URL) -> Bool { - let aPath = ancestor.standardizedFileURL.path - let uPath = url.standardizedFileURL.path - let prefix = aPath.hasSuffix("/") ? aPath : aPath + "/" - return uPath.hasPrefix(prefix) - } - - private static func buildTree(at dir: URL) -> FileNode? { - let fm = FileManager.default - guard let entries = try? fm.contentsOfDirectory( - at: dir, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - ) else { - return FileNode(url: dir, name: dir.lastPathComponent, isDirectory: true, children: []) + guard let url = fileURL, url.isFileURL else { + fileBrowser.setRoot(nil) + return } - - var children: [FileNode] = [] - for entry in entries { - let isDir = (try? entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false - if isDir { - if let sub = buildTree(at: entry), !(sub.children ?? []).isEmpty { - children.append(sub) - } - } else if browsableExtensions.contains(entry.pathExtension.lowercased()) { - children.append(FileNode(url: entry, name: entry.lastPathComponent, isDirectory: false, children: nil)) - } + let root = fileBrowser.rootURL + if let root, FileTreeBuilder.isDescendant(url, of: root) { + fileBrowser.refresh() + } else { + fileBrowser.setRoot(url.deletingLastPathComponent()) } + syncFileBrowserSelection(for: url) + } - children.sort { a, b in - if a.isDirectory != b.isDirectory { return a.isDirectory } - return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending + private func syncFileBrowserSelection(for url: URL?) { + guard let url, + url.isFileURL, + let root = fileBrowser.rootURL, + FileTreeBuilder.isDescendant(url, of: root) else { + fileBrowser.setSelectedURL(nil) + return } - - return FileNode(url: dir, name: dir.lastPathComponent, isDirectory: true, children: children) + fileBrowser.setSelectedURL(url) } func toggleTheme() { diff --git a/Sources/mindle/FileBrowserState.swift b/Sources/mindle/FileBrowserState.swift new file mode 100644 index 0000000..ededa37 --- /dev/null +++ b/Sources/mindle/FileBrowserState.swift @@ -0,0 +1,182 @@ +import Combine +import Foundation + +@MainActor +final class FileBrowserState: ObservableObject { + typealias TreeBuilder = @Sendable (URL) throws -> FileNode + typealias MetadataBuilder = @Sendable (URL) async -> GitMetadataSnapshot + + private enum TreeBuildResult: Sendable { + case success(FileNode) + case failure(String) + } + + @Published private(set) var rootURL: URL? + @Published private(set) var tree: FileNode? + @Published private(set) var rows: [FileTreeRowModel] = [] + @Published private(set) var selectedURL: URL? + @Published private(set) var isLoading = false + @Published private(set) var errorMessage: String? + @Published private(set) var gitMetadata = GitMetadataSnapshot.empty + + private let treeBuilder: TreeBuilder + private let metadataBuilder: MetadataBuilder + private var collapsedDirectories: Set = [] + private var refreshTask: Task? + private var metadataTask: Task? + private var treeWorker: Task? + private var metadataWorker: Task? + private var refreshGeneration = 0 + + init( + treeBuilder: @escaping TreeBuilder = { try FileTreeBuilder.build(at: $0) }, + metadataBuilder: @escaping MetadataBuilder = { await GitMetadataCollector.collect(for: $0) } + ) { + self.treeBuilder = treeBuilder + self.metadataBuilder = metadataBuilder + } + + func setRoot(_ url: URL?) { + let normalized = Self.normalized(url) + guard rootURL != normalized else { + refresh() + return + } + + refreshGeneration += 1 + cancelWorkers() + rootURL = normalized + tree = nil + updateRows([]) + selectedURL = nil + gitMetadata = .empty + errorMessage = nil + collapsedDirectories.removeAll() + startRefresh(generation: refreshGeneration) + } + + func refresh() { + refreshGeneration += 1 + cancelWorkers() + startRefresh(generation: refreshGeneration) + } + + func setSelectedURL(_ url: URL?) { + let normalized = Self.normalized(url) + if selectedURL != normalized { + selectedURL = normalized + } + } + + func toggleDirectory(_ url: URL) { + let normalized = url.standardizedFileURL + if collapsedDirectories.contains(normalized) { + collapsedDirectories.remove(normalized) + } else { + collapsedDirectories.insert(normalized) + } + rebuildRows() + } + + func cancelAll() { + refreshGeneration += 1 + cancelWorkers() + isLoading = false + } + + private func startRefresh(generation: Int) { + guard let rootURL else { + tree = nil + updateRows([]) + selectedURL = nil + gitMetadata = .empty + isLoading = false + errorMessage = nil + return + } + + isLoading = true + errorMessage = nil + let treeBuilder = self.treeBuilder + let treeWorker = Task.detached(priority: .userInitiated) { + do { + return TreeBuildResult.success( + try PerformanceTrace.measure("FileTreeBuild") { + try treeBuilder(rootURL) + } + ) + } catch { + return TreeBuildResult.failure(error.localizedDescription) + } + } + self.treeWorker = treeWorker + refreshTask = Task { [weak self] in + let result = await treeWorker.value + guard let self, + !Task.isCancelled, + generation == self.refreshGeneration else { return } + + self.isLoading = false + switch result { + case .success(let tree): + if self.tree != tree { + self.tree = tree + } + self.rebuildRows() + case .failure(let message): + self.metadataTask?.cancel() + self.metadataWorker?.cancel() + self.tree = nil + self.updateRows([]) + self.gitMetadata = .empty + self.errorMessage = message + } + } + + let metadataBuilder = self.metadataBuilder + let metadataWorker = Task.detached(priority: .utility) { + await metadataBuilder(rootURL) + } + self.metadataWorker = metadataWorker + metadataTask = Task { [weak self] in + let metadata = await metadataWorker.value + guard let self, + !Task.isCancelled, + generation == self.refreshGeneration else { return } + if self.gitMetadata != metadata { + self.gitMetadata = metadata + } + } + } + + private func cancelWorkers() { + refreshTask?.cancel() + metadataTask?.cancel() + treeWorker?.cancel() + metadataWorker?.cancel() + refreshTask = nil + metadataTask = nil + treeWorker = nil + metadataWorker = nil + } + + private static func normalized(_ url: URL?) -> URL? { + url?.standardizedFileURL + } + + private func rebuildRows() { + let nextRows = PerformanceTrace.measure("FileTreeFlatten") { + FileTreeBuilder.visibleRows( + in: tree, + collapsedDirectories: collapsedDirectories + ) + } + updateRows(nextRows) + } + + private func updateRows(_ nextRows: [FileTreeRowModel]) { + guard rows != nextRows else { return } + rows = nextRows + PerformanceTrace.fileTreePublished(rowCount: nextRows.count) + } +} diff --git a/Sources/mindle/FileBrowserView.swift b/Sources/mindle/FileBrowserView.swift new file mode 100644 index 0000000..3dd80c8 --- /dev/null +++ b/Sources/mindle/FileBrowserView.swift @@ -0,0 +1,227 @@ +import SwiftUI + +struct FileBrowserSidebar: View { + @ObservedObject var browser: FileBrowserState + let theme: ReaderTheme + let onRefresh: () -> Void + let onOpen: (URL) -> Void + + var body: some View { + let c = theme.colors + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "folder") + .foregroundStyle(c.accent) + Text(browser.rootURL?.lastPathComponent ?? "Files") + .font(.system(size: 13, weight: .semibold, design: .serif)) + .foregroundStyle(c.text) + .lineLimit(1) + .truncationMode(.middle) + .help(browser.rootURL?.path ?? "Files") + Spacer() + Button(action: onRefresh) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11)) + } + .buttonStyle(.plain) + .foregroundStyle(c.muted) + .help("Refresh file list") + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + Rectangle().fill(c.rule.opacity(0.4)).frame(height: 0.5) + + if browser.isLoading && browser.tree == nil { + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = browser.errorMessage { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 24, weight: .ultraLight)) + Text(errorMessage) + .multilineTextAlignment(.center) + .lineLimit(4) + } + .font(.system(size: 11, design: .serif)) + .foregroundStyle(c.muted) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if !browser.rows.isEmpty { + ScrollView { + FileBrowserRowStack( + rows: browser.rows, + selectedURL: browser.selectedURL, + gitMetadata: browser.gitMetadata, + theme: theme, + onToggle: browser.toggleDirectory, + onOpen: onOpen + ) + .padding(.vertical, 6) + } + .accessibilityIdentifier("file-browser-scroll") + } else { + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.system(size: 28, weight: .ultraLight)) + .foregroundStyle(c.muted.opacity(0.7)) + Text("No supported files\nin this directory.") + .multilineTextAlignment(.center) + .font(.system(size: 12, design: .serif).italic()) + .foregroundStyle(c.muted) + .padding(.horizontal, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .background(c.sidebar) + } +} + +private struct FileBrowserRowStack: View { + let rows: [FileTreeRowModel] + let selectedURL: URL? + let gitMetadata: GitMetadataSnapshot + let theme: ReaderTheme + let onToggle: (URL) -> Void + let onOpen: (URL) -> Void + + var body: some View { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(rows) { row in + let metadata = gitMetadata.files[row.url] + FileTreeRow( + row: row, + isCurrent: selectedURL == row.url, + changes: metadata?.changes, + lastEditedAt: metadata?.lastEditedAt, + theme: theme, + onToggle: onToggle, + onOpen: onOpen + ) + .equatable() + } + } + } +} + +struct FileTreeRow: View, Equatable { + let row: FileTreeRowModel + let isCurrent: Bool + let changes: GitFileChanges? + let lastEditedAt: Date? + let theme: ReaderTheme + let onToggle: (URL) -> Void + let onOpen: (URL) -> Void + + static func == (lhs: FileTreeRow, rhs: FileTreeRow) -> Bool { + lhs.row == rhs.row + && lhs.isCurrent == rhs.isCurrent + && lhs.changes == rhs.changes + && lhs.lastEditedAt == rhs.lastEditedAt + && lhs.theme == rhs.theme + } + + var body: some View { + let c = theme.colors + if row.kind == .directory { + Button { + withAnimation(.easeInOut(duration: 0.12)) { + onToggle(row.url) + } + } label: { + HStack(spacing: 6) { + Image(systemName: row.isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(c.muted) + .frame(width: 10) + Image(systemName: "folder") + .font(.system(size: 11)) + .foregroundStyle(c.muted) + Text(row.name) + .font(.system(size: 12, weight: .medium, design: .serif)) + .foregroundStyle(c.text) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + } + .padding(.leading, CGFloat(row.depth) * 14 + 8) + .padding(.trailing, 10) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(row.name) + .accessibilityIdentifier("file-browser-row-\(row.url.path)") + } else { + Button { + onOpen(row.url) + } label: { + HStack(spacing: 6) { + Spacer().frame(width: 10) + Image(systemName: "doc.text") + .font(.system(size: 11)) + .foregroundStyle(isCurrent ? c.accent : c.muted) + Text(row.name) + .font(.system(size: 12, design: .serif)) + .foregroundStyle(c.text) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + if let changes { + GitChangesBadge(changes: changes, theme: theme) + } + if let lastEditedAt { + LastEditedBadge(date: lastEditedAt, theme: theme) + } + } + .padding(.leading, CGFloat(row.depth) * 14 + 8) + .padding(.trailing, 10) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isCurrent ? c.accent.opacity(theme == .dark ? 0.28 : 0.22) : Color.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(row.name) + .accessibilityValue(isCurrent ? "Selected" : "") + .accessibilityIdentifier("file-browser-row-\(row.url.path)") + } + } + + private struct GitChangesBadge: View { + let changes: GitFileChanges + let theme: ReaderTheme + + var body: some View { + let c = theme.colors + Text(changes.badgeText) + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundStyle(changes.isUntracked ? c.accent : c.text.opacity(0.78)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(c.surface.opacity(0.8)) + .clipShape(Capsule()) + .help(changes.isUntracked ? "Untracked file" : "Git working-tree additions and deletions") + } + } + + private struct LastEditedBadge: View { + let date: Date + let theme: ReaderTheme + + var body: some View { + let c = theme.colors + Text(GitLastEditedFormatter.badgeText(since: date)) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(c.muted) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(c.surface.opacity(0.65)) + .clipShape(Capsule()) + .help("Last committed \(date.formatted(date: .abbreviated, time: .shortened))") + } + } +} diff --git a/Sources/mindle/FileTree.swift b/Sources/mindle/FileTree.swift new file mode 100644 index 0000000..482bf9e --- /dev/null +++ b/Sources/mindle/FileTree.swift @@ -0,0 +1,152 @@ +import Foundation + +struct FileNode: Identifiable, Equatable, Sendable { + var id: URL { url } + let url: URL + let name: String + let isDirectory: Bool + let children: [FileNode]? +} + +struct FileTreeRowModel: Identifiable, Equatable, Sendable { + enum Kind: Equatable, Sendable { + case directory + case file + } + + var id: URL { url } + let url: URL + let name: String + let depth: Int + let kind: Kind + let isExpanded: Bool +} + +enum FileTreeBuilder { + static let browsableExtensions: Set = [ + "md", "markdown", "mdown", "mkd", "txt", "pdf" + ] + + static func isBrowsableFile(_ url: URL) -> Bool { + browsableExtensions.contains(url.pathExtension.lowercased()) + } + + static func isDescendant(_ url: URL, of ancestor: URL) -> Bool { + let ancestorPath = ancestor.standardizedFileURL.path + let urlPath = url.standardizedFileURL.path + if urlPath == ancestorPath { return true } + let prefix = ancestorPath.hasSuffix("/") ? ancestorPath : ancestorPath + "/" + return urlPath.hasPrefix(prefix) + } + + static func build(at root: URL, fileManager: FileManager = .default) throws -> FileNode { + try Task.checkCancellation() + let normalizedRoot = root.standardizedFileURL + return FileNode( + url: normalizedRoot, + name: normalizedRoot.lastPathComponent, + isDirectory: true, + children: try buildChildren( + contents(of: normalizedRoot, fileManager: fileManager), + fileManager: fileManager + ) + ) + } + + static func visibleRows( + in tree: FileNode?, + collapsedDirectories: Set + ) -> [FileTreeRowModel] { + guard let children = tree?.children else { return [] } + var rows: [FileTreeRowModel] = [] + appendRows( + children, + depth: 0, + collapsedDirectories: collapsedDirectories, + to: &rows + ) + return rows + } + + private static func buildChildren( + _ entries: [URL], + fileManager: FileManager + ) throws -> [FileNode] { + var children: [FileNode] = [] + for entry in entries { + try Task.checkCancellation() + let normalizedEntry = entry.standardizedFileURL + let values = try? entry.resourceValues(forKeys: [.isDirectoryKey]) + if values?.isDirectory == true { + let nestedEntries: [URL] + do { + nestedEntries = try contents(of: normalizedEntry, fileManager: fileManager) + } catch is CancellationError { + throw CancellationError() + } catch { + continue + } + let nestedChildren = try buildChildren(nestedEntries, fileManager: fileManager) + if !nestedChildren.isEmpty { + children.append(FileNode( + url: normalizedEntry, + name: normalizedEntry.lastPathComponent, + isDirectory: true, + children: nestedChildren + )) + } + } else if isBrowsableFile(normalizedEntry) { + children.append(FileNode( + url: normalizedEntry, + name: normalizedEntry.lastPathComponent, + isDirectory: false, + children: nil + )) + } + } + + children.sort { lhs, rhs in + if lhs.isDirectory != rhs.isDirectory { return lhs.isDirectory } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return children + } + + private static func contents( + of directory: URL, + fileManager: FileManager + ) throws -> [URL] { + try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + } + + private static func appendRows( + _ nodes: [FileNode], + depth: Int, + collapsedDirectories: Set, + to rows: inout [FileTreeRowModel] + ) { + for node in nodes { + let isExpanded = node.isDirectory + && !collapsedDirectories.contains(node.url.standardizedFileURL) + rows.append(FileTreeRowModel( + url: node.url, + name: node.name, + depth: depth, + kind: node.isDirectory ? .directory : .file, + isExpanded: isExpanded + )) + if isExpanded, let children = node.children { + appendRows( + children, + depth: depth + 1, + collapsedDirectories: collapsedDirectories, + to: &rows + ) + } + } + } +} diff --git a/Sources/mindle/GitFileMetadata.swift b/Sources/mindle/GitFileMetadata.swift new file mode 100644 index 0000000..3701a87 --- /dev/null +++ b/Sources/mindle/GitFileMetadata.swift @@ -0,0 +1,368 @@ +import Foundation + +struct GitFileChanges: Equatable, Sendable { + let additions: Int? + let deletions: Int? + let isUntracked: Bool + + var badgeText: String { + if isUntracked { return "+new" } + guard let additions, let deletions else { return "±" } + return "+\(additions) −\(deletions)" + } +} + +struct GitFileMetadata: Equatable, Sendable { + var changes: GitFileChanges? = nil + var lastEditedAt: Date? = nil +} + +struct GitMetadataSnapshot: Equatable, Sendable { + let files: [URL: GitFileMetadata] + + static let empty = GitMetadataSnapshot(files: [:]) +} + +struct GitCommandResult: Sendable { + let status: Int32 + let output: Data +} + +typealias GitCommandRunner = @Sendable ([String]) async -> GitCommandResult + +enum GitLastEditedFormatter { + static func badgeText(since date: Date, now: Date = Date()) -> String { + let days = max(0, Int(now.timeIntervalSince(date) / 86_400)) + if days < 7 { return "\(days)d" } + if days < 30 { return "\(days / 7)w" } + if days < 365 { return "\(days / 30)mo" } + return "\(days / 365)y" + } +} + +enum GitMetadataCollector { + static func collect( + for browserRoot: URL, + runner: @escaping GitCommandRunner = liveRunner + ) async -> GitMetadataSnapshot { + guard !Task.isCancelled else { return .empty } + let normalizedRoot = browserRoot.standardizedFileURL + + return await PerformanceTrace.measure("GitMetadataBuild") { + async let changes = workingTreeChanges( + browserRoot: normalizedRoot, + runner: runner + ) + async let timestamps = lastEditedTimestamps( + browserRoot: normalizedRoot, + runner: runner + ) + + let (collectedChanges, collectedTimestamps) = await (changes, timestamps) + guard !Task.isCancelled else { return .empty } + + var metadata: [URL: GitFileMetadata] = [:] + for (relativePath, change) in collectedChanges { + guard let url = fileURL(relativePath, under: normalizedRoot) else { continue } + metadata[url, default: GitFileMetadata()].changes = change + } + for (relativePath, timestamp) in collectedTimestamps { + guard let url = fileURL(relativePath, under: normalizedRoot) else { continue } + metadata[url, default: GitFileMetadata()].lastEditedAt = Date( + timeIntervalSince1970: TimeInterval(timestamp) + ) + } + return GitMetadataSnapshot(files: metadata) + } + } + + static func parseNumstat(_ data: Data) -> [String: GitFileChanges] { + var changes: [String: GitFileChanges] = [:] + for record in nullSeparatedStrings(data) { + let fields = record.split( + separator: "\t", + maxSplits: 2, + omittingEmptySubsequences: false + ) + guard fields.count == 3 else { continue } + changes[String(fields[2])] = GitFileChanges( + additions: Int(fields[0]), + deletions: Int(fields[1]), + isUntracked: false + ) + } + return changes + } + + static func parseUntrackedPaths(_ data: Data) -> [String] { + nullSeparatedStrings(data).compactMap { record in + guard record.hasPrefix("?? ") else { return nil } + return String(record.dropFirst(3)) + } + } + + static func parseLastEdited(_ data: Data) -> [String: Int64] { + var timestamps: [String: Int64] = [:] + var currentTimestamp: Int64? + + for token in nullSeparatedStrings(data, preservingEmpty: true) { + if token.first == "\u{1e}" { + currentTimestamp = Int64(token.dropFirst()) + continue + } + + let path = token.trimmingCharacters(in: .newlines) + guard !path.isEmpty, let currentTimestamp, timestamps[path] == nil else { continue } + timestamps[path] = currentTimestamp + } + return timestamps + } + + private static func workingTreeChanges( + browserRoot: URL, + runner: @escaping GitCommandRunner + ) async -> [String: GitFileChanges] { + guard !Task.isCancelled else { return [:] } + let pathspecs = supportedFilePathspecs + let headDiff = await runner( + [ + "-C", browserRoot.path, + "diff", "--relative", "--numstat", "-z", "--no-renames", "HEAD", "--" + ] + pathspecs + ) + + var changes: [String: GitFileChanges] + if headDiff.status == 0 { + changes = parseNumstat(headDiff.output) + } else { + changes = [:] + let cached = await runner( + [ + "-C", browserRoot.path, + "diff", "--relative", "--numstat", "-z", "--no-renames", "--cached", "--" + ] + pathspecs + ) + merge(parseNumstat(cached.output), into: &changes) + guard !Task.isCancelled else { return changes } + let unstaged = await runner( + [ + "-C", browserRoot.path, + "diff", "--relative", "--numstat", "-z", "--no-renames", "--" + ] + pathspecs + ) + merge(parseNumstat(unstaged.output), into: &changes) + } + + guard !Task.isCancelled else { return changes } + let status = await runner( + [ + "-C", browserRoot.path, + "ls-files", "--others", "--exclude-standard", "-z", "--" + ] + pathspecs + ) + for path in nullSeparatedStrings(status.output) { + changes[path] = GitFileChanges(additions: nil, deletions: nil, isUntracked: true) + } + return changes + } + + private static func lastEditedTimestamps( + browserRoot: URL, + runner: @escaping GitCommandRunner + ) async -> [String: Int64] { + guard !Task.isCancelled else { return [:] } + let result = await runner( + [ + "-C", browserRoot.path, + "log", "--relative", "--format=%x1e%ct%x00", "--name-only", "-z", + "--no-renames", "--diff-filter=AM", "--" + ] + supportedFilePathspecs + ) + guard result.status == 0 else { return [:] } + return parseLastEdited(result.output) + } + + private static func fileURL(_ relativePath: String, under root: URL) -> URL? { + guard !relativePath.isEmpty, !relativePath.hasPrefix("/") else { return nil } + let url = root.appendingPathComponent(relativePath).standardizedFileURL + guard FileTreeBuilder.isDescendant(url, of: root) else { return nil } + return url + } + + private static func merge( + _ incoming: [String: GitFileChanges], + into changes: inout [String: GitFileChanges] + ) { + for (path, value) in incoming { + guard let existing = changes[path], + let existingAdditions = existing.additions, + let existingDeletions = existing.deletions, + let additions = value.additions, + let deletions = value.deletions else { + changes[path] = value + continue + } + changes[path] = GitFileChanges( + additions: existingAdditions + additions, + deletions: existingDeletions + deletions, + isUntracked: false + ) + } + } + + private static let liveRunner: GitCommandRunner = { arguments in + await runGit(arguments) + } + + private static func runGit(_ arguments: [String]) async -> GitCommandResult { + let operation = GitCommandOperation(arguments: arguments) + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + operation.start { continuation.resume(returning: $0) } + } + } onCancel: { + operation.cancel() + } + } + + private static func nullSeparatedStrings( + _ data: Data, + preservingEmpty: Bool = false + ) -> [String] { + data.split(separator: 0, omittingEmptySubsequences: !preservingEmpty).compactMap { + String(data: $0, encoding: .utf8) + } + } + + private static var supportedFilePathspecs: [String] { + FileTreeBuilder.browsableExtensions.sorted().flatMap { + [ + ":(glob,icase)*.\($0)", + ":(glob,icase)**/*.\($0)" + ] + } + } +} + +private final class GitCommandOperation: @unchecked Sendable { + private let process = Process() + private let outputPipe = Pipe() + private let lock = NSLock() + private var cancelRequested = false + private var finished = false + private var collectedOutput = Data() + private var reachedEndOfOutput = false + private var terminationStatus: Int32? + + init(arguments: [String]) { + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.standardOutput = outputPipe.fileHandleForWriting + process.standardError = FileHandle.nullDevice + process.environment = ProcessInfo.processInfo.environment.merging([ + "GIT_OPTIONAL_LOCKS": "0", + "LC_ALL": "C" + ]) { _, new in new } + } + + func start(completion: @escaping @Sendable (GitCommandResult) -> Void) { + outputPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let chunk = handle.availableData + if chunk.isEmpty { + self?.markEndOfOutput(completion: completion) + } else { + self?.appendOutput(chunk) + } + } + process.terminationHandler = { [weak self] process in + self?.markTerminated( + status: process.terminationStatus, + completion: completion + ) + try? self?.outputPipe.fileHandleForWriting.close() + } + do { + try process.run() + } catch { + outputPipe.fileHandleForReading.readabilityHandler = nil + try? outputPipe.fileHandleForWriting.close() + try? outputPipe.fileHandleForReading.close() + finish(GitCommandResult(status: -1, output: Data()), completion: completion) + return + } + + lock.lock() + let shouldCancel = cancelRequested + lock.unlock() + if shouldCancel, process.isRunning { + process.terminate() + } + } + + func cancel() { + lock.lock() + cancelRequested = true + let shouldTerminate = !finished && process.isRunning + lock.unlock() + if shouldTerminate { + process.terminate() + } + } + + private func appendOutput(_ data: Data) { + lock.lock() + collectedOutput.append(data) + lock.unlock() + } + + private func markEndOfOutput( + completion: @escaping @Sendable (GitCommandResult) -> Void + ) { + lock.lock() + reachedEndOfOutput = true + let result = completedResultLocked() + lock.unlock() + completeIfReady(result, completion: completion) + } + + private func markTerminated( + status: Int32, + completion: @escaping @Sendable (GitCommandResult) -> Void + ) { + lock.lock() + terminationStatus = status + let result = completedResultLocked() + lock.unlock() + completeIfReady(result, completion: completion) + } + + private func completedResultLocked() -> GitCommandResult? { + guard !finished, reachedEndOfOutput, let terminationStatus else { return nil } + finished = true + return GitCommandResult(status: terminationStatus, output: collectedOutput) + } + + private func completeIfReady( + _ result: GitCommandResult?, + completion: @escaping @Sendable (GitCommandResult) -> Void + ) { + guard let result else { return } + outputPipe.fileHandleForReading.readabilityHandler = nil + try? outputPipe.fileHandleForReading.close() + completion(result) + } + + private func finish( + _ result: GitCommandResult, + completion: @escaping @Sendable (GitCommandResult) -> Void + ) { + lock.lock() + guard !finished else { + lock.unlock() + return + } + finished = true + lock.unlock() + completion(result) + } +} diff --git a/Sources/mindle/MindleApp.swift b/Sources/mindle/MindleApp.swift index 996b87f..b2313ed 100644 --- a/Sources/mindle/MindleApp.swift +++ b/Sources/mindle/MindleApp.swift @@ -487,7 +487,7 @@ struct MindleCommands: Commands { Button((store?.showFileBrowser ?? false) ? "Hide Files" : "Show Files") { guard let store else { return } store.showFileBrowser.toggle() - if store.showFileBrowser && store.fileTree == nil { + if store.showFileBrowser && store.fileBrowser.tree == nil { store.refreshFileTree() } } diff --git a/Sources/mindle/PerformanceTrace.swift b/Sources/mindle/PerformanceTrace.swift new file mode 100644 index 0000000..f30bf4c --- /dev/null +++ b/Sources/mindle/PerformanceTrace.swift @@ -0,0 +1,36 @@ +import Foundation +import os + +enum PerformanceTrace { + private static let log = OSLog( + subsystem: Bundle.main.bundleIdentifier ?? "com.nonatofabio.mindle", + category: .pointsOfInterest + ) + + static func measure(_ name: StaticString, _ work: () throws -> T) rethrows -> T { + let id = OSSignpostID(log: log) + os_signpost(.begin, log: log, name: name, signpostID: id) + defer { os_signpost(.end, log: log, name: name, signpostID: id) } + return try work() + } + + static func measure( + _ name: StaticString, + _ work: () async throws -> T + ) async rethrows -> T { + let id = OSSignpostID(log: log) + os_signpost(.begin, log: log, name: name, signpostID: id) + defer { os_signpost(.end, log: log, name: name, signpostID: id) } + return try await work() + } + + static func fileTreePublished(rowCount: Int) { + os_signpost( + .event, + log: log, + name: "FileTreePublished", + "%{public}d visible rows", + rowCount + ) + } +} diff --git a/Tests/FileBrowserPerformance.md b/Tests/FileBrowserPerformance.md new file mode 100644 index 0000000..9cb4547 --- /dev/null +++ b/Tests/FileBrowserPerformance.md @@ -0,0 +1,94 @@ +# File browser performance and issue #36 verification + +## Automated red/green evidence + +The focused harness was wired before the implementation files existed. Against the `origin/main` shape, `./run-tests.sh` failed with: + +```text +error: error opening input file 'Sources/mindle/FileTree.swift' +error: error opening input file 'Sources/mindle/FileBrowserState.swift' +error: error opening input file 'Sources/mindle/GitFileMetadata.swift' +error: error opening input file 'Sources/mindle/PerformanceTrace.swift' +``` + +After implementation, the focused suites pass. The row/state checks specifically verify: + +- URL-derived row IDs remain stable across flattening, collapse, expansion, and refresh. +- changing the selected tab/file does not mutate or republish the rows array; +- collapsed directory state survives selection changes and an equal tree refresh; +- an equal refresh does not publish a replacement row collection; +- stale tree and Git metadata generations cannot overwrite the latest root; +- cancelled generations cannot publish rows; +- repeated batched Git collection completes without process-pipe hangs. + +These checks cover the state/identity conditions intended to prevent the LazyVStack regression, but they do not prove SwiftUI preserves the live scroll offset. The manual checklist below remains required. + +## Benchmarks + +Command: + +```bash +scripts/profile-file-browser.sh 1000 5 5 +``` + +Environment: macOS 26.6.1 (25G76), Apple M5 Max, optimized Swift (`swiftc -O`). The deterministic fixture contained 1,000 tracked Markdown documents, 10 modified documents, 10 untracked documents, one text file, hidden content, and an unsupported-only directory. It flattened to 1,231 visible rows. Values are five measured runs from the command above. + +| Metric | Before median | After median | Before runs | After runs | +|---|---:|---:|---|---| +| Main-thread refresh work | 26.482 ms | 0.025 ms | 33.547, 26.079, 32.038, 25.748, 26.482 | 0.140, 0.025, 0.022, 0.019, 0.026 | +| Tree scan to published rows | 26.482 ms | 28.729 ms | 33.547, 26.079, 32.038, 25.748, 26.482 | 28.729, 30.352, 28.501, 28.757, 27.496 | +| Initial 320×600 row render | 78.648 ms | 31.835 ms | 121.621, 78.155, 78.648, 79.159, 78.296 | 33.323, 30.179, 32.333, 31.535, 31.835 | +| Initially realized row bodies | 1,231 rows | 28 rows | 1231, 1231, 1231, 1231, 1231 | 28, 28, 28, 28, 28 | +| Git changes + last-edited, 5 files | 723.214 ms | 45.178 ms | 723.214, 723.633, 724.241, 723.171, 721.502 | 71.250, 47.595, 43.534, 45.178, 44.312 | + +The Git baseline deliberately invokes `diff` and `log` per file: 10 processes per run versus 3 batched processes. Larger per-file samples were attempted but exceeded the five-minute command bound, so they were discarded rather than reported. The final sample keeps the full 1,000-document tree and bounds only the intentionally slow per-file Git comparison. + +Interpretation: + +- total tree work is similar, but it no longer blocks the main actor; +- lazy realization reduced initial row bodies by 97.7% and median render time by 59.5%; +- even at five files, batched Git metadata was 16.0× faster; the process-count gap grows linearly with file count. + +## Manual issue #36 checklist + +Fixture setup: + +```bash +rm -rf .build/issue-36-fixture +scripts/generate-file-browser-fixture.sh 1000 .build/issue-36-fixture +open -n -a "$PWD/build/Mindle.app" \ + "$PWD/.build/issue-36-fixture/section-19/chapter-09/document-0999.md" +``` + +Checklist: + +1. Open the file sidebar and expand enough directories to place `document-0999.md` well below the first viewport. +2. Open at least four files that sort above the active file as tabs. +3. Scroll until the active row is near the vertical center; note the rows immediately above and below it and the scrollbar thumb position. +4. Close inactive tabs above the active selection one at a time. +5. Verify the same neighboring rows and scrollbar position remain visually stable and the active row remains visible. +6. Repeat while crossing the two-tabs-to-one-tab boundary, which removes the tab bar. +7. Repeat after collapsing and re-expanding a directory above the active row. +8. Repeat with keyboard `⌘W` and with tab close buttons. + +### Manual status + +Not manually verified. The final built app was launched against the 1,000-document fixture and remained running after four additional file-open events were sent. The accessibility probe needed to operate the sidebar failed before interaction: + +```text +Not authorized to send Apple events to System Events. (-1743) +``` + +Because the sidebar could not be scrolled or its tab close controls driven, no claim is made about visual scroll stability. Run the checklist above in an interactive macOS session with Accessibility/Automation permission before merging. + +## LazyVStack fallback if #36 is unstable + +`FileBrowserRowStack` is the adapter seam. If the checklist reveals movement: + +1. add an internal `FileBrowserRowRealizationPolicy` with `.lazy` and `.eagerStable`; +2. keep the flat `FileTreeRowModel`, external collapse state, Equatable rows, background scanning, batched Git work, and generation guards unchanged; +3. switch only the adapter from `LazyVStack` to `VStack` for `.eagerStable`; +4. preserve a debug/UserDefaults override long enough to compare both modes on affected systems; +5. if eager fallback is too costly for very large trees, replace the adapter with an AppKit `NSCollectionView` wrapper that virtualizes rows while explicitly restoring `NSScrollView.contentView.bounds.origin` after selection/tab updates. + +The first fallback is intentionally narrow and low-risk. The AppKit adapter is the longer-term option if SwiftUI lazy layout cannot provide stable scroll identity on macOS 14. diff --git a/Tests/harness/FileBrowserStateChecks.swift b/Tests/harness/FileBrowserStateChecks.swift new file mode 100644 index 0000000..cc4c0ca --- /dev/null +++ b/Tests/harness/FileBrowserStateChecks.swift @@ -0,0 +1,157 @@ +import Combine +import Foundation + +@MainActor +func runFileBrowserStateChecks() async -> Int { + let checks = Checks("FileBrowserState") + let fileManager = FileManager.default + let root = URL(fileURLWithPath: ".build/test-fixtures/browser-state", isDirectory: true) + .standardizedFileURL + + do { + try? fileManager.removeItem(at: root) + let chapter = root.appendingPathComponent("Chapter", isDirectory: true) + try fileManager.createDirectory(at: chapter, withIntermediateDirectories: true) + let readme = root.appendingPathComponent("README.md") + let nested = chapter.appendingPathComponent("notes.txt") + try "# Read me".write(to: readme, atomically: true, encoding: .utf8) + try "Notes".write(to: nested, atomically: true, encoding: .utf8) + + let browser = FileBrowserState(metadataBuilder: { _ in .empty }) + var rowPublications = 0 + let rowSubscription = browser.$rows.sink { _ in rowPublications += 1 } + defer { rowSubscription.cancel() } + + browser.setRoot(root) + checks.expect(await waitUntil { !browser.isLoading }, "local tree load completes") + checks.equal(browser.rootURL, root, "root normalized") + checks.equal( + browser.rows.map(\.name), + ["Chapter", "notes.txt", "README.md"], + "local rows published" + ) + + let stableRows = browser.rows + let publicationsAfterLoad = rowPublications + browser.setSelectedURL(readme.appendingPathComponent("..").appendingPathComponent("README.md")) + browser.setSelectedURL(nested) + browser.setSelectedURL(readme) + checks.equal(browser.selectedURL, readme, "selection normalized") + checks.equal(browser.rows, stableRows, "selection changes do not mutate row identity or state") + checks.equal( + rowPublications, + publicationsAfterLoad, + "selection changes do not republish rows" + ) + + browser.toggleDirectory(chapter) + checks.equal(browser.rows.map(\.name), ["Chapter", "README.md"], "directory collapses") + checks.expect(!browser.rows[0].isExpanded, "collapsed row state published") + let collapsedRows = browser.rows + browser.setSelectedURL(readme) + checks.equal(browser.rows, collapsedRows, "selection preserves collapsed state") + + let publicationsBeforeRefresh = rowPublications + browser.refresh() + checks.expect(await waitUntil { !browser.isLoading }, "refresh completes") + checks.equal(browser.rows, collapsedRows, "refresh preserves externalized expansion state") + checks.equal( + rowPublications, + publicationsBeforeRefresh, + "equal refresh result does not republish rows" + ) + + browser.toggleDirectory(chapter) + checks.equal(browser.rows.map(\.id), stableRows.map(\.id), "re-expansion restores stable row identities") + } catch { + checks.expect(false, "fixture setup failed: \(error)") + } + + let slowRoot = URL(fileURLWithPath: ".build/test-fixtures/slow").standardizedFileURL + let fastRoot = URL(fileURLWithPath: ".build/test-fixtures/fast").standardizedFileURL + let staleMetadataURL = slowRoot.appendingPathComponent("stale.md") + let currentMetadataURL = fastRoot.appendingPathComponent("current.md") + let generationBrowser = FileBrowserState( + treeBuilder: { url in + if url == slowRoot { + Thread.sleep(forTimeInterval: 0.18) + } + return syntheticTree(root: url, filename: url == slowRoot ? "stale.md" : "current.md") + }, + metadataBuilder: { url in + if url == slowRoot { + try? await Task.sleep(nanoseconds: 220_000_000) + return GitMetadataSnapshot(files: [ + staleMetadataURL: GitFileMetadata( + changes: GitFileChanges(additions: 9, deletions: 9, isUntracked: false) + ) + ]) + } + return GitMetadataSnapshot(files: [ + currentMetadataURL: GitFileMetadata( + changes: GitFileChanges(additions: 1, deletions: 0, isUntracked: false) + ) + ]) + } + ) + generationBrowser.setRoot(slowRoot) + generationBrowser.setRoot(fastRoot) + checks.expect( + await waitUntil { + !generationBrowser.isLoading + && generationBrowser.rows.map(\.name) == ["current.md"] + && generationBrowser.gitMetadata.files[currentMetadataURL] != nil + }, + "new generation publishes tree and metadata" + ) + try? await Task.sleep(nanoseconds: 300_000_000) + checks.equal(generationBrowser.rootURL, fastRoot, "latest root remains active") + checks.equal(generationBrowser.rows.map(\.name), ["current.md"], "stale tree result is rejected") + checks.expect( + generationBrowser.gitMetadata.files[staleMetadataURL] == nil, + "stale metadata result is rejected" + ) + + generationBrowser.setRoot(slowRoot) + generationBrowser.cancelAll() + try? await Task.sleep(nanoseconds: 250_000_000) + checks.expect(!generationBrowser.isLoading, "cancel clears loading state") + checks.equal(generationBrowser.rows, [], "cancelled generation cannot publish rows") + + generationBrowser.setRoot(nil) + checks.equal(generationBrowser.rootURL, nil, "nil root clears root") + checks.equal(generationBrowser.rows, [], "nil root clears rows") + + try? fileManager.removeItem(at: root) + print("FileBrowserState: \(checks.passed) passed, \(checks.failures) failed") + return checks.failures +} + +private func syntheticTree(root: URL, filename: String) -> FileNode { + let file = root.appendingPathComponent(filename).standardizedFileURL + return FileNode( + url: root, + name: root.lastPathComponent, + isDirectory: true, + children: [ + FileNode( + url: file, + name: filename, + isDirectory: false, + children: nil + ) + ] + ) +} + +@MainActor +private func waitUntil( + attempts: Int = 300, + condition: () -> Bool +) async -> Bool { + for _ in 0.. Int { + let checks = Checks("FileTree") + let fileManager = FileManager.default + let root = URL(fileURLWithPath: ".build/test-fixtures/file-tree", isDirectory: true) + .standardizedFileURL + + do { + try? fileManager.removeItem(at: root) + let alpha = root.appendingPathComponent("Alpha", isDirectory: true) + let nested = alpha.appendingPathComponent("Nested", isDirectory: true) + let empty = root.appendingPathComponent("Empty", isDirectory: true) + let hidden = root.appendingPathComponent(".Hidden", isDirectory: true) + try fileManager.createDirectory(at: nested, withIntermediateDirectories: true) + try fileManager.createDirectory(at: empty, withIntermediateDirectories: true) + try fileManager.createDirectory(at: hidden, withIntermediateDirectories: true) + + try "# A".write(to: alpha.appendingPathComponent("a.md"), atomically: true, encoding: .utf8) + try "# B".write(to: nested.appendingPathComponent("b.markdown"), atomically: true, encoding: .utf8) + try "text".write(to: root.appendingPathComponent("Beta.txt"), atomically: true, encoding: .utf8) + try "pdf".write(to: root.appendingPathComponent("paper.PDF"), atomically: true, encoding: .utf8) + try "ignored".write(to: empty.appendingPathComponent("ignored.json"), atomically: true, encoding: .utf8) + try "# Hidden".write(to: hidden.appendingPathComponent("hidden.md"), atomically: true, encoding: .utf8) + try "# MDX".write(to: root.appendingPathComponent("unsupported.mdx"), atomically: true, encoding: .utf8) + + let tree = try FileTreeBuilder.build(at: root) + let rootNames = tree.children?.map(\.name) ?? [] + checks.equal(rootNames, ["Alpha", "Beta.txt", "paper.PDF"], "folders sort before files") + checks.expect(!rootNames.contains("Empty"), "unsupported-only directory is pruned") + checks.expect(!rootNames.contains(".Hidden"), "hidden directory is skipped") + checks.expect(!rootNames.contains("unsupported.mdx"), "unsupported extension is skipped") + + let rows = FileTreeBuilder.visibleRows(in: tree, collapsedDirectories: []) + checks.equal( + rows.map(\.name), + ["Alpha", "Nested", "b.markdown", "a.md", "Beta.txt", "paper.PDF"], + "tree flattens in display order" + ) + checks.equal(rows.map(\.depth), [0, 1, 2, 1, 0, 0], "row depths") + checks.equal(Set(rows.map(\.id)).count, rows.count, "row identities are unique") + + let idsByURL = Dictionary(uniqueKeysWithValues: rows.map { ($0.url, $0.id) }) + let repeatedRows = FileTreeBuilder.visibleRows(in: tree, collapsedDirectories: []) + checks.equal(repeatedRows.map(\.id), rows.map(\.id), "flattening preserves row identities") + checks.expect( + repeatedRows.allSatisfy { idsByURL[$0.url] == $0.id }, + "identity is derived from normalized file URL" + ) + + let collapsedRows = FileTreeBuilder.visibleRows( + in: tree, + collapsedDirectories: [alpha.standardizedFileURL] + ) + checks.equal( + collapsedRows.map(\.name), + ["Alpha", "Beta.txt", "paper.PDF"], + "collapsed directory hides descendants" + ) + checks.equal( + collapsedRows.map(\.id), + [rows[0].id, rows[4].id, rows[5].id], + "collapsing preserves unaffected row identities" + ) + checks.expect(!collapsedRows[0].isExpanded, "collapsed state is carried by row model") + + checks.expect( + FileTreeBuilder.isDescendant(nested.appendingPathComponent("b.markdown"), of: root), + "nested file is inside root" + ) + checks.expect( + !FileTreeBuilder.isDescendant( + URL(fileURLWithPath: root.path + "-other/note.md"), + of: root + ), + "path-prefix sibling is outside root" + ) + checks.expect(FileTreeBuilder.isDescendant(root, of: root), "root is its own descendant") + checks.expect(FileTreeBuilder.isBrowsableFile(URL(fileURLWithPath: "note.mkd")), "mkd is supported") + checks.expect(!FileTreeBuilder.isBrowsableFile(URL(fileURLWithPath: "note.mdx")), "mdx is unsupported") + } catch { + checks.expect(false, "fixture setup/build failed: \(error)") + } + + try? fileManager.removeItem(at: root) + print("FileTree: \(checks.passed) passed, \(checks.failures) failed") + return checks.failures +} diff --git a/Tests/harness/GitFileMetadataChecks.swift b/Tests/harness/GitFileMetadataChecks.swift new file mode 100644 index 0000000..79d5c2f --- /dev/null +++ b/Tests/harness/GitFileMetadataChecks.swift @@ -0,0 +1,165 @@ +import Foundation + +func runGitFileMetadataChecks() async -> Int { + let checks = Checks("GitFileMetadata") + + let numstat = Data("12\t3\tdocs/a.md\u{0}-\t-\tdocs/paper.pdf\u{0}".utf8) + let parsedChanges = GitMetadataCollector.parseNumstat(numstat) + checks.equal(parsedChanges["docs/a.md"]?.additions, 12, "numstat additions") + checks.equal(parsedChanges["docs/a.md"]?.deletions, 3, "numstat deletions") + checks.equal(parsedChanges["docs/a.md"]?.badgeText, "+12 −3", "change badge") + checks.equal(parsedChanges["docs/paper.pdf"]?.badgeText, "±", "binary badge") + + let status = Data("?? docs/new.md\u{0} M docs/existing.md\u{0}".utf8) + checks.equal( + GitMetadataCollector.parseUntrackedPaths(status), + ["docs/new.md"], + "untracked status parsing" + ) + + let historyText = + "\u{1e}1721000000\u{0}\u{0}\ndocs/a.md\u{0}docs/b.md\u{0}" + + "\u{1e}1710000000\u{0}\u{0}\ndocs/a.md\u{0}docs/c.md\u{0}" + let parsedHistory = GitMetadataCollector.parseLastEdited(Data(historyText.utf8)) + checks.equal(parsedHistory["docs/a.md"], 1_721_000_000, "latest file commit wins") + checks.equal(parsedHistory["docs/c.md"], 1_710_000_000, "older file commit retained") + + let now = Date(timeIntervalSince1970: 2_000_000) + checks.equal( + GitLastEditedFormatter.badgeText(since: now.addingTimeInterval(-8 * 86_400), now: now), + "1w", + "week badge" + ) + checks.equal( + GitLastEditedFormatter.badgeText(since: now.addingTimeInterval(-65 * 86_400), now: now), + "2mo", + "month badge" + ) + checks.equal( + GitLastEditedFormatter.badgeText(since: now.addingTimeInterval(86_400), now: now), + "0d", + "future timestamps clamp to zero" + ) + + let fakeRoot = URL(fileURLWithPath: ".build/test-fixtures/git-stub", isDirectory: true) + .standardizedFileURL + let runner = StubGitRunner() + let stubSnapshot = await GitMetadataCollector.collect( + for: fakeRoot, + runner: { arguments in await runner.run(arguments) } + ) + let commands = await runner.commands + checks.equal(commands.count, 3, "metadata is collected with three batched Git processes") + checks.expect( + commands.allSatisfy { !$0.contains(where: { $0.hasSuffix("a.md") }) }, + "collector never invokes Git per file" + ) + checks.equal( + stubSnapshot.files[fakeRoot.appendingPathComponent("docs/a.md")]?.changes?.additions, + 2, + "batched diff maps paths under browser root" + ) + checks.expect( + stubSnapshot.files[fakeRoot.appendingPathComponent("docs/new.md")]?.changes?.isUntracked == true, + "batched status maps untracked paths" + ) + checks.equal( + Int( + stubSnapshot.files[fakeRoot.appendingPathComponent("docs/a.md")]? + .lastEditedAt?.timeIntervalSince1970 ?? 0 + ), + 1_721_000_000, + "batched history maps timestamps" + ) + + let fileManager = FileManager.default + let repositoryRoot = URL(fileURLWithPath: ".build/test-fixtures/git-metadata", isDirectory: true) + .standardizedFileURL + let root = repositoryRoot.appendingPathComponent("docs", isDirectory: true) + do { + try? fileManager.removeItem(at: repositoryRoot) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + try runGit(["init", "-q"], at: repositoryRoot) + try runGit(["config", "user.name", "Mindle Tests"], at: repositoryRoot) + try runGit(["config", "user.email", "tests@mindle.local"], at: repositoryRoot) + let tracked = root.appendingPathComponent("tracked.md") + try "one\n".write(to: tracked, atomically: true, encoding: .utf8) + try runGit(["add", "docs/tracked.md"], at: repositoryRoot) + try runGit( + ["commit", "-q", "-m", "fixture"], + at: repositoryRoot, + environment: [ + "GIT_AUTHOR_DATE": "2024-01-02T03:04:05Z", + "GIT_COMMITTER_DATE": "2024-01-02T03:04:05Z" + ] + ) + try "one\ntwo\n".write(to: tracked, atomically: true, encoding: .utf8) + let untracked = root.appendingPathComponent("new.txt") + try "new\n".write(to: untracked, atomically: true, encoding: .utf8) + + var snapshot = GitMetadataSnapshot.empty + for _ in 0..<5 { + snapshot = await GitMetadataCollector.collect(for: root) + } + checks.equal( + snapshot.files[tracked]?.changes?.additions, + 1, + "working tree changes collected repeatedly without process hangs" + ) + checks.expect( + snapshot.files[untracked]?.changes?.isUntracked == true, + "untracked file collected" + ) + checks.equal( + Int(snapshot.files[tracked]?.lastEditedAt?.timeIntervalSince1970 ?? 0), + 1_704_164_645, + "last commit timestamp collected" + ) + } catch { + checks.expect(false, "Git integration fixture failed: \(error)") + } + try? fileManager.removeItem(at: repositoryRoot) + + print("GitFileMetadata: \(checks.passed) passed, \(checks.failures) failed") + return checks.failures +} + +private actor StubGitRunner { + private(set) var commands: [[String]] = [] + + func run(_ arguments: [String]) -> GitCommandResult { + commands.append(arguments) + if arguments.contains("--numstat") { + return GitCommandResult(status: 0, output: Data("2\t1\tdocs/a.md\u{0}".utf8)) + } + if arguments.contains("ls-files") { + return GitCommandResult(status: 0, output: Data("docs/new.md\u{0}".utf8)) + } + return GitCommandResult( + status: 0, + output: Data("\u{1e}1721000000\u{0}\u{0}\ndocs/a.md\u{0}".utf8) + ) + } +} + +private func runGit( + _ arguments: [String], + at directory: URL, + environment: [String: String] = [:] +) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["-C", directory.path] + arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new } + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw NSError( + domain: "GitFileMetadataChecks", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: "git \(arguments.joined(separator: " ")) failed"] + ) + } +} diff --git a/Tests/harness/main.swift b/Tests/harness/main.swift index 0192d27..730055e 100644 --- a/Tests/harness/main.swift +++ b/Tests/harness/main.swift @@ -1,8 +1,11 @@ import Foundation var failures = 0 +failures += runFileTreeChecks() +failures += await runGitFileMetadataChecks() failures += runSSHTargetChecks() failures += await runSSHTransportChecks() +failures += await runFileBrowserStateChecks() if failures > 0 { print("\nFAILED: \(failures) check(s)") diff --git a/Tests/performance/main.swift b/Tests/performance/main.swift new file mode 100644 index 0000000..c908a5d --- /dev/null +++ b/Tests/performance/main.swift @@ -0,0 +1,306 @@ +import AppKit +import Darwin +import Foundation +import SwiftUI + +private struct BenchmarkResult { + let name: String + let unit: String + let before: [Double] + let after: [Double] +} + +private final class RealizationCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func increment() { + lock.lock() + count += 1 + lock.unlock() + } + + func value() -> Int { + lock.lock() + defer { lock.unlock() } + return count + } +} + +private struct CountingRow: View { + let row: FileTreeRowModel + let counter: RealizationCounter + + var body: some View { + let _ = counter.increment() + Text(row.name) + .frame(maxWidth: .infinity, minHeight: 22, alignment: .leading) + } +} + +private struct BenchmarkRowList: View { + let rows: [FileTreeRowModel] + let lazy: Bool + let counter: RealizationCounter + + @ViewBuilder + var body: some View { + ScrollView { + if lazy { + LazyVStack(alignment: .leading, spacing: 0) { + rowContent + } + } else { + VStack(alignment: .leading, spacing: 0) { + rowContent + } + } + } + .frame(width: 320, height: 600) + } + + private var rowContent: some View { + ForEach(rows) { row in + CountingRow(row: row, counter: counter) + } + } +} + +guard CommandLine.arguments.count >= 2 else { + fputs("usage: file-browser-benchmark FIXTURE [RUNS] [GIT_FILES]\n", stderr) + exit(2) +} + +let root = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true).standardizedFileURL +let runs = Int(CommandLine.arguments.dropFirst(2).first ?? "5") ?? 5 +let gitFileLimit = Int(CommandLine.arguments.dropFirst(3).first ?? "5") ?? 5 + +let warmTree = try FileTreeBuilder.build(at: root) +let warmRows = FileTreeBuilder.visibleRows(in: warmTree, collapsedDirectories: []) +_ = legacyBuildTree(at: root) +fputs("Benchmarking batched Git warm-up…\n", stderr) +_ = await GitMetadataCollector.collect(for: root) + +fputs("Benchmarking tree scans…\n", stderr) +var legacyTreeTimes: [Double] = [] +var dispatchTimes: [Double] = [] +var workerTimes: [Double] = [] +for _ in 0.. FileNode { + let fileManager = FileManager.default + let entries = (try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + + var children: [FileNode] = [] + for entry in entries { + let isDirectory = (try? entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + if isDirectory { + let nested = legacyBuildTree(at: entry) + if !(nested.children ?? []).isEmpty { + children.append(nested) + } + } else if FileTreeBuilder.isBrowsableFile(entry) { + children.append(FileNode( + url: entry.standardizedFileURL, + name: entry.lastPathComponent, + isDirectory: false, + children: nil + )) + } + } + + children.sort { lhs, rhs in + if lhs.isDirectory != rhs.isDirectory { return lhs.isDirectory } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return FileNode( + url: directory.standardizedFileURL, + name: directory.lastPathComponent, + isDirectory: true, + children: children + ) +} + +private func legacyCollectGitMetadata(root: URL, files: [URL]) { + for file in files { + let relativePath = String(file.path.dropFirst(root.path.count + 1)) + runGit(["-C", root.path, "diff", "--numstat", "HEAD", "--", relativePath]) + runGit(["-C", root.path, "log", "-1", "--format=%ct", "--", relativePath]) + } +} + +private func runGit(_ arguments: [String]) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + process.environment = ProcessInfo.processInfo.environment.merging([ + "GIT_OPTIONAL_LOCKS": "0", + "LC_ALL": "C" + ]) { _, new in new } + do { + try process.run() + process.waitUntilExit() + } catch { + return + } +} + +@MainActor +private func render( + rows: [FileTreeRowModel], + lazy: Bool +) -> (milliseconds: Double, realizedRows: Int) { + let counter = RealizationCounter() + let start = ContinuousClock.now + let hostingView = NSHostingView( + rootView: BenchmarkRowList(rows: rows, lazy: lazy, counter: counter) + ) + hostingView.frame = NSRect(x: 0, y: 0, width: 320, height: 600) + hostingView.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.02)) + return (start.duration(to: .now).benchmarkMilliseconds, counter.value()) +} + +@MainActor +private func waitUntilLoaded(_ browser: FileBrowserState) async { + while browser.isLoading { + try? await Task.sleep(nanoseconds: 1_000_000) + } +} + +private extension Duration { + var benchmarkMilliseconds: Double { + Double(components.seconds) * 1_000 + + Double(components.attoseconds) / 1_000_000_000_000_000 + } +} + +private func median(_ values: [Double]) -> Double { + let sorted = values.sorted() + let midpoint = sorted.count / 2 + if sorted.count.isMultiple(of: 2) { + return (sorted[midpoint - 1] + sorted[midpoint]) / 2 + } + return sorted[midpoint] +} + +private func format(_ value: Double) -> String { + String(format: "%.3f", value) +} + +private func formatRuns(_ values: [Double]) -> String { + values.map(format).joined(separator: ", ") +} + +private func machineDescription() -> String { + var size = 0 + sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0) + var bytes = [CChar](repeating: 0, count: size) + sysctlbyname("machdep.cpu.brand_string", &bytes, &size, nil, 0) + return String(cString: bytes) +} diff --git a/run-tests.sh b/run-tests.sh index 0bb472e..2f15fde 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -7,9 +7,17 @@ set -euo pipefail cd "$(dirname "$0")" mkdir -p .build swiftc -O \ + -framework Combine \ + Sources/mindle/FileTree.swift \ + Sources/mindle/FileBrowserState.swift \ + Sources/mindle/GitFileMetadata.swift \ + Sources/mindle/PerformanceTrace.swift \ Sources/mindle/SSHTarget.swift \ Sources/mindle/SSHTransport.swift \ Tests/harness/TestHarness.swift \ + Tests/harness/FileTreeChecks.swift \ + Tests/harness/FileBrowserStateChecks.swift \ + Tests/harness/GitFileMetadataChecks.swift \ Tests/harness/SSHTargetChecks.swift \ Tests/harness/SSHTransportChecks.swift \ Tests/harness/main.swift \ diff --git a/scripts/generate-file-browser-fixture.sh b/scripts/generate-file-browser-fixture.sh new file mode 100755 index 0000000..52d6d40 --- /dev/null +++ b/scripts/generate-file-browser-fixture.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +COUNT="${1:-1000}" +ROOT="${2:-$ROOT_DIR/.build/file-browser-fixture-$COUNT}" + +if ! [[ "$COUNT" =~ ^[1-9][0-9]*$ ]]; then + echo "count must be a positive integer" >&2 + exit 2 +fi + +if [ -e "$ROOT" ] && [ -n "$(find "$ROOT" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; then + echo "target directory must be empty: $ROOT" >&2 + exit 2 +fi + +mkdir -p "$ROOT" + +for ((i = 0; i < COUNT; i++)); do + section=$((i % 20)) + chapter=$(((i / 20) % 10)) + dir="$ROOT/section-$(printf '%02d' "$section")/chapter-$(printf '%02d' "$chapter")" + mkdir -p "$dir" + printf '# Document %04d\n\nDeterministic Mindle file-browser benchmark fixture.\n' "$i" \ + > "$dir/document-$(printf '%04d' "$i").md" +done + +mkdir -p "$ROOT/.hidden" "$ROOT/unsupported-only" +printf '# Hidden\n' > "$ROOT/.hidden/hidden.md" +printf 'unsupported\n' > "$ROOT/unsupported-only/ignored.json" +printf 'plain text fixture\n' > "$ROOT/readme.txt" + +git -C "$ROOT" init -q +git -C "$ROOT" config user.name "Mindle Benchmark" +git -C "$ROOT" config user.email "benchmark@mindle.local" +git -C "$ROOT" add . +GIT_AUTHOR_DATE="2024-01-02T03:04:05Z" \ +GIT_COMMITTER_DATE="2024-01-02T03:04:05Z" \ + git -C "$ROOT" commit -qm "fixture" + +for ((i = 0; i < 10 && i < COUNT; i++)); do + section=$((i % 20)) + chapter=$(((i / 20) % 10)) + file="$ROOT/section-$(printf '%02d' "$section")/chapter-$(printf '%02d' "$chapter")/document-$(printf '%04d' "$i").md" + printf '\nWorking tree change %04d.\n' "$i" >> "$file" +done +for ((i = 0; i < 10; i++)); do + printf '# Untracked %02d\n' "$i" > "$ROOT/untracked-$(printf '%02d' "$i").md" +done + +printf '%s\n' "$ROOT" diff --git a/scripts/profile-file-browser.sh b/scripts/profile-file-browser.sh new file mode 100755 index 0000000..0d1392f --- /dev/null +++ b/scripts/profile-file-browser.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +COUNT="${1:-1000}" +RUNS="${2:-5}" +GIT_FILES="${3:-5}" +FIXTURE="$ROOT_DIR/.build/file-browser-benchmark-fixture-$COUNT" +BENCHMARK="$ROOT_DIR/.build/file-browser-benchmark" + +if ! [[ "$RUNS" =~ ^[0-9]+$ ]] || ((RUNS < 3)); then + echo "runs must be an integer of at least 3" >&2 + exit 2 +fi +if ! [[ "$GIT_FILES" =~ ^[1-9][0-9]*$ ]]; then + echo "Git file count must be a positive integer" >&2 + exit 2 +fi + +rm -rf "$FIXTURE" +mkdir -p "$ROOT_DIR/.build" +"$SCRIPT_DIR/generate-file-browser-fixture.sh" "$COUNT" "$FIXTURE" >/dev/null + +cd "$ROOT_DIR" +swiftc -O \ + -framework SwiftUI \ + -framework AppKit \ + -framework Combine \ + Sources/mindle/FileTree.swift \ + Sources/mindle/FileBrowserState.swift \ + Sources/mindle/GitFileMetadata.swift \ + Sources/mindle/PerformanceTrace.swift \ + Tests/performance/main.swift \ + -o "$BENCHMARK" + +"$BENCHMARK" "$FIXTURE" "$RUNS" "$GIT_FILES" From cfdce0f7574029358a25341af5c5bc9dba4c2423 Mon Sep 17 00:00:00 2001 From: matchaboar <224671045+matchaboar@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:23:37 -0700 Subject: [PATCH 2/3] test: automate file browser scroll stability --- .github/workflows/performance-tests.yml | 28 ++ Tests/FileBrowserPerformance.md | 34 ++- .../ui/FileBrowserScrollStabilityTests.swift | 245 ++++++++++++++++++ run-ui-tests.sh | 19 ++ 4 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/performance-tests.yml create mode 100644 Tests/ui/FileBrowserScrollStabilityTests.swift create mode 100755 run-ui-tests.sh diff --git a/.github/workflows/performance-tests.yml b/.github/workflows/performance-tests.yml new file mode 100644 index 0000000..f3899aa --- /dev/null +++ b/.github/workflows/performance-tests.yml @@ -0,0 +1,28 @@ +name: File Browser Performance Tests + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + performance-tests: + runs-on: macos-26 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.2.app || sudo xcode-select -s /Applications/Xcode.app + + - name: Run file browser tests + run: | + chmod +x run-tests.sh run-ui-tests.sh + ./run-tests.sh + ./run-ui-tests.sh diff --git a/Tests/FileBrowserPerformance.md b/Tests/FileBrowserPerformance.md index 9cb4547..c3763af 100644 --- a/Tests/FileBrowserPerformance.md +++ b/Tests/FileBrowserPerformance.md @@ -21,7 +21,28 @@ After implementation, the focused suites pass. The row/state checks specifically - cancelled generations cannot publish rows; - repeated batched Git collection completes without process-pipe hangs. -These checks cover the state/identity conditions intended to prevent the LazyVStack regression, but they do not prove SwiftUI preserves the live scroll offset. The manual checklist below remains required. +`./run-ui-tests.sh` additionally mounts the production `FileBrowserSidebar` in +an `NSHostingView`, scrolls its actual `NSScrollView` to 18 positions through a +1,200-row tree, and measures the native clip-view offset through tab-count, +selection, and collapse/re-expand transitions. It requires no Accessibility or +Automation permission. + +The test's controlled red mutation assigned a fresh identity to the production +scroll view on every render. The harness failed with: + +```text +✗ Flattened sidebar moved by 23295.150 pt (tolerance 0.500 pt). +``` + +After removing that mutation, the production implementation passes: + +```text +✓ Issue #36: 198 tab/selection transitions held within 0.250 pt +``` + +The dedicated `performance-tests.yml` workflow runs both logic and UI harnesses +in a `contents: read` macOS job with checkout credentials disabled. This tests +the actual AppKit/SwiftUI runtime; a Linux container cannot execute AppKit. ## Benchmarks @@ -71,15 +92,20 @@ Checklist: 7. Repeat after collapsing and re-expanding a directory above the active row. 8. Repeat with keyboard `⌘W` and with tab close buttons. -### Manual status +### Automation status -Not manually verified. The final built app was launched against the 1,000-document fixture and remained running after four additional file-open events were sent. The accessibility probe needed to operate the sidebar failed before interaction: +External System Events automation remains unavailable because the host denied +Apple Events: ```text Not authorized to send Apple events to System Events. (-1743) ``` -Because the sidebar could not be scrolled or its tab close controls driven, no claim is made about visual scroll stability. Run the checklist above in an interactive macOS session with Accessibility/Automation permission before merging. +TCC accessibility grants are user-controlled and cannot be safely self-issued +by a test process. The in-process harness avoids that boundary: it drives the +same SwiftUI state transitions and reads the underlying `NSScrollView` directly. +The checklist remains useful as a human acceptance pass, but the scroll-offset +regression is now automated red/green coverage rather than an untested claim. ## LazyVStack fallback if #36 is unstable diff --git a/Tests/ui/FileBrowserScrollStabilityTests.swift b/Tests/ui/FileBrowserScrollStabilityTests.swift new file mode 100644 index 0000000..6286125 --- /dev/null +++ b/Tests/ui/FileBrowserScrollStabilityTests.swift @@ -0,0 +1,245 @@ +import AppKit +import SwiftUI + +enum ReaderTheme: String, CaseIterable, Codable { + case light + case sepia + case dark +} + +@MainActor +private final class Issue36HarnessState: ObservableObject { + @Published var tabCount = 5 +} + +private struct Issue36HarnessView: View { + @ObservedObject var state: Issue36HarnessState + let browser: FileBrowserState + + var body: some View { + VStack(spacing: 0) { + if state.tabCount >= 2 { + HStack { + Text("Tabs") + Spacer() + } + .padding(.horizontal, 10) + .frame(height: 31) + } + + FileBrowserSidebar( + browser: browser, + theme: .light, + onRefresh: {}, + onOpen: { _ in } + ) + } + .frame(width: 320, height: 600) + } +} + +@main +@MainActor +struct FileBrowserScrollStabilityTests { + private static let tolerance: CGFloat = 0.5 + + static func main() async { + _ = NSApplication.shared + let fixture = makeFixture() + + do { + let result = try await measure(fixture: fixture) + guard result.maxDelta <= tolerance else { + fail( + "Flattened sidebar moved by \(format(result.maxDelta)) pt " + + "(tolerance \(format(tolerance)) pt)." + ) + } + + print( + "✓ Issue #36: \(result.measurementCount) tab/selection transitions " + + "held within \(format(result.maxDelta)) pt" + ) + } catch { + fail("Issue #36 harness failed: \(error)") + } + } + + private static func measure( + fixture: (root: URL, tree: FileNode, files: [URL]) + ) async throws -> (measurementCount: Int, maxDelta: CGFloat) { + let browser = FileBrowserState( + treeBuilder: { _ in fixture.tree }, + metadataBuilder: { _ in .empty } + ) + browser.setRoot(fixture.root) + while browser.isLoading { + try await Task.sleep(for: .milliseconds(1)) + } + + let state = Issue36HarnessState() + let activeURL = fixture.files[fixture.files.count * 3 / 4] + browser.setSelectedURL(activeURL) + + let hostingView = NSHostingView( + rootView: Issue36HarnessView( + state: state, + browser: browser + ) + ) + hostingView.frame = NSRect(x: 0, y: 0, width: 320, height: 600) + let window = NSWindow( + contentRect: hostingView.frame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentView = hostingView + window.orderFrontRegardless() + settle(hostingView, window: window) + + guard let initialScrollView = firstScrollView(in: hostingView), + let documentView = initialScrollView.documentView else { + throw HarnessError.scrollViewMissing + } + let maximumOffset = max( + 0, + documentView.bounds.height - initialScrollView.contentView.bounds.height + ) + guard maximumOffset > 1_000 else { + throw HarnessError.contentTooShort(maximumOffset) + } + var measurementCount = 0 + var maxDelta: CGFloat = 0 + for fraction in stride(from: 0.05, through: 0.90, by: 0.05) { + state.tabCount = 5 + browser.setSelectedURL(activeURL) + settle(hostingView, window: window) + + guard let scrollView = firstScrollView(in: hostingView) else { + throw HarnessError.scrollViewMissing + } + let targetOffset = maximumOffset * fraction + scrollView.contentView.scroll(to: NSPoint(x: 0, y: targetOffset)) + scrollView.reflectScrolledClipView(scrollView.contentView) + settle(hostingView, window: window) + let baseline = scrollView.contentView.bounds.origin.y + for tabCount in [4, 3, 2, 1, 2, 5] { + state.tabCount = tabCount + settle(hostingView, window: window) + guard let scrollView = firstScrollView(in: hostingView) else { + throw HarnessError.scrollViewMissing + } + let offset = scrollView.contentView.bounds.origin.y + maxDelta = max(maxDelta, abs(offset - baseline)) + measurementCount += 1 + } + + for selectedURL in [ + fixture.files[fixture.files.count / 4], + activeURL, + fixture.files[fixture.files.count / 2], + activeURL + ] { + browser.setSelectedURL(selectedURL) + settle(hostingView, window: window) + guard let scrollView = firstScrollView(in: hostingView) else { + throw HarnessError.scrollViewMissing + } + let offset = scrollView.contentView.bounds.origin.y + maxDelta = max(maxDelta, abs(offset - baseline)) + measurementCount += 1 + } + + if let directoryURL = fixture.tree.children?.first?.url { + browser.toggleDirectory(directoryURL) + settle(hostingView, window: window) + browser.toggleDirectory(directoryURL) + settle(hostingView, window: window) + guard let scrollView = firstScrollView(in: hostingView) else { + throw HarnessError.scrollViewMissing + } + let offset = scrollView.contentView.bounds.origin.y + maxDelta = max(maxDelta, abs(offset - baseline)) + measurementCount += 1 + } + } + + window.orderOut(nil) + window.contentView = nil + return (measurementCount, maxDelta) + } + + private static func makeFixture() -> (root: URL, tree: FileNode, files: [URL]) { + let root = URL(fileURLWithPath: "/issue-36-fixture", isDirectory: true) + var files: [URL] = [] + let directories = (0..<48).map { directoryIndex -> FileNode in + let directory = root.appendingPathComponent( + String(format: "section-%02d", directoryIndex), + isDirectory: true + ) + let children = (0..<24).map { fileIndex -> FileNode in + let file = directory.appendingPathComponent( + String(format: "document-%02d-%02d.md", directoryIndex, fileIndex) + ) + files.append(file) + return FileNode( + url: file, + name: file.lastPathComponent, + isDirectory: false, + children: nil + ) + } + return FileNode( + url: directory, + name: directory.lastPathComponent, + isDirectory: true, + children: children + ) + } + return ( + root, + FileNode( + url: root, + name: root.lastPathComponent, + isDirectory: true, + children: directories + ), + files + ) + } + + private static func firstScrollView(in view: NSView) -> NSScrollView? { + if let scrollView = view as? NSScrollView { + return scrollView + } + for subview in view.subviews { + if let scrollView = firstScrollView(in: subview) { + return scrollView + } + } + return nil + } + + private static func settle(_ hostingView: NSView, window: NSWindow) { + for _ in 0..<5 { + hostingView.layoutSubtreeIfNeeded() + window.layoutIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.02)) + } + } + + private static func format(_ value: CGFloat) -> String { + String(format: "%.3f", value) + } + + private static func fail(_ message: String) -> Never { + fputs("✗ \(message)\n", stderr) + exit(1) + } + + private enum HarnessError: Error { + case scrollViewMissing + case contentTooShort(CGFloat) + } +} diff --git a/run-ui-tests.sh b/run-ui-tests.sh new file mode 100755 index 0000000..a154d28 --- /dev/null +++ b/run-ui-tests.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +mkdir -p .build + +swiftc -O -parse-as-library \ + -framework AppKit \ + -framework Combine \ + -framework SwiftUI \ + Sources/mindle/Theme.swift \ + Sources/mindle/FileTree.swift \ + Sources/mindle/GitFileMetadata.swift \ + Sources/mindle/PerformanceTrace.swift \ + Sources/mindle/FileBrowserState.swift \ + Sources/mindle/FileBrowserView.swift \ + Tests/ui/FileBrowserScrollStabilityTests.swift \ + -o .build/run-ui-tests + +.build/run-ui-tests From bd8e32765d7334c9b17d6ce122b7d557b872e953 Mon Sep 17 00:00:00 2001 From: matchaboar <224671045+matchaboar@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:24:33 -0700 Subject: [PATCH 3/3] ci: run performance tests on branch pushes --- .github/workflows/performance-tests.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/performance-tests.yml b/.github/workflows/performance-tests.yml index f3899aa..9598bc2 100644 --- a/.github/workflows/performance-tests.yml +++ b/.github/workflows/performance-tests.yml @@ -1,9 +1,8 @@ name: File Browser Performance Tests on: - pull_request: - branches: [main] push: + pull_request: branches: [main] permissions: