fix: history load notifies and never overwrites unreadable file; dictionary keeps ids across external edits, undo safety

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 16:04:01 +02:00
co-authored by Claude Sonnet 5.5
parent 5542f9cf70
commit 31c34a1d4a
8 changed files with 150 additions and 6 deletions
+15
View File
@@ -70,6 +70,21 @@ public struct DictionaryFile: Equatable, Sendable {
return .entry(DictionaryEntry(kind: .term(trimmed)))
}
/// Reuses the ids of `old` for entries with equal content (first unused match, in order), so ids survive a reload.
public func preservingIDs(from old: DictionaryFile) -> DictionaryFile {
var available = old.entries
var result = self
result.lines = lines.map { line in
guard case .entry(let entry) = line else { return line }
if let index = available.firstIndex(where: { $0.kind == entry.kind }) {
let reused = available.remove(at: index)
return .entry(DictionaryEntry(id: reused.id, kind: entry.kind))
}
return line
}
return result
}
public func serialized() -> String {
let out = lines.map { line -> String in
switch line {
+4 -1
View File
@@ -14,6 +14,8 @@ public final class DictionaryStore {
public let fileURL: URL
public private(set) var file = DictionaryFile()
public var onChange: (() -> Void)?
/// Fired (after `onChange`) when the file was changed by something other than this store.
public var onExternalChange: (() -> Void)?
private var lastText = ""
private var watcher: DispatchSourceFileSystemObject?
@@ -53,8 +55,9 @@ public final class DictionaryStore {
public func reloadIfChanged() {
guard let text = try? String(contentsOf: fileURL, encoding: .utf8), text != lastText else { return }
lastText = text
file = DictionaryFile(parsing: text)
file = DictionaryFile(parsing: text).preservingIDs(from: file)
onChange?()
onExternalChange?()
}
/// Watches the folder (editors replace files instead of editing them in place).
+18 -3
View File
@@ -35,6 +35,8 @@ public final class HistoryStore {
case empty
case loaded
case recoveredCorrupt(backup: URL)
/// The file exists but cannot be read; it is left alone and nothing is written over it.
case unreadable(String)
}
private struct Document: Codable {
@@ -47,6 +49,7 @@ public final class HistoryStore {
public var onChange: (() -> Void)?
private let fileURL: URL
private var readOnly = false
private var saveTask: Task<Void, Never>?
public init(fileURL: URL) {
@@ -68,18 +71,29 @@ public final class HistoryStore {
@discardableResult
public func load() -> LoadOutcome {
guard let data = try? Data(contentsOf: fileURL) else {
entries = []
return .empty
let data: Data
do {
data = try Data(contentsOf: fileURL)
} catch {
if !FileManager.default.fileExists(atPath: fileURL.path) {
entries = []
onChange?()
return .empty
}
readOnly = true
lastError = error.localizedDescription
return .unreadable(error.localizedDescription)
}
do {
entries = try Self.makeDecoder().decode(Document.self, from: data).entries
onChange?()
return .loaded
} catch {
let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-")
let backup = fileURL.deletingLastPathComponent().appending(path: "history.corrupt-\(stamp).json")
try? FileManager.default.moveItem(at: fileURL, to: backup)
entries = []
onChange?()
return .recoveredCorrupt(backup: backup)
}
}
@@ -103,6 +117,7 @@ public final class HistoryStore {
public func flush() {
saveTask?.cancel()
saveTask = nil
guard !readOnly else { return }
do {
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
+9 -2
View File
@@ -161,16 +161,23 @@ public final class DictionaryViewModel: ObservableObject {
}
public func undo() {
guard let snapshot = undoStack.popLast() else { return }
canUndo = !undoStack.isEmpty
guard let snapshot = undoStack.last else { return }
do {
try store.apply { $0 = snapshot }
undoStack.removeLast()
canUndo = !undoStack.isEmpty
} catch {
errorMessage = error.localizedDescription
}
reload()
}
/// The file was edited elsewhere: older snapshots no longer describe it, so undo is dropped.
public func externalChangeDetected() {
undoStack.removeAll()
canUndo = false
}
private func mutate(_ change: (inout DictionaryFile) -> Void) {
let snapshot = store.file
do {