Skip to content

Conversation

@ImTheSquid
Copy link

@ImTheSquid ImTheSquid commented Oct 22, 2025

Working on adding tree-style tabs. I'll add more checklist items as I discover what needs to be done. Closes #135, closes #167, closes #174.

  • Optional automatic parenting
  • Drag support
  • Arbitrary tab splits
  • Multi-WebView rendering
  • Tab split management

@ImTheSquid ImTheSquid marked this pull request as ready for review October 22, 2025 18:49
@ImTheSquid
Copy link
Author

Initial implementation done, looking for feedback for what to improve!

@ImTheSquid ImTheSquid marked this pull request as draft October 29, 2025 15:24
@ImTheSquid ImTheSquid changed the title Tree-Style Tabs Tree-Style Tabs & Split Views Oct 29, 2025
@ImTheSquid ImTheSquid marked this pull request as ready for review October 30, 2025 01:17
Copilot AI review requested due to automatic review settings December 28, 2025 15:23
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements tree-style tabs and split view functionality for the Ora browser, addressing issues #135 (tree-style tabs feature request), #167 (split tabs view), and #174 (tab dragging improvements). The implementation adds parent-child tab relationships, tileset management for split views, and consolidates tab list components.

Key Changes

  • Added hierarchical tab structure with parent-child relationships and tileset support for split views
  • Implemented drag-and-drop improvements with new delegates and visual indicators for better UX
  • Unified tab list components (PinnedTabsList, NormalTabsList) into a single TabsList component with tree rendering support

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 23 comments.

Show a summary per file
File Description
ora/Models/Tab.swift Added parent/child relationships, tileset association, and helper methods for hierarchy management
ora/Models/TabContainer.swift Added tileset management, enhanced reordering logic with reparenting behavior, and tree-aware tab ordering
ora/Models/TabTileset.swift New model representing a collection of tabs displayed in a split view
ora/Services/TabManager.swift Enhanced tab activation to handle splits, added split detection, and parent-aware tab creation
ora/Services/TabDropDelegate.swift Refactored drop delegates with new target types and split view support (contains duplicated code from GeneralDropDelegate.swift)
ora/Services/GeneralDropDelegate.swift New file with consolidated drop handling logic (duplicates structures from TabDropDelegate.swift)
ora/Services/WebViewNavigationDelegate.swift Added automatic parenting for new tabs opened from existing tabs
ora/Modules/Sidebar/TabList/TabsList.swift Unified tab list component with tree rendering via tabsSortedByParent function
ora/Modules/Sidebar/TabList/FavTabsList.swift Updated to support tilesets in favorite tabs grid
ora/Modules/Browser/BrowserSplitView.swift Modified rendering to display split view tabs side-by-side
ora/Modules/Browser/BrowserView.swift Added handler to flatten all tabs when tree-style tabs are disabled
ora/Modules/Settings/Sections/GeneralSettingsView.swift Added toggle for tree-style tabs feature
ora/Common/Utils/SettingsStore.swift Added treeTabsEnabled setting with persistence
ora/UI/TabItem.swift Added new bindings for drag target state tracking
ora/UI/FavTabItem.swift Changed to accept array of tabs for tileset rendering
ora/UI/DropCapsule.swift New drop indicator component for visual feedback
ora/UI/DragTarget.swift New overlay component showing drop targets for parenting/sibling operations
ora/Modules/Sidebar/ContainerView.swift Updated to use unified TabsList and improved drag cleanup
ora/Common/Utils/TabUtils.swift Deleted - utility functions moved or replaced
ora/Services/SectionDropDelegate.swift Deleted - replaced by GeneralDropDelegate
ora/Modules/Sidebar/TabList/PinnedTabsList.swift Deleted - replaced by unified TabsList
ora/Modules/Sidebar/TabList/NormalTabsList.swift Deleted - replaced by unified TabsList

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

IndentedTab(
tab: root,
indentationLevel: indentation,
tabs: toAppend.reversed()
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 tabs array is being reversed when adding to IndentedTab (line 39), but it's not clear why this reversal is necessary. This could lead to tabs appearing in an unexpected order in tilesets. Consider documenting why the reversal is needed, or verify this is the intended behavior.

Suggested change
tabs: toAppend.reversed()
// NOTE: We intentionally reverse `toAppend` so that tabs belonging to a
// tileset are stored in the order they are rendered in the UI. Changing
// this may alter the visible ordering of tabs within tilesets.
tabs: Array(toAppend.reversed())

Copilot uses AI. Check for mistakes.
}
let foundTiles = Set(tileset.tabs.map(\.id))
toAppend = tabs.filter { foundTiles.contains($0.id) }
assert(!toAppend.isEmpty)
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 assert statement will crash in release builds if toAppend is empty. While this might indicate a programming error, it could also happen with data corruption or race conditions. Consider using a proper error handling mechanism instead of assert, or at minimum add a guard statement that handles this case gracefully.

Suggested change
assert(!toAppend.isEmpty)
guard !toAppend.isEmpty else {
// If the tileset does not contain any matching tabs, skip processing this root
continue
}

