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)
12import Foundation
23import OrgCore
34
@@ -95,3 +96,4 @@ public enum BabelRunner {
9596 return BabelOutput(stdout: output, resultFile: result, stderr: errors, status: process.terminationStatus)
9697 }
9798}
99#endif
Sources/OrgApp/BabelSessions.swift +2
@@ -1,3 +1,4 @@
1#if os(macOS)
12import Foundation
23import OrgCore
34
@@ -122,3 +123,4 @@ public final class BabelSessions: @unchecked Sendable {
122123 }
123124 }
124125}
126#endif
Sources/OrgApp/CaptureModel.swift +1 −1
@@ -113,7 +113,7 @@ extension DocumentSession {
113113 public static func resolve(_ file: String, root: String?) -> String {
114114 if file.hasPrefix("/") { return file }
115115 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)
117117 }
118118
119119 /// `%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 {
649649 /// Recalculates a table in Emacs from a snapshot of the buffer, then replaces the table
650650 /// with Emacs's text if it hasn't changed meanwhile. Returns a message for the echo area.
651651 public func recalculateInEmacs(table: Range<Int>, point: Int, form: String) async -> String {
652 #if os(macOS)
652653 guard let entry = current else { return "No file is open." }
653654 let path = entry.path
654655 let snapshot = entry.buffer.document.text as NSString
@@ -671,11 +672,15 @@ public final class DocumentSession {
671672 } catch {
672673 return "\(error)"
673674 }
675 #else
676 return "Tables that need Emacs aren't recalculated on iOS."
677 #endif
674678 }
675679
676680 /// Runs a src block and writes its result under it, unless the block changed while it
677681 /// ran. Returns a message for the echo area.
678682 public func runBabel(_ job: BabelJob) async -> String {
683 #if os(macOS)
679684 guard let entry = current else { return "No file is open." }
680685 let directory = URL(fileURLWithPath: entry.path).deletingLastPathComponent()
681686 do {
@@ -708,6 +713,9 @@ public final class DocumentSession {
708713 } catch {
709714 return "\(error)"
710715 }
716 #else
717 return "Source blocks don't run on iOS."
718 #endif
711719 }
712720
713721 /// The block running now, to cancel.
Sources/OrgApp/EmacsImport.swift +2 −2
@@ -61,7 +61,7 @@ public struct EmacsImport: Sendable {
6161
6262 /// Where an Emacs configuration usually is, first found first.
6363 public static func defaultLocations() -> [URL] {
64 let home = FileManager.default.homeDirectoryForCurrentUser
64 let home = URL(fileURLWithPath: NSHomeDirectory())
6565 let environment = ProcessInfo.processInfo.environment
6666 var candidates: [URL] = []
6767 if let doom = environment["DOOMDIR"] { candidates.append(URL(fileURLWithPath: doom)) }
@@ -77,7 +77,7 @@ public struct EmacsImport: Sendable {
7777
7878 /// Doom's org module and core defaults, if Doom is installed.
7979 static func doomDefaultFiles() -> [URL] {
80 let home = FileManager.default.homeDirectoryForCurrentUser
80 let home = URL(fileURLWithPath: NSHomeDirectory())
8181 let environment = ProcessInfo.processInfo.environment
8282 let installs = ([environment["EMACSDIR"]].compactMap { $0 }.map { URL(fileURLWithPath: $0) })
8383 + [".config/emacs", ".emacs.d"].map { home.appendingPathComponent($0) }
Sources/OrgApp/EmacsRunner.swift +2
@@ -1,3 +1,4 @@
1#if os(macOS)
12import Foundation
23
34/// Runs `emacs --batch` for what the native code hands over, such as tables outside the
@@ -131,3 +132,4 @@ public enum EmacsRunner {
131132 return written
132133 }
133134}
135#endif
Sources/OrgApp/FileTree.swift +20 −7
@@ -23,16 +23,28 @@ public struct FileTreeNode: Sendable, Hashable, Identifiable {
2323 public let file: FileEntry?
2424 /// Nil for files, so a SwiftUI `OutlineGroup` shows no disclosure triangle.
2525 public let children: [FileTreeNode]?
26 /// An iCloud file on its way; it opens once it's indexed.
27 public var isDownloading = false
2628
2729 public var id: String { path }
2830
2931 /// 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 {
3133 final class Folder {
3234 var folders: [String: Folder] = [:]
3335 var files: [FileEntry] = []
36 var downloading: [String] = []
3437 }
3538 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 }
3648 for file in files where file.root == root {
3749 var folder = top
3850 for component in file.relativePath.split(separator: "/").dropLast() {
@@ -46,9 +58,11 @@ public struct FileTreeNode: Sendable, Hashable, Identifiable {
4658 let folders = folder.folders.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }.map {
4759 node(folder.folders[$0]!, name: $0, path: path + "/" + $0)
4860 }
49 let files = folder.files.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }.map {
61 let files = (folder.files.map {
5062 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 }
5266 return FileTreeNode(name: name, path: path, file: nil, children: folders + files)
5367 }
5468 return node(top, name: (root as NSString).lastPathComponent, path: root)
@@ -78,9 +92,8 @@ public enum FuzzyMatch {
7892
7993 /// Entries ranked by `score` against their relative path, best first.
8094 public static func rank(_ query: String, _ files: [FileEntry], limit: Int = 50) -> [FileEntry] {
81 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 }
83 .prefix(limit)
84 .map(\.0)
95 let scored: [(file: FileEntry, score: Int)] = files.compactMap { file in score(query, file.relativePath).map { (file, $0) } }
96 let ranked = scored.sorted { a, b in a.score != b.score ? a.score > b.score : a.file.relativePath < b.file.relativePath }
97 return ranked.prefix(limit).map(\.file)
8598 }
8699}
Sources/OrgApp/LinkFollow.swift +1 −1
@@ -53,7 +53,7 @@ extension DocumentSession {
5353 public static func resolve(_ path: String, from current: String?) -> String {
5454 let expanded = (path as NSString).expandingTildeInPath
5555 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()
5757 return ((folder as NSString).appendingPathComponent(expanded) as NSString).standardizingPath
5858 }
5959
Sources/OrgApp/UserConfig.swift +1 −1
@@ -16,7 +16,7 @@ public enum UserConfig {
1616 if let xdg = environment["XDG_CONFIG_HOME"], !xdg.isEmpty {
1717 return URL(fileURLWithPath: xdg).appendingPathComponent("orgstar")
1818 }
19 return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/orgstar")
19 return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".config/orgstar")
2020 }
2121
2222 /// 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
1212public final class WorkspaceModel {
1313 public private(set) var roots: [WorkspaceRoot] = []
1414 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> = []
1517 public private(set) var isIndexing = false
1618 /// The last error worth showing, such as a folder that can no longer be found.
1719 public var lastError: String?
@@ -24,6 +26,8 @@ public final class WorkspaceModel {
2426 /// Resolved root folders, keyed by their resolved path, which is how the index stores them.
2527 private var resolvedRoots: [String: URL] = [:]
2628 @ObservationIgnored private var watcher: AnyObject?
29 /// Watchers of the roots where there are no FSEvents.
30 @ObservationIgnored private var presenters: [FolderPresenter] = []
2731 /// Set once watching starts, so adding or removing a root watches the new set.
2832 @ObservationIgnored private var watching = false
2933 private var changeVersion = 0
@@ -81,9 +85,7 @@ public final class WorkspaceModel {
8185 }
8286
8387 private func rootsChanged() {
84 #if os(macOS)
8588 if watching { startWatching() }
86 #endif
8789 }
8890
8991 private func saveRoots() throws {
@@ -128,14 +130,23 @@ public final class WorkspaceModel {
128130 isIndexing = true
129131 defer { isIndexing = false }
130132 let reconciler = Reconciler(index: index, settings: settings, rules: scanRules)
133 var pending: Set<String> = []
131134 for url in resolvedRoots.values {
132135 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))
134138 } catch {
135139 lastError = "Indexing \(url.path) failed: \(error.localizedDescription)"
136140 }
137141 }
138142 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 }
139150 }
140151
141152 private func reloadFiles() throws {
@@ -149,15 +160,19 @@ public final class WorkspaceModel {
149160 /// Reconciles the paths a batch of file events named, or whole roots when events were lost.
150161 public func filesChanged(_ paths: [URL], rescan roots: [URL]) async {
151162 let reconciler = Reconciler(index: index, settings: settings, rules: scanRules)
163 var pending = downloading
152164 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) ?? [])
154167 }
155168 for (rootPath, rootURL) in resolvedRoots where !roots.contains(rootURL) {
156169 let inRoot = paths.filter { $0.path.hasPrefix(rootPath + "/") }
157170 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) ?? [])
159173 }
160174 try? reloadFiles()
175 setDownloading(pending)
161176 for path in paths {
162177 changeVersion += 1
163178 changedFile = (path.standardizedFileURL.path, changeVersion)
@@ -190,6 +205,30 @@ public final class WorkspaceModel {
190205 fsWatcher.start()
191206 watcher = fsWatcher
192207 }
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 }
193232 #endif
194233
195234 /// The root an event path belongs to.
@@ -271,7 +310,7 @@ public final class WorkspaceModel {
271310 // MARK: - Queries
272311
273312 public func tree(forRoot path: String) -> FileTreeNode {
274 FileTreeNode.build(root: path, files: files)
313 FileTreeNode.build(root: path, files: files, downloading: downloading.sorted())
275314 }
276315
277316 /// 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 {
8686 return .file(.other)
8787 }
8888
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
89105 /// Every file under `root`, skipping the folders `rules` ignores.
90106 public static func scan(_ root: URL, rules: ScanRules = ScanRules()) throws -> Discovery {
91 let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
107 let keys = Self.keys
92108 guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else {
93109 return Discovery()
94110 }
@@ -101,6 +117,8 @@ public enum WorkspaceScanner {
101117 continue
102118 }
103119 switch classify(name, rules: rules) {
120 case .file where isPending(values):
121 discovery.placeholders.append(url.standardizedFileURL)
104122 case .file(let kind):
105123 discovery.files.append(DiscoveredFile(
106124 url: url.standardizedFileURL,
Sources/OrgWorkspace/FSEventsWatcher.swift −6
@@ -2,12 +2,6 @@
22import CoreServices
33import Foundation
44
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
115/// FSEvents with file-level events. Delivers batches on a private queue.
126public final class FSEventsWatcher: @unchecked Sendable {
137 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 {
3030 for placeholder in discovery.placeholders {
3131 try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder)
3232 }
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) })
3436 report.placeholders = discovery.placeholders
3537 return report
3638 }
@@ -40,19 +42,28 @@ public struct Reconciler: Sendable {
4042 let wanted = Set(paths.map(\.standardizedFileURL.path))
4143 let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) }
4244 var found: [DiscoveredFile] = []
45 var placeholders: [URL] = []
4346 let rootComponents = root.standardizedFileURL.pathComponents.count
4447 for url in paths.map(\.standardizedFileURL) {
4548 // Files in ignored folders (`.git`) aren't part of the workspace, as in `scan`.
4649 if url.pathComponents.dropFirst(rootComponents).dropLast().contains(where: rules.skipsFolder) { continue }
4750 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)),
4952 values.isDirectory != true else { continue }
53 if WorkspaceScanner.isPending(values) {
54 try? FileManager.default.startDownloadingUbiquitousItem(at: url)
55 placeholders.append(url)
56 continue
57 }
5058 found.append(DiscoveredFile(
5159 url: url, kind: kind, size: values.fileSize ?? 0,
5260 mtime: values.contentModificationDate?.timeIntervalSince1970 ?? 0
5361 ))
5462 }
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
5667 }
5768
5869 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 {
683683 Section(isExpanded: expanded(root)) {
684684 OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in
685685 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" : "")
687688 .tag(node.file?.path)
688689 .contextMenu { fileMenu(node) }
689690 }
@@ -745,6 +746,7 @@ struct Sidebar: View {
745746 }
746747
747748 private func icon(_ node: FileTreeNode) -> String {
749 if node.isDownloading { return "icloud.and.arrow.down" }
748750 guard let file = node.file else { return "folder" }
749751 switch file.kind {
750752 case .org: return "doc.text"
Sources/Orgstar/SidebarFiles.swift +1 −1
@@ -12,7 +12,7 @@ enum FileOperation: Equatable {
1212extension Sidebar {
1313 @ViewBuilder
1414 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
1616 Button("New File…") { ask(.create(folder: folder)) }
1717 Divider()
1818 Button("Rename…") { ask(.rename(path: node.path)) }
Tests/OrgAppTests/AppTests.swift +8
@@ -74,6 +74,14 @@ struct WorkspaceModelTests {
7474 #expect(reopened.search("apples").isEmpty)
7575 }
7676
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
7785 @Test func findsBacklinks() async throws {
7886 let notes = try Folder()
7987 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;
221221- Roots are identified by bookmark, not path, so a moved root is followed or reported.
222222- iOS (later): `NSFilePresenter` plus `NSMetadataQuery` for iCloud.
223223- 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.
225225
226226**Saving**
227227
@@ -351,7 +351,7 @@ Status, 2026-10-05: phases 1–6 are built for macOS. Owed from phase 1: the ite
351351- [x] Document session: original bytes, revisions, merge base, per-view state
352352- [x] Save path with merge, recovery copies and fault-injection tests (used by the editing spike, not exposed in the viewer)
353353- [ ] 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
355355- [x] Index: schema, settings version, dirty-buffer overlay, FTS5 search
356356- [x] Mac app (read-only): sidebar of folders and files, outline of the current file, quick open (`⌘P`), full-text search
357357- [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)