krz/orgstar

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

Sources/OrgApp/WorkspaceModel.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgApp/WorkspaceModel.swift history · blame · raw

245 lines · 10770 bytes

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