diff --git a/src/manifest.json b/src/manifest.json index af51b1d0..c1d33d9a 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -23,6 +23,7 @@ "permissions": [ "activeTab", "tabs", + "tabGroups", "contextualIdentities", "cookies", "storage", @@ -32,7 +33,8 @@ "menus.overrideContext", "search", "theme", - "identity" + "identity", + "tabHide" ], "optional_permissions": [ "", @@ -40,7 +42,6 @@ "webRequest", "webRequestBlocking", "bookmarks", - "tabHide", "clipboardWrite", "clipboardRead", "history", diff --git a/src/services/tabs.fg.actions.ts b/src/services/tabs.fg.actions.ts index 92998786..a69ec96a 100644 --- a/src/services/tabs.fg.actions.ts +++ b/src/services/tabs.fg.actions.ts @@ -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+/ @@ -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>() + +export async function parentToNativeGroupTab( + groupInfo: browser.tabGroups.TabGroup, + addTab: Tab +): Promise { + 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 { if (Tabs.ready) return @@ -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() @@ -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}"`) } @@ -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) } @@ -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 => { @@ -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 diff --git a/src/services/tabs.fg.groups.ts b/src/services/tabs.fg.groups.ts index e8f7dba7..0fec4c96 100644 --- a/src/services/tabs.fg.groups.ts +++ b/src/services/tabs.fg.groups.ts @@ -55,7 +55,7 @@ export async function replaceRelGroupWithPinnedTab(groupTab: Tab, pinnedTab: Tab /** * Group tabs */ -export async function groupTabs(tabIds: ID[], conf?: GroupConfig): Promise { +export async function groupTabs(tabIds: ID[], conf?: GroupConfig): Promise { const noConfig = !conf if (!conf) conf = {} @@ -133,6 +133,8 @@ export async function groupTabs(tabIds: ID[], conf?: GroupConfig): Promise } 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 { diff --git a/src/services/tabs.fg.handlers.ts b/src/services/tabs.fg.handlers.ts index d9b8c7ec..fe1ead24 100644 --- a/src/services/tabs.fg.handlers.ts +++ b/src/services/tabs.fg.handlers.ts @@ -33,6 +33,7 @@ export function setupTabsListeners(): void { properties: [ 'audible', 'discarded', 'favIconUrl', 'hidden', 'mutedInfo', 'pinned', 'status', 'title', 'url', + 'groupId', ], }) browser.tabs.onRemoved.addListener(onTabRemoved) @@ -40,6 +41,8 @@ export function setupTabsListeners(): void { browser.tabs.onDetached.addListener(onTabDetached) browser.tabs.onAttached.addListener(onTabAttached) browser.tabs.onActivated.addListener(onTabActivated) + + Tabs.setupNativeGroupListeners() } export function resetTabsListeners(): void { @@ -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 @@ -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 @@ -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) @@ -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 diff --git a/src/services/tabs.fg.native-groups.ts b/src/services/tabs.fg.native-groups.ts new file mode 100644 index 00000000..1bdedd09 --- /dev/null +++ b/src/services/tabs.fg.native-groups.ts @@ -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 { + 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 { + 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() + } +} diff --git a/src/services/tabs.fg.ts b/src/services/tabs.fg.ts index d0042845..87659971 100644 --- a/src/services/tabs.fg.ts +++ b/src/services/tabs.fg.ts @@ -4,6 +4,7 @@ import { NOID } from 'src/defaults' import * as TabsActions from 'src/services/tabs.fg.actions' import * as TabsHandlers from 'src/services/tabs.fg.handlers' import * as TabsGroups from 'src/services/tabs.fg.groups' +import * as TabsNativeGroups from 'src/services/tabs.fg.native-groups' import * as TabsShadow from 'src/services/tabs.fg.shadow' import * as TabsScroll from 'src/services/tabs.fg.scroll' import * as TabsEditTitle from 'src/services/tabs.fg.edit-title' @@ -62,6 +63,7 @@ export const Tabs = { ...TabsActions, ...TabsHandlers, ...TabsGroups, + ...TabsNativeGroups, ...TabsShadow, ...TabsScroll, ...TabsEditTitle, diff --git a/src/types/tabs.ts b/src/types/tabs.ts index 5b4e00c0..e4bd0729 100644 --- a/src/types/tabs.ts +++ b/src/types/tabs.ts @@ -20,6 +20,7 @@ export interface Tab extends NativeTab { dstPanelId: ID autoGroupped?: boolean unpinning?: boolean + nativeGroupId?: ID moveTime?: number childLastAccessed?: number lastExpanded?: number @@ -104,6 +105,7 @@ export interface TabCache { privWin?: boolean /* only for the first tab of private window */ customTitle?: string customColor?: string + nativeGroupId?: ID index?: number isMissedGroup?: boolean @@ -116,6 +118,7 @@ export interface TabSessionData { folded: boolean customTitle?: string customColor?: string + nativeGroupId?: ID } export interface ActiveTabsHistory { diff --git a/src/types/web-ext.d.ts b/src/types/web-ext.d.ts index 7553e676..208920db 100644 --- a/src/types/web-ext.d.ts +++ b/src/types/web-ext.d.ts @@ -460,6 +460,29 @@ declare namespace browser { const onDetached: EventTarget } + /** + * TabGroups + * + * Use the tabGroups API to interact with the browser's tab grouping system. + */ + namespace tabGroups { + interface TabGroup { + id: ID + collapsed: boolean + color: string + title?: string + windowId: ID + } + + function get(groupId: ID): Promise + + type GroupListener = (group: TabGroup) => void + + const onRemoved: EventTarget + const onUpdated: EventTarget + const onMoved: EventTarget + } + /** * Sessions *