iCloud downloads, folder presenters, shared modules build for iOS !108
19 files changed, +228 −32
Layout: unified · split
Sources/OrgApp/BabelRunner.swift +2
| @@ -1,3 +1,4 @@ | ||
| 1 | #if os(macOS) | |
| 1 | 2 | import Foundation |
| 2 | 3 | import OrgCore |
| 3 | 4 | |
| @@ -95,3 +96,4 @@ public enum BabelRunner { | ||
| 95 | 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) | |
| 1 | 2 | import Foundation |
| 2 | 3 | import 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 | 113 | public static func resolve(_ file: String, root: String?) -> String { |
| 114 | 114 | if file.hasPrefix("/") { return file } |
| 115 | 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 | 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 | 649 | /// Recalculates a table in Emacs from a snapshot of the buffer, then replaces the table |
| 650 | 650 | /// with Emacs's text if it hasn't changed meanwhile. Returns a message for the echo area. |
| 651 | 651 | public func recalculateInEmacs(table: Range<Int>, point: Int, form: String) async -> String { |
| 652 | #if os(macOS) | |
| 652 | 653 | guard let entry = current else { return "No file is open." } |
| 653 | 654 | let path = entry.path |
| 654 | 655 | let snapshot = entry.buffer.document.text as NSString |
| @@ -671,11 +672,15 @@ public final class DocumentSession { | ||
| 671 | 672 | } catch { |
| 672 | 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 | 680 | /// Runs a src block and writes its result under it, unless the block changed while it |
| 677 | 681 | /// ran. Returns a message for the echo area. |
| 678 | 682 | public func runBabel(_ job: BabelJob) async -> String { |
| 683 | #if os(macOS) | |
| 679 | 684 | guard let entry = current else { return "No file is open." } |
| 680 | 685 | let directory = URL(fileURLWithPath: entry.path).deletingLastPathComponent() |
| 681 | 686 | do { |
| @@ -708,6 +713,9 @@ public final class DocumentSession { | ||
| 708 | 713 | } catch { |
| 709 | 714 | return "\(error)" |
| 710 | 715 | } |
| 716 | #else | |
| 717 | return "Source blocks don't run on iOS." | |
| 718 | #endif | |
| 711 | 719 | } |
| 712 | 720 | |
| 713 | 721 | /// The block running now, to cancel. |
Sources/OrgApp/EmacsImport.swift +2 −2
| @@ -61,7 +61,7 @@ public struct EmacsImport: Sendable { | ||
| 61 | 61 | |
| 62 | 62 | /// Where an Emacs configuration usually is, first found first. |
| 63 | 63 | public static func defaultLocations() -> [URL] { |
| 64 | let home = FileManager.default.homeDirectoryForCurrentUser | |
| 64 | let home = URL(fileURLWithPath: NSHomeDirectory()) | |
| 65 | 65 | let environment = ProcessInfo.processInfo.environment |
| 66 | 66 | var candidates: [URL] = [] |
| 67 | 67 | if let doom = environment["DOOMDIR"] { candidates.append(URL(fileURLWithPath: doom)) } |
| @@ -77,7 +77,7 @@ public struct EmacsImport: Sendable { | ||
| 77 | 77 | |
| 78 | 78 | /// Doom's org module and core defaults, if Doom is installed. |
| 79 | 79 | static func doomDefaultFiles() -> [URL] { |
| 80 | let home = FileManager.default.homeDirectoryForCurrentUser | |
| 80 | let home = URL(fileURLWithPath: NSHomeDirectory()) | |
| 81 | 81 | let environment = ProcessInfo.processInfo.environment |
| 82 | 82 | let installs = ([environment["EMACSDIR"]].compactMap { $0 }.map { URL(fileURLWithPath: $0) }) |
| 83 | 83 | + [".config/emacs", ".emacs.d"].map { home.appendingPathComponent($0) } |
Sources/OrgApp/EmacsRunner.swift +2
| @@ -1,3 +1,4 @@ | ||
| 1 | #if os(macOS) | |
| 1 | 2 | import Foundation |
| 2 | 3 | |
| 3 | 4 | /// Runs `emacs --batch` for what the native code hands over, such as tables outside the |
| @@ -131,3 +132,4 @@ public enum EmacsRunner { | ||
| 131 | 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 | 23 | public let file: FileEntry? |
| 24 | 24 | /// Nil for files, so a SwiftUI `OutlineGroup` shows no disclosure triangle. |
| 25 | 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 | 29 | public var id: String { path } |
| 28 | 30 | |
| 29 | 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 | 33 | final class Folder { |
| 32 | 34 | var folders: [String: Folder] = [:] |
| 33 | 35 | var files: [FileEntry] = [] |
| 36 | var downloading: [String] = [] | |
| 34 | 37 | } |
| 35 | 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 | 48 | for file in files where file.root == root { |
| 37 | 49 | var folder = top |
| 38 | 50 | for component in file.relativePath.split(separator: "/").dropLast() { |
| @@ -46,9 +58,11 @@ public struct FileTreeNode: Sendable, Hashable, Identifiable { | ||
| 46 | 58 | let folders = folder.folders.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }.map { |
| 47 | 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 | 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 | 66 | return FileTreeNode(name: name, path: path, file: nil, children: folders + files) |
| 53 | 67 | } |
| 54 | 68 | return node(top, name: (root as NSString).lastPathComponent, path: root) |
| @@ -78,9 +92,8 @@ public enum FuzzyMatch { | ||
| 78 | 92 | |
| 79 | 93 | /// Entries ranked by `score` against their relative path, best first. |
| 80 | 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) } } | |
| 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) | |
| 85 | 98 | } |
| 86 | 99 | } |
Sources/OrgApp/LinkFollow.swift +1 −1
| @@ -53,7 +53,7 @@ extension DocumentSession { | ||
| 53 | 53 | public static func resolve(_ path: String, from current: String?) -> String { |
| 54 | 54 | let expanded = (path as NSString).expandingTildeInPath |
| 55 | 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 | 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 | 16 | if let xdg = environment["XDG_CONFIG_HOME"], !xdg.isEmpty { |
| 17 | 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 | 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 | ||
| 12 | 12 | public final class WorkspaceModel { |
| 13 | 13 | public private(set) var roots: [WorkspaceRoot] = [] |
| 14 | 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 | 17 | public private(set) var isIndexing = false |
| 16 | 18 | /// The last error worth showing, such as a folder that can no longer be found. |
| 17 | 19 | public var lastError: String? |
| @@ -24,6 +26,8 @@ public final class WorkspaceModel { | ||
| 24 | 26 | /// Resolved root folders, keyed by their resolved path, which is how the index stores them. |
| 25 | 27 | private var resolvedRoots: [String: URL] = [:] |
| 26 | 28 | @ObservationIgnored private var watcher: AnyObject? |
| 29 | /// Watchers of the roots where there are no FSEvents. | |
| 30 | @ObservationIgnored private var presenters: [FolderPresenter] = [] | |
| 27 | 31 | /// Set once watching starts, so adding or removing a root watches the new set. |
| 28 | 32 | @ObservationIgnored private var watching = false |
| 29 | 33 | private var changeVersion = 0 |
| @@ -81,9 +85,7 @@ public final class WorkspaceModel { | ||
| 81 | 85 | } |
| 82 | 86 | |
| 83 | 87 | private func rootsChanged() { |
| 84 | #if os(macOS) | |
| 85 | 88 | if watching { startWatching() } |
| 86 | #endif | |
| 87 | 89 | } |
| 88 | 90 | |
| 89 | 91 | private func saveRoots() throws { |
| @@ -128,14 +130,23 @@ public final class WorkspaceModel { | ||
| 128 | 130 | isIndexing = true |
| 129 | 131 | defer { isIndexing = false } |
| 130 | 132 | let reconciler = Reconciler(index: index, settings: settings, rules: scanRules) |
| 133 | var pending: Set<String> = [] | |
| 131 | 134 | for url in resolvedRoots.values { |
| 132 | 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 | 138 | } catch { |
| 135 | 139 | lastError = "Indexing \(url.path) failed: \(error.localizedDescription)" |
| 136 | 140 | } |
| 137 | 141 | } |
| 138 | 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 | 152 | private func reloadFiles() throws { |
| @@ -149,15 +160,19 @@ public final class WorkspaceModel { | ||
| 149 | 160 | /// Reconciles the paths a batch of file events named, or whole roots when events were lost. |
| 150 | 161 | public func filesChanged(_ paths: [URL], rescan roots: [URL]) async { |
| 151 | 162 | let reconciler = Reconciler(index: index, settings: settings, rules: scanRules) |
| 163 | var pending = downloading | |
| 152 | 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 | 168 | for (rootPath, rootURL) in resolvedRoots where !roots.contains(rootURL) { |
| 156 | 169 | let inRoot = paths.filter { $0.path.hasPrefix(rootPath + "/") } |
| 157 | 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 | 174 | try? reloadFiles() |
| 175 | setDownloading(pending) | |
| 161 | 176 | for path in paths { |
| 162 | 177 | changeVersion += 1 |
| 163 | 178 | changedFile = (path.standardizedFileURL.path, changeVersion) |
| @@ -190,6 +205,30 @@ public final class WorkspaceModel { | ||
| 190 | 205 | fsWatcher.start() |
| 191 | 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 | 232 | #endif |
| 194 | 233 | |
| 195 | 234 | /// The root an event path belongs to. |
| @@ -271,7 +310,7 @@ public final class WorkspaceModel { | ||
| 271 | 310 | // MARK: - Queries |
| 272 | 311 | |
| 273 | 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 | 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 | 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 | 105 | /// Every file under `root`, skipping the folders `rules` ignores. |
| 90 | 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 | 108 | guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else { |
| 93 | 109 | return Discovery() |
| 94 | 110 | } |
| @@ -101,6 +117,8 @@ public enum WorkspaceScanner { | ||
| 101 | 117 | continue |
| 102 | 118 | } |
| 103 | 119 | switch classify(name, rules: rules) { |
| 120 | case .file where isPending(values): | |
| 121 | discovery.placeholders.append(url.standardizedFileURL) | |
| 104 | 122 | case .file(let kind): |
| 105 | 123 | discovery.files.append(DiscoveredFile( |
| 106 | 124 | url: url.standardizedFileURL, |
Sources/OrgWorkspace/FSEventsWatcher.swift −6
| @@ -2,12 +2,6 @@ | ||
| 2 | 2 | import CoreServices |
| 3 | 3 | import Foundation |
| 4 | 4 | |
| 5 | public 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 | 5 | /// FSEvents with file-level events. Delivers batches on a private queue. |
| 12 | 6 | public final class FSEventsWatcher: @unchecked Sendable { |
| 13 | 7 | private let paths: [String] |
Sources/OrgWorkspace/FolderPresenter.swift added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | public 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`. | |
| 12 | public 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 | 30 | for placeholder in discovery.placeholders { |
| 31 | 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 | 36 | report.placeholders = discovery.placeholders |
| 35 | 37 | return report |
| 36 | 38 | } |
| @@ -40,19 +42,28 @@ public struct Reconciler: Sendable { | ||
| 40 | 42 | let wanted = Set(paths.map(\.standardizedFileURL.path)) |
| 41 | 43 | let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) } |
| 42 | 44 | var found: [DiscoveredFile] = [] |
| 45 | var placeholders: [URL] = [] | |
| 43 | 46 | let rootComponents = root.standardizedFileURL.pathComponents.count |
| 44 | 47 | for url in paths.map(\.standardizedFileURL) { |
| 45 | 48 | // Files in ignored folders (`.git`) aren't part of the workspace, as in `scan`. |
| 46 | 49 | if url.pathComponents.dropFirst(rootComponents).dropLast().contains(where: rules.skipsFolder) { continue } |
| 47 | 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 | 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 | 58 | found.append(DiscoveredFile( |
| 51 | 59 | url: url, kind: kind, size: values.fileSize ?? 0, |
| 52 | 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 | 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 | 683 | Section(isExpanded: expanded(root)) { |
| 684 | 684 | OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in |
| 685 | 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 | 688 | .tag(node.file?.path) |
| 688 | 689 | .contextMenu { fileMenu(node) } |
| 689 | 690 | } |
| @@ -745,6 +746,7 @@ struct Sidebar: View { | ||
| 745 | 746 | } |
| 746 | 747 | |
| 747 | 748 | private func icon(_ node: FileTreeNode) -> String { |
| 749 | if node.isDownloading { return "icloud.and.arrow.down" } | |
| 748 | 750 | guard let file = node.file else { return "folder" } |
| 749 | 751 | switch file.kind { |
| 750 | 752 | case .org: return "doc.text" |
Sources/Orgstar/SidebarFiles.swift +1 −1
| @@ -12,7 +12,7 @@ enum FileOperation: Equatable { | ||
| 12 | 12 | extension Sidebar { |
| 13 | 13 | @ViewBuilder |
| 14 | 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 | 16 | Button("New File…") { ask(.create(folder: folder)) } |
| 17 | 17 | Divider() |
| 18 | 18 | Button("Rename…") { ask(.rename(path: node.path)) } |
Tests/OrgAppTests/AppTests.swift +8
| @@ -74,6 +74,14 @@ struct WorkspaceModelTests { | ||
| 74 | 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 | 85 | @Test func findsBacklinks() async throws { |
| 78 | 86 | let notes = try Folder() |
| 79 | 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgWorkspace | |
| 4 | ||
| 5 | struct 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 | 221 | - Roots are identified by bookmark, not path, so a moved root is followed or reported. |
| 222 | 222 | - iOS (later): `NSFilePresenter` plus `NSMetadataQuery` for iCloud. |
| 223 | 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 | 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 | 351 | - [x] Document session: original bytes, revisions, merge base, per-view state |
| 352 | 352 | - [x] Save path with merge, recovery copies and fault-injection tests (used by the editing spike, not exposed in the viewer) |
| 353 | 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 | 355 | - [x] Index: schema, settings version, dirty-buffer overlay, FTS5 search |
| 356 | 356 | - [x] Mac app (read-only): sidebar of folders and files, outline of the current file, quick open (`⌘P`), full-text search |
| 357 | 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) |