Watch folders added during a session !59

merged merged by cmc on 2026-10-06 00:14 UTC · krz/orgstar:fix-watch-new-roots into main

2 files changed, +31 −1

Layout: unified · split

Sources/OrgApp/WorkspaceModel.swift +15 −1
@@ -23,6 +23,8 @@ public final class WorkspaceModel {
23 /// Resolved root folders, keyed by their resolved path, which is how the index stores them. 23 /// Resolved root folders, keyed by their resolved path, which is how the index stores them.
24 private var resolvedRoots: [String: URL] = [:] 24 private var resolvedRoots: [String: URL] = [:]
25 @ObservationIgnored private var watcher: AnyObject? 25 @ObservationIgnored private var watcher: AnyObject?
26 /// Set once watching starts, so adding or removing a root watches the new set.
27 @ObservationIgnored private var watching = false
26 private var changeVersion = 0 28 private var changeVersion = 0
27 @ObservationIgnored private var overlayCache: (text: String, record: FileRecord)? 29 @ObservationIgnored private var overlayCache: (text: String, record: FileRecord)?
28 30
@@ -60,6 +62,7 @@ public final class WorkspaceModel {
60 roots.append(root) 62 roots.append(root)
61 try saveRoots() 63 try saveRoots()
62 resolveRoots() 64 resolveRoots()
65 rootsChanged()
63 await refresh() 66 await refresh()
64 } 67 }
65 68
@@ -69,12 +72,19 @@ public final class WorkspaceModel {
69 roots.removeAll { bookmarkPaths.contains($0.path) } 72 roots.removeAll { bookmarkPaths.contains($0.path) }
70 try saveRoots() 73 try saveRoots()
71 resolveRoots() 74 resolveRoots()
75 rootsChanged()
72 var change = IndexChange() 76 var change = IndexChange()
73 change.removals = files.filter { $0.root == path }.map(\.path) 77 change.removals = files.filter { $0.root == path }.map(\.path)
74 try index.apply(change) 78 try index.apply(change)
75 try reloadFiles() 79 try reloadFiles()
76 } 80 }
77 81
82 private func rootsChanged() {
83 #if os(macOS)
84 if watching { startWatching() }
85 #endif
86 }
87
78 private func saveRoots() throws { 88 private func saveRoots() throws {
79 try JSONEncoder().encode(roots).write(to: rootsFile, options: .atomic) 89 try JSONEncoder().encode(roots).write(to: rootsFile, options: .atomic)
80 } 90 }
@@ -154,8 +164,12 @@ public final class WorkspaceModel {
154 } 164 }
155 165
156 #if os(macOS) 166 #if os(macOS)
157 /// Watches every root; changes reconcile the index and are reported through `changedFile`. 167 /// Watches every root, including roots added later; changes reconcile the index and are
168 /// reported through `changedFile`.
158 public func startWatching() { 169 public func startWatching() {
170 watching = true
171 (watcher as? FSEventsWatcher)?.stop()
172 watcher = nil
159 let roots = Array(resolvedRoots.values) 173 let roots = Array(resolvedRoots.values)
160 guard !roots.isEmpty else { return } 174 guard !roots.isEmpty else { return }
161 let fsWatcher = FSEventsWatcher(roots: roots.map { $0.resolvingSymlinksInPath() }) { [weak self] events in 175 let fsWatcher = FSEventsWatcher(roots: roots.map { $0.resolvingSymlinksInPath() }) { [weak self] events in
Tests/OrgAppTests/AppTests.swift +16
@@ -99,6 +99,22 @@ struct WorkspaceModelTests {
99 #expect(model.search("alpha").count == 1) 99 #expect(model.search("alpha").count == 1)
100 } 100 }
101 101
102 /// Watching starts with no folders (a first launch); a folder added afterwards is
103 /// watched.
104 @Test func foldersAddedLaterAreWatched() async throws {
105 let notes = try Folder()
106 let model = try WorkspaceModel(directory: nil)
107 model.startWatching()
108 try await model.addRoot(notes.url)
109 let file = try notes.write("new.org", "* Fresh heading\n")
110 var found = false
111 for _ in 0..<40 where !found {
112 try await Task.sleep(for: .milliseconds(100))
113 found = model.files.contains { URL(fileURLWithPath: $0.path).resolvingSymlinksInPath() == file.resolvingSymlinksInPath() }
114 }
115 #expect(found)
116 }
117
102 @Test func changedFilesAreReindexed() async throws { 118 @Test func changedFilesAreReindexed() async throws {
103 let notes = try Folder() 119 let notes = try Folder()
104 let file = try notes.write("a.org", "* Old\n") 120 let file = try notes.write("a.org", "* Old\n")