diff --git a/.github/workflows/performance-tests.yml b/.github/workflows/performance-tests.yml new file mode 100644 index 0000000..9598bc2 --- /dev/null +++ b/.github/workflows/performance-tests.yml @@ -0,0 +1,27 @@ +name: File Browser Performance Tests + +on: + push: + pull_request: + 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/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..c3763af --- /dev/null +++ b/Tests/FileBrowserPerformance.md @@ -0,0 +1,120 @@ +# 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. + +`./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 + +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. + +### Automation status + +External System Events automation remains unavailable because the host denied +Apple Events: + +```text +Not authorized to send Apple events to System Events. (-1743) +``` + +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 + +`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/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-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/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 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"