From cd1310738a365cec2f9bdab79372638d3d004c0f Mon Sep 17 00:00:00 2001 From: Jack Hogan Date: Wed, 22 Oct 2025 10:42:33 -0400 Subject: [PATCH 01/12] Parenting implmenetation --- ora/Models/Tab.swift | 7 ++++ .../Sidebar/TabList/NormalTabsList.swift | 39 ++++++++++++++++++- ora/Services/TabManager.swift | 5 ++- ora/Services/WebViewNavigationDelegate.swift | 3 +- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index a180b267..11fcb914 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -56,6 +56,8 @@ class Tab: ObservableObject, Identifiable { @Transient var isPrivate: Bool = false @Relationship(inverse: \TabContainer.tabs) var container: TabContainer + @Relationship(deleteRule: .cascade) var children: [Tab] + @Relationship(inverse: \Tab.children) var parent: Tab? /// Whether this tab is considered alive (recently accessed) var isAlive: Bool { @@ -66,6 +68,7 @@ class Tab: ObservableObject, Identifiable { init( id: UUID = UUID(), + parent: Tab? = nil, url: URL, title: String, favicon: URL? = nil, @@ -92,6 +95,10 @@ class Tab: ObservableObject, Identifiable { self.container = container // Initialize webView with provided configuration or default + // Tab hierarchy setup + self.children = [] + self.parent = parent + let config = TabScriptHandler() self.webView = WKWebView( diff --git a/ora/Modules/Sidebar/TabList/NormalTabsList.swift b/ora/Modules/Sidebar/TabList/NormalTabsList.swift index 00c4794e..7ecd5f6c 100644 --- a/ora/Modules/Sidebar/TabList/NormalTabsList.swift +++ b/ora/Modules/Sidebar/TabList/NormalTabsList.swift @@ -1,6 +1,38 @@ import SwiftData import SwiftUI +private struct IndentedTab: Identifiable { + let tab: Tab + let indentationLevel: Int + + var id: UUID { tab.id } +} + +private func tabsSortedByParent( + _ tabs: [Tab], + withParentTabSelector parentTabSelector: UUID? = nil, + withIndentation indentation: Int = 0 +) -> [IndentedTab] { + // Start by finding only parent tags, then recurse down through + var output = [IndentedTab]() + let roots = tabs.filter { $0.parent?.id == parentTabSelector }.sorted( + by: { $0.order < $1.order + }) + for root in roots { + output.append(IndentedTab(tab: root, indentationLevel: indentation)) + output + .append( + contentsOf: tabsSortedByParent( + root.children, + withParentTabSelector: root.id, + withIndentation: indentation + 1 + ) + ) + } + + return output +} + struct NormalTabsList: View { let tabs: [Tab] @Binding var draggedItem: UUID? @@ -23,7 +55,8 @@ struct NormalTabsList: View { var body: some View { VStack(spacing: 8) { NewTabButton(addNewTab: onAddNewTab) - ForEach(tabs) { tab in + ForEach(tabsSortedByParent(tabs)) { iTab in + let tab = iTab.tab TabItem( tab: tab, isSelected: tabManager.isActive(tab), @@ -50,6 +83,10 @@ struct NormalTabsList: View { removal: .opacity.combined(with: .move(edge: .top)) )) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: shouldAnimate(tab)) + .padding( + .leading, + CGFloat(integerLiteral: iTab.indentationLevel * 8) + ) } } .onDrop( diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 7e4cd23b..9ff5a12b 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -247,7 +247,8 @@ class TabManager: ObservableObject { downloadManager: DownloadManager? = nil, focusAfterOpening: Bool = true, isPrivate: Bool, - loadSilently: Bool = false + loadSilently: Bool = false, + parentingTo tab: Tab? = nil ) -> Tab? { if let container = activeContainer { if let host = url.host { @@ -256,7 +257,7 @@ class TabManager: ObservableObject { let cleanHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host let newTab = Tab( - url: url, + parent: tab, url: url, title: cleanHost, favicon: faviconURL, container: container, diff --git a/ora/Services/WebViewNavigationDelegate.swift b/ora/Services/WebViewNavigationDelegate.swift index 9205bac0..c21c493d 100644 --- a/ora/Services/WebViewNavigationDelegate.swift +++ b/ora/Services/WebViewNavigationDelegate.swift @@ -295,7 +295,8 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { url: url, historyManager: historyManager, downloadManager: downloadManager, - isPrivate: tab.isPrivate + isPrivate: tab.isPrivate, + parentingTo: tab ) } From 5ef3f61c70a1b0da1d8fe97ac0120207052058b7 Mon Sep 17 00:00:00 2001 From: Jack Hogan Date: Wed, 22 Oct 2025 12:34:31 -0400 Subject: [PATCH 02/12] Added better outlining for how tabs will be reodered --- ora/Models/TabContainer.swift | 3 + ora/Modules/Sidebar/TabList/FavTabsList.swift | 4 +- .../Sidebar/TabList/NormalTabsList.swift | 88 +++++++++++++------ .../Sidebar/TabList/PinnedTabsList.swift | 3 + ora/Services/TabDropDelegate.swift | 36 ++++++-- ora/UI/TabItem.swift | 10 +++ 6 files changed, 110 insertions(+), 34 deletions(-) diff --git a/ora/Models/TabContainer.swift b/ora/Models/TabContainer.swift index d6038675..af118d39 100644 --- a/ora/Models/TabContainer.swift +++ b/ora/Models/TabContainer.swift @@ -30,6 +30,9 @@ class TabContainer: ObservableObject, Identifiable { } func reorderTabs(from: Tab, to: Tab) { + from.parent?.children.removeAll(where: { $0.id == from.id }) + to.children.append(from) + let dir = from.order - to.order > 0 ? -1 : 1 let tabOrder = self.tabs.sorted { dir == -1 ? $0.order > $1.order : $0.order < $1.order } diff --git a/ora/Modules/Sidebar/TabList/FavTabsList.swift b/ora/Modules/Sidebar/TabList/FavTabsList.swift index 68c095c0..45f1ed95 100644 --- a/ora/Modules/Sidebar/TabList/FavTabsList.swift +++ b/ora/Modules/Sidebar/TabList/FavTabsList.swift @@ -54,7 +54,9 @@ struct FavTabsGrid: View { of: [.text], delegate: TabDropDelegate( item: tab, - draggedItem: $draggedItem, + representative: .tab, + draggedItem: $draggedItem, targetedItem: + .constant(nil), targetSection: .fav ) ) diff --git a/ora/Modules/Sidebar/TabList/NormalTabsList.swift b/ora/Modules/Sidebar/TabList/NormalTabsList.swift index 7ecd5f6c..3708ad05 100644 --- a/ora/Modules/Sidebar/TabList/NormalTabsList.swift +++ b/ora/Modules/Sidebar/TabList/NormalTabsList.swift @@ -33,6 +33,21 @@ private func tabsSortedByParent( return output } +enum TargetedDropItem { + case tab(UUID), divider(UUID) + + func imTargeted(withMyIdBeing id: UUID, andType t: DelegateTarget) -> Bool { + switch (self, t) { + case let (.tab(uuid), .tab): + return uuid == id + case let (.divider(uuid), .divider): + return uuid == id + default: + return false + } + } +} + struct NormalTabsList: View { let tabs: [Tab] @Binding var draggedItem: UUID? @@ -51,38 +66,61 @@ struct NormalTabsList: View { @Query var containers: [TabContainer] @EnvironmentObject var tabManager: TabManager @State private var previousTabIds: [UUID] = [] + @State private var targetedDropItem: TargetedDropItem? + @Environment(\.theme) private var theme var body: some View { VStack(spacing: 8) { NewTabButton(addNewTab: onAddNewTab) ForEach(tabsSortedByParent(tabs)) { iTab in let tab = iTab.tab - TabItem( - tab: tab, - isSelected: tabManager.isActive(tab), - isDragging: draggedItem == tab.id, - onTap: { onSelect(tab) }, - onPinToggle: { onPinToggle(tab) }, - onFavoriteToggle: { onFavoriteToggle(tab) }, - onClose: { onClose(tab) }, - onDuplicate: { onDuplicate(tab) }, - onMoveToContainer: { onMoveToContainer(tab, $0) }, - availableContainers: containers - ) - .onDrag { onDrag(tab.id) } - .onDrop( - of: [.text], - delegate: TabDropDelegate( - item: tab, - draggedItem: $draggedItem, - targetSection: .normal + VStack(spacing: 1) { + TabItem( + tab: tab, + isSelected: tabManager.isActive(tab), + isDragging: draggedItem == tab.id, + isDragTarget: targetedDropItem?.imTargeted(withMyIdBeing: tab.id, andType: .tab) ?? false, + onTap: { onSelect(tab) }, + onPinToggle: { onPinToggle(tab) }, + onFavoriteToggle: { onFavoriteToggle(tab) }, + onClose: { onClose(tab) }, + onDuplicate: { onDuplicate(tab) }, + onMoveToContainer: { onMoveToContainer(tab, $0) }, + availableContainers: containers ) - ) - .transition(.asymmetric( - insertion: .opacity.combined(with: .move(edge: .bottom)), - removal: .opacity.combined(with: .move(edge: .top)) - )) - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: shouldAnimate(tab)) + .onDrag { onDrag(tab.id) } + .onDrop( + of: [.text], + delegate: TabDropDelegate( + item: tab, + representative: .tab, draggedItem: $draggedItem, + targetedItem: $targetedDropItem, + targetSection: .normal + ) + ) + .transition(.asymmetric( + insertion: .opacity.combined(with: .move(edge: .bottom)), + removal: .opacity.combined(with: .move(edge: .top)) + )) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: shouldAnimate(tab)) + + Capsule() + .frame(height: 3) + .foregroundStyle(theme.accent) + .opacity(targetedDropItem? + .imTargeted(withMyIdBeing: tab.id, andType: .divider) ?? false ? 1.0 : 0.0) + .onDrop( + of: [.text], + delegate: TabDropDelegate( + item: tab, + representative: .divider, + draggedItem: $draggedItem, + + targetedItem: $targetedDropItem, + targetSection: .normal + ) + ) + } .padding( .leading, CGFloat(integerLiteral: iTab.indentationLevel * 8) diff --git a/ora/Modules/Sidebar/TabList/PinnedTabsList.swift b/ora/Modules/Sidebar/TabList/PinnedTabsList.swift index 63b16ab1..1a9bf35e 100644 --- a/ora/Modules/Sidebar/TabList/PinnedTabsList.swift +++ b/ora/Modules/Sidebar/TabList/PinnedTabsList.swift @@ -30,6 +30,7 @@ struct PinnedTabsList: View { tab: tab, isSelected: tabManager.isActive(tab), isDragging: draggedItem == tab.id, + isDragTarget: false, onTap: { onSelect(tab) }, onPinToggle: { onPinToggle(tab) }, onFavoriteToggle: { onFavoriteToggle(tab) }, @@ -43,7 +44,9 @@ struct PinnedTabsList: View { of: [.text], delegate: TabDropDelegate( item: tab, + representative: .tab, draggedItem: $draggedItem, + targetedItem: .constant(nil), targetSection: .pinned ) ) diff --git a/ora/Services/TabDropDelegate.swift b/ora/Services/TabDropDelegate.swift index b1a95e84..cd37a7f3 100644 --- a/ora/Services/TabDropDelegate.swift +++ b/ora/Services/TabDropDelegate.swift @@ -8,19 +8,45 @@ extension Array where Element: Hashable { } } +enum DelegateTarget { + case tab, divider + + func toDropItem(withId id: UUID) -> TargetedDropItem { + switch self { + case .tab: + return .tab(id) + case .divider: + return .divider(id) + } + } +} + struct TabDropDelegate: DropDelegate { let item: Tab // to + let representative: DelegateTarget @Binding var draggedItem: UUID? + @Binding var targetedItem: TargetedDropItem? let targetSection: TabSection func dropEntered(info: DropInfo) { - guard let provider = info.itemProviders(for: [.text]).first else { return } + targetedItem = representative.toDropItem(withId: item.id) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + .init(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard let provider = info.itemProviders(for: [.text]).first else { return false } performHapticFeedback(pattern: .alignment) provider.loadObject(ofClass: NSString.self) { object, _ in if let string = object as? String, let uuid = UUID(uuidString: string) { + if uuid == item.id { + return + } DispatchQueue.main.async { // First try to find the tab in the target container var from = self.item.container.tabs.first(where: { $0.id == uuid }) @@ -60,14 +86,8 @@ struct TabDropDelegate: DropDelegate { } } } - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - .init(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { draggedItem = nil + targetedItem = nil return true } } diff --git a/ora/UI/TabItem.swift b/ora/UI/TabItem.swift index 7047d44d..f4ab3212 100644 --- a/ora/UI/TabItem.swift +++ b/ora/UI/TabItem.swift @@ -86,6 +86,7 @@ struct TabItem: View { let tab: Tab let isSelected: Bool let isDragging: Bool + let isDragTarget: Bool let onTap: () -> Void let onPinToggle: () -> Void let onFavoriteToggle: () -> Void @@ -151,6 +152,15 @@ struct TabItem: View { ) : nil ) + .overlay( + isDragTarget ? + ConditionallyConcentricRectangle(cornerRadius: 10) + .stroke( + theme.accent, + style: StrokeStyle(lineWidth: 1, dash: [5, 5]) + ) + : nil + ) .contentShape(ConditionallyConcentricRectangle(cornerRadius: 10)) .onTapGesture { onTap() From 9a9ade3d9da668e48b764d87f4d1f5c1adad2a16 Mon Sep 17 00:00:00 2001 From: Jack Hogan Date: Wed, 22 Oct 2025 13:04:06 -0400 Subject: [PATCH 03/12] Fix ordering issues --- ora/Models/TabContainer.swift | 76 ++++++++++++++----- .../Sidebar/TabList/NormalTabsList.swift | 5 +- ora/Services/TabDropDelegate.swift | 16 +++- ora/Services/TabManager.swift | 6 +- ora/UI/TabItem.swift | 2 +- 5 files changed, 77 insertions(+), 28 deletions(-) diff --git a/ora/Models/TabContainer.swift b/ora/Models/TabContainer.swift index af118d39..aa24f53b 100644 --- a/ora/Models/TabContainer.swift +++ b/ora/Models/TabContainer.swift @@ -3,6 +3,10 @@ import SwiftData // MARK: - TabContainer +enum ReparentingBehavior { + case sibling, child +} + @Model class TabContainer: ObservableObject, Identifiable { var id: UUID @@ -29,30 +33,62 @@ class TabContainer: ObservableObject, Identifiable { self.lastAccessedAt = nowDate } - func reorderTabs(from: Tab, to: Tab) { - from.parent?.children.removeAll(where: { $0.id == from.id }) - to.children.append(from) - - let dir = from.order - to.order > 0 ? -1 : 1 - - let tabOrder = self.tabs.sorted { dir == -1 ? $0.order > $1.order : $0.order < $1.order } + private func pushTabs(in tab: Tab, startingAfter idx: Int) { + for tab in tab.children { + if tab.order > idx { + tab.order += 1 + } + } + } - var started = false - for (index, tab) in tabOrder.enumerated() { - if tab.id == from.id { - started = true + func reorderTabs( + from: Tab, + to: Tab, + withReparentingBehavior reparentingBehavior: ReparentingBehavior = .sibling + ) { + if let parent = from.parent { + parent.children.removeAll(where: { $0.id == from.id }) + for child in parent.children { + if child.order > from.order { + child.order -= 1 + } } - if tab.id == to.id { - break + } + switch reparentingBehavior { + case .sibling: + to.parent?.children.insert(from, at: 0) + if let parent = to.parent { + pushTabs(in: parent, startingAfter: to.order) } - if started { - let currentTab = tab - let nextTab = tabOrder[index + 1] - - let tempOrder = currentTab.order - currentTab.order = nextTab.order - nextTab.order = tempOrder + from.order = to.order + 1 + case .child: + to.children.insert(from, at: 0) + from.order = -1 + for child in to.children { + child.order += 1 } } + +// let dir = from.order - to.order > 0 ? -1 : 1 +// +// let tabOrder = self.tabs.sorted { dir == -1 ? $0.order > $1.order : $0.order < $1.order } +// +// var started = false +// for (index, tab) in tabOrder.enumerated() { +// if tab.id == from.id { +// started = true +// } +// if tab.id == to.id { +// break +// } +// if started { +// let currentTab = tab +// let nextTab = tabOrder[index + 1] +// +// let tempOrder = currentTab.order +// currentTab.order = nextTab.order +// nextTab.order = tempOrder +// } +// } } } diff --git a/ora/Modules/Sidebar/TabList/NormalTabsList.swift b/ora/Modules/Sidebar/TabList/NormalTabsList.swift index 3708ad05..a7fb0ed8 100644 --- a/ora/Modules/Sidebar/TabList/NormalTabsList.swift +++ b/ora/Modules/Sidebar/TabList/NormalTabsList.swift @@ -70,11 +70,11 @@ struct NormalTabsList: View { @Environment(\.theme) private var theme var body: some View { - VStack(spacing: 8) { + VStack(spacing: 3) { NewTabButton(addNewTab: onAddNewTab) ForEach(tabsSortedByParent(tabs)) { iTab in let tab = iTab.tab - VStack(spacing: 1) { + VStack(spacing: 3) { TabItem( tab: tab, isSelected: tabManager.isActive(tab), @@ -115,7 +115,6 @@ struct NormalTabsList: View { item: tab, representative: .divider, draggedItem: $draggedItem, - targetedItem: $targetedDropItem, targetSection: .normal ) diff --git a/ora/Services/TabDropDelegate.swift b/ora/Services/TabDropDelegate.swift index cd37a7f3..d1cf62ff 100644 --- a/ora/Services/TabDropDelegate.swift +++ b/ora/Services/TabDropDelegate.swift @@ -19,6 +19,15 @@ enum DelegateTarget { return .divider(id) } } + + var reparentingBehavior: ReparentingBehavior { + switch self { + case .tab: + return .child + case .divider: + return .sibling + } + } } struct TabDropDelegate: DropDelegate { @@ -33,6 +42,10 @@ struct TabDropDelegate: DropDelegate { targetedItem = representative.toDropItem(withId: item.id) } + func dropExited(info: DropInfo) { + targetedItem = nil + } + func dropUpdated(info: DropInfo) -> DropProposal? { .init(operation: .move) } @@ -77,7 +90,8 @@ struct TabDropDelegate: DropDelegate { self.item.container .reorderTabs( from: from, - to: self.item + to: self.item, + withReparentingBehavior: representative.reparentingBehavior ) } } else { diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 9ff5a12b..2c7209fd 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -248,7 +248,7 @@ class TabManager: ObservableObject { focusAfterOpening: Bool = true, isPrivate: Bool, loadSilently: Bool = false, - parentingTo tab: Tab? = nil + parentingTo parent: Tab? = nil ) -> Tab? { if let container = activeContainer { if let host = url.host { @@ -257,13 +257,13 @@ class TabManager: ObservableObject { let cleanHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host let newTab = Tab( - parent: tab, url: url, + parent: parent, url: url, title: cleanHost, favicon: faviconURL, container: container, type: .normal, isPlayingMedia: false, - order: container.tabs.count + 1, + order: parent == nil ? container.tabs.count : (parent!.children.map(\.order).max() ?? -1) + 1, historyManager: historyManager, downloadManager: downloadManager, tabManager: self, diff --git a/ora/UI/TabItem.swift b/ora/UI/TabItem.swift index f4ab3212..4ba4131d 100644 --- a/ora/UI/TabItem.swift +++ b/ora/UI/TabItem.swift @@ -183,7 +183,7 @@ struct TabItem: View { } private var tabTitle: some View { - Text(tab.title) + Text(tab.title + " \(tab.order)") .font(.system(size: 13)) .foregroundColor(textColor) .lineLimit(1) From 1560862efb470781b7efc9640695233f28abe41b Mon Sep 17 00:00:00 2001 From: Jack Hogan Date: Wed, 22 Oct 2025 14:48:42 -0400 Subject: [PATCH 04/12] Other various fixes --- ora/Models/Tab.swift | 17 +++++++++++++ ora/Models/TabContainer.swift | 33 +++---------------------- ora/Modules/Sidebar/ContainerView.swift | 4 ++- ora/Services/TabDropDelegate.swift | 9 +++++++ ora/Services/TabManager.swift | 6 ++++- ora/UI/TabItem.swift | 2 +- 6 files changed, 38 insertions(+), 33 deletions(-) diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 11fcb914..a8ff0c42 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -409,6 +409,23 @@ class Tab: ObservableObject, Identifiable { webView.load(request) } } + + func deparent() { + if let parent = self.parent { + parent.children.removeAll(where: { $0.id == id }) + for child in parent.children { + if child.order > self.order { + child.order -= 1 + } + } + } else { + for sibling in container.tabs { + if sibling.order > self.order { + sibling.order -= 1 + } + } + } + } } extension FileManager { diff --git a/ora/Models/TabContainer.swift b/ora/Models/TabContainer.swift index aa24f53b..f679e4bd 100644 --- a/ora/Models/TabContainer.swift +++ b/ora/Models/TabContainer.swift @@ -7,6 +7,8 @@ enum ReparentingBehavior { case sibling, child } +func deparentTab(_ tab: Tab) {} + @Model class TabContainer: ObservableObject, Identifiable { var id: UUID @@ -46,14 +48,7 @@ class TabContainer: ObservableObject, Identifiable { to: Tab, withReparentingBehavior reparentingBehavior: ReparentingBehavior = .sibling ) { - if let parent = from.parent { - parent.children.removeAll(where: { $0.id == from.id }) - for child in parent.children { - if child.order > from.order { - child.order -= 1 - } - } - } + from.deparent() switch reparentingBehavior { case .sibling: to.parent?.children.insert(from, at: 0) @@ -68,27 +63,5 @@ class TabContainer: ObservableObject, Identifiable { child.order += 1 } } - -// let dir = from.order - to.order > 0 ? -1 : 1 -// -// let tabOrder = self.tabs.sorted { dir == -1 ? $0.order > $1.order : $0.order < $1.order } -// -// var started = false -// for (index, tab) in tabOrder.enumerated() { -// if tab.id == from.id { -// started = true -// } -// if tab.id == to.id { -// break -// } -// if started { -// let currentTab = tab -// let nextTab = tabOrder[index + 1] -// -// let tempOrder = currentTab.order -// currentTab.order = nextTab.order -// nextTab.order = tempOrder -// } -// } } } diff --git a/ora/Modules/Sidebar/ContainerView.swift b/ora/Modules/Sidebar/ContainerView.swift index 4791cf04..8f108374 100644 --- a/ora/Modules/Sidebar/ContainerView.swift +++ b/ora/Modules/Sidebar/ContainerView.swift @@ -147,7 +147,9 @@ struct ContainerView: View { draggedItem = tabId let provider = TabItemProvider(object: tabId.uuidString as NSString) provider.didEnd = { - draggedItem = nil + Task { @MainActor in + draggedItem = nil + } } return provider } diff --git a/ora/Services/TabDropDelegate.swift b/ora/Services/TabDropDelegate.swift index d1cf62ff..0884d045 100644 --- a/ora/Services/TabDropDelegate.swift +++ b/ora/Services/TabDropDelegate.swift @@ -60,6 +60,15 @@ struct TabDropDelegate: DropDelegate { if uuid == item.id { return } + + // No assigning a parent to its child + var itemParent = item.parent + while let parent = itemParent { + if parent.id == uuid { + return + } + itemParent = parent.parent + } DispatchQueue.main.async { // First try to find the tab in the target container var from = self.item.container.tabs.first(where: { $0.id == uuid }) diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 2c7209fd..9a67e21a 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -120,7 +120,9 @@ class TabManager: ObservableObject { // MARK: - Container Public API's func moveTabToContainer(_ tab: Tab, toContainer: TabContainer) { + tab.deparent() tab.container = toContainer + tab.order = (toContainer.tabs.map((\.order)).max() ?? -1) + 1 try? modelContext.save() } @@ -263,7 +265,7 @@ class TabManager: ObservableObject { container: container, type: .normal, isPlayingMedia: false, - order: parent == nil ? container.tabs.count : (parent!.children.map(\.order).max() ?? -1) + 1, + order: ((parent != nil ? parent!.children : container.tabs).map(\.order).max() ?? -1) + 1, historyManager: historyManager, downloadManager: downloadManager, tabManager: self, @@ -310,6 +312,8 @@ class TabManager: ObservableObject { } func closeTab(tab: Tab) { + tab.deparent() + // If the closed tab was active, select another tab if self.activeTab?.id == tab.id { if let nextTab = tab.container.tabs diff --git a/ora/UI/TabItem.swift b/ora/UI/TabItem.swift index 4ba4131d..f4ab3212 100644 --- a/ora/UI/TabItem.swift +++ b/ora/UI/TabItem.swift @@ -183,7 +183,7 @@ struct TabItem: View { } private var tabTitle: some View { - Text(tab.title + " \(tab.order)") + Text(tab.title) .font(.system(size: 13)) .foregroundColor(textColor) .lineLimit(1) From 8170b596cb0e4cdb89837e6c94d72b80971e9806 Mon Sep 17 00:00:00 2001 From: Jack Hogan Date: Wed, 22 Oct 2025 18:01:45 -0400 Subject: [PATCH 05/12] Remove empty function --- ora/Models/TabContainer.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/ora/Models/TabContainer.swift b/ora/Models/TabContainer.swift index f679e4bd..7678cb41 100644 --- a/ora/Models/TabContainer.swift +++ b/ora/Models/TabContainer.swift @@ -7,8 +7,6 @@ enum ReparentingBehavior { case sibling, child } -func deparentTab(_ tab: Tab) {} - @Model class TabContainer: ObservableObject, Identifiable { var id: UUID From b4244d9b6a7168daf1aef9e23c848c0606b3ef8f Mon Sep 17 00:00:00 2001 From: Jack Hogan Date: Tue, 28 Oct 2025 15:23:50 -0400 Subject: [PATCH 06/12] Start working on side by side --- ora/Models/Tab.swift | 3 +- ora/Models/TabContainer.swift | 30 +++- ora/Models/TabTileset.swift | 27 ++++ ora/Modules/Sidebar/TabList/FavTabsList.swift | 2 +- .../Sidebar/TabList/NormalTabsList.swift | 129 ++++++++++++------ .../Sidebar/TabList/PinnedTabsList.swift | 7 +- ora/Services/TabDropDelegate.swift | 32 +++-- ora/Services/TabManager.swift | 5 +- ora/UI/DragTarget.swift | 61 +++++++++ ora/UI/TabItem.swift | 11 +- 10 files changed, 243 insertions(+), 64 deletions(-) create mode 100644 ora/Models/TabTileset.swift create mode 100644 ora/UI/DragTarget.swift diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 5eee4e40..19e71c53 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -58,6 +58,7 @@ class Tab: ObservableObject, Identifiable { @Relationship(inverse: \TabContainer.tabs) var container: TabContainer @Relationship(deleteRule: .cascade) var children: [Tab] @Relationship(inverse: \Tab.children) var parent: Tab? + @Relationship(inverse: \TabTileset.tabs) var tileset: TabTileset? /// Whether this tab is considered alive (recently accessed) var isAlive: Bool { @@ -411,7 +412,7 @@ class Tab: ObservableObject, Identifiable { } } - func deparent() { + func dissociateFromRelatives() { if let parent = self.parent { parent.children.removeAll(where: { $0.id == id }) for child in parent.children { diff --git a/ora/Models/TabContainer.swift b/ora/Models/TabContainer.swift index 7678cb41..2d0edf07 100644 --- a/ora/Models/TabContainer.swift +++ b/ora/Models/TabContainer.swift @@ -15,6 +15,7 @@ class TabContainer: ObservableObject, Identifiable { var createdAt: Date var lastAccessedAt: Date + @Relationship(deleteRule: .cascade) var tilesets: [TabTileset] = [] @Relationship(deleteRule: .cascade) var tabs: [Tab] = [] @Relationship(deleteRule: .cascade) var folders: [Folder] = [] @Relationship() var history: [History] = [] @@ -41,12 +42,39 @@ class TabContainer: ObservableObject, Identifiable { } } + func removeTabFromTileset(tab: Tab) { + guard tab.tileset != nil else { return } + for (i, tileset) in tilesets.enumerated() { + if let tabIndex = tileset.tabs.firstIndex(of: tab) { + tileset.tabs.remove(at: tabIndex) + if tileset.tabs.count <= 1 { + tilesets.remove(at: i) + } + break + } + } + tab.tileset = nil + } + + func combineToTileset(withSourceTab src: Tab, andDestinationTab dst: Tab) { + // Remove from tabset if exists + removeTabFromTileset(tab: src) + reorderTabs(from: src, to: dst, withReparentingBehavior: .sibling) + if let tabset = tilesets.first(where: { $0.tabs.contains(dst) }) { + tabset.tabs.append(src) + } else { + let ts = TabTileset(tabs: []) + tilesets.append(ts) + ts.tabs = [src, dst] + } + } + func reorderTabs( from: Tab, to: Tab, withReparentingBehavior reparentingBehavior: ReparentingBehavior = .sibling ) { - from.deparent() + from.dissociateFromRelatives() switch reparentingBehavior { case .sibling: to.parent?.children.insert(from, at: 0) diff --git a/ora/Models/TabTileset.swift b/ora/Models/TabTileset.swift new file mode 100644 index 00000000..c30cc252 --- /dev/null +++ b/ora/Models/TabTileset.swift @@ -0,0 +1,27 @@ +// +// TabTileset.swift +// ora +// +// Created by Jack Hogan on 28/10/25. +// + +import Foundation +import SwiftData + +@Model +class TabTileset: ObservableObject, Identifiable { + var id: UUID + + var tabs: [Tab] + + init(id: UUID = UUID(), tabs: [Tab]) { + self.id = id + self.tabs = tabs + } + + func deparentTabs() { + for tab in tabs { + tab.dissociateFromRelatives() + } + } +} diff --git a/ora/Modules/Sidebar/TabList/FavTabsList.swift b/ora/Modules/Sidebar/TabList/FavTabsList.swift index 45f1ed95..d5713787 100644 --- a/ora/Modules/Sidebar/TabList/FavTabsList.swift +++ b/ora/Modules/Sidebar/TabList/FavTabsList.swift @@ -54,7 +54,7 @@ struct FavTabsGrid: View { of: [.text], delegate: TabDropDelegate( item: tab, - representative: .tab, + representative: .tab(tabset: true), draggedItem: $draggedItem, targetedItem: .constant(nil), targetSection: .fav diff --git a/ora/Modules/Sidebar/TabList/NormalTabsList.swift b/ora/Modules/Sidebar/TabList/NormalTabsList.swift index a7fb0ed8..ce98a3aa 100644 --- a/ora/Modules/Sidebar/TabList/NormalTabsList.swift +++ b/ora/Modules/Sidebar/TabList/NormalTabsList.swift @@ -4,14 +4,16 @@ import SwiftUI private struct IndentedTab: Identifiable { let tab: Tab let indentationLevel: Int + let tabs: [Tab] var id: UUID { tab.id } } -private func tabsSortedByParent( +private func tabsSortedByParentImpl( _ tabs: [Tab], - withParentTabSelector parentTabSelector: UUID? = nil, - withIndentation indentation: Int = 0 + withParentTabSelector parentTabSelector: UUID?, + withIndentation indentation: Int, + withTilesetUseSet usedTilesets: inout Set ) -> [IndentedTab] { // Start by finding only parent tags, then recurse down through var output = [IndentedTab]() @@ -19,13 +21,32 @@ private func tabsSortedByParent( by: { $0.order < $1.order }) for root in roots { - output.append(IndentedTab(tab: root, indentationLevel: indentation)) + var toAppend = [root] + if let tileset = root.tileset { + if usedTilesets.contains(tileset.id) { + continue + } + let foundTiles = Set(tileset.tabs.map(\.id)) + toAppend = tabs.filter { foundTiles.contains($0.id) } + assert(!toAppend.isEmpty) + usedTilesets.insert(tileset.id) + } + output + .append( + IndentedTab( + tab: root, + indentationLevel: indentation, + tabs: toAppend + ) + ) + output .append( - contentsOf: tabsSortedByParent( + contentsOf: tabsSortedByParentImpl( root.children, withParentTabSelector: root.id, - withIndentation: indentation + 1 + withIndentation: indentation + 1, + withTilesetUseSet: &usedTilesets ) ) } @@ -33,12 +54,22 @@ private func tabsSortedByParent( return output } +private func tabsSortedByParent(_ tabs: [Tab]) -> [IndentedTab] { + var usedTilesets: Set = [] + return tabsSortedByParentImpl( + tabs, + withParentTabSelector: nil, + withIndentation: 0, + withTilesetUseSet: &usedTilesets + ) +} + enum TargetedDropItem { - case tab(UUID), divider(UUID) + case tab(id: UUID, tabset: Bool), divider(UUID) func imTargeted(withMyIdBeing id: UUID, andType t: DelegateTarget) -> Bool { switch (self, t) { - case let (.tab(uuid), .tab): + case let (.tab(uuid, tabsetA), .tab(_)): return uuid == id case let (.divider(uuid), .divider): return uuid == id @@ -72,47 +103,69 @@ struct NormalTabsList: View { var body: some View { VStack(spacing: 3) { NewTabButton(addNewTab: onAddNewTab) + Text("TILESETS: \(tabManager.activeContainer?.tilesets.map(\.id))") ForEach(tabsSortedByParent(tabs)) { iTab in - let tab = iTab.tab VStack(spacing: 3) { - TabItem( - tab: tab, - isSelected: tabManager.isActive(tab), - isDragging: draggedItem == tab.id, - isDragTarget: targetedDropItem?.imTargeted(withMyIdBeing: tab.id, andType: .tab) ?? false, - onTap: { onSelect(tab) }, - onPinToggle: { onPinToggle(tab) }, - onFavoriteToggle: { onFavoriteToggle(tab) }, - onClose: { onClose(tab) }, - onDuplicate: { onDuplicate(tab) }, - onMoveToContainer: { onMoveToContainer(tab, $0) }, - availableContainers: containers - ) - .onDrag { onDrag(tab.id) } - .onDrop( - of: [.text], - delegate: TabDropDelegate( - item: tab, - representative: .tab, draggedItem: $draggedItem, - targetedItem: $targetedDropItem, - targetSection: .normal - ) + HStack { + ForEach(iTab.tabs) { tab in + TabItem( + tab: tab, + isSelected: tabManager.isActive(tab), + isDragging: draggedItem == tab.id, + isDragTarget: targetedDropItem? + .imTargeted( + withMyIdBeing: tab.id, + andType: .tab(tabset: false) + ) ?? false, + onTap: { onSelect(tab) + }, + onPinToggle: { onPinToggle(tab) }, + onFavoriteToggle: { onFavoriteToggle(tab) }, + onClose: { onClose(tab) }, + onDuplicate: { onDuplicate(tab) }, + onMoveToContainer: { onMoveToContainer(tab, $0) }, + availableContainers: containers, + draggedItem: $draggedItem, + targetedDropItem: $targetedDropItem + ) + .onDrag { onDrag(tab.id) } + .onDrop( + of: [.text], + delegate: TabDropDelegate( + item: tab, + representative: .tab(tabset: false), draggedItem: $draggedItem, + targetedItem: $targetedDropItem, + targetSection: .normal + ) + ) + .transition(.asymmetric( + insertion: .opacity.combined(with: .move(edge: .bottom)), + removal: .opacity.combined(with: .move(edge: .top)) + )) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: shouldAnimate(tab)) + } + } + .overlay( + iTab.tabs + .contains(where: { targetedDropItem?.imTargeted( + withMyIdBeing: $0.id, + andType: .tab(tabset: false) + ) ?? false }) ? DragTarget( + tab: iTab.tabs.first!, + draggedItem: $draggedItem, + targetedDropItem: $targetedDropItem + ) : nil ) - .transition(.asymmetric( - insertion: .opacity.combined(with: .move(edge: .bottom)), - removal: .opacity.combined(with: .move(edge: .top)) - )) - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: shouldAnimate(tab)) Capsule() .frame(height: 3) .foregroundStyle(theme.accent) .opacity(targetedDropItem? - .imTargeted(withMyIdBeing: tab.id, andType: .divider) ?? false ? 1.0 : 0.0) + .imTargeted(withMyIdBeing: iTab.tabs.first!.id, andType: .divider) ?? false ? 1.0 : 0.0) .onDrop( of: [.text], delegate: TabDropDelegate( - item: tab, + item: iTab.tabs.first!, representative: .divider, draggedItem: $draggedItem, targetedItem: $targetedDropItem, diff --git a/ora/Modules/Sidebar/TabList/PinnedTabsList.swift b/ora/Modules/Sidebar/TabList/PinnedTabsList.swift index 1a9bf35e..37c23395 100644 --- a/ora/Modules/Sidebar/TabList/PinnedTabsList.swift +++ b/ora/Modules/Sidebar/TabList/PinnedTabsList.swift @@ -37,14 +37,17 @@ struct PinnedTabsList: View { onClose: { onClose(tab) }, onDuplicate: { onDuplicate(tab) }, onMoveToContainer: { onMoveToContainer(tab, $0) }, - availableContainers: containers + availableContainers: containers, + // TODO: Fix + draggedItem: .constant(nil), + targetedDropItem: .constant(nil) ) .onDrag { onDrag(tab.id) } .onDrop( of: [.text], delegate: TabDropDelegate( item: tab, - representative: .tab, + representative: .tab(tabset: true), draggedItem: $draggedItem, targetedItem: .constant(nil), targetSection: .pinned diff --git a/ora/Services/TabDropDelegate.swift b/ora/Services/TabDropDelegate.swift index 0884d045..050de9e4 100644 --- a/ora/Services/TabDropDelegate.swift +++ b/ora/Services/TabDropDelegate.swift @@ -9,12 +9,12 @@ extension Array where Element: Hashable { } enum DelegateTarget { - case tab, divider + case tab(tabset: Bool), divider func toDropItem(withId id: UUID) -> TargetedDropItem { switch self { - case .tab: - return .tab(id) + case let .tab(tabset): + return .tab(id: id, tabset: tabset) case .divider: return .divider(id) } @@ -53,7 +53,9 @@ struct TabDropDelegate: DropDelegate { func performDrop(info: DropInfo) -> Bool { guard let provider = info.itemProviders(for: [.text]).first else { return false } performHapticFeedback(pattern: .alignment) - provider.loadObject(ofClass: NSString.self) { object, _ in + provider.loadObject(ofClass: NSString.self) { + object, + _ in if let string = object as? String, let uuid = UUID(uuidString: string) { @@ -96,12 +98,22 @@ struct TabDropDelegate: DropDelegate { dampingFraction: 0.8 ) ) { - self.item.container - .reorderTabs( - from: from, - to: self.item, - withReparentingBehavior: representative.reparentingBehavior - ) + if case let .tab(tabset) = representative, + tabset + { + self.item.container + .combineToTileset( + withSourceTab: from, + andDestinationTab: self.item + ) + } else { + self.item.container + .reorderTabs( + from: from, + to: self.item, + withReparentingBehavior: representative.reparentingBehavior + ) + } } } else { moveTabBetweenSections(from: from, to: self.item) diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 9a67e21a..a8d9e9d8 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -120,7 +120,7 @@ class TabManager: ObservableObject { // MARK: - Container Public API's func moveTabToContainer(_ tab: Tab, toContainer: TabContainer) { - tab.deparent() + tab.dissociateFromRelatives() tab.container = toContainer tab.order = (toContainer.tabs.map((\.order)).max() ?? -1) + 1 try? modelContext.save() @@ -312,7 +312,8 @@ class TabManager: ObservableObject { } func closeTab(tab: Tab) { - tab.deparent() + tab.dissociateFromRelatives() + activeContainer?.removeTabFromTileset(tab: tab) // If the closed tab was active, select another tab if self.activeTab?.id == tab.id { diff --git a/ora/UI/DragTarget.swift b/ora/UI/DragTarget.swift new file mode 100644 index 00000000..475e378f --- /dev/null +++ b/ora/UI/DragTarget.swift @@ -0,0 +1,61 @@ +// +// DragTarget.swift +// ora +// +// Created by Jack Hogan on 28/10/25. +// + +import SwiftUI + +struct DragTarget: View { + @Environment(\.theme) private var theme: Theme + let tab: Tab + @Binding var draggedItem: UUID? + @Binding var targetedDropItem: TargetedDropItem? + var body: some View { + HStack { + ConditionallyConcentricRectangle(cornerRadius: 10) + .stroke( + theme.accent, + style: StrokeStyle(lineWidth: 1, dash: [5, 5]) + ) + .overlay { + Image(systemName: "arrow.down.to.line") + .bold() + } + .onDrop( + of: [.text], + delegate: TabDropDelegate( + item: tab, + representative: + .tab(tabset: false), draggedItem: $draggedItem, + targetedItem: $targetedDropItem, + targetSection: .normal + ) + ) + ConditionallyConcentricRectangle(cornerRadius: 10) + .stroke( + theme.accent, + style: StrokeStyle(lineWidth: 1, dash: [5, 5]) + ) + .overlay { + Image(systemName: "arrow.forward.to.line") + .bold() + } + .onDrop( + of: [.text], + delegate: TabDropDelegate( + item: tab, + representative: + .tab(tabset: true), draggedItem: $draggedItem, + targetedItem: $targetedDropItem, + targetSection: .normal + ) + ) + } + .background { + ConditionallyConcentricRectangle(cornerRadius: 10) + .fill(.thickMaterial) + } + } +} diff --git a/ora/UI/TabItem.swift b/ora/UI/TabItem.swift index f4ab3212..8352daac 100644 --- a/ora/UI/TabItem.swift +++ b/ora/UI/TabItem.swift @@ -98,6 +98,8 @@ struct TabItem: View { @EnvironmentObject var downloadManager: DownloadManager @EnvironmentObject var privacyMode: PrivacyMode let availableContainers: [TabContainer] + @Binding var draggedItem: UUID? + @Binding var targetedDropItem: TargetedDropItem? @Environment(\.theme) private var theme @State private var isHovering = false @@ -152,15 +154,6 @@ struct TabItem: View { ) : nil ) - .overlay( - isDragTarget ? - ConditionallyConcentricRectangle(cornerRadius: 10) - .stroke( - theme.accent, - style: StrokeStyle(lineWidth: 1, dash: [5, 5]) - ) - : nil - ) .contentShape(ConditionallyConcentricRectangle(cornerRadius: 10)) .onTapGesture { onTap() From 40a73833e72f7d12e399e30958559e3f3d65132d Mon Sep 17 00:00:00 2001 From: Jack Hogan Date: Tue, 28 Oct 2025 15:28:07 -0400 Subject: [PATCH 07/12] Show all tabs in sbs as selected --- ora/Modules/Sidebar/TabList/NormalTabsList.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ora/Modules/Sidebar/TabList/NormalTabsList.swift b/ora/Modules/Sidebar/TabList/NormalTabsList.swift index ce98a3aa..698d1802 100644 --- a/ora/Modules/Sidebar/TabList/NormalTabsList.swift +++ b/ora/Modules/Sidebar/TabList/NormalTabsList.swift @@ -103,14 +103,16 @@ struct NormalTabsList: View { var body: some View { VStack(spacing: 3) { NewTabButton(addNewTab: onAddNewTab) - Text("TILESETS: \(tabManager.activeContainer?.tilesets.map(\.id))") ForEach(tabsSortedByParent(tabs)) { iTab in VStack(spacing: 3) { HStack { ForEach(iTab.tabs) { tab in TabItem( tab: tab, - isSelected: tabManager.isActive(tab), + isSelected: iTab.tabs + .contains( + where: { t in tabManager.isActive(t) + }), isDragging: draggedItem == tab.id, isDragTarget: targetedDropItem? .imTargeted( From d982979bec5b72f601f871f24a83b92399a402e9 Mon Sep 17 00:00:00 2001 From: Jack Hogan Date: Wed, 29 Oct 2025 12:25:51 -0400 Subject: [PATCH 08/12] Significant progress on split views --- ora/Common/Utils/SettingsStore.swift | 7 ++ ora/Models/Tab.swift | 16 +++ ora/Models/TabContainer.swift | 65 ++++++++++-- ora/Modules/Browser/BrowserSplitView.swift | 19 +++- ora/Modules/Browser/BrowserView.swift | 10 ++ .../Sections/GeneralSettingsView.swift | 4 + .../Sidebar/TabList/NormalTabsList.swift | 73 ++++++++------ ora/Services/SectionDropDelegate.swift | 6 +- ora/Services/TabDropDelegate.swift | 98 ++++++++++++++++++- ora/Services/TabManager.swift | 27 ++++- ora/UI/DragTarget.swift | 39 ++++---- ora/UI/DropCapsule.swift | 28 ++++++ ora/UI/TabItem.swift | 2 +- 13 files changed, 324 insertions(+), 70 deletions(-) create mode 100644 ora/UI/DropCapsule.swift diff --git a/ora/Common/Utils/SettingsStore.swift b/ora/Common/Utils/SettingsStore.swift index 821146b6..219efe3b 100644 --- a/ora/Common/Utils/SettingsStore.swift +++ b/ora/Common/Utils/SettingsStore.swift @@ -150,6 +150,7 @@ class SettingsStore: ObservableObject { private let tabRemovalTimeoutKey = "settings.tabRemovalTimeout" private let maxRecentTabsKey = "settings.maxRecentTabs" private let autoPiPEnabledKey = "settings.autoPiPEnabled" + private let treeTabsEnabledKey = "settings.treeTabsEnabled" // MARK: - Per-Container @@ -217,6 +218,10 @@ class SettingsStore: ObservableObject { didSet { defaults.set(autoPiPEnabled, forKey: autoPiPEnabledKey) } } + @Published var treeTabsEnabled: Bool { + didSet { defaults.set(treeTabsEnabled, forKey: treeTabsEnabledKey) } + } + init() { autoUpdateEnabled = defaults.bool(forKey: autoUpdateKey) blockThirdPartyTrackers = defaults.bool(forKey: trackingThirdPartyKey) @@ -271,6 +276,8 @@ class SettingsStore: ObservableObject { maxRecentTabs = maxRecentTabsValue == 0 ? 5 : maxRecentTabsValue autoPiPEnabled = defaults.object(forKey: autoPiPEnabledKey) as? Bool ?? true + + treeTabsEnabled = defaults.object(forKey: treeTabsEnabledKey) as? Bool ?? false } // MARK: - Per-container helpers diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 19e71c53..0a792873 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -181,6 +181,16 @@ class Tab: ObservableObject, Identifiable { } } + func switchSections(from: Tab, toSection sec: TabSection) { + from.type = tabType(for: sec) + switch sec { + case .pinned, .fav: + from.savedURL = from.url + case .normal: + from.savedURL = nil + } + } + func updateHeaderColor() { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in if let wv = self?.webView { @@ -428,6 +438,12 @@ class Tab: ObservableObject, Identifiable { } } } + + func abandonChildren() { + for child in children { + child.parent = parent + } + } } extension FileManager { diff --git a/ora/Models/TabContainer.swift b/ora/Models/TabContainer.swift index 2d0edf07..e947debf 100644 --- a/ora/Models/TabContainer.swift +++ b/ora/Models/TabContainer.swift @@ -16,7 +16,7 @@ class TabContainer: ObservableObject, Identifiable { var lastAccessedAt: Date @Relationship(deleteRule: .cascade) var tilesets: [TabTileset] = [] - @Relationship(deleteRule: .cascade) var tabs: [Tab] = [] + @Relationship(deleteRule: .cascade) private(set) var tabs: [Tab] = [] @Relationship(deleteRule: .cascade) var folders: [Folder] = [] @Relationship() var history: [History] = [] @@ -34,14 +34,36 @@ class TabContainer: ObservableObject, Identifiable { self.lastAccessedAt = nowDate } - private func pushTabs(in tab: Tab, startingAfter idx: Int) { - for tab in tab.children { + private func pushTabs(in tab: Tab?, startingAfter idx: Int, _ amount: Int = 1) { + for tab in tab?.children ?? tabs { if tab.order > idx { - tab.order += 1 + tab.order += amount } } } + func addTab(_ tab: Tab) { + var orderBase: Int + if SettingsStore.shared.treeTabsEnabled { + orderBase = (tab.parent?.children ?? tabs).map(\.order).max() ?? -1 + } else if let parent = tab.parent { + orderBase = parent.order + pushTabs( + in: nil, + startingAfter: parent.order + ) + } else { + orderBase = tabs.map(\.order).max() ?? -1 + } + + if !SettingsStore.shared.treeTabsEnabled { + tab.parent = nil + } + + tab.order = orderBase + 1 + tabs.append(tab) + } + func removeTabFromTileset(tab: Tab) { guard tab.tileset != nil else { return } for (i, tileset) in tilesets.enumerated() { @@ -61,7 +83,8 @@ class TabContainer: ObservableObject, Identifiable { removeTabFromTileset(tab: src) reorderTabs(from: src, to: dst, withReparentingBehavior: .sibling) if let tabset = tilesets.first(where: { $0.tabs.contains(dst) }) { - tabset.tabs.append(src) + let dstIdx = tabset.tabs.firstIndex(of: dst)! + tabset.tabs.insert(src, at: dstIdx + 1) } else { let ts = TabTileset(tabs: []) tilesets.append(ts) @@ -69,25 +92,47 @@ class TabContainer: ObservableObject, Identifiable { } } + func bringToTop(tab target: Tab) { + for tab in tabs { + if tab.order > target.order { + tab.order -= 1 + } else { + tab.order += 1 + } + } + target.order = 0 + } + func reorderTabs( from: Tab, to: Tab, withReparentingBehavior reparentingBehavior: ReparentingBehavior = .sibling ) { + let containingTilesetTabs = tilesets.first(where: { $0.tabs.contains(from) })?.tabs ?? [from] + let numRelevantTabs = containingTilesetTabs.count + from.dissociateFromRelatives() switch reparentingBehavior { case .sibling: to.parent?.children.insert(from, at: 0) - if let parent = to.parent { - pushTabs(in: parent, startingAfter: to.order) + pushTabs(in: to.parent, startingAfter: to.order, numRelevantTabs) + for (i, tab) in containingTilesetTabs.enumerated() { + tab.order = to.order + 1 + i } - from.order = to.order + 1 case .child: to.children.insert(from, at: 0) - from.order = -1 + for (i, tab) in containingTilesetTabs.enumerated() { + tab.order = -containingTilesetTabs.count + i + } for child in to.children { - child.order += 1 + child.order += numRelevantTabs } } } + + func flattenTabs() { + for tab in tabs { + tab.parent = nil + } + } } diff --git a/ora/Modules/Browser/BrowserSplitView.swift b/ora/Modules/Browser/BrowserSplitView.swift index c2c1c366..2242a304 100644 --- a/ora/Modules/Browser/BrowserSplitView.swift +++ b/ora/Modules/Browser/BrowserSplitView.swift @@ -83,15 +83,26 @@ struct BrowserSplitView: View { HomeView() } } + let activeId = tabManager.activeTab?.id ZStack { - let activeId = tabManager.activeTab?.id - ForEach(tabManager.tabsToRender) { tab in + ForEach(tabManager.tabsToRender.filter { !($0.id == activeId || tabManager.isInSplit(tab: $0)) }) { tab in + if tab.isWebViewReady { + BrowserContentContainer { + BrowserWebContentView(tab: tab) + } + .opacity(0) + .allowsHitTesting( + false + ) + } + } + } + HStack { + ForEach(tabManager.tabsToRender.filter { $0.id == activeId || tabManager.isInSplit(tab: $0) }) { tab in if tab.isWebViewReady { BrowserContentContainer { BrowserWebContentView(tab: tab) } - .opacity(tab.id == activeId ? 1 : 0) - .allowsHitTesting(tab.id == activeId) } } } diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 4fb1d2b9..9bbea6b8 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftData import SwiftUI struct BrowserView: View { @@ -11,6 +12,9 @@ struct BrowserView: View { @EnvironmentObject private var sidebarManager: SidebarManager @EnvironmentObject private var toolbarManager: ToolbarManager + @Query var containers: [TabContainer] + @StateObject private var settingsStore: SettingsStore = .shared + @State private var isMouseOverURLBar = false @State private var showFloatingURLBar = false @State private var isMouseOverSidebar = false @@ -91,5 +95,11 @@ struct BrowserView: View { } } } + .onChange(of: settingsStore.treeTabsEnabled) { _, treeEnabled in + if !treeEnabled { + // Deparent all tabs in all containers + containers.forEach { $0.flattenTabs() } + } + } } } diff --git a/ora/Modules/Settings/Sections/GeneralSettingsView.swift b/ora/Modules/Settings/Sections/GeneralSettingsView.swift index 505e6a94..59d97519 100644 --- a/ora/Modules/Settings/Sections/GeneralSettingsView.swift +++ b/ora/Modules/Settings/Sections/GeneralSettingsView.swift @@ -98,6 +98,10 @@ struct GeneralSettingsView: View { } Toggle("Auto Picture-in-Picture on tab switch", isOn: $settings.autoPiPEnabled) + Toggle( + "Use tree-style tabs", + isOn: $settings.treeTabsEnabled + ) } .padding(.vertical, 8) VStack(alignment: .leading, spacing: 12) { diff --git a/ora/Modules/Sidebar/TabList/NormalTabsList.swift b/ora/Modules/Sidebar/TabList/NormalTabsList.swift index 698d1802..aae81d8a 100644 --- a/ora/Modules/Sidebar/TabList/NormalTabsList.swift +++ b/ora/Modules/Sidebar/TabList/NormalTabsList.swift @@ -36,7 +36,7 @@ private func tabsSortedByParentImpl( IndentedTab( tab: root, indentationLevel: indentation, - tabs: toAppend + tabs: toAppend.reversed() ) ) @@ -98,11 +98,30 @@ struct NormalTabsList: View { @EnvironmentObject var tabManager: TabManager @State private var previousTabIds: [UUID] = [] @State private var targetedDropItem: TargetedDropItem? + @StateObject private var settings = SettingsStore.shared @Environment(\.theme) private var theme var body: some View { VStack(spacing: 3) { NewTabButton(addNewTab: onAddNewTab) + + Text( + "\(String(describing: tabManager.activeContainer?.tilesets.map { $0.tabs.map(\.title) }))" + ) + + DropCapsule( + id: .zero, + targetedDropItem: $targetedDropItem, + draggedItem: $draggedItem, + delegate: TopDropDelegate( + container: tabManager.activeContainer ?? containers.first!, + targetedItem: $targetedDropItem, + draggedItem: $draggedItem, + representative: .divider, + section: .normal + ) + ) + ForEach(tabsSortedByParent(tabs)) { iTab in VStack(spacing: 3) { HStack { @@ -117,10 +136,9 @@ struct NormalTabsList: View { isDragTarget: targetedDropItem? .imTargeted( withMyIdBeing: tab.id, - andType: .tab(tabset: false) + andType: .tab(tabset: true) ) ?? false, - onTap: { onSelect(tab) - }, + onTap: { onSelect(tab) }, onPinToggle: { onPinToggle(tab) }, onFavoriteToggle: { onFavoriteToggle(tab) }, onClose: { onClose(tab) }, @@ -148,36 +166,35 @@ struct NormalTabsList: View { } } .overlay( - iTab.tabs - .contains(where: { targetedDropItem?.imTargeted( - withMyIdBeing: $0.id, - andType: .tab(tabset: false) - ) ?? false }) ? DragTarget( - tab: iTab.tabs.first!, - draggedItem: $draggedItem, - targetedDropItem: $targetedDropItem - ) : nil + iTab.tabs.contains(where: { targetedDropItem?.imTargeted( + withMyIdBeing: $0.id, + andType: .tab(tabset: false) + ) ?? false }) ? DragTarget( + tab: iTab.tabs.first!, + draggedItem: $draggedItem, + targetedDropItem: $targetedDropItem, + showTree: settings.treeTabsEnabled + ) : nil ) - Capsule() - .frame(height: 3) - .foregroundStyle(theme.accent) - .opacity(targetedDropItem? - .imTargeted(withMyIdBeing: iTab.tabs.first!.id, andType: .divider) ?? false ? 1.0 : 0.0) - .onDrop( - of: [.text], - delegate: TabDropDelegate( - item: iTab.tabs.first!, - representative: .divider, - draggedItem: $draggedItem, - targetedItem: $targetedDropItem, - targetSection: .normal - ) + DropCapsule( + id: iTab.tabs.first!.id, + targetedDropItem: $targetedDropItem, + draggedItem: $draggedItem, + delegate: TabDropDelegate( + item: iTab.tabs.first!, + representative: .divider, + draggedItem: $draggedItem, + targetedItem: $targetedDropItem, + targetSection: .normal ) + ) } .padding( .leading, - CGFloat(integerLiteral: iTab.indentationLevel * 8) + CGFloat( + integerLiteral: settings.treeTabsEnabled ? iTab.indentationLevel * 8 : 0 + ) ) } } diff --git a/ora/Services/SectionDropDelegate.swift b/ora/Services/SectionDropDelegate.swift index e6a70639..75ccb092 100644 --- a/ora/Services/SectionDropDelegate.swift +++ b/ora/Services/SectionDropDelegate.swift @@ -8,8 +8,8 @@ struct SectionDropDelegate: DropDelegate { let tabManager: TabManager func dropEntered(info: DropInfo) { - guard let provider = info.itemProviders(for: [.text]).first else { return } performHapticFeedback(pattern: .alignment) + guard let provider = info.itemProviders(for: [.text]).first else { return } provider.loadObject(ofClass: NSString.self) { object, _ in guard @@ -25,6 +25,10 @@ struct SectionDropDelegate: DropDelegate { if self.items.isEmpty { // Section is empty, just change type and order let newType = tabType(for: self.targetSection) + if [.pinned, .fav].contains(newType) { + from.abandonChildren() + } + from.dissociateFromRelatives() from.type = newType // Update savedURL when moving into pinned/fav; clear when moving to normal switch newType { diff --git a/ora/Services/TabDropDelegate.swift b/ora/Services/TabDropDelegate.swift index 050de9e4..8796fbc7 100644 --- a/ora/Services/TabDropDelegate.swift +++ b/ora/Services/TabDropDelegate.swift @@ -30,6 +30,94 @@ enum DelegateTarget { } } +extension UUID { + static let zero: UUID = .init(uuidString: "00000000-0000-0000-0000-000000000000")! +} + +struct NilDropDelegate: DropDelegate { + func dropEntered(info: DropInfo) {} + + func dropExited(info: DropInfo) {} + + func dropUpdated(info: DropInfo) -> DropProposal? { + .init(operation: .forbidden) + } + + func performDrop(info: DropInfo) -> Bool { false } +} + +/// Adds a tab to the top of the list +struct TopDropDelegate: DropDelegate { + let container: TabContainer + @Binding var targetedItem: TargetedDropItem? + @Binding var draggedItem: UUID? + let representative: DelegateTarget + let section: TabSection + + func dropEntered(info: DropInfo) { + targetedItem = representative + .toDropItem(withId: .zero) + } + + func dropExited(info: DropInfo) { + targetedItem = nil + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + .init(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedItem = nil + targetedItem = nil + } + + guard let provider = info.itemProviders(for: [.text]).first else { return false } + performHapticFeedback(pattern: .alignment) + provider.loadObject(ofClass: NSString.self) { + object, + _ in + if let string = object as? String, + let uuid = UUID(uuidString: string) + { + DispatchQueue.main.async { + // First try to find the tab in the target container + var from = container.tabs.first(where: { $0.id == uuid }) + + // If not found, try to find it in all containers of the same type + if from == nil { + // Look through all tabs in all containers to find the dragged tab + for container in container.tabs.compactMap(\.container).unique() { + if let foundTab = container.tabs.first(where: { $0.id == uuid }) { + from = foundTab + break + } + } + } + + guard let from else { return } + + if Ora.section(for: from) == section { + withAnimation( + .spring( + response: 0.3, + dampingFraction: 0.8 + ) + ) { + container.bringToTop(tab: from) + } + } else { + from.switchSections(from: from, toSection: section) + container.bringToTop(tab: from) + } + } + } + } + return true + } +} + struct TabDropDelegate: DropDelegate { let item: Tab // to let representative: DelegateTarget @@ -51,6 +139,11 @@ struct TabDropDelegate: DropDelegate { } func performDrop(info: DropInfo) -> Bool { + defer { + draggedItem = nil + targetedItem = nil + } + guard let provider = info.itemProviders(for: [.text]).first else { return false } performHapticFeedback(pattern: .alignment) provider.loadObject(ofClass: NSString.self) { @@ -111,7 +204,8 @@ struct TabDropDelegate: DropDelegate { .reorderTabs( from: from, to: self.item, - withReparentingBehavior: representative.reparentingBehavior + withReparentingBehavior: SettingsStore.shared.treeTabsEnabled ? representative + .reparentingBehavior : .sibling ) } } @@ -121,8 +215,6 @@ struct TabDropDelegate: DropDelegate { } } } - draggedItem = nil - targetedItem = nil return true } } diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index a8d9e9d8..ff41f012 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -30,11 +30,26 @@ class TabManager: ObservableObject { ) } + private func addSplitMembers(to tabs: inout Set, fromContainer container: TabContainer) { + for tileset in container.tilesets { + if tileset.tabs.contains(where: { tabs.contains($0) }) { + tabs.formUnion(tileset.tabs) + } + } + } + + func isInSplit(tab: Tab) -> Bool { + activeContainer?.tilesets.contains(where: { + $0.tabs.contains(tab) && ($0.tabs.first(where: { $0.id == activeTab?.id }) != nil) + }) ?? false + } + var tabsToRender: [Tab] { guard let container = activeContainer else { return [] } let specialTabs = container.tabs.filter { $0.type == .pinned || $0.type == .fav || $0.isPlayingMedia } - let combined = Set(recentTabs + specialTabs) - return Array(combined) + var combined = Set(recentTabs + specialTabs) + addSplitMembers(to: &combined, fromContainer: container) + return Array(combined).sorted(by: { $0.order < $1.order }) } // Note: Could be made injectable via init parameter if preferred @@ -218,7 +233,7 @@ class TabManager: ObservableObject { isPrivate: isPrivate ) modelContext.insert(newTab) - container.tabs.append(newTab) + container.addTab(newTab) activeTab?.maybeIsActive = false activeTab = newTab activeTab?.maybeIsActive = true @@ -258,6 +273,8 @@ class TabManager: ObservableObject { let cleanHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host + let orderBase = (parent != nil ? parent!.children : container.tabs).map(\.order).max() ?? -1 + let newTab = Tab( parent: parent, url: url, title: cleanHost, @@ -265,14 +282,14 @@ class TabManager: ObservableObject { container: container, type: .normal, isPlayingMedia: false, - order: ((parent != nil ? parent!.children : container.tabs).map(\.order).max() ?? -1) + 1, + order: orderBase + 1, historyManager: historyManager, downloadManager: downloadManager, tabManager: self, isPrivate: isPrivate ) modelContext.insert(newTab) - container.tabs.append(newTab) + container.addTab(newTab) if focusAfterOpening { activeTab?.maybeIsActive = false diff --git a/ora/UI/DragTarget.swift b/ora/UI/DragTarget.swift index 475e378f..43dedb4a 100644 --- a/ora/UI/DragTarget.swift +++ b/ora/UI/DragTarget.swift @@ -12,27 +12,30 @@ struct DragTarget: View { let tab: Tab @Binding var draggedItem: UUID? @Binding var targetedDropItem: TargetedDropItem? + let showTree: Bool var body: some View { HStack { - ConditionallyConcentricRectangle(cornerRadius: 10) - .stroke( - theme.accent, - style: StrokeStyle(lineWidth: 1, dash: [5, 5]) - ) - .overlay { - Image(systemName: "arrow.down.to.line") - .bold() - } - .onDrop( - of: [.text], - delegate: TabDropDelegate( - item: tab, - representative: - .tab(tabset: false), draggedItem: $draggedItem, - targetedItem: $targetedDropItem, - targetSection: .normal + if showTree { + ConditionallyConcentricRectangle(cornerRadius: 10) + .stroke( + theme.accent, + style: StrokeStyle(lineWidth: 1, dash: [5, 5]) ) - ) + .overlay { + Image(systemName: "arrow.down.to.line") + .bold() + } + .onDrop( + of: [.text], + delegate: TabDropDelegate( + item: tab, + representative: + .tab(tabset: false), draggedItem: $draggedItem, + targetedItem: $targetedDropItem, + targetSection: .normal + ) + ) + } ConditionallyConcentricRectangle(cornerRadius: 10) .stroke( theme.accent, diff --git a/ora/UI/DropCapsule.swift b/ora/UI/DropCapsule.swift new file mode 100644 index 00000000..100bdeff --- /dev/null +++ b/ora/UI/DropCapsule.swift @@ -0,0 +1,28 @@ +// +// DropCapsule.swift +// ora +// +// Created by Jack Hogan on 29/10/25. +// + +import SwiftUI + +struct DropCapsule: View { + @Environment(\.theme) private var theme + let id: UUID + @Binding var targetedDropItem: TargetedDropItem? + @Binding var draggedItem: UUID? + let delegate: DropDelegate + + var body: some View { + Capsule() + .frame(height: 5) + .foregroundStyle(theme.accent) + .opacity(targetedDropItem? + .imTargeted(withMyIdBeing: id, andType: .divider) ?? false ? 0.75 : 0.0) + .onDrop( + of: [.text], + delegate: delegate + ) + } +} diff --git a/ora/UI/TabItem.swift b/ora/UI/TabItem.swift index 8352daac..c3ec3522 100644 --- a/ora/UI/TabItem.swift +++ b/ora/UI/TabItem.swift @@ -176,7 +176,7 @@ struct TabItem: View { } private var tabTitle: some View { - Text(tab.title) + Text("\(tab.order) " + tab.title) .font(.system(size: 13)) .foregroundColor(textColor) .lineLimit(1) From 8c16881bb589cd9b0bb3bdfb5ac984637f57d8bf Mon Sep 17 00:00:00 2001 From: Jack Hogan Date: Wed, 29 Oct 2025 12:44:41 -0400 Subject: [PATCH 09/12] Tab activation --- ora/Services/TabManager.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index ff41f012..8c6302f7 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -40,7 +40,7 @@ class TabManager: ObservableObject { func isInSplit(tab: Tab) -> Bool { activeContainer?.tilesets.contains(where: { - $0.tabs.contains(tab) && ($0.tabs.first(where: { $0.id == activeTab?.id }) != nil) + $0.tabs.contains(tab) && $0.tabs.contains(where: { $0.id == activeTab?.id }) }) ?? false } @@ -391,7 +391,7 @@ class TabManager: ObservableObject { try? modelContext.save() // Persist the undo operation } - func activateTab(_ tab: Tab) { + private func activateTabInner(_ tab: Tab) { activeTab?.maybeIsActive = false activeTab = tab activeTab?.maybeIsActive = true @@ -418,6 +418,16 @@ class TabManager: ObservableObject { try? modelContext.save() } + func activateTab(_ tab: Tab) { + var tabsToActivate = Set([tab]) + if let activeContainer { + addSplitMembers(to: &tabsToActivate, fromContainer: activeContainer) + } + for tab in tabsToActivate { + activateTabInner(tab) + } + } + /// Clean up old tabs that haven't been accessed recently to preserve memory func cleanupOldTabs() { let timeout = SettingsStore.shared.tabAliveTimeout From 9c7d38775f0a5cd6535c190ba34925bfa99a9d7b Mon Sep 17 00:00:00 2001 From: Jack Hogan Date: Wed, 29 Oct 2025 17:20:09 -0400 Subject: [PATCH 10/12] Code cleanup --- ora/Common/Utils/TabUtils.swift | 38 ------------------- ora/Models/Tab.swift | 12 +++--- ora/Models/TabContainer.swift | 14 +++++-- ora/Modules/Browser/BrowserSplitView.swift | 5 ++- .../Sidebar/TabList/NormalTabsList.swift | 4 +- ora/Services/SectionDropDelegate.swift | 6 +-- ora/Services/TabDropDelegate.swift | 15 +++----- ora/Services/TabManager.swift | 12 ++---- 8 files changed, 35 insertions(+), 71 deletions(-) delete mode 100644 ora/Common/Utils/TabUtils.swift diff --git a/ora/Common/Utils/TabUtils.swift b/ora/Common/Utils/TabUtils.swift deleted file mode 100644 index 17cf6897..00000000 --- a/ora/Common/Utils/TabUtils.swift +++ /dev/null @@ -1,38 +0,0 @@ -import SwiftUI - -enum TabSection { - case fav - case pinned - case normal -} - -// MARK: - Tab Utility Functions - -/// Determines if two tabs are in the same section -func isInSameSection(from: Tab, to: Tab) -> Bool { - return section(for: from) == section(for: to) -} - -/// Gets the section for a given tab based on its type -func section(for tab: Tab) -> TabSection { - switch tab.type { - case .fav: return .fav - case .pinned: return .pinned - case .normal: return .normal - } -} - -/// Converts a TabSection to corresponding TabType -func tabType(for section: TabSection) -> TabType { - switch section { - case .fav: return .fav - case .pinned: return .pinned - case .normal: return .normal - } -} - -/// Moves a tab between different sections -func moveTabBetweenSections(from: Tab, to: Tab) { - from.switchSections(from: from, to: to) - from.container.reorderTabs(from: from, to: to) -} diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 0a792873..36a41e90 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -30,7 +30,7 @@ class Tab: ObservableObject, Identifiable { var createdAt: Date var lastAccessedAt: Date? - var type: TabType + private(set) var type: TabType var order: Int var faviconLocalFile: URL? var backgroundColorHex: String = "#000000" @@ -181,13 +181,13 @@ class Tab: ObservableObject, Identifiable { } } - func switchSections(from: Tab, toSection sec: TabSection) { - from.type = tabType(for: sec) - switch sec { + func switchSections(to sec: TabType) { + type = sec + savedURL = switch sec { case .pinned, .fav: - from.savedURL = from.url + url case .normal: - from.savedURL = nil + nil } } diff --git a/ora/Models/TabContainer.swift b/ora/Models/TabContainer.swift index e947debf..5d76ead8 100644 --- a/ora/Models/TabContainer.swift +++ b/ora/Models/TabContainer.swift @@ -81,17 +81,25 @@ class TabContainer: ObservableObject, Identifiable { func combineToTileset(withSourceTab src: Tab, andDestinationTab dst: Tab) { // Remove from tabset if exists removeTabFromTileset(tab: src) - reorderTabs(from: src, to: dst, withReparentingBehavior: .sibling) + if let tabset = tilesets.first(where: { $0.tabs.contains(dst) }) { - let dstIdx = tabset.tabs.firstIndex(of: dst)! - tabset.tabs.insert(src, at: dstIdx + 1) + tabset.tabs.append(src) + reorderTabs(from: src, to: tabset.tabs.last!, withReparentingBehavior: .sibling) } else { + reorderTabs(from: src, to: dst, withReparentingBehavior: .sibling) let ts = TabTileset(tabs: []) tilesets.append(ts) ts.tabs = [src, dst] } } + func moveTab(_ tab: Tab, toSection section: TabType) { + let tabset = tilesets.first(where: { $0.tabs.contains(tab) })?.tabs ?? [tab] + for tab in tabset { + tab.switchSections(to: section) + } + } + func bringToTop(tab target: Tab) { for tab in tabs { if tab.order > target.order { diff --git a/ora/Modules/Browser/BrowserSplitView.swift b/ora/Modules/Browser/BrowserSplitView.swift index 2242a304..6a1a6674 100644 --- a/ora/Modules/Browser/BrowserSplitView.swift +++ b/ora/Modules/Browser/BrowserSplitView.swift @@ -98,8 +98,9 @@ struct BrowserSplitView: View { } } HStack { - ForEach(tabManager.tabsToRender.filter { $0.id == activeId || tabManager.isInSplit(tab: $0) }) { tab in - if tab.isWebViewReady { + let tabs = tabManager.tabsToRender.filter { $0.id == activeId || tabManager.isInSplit(tab: $0) } + if tabs.allSatisfy(\.isWebViewReady) { + ForEach(tabs) { tab in BrowserContentContainer { BrowserWebContentView(tab: tab) } diff --git a/ora/Modules/Sidebar/TabList/NormalTabsList.swift b/ora/Modules/Sidebar/TabList/NormalTabsList.swift index aae81d8a..c9394651 100644 --- a/ora/Modules/Sidebar/TabList/NormalTabsList.swift +++ b/ora/Modules/Sidebar/TabList/NormalTabsList.swift @@ -1,7 +1,7 @@ import SwiftData import SwiftUI -private struct IndentedTab: Identifiable { +struct IndentedTab: Identifiable { let tab: Tab let indentationLevel: Int let tabs: [Tab] @@ -54,7 +54,7 @@ private func tabsSortedByParentImpl( return output } -private func tabsSortedByParent(_ tabs: [Tab]) -> [IndentedTab] { +func tabsSortedByParent(_ tabs: [Tab]) -> [IndentedTab] { var usedTilesets: Set = [] return tabsSortedByParentImpl( tabs, diff --git a/ora/Services/SectionDropDelegate.swift b/ora/Services/SectionDropDelegate.swift index 75ccb092..e212090c 100644 --- a/ora/Services/SectionDropDelegate.swift +++ b/ora/Services/SectionDropDelegate.swift @@ -4,7 +4,7 @@ import SwiftUI struct SectionDropDelegate: DropDelegate { let items: [Tab] @Binding var draggedItem: UUID? - let targetSection: TabSection + let targetSection: TabType let tabManager: TabManager func dropEntered(info: DropInfo) { @@ -24,12 +24,12 @@ struct SectionDropDelegate: DropDelegate { if self.items.isEmpty { // Section is empty, just change type and order - let newType = tabType(for: self.targetSection) + let newType = self.targetSection if [.pinned, .fav].contains(newType) { from.abandonChildren() } from.dissociateFromRelatives() - from.type = newType + container.moveTab(from, toSection: newType) // Update savedURL when moving into pinned/fav; clear when moving to normal switch newType { case .pinned, .fav: diff --git a/ora/Services/TabDropDelegate.swift b/ora/Services/TabDropDelegate.swift index 8796fbc7..0cb7b4b5 100644 --- a/ora/Services/TabDropDelegate.swift +++ b/ora/Services/TabDropDelegate.swift @@ -52,7 +52,7 @@ struct TopDropDelegate: DropDelegate { @Binding var targetedItem: TargetedDropItem? @Binding var draggedItem: UUID? let representative: DelegateTarget - let section: TabSection + let section: TabType func dropEntered(info: DropInfo) { targetedItem = representative @@ -98,7 +98,7 @@ struct TopDropDelegate: DropDelegate { guard let from else { return } - if Ora.section(for: from) == section { + if from.type == section { withAnimation( .spring( response: 0.3, @@ -108,7 +108,7 @@ struct TopDropDelegate: DropDelegate { container.bringToTop(tab: from) } } else { - from.switchSections(from: from, toSection: section) + from.switchSections(to: section) container.bringToTop(tab: from) } } @@ -124,7 +124,7 @@ struct TabDropDelegate: DropDelegate { @Binding var draggedItem: UUID? @Binding var targetedItem: TargetedDropItem? - let targetSection: TabSection + let targetSection: TabType func dropEntered(info: DropInfo) { targetedItem = representative.toDropItem(withId: item.id) @@ -181,10 +181,7 @@ struct TabDropDelegate: DropDelegate { guard let from else { return } - if isInSameSection( - from: from, - to: self.item - ) { + if from.type == self.item.type { withAnimation( .spring( response: 0.3, @@ -210,7 +207,7 @@ struct TabDropDelegate: DropDelegate { } } } else { - moveTabBetweenSections(from: from, to: self.item) + from.switchSections(to: self.item.type) } } } diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 8c6302f7..f441dcf3 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -110,11 +110,9 @@ class TabManager: ObservableObject { func togglePinTab(_ tab: Tab) { if tab.type == .pinned { - tab.type = .normal - tab.savedURL = nil + tab.switchSections(to: .normal) } else { - tab.type = .pinned - tab.savedURL = tab.url + tab.switchSections(to: .pinned) } try? modelContext.save() @@ -122,11 +120,9 @@ class TabManager: ObservableObject { func toggleFavTab(_ tab: Tab) { if tab.type == .fav { - tab.type = .normal - tab.savedURL = nil + tab.switchSections(to: .normal) } else { - tab.type = .fav - tab.savedURL = tab.url + tab.switchSections(to: .fav) } try? modelContext.save() From 18e0f6f6687ff2dc61432dd9c7f4509cf4aa8aee Mon Sep 17 00:00:00 2001 From: Jack Hogan Date: Wed, 29 Oct 2025 21:14:48 -0400 Subject: [PATCH 11/12] Favorites support --- ora/Models/Tab.swift | 2 +- ora/Models/TabContainer.swift | 63 +++-- ora/Modules/Sidebar/ContainerView.swift | 10 +- ora/Modules/Sidebar/TabList/FavTabsList.swift | 31 +-- .../Sidebar/TabList/NormalTabsList.swift | 227 ----------------- .../Sidebar/TabList/PinnedTabsList.swift | 70 ----- ora/Modules/Sidebar/TabList/TabsList.swift | 241 ++++++++++++++++++ ora/Services/GeneralDropDelegate.swift | 237 +++++++++++++++++ ora/Services/SectionDropDelegate.swift | 65 ----- ora/Services/TabDropDelegate.swift | 74 ++++-- ora/Services/TabManager.swift | 18 +- ora/UI/DragTarget.swift | 10 +- ora/UI/FavTabItem.swift | 141 +++++----- 13 files changed, 682 insertions(+), 507 deletions(-) delete mode 100644 ora/Modules/Sidebar/TabList/NormalTabsList.swift delete mode 100644 ora/Modules/Sidebar/TabList/PinnedTabsList.swift create mode 100644 ora/Modules/Sidebar/TabList/TabsList.swift create mode 100644 ora/Services/GeneralDropDelegate.swift delete mode 100644 ora/Services/SectionDropDelegate.swift diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 36a41e90..51196f4a 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -431,7 +431,7 @@ class Tab: ObservableObject, Identifiable { } } } else { - for sibling in container.tabs { + for sibling in container.tabs where sibling.type == type { if sibling.order > self.order { sibling.order -= 1 } diff --git a/ora/Models/TabContainer.swift b/ora/Models/TabContainer.swift index 5d76ead8..6a94974c 100644 --- a/ora/Models/TabContainer.swift +++ b/ora/Models/TabContainer.swift @@ -34,8 +34,12 @@ class TabContainer: ObservableObject, Identifiable { self.lastAccessedAt = nowDate } - private func pushTabs(in tab: Tab?, startingAfter idx: Int, _ amount: Int = 1) { - for tab in tab?.children ?? tabs { + private func pushTabs( + in tab: Tab?, + ofType type: TabType, startingAfter idx: Int, + _ amount: Int = 1 + ) { + for tab in tab?.children ?? tabs where tab.type == type { if tab.order > idx { tab.order += amount } @@ -50,6 +54,7 @@ class TabContainer: ObservableObject, Identifiable { orderBase = parent.order pushTabs( in: nil, + ofType: tab.type, startingAfter: parent.order ) } else { @@ -78,13 +83,14 @@ class TabContainer: ObservableObject, Identifiable { tab.tileset = nil } + // TODO: Handle combining two tilesets func combineToTileset(withSourceTab src: Tab, andDestinationTab dst: Tab) { // Remove from tabset if exists removeTabFromTileset(tab: src) if let tabset = tilesets.first(where: { $0.tabs.contains(dst) }) { - tabset.tabs.append(src) reorderTabs(from: src, to: tabset.tabs.last!, withReparentingBehavior: .sibling) + tabset.tabs.append(src) } else { reorderTabs(from: src, to: dst, withReparentingBehavior: .sibling) let ts = TabTileset(tabs: []) @@ -97,18 +103,10 @@ class TabContainer: ObservableObject, Identifiable { let tabset = tilesets.first(where: { $0.tabs.contains(tab) })?.tabs ?? [tab] for tab in tabset { tab.switchSections(to: section) - } - } - - func bringToTop(tab target: Tab) { - for tab in tabs { - if tab.order > target.order { - tab.order -= 1 - } else { - tab.order += 1 + if [.pinned, .fav].contains(section) { + tab.abandonChildren() } } - target.order = 0 } func reorderTabs( @@ -118,14 +116,27 @@ class TabContainer: ObservableObject, Identifiable { ) { let containingTilesetTabs = tilesets.first(where: { $0.tabs.contains(from) })?.tabs ?? [from] let numRelevantTabs = containingTilesetTabs.count + reorderTabs(from: from, to: to.type) - from.dissociateFromRelatives() switch reparentingBehavior { case .sibling: - to.parent?.children.insert(from, at: 0) - pushTabs(in: to.parent, startingAfter: to.order, numRelevantTabs) + if let parent = to.parent { + parent.children.insert(from, at: 0) + from.parent = parent + } else { + from.parent = nil + } + // Find the highest tab in the tileset to push after + let maxToTilesetOrder = tilesets.first(where: { $0.tabs.contains(to) })?.tabs.map(\.order).max() ?? to.order + + pushTabs( + in: to.parent, + ofType: to.type, + startingAfter: maxToTilesetOrder, + numRelevantTabs + ) for (i, tab) in containingTilesetTabs.enumerated() { - tab.order = to.order + 1 + i + tab.order = maxToTilesetOrder + 1 + i } case .child: to.children.insert(from, at: 0) @@ -138,6 +149,24 @@ class TabContainer: ObservableObject, Identifiable { } } + func reorderTabs(from: Tab, to: TabType, offsetTargetTypeOrder: Bool = false) { + let containingTilesetTabs = tilesets.first(where: { $0.tabs.contains(from) })?.tabs ?? [from] + + containingTilesetTabs.forEach { $0.dissociateFromRelatives() } + + if from.type != to { + moveTab(from, toSection: to) + } + if offsetTargetTypeOrder { + for tab in tabs where tab.type == to { + tab.order += containingTilesetTabs.count + } + } + for (i, tab) in containingTilesetTabs.enumerated() { + tab.order = i + } + } + func flattenTabs() { for tab in tabs { tab.parent = nil diff --git a/ora/Modules/Sidebar/ContainerView.swift b/ora/Modules/Sidebar/ContainerView.swift index 8f108374..8799adc1 100644 --- a/ora/Modules/Sidebar/ContainerView.swift +++ b/ora/Modules/Sidebar/ContainerView.swift @@ -61,7 +61,7 @@ struct ContainerView: View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { if !privacyMode.isPrivate { - PinnedTabsList( + TabsList( tabs: pinnedTabs, draggedItem: $draggedItem, onDrag: dragTab, @@ -71,11 +71,12 @@ struct ContainerView: View { onClose: removeTab, onDuplicate: duplicateTab, onMoveToContainer: moveTab, - containers: containers + onAddNewTab: addNewTab, + isPinned: true ) Divider() } - NormalTabsList( + TabsList( tabs: normalTabs, draggedItem: $draggedItem, onDrag: dragTab, @@ -85,7 +86,8 @@ struct ContainerView: View { onClose: removeTab, onDuplicate: duplicateTab, onMoveToContainer: moveTab, - onAddNewTab: addNewTab + onAddNewTab: addNewTab, + isPinned: false ) } } diff --git a/ora/Modules/Sidebar/TabList/FavTabsList.swift b/ora/Modules/Sidebar/TabList/FavTabsList.swift index d5713787..8bdf58f9 100644 --- a/ora/Modules/Sidebar/TabList/FavTabsList.swift +++ b/ora/Modules/Sidebar/TabList/FavTabsList.swift @@ -28,19 +28,11 @@ struct FavTabsGrid: View { LazyVGrid(columns: adaptiveColumns, spacing: 10) { if tabs.isEmpty { EmptyFavTabItem() - .onDrop( - of: [.text], - delegate: SectionDropDelegate( - items: tabs, - draggedItem: $draggedItem, - targetSection: .fav, - tabManager: tabManager - ) - ) } else { - ForEach(tabs) { tab in + ForEach(tabsSortedByParent(tabs)) { iTab in + let tab = iTab.tabs.first! FavTabItem( - tab: tab, + tabs: iTab.tabs, isSelected: tabManager.isActive(tab), isDragging: draggedItem == tab.id, onTap: { onSelect(tab) }, @@ -52,8 +44,8 @@ struct FavTabsGrid: View { .onDrag { onDrag(tab.id) } .onDrop( of: [.text], - delegate: TabDropDelegate( - item: tab, + delegate: GeneralDropDelegate( + item: .tab(tab), representative: .tab(tabset: true), draggedItem: $draggedItem, targetedItem: .constant(nil), @@ -63,15 +55,16 @@ struct FavTabsGrid: View { } } } - .animation(.easeOut(duration: 0.1), value: adaptiveColumns.count) .onDrop( of: [.text], - delegate: SectionDropDelegate( - items: tabs, - draggedItem: $draggedItem, - targetSection: .fav, - tabManager: tabManager + delegate: GeneralDropDelegate( + item: + .container( + tabManager.activeContainer!), + representative: .divider, draggedItem: $draggedItem, + targetedItem: .constant(nil), targetSection: .fav ) ) + .animation(.easeOut(duration: 0.1), value: adaptiveColumns.count) } } diff --git a/ora/Modules/Sidebar/TabList/NormalTabsList.swift b/ora/Modules/Sidebar/TabList/NormalTabsList.swift deleted file mode 100644 index c9394651..00000000 --- a/ora/Modules/Sidebar/TabList/NormalTabsList.swift +++ /dev/null @@ -1,227 +0,0 @@ -import SwiftData -import SwiftUI - -struct IndentedTab: Identifiable { - let tab: Tab - let indentationLevel: Int - let tabs: [Tab] - - var id: UUID { tab.id } -} - -private func tabsSortedByParentImpl( - _ tabs: [Tab], - withParentTabSelector parentTabSelector: UUID?, - withIndentation indentation: Int, - withTilesetUseSet usedTilesets: inout Set -) -> [IndentedTab] { - // Start by finding only parent tags, then recurse down through - var output = [IndentedTab]() - let roots = tabs.filter { $0.parent?.id == parentTabSelector }.sorted( - by: { $0.order < $1.order - }) - for root in roots { - var toAppend = [root] - if let tileset = root.tileset { - if usedTilesets.contains(tileset.id) { - continue - } - let foundTiles = Set(tileset.tabs.map(\.id)) - toAppend = tabs.filter { foundTiles.contains($0.id) } - assert(!toAppend.isEmpty) - usedTilesets.insert(tileset.id) - } - output - .append( - IndentedTab( - tab: root, - indentationLevel: indentation, - tabs: toAppend.reversed() - ) - ) - - output - .append( - contentsOf: tabsSortedByParentImpl( - root.children, - withParentTabSelector: root.id, - withIndentation: indentation + 1, - withTilesetUseSet: &usedTilesets - ) - ) - } - - return output -} - -func tabsSortedByParent(_ tabs: [Tab]) -> [IndentedTab] { - var usedTilesets: Set = [] - return tabsSortedByParentImpl( - tabs, - withParentTabSelector: nil, - withIndentation: 0, - withTilesetUseSet: &usedTilesets - ) -} - -enum TargetedDropItem { - case tab(id: UUID, tabset: Bool), divider(UUID) - - func imTargeted(withMyIdBeing id: UUID, andType t: DelegateTarget) -> Bool { - switch (self, t) { - case let (.tab(uuid, tabsetA), .tab(_)): - return uuid == id - case let (.divider(uuid), .divider): - return uuid == id - default: - return false - } - } -} - -struct NormalTabsList: View { - let tabs: [Tab] - @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 onDuplicate: (Tab) -> Void - let onMoveToContainer: - ( - Tab, - TabContainer - ) -> Void - let onAddNewTab: () -> Void - @Query var containers: [TabContainer] - @EnvironmentObject var tabManager: TabManager - @State private var previousTabIds: [UUID] = [] - @State private var targetedDropItem: TargetedDropItem? - @StateObject private var settings = SettingsStore.shared - @Environment(\.theme) private var theme - - var body: some View { - VStack(spacing: 3) { - NewTabButton(addNewTab: onAddNewTab) - - Text( - "\(String(describing: tabManager.activeContainer?.tilesets.map { $0.tabs.map(\.title) }))" - ) - - DropCapsule( - id: .zero, - targetedDropItem: $targetedDropItem, - draggedItem: $draggedItem, - delegate: TopDropDelegate( - container: tabManager.activeContainer ?? containers.first!, - targetedItem: $targetedDropItem, - draggedItem: $draggedItem, - representative: .divider, - section: .normal - ) - ) - - ForEach(tabsSortedByParent(tabs)) { iTab in - VStack(spacing: 3) { - HStack { - ForEach(iTab.tabs) { tab in - TabItem( - tab: tab, - isSelected: iTab.tabs - .contains( - where: { t in tabManager.isActive(t) - }), - isDragging: draggedItem == tab.id, - isDragTarget: targetedDropItem? - .imTargeted( - withMyIdBeing: tab.id, - andType: .tab(tabset: true) - ) ?? false, - onTap: { onSelect(tab) }, - onPinToggle: { onPinToggle(tab) }, - onFavoriteToggle: { onFavoriteToggle(tab) }, - onClose: { onClose(tab) }, - onDuplicate: { onDuplicate(tab) }, - onMoveToContainer: { onMoveToContainer(tab, $0) }, - availableContainers: containers, - draggedItem: $draggedItem, - targetedDropItem: $targetedDropItem - ) - .onDrag { onDrag(tab.id) } - .onDrop( - of: [.text], - delegate: TabDropDelegate( - item: tab, - representative: .tab(tabset: false), draggedItem: $draggedItem, - targetedItem: $targetedDropItem, - targetSection: .normal - ) - ) - .transition(.asymmetric( - insertion: .opacity.combined(with: .move(edge: .bottom)), - removal: .opacity.combined(with: .move(edge: .top)) - )) - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: shouldAnimate(tab)) - } - } - .overlay( - iTab.tabs.contains(where: { targetedDropItem?.imTargeted( - withMyIdBeing: $0.id, - andType: .tab(tabset: false) - ) ?? false }) ? DragTarget( - tab: iTab.tabs.first!, - draggedItem: $draggedItem, - targetedDropItem: $targetedDropItem, - showTree: settings.treeTabsEnabled - ) : nil - ) - - DropCapsule( - id: iTab.tabs.first!.id, - targetedDropItem: $targetedDropItem, - draggedItem: $draggedItem, - delegate: TabDropDelegate( - item: iTab.tabs.first!, - representative: .divider, - draggedItem: $draggedItem, - targetedItem: $targetedDropItem, - targetSection: .normal - ) - ) - } - .padding( - .leading, - CGFloat( - integerLiteral: settings.treeTabsEnabled ? iTab.indentationLevel * 8 : 0 - ) - ) - } - } - .onDrop( - of: [.text], - delegate: SectionDropDelegate( - items: tabs, - draggedItem: $draggedItem, - targetSection: .normal, - tabManager: tabManager - ) - ) - .onAppear { - previousTabIds = tabs.map(\.id) - } - .onChange(of: tabs.map(\.id)) { newTabIds in - previousTabIds = newTabIds - } - } - - private func shouldAnimate(_ tab: Tab) -> Bool { - // Only animate if the tab's position has actually changed - guard let currentIndex = tabs.firstIndex(where: { $0.id == tab.id }), - let previousIndex = previousTabIds.firstIndex(where: { $0 == tab.id }) - else { - return true // Animate new tabs or tabs that were just created - } - return currentIndex != previousIndex - } -} diff --git a/ora/Modules/Sidebar/TabList/PinnedTabsList.swift b/ora/Modules/Sidebar/TabList/PinnedTabsList.swift deleted file mode 100644 index 37c23395..00000000 --- a/ora/Modules/Sidebar/TabList/PinnedTabsList.swift +++ /dev/null @@ -1,70 +0,0 @@ -import SwiftData -import SwiftUI - -struct PinnedTabsList: View { - let tabs: [Tab] - @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 onDuplicate: (Tab) -> Void - let onMoveToContainer: (Tab, TabContainer) -> Void - let containers: [TabContainer] - @EnvironmentObject var tabManager: TabManager - @Environment(\.theme) var theme - - var body: some View { - VStack(spacing: 8) { - Text("Pinned") - .font(.callout) - .foregroundColor(theme.mutedForeground) - .padding(.top, 8) - .frame(maxWidth: .infinity, alignment: .leading) - if tabs.isEmpty { - EmptyPinnedTabs() - } else { - ForEach(tabs) { tab in - TabItem( - tab: tab, - isSelected: tabManager.isActive(tab), - isDragging: draggedItem == tab.id, - isDragTarget: false, - onTap: { onSelect(tab) }, - onPinToggle: { onPinToggle(tab) }, - onFavoriteToggle: { onFavoriteToggle(tab) }, - onClose: { onClose(tab) }, - onDuplicate: { onDuplicate(tab) }, - onMoveToContainer: { onMoveToContainer(tab, $0) }, - availableContainers: containers, - // TODO: Fix - draggedItem: .constant(nil), - targetedDropItem: .constant(nil) - ) - .onDrag { onDrag(tab.id) } - .onDrop( - of: [.text], - delegate: TabDropDelegate( - item: tab, - representative: .tab(tabset: true), - draggedItem: $draggedItem, - targetedItem: .constant(nil), - targetSection: .pinned - ) - ) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .onDrop( - of: [.text], - delegate: SectionDropDelegate( - items: tabs, - draggedItem: $draggedItem, - targetSection: .pinned, - tabManager: tabManager - ) - ) - } -} diff --git a/ora/Modules/Sidebar/TabList/TabsList.swift b/ora/Modules/Sidebar/TabList/TabsList.swift new file mode 100644 index 00000000..75f77fdc --- /dev/null +++ b/ora/Modules/Sidebar/TabList/TabsList.swift @@ -0,0 +1,241 @@ +import SwiftData +import SwiftUI + +struct IndentedTab: Identifiable { + let tab: Tab + let indentationLevel: Int + let tabs: [Tab] + + var id: UUID { tab.id } +} + +private func tabsSortedByParentImpl( + _ tabs: [Tab], + withParentTabSelector parentTabSelector: UUID?, + withIndentation indentation: Int, + withTilesetUseSet usedTilesets: inout Set +) -> [IndentedTab] { + // Start by finding only parent tags, then recurse down through + var output = [IndentedTab]() + let roots = tabs.filter { $0.parent?.id == parentTabSelector }.sorted( + by: { $0.order < $1.order + }) + for root in roots { + var toAppend = [root] + if let tileset = root.tileset { + if usedTilesets.contains(tileset.id) { + continue + } + let foundTiles = Set(tileset.tabs.map(\.id)) + toAppend = tabs.filter { foundTiles.contains($0.id) } + assert(!toAppend.isEmpty) + usedTilesets.insert(tileset.id) + } + output + .append( + IndentedTab( + tab: root, + indentationLevel: indentation, + tabs: toAppend.reversed() + ) + ) + + output + .append( + contentsOf: tabsSortedByParentImpl( + root.children, + withParentTabSelector: root.id, + withIndentation: indentation + 1, + withTilesetUseSet: &usedTilesets + ) + ) + } + + return output +} + +func tabsSortedByParent(_ tabs: [Tab]) -> [IndentedTab] { + var usedTilesets: Set = [] + return tabsSortedByParentImpl( + tabs, + withParentTabSelector: nil, + withIndentation: 0, + withTilesetUseSet: &usedTilesets + ) +} + +enum TargetedDropItem { + case tab(id: UUID, tabset: Bool), divider(UUID) + + func imTargeted(withMyIdBeing id: UUID, andType t: DelegateTarget) -> Bool { + switch (self, t) { + case let (.tab(uuid, _), .tab(_)): + return uuid == id + case let (.divider(uuid), .divider): + return uuid == id + default: + return false + } + } +} + +struct TabsList: View { + let tabs: [Tab] + @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 onDuplicate: (Tab) -> Void + let onMoveToContainer: + ( + Tab, + TabContainer + ) -> Void + let onAddNewTab: () -> Void + @Query var containers: [TabContainer] + @EnvironmentObject var tabManager: TabManager + @State private var previousTabIds: [UUID] = [] + @State private var targetedDropItem: TargetedDropItem? + @StateObject private var settings = SettingsStore.shared + @Environment(\.theme) private var theme + let isPinned: Bool + + var body: some View { + VStack(spacing: 3) { + if isPinned { + Text("Pinned") + .font(.callout) + .foregroundColor(theme.mutedForeground) + .padding(.top, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + NewTabButton(addNewTab: onAddNewTab) + } + + DropCapsule( + id: .zero, + targetedDropItem: $targetedDropItem, + draggedItem: $draggedItem, + delegate: TopDropDelegate( + container: tabManager.activeContainer ?? containers.first!, + targetedItem: $targetedDropItem, + draggedItem: $draggedItem, + representative: .divider, + section: isPinned ? .pinned : .normal + ) + ) + + if tabs.isEmpty, isPinned { + EmptyPinnedTabs() + } else { + ForEach(tabsSortedByParent(tabs)) { iTab in + VStack(spacing: 3) { + HStack { + ForEach(iTab.tabs) { tab in + TabItem( + tab: tab, + isSelected: iTab.tabs + .contains( + where: { t in tabManager.isActive(t) + }), + isDragging: draggedItem == tab.id, + isDragTarget: targetedDropItem? + .imTargeted( + withMyIdBeing: tab.id, + andType: .tab(tabset: true) + ) ?? false, + onTap: { onSelect(tab) }, + onPinToggle: { onPinToggle(tab) }, + onFavoriteToggle: { onFavoriteToggle(tab) }, + onClose: { onClose(tab) }, + onDuplicate: { onDuplicate(tab) }, + onMoveToContainer: { onMoveToContainer(tab, $0) }, + availableContainers: containers, + draggedItem: $draggedItem, + targetedDropItem: $targetedDropItem + ) + .onDrag { onDrag(tab.id) } + .onDrop( + of: [.text], + delegate: GeneralDropDelegate( + item: .tab(tab), + representative: .tab(tabset: false), draggedItem: $draggedItem, + targetedItem: $targetedDropItem, + targetSection: isPinned ? .pinned : .normal + ) + ) + .transition(.asymmetric( + insertion: .opacity.combined(with: .move(edge: .bottom)), + removal: .opacity.combined(with: .move(edge: .top)) + )) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: shouldAnimate(tab)) + } + } + .overlay( + iTab.tabs.contains(where: { targetedDropItem?.imTargeted( + withMyIdBeing: $0.id, + andType: .tab(tabset: false) + ) ?? false }) ? DragTarget( + tab: iTab.tabs.first!, + draggedItem: $draggedItem, + targetedDropItem: $targetedDropItem, + showTree: settings.treeTabsEnabled + ) : nil + ) + + DropCapsule( + id: iTab.tabs.first!.id, + targetedDropItem: $targetedDropItem, + draggedItem: $draggedItem, + delegate: GeneralDropDelegate( + item: .tab(iTab.tabs.first!), + representative: .divider, + draggedItem: $draggedItem, + targetedItem: $targetedDropItem, + targetSection: isPinned ? .pinned : .normal + ) + ) + } + .padding( + .leading, + CGFloat( + integerLiteral: settings.treeTabsEnabled ? iTab.indentationLevel * 8 : 0 + ) + ) + } + } + } + .onDrop( + of: [.text], + delegate: tabs.isEmpty ? GeneralDropDelegate( + item: + .container( + tabManager.activeContainer!), + representative: .divider, draggedItem: $draggedItem, + targetedItem: $targetedDropItem, targetSection: isPinned ? .pinned : .normal + ) : NilDropDelegate() + ) + .onAppear { + if !isPinned { + previousTabIds = tabs.map(\.id) + } + } + .onChange(of: tabs.map(\.id)) { newTabIds in + if !isPinned { + previousTabIds = newTabIds + } + } + } + + private func shouldAnimate(_ tab: Tab) -> Bool { + // Only animate if the tab's position has actually changed + guard let currentIndex = tabs.firstIndex(where: { $0.id == tab.id }), + let previousIndex = previousTabIds.firstIndex(where: { $0 == tab.id }) + else { + return true // Animate new tabs or tabs that were just created + } + return currentIndex != previousIndex + } +} diff --git a/ora/Services/GeneralDropDelegate.swift b/ora/Services/GeneralDropDelegate.swift new file mode 100644 index 00000000..928a61c7 --- /dev/null +++ b/ora/Services/GeneralDropDelegate.swift @@ -0,0 +1,237 @@ +import AppKit +import SwiftUI + +extension Array where Element: Hashable { + func unique() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } + } +} + +enum DelegateTarget { + case tab(tabset: Bool), divider + + func toDropItem(withId id: UUID) -> TargetedDropItem { + switch self { + case let .tab(tabset): + return .tab(id: id, tabset: tabset) + case .divider: + return .divider(id) + } + } + + var reparentingBehavior: ReparentingBehavior { + switch self { + case .tab: + return .child + case .divider: + return .sibling + } + } +} + +extension UUID { + static let zero: UUID = .init(uuidString: "00000000-0000-0000-0000-000000000000")! +} + +struct NilDropDelegate: DropDelegate { + func dropEntered(info: DropInfo) {} + + func dropExited(info: DropInfo) {} + + func dropUpdated(info: DropInfo) -> DropProposal? { + .init(operation: .forbidden) + } + + func performDrop(info: DropInfo) -> Bool { false } +} + +/// Adds a tab to the top of the list +struct TopDropDelegate: DropDelegate { + let container: TabContainer + @Binding var targetedItem: TargetedDropItem? + @Binding var draggedItem: UUID? + let representative: DelegateTarget + let section: TabType + + func dropEntered(info: DropInfo) { + targetedItem = representative + .toDropItem(withId: .zero) + } + + func dropExited(info: DropInfo) { + targetedItem = nil + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + .init(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedItem = nil + targetedItem = nil + } + + guard let provider = info.itemProviders(for: [.text]).first else { return false } + performHapticFeedback(pattern: .alignment) + provider.loadObject(ofClass: NSString.self) { + object, + _ in + if let string = object as? String, + let uuid = UUID(uuidString: string) + { + DispatchQueue.main.async { + // First try to find the tab in the target container + var from = container.tabs.first(where: { $0.id == uuid }) + + // If not found, try to find it in all containers of the same type + if from == nil { + // Look through all tabs in all containers to find the dragged tab + for container in container.tabs.compactMap(\.container).unique() { + if let foundTab = container.tabs.first(where: { $0.id == uuid }) { + from = foundTab + break + } + } + } + + guard let from else { return } + + withAnimation( + .spring( + response: 0.3, + dampingFraction: 0.8 + ) + ) { + container + .reorderTabs( + from: from, + to: section, + offsetTargetTypeOrder: true + ) + } + } + } + } + return true + } +} + +enum GeneralDropDelegateItem { + case tab(Tab), container(TabContainer) + + var container: TabContainer { + switch self { + case let .tab(tab): + return tab.container + case let .container(tabContainer): + return tabContainer + } + } +} + +struct GeneralDropDelegate: DropDelegate { + let item: GeneralDropDelegateItem // to + let representative: DelegateTarget + @Binding var draggedItem: UUID? + @Binding var targetedItem: TargetedDropItem? + + let targetSection: TabType + + func dropEntered(info: DropInfo) { + if case let .tab(tab) = item { + targetedItem = representative.toDropItem(withId: tab.id) + } + } + + func dropExited(info: DropInfo) { + targetedItem = nil + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + .init(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedItem = nil + targetedItem = nil + } + + guard let provider = info.itemProviders(for: [.text]).first else { return false } + performHapticFeedback(pattern: .alignment) + provider.loadObject(ofClass: NSString.self) { + object, + _ in + if let string = object as? String, + let uuid = UUID(uuidString: string) + { + if case let .tab(tab) = item { + if uuid == tab.id { + return + } + + // No assigning a parent to its child + var itemParent = tab.parent + while let parent = itemParent { + if parent.id == uuid { + return + } + itemParent = parent.parent + } + } + DispatchQueue.main.async { + // First try to find the tab in the target container + var from = self.item.container.tabs.first(where: { $0.id == uuid }) + + // If not found, try to find it in all containers of the same type + if from == nil { + // Look through all tabs in all containers to find the dragged tab + for container in self.item.container.tabs.compactMap(\.container).unique() { + if let foundTab = container.tabs.first(where: { $0.id == uuid }) { + from = foundTab + break + } + } + } + + guard let from else { return } + + withAnimation( + .spring( + response: 0.3, + dampingFraction: 0.8 + ) + ) { + switch item { + case let .tab(tab): + if case let .tab(tabset) = representative, tabset { + self.item.container + .combineToTileset( + withSourceTab: from, + andDestinationTab: tab + ) + } else { + self.item.container + .reorderTabs( + from: from, + to: tab, + withReparentingBehavior: SettingsStore.shared.treeTabsEnabled ? representative + .reparentingBehavior : .sibling + ) + } + case .container: + self.item.container + .reorderTabs( + from: from, + to: targetSection + ) + } + } + } + } + } + return true + } +} diff --git a/ora/Services/SectionDropDelegate.swift b/ora/Services/SectionDropDelegate.swift deleted file mode 100644 index e212090c..00000000 --- a/ora/Services/SectionDropDelegate.swift +++ /dev/null @@ -1,65 +0,0 @@ -import AppKit -import SwiftUI - -struct SectionDropDelegate: DropDelegate { - let items: [Tab] - @Binding var draggedItem: UUID? - let targetSection: TabType - let tabManager: TabManager - - func dropEntered(info: DropInfo) { - performHapticFeedback(pattern: .alignment) - guard let provider = info.itemProviders(for: [.text]).first else { return } - - provider.loadObject(ofClass: NSString.self) { object, _ in - guard - let string = object as? String, - let uuid = UUID(uuidString: string) - else { return } - - DispatchQueue.main.async { - guard let container = self.items.first?.container ?? self.tabManager.activeContainer, - let from = container.tabs.first(where: { $0.id == uuid }) - else { return } - - if self.items.isEmpty { - // Section is empty, just change type and order - let newType = self.targetSection - if [.pinned, .fav].contains(newType) { - from.abandonChildren() - } - from.dissociateFromRelatives() - container.moveTab(from, toSection: newType) - // Update savedURL when moving into pinned/fav; clear when moving to normal - switch newType { - case .pinned, .fav: - from.savedURL = from.url - case .normal: - from.savedURL = nil - } - let maxOrder = container.tabs.max(by: { $0.order < $1.order })?.order ?? 0 - from.order = maxOrder + 1 - try? self.tabManager.modelContext.save() - } - // else if let to = self.items.last { - // if isInSameSection(from: from, to: to) { - // withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - // container.reorderTabs(from: from, to: to) - // } - // } else { - // moveTabBetweenSections(from: from, to: to) - // } - // } - } - } - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - draggedItem = nil - return true - } -} diff --git a/ora/Services/TabDropDelegate.swift b/ora/Services/TabDropDelegate.swift index 0cb7b4b5..2c170836 100644 --- a/ora/Services/TabDropDelegate.swift +++ b/ora/Services/TabDropDelegate.swift @@ -108,7 +108,7 @@ struct TopDropDelegate: DropDelegate { container.bringToTop(tab: from) } } else { - from.switchSections(to: section) + container.reorderTabs(from: from, to: section) container.bringToTop(tab: from) } } @@ -118,8 +118,21 @@ struct TopDropDelegate: DropDelegate { } } -struct TabDropDelegate: DropDelegate { - let item: Tab // to +enum GeneralDropDelegateItem { + case tab(Tab), container(TabContainer) + + var container: TabContainer { + switch self { + case let .tab(tab): + return tab.container + case let .container(tabContainer): + return tabContainer + } + } +} + +struct GeneralDropDelegate: DropDelegate { + let item: GeneralDropDelegateItem // to let representative: DelegateTarget @Binding var draggedItem: UUID? @Binding var targetedItem: TargetedDropItem? @@ -127,7 +140,9 @@ struct TabDropDelegate: DropDelegate { let targetSection: TabType func dropEntered(info: DropInfo) { - targetedItem = representative.toDropItem(withId: item.id) + if case let .tab(tab) = item { + targetedItem = representative.toDropItem(withId: tab.id) + } } func dropExited(info: DropInfo) { @@ -152,17 +167,19 @@ struct TabDropDelegate: DropDelegate { if let string = object as? String, let uuid = UUID(uuidString: string) { - if uuid == item.id { - return - } - - // No assigning a parent to its child - var itemParent = item.parent - while let parent = itemParent { - if parent.id == uuid { + if case let .tab(tab) = item { + if uuid == tab.id { return } - itemParent = parent.parent + + // No assigning a parent to its child + var itemParent = tab.parent + while let parent = itemParent { + if parent.id == uuid { + return + } + itemParent = parent.parent + } } DispatchQueue.main.async { // First try to find the tab in the target container @@ -181,33 +198,36 @@ struct TabDropDelegate: DropDelegate { guard let from else { return } - if from.type == self.item.type { - withAnimation( - .spring( - response: 0.3, - dampingFraction: 0.8 - ) - ) { - if case let .tab(tabset) = representative, - tabset - { + withAnimation( + .spring( + response: 0.3, + dampingFraction: 0.8 + ) + ) { + switch item { + case let .tab(tab): + if case let .tab(tabset) = representative, tabset { self.item.container .combineToTileset( withSourceTab: from, - andDestinationTab: self.item + andDestinationTab: tab ) } else { self.item.container .reorderTabs( from: from, - to: self.item, + to: tab, withReparentingBehavior: SettingsStore.shared.treeTabsEnabled ? representative .reparentingBehavior : .sibling ) } + case .container: + self.item.container + .reorderTabs( + from: from, + to: targetSection + ) } - } else { - from.switchSections(to: self.item.type) } } } diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index f441dcf3..786b3c78 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -102,18 +102,19 @@ class TabManager: ObservableObject { } func isActive(_ tab: Tab) -> Bool { - if let activeTab = self.activeTab { - return activeTab.id == tab.id + var tabsToActivate = Set([tab]) + if let activeContainer, let activeTab { + addSplitMembers(to: &tabsToActivate, fromContainer: activeContainer) + return tabsToActivate.contains(activeTab) } return false } func togglePinTab(_ tab: Tab) { - if tab.type == .pinned { - tab.switchSections(to: .normal) - } else { - tab.switchSections(to: .pinned) - } + let opposite = tab.type == .pinned ? TabType.normal : .pinned + + tab.container + .reorderTabs(from: tab, to: opposite, offsetTargetTypeOrder: true) try? modelContext.save() } @@ -222,7 +223,7 @@ class TabManager: ObservableObject { container: container, type: .normal, isPlayingMedia: false, - order: container.tabs.count + 1, + order: 0, historyManager: historyManager, downloadManager: downloadManager, tabManager: self, @@ -284,7 +285,6 @@ class TabManager: ObservableObject { tabManager: self, isPrivate: isPrivate ) - modelContext.insert(newTab) container.addTab(newTab) if focusAfterOpening { diff --git a/ora/UI/DragTarget.swift b/ora/UI/DragTarget.swift index 43dedb4a..89e3cf5f 100644 --- a/ora/UI/DragTarget.swift +++ b/ora/UI/DragTarget.swift @@ -15,7 +15,7 @@ struct DragTarget: View { let showTree: Bool var body: some View { HStack { - if showTree { + if tab.type == .normal, showTree { ConditionallyConcentricRectangle(cornerRadius: 10) .stroke( theme.accent, @@ -27,8 +27,8 @@ struct DragTarget: View { } .onDrop( of: [.text], - delegate: TabDropDelegate( - item: tab, + delegate: GeneralDropDelegate( + item: .tab(tab), representative: .tab(tabset: false), draggedItem: $draggedItem, targetedItem: $targetedDropItem, @@ -47,8 +47,8 @@ struct DragTarget: View { } .onDrop( of: [.text], - delegate: TabDropDelegate( - item: tab, + delegate: GeneralDropDelegate( + item: .tab(tab), representative: .tab(tabset: true), draggedItem: $draggedItem, targetedItem: $targetedDropItem, diff --git a/ora/UI/FavTabItem.swift b/ora/UI/FavTabItem.swift index 82d1bc5a..8df3d533 100644 --- a/ora/UI/FavTabItem.swift +++ b/ora/UI/FavTabItem.swift @@ -3,7 +3,7 @@ import SwiftData import SwiftUI struct FavTabItem: View { - let tab: Tab + let tabs: [Tab] let isSelected: Bool let isDragging: Bool let onTap: () -> Void @@ -23,68 +23,37 @@ struct FavTabItem: View { var body: some View { ZStack { - if let favicon = tab.favicon, tab.isWebViewReady { - AsyncImage( - url: favicon - ) { image in - image - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - } placeholder: { - LocalFavIcon( - faviconLocalFile: tab.faviconLocalFile, - textColor: Color(.white) - ) - } - } else { - LocalFavIcon( - faviconLocalFile: tab.faviconLocalFile, - textColor: Color(.white) - ) - } - - if tab.isPlayingMedia { - VStack { - Spacer() - HStack { - Spacer() - Image(systemName: "speaker.wave.2.fill") - .resizable() - .scaledToFit() - .frame(width: 8, height: 8) - .foregroundColor(.white.opacity(0.9)) - .background( - Circle() - .fill(Color.black.opacity(0.6)) - .frame(width: 12, height: 12) - ) - } + HStack { + ForEach(tabs) { tab in + favicon(forTab: tab) } - .padding(2) } } .onTapGesture { onTap() - if !tab.isWebViewReady { - tab - .restoreTransientState( - historyManager: historyManager, - downloadManager: downloadManager, - tabManager: tabManager, - isPrivate: privacyMode.isPrivate - ) + for tab in tabs { + if !tab.isWebViewReady { + tab + .restoreTransientState( + historyManager: historyManager, + downloadManager: downloadManager, + tabManager: tabManager, + isPrivate: privacyMode.isPrivate + ) + } } } .onAppear { - if tabManager.isActive(tab) { - tab - .restoreTransientState( - historyManager: historyManager, - downloadManager: downloadManager, - tabManager: tabManager, - isPrivate: privacyMode.isPrivate - ) + for tab in tabs { + if tabManager.isActive(tab) { + tab + .restoreTransientState( + historyManager: historyManager, + downloadManager: downloadManager, + tabManager: tabManager, + isPrivate: privacyMode.isPrivate + ) + } } } .foregroundColor(theme.foreground) @@ -110,14 +79,16 @@ struct FavTabItem: View { ) .onTapGesture { onTap() - if !tab.isWebViewReady { - tab - .restoreTransientState( - historyManager: historyManager, - downloadManager: downloadManager, - tabManager: tabManager, - isPrivate: privacyMode.isPrivate - ) + for tab in tabs { + if !tab.isWebViewReady { + tab + .restoreTransientState( + historyManager: historyManager, + downloadManager: downloadManager, + tabManager: tabManager, + isPrivate: privacyMode.isPrivate + ) + } } } .onHover { isHovering = $0 } @@ -150,6 +121,50 @@ struct FavTabItem: View { } } + @ViewBuilder + private func favicon(forTab tab: Tab) -> some View { + if let favicon = tab.favicon, tab.isWebViewReady { + AsyncImage( + url: favicon + ) { image in + image + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } placeholder: { + LocalFavIcon( + faviconLocalFile: tab.faviconLocalFile, + textColor: Color(.white) + ) + } + } else { + LocalFavIcon( + faviconLocalFile: tab.faviconLocalFile, + textColor: Color(.white) + ) + } + + if tab.isPlayingMedia { + VStack { + Spacer() + HStack { + Spacer() + Image(systemName: "speaker.wave.2.fill") + .resizable() + .scaledToFit() + .frame(width: 8, height: 8) + .foregroundColor(.white.opacity(0.9)) + .background( + Circle() + .fill(Color.black.opacity(0.6)) + .frame(width: 12, height: 12) + ) + } + } + .padding(2) + } + } + private var backgroundColor: Color { if isDragging { return theme.activeTabBackground.opacity(0.1) From 049ce550e2dc8ac22af4c9914a46ea6c91e245e2 Mon Sep 17 00:00:00 2001 From: Jack Hogan Date: Wed, 29 Oct 2025 21:45:29 -0400 Subject: [PATCH 12/12] Remove debug data --- ora/Services/TabManager.swift | 4 ++-- ora/UI/TabItem.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 2907a48f..1d9d5304 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -373,8 +373,8 @@ class TabManager: ObservableObject { undoManager.undo() // Reverts the last deletion try? modelContext.save() // Persist the undo operation } - - func togglePiP(_ currentTab: Tab?, _ oldTab: Tab?) { + + func togglePiP(_ currentTab: Tab?, _ oldTab: Tab?) { if currentTab?.id != oldTab?.id, SettingsStore.shared.autoPiPEnabled { currentTab?.webView.evaluateJavaScript("window.__oraTriggerPiP(true)") oldTab?.webView.evaluateJavaScript("window.__oraTriggerPiP()") diff --git a/ora/UI/TabItem.swift b/ora/UI/TabItem.swift index c3ec3522..8352daac 100644 --- a/ora/UI/TabItem.swift +++ b/ora/UI/TabItem.swift @@ -176,7 +176,7 @@ struct TabItem: View { } private var tabTitle: some View { - Text("\(tab.order) " + tab.title) + Text(tab.title) .font(.system(size: 13)) .foregroundColor(textColor) .lineLimit(1)