Covers the per-file license-header comment (~80 Swift files) plus the contact/website links in README.md, PRIVACY.md, and SECURITY.md.
620 lines
26 KiB
Swift
620 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() }
|
|
}
|
|
}
|
|
|
|
@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)
|
|
}
|