Copilot uses AI. Check for mistakes.
targetedDropItem: $targetedDropItem,
draggedItem: $draggedItem,
delegate: TopDropDelegate(
container: tabManager.activeContainer ?? containers.first!,
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.

Force unwrapping containers.first! will crash if there are no containers. While this might be unlikely, it's safer to provide a default container or handle this case gracefully. Consider using ?? TabContainer() or checking for nil and providing appropriate fallback behavior.

Copilot uses AI. Check for mistakes.
} else {
ForEach(tabs) { tab in
ForEach(tabsSortedByParent(tabs)) { iTab in
let tab = iTab.tabs.first!
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.

Force unwrapping iTab.tabs.first! assumes that tabs array is never empty. However, if there's an issue with the tabsSortedByParent function or data corruption, this could crash. Consider using optional binding or providing a guard statement to handle the empty case.

Copilot uses AI. Check for mistakes.
Comment on lines +134 to +206
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
)
)
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.

Multiple force unwraps of iTab.tabs.first! (lines 181, 189, 193) assume that the tabs array is never empty. However, if there's an issue with the tabsSortedByParent function or data corruption, these will crash. Consider using optional binding or providing guard statements to handle the empty case.

Suggested change
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
)
)
if let firstTab = iTab.tabs.first {
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: firstTab,
draggedItem: $draggedItem,
targetedDropItem: $targetedDropItem,
showTree: settings.treeTabsEnabled
) : nil
)
DropCapsule(
id: firstTab.id,
targetedDropItem: $targetedDropItem,
draggedItem: $draggedItem,
delegate: GeneralDropDelegate(
item: .tab(firstTab),
representative: .divider,
draggedItem: $draggedItem,
targetedItem: $targetedDropItem,
targetSection: isPinned ? .pinned : .normal
)
)
}
.padding(
.leading,
CGFloat(
integerLiteral: settings.treeTabsEnabled ? iTab.indentationLevel * 8 : 0
)
)
}

Copilot uses AI. Check for mistakes.
Comment on lines +95 to 100
var tabsToActivate = Set([tab])
if let activeContainer, let activeTab {
addSplitMembers(to: &tabsToActivate, fromContainer: activeContainer)
return tabsToActivate.contains(activeTab)
}
return false
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 isActive method logic appears reversed. It adds the passed tab to a set, then adds split members based on the activeTab, and checks if the activeTab is in that set. This seems backward - it should be checking if tab is in the set of active tabs that includes the activeTab and its split members. The correct logic would be to start with activeTab in the set, add its split members, then check if tab is in that set.

Suggested change
var tabsToActivate = Set([tab])
if let activeContainer, let activeTab {
addSplitMembers(to: &tabsToActivate, fromContainer: activeContainer)
return tabsToActivate.contains(activeTab)
}
return false
guard let activeContainer, let activeTab else {
return false
}
var activeTabs = Set([activeTab])
addSplitMembers(to: &activeTabs, fromContainer: activeContainer)
return activeTabs.contains(tab)

Copilot uses AI. Check for mistakes.

let cleanHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host

let orderBase = (parent != nil ? parent!.children : container.tabs).map(\.order).max() ?? -1
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.

Force unwrapping parent!.children on line 263 when parent != nil is unsafe. If parent somehow becomes nil between the check and the unwrap (though unlikely in this synchronous code), or if the check itself is bypassed, this will crash. Use optional chaining instead: parent?.children ?? container.tabs.

Suggested change
let orderBase = (parent != nil ? parent!.children : container.tabs).map(\.order).max() ?? -1
let orderBase = (parent?.children ?? container.tabs).map(\.order).max() ?? -1

Copilot uses AI. Check for mistakes.
delegate: GeneralDropDelegate(
item:
.container(
tabManager.activeContainer!),
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.

Force unwrapping tabManager.activeContainer! will crash if activeContainer is nil. This can happen during initialization or in edge cases. Consider using optional chaining or a guard statement to handle the nil case gracefully.

Copilot uses AI. Check for mistakes.
withIndentation indentation: Int,
withTilesetUseSet usedTilesets: inout Set<UUID>
) -> [IndentedTab] {
// Start by finding only parent tags, then recurse down through
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 comment on line 18 says "parent tags" but should say "parent tabs" since this function is dealing with tabs, not tags.

Suggested change
// Start by finding only parent tags, then recurse down through
// Start by finding only parent tabs, then recurse down through

Copilot uses AI. Check for mistakes.
let provider = TabItemProvider(object: tabId.uuidString as NSString)
provider.didEnd = {
draggedItem = nil
Task { @MainActor in
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.

Wrapping the draggedItem assignment in a Task with @mainactor is good for ensuring it runs on the main thread. However, the didEnd callback might be called after the drag operation completes, and wrapping it in an async Task could introduce a slight delay. If immediate cleanup is required, consider using DispatchQueue.main.async or ensure this delay doesn't cause UI inconsistencies.

Suggested change
Task { @MainActor in
DispatchQueue.main.async {

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tab Dragging Feels Laggy and Causes Empty Tabs or Misaligned Spacing Split Tabs View [Feature Request] Tree-Style Tabs

1 participant