krz/orgstar

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

Sources/OrgApp/WorkspaceModel.swift

056befa97fffa110959cb6010c1986461c09dc0b
orgstar/Sources/OrgApp/WorkspaceModel.swift history · blame · raw

214 lines · 8922 bytes

  1import Foundation
  2import Observation
  3import OrgDocument
  4import OrgIndex
  5import OrgWorkspace
  6
  7/// The folders the user added, the index over them, and the queries the window runs.
  8@MainActor
  9@Observable
 10public final class WorkspaceModel {
 11    public private(set) var roots: [WorkspaceRoot] = []
 12    public private(set) var files: [FileEntry] = []
 13    public private(set) var isIndexing = false
 14    /// The last error worth showing, such as a folder that can no longer be found.
 15    public var lastError: String?
 16    /// Bumped when an indexed file changes on disk, with its path.
 17    public private(set) var changedFile: (path: String, version: Int)?
 18
 19    public let index: IndexStore
 20    public let settings: IndexSettings
 21    private let rootsFile: URL
 22    /// Resolved root folders, keyed by their resolved path, which is how the index stores them.
 23    private var resolvedRoots: [String: URL] = [:]
 24    @ObservationIgnored private var watcher: AnyObject?
 25    private var changeVersion = 0
 26    @ObservationIgnored private var overlayCache: (text: String, record: FileRecord)?
 27
 28    /// `directory` holds the index and the list of roots; nil keeps both in memory.
 29    public init(directory: URL?, settings: IndexSettings = IndexSettings()) throws {
 30        self.settings = settings
 31        if let directory {
 32            try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
 33            index = try IndexStore(path: directory.appendingPathComponent("index.sqlite").path)
 34            rootsFile = directory.appendingPathComponent("roots.json")
 35        } else {
 36            index = try IndexStore()
 37            rootsFile = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-roots-\(UUID().uuidString).json")
 38        }
 39        if let data = try? Data(contentsOf: rootsFile), let saved = try? JSONDecoder().decode([WorkspaceRoot].self, from: data) {
 40            roots = saved
 41        }
 42        resolveRoots()
 43        try reloadFiles()
 44    }
 45
 46    /// `~/Library/Application Support/Orgstar`, or `ORGSTAR_DATA_DIR` when set (for development).
 47    public static var defaultDirectory: URL {
 48        if let override = ProcessInfo.processInfo.environment["ORGSTAR_DATA_DIR"] {
 49            return URL(fileURLWithPath: override)
 50        }
 51        return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Orgstar")
 52    }
 53
 54    // MARK: - Roots
 55
 56    public func addRoot(_ url: URL) async throws {
 57        let root = try WorkspaceRoot(url: url)
 58        guard !roots.contains(where: { $0.path == root.path }) else { return }
 59        roots.append(root)
 60        try saveRoots()
 61        resolveRoots()
 62        await refresh()
 63    }
 64
 65    /// Removes a root by its resolved path, as `FileEntry.root` gives it.
 66    public func removeRoot(path: String) throws {
 67        let bookmarkPaths = roots.filter { (try? $0.resolve().url.path) == path || $0.path == path }.map(\.path)
 68        roots.removeAll { bookmarkPaths.contains($0.path) }
 69        try saveRoots()
 70        resolveRoots()
 71        var change = IndexChange()
 72        change.removals = files.filter { $0.root == path }.map(\.path)
 73        try index.apply(change)
 74        try reloadFiles()
 75    }
 76
 77    private func saveRoots() throws {
 78        try JSONEncoder().encode(roots).write(to: rootsFile, options: .atomic)
 79    }
 80
 81    /// Resolves bookmarks; refreshes stale ones; reports folders that can't be found.
 82    private func resolveRoots() {
 83        resolvedRoots = [:]
 84        var refreshed = roots
 85        for (i, root) in roots.enumerated() {
 86            guard let (url, isStale) = try? root.resolve() else {
 87                lastError = "Can't find the folder \(root.path)."
 88                continue
 89            }
 90            if isStale, let fresh = try? WorkspaceRoot(url: url) { refreshed[i] = fresh }
 91            resolvedRoots[url.path] = url
 92        }
 93        if refreshed != roots {
 94            roots = refreshed
 95            try? saveRoots()
 96        }
 97    }
 98
 99    public func url(forRoot path: String) -> URL? { resolvedRoots[path] }
100
101    /// Resolved root paths, sorted.
102    public var rootPaths: [String] { resolvedRoots.keys.sorted() }
103
104    // MARK: - Indexing
105
106    /// Reconciles every root off the main thread, then reloads the file list.
107    public func refresh() async {
108        isIndexing = true
109        defer { isIndexing = false }
110        let reconciler = Reconciler(index: index, settings: settings)
111        for url in resolvedRoots.values {
112            do {
113                _ = try await Task.detached { try reconciler.reconcile(root: url) }.value
114            } catch {
115                lastError = "Indexing \(url.path) failed: \(error.localizedDescription)"
116            }
117        }
118        try? reloadFiles()
119    }
120
121    private func reloadFiles() throws {
122        let roots = Set(resolvedRoots.keys)
123        files = try index.files().compactMap { file in
124            guard let root = roots.first(where: { file.path.hasPrefix($0 + "/") }) else { return nil }
125            return FileEntry(path: file.path, root: root, kind: file.kind)
126        }
127    }
128
129    /// Reconciles the paths a batch of file events named, or whole roots when events were lost.
130    public func filesChanged(_ paths: [URL], rescan roots: [URL]) async {
131        let reconciler = Reconciler(index: index, settings: settings)
132        for rootURL in roots {
133            _ = try? await Task.detached { try reconciler.reconcile(root: rootURL) }.value
134        }
135        for (rootPath, rootURL) in resolvedRoots where !roots.contains(rootURL) {
136            let inRoot = paths.filter { $0.path.hasPrefix(rootPath + "/") }
137            guard !inRoot.isEmpty else { continue }
138            _ = try? await Task.detached { try reconciler.reconcile(root: rootURL, paths: inRoot) }.value
139        }
140        try? reloadFiles()
141        for path in paths {
142            changeVersion += 1
143            changedFile = (path.standardizedFileURL.path, changeVersion)
144        }
145    }
146
147    #if os(macOS)
148    /// Watches every root; changes reconcile the index and are reported through `changedFile`.
149    public func startWatching() {
150        let roots = Array(resolvedRoots.values)
151        guard !roots.isEmpty else { return }
152        let fsWatcher = FSEventsWatcher(roots: roots.map { $0.resolvingSymlinksInPath() }) { [weak self] events in
153            var changed: [URL] = []
154            var rescans: [URL] = []
155            for event in events {
156                switch event {
157                case .changed(let url): changed.append(url)
158                case .rescan(let url): rescans.append(url)
159                }
160            }
161            Task { @MainActor [weak self] in
162                guard let self else { return }
163                await self.filesChanged(changed.map(self.indexForm), rescan: rescans.compactMap(self.root(containing:)))
164            }
165        }
166        fsWatcher.start()
167        watcher = fsWatcher
168    }
169    #endif
170
171    /// The root an event path belongs to.
172    private func root(containing url: URL) -> URL? {
173        resolvedRoots.values.first { url.path.hasPrefix($0.resolvingSymlinksInPath().path) || url.path.hasPrefix($0.path) }
174    }
175
176    /// FSEvents reports real paths (`/private/var/...`); the index uses the root's own form.
177    func indexForm(_ url: URL) -> URL {
178        for root in resolvedRoots.values {
179            let real = root.resolvingSymlinksInPath().path
180            if real != root.path, url.path.hasPrefix(real + "/") {
181                return URL(fileURLWithPath: root.path + url.path.dropFirst(real.count))
182            }
183        }
184        return url
185    }
186
187    // MARK: - Queries
188
189    public func tree(forRoot path: String) -> FileTreeNode {
190        FileTreeNode.build(root: path, files: files)
191    }
192
193    public func search(_ query: String, overlay: [String: FileRecord] = [:]) -> [HeadingLocation] {
194        (try? index.search(query, overlay: overlay)) ?? []
195    }
196
197    /// Index rows for an open file's unsaved text, keyed by path, for queries to use in place
198    /// of its rows on disk. Empty when the file has no unsaved edits. Cached until the text
199    /// changes.
200    public func overlay(path: String?, document: DocumentState?) -> [String: FileRecord] {
201        guard let path, let document, document.isDirty, let bytes = try? document.encodedText(),
202              let entry = files.first(where: { $0.path == path }) else { return [:] }
203        if let cached = overlayCache, cached.record.path == path, cached.text == document.text {
204            return [path: cached.record]
205        }
206        let record = FileRecord(path: path, root: entry.root, kind: entry.kind, bytes: bytes, mtime: Date().timeIntervalSince1970, settings: settings)
207        overlayCache = (document.text, record)
208        return [path: record]
209    }
210
211    public func quickOpen(_ query: String) -> [FileEntry] {
212        FuzzyMatch.rank(query, files.filter { $0.kind != .conflict })
213    }
214}