krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgApp/WorkspaceModel.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Sources/OrgApp/WorkspaceModel.swift history · blame · raw

406 lines · 18730 bytes

28 symbols in this file
  1import Foundation
  2import Observation
  3import OrgDocument
  4import OrgPresentation
  5import OrgCore
  6import OrgIndex
  7import OrgWorkspace
  8
  9/// The folders the user added, the index over them, and the queries the window runs.
 10@MainActor
 11@Observable
 12public final class WorkspaceModel {
 13    public private(set) var roots: [WorkspaceRoot] = []
 14    public private(set) var files: [FileEntry] = []
 15    /// iCloud files asked to download and not indexed yet, by path.
 16    public private(set) var downloading: Set<String> = []
 17    public private(set) var isIndexing = false
 18    /// The last error worth showing, such as a folder that can no longer be found.
 19    public var lastError: String?
 20    /// Bumped when an indexed file changes on disk, with its path.
 21    public private(set) var changedFile: (path: String, version: Int)?
 22
 23    public let index: IndexStore
 24    /// A change (other TODO keywords) reindexes the files it affects.
 25    public var settings: IndexSettings {
 26        didSet {
 27            guard settings.version != oldValue.version else { return }
 28            Task { await refresh() }
 29        }
 30    }
 31    private let rootsFile: URL
 32    /// Resolved root folders, keyed by their resolved path, which is how the index stores them.
 33    private var resolvedRoots: [String: URL] = [:]
 34    @ObservationIgnored private var watcher: AnyObject?
 35    /// Watchers of the roots where there are no FSEvents.
 36    @ObservationIgnored private var presenters: [FolderPresenter] = []
 37    /// Set once watching starts, so adding or removing a root watches the new set.
 38    @ObservationIgnored private var watching = false
 39    private var changeVersion = 0
 40    @ObservationIgnored private var overlayCache: (text: String, record: FileRecord)?
 41
 42    /// `directory` holds the index and the list of roots; nil keeps both in memory.
 43    public init(directory: URL?, settings: IndexSettings = IndexSettings()) throws {
 44        self.settings = settings
 45        if let directory {
 46            try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
 47            index = try IndexStore(path: directory.appendingPathComponent("index.sqlite").path)
 48            rootsFile = directory.appendingPathComponent("roots.json")
 49        } else {
 50            index = try IndexStore()
 51            rootsFile = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-roots-\(UUID().uuidString).json")
 52        }
 53        if let data = try? Data(contentsOf: rootsFile), let saved = try? JSONDecoder().decode([WorkspaceRoot].self, from: data) {
 54            roots = saved
 55        }
 56        resolveRoots()
 57        try reloadFiles()
 58        // `remote()` in table formulas reaches tables in other files by ID.
 59        let index = index
 60        RemoteTables.fileWithID = { (id: String) -> String? in
 61            guard let path = (try? index.headings(withID: id))?.first?.path else { return nil }
 62            return FileManager.default.contents(atPath: path).map { SourceText(bytes: [UInt8]($0)).text }
 63        }
 64    }
 65
 66    /// `~/Library/Application Support/Orgstar`, or `ORGSTAR_DATA_DIR` when set (for development).
 67    public static var defaultDirectory: URL {
 68        if let override = ProcessInfo.processInfo.environment["ORGSTAR_DATA_DIR"] {
 69            return URL(fileURLWithPath: override)
 70        }
 71        return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Orgstar")
 72    }
 73
 74    // MARK: - Roots
 75
 76    public func addRoot(_ url: URL) async throws {
 77        let root = try WorkspaceRoot(url: url)
 78        // A folder already added, perhaps since moved: its bookmark resolves to it.
 79        guard !roots.contains(where: { $0.path == root.path }), resolvedRoots[root.path] == nil else { return }
 80        roots.append(root)
 81        try saveRoots()
 82        resolveRoots()
 83        rootsChanged()
 84        await refresh()
 85    }
 86
 87    /// Removes a root by its resolved path, as `FileEntry.root` gives it.
 88    public func removeRoot(path: String) throws {
 89        let bookmarkPaths = roots.filter { (try? $0.resolve().url.path) == path || $0.path == path }.map(\.path)
 90        roots.removeAll { bookmarkPaths.contains($0.path) }
 91        try saveRoots()
 92        resolveRoots()
 93        rootsChanged()
 94        var change = IndexChange()
 95        change.removals = files.filter { $0.root == path }.map(\.path)
 96        try index.apply(change)
 97        try reloadFiles()
 98    }
 99
100    private func rootsChanged() {
101        if watching { startWatching() }
102    }
103
104    private func saveRoots() throws {
105        try JSONEncoder().encode(roots).write(to: rootsFile, options: .atomic)
106    }
107
108    /// Resolves bookmarks; refreshes stale ones; reports folders that can't be found.
109    private func resolveRoots() {
110        resolvedRoots = [:]
111        var refreshed = roots
112        for (i, root) in roots.enumerated() {
113            guard let (url, isStale) = try? root.resolve() else {
114                lastError = "Can't find the folder \(root.path)."
115                continue
116            }
117            if isStale, let fresh = try? WorkspaceRoot(url: url) { refreshed[i] = fresh }
118            resolvedRoots[url.path] = url
119        }
120        if refreshed != roots {
121            roots = refreshed
122            try? saveRoots()
123        }
124    }
125
126    public func url(forRoot path: String) -> URL? { resolvedRoots[path] }
127
128    /// Resolved root paths, sorted.
129    public var rootPaths: [String] { resolvedRoots.keys.sorted() }
130
131    // MARK: - Indexing
132
133    /// Which files the folders show; a change rescans them.
134    public var scanRules = ScanRules() {
135        didSet {
136            guard scanRules != oldValue else { return }
137            Task { await refresh() }
138        }
139    }
140
141    /// Reconciles every root off the main thread, then reloads the file list.
142    public func refresh() async {
143        isIndexing = true
144        defer { isIndexing = false }
145        let reconciler = Reconciler(index: index, settings: settings, rules: scanRules)
146        var pending: Set<String> = []
147        for url in resolvedRoots.values {
148            do {
149                let report = try await Task.detached { try reconciler.reconcile(root: url) }.value
150                pending.formUnion(report.placeholders.map(\.path))
151            } catch {
152                lastError = "Indexing \(url.path) failed: \(error.localizedDescription)"
153            }
154        }
155        // Rows of folders that moved or went away (indexed under their old paths).
156        let current = Array(resolvedRoots.keys)
157        if let indexed = try? index.files() {
158            var change = IndexChange()
159            change.removals = indexed.map(\.path).filter { path in !current.contains { path.hasPrefix($0 + "/") } }
160            if !change.removals.isEmpty { try? index.apply(change) }
161        }
162        try? reloadFiles()
163        setDownloading(pending)
164    }
165
166    /// Pending downloads, less the ones that have arrived and been indexed.
167    private func setDownloading(_ pending: Set<String>) {
168        let still = pending.subtracting(files.map(\.path))
169        if still != downloading { downloading = still }
170    }
171
172    private func reloadFiles() throws {
173        let roots = Set(resolvedRoots.keys)
174        files = try index.files().compactMap { file in
175            guard let root = roots.first(where: { file.path.hasPrefix($0 + "/") }) else { return nil }
176            return FileEntry(path: file.path, root: root, kind: file.kind)
177        }
178    }
179
180    /// Reconciles the paths a batch of file events named, or whole roots when events were lost.
181    public func filesChanged(_ paths: [URL], rescan roots: [URL]) async {
182        let reconciler = Reconciler(index: index, settings: settings, rules: scanRules)
183        var pending = downloading
184        for rootURL in roots {
185            let report = try? await Task.detached { try reconciler.reconcile(root: rootURL) }.value
186            pending = pending.filter { !$0.hasPrefix(rootURL.path + "/") }.union(report?.placeholders.map(\.path) ?? [])
187        }
188        for (rootPath, rootURL) in resolvedRoots where !roots.contains(rootURL) {
189            let inRoot = paths.filter { $0.path.hasPrefix(rootPath + "/") }
190            guard !inRoot.isEmpty else { continue }
191            let report = try? await Task.detached { try reconciler.reconcile(root: rootURL, paths: inRoot) }.value
192            pending.formUnion(report?.placeholders.map(\.path) ?? [])
193        }
194        try? reloadFiles()
195        setDownloading(pending)
196        for path in paths {
197            changeVersion += 1
198            changedFile = (path.standardizedFileURL.path, changeVersion)
199        }
200    }
201
202    #if os(macOS)
203    /// Watches every root, including roots added later; changes reconcile the index and are
204    /// reported through `changedFile`.
205    public func startWatching() {
206        watching = true
207        (watcher as? FSEventsWatcher)?.stop()
208        watcher = nil
209        let roots = Array(resolvedRoots.values)
210        guard !roots.isEmpty else { return }
211        let fsWatcher = FSEventsWatcher(roots: roots.map { $0.resolvingSymlinksInPath() }) { [weak self] events in
212            var changed: [URL] = []
213            var rescans: [URL] = []
214            for event in events {
215                switch event {
216                case .changed(let url): changed.append(url)
217                case .rescan(let url): rescans.append(url)
218                }
219            }
220            Task { @MainActor [weak self] in
221                guard let self else { return }
222                await self.filesChanged(changed.map(self.indexForm), rescan: rescans.compactMap(self.root(containing:)))
223            }
224        }
225        fsWatcher.start()
226        watcher = fsWatcher
227    }
228    #else
229    /// Watches every root through file presenters; changes reconcile the index and are
230    /// reported through `changedFile`.
231    public func startWatching() {
232        watching = true
233        presenters.forEach { $0.stop() }
234        presenters = resolvedRoots.values.map { root in
235            FolderPresenter(root: root) { [weak self] events in
236                Task { @MainActor [weak self] in
237                    guard let self else { return }
238                    var changed: [URL] = []
239                    var rescans: [URL] = []
240                    for event in events {
241                        switch event {
242                        case .changed(let url): changed.append(url)
243                        case .rescan(let url): rescans.append(url)
244                        }
245                    }
246                    await self.filesChanged(changed, rescan: rescans.compactMap(self.root(containing:)))
247                }
248            }
249        }
250        presenters.forEach { $0.start() }
251    }
252    #endif
253
254    /// The root an event path belongs to.
255    private func root(containing url: URL) -> URL? {
256        resolvedRoots.values.first { url.path.hasPrefix($0.resolvingSymlinksInPath().path) || url.path.hasPrefix($0.path) }
257    }
258
259    /// FSEvents reports real paths (`/private/var/...`); the index uses the root's own form.
260    func indexForm(_ url: URL) -> URL {
261        for root in resolvedRoots.values {
262            let real = root.resolvingSymlinksInPath().path
263            if real != root.path, url.path.hasPrefix(real + "/") {
264                return URL(fileURLWithPath: root.path + url.path.dropFirst(real.count))
265            }
266        }
267        return url
268    }
269
270    // MARK: - Files
271
272    public struct FileError: LocalizedError {
273        public let message: String
274        public var errorDescription: String? { message }
275    }
276
277    /// Creates an empty file at `name` (a name or a relative path; `.org` is added when it has
278    /// no extension) inside `folder`, which must be in a root. Returns its path.
279    public func createFile(named name: String, in folder: String) async throws -> String {
280        let trimmed = name.trimmingCharacters(in: .whitespaces)
281        guard !trimmed.isEmpty, !trimmed.hasPrefix("/"), !trimmed.split(separator: "/").contains(where: { $0 == ".." }) else {
282            throw FileError(message: "Not a file name: \(name)")
283        }
284        var url = URL(fileURLWithPath: folder).appendingPathComponent(trimmed)
285        if url.pathExtension.isEmpty { url.appendPathExtension("org") }
286        guard rootURL(containing: url.path) != nil else { throw FileError(message: "\(folder) isn't in a folder you added.") }
287        guard !FileManager.default.fileExists(atPath: url.path) else { throw FileError(message: "\(url.lastPathComponent) already exists.") }
288        try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
289        try Data().write(to: url, options: .withoutOverwriting)
290        await rescan(containing: url.path)
291        return url.standardizedFileURL.path
292    }
293
294    /// Renames a file or folder in place. Returns the new path.
295    public func rename(_ path: String, to name: String) async throws -> String {
296        let trimmed = name.trimmingCharacters(in: .whitespaces)
297        guard !trimmed.isEmpty, !trimmed.contains("/"), trimmed != ".", trimmed != ".." else {
298            throw FileError(message: "Not a file name: \(name)")
299        }
300        let target = URL(fileURLWithPath: path).deletingLastPathComponent().appendingPathComponent(trimmed)
301        guard target.path != path else { return path }
302        guard !FileManager.default.fileExists(atPath: target.path) else { throw FileError(message: "\(trimmed) already exists.") }
303        try FileManager.default.moveItem(atPath: path, toPath: target.path)
304        await rescan(containing: path)
305        return target.standardizedFileURL.path
306    }
307
308    /// How `trash` disposes of a file; tests replace it so nothing reaches the real Trash.
309    @ObservationIgnored public var trasher: @Sendable (URL) throws -> Void = { url in
310        try FileManager.default.trashItem(at: url, resultingItemURL: nil)
311    }
312
313    /// Moves a file or folder to the Trash.
314    public func trash(_ path: String) async throws {
315        try trasher(URL(fileURLWithPath: path))
316        await rescan(containing: path)
317    }
318
319    private func rootURL(containing path: String) -> URL? {
320        resolvedRoots.first { path == $0.key || path.hasPrefix($0.key + "/") }?.value
321    }
322
323    /// Brings the index up to date for the root holding `path` now, rather than when the
324    /// file events arrive.
325    private func rescan(containing path: String) async {
326        guard let root = rootURL(containing: path) else { return }
327        await filesChanged([], rescan: [root])
328    }
329
330    // MARK: - Queries
331
332    public func tree(forRoot path: String) -> FileTreeNode {
333        FileTreeNode.build(root: path, files: files, downloading: downloading.sorted())
334    }
335
336    /// Headings with links to `target`, the target heading's own links left out.
337    public func backlinks(to target: Backlinks.Target, excluding start: Int? = nil, overlay: [String: FileRecord] = [:]) -> [HeadingLocation] {
338        var seen = Set<HeadingLocation>()
339        return ((try? index.links(overlay: overlay)) ?? []).compactMap { link in
340            guard !(link.source.path == target.path && link.source.start == start),
341                  Backlinks.links(link.target, from: link.source.path, to: target), seen.insert(link.source).inserted else { return nil }
342            return link.source
343        }
344    }
345
346    public func search(_ query: String, overlay: [String: FileRecord] = [:]) -> [HeadingLocation] {
347        (try? index.search(query, overlay: overlay)) ?? []
348    }
349
350    /// Index rows for an open file's unsaved text, keyed by path, for queries to use in place
351    /// of its rows on disk. Empty when the file has no unsaved edits. Cached until the text
352    /// changes.
353    /// Index records for open buffers with unsaved edits, so search sees what they hold.
354    public func overlay(_ documents: [(path: String, document: DocumentState)]) -> [String: FileRecord] {
355        var result: [String: FileRecord] = [:]
356        for (path, document) in documents where document.isDirty {
357            result.merge(overlay(path: path, document: document)) { a, _ in a }
358        }
359        return result
360    }
361
362    public func overlay(path: String?, document: DocumentState?) -> [String: FileRecord] {
363        guard let path, let document, document.isDirty, let bytes = try? document.encodedText(),
364              let entry = files.first(where: { $0.path == path }) else { return [:] }
365        if let cached = overlayCache, cached.record.path == path, cached.text == document.text {
366            return [path: cached.record]
367        }
368        let record = FileRecord(path: path, root: entry.root, kind: entry.kind, bytes: bytes, mtime: Date().timeIntervalSince1970, settings: settings)
369        overlayCache = (document.text, record)
370        return [path: record]
371    }
372
373    /// Headings down to level 3 in every org file, and each file itself. The open file's come
374    /// from its buffer, so they match unsaved edits.
375    public func refileTargets(openPath: String?, document: DocumentState?) -> [RefileTarget] {
376        let indexed = (try? index.outline(maxLevel: 3)) ?? []
377        var targets: [RefileTarget] = []
378        for file in files where file.kind == .org {
379            let name = file.relativePath
380            if file.path == openPath, let document {
381                let hash = FileRecord.hash(document.mergeBase)
382                targets.append(RefileTarget(label: name, path: file.path, start: nil, title: "", level: 0, contentHash: hash))
383                var stack: [String] = []
384                for entry in Presentation.outline(document.tree) {
385                    stack = Array(stack.prefix(entry.level - 1)) + [entry.title]
386                    guard entry.level <= 3 else { continue }
387                    targets.append(RefileTarget(label: name + "/" + stack.joined(separator: "/"), path: file.path, start: entry.start, title: entry.title, level: entry.level, contentHash: hash))
388                }
389                continue
390            }
391            let headings = indexed.filter { $0.location.path == file.path }
392            targets.append(RefileTarget(label: name, path: file.path, start: nil, title: "", level: 0, contentHash: headings.first?.location.contentHash ?? ""))
393            for heading in headings {
394                targets.append(RefileTarget(
395                    label: name + "/" + heading.outlinePath.joined(separator: "/"), path: file.path, start: heading.location.start,
396                    title: heading.location.title, level: heading.level, contentHash: heading.location.contentHash
397                ))
398            }
399        }
400        return targets
401    }
402
403    public func quickOpen(_ query: String) -> [FileEntry] {
404        FuzzyMatch.rank(query, files.filter { $0.kind != .conflict })
405    }
406}