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)
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)