diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 96f0069..1235ad4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,28 @@ permissions: contents: write jobs: + tests: + permissions: + contents: read + runs-on: macos-26 + 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 logic and screenshot tests + run: | + chmod +x run-tests.sh run-screenshot-tests.sh + ./run-tests.sh + ./run-screenshot-tests.sh + build: + needs: tests + permissions: + contents: write runs-on: macos-26 env: # Non-secret mirror so `if:` conditions can gate on signing availability. @@ -27,6 +48,7 @@ jobs: with: fetch-depth: 0 # full history so build.sh's rev-list count is accurate fetch-tags: true # ensure local refs/tags/* so `git describe` resolves the tag + persist-credentials: false - name: Select Xcode # Xcode 26 ships the macOS 26 SDK, which is what lights up @@ -206,6 +228,7 @@ jobs: - name: Sign DMG for Sparkle and update appcast if: env.SIGN_IDENTITY != '' && startsWith(github.ref, 'refs/tags/v') env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} run: | set -euo pipefail @@ -264,7 +287,7 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add docs/appcast.xml git commit -m "Update appcast for $TAG_NAME (signed DMG entry)" - git push origin main + git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" main - name: Package zip (fallback) run: | diff --git a/Sources/mindle/BrowserDisplaySettings.swift b/Sources/mindle/BrowserDisplaySettings.swift new file mode 100644 index 0000000..bcf6e90 --- /dev/null +++ b/Sources/mindle/BrowserDisplaySettings.swift @@ -0,0 +1,9 @@ +import Foundation + +enum BrowserDisplaySettings { + static let highlightActiveFileKey = "mindle.fileBrowser.highlightActiveFile" + + static func highlightActiveFile(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: highlightActiveFileKey) as? Bool ?? true + } +} diff --git a/Sources/mindle/ContentView.swift b/Sources/mindle/ContentView.swift index f9cad66..d640d15 100644 --- a/Sources/mindle/ContentView.swift +++ b/Sources/mindle/ContentView.swift @@ -71,6 +71,12 @@ struct ContentView: View { .lineLimit(1) .truncationMode(.middle) .padding(.horizontal, 14) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + if let window = NSApp.keyWindow { + TitleBarDoubleClick.perform(on: window) + } + } } ToolbarItemGroup(placement: .primaryAction) { @@ -1288,133 +1294,21 @@ struct AnnotationMessageRow: View { struct FileBrowserSidebar: View { @EnvironmentObject var store: DocumentStore + @AppStorage(BrowserDisplaySettings.highlightActiveFileKey) + private var highlightActiveFile = true 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) - } + FileBrowserSidebarContent( + rootURL: store.fileTree?.url ?? store.fileURL?.deletingLastPathComponent(), + tree: store.fileTree, + selectedURL: store.fileURL, + isLoading: store.fileBrowserIsLoading, + errorMessage: store.fileBrowserErrorMessage, + highlightActiveFile: highlightActiveFile, + theme: store.theme, + onRefresh: store.refreshFileTree, + onOpen: store.open + ) } } diff --git a/Sources/mindle/DocumentStore.swift b/Sources/mindle/DocumentStore.swift index e74209a..c87fe5c 100644 --- a/Sources/mindle/DocumentStore.swift +++ b/Sources/mindle/DocumentStore.swift @@ -16,10 +16,6 @@ extension URL { } } -enum ReaderTheme: String, CaseIterable, Codable { - case light, sepia, dark -} - /// What kind of document is in the active tab — picks the renderer pipeline. /// Markdown flows through the WKWebView + markdown-it pipeline; PDF flows /// through the native PDFKit pipeline. Derived from the file URL's extension @@ -169,14 +165,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; @@ -301,6 +289,9 @@ final class DocumentStore: ObservableObject { @Published var showAnnotations: Bool = false @Published var showFileBrowser: Bool = false @Published var fileTree: FileNode? = nil + @Published var fileBrowserIsLoading = false + @Published var fileBrowserErrorMessage: String? + private var fileBrowserRefreshGeneration = 0 // Tabs (per-window). Empty when no document is open; otherwise the active // tab's state mirrors `fileURL` / `rawText` / `annotations` above. @@ -1175,8 +1166,31 @@ final class DocumentStore: ObservableObject { 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()) + guard let url = fileURL else { + fileBrowserRefreshGeneration += 1 + fileTree = nil + fileBrowserIsLoading = false + fileBrowserErrorMessage = nil + return + } + let rootURL = url.deletingLastPathComponent() + fileBrowserRefreshGeneration += 1 + let generation = fileBrowserRefreshGeneration + fileBrowserIsLoading = true + fileBrowserErrorMessage = nil + + Task { @MainActor [weak self] in + await Task.yield() + guard let self, self.fileBrowserRefreshGeneration == generation else { return } + do { + self.fileTree = try Self.buildTree(at: rootURL) + self.fileBrowserErrorMessage = nil + } catch { + self.fileTree = nil + self.fileBrowserErrorMessage = error.localizedDescription + } + self.fileBrowserIsLoading = false + } } private static func isDescendant(url: URL, of ancestor: URL) -> Bool { @@ -1186,21 +1200,19 @@ final class DocumentStore: ObservableObject { return uPath.hasPrefix(prefix) } - private static func buildTree(at dir: URL) -> FileNode? { + private static func buildTree(at dir: URL) throws -> FileNode { let fm = FileManager.default - guard let entries = try? fm.contentsOfDirectory( + let entries = try fm.contentsOfDirectory( at: dir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] - ) else { - return FileNode(url: dir, name: dir.lastPathComponent, isDirectory: true, children: []) - } + ) 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 { + if let sub = try? buildTree(at: entry), !(sub.children ?? []).isEmpty { children.append(sub) } } else if browsableExtensions.contains(entry.pathExtension.lowercased()) { diff --git a/Sources/mindle/FileBrowserPresentation.swift b/Sources/mindle/FileBrowserPresentation.swift new file mode 100644 index 0000000..2eb5f89 --- /dev/null +++ b/Sources/mindle/FileBrowserPresentation.swift @@ -0,0 +1,42 @@ +import Foundation + +struct FileNode: Identifiable, Equatable { + var id: URL { url } + let url: URL + let name: String + let isDirectory: Bool + let children: [FileNode]? +} + +enum FileBrowserPresentationState: Equatable { + case loading + case error(String) + case populated + case empty +} + +enum FileBrowserPresentation { + static func headerTitle(rootURL: URL?) -> String { + guard let title = rootURL?.lastPathComponent, !title.isEmpty else { + return "Files" + } + return title + } + + static func state( + tree: FileNode?, + isLoading: Bool, + errorMessage: String? + ) -> FileBrowserPresentationState { + if isLoading && tree == nil { + return .loading + } + if let errorMessage { + return .error(errorMessage) + } + if let children = tree?.children, !children.isEmpty { + return .populated + } + return .empty + } +} diff --git a/Sources/mindle/FileBrowserView.swift b/Sources/mindle/FileBrowserView.swift new file mode 100644 index 0000000..203f75f --- /dev/null +++ b/Sources/mindle/FileBrowserView.swift @@ -0,0 +1,239 @@ +import SwiftUI + +struct FileBrowserSidebarContent: View { + let rootURL: URL? + let tree: FileNode? + let selectedURL: URL? + let isLoading: Bool + let errorMessage: String? + let highlightActiveFile: Bool + 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.fill") + .font(.system(size: 12)) + .foregroundStyle(c.accent) + Text(FileBrowserPresentation.headerTitle(rootURL: rootURL)) + .font(.system(size: 13, weight: .semibold, design: .serif)) + .foregroundStyle(c.text) + .lineLimit(1) + .truncationMode(.middle) + .help(rootURL?.path ?? "Files") + Spacer() + Button(action: onRefresh) { + Group { + if isLoading { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11, weight: .medium)) + } + } + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .foregroundStyle(c.muted) + .disabled(isLoading) + .help(isLoading ? "Refreshing file list" : "Refresh file list") + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + + Rectangle().fill(c.rule.opacity(0.4)).frame(height: 0.5) + + switch FileBrowserPresentation.state( + tree: tree, + isLoading: isLoading, + errorMessage: errorMessage + ) { + case .loading: + FileBrowserLoadingState(theme: theme) + case .error(let message): + FileBrowserErrorState(message: message, theme: theme, onRetry: onRefresh) + case .populated: + ScrollView { + // Eager rows keep the tree's measured height stable when + // the active file or tab bar changes, avoiding #36's + // apparent selection jump. + VStack(alignment: .leading, spacing: 0) { + ForEach(tree?.children ?? []) { child in + FileTreeRow( + node: child, + depth: 0, + selectedURL: selectedURL, + highlightActiveFile: highlightActiveFile, + theme: theme, + onOpen: onOpen + ) + } + } + .padding(.vertical, 6) + } + case .empty: + FileBrowserEmptyState(theme: theme) + } + } + .background(c.sidebar) + } +} + +private struct FileBrowserLoadingState: View { + let theme: ReaderTheme + + var body: some View { + let c = theme.colors + VStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Looking for supported files…") + .font(.system(size: 11, design: .serif)) + .foregroundStyle(c.muted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct FileBrowserErrorState: View { + let message: String + let theme: ReaderTheme + let onRetry: () -> Void + + var body: some View { + let c = theme.colors + VStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 24, weight: .ultraLight)) + .foregroundStyle(c.muted) + Text("Couldn’t load this folder") + .font(.system(size: 13, weight: .semibold, design: .serif)) + .foregroundStyle(c.text) + Text(message) + .font(.system(size: 11, design: .serif)) + .foregroundStyle(c.muted) + .multilineTextAlignment(.center) + .lineLimit(4) + Button("Try Again", action: onRetry) + .buttonStyle(.borderless) + .foregroundStyle(c.accent) + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct FileBrowserEmptyState: View { + let theme: ReaderTheme + + var body: some View { + let c = theme.colors + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.system(size: 28, weight: .ultraLight)) + .foregroundStyle(c.muted.opacity(0.7)) + Text("No supported files\nin this folder.") + .multilineTextAlignment(.center) + .font(.system(size: 12, design: .serif).italic()) + .foregroundStyle(c.muted) + .padding(.horizontal, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct FileTreeRow: View { + let node: FileNode + let depth: Int + let selectedURL: URL? + let highlightActiveFile: Bool + let theme: ReaderTheme + let onOpen: (URL) -> Void + @State private var isExpanded = true + + var body: some View { + let c = 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, + selectedURL: selectedURL, + highlightActiveFile: highlightActiveFile, + theme: theme, + onOpen: onOpen + ) + } + } + } else { + let isCurrent = selectedURL?.standardizedFileURL == node.url.standardizedFileURL + let isHighlighted = highlightActiveFile && isCurrent + Button { + onOpen(node.url) + } label: { + HStack(spacing: 6) { + Spacer().frame(width: 10) + Image(systemName: "doc.text") + .font(.system(size: 11)) + .foregroundStyle(isHighlighted ? c.accent : c.muted) + Text(node.name) + .font(.system( + size: 12, + weight: isHighlighted ? .medium : .regular, + 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 { + if isHighlighted { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(c.accent.opacity(theme == .dark ? 0.28 : 0.22)) + .padding(.horizontal, 4) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } +} diff --git a/Sources/mindle/SettingsView.swift b/Sources/mindle/SettingsView.swift index 533bccc..68fea25 100644 --- a/Sources/mindle/SettingsView.swift +++ b/Sources/mindle/SettingsView.swift @@ -2,6 +2,8 @@ import SwiftUI struct SettingsView: View { @AppStorage("mindle.fontScale") private var defaultFontScale: Double = 1.0 + @AppStorage(BrowserDisplaySettings.highlightActiveFileKey) + private var highlightActiveFile = true var body: some View { Form { @@ -35,6 +37,10 @@ struct SettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) } } + + Section("File Browser") { + Toggle("Highlight the active file", isOn: $highlightActiveFile) + } } .formStyle(.grouped) .frame(width: 450) diff --git a/Sources/mindle/Theme.swift b/Sources/mindle/Theme.swift index 770dfaf..8af9d27 100644 --- a/Sources/mindle/Theme.swift +++ b/Sources/mindle/Theme.swift @@ -1,6 +1,10 @@ import SwiftUI import AppKit +enum ReaderTheme: String, CaseIterable, Codable { + case light, sepia, dark +} + struct ThemeColors { let background: Color let surface: Color diff --git a/Sources/mindle/TitleBarDoubleClick.swift b/Sources/mindle/TitleBarDoubleClick.swift new file mode 100644 index 0000000..b6547bb --- /dev/null +++ b/Sources/mindle/TitleBarDoubleClick.swift @@ -0,0 +1,19 @@ +import AppKit + +enum TitleBarDoubleClick { + static func perform( + on window: NSWindow, + preference: String? = UserDefaults.standard.string( + forKey: "AppleActionOnDoubleClick" + ) + ) { + switch preference?.lowercased() { + case "minimize": + window.miniaturize(nil) + case "none": + break + default: + window.performZoom(nil) + } + } +} diff --git a/Tests/README.md b/Tests/README.md new file mode 100644 index 0000000..5549bff --- /dev/null +++ b/Tests/README.md @@ -0,0 +1,28 @@ +# Test Results + +Environment: macOS 26.6.1 (arm64), Swift 6.3.1. + +## Screenshot regression runs + +| Run | Command | Result | Duration | +| --- | --- | --- | ---: | +| Red | `./run-screenshot-tests.sh` | Failed: all four baselines were missing | 2.11s | +| Record | `./run-screenshot-tests.sh --record` | Passed; baselines recorded | 1.66s | +| Verify 1 | `./run-screenshot-tests.sh` | Passed | 1.63s | +| Verify 2 | `./run-screenshot-tests.sh` | Passed | 1.77s | +| Verify 3 | `./run-screenshot-tests.sh` | Passed | 1.68s | + +The populated light and dark baselines cover the scoped folder header, refresh +affordance, expanded hierarchy, and active-file treatment. The remaining +baselines cover the light empty state and dark error state with retry action. + +## Focused validation + +| Phase | Command | Result | Duration | +| --- | --- | --- | ---: | +| UI logic red | `./run-tests.sh` | Failed: presentation/settings/title-bar implementations were absent | 0.39s | +| UI logic green | `./run-tests.sh` | Passed: 14 focused UI checks plus the existing harness | 3.21s | +| App build | `./build.sh` | Passed | 16.88s | + +These are correctness and screenshot-regression results, not performance +measurements. diff --git a/Tests/harness/BrowserDisplaySettingsChecks.swift b/Tests/harness/BrowserDisplaySettingsChecks.swift new file mode 100644 index 0000000..16cf67f --- /dev/null +++ b/Tests/harness/BrowserDisplaySettingsChecks.swift @@ -0,0 +1,22 @@ +import Foundation + +func runBrowserDisplaySettingsChecks() -> Int { + let checks = Checks("BrowserDisplaySettings") + let suiteName = "BrowserDisplaySettingsChecks.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + checks.expect( + BrowserDisplaySettings.highlightActiveFile(defaults: defaults), + "active-file highlighting defaults on" + ) + + defaults.set(false, forKey: BrowserDisplaySettings.highlightActiveFileKey) + checks.expect( + !BrowserDisplaySettings.highlightActiveFile(defaults: defaults), + "stored active-file preference is respected" + ) + + print("BrowserDisplaySettings: \(checks.passed) passed, \(checks.failures) failed") + return checks.failures +} diff --git a/Tests/harness/FileBrowserPresentationChecks.swift b/Tests/harness/FileBrowserPresentationChecks.swift new file mode 100644 index 0000000..e17ea97 --- /dev/null +++ b/Tests/harness/FileBrowserPresentationChecks.swift @@ -0,0 +1,67 @@ +import Foundation + +func runFileBrowserPresentationChecks() -> Int { + let checks = Checks("FileBrowserPresentation") + let root = URL(fileURLWithPath: "/Users/reader/Field Notes", isDirectory: true) + let file = root.appendingPathComponent("chapter.md") + let populatedTree = FileNode( + url: root, + name: "Field Notes", + isDirectory: true, + children: [ + FileNode(url: file, name: "chapter.md", isDirectory: false, children: nil) + ] + ) + let emptyTree = FileNode( + url: root, + name: "Field Notes", + isDirectory: true, + children: [] + ) + + checks.equal( + FileBrowserPresentation.headerTitle(rootURL: root), + "Field Notes", + "header uses the scoped folder name" + ) + checks.equal( + FileBrowserPresentation.headerTitle(rootURL: nil), + "Files", + "header falls back when no scope is available" + ) + checks.equal( + FileBrowserPresentation.state(tree: nil, isLoading: true, errorMessage: nil), + .loading, + "initial scan shows loading" + ) + checks.equal( + FileBrowserPresentation.state( + tree: nil, + isLoading: false, + errorMessage: "Permission denied." + ), + .error("Permission denied."), + "scan failures show an error" + ) + checks.equal( + FileBrowserPresentation.state( + tree: populatedTree, + isLoading: true, + errorMessage: nil + ), + .populated, + "refresh preserves populated content" + ) + checks.equal( + FileBrowserPresentation.state( + tree: emptyTree, + isLoading: false, + errorMessage: nil + ), + .empty, + "empty folders show the empty state" + ) + + print("FileBrowserPresentation: \(checks.passed) passed, \(checks.failures) failed") + return checks.failures +} diff --git a/Tests/harness/TitleBarDoubleClickChecks.swift b/Tests/harness/TitleBarDoubleClickChecks.swift new file mode 100644 index 0000000..52c16bb --- /dev/null +++ b/Tests/harness/TitleBarDoubleClickChecks.swift @@ -0,0 +1,45 @@ +import AppKit + +func runTitleBarDoubleClickChecks() -> Int { + let checks = Checks("TitleBarDoubleClick") + let window = RecordingWindow() + + TitleBarDoubleClick.perform(on: window, preference: "Maximize") + checks.equal(window.zoomCount, 1, "Maximize preference zooms") + checks.equal(window.miniaturizeCount, 0, "Maximize does not minimize") + + TitleBarDoubleClick.perform(on: window, preference: "Minimize") + checks.equal(window.miniaturizeCount, 1, "Minimize preference minimizes") + + TitleBarDoubleClick.perform(on: window, preference: "None") + checks.equal(window.zoomCount, 1, "None leaves zoom unchanged") + checks.equal(window.miniaturizeCount, 1, "None leaves minimize unchanged") + + TitleBarDoubleClick.perform(on: window, preference: nil) + checks.equal(window.zoomCount, 2, "missing preference defaults to zoom") + + print("TitleBarDoubleClick: \(checks.passed) passed, \(checks.failures) failed") + return checks.failures +} + +private final class RecordingWindow: NSWindow { + var zoomCount = 0 + var miniaturizeCount = 0 + + init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + } + + override func performZoom(_ sender: Any?) { + zoomCount += 1 + } + + override func miniaturize(_ sender: Any?) { + miniaturizeCount += 1 + } +} diff --git a/Tests/harness/main.swift b/Tests/harness/main.swift index 0192d27..cff70ed 100644 --- a/Tests/harness/main.swift +++ b/Tests/harness/main.swift @@ -1,8 +1,11 @@ import Foundation var failures = 0 +failures += runBrowserDisplaySettingsChecks() +failures += runFileBrowserPresentationChecks() failures += runSSHTargetChecks() failures += await runSSHTransportChecks() +failures += runTitleBarDoubleClickChecks() if failures > 0 { print("\nFAILED: \(failures) check(s)") diff --git a/Tests/snapshots/FileBrowserSnapshotTests.swift b/Tests/snapshots/FileBrowserSnapshotTests.swift new file mode 100644 index 0000000..3825fd9 --- /dev/null +++ b/Tests/snapshots/FileBrowserSnapshotTests.swift @@ -0,0 +1,195 @@ +import AppKit +import SwiftUI + +@main +@MainActor +struct FileBrowserSnapshotTests { + private static let size = NSSize(width: 320, height: 360) + + static func main() throws { + _ = NSApplication.shared + let record = CommandLine.arguments.contains("--record") + let fixtures: [(String, ReaderTheme, SnapshotState)] = [ + ("file-browser-light", .light, .populated), + ("file-browser-dark", .dark, .populated), + ("file-browser-empty", .light, .empty), + ("file-browser-error", .dark, .error) + ] + + var failures = 0 + for (name, theme, state) in fixtures { + let actual = try render(theme: theme, state: state) + let baselineURL = URL(fileURLWithPath: "Tests/snapshots/\(name).png") + if record { + try actual.write(to: baselineURL, options: .atomic) + print("Recorded \(baselineURL.path)") + continue + } + + guard let expected = try? Data(contentsOf: baselineURL) else { + print("✗ Missing snapshot: \(baselineURL.path)") + failures += 1 + continue + } + if actual == expected { + print("✓ \(name)") + continue + } + + let difference = try imageDifference(expected: expected, actual: actual) + if difference.meanChannelDelta <= 0.015 + && difference.changedPixelFraction <= 0.025 { + print("✓ \(name) (within rendering tolerance)") + } else { + let outputURL = URL(fileURLWithPath: ".build/\(name)-actual.png") + try actual.write(to: outputURL, options: .atomic) + print( + "✗ \(name): mean delta \(formatted(difference.meanChannelDelta)), " + + "changed pixels \(formatted(difference.changedPixelFraction)); " + + "actual written to \(outputURL.path)" + ) + failures += 1 + } + } + + if failures > 0 { + exit(1) + } + print("ALL SCREENSHOT TESTS PASSED") + } + + private static func render(theme: ReaderTheme, state: SnapshotState) throws -> Data { + let root = URL(fileURLWithPath: "/fixture/Field Notes", isDirectory: true) + let chapters = root.appendingPathComponent("Chapters", isDirectory: true) + let active = chapters.appendingPathComponent("01-introduction.md") + let draft = chapters.appendingPathComponent("02-open-questions.md") + let notes = root.appendingPathComponent("meeting-notes.txt") + let populatedTree = FileNode( + url: root, + name: root.lastPathComponent, + isDirectory: true, + children: [ + FileNode( + url: chapters, + name: "Chapters", + isDirectory: true, + children: [ + FileNode( + url: active, + name: "01-introduction.md", + isDirectory: false, + children: nil + ), + FileNode( + url: draft, + name: "02-open-questions.md", + isDirectory: false, + children: nil + ) + ] + ), + FileNode( + url: notes, + name: "meeting-notes.txt", + isDirectory: false, + children: nil + ) + ] + ) + let emptyTree = FileNode( + url: root, + name: root.lastPathComponent, + isDirectory: true, + children: [] + ) + let view = FileBrowserSidebarContent( + rootURL: root, + tree: state == .populated ? populatedTree : (state == .empty ? emptyTree : nil), + selectedURL: active, + isLoading: false, + errorMessage: state == .error ? "The folder couldn’t be read." : nil, + highlightActiveFile: true, + theme: theme, + onRefresh: {}, + onOpen: { _ in } + ) + .frame(width: size.width, height: size.height) + .environment(\.colorScheme, theme == .dark ? .dark : .light) + + let hostingView = NSHostingView(rootView: view) + hostingView.frame = NSRect(origin: .zero, size: size) + hostingView.appearance = NSAppearance(named: theme == .dark ? .darkAqua : .aqua) + let window = NSWindow( + contentRect: hostingView.frame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentView = hostingView + hostingView.layoutSubtreeIfNeeded() + window.layoutIfNeeded() + + guard let bitmap = hostingView.bitmapImageRepForCachingDisplay(in: hostingView.bounds) else { + throw SnapshotError.renderFailed + } + hostingView.cacheDisplay(in: hostingView.bounds, to: bitmap) + guard let png = bitmap.representation(using: .png, properties: [:]) else { + throw SnapshotError.renderFailed + } + return png + } + + private static func imageDifference( + expected: Data, + actual: Data + ) throws -> (meanChannelDelta: Double, changedPixelFraction: Double) { + guard let expectedImage = NSBitmapImageRep(data: expected), + let actualImage = NSBitmapImageRep(data: actual), + expectedImage.pixelsWide == actualImage.pixelsWide, + expectedImage.pixelsHigh == actualImage.pixelsHigh else { + throw SnapshotError.incompatibleImages + } + + var totalDelta = 0.0 + var changedPixels = 0 + let pixelCount = expectedImage.pixelsWide * expectedImage.pixelsHigh + for y in 0.. 0.10 { + changedPixels += 1 + } + } + } + return ( + totalDelta / Double(pixelCount), + Double(changedPixels) / Double(pixelCount) + ) + } + + private static func formatted(_ value: Double) -> String { + String(format: "%.4f", value) + } + + private enum SnapshotError: Error { + case renderFailed + case incompatibleImages + } + + private enum SnapshotState { + case populated + case empty + case error + } +} diff --git a/Tests/snapshots/file-browser-dark.png b/Tests/snapshots/file-browser-dark.png new file mode 100644 index 0000000..1fabdff Binary files /dev/null and b/Tests/snapshots/file-browser-dark.png differ diff --git a/Tests/snapshots/file-browser-empty.png b/Tests/snapshots/file-browser-empty.png new file mode 100644 index 0000000..d86cff7 Binary files /dev/null and b/Tests/snapshots/file-browser-empty.png differ diff --git a/Tests/snapshots/file-browser-error.png b/Tests/snapshots/file-browser-error.png new file mode 100644 index 0000000..677cdf2 Binary files /dev/null and b/Tests/snapshots/file-browser-error.png differ diff --git a/Tests/snapshots/file-browser-light.png b/Tests/snapshots/file-browser-light.png new file mode 100644 index 0000000..b71284a Binary files /dev/null and b/Tests/snapshots/file-browser-light.png differ diff --git a/run-screenshot-tests.sh b/run-screenshot-tests.sh new file mode 100755 index 0000000..668e772 --- /dev/null +++ b/run-screenshot-tests.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +mkdir -p .build + +swiftc -O -parse-as-library \ + -framework AppKit \ + -framework SwiftUI \ + Sources/mindle/Theme.swift \ + Sources/mindle/FileBrowserPresentation.swift \ + Sources/mindle/FileBrowserView.swift \ + Tests/snapshots/FileBrowserSnapshotTests.swift \ + -o .build/run-screenshot-tests + +.build/run-screenshot-tests "$@" diff --git a/run-tests.sh b/run-tests.sh index 0bb472e..a7e27f6 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -7,11 +7,18 @@ set -euo pipefail cd "$(dirname "$0")" mkdir -p .build swiftc -O \ + -framework AppKit \ + Sources/mindle/BrowserDisplaySettings.swift \ + Sources/mindle/FileBrowserPresentation.swift \ Sources/mindle/SSHTarget.swift \ Sources/mindle/SSHTransport.swift \ + Sources/mindle/TitleBarDoubleClick.swift \ Tests/harness/TestHarness.swift \ + Tests/harness/BrowserDisplaySettingsChecks.swift \ + Tests/harness/FileBrowserPresentationChecks.swift \ Tests/harness/SSHTargetChecks.swift \ Tests/harness/SSHTransportChecks.swift \ + Tests/harness/TitleBarDoubleClickChecks.swift \ Tests/harness/main.swift \ -o .build/run-tests .build/run-tests