NSWorkspace.shared.open() silently drops #fragment anchors on file:// URLs, so "Fix It Myself" always landed on the Help Book index instead of the relevant section. Replaced with GitSyncManualFixSheet, an in-app sheet showing the real conflicting filenames and sync path. Also indent conversation rows one level deeper than their containing folder in the sidebar and conversation list, so nesting is visible on the conversations themselves and not just the folder headers.
626 lines
26 KiB
Swift
626 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, depth: entry.depth + 1)
|
|
}
|
|
}
|
|
} 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, depth: Int = 0) -> some View {
|
|
SidebarConversationRow(conversation: conversation)
|
|
.padding(.leading, CGFloat(depth) * 14)
|
|
.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)
|
|
}
|