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/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 64811aad..7df9971c 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" @@ -56,6 +56,9 @@ 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? + @Relationship(inverse: \TabTileset.tabs) var tileset: TabTileset? /// Whether this tab is considered alive (recently accessed) var isAlive: Bool { @@ -66,6 +69,7 @@ class Tab: ObservableObject, Identifiable { init( id: UUID = UUID(), + parent: Tab? = nil, url: URL, title: String, favicon: URL? = nil, @@ -92,6 +96,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( @@ -177,6 +185,16 @@ class Tab: ObservableObject, Identifiable { } } + func switchSections(to sec: TabType) { + type = sec + savedURL = switch sec { + case .pinned, .fav: + url + case .normal: + nil + } + } + func updateHeaderColor() { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in if let wv = self?.webView { @@ -407,6 +425,29 @@ class Tab: ObservableObject, Identifiable { webView.load(request) } } + + func dissociateFromRelatives() { + 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 where sibling.type == type { + if sibling.order > self.order { + sibling.order -= 1 + } + } + } + } + + func abandonChildren() { + for child in children { + child.parent = parent + } + } } extension FileManager { diff --git a/ora/Models/TabContainer.swift b/ora/Models/TabContainer.swift index d6038675..6a94974c 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 @@ -11,7 +15,8 @@ class TabContainer: ObservableObject, Identifiable { var createdAt: Date var lastAccessedAt: Date - @Relationship(deleteRule: .cascade) var tabs: [Tab] = [] + @Relationship(deleteRule: .cascade) var tilesets: [TabTileset] = [] + @Relationship(deleteRule: .cascade) private(set) var tabs: [Tab] = [] @Relationship(deleteRule: .cascade) var folders: [Folder] = [] @Relationship() var history: [History] = [] @@ -29,27 +34,142 @@ class TabContainer: ObservableObject, Identifiable { self.lastAccessedAt = nowDate } - func reorderTabs(from: Tab, to: Tab) { - let dir = from.order - to.order > 0 ? -1 : 1 + 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 + } + } + } + + 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, + ofType: tab.type, + startingAfter: parent.order + ) + } else { + orderBase = tabs.map(\.order).max() ?? -1 + } - let tabOrder = self.tabs.sorted { dir == -1 ? $0.order > $1.order : $0.order < $1.order } + if !SettingsStore.shared.treeTabsEnabled { + tab.parent = nil + } - var started = false - for (index, tab) in tabOrder.enumerated() { - if tab.id == from.id { - started = true - } - if tab.id == to.id { + tab.order = orderBase + 1 + tabs.append(tab) + } + + 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 } - if started { - let currentTab = tab - let nextTab = tabOrder[index + 1] + } + tab.tileset = nil + } + + // TODO: Handle combining two tilesets + func combineToTileset(withSourceTab src: Tab, andDestinationTab dst: Tab) { + // Remove from tabset if exists + removeTabFromTileset(tab: src) - let tempOrder = currentTab.order - currentTab.order = nextTab.order - nextTab.order = tempOrder + if let tabset = tilesets.first(where: { $0.tabs.contains(dst) }) { + 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: []) + 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) + if [.pinned, .fav].contains(section) { + tab.abandonChildren() } } } + + 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 + reorderTabs(from: from, to: to.type) + + switch reparentingBehavior { + case .sibling: + 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 = maxToTilesetOrder + 1 + i + } + case .child: + to.children.insert(from, at: 0) + for (i, tab) in containingTilesetTabs.enumerated() { + tab.order = -containingTilesetTabs.count + i + } + for child in to.children { + child.order += numRelevantTabs + } + } + } + + 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/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/Browser/BrowserSplitView.swift b/ora/Modules/Browser/BrowserSplitView.swift index c2c1c366..6a1a6674 100644 --- a/ora/Modules/Browser/BrowserSplitView.swift +++ b/ora/Modules/Browser/BrowserSplitView.swift @@ -83,15 +83,27 @@ 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(tab.id == activeId ? 1 : 0) - .allowsHitTesting(tab.id == activeId) + .opacity(0) + .allowsHitTesting( + false + ) + } + } + } + HStack { + 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/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/ContainerView.swift b/ora/Modules/Sidebar/ContainerView.swift index 4791cf04..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 ) } } @@ -147,7 +149,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/Modules/Sidebar/TabList/FavTabsList.swift b/ora/Modules/Sidebar/TabList/FavTabsList.swift index 68c095c0..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,24 +44,27 @@ struct FavTabsGrid: View { .onDrag { onDrag(tab.id) } .onDrop( of: [.text], - delegate: TabDropDelegate( - item: tab, - draggedItem: $draggedItem, + delegate: GeneralDropDelegate( + item: .tab(tab), + representative: .tab(tabset: true), + draggedItem: $draggedItem, targetedItem: + .constant(nil), targetSection: .fav ) ) } } } - .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 00c4794e..00000000 --- a/ora/Modules/Sidebar/TabList/NormalTabsList.swift +++ /dev/null @@ -1,81 +0,0 @@ -import SwiftData -import SwiftUI - -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] = [] - - var body: some View { - VStack(spacing: 8) { - NewTabButton(addNewTab: onAddNewTab) - ForEach(tabs) { tab in - TabItem( - tab: tab, - isSelected: tabManager.isActive(tab), - isDragging: draggedItem == tab.id, - onTap: { onSelect(tab) }, - onPinToggle: { onPinToggle(tab) }, - onFavoriteToggle: { onFavoriteToggle(tab) }, - onClose: { onClose(tab) }, - onDuplicate: { onDuplicate(tab) }, - onMoveToContainer: { onMoveToContainer(tab, $0) }, - availableContainers: containers - ) - .onDrag { onDrag(tab.id) } - .onDrop( - of: [.text], - delegate: TabDropDelegate( - item: tab, - draggedItem: $draggedItem, - 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)) - } - } - .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 63b16ab1..00000000 --- a/ora/Modules/Sidebar/TabList/PinnedTabsList.swift +++ /dev/null @@ -1,64 +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, - 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: .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 e6a70639..00000000 --- a/ora/Services/SectionDropDelegate.swift +++ /dev/null @@ -1,61 +0,0 @@ -import AppKit -import SwiftUI - -struct SectionDropDelegate: DropDelegate { - let items: [Tab] - @Binding var draggedItem: UUID? - let targetSection: TabSection - let tabManager: TabManager - - func dropEntered(info: DropInfo) { - guard let provider = info.itemProviders(for: [.text]).first else { return } - performHapticFeedback(pattern: .alignment) - - 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 = tabType(for: self.targetSection) - from.type = 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 b1a95e84..2c170836 100644 --- a/ora/Services/TabDropDelegate.swift +++ b/ora/Services/TabDropDelegate.swift @@ -8,27 +8,87 @@ extension Array where Element: Hashable { } } -struct TabDropDelegate: DropDelegate { - let item: Tab // to - @Binding var draggedItem: UUID? +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) {} - let targetSection: TabSection + 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) { - guard let provider = info.itemProviders(for: [.text]).first else { return } + 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 + 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 = self.item.container.tabs.first(where: { $0.id == uuid }) + 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 self.item.container.tabs.compactMap(\.container).unique() { + for container in container.tabs.compactMap(\.container).unique() { if let foundTab = container.tabs.first(where: { $0.id == uuid }) { from = foundTab break @@ -38,28 +98,55 @@ struct TabDropDelegate: DropDelegate { guard let from else { return } - if isInSameSection( - from: from, - to: self.item - ) { + if from.type == section { withAnimation( .spring( response: 0.3, dampingFraction: 0.8 ) ) { - self.item.container - .reorderTabs( - from: from, - to: self.item - ) + container.bringToTop(tab: from) } } else { - moveTabBetweenSections(from: from, to: self.item) + container.reorderTabs(from: from, to: section) + container.bringToTop(tab: from) } } } } + 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? { @@ -67,7 +154,84 @@ struct TabDropDelegate: DropDelegate { } func performDrop(info: DropInfo) -> Bool { - draggedItem = nil + 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/TabManager.swift b/ora/Services/TabManager.swift index 1c13f8d9..1d9d5304 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -20,11 +20,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.contains(where: { $0.id == activeTab?.id }) + }) ?? 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 @@ -77,31 +92,28 @@ 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.type = .normal - tab.savedURL = nil - } else { - tab.type = .pinned - tab.savedURL = tab.url - } + let opposite = tab.type == .pinned ? TabType.normal : .pinned + + tab.container + .reorderTabs(from: tab, to: opposite, offsetTargetTypeOrder: true) try? modelContext.save() } 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() @@ -110,7 +122,9 @@ class TabManager: ObservableObject { // MARK: - Container Public API's func moveTabToContainer(_ tab: Tab, toContainer: TabContainer) { + tab.dissociateFromRelatives() tab.container = toContainer + tab.order = (toContainer.tabs.map((\.order)).max() ?? -1) + 1 try? modelContext.save() } @@ -199,14 +213,14 @@ class TabManager: ObservableObject { container: container, type: .normal, isPlayingMedia: false, - order: container.tabs.count + 1, + order: 0, historyManager: historyManager, downloadManager: downloadManager, tabManager: self, isPrivate: isPrivate ) modelContext.insert(newTab) - container.tabs.append(newTab) + container.addTab(newTab) activeTab?.maybeIsActive = false activeTab = newTab activeTab?.maybeIsActive = true @@ -237,7 +251,8 @@ class TabManager: ObservableObject { downloadManager: DownloadManager? = nil, focusAfterOpening: Bool = true, isPrivate: Bool, - loadSilently: Bool = false + loadSilently: Bool = false, + parentingTo parent: Tab? = nil ) -> Tab? { if let container = activeContainer { if let host = url.host { @@ -245,21 +260,22 @@ 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( - url: url, + parent: parent, url: url, title: cleanHost, favicon: faviconURL, container: container, type: .normal, isPlayingMedia: false, - order: container.tabs.count + 1, + order: orderBase + 1, historyManager: historyManager, downloadManager: downloadManager, tabManager: self, isPrivate: isPrivate ) - modelContext.insert(newTab) - container.tabs.append(newTab) + container.addTab(newTab) if focusAfterOpening { activateTab(newTab) @@ -296,6 +312,9 @@ class TabManager: ObservableObject { } func closeTab(tab: Tab) { + tab.dissociateFromRelatives() + activeContainer?.removeTabFromTileset(tab: tab) + // If the closed tab was active, select another tab if self.activeTab?.id == tab.id { if let nextTab = tab.container.tabs @@ -362,7 +381,7 @@ class TabManager: ObservableObject { } } - func activateTab(_ tab: Tab) { + private func activateTabInner(_ tab: Tab) { // Toggle Picture-in-Picture on tab switch togglePiP(tab, activeTab) @@ -393,6 +412,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 diff --git a/ora/Services/WebViewNavigationDelegate.swift b/ora/Services/WebViewNavigationDelegate.swift index e3a0f187..3314e42e 100644 --- a/ora/Services/WebViewNavigationDelegate.swift +++ b/ora/Services/WebViewNavigationDelegate.swift @@ -316,7 +316,8 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { url: url, historyManager: historyManager, downloadManager: downloadManager, - isPrivate: tab.isPrivate + isPrivate: tab.isPrivate, + parentingTo: tab ) } diff --git a/ora/UI/DragTarget.swift b/ora/UI/DragTarget.swift new file mode 100644 index 00000000..89e3cf5f --- /dev/null +++ b/ora/UI/DragTarget.swift @@ -0,0 +1,64 @@ +// +// 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? + let showTree: Bool + var body: some View { + HStack { + if tab.type == .normal, 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: GeneralDropDelegate( + item: .tab(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: GeneralDropDelegate( + item: .tab(tab), + representative: + .tab(tabset: true), draggedItem: $draggedItem, + targetedItem: $targetedDropItem, + targetSection: .normal + ) + ) + } + .background { + ConditionallyConcentricRectangle(cornerRadius: 10) + .fill(.thickMaterial) + } + } +} 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/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) diff --git a/ora/UI/TabItem.swift b/ora/UI/TabItem.swift index 7047d44d..8352daac 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 @@ -97,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