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
5 changes: 3 additions & 2 deletions src/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"permissions": [
"activeTab",
"tabs",
"tabGroups",
"contextualIdentities",
"cookies",
"storage",
Expand All @@ -32,15 +33,15 @@
"menus.overrideContext",
"search",
"theme",
"identity"
"identity",
"tabHide"
],
"optional_permissions": [
"<all_urls>",
"proxy",
"webRequest",
"webRequestBlocking",
"bookmarks",
"tabHide",
"clipboardWrite",
"clipboardRead",
"history",
Expand Down
76 changes: 76 additions & 0 deletions src/services/tabs.fg.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Permissions } from 'src/services/permissions'
import { Notifications } from 'src/services/notifications'
import * as Selection from './selection'
import { Favicons } from './_services.fg'
import { groupTabs } from './tabs.fg.groups'

const URL_WITHOUT_PROTOCOL_RE = /^(.+\.)\/?(.+\/)?\w+/

Expand Down Expand Up @@ -135,6 +136,68 @@ export function getStatus(tab: Tab): TabStatus {
return TabStatus.Complete
}

/**
* Create a hidden tab to represent a native group
* This acts as a parent tab for all tabs in the native group
*/
const groupTabBeingCreated = new Map<ID, Promise<void>>()

export async function parentToNativeGroupTab(
groupInfo: browser.tabGroups.TabGroup,
addTab: Tab
): Promise<undefined> {
const { id, title, color, collapsed } = groupInfo

if (groupTabBeingCreated.has(id)) {
await groupTabBeingCreated.get(id)
}

const existing = Tabs.list.find(t => t.nativeGroupId === id)
if (existing) {
addTab.parentId = existing.id
return
}

let resolve = () => {}
groupTabBeingCreated.set(
id,
new Promise(res => {
resolve = res
})
)

try {
// Group tabs
const tabId = await groupTabs([addTab.id], {
title,
})
if (!tabId) return

resolve()
groupTabBeingCreated.delete(id)

const tab = Tabs.byId[tabId]
if (!tab) return

// Set native group properties
tab.customColor = color
tab.folded = collapsed ?? false
tab.nativeGroupId = id

// Update reactive properties
tab.reactive.customColor = color ?? null
tab.reactive.folded = collapsed ?? false

// Hide the tab immediately
await browser.tabs.hide(tab.id)
} catch (err) {
resolve()
groupTabBeingCreated.delete(id)

Logs.err('Tabs.createHiddenNativeGroupTab: Failed to create hidden tab:', err)
}
}

