krz/orgstar

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

Commit f9597060ef

f9597060ef6dd82e4f521b574eadc684faa13d64

parent: 7a7cb1803d

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 22:29 UTC

iCloud downloads, folder presenters, and shared modules that build for iOS

iCloud files not on the device (dataless since macOS 14, or .name.icloud) are
asked to download instead of read, listed in the sidebar as downloading, and
indexed when they arrive; an evicted file keeps its index rows. Without
FSEvents (iOS), roots are watched with NSFilePresenter.

OrgApp and its dependencies build for iOS: Babel and Emacs processes are
macOS-only, and the home folder comes from NSHomeDirectory().

Ref docs/review-2026-10-05.md tier 3 item 6

Layout: unified · split

Sources/OrgApp/BabelRunner.swift +2
@@ -1,3 +1,4 @@
1#if os(macOS)
1import Foundation 2import Foundation
2import OrgCore 3import OrgCore
3 4
@@ -95,3 +96,4 @@ public enum BabelRunner {
95 return BabelOutput(stdout: output, resultFile: result, stderr: errors, status: process.terminationStatus) 96 return BabelOutput(stdout: output, resultFile: result, stderr: errors, status: process.terminationStatus)
96 } 97 }
97} 98}
99#endif
Sources/OrgApp/BabelSessions.swift +2
@@ -1,3 +1,4 @@
1#if os(macOS)
1import Foundation 2import Foundation
2import OrgCore 3import OrgCore
3 4
@@ -122,3 +123,4 @@ public final class BabelSessions: @unchecked Sendable {
122 } 123 }
123 } 124 }
124} 125}
126#endif
Sources/OrgApp/CaptureModel.swift +1 −1
@@ -113,7 +113,7 @@ extension DocumentSession {
113 public static func resolve(_ file: String, root: String?) -> String { 113 public static func resolve(_ file: String, root: String?) -> String {
114 if file.hasPrefix("/") { return file } 114 if file.hasPrefix("/") { return file }
115 if file.hasPrefix("~") { return (file as NSString).expandingTildeInPath } 115 if file.hasPrefix("~") { return (file as NSString).expandingTildeInPath }
116 return ((root ?? FileManager.default.homeDirectoryForCurrentUser.path) as NSString).appendingPathComponent(file) 116 return ((root ?? NSHomeDirectory()) as NSString).appendingPathComponent(file)
117 } 117 }
118 118
119 /// `%a`: a link to the heading at the caret, as `org-store-link` makes one. 119 /// `%a`: a link to the heading at the caret, as `org-store-link` makes one.
Sources/OrgApp/DocumentSession.swift +8
@@ -649,6 +649,7 @@ public final class DocumentSession {
649 /// Recalculates a table in Emacs from a snapshot of the buffer, then replaces the table 649 /// Recalculates a table in Emacs from a snapshot of the buffer, then replaces the table
650 /// with Emacs's text if it hasn't changed meanwhile. Returns a message for the echo area. 650 /// with Emacs's text if it hasn't changed meanwhile. Returns a message for the echo area.
651 public func recalculateInEmacs(table: Range<Int>, point: Int, form: String) async -> String { 651 public func recalculateInEmacs(table: Range<Int>, point: Int, form: String) async -> String {
652 #if os(macOS)
652 guard let entry = current else { return "No file is open." } 653 guard let entry = current else { return "No file is open." }
653 let path = entry.path 654 let path = entry.path
654 let snapshot = entry.buffer.document.text as NSString 655 let snapshot = entry.buffer.document.text as NSString
@@ -671,11 +672,15 @@ public final class DocumentSession {
671 } catch { 672 } catch {
672 return "\(error)" 673 return "\(error)"
673 } 674 }
675 #else
676 return "Tables that need Emacs aren't recalculated on iOS."
677 #endif
674 } 678 }
675 679
676 /// Runs a src block and writes its result under it, unless the block changed while it 680 /// Runs a src block and writes its result under it, unless the block changed while it
677 /// ran. Returns a message for the echo area. 681 /// ran. Returns a message for the echo area.
678 public func runBabel(_ job: BabelJob) async -> String { 682 public func runBabel(_ job: BabelJob) async -> String {
683 #if os(macOS)
679 guard let entry = current else { return "No file is open." } 684 guard let entry = current else { return "No file is open." }
680 let directory = URL(fileURLWithPath: entry.path).deletingLastPathComponent() 685 let directory = URL(fileURLWithPath: entry.path).deletingLastPathComponent()
681 do { 686 do {
@@ -708,6 +713,9 @@ public final class DocumentSession {
708 } catch { 713 } catch {
709 return "\(error)" 714 return "\(error)"
710 } 715 }
716 #else
717 return "Source blocks don't run on iOS."
718 #endif
711 } 719 }
712 720
713 /// The block running now, to cancel. 721 /// The block running now, to cancel.
Sources/OrgApp/EmacsImport.swift +2 −2
@@ -61,7 +61,7 @@ public struct EmacsImport: Sendable {
61 61
62 /// Where an Emacs configuration usually is, first found first. 62 /// Where an Emacs configuration usually is, first found first.
63 public static func defaultLocations() -> [URL] { 63 public static func defaultLocations() -> [URL] {
64 let home = FileManager.default.homeDirectoryForCurrentUser 64 let home = URL(fileURLWithPath: NSHomeDirectory())
65 let environment = ProcessInfo.processInfo.environment 65 let environment = ProcessInfo.processInfo.environment
66 var candidates: [URL] = [] 66 var candidates: [URL] = []
67 if let doom = environment["DOOMDIR"] { candidates.append(URL(fileURLWithPath: doom)) } 67 if let doom = environment["DOOMDIR"] { candidates.append(URL(fileURLWithPath: doom)) }
@@ -77,7 +77,7 @@ public struct EmacsImport: Sendable {
77 77
78 /// Doom's org module and core defaults, if Doom is installed. 78 /// Doom's org module and core defaults, if Doom is installed.
79 static func doomDefaultFiles() -> [URL] { 79 static func doomDefaultFiles() -> [URL] {
80 let home = FileManager.default.homeDirectoryForCurrentUser 80 let home = URL(fileURLWithPath: NSHomeDirectory())
81 let environment = ProcessInfo.processInfo.environment 81 let environment = ProcessInfo.processInfo.environment
82 let installs = ([environment["EMACSDIR"]].compactMap { $0 }.map { URL(fileURLWithPath: $0) }) 82 let installs = ([environment["EMACSDIR"]].compactMap { $0 }.map { URL(fileURLWithPath: $0) })
83 + [".config/emacs", ".emacs.d"].map { home.appendingPathComponent($0) } 83 + [".config/emacs", ".emacs.d"].map { home.appendingPathComponent($0) }
Sources/OrgApp/EmacsRunner.swift +2
@@ -1,3 +1,4 @@
1#if os(macOS)
1import Foundation 2import Foundation
2 3
3/// Runs `emacs --batch` for what the native code hands over, such as tables outside the 4/// Runs `emacs --batch` for what the native code hands over, such as tables outside the
@@ -131,3 +132,4 @@ public enum EmacsRunner {
131 return written 132 return written
132 } 133 }
133} 134}
135#endif
Sources/OrgApp/FileTree.swift +20 −7
@@ -23,16 +23,28 @@ public struct FileTreeNode: Sendable, Hashable, Identifiable {
23 public let file: FileEntry? 23 public let file: FileEntry?
24 /// Nil for files, so a SwiftUI `OutlineGroup` shows no disclosure triangle. 24 /// Nil for files, so a SwiftUI `OutlineGroup` shows no disclosure triangle.
25 public let children: [FileTreeNode]? 25 public let children: [FileTreeNode]?
26 /// An iCloud file on its way; it opens once it's indexed.
27 public var isDownloading = false
26 28
27 public var id: String { path } 29 public var id: String { path }
28 30
29 /// Folders first, then files, each by name. Conflict copies are listed with their files. 31 /// Folders first, then files, each by name. Conflict copies are listed with their files.
30 public static func build(root: String, files: [FileEntry]) -> FileTreeNode { 32 public static func build(root: String, files: [FileEntry], downloading: [String] = []) -> FileTreeNode {
31 final class Folder { 33 final class Folder {
32 var folders: [String: Folder] = [:] 34 var folders: [String: Folder] = [:]
33 var files: [FileEntry] = [] 35 var files: [FileEntry] = []
36 var downloading: [String] = []
34 } 37 }
35 let top = Folder() 38 let top = Folder()
39 for path in downloading where path.hasPrefix(root + "/") {
40 var folder = top
41 for component in path.dropFirst(root.count + 1).split(separator: "/").dropLast() {
42 let name = String(component)
43 if folder.folders[name] == nil { folder.folders[name] = Folder() }
44 folder = folder.folders[name]!
45 }
46 folder.downloading.append(path)
47 }
36 for file in files where file.root == root { 48 for file in files where file.root == root {
37 var folder = top 49 var folder = top
38 for component in file.relativePath.split(separator: "/").dropLast() { 50 for component in file.relativePath.split(separator: "/").dropLast() {
@@ -46,9 +58,11 @@ public struct FileTreeNode: Sendable, Hashable, Identifiable {
46 let folders = folder.folders.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }.map { 58 let folders = folder.folders.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }.map {
47 node(folder.folders[$0]!, name: $0, path: path + "/" + $0) 59 node(folder.folders[$0]!, name: $0, path: path + "/" + $0)
48 } 60 }
49 let files = folder.files.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }.map { 61 let files = (folder.files.map {
50 FileTreeNode(name: $0.name, path: $0.path, file: $0, children: nil) 62 FileTreeNode(name: $0.name, path: $0.path, file: $0, children: nil)
51 } 63 } + folder.downloading.map {
64 FileTreeNode(name: ($0 as NSString).lastPathComponent, path: $0, file: nil, children: nil, isDownloading: true)
65 }).sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
52 return FileTreeNode(name: name, path: path, file: nil, children: folders + files) 66 return FileTreeNode(name: name, path: path, file: nil, children: folders + files)
53 } 67 }
54 return node(top, name: (root as NSString).lastPathComponent, path: root) 68 return node(top, name: (root as NSString).lastPathComponent, path: root)
@@ -78,9 +92,8 @@ public enum FuzzyMatch {
78 92
79 /// Entries ranked by `score` against their relative path, best first. 93 /// Entries ranked by `score` against their relative path, best first.
80 public static func rank(_ query: String, _ files: [FileEntry], limit: Int = 50) -> [FileEntry] { 94 public static func rank(_ query: String, _ files: [FileEntry], limit: Int = 50) -> [FileEntry] {
81 files.compactMap { file in score(query, file.relativePath).map { (file, $0) } } 95 let scored: [(file: FileEntry, score: Int)] = files.compactMap { file in score(query, file.relativePath).map { (file, $0) } }
82 .sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.0.relativePath < $1.0.relativePath } 96 let ranked = scored.sorted { a, b in a.score != b.score ? a.score > b.score : a.file.relativePath < b.file.relativePath }
83 .prefix(limit) 97 return ranked.prefix(limit).map(\.file)
84 .map(\.0)
85 } 98 }
86} 99}
Sources/OrgApp/LinkFollow.swift +1 −1
@@ -53,7 +53,7 @@ extension DocumentSession {
53 public static func resolve(_ path: String, from current: String?) -> String { 53 public static func resolve(_ path: String, from current: String?) -> String {
54 let expanded = (path as NSString).expandingTildeInPath 54 let expanded = (path as NSString).expandingTildeInPath
55 if expanded.hasPrefix("/") { return (expanded as NSString).standardizingPath } 55 if expanded.hasPrefix("/") { return (expanded as NSString).standardizingPath }
56 let folder = current.map { ($0 as NSString).deletingLastPathComponent } ?? FileManager.default.homeDirectoryForCurrentUser.path 56 let folder = current.map { ($0 as NSString).deletingLastPathComponent } ?? NSHomeDirectory()
57 return ((folder as NSString).appendingPathComponent(expanded) as NSString).standardizingPath 57 return ((folder as NSString).appendingPathComponent(expanded) as NSString).standardizingPath
58 } 58 }
59 59
Sources/OrgApp/UserConfig.swift +1 −1
@@ -16,7 +16,7 @@ public enum UserConfig {
16 if let xdg = environment["XDG_CONFIG_HOME"], !xdg.isEmpty { 16 if let xdg = environment["XDG_CONFIG_HOME"], !xdg.isEmpty {
17 return URL(fileURLWithPath: xdg).appendingPathComponent("orgstar") 17 return URL(fileURLWithPath: xdg).appendingPathComponent("orgstar")
18 } 18 }
19 return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/orgstar") 19 return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".config/orgstar")
20 } 20 }
21 21
22 /// A file of the configuration folder; one that exists only where earlier versions kept 22 /// A file of the configuration folder; one that exists only where earlier versions kept
Sources/OrgApp/WorkspaceModel.swift +45 −6
@@ -12,6 +12,8 @@ import OrgWorkspace
12public final class WorkspaceModel { 12public final class WorkspaceModel {
13 public private(set) var roots: [WorkspaceRoot] = [] 13 public private(set) var roots: [WorkspaceRoot] = []
14 public private(set) var files: [FileEntry] = [] 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> = []
15 public private(set) var isIndexing = false 17 public private(set) var isIndexing = false
16 /// The last error worth showing, such as a folder that can no longer be found. 18 /// The last error worth showing, such as a folder that can no longer be found.
17 public var lastError: String? 19 public var lastError: String?
@@ -24,6 +26,8 @@ public final class WorkspaceModel {
24 /// Resolved root folders, keyed by their resolved path, which is how the index stores them. 26 /// Resolved root folders, keyed by their resolved path, which is how the index stores them.
25 private var resolvedRoots: [String: URL] = [:] 27 private var resolvedRoots: [String: URL] = [:]
26 @ObservationIgnored private var watcher: AnyObject? 28 @ObservationIgnored private var watcher: AnyObject?
29 /// Watchers of the roots where there are no FSEvents.
30 @ObservationIgnored private var presenters: [FolderPresenter] = []
27 /// Set once watching starts, so adding or removing a root watches the new set. 31 /// Set once watching starts, so adding or removing a root watches the new set.
28 @ObservationIgnored private var watching = false 32 @ObservationIgnored private var watching = false
29 private var changeVersion = 0 33 private var changeVersion = 0
@@ -81,9 +85,7 @@ public final class WorkspaceModel {
81 } 85 }
82 86
83 private func rootsChanged() { 87 private func rootsChanged() {
84 #if os(macOS)
85 if watching { startWatching() } 88 if watching { startWatching() }
86 #endif
87 } 89 }
88 90
89 private func saveRoots() throws { 91 private func saveRoots() throws {
@@ -128,14 +130,23 @@ public final class WorkspaceModel {
128 isIndexing = true 130 isIndexing = true
129 defer { isIndexing = false } 131 defer { isIndexing = false }
130 let reconciler = Reconciler(index: index, settings: settings, rules: scanRules) 132 let reconciler = Reconciler(index: index, settings: settings, rules: scanRules)
133 var pending: Set<String> = []
131 for url in resolvedRoots.values { 134 for url in resolvedRoots.values {
132 do { 135 do {
133 _ = try await Task.detached { try reconciler.reconcile(root: url) }.value 136 let report = try await Task.detached { try reconciler.reconcile(root: url) }.value
137 pending.formUnion(report.placeholders.map(\.path))
134 } catch { 138 } catch {
135 lastError = "Indexing \(url.path) failed: \(error.localizedDescription)" 139 lastError = "Indexing \(url.path) failed: \(error.localizedDescription)"
136 } 140 }
137 } 141 }
138 try? reloadFiles() 142 try? reloadFiles()
143 setDownloading(pending)
144 }
145
146 /// Pending downloads, less the ones that have arrived and been indexed.
147 private func setDownloading(_ pending: Set<String>) {
148 let still = pending.subtracting(files.map(\.path))
149 if still != downloading { downloading = still }
139 } 150 }
140 151
141 private func reloadFiles() throws { 152 private func reloadFiles() throws {
@@ -149,15 +160,19 @@ public final class WorkspaceModel {
149 /// Reconciles the paths a batch of file events named, or whole roots when events were lost. 160 /// Reconciles the paths a batch of file events named, or whole roots when events were lost.
150 public func filesChanged(_ paths: [URL], rescan roots: [URL]) async { 161 public func filesChanged(_ paths: [URL], rescan roots: [URL]) async {
151 let reconciler = Reconciler(index: index, settings: settings, rules: scanRules) 162 let reconciler = Reconciler(index: index, settings: settings, rules: scanRules)
163 var pending = downloading
152 for rootURL in roots { 164 for rootURL in roots {
153 _ = try? await Task.detached { try reconciler.reconcile(root: rootURL) }.value 165 let report = try? await Task.detached { try reconciler.reconcile(root: rootURL) }.value
166 pending = pending.filter { !$0.hasPrefix(rootURL.path + "/") }.union(report?.placeholders.map(\.path) ?? [])
154 } 167 }
155 for (rootPath, rootURL) in resolvedRoots where !roots.contains(rootURL) { 168 for (rootPath, rootURL) in resolvedRoots where !roots.contains(rootURL) {
156 let inRoot = paths.filter { $0.path.hasPrefix(rootPath + "/") } 169 let inRoot = paths.filter { $0.path.hasPrefix(rootPath + "/") }
157 guard !inRoot.isEmpty else { continue } 170 guard !inRoot.isEmpty else { continue }
158 _ = try? await Task.detached { try reconciler.reconcile(root: rootURL, paths: inRoot) }.value 171 let report = try? await Task.detached { try reconciler.reconcile(root: rootURL, paths: inRoot) }.value
172 pending.formUnion(report?.placeholders.map(\.path) ?? [])
159 } 173 }
160 try? reloadFiles() 174 try? reloadFiles()
175 setDownloading(pending)
161 for path in paths { 176 for path in paths {
162 changeVersion += 1 177 changeVersion += 1
163 changedFile = (path.standardizedFileURL.path, changeVersion) 178 changedFile = (path.standardizedFileURL.path, changeVersion)
@@ -190,6 +205,30 @@ public final class WorkspaceModel {
190 fsWatcher.start() 205 fsWatcher.start()
191 watcher = fsWatcher 206 watcher = fsWatcher
192 } 207 }
208 #else
209 /// Watches every root through file presenters; changes reconcile the index and are
210 /// reported through `changedFile`.
211 public func startWatching() {
212 watching = true
213 presenters.forEach { $0.stop() }
214 presenters = resolvedRoots.values.map { root in
215 FolderPresenter(root: root) { [weak self] events in
216 Task { @MainActor [weak self] in
217 guard let self else { return }
218 var changed: [URL] = []
219 var rescans: [URL] = []
220 for event in events {
221 switch event {
222 case .changed(let url): changed.append(url)
223 case .rescan(let url): rescans.append(url)
224 }
225 }
226 await self.filesChanged(changed, rescan: rescans.compactMap(self.root(containing:)))
227 }
228 }
229 }
230 presenters.forEach { $0.start() }
231 }
193 #endif 232 #endif
194 233
195 /// The root an event path belongs to. 234 /// The root an event path belongs to.
@@ -271,7 +310,7 @@ public final class WorkspaceModel {
271 // MARK: - Queries 310 // MARK: - Queries
272 311
273 public func tree(forRoot path: String) -> FileTreeNode { 312 public func tree(forRoot path: String) -> FileTreeNode {
274 FileTreeNode.build(root: path, files: files) 313 FileTreeNode.build(root: path, files: files, downloading: downloading.sorted())
275 } 314 }
276 315
277 /// Headings with links to `target`, the target heading's own links left out. 316 /// Headings with links to `target`, the target heading's own links left out.
Sources/OrgWorkspace/Discovery.swift +19 −1
@@ -86,9 +86,25 @@ public enum WorkspaceScanner {
86 return .file(.other) 86 return .file(.other)
87 } 87 }
88 88
89 /// Resource keys a scan reads.
90 static let keys: [URLResourceKey] = [
91 .isDirectoryKey, .fileSizeKey, .contentModificationDateKey, .isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey,
92 ]
93
94 /// An iCloud file whose contents aren't on this device: reading it would wait for the
95 /// download, so it's asked to download and indexed when it arrives. Since macOS 14 these
96 /// keep their own names rather than `.name.icloud`.
97 public static func isPending(isUbiquitous: Bool?, status: URLUbiquitousItemDownloadingStatus?) -> Bool {
98 isUbiquitous == true && status == .notDownloaded
99 }
100
101 static func isPending(_ values: URLResourceValues) -> Bool {
102 isPending(isUbiquitous: values.isUbiquitousItem, status: values.ubiquitousItemDownloadingStatus)
103 }
104
89 /// Every file under `root`, skipping the folders `rules` ignores. 105 /// Every file under `root`, skipping the folders `rules` ignores.
90 public static func scan(_ root: URL, rules: ScanRules = ScanRules()) throws -> Discovery { 106 public static func scan(_ root: URL, rules: ScanRules = ScanRules()) throws -> Discovery {
91 let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey] 107 let keys = Self.keys
92 guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else { 108 guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else {
93 return Discovery() 109 return Discovery()
94 } 110 }
@@ -101,6 +117,8 @@ public enum WorkspaceScanner {
101 continue 117 continue
102 } 118 }
103 switch classify(name, rules: rules) { 119 switch classify(name, rules: rules) {
120 case .file where isPending(values):
121 discovery.placeholders.append(url.standardizedFileURL)
104 case .file(let kind): 122 case .file(let kind):
105 discovery.files.append(DiscoveredFile( 123 discovery.files.append(DiscoveredFile(
106 url: url.standardizedFileURL, 124 url: url.standardizedFileURL,
Sources/OrgWorkspace/FSEventsWatcher.swift −6
@@ -2,12 +2,6 @@
2import CoreServices 2import CoreServices
3import Foundation 3import Foundation
4 4
5public enum WatchEvent: Sendable, Equatable {
6 case changed(URL)
7 /// Events were dropped or coalesced for this folder, or a root moved: rescan it.
8 case rescan(URL)
9}
10
11/// FSEvents with file-level events. Delivers batches on a private queue. 5/// FSEvents with file-level events. Delivers batches on a private queue.
12public final class FSEventsWatcher: @unchecked Sendable { 6public final class FSEventsWatcher: @unchecked Sendable {
13 private let paths: [String] 7 private let paths: [String]
Sources/OrgWorkspace/FolderPresenter.swift added +66
@@ -0,0 +1,66 @@
1import Foundation
2
3public enum WatchEvent: Sendable, Equatable {
4 case changed(URL)
5 /// Events were dropped or coalesced for this folder, or a root moved: rescan it.
6 case rescan(URL)
7}
8
9/// Watches a folder as an `NSFilePresenter`: told of coordinated writes inside it, which is
10/// how iCloud and File Provider syncing (Möbius Sync for Syncthing) change files on iOS, where
11/// there are no FSEvents. Events arrive in batches after `latency`.
12public final class FolderPresenter: NSObject, NSFilePresenter, @unchecked Sendable {
13 public let presentedItemURL: URL?
14 public let presentedItemOperationQueue = OperationQueue()
15 private let latency: TimeInterval
16 private let handler: @Sendable ([WatchEvent]) -> Void
17 private let lock = NSLock()
18 private var pending: [WatchEvent] = []
19 private var scheduled = false
20
21 public init(root: URL, latency: TimeInterval = 0.3, handler: @escaping @Sendable ([WatchEvent]) -> Void) {
22 presentedItemURL = root
23 self.latency = latency
24 self.handler = handler
25 presentedItemOperationQueue.maxConcurrentOperationCount = 1
26 super.init()
27 }
28
29 public func start() { NSFileCoordinator.addFilePresenter(self) }
30
31 public func stop() { NSFileCoordinator.removeFilePresenter(self) }
32
33 private func add(_ event: WatchEvent) {
34 let schedule: Bool = lock.withLock {
35 if !pending.contains(event) { pending.append(event) }
36 defer { scheduled = true }
37 return !scheduled
38 }
39 guard schedule else { return }
40 DispatchQueue.global().asyncAfter(deadline: .now() + latency) { [self] in
41 let batch: [WatchEvent] = lock.withLock {
42 defer {
43 pending = []
44 scheduled = false
45 }
46 return pending
47 }
48 handler(batch)
49 }
50 }
51
52 public func presentedSubitemDidChange(at url: URL) { add(.changed(url)) }
53 public func presentedSubitemDidAppear(at url: URL) { add(.changed(url)) }
54 public func presentedSubitem(at oldURL: URL, didMoveTo newURL: URL) {
55 add(.changed(oldURL))
56 add(.changed(newURL))
57 }
58 public func accommodatePresentedSubitemDeletion(at url: URL, completionHandler: @escaping @Sendable ((any Error)?) -> Void) {
59 add(.changed(url))
60 completionHandler(nil)
61 }
62 public func presentedItemDidChange() {
63 if let presentedItemURL { add(.rescan(presentedItemURL)) }
64 }
65 public func presentedItemDidMove(to newURL: URL) { add(.rescan(newURL)) }
66}
Sources/OrgWorkspace/Reconciler.swift +14 −3
@@ -30,7 +30,9 @@ public struct Reconciler: Sendable {
30 for placeholder in discovery.placeholders { 30 for placeholder in discovery.placeholders {
31 try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder) 31 try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder)
32 } 32 }
33 var report = try reconcile(root: root, found: discovery.files, known: index.fileStates(root: root.path)) 33 // Files iCloud evicted keep their rows until they're back.
34 let pending = Set(discovery.placeholders.map(\.path))
35 var report = try reconcile(root: root, found: discovery.files, known: index.fileStates(root: root.path).filter { !pending.contains($0.key) })
34 report.placeholders = discovery.placeholders 36 report.placeholders = discovery.placeholders
35 return report 37 return report
36 } 38 }
@@ -40,19 +42,28 @@ public struct Reconciler: Sendable {
40 let wanted = Set(paths.map(\.standardizedFileURL.path)) 42 let wanted = Set(paths.map(\.standardizedFileURL.path))
41 let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) } 43 let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) }
42 var found: [DiscoveredFile] = [] 44 var found: [DiscoveredFile] = []
45 var placeholders: [URL] = []
43 let rootComponents = root.standardizedFileURL.pathComponents.count 46 let rootComponents = root.standardizedFileURL.pathComponents.count
44 for url in paths.map(\.standardizedFileURL) { 47 for url in paths.map(\.standardizedFileURL) {
45 // Files in ignored folders (`.git`) aren't part of the workspace, as in `scan`. 48 // Files in ignored folders (`.git`) aren't part of the workspace, as in `scan`.
46 if url.pathComponents.dropFirst(rootComponents).dropLast().contains(where: rules.skipsFolder) { continue } 49 if url.pathComponents.dropFirst(rootComponents).dropLast().contains(where: rules.skipsFolder) { continue }
47 guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent, rules: rules), 50 guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent, rules: rules),
48 let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isDirectoryKey]), 51 let values = try? url.resourceValues(forKeys: Set(WorkspaceScanner.keys)),
49 values.isDirectory != true else { continue } 52 values.isDirectory != true else { continue }
53 if WorkspaceScanner.isPending(values) {
54 try? FileManager.default.startDownloadingUbiquitousItem(at: url)
55 placeholders.append(url)
56 continue
57 }
50 found.append(DiscoveredFile( 58 found.append(DiscoveredFile(
51 url: url, kind: kind, size: values.fileSize ?? 0, 59 url: url, kind: kind, size: values.fileSize ?? 0,
52 mtime: values.contentModificationDate?.timeIntervalSince1970 ?? 0 60 mtime: values.contentModificationDate?.timeIntervalSince1970 ?? 0
53 )) 61 ))
54 } 62 }
55 return try reconcile(root: root, found: found, known: known) 63 let pending = Set(placeholders.map(\.path))
64 var report = try reconcile(root: root, found: found, known: known.filter { !pending.contains($0.key) })
65 report.placeholders = placeholders
66 return report
56 } 67 }
57 68
58 private func reconcile(root: URL, found: [DiscoveredFile], known: [String: FileState]) throws -> ReconcileReport { 69 private func reconcile(root: URL, found: [DiscoveredFile], known: [String: FileState]) throws -> ReconcileReport {
Sources/Orgstar/ContentView.swift +3 −1
@@ -683,7 +683,8 @@ struct Sidebar: View {
683 Section(isExpanded: expanded(root)) { 683 Section(isExpanded: expanded(root)) {
684 OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in 684 OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in
685 Label(node.name, systemImage: icon(node)) 685 Label(node.name, systemImage: icon(node))
686 .foregroundStyle(node.file?.kind == .conflict ? .orange : theme.color("sidebar-foreground") ?? .primary) 686 .foregroundStyle(node.file?.kind == .conflict ? .orange : node.isDownloading ? .secondary : theme.color("sidebar-foreground") ?? .primary)
687 .help(node.isDownloading ? "Downloading from iCloud" : "")
687 .tag(node.file?.path) 688 .tag(node.file?.path)
688 .contextMenu { fileMenu(node) } 689 .contextMenu { fileMenu(node) }
689 } 690 }
@@ -745,6 +746,7 @@ struct Sidebar: View {
745 } 746 }
746 747
747 private func icon(_ node: FileTreeNode) -> String { 748 private func icon(_ node: FileTreeNode) -> String {
749 if node.isDownloading { return "icloud.and.arrow.down" }
748 guard let file = node.file else { return "folder" } 750 guard let file = node.file else { return "folder" }
749 switch file.kind { 751 switch file.kind {
750 case .org: return "doc.text" 752 case .org: return "doc.text"
Sources/Orgstar/SidebarFiles.swift +1 −1
@@ -12,7 +12,7 @@ enum FileOperation: Equatable {
12extension Sidebar { 12extension Sidebar {
13 @ViewBuilder 13 @ViewBuilder
14 func fileMenu(_ node: FileTreeNode) -> some View { 14 func fileMenu(_ node: FileTreeNode) -> some View {
15 let folder = node.file == nil ? node.path : (node.path as NSString).deletingLastPathComponent 15 let folder = node.file == nil && !node.isDownloading ? node.path : (node.path as NSString).deletingLastPathComponent
16 Button("New File…") { ask(.create(folder: folder)) } 16 Button("New File…") { ask(.create(folder: folder)) }
17 Divider() 17 Divider()
18 Button("Rename…") { ask(.rename(path: node.path)) } 18 Button("Rename…") { ask(.rename(path: node.path)) }
Tests/OrgAppTests/AppTests.swift +8
@@ -74,6 +74,14 @@ struct WorkspaceModelTests {
74 #expect(reopened.search("apples").isEmpty) 74 #expect(reopened.search("apples").isEmpty)
75 } 75 }
76 76
77 @Test func listsDownloadsInTheTree() {
78 let files = [FileEntry(path: "/r/a.org", root: "/r", kind: .org)]
79 let tree = FileTreeNode.build(root: "/r", files: files, downloading: ["/r/b.org", "/r/sub/c.org"])
80 #expect(tree.children?.map(\.name) == ["sub", "a.org", "b.org"])
81 #expect(tree.children?[2].isDownloading == true && tree.children?[2].file == nil)
82 #expect(tree.children?[0].children?.map(\.name) == ["c.org"])
83 }
84
77 @Test func findsBacklinks() async throws { 85 @Test func findsBacklinks() async throws {
78 let notes = try Folder() 86 let notes = try Folder()
79 try notes.write("a.org", "* Sink\n:PROPERTIES:\n:ID: s-1\n:CUSTOM_ID: sink\n:END:\nsee [[*Taps]]\n* Taps\n") 87 try notes.write("a.org", "* Sink\n:PROPERTIES:\n:ID: s-1\n:CUSTOM_ID: sink\n:END:\nsee [[*Taps]]\n* Taps\n")
Tests/OrgWorkspaceTests/ICloudTests.swift added +31
@@ -0,0 +1,31 @@
1import Foundation
2import Testing
3@testable import OrgWorkspace
4
5struct ICloudTests {
6 @Test func onlyFilesNotOnTheDeviceWait() {
7 #expect(WorkspaceScanner.isPending(isUbiquitous: true, status: .notDownloaded))
8 #expect(!WorkspaceScanner.isPending(isUbiquitous: true, status: .current))
9 #expect(!WorkspaceScanner.isPending(isUbiquitous: true, status: .downloaded))
10 #expect(!WorkspaceScanner.isPending(isUbiquitous: nil, status: nil))
11 #expect(!WorkspaceScanner.isPending(isUbiquitous: false, status: .notDownloaded))
12 }
13
14 @Test func folderPresenterHearsCoordinatedWrites() throws {
15 let root = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-presenter-\(UUID().uuidString)").resolvingSymlinksInPath()
16 try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
17 defer { try? FileManager.default.removeItem(at: root) }
18 let file = root.appendingPathComponent("a.org")
19 try "* A\n".write(to: file, atomically: true, encoding: .utf8)
20 let received = Received()
21 let presenter = FolderPresenter(root: root, latency: 0.05) { events in received.add(events) }
22 presenter.start()
23 defer { presenter.stop() }
24 var error: NSError?
25 NSFileCoordinator(filePresenter: nil).coordinate(writingItemAt: file, options: .forReplacing, error: &error) { url in
26 try? "* B\n".write(to: url, atomically: true, encoding: .utf8)
27 }
28 #expect(received.wait(for: "a.org", timeout: 3))
29 }
30}
31
docs/design.md +2 −2
@@ -221,7 +221,7 @@ A workspace is one or more root folders. The files are the only source of truth;
221- Roots are identified by bookmark, not path, so a moved root is followed or reported. 221- Roots are identified by bookmark, not path, so a moved root is followed or reported.
222- iOS (later): `NSFilePresenter` plus `NSMetadataQuery` for iCloud. 222- iOS (later): `NSFilePresenter` plus `NSMetadataQuery` for iCloud.
223- Ignore Syncthing temp files (`.syncthing.*.tmp`). Show Syncthing conflict files (`*.sync-conflict-*`) with a diff against the original. 223- Ignore Syncthing temp files (`.syncthing.*.tmp`). Show Syncthing conflict files (`*.sync-conflict-*`) with a diff against the original.
224- iCloud placeholders: a file not yet downloaded is requested with `startDownloadingUbiquitousItem` and shown as loading. 224- iCloud placeholders: a file not yet downloaded (a dataless file since macOS 14, or `.name.icloud`) is requested with `startDownloadingUbiquitousItem`, shown as downloading, and indexed when it arrives. An evicted file keeps its index rows.
225 225
226**Saving** 226**Saving**
227 227
@@ -351,7 +351,7 @@ Status, 2026-10-05: phases 1–6 are built for macOS. Owed from phase 1: the ite
351- [x] Document session: original bytes, revisions, merge base, per-view state 351- [x] Document session: original bytes, revisions, merge base, per-view state
352- [x] Save path with merge, recovery copies and fault-injection tests (used by the editing spike, not exposed in the viewer) 352- [x] Save path with merge, recovery copies and fault-injection tests (used by the editing spike, not exposed in the viewer)
353- [ ] Editable TextKit 2 spike: folding and org-indent display with correct caret movement, selection, IME composition, VoiceOver and copy/paste across folded text (done but the VoiceOver check) 353- [ ] Editable TextKit 2 spike: folding and org-indent display with correct caret movement, selection, IME composition, VoiceOver and copy/paste across folded text (done but the VoiceOver check)
354- [ ] Workspace: folders, bookmarks, discovery/agenda/link scopes, FSEvents with reconciliation scans, Syncthing temp/conflict handling, iCloud placeholders (done but placeholders, which are only asked to download) 354- [x] Workspace: folders, bookmarks, discovery/agenda/link scopes, FSEvents with reconciliation scans, Syncthing temp/conflict handling, iCloud placeholders
355- [x] Index: schema, settings version, dirty-buffer overlay, FTS5 search 355- [x] Index: schema, settings version, dirty-buffer overlay, FTS5 search
356- [x] Mac app (read-only): sidebar of folders and files, outline of the current file, quick open (`⌘P`), full-text search 356- [x] Mac app (read-only): sidebar of folders and files, outline of the current file, quick open (`⌘P`), full-text search
357- [x] Rendering over the text: heading styles, TODO/priority/tag styling, links, emphasis, tables, src blocks highlighted for org, emacs-lisp, sh and python only (the rest move to phase 2) 357- [x] Rendering over the text: heading styles, TODO/priority/tag styling, links, emphasis, tables, src blocks highlighted for org, emacs-lisp, sh and python only (the rest move to phase 2)