iCloud downloads, folder presenters, shared modules build for iOS !108

merged merged by cmc on 2026-10-06 22:29 UTC · krz/orgstar:icloud into main

19 files changed, +228 −32

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)