From 7d5929dc8cc836bf007f96eb9eaedd23d3d79f81 Mon Sep 17 00:00:00 2001 From: J2025 Date: Wed, 10 Sep 2025 08:25:03 +0200 Subject: [PATCH] feat: Add right-click folder creation in sidebar - Enhanced Folder model to support tabs with order property - Added folder relationship to Tab model for organizing tabs into folders - Implemented folder management methods in TabManager (create, rename, delete, move tabs) - Created FolderView component for displaying folders in sidebar with expand/collapse functionality - Added CreateFolderSheet for folder creation dialog - Updated ContainerView with right-click context menu for creating folders - Enhanced TabItem context menu with folder management options - Implemented drag-and-drop support for moving tabs into folders This feature allows users to right-click in the sidebar to create folders for better tab organization. Users can drag tabs into folders, expand/collapse folders, rename them, and move tabs between folders. --- ora/Models/Folder.swift | 6 +- ora/Models/Tab.swift | 1 + ora/Modules/Sidebar/ContainerView.swift | 48 ++++- ora/Modules/Sidebar/CreateFolderSheet.swift | 52 ++++++ ora/Modules/Sidebar/FolderView.swift | 197 ++++++++++++++++++++ ora/Services/TabManager.swift | 41 +++- ora/UI/TabItem.swift | 17 ++ 7 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 ora/Modules/Sidebar/CreateFolderSheet.swift create mode 100644 ora/Modules/Sidebar/FolderView.swift diff --git a/ora/Models/Folder.swift b/ora/Models/Folder.swift index 0f79bcb0..e1d1c457 100644 --- a/ora/Models/Folder.swift +++ b/ora/Models/Folder.swift @@ -8,17 +8,21 @@ class Folder: ObservableObject, Identifiable { var id: UUID var name: String var isOpened: Bool + var order: Int + @Relationship(deleteRule: .nullify) var tabs: [Tab] = [] @Relationship(inverse: \TabContainer.folders) var container: TabContainer init( id: UUID = UUID(), name: String, isOpened: Bool = false, + order: Int = 0, container: TabContainer ) { - self.id = UUID() + self.id = id self.name = name self.isOpened = isOpened + self.order = order self.container = container } } diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 220ef238..4a53bd3f 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -52,6 +52,7 @@ class Tab: ObservableObject, Identifiable { @Transient @Published var hoveredLinkURL: String? @Relationship(inverse: \TabContainer.tabs) var container: TabContainer + @Relationship(inverse: \Folder.tabs) var folder: Folder? init( id: UUID = UUID(), diff --git a/ora/Modules/Sidebar/ContainerView.swift b/ora/Modules/Sidebar/ContainerView.swift index ed3559e2..0cb385df 100644 --- a/ora/Modules/Sidebar/ContainerView.swift +++ b/ora/Modules/Sidebar/ContainerView.swift @@ -8,6 +8,8 @@ struct ContainerView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var tabManager: TabManager @State private var draggedItem: UUID? + @State private var showCreateFolderDialog = false + @State private var newFolderName = "" var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -36,8 +38,24 @@ struct ContainerView: View { containers: containers ) Divider() + + // Display folders + ForEach(folders) { folder in + FolderView( + folder: folder, + draggedItem: $draggedItem, + onDrag: dragTab, + onSelect: selectTab, + onPinToggle: togglePin, + onFavoriteToggle: toggleFavorite, + onClose: removeTab, + onMoveToContainer: moveTab, + availableContainers: containers + ) + } + NormalTabsList( - tabs: normalTabs, + tabs: normalTabsNotInFolders, draggedItem: $draggedItem, onDrag: dragTab, onSelect: selectTab, @@ -51,6 +69,23 @@ struct ContainerView: View { } } .modifier(WindowDragIfAvailable()) + .contextMenu { + Button("Create New Folder") { + showCreateFolderDialog = true + } + } + .sheet(isPresented: $showCreateFolderDialog) { + CreateFolderSheet( + isPresented: $showCreateFolderDialog, + folderName: $newFolderName, + onCreate: { + if !newFolderName.isEmpty { + _ = tabManager.createFolder(name: newFolderName, in: container) + newFolderName = "" + } + } + ) + } } private var favoriteTabs: [Tab] { @@ -70,6 +105,17 @@ struct ContainerView: View { .sorted(by: { $0.order > $1.order }) .filter { $0.type == .normal } } + + private var normalTabsNotInFolders: [Tab] { + return container.tabs + .sorted(by: { $0.order > $1.order }) + .filter { $0.type == .normal && $0.folder == nil } + } + + private var folders: [Folder] { + return container.folders + .sorted(by: { $0.order < $1.order }) + } private func addNewTab() { appState.showLauncher = true diff --git a/ora/Modules/Sidebar/CreateFolderSheet.swift b/ora/Modules/Sidebar/CreateFolderSheet.swift new file mode 100644 index 00000000..20e2a13b --- /dev/null +++ b/ora/Modules/Sidebar/CreateFolderSheet.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct CreateFolderSheet: View { + @Binding var isPresented: Bool + @Binding var folderName: String + let onCreate: () -> Void + + @Environment(\.theme) private var theme + @FocusState private var isFocused: Bool + + var body: some View { + VStack(spacing: 20) { + Text("Create New Folder") + .font(.headline) + .foregroundColor(theme.foreground) + + TextField("Folder name", text: $folderName) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .focused($isFocused) + .onSubmit { + if !folderName.isEmpty { + onCreate() + isPresented = false + } + } + + HStack(spacing: 12) { + Button("Cancel") { + folderName = "" + isPresented = false + } + .keyboardShortcut(.escape) + + Button("Create") { + if !folderName.isEmpty { + onCreate() + isPresented = false + } + } + .keyboardShortcut(.return) + .disabled(folderName.isEmpty) + } + } + .padding() + .frame(width: 300) + .background(theme.solidWindowBackgroundColor) + .onAppear { + folderName = "New Folder" + isFocused = true + } + } +} diff --git a/ora/Modules/Sidebar/FolderView.swift b/ora/Modules/Sidebar/FolderView.swift new file mode 100644 index 00000000..c124db85 --- /dev/null +++ b/ora/Modules/Sidebar/FolderView.swift @@ -0,0 +1,197 @@ +import SwiftUI +import SwiftData + +struct FolderView: View { + let folder: Folder + @Binding var draggedItem: UUID? + let onDrag: (UUID) -> NSItemProvider + let onSelect: (Tab) -> Void + let onPinToggle: (Tab) -> Void + let onFavoriteToggle: (Tab) -> Void + let onClose: (Tab) -> Void + let onMoveToContainer: (Tab, TabContainer) -> Void + let availableContainers: [TabContainer] + + @EnvironmentObject var tabManager: TabManager + @Environment(\.theme) private var theme + @State private var isHovering = false + @State private var isEditingName = false + @State private var editedName = "" + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + // Folder header + HStack { + Image(systemName: folder.isOpened ? "chevron.down" : "chevron.right") + .font(.system(size: 10)) + .foregroundColor(theme.foreground.opacity(0.6)) + .onTapGesture { + tabManager.toggleFolderOpen(folder) + } + + Image(systemName: "folder.fill") + .font(.system(size: 12)) + .foregroundColor(theme.foreground.opacity(0.8)) + + if isEditingName { + TextField("Folder name", text: $editedName, onCommit: { + if !editedName.isEmpty { + tabManager.renameFolder(folder, newName: editedName) + } + isEditingName = false + }) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 13)) + .foregroundColor(theme.foreground) + .onAppear { + editedName = folder.name + } + } else { + Text(folder.name) + .font(.system(size: 13)) + .foregroundColor(theme.foreground) + .lineLimit(1) + .onTapGesture(count: 2) { + isEditingName = true + } + } + + Spacer() + + if folder.tabs.count > 0 { + Text("\(folder.tabs.count)") + .font(.system(size: 10)) + .foregroundColor(theme.foreground.opacity(0.5)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(theme.foreground.opacity(0.1)) + .cornerRadius(4) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(isHovering ? theme.activeTabBackground.opacity(0.2) : Color.clear) + .cornerRadius(6) + .onHover { isHovering = $0 } + .contextMenu { + Button("Rename Folder") { + isEditingName = true + } + + if folder.tabs.isEmpty { + Button("Delete Folder") { + tabManager.deleteFolder(folder) + } + } else { + Button("Delete Folder and Move Tabs Out") { + tabManager.deleteFolder(folder) + } + } + } + + // Folder contents (tabs) + if folder.isOpened { + VStack(alignment: .leading, spacing: 2) { + ForEach(folder.tabs.sorted(by: { $0.order > $1.order })) { tab in + TabItem( + tab: tab, + isSelected: tabManager.isActive(tab), + isDragging: draggedItem == tab.id, + onTap: { onSelect(tab) }, + onPinToggle: { onPinToggle(tab) }, + onFavoriteToggle: { onFavoriteToggle(tab) }, + onClose: { onClose(tab) }, + onMoveToContainer: { onMoveToContainer(tab, $0) }, + availableContainers: availableContainers + ) + .padding(.leading, 20) + .onDrag { onDrag(tab.id) } + .onDrop( + of: [.text], + delegate: FolderTabDropDelegate( + folder: folder, + targetTab: tab, + draggedItem: $draggedItem, + tabManager: tabManager + ) + ) + } + } + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: folder.tabs.map(\.id)) + } + } + .onDrop( + of: [.text], + delegate: FolderDropDelegate( + folder: folder, + draggedItem: $draggedItem, + tabManager: tabManager + ) + ) + } +} + +// MARK: - Drop Delegates + +struct FolderDropDelegate: DropDelegate { + let folder: Folder + @Binding var draggedItem: UUID? + let tabManager: TabManager + + func validateDrop(info: DropInfo) -> Bool { + return info.hasItemsConforming(to: [.text]) + } + + func dropEntered(info: DropInfo) { + // Visual feedback when hovering + } + + func performDrop(info: DropInfo) -> Bool { + guard let draggedTabId = draggedItem, + let tab = folder.container.tabs.first(where: { $0.id == draggedTabId }) else { + return false + } + + withAnimation { + tabManager.moveTabToFolder(tab, folder: folder) + } + + return true + } +} + +struct FolderTabDropDelegate: DropDelegate { + let folder: Folder + let targetTab: Tab + @Binding var draggedItem: UUID? + let tabManager: TabManager + + func validateDrop(info: DropInfo) -> Bool { + return info.hasItemsConforming(to: [.text]) + } + + func performDrop(info: DropInfo) -> Bool { + guard let draggedTabId = draggedItem, + let draggedTab = folder.container.tabs.first(where: { $0.id == draggedTabId }), + draggedTab.id != targetTab.id else { + return false + } + + withAnimation { + // Move tab to folder if not already in it + if draggedTab.folder != folder { + tabManager.moveTabToFolder(draggedTab, folder: folder) + } + + // Reorder within folder + let draggedOrder = draggedTab.order + let targetOrder = targetTab.order + + if draggedOrder != targetOrder { + folder.container.reorderTabs(from: draggedTab, to: targetTab) + } + } + + return true + } +} diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 71731401..23fa7644 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -363,7 +363,7 @@ class TabManager: ObservableObject { if message.name == "listener", let url = message.body as? String { - // You can update the active tab’s url if needed + // You can update the active tab's url if needed DispatchQueue.main.async { if let validURL = URL(string: url) { self.activeTab?.url = validURL @@ -375,4 +375,43 @@ class TabManager: ObservableObject { } } } + + // MARK: - Folder Management + + func createFolder(name: String, in container: TabContainer) -> Folder { + let maxOrder = container.folders.map { $0.order }.max() ?? 0 + let folder = Folder( + name: name, + isOpened: true, + order: maxOrder + 1, + container: container + ) + modelContext.insert(folder) + try? modelContext.save() + return folder + } + + func renameFolder(_ folder: Folder, newName: String) { + folder.name = newName + try? modelContext.save() + } + + func deleteFolder(_ folder: Folder) { + // Move tabs back to container before deleting folder + for tab in folder.tabs { + tab.folder = nil + } + modelContext.delete(folder) + try? modelContext.save() + } + + func moveTabToFolder(_ tab: Tab, folder: Folder?) { + tab.folder = folder + try? modelContext.save() + } + + func toggleFolderOpen(_ folder: Folder) { + folder.isOpened.toggle() + try? modelContext.save() + } } diff --git a/ora/UI/TabItem.swift b/ora/UI/TabItem.swift index e87339d3..e7682e80 100644 --- a/ora/UI/TabItem.swift +++ b/ora/UI/TabItem.swift @@ -219,6 +219,23 @@ struct TabItem: View { } } } + + Menu("Move to Folder") { + if tab.folder != nil { + Button("Remove from Folder") { + tabManager.moveTabToFolder(tab, folder: nil) + } + Divider() + } + + ForEach(tab.container.folders.sorted(by: { $0.order < $1.order })) { folder in + if tab.folder?.id != folder.id { + Button(action: { tabManager.moveTabToFolder(tab, folder: folder) }) { + Label(folder.name, systemImage: "folder") + } + } + } + } Divider()