Sync folder structure via Git Sync (folders.json), plus bugs found testing it
Folders and conversation→folder assignments now sync across machines:
- Folder gains updatedAt (v11 migration) to resolve renames/reparents
last-write-wins across machines.
- New folders.json manifest at the sync repo root: folder tree +
conversationId→folderId assignments, imported before conversation
files so new conversations land in the right folder immediately.
- Local folders missing from the manifest are pruned (reparent-safe),
guarded the same way conversation-orphan cleanup already is against
an empty/stale manifest wiping everything.
Three real bugs found and fixed during live multi-machine testing:
- Sidebar never refreshed after Git Sync imported conversations/folders
directly into the database — only reloaded on launch or when the
advanced conversation list closed, with no equivalent hook for the
Settings sheet.
- "Sync Now" exported before pulling, so it could write folders.json
as an untracked file that then collided with the remote's tracked
copy on the next pull ("untracked working tree files would be
overwritten by merge"). Reordered to pull → import → export → push.
- Folder assignment only applied to brand-new conversations during
import, so any conversation already synced to a machine before this
feature existed never got filed — which in practice is every
conversation on a second machine, not an edge case. Now backfills
a folder assignment for existing conversations that aren't filed
anywhere locally yet, without clobbering an already-set folderId.
Also renamed the "Initialize Repository" button to "Clone Repository"
(it's always been a git clone, not new-repo creation) across the UI,
localization catalog, and Help Book.
This commit is contained in:
@@ -8059,36 +8059,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Initialize Repository" : {
|
||||
"Clone Repository" : {
|
||||
"localizations" : {
|
||||
"da" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Initialiser repository"
|
||||
"value" : "Klon repository"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Repository initialisieren"
|
||||
"value" : "Repository klonen"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Initialiser le dépôt"
|
||||
"value" : "Cloner le dépôt"
|
||||
}
|
||||
},
|
||||
"nb" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Initialiser repositorium"
|
||||
"value" : "Klon repositorium"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Initiera förvar"
|
||||
"value" : "Klona förvar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,19 +29,22 @@ struct Folder: Identifiable, Codable, Sendable {
|
||||
var sortOrder: Int
|
||||
let createdAt: Date
|
||||
var parentId: UUID?
|
||||
var updatedAt: Date
|
||||
|
||||
nonisolated init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
sortOrder: Int = 0,
|
||||
createdAt: Date = Date(),
|
||||
parentId: UUID? = nil
|
||||
parentId: UUID? = nil,
|
||||
updatedAt: Date? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.sortOrder = sortOrder
|
||||
self.createdAt = createdAt
|
||||
self.parentId = parentId
|
||||
self.updatedAt = updatedAt ?? createdAt
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,24 @@ struct SyncStatus: Equatable {
|
||||
var remoteStatus: String? // "up-to-date", "ahead 3", "behind 2", etc.
|
||||
}
|
||||
|
||||
/// Serialized as `folders.json` at the sync repo root. Unlike conversation exports, this is a
|
||||
/// manifest meant to be machine-written/read only (the repo's README already warns against manual
|
||||
/// edits), so plain JSON is used rather than the hand-rolled markdown format — no need for that
|
||||
/// format's human-readability tradeoffs here.
|
||||
nonisolated struct FolderSyncManifest: Codable {
|
||||
nonisolated struct FolderEntry: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let parentId: String?
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
}
|
||||
|
||||
var folders: [FolderEntry]
|
||||
/// conversationId -> folderId. Only present for conversations actually filed in a folder.
|
||||
var assignments: [String: String]
|
||||
}
|
||||
|
||||
nonisolated struct ConversationExport {
|
||||
let id: String
|
||||
let name: String
|
||||
|
||||
@@ -1615,7 +1615,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
<li>Syncing happens automatically after every explicit save — no separate auto-save toggle needed. See <a href="#unsaved-changes">Unsaved Changes & Crash Recovery</a>.</li>
|
||||
<li><strong>Manual Sync</strong>:
|
||||
<ul>
|
||||
<li><strong>Initialize Repository</strong> - Clone repository for first-time setup</li>
|
||||
<li><strong>Clone Repository</strong> - Clone repository for first-time setup</li>
|
||||
<li><strong>Sync Now</strong> - Full sync (export + pull + import + push)</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -46,6 +46,7 @@ struct FolderRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
var sortOrder: Int
|
||||
var createdAt: String
|
||||
var parentId: String?
|
||||
var updatedAt: String?
|
||||
}
|
||||
|
||||
struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
@@ -381,6 +382,16 @@ final class DatabaseService: Sendable {
|
||||
try db.create(index: "idx_folders_parentId", on: "folders", columns: ["parentId"])
|
||||
}
|
||||
|
||||
migrator.registerMigration("v11") { db in
|
||||
// Needed to resolve folder renames/reparents last-write-wins when Git Sync brings in
|
||||
// folder state from another machine — without a timestamp there's no way to tell whose
|
||||
// version of a rename is newer. Backfilled from createdAt for existing rows.
|
||||
try db.alter(table: "folders") { t in
|
||||
t.add(column: "updatedAt", .text)
|
||||
}
|
||||
try db.execute(sql: "UPDATE folders SET updatedAt = createdAt WHERE updatedAt IS NULL")
|
||||
}
|
||||
|
||||
return migrator
|
||||
}
|
||||
|
||||
@@ -632,7 +643,8 @@ final class DatabaseService: Sendable {
|
||||
name: folder.name,
|
||||
sortOrder: folder.sortOrder,
|
||||
createdAt: Self.isoString(from: folder.createdAt),
|
||||
parentId: parentId?.uuidString
|
||||
parentId: parentId?.uuidString,
|
||||
updatedAt: Self.isoString(from: folder.updatedAt)
|
||||
)
|
||||
try dbQueue.write { db in
|
||||
try record.insert(db)
|
||||
@@ -651,8 +663,8 @@ final class DatabaseService: Sendable {
|
||||
nonisolated func renameFolder(id: UUID, name: String) throws {
|
||||
try dbQueue.write { db in
|
||||
try db.execute(
|
||||
sql: "UPDATE folders SET name = ? WHERE id = ?",
|
||||
arguments: [name, id.uuidString]
|
||||
sql: "UPDATE folders SET name = ?, updatedAt = ? WHERE id = ?",
|
||||
arguments: [name, Self.isoString(from: Date()), id.uuidString]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -668,8 +680,38 @@ final class DatabaseService: Sendable {
|
||||
throw FolderError.wouldCreateCycle
|
||||
}
|
||||
}
|
||||
try db.execute(sql: "UPDATE folders SET parentId = ? WHERE id = ?",
|
||||
arguments: [parentId?.uuidString, id.uuidString])
|
||||
try db.execute(sql: "UPDATE folders SET parentId = ?, updatedAt = ? WHERE id = ?",
|
||||
arguments: [parentId?.uuidString, Self.isoString(from: Date()), id.uuidString])
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates or updates a folder with an externally-supplied identity and timestamps, for Git
|
||||
/// Sync import — as opposed to `createFolder`, which is for user-initiated creation and always
|
||||
/// mints a fresh id/timestamps. Last-write-wins: if the folder already exists locally, only
|
||||
/// overwrites name/parentId when `updatedAt` is strictly newer than the local row's.
|
||||
nonisolated func upsertSyncedFolder(id: UUID, name: String, parentId: UUID?, createdAt: Date, updatedAt: Date) throws {
|
||||
let existing = try dbQueue.read { db in
|
||||
try FolderRecord.fetchOne(db, key: id.uuidString)
|
||||
}
|
||||
if let existing {
|
||||
guard let existingUpdatedAt = existing.updatedAt.flatMap(Self.isoDate(from:)),
|
||||
existingUpdatedAt < updatedAt
|
||||
else { return }
|
||||
try dbQueue.write { db in
|
||||
try db.execute(
|
||||
sql: "UPDATE folders SET name = ?, parentId = ?, updatedAt = ? WHERE id = ?",
|
||||
arguments: [name, parentId?.uuidString, Self.isoString(from: updatedAt), id.uuidString]
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let record = FolderRecord(
|
||||
id: id.uuidString, name: name, sortOrder: try nextFolderSortOrder(),
|
||||
createdAt: Self.isoString(from: createdAt), parentId: parentId?.uuidString,
|
||||
updatedAt: Self.isoString(from: updatedAt)
|
||||
)
|
||||
try dbQueue.write { db in
|
||||
try record.insert(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,9 +744,12 @@ final class DatabaseService: Sendable {
|
||||
guard let id = UUID(uuidString: record.id),
|
||||
let createdAt = Self.isoDate(from: record.createdAt)
|
||||
else { return nil }
|
||||
// Falls back to createdAt if updatedAt is somehow missing (shouldn't happen post-v11
|
||||
// migration, which backfills every existing row) rather than failing the whole fetch.
|
||||
let updatedAt = record.updatedAt.flatMap(Self.isoDate(from:)) ?? createdAt
|
||||
return Folder(
|
||||
id: id, name: record.name, sortOrder: record.sortOrder, createdAt: createdAt,
|
||||
parentId: record.parentId.flatMap { UUID(uuidString: $0) }
|
||||
parentId: record.parentId.flatMap { UUID(uuidString: $0) }, updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -196,6 +196,28 @@ class GitSyncService {
|
||||
log.info("Removed orphaned export for deleted conversation: \(filename)")
|
||||
}
|
||||
|
||||
// Export the folder tree + conversation→folder assignments alongside the conversations
|
||||
// themselves, so a fresh machine's import can restore folder structure too. See
|
||||
// upsertSyncedFolder/orphanedLocalFolderIds for how the import side consumes this.
|
||||
let allFolders = try db.listFolders()
|
||||
let manifest = FolderSyncManifest(
|
||||
folders: allFolders.map {
|
||||
FolderSyncManifest.FolderEntry(
|
||||
id: $0.id.uuidString, name: $0.name, parentId: $0.parentId?.uuidString,
|
||||
createdAt: $0.createdAt, updatedAt: $0.updatedAt
|
||||
)
|
||||
},
|
||||
assignments: Dictionary(uniqueKeysWithValues: conversations.compactMap { conv in
|
||||
conv.folderId.map { (conv.id.uuidString, $0.uuidString) }
|
||||
})
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let manifestData = try encoder.encode(manifest)
|
||||
try manifestData.write(to: URL(fileURLWithPath: localPath + "/folders.json"))
|
||||
log.debug("Exported folders.json (\(allFolders.count) folders)")
|
||||
|
||||
await updateStatus()
|
||||
}
|
||||
|
||||
@@ -221,6 +243,21 @@ class GitSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Given the folder ids present in a just-pulled `folders.json` manifest and the folder ids
|
||||
/// that exist locally, returns the local ids that should be deleted (folder was removed
|
||||
/// upstream since the last sync). Same empty-manifest safety guard as
|
||||
/// `orphanedExportFilenames` — an empty manifest is indistinguishable from "haven't imported
|
||||
/// folders.json yet" (e.g. an older sync repo with no manifest at all, or a fresh clone before
|
||||
/// the first export), so treating it as "delete every local folder" would be exactly the same
|
||||
/// class of mass-deletion bug that hit conversation sync.
|
||||
nonisolated static func orphanedLocalFolderIds(
|
||||
manifestFolderIds: Set<String>,
|
||||
localFolderIds: Set<String>
|
||||
) -> [String] {
|
||||
guard !manifestFolderIds.isEmpty else { return [] }
|
||||
return localFolderIds.filter { !manifestFolderIds.contains($0) }
|
||||
}
|
||||
|
||||
/// Import conversations from markdown files
|
||||
func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) {
|
||||
try ensureCloned()
|
||||
@@ -233,6 +270,49 @@ class GitSyncService {
|
||||
return (0, 0, 0)
|
||||
}
|
||||
|
||||
// Import the folder tree + assignments before any conversation, so a brand-new
|
||||
// conversation created below can immediately reference a folder that already exists
|
||||
// locally. Missing/unparsable folders.json (older sync repos, or a fresh clone before the
|
||||
// first export) is treated as "no folders to import," not an error.
|
||||
var folderAssignments: [String: String] = [:]
|
||||
let manifestPath = localPath + "/folders.json"
|
||||
if let manifestData = try? Data(contentsOf: URL(fileURLWithPath: manifestPath)) {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
if let manifest = try? decoder.decode(FolderSyncManifest.self, from: manifestData) {
|
||||
folderAssignments = manifest.assignments
|
||||
|
||||
// Insert parents before children so parentId's foreign key reference (folders.
|
||||
// parentId references folders(id)) is always satisfied.
|
||||
let manifestFolders = manifest.folders.compactMap { entry -> Folder? in
|
||||
guard let id = UUID(uuidString: entry.id) else { return nil }
|
||||
return Folder(
|
||||
id: id, name: entry.name, createdAt: entry.createdAt,
|
||||
parentId: entry.parentId.flatMap { UUID(uuidString: $0) },
|
||||
updatedAt: entry.updatedAt
|
||||
)
|
||||
}
|
||||
for (folder, _) in Folder.orderedTree(from: manifestFolders) {
|
||||
try? db.upsertSyncedFolder(
|
||||
id: folder.id, name: folder.name, parentId: folder.parentId,
|
||||
createdAt: folder.createdAt, updatedAt: folder.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
// Delete local folders no longer present upstream — reparents their contents up
|
||||
// one level via the existing deleteFolder semantics.
|
||||
let manifestFolderIds = Set(manifest.folders.map { $0.id })
|
||||
let localFolderIds = Set((try? db.listFolders())?.map { $0.id.uuidString } ?? [])
|
||||
for idString in Self.orphanedLocalFolderIds(manifestFolderIds: manifestFolderIds, localFolderIds: localFolderIds) {
|
||||
if let id = UUID(uuidString: idString) {
|
||||
try? db.deleteFolder(id: id)
|
||||
log.info("Removed local folder no longer present in sync repo: \(idString)")
|
||||
}
|
||||
}
|
||||
log.debug("Imported folders.json (\(manifest.folders.count) folders)")
|
||||
}
|
||||
}
|
||||
|
||||
let files = try FileManager.default.contentsOfDirectory(atPath: conversationsDir)
|
||||
let mdFiles = files.filter { $0.hasSuffix(".md") }
|
||||
|
||||
@@ -254,8 +334,20 @@ class GitSyncService {
|
||||
|
||||
// Check if conversation already exists (by ID)
|
||||
if let existingId = UUID(uuidString: export.id) {
|
||||
if (try? db.loadConversation(id: existingId)) != nil {
|
||||
// Already exists - skip
|
||||
if let (existingConversation, _) = try? db.loadConversation(id: existingId) {
|
||||
// Already exists - skip re-importing its content, but still backfill a
|
||||
// folder assignment if the manifest has one and this conversation isn't
|
||||
// filed anywhere locally yet. Without this, a conversation that was synced
|
||||
// to this machine before folder sync existed (or before it was ever put in
|
||||
// a folder on any machine) would never get filed here — every conversation
|
||||
// in a multi-machine setup already exists locally by the time folders.json
|
||||
// starts carrying assignments, so this isn't an edge case, it's the normal
|
||||
// case. Never overwrites an existing local folderId, so a conversation
|
||||
// already filed (by this machine or a prior import) isn't silently moved.
|
||||
if existingConversation.folderId == nil,
|
||||
let assignedFolderId = folderAssignments[export.id].flatMap(UUID.init) {
|
||||
try? db.moveConversation(id: existingId, toFolder: assignedFolderId)
|
||||
}
|
||||
log.debug("Skipping existing conversation: \(export.name)")
|
||||
skipped += 1
|
||||
continue
|
||||
@@ -282,13 +374,18 @@ class GitSyncService {
|
||||
)
|
||||
}
|
||||
|
||||
// Import to database with primaryModel
|
||||
// Import to database with primaryModel, plus its folder assignment (if any) from
|
||||
// folders.json — only applies here at first-import; an existing local conversation
|
||||
// that's later moved to a different folder on another machine doesn't get updated,
|
||||
// matching how its content/name aren't updated either once already imported.
|
||||
let conversationId = UUID(uuidString: export.id) ?? UUID()
|
||||
let folderId = folderAssignments[export.id].flatMap { UUID(uuidString: $0) }
|
||||
_ = try db.saveConversation(
|
||||
id: conversationId,
|
||||
name: export.name,
|
||||
messages: messages,
|
||||
primaryModel: export.primaryModel
|
||||
primaryModel: export.primaryModel,
|
||||
folderId: folderId
|
||||
)
|
||||
log.info("Imported: \(export.name)")
|
||||
imported += 1
|
||||
@@ -337,23 +434,27 @@ class GitSyncService {
|
||||
### Export (Automatic)
|
||||
- Confab saves conversations to its local database
|
||||
- Auto-sync exports conversations to `conversations/*.md`
|
||||
- Your folder structure (if you organize conversations into folders) is exported to `folders.json`
|
||||
- Files are committed and pushed to this git repository
|
||||
|
||||
### Import (On New Machine)
|
||||
- Clone this repository on a new machine
|
||||
- Confab imports markdown files into its database
|
||||
- Your conversation history is restored
|
||||
- Confab imports markdown files and folders.json into its database
|
||||
- Your conversation history and folder structure are restored
|
||||
|
||||
### Sync Across Machines
|
||||
- Machine A: Chat → Auto-save → Export → Push to git
|
||||
- Machine B: Pull from git → Auto-import → Database updated
|
||||
- Conversations stay in sync across all machines
|
||||
- Folder renames/moves also sync between machines; a conversation's folder is only set
|
||||
the first time it's imported onto a new machine
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── README.md # This file
|
||||
├── folders.json # Your folder structure (auto-managed, don't edit)
|
||||
└── conversations/ # Your conversations
|
||||
├── conversation-1.md
|
||||
├── conversation-2.md
|
||||
|
||||
@@ -231,6 +231,11 @@ struct SidebarView: View {
|
||||
// conversation list modal live in its own @State — refresh ours once it closes.
|
||||
if !isShowing { loadData() }
|
||||
}
|
||||
.onChange(of: chatViewModel.showSettings) { _, isShowing in
|
||||
// Git Sync (Settings → Sync) can import conversations/folders directly into the
|
||||
// database — refresh ours once the sheet closes so they show up without a relaunch.
|
||||
if !isShowing { loadData() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -1737,7 +1737,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
} else {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
Text("Initialize Repository")
|
||||
Text("Clone Repository")
|
||||
}
|
||||
.frame(minWidth: 160)
|
||||
}
|
||||
@@ -3023,18 +3023,22 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
syncTestResult = nil
|
||||
|
||||
do {
|
||||
// Step 1: Export all conversations
|
||||
syncTestResult = "Exporting conversations..."
|
||||
try await gitSync.exportAllConversations()
|
||||
|
||||
// Step 2: Pull from remote
|
||||
// Step 1: Pull from remote first, into a working tree that hasn't been touched by
|
||||
// this round's export yet. Exporting before pulling can write files (e.g.
|
||||
// folders.json, which every machine writes to the same path) that git then refuses
|
||||
// to merge over: "untracked working tree files would be overwritten by merge".
|
||||
syncTestResult = "Pulling changes..."
|
||||
try await gitSync.pull()
|
||||
|
||||
// Step 3: Import any new conversations
|
||||
// Step 2: Import any new/updated conversations and folders from what was just pulled
|
||||
syncTestResult = "Importing conversations..."
|
||||
let result = try await gitSync.importAllConversations()
|
||||
|
||||
// Step 3: Export — now safe, re-derives working tree files from the local DB state,
|
||||
// which already reflects whatever was just imported plus this machine's own changes
|
||||
syncTestResult = "Exporting conversations..."
|
||||
try await gitSync.exportAllConversations()
|
||||
|
||||
// Step 4: Push to remote
|
||||
syncTestResult = "Pushing changes..."
|
||||
try await gitSync.push()
|
||||
|
||||
Reference in New Issue
Block a user