Commit f9597060ef
Verified · cmc
Layout: unified · split
Sources/OrgApp/BabelRunner.swift +2
| @@ -1,3 +1,4 @@ | |||
| 1 | #if os(macOS) | ||
| 1 | import Foundation | 2 | import Foundation |
| 2 | import OrgCore | 3 | import 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) | ||
| 1 | import Foundation | 2 | import Foundation |
| 2 | import OrgCore | 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 | 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) | ||
| 1 | import Foundation | 2 | import 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 | |||
| 12 | public final class WorkspaceModel { | 12 | public 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 @@ | |||
| 2 | import CoreServices | 2 | import CoreServices |
| 3 | import Foundation | 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 | /// FSEvents with file-level events. Delivers batches on a private queue. | 5 | /// FSEvents with file-level events. Delivers batches on a private queue. |
| 12 | public final class FSEventsWatcher: @unchecked Sendable { | 6 | public 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 @@ | |||
| 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 | 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 { | |||
| 12 | extension Sidebar { | 12 | extension 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 @@ | |||
| 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 | - 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) |