-
Notifications
You must be signed in to change notification settings - Fork 60
Tree-Style Tabs & Split Views #152
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cd13107
5ef3f61
9a9ade3
1560862
8170b59
01e3a0c
0ef9e1f
b4244d9
40a7383
d982979
8c16881
9c7d387
18e0f6f
0c33ece
049ce55
b4c7aed
777b47f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+49
to
+69
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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] | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+72
to
+82
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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] | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+96
to
+98
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| let ts = TabTileset(tabs: []) | |
| tilesets.append(ts) | |
| ts.tabs = [src, dst] | |
| let ts = TabTileset(tabs: [src, dst]) | |
| tilesets.append(ts) |
Copilot
AI
Dec 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When reparenting behavior is .child, tabs are given negative order values (line 144), which could cause issues with sorting and comparisons. While this might be intentional to place children before other tabs, negative order values could interact unexpectedly with other code that assumes order values are non-negative. Consider using a different approach to maintain child order, or document this behavior clearly.
| 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 | |
| } | |
| // Insert the moved tab as a child of `to` | |
| // and position the entire tileset group immediately before | |
| // `to`'s existing children without using negative order values. | |
| let insertionOrder = to.children.first?.order ?? 0 | |
| // Make room for the incoming group by shifting existing tabs | |
| // of the same type that are at or after the insertion point. | |
| for tab in tabs where tab.type == to.type && tab.order >= insertionOrder { | |
| tab.order += numRelevantTabs | |
| } | |
| to.children.insert(from, at: 0) | |
| // Assign consecutive orders to the moved group starting at | |
| // the insertion point. | |
| for (i, tab) in containingTilesetTabs.enumerated() { | |
| tab.order = insertionOrder + i | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+102
to
+106
|
||||||||||||||||||||||||||||||
| if tabs.allSatisfy(\.isWebViewReady) { | |
| ForEach(tabs) { tab in | |
| BrowserContentContainer { | |
| BrowserWebContentView(tab: tab) | |
| } | |
| ForEach(tabs) { tab in | |
| if tab.isWebViewReady { | |
| BrowserContentContainer { | |
| BrowserWebContentView(tab: tab) | |
| } | |
| } else { | |
| BrowserContentContainer { | |
| ProgressView() | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The dissociateFromRelatives method only decrements order for siblings/children that come after the dissociated tab. However, when a tab is dissociated and its parent changes, the children's orders relative to the new parent context may be incorrect. This could lead to ordering inconsistencies when tabs are reparented.