Sources/OrgApp/WorkspaceModel.swift
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}