Fix deletions not sticking (git sync resurrection), persist folder
collapse state, make merge provider picker visibly clickable 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).
This commit is contained in:
@@ -68,7 +68,7 @@ struct SyncStatus: Equatable {
|
|||||||
var remoteStatus: String? // "up-to-date", "ahead 3", "behind 2", etc.
|
var remoteStatus: String? // "up-to-date", "ahead 3", "behind 2", etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ConversationExport {
|
nonisolated struct ConversationExport {
|
||||||
let id: String
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
@@ -76,7 +76,7 @@ struct ConversationExport {
|
|||||||
let primaryModel: String? // Primary model used in conversation
|
let primaryModel: String? // Primary model used in conversation
|
||||||
let messages: [MessageExport]
|
let messages: [MessageExport]
|
||||||
|
|
||||||
struct MessageExport {
|
nonisolated struct MessageExport {
|
||||||
let role: String
|
let role: String
|
||||||
let content: String
|
let content: String
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
@@ -85,7 +85,7 @@ struct ConversationExport {
|
|||||||
let modelId: String? // Model that generated this message
|
let modelId: String? // Model that generated this message
|
||||||
}
|
}
|
||||||
|
|
||||||
func toMarkdown() -> String {
|
nonisolated func toMarkdown() -> String {
|
||||||
var md = "# \(name)\n\n"
|
var md = "# \(name)\n\n"
|
||||||
md += "**ID**: `\(id)`\n"
|
md += "**ID**: `\(id)`\n"
|
||||||
md += "**Created**: \(ISO8601DateFormatter().string(from: createdAt))\n"
|
md += "**Created**: \(ISO8601DateFormatter().string(from: createdAt))\n"
|
||||||
@@ -129,7 +129,7 @@ struct ConversationExport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse markdown back to 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)
|
let lines = markdown.components(separatedBy: .newlines)
|
||||||
var lineIndex = 0
|
var lineIndex = 0
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ enum ConversationMergeService {
|
|||||||
for id in conversationIds {
|
for id in conversationIds {
|
||||||
_ = try? DatabaseService.shared.deleteConversation(id: id)
|
_ = try? DatabaseService.shared.deleteConversation(id: id)
|
||||||
}
|
}
|
||||||
|
GitSyncService.shared.syncAfterDeletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.db.info("Combined \(conversationIds.count) conversations into '\(name)' (mode: \(mode.rawValue), deleteOriginals: \(deleteOriginals))")
|
Log.db.info("Combined \(conversationIds.count) conversations into '\(name)' (mode: \(mode.rawValue), deleteOriginals: \(deleteOriginals))")
|
||||||
|
|||||||
@@ -174,9 +174,40 @@ class GitSyncService {
|
|||||||
log.debug("Exported: \(filename)")
|
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()
|
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<String>,
|
||||||
|
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
|
/// Import conversations from markdown files
|
||||||
func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) {
|
func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) {
|
||||||
try ensureCloned()
|
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)
|
/// Perform auto-sync with debouncing (export + push)
|
||||||
/// Debounces multiple rapid sync requests to avoid spamming git
|
/// Debounces multiple rapid sync requests to avoid spamming git
|
||||||
func autoSync() async {
|
func autoSync() async {
|
||||||
|
|||||||
@@ -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<UUID> {
|
||||||
|
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
|
/// ISO8601 timestamp of the last local change to favoriteModelIds — used to
|
||||||
/// resolve last-write-wins conflicts when syncing favorites across machines.
|
/// resolve last-write-wins conflicts when syncing favorites across machines.
|
||||||
var favoriteModelsUpdatedAt: String {
|
var favoriteModelsUpdatedAt: String {
|
||||||
|
|||||||
@@ -161,7 +161,10 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.onAppear { loadData() }
|
.onAppear {
|
||||||
|
loadData()
|
||||||
|
collapsedFolders = SettingsService.shared.collapsedFolderIds
|
||||||
|
}
|
||||||
.onChange(of: chatViewModel.currentConversationName) { loadData() }
|
.onChange(of: chatViewModel.currentConversationName) { loadData() }
|
||||||
.onChange(of: chatViewModel.messages.count) { loadData() }
|
.onChange(of: chatViewModel.messages.count) { loadData() }
|
||||||
}
|
}
|
||||||
@@ -204,6 +207,7 @@ struct SidebarView: View {
|
|||||||
} else {
|
} else {
|
||||||
collapsedFolders.insert(folderId)
|
collapsedFolders.insert(folderId)
|
||||||
}
|
}
|
||||||
|
SettingsService.shared.collapsedFolderIds = collapsedFolders
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool {
|
private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool {
|
||||||
@@ -302,6 +306,7 @@ struct SidebarView: View {
|
|||||||
withAnimation {
|
withAnimation {
|
||||||
conversations.removeAll { $0.id == conversation.id }
|
conversations.removeAll { $0.id == conversation.id }
|
||||||
}
|
}
|
||||||
|
GitSyncService.shared.syncAfterDeletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func renameConversation(_ conversation: Conversation) {
|
private func renameConversation(_ conversation: Conversation) {
|
||||||
|
|||||||
@@ -137,11 +137,22 @@ struct CombineConversationsSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: mergeProvider.iconName)
|
||||||
Text(mergeProvider.displayName)
|
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)
|
.menuStyle(.borderlessButton)
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
.font(.caption)
|
|
||||||
.disabled(isProcessing || isLoadingMergeModels)
|
.disabled(isProcessing || isLoadingMergeModels)
|
||||||
|
|
||||||
Button("Change Model…") {
|
Button("Change Model…") {
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ struct ConversationListView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
loadConversations()
|
loadConversations()
|
||||||
searchFocused = true
|
searchFocused = true
|
||||||
|
collapsedFolders = SettingsService.shared.collapsedFolderIds
|
||||||
}
|
}
|
||||||
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
|
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
|
||||||
.sheet(isPresented: $showCombineSheet) {
|
.sheet(isPresented: $showCombineSheet) {
|
||||||
@@ -450,6 +451,7 @@ struct ConversationListView: View {
|
|||||||
} else {
|
} else {
|
||||||
collapsedFolders.insert(folderId)
|
collapsedFolders.insert(folderId)
|
||||||
}
|
}
|
||||||
|
SettingsService.shared.collapsedFolderIds = collapsedFolders
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool {
|
private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool {
|
||||||
@@ -571,6 +573,7 @@ struct ConversationListView: View {
|
|||||||
isSelecting = false
|
isSelecting = false
|
||||||
}
|
}
|
||||||
selectedIndex = 0
|
selectedIndex = 0
|
||||||
|
GitSyncService.shared.syncAfterDeletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func renameConversation(_ conversation: Conversation) {
|
private func renameConversation(_ conversation: Conversation) {
|
||||||
@@ -609,6 +612,7 @@ struct ConversationListView: View {
|
|||||||
conversations.removeAll { $0.id == conversation.id }
|
conversations.removeAll { $0.id == conversation.id }
|
||||||
}
|
}
|
||||||
selectedIndex = min(selectedIndex, max(0, filteredConversations.count - 1))
|
selectedIndex = min(selectedIndex, max(0, filteredConversations.count - 1))
|
||||||
|
GitSyncService.shared.syncAfterDeletion()
|
||||||
} catch {
|
} catch {
|
||||||
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
|
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Foundation
|
||||||
@testable import oAI
|
@testable import oAI
|
||||||
|
|
||||||
@Suite("GitSyncService pure helpers")
|
@Suite("GitSyncService pure helpers")
|
||||||
@@ -105,4 +106,49 @@ struct GitSyncServiceTests {
|
|||||||
func extractProviderFallsBackForUnknownHost() {
|
func extractProviderFallsBackForUnknownHost() {
|
||||||
#expect(GitSyncService.extractProvider(from: "https://gitlab.pm/rune/oai-swift.git") == "Git repository")
|
#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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user