krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 0fbfd00c08

0fbfd00c08ed4b2c481b0a0ef42ce897c51ef32a

parent: 1535a3164e

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 00:15 UTC

Add index reconciliation

Layout: unified · split

Sources/OrgWorkspace/Reconciler.swift added +101
@@ -0,0 +1,101 @@
1import Foundation
2import OrgIndex
3
4public struct ReconcileReport: Sendable, Equatable {
5 public var indexed: [String] = []
6 public var touched: [String] = []
7 public var moved: [String] = []
8 public var removed: [String] = []
9 public var unchanged = 0
10 /// iCloud files asked to download; they are indexed once they arrive.
11 public var placeholders: [URL] = []
12}
13
14/// Brings the index in line with the files under a root. File events are only hints, so this
15/// is what runs at launch, after dropped events, and for every batch of changed paths.
16public struct Reconciler: Sendable {
17 public let index: IndexStore
18 public let settings: IndexSettings
19
20 public init(index: IndexStore, settings: IndexSettings) {
21 self.index = index
22 self.settings = settings
23 }
24
25 /// Reconciles the whole root.
26 public func reconcile(root: URL) throws -> ReconcileReport {
27 let discovery = try WorkspaceScanner.scan(root)
28 for placeholder in discovery.placeholders {
29 try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder)
30 }
31 var report = try reconcile(root: root, found: discovery.files, known: index.fileStates(root: root.path))
32 report.placeholders = discovery.placeholders
33 return report
34 }
35
36 /// Reconciles only `paths` under the root, for a batch of file events.
37 public func reconcile(root: URL, paths: [URL]) throws -> ReconcileReport {
38 let wanted = Set(paths.map(\.standardizedFileURL.path))
39 let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) }
40 var found: [DiscoveredFile] = []
41 for url in paths.map(\.standardizedFileURL) {
42 guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent),
43 let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isDirectoryKey]),
44 values.isDirectory != true else { continue }
45 found.append(DiscoveredFile(
46 url: url, kind: kind, size: values.fileSize ?? 0,
47 mtime: values.contentModificationDate?.timeIntervalSince1970 ?? 0
48 ))
49 }
50 return try reconcile(root: root, found: found, known: known)
51 }
52
53 private func reconcile(root: URL, found: [DiscoveredFile], known: [String: FileState]) throws -> ReconcileReport {
54 var report = ReconcileReport()
55 var change = IndexChange()
56 var candidates: [(file: DiscoveredFile, bytes: [UInt8], hash: String)] = []
57
58 for file in found {
59 let path = file.url.path
60 if let state = known[path], state.size == file.size, state.mtime == file.mtime,
61 state.settingsVersion == settings.version, state.kind == file.kind {
62 report.unchanged += 1
63 continue
64 }
65 guard let data = try? Data(contentsOf: file.url) else { continue }
66 let bytes = [UInt8](data)
67 let hash = FileRecord.hash(bytes)
68 if let state = known[path], state.hash == hash, state.settingsVersion == settings.version, state.kind == file.kind {
69 change.touches.append((path, file.mtime))
70 report.touched.append(path)
71 } else {
72 candidates.append((file, bytes, hash))
73 }
74 }
75
76 // A file that vanished and one that appeared with the same content is a rename.
77 let foundPaths = Set(found.map(\.url.path))
78 var missing = known.filter { !foundPaths.contains($0.key) }
79 for candidate in candidates {
80 let path = candidate.file.url.path
81 if known[path] == nil,
82 let (oldPath, _) = missing.first(where: {
83 $0.value.hash == candidate.hash && $0.value.settingsVersion == settings.version && $0.value.kind == candidate.file.kind
84 }) {
85 missing[oldPath] = nil
86 change.moves.append((oldPath, path, candidate.file.mtime))
87 report.moved.append(path)
88 } else {
89 change.records.append(FileRecord(
90 path: path, root: root.path, kind: candidate.file.kind,
91 bytes: candidate.bytes, mtime: candidate.file.mtime, settings: settings
92 ))
93 report.indexed.append(path)
94 }
95 }
96 change.removals = missing.keys.sorted()
97 report.removed = change.removals
98 try index.apply(change)
99 return report
100 }
101}
Tests/OrgWorkspaceTests/ReconcileTests.swift added +81
@@ -0,0 +1,81 @@
1import Foundation
2import OrgIndex
3import Testing
4@testable import OrgWorkspace
5
6struct ReconcileTests {
7 @Test func indexesUpdatesMovesAndRemoves() throws {
8 let folder = try Folder()
9 let index = try IndexStore()
10 let reconciler = Reconciler(index: index, settings: IndexSettings())
11 let a = try folder.write("a.org", "* Alpha\n")
12 try folder.write("b.org", "* Beta\n")
13
14 var report = try reconciler.reconcile(root: folder.url)
15 #expect(report.indexed.count == 2)
16 #expect(try index.search("alpha").map(\.path) == [a.path])
17
18 report = try reconciler.reconcile(root: folder.url)
19 #expect(report.unchanged == 2 && report.indexed.isEmpty)
20
21 try FileManager.default.moveItem(at: folder.url.appendingPathComponent("b.org"), to: folder.url.appendingPathComponent("c.org"))
22 try folder.write("a.org", "* Gamma\n")
23 report = try reconciler.reconcile(root: folder.url)
24 #expect(report.moved.map { URL(fileURLWithPath: $0).lastPathComponent } == ["c.org"])
25 #expect(report.indexed == [a.path])
26 #expect(try index.search("gamma").count == 1)
27 #expect(try index.search("beta").map { URL(fileURLWithPath: $0.path).lastPathComponent } == ["c.org"])
28
29 try FileManager.default.removeItem(at: a)
30 report = try reconciler.reconcile(root: folder.url)
31 #expect(report.removed == [a.path])
32 #expect(try index.search("gamma").isEmpty)
33 }
34
35 @Test func touchedFileWithSameContentIsNotReparsed() throws {
36 let folder = try Folder()
37 let index = try IndexStore()
38 let reconciler = Reconciler(index: index, settings: IndexSettings())
39 let a = try folder.write("a.org", "* Alpha\n")
40 _ = try reconciler.reconcile(root: folder.url)
41 try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: 60)], ofItemAtPath: a.path)
42 #expect(try reconciler.reconcile(root: folder.url).touched == [a.path])
43 }
44
45 @Test func newSettingsVersionReindexes() throws {
46 let folder = try Folder()
47 let index = try IndexStore()
48 try folder.write("a.org", "* Alpha\n")
49 _ = try Reconciler(index: index, settings: IndexSettings(version: 1)).reconcile(root: folder.url)
50 #expect(try Reconciler(index: index, settings: IndexSettings(version: 2)).reconcile(root: folder.url).indexed.count == 1)
51 }
52
53 @Test func conflictCopiesAreListedButNotSearchable() throws {
54 let folder = try Folder()
55 let index = try IndexStore()
56 try folder.write("a.sync-conflict-1.org", "* Alpha\n")
57 _ = try Reconciler(index: index, settings: IndexSettings()).reconcile(root: folder.url)
58 #expect(try index.files().map(\.kind) == [.conflict])
59 #expect(try index.search("alpha").isEmpty)
60 }
61
62 @Test func reconcilingChangedPaths() throws {
63 let folder = try Folder()
64 let index = try IndexStore()
65 let reconciler = Reconciler(index: index, settings: IndexSettings())
66 let a = try folder.write("a.org", "* Alpha\n")
67 let b = try folder.write("b.org", "* Beta\n")
68 _ = try reconciler.reconcile(root: folder.url)
69 try folder.write("a.org", "* Delta\n")
70 try FileManager.default.removeItem(at: b)
71 let report = try reconciler.reconcile(root: folder.url, paths: [a, b])
72 #expect(report.indexed == [a.path])
73 #expect(report.removed == [b.path])
74 }
75
76 @Test func rootBookmarkResolves() throws {
77 let folder = try Folder()
78 let root = try WorkspaceRoot(url: folder.url)
79 #expect(try root.resolve().url.resolvingSymlinksInPath() == folder.url.resolvingSymlinksInPath())
80 }
81}