From 72540305adaa88435ac75f612ae3111119ed1e00 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 28 Jul 2026 15:21:11 +0200 Subject: [PATCH] Fix deletions not sticking (git sync resurrection), persist folder collapse state, make merge provider picker visibly clickable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleted conversations coming back: exportAllConversations() only ever wrote files for conversations that currently exist — it never removed the exported markdown file for a conversation that had been deleted locally. That file just sits in the sync repo forever, so every future pull+import (including on every app startup) silently resurrects it, since importAllConversations() only skips an import when a matching local ID already exists. Fixed by having export also delete orphaned files (conversation ID no longer present locally), and added GitSyncService.syncAfterDeletion() — a debounced export+push triggered right after any delete/bulk-delete/merge-cleanup, so the removal reaches the remote promptly instead of waiting on an unrelated future auto-save. Existing duplicates need one more manual delete to clear, but they'll stay gone after that. Folder collapse state now persists (SettingsService.collapsedFolderIds, JSON-encoded like favoriteModelIds) and is restored on app launch, in both the sidebar and the advanced conversation list. Merge model picker: the provider switcher was legitimate (it does load each provider's own catalog independently) but looked like plain text — no chevron, no button styling — so it wasn't obviously clickable. Restyled to match HeaderView's provider menu affordance (icon + label + chevron on a colored pill). --- oAI/Models/SyncModels.swift | 8 ++-- oAI/Services/ConversationMergeService.swift | 1 + oAI/Services/GitSyncService.swift | 40 ++++++++++++++++ oAI/Services/SettingsService.swift | 21 +++++++++ oAI/Views/Main/SidebarView.swift | 7 ++- .../Screens/CombineConversationsSheet.swift | 15 +++++- oAI/Views/Screens/ConversationListView.swift | 4 ++ oAITests/GitSyncServiceTests.swift | 46 +++++++++++++++++++ 8 files changed, 135 insertions(+), 7 deletions(-) diff --git a/oAI/Models/SyncModels.swift b/oAI/Models/SyncModels.swift index 6412209..75eaaa6 100644 --- a/oAI/Models/SyncModels.swift +++ b/oAI/Models/SyncModels.swift @@ -68,7 +68,7 @@ struct SyncStatus: Equatable { var remoteStatus: String? // "up-to-date", "ahead 3", "behind 2", etc. } -struct ConversationExport { +nonisolated struct ConversationExport { let id: String let name: String let createdAt: Date @@ -76,7 +76,7 @@ struct ConversationExport { let primaryModel: String? // Primary model used in conversation let messages: [MessageExport] - struct MessageExport { + nonisolated struct MessageExport { let role: String let content: String let timestamp: Date @@ -85,7 +85,7 @@ struct ConversationExport { let modelId: String? // Model that generated this message } - func toMarkdown() -> String { + nonisolated func toMarkdown() -> String { var md = "# \(name)\n\n" md += "**ID**: `\(id)`\n" md += "**Created**: \(ISO8601DateFormatter().string(from: createdAt))\n" @@ -129,7 +129,7 @@ struct ConversationExport { } /// Parse markdown back to ConversationExport - static func fromMarkdown(_ markdown: String) throws -> ConversationExport { + nonisolated static func fromMarkdown(_ markdown: String) throws -> ConversationExport { let lines = markdown.components(separatedBy: .newlines) var lineIndex = 0 diff --git a/oAI/Services/ConversationMergeService.swift b/oAI/Services/ConversationMergeService.swift index 1e2fbd1..7015ab7 100644 --- a/oAI/Services/ConversationMergeService.swift +++ b/oAI/Services/ConversationMergeService.swift @@ -94,6 +94,7 @@ enum ConversationMergeService { for id in conversationIds { _ = try? DatabaseService.shared.deleteConversation(id: id) } + GitSyncService.shared.syncAfterDeletion() } Log.db.info("Combined \(conversationIds.count) conversations into '\(name)' (mode: \(mode.rawValue), deleteOriginals: \(deleteOriginals))") diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index b9827ae..52ba9dc 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -174,9 +174,40 @@ class GitSyncService { log.debug("Exported: \(filename)") } + // Remove files for conversations that no longer exist locally (e.g. deleted since + // the last export). Without this, a deletion is never reflected in the sync repo, + // so importAllConversations() silently resurrects it on every future pull. + let currentIds = Set(conversations.map { $0.id.uuidString }) + let existingFiles = (try? FileManager.default.contentsOfDirectory(atPath: conversationsDir)) ?? [] + let mdFilesWithContent: [(filename: String, markdown: String)] = existingFiles + .filter { $0.hasSuffix(".md") } + .compactMap { filename in + guard let markdown = try? String(contentsOfFile: conversationsDir + "/" + filename, encoding: .utf8) + else { return nil } + return (filename, markdown) + } + for filename in Self.orphanedExportFilenames(currentIds: currentIds, files: mdFilesWithContent) { + try? FileManager.default.removeItem(atPath: conversationsDir + "/" + filename) + log.info("Removed orphaned export for deleted conversation: \(filename)") + } + await updateStatus() } + /// Given the current conversation IDs and the (filename, markdown content) pairs found in + /// the sync repo's conversations directory, returns the filenames whose export ID doesn't + /// match any current conversation — i.e. files safe to delete because their conversation + /// was removed from the database since the last export. + nonisolated static func orphanedExportFilenames( + currentIds: Set, + files: [(filename: String, markdown: String)] + ) -> [String] { + files.compactMap { file in + guard let export = try? ConversationExport.fromMarkdown(file.markdown) else { return nil } + return currentIds.contains(export.id) ? nil : file.filename + } + } + /// Import conversations from markdown files func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) { try ensureCloned() @@ -424,6 +455,15 @@ class GitSyncService { } } + /// Fire-and-forget sync trigger for conversation-deletion call sites. No-ops when sync + /// isn't configured. Deletions otherwise only reach the sync repo on the next incidental + /// auto-sync (or never, if the app is closed first) — this makes the removal propagate + /// promptly instead of the deleted conversation silently reappearing on next pull+import. + func syncAfterDeletion() { + guard settings.syncConfigured else { return } + Task { await autoSync() } + } + /// Perform auto-sync with debouncing (export + push) /// Debounces multiple rapid sync requests to avoid spamming git func autoSync() async { diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index 3f8088e..7c2b1ee 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -515,6 +515,27 @@ class SettingsService { } } + // MARK: - Folder Collapse State + + /// IDs of folders currently collapsed in the sidebar/conversation list — persisted so the + /// app reopens with folders in the same expanded/collapsed state the user left them in. + var collapsedFolderIds: Set { + get { + guard let json = cache["collapsedFolderIds"], + let data = json.data(using: .utf8), + let ids = try? JSONDecoder().decode([String].self, from: data) else { return [] } + return Set(ids.compactMap { UUID(uuidString: $0) }) + } + set { + let sorted = newValue.map { $0.uuidString }.sorted() + if let data = try? JSONEncoder().encode(sorted), + let json = String(data: data, encoding: .utf8) { + cache["collapsedFolderIds"] = json + DatabaseService.shared.setSetting(key: "collapsedFolderIds", value: json) + } + } + } + /// ISO8601 timestamp of the last local change to favoriteModelIds — used to /// resolve last-write-wins conflicts when syncing favorites across machines. var favoriteModelsUpdatedAt: String { diff --git a/oAI/Views/Main/SidebarView.swift b/oAI/Views/Main/SidebarView.swift index cbe69a0..84c3e57 100644 --- a/oAI/Views/Main/SidebarView.swift +++ b/oAI/Views/Main/SidebarView.swift @@ -161,7 +161,10 @@ struct SidebarView: View { } } - .onAppear { loadData() } + .onAppear { + loadData() + collapsedFolders = SettingsService.shared.collapsedFolderIds + } .onChange(of: chatViewModel.currentConversationName) { loadData() } .onChange(of: chatViewModel.messages.count) { loadData() } } @@ -204,6 +207,7 @@ struct SidebarView: View { } else { collapsedFolders.insert(folderId) } + SettingsService.shared.collapsedFolderIds = collapsedFolders } private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool { @@ -302,6 +306,7 @@ struct SidebarView: View { withAnimation { conversations.removeAll { $0.id == conversation.id } } + GitSyncService.shared.syncAfterDeletion() } private func renameConversation(_ conversation: Conversation) { diff --git a/oAI/Views/Screens/CombineConversationsSheet.swift b/oAI/Views/Screens/CombineConversationsSheet.swift index 3e45b7f..1fdd786 100644 --- a/oAI/Views/Screens/CombineConversationsSheet.swift +++ b/oAI/Views/Screens/CombineConversationsSheet.swift @@ -137,11 +137,22 @@ struct CombineConversationsSheet: View { } } } label: { - Text(mergeProvider.displayName) + HStack(spacing: 4) { + Image(systemName: mergeProvider.iconName) + Text(mergeProvider.displayName) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 7)) + .opacity(0.7) + } + .font(.caption) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.providerColor(mergeProvider)) + .cornerRadius(4) } .menuStyle(.borderlessButton) .fixedSize() - .font(.caption) .disabled(isProcessing || isLoadingMergeModels) Button("Change Model…") { diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index 42d6290..de668a2 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -277,6 +277,7 @@ struct ConversationListView: View { .onAppear { loadConversations() searchFocused = true + collapsedFolders = SettingsService.shared.collapsedFolderIds } .frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600) .sheet(isPresented: $showCombineSheet) { @@ -450,6 +451,7 @@ struct ConversationListView: View { } else { collapsedFolders.insert(folderId) } + SettingsService.shared.collapsedFolderIds = collapsedFolders } private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool { @@ -571,6 +573,7 @@ struct ConversationListView: View { isSelecting = false } selectedIndex = 0 + GitSyncService.shared.syncAfterDeletion() } private func renameConversation(_ conversation: Conversation) { @@ -609,6 +612,7 @@ struct ConversationListView: View { conversations.removeAll { $0.id == conversation.id } } selectedIndex = min(selectedIndex, max(0, filteredConversations.count - 1)) + GitSyncService.shared.syncAfterDeletion() } catch { Log.db.error("Failed to delete conversation: \(error.localizedDescription)") } diff --git a/oAITests/GitSyncServiceTests.swift b/oAITests/GitSyncServiceTests.swift index 67a2b4d..fb97c59 100644 --- a/oAITests/GitSyncServiceTests.swift +++ b/oAITests/GitSyncServiceTests.swift @@ -6,6 +6,7 @@ // Copyright (C) 2026 Rune Olsen import Testing +import Foundation @testable import oAI @Suite("GitSyncService pure helpers") @@ -105,4 +106,49 @@ struct GitSyncServiceTests { func extractProviderFallsBackForUnknownHost() { #expect(GitSyncService.extractProvider(from: "https://gitlab.pm/rune/oai-swift.git") == "Git repository") } + + // MARK: - orphanedExportFilenames + + private func makeExportMarkdown(id: String, name: String = "Test") -> String { + ConversationExport( + id: id, + name: name, + createdAt: Date(), + updatedAt: Date(), + primaryModel: nil, + messages: [.init(role: "user", content: "hi", timestamp: Date(), tokens: nil, cost: nil, modelId: nil)] + ).toMarkdown() + } + + @Test("A file whose ID still exists locally is not orphaned") + func keepsFilesForExistingConversations() { + let id = UUID().uuidString + let files = [(filename: "chat.md", markdown: makeExportMarkdown(id: id))] + let orphans = GitSyncService.orphanedExportFilenames(currentIds: [id], files: files) + #expect(orphans.isEmpty) + } + + @Test("A file whose ID no longer exists locally is orphaned") + func flagsFilesForDeletedConversations() { + let deletedId = UUID().uuidString + let keptId = UUID().uuidString + let files = [ + (filename: "deleted.md", markdown: makeExportMarkdown(id: deletedId)), + (filename: "kept.md", markdown: makeExportMarkdown(id: keptId)), + ] + let orphans = GitSyncService.orphanedExportFilenames(currentIds: [keptId], files: files) + #expect(orphans == ["deleted.md"]) + } + + @Test("Files that fail to parse are left alone rather than deleted") + func unparsableFilesAreNotFlaggedAsOrphans() { + let files = [(filename: "not-a-conversation.md", markdown: "not a valid export file")] + let orphans = GitSyncService.orphanedExportFilenames(currentIds: [], files: files) + #expect(orphans.isEmpty) + } + + @Test("Empty file list produces no orphans") + func emptyFileListProducesNoOrphans() { + #expect(GitSyncService.orphanedExportFilenames(currentIds: [UUID().uuidString], files: []).isEmpty) + } }