Commit 391721e4ef
Verified · cmc
Layout: unified · split
Package.swift +5 −2
| @@ -11,7 +11,8 @@ let package = Package( | ||
| 11 | 11 | .library(name: "OrgWorkspace", targets: ["OrgWorkspace"]), |
| 12 | 12 | .library(name: "OrgPresentation", targets: ["OrgPresentation"]), |
| 13 | 13 | .library(name: "OrgEditorAppKit", targets: ["OrgEditorAppKit"]), |
| 14 | .executable(name: "OrgstarSpike", targets: ["OrgstarSpike"]) | |
| 14 | .executable(name: "OrgstarSpike", targets: ["OrgstarSpike"]), | |
| 15 | .library(name: "OrgApp", targets: ["OrgApp"]) | |
| 15 | 16 | ], |
| 16 | 17 | dependencies: [ |
| 17 | 18 | .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1") |
| @@ -24,11 +25,13 @@ let package = Package( | ||
| 24 | 25 | .target(name: "OrgPresentation", dependencies: ["OrgCore"]), |
| 25 | 26 | .target(name: "OrgEditorAppKit", dependencies: ["OrgDocument", "OrgPresentation"]), |
| 26 | 27 | .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]), |
| 28 | .target(name: "OrgApp", dependencies: ["OrgWorkspace", "OrgDocument", "OrgPresentation"]), | |
| 27 | 29 | .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), |
| 28 | 30 | .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), |
| 29 | 31 | .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), |
| 30 | 32 | .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]), |
| 31 | 33 | .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]), |
| 32 | .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"]) | |
| 34 | .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"]), | |
| 35 | .testTarget(name: "OrgAppTests", dependencies: ["OrgApp"]) | |
| 33 | 36 | ] |
| 34 | 37 | ) |
Sources/OrgApp/DocumentSession.swift added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | import OrgCore | |
| 4 | import OrgDocument | |
| 5 | import OrgIndex | |
| 6 | import OrgPresentation | |
| 7 | ||
| 8 | /// The file open in a window: its state, its outline, and requests for the editor to show a | |
| 9 | /// location. The view layer owns the text view and follows `revealRequest` and `diskBytes`. | |
| 10 | @MainActor | |
| 11 | @Observable | |
| 12 | public final class DocumentSession { | |
| 13 | public private(set) var path: String? | |
| 14 | public private(set) var document: DocumentState? | |
| 15 | public private(set) var outline: [OutlineEntry] = [] | |
| 16 | /// Bumped to ask the editor to reveal an offset. | |
| 17 | public private(set) var revealRequest: (offset: Int, id: Int)? | |
| 18 | /// New bytes from disk for the open file, for the editor to merge or reload. | |
| 19 | public private(set) var diskBytes: (bytes: [UInt8], id: Int)? | |
| 20 | public var lastError: String? | |
| 21 | ||
| 22 | private let fileSystem: FileSystem | |
| 23 | private var requestID = 0 | |
| 24 | ||
| 25 | public init(fileSystem: FileSystem = CoordinatedFileSystem()) { | |
| 26 | self.fileSystem = fileSystem | |
| 27 | } | |
| 28 | ||
| 29 | public func open(_ path: String) { | |
| 30 | do { | |
| 31 | guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else { | |
| 32 | lastError = "\((path as NSString).lastPathComponent) no longer exists." | |
| 33 | return | |
| 34 | } | |
| 35 | let document = DocumentState(bytes: bytes) | |
| 36 | self.path = path | |
| 37 | self.document = document | |
| 38 | outline = Presentation.outline(document.tree) | |
| 39 | revealRequest = nil | |
| 40 | diskBytes = nil | |
| 41 | } catch { | |
| 42 | lastError = "Can't open \(path): \(error.localizedDescription)" | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets | |
| 47 | /// are used only if the file is unchanged since it was indexed; otherwise the heading is | |
| 48 | /// found again by position and title. | |
| 49 | public func show(_ location: HeadingLocation) { | |
| 50 | if path != location.path { open(location.path) } | |
| 51 | guard let document else { return } | |
| 52 | let hash = FileRecord.hash(document.mergeBase) | |
| 53 | let offset: Int | |
| 54 | if hash == location.contentHash { | |
| 55 | offset = location.start | |
| 56 | } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title { | |
| 57 | offset = outline[location.ordinal].start | |
| 58 | } else if let entry = outline.first(where: { $0.title == location.title }) { | |
| 59 | offset = entry.start | |
| 60 | } else { | |
| 61 | return | |
| 62 | } | |
| 63 | reveal(offset) | |
| 64 | } | |
| 65 | ||
| 66 | public func reveal(_ offset: Int) { | |
| 67 | requestID += 1 | |
| 68 | revealRequest = (offset, requestID) | |
| 69 | } | |
| 70 | ||
| 71 | /// Called when the workspace sees the open file change on disk. | |
| 72 | public func fileChanged(_ path: String) { | |
| 73 | guard path == self.path, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return } | |
| 74 | guard bytes != document?.mergeBase else { return } | |
| 75 | var updated = document ?? DocumentState(bytes: bytes) | |
| 76 | _ = updated.diskChanged(to: bytes) | |
| 77 | document = updated | |
| 78 | outline = Presentation.outline(updated.tree) | |
| 79 | requestID += 1 | |
| 80 | diskBytes = (bytes, requestID) | |
| 81 | } | |
| 82 | } | |
Sources/OrgApp/FileTree.swift added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgIndex | |
| 3 | ||
| 4 | /// One file the workspace knows about. | |
| 5 | public struct FileEntry: Sendable, Hashable, Identifiable { | |
| 6 | public let path: String | |
| 7 | public let root: String | |
| 8 | public let kind: FileKind | |
| 9 | ||
| 10 | public var id: String { path } | |
| 11 | public var name: String { (path as NSString).lastPathComponent } | |
| 12 | /// Path below the root, for display and agenda globs. | |
| 13 | public var relativePath: String { | |
| 14 | path.hasPrefix(root + "/") ? String(path.dropFirst(root.count + 1)) : path | |
| 15 | } | |
| 16 | } | |
| 17 | ||
| 18 | /// A folder or file in the sidebar. | |
| 19 | public struct FileTreeNode: Sendable, Hashable, Identifiable { | |
| 20 | public let name: String | |
| 21 | /// The file's or folder's absolute path. | |
| 22 | public let path: String | |
| 23 | public let file: FileEntry? | |
| 24 | /// Nil for files, so a SwiftUI `OutlineGroup` shows no disclosure triangle. | |
| 25 | public let children: [FileTreeNode]? | |
| 26 | ||
| 27 | public var id: String { path } | |
| 28 | ||
| 29 | /// Folders first, then files, each by name. Conflict copies are listed with their files. | |
| 30 | public static func build(root: String, files: [FileEntry]) -> FileTreeNode { | |
| 31 | final class Folder { | |
| 32 | var folders: [String: Folder] = [:] | |
| 33 | var files: [FileEntry] = [] | |
| 34 | } | |
| 35 | let top = Folder() | |
| 36 | for file in files where file.root == root { | |
| 37 | var folder = top | |
| 38 | for component in file.relativePath.split(separator: "/").dropLast() { | |
| 39 | let name = String(component) | |
| 40 | if folder.folders[name] == nil { folder.folders[name] = Folder() } | |
| 41 | folder = folder.folders[name]! | |
| 42 | } | |
| 43 | folder.files.append(file) | |
| 44 | } | |
| 45 | func node(_ folder: Folder, name: String, path: String) -> FileTreeNode { | |
| 46 | let folders = folder.folders.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }.map { | |
| 47 | node(folder.folders[$0]!, name: $0, path: path + "/" + $0) | |
| 48 | } | |
| 49 | let files = folder.files.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }.map { | |
| 50 | FileTreeNode(name: $0.name, path: $0.path, file: $0, children: nil) | |
| 51 | } | |
| 52 | return FileTreeNode(name: name, path: path, file: nil, children: folders + files) | |
| 53 | } | |
| 54 | return node(top, name: (root as NSString).lastPathComponent, path: root) | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | /// Subsequence matching for quick open: every query character must appear in order. Matches | |
| 59 | /// at word starts and runs of consecutive characters score higher; shorter names win ties. | |
| 60 | public enum FuzzyMatch { | |
| 61 | public static func score(_ query: String, _ candidate: String) -> Int? { | |
| 62 | let query = Array(query.lowercased().filter { !$0.isWhitespace }) | |
| 63 | let text = Array(candidate.lowercased()) | |
| 64 | guard !query.isEmpty else { return 0 } | |
| 65 | var score = 0 | |
| 66 | var q = 0 | |
| 67 | var previousMatch = -2 | |
| 68 | for (i, c) in text.enumerated() where q < query.count && c == query[q] { | |
| 69 | score += 1 | |
| 70 | if i == previousMatch + 1 { score += 5 } | |
| 71 | if i == 0 || "/ -_.".contains(text[i - 1]) { score += 8 } | |
| 72 | previousMatch = i | |
| 73 | q += 1 | |
| 74 | } | |
| 75 | guard q == query.count else { return nil } | |
| 76 | return score * 100 - text.count | |
| 77 | } | |
| 78 | ||
| 79 | /// Entries ranked by `score` against their relative path, best first. | |
| 80 | public static func rank(_ query: String, _ files: [FileEntry], limit: Int = 50) -> [FileEntry] { | |
| 81 | files.compactMap { file in score(query, file.relativePath).map { (file, $0) } } | |
| 82 | .sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.0.relativePath < $1.0.relativePath } | |
| 83 | .prefix(limit) | |
| 84 | .map(\.0) | |
| 85 | } | |
| 86 | } | |
Sources/OrgApp/WorkspaceModel.swift added +198
| @@ -0,0 +1,198 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | import OrgIndex | |
| 4 | import OrgWorkspace | |
| 5 | ||
| 6 | /// The folders the user added, the index over them, and the queries the window runs. | |
| 7 | @MainActor | |
| 8 | @Observable | |
| 9 | public final class WorkspaceModel { | |
| 10 | public private(set) var roots: [WorkspaceRoot] = [] | |
| 11 | public private(set) var files: [FileEntry] = [] | |
| 12 | public private(set) var isIndexing = false | |
| 13 | /// The last error worth showing, such as a folder that can no longer be found. | |
| 14 | public var lastError: String? | |
| 15 | /// Bumped when an indexed file changes on disk, with its path. | |
| 16 | public private(set) var changedFile: (path: String, version: Int)? | |
| 17 | ||
| 18 | public let index: IndexStore | |
| 19 | public let settings: IndexSettings | |
| 20 | private let rootsFile: URL | |
| 21 | /// Resolved root folders, keyed by their resolved path, which is how the index stores them. | |
| 22 | private var resolvedRoots: [String: URL] = [:] | |
| 23 | @ObservationIgnored private var watcher: AnyObject? | |
| 24 | private var changeVersion = 0 | |
| 25 | ||
| 26 | /// `directory` holds the index and the list of roots; nil keeps both in memory. | |
| 27 | public init(directory: URL?, settings: IndexSettings = IndexSettings()) throws { | |
| 28 | self.settings = settings | |
| 29 | if let directory { | |
| 30 | try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | |
| 31 | index = try IndexStore(path: directory.appendingPathComponent("index.sqlite").path) | |
| 32 | rootsFile = directory.appendingPathComponent("roots.json") | |
| 33 | } else { | |
| 34 | index = try IndexStore() | |
| 35 | rootsFile = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-roots-\(UUID().uuidString).json") | |
| 36 | } | |
| 37 | if let data = try? Data(contentsOf: rootsFile), let saved = try? JSONDecoder().decode([WorkspaceRoot].self, from: data) { | |
| 38 | roots = saved | |
| 39 | } | |
| 40 | resolveRoots() | |
| 41 | try reloadFiles() | |
| 42 | } | |
| 43 | ||
| 44 | /// `~/Library/Application Support/Orgstar`, or `ORGSTAR_DATA_DIR` when set (for development). | |
| 45 | public static var defaultDirectory: URL { | |
| 46 | if let override = ProcessInfo.processInfo.environment["ORGSTAR_DATA_DIR"] { | |
| 47 | return URL(fileURLWithPath: override) | |
| 48 | } | |
| 49 | return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Orgstar") | |
| 50 | } | |
| 51 | ||
| 52 | // MARK: - Roots | |
| 53 | ||
| 54 | public func addRoot(_ url: URL) async throws { | |
| 55 | let root = try WorkspaceRoot(url: url) | |
| 56 | guard !roots.contains(where: { $0.path == root.path }) else { return } | |
| 57 | roots.append(root) | |
| 58 | try saveRoots() | |
| 59 | resolveRoots() | |
| 60 | await refresh() | |
| 61 | } | |
| 62 | ||
| 63 | /// Removes a root by its resolved path, as `FileEntry.root` gives it. | |
| 64 | public func removeRoot(path: String) throws { | |
| 65 | let bookmarkPaths = roots.filter { (try? $0.resolve().url.path) == path || $0.path == path }.map(\.path) | |
| 66 | roots.removeAll { bookmarkPaths.contains($0.path) } | |
| 67 | try saveRoots() | |
| 68 | resolveRoots() | |
| 69 | var change = IndexChange() | |
| 70 | change.removals = files.filter { $0.root == path }.map(\.path) | |
| 71 | try index.apply(change) | |
| 72 | try reloadFiles() | |
| 73 | } | |
| 74 | ||
| 75 | private func saveRoots() throws { | |
| 76 | try JSONEncoder().encode(roots).write(to: rootsFile, options: .atomic) | |
| 77 | } | |
| 78 | ||
| 79 | /// Resolves bookmarks; refreshes stale ones; reports folders that can't be found. | |
| 80 | private func resolveRoots() { | |
| 81 | resolvedRoots = [:] | |
| 82 | var refreshed = roots | |
| 83 | for (i, root) in roots.enumerated() { | |
| 84 | guard let (url, isStale) = try? root.resolve() else { | |
| 85 | lastError = "Can't find the folder \(root.path)." | |
| 86 | continue | |
| 87 | } | |
| 88 | if isStale, let fresh = try? WorkspaceRoot(url: url) { refreshed[i] = fresh } | |
| 89 | resolvedRoots[url.path] = url | |
| 90 | } | |
| 91 | if refreshed != roots { | |
| 92 | roots = refreshed | |
| 93 | try? saveRoots() | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | public func url(forRoot path: String) -> URL? { resolvedRoots[path] } | |
| 98 | ||
| 99 | /// Resolved root paths, sorted. | |
| 100 | public var rootPaths: [String] { resolvedRoots.keys.sorted() } | |
| 101 | ||
| 102 | // MARK: - Indexing | |
| 103 | ||
| 104 | /// Reconciles every root off the main thread, then reloads the file list. | |
| 105 | public func refresh() async { | |
| 106 | isIndexing = true | |
| 107 | defer { isIndexing = false } | |
| 108 | let reconciler = Reconciler(index: index, settings: settings) | |
| 109 | for url in resolvedRoots.values { | |
| 110 | do { | |
| 111 | _ = try await Task.detached { try reconciler.reconcile(root: url) }.value | |
| 112 | } catch { | |
| 113 | lastError = "Indexing \(url.path) failed: \(error.localizedDescription)" | |
| 114 | } | |
| 115 | } | |
| 116 | try? reloadFiles() | |
| 117 | } | |
| 118 | ||
| 119 | private func reloadFiles() throws { | |
| 120 | let roots = Set(resolvedRoots.keys) | |
| 121 | files = try index.files().compactMap { file in | |
| 122 | guard let root = roots.first(where: { file.path.hasPrefix($0 + "/") }) else { return nil } | |
| 123 | return FileEntry(path: file.path, root: root, kind: file.kind) | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | /// Reconciles the paths a batch of file events named, or whole roots when events were lost. | |
| 128 | public func filesChanged(_ paths: [URL], rescan roots: [URL]) async { | |
| 129 | let reconciler = Reconciler(index: index, settings: settings) | |
| 130 | for rootURL in roots { | |
| 131 | _ = try? await Task.detached { try reconciler.reconcile(root: rootURL) }.value | |
| 132 | } | |
| 133 | for (rootPath, rootURL) in resolvedRoots where !roots.contains(rootURL) { | |
| 134 | let inRoot = paths.filter { $0.path.hasPrefix(rootPath + "/") } | |
| 135 | guard !inRoot.isEmpty else { continue } | |
| 136 | _ = try? await Task.detached { try reconciler.reconcile(root: rootURL, paths: inRoot) }.value | |
| 137 | } | |
| 138 | try? reloadFiles() | |
| 139 | for path in paths { | |
| 140 | changeVersion += 1 | |
| 141 | changedFile = (path.standardizedFileURL.path, changeVersion) | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | #if os(macOS) | |
| 146 | /// Watches every root; changes reconcile the index and are reported through `changedFile`. | |
| 147 | public func startWatching() { | |
| 148 | let roots = Array(resolvedRoots.values) | |
| 149 | guard !roots.isEmpty else { return } | |
| 150 | let fsWatcher = FSEventsWatcher(roots: roots.map { $0.resolvingSymlinksInPath() }) { [weak self] events in | |
| 151 | var changed: [URL] = [] | |
| 152 | var rescans: [URL] = [] | |
| 153 | for event in events { | |
| 154 | switch event { | |
| 155 | case .changed(let url): changed.append(url) | |
| 156 | case .rescan(let url): rescans.append(url) | |
| 157 | } | |
| 158 | } | |
| 159 | Task { @MainActor [weak self] in | |
| 160 | guard let self else { return } | |
| 161 | await self.filesChanged(changed.map(self.indexForm), rescan: rescans.compactMap(self.root(containing:))) | |
| 162 | } | |
| 163 | } | |
| 164 | fsWatcher.start() | |
| 165 | watcher = fsWatcher | |
| 166 | } | |
| 167 | #endif | |
| 168 | ||
| 169 | /// The root an event path belongs to. | |
| 170 | private func root(containing url: URL) -> URL? { | |
| 171 | resolvedRoots.values.first { url.path.hasPrefix($0.resolvingSymlinksInPath().path) || url.path.hasPrefix($0.path) } | |
| 172 | } | |
| 173 | ||
| 174 | /// FSEvents reports real paths (`/private/var/...`); the index uses the root's own form. | |
| 175 | func indexForm(_ url: URL) -> URL { | |
| 176 | for root in resolvedRoots.values { | |
| 177 | let real = root.resolvingSymlinksInPath().path | |
| 178 | if real != root.path, url.path.hasPrefix(real + "/") { | |
| 179 | return URL(fileURLWithPath: root.path + url.path.dropFirst(real.count)) | |
| 180 | } | |
| 181 | } | |
| 182 | return url | |
| 183 | } | |
| 184 | ||
| 185 | // MARK: - Queries | |
| 186 | ||
| 187 | public func tree(forRoot path: String) -> FileTreeNode { | |
| 188 | FileTreeNode.build(root: path, files: files) | |
| 189 | } | |
| 190 | ||
| 191 | public func search(_ query: String, overlay: [String: FileRecord] = [:]) -> [HeadingLocation] { | |
| 192 | (try? index.search(query, overlay: overlay)) ?? [] | |
| 193 | } | |
| 194 | ||
| 195 | public func quickOpen(_ query: String) -> [FileEntry] { | |
| 196 | FuzzyMatch.rank(query, files.filter { $0.kind != .conflict }) | |
| 197 | } | |
| 198 | } | |
Sources/OrgIndex/IndexStore.swift +9 −1
| @@ -12,12 +12,20 @@ public struct FileState: Sendable, Equatable { | ||
| 12 | 12 | |
| 13 | 13 | /// A heading as found by a query. `contentHash` is the hash of the text the offsets refer to, |
| 14 | 14 | /// so a caller can tell whether they still apply to an open buffer. |
| 15 | public struct HeadingLocation: Sendable, Equatable { | |
| 15 | public struct HeadingLocation: Sendable, Hashable { | |
| 16 | 16 | public let path: String |
| 17 | 17 | public let ordinal: Int |
| 18 | 18 | public let title: String |
| 19 | 19 | public let start: Int |
| 20 | 20 | public let contentHash: String |
| 21 | ||
| 22 | public init(path: String, ordinal: Int, title: String, start: Int, contentHash: String) { | |
| 23 | self.path = path | |
| 24 | self.ordinal = ordinal | |
| 25 | self.title = title | |
| 26 | self.start = start | |
| 27 | self.contentHash = contentHash | |
| 28 | } | |
| 21 | 29 | } |
| 22 | 30 | |
| 23 | 31 | /// One reconciliation's worth of changes, applied in a single transaction. |
Tests/OrgAppTests/AppTests.swift added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | import OrgIndex | |
| 4 | import Testing | |
| 5 | @testable import OrgApp | |
| 6 | ||
| 7 | /// A temporary folder that removes itself. | |
| 8 | final class Folder { | |
| 9 | let url: URL | |
| 10 | ||
| 11 | init() throws { | |
| 12 | url = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)").standardizedFileURL | |
| 13 | try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) | |
| 14 | } | |
| 15 | ||
| 16 | deinit { | |
| 17 | try? FileManager.default.removeItem(at: url) | |
| 18 | } | |
| 19 | ||
| 20 | @discardableResult | |
| 21 | func write(_ path: String, _ text: String) throws -> URL { | |
| 22 | let file = url.appendingPathComponent(path) | |
| 23 | try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) | |
| 24 | try Data(text.utf8).write(to: file) | |
| 25 | return file.standardizedFileURL | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | struct FileTreeTests { | |
| 30 | @Test func foldersFirstThenFiles() { | |
| 31 | let files = ["b.org", "a/z.org", "a/y.org", "c.sync-conflict-1.org", "a2.org"].map { | |
| 32 | FileEntry(path: "/r/" + $0, root: "/r", kind: $0.contains("conflict") ? .conflict : .org) | |
| 33 | } | |
| 34 | let tree = FileTreeNode.build(root: "/r", files: files) | |
| 35 | #expect(tree.children?.map(\.name) == ["a", "a2.org", "b.org", "c.sync-conflict-1.org"]) | |
| 36 | #expect(tree.children?[0].children?.map(\.name) == ["y.org", "z.org"]) | |
| 37 | #expect(tree.children?[1].children == nil) | |
| 38 | } | |
| 39 | ||
| 40 | @Test func fuzzyMatching() { | |
| 41 | #expect(FuzzyMatch.score("wn", "work/notes.org") != nil) | |
| 42 | #expect(FuzzyMatch.score("xyz", "work/notes.org") == nil) | |
| 43 | let files = ["work/notes.org", "notes.org", "personal/wine.org"].map { FileEntry(path: "/r/" + $0, root: "/r", kind: .org) } | |
| 44 | #expect(FuzzyMatch.rank("notes", files).map(\.relativePath) == ["notes.org", "work/notes.org"]) | |
| 45 | #expect(FuzzyMatch.rank("", files).count == 3) | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | @MainActor | |
| 50 | struct WorkspaceModelTests { | |
| 51 | @Test func addSearchRemoveAndPersist() async throws { | |
| 52 | let notes = try Folder() | |
| 53 | let support = try Folder() | |
| 54 | try notes.write("a.org", "* Groceries\nbuy apples\n") | |
| 55 | try notes.write("sub/b.org", "* Taxes\n") | |
| 56 | try notes.write("c.sync-conflict-1.org", "* Taxes copy\n") | |
| 57 | ||
| 58 | let model = try WorkspaceModel(directory: support.url) | |
| 59 | try await model.addRoot(notes.url) | |
| 60 | #expect(model.files.count == 3) | |
| 61 | #expect(model.search("apples").map(\.title) == ["Groceries"]) | |
| 62 | #expect(model.quickOpen("bo").map(\.name) == ["b.org"]) | |
| 63 | let root = try #require(model.rootPaths.first) | |
| 64 | #expect(model.tree(forRoot: root).children?.map(\.name) == ["sub", "a.org", "c.sync-conflict-1.org"]) | |
| 65 | ||
| 66 | let reopened = try WorkspaceModel(directory: support.url) | |
| 67 | #expect(reopened.roots.count == 1) | |
| 68 | #expect(reopened.files.count == 3) | |
| 69 | ||
| 70 | try reopened.removeRoot(path: root) | |
| 71 | #expect(reopened.roots.isEmpty) | |
| 72 | #expect(reopened.files.isEmpty) | |
| 73 | #expect(reopened.search("apples").isEmpty) | |
| 74 | } | |
| 75 | ||
| 76 | @Test func changedFilesAreReindexed() async throws { | |
| 77 | let notes = try Folder() | |
| 78 | let file = try notes.write("a.org", "* Old\n") | |
| 79 | let model = try WorkspaceModel(directory: nil) | |
| 80 | try await model.addRoot(notes.url) | |
| 81 | try notes.write("a.org", "* New\n") | |
| 82 | await model.filesChanged([file], rescan: []) | |
| 83 | #expect(model.search("new").count == 1) | |
| 84 | #expect(model.changedFile?.path == file.path) | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | @MainActor | |
| 89 | struct DocumentSessionTests { | |
| 90 | @Test func openShowAndReload() throws { | |
| 91 | let notes = try Folder() | |
| 92 | let file = try notes.write("a.org", "* One\n* Two\n") | |
| 93 | let session = DocumentSession() | |
| 94 | session.open(file.path) | |
| 95 | #expect(session.outline.map(\.title) == ["One", "Two"]) | |
| 96 | ||
| 97 | let location = HeadingLocation(path: file.path, ordinal: 1, title: "Two", start: 6, contentHash: "stale") | |
| 98 | session.show(location) | |
| 99 | #expect(session.revealRequest?.offset == 6) | |
| 100 | ||
| 101 | try notes.write("a.org", "intro\n* One\n* Two\n") | |
| 102 | session.fileChanged(file.path) | |
| 103 | #expect(session.outline.map(\.start) == [6, 12]) | |
| 104 | #expect(session.diskBytes != nil) | |
| 105 | session.show(location) | |
| 106 | #expect(session.revealRequest?.offset == 12) | |
| 107 | } | |
| 108 | ||
| 109 | @Test func missingFileReportsAnError() { | |
| 110 | let session = DocumentSession() | |
| 111 | session.open("/nonexistent/a.org") | |
| 112 | #expect(session.lastError != nil) | |
| 113 | #expect(session.document == nil) | |
| 114 | } | |
| 115 | } | |