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:
2026-07-28 13:11:02 +02:00
parent bf328c2629
commit 24847762c4
2 changed files with 203 additions and 52 deletions
+91 -28
View File
@@ -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,22 +44,34 @@ struct SidebarView: View {
var body: some View {
VStack(spacing: 0) {
// New Chat button
// 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))
Spacer()
}
.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")
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
// Search field
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
@@ -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)
+103 -15
View File
@@ -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()