diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 96f0069..2061607 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,12 @@ jobs: # letting us keep a macOS 14 deployment target in build.sh. 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 + - name: Import signing certificate if: env.SIGN_IDENTITY != '' && env.IS_RELEASE_EVENT == 'true' env: diff --git a/AGENTS.md b/AGENTS.md index 40fae52..fa58dc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,6 +147,8 @@ Skills worth writing for this repo specifically (not yet authored): ## Build & run +Agent-run commands must always have an explicit bounded timeout. Use 30 seconds for quick inspection commands, 2–4 minutes for tests, and at most 5 minutes for a full build. If a command exceeds its bound, stop it, report the timeout, and investigate rather than leaving an unbounded process running. + ```bash ./build.sh # produces build/Mindle.app open build/Mindle.app diff --git a/README.md b/README.md index 095302a..727ec12 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,11 @@ Requires **macOS 14+** and **Xcode Command Line Tools** (`xcode-select --install ### Workflow - **Tabs and multi-window** — open many files in one window (`⌘O` adds a tab) or pop a new window with `⌘N`. Each tab carries its own scroll, theme, font scale, and collaborator registry. `⌘W` closes the active tab when more than one is open, otherwise the window. +- **Open a folder** — `⌘⌥O` opens a local directory directly. +- **SSH profiles** — the ssh button at the top left opens the favorite profile from `~/Library/Application Support/Mindle/ssh-profiles.yaml` or **Mindle → Settings → SSH Profiles**. - **Open remote URLs** — `⌘⇧L` opens any `http(s)` URL pointing at raw Markdown (a GitHub raw link, a hosted doc) as a tab. Annotations on URL documents persist locally keyed by URL hash, so re-opening the same URL brings the annotations back. - **Open from Clipboard** — `⌘⇧V` opens the pasteboard contents as a Markdown tab. Use this when the source is behind a login your browser already handles (internal GitLab raw, Confluence, anything auth-walled): copy the raw Markdown there, paste it here. Tabs are content-addressed, so re-pasting identical text re-opens the same tab with its prior annotations. -- **File browser** — scoped sidebar tree of every `.md` and `.txt` in the current folder (`⌘⇧F`). Never escapes upward. +- **File browser** — scoped sidebar tree of `.md`, `.markdown`, `.mdown`, `.mkd`, `.txt`, and `.pdf` files (`⌘⇧F`). Additional configuration is available under **Mindle → Settings → File Browser**. - **Find in document** — live search with match count, `⌘F` / `⌘G` / `⌘⇧G`. - **Live reload** — external edits (vim, an agent, Dropbox, anything) re-render automatically. Bursty writes are debounced; scroll position is preserved. Sidecar changes (a teammate's annotation arriving via shared folder or `git pull`) flow in the same way. - **Diff-on-reload** — when an external write changes the active file, Mindle renders the change as a Word-style track-changes overlay you can ✓ Keep or ✗ Revert per chunk, or whole-document with `⌘⌥⏎` / `⌘⌥⌫`. Diffs run a second pass at word granularity inside each line, so you see just the changed words struck or underlined — not the whole line. @@ -114,6 +116,7 @@ See [Agent Collaboration](#agent-collaboration) below for setup. | Shortcut | Action | |----------|--------| | `⌘O` | Open a file (adds a tab if a window is open) | +| `⌘⌥O` | Open a folder in the file sidebar | | `⌘N` | New window | | `⌘W` | Close active tab (or window, when only one tab is open) | | `⌘F` | Find in document | diff --git a/Resources/web/reader.js b/Resources/web/reader.js index 4aa75da..395bc45 100644 --- a/Resources/web/reader.js +++ b/Resources/web/reader.js @@ -1250,16 +1250,30 @@ if (!src) return { url: null }; if (src.startsWith("data:")) return { url: src }; if (/^https?:/i.test(src)) return { blocked: true }; + const pathSrc = decodeImagePath(src); if (/^file:\/\//i.test(src)) { - const path = src.replace(/^file:\/\//i, ""); - return { url: "mindle-file://" + path }; + const path = pathSrc.replace(/^file:\/\//i, ""); + return { url: "mindle-file://" + encodeImagePath(path) }; } - if (src.startsWith("/")) { - return { url: "mindle-file://" + encodeURI(src) }; + if (pathSrc.startsWith("/")) { + return { url: "mindle-file://" + encodeImagePath(pathSrc) }; + } + if (!baseDir) return { url: pathSrc }; + const resolved = resolveRelativePath(baseDir, pathSrc); + return { url: "mindle-file://" + encodeImagePath(resolved) }; + } + + function decodeImagePath(src) { + const path = src.split(/[?#]/, 1)[0]; + try { + return decodeURIComponent(path); + } catch (_) { + return path; + } + + function encodeImagePath(path) { + return path.split("/").map(encodeURIComponent).join("/"); } - if (!baseDir) return { url: src }; - const resolved = resolveRelativePath(baseDir, src); - return { url: "mindle-file://" + encodeURI(resolved) }; } function resolveRelativePath(base, rel) { diff --git a/Sources/mindle/BrowserDisplaySettings.swift b/Sources/mindle/BrowserDisplaySettings.swift new file mode 100644 index 0000000..dcc21e8 --- /dev/null +++ b/Sources/mindle/BrowserDisplaySettings.swift @@ -0,0 +1,23 @@ +import Foundation + +enum BrowserDisplaySettings { + static let showGitChangesKey = "mindle.fileBrowser.showGitChanges" + static let showLastEditedKey = "mindle.fileBrowser.showLastEdited" + static let highlightActiveFileKey = "mindle.fileBrowser.highlightActiveFile" + + static func showGitChanges(defaults: UserDefaults = .standard) -> Bool { + enabledByDefault(showGitChangesKey, defaults: defaults) + } + + static func showLastEdited(defaults: UserDefaults = .standard) -> Bool { + enabledByDefault(showLastEditedKey, defaults: defaults) + } + + static func highlightActiveFile(defaults: UserDefaults = .standard) -> Bool { + enabledByDefault(highlightActiveFileKey, defaults: defaults) + } + + private static func enabledByDefault(_ key: String, defaults: UserDefaults) -> Bool { + defaults.object(forKey: key) as? Bool ?? true + } +} diff --git a/Sources/mindle/ContentView.swift b/Sources/mindle/ContentView.swift index f9cad66..8b48646 100644 --- a/Sources/mindle/ContentView.swift +++ b/Sources/mindle/ContentView.swift @@ -3,6 +3,8 @@ import AppKit struct ContentView: View { @EnvironmentObject var store: DocumentStore + @AppStorage(BrowserDisplaySettings.showGitChangesKey) private var showGitChanges = true + @AppStorage(BrowserDisplaySettings.showLastEditedKey) private var showLastEdited = true var body: some View { let c = store.theme.colors @@ -13,7 +15,7 @@ struct ContentView: View { // the material bleeds to the window's system-gray backing. c.background.ignoresSafeArea() - if store.fileURL == nil { + if store.fileURL == nil && store.fileBrowserRootURL == nil { EmptyStateView() } else { VStack(spacing: 0) { @@ -22,12 +24,22 @@ struct ContentView: View { } HSplitView { if store.showFileBrowser { - FileBrowserSidebar() + FileBrowserSidebar( + browser: store.fileBrowser, + theme: store.theme, + onRefresh: store.refreshFileTree, + onOpen: store.openBrowserItem + ) .frame(minWidth: 200, idealWidth: 260, maxWidth: 400) } - ReaderPane() - .frame(minWidth: 480) - if store.showAnnotations { + if store.fileURL == nil { + DirectoryReaderPlaceholder() + .frame(minWidth: 480) + } else { + ReaderPane() + .frame(minWidth: 480) + } + if store.showAnnotations && store.fileURL != nil { AnnotationsSidebar() .frame(minWidth: 280, idealWidth: 340, maxWidth: 460) } @@ -36,12 +48,22 @@ struct ContentView: View { } } .toolbar { + ToolbarItem(placement: .navigation) { + Button { + Task { await store.openFavoriteSSHProfile() } + } label: { + Image(systemName: "network") + .foregroundStyle(c.text) + } + .help("Open favorite SSH profile") + } + ToolbarItem(placement: .navigation) { Button { store.openWithPanel() } label: { Image(systemName: "doc.text") .foregroundStyle(c.text) } - .help("Open a Markdown file (⌘O)") + .help("Open a supported document (⌘O)") } ToolbarItem(placement: .navigation) { @@ -49,7 +71,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: { @@ -57,7 +79,7 @@ struct ContentView: View { .foregroundStyle(store.showFileBrowser ? c.accent : c.muted) } .help("Toggle files (⌘⇧F)") - .disabled(store.fileURL == nil) + .disabled(store.fileURL == nil && store.fileBrowserRootURL == nil) } ToolbarItem(placement: .principal) { @@ -71,6 +93,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) { @@ -133,19 +161,25 @@ struct ContentView: View { .foregroundStyle(store.showAnnotations ? c.accent : c.muted) } .help("Toggle annotations (⌘⇧A)") + .disabled(store.fileURL == nil) } } .onDrop(of: [.fileURL], isTargeted: nil) { providers in if let p = providers.first { _ = p.loadObject(ofClass: URL.self) { url, _ in if let url { - Task { @MainActor in store.open(url: url) } + Task { @MainActor in store.openItem(url: url) } } } return true } return false } + .onChange(of: showGitChanges) { _, _ in store.refreshFileTree() } + .onChange(of: showLastEdited) { _, _ in store.refreshFileTree() } + .onDisappear { + store.fileBrowser.cancelAll() + } } private func themeIcon(_ t: ReaderTheme) -> String { @@ -210,11 +244,16 @@ struct EmptyStateView: View { Text("A quiet place to read Markdown.") .font(.system(size: 14, design: .serif).italic()) .foregroundStyle(c.muted) - Button("Open a File…") { store.openWithPanel() } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .padding(.top, 6) - Text("…or drop a .md file onto this window") + HStack(spacing: 10) { + Button("Open a File…") { store.openWithPanel() } + .buttonStyle(.borderedProminent) + .controlSize(.large) + Button("Open a Folder…") { store.openDirectoryWithPanel() } + .buttonStyle(.bordered) + .controlSize(.large) + } + .padding(.top, 6) + Text("…or drop a supported file or folder onto this window") .font(.system(size: 11)) .foregroundStyle(c.muted) Link("Read the guide →", destination: URL(string: "https://nonatofabio.github.io/mindle/")!) @@ -226,6 +265,24 @@ struct EmptyStateView: View { } } +struct DirectoryReaderPlaceholder: View { + @EnvironmentObject var store: DocumentStore + + var body: some View { + let c = store.theme.colors + VStack(spacing: 14) { + Image(systemName: "doc.text.magnifyingglass") + .font(.system(size: 44, weight: .ultraLight)) + .foregroundStyle(c.muted) + Text("Choose a file to begin reading.") + .font(.system(size: 15, design: .serif).italic()) + .foregroundStyle(c.muted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(c.background) + } +} + // MARK: - Reader struct ReaderPane: View { @@ -640,7 +697,10 @@ func activeDisplayTitle(store: DocumentStore) -> String { let tab = store.tabs.first(where: { $0.id == id }) { return displayTitle(for: tab.sourceURL ?? tab.fileURL) } - return store.fileURL.map { displayTitle(for: $0) } ?? "Mindle" + if let fileURL = store.fileURL { + return displayTitle(for: fileURL) + } + return store.fileBrowserRootURL?.lastPathComponent ?? "Mindle" } /// Known reaction kinds for the rc1 vocabulary. The codec is open-ended @@ -1284,140 +1344,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..2da147b 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; @@ -300,7 +288,11 @@ final class DocumentStore: ObservableObject { } @Published var showAnnotations: Bool = false @Published var showFileBrowser: Bool = false - @Published var fileTree: FileNode? = nil + @Published private(set) var fileBrowserRootURL: URL? + @Published private(set) var remoteAssetRevision: Int = 0 + private var fileBrowserRootIsExplicit = false + private var activeRemoteProfile: SSHProfile? + let fileBrowser = FileBrowserState() // Tabs (per-window). Empty when no document is open; otherwise the active // tab's state mirrors `fileURL` / `rawText` / `annotations` above. @@ -510,6 +502,43 @@ final class DocumentStore: ObservableObject { } } + func openDirectoryWithPanel() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.prompt = "Open" + if panel.runModal() == .OK, let url = panel.url { + openDirectory(url: url) + } + } + + func openItem(url: URL) { + var isDirectory: ObjCBool = false + if url.isFileURL, + FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue { + openDirectory(url: url) + } else { + open(url: url) + } + } + + func openDirectory(url: URL) { + var isDirectory: ObjCBool = false + guard url.isFileURL, + FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue, + FileManager.default.isReadableFile(atPath: url.path) else { + NSSound.beep() + return + } + + setFileBrowserRoot(url, isExplicit: true) + syncFileBrowserSelection(for: fileURL) + showFileBrowser = true + } + func open(url: URL) { // Already open in this window? Switch to its tab without re-reading. if let existing = tabs.first(where: { $0.fileURL == url }) { @@ -522,8 +551,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 +570,12 @@ 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 remoteTarget != nil { + shouldRebuildTree = false + } else if fileBrowserRootIsExplicit { + shouldRebuildTree = false + } else if let root = fileBrowserRootURL { + shouldRebuildTree = !FileTreeBuilder.isDescendant(url, of: root) } else { shouldRebuildTree = true } @@ -571,7 +608,14 @@ final class DocumentStore: ObservableObject { // Capture the sidecar-loaded annotations into the tab snapshot. snapshotActiveTab() - if shouldRebuildTree { refreshFileTree() } + if shouldRebuildTree { + setFileBrowserRoot(url.deletingLastPathComponent(), isExplicit: false) + } + if remoteTarget == nil { + syncFileBrowserSelection(for: url) + } else { + fileBrowser.setSelectedURL(sourceURL) + } if url.isFileURL && remoteTarget == nil { NSDocumentController.shared.noteNewRecentDocumentURL(url) } @@ -594,6 +638,7 @@ final class DocumentStore: ObservableObject { } let proxy = target.proxyURL(cacheDir: cacheDir) do { + try target.migrateLegacyCacheIfNeeded(cacheDir: cacheDir) try await SSHTransport.fetch(target, to: proxy) // A concurrent openRemote for the same target may have created // the tab while our fetch was in flight (the pre-await dedup @@ -603,7 +648,16 @@ final class DocumentStore: ObservableObject { } let kind = DocumentKind.kind(for: proxy) let text: String = (kind == .pdf) ? "" : try String(contentsOf: proxy, encoding: .utf8) + let assetFailures = kind == .markdown + ? await SSHTransport.fetchReferencedImages( + in: text, + for: target, + cacheDir: cacheDir + ) + : [] + remoteAssetRevision &+= 1 finishOpen(url: proxy, text: text, kind: kind, sourceURL: source, remoteTarget: target) + presentRemoteAssetFailures(assetFailures, target: target) } catch { presentRemoteError(title: "Couldn’t open \(target.canonical)", error: error) } @@ -618,6 +672,20 @@ final class DocumentStore: ObservableObject { alert.runModal() } + private func presentRemoteAssetFailures( + _ failures: [RemoteAssetFetchFailure], + target: SSHTarget + ) { + guard !failures.isEmpty else { return } + let preview = failures.prefix(3).map { "• \($0.path): \($0.message)" }.joined(separator: "\n") + let remainder = failures.count > 3 ? "\n…and \(failures.count - 3) more." : "" + let alert = NSAlert() + alert.messageText = "Opened \(target.canonical), but some images couldn’t be fetched" + alert.informativeText = preview + remainder + alert.alertStyle = .warning + alert.runModal() + } + // MARK: - Live reload /// Re-reads the active file from disk in response to a watcher event. @@ -646,10 +714,27 @@ final class DocumentStore: ObservableObject { /// reload path as a local watcher event so diff-on-reload kicks in when /// the remote file changed underneath us. func reloadRemote() async { - guard let target = activeRemoteTarget, let url = fileURL else { return } + guard let target = activeRemoteTarget, + let url = fileURL, + let cacheDir = Self.sshCacheDir() else { return } do { try await SSHTransport.fetch(target, to: url) - reloadFromDisk() + let kind = DocumentKind.kind(for: url) + let text = kind == .markdown + ? try String(contentsOf: url, encoding: .utf8) + : "" + let assetFailures = kind == .markdown + ? await SSHTransport.fetchReferencedImages( + in: text, + for: target, + cacheDir: cacheDir + ) + : [] + remoteAssetRevision &+= 1 + if kind == .markdown { + reloadFromDisk() + } + presentRemoteAssetFailures(assetFailures, target: target) } catch { presentRemoteError(title: "Couldn’t refresh \(target.canonical)", error: error) } @@ -856,6 +941,7 @@ final class DocumentStore: ObservableObject { let newTabID = newTab.id activeTabID = newTabID fileURL = url + fileBrowser.setSelectedURL(nil) rawText = placeholder lastSyncedText = placeholder annotations = [] @@ -920,6 +1006,7 @@ final class DocumentStore: ObservableObject { tabs.append(newTab) activeTabID = newTab.id fileURL = url + fileBrowser.setSelectedURL(nil) rawText = raw lastSyncedText = raw annotations = [] @@ -985,6 +1072,7 @@ final class DocumentStore: ObservableObject { tabs[idx].lastSyncedText = "" if activeTabID == tabID { fileURL = cacheURL + fileBrowser.setSelectedURL(nil) rawText = "" lastSyncedText = "" resetReaderPrefsToUserDefaults() @@ -1098,6 +1186,7 @@ final class DocumentStore: ObservableObject { // Last tab closed — back to empty state. activeTabID = nil fileURL = nil + fileBrowser.setSelectedURL(nil) activeRemoteTarget = nil rawText = "" lastSyncedText = "" @@ -1124,6 +1213,11 @@ final class DocumentStore: ObservableObject { private func loadTabState(_ tab: DocumentTab) { fileURL = tab.fileURL + if tab.sourceURL?.isMindleSSH == true { + fileBrowser.setSelectedURL(tab.sourceURL) + } else { + syncFileBrowserSelection(for: tab.fileURL) + } self.activeRemoteTarget = tab.sourceURL.flatMap { SSHTarget(sourceURL: $0) } rawText = tab.rawText lastSyncedText = tab.lastSyncedText @@ -1172,48 +1266,89 @@ 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()) + if let profile = activeRemoteProfile { + Task { await openSSHProfile(profile) } + } else { + fileBrowser.refresh() + } } - 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) + func openBrowserItem(_ url: URL) { + if let target = SSHTarget(sourceURL: url) { + Task { await openRemote(target) } + } else { + open(url: url) + } } - 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: []) + func openFavoriteSSHProfile() async { + do { + let profiles = try SSHProfileConfiguration.load() + guard let profile = SSHProfileConfiguration.favoriteProfile(in: profiles) else { + throw SSHProfileConfigurationError.noProfiles + } + await openSSHProfile(profile) + } catch { + presentRemoteError(title: "Couldn’t open favorite SSH profile", error: error) } + } - 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)) + func openSSHProfile(_ profile: SSHProfile) async { + activeRemoteProfile = profile + fileBrowserRootIsExplicit = true + fileBrowserRootURL = profile.rootTarget.sourceURL + let generation = fileBrowser.beginRemoteLoad(profile: profile) + showFileBrowser = true + do { + let listing = try await SSHTransport.listDocuments(in: profile) + let applied = fileBrowser.finishRemoteLoad( + profile: profile, + listing: listing, + generation: generation + ) + if applied { + fileBrowserRootURL = listing.root.sourceURL + } + let rootPrefix = listing.root.remotePath.hasSuffix("/") + ? listing.root.remotePath + : listing.root.remotePath + "/" + if applied, + let target = activeRemoteTarget, + target.userHost == listing.root.userHost, + (target.remotePath == listing.root.remotePath + || target.remotePath.hasPrefix(rootPrefix)) { + fileBrowser.setSelectedURL(target.sourceURL) + } + } catch { + let applied = fileBrowser.failRemoteLoad( + profile: profile, + message: error.localizedDescription, + generation: generation + ) + if applied { + presentRemoteError(title: "Couldn’t open SSH profile “\(profile.name)”", error: error) } } + } - children.sort { a, b in - if a.isDirectory != b.isDirectory { return a.isDirectory } - return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending - } + private func setFileBrowserRoot(_ url: URL?, isExplicit: Bool) { + let normalized = url?.standardizedFileURL + activeRemoteProfile = nil + fileBrowserRootIsExplicit = normalized != nil && isExplicit + fileBrowserRootURL = normalized + fileBrowser.setRoot(normalized) + } - return FileNode(url: dir, name: dir.lastPathComponent, isDirectory: true, children: children) + private func syncFileBrowserSelection(for url: URL?) { + guard let url, + url.isFileURL, + let root = fileBrowserRootURL, + FileTreeBuilder.isDescendant(url, of: root) else { + fileBrowser.setSelectedURL(nil) + return + } + fileBrowser.setSelectedURL(url) } func toggleTheme() { diff --git a/Sources/mindle/FileBrowserState.swift b/Sources/mindle/FileBrowserState.swift new file mode 100644 index 0000000..b107cc6 --- /dev/null +++ b/Sources/mindle/FileBrowserState.swift @@ -0,0 +1,214 @@ +import Combine +import Foundation + +@MainActor +final class FileBrowserState: ObservableObject { + private enum TreeBuildResult: Sendable { + case success(FileNode) + case failure(String) + } + + @Published private(set) var rootURL: URL? + @Published private(set) var rootDisplayName: String? + @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 var collapsedDirectories: Set = [] + private var refreshTask: Task? + private var metadataTask: Task? + private var treeWorker: Task? + private var metadataWorker: Task? + private var refreshGeneration = 0 + + func setRoot(_ url: URL?) { + let normalized = Self.normalized(url) + guard rootURL != normalized else { + refresh() + return + } + refreshTask?.cancel() + metadataTask?.cancel() + treeWorker?.cancel() + metadataWorker?.cancel() + rootURL = normalized + rootDisplayName = normalized?.lastPathComponent + tree = nil + rows = [] + gitMetadata = .empty + errorMessage = nil + collapsedDirectories.removeAll() + refresh() + } + + func beginRemoteLoad(profile: SSHProfile) -> Int { + refreshGeneration += 1 + cancelWorkers() + rootURL = profile.rootTarget.sourceURL + rootDisplayName = profile.name + tree = nil + rows = [] + selectedURL = nil + gitMetadata = .empty + errorMessage = nil + collapsedDirectories.removeAll() + isLoading = true + return refreshGeneration + } + + func finishRemoteLoad( + profile: SSHProfile, + listing: RemoteDocumentListing, + generation: Int + ) -> Bool { + guard generation == refreshGeneration, + rootURL == profile.rootTarget.sourceURL else { return false } + rootURL = listing.root.sourceURL + tree = FileTreeBuilder.buildRemote(root: listing.root, files: listing.files) + isLoading = false + errorMessage = nil + rebuildRows() + return true + } + + func failRemoteLoad(profile: SSHProfile, message: String, generation: Int) -> Bool { + guard generation == refreshGeneration, + rootURL == profile.rootTarget.sourceURL else { return false } + tree = nil + rows = [] + isLoading = false + errorMessage = message + return true + } + + func refresh() { + refreshTask?.cancel() + metadataTask?.cancel() + treeWorker?.cancel() + metadataWorker?.cancel() + refreshGeneration += 1 + let generation = refreshGeneration + + guard let rootURL else { + tree = nil + rows = [] + isLoading = false + errorMessage = nil + return + } + + isLoading = true + errorMessage = nil + let includeGitChanges = BrowserDisplaySettings.showGitChanges() + let includeLastEdited = BrowserDisplaySettings.showLastEdited() + + let treeWorker = Task.detached(priority: .userInitiated) { + do { + return TreeBuildResult.success( + try PerformanceTrace.measure("FileTreeBuild") { + try FileTreeBuilder.build(at: 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): + self.tree = tree + self.rebuildRows() + case .failure(let errorMessage): + self.metadataTask?.cancel() + self.metadataWorker?.cancel() + self.tree = nil + self.rows = [] + self.gitMetadata = .empty + self.errorMessage = errorMessage + } + } + + guard includeGitChanges || includeLastEdited else { + gitMetadata = .empty + return + } + let metadataWorker = Task.detached(priority: .utility) { + PerformanceTrace.measure("GitMetadataBuild") { + GitMetadataCollector.collect( + for: rootURL, + includeChanges: includeGitChanges, + includeLastEdited: includeLastEdited + ) + } + } + self.metadataWorker = metadataWorker + metadataTask = Task { [weak self] in + let metadata = await metadataWorker.value + + guard let self, + !Task.isCancelled, + generation == self.refreshGeneration else { return } + self.gitMetadata = metadata + } + } + + func setSelectedURL(_ url: URL?) { + let normalized = Self.normalized(url) + if selectedURL != normalized { + selectedURL = normalized + } + } + + func toggleDirectory(_ url: URL) { + let normalized = Self.normalized(url)! + if collapsedDirectories.contains(normalized) { + collapsedDirectories.remove(normalized) + } else { + collapsedDirectories.insert(normalized) + } + rebuildRows() + } + + func cancelAll() { + refreshGeneration += 1 + cancelWorkers() + isLoading = false + } + + 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? { + guard let url else { return nil } + return url.isFileURL ? url.standardizedFileURL : url + } + + private func rebuildRows() { + rows = PerformanceTrace.measure("FileTreeFlatten") { + FileTreeBuilder.visibleRows( + in: tree, + collapsedDirectories: collapsedDirectories + ) + } + PerformanceTrace.fileTreePublished(rowCount: rows.count) + } +} diff --git a/Sources/mindle/FileBrowserView.swift b/Sources/mindle/FileBrowserView.swift new file mode 100644 index 0000000..54de6ed --- /dev/null +++ b/Sources/mindle/FileBrowserView.swift @@ -0,0 +1,255 @@ +import Foundation +import SwiftUI + +struct FileBrowserSidebar: View { + @ObservedObject var browser: FileBrowserState + @AppStorage(BrowserDisplaySettings.showGitChangesKey) private var showGitChanges = true + @AppStorage(BrowserDisplaySettings.showLastEditedKey) private var showLastEdited = true + @AppStorage(BrowserDisplaySettings.highlightActiveFileKey) private var highlightActiveFile = true + let theme: ReaderTheme + let onRefresh: () -> Void + let onOpen: (URL) -> Void + + var body: some View { + FileBrowserSidebarContent( + rootDisplayName: browser.rootDisplayName, + rootURL: browser.rootURL, + tree: browser.tree, + rows: browser.rows, + selectedURL: browser.selectedURL, + isLoading: browser.isLoading, + errorMessage: browser.errorMessage, + gitMetadata: browser.gitMetadata, + showGitChanges: showGitChanges, + showLastEdited: showLastEdited, + highlightActiveFile: highlightActiveFile, + theme: theme, + now: nil, + onRefresh: onRefresh, + onToggle: browser.toggleDirectory, + onOpen: onOpen + ) + } +} + +struct FileBrowserSidebarContent: View { + let rootDisplayName: String? + let rootURL: URL? + let tree: FileNode? + let rows: [FileTreeRowModel] + let selectedURL: URL? + let isLoading: Bool + let errorMessage: String? + let gitMetadata: GitMetadataSnapshot + let showGitChanges: Bool + let showLastEdited: Bool + let highlightActiveFile: Bool + let theme: ReaderTheme + let now: Date? + let onRefresh: () -> Void + let onToggle: (URL) -> 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(rootDisplayName ?? rootURL?.lastPathComponent ?? "Files") + .font(.system(size: 13, weight: .semibold, design: .serif)) + .foregroundStyle(c.text) + .lineLimit(1) + .truncationMode(.middle) + .help(rootURL?.absoluteString ?? "Files") + Spacer() + Button { + onRefresh() + } 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 isLoading && tree == nil { + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let 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 !rows.isEmpty { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(rows) { row in + let metadata = gitMetadata.files[row.url] + FileTreeRow( + row: row, + isCurrent: selectedURL == row.url.standardizedFileURL, + changes: showGitChanges ? metadata?.changes : nil, + lastEditedAt: showLastEdited ? metadata?.lastEditedAt : nil, + highlightActiveFile: highlightActiveFile, + theme: theme, + now: now, + onToggle: onToggle, + onOpen: onOpen + ) + .equatable() + } + } + .padding(.vertical, 6) + } + } 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) + } +} + +struct FileTreeRow: View, Equatable { + let row: FileTreeRowModel + let isCurrent: Bool + let changes: GitFileChanges? + let lastEditedAt: Date? + let highlightActiveFile: Bool + let theme: ReaderTheme + let now: Date? + 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.highlightActiveFile == rhs.highlightActiveFile + && lhs.theme == rhs.theme + && lhs.now == rhs.now + } + + 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) + } else { + let isHighlighted = highlightActiveFile && isCurrent + Button { + onOpen(row.url) + } label: { + HStack(spacing: 6) { + Spacer().frame(width: 10) + Image(systemName: "doc.text") + .font(.system(size: 11)) + .foregroundStyle(isHighlighted ? 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, now: now) + } + } + .padding(.leading, CGFloat(row.depth) * 14 + 8) + .padding(.trailing, 10) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isHighlighted ? c.accent.opacity(theme == .dark ? 0.28 : 0.22) : Color.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + + 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") + } + } + + struct LastEditedBadge: View { + let date: Date + let theme: ReaderTheme + let now: Date? + + var body: some View { + let c = theme.colors + Text(GitLastEditedFormatter.badgeText(since: date, now: now ?? 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..93826ee --- /dev/null +++ b/Sources/mindle/FileTree.swift @@ -0,0 +1,214 @@ +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 +} + +struct RemoteDocumentListing: Equatable { + let root: SSHTarget + let files: [SSHTarget] +} + +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 + let entries = try contents(of: normalizedRoot, fileManager: fileManager) + return FileNode( + url: normalizedRoot, + name: normalizedRoot.lastPathComponent, + isDirectory: true, + children: try buildChildren(entries, fileManager: fileManager) + ) + } + + static func buildRemote(root: SSHTarget, files: [SSHTarget]) -> FileNode { + final class MutableNode { + let name: String + let target: SSHTarget + let isDirectory: Bool + var children: [String: MutableNode] = [:] + + init(name: String, target: SSHTarget, isDirectory: Bool) { + self.name = name + self.target = target + self.isDirectory = isDirectory + } + } + + let rootNode = MutableNode( + name: (root.remotePath as NSString).lastPathComponent, + target: root, + isDirectory: true + ) + let rootPrefix = root.remotePath.hasSuffix("/") ? root.remotePath : root.remotePath + "/" + + for file in files where file.userHost == root.userHost && file.remotePath.hasPrefix(rootPrefix) { + let relative = String(file.remotePath.dropFirst(rootPrefix.count)) + let components = relative.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + guard !components.isEmpty else { continue } + + var parent = rootNode + var currentPath = root.remotePath + for (index, component) in components.enumerated() { + currentPath = (currentPath as NSString).appendingPathComponent(component) + let isDirectory = index < components.count - 1 + if let existing = parent.children[component] { + parent = existing + } else if let target = SSHTarget(userHost: root.userHost, remotePath: currentPath) { + let node = MutableNode(name: component, target: target, isDirectory: isDirectory) + parent.children[component] = node + parent = node + } + } + } + + func freeze(_ node: MutableNode) -> FileNode { + let children = node.children.values + .sorted { + if $0.isDirectory != $1.isDirectory { return $0.isDirectory } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + .map(freeze) + return FileNode( + url: node.target.sourceURL!, + name: node.name, + isDirectory: node.isDirectory, + children: node.isDirectory ? children : nil + ) + } + + return freeze(rootNode) + } + + 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..6af0462 --- /dev/null +++ b/Sources/mindle/GitFileMetadata.swift @@ -0,0 +1,258 @@ +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: [:]) +} + +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, + includeChanges: Bool, + includeLastEdited: Bool + ) -> GitMetadataSnapshot { + guard includeChanges || includeLastEdited, + !Task.isCancelled, + let repositoryRoot = repositoryRoot(containing: browserRoot) else { + return .empty + } + + var metadata: [URL: GitFileMetadata] = [:] + if includeChanges { + let changes = workingTreeChanges( + browserRoot: browserRoot + ) + for (relativePath, change) in changes { + let url = repositoryRoot.appendingPathComponent(relativePath).standardizedFileURL + guard FileTreeBuilder.isDescendant(url, of: browserRoot) else { continue } + metadata[url, default: GitFileMetadata()].changes = change + } + } + + if includeLastEdited && !Task.isCancelled { + let timestamps = lastEditedTimestamps(browserRoot: browserRoot) + for (relativePath, timestamp) in timestamps { + let url = repositoryRoot.appendingPathComponent(relativePath).standardizedFileURL + guard FileTreeBuilder.isDescendant(url, of: browserRoot) 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 } + let path = String(fields[2]) + changes[path] = 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 repositoryRoot(containing url: URL) -> URL? { + let result = runGit(["-C", url.path, "rev-parse", "--show-toplevel"]) + guard result.status == 0, + let path = String(data: result.output, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty else { + return nil + } + return URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + } + + private static func workingTreeChanges( + browserRoot: URL + ) -> [String: GitFileChanges] { + guard !Task.isCancelled else { return [:] } + let pathspecs = supportedFilePathspecs + let headDiff = runGit( + ["-C", browserRoot.path, "diff", "--numstat", "-z", "--no-renames", "HEAD", "--"] + + pathspecs + ) + + var changes: [String: GitFileChanges] + if headDiff.status == 0 { + changes = parseNumstat(headDiff.output) + } else { + changes = [:] + merge(parseNumstat(runGit( + ["-C", browserRoot.path, "diff", "--numstat", "-z", "--no-renames", "--cached", "--"] + + pathspecs + ).output), + into: &changes + ) + merge(parseNumstat(runGit( + ["-C", browserRoot.path, "diff", "--numstat", "-z", "--no-renames", "--"] + + pathspecs + ).output), + into: &changes + ) + } + + guard !Task.isCancelled else { return changes } + let status = runGit( + ["-C", browserRoot.path, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--"] + + pathspecs + ) + for path in parseUntrackedPaths(status.output) { + changes[path] = untrackedChanges() + } + return changes + } + + private static func lastEditedTimestamps( + browserRoot: URL + ) -> [String: Int64] { + guard !Task.isCancelled else { return [:] } + let result = runGit( + [ + "-C", browserRoot.path, + "log", "--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 untrackedChanges() -> GitFileChanges { + GitFileChanges(additions: nil, deletions: nil, isUntracked: true) + } + + 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 func runGit(_ arguments: [String]) -> (status: Int32, output: Data) { + let process = Process() + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("mindle-git-\(UUID().uuidString)") + guard FileManager.default.createFile(atPath: outputURL.path, contents: nil), + let output = try? FileHandle(forWritingTo: outputURL) else { + return (-1, Data()) + } + defer { + try? output.close() + try? FileManager.default.removeItem(at: outputURL) + } + + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.standardOutput = output + process.standardError = FileHandle.nullDevice + process.environment = ProcessInfo.processInfo.environment.merging([ + "GIT_OPTIONAL_LOCKS": "0", + "LC_ALL": "C" + ]) { _, new in new } + + do { + try process.run() + while process.isRunning { + if Task.isCancelled { + process.terminate() + } + Thread.sleep(forTimeInterval: 0.01) + } + process.waitUntilExit() + try output.synchronize() + guard !Task.isCancelled else { return (-1, Data()) } + let data = (try? Data(contentsOf: outputURL, options: .mappedIfSafe)) ?? Data() + return (process.terminationStatus, data) + } catch { + return (-1, Data()) + } + } + + 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().map { + ":(glob,icase)**/*.\($0)" + } + } +} diff --git a/Sources/mindle/ImageSchemeHandler.swift b/Sources/mindle/ImageSchemeHandler.swift index 8cb4222..486c617 100644 --- a/Sources/mindle/ImageSchemeHandler.swift +++ b/Sources/mindle/ImageSchemeHandler.swift @@ -31,7 +31,8 @@ final class ImageSchemeHandler: NSObject, WKURLSchemeHandler { httpVersion: "HTTP/1.1", headerFields: [ "Content-Type": mime, - "Content-Length": "\(data.count)" + "Content-Length": "\(data.count)", + "Cache-Control": "no-store" ] )! urlSchemeTask.didReceive(resp) diff --git a/Sources/mindle/MindleApp.swift b/Sources/mindle/MindleApp.swift index 996b87f..c5e582d 100644 --- a/Sources/mindle/MindleApp.swift +++ b/Sources/mindle/MindleApp.swift @@ -220,7 +220,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if let target = SSHTarget(sourceURL: url) { routeRemoteOpen(target) } else if let store = activeStore { - store.open(url: url) + store.openItem(url: url) } else { // Called before any RootView has registered its store; buffer // and replay into the first window that appears. @@ -315,7 +315,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if let target = SSHTarget(sourceURL: first) { Task { await store.openRemote(target) } } else { - store.open(url: first) + store.openItem(url: first) } } } @@ -361,7 +361,7 @@ struct RootView: View { let args = CommandLine.arguments.dropFirst() if store.fileURL == nil, let path = args.first(where: { !$0.hasPrefix("-") }) { - store.open(url: URL(fileURLWithPath: path)) + store.openItem(url: URL(fileURLWithPath: path)) } } } @@ -390,6 +390,9 @@ struct MindleCommands: Commands { Button("Open…") { store?.openWithPanel() } .keyboardShortcut("o", modifiers: .command) .disabled(store == nil) + Button("Open Folder…") { store?.openDirectoryWithPanel() } + .keyboardShortcut("o", modifiers: [.command, .option]) + .disabled(store == nil) OpenRecentMenu(store: store) Button("Open URL…") { store?.openURLWithPrompt() } .keyboardShortcut("l", modifiers: [.command, .shift]) @@ -487,12 +490,12 @@ 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() } } .keyboardShortcut("f", modifiers: [.command, .shift]) - .disabled(store?.fileURL == nil) + .disabled(store?.fileURL == nil && store?.fileBrowserRootURL == nil) Button((store?.showAnnotations ?? false) ? "Hide Annotations" : "Show Annotations") { store?.showAnnotations.toggle() diff --git a/Sources/mindle/PDFReaderView.swift b/Sources/mindle/PDFReaderView.swift index b2be1fe..0eae912 100644 --- a/Sources/mindle/PDFReaderView.swift +++ b/Sources/mindle/PDFReaderView.swift @@ -294,12 +294,17 @@ struct PDFReaderView: NSViewRepresentable { guard let url = store.fileURL, url.isFileURL else { view.document = nil context.coordinator.lastLoadedURL = nil + context.coordinator.lastLoadedRemoteRevision = nil + return + } + if context.coordinator.lastLoadedURL == url, + context.coordinator.lastLoadedRemoteRevision == store.remoteAssetRevision { return } - if context.coordinator.lastLoadedURL == url { return } if let doc = PDFDocument(url: url) { view.document = doc context.coordinator.lastLoadedURL = url + context.coordinator.lastLoadedRemoteRevision = store.remoteAssetRevision // Document loads can outpace the first layout pass — fit-width // again now that the page dimensions are known. view.applyFitWidth() @@ -307,6 +312,7 @@ struct PDFReaderView: NSViewRepresentable { } else { view.document = nil context.coordinator.lastLoadedURL = nil + context.coordinator.lastLoadedRemoteRevision = nil store.pdfStatus = .unloadable } } @@ -314,6 +320,7 @@ struct PDFReaderView: NSViewRepresentable { final class Coordinator { weak var view: FitWidthPDFView? var lastLoadedURL: URL? + var lastLoadedRemoteRevision: Int? var lastFontScale: CGFloat = 0 var lastFitWidthAt: Date? var lastHighlightAt: Date? diff --git a/Sources/mindle/PerformanceTrace.swift b/Sources/mindle/PerformanceTrace.swift new file mode 100644 index 0000000..8de6099 --- /dev/null +++ b/Sources/mindle/PerformanceTrace.swift @@ -0,0 +1,26 @@ +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 fileTreePublished(rowCount: Int) { + os_signpost( + .event, + log: log, + name: "FileTreePublished", + "%{public}d visible rows", + rowCount + ) + } +} diff --git a/Sources/mindle/RemoteMarkdownAssets.swift b/Sources/mindle/RemoteMarkdownAssets.swift new file mode 100644 index 0000000..6ea8349 --- /dev/null +++ b/Sources/mindle/RemoteMarkdownAssets.swift @@ -0,0 +1,75 @@ +import Foundation + +enum RemoteMarkdownAssets { + static func relativePaths(in markdown: String) -> [String] { + var paths: [String] = [] + var seen: Set = [] + + func appendMatches(pattern: String, allowedLabels: Set? = nil) { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return } + let range = NSRange(markdown.startIndex..., in: markdown) + for match in regex.matches(in: markdown, range: range) { + if let allowedLabels, + let labelRange = Range(match.range(at: 1), in: markdown) { + let label = String(markdown[labelRange]).lowercased() + guard allowedLabels.contains(label) else { continue } + } + let firstPathCapture = allowedLabels == nil ? 1 : 2 + let candidate = [firstPathCapture, firstPathCapture + 1].compactMap { capture in + Range(match.range(at: capture), in: markdown).map { String(markdown[$0]) } + }.first + guard let path = normalizedRelativePath(candidate), seen.insert(path).inserted else { + continue + } + paths.append(path) + } + } + + appendMatches(pattern: #"!\[[^\]]*\]\(\s*(?:<([^>]+)>|([^\s\)]+))"#) + + let referenceRegex = try? NSRegularExpression(pattern: #"!\[([^\]]*)\]\[([^\]]*)\]"#) + let fullRange = NSRange(markdown.startIndex..., in: markdown) + let labels: [String] = referenceRegex?.matches(in: markdown, range: fullRange).compactMap { + let explicit = Range($0.range(at: 2), in: markdown).map { String(markdown[$0]) } ?? "" + let fallback = Range($0.range(at: 1), in: markdown).map { String(markdown[$0]) } ?? "" + let label = explicit.isEmpty ? fallback : explicit + return label.isEmpty ? nil : label.lowercased() + } ?? [] + let referencedLabels = Set(labels) + if !referencedLabels.isEmpty { + appendMatches( + pattern: #"(?m)^\s*\[([^\]]+)\]:\s*(?:<([^>]+)>|(\S+))"#, + allowedLabels: referencedLabels + ) + } + return paths + } + + static func target(for relativePath: String, from document: SSHTarget) -> SSHTarget? { + guard let relative = normalizedRelativePath(relativePath) else { return nil } + let parent = (document.remotePath as NSString).deletingLastPathComponent + let joined = (parent as NSString).appendingPathComponent(relative) + let normalized = (joined as NSString).standardizingPath + return SSHTarget(userHost: document.userHost, remotePath: normalized) + } + + private static func normalizedRelativePath(_ candidate: String?) -> String? { + guard var path = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty else { + return nil + } + if let marker = path.firstIndex(where: { $0 == "?" || $0 == "#" }) { + path = String(path[.. URL { + guard let support = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + throw SSHProfileConfigurationError.unavailable + } + return support + .appendingPathComponent("Mindle", isDirectory: true) + .appendingPathComponent("ssh-profiles.yaml") + } + + @discardableResult + static func ensureConfigExists(fileManager: FileManager = .default) throws -> URL { + let url = try configURL(fileManager: fileManager) + guard !fileManager.fileExists(atPath: url.path) else { return url } + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try defaultYAML.write(to: url, atomically: true, encoding: .utf8) + return url + } + + static func load(fileManager: FileManager = .default) throws -> [SSHProfile] { + let url = try ensureConfigExists(fileManager: fileManager) + return try parse(String(contentsOf: url, encoding: .utf8)) + } + + static func favoriteProfile(in profiles: [SSHProfile]) -> SSHProfile? { + profiles.first(where: \.favorite) ?? profiles.first + } + + static func parse(_ yaml: String) throws -> [SSHProfile] { + struct Draft { + let line: Int + var fields: [String: String] + } + + var drafts: [Draft] = [] + var current: Draft? + var sawProfiles = false + + func finishCurrent() { + if let current { + drafts.append(current) + } + current = nil + } + + for (index, rawLine) in yaml.components(separatedBy: .newlines).enumerated() { + let lineNumber = index + 1 + let line = stripComment(rawLine).trimmingCharacters(in: .whitespaces) + if line.isEmpty { continue } + if line == "profiles:" { + finishCurrent() + sawProfiles = true + continue + } + guard sawProfiles else { + throw SSHProfileConfigurationError.invalidLine(lineNumber, line) + } + + if line.hasPrefix("-") { + finishCurrent() + current = Draft(line: lineNumber, fields: [:]) + let remainder = line.dropFirst().trimmingCharacters(in: .whitespaces) + if !remainder.isEmpty { + let (key, value) = try field(in: String(remainder), line: lineNumber) + current!.fields[key] = value + } + } else { + guard current != nil else { + throw SSHProfileConfigurationError.invalidLine(lineNumber, line) + } + let (key, value) = try field(in: line, line: lineNumber) + current!.fields[key] = value + } + } + finishCurrent() + + guard !drafts.isEmpty else { + throw SSHProfileConfigurationError.noProfiles + } + + var favoriteCount = 0 + let profiles = try drafts.map { draft -> SSHProfile in + guard let name = nonEmpty(draft.fields["name"]) else { + throw SSHProfileConfigurationError.missingField(draft.line, "name") + } + guard let hostname = nonEmpty(draft.fields["hostname"]) else { + throw SSHProfileConfigurationError.missingField(draft.line, "hostname") + } + guard let rootPath = nonEmpty(draft.fields["path"]) else { + throw SSHProfileConfigurationError.missingField(draft.line, "path") + } + guard rootPath.hasPrefix("/") else { + throw SSHProfileConfigurationError.invalidPath(draft.line, rootPath) + } + + let favorite: Bool + switch draft.fields["favorite"]?.lowercased() { + case nil, "false": + favorite = false + case "true": + favorite = true + favoriteCount += 1 + case let value?: + throw SSHProfileConfigurationError.invalidFavorite(draft.line, value) + } + + guard let profile = SSHProfile( + name: name, + hostname: hostname, + rootPath: (rootPath as NSString).standardizingPath, + favorite: favorite + ) else { + throw SSHProfileConfigurationError.invalidHostname(draft.line, hostname) + } + return profile + } + + guard favoriteCount <= 1 else { + throw SSHProfileConfigurationError.duplicateFavorite + } + return profiles + } + + private static func field(in text: String, line: Int) throws -> (String, String) { + guard let colon = text.firstIndex(of: ":") else { + throw SSHProfileConfigurationError.invalidLine(line, text) + } + let key = text[.. String { + var quote: Character? + var escaped = false + for index in line.indices { + let character = line[index] + if escaped { + escaped = false + continue + } + if character == "\\", quote == "\"" { + escaped = true + continue + } + if character == "\"" || character == "'" { + quote = quote == character ? nil : (quote ?? character) + continue + } + if character == "#", quote == nil { + return String(line[.. String { + guard value.count >= 2, + let first = value.first, + let last = value.last, + (first == "\"" && last == "\"") || (first == "'" && last == "'") else { + return value + } + let inner = String(value.dropFirst().dropLast()) + if first == "\"" { + return inner + .replacingOccurrences(of: "\\\"", with: "\"") + .replacingOccurrences(of: "\\\\", with: "\\") + } + return inner.replacingOccurrences(of: "''", with: "'") + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } +} diff --git a/Sources/mindle/SSHTarget.swift b/Sources/mindle/SSHTarget.swift index 24f1319..525317b 100644 --- a/Sources/mindle/SSHTarget.swift +++ b/Sources/mindle/SSHTarget.swift @@ -27,10 +27,46 @@ struct SSHTarget: Equatable { return comps.url } - /// Deterministic local proxy: `//`. + /// Deterministic local proxy that mirrors the remote directory structure. func proxyURL(cacheDir: URL) -> URL { - cacheDir.appendingPathComponent(Self.fnv1a(canonical), isDirectory: true) - .appendingPathComponent(basename) + return remotePath + .split(separator: "/", omittingEmptySubsequences: true) + .reduce( + cacheDir.appendingPathComponent(Self.fnv1a(userHost), isDirectory: true) + ) { partial, component in + partial.appendingPathComponent(String(component)) + } + } + + func migrateLegacyCacheIfNeeded( + cacheDir: URL, + fileManager: FileManager = .default + ) throws { + let legacyProxy = cacheDir + .appendingPathComponent(Self.fnv1a(canonical), isDirectory: true) + .appendingPathComponent(basename) + let proxy = proxyURL(cacheDir: cacheDir) + let legacySidecar = legacyProxy.deletingLastPathComponent() + .appendingPathComponent(".\(legacyProxy.lastPathComponent).mindle.json") + let sidecar = proxy.deletingLastPathComponent() + .appendingPathComponent(".\(proxy.lastPathComponent).mindle.json") + + guard fileManager.fileExists(atPath: legacyProxy.path) + || fileManager.fileExists(atPath: legacySidecar.path) else { + return + } + try fileManager.createDirectory( + at: proxy.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if fileManager.fileExists(atPath: legacyProxy.path), + !fileManager.fileExists(atPath: proxy.path) { + try fileManager.moveItem(at: legacyProxy, to: proxy) + } + if fileManager.fileExists(atPath: legacySidecar.path), + !fileManager.fileExists(atPath: sidecar.path) { + try fileManager.moveItem(at: legacySidecar, to: sidecar) + } } // MARK: Parsing @@ -43,9 +79,7 @@ struct SSHTarget: Equatable { guard let colon = s.firstIndex(of: ":") else { return nil } let uh = String(s[../` URL. `url.path` is already @@ -57,14 +91,20 @@ struct SSHTarget: Equatable { guard let slash = trimmed.firstIndex(of: "/") else { return nil } let uh = String(trimmed[.. 1 else { return nil } - self.userHost = uh - self.remotePath = rp + guard rp.count > 1 else { return nil } + self.init(userHost: uh, remotePath: rp) } - private init(userHost: String, remotePath: String) { - self.userHost = userHost - self.remotePath = remotePath + init?(userHost: String, remotePath: String) { + let normalizedHost = userHost.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedPath = (remotePath as NSString).standardizingPath + guard !normalizedHost.isEmpty, + !normalizedHost.hasPrefix("-"), + normalizedHost.rangeOfCharacter(from: .whitespacesAndNewlines) == nil, + !normalizedHost.contains("/"), + normalizedPath.hasPrefix("/") else { return nil } + self.userHost = normalizedHost + self.remotePath = normalizedPath } /// FNV-1a 64-bit hash, hex — same family as DocumentStore's content/url diff --git a/Sources/mindle/SSHTransport.swift b/Sources/mindle/SSHTransport.swift index ffcf8e7..fdda4b1 100644 --- a/Sources/mindle/SSHTransport.swift +++ b/Sources/mindle/SSHTransport.swift @@ -2,6 +2,11 @@ import Foundation struct ProcessResult { let status: Int32; let stdout: Data; let stderr: Data } +struct RemoteAssetFetchFailure: Equatable { + let path: String + let message: String +} + /// Runs an external process. Behind a protocol so tests inject a fake — /// real ssh/scp I/O is not unit-testable. protocol ProcessRunner { @@ -16,13 +21,43 @@ struct SystemProcessRunner: ProcessRunner { proc.arguments = arguments let out = Pipe(); let err = Pipe() proc.standardOutput = out; proc.standardError = err - proc.terminationHandler = { p in - let o = out.fileHandleForReading.readDataToEndOfFile() - let e = err.fileHandleForReading.readDataToEndOfFile() - cont.resume(returning: ProcessResult(status: p.terminationStatus, stdout: o, stderr: e)) - } - do { try proc.run() } catch { + do { + try proc.run() + } catch { cont.resume(throwing: SSHTransportError.launchFailed(error.localizedDescription)) + return + } + + DispatchQueue.global(qos: .userInitiated).async { + let reads = DispatchGroup() + let lock = NSLock() + var stdout = Data() + var stderr = Data() + + reads.enter() + DispatchQueue.global(qos: .userInitiated).async { + let data = out.fileHandleForReading.readDataToEndOfFile() + lock.lock() + stdout = data + lock.unlock() + reads.leave() + } + reads.enter() + DispatchQueue.global(qos: .userInitiated).async { + let data = err.fileHandleForReading.readDataToEndOfFile() + lock.lock() + stderr = data + lock.unlock() + reads.leave() + } + + proc.waitUntilExit() + reads.wait() + cont.resume(returning: ProcessResult( + status: proc.terminationStatus, + stdout: stdout, + stderr: stderr + )) } } } @@ -31,6 +66,7 @@ struct SystemProcessRunner: ProcessRunner { enum SSHTransportError: Error, LocalizedError { case nonZeroExit(status: Int32, stderr: String) case launchFailed(String) + case invalidListing var errorDescription: String? { switch self { @@ -39,6 +75,8 @@ enum SSHTransportError: Error, LocalizedError { return trimmed.isEmpty ? "SSH command failed." : trimmed case .launchFailed(let m): return "Couldn't launch ssh/scp: \(m)" + case .invalidListing: + return "SSH returned an invalid file listing." } } } @@ -50,6 +88,8 @@ enum SSHTransport { static let scpPath = "/usr/bin/scp" static let sshPath = "/usr/bin/ssh" static let remoteTmpSuffix = ".mindle-tmp" + static let listingRootMarker = "\u{1e}MINDLE_ROOT\u{1e}" + private static let cacheWriteLock = NSLock() /// POSIX single-quote: wrap in '…', escaping embedded ' as '\''. static func shellSingleQuote(_ s: String) -> String { @@ -89,6 +129,20 @@ enum SSHTransport { return sshFlags + [target.userHost, cmd] } + static func listDocumentsArgs(_ profile: SSHProfile) -> [String] { + let extensions = FileTreeBuilder.browsableExtensions.sorted().map { + "-iname \(shellSingleQuote("*.\($0)"))" + }.joined(separator: " -o ") + let configuredRoot = shellSingleQuote(profile.rootPath) + let command = """ + root=\(configuredRoot); \ + if [ ! -d "$root" ]; then root=$HOME; fi; \ + printf '\\036MINDLE_ROOT\\036%s\\0' "$root"; \ + find "$root" -path '*/.*' -prune -o -type f \\( \(extensions) \\) -print0 + """ + return sshFlags + [profile.hostname, command] + } + // MARK: Operations /// Fetch the remote file to `proxyURL` atomically: scp to a sibling @@ -97,16 +151,84 @@ enum SSHTransport { static func fetch(_ target: SSHTarget, to proxyURL: URL, runner: ProcessRunner = SystemProcessRunner()) async throws { let fm = FileManager.default try fm.createDirectory(at: proxyURL.deletingLastPathComponent(), withIntermediateDirectories: true) - let tmp = proxyURL.appendingPathExtension("fetch") - try? fm.removeItem(at: tmp) + let tmp = proxyURL.appendingPathExtension("fetch-\(UUID().uuidString)") let res = try await runner.run(launchPath: scpPath, arguments: fetchArgs(target, tmp: tmp)) guard res.status == 0 else { try? fm.removeItem(at: tmp) throw SSHTransportError.nonZeroExit(status: res.status, stderr: String(data: res.stderr, encoding: .utf8) ?? "") } - if fm.fileExists(atPath: proxyURL.path) { try fm.removeItem(at: proxyURL) } - try fm.moveItem(at: tmp, to: proxyURL) + try cacheWriteLock.withLock { + if fm.fileExists(atPath: proxyURL.path) { + try fm.removeItem(at: proxyURL) + } + try fm.moveItem(at: tmp, to: proxyURL) + } + } + + static func listDocuments( + in profile: SSHProfile, + runner: ProcessRunner = SystemProcessRunner() + ) async throws -> RemoteDocumentListing { + let result = try await runner.run( + launchPath: sshPath, + arguments: listDocumentsArgs(profile) + ) + guard result.status == 0 else { + throw SSHTransportError.nonZeroExit( + status: result.status, + stderr: String(data: result.stderr, encoding: .utf8) ?? "" + ) + } + + let output = String(data: result.stdout, encoding: .utf8) ?? "" + let tokens = output.split(separator: "\0", omittingEmptySubsequences: true).map(String.init) + guard let rootPath = tokens.compactMap({ token -> String? in + guard let marker = token.range(of: listingRootMarker) else { return nil } + return String(token[marker.upperBound...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + }).first, + let root = SSHTarget(userHost: profile.hostname, remotePath: rootPath) else { + throw SSHTransportError.invalidListing + } + + let rootPrefix = root.remotePath.hasSuffix("/") + ? root.remotePath + : root.remotePath + "/" + let files = tokens.compactMap { token -> SSHTarget? in + guard !token.contains(listingRootMarker) else { return nil } + let path = token.trimmingCharacters(in: .newlines) + guard path.hasPrefix(rootPrefix) else { return nil } + return SSHTarget(userHost: root.userHost, remotePath: path) + } + .sorted { $0.remotePath.localizedCaseInsensitiveCompare($1.remotePath) == .orderedAscending } + return RemoteDocumentListing(root: root, files: files) + } + + static func fetchReferencedImages( + in markdown: String, + for document: SSHTarget, + cacheDir: URL, + runner: ProcessRunner = SystemProcessRunner() + ) async -> [RemoteAssetFetchFailure] { + var failures: [RemoteAssetFetchFailure] = [] + for relativePath in RemoteMarkdownAssets.relativePaths(in: markdown) { + guard let target = RemoteMarkdownAssets.target( + for: relativePath, + from: document + ) else { + continue + } + do { + try await fetch(target, to: target.proxyURL(cacheDir: cacheDir), runner: runner) + } catch { + failures.append(RemoteAssetFetchFailure( + path: relativePath, + message: error.localizedDescription + )) + } + } + return failures } /// Upload `proxyURL` to a remote temp path, then remote-`mv` it onto the diff --git a/Sources/mindle/SettingsView.swift b/Sources/mindle/SettingsView.swift index 533bccc..98e037e 100644 --- a/Sources/mindle/SettingsView.swift +++ b/Sources/mindle/SettingsView.swift @@ -1,7 +1,14 @@ import SwiftUI +import AppKit struct SettingsView: View { @AppStorage("mindle.fontScale") private var defaultFontScale: Double = 1.0 + @AppStorage(BrowserDisplaySettings.showGitChangesKey) private var showGitChanges = true + @AppStorage(BrowserDisplaySettings.showLastEditedKey) private var showLastEdited = true + @AppStorage(BrowserDisplaySettings.highlightActiveFileKey) private var highlightActiveFile = true + @State private var sshProfiles: [SSHProfile] = [] + @State private var sshProfilesError: String? + @State private var sshProfilesURL: URL? var body: some View { Form { @@ -35,8 +42,77 @@ struct SettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) } } + + Section("File Browser") { + Toggle("Show Git additions and deletions", isOn: $showGitChanges) + Toggle("Show last edited from Git history", isOn: $showLastEdited) + Toggle("Highlight the active file", isOn: $highlightActiveFile) + } + + Section("SSH Profiles") { + if let error = sshProfilesError { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + } else { + ForEach(sshProfiles) { profile in + HStack(spacing: 10) { + Image(systemName: profile.favorite ? "star.fill" : "server.rack") + .foregroundStyle(profile.favorite ? .yellow : .secondary) + .frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text(profile.name) + Text("\(profile.hostname):\(profile.rootPath)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + if profile.favorite { + Text("Favorite") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + HStack { + Button("Open YAML") { + if let url = sshProfilesURL { + NSWorkspace.shared.open(url) + } + } + .disabled(sshProfilesURL == nil) + + Button("Reload") { + loadSSHProfiles() + } + + Spacer() + if let url = sshProfilesURL { + Text(url.path) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } } .formStyle(.grouped) .frame(width: 450) + .onAppear { + loadSSHProfiles() + } + } + + private func loadSSHProfiles() { + do { + sshProfilesURL = try SSHProfileConfiguration.ensureConfigExists() + sshProfiles = try SSHProfileConfiguration.load() + sshProfilesError = nil + } catch { + sshProfiles = [] + sshProfilesError = error.localizedDescription + } } } 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/Sources/mindle/WebReaderView.swift b/Sources/mindle/WebReaderView.swift index 0769aba..1e57513 100644 --- a/Sources/mindle/WebReaderView.swift +++ b/Sources/mindle/WebReaderView.swift @@ -41,7 +41,10 @@ struct WebReaderView: NSViewRepresentable { // Re-render whenever rawText OR lastSyncedText changes — the diff // pipeline keys off the pair, so a baseline change has to flow // through to the view even when rawText is unchanged. - if store.rawText != coord.lastSource || store.lastSyncedText != coord.lastSyncedText { + if store.rawText != coord.lastSource + || store.lastSyncedText != coord.lastSyncedText + || store.fileURL != coord.lastFileURL + || store.remoteAssetRevision != coord.lastRemoteAssetRevision { // Same file, rawText changed → live reload, preserve scroll. // Different file (or first load) → fresh load, start at top. // Baseline-only change (accept/reject) preserves scroll too. @@ -49,6 +52,7 @@ struct WebReaderView: NSViewRepresentable { coord.lastSource = store.rawText coord.lastSyncedText = store.lastSyncedText coord.lastFileURL = store.fileURL + coord.lastRemoteAssetRevision = store.remoteAssetRevision let baseDir = store.fileURL?.deletingLastPathComponent().path ?? "" // mindleLoad's third arg is the diff baseline: when it differs // from arg one, the JS layer renders track-changes with chips. @@ -157,6 +161,7 @@ struct WebReaderView: NSViewRepresentable { var lastSource: String = "" var lastSyncedText: String = "" var lastFileURL: URL? + var lastRemoteAssetRevision = 0 var lastTheme: String = "" var lastFontScale: Double = 0 var lastReadingWidth: String = "" diff --git a/Tests/harness/FileBrowserStateChecks.swift b/Tests/harness/FileBrowserStateChecks.swift new file mode 100644 index 0000000..57bca36 --- /dev/null +++ b/Tests/harness/FileBrowserStateChecks.swift @@ -0,0 +1,132 @@ +import Foundation + +@MainActor +func runFileBrowserStateChecks() async -> Int { + let checks = Checks("FileBrowserState") + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("mindle-browser-state-\(UUID().uuidString)", isDirectory: true) + + do { + 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() + browser.setRoot(root) + checks.expect( + await waitUntil { !browser.isLoading }, + "local tree load completes" + ) + checks.equal(browser.rootURL, root.standardizedFileURL, "root normalized") + checks.equal(browser.rootDisplayName, root.lastPathComponent, "root display name") + checks.equal( + browser.rows.map(\.name), + ["Chapter", "notes.txt", "README.md"], + "local rows published" + ) + + browser.setSelectedURL(readme.appendingPathComponent("..").appendingPathComponent("README.md")) + checks.equal(browser.selectedURL, readme.standardizedFileURL, "selection normalized") + + browser.toggleDirectory(chapter) + checks.equal(browser.rows.map(\.name), ["Chapter", "README.md"], "directory collapses") + checks.expect(browser.rows[0].isExpanded == false, "collapsed row state published") + browser.toggleDirectory(chapter) + checks.equal(browser.rows.count, 3, "directory expands") + + let firstProfile = SSHProfile( + name: "First", + hostname: "first", + rootPath: "/workspace", + favorite: false + )! + let secondProfile = SSHProfile( + name: "Second", + hostname: "second", + rootPath: "/docs", + favorite: true + )! + let staleGeneration = browser.beginRemoteLoad(profile: firstProfile) + let currentGeneration = browser.beginRemoteLoad(profile: secondProfile) + checks.expect( + !browser.finishRemoteLoad( + profile: firstProfile, + listing: RemoteDocumentListing( + root: firstProfile.rootTarget, + files: [SSHTarget(userHostPath: "first:/workspace/old.md")!] + ), + generation: staleGeneration + ), + "stale remote result rejected" + ) + checks.expect( + browser.finishRemoteLoad( + profile: secondProfile, + listing: RemoteDocumentListing( + root: SSHTarget(userHostPath: "second:/Users/test")!, + files: [SSHTarget(userHostPath: "second:/Users/test/current.md")!] + ), + generation: currentGeneration + ), + "current remote result accepted" + ) + checks.equal(browser.rootDisplayName, "Second", "remote profile name displayed") + checks.equal( + SSHTarget(sourceURL: browser.rootURL!)?.remotePath, + "/Users/test", + "effective fallback root published" + ) + checks.equal(browser.rows.map(\.name), ["current.md"], "remote rows published") + + let failureGeneration = browser.beginRemoteLoad(profile: firstProfile) + checks.expect( + browser.failRemoteLoad( + profile: firstProfile, + message: "Connection failed", + generation: failureGeneration + ), + "current remote failure accepted" + ) + checks.equal(browser.errorMessage, "Connection failed", "remote error published") + checks.expect(!browser.isLoading, "remote failure clears loading") + + _ = browser.beginRemoteLoad(profile: secondProfile) + browser.cancelAll() + checks.expect(!browser.isLoading, "cancel clears loading") + + browser.setRoot(nil) + checks.equal(browser.rootURL, nil, "nil root clears root") + checks.equal(browser.rows, [], "nil root clears rows") + } catch { + checks.expect(false, "fixture setup failed: \(error)") + } + + let suiteName = "mindle-browser-settings-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + checks.expect(BrowserDisplaySettings.showGitChanges(defaults: defaults), "Git changes default on") + checks.expect(BrowserDisplaySettings.showLastEdited(defaults: defaults), "last edited default on") + checks.expect(BrowserDisplaySettings.highlightActiveFile(defaults: defaults), "active highlight default on") + defaults.set(false, forKey: BrowserDisplaySettings.showGitChangesKey) + checks.expect(!BrowserDisplaySettings.showGitChanges(defaults: defaults), "Git changes can be disabled") + + try? fileManager.removeItem(at: root) + print("FileBrowserState: \(checks.passed) passed, \(checks.failures) failed") + return checks.failures +} + +@MainActor +private func waitUntil( + attempts: Int = 200, + condition: () -> Bool +) async -> Bool { + for _ in 0.. Int { + let checks = Checks("FileTree") + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("mindle-file-tree-\(UUID().uuidString)", isDirectory: true) + + do { + 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"), "MDX remains unsupported") + + let rows = FileTreeBuilder.visibleRows(in: tree, collapsedDirectories: []) + checks.equal( + rows.map(\.name), + ["Alpha", "Nested", "b.markdown", "a.md", "Beta.txt", "paper.PDF"], + "all folders are expanded by default" + ) + checks.equal(rows.map(\.depth), [0, 1, 2, 1, 0, 0], "row depths") + + let collapsedRows = FileTreeBuilder.visibleRows( + in: tree, + collapsedDirectories: [alpha.standardizedFileURL] + ) + checks.equal( + collapsedRows.map(\.name), + ["Alpha", "Beta.txt", "paper.PDF"], + "collapsed directory hides descendants" + ) + + checks.expect( + FileTreeBuilder.isDescendant(nested.appendingPathComponent("b.markdown"), of: root), + "nested file is inside root" + ) + checks.expect( + !FileTreeBuilder.isDescendant(fileManager.temporaryDirectory, of: root), + "unrelated directory is outside 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") + + let remoteRoot = SSHTarget(userHostPath: "test:/workspace")! + let remoteTree = FileTreeBuilder.buildRemote( + root: remoteRoot, + files: [ + SSHTarget(userHostPath: "test:/workspace/book/README.md")!, + SSHTarget(userHostPath: "test:/workspace/notes.txt")!, + SSHTarget(userHostPath: "other:/workspace/ignored.md")!, + SSHTarget(userHostPath: "test:/workspace-other/ignored.md")! + ] + ) + let remoteRows = FileTreeBuilder.visibleRows(in: remoteTree, collapsedDirectories: []) + checks.equal( + remoteRows.map(\.name), + ["book", "README.md", "notes.txt"], + "remote files form a browsable tree" + ) + checks.equal( + SSHTarget(sourceURL: remoteRows[1].url)?.canonical, + "test:/workspace/book/README.md", + "remote row keeps SSH identity" + ) + } 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..d4a5cde --- /dev/null +++ b/Tests/harness/GitFileMetadataChecks.swift @@ -0,0 +1,131 @@ +import Foundation + +func runGitFileMetadataChecks() -> 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 history = Data(historyText.utf8) + let parsedHistory = GitMetadataCollector.parseLastEdited(history) + 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(-3_600), + now: now + ), + "0d", + "same-day badge" + ) + 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" + ) + checks.equal( + GitLastEditedFormatter.badgeText(since: now.addingTimeInterval(-365 * 86_400), now: now), + "1y", + "year badge" + ) + + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("mindle-git-metadata-\(UUID().uuidString)", isDirectory: true) + do { + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + try runGit(["init", "-q"], at: root) + try runGit(["config", "user.name", "Mindle Tests"], at: root) + try runGit(["config", "user.email", "tests@mindle.local"], at: root) + let tracked = root.appendingPathComponent("tracked.md") + try "one\n".write(to: tracked, atomically: true, encoding: .utf8) + try runGit(["add", "tracked.md"], at: root) + try runGit( + ["commit", "-q", "-m", "fixture"], + at: root, + 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) + + let snapshot = GitMetadataCollector.collect( + for: root, + includeChanges: true, + includeLastEdited: true + ) + checks.equal(snapshot.files[tracked.standardizedFileURL]?.changes?.additions, 1, "working tree collected") + checks.expect( + snapshot.files[untracked.standardizedFileURL]?.changes?.isUntracked == true, + "untracked file collected" + ) + checks.equal( + Int(snapshot.files[tracked.standardizedFileURL]?.lastEditedAt?.timeIntervalSince1970 ?? 0), + 1_704_164_645, + "last commit timestamp collected" + ) + } catch { + checks.expect(false, "Git integration fixture failed: \(error)") + } + try? fileManager.removeItem(at: root) + + print("GitFileMetadata: \(checks.passed) passed, \(checks.failures) failed") + return checks.failures +} + +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/RemoteMarkdownAssetsChecks.swift b/Tests/harness/RemoteMarkdownAssetsChecks.swift new file mode 100644 index 0000000..8faf5d6 --- /dev/null +++ b/Tests/harness/RemoteMarkdownAssetsChecks.swift @@ -0,0 +1,56 @@ +import Foundation + +func runRemoteMarkdownAssetsChecks() -> Int { + let c = Checks("RemoteMarkdownAssets") + let markdown = """ + ![plain](image.png) + ![nested](assets/diagram%20one.webp "Diagram") + ![parent](../shared/chart.svg) + ![remote](https://example.com/no.png) + ![absolute](/srv/static/no.png) + ![brand][logo] + [logo]: + [ordinary-link]: docs/readme.md + """ + c.equal( + RemoteMarkdownAssets.relativePaths(in: markdown), + ["image.png", "assets/diagram one.webp", "../shared/chart.svg", "images/logo.png"], + "relative image extraction" + ) + c.equal( + RemoteMarkdownAssets.relativePaths(in: """ + ![query](images/plot.png?raw=1#chart) + ![query duplicate](images/plot.png) + ![reserved](images/plot%231%3Ffinal.png) + ![bad encoding](images/100%.png) + ![mailto](mailto:test@example.com) + """), + ["images/plot.png", "images/plot#1?final.png", "images/100%.png"], + "paths normalize, deduplicate, and reject URL schemes" + ) + + let document = SSHTarget(userHostPath: "test:/workspace/book/README.md")! + c.equal( + RemoteMarkdownAssets.target(for: "image.png", from: document)?.canonical, + "test:/workspace/book/image.png", + "sibling image target" + ) + c.equal( + RemoteMarkdownAssets.target(for: "../shared/chart.svg", from: document)?.canonical, + "test:/workspace/shared/chart.svg", + "parent image target" + ) + + let cache = URL(fileURLWithPath: "/tmp/mindle-ssh-cache", isDirectory: true) + c.equal( + document.proxyURL(cacheDir: cache).path, + cache + .appendingPathComponent(SSHTarget.fnv1a("test")) + .appendingPathComponent("workspace/book/README.md") + .path, + "remote mirror preserves directory layout" + ) + + if c.failures == 0 { print("✓ RemoteMarkdownAssets: \(c.passed) checks passed") } + return c.failures +} diff --git a/Tests/harness/SSHProfileChecks.swift b/Tests/harness/SSHProfileChecks.swift new file mode 100644 index 0000000..650ee64 --- /dev/null +++ b/Tests/harness/SSHProfileChecks.swift @@ -0,0 +1,124 @@ +import Foundation + +func runSSHProfileChecks() -> Int { + let c = Checks("SSHProfile") + let yaml = """ + profiles: + - name: "test" + hostname: 'test' + path: /workspace + favorite: true + - name: docs + hostname: docs.example + path: /srv/docs # inline comment + """ + + do { + let profiles = try SSHProfileConfiguration.parse(yaml) + c.equal(profiles.count, 2, "profile count") + c.equal(profiles[0].name, "test", "quoted name") + c.equal(profiles[0].hostname, "test", "quoted hostname") + c.equal(profiles[0].rootPath, "/workspace", "root path") + c.expect(profiles[0].favorite, "favorite parsed") + c.equal( + SSHProfileConfiguration.favoriteProfile(in: profiles)?.name, + "test", + "favorite selected" + ) + } catch { + c.expect(false, "valid YAML failed: \(error)") + } + + do { + _ = try SSHProfileConfiguration.parse(""" + profiles: + - name: bad + hostname: test + path: relative + """) + c.expect(false, "relative path should fail") + } catch SSHProfileConfigurationError.invalidPath { + c.expect(true, "relative path rejected") + } catch { + c.expect(false, "relative path returned wrong error: \(error)") + } + + do { + _ = try SSHProfileConfiguration.parse(""" + profiles: + - name: one + hostname: one + path: /one + favorite: true + - name: two + hostname: two + path: /two + favorite: true + """) + c.expect(false, "multiple favorites should fail") + } catch SSHProfileConfigurationError.duplicateFavorite { + c.expect(true, "multiple favorites rejected") + } catch { + c.expect(false, "multiple favorites returned wrong error: \(error)") + } + + do { + let profiles = try SSHProfileConfiguration.parse(""" + profiles: + - name: first + hostname: first + path: /first + - name: second + hostname: second + path: /second + """) + c.equal( + SSHProfileConfiguration.favoriteProfile(in: profiles)?.name, + "first", + "first profile is fallback favorite" + ) + } catch { + c.expect(false, "fallback favorite fixture failed: \(error)") + } + + do { + _ = try SSHProfileConfiguration.parse("profiles:\n") + c.expect(false, "empty profile list should fail") + } catch SSHProfileConfigurationError.noProfiles { + c.expect(true, "empty profile list rejected") + } catch { + c.expect(false, "empty profile list returned wrong error: \(error)") + } + + do { + _ = try SSHProfileConfiguration.parse(""" + profiles: + - name: bad + hostname: test + path: /workspace + favorite: yes + """) + c.expect(false, "non-boolean favorite should fail") + } catch SSHProfileConfigurationError.invalidFavorite { + c.expect(true, "non-boolean favorite rejected") + } catch { + c.expect(false, "non-boolean favorite returned wrong error: \(error)") + } + + do { + _ = try SSHProfileConfiguration.parse(""" + profiles: + - name: unsafe + hostname: -oProxyCommand=bad + path: /workspace + """) + c.expect(false, "option-like hostname should fail") + } catch SSHProfileConfigurationError.invalidHostname { + c.expect(true, "option-like hostname rejected") + } catch { + c.expect(false, "option-like hostname returned wrong error: \(error)") + } + + if c.failures == 0 { print("✓ SSHProfile: \(c.passed) checks passed") } + return c.failures +} diff --git a/Tests/harness/SSHTargetChecks.swift b/Tests/harness/SSHTargetChecks.swift index 8cc2919..c18ea59 100644 --- a/Tests/harness/SSHTargetChecks.swift +++ b/Tests/harness/SSHTargetChecks.swift @@ -20,6 +20,12 @@ func runSSHTargetChecks() -> Int { c.expect(SSHTarget(userHostPath: "no-colon-here") == nil, "rejects missing colon") c.expect(SSHTarget(userHostPath: ":/empty/host.md") == nil, "rejects empty host") c.expect(SSHTarget(userHostPath: " ") == nil, "rejects blank") + c.expect(SSHTarget(userHostPath: "-oProxyCommand=bad:/file.md") == nil, "rejects option-like host") + c.equal( + SSHTarget(userHostPath: "devbox:/../../etc/notes.md")?.remotePath, + "/etc/notes.md", + "normalizes parent components" + ) // sourceURL round-trips let t3 = SSHTarget(userHostPath: "fabio@devbox:/home/fabio/my notes.md")! @@ -37,6 +43,42 @@ func runSSHTargetChecks() -> Int { c.expect(a.proxyURL(cacheDir: dir) != other.proxyURL(cacheDir: dir), "proxy differs by host") c.equal(a.proxyURL(cacheDir: dir).lastPathComponent, "spec.md", "proxy basename") + let migrationRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("mindle-ssh-migration-\(UUID().uuidString)", isDirectory: true) + let migrationTarget = SSHTarget(userHostPath: "devbox:/docs/guide.md")! + let legacyProxy = migrationRoot + .appendingPathComponent(SSHTarget.fnv1a(migrationTarget.canonical), isDirectory: true) + .appendingPathComponent("guide.md") + let legacySidecar = legacyProxy.deletingLastPathComponent() + .appendingPathComponent(".guide.md.mindle.json") + do { + try FileManager.default.createDirectory( + at: legacyProxy.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "legacy document".write(to: legacyProxy, atomically: true, encoding: .utf8) + try "legacy annotations".write(to: legacySidecar, atomically: true, encoding: .utf8) + try migrationTarget.migrateLegacyCacheIfNeeded(cacheDir: migrationRoot) + + let migratedProxy = migrationTarget.proxyURL(cacheDir: migrationRoot) + let migratedSidecar = migratedProxy.deletingLastPathComponent() + .appendingPathComponent(".guide.md.mindle.json") + c.equal( + try String(contentsOf: migratedProxy, encoding: .utf8), + "legacy document", + "legacy proxy migrated" + ) + c.equal( + try String(contentsOf: migratedSidecar, encoding: .utf8), + "legacy annotations", + "legacy annotation sidecar migrated" + ) + c.expect(!FileManager.default.fileExists(atPath: legacyProxy.path), "legacy proxy removed") + } catch { + c.expect(false, "legacy cache migration failed: \(error)") + } + try? FileManager.default.removeItem(at: migrationRoot) + if c.failures == 0 { print("✓ SSHTarget: \(c.passed) checks passed") } return c.failures } diff --git a/Tests/harness/SSHTransportChecks.swift b/Tests/harness/SSHTransportChecks.swift index a30c8fd..355f68c 100644 --- a/Tests/harness/SSHTransportChecks.swift +++ b/Tests/harness/SSHTransportChecks.swift @@ -33,6 +33,229 @@ func runSSHTransportChecks() async -> Int { c.expect(margs.contains("mv '/a/my notes.md.mindle-tmp' '/a/my notes.md'"), "remoteMvArgs quoted mv") c.expect(margs.contains("BatchMode=yes"), "remoteMvArgs has BatchMode") + let profile = SSHProfile( + name: "test", + hostname: "test", + rootPath: "/workspace", + favorite: true + )! + let listArgs = SSHTransport.listDocumentsArgs(profile) + c.expect(listArgs.contains("test"), "listDocumentsArgs has hostname") + c.expect( + listArgs.last?.contains("root='/workspace'") == true, + "listDocumentsArgs quotes configured root" + ) + c.expect( + listArgs.last?.contains("if [ ! -d \"$root\" ]; then root=$HOME; fi") == true, + "listDocumentsArgs falls back to remote home" + ) + c.expect( + listArgs.last?.contains("find \"$root\"") == true, + "listDocumentsArgs searches effective root" + ) + c.expect( + listArgs.last?.contains("-iname '*.md'") == true, + "listDocumentsArgs includes Markdown" + ) + + let listed = """ + Welcome to Microsoft Azure Linux 3.0 (aarch64) + \(SSHTransport.listingRootMarker)/workspace\u{0}/workspace/book/README.md\u{0}/workspace/notes.txt\u{0} + """ + let listRunner = FakeRunner(result: ProcessResult( + status: 0, + stdout: listed.data(using: .utf8)!, + stderr: Data() + )) + do { + let listing = try await SSHTransport.listDocuments(in: profile, runner: listRunner) + c.equal(listing.root.canonical, "test:/workspace", "configured root reported") + c.equal( + listing.files.map(\.canonical), + ["test:/workspace/book/README.md", "test:/workspace/notes.txt"], + "listDocuments ignores login banner and parses paths" + ) + } catch { + c.expect(false, "listDocuments failed: \(error)") + } + do { + _ = try await SSHTransport.listDocuments( + in: profile, + runner: FakeRunner(result: ProcessResult( + status: 2, + stdout: Data(), + stderr: Data("find failed".utf8) + )) + ) + c.expect(false, "listDocuments should throw on non-zero exit") + } catch let SSHTransportError.nonZeroExit(status, stderr) { + c.equal(status, 2, "listDocuments error status") + c.equal(stderr, "find failed", "listDocuments error stderr") + } catch { + c.expect(false, "listDocuments threw wrong error: \(error)") + } + let fallbackOutput = """ + \(SSHTransport.listingRootMarker)/home/test\u{0}/home/test/README.md\u{0}/workspace/outside.md\u{0} + """ + do { + let listing = try await SSHTransport.listDocuments( + in: profile, + runner: FakeRunner(result: ProcessResult( + status: 0, + stdout: Data(fallbackOutput.utf8), + stderr: Data() + )) + ) + c.equal(listing.root.canonical, "test:/home/test", "missing root falls back to remote home") + c.equal( + listing.files.map(\.canonical), + ["test:/home/test/README.md"], + "fallback listing is scoped to remote home" + ) + } catch { + c.expect(false, "home fallback listing failed: \(error)") + } + do { + _ = try await SSHTransport.listDocuments( + in: profile, + runner: FakeRunner(result: ProcessResult( + status: 0, + stdout: Data("/workspace/README.md\u{0}".utf8), + stderr: Data() + )) + ) + c.expect(false, "listing without effective root should fail") + } catch SSHTransportError.invalidListing { + c.expect(true, "listing without effective root rejected") + } catch { + c.expect(false, "malformed listing returned wrong error: \(error)") + } + + let fallbackFixture = FileManager.default.temporaryDirectory + .appendingPathComponent("mindle-home-fallback-\(UUID().uuidString)", isDirectory: true) + let existingEmptyRoot = fallbackFixture.appendingPathComponent("empty", isDirectory: true) + do { + try FileManager.default.createDirectory( + at: existingEmptyRoot, + withIntermediateDirectories: true + ) + try "# Home".write( + to: fallbackFixture.appendingPathComponent("README.md"), + atomically: true, + encoding: .utf8 + ) + + let missingProfile = SSHProfile( + name: "missing", + hostname: "test", + rootPath: fallbackFixture.appendingPathComponent("missing").path, + favorite: false + )! + let missingResult = try await SystemProcessRunner().run( + launchPath: "/usr/bin/env", + arguments: [ + "HOME=\(fallbackFixture.path)", + "/bin/sh", "-c", + SSHTransport.listDocumentsArgs(missingProfile).last! + ] + ) + let missingListing = try await SSHTransport.listDocuments( + in: missingProfile, + runner: FakeRunner(result: missingResult) + ) + c.equal( + missingListing.root.remotePath, + fallbackFixture.path, + "generated shell command opens HOME when configured root is missing" + ) + c.equal( + missingListing.files.map(\.basename), + ["README.md"], + "generated shell command lists HOME documents" + ) + + let emptyProfile = SSHProfile( + name: "empty", + hostname: "test", + rootPath: existingEmptyRoot.path, + favorite: false + )! + let emptyResult = try await SystemProcessRunner().run( + launchPath: "/usr/bin/env", + arguments: [ + "HOME=\(fallbackFixture.path)", + "/bin/sh", "-c", + SSHTransport.listDocumentsArgs(emptyProfile).last! + ] + ) + let emptyListing = try await SSHTransport.listDocuments( + in: emptyProfile, + runner: FakeRunner(result: emptyResult) + ) + c.equal( + emptyListing.root.remotePath, + existingEmptyRoot.path, + "existing empty root does not fall back to HOME" + ) + c.equal(emptyListing.files, [], "existing empty root remains empty") + } catch { + c.expect(false, "shell-level HOME fallback fixture failed: \(error)") + } + try? FileManager.default.removeItem(at: fallbackFixture) + + let cache = FileManager.default.temporaryDirectory + .appendingPathComponent("mindle-asset-fetch-\(UUID().uuidString)", isDirectory: true) + let assetRunner = AssetRunner(failingPath: "missing.png") + let assetFailures = await SSHTransport.fetchReferencedImages( + in: "![ok](images/ok.png)\n![missing](images/missing.png)", + for: SSHTarget(userHostPath: "test:/workspace/README.md")!, + cacheDir: cache, + runner: assetRunner + ) + c.equal( + assetFailures, + [RemoteAssetFetchFailure(path: "images/missing.png", message: "missing remote asset")], + "referenced image failures are reported" + ) + let fetchedTargets = await assetRunner.fetchedTargets() + c.equal( + fetchedTargets, + ["test:/workspace/images/ok.png", "test:/workspace/images/missing.png"], + "referenced images fetched in document order" + ) + c.expect( + FileManager.default.fileExists( + atPath: SSHTarget(userHostPath: "test:/workspace/images/ok.png")! + .proxyURL(cacheDir: cache) + .path + ), + "successful referenced image stored in cache" + ) + try? FileManager.default.removeItem(at: cache) + + let concurrentCache = FileManager.default.temporaryDirectory + .appendingPathComponent("mindle-concurrent-fetch-\(UUID().uuidString)", isDirectory: true) + let concurrentTarget = SSHTarget(userHostPath: "test:/workspace/concurrent.md")! + let concurrentProxy = concurrentTarget.proxyURL(cacheDir: concurrentCache) + do { + async let first: Void = SSHTransport.fetch( + concurrentTarget, + to: concurrentProxy, + runner: DelayedFetchRunner(content: "first", delayNanoseconds: 30_000_000) + ) + async let second: Void = SSHTransport.fetch( + concurrentTarget, + to: concurrentProxy, + runner: DelayedFetchRunner(content: "second", delayNanoseconds: 0) + ) + _ = try await (first, second) + let content = try String(contentsOf: concurrentProxy, encoding: .utf8) + c.expect(content == "first" || content == "second", "concurrent fetch leaves a complete proxy") + } catch { + c.expect(false, "concurrent fetch failed: \(error)") + } + try? FileManager.default.removeItem(at: concurrentCache) + // shellSingleQuote c.equal(SSHTransport.shellSingleQuote("/a/b"), "'/a/b'", "shellSingleQuote simple") c.equal(SSHTransport.shellSingleQuote("it's"), "'it'\\''s'", "shellSingleQuote escapes quote") @@ -51,6 +274,30 @@ func runSSHTransportChecks() async -> Int { c.expect(false, "fetch threw wrong error: \(error)") } + do { + let largeOutput = String(repeating: "x", count: 100_000) + let result = try await SystemProcessRunner().run( + launchPath: "/usr/bin/printf", + arguments: ["%s", largeOutput] + ) + c.equal(result.status, 0, "large-output process status") + c.equal(result.stdout.count, largeOutput.utf8.count, "large process output drains without deadlock") + } catch { + c.expect(false, "large-output process failed: \(error)") + } + + do { + _ = try await SystemProcessRunner().run( + launchPath: "/path/that/does/not/exist", + arguments: [] + ) + c.expect(false, "missing executable should fail to launch") + } catch SSHTransportError.launchFailed { + c.expect(true, "missing executable reports launch failure") + } catch { + c.expect(false, "missing executable returned wrong error: \(error)") + } + if c.failures == 0 { print("✓ SSHTransport: \(c.passed) checks passed") } return c.failures } @@ -59,3 +306,43 @@ private struct FakeRunner: ProcessRunner { let result: ProcessResult func run(launchPath: String, arguments: [String]) async throws -> ProcessResult { result } } + +private actor AssetRunner: ProcessRunner { + let failingPath: String + private var targets: [String] = [] + + init(failingPath: String) { + self.failingPath = failingPath + } + + func run(launchPath: String, arguments: [String]) async throws -> ProcessResult { + let target = arguments[arguments.count - 2] + targets.append(target) + if target.hasSuffix(failingPath) { + return ProcessResult( + status: 1, + stdout: Data(), + stderr: Data("missing remote asset".utf8) + ) + } + try Data("image".utf8).write(to: URL(fileURLWithPath: arguments.last!)) + return ProcessResult(status: 0, stdout: Data(), stderr: Data()) + } + + func fetchedTargets() -> [String] { + targets + } +} + +private struct DelayedFetchRunner: ProcessRunner { + let content: String + let delayNanoseconds: UInt64 + + func run(launchPath: String, arguments: [String]) async throws -> ProcessResult { + try Data(content.utf8).write(to: URL(fileURLWithPath: arguments.last!)) + if delayNanoseconds > 0 { + try await Task.sleep(nanoseconds: delayNanoseconds) + } + return ProcessResult(status: 0, stdout: Data(), stderr: Data()) + } +} diff --git a/Tests/harness/TitleBarDoubleClickChecks.swift b/Tests/harness/TitleBarDoubleClickChecks.swift new file mode 100644 index 0000000..f7a2b0c --- /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 preference leaves zoom unchanged") + checks.equal(window.miniaturizeCount, 1, "None preference 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..953702b 100644 --- a/Tests/harness/main.swift +++ b/Tests/harness/main.swift @@ -1,8 +1,14 @@ import Foundation var failures = 0 +failures += runFileTreeChecks() +failures += runGitFileMetadataChecks() failures += runSSHTargetChecks() +failures += runSSHProfileChecks() +failures += runRemoteMarkdownAssetsChecks() failures += await runSSHTransportChecks() +failures += await runFileBrowserStateChecks() +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..839d9cb --- /dev/null +++ b/Tests/snapshots/FileBrowserSnapshotTests.swift @@ -0,0 +1,215 @@ +import AppKit +import SwiftUI + +@main +@MainActor +struct FileBrowserSnapshotTests { + private static let size = NSSize(width: 320, height: 360) + private static let fixedNow = Date(timeIntervalSince1970: 1_735_689_600) + + 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 newFile = root.appendingPathComponent("meeting-notes.txt") + let rows = [ + FileTreeRowModel( + url: chapters, + name: "Chapters", + depth: 0, + kind: .directory, + isExpanded: true + ), + FileTreeRowModel( + url: active, + name: "01-introduction.md", + depth: 1, + kind: .file, + isExpanded: false + ), + FileTreeRowModel( + url: draft, + name: "02-open-questions.md", + depth: 1, + kind: .file, + isExpanded: false + ), + FileTreeRowModel( + url: newFile, + name: "meeting-notes.txt", + depth: 0, + kind: .file, + isExpanded: false + ) + ] + let metadata = GitMetadataSnapshot(files: [ + active: GitFileMetadata( + changes: GitFileChanges(additions: 12, deletions: 3, isUntracked: false), + lastEditedAt: fixedNow.addingTimeInterval(-8 * 86_400) + ), + draft: GitFileMetadata( + changes: nil, + lastEditedAt: fixedNow.addingTimeInterval(-65 * 86_400) + ), + newFile: GitFileMetadata( + changes: GitFileChanges(additions: nil, deletions: nil, isUntracked: true), + lastEditedAt: nil + ) + ]) + let tree = FileNode( + url: root, + name: root.lastPathComponent, + isDirectory: true, + children: [] + ) + let view = FileBrowserSidebarContent( + rootDisplayName: "Field Notes", + rootURL: root, + tree: state == .error ? nil : tree, + rows: state == .populated ? rows : [], + selectedURL: active, + isLoading: false, + errorMessage: state == .error ? "The SSH connection timed out." : nil, + gitMetadata: metadata, + showGitChanges: true, + showLastEdited: true, + highlightActiveFile: true, + theme: theme, + now: fixedNow, + onRefresh: {}, + onToggle: { _ in }, + 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..0e30824 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..0f2e820 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..1e7fe97 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..fb89138 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..8a9941d --- /dev/null +++ b/run-screenshot-tests.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +mkdir -p .build + +swiftc -O -parse-as-library \ + -profile-generate \ + -profile-coverage-mapping \ + -framework AppKit \ + -framework SwiftUI \ + Sources/mindle/Theme.swift \ + Sources/mindle/BrowserDisplaySettings.swift \ + Sources/mindle/PerformanceTrace.swift \ + Sources/mindle/SSHTarget.swift \ + Sources/mindle/SSHProfile.swift \ + Sources/mindle/FileTree.swift \ + Sources/mindle/GitFileMetadata.swift \ + Sources/mindle/FileBrowserState.swift \ + Sources/mindle/FileBrowserView.swift \ + Tests/snapshots/FileBrowserSnapshotTests.swift \ + -o .build/run-screenshot-tests + +LLVM_PROFILE_FILE=.build/screenshot-tests.profraw .build/run-screenshot-tests "$@" +if [[ "${1:-}" != "--record" ]]; then + xcrun llvm-profdata merge -sparse \ + .build/screenshot-tests.profraw \ + -o .build/screenshot-tests.profdata + xcrun llvm-cov report .build/run-screenshot-tests \ + -instr-profile=.build/screenshot-tests.profdata \ + -ignore-filename-regex='Tests/' \ + Sources/mindle/FileBrowserView.swift +fi diff --git a/run-tests.sh b/run-tests.sh index 0bb472e..a74f748 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -7,11 +7,41 @@ set -euo pipefail cd "$(dirname "$0")" mkdir -p .build swiftc -O \ + -profile-generate \ + -profile-coverage-mapping \ + -framework AppKit \ + Sources/mindle/FileTree.swift \ + Sources/mindle/BrowserDisplaySettings.swift \ + Sources/mindle/FileBrowserState.swift \ + Sources/mindle/GitFileMetadata.swift \ + Sources/mindle/PerformanceTrace.swift \ Sources/mindle/SSHTarget.swift \ + Sources/mindle/SSHProfile.swift \ + Sources/mindle/RemoteMarkdownAssets.swift \ Sources/mindle/SSHTransport.swift \ + Sources/mindle/TitleBarDoubleClick.swift \ Tests/harness/TestHarness.swift \ + Tests/harness/FileTreeChecks.swift \ + Tests/harness/FileBrowserStateChecks.swift \ + Tests/harness/GitFileMetadataChecks.swift \ Tests/harness/SSHTargetChecks.swift \ + Tests/harness/SSHProfileChecks.swift \ + Tests/harness/RemoteMarkdownAssetsChecks.swift \ Tests/harness/SSHTransportChecks.swift \ + Tests/harness/TitleBarDoubleClickChecks.swift \ Tests/harness/main.swift \ -o .build/run-tests -.build/run-tests +LLVM_PROFILE_FILE=.build/logic-tests.profraw .build/run-tests +xcrun llvm-profdata merge -sparse .build/logic-tests.profraw -o .build/logic-tests.profdata +xcrun llvm-cov report .build/run-tests \ + -instr-profile=.build/logic-tests.profdata \ + -ignore-filename-regex='Tests/' \ + Sources/mindle/BrowserDisplaySettings.swift \ + Sources/mindle/FileBrowserState.swift \ + Sources/mindle/FileTree.swift \ + Sources/mindle/GitFileMetadata.swift \ + Sources/mindle/RemoteMarkdownAssets.swift \ + Sources/mindle/SSHProfile.swift \ + Sources/mindle/SSHTarget.swift \ + Sources/mindle/SSHTransport.swift \ + Sources/mindle/TitleBarDoubleClick.swift diff --git a/scripts/generate-file-browser-fixture.sh b/scripts/generate-file-browser-fixture.sh new file mode 100755 index 0000000..d1a6a66 --- /dev/null +++ b/scripts/generate-file-browser-fixture.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +COUNT="${1:-1000}" +ROOT="${2:-$(mktemp -d "${TMPDIR:-/tmp}/mindle-file-browser.XXXXXX")}" + +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\nFixture content for Mindle file-browser profiling.\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" +printf '%s\n' "$ROOT" diff --git a/scripts/profile-file-browser.sh b/scripts/profile-file-browser.sh new file mode 100755 index 0000000..b13435b --- /dev/null +++ b/scripts/profile-file-browser.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +COUNT="${1:-1000}" +TRACE_DIR="${2:-${TMPDIR:-/tmp}/mindle-profile-$(date +%Y%m%d-%H%M%S)}" + +mkdir -p "$TRACE_DIR" +FIXTURE="$("$SCRIPT_DIR/generate-file-browser-fixture.sh" "$COUNT")" + +cd "$ROOT_DIR" +./build.sh + +APP_BIN="$ROOT_DIR/build/Mindle.app/Contents/MacOS/mindle" +"$APP_BIN" "$FIXTURE" > "$TRACE_DIR/app.log" 2>&1 & +APP_PID=$! + +cleanup() { + if kill -0 "$APP_PID" 2>/dev/null; then + kill "$APP_PID" + fi +} +trap cleanup EXIT + +sleep 2 +echo "Fixture: $FIXTURE" +echo "Trace output: $TRACE_DIR" +echo "During the capture: scroll the file tree end-to-end, resize the left divider repeatedly, and open several files." + +if xcrun xctrace list templates >/dev/null 2>&1; then + echo "Recording a 30-second Time Profiler trace..." + xcrun xctrace record \ + --template "Time Profiler" \ + --attach "$APP_PID" \ + --time-limit 30s \ + --output "$TRACE_DIR/file-browser.trace" +else + echo "Full Xcode is not selected; capturing a 30-second sample instead." + echo "Select Xcode with xcode-select to enable Instruments Time Profiler and SwiftUI traces." + sample "$APP_PID" 30 -file "$TRACE_DIR/file-browser.sample.txt" +fi + +echo "Capture complete: $TRACE_DIR"