Sfoglia il codice sorgente

Fix history restore, dictionary races, and history persistence reliability.

Restore history entries when reopening the same tool, prevent stale dictionary lookups from overwriting newer results, capture translator/email metadata at request start, and serialize history storage with proper error logging.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 mese fa
parent
commit
69b1dbf869

+ 44 - 20
gramora/Services/HistoryStorageService.swift

@@ -1,4 +1,5 @@
 import Foundation
+import os
 
 extension Notification.Name {
     static let historyDidChange = Notification.Name("historyDidChange")
@@ -17,43 +18,66 @@ final class HistoryStorageService: HistoryStoring {
     private let defaults: UserDefaults
     private let storageKey = "gramora.history.entries"
     private let maxEntries = 100
+    private let queue = DispatchQueue(label: "com.gramora.history.storage")
+    private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "gramora", category: "HistoryStorage")
 
     init(defaults: UserDefaults = .standard) {
         self.defaults = defaults
     }
 
     func loadEntries() -> [HistoryEntry] {
-        guard
-            let data = defaults.data(forKey: storageKey),
-            let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data)
-        else {
-            return []
+        queue.sync {
+            loadEntriesUnlocked()
         }
-
-        return entries.sorted { $0.createdAt > $1.createdAt }
     }
 
     func save(_ entry: HistoryEntry) {
-        var entries = loadEntries()
-        entries.removeAll { $0.id == entry.id }
-        entries.insert(entry, at: 0)
-        entries = Array(entries.prefix(maxEntries))
-        persist(entries)
+        queue.sync {
+            var entries = loadEntriesUnlocked()
+            entries.removeAll { $0.id == entry.id }
+            entries.insert(entry, at: 0)
+            entries = Array(entries.prefix(maxEntries))
+            persistUnlocked(entries)
+        }
     }
 
     func delete(id: UUID) {
-        var entries = loadEntries()
-        entries.removeAll { $0.id == id }
-        persist(entries)
+        queue.sync {
+            var entries = loadEntriesUnlocked()
+            entries.removeAll { $0.id == id }
+            persistUnlocked(entries)
+        }
     }
 
     func clearAll() {
-        persist([])
+        queue.sync {
+            persistUnlocked([])
+        }
     }
 
-    private func persist(_ entries: [HistoryEntry]) {
-        guard let data = try? JSONEncoder().encode(entries) else { return }
-        defaults.set(data, forKey: storageKey)
-        NotificationCenter.default.post(name: .historyDidChange, object: nil)
+    private func loadEntriesUnlocked() -> [HistoryEntry] {
+        guard let data = defaults.data(forKey: storageKey) else {
+            return []
+        }
+
+        do {
+            let entries = try JSONDecoder().decode([HistoryEntry].self, from: data)
+            return entries.sorted { $0.createdAt > $1.createdAt }
+        } catch {
+            logger.error("Failed to decode history entries: \(error.localizedDescription, privacy: .public)")
+            return []
+        }
+    }
+
+    private func persistUnlocked(_ entries: [HistoryEntry]) {
+        do {
+            let data = try JSONEncoder().encode(entries)
+            defaults.set(data, forKey: storageKey)
+            DispatchQueue.main.async {
+                NotificationCenter.default.post(name: .historyDidChange, object: nil)
+            }
+        } catch {
+            logger.error("Failed to persist history entries: \(error.localizedDescription, privacy: .public)")
+        }
     }
 }

+ 18 - 7
gramora/ViewModels/DictionaryViewModel.swift

@@ -18,6 +18,8 @@ final class DictionaryViewModel: ObservableObject {
     private let service: DictionaryServicing
     private var speechSynthesizer = AVSpeechSynthesizer()
     private var audioPlayer: AVAudioPlayer?
+    private var lookupTask: Task<Void, Never>?
+    private var activeLookupID = UUID()
 
     init(service: DictionaryServicing = DictionaryService()) {
         self.service = service
@@ -38,16 +40,12 @@ final class DictionaryViewModel: ObservableObject {
             return
         }
 
-        Task {
-            await performLookUp(for: trimmed)
-        }
+        startLookUp(for: trimmed)
     }
 
     func lookUpRecent(_ word: String) {
         query = word
-        Task {
-            await performLookUp(for: word)
-        }
+        startLookUp(for: word)
     }
 
     func pasteWord() {
@@ -84,20 +82,33 @@ final class DictionaryViewModel: ObservableObject {
         speechSynthesizer.speak(utterance)
     }
 
-    private func performLookUp(for word: String) async {
+    private func startLookUp(for word: String) {
+        lookupTask?.cancel()
+        let lookupID = UUID()
+        activeLookupID = lookupID
+
+        lookupTask = Task {
+            await performLookUp(for: word, lookupID: lookupID)
+        }
+    }
+
+    private func performLookUp(for word: String, lookupID: UUID) async {
         isLoading = true
         errorMessage = nil
         entry = nil
 
         do {
             let result = try await service.lookUp(word: word)
+            guard !Task.isCancelled, activeLookupID == lookupID else { return }
             entry = result
             query = result.word
             addRecentSearch(result.word)
         } catch {
+            guard !Task.isCancelled, activeLookupID == lookupID else { return }
             errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
         }
 
+        guard activeLookupID == lookupID else { return }
         isLoading = false
     }
 

+ 14 - 4
gramora/ViewModels/EmailWriterViewModel.swift

@@ -82,20 +82,24 @@ final class EmailWriterViewModel: ObservableObject {
         errorMessage = nil
 
         let subjectLine = subject.trimmingCharacters(in: .whitespacesAndNewlines)
+        let requestEmailType = emailType
+        let requestTone = tone
 
         Task {
             do {
                 let email = try await emailWriterService.generateEmail(
                     prompt: trimmedPrompt,
                     subject: subjectLine,
-                    emailType: emailType,
-                    tone: tone
+                    emailType: requestEmailType,
+                    tone: requestTone
                 )
                 generatedEmail = email
                 saveToHistory(
                     subject: subjectLine,
                     prompt: trimmedPrompt,
-                    generatedEmail: email
+                    generatedEmail: email,
+                    emailType: requestEmailType,
+                    tone: requestTone
                 )
             } catch {
                 errorMessage = error.localizedDescription
@@ -116,7 +120,13 @@ final class EmailWriterViewModel: ObservableObject {
         errorMessage = nil
     }
 
-    private func saveToHistory(subject: String, prompt: String, generatedEmail: String) {
+    private func saveToHistory(
+        subject: String,
+        prompt: String,
+        generatedEmail: String,
+        emailType: EmailType,
+        tone: EmailTone
+    ) {
         let payload = HistoryPayload.emailWriter(
             EmailWriterHistoryData(
                 subject: subject,

+ 17 - 4
gramora/ViewModels/LanguageTranslatorViewModel.swift

@@ -102,14 +102,22 @@ final class LanguageTranslatorViewModel: ObservableObject {
         translatedText = ""
         errorMessage = nil
 
+        let requestSourceLanguage = sourceLanguage
+        let requestTargetLanguage = targetLanguage
+
         Task {
             do {
                 translatedText = try await translationService.translate(
                     trimmed,
-                    from: sourceLanguage,
-                    to: targetLanguage
+                    from: requestSourceLanguage,
+                    to: requestTargetLanguage
+                )
+                saveToHistory(
+                    sourceText: trimmed,
+                    translatedText: translatedText,
+                    sourceLanguage: requestSourceLanguage,
+                    targetLanguage: requestTargetLanguage
                 )
-                saveToHistory(sourceText: trimmed, translatedText: translatedText)
             } catch {
                 errorMessage = error.localizedDescription
             }
@@ -128,7 +136,12 @@ final class LanguageTranslatorViewModel: ObservableObject {
         errorMessage = nil
     }
 
-    private func saveToHistory(sourceText: String, translatedText: String) {
+    private func saveToHistory(
+        sourceText: String,
+        translatedText: String,
+        sourceLanguage: TranslationLanguage,
+        targetLanguage: TranslationLanguage
+    ) {
         let payload = HistoryPayload.languageTranslator(
             TranslationHistoryData(
                 sourceText: sourceText,

+ 3 - 0
gramora/Views/EmailWriterView.swift

@@ -47,6 +47,9 @@ struct EmailWriterView: View {
             viewModel.handlePromptChange()
         }
         .onAppear(perform: restoreIfNeeded)
+        .onChange(of: restoreEntry?.id) { _ in
+            restoreIfNeeded()
+        }
     }
 
     private func restoreIfNeeded() {

+ 3 - 0
gramora/Views/GrammarCheckerView.swift

@@ -71,6 +71,9 @@ struct GrammarCheckerView: View {
             viewModel.handleTextChange()
         }
         .onAppear(perform: restoreIfNeeded)
+        .onChange(of: restoreEntry?.id) { _ in
+            restoreIfNeeded()
+        }
     }
 
     private func loadingLayout(

+ 3 - 0
gramora/Views/JournalFinderView.swift

@@ -58,6 +58,9 @@ struct JournalFinderView: View {
             if viewModel.hasResults { viewModel.resetResults() }
         }
         .onAppear(perform: restoreIfNeeded)
+        .onChange(of: restoreEntry?.id) { _ in
+            restoreIfNeeded()
+        }
     }
 
     private func restoreIfNeeded() {

+ 3 - 0
gramora/Views/LanguageTranslatorView.swift

@@ -36,6 +36,9 @@ struct LanguageTranslatorView: View {
             viewModel.syncTargetLanguageIfNeeded()
         }
         .onAppear(perform: restoreIfNeeded)
+        .onChange(of: restoreEntry?.id) { _ in
+            restoreIfNeeded()
+        }
     }
 
     private func restoreIfNeeded() {

+ 3 - 0
gramora/Views/SpellCheckerView.swift

@@ -71,6 +71,9 @@ struct SpellCheckerView: View {
             viewModel.handleTextChange()
         }
         .onAppear(perform: restoreIfNeeded)
+        .onChange(of: restoreEntry?.id) { _ in
+            restoreIfNeeded()
+        }
     }
 
     private func restoreIfNeeded() {