From 0e905d4f49333dea998b61f2a89cba281e826bff Mon Sep 17 00:00:00 2001 From: matchaboar <224671045+matchaboar@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:27:56 -0700 Subject: [PATCH 1/2] feat: add file browser and SSH profiles --- .github/workflows/build.yml | 6 + AGENTS.md | 2 + README.md | 5 +- Resources/web/reader.js | 28 +- Sources/mindle/BrowserDisplaySettings.swift | 23 ++ Sources/mindle/ContentView.swift | 224 +++++--------- Sources/mindle/DocumentStore.swift | 237 +++++++++++---- Sources/mindle/FileBrowserState.swift | 214 +++++++++++++ Sources/mindle/FileBrowserView.swift | 255 ++++++++++++++++ Sources/mindle/FileTree.swift | 214 +++++++++++++ Sources/mindle/GitFileMetadata.swift | 258 ++++++++++++++++ Sources/mindle/ImageSchemeHandler.swift | 3 +- Sources/mindle/MindleApp.swift | 13 +- Sources/mindle/PDFReaderView.swift | 9 +- Sources/mindle/PerformanceTrace.swift | 26 ++ Sources/mindle/RemoteMarkdownAssets.swift | 75 +++++ Sources/mindle/SSHProfile.swift | 247 +++++++++++++++ Sources/mindle/SSHTarget.swift | 64 +++- Sources/mindle/SSHTransport.swift | 142 ++++++++- Sources/mindle/SettingsView.swift | 76 +++++ Sources/mindle/Theme.swift | 4 + Sources/mindle/TitleBarDoubleClick.swift | 19 ++ Sources/mindle/WebReaderView.swift | 7 +- Tests/harness/FileBrowserStateChecks.swift | 132 ++++++++ Tests/harness/FileTreeChecks.swift | 98 ++++++ Tests/harness/GitFileMetadataChecks.swift | 131 ++++++++ .../harness/RemoteMarkdownAssetsChecks.swift | 56 ++++ Tests/harness/SSHProfileChecks.swift | 124 ++++++++ Tests/harness/SSHTargetChecks.swift | 42 +++ Tests/harness/SSHTransportChecks.swift | 287 ++++++++++++++++++ Tests/harness/TitleBarDoubleClickChecks.swift | 45 +++ Tests/harness/main.swift | 6 + .../snapshots/FileBrowserSnapshotTests.swift | 215 +++++++++++++ Tests/snapshots/file-browser-dark.png | Bin 0 -> 46333 bytes Tests/snapshots/file-browser-empty.png | Bin 0 -> 30526 bytes Tests/snapshots/file-browser-error.png | Bin 0 -> 26868 bytes Tests/snapshots/file-browser-light.png | Bin 0 -> 45703 bytes run-screenshot-tests.sh | 32 ++ run-tests.sh | 32 +- scripts/generate-file-browser-fixture.sh | 32 ++ scripts/profile-file-browser.sh | 44 +++ 41 files changed, 3188 insertions(+), 239 deletions(-) create mode 100644 Sources/mindle/BrowserDisplaySettings.swift create mode 100644 Sources/mindle/FileBrowserState.swift create mode 100644 Sources/mindle/FileBrowserView.swift create mode 100644 Sources/mindle/FileTree.swift create mode 100644 Sources/mindle/GitFileMetadata.swift create mode 100644 Sources/mindle/PerformanceTrace.swift create mode 100644 Sources/mindle/RemoteMarkdownAssets.swift create mode 100644 Sources/mindle/SSHProfile.swift create mode 100644 Sources/mindle/TitleBarDoubleClick.swift create mode 100644 Tests/harness/FileBrowserStateChecks.swift create mode 100644 Tests/harness/FileTreeChecks.swift create mode 100644 Tests/harness/GitFileMetadataChecks.swift create mode 100644 Tests/harness/RemoteMarkdownAssetsChecks.swift create mode 100644 Tests/harness/SSHProfileChecks.swift create mode 100644 Tests/harness/TitleBarDoubleClickChecks.swift create mode 100644 Tests/snapshots/FileBrowserSnapshotTests.swift create mode 100644 Tests/snapshots/file-browser-dark.png create mode 100644 Tests/snapshots/file-browser-empty.png create mode 100644 Tests/snapshots/file-browser-error.png create mode 100644 Tests/snapshots/file-browser-light.png create mode 100755 run-screenshot-tests.sh create mode 100755 scripts/generate-file-browser-fixture.sh create mode 100755 scripts/profile-file-browser.sh 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..d4ae441 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. Mindle shows its supported documents in the existing file sidebar and waits for you to choose one; the directory remains the window's scope for the rest of that window session. +- **SSH profiles** — the network button at the top left opens the favorite profile from `~/Library/Application Support/Mindle/ssh-profiles.yaml`. Each entry under `profiles:` has `name`, `hostname`, absolute `path`, and optional `favorite: true` fields. If the configured path does not exist, Mindle opens the remote user's home directory (`$HOME`) instead; an existing but unreadable path remains an error. Mindle lists supported documents without copying the whole workspace, then fetches the selected file and any relative Markdown images into a matching local cache layout. Open or reload the YAML from **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** — fast, scoped sidebar tree of `.md`, `.markdown`, `.mdown`, `.mkd`, `.txt`, and `.pdf` files (`⌘⇧F`). Tree scanning and batched Git metadata run off the main thread, and rows are rendered lazily, so large documentation folders remain responsive. Changed files show working-tree `+ / −` counts, tracked files show a compact last-commit age, and the active tab gets a stronger row highlight. All three cues are enabled by default and independently configurable under **Mindle → Settings → File Browser**. The browser never escapes above its selected root. - **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 0000000000000000000000000000000000000000..0e30824b8eccdd91deb8ee7280e0c18e153a9a88 GIT binary patch literal 46333 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 Xu}9Dk-!afiz~|z5w{yfZfxr9@N%4EJ literal 0 HcmV?d00001 diff --git a/Tests/snapshots/file-browser-empty.png b/Tests/snapshots/file-browser-empty.png new file mode 100644 index 0000000000000000000000000000000000000000..0f2e82019b79745432fb9b3867c9449a5c9d68e5 GIT binary patch literal 30526 zcmeFZby!qi8#X#1DG~~ZfP^9-l9D1V3L+`pAkDxG-7$oSh=7RFCEeXMG$IU$zz{=s z4qZdcxB12UopW91kMq}w?|bLEX3w5ld#&}vde;5i&kj>lkte-LcM}8xkt)1?sSX0+ z1_00BLTB^ z+xoEr4A{n{tAQN?}4Y-`1T&%ULs zhRUpYflt12tBjd3_h-Z+`EL0`8PjK-aun9DU07HM>_728YI{N-FI8pubucPamuUf9 z{_ruhb*0W1yfs%h#=j(v24P<^@0`eFV_zl2v}$ddKi4z63XojUjHgb+drG~~imPDu zs56m5{)zKKJE6TgNu_6Wu&}db(UZH1AD7K+UkB(jarS*7>>(T98PK@Z9mz2Gb(e*3 zH~XUKRL!lVEcAs89LsmNNS@UsI?|Jj2!*y=mWX5{k zD_nRan~d2Y;wQjpJ`tU}-RZ@Af2rNa68_coXYk8i3jVJ!&qo1TMCLz3v_`QzXEMi} z9U)4cpv?RCqY?M29ueHW5%#0gG=l;9@qA&%6R!!=u@U6rIN0{AWs;Kd_9XEMu?{iA z`y`D=ulC;=M-noqNqI6$t)=1^*e8KZtbiurDE zN(hqd!C)}{SHD6SZp_J(pDI4P_+Gi(nvGv#YE4e^eIHjSw2%kybk(%AbPyB|A~7Xd zbizTm(w_!1QxIfGi@zp_`EjD>nA+Fo%16%y>Pn|7!rPR_AIGf;2A9#?2a%@JCWGJs z2XhbWZbI88hKW-H8FvYMUsAk(|NAzZSwQ?l?TuzzuRE#^#FAL{ymH=6wMhd*jYBloK1r2TULwPe=guL0jwpRhrgw;wRD7(I0RIukAQGsl9)nNKu?i{+3c>Y{{lsFmvCkh<-gN#{wPNvBEWNzUgfQ00@{+}{W5ed|Jp zF^7$Z9*IiI?8+@UuU%5DvaOm|SP`qYZgzZXvsAL8;{SDzke24&v_2KL9d~N1v|WjI zHu7EZrhfCJ()QEIx07CzeN`TIX~X^*vVY2v#D8wDbN!hpaZz8zp)}X$^HO@?^1y}g zK(JCHF3&ErZw!It9-S^k<)AWiGgk}Z@cY+2x%}Ka9?i)V(s&pj*zjv}pzJO+D=--6l1^l}is57Ke;W zsC#6cyq^F0HJVWF3i_UIg1(Cm!9NKd0gu?(o833#5|}ad{Zof}Z@J*`6;gp*%2SBr z7#Tb$dx&(g`zWOqW9$+k0zGR5T;bIZemA!|99le^1++swSL-RFV$vZt@i6T6#S%RO#4Xf_zq zDl*ZFyU&bwm-8Aj7Oy;5l$$f9$)njQ+mk*1E~-Air~eXL?4pd*U-?6zVBDFseSP)jCNvlous}@F;W$ zO-o2?E&a9A0uO=zgymw*OBh6w8bi;;TScWA9Usqe4~C0`dWchy+#=>8wkP>R{D!E2 zsDa*|F`3qoeu{G8E*0;;ve2ZY@tuewNOky!U#KAQq!mA&DX>C4w`(V|ox} zLl+fEP7k-b*nVC3+SWpr>f?fZaaaQ-gi?YVvwF`=$yLd+KXd$Pd8PSa=hMZU&h1Da zN+t^U!z=9|ND%)I*68T?MBSKEVGcf9H}#(PLzF8@&(iGUxfGyE53(QSY$>g$+!J(~ z?i|pp9a$K$i?L@z#JfT(=9i{=<#YIQh;r^Lv1GAkZ>yX?`gli^*n9YFoqpKoz4*NM zyg)SErZ`V=i^Z7RKY2c;{Q06nj_OKce+d`7sG$53RMpg>&R`pPIHDPoFOXByci*C`BGT4)7^%D&9opydgre)wEAtkfskPcCkngh` zJJhFlHSabhr9QiyyR}XGhqhQ?+nOiktd6g4H&pMGYrZIU!m5&{zeOZe%o=5A0mV$k zdfh#^i`YzN;)i?VxXBCz&3;M_*QRMUj;|`Sjo!;R3upAD**AieTa`DRV$62B3}WXL zA1O}8MjAD|TaUZ*wzo8UAlouKEtWX$5_1{9(+A%%NG)%Igq(juaE5VabCwqG8)!A_ z`?$?-5ZZN@=9}~x8&n)0HE?VWe6Np%=&2f&8qU6Dd^=Ou=G^g%H0?>nlM-zLO*G1; z%4xm3HtA&&59D~(u(r!naXpsYfYrdko@7GX#L`HsCZ(>~^gNRxOl;ty_3VdL?)<=? zlr#?o4>!#o?X-fuy!;7BmCq|~{lcQ{$(V-Kg_+jo2)9T#)N%RocQoPo_w%%?_~XUu z8}o$vWX@#BON*o7ZrOfW)}z;#{g~7bMIR!&m%5KT-h~<@HYJf=ZXF(!SXHk4V)2&O zzMsu#yvoLF%& zUM0i+I;);i00y}pq`NK@Kl8c430vlna-b2iA7G4G>v&n zp|6Sw+X)95pD_Aw7#@DS%G)YmUrc1@QB7_+%9kJ`920?fm@#r;|#zV9b&r?5Z4GwXTQ<a!xm4<0?}O~S1B%~I{-_9`a7e=oiEBiL4W zoPeIjv5i!uUIAUR#DiT;Fjw`-{qFmf^{nauHAuJ#b?|Ul6-!zh+UmE@{Q9+7{uT)? z5pw|O`Vb(38_~?cf&U7GgGV8KeQ?CZljgzu=Z$o$01j@tHMNSA6a3wMpoKv^qJUJb zXI=jp6H$QSjemO>U=56K@JaH*!+(2%CtdRIMgG4t+@A=!pyEa{`b&?cpV3R}%PaRZ zzKo4jfoC4q^OW$`K&P#&`aSzT(+VJ6xAoOFySTWyh3G|~e4*rV2{OS1TIZ*hmXl@Q@U~m`pS4!m4(Ckvm=gXii-m{jbENVV4Gt zSno>PVJny2Ihoq+=98;Z(Sv@0C)2#Ab3Ia5VH1_ss=LBx2c49vd?GWRvip9gU9#j7 z_A7`aCgWve)kMMGWFK_TlOk*Uzg4J5b}o!PRj5$A_-WeJVKp0X>E+(D5HQSI8^1Nw zcslhKEJbXI&EWi3q#(b62P41FacqLM^)^4J$bCMOx~$CguW?$r4({lwcgCBcB9i_cQB6MQnb7sD+r3J8TH+5Q_n;;CUV`(D%AF~ z`Sj_NT}5MpgioYix!J1_zcjBEJpo{@UH&L^aQfC*A?OY+4tO6)RLR7!DSv*!@{cV} zJp5g0?I8fWI=A}ujSyii>5{Vk`-f3YvVQjJP+pdW!0qx~;xL_2-fL^vY|xHK6z0Ed5EhC{ooPGdu=y&k4QV;u zc7R!D=6FZ$PXPfe?YQd41fd0)g36uyHM}NePmdh^43e5S$S* z0jqw#+Nu4srIt35=@0&gL!ru0XqPnH=LCv+=$(1+eE$89lCBD^LhZJairUahS^In99KFgMYIlPg`-HQDG_3l&X6-!KkD!9Pmqp>bwVr!-T+Wu$ zoQsRm;irUud6+=(O{I9gZbQemM`MVMLD*I3M0Mf9bSs##LPex>d`Ugm#N%5qY~=35 zquPD#<`}|iL5ymfsvkjN^*AEjgHcc;6=fxCFJY6KQr|c8W}p=LfPX?)h9f51@BEmD zsGxecHSpy4=;BFTF)5EKYadwQby#8Y?!iK0oHJ z%ybS{iei-L|9G1{!F@Zjc_$FVG>`^w^*x;rMI%e0!(_DlS?wn}uvZBZ@Z*(mlZ&9U znG^pnQ+|NBR!SQE?a^@f1=4HxT?|`T-=R%rVJxo+RJ+7*nQO#(yx2fQ06g+~%z^Kj z_O1rY57=%nQ-XDxpRn#SZoZtz&Md5z)OmfhF!z$l862;pHquHp8X@OP$|N~;>mJ`+ z9t+&nP_IP4dXmB5Xu8pZSL~Db+}ji7RA%14R-(wpfCky-xq`OEZ6?CyQk zYH9QOpX|(uMvt`f>GYV9ojt8Wf%LG*Js1=ANuOisTtaJ$BcZdVQ4r@zp!4ADmA& zI4yQhqB3Cxj*>U2X0MJJtdK=|)RLR1+S0SLK^VA0VY#imUF8RL11qN3VH%CAXj7f^ zbw#(sQg7lmji=OEZ(l$B>ZpqT{vF?*{saNUu&NY1W!o1H!Gl{T^ z#Ro1Y)7Z_cKZ+$)y!tivmOS-K4_#JMJT!@14pz9!WDZJx;_yBHkaKp{#p@5vy$}`k zeQnHgyX-g3b?LGqnh&*#0;hpK68Pgh(w`~m8BT@ zahdQ5+AG%zJ$g_a4O(QQR41vgAi~mWxMotqN_jwaxT`<@>F_c2spl^=*lk#l8*Ets zo~VH&kw(=%y3x;(n{1YACOWM0pt99n+OPMjtNfSmab;R%u`R5~ok%l$VO0u?e-aly zAX5yz$$s-Y-OG&V&Yp_A&zNupjj1WOu@i)4oKdv;Od9q=3PEd~{7!5rmOOJ1p?nKD zWTLkoP7pigj?zK2_=O=2k0`~Lzd1qEKjoCXt-Rp%-%~`fn>U_q=)k{DHLIr0K!1l} zm`?AC|I8@yD`Jg@XT~f?B?WBoq;~x5p2!4>u#YG}fz|jrz?$GlkA=gr zmv1Yf)7~^_o`$uiZ;dZ+!eHkp7M_a;Mu~ReTZX2vF-vPbS8ZToRhecclliL%PCTQ` zrmGZK4t}J_yYLPPQcYyru|=i-x)bCgBir!>&y!$p6gzn_V^(3b5l2F zfYEqw(il?Qk}SPa#g`V#Lh;=EUOVF*Zl^^Onb~^WXL9xH;xHF`yQrepkuuJ9vFi4$ zL(|>|Qn%UH1V8(Du~}z(QLwe+^FMJdHcXYdc`IBZk-Apx~lWDk6Vvrnn zN}Ka~HVrD;AdPH>4JLWEq|v*ZJBJyAt30KPE?N#E84%Gibowq9nw#d|9imzd1rO~7 zzhY_2RjQZr64SroSJHg!je;%tH9Ab+lDpZGl=fE`+QYN^i*s1&CX5A<9hW0K_o#hV z3b8S|X+M@3v$Z+_;JMLSnE@T6<~>C5EAGEyC$D(9W#7Xq28`{>I=W~~tgv)-{e~_G zH!Xt4M`DFwuZul)4gL9%IG!mHbfR6!8*~YhwOYlpLHu}p@pyqp3F^OHJ@%+0O&d(n z{pCRqGP&5GwE>@z3s|1@hD%N0bH(TRtw+bGokwH&vc<8EqHgp$wSK`!_3B?7Qz3B< zu=6D7h+7$26_&V=YJ8_@a4DCq>1q&rQM!~v$)1{W3#Olc1V_n9*_3 znch)qEp~g-chcgKYe%eQ^5X7w>>0R4Jzi~ByX`F0Gfk`BXM)-97vN>>m|g0fV7hX> z3Y#rjXPx&ezA5N1Gv&KTZ{k8JR)}rQjFQyfX1mfIEh;bEb@T#h=8Y83(K|GWF7J9wAIXX-4=m<* z9a#0=m9B2V4`+)-X08QU&b3}~)J9YrUt+r>B_?WuT2_RIJ>aJc;nBsuZBBn+Ee8W) z9DY~(2V!i^)y0t$8LtkOp!;O8?%Tgs1Js*M9NXCYU(pyX>66Mw&@Ho7`JYTr^zP%I z?@?YapUjPTce!-8=y*p~RV#I~c!LR!JZ{JgNcsewcSv3y*!!x2ibD0@Qq)~xHowja zx~@@(5Iw5TpyL$`bM;IR%wI5hI6pX=%R8K1R@QW+dY)C=Z|R*}P@`cNmnw7Lc%(O9K1f=sQPQe75Ede920piQ^4AOdrAm!7r>+RK-5Zu1lV#nb|n^yS|e*5t+ zd82yGks#0e1@bqjsle^Ef&vl|5IrtyU_HUNLQ}6p`xSh;Obs2ijvwuZ}(1Et^D=uGBRYNQ(U*^{O zqbI91lB}CvO2D68YP+18&fgukE2*DUAub#HF6&3%5#E4rV;ZLah{_}_-Y?(icdjC$tW*olA8uhNy+a9#3T|Q(HZC)TMkIzA)Iu#gRL_`Wfpsz= zjB+slyE+vW)%cL2T}+R<9kN_f;*nQblyTlpYV08>?`}ED0X|L8=HORlX_mAD!}7R{ zYUwn%&9^3}r9BVysM|5y2OYoi-rbn0cR5|mY=KTcKgt&bH(zd9OSz0(29XNpy_DIV z4`nK^y<$=%n<17Q3rI@4Sjt_Af;I8#cB%el8eEjW8Imt9%o`03mSP4Il#@R7d$b&k z7CkoVqMy)n(%+K`bA;7we5=tZHRec?!d`@P>_ja`FutWR)-KYe^*1Oi6{>saSHIg% zG9}0{f3%@-a=NHmr!^1CyWKf&iAWrsuk9UMZ&TA~)~vyE;r$>rW`UT0rBPF4n2^v{ z>VGr~O_jsHN^)o!ioEInEAsc14*ze0o>9ZV4aJ{9eE(W{z^=qfAxsXIhikL^e z|F+OHyyk=jgsib)U{by8ZVykj*gK!aI(TaBSL;s*I=s#4BW6$jQjyOlp+f!&!IYHq1!2~)5R>=KSC}zE`^}j_jrNqFO>+NT z37@I*-sz0gsN{<;G=IBZBWB*xW*-0nrP7MAoNc*;vVCk=)7OeQ7?NCuzqRwmJ1%4{ zbK69Ib8A54@_p_7IwP+P1N68zhSx1hgrxNRa%ZPp`PXc9vEm@AHza)e@u+pG*MN$I zOMVj7^Ks$JaXKWUF`F_b9@ubS#OCTZ2&$$s`qcm)=O7L)J_j}baFQV6VEm?)7#4f= zT%1+eyU(z!n7{=H1mc2fIixxcKn3_mzy|p3FQO@?tKA-GYNu%~uq}aVXlicF6wG!5 zp@D{2n9G>}t*ZFb>aK&cRTD?mheKI^o|MYb7N#vQc=)nUkiR8FRoPbhRs77|hM=%6 z!Qz4ogf}r+w#4TtZ}FUfcYUQ1ie z8aJ4Lw2bsg=oV$J_Q;ypU2c8w2tIgs4o7Gb&f^Z&RId=RLrWMPWR8}6)`0G8uV4$q zaW~^RkxYEEjj-VLnewo+(YRj*(UC{zhsio_TQGF}D(9iZ@W;u@f#<`@zJ?QvBUPnr zlt@ah_V5>JY&1w4J#ISP-S4Hoh^8ZLaJY^qQ!}IJ{v(&2=99_#-O0M1`1K*X^`4fk zieB_Mds=dQpGuVZu*Kjyi<_Hx$b;`J7l*fsbrdJVc_ke>bV-^c3nae%RY6&D?+s zT`=ZQw9v=sV;*|$y1%}y3{^QxcF@{V&y1?st|50$twmKq63hqa4J368BFOGeD!6pp z{+V;MQ{62mVChejnw7+Csf$P%l}TP49HZa{4-VH{B>a_CzY@tEg0u45W1#sPymzYk z2!c6BzGnH`rCo02A^?P^!-8a@7JixX(|Xx|w~bIPSnBXpkXyp%xavVE+ztoTRR6Ph z_^HsTOs%z0b_Clq?YuuV-VeLW-gOw(X-OB^4?%{vQIto85Wz&f%X3%7M)RJ1gCdNQ z-=(YAb8+s^NB}vZ<$1a8R_3}rIeJ7t!mU^RWCK~DKPI?d5$S?i&z(S(N!1+SL=w9z zoOz+^nFL5^c!cLU97q1-vHMOrb{zE%b@^cq%X{wGb$H~HxOrzNmf56#Y(E3Qr`eTc zmjPmJF@cDONS0Xu^s$v)2aEiai_s~R6ly>7fS*IU4+PoB`$_(ij5qb~$Bgxy1kQKeS9+Hc``NUEwT5cg{x2>{iL&W*S4=_9-r_}n!o*6Y` zqz(2zQj1>K_}zK2g`Bj*>0j&HG-WyB)~&DzqfecI!{9gl*1(wWH;en?N*!f#l#_(f zd|+EK&t0_;g*ZD5Lv?)`)D{TUW@<{dYhdrHp8{J>kK(ih-%#AiKiPd#FH@U+P9!(f zVAG5|T!z-(M;nj^ckaA>&Jl7ARm-8wW2%w!qfK-DS`|<2dKRX?&`*nq0JO?93_H8M zlMrznbrpRa23Avwt8my8s#_ulxoo@Sx#8dNZcGi2te#fj3B(P#K;oQud2Hw$UWP`xDj!@JBBEew!ME~S>_M84H4%jaH)kC zrvCn^LoqUfqOP9)(xe22O`ZuUsAF!A)k2+8#u~fd%QfWQ%hOqrgjxV_Cvtb|>4{`@ zw*UPth83nMP16sDZy22PrA!8D|AB+0WvSE@=SK2}w*)p<8=$)2(cw&agK99;fqbVr zGsLZSwQN&F%KzftL3vb4v=%ok&`TR%I%TBl1;41zm z(G6$UlX*AgKR~l8?)MYpLxsSK{~*nb*RXYg{ijdY&h>gX`x-#R-TuX)|BHzKKa~Ke zXJyS!rB3*tDFA;G!6zIOKAjG@Z=(K{%_(auw<&xh>Cb<}_W|_t)qjQckAD9*Tl2qf za_WDxT-<+$`@eFg^}o{nuXL|N{Qo6r1OHX#|0?r;l^NKs{r`~i^IzNjukHRXW4k{? zt_eLhYq$UnZDn`9!_D|}cLY7aMoE5-Wo)_rF3%1J*VKr_#TPiG3>31viTFg#ln6o9 zBwU`8BYQnwGE^zV7{kiQ0)Sl9W{;0~#YD z&dc6Gh*&4MZ3@SuryCPfB^r~E#RdseQw><3s9^EgEK!6^y~@0}(UD9nZyx+Wr+p*Gj(tdwfvPzz@}pljT@$RDYGNfKp#9 z0#U!3+EF}@Szbj^NYCBFzlZ1iIbbUignPu!2fKPPh7opJSnmO>&oGF&VYazZKIX*CQZf0j9;M6DZXj3HdRk$RddH3h4`9kw)*gJyR-!%|=mr2{) z_|d|#M9V&Hi!#EbzjE^rq(S|W-g8_O(&;yGjvAAmE<(h1Giqs{mBFYDkXZTY2I~0v zj?hF=mlgWyyH+y5MAHRGF#svr>H_D@LWNHmfmy zeb_qj1|gZnvZ%8|U zV1EywC0QgCpy5}Z0C1wm0;ISK5x>3{BHj}(0`ps3xzd!O@=~MC#=`mS+j6}!)3+mR zMY2GzD&zt{@j_Z{Vb|9(JwHxa*{+=hcv+-RCA?ipVc)7t4-UlQofhSiLP+B3hJNY! zWF+4MNTa*}Y4qw22oM>in$&Yd`B=V&yntC}*_K$_lY!T&gzICawO*UWwbNNcTK2!> zfuXcIQ8I%VMZ88;i7EdJ%2C4+5mM zZ&H9NEA*dn;9XGbHhJa`CBv*!+zM=wMP9-0fA;p4uS&ITO=xW7gxY=ynajE@&5Xpw zYt_}^4UtWMM{EP?YVz7?>`gs5IG{)N=B7XjwYjGs4fVyrYy{&&@B#V%WCP?cJW8+{ z1W3f^1YKFHWSt=C=Y37w?0oSe=4G1eA7Ut9-|@DX*;}@oKq_XBf*I7E$;g3^ORAq< zRMfAWG1cT%S(LI>pVON7s6jDqKzNE@!Bv@b0$^)_O#(o==Q;Op(j5uF6e_)3-WK(}nFkW!=lDbc6DIP>4{*z=ww;@QHG ztzSf4?!MPKpu4sYkHAy6ntYEJEfm0Lo}|Z?0h*Zr(1!LB88g0KM#evoB}g2~l-Jxp z8`9x5YMAOzIZxfL^f-hRR)%13!J>|H>9&(q3U2O}27p6NM*`76wOnr?aN2--6ULs* zH|~m_DsO1iuD|@s+V^>dN<7x`V8v{Vy}X9$Rju6^l!l9(;rUCDcAI3v(` zjR47j>iphvUCA;=_e%D;7OawCC&O_QmKJ_34V5@>};Oqgl*i4P%> z0{Fj7CCJ{#&st7qUJU1JwDk7bnH2j?0J=|M^BB0q2X&2Pc3dJ&Mbo99^Hu;&&ONow zqp*552tT~P1|kN~p@f)Z42yX!%z>GkdqzY7in{UK1Rw&q@uvF+P_lg%06=;nfZ92b z$Ep^UWl-lBMl0r?)jK#?dLFYx5A34?7az4go?FBl9D5ibJxy|mo*o7q_qb&63B$=@ z#Ji3U?RM|w2MxM%13Rhlhm(yK9AM4#Y=y8ea}&cnAUpRFX-&ZgSwSA%WSr2mY}Je0I~qj;j9%~CUH%6r~4GyGYfOz*l2b%0A+ zH17BYOeWy5xvz;__UA_tRg^CsoVfiG9|FWpwj3bIo1++~!X^@j6QUO8*E`WpQOT`Sr=x%md7ISMg36aEFsOt?gy*Is(@SGEn2MWb|2q?grj4r0PTvD(`o;9c{3 zd2JgK6^-6-;kz~hIzaef({c`=-I9RRmLFk25}K!A`iAxT5J2C2hb%F2z#MO+(hYwah%#y*e+fS+4T%a?9)7fFx{sH7zE z-w1jxu7)NO6tl48&6irO}9zPVId zA(XBSu~3J6xY0uub0u`DpA91Flm#Xv$~%kdACNjgx*PWpr$7D^>A&R*fu4X$FTRcX zhWn4;D1HKy82^--{y#AP|8%zhwDP4Kgu`e4MEGxxG9df~U>W`_FQI<(Z(RV4+Z^!d z-J14*Fa7&J|5qKsYyF%*4tTV`^oQbOD9|Y5(QA&s-vUbe8E}!b8s!iEX@Z%89B5R0 zBU=5hRMGW;7Eo(}95dPfT$T}-Ezc2_?EjQa@B?aSrPKw)F~HA(gS0r{`}XqW-~5%& zzy8dE3kV_p-<=R?Io#}QA4Dv-fwG>So*b(3HlWHH%p$2>8Ja|bN`Vy>EGxm}u}LiQ zzXsvy=`p>7J8CTt4-3j1Gbid@JZvJKDkrTZ2HrT&E?QLXpn2wdT9zOM`vLGaLnq43 zLoe&6Z_&F40nAt@T52dF@x{S@UuhCx?Wj#or$8g0?d2Se+(@fqVDU6+bYJEW3zgMjU&gc|s!LS(*k!6Z`<(Hm;-4li^0-!b0&%ey(vDD+hMd6vz|`JedF% zb%fN_UL6Neuu*JKE9i6lt>Kc(5pS5!5<{Taw_ZXFY~q z%a$vfCfFAvIYoAJxYtIU;^E(s?AYFFq2PVE7VT>^8V-;}2SVWP+mrF7zRTLfOxt`ilFh-e_Z4|$sg2Z+)22)W4n==ND1xOZXW_o+;I9s0+F z^#*<0lhxJ5Fu$14J_JIDknB;5A}4Wx0-Z541qom?Tm{iDIu)cZE0M*Vg53I5W$>+& zh74}KihhD{NRD|=)6G}Yfb%iPHR$5%QJn(_l|m$Uxm?*D7s7%!AAt&@)-Xq57@o&# zW>kM7YaU^~u@U&LxtGQQG^7UXVS_;}?n#<)E^kN({Zs)N!(>7%w+8mH!ffmbFehbr zkylto(n9fMZd_7h(@o^I6+kkQt6c>gto6Bdu}jLab-!nip>uB!=QoFr4+yA{CAT23 zL8$W&0zXv;*&vH-L(K0(LV|jL?F*ti;uYHPxvPysgX_BHye=4`4y(MlIPOJ#ZYw|8 zncdOvdG0ab!5!D;eK`fk2GHB2$Er@}jEI)%S9b$3&QOiB8<|6*uThL&P)ebBMn$1& z@-LuW8WC^p2Ssk1+H!4=b%V-Aa+K+HKA8GpR3loZo9f4@s38EofM#4R&8AIxR%PdC z`mc%*q1TxJi>XEn9e}FxIq8S?mgts0@v;d4s%Kd;-)452MgbIriRY+yeSc=IhNA0) ziXbBy_2xZ1%|#VQsyu+CD}&|f=t-&Ga#~`(t{=s;FzwuyPDcYS%FVhj`gUG)fSfIO zhZaHH>EjFQ?6GAJv?5N`<2e%CRk zI}3k4&U2yBGXx3(2q^D$8UsA`x>l2#n#kN8+bfS_7X~bzC5WF<)?mjKgzr%$>KJ=< z;KNO^nDk>#s`*9BC!LEz~Ny5~P2WKZRI6^5&xR2Ihx&h&b0QRDY zX%B4geM#Tn=;2P^bw!az$1HMdg!h~XA~5|3ez81#+(edib?MV68A$iGh`AV`A+sfDs4^@X zZ>Ak>QWnF!r<;8|E??_Wkukw8OpY;JN+p8`#8VczTeYyfKf6O2J=m%Stz2a3B8{RuAWkxr*&`Z1rjg!7A?0FI52 zM$W}FeoTpxSW+J^eB97==uG&FAU{A8En^W+Qz0n&b|a_x?Y$)aWhR~rTX2G1$yt^t z_99I9_tLm&#o)JZ-x}^dJ(A_(6ic2m;5N{wN**cD(bvC&;Tm#w+nswb78Dy36LVod z({y6LtLyNAjKRXefldEwERWH z7?kfl6alOgllXJMK4g+F#r%f0HT?jONNKb1^wmkM7?y-reD)BX^8C5S@s`)h^DR|hws zQ*{#qdm@7nVT+&40EO>iMC)uNZ+hL39O(b4PY5h z>E%IJ`xAD^-l~6HYfS7tqP$W8N=c+IiGSnj!jQg{xboC$Ws^-_g_j{oHZsFx00yCa z%rU&`vht`sP;!{plY9OO1_aiQfv+|PMOUjVPKVC{16dC>Xsluxyt8g3YTbhhr z0zt72ICVF}V7$So#IirWv&ox;#^3bnFqd!|)8gk-kIKaua>H0z!%)kMJy8n8UN>DD zmP=VLV~Y%E_o4`r@WOE|J>S-iGEwROA^i4;;34aPs9%YUM8wPRfgb>@;@&#IhQrX&Cn(DR26U`C__kH!G?n4V zq}yVI4x0nvW^j9Z`&=1y`%5)82v8UbCP~--7!;jzgWyo1h7F&P1osV40uiiLGW*W$ zSoChp(Pl7l9ewr`fpY3+zaN2AfnJ&AAeDa;YTaK33BCbFSFHqfUxQ5b;@` zeUwHmSSqN5)eto&E2A@UL0#R~e>tuBp^LKyxx5egkwO4~UP0jK8@hlVeRAj7_XS}71 z@2N=2%*j^5^LW$k$(Nk^-X{DIda}^jg{!>Q4pRx|Xq_b6ukD+~x$ zB!QT>NEOmT5|~N!*b#_cry|CcW)HtljhrN<8WU4{Qy<^ z^mh868(#aJhP1yNC_^_Nj<5WT-j8mqopx7z(`Q?zyz4TU+PQ6_JaXUIbyH+ljc?@s zdwGMAeOkP=prA-~2l_;D(_TwYwEiYdumA z;tp_A>8D}-k`Mr81s__Bm87WahXgfKm$;%tbc3QR0hgIb##XmYj}9pHa}{%I3wj0h zP}UZ=_!%A=(qyRrn>@E0r-Sd|{6uUlKj+lDrE{lvQMWJV=HdE?(O-Y56*i}lxo z*##k=+Dl@An6*(9KX|7AEh!}QmZ{eioCKZzWzZIMK;#crx8>d-Pna20+3YydFiei; zRNKsqO;=q8s_I(2+JwXJaw{COC^;09)F@zSp=?ac73+^WVGHt1l--ji zKl8Dbq=dNmAJehvIW(e<%uhqY$@0PigWHIJN>cEzp96Wknjm$PM$@cS&Renign3Cr$mGvF?7D7V8M|T}g;D$<{Xi z?d)^z4cXJROjY*Ub!2gpz%7hCo78Kc+l)9xZFKXmYWIS1Sf3;r6JE*|VwBvds@TB% zTVp1?QXUa_u+3tRsk4p70+-A8gArpulqiKO3X~wEc#IC}>~RBG64vzJeAWZv=Y5CV z9(`K1I-?I`UvIa3F9Ni!fd~T~NR^nC=(5-tS$F4ObB8A0C;gFzX|=(fF}bW9r{IQXP9+EJx<|G*mIzY zP$OUnVtk}H>>KQj>1YcM-5%i&-d^fStAa1}<%+%COx3?ny)<$sOZ#a^zssNmD&$`A z*y|xmm3tQQ)KN~gibtO(yTaodT*kH`A&-w-IC*B!&}=YShvVt+2cZf1^U+QV%wa-F zwaN@Qq{#m5W@4&A5mI#;JyTuSIWYaK6G8J>UV`}LvUclqi(jMLvnAu5iOR7^G~>Ip z-_FMSHO)EE(J>p!J3?j=k3>7}d{Z&whNGOTI;vwg@1QMyJRa3qcN7 z&mmE6qXObQ#i_lKp8c&#)!Iwdr22#6Edd5uMz9-FbR&v-w7R`GRm-=Fnr33KLFXJp zoJL;?xNv_wx(P}7d`kP#!IOPJm(|E|G0R0rvFaDqHbYHvvv~s-iQaNQWc+%A{K4yl z8sT5`V_fa{pU$z|rl2MobYh$&X1yZ_^0*gdi2ksrm&T_d3h_HmI@ zJ$g2vxLS&OmkJGF{>wp{r6VBwVynxf|$eWRo5y_u4MYbp+TsQ&#hC0+=ou z?;gIPN6mfOz(t;Plm6+3QLa;b_R=e_`RL|0%wgKikKnd9CRCUVWNwt;C}c+%C?4NQ z-EUz#&${2*l200QXui?R2vkwCzI%JLX2&4AI^L&J!(coDF3ioesK~<_?GnLvhfUhg zwcVNuBuSVuAt)qz&vV{wy;Pvt|If*y#Q)Jf61^nK3wd%cE1kHI`YN(!#3C(|L4YT% za3>q{1w2%I5JE!JIEIgJq=b3@2YurTK-_l#$aBU77j&)gbPRvY*fWpdk;dffa zpM@rJ{T1DO;gI=uR`;@>N*;HBnQW6c3a!JAY4kVU@or^~})}2HX;3Fg`AR;Xy!a z`E_>WRg~X$_koWQ#nTmhPvc>#mfOcDBI6^QkJ=LhNXK8!}Tl!KwfsK zzWbsldzd5o{^Hsh&54}qik3RhLa&W{nyvC*xj$&1E_5!HZ=U;pHTkPnCOs9u4&QKa z&X)nVnxhPcZCRS%$~24W14>6tU3wTyYNL`j7h2$NGrMzqPD*G~!r44|Od_uYzwATo zjtkP$E9(yi9O41T6E7|e-2CBd>Hn!|Ao{X)Y#|Grq^djoqgrQ>_&?gauBfKAbxjcv zkfJmJ5!_g*iUCBVi{Pecii_o#(m*2W6hDQGUr_X|Fr}^{usfCa3l2zBwAS2_VWwDF8~Y{0vxs`_xEFr@=Nd1! z0<%o=V@f(S2Jcpa5mccHh(_SnikjT>%^#!P$1ZvG#k>N{ zVP>Kj2~QmRm~e5G@rG6Hi6D0|dBk5kLasjr8ALE!QpnT{@ z@uFP@$G*?{SHe8fb~h~=EvM-otzF9>teu6Z_2x)s&zu#!dFPE`^1>x-05QPibU$&W zs!G9WtlqzbWBzUL20~ZY_6Ko|$;p27dTeh6f)XItSMoyP{o9@VSH6L&*?S)oUea-W z{8qry&;_b@49*9{rC?p~^$E0!yoURK|7aXHtgy!?v{nkDdR!~bdSBV4I9m65+rOL9 z85f!8D6gP~N`;<~29$~$fCa711+oq@Ww4*U37qUr6Z*GkOgNtwmXxldg2Z=7Z;kVy zQpy)2)Z-V@YEnzecism58cpj0{l-KH4QX48vk&=TrpL9MMg{dp_ZTYX3j!MYQ;{@c zTis1)W5ZY9{}eCm;uoS|m3ZvjMG$v^un3z14)Z(W?3jsH^KwX{K*vzhc`N>VuY%@I zkyfyrKZ=KX5`d*l2?P@5aY?GQ%)QtR-5g#7=^856v=ppF6s1<(z9RK0Y86loBPY7lLV6y1#OJ zIN^e&y-Ho{Pdpo)VX&4>4&u|iB@~^a(=5&ygQQYJaZCcwFCX=Lb4v}CEOz;(VCcQJ znZcFK*ILZ+tj^BXX!krW`Iq?R;$1V%aqNQ%H`q`>U7gYGWm9{5Rpdw>AiV4GoSe8b zJV-M%!1r%|F8x6n(yIg|tE8;>25NIcp zA~J1t@4LOzOhu%0IBmIYF8jfx|C_z|i^l#iiyTwbq}uNG;5kM+Ml+ngrzd%j2JCpF zEc!9tvSTK|o|rQtb|roLu1*C&!t%vE*`plRUV*%azVinFO>%Uz-TJR&{=TvSkbyB& zDBv_)Jd9?M`ZTF-6yH2N5h6m^Ny(v(p(jq-G}QYqyL&d0MJSvaxUMXfPrJD}ONl5? zJAxU7r`R73i2GlGdoX6x46bl|*CsApDo8H)O=>BXvLT%trHqQoDSXS`TrLFq#y~#O^J<=9hK-S-O%W+I&XEKO1>FXxYZcK;Y z)B@KSNRuH??6xfyI(vJ4mrE|^_?hFmEjq`@^Cl1g^w&5le?pJ=E5Mu7aOh?^?>CEM zZt|GfWny9($#h!o+&S@gG>|Bf393eHFPMZ^R3CnG4k^Ljk?3vT%S*Gz;9AC#5*5G1 zi$16d!2`mLR072lnJR5A!Ruy&F^xd{bunn21ud{8hJ0NHTO63317anJ&O>fA?2EEd zCIp4)9RE5pII7gwID;Mq#WtlVyhjy&r^=+ToCjo0I0u3^Z>*(L)&(XxKaa1;3uy&r zr8{}YWq|kJAhrp-+P;pwHmO@H=-pJe%2euhFK+}Z^Y->&*dk)C#&}9-;;|>b9_8_+ z{>rGfMSp}-QpW}eEmA3D9Civ7d}?yeb;Qg6ms-r3XHb1 zK8_`*ySFrg<~}%6FOVa>xKSS1Y=t4)nq>%sv~sU#E8y<#oy&dFgQ0b?j_USvZkHnMgRSRpzq?8ZrG}WKp%?z`w2iC+>G2iG*ShinU1J`E{)m zEgm6wn*aP3;KD-AwH)#&BvOT{45SB z%wp~eaI_lm`W(R|NGH~F!A0N8$ru`D(_fbn@li=+YBtGgyP*TG_<)w56Y*U z&Yty*TbV2~!{p87?1i>Aio@Y*uf!&0)=@jvES(XIw5YTdCr3xebws_b#k~2X&B)mx ztXhx+MH?9(ts8Co3K3B3CgwKzzK*kk4$8$==jN{O`Ys~G{o}o)vnMTQD@=uC)ItJy z>`#ZsogRI3@CUbQ^$quTP)% zH*K?|nZ((jvmJ@48iG!1btdM=uw&L;9NJ^K;$G*5{X-zH7DsVKJ>6+=kxJb-bSCN(+Fc#=ptc&hwN9K|5RlZ;9W&s8A+DeN!8Tv+oW;D~ff?kTyd{io)Cp zFfd&zMRq5V*(MIfW|bWNi%;@H*#)`mg%$2aU=vgWy_z_BL_UbgEY1s+^6Zs%gmHrVhF=FX?-kO=W;i*1reWLi0n;J^8i(2N( z8wjoK{?G@6yy8{P9_V<9ynW?aNU^^;MrL?*)i($G#hBl2vSVYHVi0X4O)I)ygj$F@ zMbJfXrpfR1mEBkypdwmsjFJ~fl`d8bk)3a;8|D~Sd5$1>w<eS^>e0(EE655M*zS zd?0CtlN;jH6G~Aytn?|tV8h^$I%mgtP0=-N%Sj=~F$P7&F3F@Ti`O(wUQy$wO+Byr zd#S4Y76>G7g3Bqb_{*3#@@6DRaXPJ&Yy;Cy7ke;!X)Z{*n3pUiJJn`+@v*EdAv zss|^jXQuO*jXzi(=|(BABGc%*tEk*vSQ!kYo?L&XDDP0|+&b$rcmx@SDu1xL&QQo+ z&v!!p9g3;)^h54cB>7>JC8M&{#z|fyQm(FvknqL)P`}C%7Q{uxs4El7ra3V}C@Iz) zH*F6!c3nOjH!x-8o2aqmslDtwyYt~I?-)a@11;%{QYvR3+xbrEM}s;NcA6%+2UF{O zr>e_fPrARHNjeYEz~H~FuJhc>%cDf$yP|p2J#{9GNy8OklXzF7u;gFbB+lX&`k2J2 z5ctyu7ORD80;_oJR48p3s_$Z92=^{sqWo>ZhjH2T2RT(j%KaN4u1!`43Q*<92QQU8 zCQgoNl@xN9MoPAbA0i00e^1MAFjZzYVl9Wwv|pUKI^A;miO?DCoZa)F0WZQ5#X>)V z)$rMq(T1TK8+J+cfYd?fv4rV>(y*rwKxk$B@$A|ULWd&yK96mvdu8SrH+7*F0ti5s zZ4LwBoE+TqIpG0S0{YC#{0bnl?h3-4!sh9(G2HQ7{TZfsbrAVwjoypw5&+ zMRbaK63Wq~kYZRCXY73+oKG%9bd8oHiJT zO{=O6U1n~eZz8lhhJ{f$NGo}8@lAUg?eSxDeKKE71dAt!FM*MNtf`n3cR38U?1#O) zP*SM(Ic8qha7qsoD3H6k&`70rV7l9(u)zp1wH_ndPLwSG)2*|&f(1$DCTehT&NR3l zY${#h?8!@syJ!2R#j-U(04n4dknOY;N%!yrd0e4#WX8p*cYM}O_u*vaYS}nttcJ)E zv6K84IOZF<-gDJ|*CLehx4cbFTEp>5CHP)_oeB0G*Qj?Y^v2zi zQEgTS6B=#yb?K$>anv!KkGb6cQg7<>^);u{^X^n7^7;@8-ZL2irUpw$+CTsSh%g}z zj5`cU(;eT2=stn5BYhI-rwon^);lkWCu9MAedIP!i+{7lJU{ebVIRN4@n(I&TmPdc z6aZ>gjORJw{~$oT=YV|hp5v+ieujdp4_!`uMt3&+Mv0z+3)ugP{d=)LFZS06*u(bs zi~YS5Xn*(LBm3`EP$BFW2m6H*cq!}`jr#>OsE+>si-YDpI404Bfx+Qu>`x~jdBNd+<34hLjH=IPdq!Z2{hhrY3*kfB%N21j_-3 zs-x_GMm8}qt!-iCB>(*z2j5087z#@M898mK$*{XS0;8WAI3CH*8}a4N$v-2h;N+&J z>f`^MdNLE!dF%N9jD#{WZc}X24*xmz{fpdxlK)rJmD%ym*L8C;Rtx-F6vK60fz7^d3}{CZHm{DOIKS5+Eog z(xpg%5D-EODfALZ!ng5xp7)zM|IUwh&deE^$*{At?_KVDm20hatsV8yK=;J)v&TUo z(209@Z#@Qq7{h?iFV>^L9+1xW5fF&o&{;?4;XNH4(TAQ;M`t$&5a@1HqA81+QP-I) z%g6UvIU~b9{z?};ClU6M(*lIQ&6)U)nN^}Q{FALNFOL->(&o15jniN-gSp$6qEeY? zY>mvBCm($(Y)q%ZL5)P|>GYL!wFBDXB)tE+A32jB#3#g zPZP2jPyGUcet{%8FWFBnEwTEu9K|4$mC>La7kRaB)%5WLNKEbBVot`eTRItXk6AZF z>Bm4#<`)tcnY2INn6eFQ(SMD;Su7oK@}^bEk5re*;vaaI;EHKU%_NpJ9njCKFSN6c zg*kG}jUV};|D`Q#kkRSu-E*(UL~N&aVbM^j2k~!Tnz4XR zCr9p)j8hWx*&jT_;2e(rbT8#;Is^+%zFwKQ0 zDLmQC3Ouu|jQ4EMx2JLHUhedtqK9&z&q^5F8BVqKYeN%rMP9`18T?YN?tUX4`X>?^K{ z8T8@yDyd;+TUGVVGRlFi!W^87Z@&0@MdEmlz+MfUnQQC%I*N*MpbW0LXst!N-7u@3 z5DC_b1gUefj%gorRZTfw`690b@mXN%YtYLVpr1D+Ihjxlj;m35bm5CloiA>#O5L*k z#1|;{32DlHT=XD-uW0tXPR8Cc>y@K>PcB{m!_^)jDmeEe$N~Am`{%1$tDILp!2QpM z8ME5`d~N*uV1;sHTe|JFemf{vQ1E>pU-kK;Tuf1)+ih}$JRZ0DHiF`@8uqw}}dpIXPU2tCyD7uCW~FR;|TN%Qark(qvfzS95W#0zoq zan#Ig=yQAJpX*1dCJwJP^!K4$!5eRm5-;?h{&?l(tYL)^J+Gj-0P`;T5_)<;m*l$l zuq8l8qYbqfk`Th>))rxDt5^PNjGxHtd(yS@EJ#4@%{AMSF)kkAG#JOe)8wCL6*T&s zcIJ%N0wLYl;qi zBYZV?baMqi#=Oa0TRMN0JM+z%oS0wp&#qg%Cf^_oaxVpJUYdBJsS~6S=4>O=%kI^# z!p9Nw<8e8=eq3M2{qift$BWw1{sgtW^|8T@1!Y7k!j#CYPR}o7g|V$U?^*@(TfZ>H zZS7v9UJ2z``HtjVViteN^gX6kN?+9eQ~`JNF^BNXaGmhsRm!RLHFOufJMv%sF$ z^YZoAG7`M+dwwFibi0y&#&?Nav^Xst%_|XoE~huI_!IHd)kksVfjpJHY z+5@n*Y~ko5LnEjW#QE8?6jy(j_GfpmF1UVlC8AY|V1_u22N8~jxWUKH_a{6j_uGzz(IXXiXqsNB0@G?W= zrCyI08My5%E(hp~d5L+kY+S7}O8(-S+k@^KmHmw^!W3X~3v(C0C4W)YDSNyUY0biB zXI`~iY&Cj(lwtJRXarZMc)fVLcv9RK0hn{UCGGtleb1J5iWahGx~0Wee&Qv&C%Xy% zRlZ4u_7kKNcI<@$9!l!&V;YbB@^ zQ#Qdv^_XynS?CA%%F($m_mVFTkD#B}HjZwl;Eig9(|h_IlB*0Wf_mTf-t7D6i0Pf^ z)l9>rNTu+mt|%hyZq)oSvo|ZQc~-GhakWMedL2_ay6z_(&4aqs3hY4qM` zOo>2o_Sh0;)&01;i@OJA$erd^IBqg-T_HXJBYF~*#=+IPBrLItC{2=A`E-(e@>Ilw zlcZeMTy@=hy2i1M9~DL8m)#n6FpA&(ut? z>)Za6a)sM=X`M~p6@FZePa3WG^+W{JeW%7cE99+&tU5~5!O(gIY$rn2*A=!;B+tnC zm3ghKmv7$P7+Zp6=JrG_$lx-z&ifzr@859S4qlljC$9Lk66B$}D}pQ7DZ|e3x7_g< z1@epdhWJ*ebi)XFjB=k}5i}w@HM_N}d!+^W8u=4mcwkp5q@0e3*jI18t}Xoh!XMdz z8082*bxt<+V=~9w*oKbXXZ^<7DBvcX!EYrn$vu1Kiu9cS1+lB@-o{HlEeepIWivbB z0pWee)OZZo;@Qerks?n;#NwZwjgR9HKsr)McZ=>i+v}W;oYgIfYUFn3){xy>qvZjyZ|-X4`RbCX@V1(yq03k3DW^Ca>~ z2K(nDPdzynI84C{3ER+7|o!6U4iUT)3#V4P7)K!+>pYM>Lopdajwns}@=|Qeo zW>qw~zuu4Tla7+kmo6(=w={0H2=bX(W^wH-`wHo?wyfOVYP?A3Pi#nXH#Y>ASxr9@ zel%78-LtLxME0f1OQj}9pO7)ns$uxfy7XJ=a_-yHR&^czdiW#`O9@M;8{4=E!~twv zn_1s%v!5#zrPfbvrF?cQoarCR%=WwI=ko+*lKpM%%hz%D>Y&@Q7DdIRiG;?**{Rm% zSf4l_%yz|gBAI1BaX*`$vW=@@nqjdx>3MRLX1_Jusne??v2~Z$yO;H*_)T2rT<3P% z(+JD7ru36E;^s!FW7R^pc%ZI{V7@TeyL!cJreX?LiNi`=F;st+I^;>13r{dL=*VzU z6AIlU%6~^@sJCf&5sL@L7rd+&PabqrYG#`DDSIY$qdLe2n(vps<`Vi#)a%ZeEZ+19 zRE1(4@OgKiuubTW3 zd#%xOD*`LBuR5s~H_6t74%8ykOIUue3a2C+$!|eCRwXX_=`5T7C38_6{ z8Stbn8W@ZHsQNy7F?)Dp=sWV_vLaqNlmq(W{%;<3=)sX+n3oIBlp;%j0E5}?p3Xz| z6Cef;Cfo|}-Oa)5p5ucDpi96uD~OrlEa(WZ#Q=OX83g{%_8o=`Af~_j89|^3XAtv$ zk1+uLAO5`qK8Iue^Plk(ASA1+|}X-`H9^R-QGCA znB_G(5mYjux|6kHj>x#dE#*;l)tr@6R67jBz{q^`ZR=U4f2{-?6cVS+g?# z=YSUslA@rKHxe&C4}S(fskPwe+Br(Z?Wf-Fz9`5 zDFc0{%&n@ce&nj6++!M3j>w54!9OutgZ%WY5L@3s;&;RbJYLJ+Fp%=P!8!K3&X6Xm zMJjW%K_qVrDv+?X3+bhHoKl3B^xR+(8VjsLYYn8pragU< z<-m=TcG7`=z7}S6TaOHsEa4M1g{$J%y1u9-%hpX^VGAqtwS*7mbU0&u(C$Ub!Mm## zAwQOD?S#(*YVzXFvED2VwV<-Ii3XY!tn=K@M>R{BPf^@;7qc0y=jB4EmdOr?Q04o{ zRB8{tldazcV-b#IiaW4TUF{R;YP+S*|M@xoLslPiKYTuMb63Z&kPv$CYO`Hur`kPN zo_uSJLR~z#Cgd~n$h}By?p@nZj+^p!BcU`|3G1CujrGM8aD*(q3TTabnN8_+8ZtHo zCm3jKecXJ((S)obQx>zcTQ>6#mYN{*5x97`(H_xBYq%jgg@Kxm3@}=x8Pm!~EnM@n zsdKu{8-2HQg5Xn@z=X5m3-!#S=^;JhLDmi;7-uLADXkM{6} z@Q-(5Ewpa4YRrG!$-nNkAX69jjl-=GUx05|Tqsz~hJRlEHX?O6OAHsv(boHU;_<;D zO6FIT(~nV|w?nVgL8Lc}_*-XkJLk-d%C5t&SC7pH{SI&{@Ir2{Z4!;*=LX6YZJ(Ug z^wf{1yO*yOI7fuU8>~=%3=F?XH?Z(fGZu-Y9n?*Fy4Dq$H~HTazEWV2nYI!bdsz`q zr#2DOTu=5v2Q>H5c*Hc#x^eD{J*ZJSSd~ zzODGzKwOZ0KKwvAFGjvaG`*ZJQK)~b^r~%pU_PNV?j>!RuQf#@#$oW~d_0Tvb24In z{q(`P-4ir|$M9@}@$||2xk?k~g(&2KdyFnrwS>E)W1E1f&1I=vLW;^m-+CQ!V zOPd&{G8-9;hCK^&W2B#4=z?A}SN!M=^x(m>HEQR9W9~#?t=A-fC0x)3x=U z@lrZU#1IXE>ofv(EJ@8{HH_)x-GgoD!3PnDnVTyb?DwD|T|Ex>_Okz4M{=H1RzQKj zDo64D3KBlyl-;}mJFD&=d)0kn(|a+~$ZLZaLZrKq-0QGP53-t!Lc>-i)7?HCS2vgYl+X?eglWn_hHk5ikaOOq6y zJw(}$E)5FI!FN^~7RjOGpF;&ey7ANJnn}Y>1y9BAZ1Yb}N;wrmLh;S`*Vhf_2!dC< zOEfM03{9>&59JY!z8em6gs!%2wu#U$uyGBqFQdVARJkEm9j;RdIcVUpz_1pzt#IS= z;NBz*C6qi(BSGQm23j;N%j`?E4JpmFip_q%mZ}nJ%RV_hB+VIKT%sMNR-zcHMZ*sa zzhDu5C#j$E%UrSl41KduD^ZAp5}z60!2u)M%V0CDMQ)J1viDKMq3h6}J>ImHcJAq< z*Jzd@;JjV^SQOWfmc8F))qUD`n){S6)g-cu?k!7KwS6pb;8qn1l8ZPAR!pB%xelk-Vm$8cOks8w&u;QOK&l=s zuQB45Bz>6mZ+_Z_CRD+3wPRwjXVnxiy~`Ra^I=Te1Bk{^3n3@^8Cnae))H~zddEfw z39u81uk+wb?C_nbAgJ(pOQ$6Rm6>pL(K9~XQ(3|5_j+Cuh_$Vo1c|@8= z4^MXVY*$ANudl#EVEsRNFMW=v{vDri%htTHnzna;?IJM-Lfxrd)%eU;_tR>Kvsd*pjAu<*Fv;jEXtC3iF5x?G3_A~Tr;i6%Od~gQw zaX9jsVXqA!RZ!J|w{ZziTDD4{1u0Q7Nq)@)=&-=_u5#uKwDT>$`Rc*#z2R)RpC?aR z`)c%XiQhSGJb4NbQ*AAt$eoP3mMx*o8S?(LmLi79JIHcZZLK{)H5>WorIzE0Wsx$p z_-*BMKgghYfdfA!Aa7xg3EGHdrSl@ zo{UlHQFfbJi%VGlwLLySx6|A=vGP(_w({)C#~;wA<#~UrK@GKN(ieTBczYJL4k+s9 zJ84U8XylZ_@L91{p%wq2Mb(oU-sYGbA|Uzjb;Gx7UcD!Q^^iBpm={E3$o@68$Rj6J zm);RC=RT;?srBgEK1usrQlYTHI|HriQySnP;8$kk&rU;~K*{z}k7a(IEFco#dZxq1 zjkqDX!c1R_X!Fl0kD~JthDYPouc*O2ZXa~Oyw)z}nf0p@iq)wdG}gNSXxopeM6g}t z^+8M>N`Xhvg}7|#9}sn}>AR<-N;f-Bp?EqyJNXYXGx@Qhmz{#`x^%S~iEk%6;LS^Z z1eUM$e;frj(2c=cMG)8K^p^F+GW!|W__6CSxB2(F!h`yDK z{IEXmin*|2hzu;)W;564I=S9%_}6EhjR@;5Io-`ZXsGl-18ZZ615N)BqtbLmOYIOl zQ~UN2r!*2CSjoWiKjg-T>Il=x^|(|MN=0i_y`vnZ3NYJ01%)?+t7lSx^JOG;KAF+2tTOIF|XOw`h`6*8Ps?GFk!lN zBZL3EV8IB->{mJVxnX1x0G7zpqQfYVsoH2+zs5k6+|+Jo@@UIjAq31RwoiFl9a(Fa z!TGov;lExztocDmH{fw?5M|xF?;3ipE<(`L%%l{@hsw#YJPaw8AdG5cmFoveMXsd{ zIT)Q9$KHB*Bz^j~DdbViDsLm>AJ@&ZYKs{gs!q$ZeAR}b>j~~+Ir@sT#L?Nn7q=;6 zHBFTc&0F1>x404VF%M0O%gP-0qqjQbmTjGB7LBa-DB=CRYK%vZo0t+C3&7z_7AEk9GuIHc9!DN@`!5r);A z7Pl+;dGEi8Omae(Tv2l$)%oZkT%hcVLLi&hg$_XLr+(^A3SbwIEJ z#%fHKiEx8V+g2x^w>_YkKmz9Hh21p_uEpbKEM83jK~HMtukzW{Mm)SZa{Zf9wKsm9 z;E@TlG^Sk{sOwLw*%n(4rSud!N!_Iud+|D}pOJz#5x`PTiuS;STVl8&$Iy-85B&73 zJo0^_po;jeE6^$Gd^F09+?`Y}s4WAyaYtcmoLR5RLK)%I=Y7CS6$T&O;YFI!^;b&g zv464YZ}D>a9$^vnT>fXj_!8+9go_t_JeXL43PeO}mPmoNFFG|OcJP1OC!t}Kcb?Z& z?{~;`lzzu2S<;6{4z_`nQy2DUW8@u%M@dfOar}yng+=Dhlx@&h=&Vlf2n_(-h9y!C z?cG!RQ#*6KJ#pDML{dyX0sTEzMC-HvHv1ik*Wv`_IMA^_TRX)X@L<&q*o2p8eZ6}=7RdS2~=?( zbULD1;5CKDki_+#?XKoaMRh|hry~=NWQEky5Jzx(ez3KrZ+g8lzRmud{c09^78~V5TQ)`hltXXeiRY6Shk-cq6|tCD=jbF1 z7uDJ>t1;+3>C4M**|VE^_!uVH+QbS0rD7a&|5UB*+|T#pb2nC;ID-&ap5K7{oHv?T z8@0nm|Muqfk6sM)6hI}`$O(AVKizhoB>*AUJksm3+>4=v=X}W@ox!uV!yV=&YyQDT zUAt>I%NYCQTyx($o)~WIq`6n019yMho04X$DAvyqN;h8mdYJDxyNy+)M1P(;Sab6; zNA*;nfy=n~esq?snn+Al>A`A*gVQ}!u@j*Zh_-yKtqc9BAN1sU}oh?N#K3~689-95hSByl1sSb{Eo2nj>Q zI3u>(&+EV>BC?b&O=+Pmp-+^ehGCHp4GGAz^G8}uZ}^~u{H#1m0PD8m8Sk^BjdmrL zAn9LfpYOy#G7@e5oC&9H;FOURA75I-!OzlHJMW3@ul(}LLP$jyOJDJRE-*`i?CiM{ z;#}8jtd%5g67YrDSs`kjT5|-Ajb7*?Z~$ad*k|PZc$WZTAz8yYqzRFYD;kZK_+jqX z1Hjvs$$YnH&ywW6sPSL*L_NZe9O`!R^k#wb{EcgSLen1~%RJh7tJpH=%byNd+CqO- zWGqyIQg*NxsJf71w+_{aDhrO`zSgqSeBdn2>qn=rXeEN})=>BrIvIx_F*5850R&Ka zoD{KM3F||d{a96-rWmO1{&}^oCAg+5>U*S4nA^Wcw$8CCoTP{da5&&P#3@M5$f{r* zXK_iL^>)@o!W(x4X`8Y?N86c3C%BZkjQ>rFxmnB`V@JN3(idHN-d>#le9zB|RQVDC46<@r7X*zBgst?B5Tc+I_Mdpqz0Ti^FT)uJON zJm6HEh=Y9Ty?|%WLe8~t>^5!CAx@l8hGNsy1$h4M{f0zYMW=rK?7fk~4Qz_m-XKSn z4>WV-hg%8v^j#RaVbLd)$2=o*Z+sxMpyuw^Y{TpGBh;J`EKi)>FP00tD|0O8*$Q8$D5&m6(>I0wnRbn9j9 zJd*z(K%#ZTcAE?s@@Lz!1_KTen;GN2cZa@NsQa(J1JohpYRBgr)0BSYjiOt{-}+tG zn)q8apTuE9!SZxW)NB0C=Z8cVt)tYF(r6%dcET%)}z%-kNN5`GpKW=|=x zopCs4mI;JA6p1I%c4JD@!M1f`7bnzxiFZ1_4Ij`~Rw~WQ6(0w?n-fl<7CP!)X29tb zN*nCdEi1_dd3b{aVrf2mNO3@Y*rJKFI3}U_^yYpk?gA&TBbl5MUH`}1O0>h#2Xf?a-@)bjQn}(J8WTq30;rl~8m;H*5C6reStZ zX{`TmPt?=S(j~Q(CdCjcF8gO2KS$@e&( zJuumIeZ6)V`tN;i-540?n_BVcmXeIq+)ELzSsk;B*UP;V+f5&ZprJn?-k%_SpwNZF zx;!!0lMkFi*O#vDbZx&{3Ce4Z{$aTidN&YLh)fg?S|xAd;2uZ=%%yMJJ%x6Dvq0RE zOlkqAfY~W8PER{yc=duGm@}j5Kk5^Y$4bP(`80#E>aCj{N_(C{uxQ%un!qBW!p?W4 z0+n^4+0|!rX>i=ZsrF|JY3?=g*2f6M3HK8B*m7asu(&WGMMMdr(6XmvpaLWM!y}yZ z3|(wqb!A{1s)e^eh5Q$wtkqHH5~dTH2i!h{2u=2l*I3J&;q`5LsX|`872~EK$Vu>v z-{s+ys(DE_6DN*+V^Hux@_?Rzd#(GnP%Cet9(?WhW;a_1ynOCUtkOMH(?N{$kG#yG z5!Zf0T2t& zb#C2Rfv~6L4|Drc`7`-p?F}__4XmK*A8oK<%;Ci z@tuSf62p5-Hh%GGcE7@7(MnXR^@BQMw>|G>V7|rB<+kZ0h42&nZo}YQH zbM&$D|8_VWVQ#H_+24xQuKbD|(6dr_ZPe;q!j=tC22kc@P`An?=Sh)-gSj5hy< z|7WuPZ%>`gPasS4QMTxRlOLwd#Qdf=bwi)2@iW_hqyShYha*HrxqmD4Pdfi;=0BtM z*B<;c=>MGAKS%x##QcS9{{Y-S@cIw9{|j3F#e0XL(m!bQ4;uZ0M*sfMcD0Lv^7-~v=1!w$0maOmTy%S>j`_rw^P(?rXM z4b%$#kozOm&jxe6Nj0YVKwTX#bZEPEp`m2d~MK z3qj-#Z`u~xzA8-DNh-qLDZ&h}{l9D&7|OrDDsp9J&8sGqBJzDk`I90L#sOAvT8%TQ zCc?qT7h?Qq07#F18U->5B@u?WW1MEGtw33Q7~rm@*`lEt6vQ`)ZmUp=L_p>jS25s5 zjDR~Xh${}|5`DKleeh}X2irj5SEH2+B?>;vnRtK?sK+As{2^ZTI6(OLII}gNl~V27vU|v$tWOv6RmvT#U>X zM^35{e^NilAC|^L>PH5uoCdm=4fHBbvH^+7v^K$ZaBWnd1e6TsbZ-p~`OO&iwR-W#YLOX_e8MVS|LC())+G>rH83 zav>)oI)QxYZJVF(#PGj#I1Ej#uS)~*S(W`A z`3e{r@o4A{F!CZNBGjor_<{yNJ}8nAI0A6QEPyohxY=TFDu)x|B}Y8sa1KVSwL!%+ z&SgLi>`)#Vvvcu!@-#2!wlNg&J8nHUfeK?IK>hXs$XQa_q8<4b9?e_w z&ixtJ^RIQ28Rbk=ocf0;N>*nIIAMBW+L?gZsK#g2a*MtLuLdB3O~~JhjH3jtIrcLa%(Vxgt(sEJR-Lj_j`)kj?Vy2i| zft!$SIVV@^ekhgTBh({BgrCmJ42*{axGo-`s?sU!+P((YxPG*JOxsP`;EG%=Se={% zJeAKWP7vX$#l-zE?ZVdbr)@(FEP@%?yMyVX=yd+pF@HBgWe+_Asy1PBcxs`;JC93K z0`B~fShKl{i_Zs0Y@^b#Q%76J#sJ>t5VxUv7QkWtW$kV$0Zd@A2Lr=%pZG8mx9Bgs z7lXN|JfN^E4)*N2tx;&0g}c5^&n*a0U_-mkw|LT}5ujk(>cY(lG8Je(0gSSI zAs(l4=|N_oZs2;C6u=Xpvj3^)^M)p;FuYX~4BZ`U2EPsjRsa9b^={mu0Jm2Q9siNkQ0?Z%C`LQ989ViwUE}5guQ;Pe&etr z4KxRAS1}9y0N4cQq9ErbD+SFo6so(>TxpT1`B>d@ZB0>?WM;C0$e9N*+55IpwH>OtC5Mu}=?5q=GXC8*V9ek<emg!yQ_ww0qpQAuroTood3;Jn3vXeeN~ z_uZO}3am0DA52Ihuj*%)j6?%Bw|a?{UHF>ICa?ADEei?2g6-YX!7n!w3dE!H(#haPi)nYmRn zxB47#bqkwhu&<%tB+SyH{`pf#*`BfG^vfe`>q@9=Y=<+yR0f!CxDY?-oVF;yBN6M% zpgbp?cLb~lG>CMz^gA;vJ0BqBt?V^%r@myjz7x|KqEa?o(5rww%YTOWJ5k2KM3sr> zf&u^Il$Dy$87lx$ivk!x$qT)yMhIWm%M6T6y4oN?6}yr`W)~?=)B&k#Ak=wxb*9F7 zA0V40Y-9Znh$^j?uEUNeB(!b*9Ai&Yfu-j&m0<$)0mWpVwNwR2$az2~!y8I!E39Jv zcFf-5F)^Jt#i^3|W9Lc8gBRn(>0vw?Nq4Yl9LYwjHuYD(Z`D4{31XT3LQs5h_Sk6% zMk!!j&Utf5VcchXl$cx+ub3#liw#&L;TO`Or|En17o1N61wO!x-mU*%;|e<`0}6EV z-HXu&hc@=mKZ}Wi&Yb#78a=G3G2H?lOO&Z3{Xf3~Mhqyao&U>$1b$^c8W{#s(LQRS z{C~=a|DR`Pcpk=H?QzJNJUsq5Ag1r01F8#b1DztGfYO8bpJ5OC0}l6`29`=}=FqYo zUhc9su*wWqel_0v@9`Ia?>B$Q{^wV1n=nB8ne0FLB>eaIhrsvO;eQEZz~x#&T)^b~ z)Fw#&_xMkooFKMib^m)12Ecv&-+pj!qsHq%g9*@9AwFD??vZpSUbt!kQ3F&s3r;9c zUM^`^NXy3zrK{$fmi}?!jzl{&X%ZSoDrm1*I1CPH-V?SlvPM*Epl9iV{N=phM-s=L z@B#&Mjp3Gk;a@l&+FCpkC?H=|67n5;%{h_tx4>p!|s~+q$S37BV z==|PrtoRR`H+nk<0+jN@E}AT$jN5n>@Xob;n|b{Jzi2>e1*2flY!dL`okW~ql35sirh`$TKq;54$uZ355eBq+~cl_(kMzm0x z))}p7Ukqg3yamv#?AOH=6dYG%!2^WyVJm@<=g*l;_$I{lJE?A347mL@%G>G5hJgYA zYbtPrDu`cq9#X=$GI8|l%pBpRey(!XDDeV-WIjO6kpn&_DXI$t*~z5eX6IVZt4RWu`)$9IEg9v_>1(Ru?KJCm+;Yno$^ zg)s2L)&;=KJBhs9CNeP;iC$g>r1FLGLQxH>g9vC_X2Ow1gc_z2z&!bY(@+EGL&NxQ zMWsJQ;<3(LUk*L5`mV+S)ytf-Q4+xXEk*8r1MhWPG&$g0R62E$O%)?Tiq>prcL=W> z%`fyhEQfCb#?>nErH6BDT9i7|Zg* z>_8AbcTD$>!Cx}f>Vs$KCheyXgQZCNMD_ETm+GK1^CoC=&DcCJgW~IslRoH5aK&we z?E)DH80JD`gb5EazaaeAa%_2=2Xh>>{4~ebb;3WGnHZPmM~ehqD90CY4?7ozsCT$| z`1NG^O=X4dwyhK%0y00tvTfAz6rG2mv=n(n+Qkr!GM%5QY1(JD)_bx2H5%>NQ+rMH zi0zI?lX(*OjGR?Uzs8R8c&oKNK|PPu6HyK=1)Hx$W5$k!Fg z%mn;?zA?{0cKoZpm_rA5A8>(1^_Tzw#Zw)y7i%`qW$#krwY{eRldfT!N?}{a_R1rv z>&NSwI?uZ19phH&a&mR*OG~xg*^Ew_X^SWXLXWKYd%X*t1HPKVj{hK`7hTxc9E)AVp`dB6_h$}4rFl^HGqpERK&A{LL_)no ziO#)`IRt7A=w3}qiv!uy4gE{58IhcdM$#6u*Lcf*?|$M$v6Z@gU4B@MaM{O|$Edi8 z?+G9++uoe$IoZdTJZfh5a0<-}39cA>LRaI#35wkR@zkL#Kj*PwC@;?E$w&AeUaHD` zG>Ny&SM=(z`EOU1dZ;EuDXu zLc9z$8`hPZ$-%-ipK z0KSg)Ez91jvlDX4Sj#qFbv}4l0mMA{hj4gb`*Ncf_>tp8wez^^7Im`OKc_DAHftVcQUw2z~6SAq7 zArG254OQ{raWBHW2S#s*RU2;I`JYePe3<|Ex_%%@$1cnk# z4elvA_TE(^mfJ;(ZnZM)EK1e2H=d(r2CoNsp;NYEs@^;GrewK$-6B^23sE>;<>Vv0 zf9B-V(~4#l`e|1$cF1i2@l6s`-cfHhFRL7}HJYx54YZW6&K>gpi+iOrML<8`3%wDFg$=SL_bE|NG$>$ofJ$1WFs=q2h*h@E zhHFQ0yjin|)c{qrM^qSxM83__>}K){9P3kBBb|D zCl5$9%W{h#DZiw}So8?++OfMz&HA5|8CGW^L=HPmhl95-PQAmO2;IS|^|QX^e+A@B zmL|1(TLhoCey6POdH{D^G3g#2Y1VzT^%=A`yz`1f zcZc~dLY?C}Orvdr#LVO>=+JAfgAUJy z)al+IQ@unVm|y4>Q{XJA%Fxi3tQ;#tIIoo9)V1^|b(u0@5zW%ZZ@8_qW#4x*r(MfJ z))%{(TeISH-#%*c!0Fry{^EQhOkw{z5Ml*WXSaTz1@+YP3VR$YmSd25Q7jjLDUsns zVcOm9RkZ7y!`Kb1qH}%5J$vo}y}QHYuIZ7O#aRw&vAGaxo#)0k|55}d1*@`8gxI4b z=&C_>{$IRCB^|5JwP$&Dv#h7SA8l;@I&7g83b1oirK#u|;y1EkTJ_#^lnw|aPG$3f`Tt-8c%a zJg-)-WC=7z5mqdjOIdGJcx#mUb-vxNMBhiP1t=IzHg0bEDa6&qO8nT{LPLfdzxBIU zYt(;GYZi(h4fO-lhVro@Jrgw<`^E$@baRExBmdmZ#`P?{lKX3V?t268mZb# zlQ6Uw+`HR;C2+G6S7njCLS7uB@gB^oR075q1wc`0%!z6fO+`N5FR^xQjdIOjxe1Y2 zmt-r?r|924Q}I|M1)WE}QDEN+b^5NjyYabSnO^z5-(w_%DXTI#V zwMPk!8fw%K=fXyf?~J`o%^y13UulK?v?2peyX zxaR;Vn)XM(3zc4^#762hzvWXnr{+soQ!lRJxe(yAK9{*+N4Hgeik66zYQn`~RFMmE z{l&4L(CMmRG&Xt1Zd9`0ggr*(3*1gR8;&%am0mJIUGXCWxjS{B zp^)N1OsvWEkyLavL~J`d_ynz6nW0=oZ~i$d@D^X|0%O!+9-PV=oFGN-<6mjez;D+T zq|YX2Md93wWI_~#!A^=r)w*EDBFj%99-%&Uo!iHn?lZ!+U1St;uZ2=2n~@G;}?)!l0Ary(jMD@-I=(p zGdqwXjIE=5ZC#ICPm6%>ZH&A%+Zq+fcaQMfm_BJy>6gtCQS~PA@DR4we%SY>C}3MW zl&0$Kl-7Yp81Re_7Xl395H22~kLg=Ms^pV@Ac-rsurELAVYv0Gm7?nWW&hKN<(}%# z#X%XX4r1l1K&Dox`c80B!McH9xsub5$}2L-bD6os@kvybv}3u=dv>X%&A z{z$oQ)LX}l6JY<&Qq*iOxlhT`H%ISLs6$=He9-LK7bwAvH?=viJ1%Qa=dyxS4H})L zA1?P_iQwTW8={*zp{MhC+?{U6`;|-sVDa4L&KVw{T4aTBw6%)K-|{Q)FDAG{QR0rf zc2Vx_5pQgxkSrZtx50JDOFRCV9r=-(8>w&ul*qw?ZKB7qCPzj&rj4{mn1_ChFrl(~ zJ{ZjQCiQ*1GN!)M#OnTMplQrJvM2V|uV}3-*wAg}cz?xsWrDwAq@I3zg{EuCH5ugO z9pYXr=NTtfU5d&l^*HHhPndJ!8cw;dY>_wlc9bCzwmlo2E3r^L=JtgYHpE)0P6S`7UYg2B2%TYJSK@^i_^$aUCyciugqmSS6yp6Z?~ zw5TG3l>8$WA(-`>KRf=rxfAUw@{ZNVY472_jh?TkQOuohpINcU#fb@OsFqgL6+_ps zzhKuB>znRC2^~rR(0t#lR6BrD@pVUC!`b>Own$N0iTioDYiH1_(gu~0ikY~Ve_*q> zKeD~@mu(-fFAKtcPHih4cd#oRFuV|DCy0A|r9xWS(K}Z^Y|3IiPkam1_xs3tXGAQP_@M( zH(E#x)DrJcBBjyDC%WN_wt_f6g`1B?VhBc^syS{}_oe8s9{`3+Bw+46(a+U3l<3mi zN-A%gqx$e7+YVF8EwpDgf|%=WV-^$#m&UWFcY+j!A1PZatF25bAOw?@CtQl$*Qa-O zg(7>TI!+<=2H&Wh7wd!@lh^#_B8ju3742@%tTdlFI0-Bsq3M3&0O|{x?bYA1*n*dI z3Vq%nzX;v%06LPo9n+L7$dy2t++s?XW#?+Ljlvbq4*NGf;^=9~lI{n^uJY0 z<;`L)_g`O3&P>jU@&yFQYS?3=uP$<#aw^#@N_k|orJZ|!1^!%>ST4S$H1Ksh-$JhkBbIhGG&Vm@erx#v67v#8R*IB)Z z(xS_8U~W@@mj{S9Jc4Nj&&JA9IW#s`sg0f;UR=p|H-KV^U2uiovh-sB%*uz-(7;PGF|C`}=_eW2oJoYd8IZZDR7J zkTJmIt>1#NyI3h*FgPA{x%v4Fm58wqO<&oLx4VEl_q&2c>?Wl<3BZiS(u{|&I{Tm` zdiuS}8>LX**`a$1I{al3K)Sc?P4B4jcPRk)?*OtFVrmO3cq;cn_vmoCs;QX5*(sF4)TCnr%8e%!}{p0xy!$wRLPL z?D_TCbz$1%6k;^U2w8|BShTq8BTCEU$vbm?3XY{XCWBxnFXk_1Iiaa&cRzO$-oKq? z{cX&RnEvQV+^gl)8S!&qokkO|L|?EfX4 z6#=9?;?{aK`F}frG-vUpFPi_$I6DIQ?7pM=UjNIE8v`2h?d9@++jy8=fGX1Hzu|wA z{SW_5-9NDbDtrG_=AXGZEcg90mH*rX;70$s$babKuwwrY9{fXW81cEzx(n3+xz)k=j58np3Jo} zYt7Pq|FA>lzKOxZV8H+Y0C)*;VFdsH)E9V8LVW_>0SI-^0swHbW&&h;PDVIsp->Zvv7A;79~1F$Nrpv_Igdz8J=5t=14d z5mf;+ZEX;B%c9Z>>aewXYILOpr)oXb^#DN2Db+^$MLO5V+rg^OIETx5<`)1aE@&z> z#vCYtY#LoOJhYyy__y!zS)eGB0Kg>RD-xyQ>fs@jE99q@&SbU)K#n;bS4rLb(uZz% zgLoMdsIRb42CV|r4Z%A!piP}T{s4?WL110qy+b;1L9mP}7(r00{CBGPYT552bI)6Zh4? zry7ZY96(sS7*~YrkHVdZyCjCZ%zrI=lyj=63KkUT{GdlK6Qkby3U?gx8I|ah0dgqV zkJ;)S!)j*2Z(}>pHLt#EcePFJ)NJluc!)>`K}D_(Av>QT?0!t3@AeE8&kuwfJxyJi z^Ie5m3aHgB<9qUj0JxE$7WttqI8$J1{PN0Me_^i|d-(eS1_iz%fenBdUxwzr<59Hr z`w3o>3G4sFbf^8h>GjQM)?}cpH6RfE(RrO=>%PF`sf<-?T0Glg_MZbIz`7>Uc9pa10>ZWP+ z+xsqm=9a_$NxHI;!j{&_>fWowS~6W$Y~?eio1ziZUfgJzCo4t;rar4Kr6X3Kvz@aY zB!B&Tk&jKK{`11;fVt;H%|}Q8@4)l(v-kUOATHRZ7~-Q8`)gwDURORug`No_Y~nR2 zOK>SI_~U_ISLFmC1puoDyJH7(-i7`6g8=DM4nMc}r}$qF>b6-U-H!Cw)PTO9Xl39h z{1A(v4L{7QFmM3yKfh!EI(=_8iJD-nyO(C5v;6QbKY0ox1q4hYf7SO*AyNqe+xE9b z(j|ck<=2ljB!M3i&I>_FjieTqjGYsQsDUpQ@D@fOCW~T_IlsX4~3d}ovsMm6v zdYf_k>o)JUoj%tUNOiI>(U%w;ao#-gINt#;0@1%?>*^c;cI zq!_UygRMhiL&<~DL--ULXjEYsq+vukqj_aNPk%DY)5>vD;1@3|VV5AXWTnYy^V1a0 z%gZX-DC(M-nx2A}V?utxz+BTn zqp_gLO5(SuP|2VFUVfs{zASyluq?mqwmeejVv#-Log*||Jr6yNd_+CHRNB%`TbO%Ll3c_lRH<+gq5}zQpkDh}rZo>U z4>Hd(AB@~j+(g_9{;dCo^5*rdddG)Z1@8}M52x~l8FQ7P7yb<1 z0Im?*nw8sXkw?KL??HTPFfJBS^xZlDYYDp#Ym9N(de(f_!crecpPFf1&vUwICBSIg zCeEs6ez!;>nPO&kK}o-5{x;=Eu>miAWZWpZR<_z>G-6b6EWvnXba|9FZ6$>)1tax> zsnbB9epAg*t*qX(`mmb09><1brDp!xg(~dx0Og2z``nk(#(CCxiTUty?6T~|!<9=b z7%Out>y>G%H_O5$l_i~O;$^#2`%~5>?K-iuwbR;DsxvzFp9tp&D8VuaXSqkj%HBJ7_zSlD^+tvEQ1ZcC<2M1Aa>9mbv3w>&K(##5>oL=4jU z+Z?N1kXw{mw7y8;V{<#NFZNf{Y2j-1Rita%G%>98x6(LU*u9*dZ_&C`I$T^=-HP8V z9@=H*j)d-0?_}JOxPEdSzp=dcyx2QWym0C|p|cUYz`0mkm+fDQK#g8uIQNTgj_xu^ zmkp*{VH>Ttd{4l7%WlL`je&O!o;K#Ei-=vucXA5@WMuLTd zriQkJorac#DuHUjw#3W$qJ_PRx{c0Awd+bw$jt4ieCX7{pgUN(^$_3|Fb2)_`5SCB zY$a4D{&#%BXj81{C`9Z|1xL^UuD$kc6NpLA%d9 z9bwOS1aNP;Z!yJonwA$yof7NNcxP1merJ5L(3`xe7{M{@tBEqxnVFY4i47icp+xE<5i#hX zT|I21v`XBdxcK~tu7uu}p2hyQ33v8o`b#;}nF(#?QxkpDWw81q^;TKZl5s7@Xa`#` zm&uBjq4o1>k{kLB`q)VZK4Yglh?Bs$|3+9wxC%zQPD)*sS?pELQ#hU{#?G*qx97K%i;>O?&8+G+tH77AF{)6i ze5%UwYfa^L4G*WSV@Qks%3|FS9nG5iyB3O*@xO4efG)l|Pmg8Fvw%5DbBb}n0R_?3s6VD-E z5?`|4Q|@=_!L}eZ5bP1=-wf|&`h`Y?NbkhoMxV2S%7UW$cl+;qz6WcjwWTAxo!;J5 z7}xF(6T6G4;N;_JJJwyOZB?)D)a;}ZEe(TFH=fd@VKBepK%;4_f zaX2lTSlV~cIY9UreyZPUdwF_RX`C18Y<_bt8f!0q_a6xC56{9#UmmE(Z{Kw}>fP%a z>D1cN74K=R1Edhx`pWfk%nweXMmG@?LaWeYBHCMk|}v>fcJsoB3FGlX|)|aK6n~ z|FQbGdjBNr^Yk&d%=D}9Ch%2cW5lQuPUp=1?71bMt>xi7y^RfjBdLwaRrM|F=6y6c z4>6kW+lTP8()-f6!%XreUE|C3^Rppn^G_ctUy!+}uVB8(t$?3oU|j(RqQ}B_PgR`+ z$4pjypdZks6ESZP=UT^`S-b_<=hhWqRX&h_dAaOZ7=C74UsqY*C|oWj$O-$mgKx@& zziYeaDY9p7rn@^Sj+u_wyb*2uB_ zx22Jqgt3eafD(8O1po)Z0zd$-K!7JN2=>3%q9Eh|us`pE0sz5g0Pw%dd;@;|@e>0) z|M;K3K0$wi{!;?f_b1rD*FZP{04S6mG6FxKZN=3c004B-KTZ${1(HhufFB?sETH5H za+(RLht`MDk53Y??s3unfZ*>-R760jkVg*c9|lV446elA*9qkh#9oUrae zN0EReDE{Na)8W+k!d~J$4hQ@kTgG~1^z_swd9~4I-6NfnnfWxTDh2@k@8JsXH>=}N zz%7qL0P^o4;|m(Y2KqmRzJS2+)3t7LYyVpWB~7j$xjmR&X(T-^8X zgB;+7_-{oqzRAcx0k<`<<1?iH)da)84)*UeNH&eY$pP)B{x^;i|2#t+*p!F=ZSVhU z%AfuF|KBT@GNDkky3y#btIZArI9Zj^;vG2NaSYm&4l6w^woliem)hLoXG@eduMZ}Y zPYc~{_GOw~ul$9@Hj)^PhMU$t&Q=;p>FMRhvpWg$rIO=DQ<+-VAhG`*(5@aqfc@Th z=1Lsr-46p*DS6OKm0sK@XKUY6PW1iTEWpCNQ_Uc3^ga^Q?*x_mvlY6T!U^%&D&EIU zuc0{7+S9dG*%n7P`J9{_66F%5>mEeZJo((9(Bqk9)5R*}8j}gUX4@@)tyc6hm$?dU zDeotvuzHP*kye+B*pD}xGIJjtTJ7uP!9j6tm+jt*e2Msey9Op_IQ;k2)h4?hH5NW9 zl`?|0mUhz$O$_6ScNypNRim}MWX(ELLK;n8EDJ8Sf8OMcph~Y>fy3>(?)G#^-F|n_ znJ;Mbo3Ii6CPa$woz`PNLm!!vLQ?DKS<1Tj)1 z_1~){MgaV^TnddjOszAn!O1&!3F9@m#}UmF0AQtz9RJO%**B3 zzNPZl6MpH}R}&b!mOrhqnC83VnttiDyMObzyt7Z69RuC~;I5$(T(#zT%NmgBm zjEI=bF4w4!LF9Fn#dFImhQqo|ay(mJGSdxRQmZi>>SbnH(R=L+$6#7m(>9&_>6a&S zf>F8@ODtx1Jgcj3HSp;XL$D}ka|U($qRa2c^sgR=<>eB@UfsWka`UU-CwOy-lX;rD z^tsvM&f&Y0Y7?-v)9KtfgLEx!_a<=8PK$8q9qdNsO5;>Ge|#vuN4OScrMs?8@zk2r zFs&vW3aLgdRmCBWwd3E3!$^`936@JWW`s}{--QEYB7Vcn=S!Kq?MUJyYT)8$GfVb9 zK^OE^>ULTq_`E(sUoRSz=h<%k-e)x+^m@K^pPHa+aAiIwxxZ=L@J^=dz|4OXD=L18 z>2>aL{;S10q68!)N>B6nF~nj~BXgyyd;4RWalBp{cW?VtsufyuGu`d(PRbi@Uld4E z6G#DT{0S_%(S|a;(gd%cvTd@o-`U9c#~(NBcS5p?pI03w%&wBzD#uVvaUf#54koe} z{0I`MR2f+Zp1D2l%dXG3a&qj!^5lLAD%6{Q9!g_5>VUlDTdlWf%wV?@lzr1rYtU-8 zua3HS4_YzzT-u#eeHj+R_tIxYL_|#fB@{feJH()P&_B*>X4z;aIHfEWjhDDHAZw0Z zg@aBm$6umaS@8BLvLixPSkUKsh+e(LvA9|SRza98I9TkEAel%6rqpNfULQ{o*^i=$ zHRQ2GtGOltxH`KB>26_@$Yisoy>{F&Xf%XHdZ{7lS1 zw$3$A_K$-DYp~+u)D1Ho7LE=obGa)>a#M{qe4lA%wXs+&i#*>y9q+V%ya#JIYPY${ zhsM~pmw(G#Hg~^0P}}kQ>H4ttu$S%g9$H`NdbQ1Hzk48=(MyA4z*1w`m2k*)0XOB} zSEF8-CsESW=JgVV-ZsHKqx1e~P>q1V5LIn7g09}?TFw2m8{hx+?P!#3Qseu}Juahu z?}7c}<@Xcg_6!~m=RKjPFvkl#RI>>Fl!NRGw3AowSG%~)0rFboF*y)Q(z&6iHX%BbVkELa#@}^72em&H#L3hgYh*si$k`zm#iy4Uzfd#?pH;Ae>9`Y*Q^Iu)gq9=uS66A9@{HH z!7j$lK-f1n`3qm_<$hv0^I|YoS8}I6G7;5E8neB!l12?uRy_*BkF>I#mEU@;M%`hH zOs&xx7q`fNmW6&K8jD5a$xtg11Zkz#Q&lXgX^TQLnIu%*uq&prsfj}ude4+Dg`K^w614$>ylU`)|`{Di=JLg znNGWgI^_=~-hL_b=S?VxpAw<4f2`jaM(D0Q%pxFfV6$4v5nhk-!eH%wW3Rk-Y7I*{Y1mUKpS-MJ`)ohDGev>zS~VA%?r6(e(6S# zfn&T{o4_=U3)ugbqiQjV9nj(#F1P`#TNKG1{zqIyq!BmAag- z4x#xhh7d&9@5LF9_oLAlbVW{=E{rJT2tykv`v}@vt+kA;ehSt=J8{#;!kFm@sz+1d z2;s7Ddsu69nys%sJIF3PY)r;HsqiG)x=YsewoA|P;7^MM+Wpw!WV*s1{G;&J z)C8e*^>wDmFA}{i_`F^fNe&14Wj>}mrl}6~*^kHF%YiOPny{X53|_FXdGg<~d2KAvy3F~a;fSMr?eDMh|`&l zwMwP}KpWz2?4s+#Ic_K;s>yJId}wwGK@z9avG~pYSlk}<_1;KfPV#t)S9e7kNpnQA)A2BfsH+x~F%%8*L+Y9)*%7#cSSp6n>+x6~{ z!CK~~eS!47O}^jVJ#e3wtjd%J5g*(b$+{t%-XbW3Vb@UNj~ zgxnamr2NEF!*4KrG7g`Oex^QAvNeTyu*PHGoX36om<| zF7!!M#uCvIBfJY3^v@c;ry=V1CT<_*29bTT_#;t&b7wkfxY{8jkH3Z-ZmH8E#Xy*q zFOf>3RxCEjnwR($*%IYNT(+j6QRk|}XcCor9gq*}c71uww3mG3jy1Dvd@!Er z3_8#z@2F3OF}pfBtmePVA!^^h%=pqP0aL%#n~(evZ&L7Gq4(fKH@)4f()mc@!o z0@96uZRhN-J%^O8)Xi8t~9hg>g{&N>wuz2lyVwM>*szpnS4 z2FXjDHo{aL4II7gsz1HPEWW*OWckww5Rt})qLP(pxu)J(j9wnI40NnC+7!N2sLv#g zAXFL%)tC&3Yu`dL1l&}0@PMs+m(XmsZ$>dVsy?nGc)H8>sZjWRP`92o=kdNrW5 zeUzMNV1>}iTBXJN@e1qwgdqQYFvdlxB?aT`_3uWi_srAXpi%9UyXJ8L&#*FE zx**r7i^I&D_bU8K-JW!kh!7p&G(qm~lAmQss_3!0KUa}-+nntsdkj~7&rU6}N?x4{UKI7R{GTtZNG7 zk0{k!zCBi6xf8(^X6orgR9tSh?~|_9$M<}r$F-7y{c$Ff#yn31Ga@>a&DVL4-J3vN z7M7wB34@mv+!muQH;gS{v}2DRjpWV)UP;2Qk{?CqNIUyLsfGJ0Ktt@n*M!sk08Sst<*<)K1M09pO6bZ26(^*514e{-+2uz}*?9Jq=SDeCoNy0oR^aL9z}Z(P zKDm5ykf!es@TpDYhwHX-OrzP36J&c~i((n;=|67B|7IunK@o{S)Ie1$-=*4Xy@Q>5 z{FMqnf$u$Rz@d=u*D_C61E`#! zQkC+}8tmye_8BxXzy%~Y-d{2JPja{|lz(+Sd|lY@K=OZRXkj1?9Zt(CoA@^e4V+c{ ze^AIE|Buc7KZ#i1n4C=4aUT9(!Qs*x^bcb5+?5S9i|2nv69eQMh{wX+{?cD5`ybqk zuN>g|>;LE(bj181hej9~g;M{`6g1y9$iKFnKYn+OK*<5zmmm&~{(A}lg5csmr2^%W z5fsf=$GO|B<=>S-!Vfa&@t;xw-WCCX_?fJ2n??Iy`+!Zs`@gIyn=e&_i;WjCI6Urx zM8w2niZ^$6iTZsoQg_FRq(HKN|30C{_Fx>B1Gv@Va5+oA;UvezAPuFnRlZXw75z-0 z(VWLQ1}0L53l-Y-TfZT03cX&OuLp^vQ=FRq(VKu{aE0tz1io1yvtqb6T>ovtWucb>tHaxx20;M z^;(Pbi5B6%BklJHl?MY4--@am2x+#F!ho7^N3F!^;w=h{v_)lJM8i#AG3Y)GZue2u z4N4}{X09i5)?3tWm`y)Q{t|@9Gaf66V=dRM*<$y4j_`cFBD=om{5awTLS7>6R_E9j zrxWoCEe=uw0=XnQUBa6Kx|pIO@}UHZc*~Urkp&*x&7Zd?3%U#4L5O^wx|t4S?a`h8 zO;Z9wP{c$CdiKW_GztYdVC~;s)`7%x60cXw&~$!jAFGJxsNAhaBuO8EK@@Gfe(>{M_NC^Fda*>=4 zFxNDI;ddAStE%#Nz-x9sD`B=;z6HUg2^Rl*0U?t41xo^cCyy_%Mb1zq0^y9?ek$v9 zhZnY$yo1KjP=8DeHjuT@|AS-7;Pp~@LP57v7-^R=81S|T<9j6We!EL{-kw<2ELEY& zcRCqJV6n|#?eNls8!?i+HL8nRS-P$>o7pR^>=zG5AEN+df^4(?tvxo0fyc=Jf`Y{c z%QYLp=c!#_{IP0`M!xd$R>%ysEVFqYzrOv@DnGBoEARxO0NQ$U!rCtbqLD`RA{1&)B_V8AI`O(*%K+16X3ouvO8N;2tDp(*sQ}T zE)*86@aC8id#eAw4ChR4=*Ir`s9#9UzO8=W00VXpveuk*NDRBW+K=fYR(4(adql<0 zSDW{y#L`dPp#ms0>WXlKR_MRN6|Ki(SG(5joFMI*6jn2bkfU#=`3&ez<(JQy!jtnIEg z-?+C+C6E6hvT)dL`XV&=RrNh70?fHzMgCm%mocc~tRRou;S)ebg(=~OPgR(fc240u zrE*Op_ZMo_%5?%Dy*^WVS!}V=kXTK%#7m_@9dGGHY9a#o|6!$37y>cRg0lp~eHA&L85P0Gp-h!_fM)|Wj638mq$7V;zvj2OIR8~p;SfCT6D zZoF(Ny4Xomlk`8EB(a~NL)(uF-HEJBAov*72pG+56<4>??~MYnS!;dcGeM7%MPll$rBu9t|DuUv|*@%oESt$VrNLj%ZLR6k~NyY+KRPv!L0bz-y5 zmZ>X-^BI@`ky)e&hxcn>YIQ3DIx&F@MR2&I4dl{qv1rBjE;M8cmp_*`~AZP26?F*1x=0h+J^g=<~u!>+nbx2llALTAT4IUH>~W{QZ=ql>=7Rhk|MZTuG=Z#@&(uG zLnQ$i%_uDvIT9JkzUFYy(~E~T;O-;k`O^c*)?|LFmGew4 zg8-|NAagsNyOhP-<1PT_}$?6HIf-u0_`n>2%sH+@<#O5EKI+cWEYmpii^u zG@Q%oZnp?@kIEgMZn&Giia>c-DT&+%*DHOs5TUcZ3C5oi$Sx8CBTr)I$9hq@6H=}O zjksPWhlBB%Vn_!)&1nf>wSHtDKg9X6uNFqo@`uyJ!UAh1HZpRQjP>V;o&03?<6={0 zVg8BZIg7iA6ow7)N*y|?@GXZ3aOZGJs`x(whFWaJ24c95O`3~x{|lL!bqPrkEO`QOp$VIQiA8@~9)K(6mjm`jc|*J2fy@5l`FGvm zY+RiaUwi%sPitACeRC=O%Hcr znYU$AoShSPZ(i&~m3;0e%1Ei#BX_McjJpQQC36)zg}f?z$ieRw znqEBcKD-Wv8b!K%U-7qx6HArCQtRi}By_TPy$T!;FEe16q365^L0g4_5h$&uxS8PL zoz;E_#d*p(|C7;DB>0L!5RCtk6jOe;@nPv()K>3cERchHa8?uNnJySZ-52P5y|#Ed zZ@t=Yar!=;H{@=PvQh?0&&W7jW~`<*25;VtS=F$*h^pSE zkwbR9uE7`HAFjDU@|Vsr$i$+UrIb%MH~Wm}F$v4$uK4uSv7!P5(!k7QOe;_gA86&c zJKx_c&=b-IuZX~VOPrGS(U^eefY_l#`Z?$VH9ya)XQD>3mE?P9jQ)3K=DG;&+6#H< zEm#Yay^9424jndJ6=!MG8}gk}zrM-)1Of$LHicduw#xnPxTq`ino{904JJ*!*z?13 z<`>keFTdZUMyqosOk4< zPfuS@v;_nNrrX?Li?v&mU>4acK%GDxvA#g%q_YVrS9B}@x6vfd%&@8Ct5A}e37!Xk z_d*-gZi|H&G)`a=y}nj&fw2d++*4G-XI1GiQuGoiyQ3&uuc}{KGLHQSr2dQee~km( zP$ZutrZn5T_gcKe2Ucu4b9IrS@Lb1MiWLi$);n8_J5y?aX>~($)ZkWS=euak0pfun zjkk9LAa27aJ6ET)QFWo)`}RA@xacybO{1JCY0MwgZNJ3_2;W33ap#=L z<$k(XJWJXQkQ^#u`*x}{6vstwm&DA+A=X=E?)7$u>Bn+@tgPvdX@UEwz-qOu1Y5G* z7e3?<0efe$QRY+?akx^h$p#ys-s+5t<8pEbOynv8^avBFE)e%V?wipKL`5FIcD$Ug zaDrmUR|mxe(KE$;l}%^W7LCFwPfws$R}e$7S*qzf6cl>}&T&DYUREBM*SvH78<>|Q z7J16N;A6d9zU#Gdj*D;jU-^8zfoC#f9ce@W$ubZtWnmJ!UV6!J^zzx0BYiQt9eDiC zcg`8p5z#T)lH4UDw>f(#m^z=%JOAmF9m|03y>Lpn_JTPB5Q&DZh;U zsQ%*@AzGY02=Xiv5>nCi=K|%KxVJcEHO?ql|AWQq+~v4W9~_Tz8V+|H^RmO7Wob~Z z{2ja@r9k&f>RPw#`R_p3<3~4T{R`@S=Tblb>D9}{K(P`Z;jp`_U?(Z~)CTltHH_~= zKkBOthfp+{`saYTB$FhXuA(0n>FCi`({W2JZU)dHP*&(2i^&BP9?zLg{;@#1ZbEY_ zDRxEkt!rs!A>go6H#F*!tTjO+r_$--b+-oc(4Z0g)IydYvRl|{Lqa|&+f1~t5CsRS zS?VRKE_i1rWj1Zac|cFbxO3m~r>hfa5KlUe0Zt0*zsUkWH2%$BD86Ph&lDRq1Glm; zdLx8?JJx^!fIp06^NM^E&21hhVFjGt$n$cOuyzeNAII3dQ?UQ-QIbo3B-bKO06x?< z;wAwY@C#IEkEom|3!I+P2Y=}5pWs}3T2e>;B{V29x`bFDGJdP`RrLr3Tmkc_6UWzl zse#=G2Ms^$e@&SRI3OAT#Z}TR-~XU#S1tG<*r9sIY36^-8?YQq7n+L%JHg*O1n}K1 zsexY7iEE>ae=8-4*#?p?1x-@FLH=bkV|-1ZfrLyFod?-}z#L7?7nHtF8wDZczbd$; zfP`PI=MvX{j7uInXouKDU}2Mh9shro@PlR%^TYl;SgbMmuQB4WK*OoxuF=-d|9KGy z;60v!B%Z$l?H_#(>}`IK6e2oU$iFh-KPN2kaUh2r4X1xC<1(HAB*G-+B{J$zL zfqlR~QEfEwUsGa;pdSkc3uKN_|fs5IF0KXkEp_N;j+`?ve$H$#240;zd5z89ub1bn)AXqr=(}rr3LFwJtIotK< zC#YZ<{t}M?(7jRwQU%O$bYjb{C$=VVep0Hx_nspMQ{ac15-8d$cSrYo;Zh?8LMlr~ z7KnC5)s#GymgC(#HUDGQ04le(aPaQq4oQ>%}7fo+wO8Cx!D4F>)1lK#*okm$UDZ~ zq#rQS%`#AE)io&QSqB6R=1U#q$!+P!(P}?~pKz7h-d=hywZ8C~O+LyG zC3nWuQX;N3P$h%yU3Pn+Z2AbiY0V;xwZC*`=1k0BSH^Q;7;c=O*&l7YdGUa~PDlX* zmqb1#%j%K8lLUbPXw|FyD{07(8H8=Pk^tI}5p=-bg?BjLjxa-%sgre99k>Djs}A+b zZPPKuP&`@V6L+%MlV$I@-tQd#P=%y!9Cc5_gDK5nP)urBmeFH*WK3LKQa-PbdTL#6 zvU%TSz6F_<_SZC&lInwep$gr2ksFhtxWG<{#WA@rtoCh64-Y!gk*YlE&du&^ewK-5-5c59e!zv91UR4lXT3U-5h# zOJ}Pm9UY^^8$6C~JR{(n(D;hvjVO3Ef3iDXpUP-a?5$^C2!9gi%LR^f*&XKyI@FB} zf=^NHYOjUnE77)Hq1Je5I8`j-Od(HD{_1g9aE;xT)tk#{)8Tz*&vMtIec@>_Xj`n@ zSiL(KE49qCBC_1zAz-snV^?meJWrXs+JsEKka%~o#|xJ4QMnK~7Wy6la6ehwnyzu; zay#gu%h13^U3*#!@&kECK}OD+wT^4|8rO2m!6z3JO{)C+-? z)Y%U4XDYkl2Z0u2#A&ry74WAw1wm+d_{3bXD9ORQm>1TJsw#S*hdM8V(@AoSU?~Es z0^t)5YJ5T8;l^OKX<1>OtZY=IV%Ni=q!75XRjg1!Wec??V!*L)%)C=A$;y;XDO|jR ziR{$d-k2)C`J9>Qf=eOB%iuJhcQRMWx($i_Kd^zu9LkxzctWJ zOb<;kkzFV$Cl@1~LN9QCu~LY5HMgKE^-Hk&t(vf@1L%+dIve|OB!S-NeBI7>g$6Bk zhjcOw0$!8R6vC5*awd}q&)FW;OiriW#PxPkp&&R~Ts-cHr{HF#g=&=R*JefQqSV?w zDb>L(p|J(M_UO9#@e1NT5c;DzleS_&i0R==bhuyJv@{e%b0;5Zzx>f|!S! zYYTTL-+AnL9!si+&Qa&7rIn+xnY4(AQKO#iPw|tg9{W+z4l7ferW>8~R4c%Ty5CyC z2ORPVR^IAM?Hsyq-L&A!-d=QDe)@YlONonHCWC6N_`S6j*_}18+O=d%Ix%AT!9GKf zsfO*i1q7}ofW0^mEisYR`xH@mv6q64AC8b8UM$9fedG8^3;K&Ti;s$3Ru$%$kp0sG zF(cOeWAZ9E6B%9M*}$;c!^;{{9a1meO-L z-=>g#<59`xlYSMLFV`;Z(=8ZHB~dJh7>*>@0H&ox#A3@4sMS~;OMt9UJ2o+Fm$KlO z2fP^kjt`(e4io0R!lOpdGnAt-pkEkPPcR^^3BS^(I*B>#F+6%N!Hmk^aAsX@C~ivZ zSar~}Jg#UD_E>71TqHb^8U2ixO1an!rt`)`R%+=w@$8<=2|_-e$tRTn1O#KxUhZcU zQC7R{kEJsgUBnsP3J{Rwy2|gJAQvZe%Xbz@E1_8}@M*)PHgK`09QNr}e3+}Muy_VN zD>k>2Gb8en^hP5|+6Ch}6j5)Tte3q;j*-S~uk@>WOh$pN0@-%zK8d*U1MeWxizV!K#p&hYNFXfVjDfzDbpjO!T z*>UerN7X5 zIKNZ*h5eF;L8B%DVtgMn(#wyP=SI(~Bd&tM=ZEab>(zL8Qcg;Z7vp=M8!&;l$BOtV zI^KmcEo$uf@<}4L?FzjK-Jex3NF~JD9&MDVwT4WkJI@m-&+S<0E$_)cNKD4HuGh(Q zhaNs&w165!PWePphO%}gUi&BU8x^auhAW!hjtczYqxb=%p5@Q`USO&bVt7_GUOMti~k2a!%sWW7we4`u=dr3N#2=lQ=<55o1JZNJtPGP6(^)j0n;c1UNI)KPguOmoKC^8Y-^O9jNq+8YI zbvs_VJe6g9h^`uCxURgMhkl|p3q(ClDzT$s2Q3xdTm*VSF`zGw){J-lAjQy z)b#Fsh|49XUi|j%F7$!(i-8!vB3Wc2v$ywj-O`RivAK+u@~g#~qe=TRA4iSPTG2M0SS8@z-->lgJM5a+ z`EV;wniz<|9~jS}p=8uLufRL3?-=(69OWuXtom$6StkageAq1b#JOW4-cNbHa2UrW zD6W=4s0f40u0b_d41Dw|PDpM2?o-xBCnEL#Ls1I-Up8h|ih`_@Qo1 zk+tz8=TJ~klLOMpyzPJ3RzKiHlcO80%OMDozQ;N$1N=){K_?$+`>RnXk#ZkB){n1} z6n+W1eR+B!FO*4otyT)XfStZCX8i-KFSVLvqeN*Y%GyDgO;&Zd+rr1{nv5k;tJjDZ z(&A>`Z#5zB;`|rzi?h@#f<#BE9gyt@bUv0u%Ny3(oWx?}mesMoxO{_G!N@gK z$Qf22D(dxe7&9|M3yWZ#OxkbY*Gl<$NHsO@oGq2wp>&U#VnBJqL_^7iD!3~$gfS!K+-k)K?qQ7soLoDL9<3C%_PY49P1KA!Y-Ohal%OhYnOpVW~P z3)~#|1uyM&{8`O#KD8dvs8njRi)WiUdEIyXwly(X)u%MUA$413g`9V#9?<@<@svu^JIUDmRL8dx|d$p`ChEl z@(biga9AS#t5V?yH=g#;uo0{lif>azl}_heWw>>)?#|b-#M>jQ4gnKc{Nx3X&YvQi z9Hjf75WW<6%4JVF5yBmi?*qeAk;qY4{>>>k!76A_$445vj~>^tk0Obt^NNz1qLDmx zdBs?YJbvoH9w;;kBuE=U7izwKe%SO~Uqu|Bk2lK>64C`>P#c>tkF8i~rxe2mtSOQ^ zu~Os6Ud|lN`VW$-TERdVM)TW4wfg(h_1wnp$_mG$>FWD|dJwJ36}U$`<1OEengIspY~S~M=R5x?zu9ZAJ+t;&&w8HwzShZWBGgzkR;NSIkP%jwI#9>lR*)CJ zvqB~TgYsu4$Z7R`Mub^LNDdmeGXDpNny?DF*XEB@E5$)-kiL)RO?E3oQv)J^ z@7vxy-O2UZnvK(GdmB@JKd+~RaO0^?5LL5I_c)hdhQQQMCCigCeQ*iOV>OS~fC%Obbkfu+y$oO>t{bNU?oNj!+2neh5$d`I;r?pha()tf$kb! zm;^QZWk>2*JUcJbz=qzHAS+qnWievK&9RK8StpmgS)0;5^S4N{@0h2)5Xb{-Rz~A$ zlVw{=hwH*TB4?dm=e_P2_c!0yw>Wg%NIiH{0|Od9{lfw$xZL#GoqQ; zi+&@o@p)P0Pqd%T6vw)raPmRJK^n^0jorQfx`JN&XEM8*NZ0o?uBvF0IXQ^o;bA~e z%@WqdBd`r9>eyZ0BYV(jxt}bvWc%Nhv`|eI3D__mKfe1j=wSw)@#_IsThEkQAWqnS zKVHwF7RBIO0d8xfHhZAjR#May6(wLjowzf4T7e-DojI+7@j7uNrD`uAIj4*E;ZHbH zqs?3p1M{z)WQ}U$#{z2B563J>317!MYnQ>gworh>fjcihfKcy=5e<$zJ8Cwj2F;bJ#|w-t$A<{lhSB&4UY9 z+94(fti(8Di_>aqz|L);1`ao6AD=a&k0(d-Px?*~;3k#w0SN7Tdh+|nzrBu)ja8i@ zx1bulUOOC)Vc#)n2c){QEB0LUFte>+<8Ehi{qji#9;fqsOqK55JHK^{%o-cP_yj&# zjpid-A6uL?lUUa3pFLkFWRF|gzMgX*2_2W5^ADQ6apDLrYhw`KXnZBe%v5Q%tva=P z*56(p^a;+CBHB(Y};%wCsm_TGYvk#{$i+j8bKGv&~qyw>8R+3#6`oz=s{WUfSg z1gP1qW#3$J%X`jqm?fycv^`PWK{GO1dV%45|1!3pRE3vj;D}RHbl}39kN<|oTI2VZ z?ag#}&=_T82uY8_LS;!EIt4CG+KLYkayz|b8P*Vud@}pXV2J23QKmyWQO_P~oT zJM6kiF+WBZwaaB9`7X$aJYy4`72aXD=?!a_7t-s#e2jG;IB-Z&rSZs+rRj&125ldc z6scr6>Mg7VXNKb3xYj5mGws$mD#g(gsNbh%@t`QgUy>e0pi*KkJ5PN4MjNQtVEtoM zrKE3^8Sov{F4=22x^zPj@x!-5pL7YU6aaaX`7fW-76$nYDjd4WnV_w43L(3Xfdptj zEyuGg_;kkQlbK5%@Nr8txGRaZTlDU+JoxM)8|-{XZn0h@0xQ-?Zg7Z3YdL#7gAMVJ z5p7kiW0s~ar?h6Z;Vmp}aB(-x$=jHhcec61;%%d%Ng!qAbp1}N!$M+| zoDTWXdNycZzd-0s$)|?oo3Ppw5MDC20_lMF%D0@9=CP8G+oavKF89egGqE}3k09ox z(mvRo$s!>rRkab(Hgr5UajRR{gjj0)0Nos}3YV`YM=R0&^g-3O`dN*RUz8-lU7rSD zp^O^N5#;6fq~D7tXWgkGVc|o2Vj<^rrBFlNExc`;L_7UiiU`0-9YgT`#FaXXjJ;g7 zHv~JSOuMqQUhZ2Gj3N&nI<>#2CDS}WvYnQ?DPohrr_H(0+k3FG<8#q)!}p6#*aYv^CfBGv@n|+fZthF(m9*Br z@*Lvw&fqys>drorB`M~uJs0+-(>}P_RQGr^5j(}{dqo3{y1SK!CbxWLuZ{WTMbIZ{ z%s|Fp^daJI>YzKVQkL~dS8myE!2-j;Q0tW&U9&o6w~rOF1`9Xi5cz_P#391L;;%EO zKko5q&2)5od(8?XqMo5)k?;|3b0vKuYdMcBu?VH6FR3@vZ(l%bp<3^4(d`%^^TP0f zQj|YYkHk4oj>a&bDEFNs*DijKNhRX@k-=CN=MBAGq#ERg4C*GG$TitOEA2knSA~XI z5EaxnTW1L`);{OP7yEdB;#T)bA)iAM+sR|lnafx#FAU;$$Pj4Wc%3l5KP@sJZCYQ$ zaj0|yW*@Ab6<{S>qCCZv;UE7v)O8--hiNAFp@f&{r}4`rSHgoz9l`NEY5Q+XE;vu? zEIBe^#YPDO50$CK@S{y>z0D7&ZLoFns>j{gm!U8HaDV0J_v_DBRwu9v%Axye?9mUupL#4y-?E`|0=GWn*bATwtqiHN zdk)<{#CrkWfpd8UPDzUKvK?%~C1_d{u)SkQ+uN~BHLs9FF4|R@psfM0vbxuwX{u>u zkwd9Uf-Suf!|SIZ73p(_P5QLAJkR0@?h9@vFy$(c8NDqj!L+m{<}w${`b|Aogs*~# zYl~fZi_Qb>&%ZARE`(LQ8Ik!eZRcd*xGJ+2c zvhOSmW>t!iV^g?nX3GnkWpH{p(r2v>8Q`~%I@}8SZ&T?WaOsACo|O^^EwR_mRX!b>qH5i92|MlV zmgsn^YnD`yht*9dY*5FDTO!|UTZ9;yOAx}njCGIyF!J|-z&z$|6ScTL( z{ivp&q-erF-ru4tzwGwxNANJmmj>r1z4{) zVKBN(@pI5(xVi1yyQq}=&Jx3vnSuGw{*6B6jl>~4Pe)MfHy|W!f(9)n$Gl{G7iJ1J zOLT@M-(?2q(i=OyJ{H*l z#OiRhzgLfVbLv+}AT4E~Atd#ZH13sWjCTU+>We@W1&Z=7F-`k99KjamBPgrXyjDG= z4AqU+HbkiC<;Gl{(?+xK78xvs_H8~w2O|FXF?Tv9hI)$HBAOvwx8w@`#71~KP8`B2mjK}20I)Z zay8jOgWB`h(~1($KpwAguNfQ_!ld*mG(v&VXy#OO6SL=E4yIL>^A%CcM0~u3?5UKu zU0v{vd!(Fn7z#CUrScg(YJ`)idpPf;o_WLJf z?G;#KFRRzhZ_H~~{dw^@*a4F}4P9M#_idt^sgGL+6)J6cb9KRnB+?zXqT@Z+Fo^cx9YC@2Baw|6qxhn zzEwkFlwr<#ptm%b^no13&emAPr@p-=vnU{yW6~d z^ZWJmi_uS@Yh2-|kz{%uB9toGktqF%kGVRc;oy*Ui#3sd3jTB$uiLxO#VuzO>BR}0 z$e}-nw%0;G6M}@1Lpf;NIBLz^$AzW{nLr#ew;y~|;U>RTF!TRv8< z%*IKIBQUEGPPRM3VjPV6zl?$RYmx*3D#n4!`(&g9;{va{2ETPxzq*I_yI^!+Nd^Md zpN^uyml!aN_B7a+ov&j@@0@?XHwN?e(L zg?Vsc(FNC8e%Sn&)zue`wd;EAUGU7dJ^igf4OPoRG|C?(<8tNym9s*FkO&A0)P~IB zSf5IxN_7s)@8w_oT1SsNDBjdt4f5eJg~kNV8*CF-(VxT};z+5n=8>3UNL9nuv1y83 zq_Bj0SwjzVtAh1zu0K>b--V4GTDIXd9-92sC|nTsGC5zA_F=Q;K^vnF`da|a%(@SF zEJ`w9AZg=YFCE|#qea1c`W;XHP4C5U?wTI$jN)gF`ehB|4BWRSPlrR+SuZI6pN<0? z@ax!jnQH!5i{VKM2u&oPYsc;WM{)!xFzoSikX)Y-m;KlH)71-D#?DNd!^P46d#z(I z;O(KP*7<+NGU}(m9dLU!xBeM3|Hv|911L~Ge*6A?W%nxFJ~CPJ|0FX&zv2}Gh`V0V ze@6eW66DMnV50d}SCYv6KWEYhMnG)x`RmdDGZs;^|Gm@f^~RU~Hb^@J=u{FSW2yeD z3Ic`|(DCk8LrRYS%3)spKteN)$FF_z-)Hfx#(+uc^ltzE*1RwZI!N6l2BOj3Dp>0P+Z)E{PR`!DknN? zbtFcVw24=AbkacXngJb$GA=aK(X4LeFY4i^{Yu(ox$fIzedDGCw*ULysTtNbH}ea9 zX(-K}f3?=oU=54@ZZhP;pb3C(I~Imr2EF&Z4eP(O*g`t>0remA?d7ieBkGONJYb+; z5QisAo?6ea0>Br$lRG3&>i*kH62Pjo;(WD-&!Smc07$bHVt6{~X@nhB-H+y90+_6i z&5kbt`D%C~0L7T~>Yqc0#swsGt#;gh2JjSR^9AGBNi^(Q(C^8>c6jeR6BVNoyT4EEk{zFC-F|G{!ul5QkA= zK6}P%1HhkBQ2rEjqY;EEAxVN*{1%07ip?60j{s3Y#EV8D-G0gx1x~zXFFr*;2Yjpn zCmS{K2JGFLwFODjvpoz7TI6l<4}{W{fO|j>Zax(Mj!}`6i9)bE0f0~G(;Nb{l3)4` z*x_!=<5Dz0{AQapNNvLc5J>{B4xyzv5`Y6=H95V!cAFFZ zRhC>c1AiyF9ir8(?E1-au%LZ(3hD_7vdo?2UVp;wYb;}eQ=CC7Bg>U_6!(K^LqK=T zgm`RI*I!$#x7shaj&I~JY?o1LdMLXmmGCa$x0=-9f*vg>aT>PM0`>_FtU3xj{SroUs9K);9VT z;_2mXba187u!v*T@?{TL){3&cd6TynzKpBPVXT=DR8syWyUuUz-ajr$;F(kRX1`7UnipaGu0zkoDIGII=Sefnm3=bPuJ8U1@{v0cB8URyDn4y_rv(K!&U(gVVsYsKMz7O7^1`?Du z0BXwl`asjB;T0D;nn5z+afvfGSLo<2{1B6ion2Ai^q5s&s}dli3E7Wux=c{vgo^ie zeD!$-4c`==V1eHO-$U-Fr;=)ttmj55S0S5B8h>z46qcDPUnp|vS1AE>JmN40aX&er%D+?v7>d8z=LoDVS7Ia`Kjp8@-`^ak05*8Dgj*}h)2n?xPB~~G)j){P zxnaRKZuAi}0?#hYX6py6XRUOEOv>sY4;0OU>Z&_Q_Hy{O-!I>7w4^M7nF)HlX9Eod8O^aI5 z3206iK1Y{8bBM$|c>)+li52I@(*zW>stnmck1f*B@K%7<69=^NtHGxG;y2&%Uyb<= zKKgS{b<9ll@EL{{FFuXGq^NouXnhL})`{b5ztH4y$$RN2rzDFQJJ}MgiqZh3AH3#Q zh13#;A2&QxYV-l~T3sXeW-5mF-Nnen#frz~6%1b!(C+=h}l(sijgaR7T`(LFW>#19RJrTblsWdQGhzpvGX`iCuF2X)QIOr4^DHt+-{SB_DMrAwDsW1sxFCM} zS^rqw2XH#Mojy-88;t%z$LFvrezEHF*44Fsh4!TKwI||gN@W-wO2hDIhx-h8p!hRI`{mkKFM!T&HthtZ+IAS&cr-dmmf)TNIVpGgo90yUi*13qI9a;fT9aN#t9*YrQQ{tJyy~^i{Sy=vRB7amdf!`TR#Nj={~kcR z(^4B?KOtpeBGl0QW)qlH-!Ihu1XOIjg-H8TyP6OE@IJzd?W2V{c^NnpUc8b2ZNrVo z;c`pDiqknRs#eLnh)30_d9UqWT*w@q&+SFh@SIae`^_TK?fGDs#nr3H<*tLzIxU?r z(*m3TD}csGKrggi1s1fnnM@NntjD|GPGPC3{JDa(p%YjQ{6+f#{YkeV0Kb^fl7QJY zTF>Y;1&wE@0mb*iN4%eNgZz!UQX@%hMeKQU^{_g7q2!hA0C%pAX@xbyXZ8Rt>O#XIXzUNhTS z6;wvQ>lso%j-`4p>4WK#ijVlm8u+~<+rYY~eGL5t7=o__-iTf47puPHSe!WhsqlF4 zCMTs8JpAjgTlJ)5^gvRih&@_+`vEGY5hRh4xi;tt%q&$QXRyr*)LCbYA;3Xhe18&( zlOz8gNpzKu#*bfv?+M`*+6W5_aXx5jhy5IIxB_0R;mdI;CJG_@X{%4P;XwRU4tFlX z>}zP~3ZE~VW0Q|Hn%hrxi~+EuAFmr!Zt=%!wYGTyk3AuZmdVcNrjQ-uf z9zqn_1w=-rU(ai|#$a@& zbLsMVA)dIG@xch**Dwh_R4-Bno7vw3c`HTu3lUM#v$$>H*4uw_-!59Cv&{g|9=GMp zHPNh$R*%z?exn-Gch@9TsMyas|Ij|?F+i_*`f{RnJ74dt{c=p~K%px1FGRKyD`F5> zzD>g`|JrZ4CYAN7UwQ)_8{U}~B#)$kb`XPv=UQAq+TEBUu2R5qIg5gha*-b+sU?{@ zas_DXjWm)AMOo!G>%2Pv^vuNr6!OE$!I;SBV#ri(fW39o zp#^?Ev&OGq0FYsHi#>Z6?_%CNmlDH{yoKkkk-w(Q{VM<| za_n}iRnFg}1mZJGCL|M+hU7SUsW}v)WFtcY$0-LJ;4w6hH@6nThnNecpXVA&Hg22=tJ2iwdc; z^l)2t*AqVYeh%-Ec#^vaP)sc<0JmO=4`aTe6yV!DI3#;;pfsv*kY)^TGjNaHE=$WYY9CS&$}RAqo`6Xy!A&1MH=& zDUnMxox$fw-{U3PMstF|AWz5MCz;+i8r#uhM^0P6FsI&G0?@r_$aib&S-LVoQ^hQC zbwBh)REvXY$ySLwVSjky)yaw`1=F7dHniQ(VwG$S24xA04c6sJq$uC8NK}nr9f?HN zIRsR}rVkqBI(oyZcimz>tYq z?)V+nE1E}!PxUNimwPR81X^|>#0I?S-q|7e!d!D`i%LSAv)tsKGmpR#Bp~lR>#zI5QYv{5= zp1Nc1{qAQSE|ss`9rP{o-Cqx2-m2RDd z8stszH2X(4zbn9J!%W{VX6;_^)ldi(a&zoc4;zVn*}?1Cp0jMTst!W5VZl$>6WNY(n|Y%|zk`80y#gdQ2jMam#Nzr$u(PLb`nEcC1#%PGkL{GhC2V3Ov% z))iP&>UK2$;nZU}@O1)*zHQJE1cVUq=#}5qiFGcN2_q1#1{PFZYjgpq1oAU(* z@@9mQ!`YvzK1XJQ*2J{$*HbDL)ymz_=d&MV5;nU{05WxgTRrTF=fO0~iskljbi=HjBebP=0BjlpjPjxwl@v`rS}{xzs%%25|Z>h<-^)4-We2E z146|uzZ@q=ZB@K1X4EVe5h5Z$ihLZkjPxP{9ZYgkD*8LoGzj z+8XI$a)XrbK3RX-<@o&faiE%yQOJ&;aW*PsB+~GDUEHL_g`GQ}0!_Vw*>PYzW8pTD zUk07dXB~tN5*%ca$>$nWX-`7k41v%uQuK$~4%`V|-__Jvj+3@sS$_VUC^*`C@YF~^ zJ0jaCvSDy>fCy)RM07EU!=?_VcYoIBY%2VkKZ=w`TB#%EPxN@RKP*t-<{ODb6Mq2oz7ReQu6S${Ar~a{`9|JMs7sokm0PT=565CPJfBL#_HR zt`76&SRSpce@WM`ENQ6~(rn}iVww(Vph^J}Fs2yBTBCzZJ1;pbRNxA!AA0LKf+kqC zD{~B^&)%0m#n_VJp@v85lFMObf^}39-dWZjATcx@~JFG9ym z{928O*TJz{tk+U$R|2pC5Z7vC6n5^%5_QnT5)d<%`u<^M9AS}>8x&{OEi$dhWEy-h zImR71n@Z>3m!p~nunbZqIgv_ttzC5RgR&|s$RLkdr@O8D_^iXqv#CMnn*g`*r`=ED zx}GR^F(R#qg#ls^hDVDM^jrn>VWO5sy_?Y~uwVr1pR5ANn8G zvLUkppoi&+)b;iCJ3=c7N-Pvn)iwR^%yQaTD3+1MmarJIe$knB2MD!U-Jqnwy0Oft zDPAbAlI@#P5^V)j;Q*G}wO%Q-SxNX>;2J$fx%IAo>b&GaG-`WPKpQ9~_Qw2Ti)`^*v1%@yHQ*d=JKr9o zp>JX&jy`?tqvO>c=Cz;x?<3TyKIW4{|GzPxt(Gj9NHD77PUL4NXKH20-cgVDRb_;~etR#VsO|oX8il4;F z5GKBvufF%fZ?QDS+HdY!N&U1nz35D^%QVZrueyHRj;>GUw}SuHw2`)OF%!pf)a5>@ zqY`T@Yu6VXNog25S>eHDpS>T_8(i52awf&U7J*Y`fH$s8>V|8W#K_m)zV2yvmvwZp zp$hz50QZY-wVuv-xa?nNF%+};@>GD1?qf^fZ>?@^WKoi}5iXA!tP5wvUL*0lh((J9 zfYFen-e+tRI=tWF*EUK*g;})1a*~ly_V8DoHvG*yg%hnfw4eU`5jam^Gh|mf#a^>! zhwJ{RE|rVl_3vJmPvZgLgrq)5Yy73}fF+RQ>Oyd+)WQPh`tIKN3wS(3c^}yxYy^^! zRf@`Pw0WG-HaaY53GE7s(f!n)wnpxTCw>+;-1Z#>c^x(^wiOSYVaOoEF}a|FRcI3D zkBTzQJS*;|{E6bY6MniJcJ9KzI0(hO1m(*LnnZ`H6jatEOZeQdD(FZ4sX6Jb#ZMv! zDEORM zx&pCxY8NaCU7WK|EQ$@wE*qrskWM2-wC&@LIuv9}Knr@yk`#=`LP} zc()mwWxTc97O6G-w8*goCHvw90QcWdX|-GBXv)I%eq)imf}`6nDR0S5Hq^_2&aRH zg&bwWw~n?vt^bo~(|VA!`SF9($bI-?&D!nPC@iR*gT$o0ls(kV}8#Di4o-CkV% z;kMKiYMJG(LRWu#4A1!NI7pJkL8u23#+3Dk>}9kU*(R^IOOqI16P4xZ01c{B-V~Ac zr=+{<@5?6!9xIvmLY1rp`O!{=E*T6DV;-GiDtvWgqFh6MzK^%NyVskzDejY(FiAT( zTQmNXPn+&**(5 z<{-hsJX<8-)M<#q6G;+!Ql@NQ`A$)3#Q-t|_+V(5s~IYDkSc_M6We{>L~g6dL)itY z^y6Y=Y|mWrL+y-~excd)qgAIR_F%F6*EI#sAMrgSN?u#91c{GjX&;X)aSuM}ZrC~8 z4~lacL&!I3&GGFSm6t_~$SY3|!4JpgnRZux72rXq$nBaz>igd_D%2zS;`lj|mp@bl zEHkeYC`0LUWRQ;Ad{2FfwiT8d29XEI3V1x$B=6g~M!Bv7;d5KoWgWr>FU_u9za^T9 zSsLIDzI1n!g++{XHB8OQQ1O@&=WbnQOPOYE%5Gi*QBbyA?54BZ@uZ&0^>IA^21{@7~+3ALZ~ySdxTEy zsR~J%VpzQy?q;n-bv`I5+N~A^qRlaH9*&Qw%c(^*nbFMA3&4>V9hWt6$2XhUPyELF zey#4LkKapBy}6lIN6;HlZ2?ovasAS2m>DOo@Y>XIudjvk*A4eAaplkj|NG;M-FZJK z^y=VpGg{1Dhj7@P+$p4slq7T$wwTk%EM@&W7GH{flMcH^`-`wbIRk=Ay^gHPlaHp{HrDgHahO=;W!J07Q*aDel|cP#r!C?=?3oU3$cK9eHfpysgp zfvps**ZO2F1_aOd-LYyH!U+d?wHAE3C(#0(9^ZeW+@hfm!LQWP{R#C|_M7847nV)5 zZ{6Sdd=^j1oj(d(MkrD~#)UK>dfvG=0z2dnGK1rVRpZl3v$-3v3C$imkVU_ZR28OwT+5C|3QGNtL zpts-7Jw4T2P3nceH{vt70&Ev2ab0K<^7jk;O_Ey)sN%@m9-d)da)k_Cv5Yz!x%}@Z zb_133aqQ>W!m_k-wS8|MSD3;hKT}{`O4P6k$WFJWim&Vc)xX>J9Q72(_BJqAZpGyP3TZ?j)X>gx>oL`*jvu&vFLp zp>E(7GG%%4W=@E8CdYr*l$)l`HmN5@c0~ScbRLd9dH!B~>PEMZ8^pa+Wm=M#J`dp1oZAw+$c$Sdl1 z^;Inle;bU$0`|_Bgag`ba~#UB(6>RdE=>+iS@>P{*ie#^u1N*1%(0$jFoe973v2fD z*?13A)2jEOIEON1(&%myO)9JMR8WHPbwHPkLC$V!m&h$@Q(BPm{@R>B4JooM!!iLa za2MbkVhJQA%DA5qcag%_p-#`%BAb}Jhes7%zBI#^-n`9PYL@b5@_MdSkKfh(wO#q| zC8!vY9|8qKqlR#Weua3uXA<-M3OVQf`*%E`&8{^Rl(jY4*?*e{KFflP?5OBgg!}g2 zYw<`wy+)#3{zd-p$B_bg7-*5AOHQs#{<$1zKNt%mAecfo_@7(h(Q+4}qIU7jWJ*5& z-&+_v0xdey+x)%T-_Hyr1|SI+woZk-{P+JD&pBz4aO^*0|EEW(1b9e;8XkAt|G8y1 z8fsT}RQa3#^hh1(a_WIH&G-M@vKeTxIZog|Ju;IAx~#sk-}|3iJ|#)zhOz$NGm49$ z4n$GRiJy9HrZpcwh969+_oozPpIBY&yB6IamK1qYlW^_*c#78bj-krn-)!{=?L~5@(d~)rN7D*hj%6^~D{!J1f3UN||MxMmlxU@AHX>Ah(feW9 z<$n`;vF>)>Pco7Zs8_~^ni%9s>yRCR0&arQDZhmM@@3ljCe@A3289B}KM!rpkG285 zHI0bMBqcO)4`U#<$?`N+z66kMQH%zwH}i1{G?q6<3!^|THeKrRywDYK?th-FXI)|u z!69hXxMW{ud2RIk!CzSApK%ko8_@L?b90Ila&C5!AX`i&bTU-0FZ{Gt)`1K-TBUro z_jCT-Y_Y@bvJ0Z9t=*UGHt0lX zU8i3@6XbT;iG0xtnqUXRpN-;(wTnbmM~@<6(LM;g`}=b}!H8^#1K;{hPf%l0s?!Gu zEjy~${pM8=0wTANN$vxJ%hW8-4d~>b1Kv?A!!tOL5w*EIS zlQ~mfJ3RdG9oz^f&GjDlakrZ_0Lm^A! z{pR(OP#5*fAE!C<;LbV2Q~AN_%o+G8!Z0Zs&DZd9w#mLp6%00Y9t@%OF4pzhPP%lr z{OwuLvYot?Mx>>a3&7e0F8Y$}f_dJ;?3z{<#c$@1;ENX{T#!!wwPPUVGr#NyHA=%4 zoX^p99`cIuJ31W=?$`xl-7k;FkZ8Lx@yo%74!^8jAlQq8>u#_gc8*-_#}tS}6Q54I z-yb&#;Yx{jcnvAcT5IW9{p>7*-g^S7hEZS~B+VM}e7kY;Zn?hy{w6EZKP+wO26*2V z;QJ~`NW;U}(t4C3TfiF{(FWldEIakdAXtJLaWh|CoE=H6&G}mF>xj z82RFNvik|?NGy^(f$OAU?qqboD0}9U`vQ@d$4;`oed}w8nYN*q=H;k^e_6|JmQULK zNgG(#ZH7d^2yr);#^t+iluls}QzsujzWsK$K~gnK>C|U3I;-!nLg{zKvv0eI7|SiN z^96Rwqb4C#ljY4D;m7KkOBmdU$5V(sXUCPcNOK?7xpohm;rYc5;sFe@Zv@`WvDV|v z^>$0}gXAChiHG`9li;b}X_l9^mo2?=Udz=a%P3INSoq|za((MEmdZmPgL={gk-U{S zUu>8&OfkH-A+&sk7y(l*$eaPu)CUo~CW^-p(0ru`z%CX=Zt;_}rtbgt_L6_t z$_fx;cKLc(K>#+>&|}}uFk98{T;TW!=OKplxsH>DQ88ev$ZvW#YdDJAU+dTFc*neY z2?ZlMZYNV7D}IQ3Ig9rCxwrlBy-%C7%~%1V*;|g_nVWT7P_f8CasIBCk8h0MRX&Xs zj__I+zqh|h)g1V4W^)QrG==`~xPQ%v3%u3b0k4mqQ4Klw(^&FrLxk%Vj4K!b^k;2P ztROyU9_MFAZlzFxEV?esLD&uP4)0@iFAgH*TTA!5mI@Yi|AZJ#pHUmb8DOehc67mc zHC4EhzVocTrseTYYP*WUpL!^X6{9qdj&{^P z!eX7f(MSaGVRQw{K*5To{`c_uc3uRhe3gj8 zst9zn=$*DIN<@Kt7|G|K_!d$mc|GKMb1YTZf}8!mt**+Jv)$3TZiunUM$71-KNE@# zm4HG(AkiI%p`CHiqs!_*8>GW0P0XpyqHa`BDcj`IVlzr;%*_z=aBVfczuK<+bqs^5 zrti!2^&Fz+auF!DW%W)T8iCF0X7y~g@JC>gKzS@_&e^~?her}QSe6l|T6(^M}z zKl;wJ4)I8%*zJ(Cb1_o&dbipXKkXDZ)3Zt|AvNKm_V7T`<-*ep1r-LiX&?pP#3QOJKUhvd=PHgi1 zR{RjO(zbp5JB47C_Y9lajDJeE^^!?Q)7Hcvo61{*-G^*g-=MnK>9FOpL-YDb{9HLa zWE7Zj|4=&rvP>81uv$72o`SgfU*=xz*$@jQqs;TodHrf4&rwj=4D~pl`>2 zdK#MG@9uEfJA^B!QaDn*;yrFQI<&homl5B|c?aGf&0xy5H%tyPgPs=ZoE|Aqe;G@K zzjnE3feNbo3w5+?R>cU}RJD569etFT%JI7mf%5OdFLEtP7_w8cjOdyWnf0SSfElmd zd&NBM(B7wBWCtB&$C)YGaT}r(LM>0?j^wKxg=F8fGHccM+54HM$5ncP8Hr=Y3^}3v zAm>ON=V4`=q04U`IUyca_aO%L3kylaExj21Tb^MTAP2CE4<>DB*)L3=9AZu>GAfp6 zJ2Fi<9}_!Glj{KHok@6vZzgIQ>fqZOqIXe<*@^(?2^oE>)_Y{B7ojfR@KQ!W#(xk%IEVi=FZvaDL?f+Iv7VZ z$7^ZH{Z~~##x6(G@+5p5@hMhL!RWd)>Lbz-W@e_)_^$F27mu^&#JiL&{86B~UFtQW z|CoCjed@alFswRnqTsOdy0H_1c=B~eh#w?d`aIK$?F0(Sj~37NG>bLOQ{w!07@EP9Lg1y_u$r?gjZ zn+rN_Z;v=M8}&+&SXjPaBg}-ZBF+d$t#%O=x+V+fqc>HDm{#~0VDBBH^pUFU30;T9 zCo25sZpU_YY57w1vX?rau9kU!6|jUxOr1>N7EaWS7Z^pO|GvB)*0zr=J8et% zETAcopyqPNJ(+LN=XRetX;BrUee5ar(Gtg+YkQj zbA~?dJx1dA>dQy%mrIv6PVLJ8g*D@JP>SA5Wq0(+@;?#6t&R4nKZh*tQUFEOAWy8= z39Qk5#`S3P7-$H94rZ-ULN_~BMsUQgzIJb1m?Y; zy*e~9O71l(_kpd4{Jkm8y;+O|V@z_tk(Fh(pS!q3xp}kSbacBcy~pyzl0(yqyIteL zPSpNcVD5vPh)yoPBjhs6V|S)d@z#754TvFKvc=d>7o~s`CB@bqs_ncofgh`vh_nJ} zeinb-;ZU1viLwDfKKdwDHOVfRYngIcRVX=GIjPujJE06)@(CdkETZbPciEFH6B^-o zXy54!$@=1dcNlWfdRVnUcvL9y$36MDV8nrWY{_|jQVPbm3%mUNNlzUJhM@8j+? zhB0mOr$v*^^UYoJXoF;@yf>7)#NhY$oDRO5vp{~rSIfWke!db6-_GK&r!!runX7ef}x z{qX2jk1w*IM{zM9Y-yUBc$7wg>3>#|@=UM?DofG9iZ&uuFmiTPxnEjVdC=~WA0>FO z>~Px0`Iypq^>Z{zXf;)qt7vBhf&Vf|j{B3-;chP3 z5Eaq=rMFhzzYZzA_g>ysej8 zTLvRle5!XnhNjak1+yj2E0yaUAlCgaU^^!ShW7jCqf8z~9H*@Q+oB3W+6XIImri1%1&TVtTpkHkX+>=)}ZHMVK#HV{-v)CoAcN zWayq_||)Q~$Wu z=mhQhNrWtyw|Os)BtFG6(>NcW@|HG<AlN>5S&k z9YuEzA<5Tat;N!2qf{hwyK|W1ZYnxBB!z57&gE27Z5nglat||`&CKTeao_nLzCUcg zeSX>J^SB=G>wUdmujlo+u9m?1)I{x}p>*@!g!dYKi1@Jjt2{UD>HKJI*a05-N=gO@ z9+<`bl8bKo!Kn5ID>X8xNrs~0-#!>@2M$gN9kQjDDL@Ekh%^)p8qS4hz3-$30Chfb)H79j9qPQlMT^C!z-JD+1>I zC$CG-+F1@baVxc-f?QspOq91H6?|e^*H4NIyUQ3g(?Z_q&iQbwXb^6M_${0`BZ`*Z z!1%9iG=>xUO$wSOI=}h7xL|*`NjU*Y?Rj_8F0VfIxO7Rn-JEEIc4Uf61>v!`o@ShP z3JEK>9;Q)Yy~}&Q1u6UV{DM@&n`y7+psS;uY( zvj%0eZZ-k4{U&*P-zUI)(Y}ARsX0Cv$yWPEi9!_*FR5#J)VEysQi;y;wY6V1NvU&l z@%Vt5B`|XI8*EXjV~b&e%gGRJh?_7`y%oYT-P!({nasv1d-@gjIy~ly+ixi3Sx!?d zK!dJpu=Y!{0%(^iKcKgA4WCmh97vV<0bp;k1kIHYA(bJ1jhZ9k4o7GvYx9yvMNv>r z_QJ_Q-3`D311T)@4Q!f}WzGalQlf;|2qtQiK+6=+YB>Z6jz2L0Skz9;7AI{CdysmK zILVueY(T3{^9SeKIgIcs*Mcv$Gwo}?q6T>ETE+#+T*eq56Nf1E(YZsb-_Ux!Hg=nS z0T?ymY?S|H&{I|@=+HOkBfMr(bHCecvDl{9L-NylD#6I~C46qb<#d-o1|VJ*t~m+1^@ zWn9f+ke^s&h*uq0nn0!+`*5?j9v$Vs38;`fNu8(dSRC%VjhTtJYZrjyKt5&$DK`he zpV)BbLfZkV=Pvk;YxyG-P2@o9lSc49qzRboqeIl_%Wlao0l`j{Y2CP795 zfXUZ=)TrmR9jCP5x!>sgLhBB%HKC3fK-+61he<*lY_0Tg^LW%0vqkwV*@0z7X61y< zWfy`qoJbGH?X@Foj~3J!xLr+L3?i-Y$eiKDI?SOXF2|`-*zywJ)X5`0{13xI<{tNi zLFxDL51=Pocl%{J9M`c0#g7ALgok#9VHf-Ob6@QP6*&;35Lnvqxi`ntv_cP3BKl^& zWEGqHp=E&&Xk+_#Rx&Kg$?75Ejlo)_{-fU2kha-H4!k3m1lS5b8!hGd6OF#&jsF7L zC(aVspWshvf(=ll)3y_x!qyYCM@Ig6y;FGgPC%}|_Hw>+;Z&_|q~6<|C3$m$14h8Q z6k%h@o5VyBG?O0Ef)u&lMHG6JF3hEJtJhzlS@Hk16@2QQ5CH+9vlzX}P7n&i(<3Eugu>l&0Dd5w=sa)pla!kVg4C)(gzN5Ieoc9z2+}VGh2^ zmGSunc+HXjcYcYJ}NGvycFEh@oHS)rQ*_0vCdD= zV{n_+ak6UnJH6>g^#e%`Rn+ope#sc5;gLJk;BKq&b>#=3Ci_#(f#u}dA{egoNal22 zTJz*%`cv%@(}Qb3eI0K)M?){0o%(yledYk2&(uNpQ404Rn<*x|7fN3FIz+O)7&aWT z1&$b1A;F_s_Kr4K`pf2cp>u2xlK)3h(CCb)0oQ=RVHqi0qR_mz>Obx)-W;k3GpY2h zXbxjleShX4ZOOAB`rBmdEOHa{=pRx91I`<rfxrKMNA3565g!qo5=am78MutAX)Qh zjO76!x%XjeSmov1=NHg}8TdVFB`Im;+V9zeT&6ykiBBumMEp%^;iUkj_04Wybi%8X zZ(GVmBPPw2I&ONc6ABENVoyZ@U6&DiIFrIrE2*Y%b${@#Mxg$(tUndkNZ*!dwv9h{ zu$oSIU@DD1kLmfWg8Mv7*n(*_wsdM|y1KTPEU1Fl z0r@$D@QRr0@oQzX_rs72$thj%{Ta#&Kt!sFRCqX;zkLO^+>BIi8JCT<*=L&;o1J&y z1Fc4Zv?|*@`l=}rG%`yF z!bbwuN2>m2_`nLfnGFXe1}Oqt#G3xVzb}_owaqa5s~iE0P!q2}FB`$0Qv|#A+y%Ks zj%qd#Y}oNiJ3C0RLHqL$RMU6IM);kpf3SkZ;!o;t_WDXN3uvmE+gwhd7+;}imWgF7 zn!e)j(q%jMe@U1fXKkRTnPR{|G_F<2*3RTaRv1vf z3IZeImCw0Dt*;bAcE0WT+gvpN>}^o<%4h$B;xd$#<1%g}99n&T3Xr_B)bFdD&U5!J zh4WPPkE^IK9pH2@=dhJLW92W-B*iIsJ3H=O1%YP(3TI+aru!4?9WhW;ykwwTxMB4f z0FAHG-$bvzO#brKk&qAjPcSSmIQdkQHyL?*l`kx-H6UM7{}n58F(H>xN8|!huG{5~ z0(*Dm# b!@_dm`bd&D{0UqE_&J|+J5he@((V5NjOwdd literal 0 HcmV?d00001 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" From f880a027fdeec2214685797255c48bf98cefe7e6 Mon Sep 17 00:00:00 2001 From: matchaboar <224671045+matchaboar@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:51:43 -0700 Subject: [PATCH 2/2] docs: simplify file browser workflow notes --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d4ae441..727ec12 100644 --- a/README.md +++ b/README.md @@ -70,11 +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. Mindle shows its supported documents in the existing file sidebar and waits for you to choose one; the directory remains the window's scope for the rest of that window session. -- **SSH profiles** — the network button at the top left opens the favorite profile from `~/Library/Application Support/Mindle/ssh-profiles.yaml`. Each entry under `profiles:` has `name`, `hostname`, absolute `path`, and optional `favorite: true` fields. If the configured path does not exist, Mindle opens the remote user's home directory (`$HOME`) instead; an existing but unreadable path remains an error. Mindle lists supported documents without copying the whole workspace, then fetches the selected file and any relative Markdown images into a matching local cache layout. Open or reload the YAML from **Mindle → Settings → SSH Profiles**. +- **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** — fast, scoped sidebar tree of `.md`, `.markdown`, `.mdown`, `.mkd`, `.txt`, and `.pdf` files (`⌘⇧F`). Tree scanning and batched Git metadata run off the main thread, and rows are rendered lazily, so large documentation folders remain responsive. Changed files show working-tree `+ / −` counts, tracked files show a compact last-commit age, and the active tab gets a stronger row highlight. All three cues are enabled by default and independently configurable under **Mindle → Settings → File Browser**. The browser never escapes above its selected root. +- **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.