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