Sources/OrgApp/WorkspaceModel.swift
406 lines · 18730 bytes
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}