let waitingForTabsReadiness: (() => void)[] = []
export async function waitForTabsReady(): Promise<void> {
if (Tabs.ready) return
Expand Down Expand Up @@ -347,6 +410,10 @@ async function restoreTabsState(src?: LoadSrc, ignoreLockedTabs?: boolean): Prom

tabs = await restoreTabPanelsContent(tabs)

for (const tab of tabs) {
Tabs.handleTabGroupChanged(tab.id, tab.groupId)
}

Tabs.list = tabs
Sidebar.recalcTabsPanels()
if (Settings.state.tabsTree) updateTabsTree()
Expand Down Expand Up @@ -470,6 +537,7 @@ function restoreTab(
tab.reactive.folded = tab.folded = !!props.folded
if (props.customTitle) tab.customTitle = props.customTitle
if (props.customColor) tab.reactive.customColor = tab.customColor = props.customColor
if (props.nativeGroupId) tab.nativeGroupId = props.nativeGroupId
} else {
Logs.warn(`Tabs.restoreTab: no props for: "${tab.id} i${tab.index} url${tab.url}"`)
}
Expand Down Expand Up @@ -692,6 +760,7 @@ export function cacheTabsData(delay = 300): void {
if (tab.cookieStoreId !== CONTAINER_ID) info.ctx = tab.cookieStoreId
if (tab.customTitle) info.customTitle = tab.customTitle
if (tab.customColor) info.customColor = tab.customColor
if (tab.nativeGroupId !== undefined) info.nativeGroupId = tab.nativeGroupId
data.push(info)
}

Expand Down Expand Up @@ -775,6 +844,8 @@ function _saveTabData(tabId: ID, forced?: boolean): void {
else delete data.customTitle
if (tab.customColor) data.customColor = tab.customColor
else delete data.customColor
if (tab.nativeGroupId !== undefined) data.nativeGroupId = tab.nativeGroupId
else delete data.nativeGroupId

// Logs.info('Tabs.saveTabData: Saving...', tabId, { ...data })
browser.sessions.setTabValue(tabId, 'data', data).catch(err => {
Expand Down Expand Up @@ -1696,6 +1767,11 @@ export function updateNativeTabsVisibility(): void {
for (const tab of Tabs.list) {
if (tab.pinned) continue

if (tab.nativeGroupId) {
if (!tab.hidden) toHide.push(tab.id)
continue
}

if (hideFolded && tab.invisible) {
if (!tab.hidden) toHide.push(tab.id)
continue
Expand Down
4 changes: 3 additions & 1 deletion src/services/tabs.fg.groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export async function replaceRelGroupWithPinnedTab(groupTab: Tab, pinnedTab: Tab
/**
* Group tabs
*/
export async function groupTabs(tabIds: ID[], conf?: GroupConfig): Promise<void> {
export async function groupTabs(tabIds: ID[], conf?: GroupConfig): Promise<ID | undefined> {
const noConfig = !conf
if (!conf) conf = {}

Expand Down Expand Up @@ -133,6 +133,8 @@ export async function groupTabs(tabIds: ID[], conf?: GroupConfig): Promise<void>
}
const dst = { index: groupTab.index + 1, panelId: panel.id, parentId: groupTab.id }
await Tabs.move(tabs, {}, dst)

return groupTab.id
}

export async function openGroupConfigPopup(config: GroupConfig): Promise<GroupConfigResult> {
Expand Down
16 changes: 16 additions & 0 deletions src/services/tabs.fg.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@ export function setupTabsListeners(): void {
properties: [
'audible', 'discarded', 'favIconUrl', 'hidden',
'mutedInfo', 'pinned', 'status', 'title', 'url',
'groupId',
],
})
browser.tabs.onRemoved.addListener(onTabRemoved)
browser.tabs.onMoved.addListener(onTabMoved)
browser.tabs.onDetached.addListener(onTabDetached)
browser.tabs.onAttached.addListener(onTabAttached)
browser.tabs.onActivated.addListener(onTabActivated)

Tabs.setupNativeGroupListeners()
}

export function resetTabsListeners(): void {
Expand All @@ -50,6 +53,8 @@ export function resetTabsListeners(): void {
browser.tabs.onDetached.removeListener(onTabDetached)
browser.tabs.onAttached.removeListener(onTabAttached)
browser.tabs.onActivated.removeListener(onTabActivated)

Tabs.resetNativeGroupListeners()
}

let waitForOtherReopenedTabsTimeout: number | undefined
Expand Down Expand Up @@ -446,6 +451,8 @@ function onTabCreated(nativeTab: NativeTab, attached?: boolean): void {
if (!tab.invisible) Sidebar.addToVisibleTabs(panel.id, tab)
Tabs.updateUrlCounter(tab.url, 1)

Tabs.handleTabGroupChanged(tab.id, nativeTab.groupId)

// Update tree
if (Settings.state.tabsTree && !tab.pinned && panel) {
let treeHasChanged = false
Expand Down Expand Up @@ -831,6 +838,9 @@ function onTabUpdated(tabId: ID, change: browser.tabs.ChangeInfo, nativeTab: Nat
// Update tab object
Object.assign(tab, change)

// Handle native group membership change
Tabs.handleTabGroupChanged(tabId, change.groupId)

// Handle media state change
if (change.audible !== undefined || change.mutedInfo?.muted !== undefined) {
Sidebar.updateMediaStateOfPanelDebounced(100, tab.panelId, tab)
Expand Down Expand Up @@ -1546,6 +1556,12 @@ function onTabActivated(info: browser.tabs.ActiveInfo): void {
Logs.err('Tabs.onTabActivated: Cannot hide prev active tab', err)
})
}

if (prevActive.nativeGroupId) {
browser.tabs.hide?.(prevActive.id).catch(err => {
Logs.err('Tabs.onTabActivated: Cannot hide prev active tab for native group', err)
})
}
}

tab.reactive.active = tab.active = true
Expand Down
163 changes: 163 additions & 0 deletions src/services/tabs.fg.native-groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { NOID } from 'src/defaults'
import { Tabs } from './tabs.fg'
import * as Logs from './logs'

/**
* Firefox Native Tab Groups Support
*
* Handles events and operations for Firefox's native tab groups.
* Hidden tabs are created to represent native groups in Sidebery's tree.
*/
export function getHiddenGroupTabId(groupId: ID): ID {
const hiddenTab = Tabs.list.find(t => t.nativeGroupId === groupId)
return hiddenTab ? hiddenTab.id : NOID
}

/**
* Handle native group change events from Firefox
* Called when a tab joins or leaves a native group
*/
export async function handleTabGroupChanged(tabId: ID, newGroupId?: ID | null): Promise<void> {
const tab = Tabs.byId[tabId]
if (!tab) {
Logs.warn('Tabs.handleTabGroupChanged: Tab not found:', tabId)
return
}

const oldGroupId = tab.nativeGroupId ?? NOID
newGroupId ??= NOID

// No change
if (oldGroupId === newGroupId) return

Logs.info('Tabs.handleTabGroupChanged:', tabId, 'from', oldGroupId, 'to', newGroupId)

if (newGroupId !== NOID) {
// Tab joined a group - set parent to hidden group tab
const group = await browser.tabGroups.get(newGroupId)
await Tabs.parentToNativeGroupTab(group, tab)
} else if (oldGroupId !== NOID) {
// Tab left a group - remove parent
tab.parentId = NOID
}

Tabs.updateTabsTree()
}

/**
* Setup event listeners for native group changes
*/
export function setupNativeGroupListeners(): void {
if (!browser.tabGroups) {
Logs.info('Tabs.setupNativeGroupListeners: tabGroups API not available')
return
}

Logs.info('Tabs.setupNativeGroupListeners: Setting up listeners')

browser.tabGroups.onUpdated.addListener(onGroupUpdated)
browser.tabGroups.onRemoved.addListener(onGroupRemoved)
browser.tabGroups.onMoved.addListener(onGroupMoved)
}

/**
* Reset/remove event listeners for native group changes
*/
export function resetNativeGroupListeners(): void {
if (!browser.tabGroups) return

Logs.info('Tabs.resetNativeGroupListeners: Removing listeners')

browser.tabGroups.onUpdated.removeListener(onGroupUpdated)
browser.tabGroups.onRemoved.removeListener(onGroupRemoved)
browser.tabGroups.onMoved.removeListener(onGroupMoved)
}

/**
* Handle native group update
* Updates the hidden tab properties
*/
export function onGroupUpdated(group: browser.tabGroups.TabGroup): void {
const { id: groupId, title, color, collapsed } = group
Logs.info('Tabs.onGroupUpdated:', groupId, title, color, collapsed)

const hiddenTabId = Tabs.getHiddenGroupTabId(groupId)
const hiddenTab = Tabs.byId[hiddenTabId]

if (!hiddenTab) {
Logs.warn('Tabs.onGroupUpdated: Hidden group tab not found for:', groupId)
return
}

let changed = false

// Update title
if (title !== undefined) {
hiddenTab.title = title
hiddenTab.customTitle = title
changed = true
}

// Update color
if (color !== undefined) {
hiddenTab.customColor = color
hiddenTab.reactive.customColor = color
changed = true
}

// Update collapsed state
if (collapsed !== undefined) {
hiddenTab.folded = collapsed
hiddenTab.reactive.folded = collapsed
changed = true
}

if (changed) {
Tabs.cacheTabsData()
}
}

/**
* Handle native group removal
* Removes the hidden tab and updates children
*/
export async function onGroupRemoved(group: browser.tabGroups.TabGroup): Promise<void> {
Logs.info('Tabs.onGroupRemoved:', group)

const hiddenTabId = Tabs.getHiddenGroupTabId(group.id)
const hiddenTab = Tabs.byId[hiddenTabId]

if (!hiddenTab) {
Logs.warn('Tabs.onGroupRemoved: Hidden group tab not found for:', group)
return
}

// Remove the hidden tab
try {
await browser.tabs.remove(hiddenTabId)
} catch (err) {
Logs.err('Tabs.onGroupRemoved: Failed to remove hidden tab:', err)
}

// Update tree and cache
Tabs.updateTabsTree()
Tabs.cacheTabsData()
}

/**
* Event handler for group moves
*/
function onGroupMoved(group: browser.tabGroups.TabGroup): void {
Logs.info('Tabs.onGroupMoved:', group)
// Group position in tab list should be handled by tab move events
// This event is primarily for metadata tracking
// We may need to reorder the hidden tab if needed
const hiddenTabId = Tabs.getHiddenGroupTabId(group.id)
const hiddenTab = Tabs.byId[hiddenTabId]

if (hiddenTab) {
// The hidden tab position should match the first tab in the group
// This will be handled naturally by the tab tree update
Tabs.updateTabsTree()
}
}
Loading