krz/orgstar

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

Sources/OrgWorkspace/Reconciler.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/OrgWorkspace/Reconciler.swift history · blame · raw

131 lines · 6444 bytes

  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    public let rules: ScanRules
 20
 21    public init(index: IndexStore, settings: IndexSettings, rules: ScanRules = ScanRules()) {
 22        self.index = index
 23        self.settings = settings
 24        self.rules = rules
 25    }
 26
 27    /// Reconciles the whole root.
 28    public func reconcile(root: URL) throws -> ReconcileReport {
 29        let discovery = try WorkspaceScanner.scan(root, rules: rules)
 30        for placeholder in discovery.placeholders {
 31            try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder)
 32        }
 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) })
 36        report.placeholders = discovery.placeholders
 37        return report
 38    }
 39
 40    /// Reconciles only `paths` under the root, for a batch of file events.
 41    public func reconcile(root: URL, paths: [URL]) throws -> ReconcileReport {
 42        let wanted = Set(paths.map(\.standardizedFileURL.path))
 43        let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) }
 44        var found: [DiscoveredFile] = []
 45        var placeholders: [URL] = []
 46        let rootComponents = root.standardizedFileURL.pathComponents.count
 47        for url in paths.map(\.standardizedFileURL) {
 48            // Files in ignored folders (`.git`) aren't part of the workspace, as in `scan`.
 49            if url.pathComponents.dropFirst(rootComponents).dropLast().contains(where: rules.skipsFolder) { continue }
 50            guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent, rules: rules),
 51                  let values = try? url.resourceValues(forKeys: Set(WorkspaceScanner.keys)),
 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            }
 58            found.append(DiscoveredFile(
 59                url: url, kind: kind, size: values.fileSize ?? 0,
 60                mtime: values.contentModificationDate?.timeIntervalSince1970 ?? 0
 61            ))
 62        }
 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
 67    }
 68
 69    private func reconcile(root: URL, found: [DiscoveredFile], known: [String: FileState]) throws -> ReconcileReport {
 70        var report = ReconcileReport()
 71        var change = IndexChange()
 72        var candidates: [(file: DiscoveredFile, bytes: [UInt8], hash: String)] = []
 73
 74        for file in found {
 75            let path = file.url.path
 76            if let state = known[path], state.size == file.size, state.mtime == file.mtime,
 77               state.settingsVersion == settings.version, state.kind == file.kind {
 78                report.unchanged += 1
 79                continue
 80            }
 81            if file.kind == .other {
 82                let record = FileRecord(path: path, root: root.path, unreadSize: file.size, mtime: file.mtime, settings: settings)
 83                if let state = known[path], state.hash == record.hash, state.settingsVersion == settings.version, state.kind == .other {
 84                    report.unchanged += 1
 85                } else {
 86                    candidates.append((file, [], record.hash))
 87                }
 88                continue
 89            }
 90            guard let data = try? Data(contentsOf: file.url) else { continue }
 91            let bytes = [UInt8](data)
 92            let hash = FileRecord.hash(bytes)
 93            if let state = known[path], state.hash == hash, state.settingsVersion == FileRecord.settingsVersion(settings, bytes: bytes, path: path),
 94               state.kind == file.kind {
 95                change.touches.append((path, file.mtime))
 96                report.touched.append(path)
 97            } else {
 98                candidates.append((file, bytes, hash))
 99            }
100        }
101
102        // A file that vanished and one that appeared with the same content is a rename.
103        let foundPaths = Set(found.map(\.url.path))
104        var missing = known.filter { !foundPaths.contains($0.key) }
105        for candidate in candidates {
106            let path = candidate.file.url.path
107            if known[path] == nil,
108               let (oldPath, _) = missing.first(where: {
109                   $0.value.hash == candidate.hash && $0.value.kind == candidate.file.kind
110                       && $0.value.settingsVersion == (candidate.file.kind == .other ? settings.version : FileRecord.settingsVersion(settings, bytes: candidate.bytes, path: path))
111               }) {
112                missing[oldPath] = nil
113                change.moves.append((oldPath, path, candidate.file.mtime))
114                report.moved.append(path)
115            } else if candidate.file.kind == .other {
116                change.records.append(FileRecord(path: path, root: root.path, unreadSize: candidate.file.size, mtime: candidate.file.mtime, settings: settings))
117                report.indexed.append(path)
118            } else {
119                change.records.append(FileRecord(
120                    path: path, root: root.path, kind: candidate.file.kind,
121                    bytes: candidate.bytes, mtime: candidate.file.mtime, settings: settings
122                ))
123                report.indexed.append(path)
124            }
125        }
126        change.removals = missing.keys.sorted()
127        report.removed = change.removals
128        try index.apply(change)
129        return report
130    }
131}