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.
101 lines
3.8 KiB
Swift
101 lines
3.8 KiB
Swift
//
|
|
// Folder.swift
|
|
// Confab
|
|
//
|
|
// Model for grouping saved conversations
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of Confab.
|
|
//
|
|
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
// You may use, study, modify, and share it for any noncommercial
|
|
// purpose. Commercial use — including selling Confab or any part of
|
|
// it, standalone or bundled into another product or service —
|
|
// requires a separate commercial license from the copyright holder.
|
|
//
|
|
// See the LICENSE file or
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://confab.no>.
|
|
|
|
|
|
import Foundation
|
|
|
|
struct Folder: Identifiable, Codable, Sendable {
|
|
let id: UUID
|
|
var name: String
|
|
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,
|
|
updatedAt: Date? = nil
|
|
) {
|
|
self.id = id
|
|
self.name = name
|
|
self.sortOrder = sortOrder
|
|
self.createdAt = createdAt
|
|
self.parentId = parentId
|
|
self.updatedAt = updatedAt ?? createdAt
|
|
}
|
|
}
|
|
|
|
extension Folder {
|
|
/// Depth-first, indented ordering for flat-list display. Assumes `folders` already has the
|
|
/// desired sibling order (e.g. listFolders()'s alphabetical order) — only re-groups by
|
|
/// parent/child, preserving each existing sibling ordering.
|
|
nonisolated static func orderedTree(from folders: [Folder]) -> [(folder: Folder, depth: Int)] {
|
|
var childrenByParent: [UUID?: [Folder]] = [:]
|
|
for folder in folders {
|
|
childrenByParent[folder.parentId, default: []].append(folder)
|
|
}
|
|
var result: [(folder: Folder, depth: Int)] = []
|
|
func walk(parentId: UUID?, depth: Int, visiting: Set<UUID>) {
|
|
for folder in childrenByParent[parentId] ?? [] {
|
|
guard !visiting.contains(folder.id) else { continue } // defensive cycle guard
|
|
result.append((folder, depth))
|
|
walk(parentId: folder.id, depth: depth + 1, visiting: visiting.union([folder.id]))
|
|
}
|
|
}
|
|
walk(parentId: nil, depth: 0, visiting: [])
|
|
return result
|
|
}
|
|
|
|
/// True if `candidateId` is `ancestorId` itself, or nested anywhere below it. A single call
|
|
/// `isDescendant(target.id, of: source.id, in: folders)` rejects both a self-drop (target ==
|
|
/// source) and any deeper cycle (target currently lives under source).
|
|
nonisolated static func isDescendant(_ candidateId: UUID, of ancestorId: UUID, in folders: [Folder]) -> Bool {
|
|
var current: UUID? = candidateId
|
|
var visited: Set<UUID> = []
|
|
while let id = current, !visited.contains(id) {
|
|
if id == ancestorId { return true }
|
|
visited.insert(id)
|
|
current = folders.first(where: { $0.id == id })?.parentId
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Given an ordered tree and the set of explicitly-collapsed folder ids, returns ids whose
|
|
/// header should still render — collapsing a folder hides its whole subtree, but its own
|
|
/// header stays visible so it can be expanded again.
|
|
nonisolated static func visibleFolderIds(tree: [(folder: Folder, depth: Int)], collapsed: Set<UUID>) -> Set<UUID> {
|
|
var visible: Set<UUID> = []
|
|
var hiddenAtOrBelowDepth: Int? = nil
|
|
for (folder, depth) in tree {
|
|
if let hiddenDepth = hiddenAtOrBelowDepth, depth > hiddenDepth { continue }
|
|
hiddenAtOrBelowDepth = nil
|
|
visible.insert(folder.id)
|
|
if collapsed.contains(folder.id) { hiddenAtOrBelowDepth = depth }
|
|
}
|
|
return visible
|
|
}
|
|
}
|