From 24847762c4261cf453ade4449d9d237018bdc76b Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 28 Jul 2026 13:11:02 +0200 Subject: [PATCH] Make folders collapsible, draggable, bold, and add New Folder button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- oAI/Views/Main/SidebarView.swift | 137 ++++++++++++++----- oAI/Views/Screens/ConversationListView.swift | 118 ++++++++++++++-- 2 files changed, 203 insertions(+), 52 deletions(-) diff --git a/oAI/Views/Main/SidebarView.swift b/oAI/Views/Main/SidebarView.swift index a1ce611..c327b54 100644 --- a/oAI/Views/Main/SidebarView.swift +++ b/oAI/Views/Main/SidebarView.swift @@ -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 = [] 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 { + 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) diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index c297bf8..5d5099f 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -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 = [] @State private var selectedConversations: Set = [] @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 { + 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()