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.
625 lines
26 KiB
Swift
625 lines
26 KiB
Swift
//
|
|
// SidebarView.swift
|
|
// Confab
|
|
//
|
|
// Collapsible sidebar: new chat, conversation list, status pills
|
|
//
|
|
// 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 SwiftUI
|
|
#if os(macOS)
|
|
import AppKit
|
|
#endif
|
|
|
|
struct SidebarView: View {
|
|
@Environment(ChatViewModel.self) private var chatViewModel
|
|
@State private var conversations: [Conversation] = []
|
|
@State private var folders: [Folder] = []
|
|
@State private var searchText = ""
|
|
@State private var collapsedFolders: Set<UUID> = []
|
|
@State private var selectedConversations: Set<UUID> = []
|
|
@State private var lastClickedId: UUID? = nil
|
|
|
|
private var filteredConversations: [Conversation] {
|
|
guard !searchText.isEmpty else { return conversations }
|
|
return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) }
|
|
}
|
|
|
|
private var conversationsByFolder: [UUID?: [Conversation]] {
|
|
Dictionary(grouping: filteredConversations, by: { $0.folderId })
|
|
}
|
|
|
|
private var orderedFolderTree: [(folder: Folder, depth: Int)] { Folder.orderedTree(from: folders) }
|
|
private var visibleFolderIds: Set<UUID> { Folder.visibleFolderIds(tree: orderedFolderTree, collapsed: collapsedFolders) }
|
|
|
|
/// Flattened conversation order matching what's actually rendered in the List — folders in
|
|
/// depth-first tree order (skipping collapsed ones' contents, since they're not
|
|
/// visible/selectable), then Unfiled last. Used as the anchor sequence for Shift-click range
|
|
/// selection, same pattern as ConversationListView's version.
|
|
private var visibleOrderedConversations: [Conversation] {
|
|
guard !folders.isEmpty else { return filteredConversations }
|
|
var result: [Conversation] = []
|
|
for (folder, _) in orderedFolderTree where visibleFolderIds.contains(folder.id) && !collapsedFolders.contains(folder.id) {
|
|
result.append(contentsOf: conversationsByFolder[folder.id] ?? [])
|
|
}
|
|
result.append(contentsOf: conversationsByFolder[nil] ?? [])
|
|
return result
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// New Chat / New Folder buttons — swaps to a selection toolbar while selecting
|
|
HStack(spacing: 4) {
|
|
if !selectedConversations.isEmpty {
|
|
Text("\(selectedConversations.count) selected")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Menu {
|
|
if !folders.isEmpty {
|
|
ForEach(orderedFolderTree, id: \.folder.id) { entry in
|
|
Button(String(repeating: " ", count: entry.depth) + entry.folder.name) {
|
|
moveSelectedToFolder(entry.folder.id)
|
|
}
|
|
}
|
|
Divider()
|
|
}
|
|
Button("Remove from Folder") {
|
|
moveSelectedToFolder(nil)
|
|
}
|
|
} label: {
|
|
Image(systemName: "folder")
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.confabPrimary)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.fixedSize()
|
|
.help("Move to Folder")
|
|
|
|
Button { selectedConversations.removeAll() } label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.keyboardShortcut(.escape, modifiers: [])
|
|
.help("Cancel Selection")
|
|
} else {
|
|
Button(action: { chatViewModel.newConversation() }) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "square.and.pencil")
|
|
.font(.system(size: 14))
|
|
Text("New Chat")
|
|
.font(.system(size: 14, weight: .medium))
|
|
}
|
|
.foregroundColor(.confabPrimary)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Spacer()
|
|
|
|
Button(action: { createFolderPrompt() }) {
|
|
Image(systemName: "folder.badge.plus")
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.confabPrimary)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("New Folder")
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
|
|
// Search field
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
TextField("Search conversations…", text: $searchText)
|
|
.textFieldStyle(.plain)
|
|
.font(.system(size: 13))
|
|
if !searchText.isEmpty {
|
|
Button {
|
|
searchText = ""
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
Divider().frame(height: 12)
|
|
Button {
|
|
chatViewModel.showConversations = true
|
|
} label: {
|
|
Image(systemName: "slider.horizontal.3")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Advanced search — semantic search, bulk delete, export")
|
|
}
|
|
.padding(7)
|
|
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6))
|
|
.padding(.horizontal, 8)
|
|
.padding(.bottom, 6)
|
|
|
|
Divider()
|
|
|
|
// Conversation list
|
|
if filteredConversations.isEmpty {
|
|
Spacer()
|
|
VStack(spacing: 8) {
|
|
Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass")
|
|
.font(.title2)
|
|
.foregroundStyle(.tertiary)
|
|
Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
} else if folders.isEmpty {
|
|
List {
|
|
ForEach(filteredConversations) { conversation in
|
|
conversationRow(conversation)
|
|
}
|
|
}
|
|
.listStyle(.sidebar)
|
|
} else {
|
|
List {
|
|
ForEach(orderedFolderTree, id: \.folder.id) { entry in
|
|
if visibleFolderIds.contains(entry.folder.id) {
|
|
let folderConversations = conversationsByFolder[entry.folder.id] ?? []
|
|
if !folderConversations.isEmpty || searchText.isEmpty {
|
|
Section {
|
|
if !collapsedFolders.contains(entry.folder.id) {
|
|
ForEach(folderConversations) { conversation in
|
|
conversationRow(conversation)
|
|
}
|
|
}
|
|
} header: {
|
|
folderHeader(entry.folder, depth: entry.depth)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let unfiled = conversationsByFolder[nil] ?? []
|
|
if !unfiled.isEmpty {
|
|
Section {
|
|
ForEach(unfiled) { conversation in
|
|
conversationRow(conversation)
|
|
}
|
|
} header: {
|
|
Text("Unfiled")
|
|
.dropDestination(for: String.self) { items, _ in
|
|
_ = handleDrop(items, toFolder: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.sidebar)
|
|
}
|
|
|
|
}
|
|
.onAppear {
|
|
loadData()
|
|
collapsedFolders = SettingsService.shared.collapsedFolderIds
|
|
}
|
|
.onChange(of: chatViewModel.currentConversationName) { loadData() }
|
|
.onChange(of: chatViewModel.messages.count) { loadData() }
|
|
.onChange(of: chatViewModel.showConversations) { _, isShowing in
|
|
// Folders/conversations created, renamed, or deleted in the advanced
|
|
// 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
|
|
private func folderHeader(_ folder: Folder, depth: Int) -> some View {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.rotationEffect(.degrees(collapsedFolders.contains(folder.id) ? 0 : 90))
|
|
Text(folder.name)
|
|
.font(.system(size: 12, weight: .bold))
|
|
}
|
|
.padding(.leading, CGFloat(depth) * 14)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
toggleCollapsed(folder.id)
|
|
}
|
|
}
|
|
.contextMenu {
|
|
Button {
|
|
createFolderPrompt(parentId: folder.id)
|
|
} label: {
|
|
Label("New Subfolder…", systemImage: "folder.badge.plus")
|
|
}
|
|
Button {
|
|
renameFolderPrompt(folder)
|
|
} label: {
|
|
Label("Rename Folder", systemImage: "pencil")
|
|
}
|
|
Button(role: .destructive) {
|
|
deleteFolder(folder)
|
|
} label: {
|
|
Label("Delete Folder", systemImage: "trash")
|
|
}
|
|
}
|
|
.draggable(DraggedItem.folder(folder.id).rawValue) {
|
|
Text(folder.name)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6))
|
|
}
|
|
.dropDestination(for: String.self) { items, _ in
|
|
_ = handleDrop(items, toFolder: folder.id)
|
|
}
|
|
}
|
|
|
|
private func toggleCollapsed(_ folderId: UUID) {
|
|
if collapsedFolders.contains(folderId) {
|
|
collapsedFolders.remove(folderId)
|
|
} else {
|
|
collapsedFolders.insert(folderId)
|
|
}
|
|
SettingsService.shared.collapsedFolderIds = collapsedFolders
|
|
}
|
|
|
|
private func handleDrop(_ items: [String], toFolder targetFolderId: UUID?) -> Bool {
|
|
var moved = false
|
|
for raw in items {
|
|
guard let item = DraggedItem(rawValue: raw) else { continue }
|
|
switch item {
|
|
case .conversations(let ids):
|
|
for id in ids {
|
|
guard let conversation = conversations.first(where: { $0.id == id }) else { continue }
|
|
moveConversation(conversation, toFolder: targetFolderId)
|
|
moved = true
|
|
}
|
|
case .folder(let sourceId):
|
|
guard sourceId != targetFolderId else { continue }
|
|
if let targetFolderId, Folder.isDescendant(targetFolderId, of: sourceId, in: folders) { continue }
|
|
do {
|
|
try DatabaseService.shared.moveFolder(id: sourceId, toParent: targetFolderId)
|
|
if let i = folders.firstIndex(where: { $0.id == sourceId }) { folders[i].parentId = targetFolderId }
|
|
moved = true
|
|
} catch {
|
|
Log.db.error("Failed to move folder: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
return moved
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func conversationRow(_ conversation: Conversation) -> some View {
|
|
SidebarConversationRow(conversation: conversation)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture(count: 2) {
|
|
chatViewModel.loadConversation(conversation)
|
|
selectedConversations.removeAll()
|
|
}
|
|
.onTapGesture(count: 1) {
|
|
handleRowTap(conversation)
|
|
}
|
|
.listRowBackground(
|
|
chatViewModel.currentConversationName == conversation.name
|
|
? Color.confabAccent.opacity(0.15)
|
|
: selectedConversations.contains(conversation.id)
|
|
? Color(nsColor: .selectedContentBackgroundColor).opacity(0.35)
|
|
: Color.clear
|
|
)
|
|
.draggable(DraggedItem.conversations(
|
|
selectedConversations.contains(conversation.id) && selectedConversations.count > 1
|
|
? Array(selectedConversations) : [conversation.id]
|
|
).rawValue) {
|
|
if selectedConversations.contains(conversation.id) && selectedConversations.count > 1 {
|
|
Text("\(selectedConversations.count) conversations")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6))
|
|
} else {
|
|
Text(conversation.name)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6))
|
|
}
|
|
}
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
Button(role: .destructive) {
|
|
deleteConversation(conversation)
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
Button {
|
|
renameConversation(conversation)
|
|
} label: {
|
|
Label("Rename", systemImage: "pencil")
|
|
}
|
|
.tint(.orange)
|
|
}
|
|
.contextMenu {
|
|
Menu {
|
|
if conversation.folderId != nil {
|
|
Button {
|
|
moveConversationOrSelection(conversation, toFolder: nil)
|
|
} label: {
|
|
Label("Remove from Folder", systemImage: "folder.badge.minus")
|
|
}
|
|
Divider()
|
|
}
|
|
ForEach(orderedFolderTree, id: \.folder.id) { entry in
|
|
if entry.folder.id != conversation.folderId {
|
|
Button {
|
|
moveConversationOrSelection(conversation, toFolder: entry.folder.id)
|
|
} label: {
|
|
Text(String(repeating: " ", count: entry.depth) + entry.folder.name)
|
|
}
|
|
}
|
|
}
|
|
Divider()
|
|
Button {
|
|
createFolderPrompt(andMove: conversation)
|
|
} label: {
|
|
Label("New Folder…", systemImage: "folder.badge.plus")
|
|
}
|
|
} label: {
|
|
Label(selectedConversations.contains(conversation.id) && selectedConversations.count > 1
|
|
? "Move \(selectedConversations.count) to Folder" : "Move to Folder", systemImage: "folder")
|
|
}
|
|
Button {
|
|
renameConversation(conversation)
|
|
} label: {
|
|
Label("Rename", systemImage: "pencil")
|
|
}
|
|
Button(role: .destructive) {
|
|
deleteConversation(conversation)
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadData() {
|
|
conversations = (try? DatabaseService.shared.listConversations()) ?? []
|
|
folders = (try? DatabaseService.shared.listFolders()) ?? []
|
|
}
|
|
|
|
private func deleteConversation(_ conversation: Conversation) {
|
|
_ = try? DatabaseService.shared.deleteConversation(id: conversation.id)
|
|
withAnimation {
|
|
conversations.removeAll { $0.id == conversation.id }
|
|
}
|
|
selectedConversations.remove(conversation.id)
|
|
GitSyncService.shared.syncAfterDeletion()
|
|
}
|
|
|
|
private func renameConversation(_ conversation: Conversation) {
|
|
#if os(macOS)
|
|
let alert = NSAlert()
|
|
alert.messageText = "Rename Conversation"
|
|
alert.addButton(withTitle: "Rename")
|
|
alert.addButton(withTitle: "Cancel")
|
|
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
|
input.stringValue = conversation.name
|
|
input.selectText(nil)
|
|
alert.accessoryView = input
|
|
alert.window.initialFirstResponder = input
|
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
let newName = input.stringValue.trimmingCharacters(in: .whitespaces)
|
|
guard !newName.isEmpty, newName != conversation.name else { return }
|
|
do {
|
|
_ = try DatabaseService.shared.updateConversation(id: conversation.id, name: newName, messages: nil)
|
|
if let i = conversations.firstIndex(where: { $0.id == conversation.id }) {
|
|
conversations[i].name = newName
|
|
conversations[i].updatedAt = Date()
|
|
}
|
|
chatViewModel.didRenameConversation(id: conversation.id, newName: newName)
|
|
} catch {
|
|
Log.db.error("Failed to rename conversation: \(error.localizedDescription)")
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func moveConversation(_ conversation: Conversation, toFolder folderId: UUID?) {
|
|
do {
|
|
try DatabaseService.shared.moveConversation(id: conversation.id, toFolder: folderId)
|
|
if let i = conversations.firstIndex(where: { $0.id == conversation.id }) {
|
|
conversations[i].folderId = folderId
|
|
}
|
|
} catch {
|
|
Log.db.error("Failed to move conversation: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
/// Moves every currently-selected conversation to a folder (or removes them all from their
|
|
/// folders if `folderId` is nil).
|
|
private func moveSelectedToFolder(_ folderId: UUID?) {
|
|
for id in selectedConversations {
|
|
guard let conversation = conversations.first(where: { $0.id == id }) else { continue }
|
|
moveConversation(conversation, toFolder: folderId)
|
|
}
|
|
}
|
|
|
|
/// Right-clicking a conversation that's part of a multi-item selection moves the whole
|
|
/// selection; right-clicking a single (non-selected, or lone-selected) row moves just that one.
|
|
private func moveConversationOrSelection(_ conversation: Conversation, toFolder folderId: UUID?) {
|
|
if selectedConversations.contains(conversation.id) && selectedConversations.count > 1 {
|
|
moveSelectedToFolder(folderId)
|
|
} else {
|
|
moveConversation(conversation, toFolder: folderId)
|
|
}
|
|
}
|
|
|
|
/// Standard macOS row-click handling: ⌘-click toggles the individual row (additive), Shift-click
|
|
/// extends/creates a contiguous range from the last-clicked row, and a plain click replaces the
|
|
/// selection with just this row. Never opens the conversation — that's double-click's job.
|
|
private func handleRowTap(_ conversation: Conversation) {
|
|
#if os(macOS)
|
|
let modifiers = NSEvent.modifierFlags
|
|
if modifiers.contains(.command) {
|
|
if selectedConversations.contains(conversation.id) {
|
|
selectedConversations.remove(conversation.id)
|
|
} else {
|
|
selectedConversations.insert(conversation.id)
|
|
}
|
|
lastClickedId = conversation.id
|
|
return
|
|
}
|
|
if modifiers.contains(.shift) {
|
|
let orderedIds = visibleOrderedConversations.map { $0.id }
|
|
selectedConversations.formUnion(
|
|
ConversationListView.idsInRange(orderedIds: orderedIds, anchorId: lastClickedId, targetId: conversation.id)
|
|
)
|
|
lastClickedId = conversation.id
|
|
return
|
|
}
|
|
#endif
|
|
selectedConversations = [conversation.id]
|
|
lastClickedId = conversation.id
|
|
}
|
|
|
|
private func createFolderPrompt(andMove conversation: Conversation? = nil, parentId: UUID? = nil) {
|
|
#if os(macOS)
|
|
let alert = NSAlert()
|
|
alert.messageText = parentId == nil ? "New Folder" : "New Subfolder"
|
|
alert.addButton(withTitle: "Create")
|
|
alert.addButton(withTitle: "Cancel")
|
|
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
|
alert.accessoryView = input
|
|
alert.window.initialFirstResponder = input
|
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
let name = input.stringValue.trimmingCharacters(in: .whitespaces)
|
|
guard !name.isEmpty else { return }
|
|
do {
|
|
let folder = try DatabaseService.shared.createFolder(name: name, parentId: parentId)
|
|
folders.append(folder)
|
|
sortFolders()
|
|
if let conversation = conversation {
|
|
moveConversation(conversation, toFolder: folder.id)
|
|
}
|
|
} catch {
|
|
Log.db.error("Failed to create folder: \(error.localizedDescription)")
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func sortFolders() {
|
|
folders.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
|
}
|
|
|
|
private func renameFolderPrompt(_ folder: Folder) {
|
|
#if os(macOS)
|
|
let alert = NSAlert()
|
|
alert.messageText = "Rename Folder"
|
|
alert.addButton(withTitle: "Rename")
|
|
alert.addButton(withTitle: "Cancel")
|
|
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
|
input.stringValue = folder.name
|
|
input.selectText(nil)
|
|
alert.accessoryView = input
|
|
alert.window.initialFirstResponder = input
|
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
let newName = input.stringValue.trimmingCharacters(in: .whitespaces)
|
|
guard !newName.isEmpty, newName != folder.name else { return }
|
|
do {
|
|
try DatabaseService.shared.renameFolder(id: folder.id, name: newName)
|
|
if let i = folders.firstIndex(where: { $0.id == folder.id }) {
|
|
folders[i].name = newName
|
|
}
|
|
sortFolders()
|
|
} catch {
|
|
Log.db.error("Failed to rename folder: \(error.localizedDescription)")
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func deleteFolder(_ folder: Folder) {
|
|
do {
|
|
try DatabaseService.shared.deleteFolder(id: folder.id)
|
|
// Matches the DB's reparent-up-one-level semantics: children and conversations
|
|
// filed directly in this folder move to its own parent (nil if it was top-level),
|
|
// not blanket-unfiled.
|
|
let parentId = folder.parentId
|
|
folders.removeAll { $0.id == folder.id }
|
|
for i in folders.indices where folders[i].parentId == folder.id {
|
|
folders[i].parentId = parentId
|
|
}
|
|
for i in conversations.indices where conversations[i].folderId == folder.id {
|
|
conversations[i].folderId = parentId
|
|
}
|
|
} catch {
|
|
Log.db.error("Failed to delete folder: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sidebar conversation row
|
|
|
|
struct SidebarConversationRow: View {
|
|
let conversation: Conversation
|
|
|
|
private var formattedDate: String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "dd.MM.yyyy"
|
|
return formatter.string(from: conversation.updatedAt)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(conversation.name)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
HStack(spacing: 4) {
|
|
Text("^[\(conversation.messageCount) message](inflect: true)")
|
|
.font(.system(size: 11))
|
|
Text("·")
|
|
.font(.system(size: 11))
|
|
Text(formattedDate)
|
|
.font(.system(size: 11))
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
SidebarView()
|
|
.environment(ChatViewModel())
|
|
.frame(width: 240, height: 600)
|
|
}
|