Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ora/Common/Utils/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
38 changes: 0 additions & 38 deletions ora/Common/Utils/TabUtils.swift

This file was deleted.

43 changes: 42 additions & 1 deletion ora/Models/Tab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -66,6 +69,7 @@ class Tab: ObservableObject, Identifiable {

init(
id: UUID = UUID(),
parent: Tab? = nil,
url: URL,
title: String,
favicon: URL? = nil,
Expand All @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
Comment on lines +429 to +442
Copy link

Copilot AI Dec 28, 2025

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.

Suggested change
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
}
}
private func normalizeOrder(for tabs: [Tab]) {
let orderedTabs = tabs.sorted { $0.order < $1.order }
for (index, tab) in orderedTabs.enumerated() {
tab.order = index
}
}
func dissociateFromRelatives() {
if let parent = self.parent {
parent.children.removeAll(where: { $0.id == id })
normalizeOrder(for: parent.children)
} else {
let sameTypeTabs = container.tabs.filter { $0.type == type && $0.id != id }
normalizeOrder(for: sameTypeTabs)

Copilot uses AI. Check for mistakes.
}
}

func abandonChildren() {
for child in children {
child.parent = parent
}
}
}

extension FileManager {
Expand Down
152 changes: 136 additions & 16 deletions ora/Models/TabContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import SwiftData

// MARK: - TabContainer

enum ReparentingBehavior {
case sibling, child
}

@Model
class TabContainer: ObservableObject, Identifiable {
var id: UUID
Expand All @@ -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] = []

Expand All @@ -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
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the addTab method, when SettingsStore.shared.treeTabsEnabled is true and tab.parent exists, the orderBase calculation uses tab.parent?.children which may not reflect the correct order base if the parent has children from multiple insertion points. Additionally, the method appends to tabs array without considering if the tab is already present, which could lead to duplicate entries if called multiple times.

Copilot uses AI. Check for mistakes.
}

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
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method modifies the tilesets array while iterating over it (removing at index i), which could cause issues if the array needs to be modified further. While the break statement prevents immediate issues, this pattern is fragile. Consider iterating in reverse or collecting indices to remove first.

Copilot uses AI. Check for mistakes.
tab.tileset = nil
}

// TODO: Handle combining two tilesets
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TODO comment indicates that combining two tilesets is not fully handled. This is a critical piece of functionality for split view management that remains incomplete. Users attempting to combine already-split tabs could encounter unexpected behavior.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating an empty TabTileset and then appending it to tilesets before populating it with tabs could result in transient invalid states. Consider initializing the TabTileset with the tabs directly: let ts = TabTileset(tabs: [src, dst]) and then appending to tilesets.

Suggested change
let ts = TabTileset(tabs: [])
tilesets.append(ts)
ts.tabs = [src, dst]
let ts = TabTileset(tabs: [src, dst])
tilesets.append(ts)

Copilot uses AI. Check for mistakes.
}
}

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
}
Comment on lines +142 to +148
Copy link

Copilot AI Dec 28, 2025

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.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
}
}

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
}
}
}
27 changes: 27 additions & 0 deletions ora/Models/TabTileset.swift
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()
}
}
}
20 changes: 16 additions & 4 deletions ora/Modules/Browser/BrowserSplitView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The split view rendering waits for all tabs to be ready (allSatisfy) before showing any of them (line 102). This means if one tab in a split is slow to load or has issues, all tabs in the split will remain invisible. Consider rendering each tab independently as it becomes ready, or providing a loading indicator for tabs that aren't ready yet.

Suggested change
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()
}

Copilot uses AI. Check for mistakes.
}
}
}
Expand Down
Loading