Make folders collapsible, draggable, bold, and add New Folder button
Folder sections now collapse via an explicit chevron/tap header (rather than relying on platform-dependent native disclosure, which didn't render/click reliably) — same collapsed-state binding also backs Section(isExpanded:) so content visibility stays in sync. Conversations are draggable onto folder/Unfiled headers to file/unfile them. Folder names render bold. "New Folder" is now a dedicated button next to "New Chat" in the sidebar, and next to "Select" in the advanced conversation list, instead of a small icon buried in the search row.
This commit is contained in:
@@ -31,6 +31,7 @@ struct SidebarView: View {
|
||||
@State private var conversations: [Conversation] = []
|
||||
@State private var folders: [Folder] = []
|
||||
@State private var searchText = ""
|
||||
@State private var collapsedFolders: Set<UUID> = []
|
||||
|
||||
private var filteredConversations: [Conversation] {
|
||||
guard !searchText.isEmpty else { return conversations }
|
||||
@@ -43,21 +44,33 @@ struct SidebarView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// New Chat button
|
||||
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))
|
||||
Spacer()
|
||||
// New Chat / New Folder buttons
|
||||
HStack(spacing: 4) {
|
||||
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(.oaiPrimary)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { createFolderPrompt() }) {
|
||||
Image(systemName: "folder.badge.plus")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("New Folder")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
// Search field
|
||||
HStack(spacing: 6) {
|
||||
@@ -77,15 +90,6 @@ struct SidebarView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Divider().frame(height: 12)
|
||||
Button {
|
||||
createFolderPrompt()
|
||||
} label: {
|
||||
Image(systemName: "folder.badge.plus")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("New Folder")
|
||||
Button {
|
||||
chatViewModel.showConversations = true
|
||||
} label: {
|
||||
@@ -127,34 +131,27 @@ struct SidebarView: View {
|
||||
ForEach(folders) { folder in
|
||||
let folderConversations = conversationsByFolder[folder.id] ?? []
|
||||
if !folderConversations.isEmpty || searchText.isEmpty {
|
||||
Section {
|
||||
Section(isExpanded: isExpandedBinding(for: folder.id)) {
|
||||
ForEach(folderConversations) { conversation in
|
||||
conversationRow(conversation)
|
||||
}
|
||||
} header: {
|
||||
Text(folder.name)
|
||||
.contextMenu {
|
||||
Button {
|
||||
renameFolderPrompt(folder)
|
||||
} label: {
|
||||
Label("Rename Folder", systemImage: "pencil")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
deleteFolder(folder)
|
||||
} label: {
|
||||
Label("Delete Folder", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
folderHeader(folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let unfiled = conversationsByFolder[nil] ?? []
|
||||
if !unfiled.isEmpty {
|
||||
Section("Unfiled") {
|
||||
Section {
|
||||
ForEach(unfiled) { conversation in
|
||||
conversationRow(conversation)
|
||||
}
|
||||
} header: {
|
||||
Text("Unfiled")
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
handleDrop(items, toFolder: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,6 +164,71 @@ struct SidebarView: View {
|
||||
.onChange(of: chatViewModel.messages.count) { loadData() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func folderHeader(_ folder: Folder) -> 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))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
toggleCollapsed(folder.id)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
renameFolderPrompt(folder)
|
||||
} label: {
|
||||
Label("Rename Folder", systemImage: "pencil")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
deleteFolder(folder)
|
||||
} label: {
|
||||
Label("Delete Folder", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
private func isExpandedBinding(for folderId: UUID) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { !collapsedFolders.contains(folderId) },
|
||||
set: { isExpanded in
|
||||
if isExpanded {
|
||||
collapsedFolders.remove(folderId)
|
||||
} else {
|
||||
collapsedFolders.insert(folderId)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool {
|
||||
var moved = false
|
||||
for idString in items {
|
||||
guard let id = UUID(uuidString: idString),
|
||||
let conversation = conversations.first(where: { $0.id == id })
|
||||
else { continue }
|
||||
moveConversation(conversation, toFolder: folderId)
|
||||
moved = true
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func conversationRow(_ conversation: Conversation) -> some View {
|
||||
SidebarConversationRow(conversation: conversation)
|
||||
@@ -179,6 +241,7 @@ struct SidebarView: View {
|
||||
? Color.oaiAccent.opacity(0.15)
|
||||
: Color.clear
|
||||
)
|
||||
.draggable(conversation.id.uuidString)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
deleteConversation(conversation)
|
||||
|
||||
@@ -29,6 +29,7 @@ struct ConversationListView: View {
|
||||
@State private var searchText = ""
|
||||
@State private var conversations: [Conversation] = []
|
||||
@State private var folders: [Folder] = []
|
||||
@State private var collapsedFolders: Set<UUID> = []
|
||||
@State private var selectedConversations: Set<UUID> = []
|
||||
@State private var isSelecting = false
|
||||
@State private var useSemanticSearch = false
|
||||
@@ -99,6 +100,13 @@ struct ConversationListView: View {
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
createFolderPrompt()
|
||||
} label: {
|
||||
Label("New Folder", systemImage: "folder.badge.plus")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if !conversations.isEmpty {
|
||||
Button("Select") {
|
||||
isSelecting = true
|
||||
@@ -213,34 +221,27 @@ struct ConversationListView: View {
|
||||
ForEach(folders) { folder in
|
||||
let folderConversations = conversationsByFolder[folder.id] ?? []
|
||||
if !folderConversations.isEmpty || searchText.isEmpty {
|
||||
Section {
|
||||
Section(isExpanded: isExpandedBinding(for: folder.id)) {
|
||||
ForEach(folderConversations) { conversation in
|
||||
conversationRow(conversation)
|
||||
}
|
||||
} header: {
|
||||
Text(folder.name)
|
||||
.contextMenu {
|
||||
Button {
|
||||
renameFolderPrompt(folder)
|
||||
} label: {
|
||||
Label("Rename Folder", systemImage: "pencil")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
deleteFolder(folder)
|
||||
} label: {
|
||||
Label("Delete Folder", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
folderHeader(folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let unfiled = conversationsByFolder[nil] ?? []
|
||||
if !unfiled.isEmpty {
|
||||
Section("Unfiled") {
|
||||
Section {
|
||||
ForEach(unfiled) { conversation in
|
||||
conversationRow(conversation)
|
||||
}
|
||||
} header: {
|
||||
Text("Unfiled")
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
handleDrop(items, toFolder: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,6 +346,7 @@ struct ConversationListView: View {
|
||||
: Color.clear
|
||||
)
|
||||
.id(conversation.id)
|
||||
.draggable(conversation.id.uuidString)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
deleteConversation(conversation)
|
||||
@@ -401,6 +403,92 @@ struct ConversationListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func folderHeader(_ folder: Folder) -> 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))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
toggleCollapsed(folder.id)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
renameFolderPrompt(folder)
|
||||
} label: {
|
||||
Label("Rename Folder", systemImage: "pencil")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
deleteFolder(folder)
|
||||
} label: {
|
||||
Label("Delete Folder", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
private func isExpandedBinding(for folderId: UUID) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { !collapsedFolders.contains(folderId) },
|
||||
set: { isExpanded in
|
||||
if isExpanded {
|
||||
collapsedFolders.remove(folderId)
|
||||
} else {
|
||||
collapsedFolders.insert(folderId)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool {
|
||||
var moved = false
|
||||
for idString in items {
|
||||
guard let id = UUID(uuidString: idString),
|
||||
let conversation = conversations.first(where: { $0.id == id })
|
||||
else { continue }
|
||||
moveConversation(conversation, toFolder: folderId)
|
||||
moved = true
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
private func createFolderPrompt() {
|
||||
#if os(macOS)
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "New Folder"
|
||||
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)
|
||||
folders.append(folder)
|
||||
} catch {
|
||||
Log.db.error("Failed to create folder: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func loadConversations() {
|
||||
do {
|
||||
conversations = try DatabaseService.shared.listConversations()
|
||||
|
||||
Reference in New Issue
Block a user