diff --git a/docs/api/tabs/general.md b/docs/api/tabs/general.md new file mode 100644 index 00000000..87ff37bb --- /dev/null +++ b/docs/api/tabs/general.md @@ -0,0 +1,42 @@ +# Tabs General Documentation + +## Success Creteria + +- Have different active tab groups in different spaces +- Tabs in sidebar achievable +- Different tab groups in different places visible at once +- Have different containers in a Space ("Favourite", "Pinned", "Normal") + +## Managers + +- Tab Manager +- Tab Group Manager +- Active Tab Group Manager +- Tabs Container Manager + +## Objects + +- Tab +- Tab Group +- Tab Container + +## TODO APIs + +- TabGroup.transferTab() -> boolean (transfer tab to another tab group, true if success & false if failed) +- TabGroup.createTab() -> Tab (added to TabGroup automatically) +- TabContainer.newNormalTabGroup() -> NormalTabGroup (added to TabContainer automatically) + +## How to: + +### Create a new tab + +1. Create a new tab group via TabContainer.newNormalTabGroup() +2. Create a new tab via TabGroup.createTab() + +### Change a Tab Group's Tab Container + +- **TODO!** + +### Create a Tab Folder + +1. Run TabContainer.createTabFolder() diff --git a/docs/api/tabs/tab-group.md b/docs/api/tabs/tab-group.md new file mode 100644 index 00000000..c885a84f --- /dev/null +++ b/docs/api/tabs/tab-group.md @@ -0,0 +1,253 @@ +# Tab Group API Documentation + +## Overview + +The Tab Group system provides a way to organize and manage collections of tabs within the Flow browser. Tab groups can contain multiple tabs and provide functionality for managing focus, window assignment, and tab lifecycle within the group. + +## Core Components + +### TabGroup Class + +The main `TabGroup` class is the central component that orchestrates tab management through specialized controllers. + +#### Constructor + +```typescript +constructor(variant: TabGroupVariant, details: TabGroupCreationDetails) +``` + +Creates a new tab group with the specified variant and creation details. + +#### Properties + +- `id: string` - Unique identifier for the tab group +- `destroyed: boolean` - Whether the tab group has been destroyed +- `type: TabGroupTypes` - The type of tab group ("normal", "split", or "glance") +- `maxTabs: number` - Maximum number of tabs allowed in the group (-1 for unlimited) +- `creationDetails: TabGroupCreationDetails` - Details used to create the tab group +- `window: TabGroupWindowController` - Controller for managing the window +- `tabs: TabGroupTabsController` - Controller for managing tabs +- `focusedTab: TabGroupFocusedTabController` - Controller for managing focused tab + +#### Methods + +- `destroy()` - Destroys the tab group and cleans up all resources +- `throwIfDestroyed()` - Throws an error if the tab group has been destroyed + +#### Events + +The `TabGroup` class extends `TypedEventEmitter` and emits the following events: + +- `"window-changed"` - Emitted when the tab group's window changes +- `"tab-added"` - Emitted when a tab is added to the group +- `"tab-removed"` - Emitted when a tab is removed from the group +- `"destroyed"` - Emitted when the tab group is destroyed + +## Controllers + +### TabGroupTabsController + +Manages the collection of tabs within a tab group. + +#### Methods + +- `addTab(tab: Tab): boolean` - Adds a tab to the group + + - Returns `false` if the tab is already in the group or would exceed maxTabs + - Sets up event listeners for tab lifecycle management + - Emits "tab-added" event + +- `removeTab(tab: Tab): boolean` - Removes a tab from the group + + - Returns `false` if the tab is not in the group + - Cleans up event listeners to prevent memory leaks + - Emits "tab-removed" event + +- `get(): Tab[]` - Returns all tabs currently in the group + + - Filters out destroyed tabs automatically + +- `cleanupListeners()` - Cleans up all event listeners for all tabs + +#### Behavior + +- Automatically destroys the tab group when the last tab is removed +- Respects the `maxTabs` limit when adding tabs +- Maintains event listeners for tab destruction and focus events +- Provides O(1) tab lookup using internal Set data structure + +### TabGroupFocusedTabController + +Manages which tab is currently focused within the tab group. + +#### Methods + +- `set(tab: Tab): boolean` - Sets the focused tab + + - Returns `false` if the tab is already focused + - Automatically removes the previous focused tab + +- `remove(): boolean` - Removes the currently focused tab + - Returns `false` if no tab was focused + +#### Behavior + +- Automatically sets focus to the first tab when a tab is added to an empty group +- Automatically reassigns focus when the focused tab is removed +- Listens for "tab-added" and "tab-removed" events to manage focus + +### TabGroupWindowController + +Manages the window that contains the tab group. + +#### Methods + +- `get(): TabbedBrowserWindow` - Returns the current window +- `set(window: TabbedBrowserWindow): boolean` - Sets the window + + - Returns `false` if the window is already set + - Emits "window-changed" event + - Updates all tabs in the group to use the new window + +- `updateTabsWindow()` - Updates all tabs in the group to use the current window + +## Types and Interfaces + +### TabGroupTypes + +```typescript +type TabGroupTypes = "normal" | "split" | "glance"; +``` + +Defines the available tab group types: + +- `"normal"` - Standard tab group with configurable tab limit +- `"split"` - Tab group designed for split-screen functionality +- `"glance"` - Tab group for quick preview/glance functionality + +### TabGroupVariant + +```typescript +interface TabGroupVariant { + type: TabGroupTypes; + maxTabs: number; +} +``` + +Specifies the variant configuration for a tab group: + +- `type` - The type of tab group +- `maxTabs` - Maximum number of tabs allowed (-1 for unlimited) + +### TabGroupCreationDetails + +```typescript +interface TabGroupCreationDetails { + browser: Browser; + window: TabbedBrowserWindow; + spaceId: string; +} +``` + +Contains the details needed to create a tab group: + +- `browser` - The browser instance that owns the tab group +- `window` - The initial window for the tab group +- `spaceId` - The space ID where the tab group belongs + +## Specialized Tab Group Types + +### NormalTabGroup + +A specialized tab group that can only contain one tab. + +```typescript +class NormalTabGroup extends TabGroup { + constructor(details: TabGroupCreationDetails) { + super({ type: "normal", maxTabs: 1 }, details); + } +} +``` + +## Usage Examples + +### Creating a Tab Group + +```typescript +import { TabGroup } from "@/browser/tabs/tab-group"; + +const tabGroup = new TabGroup( + { type: "normal", maxTabs: 5 }, + { + browser: browserInstance, + window: windowInstance, + spaceId: "space-123" + } +); +``` + +### Adding Tabs to a Group + +```typescript +const success = tabGroup.tabs.addTab(tab); +if (success) { + console.log("Tab added successfully"); +} else { + console.log("Failed to add tab (already exists or exceeds limit)"); +} +``` + +### Listening for Events + +```typescript +tabGroup.connect("tab-added", (tab) => { + console.log("Tab added:", tab.id); +}); + +tabGroup.connect("tab-removed", (tab) => { + console.log("Tab removed:", tab.id); +}); + +tabGroup.connect("window-changed", () => { + console.log("Window changed for tab group"); +}); +``` + +### Managing Focus + +```typescript +// Set focused tab +tabGroup.focusedTab.set(tab); + +// Remove focused tab +tabGroup.focusedTab.remove(); +``` + +### Changing Window + +```typescript +tabGroup.window.set(newWindow); +``` + +### Destroying a Tab Group + +```typescript +tabGroup.destroy(); +``` + +## Error Handling + +The tab group system includes several error handling mechanisms: + +- `throwIfDestroyed()` - Prevents operations on destroyed tab groups +- Event listener cleanup to prevent memory leaks +- Automatic filtering of destroyed tabs in `get()` method +- Graceful handling of tab limits in `addTab()` + +## Implementation Notes + +- Tab groups use a Set-based data structure for O(1) tab lookup performance +- Event listeners are automatically cleaned up when tabs are removed +- The system is designed to be memory-efficient and prevent leaks +- Tab groups automatically destroy themselves when they become empty +- Focus management is handled automatically based on tab addition/removal events diff --git a/docs/api/tabs/tab.md b/docs/api/tabs/tab.md index 827ec972..e359042e 100644 --- a/docs/api/tabs/tab.md +++ b/docs/api/tabs/tab.md @@ -1,89 +1,282 @@ -# Tab Class Documentation +# Tab API Documentation -The `Tab` class represents a single tab within the Flow browser. It manages the web content (`WebContentsView`), state, layout, and lifecycle of a browser tab. +The `Tab` class is the core component that manages individual browser tabs in the Flow Browser. It provides a comprehensive interface for tab management, including webview handling, navigation, bounds management, and various tab-specific features. ## Overview -- **Manages Web Content:** Each `Tab` instance encapsulates an Electron `WebContentsView` and its associated `WebContents` object, responsible for rendering web pages. -- **State Management:** Tracks various states like URL, title, loading status, audio status, and visibility. -- **Layout Control:** Supports different layouts (`normal`, `glance`, `split`) and updates the view bounds accordingly. -- **Lifecycle:** Handles creation, showing/hiding, and destruction of the tab. -- **Events:** Emits events for focus changes, updates, and destruction. +The Tab class extends `TypedEventEmitter` and orchestrates multiple controllers to handle different aspects of tab functionality. Each tab represents a single browsing context with its own webview, navigation history, and state management. -## Creating a Tab - -A `Tab` instance is typically created by the `TabManager`. The constructor requires `TabCreationDetails` and `TabCreationOptions`. +## Class Structure ```typescript -// Simplified example - Usually done within TabManager -const tab = new Tab( - { - browser: browserInstance, - tabManager: tabManagerInstance, - profileId: "default", - spaceId: "main", - session: electronSession - }, - { - window: browserWindowInstance, // Optional: Assign to a window immediately - webContentsViewOptions: { - /* ... */ - } // Optional: Customize WebContentsView - } -); +class Tab extends TypedEventEmitter ``` -### `TabCreationDetails` - -- `browser`: The main `Browser` controller instance. -- `tabManager`: The `TabManager` instance responsible for this tab. -- `profileId`: The ID of the user profile associated with this tab. -- `spaceId`: The ID of the space this tab belongs to. -- `session`: The Electron `Session` object to use for this tab's web content. - -### `TabCreationOptions` - -- `window`: (Optional) The `TabbedBrowserWindow` to initially attach the tab to. -- `webContentsViewOptions`: (Optional) Electron `WebContentsViewConstructorOptions` to customize the underlying view and web preferences. - -## Key Properties - -- `id`: (Readonly `number`) The unique ID of the tab's `WebContents`. -- `profileId`: (Readonly `string`) The associated profile ID. -- `spaceId`: (`string`) The associated space ID. Can be updated. -- `visible`: (`boolean`) Whether the tab's view is currently visible. -- `isDestroyed`: (`boolean`) Whether the tab has been destroyed. -- `layout`: (`TabLayout`) The current layout configuration (e.g., `{ type: 'normal' }`). -- `faviconURL`: (`string | null`) The URL of the current favicon. -- `title`: (`string`) The current page title. -- `url`: (`string`) The current page URL. -- `isLoading`: (`boolean`) Whether the page is currently loading. -- `audible`: (`boolean`) Whether the tab is currently playing audio. -- `muted`: (`boolean`) Whether the tab's audio is muted. -- `view`: (Readonly `PatchedWebContentsView`) The Electron `WebContentsView` instance. -- `webContents`: (Readonly `WebContents`) The Electron `WebContents` instance associated with the view. - -## Key Methods - -- `setWindow(window: TabbedBrowserWindow | null)`: Attaches the tab's view to a given window or detaches it if `null` is passed. -- `loadURL(url: string, replace?: boolean)`: Loads the specified URL in the tab. If `replace` is true, it attempts to replace the current history entry. -- `loadErrorPage(errorCode: number, url: string)`: Loads a custom error page for the given error code and original URL. -- `setLayout(layout: TabLayout)`: Sets the tab's layout configuration and updates the view. -- `updateLayout()`: Recalculates and applies the view's bounds based on the current `layout` and window dimensions. Should be called when the window resizes or layout changes. -- `updateTabState()`: Reads the current state (title, URL, loading, audio) from `webContents` and updates the corresponding `Tab` properties. Emits an `updated` event if any state changed. Returns `true` if changed, `false` otherwise. -- `show()`: Makes the tab's view visible. -- `hide()`: Hides the tab's view. -- `destroy()`: Cleans up resources, removes the view, and marks the tab as destroyed. Emits the `destroyed` event. +### Properties + +#### Core Properties + +- `id: string` - Unique identifier for the tab +- `loadedProfile: LoadedProfile` - The profile this tab belongs to +- `creationDetails: TabCreationDetails` - Details used to create the tab +- `destroyed: boolean` - Whether the tab has been destroyed +- `browser: Browser` - Reference to the parent browser instance +- `profileId: string` - ID of the profile this tab belongs to + +#### Controllers + +The Tab class manages various controllers that handle specific aspects of tab functionality: + +- `window: TabWindowController` - Manages tab window assignment +- `data: TabDataController` - Handles tab data and state synchronization +- `bounds: TabBoundsController` - Manages tab positioning and sizing +- `visiblity: TabVisiblityController` - Controls tab visibility +- `webview: TabWebviewController` - Manages the webview component +- `pip: TabPipController` - Handles Picture-in-Picture functionality +- `saving: TabSavingController` - Manages tab saving operations +- `contextMenu: TabContextMenuController` - Handles context menu functionality +- `errorPage: TabErrorPageController` - Manages error page display +- `navigation: TabNavigationController` - Handles navigation and history +- `sleep: TabSleepController` - Manages tab sleep/wake functionality + +## Creation Details + +### TabCreationDetails Interface + +```typescript +interface TabCreationDetails { + browser: Browser; + window: TabbedBrowserWindow; + tabId?: string; + loadedProfile: LoadedProfile; + webContentsViewOptions: Electron.WebContentsViewConstructorOptions; + navHistory?: NavigationEntry[]; + navHistoryIndex?: number; + defaultURL?: string; + asleep?: boolean; +} +``` ## Events -The `Tab` class extends `TypedEventEmitter` and emits the following events: +The Tab class emits the following events: + +- `"window-changed"` - Emitted when the tab's window changes +- `"webview-attached"` - Emitted when the webview is attached +- `"webview-detached"` - Emitted when the webview is detached +- `"pip-active-changed"` - Emitted when Picture-in-Picture state changes +- `"bounds-changed"` - Emitted when tab bounds change +- `"visiblity-changed"` - Emitted when tab visibility changes +- `"sleep-changed"` - Emitted when tab sleep state changes +- `"nav-history-changed"` - Emitted when navigation history changes +- `"data-changed"` - Emitted when tab data changes +- `"focused"` - Emitted when the tab gains focus +- `"destroyed"` - Emitted when the tab is destroyed + +## Methods + +### Core Methods + +#### `destroy()` + +Destroys the tab and cleans up resources. + +- Detaches the webview +- Emits the "destroyed" event +- Destroys the event emitter + +#### `throwIfDestroyed()` + +Throws an error if the tab has already been destroyed. + +## Controllers Documentation + +### TabBoundsController + +Manages tab positioning and sizing. + +#### Properties + +- `isAnimating: boolean` - Whether the tab is currently animating + +#### Methods + +- `startAnimating()` - Starts animation mode +- `stopAnimating()` - Stops animation mode +- `set(bounds: PageBounds)` - Sets tab bounds +- `get()` - Gets current bounds +- `updateWebviewBounds()` - Updates webview bounds based on visibility + +### TabDataController + +Handles tab data synchronization and state management. + +#### Properties + +- `window: TabbedBrowserWindow | null` - Current window +- `pipActive: boolean` - Picture-in-Picture state +- `asleep: boolean` - Sleep state +- `title: string` - Tab title +- `url: string` - Current URL +- `isLoading: boolean` - Loading state +- `audible: boolean` - Audio state +- `muted: boolean` - Muted state + +#### Methods + +- `refreshData()` - Refreshes all tab data +- `setupWebviewData(webContents)` - Sets up webview event listeners +- `get()` - Returns complete tab data object + +### TabNavigationController + +Manages tab navigation and history. + +#### Properties + +- `navHistory: NavigationEntry[]` - Navigation history +- `navHistoryIndex: number` - Current history index + +#### Methods + +- `setupWebviewNavigation(webContents)` - Sets up navigation for webview +- `syncNavHistory()` - Synchronizes navigation history +- `loadUrl(url, replace?)` - Loads a URL, optionally replacing current entry + +### TabWebviewController + +Manages the webview component. + +#### Properties + +- `webContentsView: WebContentsView | null` - The webview component +- `webContents: WebContents | null` - The webview's web contents +- `attached: boolean` - Whether webview is attached + +#### Methods + +- `attach()` - Attaches the webview +- `detach()` - Detaches and destroys the webview + +### TabWindowController + +Manages tab window assignment. + +#### Methods + +- `get()` - Gets current window +- `set(window)` - Sets the tab's window +- `updateWebviewWindow()` - Updates webview window assignment + +### TabVisiblityController + +Controls tab visibility. + +#### Properties + +- `isVisible: boolean` - Current visibility state + +#### Methods + +- `setVisible(visible)` - Sets tab visibility +- `updateWebviewVisiblity()` - Updates webview visibility + +### TabPipController + +Handles Picture-in-Picture functionality. + +#### Properties + +- `active: boolean` - Whether PiP is active + +#### Methods + +- `tryEnterPiP()` - Attempts to enter Picture-in-Picture mode +- `tryExitPiP()` - Attempts to exit Picture-in-Picture mode + +### TabSleepController + +Manages tab sleep/wake functionality. + +#### Properties + +- `asleep: boolean` - Whether the tab is asleep + +#### Methods + +- `putToSleep()` - Puts the tab to sleep +- `wakeUp()` - Wakes up the tab + +### TabContextMenuController + +Handles context menu functionality for tabs. Automatically sets up context menus when the webview is attached. + +### TabErrorPageController + +Manages error page display when navigation fails. + +#### Methods + +- `loadErrorPage(errorCode, url)` - Loads an error page for failed navigation + +### TabSavingController + +Manages tab saving operations (currently minimal implementation). + +## Usage Example + +```typescript +import { Tab, TabCreationDetails } from "@/browser/tabs/tab"; + +// Create tab creation details +const creationDetails: TabCreationDetails = { + browser: browserInstance, + window: tabbedWindow, + loadedProfile: profile, + webContentsViewOptions: {}, + defaultURL: "https://example.com" +}; + +// Create a new tab +const tab = new Tab(creationDetails); + +// Set up event listeners +tab.on("data-changed", () => { + console.log("Tab data changed"); +}); + +tab.on("focused", () => { + console.log("Tab focused"); +}); + +// Manage tab visibility +tab.visiblity.setVisible(true); + +// Load a URL +tab.navigation.loadUrl("https://example.com"); + +// Access tab data +const tabData = tab.data.get(); +console.log("Tab title:", tabData.title); +console.log("Tab URL:", tabData.url); + +// Clean up +tab.destroy(); +``` + +## Integration + +The Tab class integrates with: -- `focused`: Emitted when the tab's `webContents` gains focus. Used by `TabManager` to track the active tab. -- `updated`: Emitted when the tab's state (e.g., title, URL, loading status, favicon, visibility) changes, often triggered by internal calls to `updateTabState()` or methods like `show()`/`hide()`. -- `destroyed`: Emitted when the `destroy()` method is called and the tab is successfully cleaned up. +- **Browser**: Parent browser instance that manages multiple tabs +- **TabbedBrowserWindow**: Window that displays the tab +- **LoadedProfile**: Profile that provides session and extensions +- **WebContentsView**: Electron's webview component for rendering web content -## Internal Helpers +## Best Practices -- `createWebContentsView()`: Factory function used internally by the constructor to create the `WebContentsView` with appropriate web preferences (sandbox, preload script, session). -- `setupEventListeners()`: Sets up listeners on the `webContents` object to react to page events (focus, favicon updates, load failures, navigation, media playback) and trigger state updates or actions. +1. Always call `destroy()` when a tab is no longer needed +2. Use `throwIfDestroyed()` before performing operations on tabs +3. Listen to appropriate events for state synchronization +4. Use the controller APIs rather than directly manipulating internal state +5. Handle webview attachment/detachment properly for performance diff --git a/docs/references/glossary.md b/docs/references/glossary.md new file mode 100644 index 00000000..6632cdfd --- /dev/null +++ b/docs/references/glossary.md @@ -0,0 +1,7 @@ +# Glossary + +- Tab: A single container displaying a single URL. + +- Tab Group: A collection of tabs that are displayed at the same time. + +- Tab Container: A holder for a bunch of tab groups. Controls which tab groups are visible at a time. diff --git a/src/main/browser/browser.ts b/src/main/browser/browser.ts index b6dff394..49c30f78 100644 --- a/src/main/browser/browser.ts +++ b/src/main/browser/browser.ts @@ -4,14 +4,13 @@ import { app, WebContents } from "electron"; import { BrowserEvents } from "@/browser/events"; import { ProfileManager, LoadedProfile } from "@/browser/profile-manager"; import { WindowManager, BrowserWindowType, BrowserWindowCreationOptions } from "@/browser/window-manager"; -import { TabManager } from "@/browser/tabs/tab-manager"; -import { Tab } from "@/browser/tabs/tab"; import { setupMenu } from "@/browser/utility/menu"; import { settings } from "@/settings/main"; import { onboarding } from "@/onboarding/main"; import "@/modules/extensions/main"; import { waitForElectronComponentsToBeReady } from "@/modules/electron-components"; import { debugPrint } from "@/modules/output"; +import { TabOrchestrator } from "@/browser/tabs"; /** * Main Browser controller that coordinates browser components @@ -24,9 +23,8 @@ import { debugPrint } from "@/modules/output"; export class Browser extends TypedEventEmitter { private readonly profileManager: ProfileManager; private readonly windowManager: WindowManager; - private readonly tabManager: TabManager; + public readonly tabs: TabOrchestrator; private _isDestroyed: boolean = false; - public tabs: TabManager; public updateMenu: () => Promise; /** @@ -36,10 +34,7 @@ export class Browser extends TypedEventEmitter { super(); this.windowManager = new WindowManager(this); this.profileManager = new ProfileManager(this, this); - this.tabManager = new TabManager(this); - - // A public reference to the tab manager - this.tabs = this.tabManager; + this.tabs = new TabOrchestrator(this); // Load menu this.updateMenu = setupMenu(this); @@ -180,13 +175,6 @@ export class Browser extends TypedEventEmitter { } } - /** - * Get tab from ID - */ - public getTabFromId(tabId: number): Tab | undefined { - return this.tabManager.getTabById(tabId); - } - /** * Sends a message to all core WebContents */ diff --git a/src/main/browser/profile-manager.ts b/src/main/browser/profile-manager.ts index 11e9c53e..f7a9694d 100644 --- a/src/main/browser/profile-manager.ts +++ b/src/main/browser/profile-manager.ts @@ -5,7 +5,7 @@ import { getProfile, getProfilePath, ProfileData } from "@/sessions/profiles"; import { BrowserEvents } from "@/browser/events"; import { Browser } from "@/browser/browser"; import { ElectronChromeExtensions } from "electron-chrome-extensions"; -import { NEW_TAB_URL } from "@/browser/tabs/tab-manager"; +import { NEW_TAB_URL } from "@/browser/tabs/managers/tab-manager"; import { ExtensionInstallStatus, installChromeWebStore } from "electron-chrome-web-store"; import path from "path"; import { setWindowSpace } from "@/ipc/session/spaces"; diff --git a/src/main/browser/tabs/index.ts b/src/main/browser/tabs/index.ts new file mode 100644 index 00000000..2d8e9317 --- /dev/null +++ b/src/main/browser/tabs/index.ts @@ -0,0 +1,24 @@ +import { Browser } from "@/browser/browser"; +import { TabGroupManager } from "@/browser/tabs/managers/tab-group-manager"; +import { ActiveTabGroupManager } from "@/browser/tabs/managers/active-tab-group-manager"; +import { TabManager } from "@/browser/tabs/managers/tab-manager"; +import { TabsContainerManager } from "@/browser/tabs/managers/tabs-container-manager"; + +export class TabOrchestrator { + public readonly tabManager: TabManager; + public readonly tabGroupManager: TabGroupManager; + public readonly activeTabGroupManager: ActiveTabGroupManager; + public readonly tabsContainerManager: TabsContainerManager; + + constructor(browser: Browser) { + this.tabManager = new TabManager(browser); + this.tabGroupManager = new TabGroupManager(browser); + this.activeTabGroupManager = new ActiveTabGroupManager(browser); + this.tabsContainerManager = new TabsContainerManager(browser); + } + + public destroy(): void { + this.tabManager.destroy(); + this.tabGroupManager.destroy(); + } +} diff --git a/src/main/browser/tabs/managers/active-tab-group-manager.ts b/src/main/browser/tabs/managers/active-tab-group-manager.ts new file mode 100644 index 00000000..c7d54ec6 --- /dev/null +++ b/src/main/browser/tabs/managers/active-tab-group-manager.ts @@ -0,0 +1,68 @@ +import { Browser } from "@/browser/browser"; +import { TabGroup } from "@/browser/tabs/objects/tab-group"; +import { TabbedBrowserWindow } from "@/browser/window"; +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; + +type ActiveTabGroupManagerEvents = { + destroyed: []; +}; + +export class ActiveTabGroupManager extends TypedEventEmitter { + public isDestroyed: boolean = false; + + private readonly browser: Browser; + private windowSpaceActiveTabGroup: Map<`${string}-${string}`, TabGroup>; + + constructor(browser: Browser) { + super(); + this.browser = browser; + this.windowSpaceActiveTabGroup = new Map(); + + browser.on("window-created", this._onNewWindow); + for (const window of browser.getWindows()) { + this._onNewWindow(window); + } + } + + private _onNewWindow(window: TabbedBrowserWindow) { + const refresh = () => { + this._updateActiveTabGroup(window); + }; + refresh(); + window.on("current-space-changed", refresh); + } + + private _updateActiveTabGroup(window: TabbedBrowserWindow) { + const windowSpace = window.getCurrentSpace(); + for (const [key, tabGroup] of this.windowSpaceActiveTabGroup.entries()) { + if (key === `${window.id}-${windowSpace}`) { + tabGroup.visiblity.setVisible(true); + } else if (key.startsWith(`${window.id}-`)) { + tabGroup.visiblity.setVisible(false); + } + } + } + + public setActiveTabGroup(tabGroup: TabGroup) { + const window = tabGroup.window.get(); + const windowSpace = window.getCurrentSpace(); + this.windowSpaceActiveTabGroup.set(`${window.id}-${windowSpace}`, tabGroup); + this._updateActiveTabGroup(window); + } + + /** + * Destroy the tab group manager + */ + public destroy(): void { + if (this.isDestroyed) { + return; + } + + this.isDestroyed = true; + this.emit("destroyed"); + + // TODO: Destroy all tab groups + + this.destroyEmitter(); + } +} diff --git a/src/main/browser/tabs/managers/tab-group-manager.ts b/src/main/browser/tabs/managers/tab-group-manager.ts new file mode 100644 index 00000000..a6c9a871 --- /dev/null +++ b/src/main/browser/tabs/managers/tab-group-manager.ts @@ -0,0 +1,129 @@ +import { Browser } from "@/browser/browser"; +import { Tab } from "@/browser/tabs/objects/tab"; +import { TabGroup } from "@/browser/tabs/objects/tab-group"; +import { NormalTabGroup } from "@/browser/tabs/objects/tab-group/types/normal"; +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { getSpacesFromProfile } from "@/sessions/spaces"; + +type TabGroupManagerEvents = { + "tab-group-created": [TabGroup]; + "tab-group-removed": [TabGroup]; + destroyed: []; +}; + +export class TabGroupManager extends TypedEventEmitter { + public tabGroups: Map = new Map(); + public isDestroyed: boolean = false; + + private readonly browser: Browser; + + constructor(browser: Browser) { + super(); + this.browser = browser; + + const tabOrchestrator = browser.tabs; + + // Setup event listeners + this.on("tab-group-created", (tabGroup) => { + tabGroup.on("destroyed", () => { + this._removeTabGroup(tabGroup); + }); + }); + + tabOrchestrator.tabManager.on("tab-created", async (tab) => { + const space = await this._getFirstSpace(tab.profileId); + if (space) { + this._createBasicTabGroup(tab, space); + } + }); + + this.on("tab-group-removed", (tabGroup) => { + const tabs = tabGroup.tabs.get(); + for (const tab of tabs) { + this._createBasicTabGroup(tab, tabGroup.creationDetails.space); + } + }); + } + + private async _getFirstSpace(profileId: string): Promise { + const spaces = await getSpacesFromProfile(profileId); + if (spaces.length > 0) { + return spaces[0].id; + } + return undefined; + } + + /** + * Create a basic tab group for a tab + * @param tab - The tab to create a tab group for + */ + private _createBasicTabGroup(tab: Tab, space: string) { + const window = tab.window.get(); + const tabGroup = new NormalTabGroup({ + browser: this.browser, + window: window, + space: space + }); + tabGroup.tabs.addTab(tab); + this.addTabGroup(tabGroup); + } + + /** + * Add a tab group to the manager + * @param tabGroup - The tab group to add + */ + public addTabGroup(tabGroup: TabGroup) { + this.tabGroups.set(tabGroup.id, tabGroup); + this.emit("tab-group-created", tabGroup); + } + + /** + * Get a tab group from a tab + */ + public getTabGroupFromTab(tab: Tab): TabGroup | undefined { + for (const tabGroup of this.tabGroups.values()) { + if (tabGroup.tabs.hasTab(tab.id)) { + return tabGroup; + } + } + + return undefined; + } + + /** + * Remove a tab group from the manager + * @param tabGroup - The tab group to remove + * @internal Should not be used directly, use `tabGroup.destroy()` instead + */ + private _removeTabGroup(tabGroup: TabGroup) { + this.tabGroups.delete(tabGroup.id); + this.emit("tab-group-removed", tabGroup); + } + + /** + * Get all tab groups + * @returns All tab groups + */ + public getTabGroups(): TabGroup[] { + return Array.from(this.tabGroups.values()); + } + + /** + * Destroy the tab group manager + */ + public destroy(): void { + if (this.isDestroyed) { + return; + } + + this.isDestroyed = true; + this.emit("destroyed"); + + // Destroy all tab groups + for (const tabGroup of this.tabGroups.values()) { + tabGroup.destroy(); + } + + this.destroyEmitter(); + } +} diff --git a/src/main/browser/tabs/managers/tab-manager.ts b/src/main/browser/tabs/managers/tab-manager.ts new file mode 100644 index 00000000..529d24f7 --- /dev/null +++ b/src/main/browser/tabs/managers/tab-manager.ts @@ -0,0 +1,195 @@ +import { Browser } from "@/browser/browser"; +import { Tab, TabCreationDetails } from "@/browser/tabs/objects/tab"; +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { windowTabsChanged } from "@/ipc/browser/tabs"; +import { WebContents } from "electron"; + +type TabManagerEvents = { + "tab-created": [Tab]; + "tab-updated": [Tab]; + "tab-removed": [Tab]; + destroyed: []; +}; + +type TabCreationOptions = Omit; + +export class TabManager extends TypedEventEmitter { + public tabs: Map = new Map(); + public isDestroyed: boolean = false; + + private readonly browser: Browser; + + constructor(browser: Browser) { + super(); + this.browser = browser; + + // Setup event listeners for window tab changes + this.on("tab-created", (tab) => { + const window = tab.window.get(); + if (window) { + windowTabsChanged(window.id); + } + }); + + this.on("tab-updated", (tab) => { + const window = tab.window.get(); + if (window) { + windowTabsChanged(window.id); + } + }); + + this.on("tab-removed", (tab) => { + const window = tab.window.get(); + if (window) { + windowTabsChanged(window.id); + } + }); + } + + /** + * Create a tab + */ + public createTab(windowId: number, profileId: string, options: TabCreationOptions): Tab { + if (this.isDestroyed) { + throw new Error("TabManager has been destroyed"); + } + + // Get window + const window = this.browser.getWindowById(windowId); + if (!window) { + throw new Error("Window not found"); + } + + // Get loaded profile + const loadedProfile = this.browser.getLoadedProfile(profileId); + if (!loadedProfile) { + throw new Error("Profile not found"); + } + + // Create tab + const tab = new Tab({ + browser: this.browser, + window, + ...options + }); + + // Add to tabs map + this.tabs.set(tab.id, tab); + + // Setup event listeners + this.setupTabEventListeners(tab); + + // Emit tab created event + this.emit("tab-created", tab); + + return tab; + } + + /** + * Setup event listeners for a tab + */ + private setupTabEventListeners(tab: Tab): void { + // Handle tab destruction + tab.on("destroyed", () => { + this._removeTab(tab); + }); + + // Handle tab updates + tab.on("data-changed", () => { + this.emit("tab-updated", tab); + }); + } + + /** + * Remove a tab from the tab manager + * @internal Should not be used directly, use `tab.destroy()` instead + */ + private _removeTab(tab: Tab): void { + if (!this.tabs.has(tab.id)) { + return; + } + + // Remove from tabs map + this.tabs.delete(tab.id); + + // Emit tab removed event + this.emit("tab-removed", tab); + } + + /** + * Get a tab by ID + */ + public getTabById(tabId: string): Tab | undefined { + return this.tabs.get(tabId); + } + + /** + * Get a tab by webContents + */ + public getTabByWebContents(webContents: WebContents): Tab | undefined { + for (const tab of this.tabs.values()) { + if (tab.webview.webContents === webContents) { + return tab; + } + } + return undefined; + } + + /** + * Get all tabs + */ + public getAllTabs(): Tab[] { + return Array.from(this.tabs.values()); + } + + /** + * Get all tabs in a window + */ + public getTabsInWindow(windowId: number): Tab[] { + const result: Tab[] = []; + for (const tab of this.tabs.values()) { + const tabWindow = tab.window.get(); + if (tabWindow.id === windowId) { + result.push(tab); + } + } + return result; + } + + /** + * Get the count of tabs + */ + public getTabCount(): number { + return this.tabs.size; + } + + /** + * Check if a tab exists + */ + public hasTab(tabId: string): boolean { + return this.tabs.has(tabId); + } + + /** + * Destroy the tab manager + */ + public destroy(): void { + if (this.isDestroyed) { + return; + } + + this.isDestroyed = true; + this.emit("destroyed"); + + // Destroy all tabs + const tabsToDestroy = Array.from(this.tabs.values()); + for (const tab of tabsToDestroy) { + tab.destroy(); + } + + // Clear tabs map + this.tabs.clear(); + + this.destroyEmitter(); + } +} diff --git a/src/main/browser/tabs/managers/tabs-container-manager.ts b/src/main/browser/tabs/managers/tabs-container-manager.ts new file mode 100644 index 00000000..17165867 --- /dev/null +++ b/src/main/browser/tabs/managers/tabs-container-manager.ts @@ -0,0 +1,115 @@ +import { Browser } from "@/browser/browser"; +import { TabContainer } from "@/browser/tabs/objects/tab-containers/tab-container"; +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { getSpacesFromProfile, SpaceData, spacesEmitter } from "@/sessions/spaces"; + +type TabsContainerManagerEvents = { + destroyed: []; +}; + +/** + * Manages tab containers for browser profiles and spaces + * @class + * @extends TypedEventEmitter + */ +export class TabsContainerManager extends TypedEventEmitter { + public isDestroyed: boolean = false; + + private readonly browser: Browser; + private spaceContainerMap: Map = new Map(); + + constructor(browser: Browser) { + super(); + this.browser = browser; + + const tabOrchestrator = browser.tabs; + tabOrchestrator.tabManager.on("tab-created", (tab) => { + console.log("tab-created", tab); + }); + + // Setup tab containers for all loaded profiles & all new loaded profiles + const loadedProfiles = browser.getLoadedProfiles(); + for (const profile of loadedProfiles) { + this._setupProfile(profile.profileId); + } + + browser.on("profile-loaded", (profileId) => { + this._setupProfile(profileId); + }); + } + + /** + * Sets up tab containers for a specific profile + * @private + * @param {string} profileId - The ID of the profile to set up + * @returns {Promise} + */ + private async _setupProfile(profileId: string): Promise { + const spacesSet = new Set(); + + const updateSpaces = async () => { + const spaces = await getSpacesFromProfile(profileId); + for (const space of spaces) { + if (!spacesSet.has(space.id)) { + this._setupSpace(space); + spacesSet.add(space.id); + } + } + }; + + updateSpaces(); + spacesEmitter.on("changed", updateSpaces); + } + + /** + * Sets up tab containers for a specific space + * Creates both pinned and normal containers for organizing tabs + * @private + * @param {SpaceData & { id: string }} space - The space data including its ID + */ + private _setupSpace(space: SpaceData & { id: string }): void { + const spaceContainer = new TabContainer(space.id); + this.spaceContainerMap.set(space.id, spaceContainer); + + // Add tab group to the correct container on create + // const browser = this.browser; + // const tabOrchestrator = browser.tabs; + // tabOrchestrator.tabGroupManager.on("tab-group-created", (tabGroup) => { + // spaceContainer.addChild({ + // type: "tab-group", + // item: tabGroup + // }); + // }); + } + + /** + * Gets the tab container for a specific space + * @public + * @param {string} spaceId - The ID of the space to get the container for + * @returns {TabContainer} - The tab container for the space + */ + public getSpaceContainer(spaceId: string): TabContainer { + const spaceContainer = this.spaceContainerMap.get(spaceId); + if (!spaceContainer) { + throw new Error(`Space container not found for spaceId: ${spaceId}`); + } + return spaceContainer; + } + + /** + * Destroys the tabs container manager and cleans up resources + * Emits the 'destroyed' event and prevents further destruction attempts + * @public + * @returns {void} + */ + public destroy(): void { + if (this.isDestroyed) { + return; + } + + this.isDestroyed = true; + this.emit("destroyed"); + + this.destroyEmitter(); + } +} diff --git a/src/main/browser/tabs/objects/tab-containers/base.ts b/src/main/browser/tabs/objects/tab-containers/base.ts new file mode 100644 index 00000000..3a1dc745 --- /dev/null +++ b/src/main/browser/tabs/objects/tab-containers/base.ts @@ -0,0 +1,174 @@ +import { TabGroup } from "@/browser/tabs/objects/tab-group"; +import { TabFolder } from "@/browser/tabs/objects/tab-containers/tab-folder"; +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { NormalTabGroup } from "@/browser/tabs/objects/tab-group/types/normal"; +import { TabbedBrowserWindow } from "@/browser/window"; +import { Browser } from "@/browser/browser"; + +// Container Child // +type TabGroupChild = { + type: "tab-group"; + item: TabGroup; +}; +type TabFolderChild = { + type: "tab-folder"; + item: TabFolder; +}; +export type ContainerChild = TabGroupChild | TabFolderChild; + +// Exported Tab Data // +export type ExportedBaseTabContainer = { + type: "tab-container"; + children: ExportedTabData[]; +}; + +export type ExportedTabGroup = { + type: "tab-group"; +}; +export type ExportedTabContainer = { + type: "tab-container"; + children: ExportedTabData[]; +}; +export type ExportedTabsFolder = { + type: "tab-folder"; + name: string; + children: ExportedTabData[]; +}; +// ExportedTabContainer would not be a child: it should only be the root container. +type ExportedTabData = ExportedTabGroup | ExportedTabsFolder; + +// Tab Container Shared Data // +export interface TabContainerSharedData { + browser: Browser; + window: TabbedBrowserWindow; + space: string; +} + +// Base Tab Container // +export type BaseTabContainerEvents = { + "child-added": [child: ContainerChild]; + "child-removed": [child: ContainerChild]; + "children-moved": []; +}; + +export class BaseTabContainer< + TEvents extends BaseTabContainerEvents = BaseTabContainerEvents +> extends TypedEventEmitter { + public children: ContainerChild[]; + + public sharedData: TabContainerSharedData; + + constructor(sharedData: TabContainerSharedData) { + super(); + + this.children = []; + this.sharedData = sharedData; + } + + // Modify Children // + + /** + * Add a child to the container. + * @param child - The child to add. + */ + private addChild(child: ContainerChild): void { + this.children.push(child); + this.emit("child-added", child); + } + + /** + * Remove a child from the container. + * @param child - The child to remove. + * @returns Whether the child was removed. + */ + private removeChild(child: ContainerChild): boolean { + const index = this.children.indexOf(child); + if (index === -1) return false; + + this.children.splice(index, 1); + this.emit("child-removed", child); + return true; + } + + /** + * Move a child to a new index. + * @param from - The index to move from. + * @param to - The index to move to. + * @returns Whether the child was moved. + */ + public moveChild(from: number, to: number): boolean { + if (from < 0 || from >= this.children.length || to < 0 || to >= this.children.length) { + return false; + } + + const [child] = this.children.splice(from, 1); + this.children.splice(to, 0, child); + this.emit("children-moved"); + return true; + } + + // Get Children // + + public findChildrenByType(type: "tab-group" | "tab-container"): ContainerChild[] { + return this.children.filter((child) => child.type === type); + } + + public getChildIndex(child: ContainerChild): number { + return this.children.indexOf(child); + } + + get childCount(): number { + return this.children.length; + } + + // New Children // + + public newTabFolder(name: string): TabFolder { + const folder = new TabFolder(name, this); + this.addChild({ type: "tab-folder", item: folder }); + return folder; + } + + public newNormalTabGroup(): NormalTabGroup { + const tabGroup = new NormalTabGroup({ + browser: this.sharedData.browser, + window: this.sharedData.window, + space: this.sharedData.space + }); + this.addChild({ type: "tab-group", item: tabGroup }); + return tabGroup; + } + + // Others // + + /** + * Get all tab groups in the container. + * @returns All tab groups in the container. + */ + public getAllTabGroups(): TabGroup[] { + const scanTabContainer = (container: BaseTabContainer): TabGroup[] => { + return container.children.flatMap((child) => { + if (child.type === "tab-group") { + return [child.item]; + } + if (child.type === "tab-folder") { + return scanTabContainer(child.item); + } + return []; + }); + }; + + return scanTabContainer(this); + } + + // Export // + + protected baseExport(): ExportedBaseTabContainer { + return { + type: "tab-container", + children: this.children.map((child) => { + return child.item.export(); + }) + }; + } +} diff --git a/src/main/browser/tabs/objects/tab-containers/tab-container.ts b/src/main/browser/tabs/objects/tab-containers/tab-container.ts new file mode 100644 index 00000000..3ac86963 --- /dev/null +++ b/src/main/browser/tabs/objects/tab-containers/tab-container.ts @@ -0,0 +1,27 @@ +import { BaseTabContainer, BaseTabContainerEvents, ExportedTabContainer, TabContainerSharedData } from "./base"; + +type TabContainerEvents = BaseTabContainerEvents & { + "space-changed": [spaceId: string]; +}; + +export class TabContainer extends BaseTabContainer { + public spaceId: string; + + constructor(spaceId: string, sharedData: TabContainerSharedData) { + super(sharedData); + this.spaceId = spaceId; + } + + public setSpace(spaceId: string): void { + this.spaceId = spaceId; + this.emit("space-changed", spaceId); + } + + public export(): ExportedTabContainer { + const { children } = this.baseExport(); + return { + type: "tab-container", + children + }; + } +} diff --git a/src/main/browser/tabs/objects/tab-containers/tab-folder.ts b/src/main/browser/tabs/objects/tab-containers/tab-folder.ts new file mode 100644 index 00000000..e53fa535 --- /dev/null +++ b/src/main/browser/tabs/objects/tab-containers/tab-folder.ts @@ -0,0 +1,25 @@ +import { BaseTabContainer, ExportedTabsFolder } from "./base"; + +export class TabFolder extends BaseTabContainer { + public name: string; + public parent: BaseTabContainer; + + constructor(name: string, parent: BaseTabContainer) { + super(parent.sharedData); + this.name = name; + this.parent = parent; + } + + public setName(name: string): void { + this.name = name; + } + + public export(): ExportedTabsFolder { + const { children } = this.baseExport(); + return { + type: "tab-folder", + name: this.name, + children + }; + } +} diff --git a/src/main/browser/tabs/objects/tab-group/controllers/focused-tab.ts b/src/main/browser/tabs/objects/tab-group/controllers/focused-tab.ts new file mode 100644 index 00000000..b86fbca3 --- /dev/null +++ b/src/main/browser/tabs/objects/tab-group/controllers/focused-tab.ts @@ -0,0 +1,62 @@ +import { Tab } from "@/browser/tabs/objects/tab"; +import { TabGroup } from "@/browser/tabs/objects/tab-group"; + +/** + * Controller responsible for managing the focused tab within a tab group. + * Handles setting/removing the focused tab. + */ +export class TabGroupFocusedTabController { + /** Reference to the tab group this controller manages */ + private readonly tabGroup: TabGroup; + + /** ID of the focused tab in this tab group. */ + private focusedTabId: string | null = null; + + /** + * Creates a new TabGroupFocusedTabController instance. + * @param tabGroup - The tab group this controller will manage + */ + constructor(tabGroup: TabGroup) { + this.tabGroup = tabGroup; + + this._setupEventListeners(); + } + + private _setupEventListeners() { + this.tabGroup.connect("tab-added", (tab) => { + if (!this.focusedTabId) { + this.set(tab); + } + }); + + this.tabGroup.connect("tab-removed", (tab) => { + if (this.focusedTabId === tab.id) { + this.remove(); + + const tabGroup = this.tabGroup; + const tabs = tabGroup.tabs.get(); + if (tabs.length > 0) { + this.set(tabs[0]); + } + } + }); + } + + public set(tab: Tab) { + if (this.focusedTabId === tab.id) { + return false; + } + + this.remove(); + this.focusedTabId = tab.id; + return true; + } + + public remove() { + if (this.focusedTabId) { + this.focusedTabId = null; + return true; + } + return false; + } +} diff --git a/src/main/browser/tabs/objects/tab-group/controllers/index.ts b/src/main/browser/tabs/objects/tab-group/controllers/index.ts new file mode 100644 index 00000000..a24e8008 --- /dev/null +++ b/src/main/browser/tabs/objects/tab-group/controllers/index.ts @@ -0,0 +1,6 @@ +import { TabGroupFocusedTabController } from "@/browser/tabs/objects/tab-group/controllers/focused-tab"; +import { TabGroupTabsController } from "@/browser/tabs/objects/tab-group/controllers/tabs"; +import { TabGroupWindowController } from "@/browser/tabs/objects/tab-group/controllers/window"; +import { TabGroupVisiblityController } from "@/browser/tabs/objects/tab-group/controllers/visiblity"; + +export { TabGroupFocusedTabController, TabGroupTabsController, TabGroupWindowController, TabGroupVisiblityController }; diff --git a/src/main/browser/tabs/objects/tab-group/controllers/space.ts b/src/main/browser/tabs/objects/tab-group/controllers/space.ts new file mode 100644 index 00000000..a1b56c0c --- /dev/null +++ b/src/main/browser/tabs/objects/tab-group/controllers/space.ts @@ -0,0 +1,28 @@ +import { TabGroup } from "@/browser/tabs/objects/tab-group"; + +export class TabGroupSpaceController { + private readonly tabGroup: TabGroup; + private space: string; + + constructor(tabGroup: TabGroup) { + this.tabGroup = tabGroup; + + const creationDetails = tabGroup.creationDetails; + this.space = creationDetails.space; + } + + public get() { + return this.space; + } + + public set(space: string) { + if (this.space === space) { + return false; + } + + this.space = space; + this.tabGroup.emit("space-changed"); + + return true; + } +} diff --git a/src/main/browser/tabs/objects/tab-group/controllers/tabs.ts b/src/main/browser/tabs/objects/tab-group/controllers/tabs.ts new file mode 100644 index 00000000..0d7bf530 --- /dev/null +++ b/src/main/browser/tabs/objects/tab-group/controllers/tabs.ts @@ -0,0 +1,159 @@ +import { Browser } from "@/browser/browser"; +import { Tab } from "@/browser/tabs/objects/tab"; +import { TabGroup } from "@/browser/tabs/objects/tab-group"; + +/** + * Controller responsible for managing tabs within a tab group. + * Handles adding/removing tabs, maintaining tab state, and cleaning up event listeners. + */ +export class TabGroupTabsController { + /** Reference to the browser instance that owns this tab group */ + private readonly browser: Browser; + + /** Reference to the tab group this controller manages */ + private readonly tabGroup: TabGroup; + + /** Set of tab IDs that belong to this tab group. Uses Set for O(1) operations. */ + private readonly tabIds: Set; + + /** + * Map of tab ID to array of listener disconnector functions. + * Used to clean up event listeners when tabs are removed. + */ + private readonly tabListenersDisconnectors: Map void)[]> = new Map(); + + /** + * Creates a new TabGroupTabsController instance. + * @param tabGroup - The tab group this controller will manage + */ + constructor(tabGroup: TabGroup) { + this.browser = tabGroup.creationDetails.browser; + this.tabGroup = tabGroup; + + // Initialize empty set of tab IDs + this.tabIds = new Set(); + + // Setup event listeners for focused tab + tabGroup.connect("tab-removed", () => { + if (this.tabIds.size === 0) { + // Destroy the tab group + this.tabGroup.destroy(); + } + }); + } + + /** + * Adds a tab to this tab group. + * Sets up event listeners and emits the "tab-added" event. + * Respects the maximum number of tabs allowed in the group. + * + * @param tab - The tab to add to the group + * @returns true if the tab was added successfully, false if it was already in the group or would exceed the maximum tab limit + */ + public addTab(tab: Tab) { + // Check if tab is already in this group + const hasTab = this.tabIds.has(tab.id); + if (hasTab) { + return false; + } + + // Check if adding this tab would exceed the maximum allowed tabs + // -1 means no limit + if (this.tabGroup.maxTabs !== -1 && this.tabIds.size >= this.tabGroup.maxTabs) { + return false; + } + + // Add tab ID to our set + this.tabIds.add(tab.id); + + // Setup event listeners for tab lifecycle management + const disconnectDestroyListener = tab.connect("destroyed", () => { + this.removeTab(tab); + }); + + const disconnectFocusedListener = tab.connect("focused", () => { + this.tabGroup.focusedTab.set(tab); + }); + + // Store the disconnector function for cleanup later + this.tabListenersDisconnectors.set(tab.id, [disconnectDestroyListener, disconnectFocusedListener]); + + // Notify tab group that a new tab was added + this.tabGroup.emit("tab-added", tab); + return true; + } + + /** + * Removes a tab from this tab group. + * Cleans up event listeners and emits the "tab-removed" event. + * + * @param tab - The tab to remove from the group + * @returns true if the tab was removed successfully, false if it wasn't in the group + */ + public removeTab(tab: Tab) { + // Check if tab exists in this group + const hasTab = this.tabIds.has(tab.id); + if (!hasTab) { + return false; + } + + // Remove tab ID from our set + this.tabIds.delete(tab.id); + + // Clean up event listeners to prevent memory leaks + const disconnectors = this.tabListenersDisconnectors.get(tab.id); + if (disconnectors) { + disconnectors.forEach((disconnector) => { + disconnector(); + }); + } + this.tabListenersDisconnectors.delete(tab.id); + + // Notify tab group that a tab was removed + this.tabGroup.emit("tab-removed", tab); + return true; + } + + /** + * Gets all tabs that belong to this tab group. + * Filters out any tabs that may have been destroyed but not properly cleaned up. + * + * @returns Array of Tab instances that are currently in this group + */ + public get(): Tab[] { + const tabOrchestrator = this.browser.tabs; + + // Convert Set to Array and map tab IDs to actual Tab instances + const tabs = Array.from(this.tabIds).map((id) => { + return tabOrchestrator.tabManager.getTabById(id); + }); + + // Filter out any undefined tabs (in case a tab was destroyed but not properly removed) + return tabs.filter((tab) => tab !== undefined); + } + + /** + * Checks if a tab belongs to this tab group. + * @param tabId - The ID of the tab to check + * @returns true if the tab belongs to this group, false otherwise + */ + public hasTab(tabId: string): boolean { + return this.tabIds.has(tabId); + } + + /** + * Cleans up all event listeners for all tabs in this group. + * Should be called when the tab group is being destroyed to prevent memory leaks. + */ + public cleanupListeners() { + // Disconnect all event listeners + this.tabListenersDisconnectors.forEach((disconnectors) => { + disconnectors.forEach((disconnector) => { + disconnector(); + }); + }); + + // Clear the disconnectors map + this.tabListenersDisconnectors.clear(); + } +} diff --git a/src/main/browser/tabs/objects/tab-group/controllers/visiblity.ts b/src/main/browser/tabs/objects/tab-group/controllers/visiblity.ts new file mode 100644 index 00000000..1a04b35d --- /dev/null +++ b/src/main/browser/tabs/objects/tab-group/controllers/visiblity.ts @@ -0,0 +1,40 @@ +import { TabGroup } from "@/browser/tabs/objects/tab-group"; + +export class TabGroupVisiblityController { + private readonly tabGroup: TabGroup; + + public isVisible: boolean; + + constructor(tabGroup: TabGroup) { + this.tabGroup = tabGroup; + + this.isVisible = false; + + tabGroup.on("tab-added", (tab) => { + tab.visiblity.setVisible(this.isVisible); + }); + + tabGroup.on("tab-removed", (tab) => { + tab.visiblity.setVisible(this.isVisible); + }); + } + + /** + * Set the visibility of the tab group + * @param visible - Whether the tab group should be visible + */ + public setVisible(visible: boolean) { + this.isVisible = visible; + this.updateTabsVisibility(); + } + + /** + * Update the visibility of all tabs in the tab group + */ + public updateTabsVisibility() { + const tabs = this.tabGroup.tabs.get(); + for (const tab of tabs) { + tab.visiblity.setVisible(this.isVisible); + } + } +} diff --git a/src/main/browser/tabs/objects/tab-group/controllers/window.ts b/src/main/browser/tabs/objects/tab-group/controllers/window.ts new file mode 100644 index 00000000..bdcf2bb9 --- /dev/null +++ b/src/main/browser/tabs/objects/tab-group/controllers/window.ts @@ -0,0 +1,43 @@ +import { TabGroup } from "@/browser/tabs/objects/tab-group"; +import { TabbedBrowserWindow } from "@/browser/window"; + +export class TabGroupWindowController { + private readonly tabGroup: TabGroup; + private window: TabbedBrowserWindow; + + constructor(tabGroup: TabGroup) { + this.tabGroup = tabGroup; + + const creationDetails = tabGroup.creationDetails; + this.window = creationDetails.window; + } + + public get() { + return this.window; + } + + public set(window: TabbedBrowserWindow) { + if (this.window === window) { + return false; + } + + this.window = window; + this.tabGroup.emit("window-changed"); + + this.updateTabsWindow(); + + return true; + } + + // Overrides the windows of all tabs in the tab group + public updateTabsWindow() { + const tabGroup = this.tabGroup; + + const tabs = tabGroup.tabs.get(); + for (const tab of tabs) { + tab.window.set(this.window); + } + + return true; + } +} diff --git a/src/main/browser/tabs/objects/tab-group/index.ts b/src/main/browser/tabs/objects/tab-group/index.ts new file mode 100644 index 00000000..7e78cafc --- /dev/null +++ b/src/main/browser/tabs/objects/tab-group/index.ts @@ -0,0 +1,88 @@ +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { generateID } from "@/modules/utils"; +import { Tab } from "@/browser/tabs/objects/tab"; +import { + TabGroupFocusedTabController, + TabGroupTabsController, + TabGroupVisiblityController, + TabGroupWindowController +} from "@/browser/tabs/objects/tab-group/controllers"; +import { Browser } from "@/browser/browser"; +import { TabbedBrowserWindow } from "@/browser/window"; +import { ExportedTabGroup } from "@/browser/tabs/objects/tab-containers/base"; + +type TabGroupTypes = "normal" | "split" | "glance"; + +type TabGroupEvents = { + "window-changed": []; + "tab-added": [Tab]; + "tab-removed": [Tab]; + "space-changed": []; + destroyed: []; +}; + +export interface TabGroupCreationDetails { + browser: Browser; + window: TabbedBrowserWindow; + space: string; +} + +export interface TabGroupVariant { + type: TabGroupTypes; + maxTabs: number; +} + +export class TabGroup extends TypedEventEmitter { + public readonly id: string; + public destroyed: boolean; + + public readonly type: TabGroupTypes; + public readonly maxTabs: number; + public readonly creationDetails: TabGroupCreationDetails; + + protected tabIds: string[] = []; + + public readonly window: TabGroupWindowController; + public readonly tabs: TabGroupTabsController; + public readonly focusedTab: TabGroupFocusedTabController; + public readonly visiblity: TabGroupVisiblityController; + + constructor(variant: TabGroupVariant, details: TabGroupCreationDetails) { + super(); + + this.id = generateID(); + this.destroyed = false; + + this.type = variant.type; + this.maxTabs = variant.maxTabs; + this.creationDetails = details; + + this.window = new TabGroupWindowController(this); + this.tabs = new TabGroupTabsController(this); + this.focusedTab = new TabGroupFocusedTabController(this); + this.visiblity = new TabGroupVisiblityController(this); + } + + public destroy() { + this.throwIfDestroyed(); + + this.destroyed = true; + this.emit("destroyed"); + + this.tabs.cleanupListeners(); + + this.destroyEmitter(); + } + + public throwIfDestroyed() { + if (this.destroyed) { + throw new Error("Tab group already destroyed"); + } + } + + public export(): ExportedTabGroup { + return { + type: "tab-group" + }; + } +} diff --git a/src/main/browser/tabs/objects/tab-group/types/normal.ts b/src/main/browser/tabs/objects/tab-group/types/normal.ts new file mode 100644 index 00000000..badbe22b --- /dev/null +++ b/src/main/browser/tabs/objects/tab-group/types/normal.ts @@ -0,0 +1,10 @@ +import { TabGroup, TabGroupCreationDetails } from "../index"; + +/** + * A tab group that can only have one tab. + */ +export class NormalTabGroup extends TabGroup { + constructor(details: TabGroupCreationDetails) { + super({ type: "normal", maxTabs: 1 }, details); + } +} diff --git a/src/main/browser/tabs/objects/tab/controllers/bounds.ts b/src/main/browser/tabs/objects/tab/controllers/bounds.ts new file mode 100644 index 00000000..b98eedf0 --- /dev/null +++ b/src/main/browser/tabs/objects/tab/controllers/bounds.ts @@ -0,0 +1,77 @@ +/* +TabBoundsController: +- This controller is responsible for managing the bounds of the tab +- Methods should be called by the Tab Container. +*/ + +import { Tab } from "@/browser/tabs/objects/tab"; +import { PageBounds } from "~/flow/types"; + +export class TabBoundsController { + private readonly tab: Tab; + + /** + * Whether the tab is currently animating. + * When animating, the size of the bounds will not be updated. + * This is because there are a lot of complex calculations done in the tab when animating, which cause performance issues. + */ + public isAnimating: boolean; + + /** + * The bounds of the tab. + */ + private bounds: PageBounds; + + constructor(tab: Tab) { + this.tab = tab; + + this.isAnimating = false; + this.bounds = { + x: 0, + y: 0, + width: 0, + height: 0 + }; + } + + public startAnimating() { + this.isAnimating = true; + } + + public stopAnimating() { + this.isAnimating = false; + } + + public set(bounds: PageBounds) { + this.bounds = bounds; + this.tab.emit("bounds-changed", bounds); + + this.updateWebviewBounds(); + } + + public get() { + return this.bounds; + } + + // Trigged on: + // - Bounds being set (tab.bounds.set) + // - Visibility being set (tab.visiblity.setVisible) + // - Webview being attached (tab.webview.attach) + public updateWebviewBounds() { + // Only update the bounds if the tab is visible for performance + if (!this.tab.visiblity.isVisible) { + return false; + } + + const tab = this.tab; + + const webContentsView = tab.webview.webContentsView; + if (!webContentsView) { + return false; + } + + const bounds = tab.bounds.get(); + webContentsView.setBounds(bounds); + return true; + } +} diff --git a/src/main/browser/tabs/tab-context-menu.ts b/src/main/browser/tabs/objects/tab/controllers/context-menu.ts similarity index 88% rename from src/main/browser/tabs/tab-context-menu.ts rename to src/main/browser/tabs/objects/tab/controllers/context-menu.ts index 186dd679..f897f789 100644 --- a/src/main/browser/tabs/tab-context-menu.ts +++ b/src/main/browser/tabs/objects/tab/controllers/context-menu.ts @@ -1,5 +1,10 @@ +/* +TabContextMenuController: +- This controller is responsible for creating and controlling the context menu for the tab +*/ + +import { Tab } from "@/browser/tabs/objects/tab"; import { Browser } from "@/browser/browser"; -import { Tab } from "@/browser/tabs/tab"; import { TabbedBrowserWindow } from "@/browser/window"; import contextMenu from "electron-context-menu"; @@ -29,14 +34,11 @@ interface MenuActions { [key: string]: MenuItemFunction | InspectFunction; } -export function createTabContextMenu( - browser: Browser, - tab: Tab, - profileId: string, - tabbedWindow: TabbedBrowserWindow, - spaceId: string -) { - const webContents = tab.webContents; +function createTabContextMenu(browser: Browser, tab: Tab, profileId: string) { + const webContents = tab.webview.webContents; + if (!webContents) { + return false; + } contextMenu({ window: webContents, @@ -49,9 +51,15 @@ export function createTabContextMenu( // Helper function to create a new tab const createNewTab = async (url: string, window?: TabbedBrowserWindow) => { - const sourceTab = await browser.tabs.createTab(window ? window.id : tabbedWindow.id, profileId, spaceId); - sourceTab.loadURL(url); - browser.tabs.setActiveTab(sourceTab); + // TODO: Implement this + console.log("createNewTab", url, window, profileId); + + // const tabbedWindow = tab.window.get(); + // const spaceId = tab.space.get(); + + // const sourceTab = await browser.tabs.createTab(window ? window.id : tabbedWindow?.id, profileId, spaceId, url); + // sourceTab.loadURL(url); + // browser.tabs.setActiveTab(sourceTab); }; // Create all menu sections @@ -118,6 +126,8 @@ export function createTabContextMenu( return combineSections(sections, defaultActions as MenuActions); } }); + + return true; } function createOpenLinkItems( @@ -284,3 +294,17 @@ function combineSections( return combinedSections; } + +export class TabContextMenuController { + // private readonly tab: Tab; + + constructor(tab: Tab) { + // this.tab = tab; + + tab.on("webview-attached", () => { + const browser = tab.browser; + const profileId = tab.profileId; + createTabContextMenu(browser, tab, profileId); + }); + } +} diff --git a/src/main/browser/tabs/objects/tab/controllers/data.ts b/src/main/browser/tabs/objects/tab/controllers/data.ts new file mode 100644 index 00000000..b621202c --- /dev/null +++ b/src/main/browser/tabs/objects/tab/controllers/data.ts @@ -0,0 +1,158 @@ +/* +TabDataController: +- This controller stores all the data that needs to be synced with the frontend +*/ + +import { Tab } from "@/browser/tabs/objects/tab"; +import { TabbedBrowserWindow } from "@/browser/window"; +import { WebContents } from "electron"; + +type PropertiesFromOtherControllers = "window" | "pipActive" | "asleep"; +type PropertiesFromWebview = "title" | "url" | "isLoading" | "audible" | "muted"; + +type TabDataProperties = PropertiesFromOtherControllers | PropertiesFromWebview; + +export class TabDataController { + private readonly tab: Tab; + + // from other controllers + public window: TabbedBrowserWindow | null = null; + public pipActive: boolean = false; + public asleep: boolean = false; + + // from webview (recorded here) + public title: string = ""; + public url: string = ""; + public isLoading: boolean = true; + public audible: boolean = false; + public muted: boolean = false; + + // recorded here + // none currently + + constructor(tab: Tab) { + this.tab = tab; + + tab.on("window-changed", () => this.refreshData()); + tab.on("pip-active-changed", () => this.refreshData()); + tab.on("sleep-changed", () => this.refreshData()); + tab.on("nav-history-changed", () => this.emitDataChanged()); + + tab.on("webview-detached", () => this.onWebviewDetached()); + + // Wait for every controller to be ready + setImmediate(() => this.refreshData()); + } + + private emitDataChanged() { + this.tab.emit("data-changed"); + } + + public refreshData() { + let changed = false; + + const tab = this.tab; + + const setProperty = (property: T, value: TabDataController[T]) => { + if (this[property] !== value) { + this[property] = value as this[T]; + changed = true; + } + }; + + /// From other controllers /// + + // Window + const window = tab.window.get(); + setProperty("window", window); + + // Picture in Picture + const pipActive = tab.pip.active; + setProperty("pipActive", pipActive); + + // asleep + const asleep = tab.sleep.asleep; + setProperty("asleep", asleep); + + /// From webview /// + + const webContents = tab.webview.webContents; + if (webContents) { + // title + const title = webContents.getTitle(); + setProperty("title", title); + + // url + const url = webContents.getURL(); + setProperty("url", url); + + // isLoading + const isLoading = webContents.isLoading(); + setProperty("isLoading", isLoading); + + // audible + const audible = webContents.isCurrentlyAudible(); + setProperty("audible", audible); + + // muted + const muted = webContents.isAudioMuted(); + setProperty("muted", muted); + } + + /// Finalise /// + + // Process changes + if (changed) { + this.emitDataChanged(); + } + return changed; + } + + public setupWebviewChangeHooks(webContents: WebContents) { + // audible + webContents.on("audio-state-changed", () => this.refreshData()); + webContents.on("media-started-playing", () => this.refreshData()); + webContents.on("media-paused", () => this.refreshData()); + + // title + webContents.on("page-title-updated", () => this.refreshData()); + + // isLoading + webContents.on("did-finish-load", () => this.refreshData()); + webContents.on("did-start-loading", () => this.refreshData()); + webContents.on("did-stop-loading", () => this.refreshData()); + + // url + webContents.on("did-finish-load", () => this.refreshData()); + webContents.on("did-start-navigation", () => this.refreshData()); + webContents.on("did-redirect-navigation", () => this.refreshData()); + webContents.on("did-navigate-in-page", () => this.refreshData()); + } + + private onWebviewDetached() { + return false; + } + + public get() { + const tab = this.tab; + const navHistory = tab.navigation.navHistory; + const navHistoryIndex = tab.navigation.navHistoryIndex; + + return { + // from other controllers + window: this.window, + pipActive: this.pipActive, + + // from navigation + navHistory: navHistory, + navHistoryIndex: navHistoryIndex, + + // from webview + title: this.title, + url: this.url, + isLoading: this.isLoading, + audible: this.audible, + muted: this.muted + }; + } +} diff --git a/src/main/browser/tabs/objects/tab/controllers/error-page.ts b/src/main/browser/tabs/objects/tab/controllers/error-page.ts new file mode 100644 index 00000000..4d3cc700 --- /dev/null +++ b/src/main/browser/tabs/objects/tab/controllers/error-page.ts @@ -0,0 +1,49 @@ +/* +TabErrorPageController: +- This controller is responsible for loading error pages +*/ + +import { Tab } from "@/browser/tabs/objects/tab"; +import { FLAGS } from "@/modules/flags"; + +export class TabErrorPageController { + private readonly tab: Tab; + + constructor(tab: Tab) { + this.tab = tab; + + tab.on("webview-attached", () => { + const webContents = tab.webview.webContents; + if (!webContents) { + return; + } + + webContents.on("did-fail-load", (event, errorCode, _errorDescription, validatedURL, isMainFrame) => { + event.preventDefault(); + + // Skip aborted operations (user navigation cancellations) + if (isMainFrame && errorCode !== -3) { + this.loadErrorPage(errorCode, validatedURL); + } + }); + }); + } + + public loadErrorPage(errorCode: number, url: string) { + // Errored on error page? Don't show another error page to prevent infinite loop + const parsedURL = URL.parse(url); + if (parsedURL && parsedURL.protocol === "flow:" && parsedURL.hostname === "error") { + return; + } + + // Craft error page URL + const errorPageURL = new URL("flow://error"); + errorPageURL.searchParams.set("errorCode", errorCode.toString()); + errorPageURL.searchParams.set("url", url); + errorPageURL.searchParams.set("initial", "1"); + + // Load error page + const replace = FLAGS.ERROR_PAGE_LOAD_MODE === "replace"; + this.tab.navigation.loadUrl(errorPageURL.toString(), replace); + } +} diff --git a/src/main/browser/tabs/objects/tab/controllers/index.ts b/src/main/browser/tabs/objects/tab/controllers/index.ts new file mode 100644 index 00000000..334265d9 --- /dev/null +++ b/src/main/browser/tabs/objects/tab/controllers/index.ts @@ -0,0 +1,25 @@ +import { TabBoundsController } from "./bounds"; +import { TabPipController } from "./pip"; +import { TabSavingController } from "./saving"; +import { TabVisiblityController } from "./visiblity"; +import { TabWebviewController } from "./webview"; +import { TabWindowController } from "./window"; +import { TabContextMenuController } from "./context-menu"; +import { TabErrorPageController } from "./error-page"; +import { TabNavigationController } from "./navigation"; +import { TabDataController } from "./data"; +import { TabSleepController } from "./sleep"; + +export { + TabBoundsController, + TabPipController, + TabSavingController, + TabVisiblityController, + TabWebviewController, + TabWindowController, + TabContextMenuController, + TabErrorPageController, + TabNavigationController, + TabDataController, + TabSleepController +}; diff --git a/src/main/browser/tabs/objects/tab/controllers/navigation.ts b/src/main/browser/tabs/objects/tab/controllers/navigation.ts new file mode 100644 index 00000000..c3207ac8 --- /dev/null +++ b/src/main/browser/tabs/objects/tab/controllers/navigation.ts @@ -0,0 +1,109 @@ +/* +TabNavigationController: +- This controller is responsible for managing the navigation history of the tab +- This includes restoring the navigation history on tab creation and syncing the navigation history with the webview +*/ + +import { Tab } from "@/browser/tabs/objects/tab"; +import { NavigationEntry, WebContents } from "electron"; + +export const DEFAULT_URL = "flow://new-tab"; + +export function generateNavHistoryWithURL(url: string): NavigationEntry[] { + const entry: NavigationEntry = { + title: "", + url: url + }; + return [entry]; +} + +export class TabNavigationController { + private readonly tab: Tab; + + public navHistory: NavigationEntry[]; + private _navHistoryIndex?: number; + + constructor(tab: Tab) { + const creationDetails = tab.creationDetails; + + this.tab = tab; + + const defaultUrl = creationDetails.defaultURL ?? DEFAULT_URL; + this.navHistory = creationDetails.navHistory ?? generateNavHistoryWithURL(defaultUrl); + this._navHistoryIndex = creationDetails.navHistoryIndex; + } + + public get navHistoryIndex(): number { + return this._navHistoryIndex ?? this.navHistory.length - 1; + } + + public setupWebviewNavigation(webContents: WebContents) { + // Restore the navigation history + webContents.navigationHistory.restore({ + entries: this.navHistory, + index: this.navHistoryIndex + }); + } + + // Not really sync: This replaces the one stored with the webview's latest records + public syncNavHistory() { + const tab = this.tab; + const webContents = tab.webview.webContents; + if (!webContents) { + return false; + } + + const navHistory = webContents.navigationHistory.getAllEntries(); + const activeIndex = webContents.navigationHistory.getActiveIndex(); + + this.navHistory = navHistory; + this._navHistoryIndex = activeIndex; + + tab.emit("nav-history-changed"); + + return true; + } + + public loadUrl(url: string, replace: boolean = false) { + const tab = this.tab; + const webContents = tab.webview.webContents; + if (!webContents) { + const navHistoryIndex = this.navHistoryIndex; + if (replace && navHistoryIndex >= 0) { + // Replace the current entry if replace is true + this.navHistory[navHistoryIndex] = { + title: "", + url: url + }; + } else { + // Otherwise insert a new entry after the current position + this.navHistory.splice(navHistoryIndex + 1, 0, { + title: "", + url: url + }); + // Remove any forward history + if (navHistoryIndex < this.navHistory.length - 2) { + this.navHistory = this.navHistory.slice(0, navHistoryIndex + 2); + } + this._navHistoryIndex = navHistoryIndex + 1; + } + tab.emit("nav-history-changed"); + return true; + } + + // Only run this if replace is true + const activeIndex = replace ? webContents.navigationHistory.getActiveIndex() : undefined; + + webContents.loadURL(url); + + // Remove the record at the old index if replace is true + if (activeIndex !== undefined) { + webContents.navigationHistory.removeEntryAtIndex(activeIndex); + } + + // Might not be needed, but just to be safe + this.syncNavHistory(); + + return true; + } +} diff --git a/src/main/browser/tabs/objects/tab/controllers/pip.ts b/src/main/browser/tabs/objects/tab/controllers/pip.ts new file mode 100644 index 00000000..b5f30fbc --- /dev/null +++ b/src/main/browser/tabs/objects/tab/controllers/pip.ts @@ -0,0 +1,123 @@ +/* +TabPipController: +- This controller is responsible for managing the Picture in Picture mode of the tab +*/ + +import { Tab } from "@/browser/tabs/objects/tab"; + +// This function must be self-contained: it runs in the actual tab's context +const enterPiP = async function () { + const videos = Array.from(document.querySelectorAll("video")).filter( + (video) => !video.paused && !video.ended && video.readyState > 2 + ); + + if (videos.length > 0 && document.pictureInPictureElement !== videos[0]) { + try { + const video = videos[0]; + + await video.requestPictureInPicture(); + + const onLeavePiP = () => { + // little hack to check if they clicked back to tab or closed PiP + // when going back to tab, the video will continue playing + // when closing PiP, the video will pause + setTimeout(() => { + const goBackToTab = !video.paused && !video.ended; + flow.tabs.disablePictureInPicture(goBackToTab); + }, 50); + video.removeEventListener("leavepictureinpicture", onLeavePiP); + }; + + video.addEventListener("leavepictureinpicture", onLeavePiP); + return true; + } catch (e) { + console.error("Failed to enter Picture in Picture mode:", e); + return false; + } + } + return null; +}; + +// This function must be self-contained: it runs in the actual tab's context +const exitPiP = function () { + if (document.pictureInPictureElement) { + document.exitPictureInPicture(); + return true; + } + return false; +}; + +export class TabPipController { + private readonly tab: Tab; + public active: boolean = false; + + constructor(tab: Tab) { + this.tab = tab; + + // Reset the active state when the webview is attached or detached + tab.on("webview-attached", () => { + this.setActive(false); + }); + tab.on("webview-detached", () => { + this.setActive(false); + }); + } + + private setActive(active: boolean) { + if (this.active === active) { + return false; + } + + this.active = active; + this.tab.emit("pip-active-changed", active); + return true; + } + + public async tryEnterPiP() { + const tab = this.tab; + const webContents = tab.webview.webContents; + if (!webContents) { + return false; + } + + const enteredPiPPromise = webContents + .executeJavaScript(`(${enterPiP})()`, true) + .then((res) => { + return res === true; + }) + .catch((err) => { + console.error("PiP error:", err); + return false; + }); + + const enteredPiP = await enteredPiPPromise; + if (enteredPiP) { + this.setActive(true); + } + + return enteredPiP; + } + + public async tryExitPiP() { + const tab = this.tab; + const webContents = tab.webview.webContents; + if (!webContents) { + return false; + } + + const exitedPiPPromise = webContents + .executeJavaScript(`(${exitPiP})()`, true) + .then((res) => res === true) + .catch((err) => { + console.error("PiP error:", err); + return false; + }); + + const exitedPiP = await exitedPiPPromise; + if (exitedPiP) { + this.setActive(false); + } + + return exitedPiP; + } +} diff --git a/src/main/browser/tabs/objects/tab/controllers/saving.ts b/src/main/browser/tabs/objects/tab/controllers/saving.ts new file mode 100644 index 00000000..15dab8e4 --- /dev/null +++ b/src/main/browser/tabs/objects/tab/controllers/saving.ts @@ -0,0 +1,14 @@ +/* +TabSavingController: +- TBD +*/ + +import { Tab } from "@/browser/tabs/objects/tab"; + +export class TabSavingController { + private readonly tab: Tab; + + constructor(tab: Tab) { + this.tab = tab; + } +} diff --git a/src/main/browser/tabs/objects/tab/controllers/sleep.ts b/src/main/browser/tabs/objects/tab/controllers/sleep.ts new file mode 100644 index 00000000..3972eec1 --- /dev/null +++ b/src/main/browser/tabs/objects/tab/controllers/sleep.ts @@ -0,0 +1,55 @@ +/* +TabSleepController: +- This controller handles the sleep state of the tab +- When the tab is asleep, the webview is detached +- When the tab is awake, the webview is attached +*/ + +import { Tab } from "@/browser/tabs/objects/tab"; + +export class TabSleepController { + private readonly tab: Tab; + + public asleep: boolean; + + constructor(tab: Tab) { + const creationDetails = tab.creationDetails; + + this.tab = tab; + + this.asleep = creationDetails.asleep ?? false; + + tab.on("sleep-changed", () => this.updateWebviewSleep()); + setImmediate(() => this.updateWebviewSleep()); + } + + public putToSleep() { + if (this.asleep) { + return false; + } + + this.asleep = true; + this.tab.emit("sleep-changed"); + return true; + } + + public wakeUp() { + if (!this.asleep) { + return false; + } + + this.asleep = false; + this.tab.emit("sleep-changed"); + return true; + } + + private updateWebviewSleep() { + const tab = this.tab; + const webview = tab.webview; + if (this.asleep) { + webview.detach(); + } else { + webview.attach(); + } + } +} diff --git a/src/main/browser/tabs/objects/tab/controllers/visiblity.ts b/src/main/browser/tabs/objects/tab/controllers/visiblity.ts new file mode 100644 index 00000000..4aaec55d --- /dev/null +++ b/src/main/browser/tabs/objects/tab/controllers/visiblity.ts @@ -0,0 +1,46 @@ +/* +TabVisiblityController: +- This controller is responsible for managing the visiblity of the tab +- Methods should be called by the Tab Container. +*/ + +import { Tab } from "@/browser/tabs/objects/tab"; + +export class TabVisiblityController { + private readonly tab: Tab; + + public isVisible: boolean; + + constructor(tab: Tab) { + this.tab = tab; + + this.isVisible = false; + } + + public setVisible(visible: boolean) { + if (this.isVisible === visible) { + return false; + } + + this.isVisible = visible; + this.tab.emit("visiblity-changed", visible); + + this.tab.bounds.updateWebviewBounds(); + + return true; + } + + // Trigged on: + // - Visibility being set (tab.visiblity.setVisible) + // - Webview being attached (tab.webview.attach) + public updateWebviewVisiblity() { + const tab = this.tab; + const webContentsView = tab.webview.webContentsView; + if (!webContentsView) { + return false; + } + + webContentsView.setVisible(this.isVisible); + return true; + } +} diff --git a/src/main/browser/tabs/objects/tab/controllers/webview.ts b/src/main/browser/tabs/objects/tab/controllers/webview.ts new file mode 100644 index 00000000..ec4fef2b --- /dev/null +++ b/src/main/browser/tabs/objects/tab/controllers/webview.ts @@ -0,0 +1,105 @@ +/* +TabWebviewController: +- This controller is responsible for managing the webview of the tab +- It is responsible for creating and destroying the webview +*/ + +import { Tab } from "@/browser/tabs/objects/tab"; +import { Session, WebContents, WebContentsView, WebPreferences } from "electron"; + +interface PatchedWebContentsView extends WebContentsView { + destroy: () => void; +} + +function createWebContentsView( + session: Session, + options: Electron.WebContentsViewConstructorOptions +): PatchedWebContentsView { + const webContents = options.webContents; + const webPreferences: WebPreferences = { + // Merge with any additional preferences + ...(options.webPreferences || {}), + + // Basic preferences + sandbox: true, + webSecurity: true, + session: session, + scrollBounce: true, + safeDialogs: true, + navigateOnDragDrop: true, + transparent: true + + // Provide access to 'flow' globals (replaced by implementation in protocols.ts) + // preload: PATHS.PRELOAD + }; + + const webContentsView = new WebContentsView({ + webPreferences, + // Only add webContents if it is provided + ...(webContents ? { webContents } : {}) + }); + + webContentsView.setVisible(false); + return webContentsView as PatchedWebContentsView; +} + +export class TabWebviewController { + private readonly tab: Tab; + public webContentsView: PatchedWebContentsView | null; + public webContents: WebContents | null; + + constructor(tab: Tab) { + this.tab = tab; + this.webContentsView = null; + this.webContents = null; + } + + public get attached() { + return this.webContentsView !== null; + } + + public attach() { + this.tab.throwIfDestroyed(); + + if (this.webContentsView) { + return false; + } + + const tab = this.tab; + const creationDetails = tab.creationDetails; + + const webContentsView = createWebContentsView(tab.loadedProfile.session, creationDetails.webContentsViewOptions); + this.webContentsView = webContentsView; + this.webContents = webContentsView.webContents; + + this.webContents.on("focus", () => { + tab.emit("focused"); + }); + + tab.navigation.setupWebviewNavigation(this.webContents); + tab.data.setupWebviewChangeHooks(this.webContents); + + tab.emit("webview-attached"); + + tab.window.updateWebviewWindow(); + tab.bounds.updateWebviewBounds(); + tab.visiblity.updateWebviewVisiblity(); + + return true; + } + + public detach() { + if (!this.webContentsView) { + return false; + } + + const tab = this.tab; + + this.webContentsView.destroy(); + this.webContentsView = null; + this.webContents = null; + + tab.emit("webview-detached"); + return true; + } +} diff --git a/src/main/browser/tabs/objects/tab/controllers/window.ts b/src/main/browser/tabs/objects/tab/controllers/window.ts new file mode 100644 index 00000000..faa17fbb --- /dev/null +++ b/src/main/browser/tabs/objects/tab/controllers/window.ts @@ -0,0 +1,70 @@ +/* +TabWindowController: +- This controller is responsible for managing the window of the tab +- Methods should be called by the Tab Container. +*/ + +import { Tab } from "@/browser/tabs/objects/tab"; +import { TabbedBrowserWindow } from "@/browser/window"; + +const TAB_ZINDEX = 2; + +export class TabWindowController { + private readonly tab: Tab; + private window: TabbedBrowserWindow; + + private oldWindow: TabbedBrowserWindow | null = null; + + constructor(tab: Tab) { + this.tab = tab; + + const creationDetails = tab.creationDetails; + this.window = creationDetails.window; + } + + public get() { + return this.window; + } + + public set(window: TabbedBrowserWindow) { + if (this.window === window) { + return false; + } + + this.window = window; + this.tab.emit("window-changed"); + + this.updateWebviewWindow(); + + return true; + } + + // Trigged on: + // - Window being set (tab.window.set) + // - Webview being attached (tab.webview.attach) + public updateWebviewWindow() { + const tab = this.tab; + + const webContentsView = tab.webview.webContentsView; + if (!webContentsView) { + return false; + } + + const window = tab.window.get(); + + // Remove the view from the old window + if (this.oldWindow && this.oldWindow !== window) { + this.oldWindow.viewManager.removeView(webContentsView); + } + + // Add the view to the new window if it exists + if (window) { + window.viewManager.addOrUpdateView(webContentsView, TAB_ZINDEX); + } + + // Update the old window + this.oldWindow = window; + + return true; + } +} diff --git a/src/main/browser/tabs/objects/tab/index.ts b/src/main/browser/tabs/objects/tab/index.ts new file mode 100644 index 00000000..097a929c --- /dev/null +++ b/src/main/browser/tabs/objects/tab/index.ts @@ -0,0 +1,120 @@ +import { Browser } from "@/browser/browser"; +import { LoadedProfile } from "@/browser/profile-manager"; +import { + TabBoundsController, + TabPipController, + TabSavingController, + TabVisiblityController, + TabWebviewController, + TabWindowController, + TabContextMenuController, + TabErrorPageController, + TabNavigationController, + TabDataController, + TabSleepController +} from "@/browser/tabs/objects/tab/controllers"; +import { TabbedBrowserWindow } from "@/browser/window"; +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { generateID } from "@/modules/utils"; +import { NavigationEntry } from "electron"; +import { PageBounds } from "~/flow/types"; + +type TabEvents = { + "window-changed": []; + "webview-attached": []; + "webview-detached": []; + "pip-active-changed": [boolean]; + "bounds-changed": [PageBounds]; + "visiblity-changed": [boolean]; + "sleep-changed": []; + "nav-history-changed": []; + "data-changed": []; + + focused: []; + + destroyed: []; +}; + +export interface TabCreationDetails { + browser: Browser; + + window: TabbedBrowserWindow; + + tabId?: string; + loadedProfile: LoadedProfile; + webContentsViewOptions: Electron.WebContentsViewConstructorOptions; + + navHistory?: NavigationEntry[]; + navHistoryIndex?: number; + defaultURL?: string; + + asleep?: boolean; +} + +export class Tab extends TypedEventEmitter { + public readonly id: string; + public readonly loadedProfile: LoadedProfile; + public readonly creationDetails: TabCreationDetails; + public destroyed: boolean; + + public readonly browser: Browser; + public readonly profileId: string; + + public readonly window: TabWindowController; + + public readonly data: TabDataController; + + public readonly bounds: TabBoundsController; + public readonly visiblity: TabVisiblityController; + + public readonly webview: TabWebviewController; + public readonly pip: TabPipController; + public readonly saving: TabSavingController; + public readonly contextMenu: TabContextMenuController; + public readonly errorPage: TabErrorPageController; + public readonly navigation: TabNavigationController; + public readonly sleep: TabSleepController; + + constructor(details: TabCreationDetails) { + super(); + + this.id = details.tabId ?? generateID(); + this.loadedProfile = details.loadedProfile; + this.creationDetails = details; + this.destroyed = false; + + this.browser = details.browser; + this.profileId = details.loadedProfile.profileId; + + this.window = new TabWindowController(this); + + this.data = new TabDataController(this); + + this.bounds = new TabBoundsController(this); + this.visiblity = new TabVisiblityController(this); + + this.webview = new TabWebviewController(this); + this.pip = new TabPipController(this); + this.saving = new TabSavingController(this); + this.contextMenu = new TabContextMenuController(this); + this.errorPage = new TabErrorPageController(this); + this.navigation = new TabNavigationController(this); + this.sleep = new TabSleepController(this); + } + + public destroy() { + this.throwIfDestroyed(); + + this.destroyed = true; + this.webview.detach(); + this.emit("destroyed"); + + this.destroyEmitter(); + } + + public throwIfDestroyed() { + if (this.destroyed) { + throw new Error("Tab already destroyed"); + } + } +} diff --git a/src/main/browser/tabs/tab-bounds.ts b/src/main/browser/tabs/tab-bounds.ts deleted file mode 100644 index de20a79e..00000000 --- a/src/main/browser/tabs/tab-bounds.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { Tab } from "@/browser/tabs/tab"; -import { Rectangle } from "electron"; -import { performance } from "perf_hooks"; - -const FRAME_RATE = 60; -const MS_PER_FRAME = 1000 / FRAME_RATE; -const SPRING_STIFFNESS = 300; -const SPRING_DAMPING = 30; -const MIN_DISTANCE_THRESHOLD = 0.01; -const MIN_VELOCITY_THRESHOLD = 0.01; - -// Type definitions for clarity -type Dimension = "x" | "y" | "width" | "height"; -const DIMENSIONS: Dimension[] = ["x", "y", "width", "height"]; -type Velocity = Record; - -/** - * Helper function to compare two Rectangle objects for equality. - * Handles null cases. - */ -export function isRectangleEqual(rect1: Rectangle | null, rect2: Rectangle | null): boolean { - // If both are the same instance (including both null), they are equal. - if (rect1 === rect2) { - return true; - } - // If one is null and the other isn't, they are not equal. - if (!rect1 || !rect2) { - return false; - } - // Compare properties if both are non-null. - return rect1.x === rect2.x && rect1.y === rect2.y && rect1.width === rect2.width && rect1.height === rect2.height; -} - -/** - * Rounds the properties of a Rectangle object to the nearest integer. - * Returns null if the input is null. - */ -function roundRectangle(rect: Rectangle | null): Rectangle | null { - if (!rect) { - return null; - } - return { - x: Math.round(rect.x), - y: Math.round(rect.y), - width: Math.round(rect.width), - height: Math.round(rect.height) - }; -} - -export class TabBoundsController { - private readonly tab: Tab; - public targetBounds: Rectangle | null = null; - // Current animated bounds (can have fractional values) - public bounds: Rectangle | null = null; - // The last integer bounds actually applied to the view - private lastAppliedBounds: Rectangle | null = null; - private velocity: Velocity = { x: 0, y: 0, width: 0, height: 0 }; - private lastUpdateTime: number | null = null; - private animationFrameId: NodeJS.Timeout | null = null; - - constructor(tab: Tab) { - this.tab = tab; - } - - /** - * Starts the animation loop if it's not already running. - */ - private startAnimationLoop(): void { - if (this.animationFrameId !== null) { - return; // Already running - } - // Ensure we have a valid starting time - if (this.lastUpdateTime === null) { - this.lastUpdateTime = performance.now(); - } - - const loop = () => { - const now = performance.now(); - // Ensure deltaTime is reasonable, capping to avoid large jumps - const deltaTime = this.lastUpdateTime ? Math.min((now - this.lastUpdateTime) / 1000, 1 / 30) : 1 / FRAME_RATE; // Use FRAME_RATE constant - this.lastUpdateTime = now; - - const settled = this.updateBounds(deltaTime); - this.updateViewBounds(); // Apply potentially changed bounds to the view - - if (settled) { - this.stopAnimationLoop(); - } else { - // Schedule next frame using standard setTimeout - this.animationFrameId = setTimeout(loop, MS_PER_FRAME); - } - }; - // Start the loop using standard setTimeout - this.animationFrameId = setTimeout(loop, MS_PER_FRAME); - } - - /** - * Stops the animation loop if it's running. - */ - private stopAnimationLoop(): void { - if (this.animationFrameId !== null) { - clearTimeout(this.animationFrameId); // Only need clearTimeout - this.animationFrameId = null; - this.lastUpdateTime = null; // Reset time tracking when stopped - } - } - - /** - * Sets the target bounds and starts the animation towards them. - * If bounds are already the target, does nothing. - * If bounds are set for the first time, applies them immediately. - * @param bounds The desired final bounds for the tab's view. - */ - public setBounds(bounds: Rectangle): void { - // Don't restart animation if the target hasn't changed - if (this.targetBounds && isRectangleEqual(this.targetBounds, bounds)) { - return; - } - - this.targetBounds = { ...bounds }; // Copy to avoid external mutation - - if (!this.bounds) { - // If this is the first time bounds are set, apply immediately - this.setBoundsImmediate(bounds); - } else { - // Otherwise, start the animation loop to transition - this.startAnimationLoop(); - } - } - - /** - * Sets the bounds immediately, stopping any existing animation - * and directly applying the new bounds to the view. - * @param bounds The exact bounds to apply immediately. - */ - public setBoundsImmediate(bounds: Rectangle): void { - this.stopAnimationLoop(); // Stop any ongoing animation - - const newBounds = { ...bounds }; // Create a copy - this.targetBounds = newBounds; // Update target to match - this.bounds = newBounds; // Update current animated bounds - this.velocity = { x: 0, y: 0, width: 0, height: 0 }; // Reset velocity - - this.updateViewBounds(); // Apply the change to the view - } - - /** - * Applies the current animated bounds (rounded to integers) to the - * actual BrowserView, but only if they have changed since the last application - * or if the tab is not visible. - */ - private updateViewBounds(): void { - // Don't attempt to set bounds if the tab isn't visible or doesn't have bounds yet - // Also check targetBounds to ensure we have a valid state to eventually reach. - if (!this.tab.visible || !this.bounds || !this.targetBounds) { - // If not visible, we might still want to ensure the final state is applied - // if the animation finished while hidden. - if (!this.tab.visible && this.bounds && this.targetBounds && !isRectangleEqual(this.bounds, this.targetBounds)) { - // If hidden but not at target, snap to target and update lastApplied if needed - this.bounds = { ...this.targetBounds }; - const integerBounds = roundRectangle(this.bounds); - if (!isRectangleEqual(integerBounds, this.lastAppliedBounds)) { - // Even though not visible, update lastAppliedBounds to reflect the snapped state - this.lastAppliedBounds = integerBounds; - } - } - return; - } - - // Calculate the integer bounds intended for the view - const integerBounds = roundRectangle(this.bounds); - - // Only call setBounds on the view if the *rounded* bounds have actually changed - if (!isRectangleEqual(integerBounds, this.lastAppliedBounds)) { - if (integerBounds) { - // Ensure integerBounds is not null before setting - this.tab.view.setBounds(integerBounds); - this.lastAppliedBounds = integerBounds; // Store the bounds that were actually applied - } else { - // If rounding resulted in null (shouldn't happen with valid this.bounds), clear last applied - this.lastAppliedBounds = null; - } - } - } - - /** - * Updates the animated bounds based on spring physics for a given time delta. - * Reduces object allocation by modifying the existing `this.bounds` object. - * @param deltaTime The time elapsed since the last update in seconds. - * @returns `true` if the animation has settled, `false` otherwise. - */ - public updateBounds(deltaTime: number): boolean { - // Stop animation immediately if the tab is no longer visible - if (!this.tab.visible) { - this.stopAnimationLoop(); - // Consider the animation settled if the tab is not visible - return true; - } - - // If target or current bounds are missing, animation cannot proceed - if (!this.targetBounds || !this.bounds) { - this.stopAnimationLoop(); - return true; - } - - let allSettled = true; - - // Iterate over each dimension (x, y, width, height) for physics calculation - for (const dim of DIMENSIONS) { - const targetValue = this.targetBounds[dim]; - const currentValue = this.bounds[dim]; - const currentVelocity = this.velocity[dim]; - - const delta = targetValue - currentValue; - - // Check if this specific dimension is settled - const isDistanceSettled = Math.abs(delta) < MIN_DISTANCE_THRESHOLD; - const isVelocitySettled = Math.abs(currentVelocity) < MIN_VELOCITY_THRESHOLD; - - if (isDistanceSettled && isVelocitySettled) { - // Snap this dimension to the target and zero its velocity - this.bounds[dim] = targetValue; - this.velocity[dim] = 0; - // This dimension is settled, continue checking others - } else { - // If any dimension is not settled, the whole animation is not settled - allSettled = false; - - // Calculate spring forces and update velocity for this dimension - const force = delta * SPRING_STIFFNESS; - const dampingForce = currentVelocity * SPRING_DAMPING; - const acceleration = force - dampingForce; // Mass assumed to be 1 - this.velocity[dim] += acceleration * deltaTime; - - // Update position based on velocity for this dimension - this.bounds[dim] += this.velocity[dim] * deltaTime; - } - } - - // If all dimensions have settled in this frame, ensure exact final state - if (allSettled) { - // This might be slightly redundant if snapping works perfectly, but ensures precision - this.bounds.x = this.targetBounds.x; - this.bounds.y = this.targetBounds.y; - this.bounds.width = this.targetBounds.width; - this.bounds.height = this.targetBounds.height; - this.velocity = { x: 0, y: 0, width: 0, height: 0 }; - } - - return allSettled; // Return true if all dimensions are settled - } - - /** - * Cleans up resources, stopping the animation loop. - * Should be called when the controller is no longer needed. - */ - public destroy(): void { - this.stopAnimationLoop(); - // Optionally clear references if needed, though JS garbage collection handles this - // this.tab = null; // If Tab has circular refs, might help, but likely not needed - this.targetBounds = null; - this.bounds = null; - this.lastAppliedBounds = null; - } -} diff --git a/src/main/browser/tabs/tab-groups/glance.ts b/src/main/browser/tabs/tab-groups/glance.ts deleted file mode 100644 index 3cd905b7..00000000 --- a/src/main/browser/tabs/tab-groups/glance.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { BaseTabGroup } from "@/browser/tabs/tab-groups"; - -export class GlanceTabGroup extends BaseTabGroup { - public frontTabId: number = -1; - public mode: "glance" = "glance" as const; - - constructor(...args: ConstructorParameters) { - super(...args); - - this.on("tab-removed", () => { - if (this.tabIds.length !== 2) { - // A glance tab group must have 2 tabs - this.destroy(); - } - }); - } - - public setFrontTab(tabId: number) { - this.frontTabId = tabId; - } -} diff --git a/src/main/browser/tabs/tab-groups/index.ts b/src/main/browser/tabs/tab-groups/index.ts deleted file mode 100644 index de968971..00000000 --- a/src/main/browser/tabs/tab-groups/index.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { Tab } from "@/browser/tabs/tab"; -import { GlanceTabGroup } from "@/browser/tabs/tab-groups/glance"; -import { SplitTabGroup } from "@/browser/tabs/tab-groups/split"; -import { TabManager } from "@/browser/tabs/tab-manager"; -import { TypedEventEmitter } from "@/modules/typed-event-emitter"; -import { Browser } from "@/browser/browser"; - -// Interfaces and Types -export type TabGroupEvents = { - "tab-added": [number]; - "tab-removed": [number]; - "space-changed": []; - "window-changed": []; - destroy: []; -}; - -function getTabFromId(tabManager: TabManager, id: number): Tab | null { - const tab = tabManager.getTabById(id); - if (!tab) { - return null; - } - return tab; -} - -// Tab Group Class -export type TabGroup = GlanceTabGroup | SplitTabGroup; - -export class BaseTabGroup extends TypedEventEmitter { - public readonly id: number; - public isDestroyed: boolean = false; - - public windowId: number; - public profileId: string; - public spaceId: string; - - protected browser: Browser; - protected tabManager: TabManager; - protected tabIds: number[] = []; - - constructor(browser: Browser, tabManager: TabManager, id: number, initialTabs: [Tab, ...Tab[]]) { - super(); - - this.browser = browser; - this.tabManager = tabManager; - this.id = id; - - const initialTab = initialTabs[0]; - - this.windowId = initialTab.getWindow().id; - this.profileId = initialTab.profileId; - this.spaceId = initialTab.spaceId; - - for (const tab of initialTabs) { - this.addTab(tab.id); - } - - // Change space of all tabs in the group - this.on("space-changed", () => { - for (const tab of this.tabs) { - if (tab.spaceId !== this.spaceId) { - tab.setSpace(this.spaceId); - } - } - }); - } - - public setSpace(spaceId: string) { - this.errorIfDestroyed(); - - this.spaceId = spaceId; - this.emit("space-changed"); - - for (const tab of this.tabs) { - this.syncTab(tab); - } - } - - public setWindow(windowId: number) { - this.errorIfDestroyed(); - - this.windowId = windowId; - this.emit("window-changed"); - - for (const tab of this.tabs) { - this.syncTab(tab); - } - } - - public syncTab(tab: Tab) { - this.errorIfDestroyed(); - - tab.setSpace(this.spaceId); - - const window = this.browser.getWindowById(this.windowId); - if (window) { - tab.setWindow(window); - } - } - - protected errorIfDestroyed() { - if (this.isDestroyed) { - throw new Error("TabGroup already destroyed!"); - } - } - - public hasTab(tabId: number): boolean { - this.errorIfDestroyed(); - - return this.tabIds.includes(tabId); - } - - public addTab(tabId: number) { - this.errorIfDestroyed(); - - if (this.hasTab(tabId)) { - return false; - } - - const tab = getTabFromId(this.tabManager, tabId); - if (tab === null) { - return false; - } - - tab.groupId = this.id; - - this.tabIds.push(tabId); - this.emit("tab-added", tabId); - - // Event Listeners - const onTabDestroyed = () => { - this.removeTab(tabId); - }; - const onTabRemoved = (tabId: number) => { - if (tabId === tab.id) { - disconnectAll(); - } - }; - const onTabSpaceChanged = () => { - const newSpaceId = tab.spaceId; - if (newSpaceId !== this.spaceId) { - this.setSpace(newSpaceId); - } - }; - const onTabWindowChanged = () => { - const newWindowId = tab.getWindow()?.id; - if (newWindowId !== this.windowId) { - this.setWindow(newWindowId); - } - }; - const onActiveTabChanged = (windowId: number, spaceId: string) => { - if (windowId === this.windowId && spaceId === this.spaceId) { - const activeTab = this.tabManager.getActiveTab(windowId, spaceId); - if (activeTab === tab) { - // Set this tab group as active instead of just the tab - // @ts-expect-error: the base class won't be used directly anyways - this.tabManager.setActiveTab(this); - } - } - }; - const onDestroy = () => { - disconnectAll(); - }; - - const disconnectAll = () => { - disconnect1(); - disconnect2(); - disconnect3(); - disconnect4(); - disconnect5(); - disconnect6(); - }; - const disconnect1 = tab.connect("destroyed", onTabDestroyed); - const disconnect2 = this.connect("tab-removed", onTabRemoved); - const disconnect3 = tab.connect("space-changed", onTabSpaceChanged); - const disconnect4 = tab.connect("window-changed", onTabWindowChanged); - const disconnect5 = this.tabManager.connect("active-tab-changed", onActiveTabChanged); - const disconnect6 = this.connect("destroy", onDestroy); - - // Sync tab space and window - this.syncTab(tab); - return true; - } - - public removeTab(tabId: number) { - this.errorIfDestroyed(); - - if (!this.hasTab(tabId)) { - return false; - } - - // Clear the groupId on the tab being removed - const tab = getTabFromId(this.tabManager, tabId); - if (tab && tab.groupId === this.id) { - tab.groupId = null; - } - - this.tabIds = this.tabIds.filter((id) => id !== tabId); - this.emit("tab-removed", tabId); - return true; - } - - public get tabs(): Tab[] { - this.errorIfDestroyed(); - - const tabManager = this.tabManager; - return this.tabIds - .map((id) => { - return getTabFromId(tabManager, id); - }) - .filter((tab) => tab !== null); - } - - public get position(): number { - this.errorIfDestroyed(); - return this.tabs[0].position; - } - - public destroy() { - this.errorIfDestroyed(); - - // Clear groupId for all tabs in the group before destroying - for (const tab of this.tabs) { - if (tab.groupId === this.id) { - tab.groupId = null; - } - } - - this.isDestroyed = true; - this.emit("destroy"); - this.destroyEmitter(); - } -} diff --git a/src/main/browser/tabs/tab-groups/split.ts b/src/main/browser/tabs/tab-groups/split.ts deleted file mode 100644 index ca9b3596..00000000 --- a/src/main/browser/tabs/tab-groups/split.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BaseTabGroup } from "@/browser/tabs/tab-groups"; - -export class SplitTabGroup extends BaseTabGroup { - public mode: "split" = "split" as const; - - // TODO: Implement split tab group layout -} diff --git a/src/main/browser/tabs/tab-manager.ts b/src/main/browser/tabs/tab-manager.ts deleted file mode 100644 index ca86aaf4..00000000 --- a/src/main/browser/tabs/tab-manager.ts +++ /dev/null @@ -1,767 +0,0 @@ -import { Browser } from "@/browser/browser"; -import { Tab, TabCreationOptions } from "@/browser/tabs/tab"; -import { BaseTabGroup, TabGroup } from "@/browser/tabs/tab-groups"; -import { GlanceTabGroup } from "@/browser/tabs/tab-groups/glance"; -import { SplitTabGroup } from "@/browser/tabs/tab-groups/split"; -import { windowTabsChanged } from "@/ipc/browser/tabs"; -import { setWindowSpace } from "@/ipc/session/spaces"; -import { TypedEventEmitter } from "@/modules/typed-event-emitter"; -import { shouldArchiveTab, shouldSleepTab } from "@/saving/tabs"; -import { getLastUsedSpace, getLastUsedSpaceFromProfile } from "@/sessions/spaces"; -import { WebContents } from "electron"; -import { TabGroupMode } from "~/types/tabs"; - -export const NEW_TAB_URL = "flow://new-tab"; -const ARCHIVE_CHECK_INTERVAL_MS = 10 * 1000; - -type TabManagerEvents = { - "tab-created": [Tab]; - "tab-changed": [Tab]; - "tab-removed": [Tab]; - "current-space-changed": [number, string]; - "active-tab-changed": [number, string]; - destroyed: []; -}; - -type WindowSpaceReference = `${number}-${string}`; - -// Tab Class -export class TabManager extends TypedEventEmitter { - // Public properties - public tabs: Map; - public isDestroyed: boolean = false; - - // Window Space Maps - public windowActiveSpaceMap: Map = new Map(); - public spaceActiveTabMap: Map = new Map(); - public spaceFocusedTabMap: Map = new Map(); - public spaceActivationHistory: Map = new Map(); - - // Tab Groups - public tabGroups: Map; - private tabGroupCounter: number = 0; - - // Private properties - private readonly browser: Browser; - - /** - * Creates a new tab manager instance - */ - constructor(browser: Browser) { - super(); - - this.tabs = new Map(); - this.tabGroups = new Map(); - this.browser = browser; - - // Setup event listeners - this.on("active-tab-changed", (windowId, spaceId) => { - this.processActiveTabChange(windowId, spaceId); - windowTabsChanged(windowId); - }); - - this.on("current-space-changed", (windowId, spaceId) => { - this.processActiveTabChange(windowId, spaceId); - windowTabsChanged(windowId); - }); - - this.on("tab-created", (tab) => { - windowTabsChanged(tab.getWindow().id); - }); - - this.on("tab-changed", (tab) => { - windowTabsChanged(tab.getWindow().id); - }); - - this.on("tab-removed", (tab) => { - windowTabsChanged(tab.getWindow().id); - }); - - // Archive tabs over their lifetime - const interval = setInterval(() => { - for (const tab of this.tabs.values()) { - if (!tab.visible && shouldArchiveTab(tab.lastActiveAt)) { - tab.destroy(); - } - if (!tab.visible && !tab.asleep && shouldSleepTab(tab.lastActiveAt)) { - tab.putToSleep(); - } - } - }, ARCHIVE_CHECK_INTERVAL_MS); - - this.on("destroyed", () => { - clearInterval(interval); - }); - } - - /** - * Create a new tab - */ - public async createTab( - windowId?: number, - profileId?: string, - spaceId?: string, - webContentsViewOptions?: Electron.WebContentsViewConstructorOptions, - tabCreationOptions: Partial = {} - ) { - if (this.isDestroyed) { - throw new Error("TabManager has been destroyed"); - } - - if (!windowId) { - const focusedWindow = this.browser.getFocusedWindow(); - if (focusedWindow) { - windowId = focusedWindow.id; - } else { - const windows = this.browser.getWindows(); - if (windows.length > 0) { - windowId = windows[0].id; - } else { - throw new Error("Could not determine window ID for new tab"); - } - } - } - - // Get profile ID and space ID if not provided - if (!profileId) { - const lastUsedSpace = await getLastUsedSpace(); - if (lastUsedSpace) { - profileId = lastUsedSpace.profileId; - spaceId = lastUsedSpace.id; - } else { - throw new Error("Could not determine profile ID for new tab"); - } - } else if (!spaceId) { - try { - const lastUsedSpace = await getLastUsedSpaceFromProfile(profileId); - if (lastUsedSpace) { - spaceId = lastUsedSpace.id; - } else { - throw new Error("Could not determine space ID for new tab"); - } - } catch (error) { - console.error("Failed to get last used space:", error); - throw new Error("Could not determine space ID for new tab"); - } - } - - // Load profile if not already loaded - const browser = this.browser; - await browser.loadProfile(profileId); - - // Create tab - return this.internalCreateTab(windowId, profileId, spaceId, webContentsViewOptions, tabCreationOptions); - } - - /** - * Internal method to create a tab - * Does not load profile or anything else! - */ - public internalCreateTab( - windowId: number, - profileId: string, - spaceId: string, - webContentsViewOptions?: Electron.WebContentsViewConstructorOptions, - tabCreationOptions: Partial = {} - ) { - if (this.isDestroyed) { - throw new Error("TabManager has been destroyed"); - } - - // Get window - const window = this.browser.getWindowById(windowId); - if (!window) { - // Should never happen - throw new Error("Window not found"); - } - - // Get loaded profile - const browser = this.browser; - const profile = browser.getLoadedProfile(profileId); - if (!profile) { - throw new Error("Profile not found"); - } - - const profileSession = profile.session; - - // Create tab - const tab = new Tab( - { - browser: this.browser, - tabManager: this, - profileId: profileId, - spaceId: spaceId, - session: profileSession, - loadedProfile: profile - }, - { - window: window, - webContentsViewOptions, - ...tabCreationOptions - } - ); - - this.tabs.set(tab.id, tab); - - // Setup event listeners - tab.on("updated", () => { - this.emit("tab-changed", tab); - }); - tab.on("space-changed", () => { - this.emit("tab-changed", tab); - }); - tab.on("window-changed", () => { - this.emit("tab-changed", tab); - }); - tab.on("focused", () => { - if (this.isTabActive(tab)) { - this.setFocusedTab(tab); - } - }); - - tab.on("destroyed", () => { - this.removeTab(tab); - }); - - // Return tab - this.emit("tab-created", tab); - return tab; - } - - /** - * Disable Picture in Picture mode for a tab - */ - public disablePictureInPicture(tabId: number, goBackToTab: boolean) { - const tab = this.getTabById(tabId); - if (tab && tab.isPictureInPicture) { - tab.updateStateProperty("isPictureInPicture", false); - - if (goBackToTab) { - // Set the space for the window - const win = tab.getWindow(); - setWindowSpace(win, tab.spaceId); - - // Focus window - win.window.focus(); - - // Set active tab - this.setActiveTab(tab); - } - - return true; - } - return false; - } - - /** - * Process an active tab change - */ - private processActiveTabChange(windowId: number, spaceId: string) { - const tabsInWindow = this.getTabsInWindow(windowId); - for (const tab of tabsInWindow) { - if (tab.spaceId === spaceId) { - const isActive = this.isTabActive(tab); - if (isActive && !tab.visible) { - tab.show(); - } else if (!isActive && tab.visible) { - tab.hide(); - } else { - // Update layout even if visibility hasn't changed, e.g., for split view resizing - tab.updateLayout(); - } - } else { - // Not in active space - tab.hide(); - } - } - } - - public isTabActive(tab: Tab) { - const windowSpaceReference = `${tab.getWindow().id}-${tab.spaceId}` as WindowSpaceReference; - const activeTabOrGroup = this.spaceActiveTabMap.get(windowSpaceReference); - - if (!activeTabOrGroup) { - return false; - } - - if (activeTabOrGroup instanceof Tab) { - // Active item is a Tab - return tab.id === activeTabOrGroup.id; - } else { - // Active item is a Tab Group - return activeTabOrGroup.hasTab(tab.id); - } - } - - /** - * Set the active tab for a space - */ - public setActiveTab(tabOrGroup: Tab | TabGroup) { - let windowId: number; - let spaceId: string; - let tabToFocus: Tab | undefined; - let idToStore: number; - - if (tabOrGroup instanceof Tab) { - windowId = tabOrGroup.getWindow().id; - spaceId = tabOrGroup.spaceId; - tabToFocus = tabOrGroup; - idToStore = tabOrGroup.id; - } else { - windowId = tabOrGroup.windowId; - spaceId = tabOrGroup.spaceId; - tabToFocus = tabOrGroup.tabs.length > 0 ? tabOrGroup.tabs[0] : undefined; - idToStore = tabOrGroup.id; - } - - const windowSpaceReference = `${windowId}-${spaceId}` as WindowSpaceReference; - this.spaceActiveTabMap.set(windowSpaceReference, tabOrGroup); - - // Update activation history - const history = this.spaceActivationHistory.get(windowSpaceReference) ?? []; - const existingIndex = history.indexOf(idToStore); - if (existingIndex > -1) { - history.splice(existingIndex, 1); - } - history.push(idToStore); - this.spaceActivationHistory.set(windowSpaceReference, history); - - if (tabToFocus) { - this.setFocusedTab(tabToFocus); - } else { - // If group has no tabs, remove focus - this.removeFocusedTab(windowId, spaceId); - } - - this.emit("active-tab-changed", windowId, spaceId); - } - - /** - * Get the active tab or group for a space - */ - public getActiveTab(windowId: number, spaceId: string): Tab | TabGroup | undefined { - const windowSpaceReference = `${windowId}-${spaceId}` as WindowSpaceReference; - return this.spaceActiveTabMap.get(windowSpaceReference); - } - - /** - * Remove the active tab for a space and set a new one if possible - */ - public removeActiveTab(windowId: number, spaceId: string) { - const windowSpaceReference = `${windowId}-${spaceId}` as WindowSpaceReference; - this.spaceActiveTabMap.delete(windowSpaceReference); - this.removeFocusedTab(windowId, spaceId); - - // Try finding next active from history - const history = this.spaceActivationHistory.get(windowSpaceReference); - if (history) { - // Iterate backwards through history (most recent first) - for (let i = history.length - 1; i >= 0; i--) { - const itemId = history[i]; - // Check if it's an existing Tab - const tab = this.getTabById(itemId); - if (tab && !tab.isDestroyed && tab.getWindow().id === windowId && tab.spaceId === spaceId) { - // Ensure tab hasn't been moved out of the space since last activation check - this.setActiveTab(tab); - return; // Found replacement - } - // Check if it's an existing TabGroup - const group = this.getTabGroupById(itemId); - // Ensure group is not empty and belongs to the correct window/space - if ( - group && - !group.isDestroyed && - group.tabs.length > 0 && - group.windowId === windowId && - group.spaceId === spaceId - ) { - this.setActiveTab(group); - return; // Found replacement - } - // If item not found or invalid, it will be removed from history eventually - // by removeTab/internalDestroyTabGroup, or we can clean it here (optional) - } - } - - // Find the next available tab or group in the same window/space to activate - const tabsInSpace = this.getTabsInWindowSpace(windowId, spaceId); - const groupsInSpace = this.getTabGroupsInWindow(windowId).filter( - (group) => group.spaceId === spaceId && !group.isDestroyed && group.tabs.length > 0 // Ensure group valid - ); - - // Prioritize setting a non-empty group as active if available - if (groupsInSpace.length > 0) { - // Activate the first valid group found - this.setActiveTab(groupsInSpace[0]); - } else if (tabsInSpace.length > 0) { - // If no group found or no groups exist, activate the first individual tab - // Note: tabsInSpace already filters by window/space and existence in this.tabs - this.setActiveTab(tabsInSpace[0]); - } else { - // No valid tabs or groups left, emit change without setting a new active tab - this.emit("active-tab-changed", windowId, spaceId); - } - } - - /** - * Set the focused tab for a space - */ - private setFocusedTab(tab: Tab) { - const windowSpaceReference = `${tab.getWindow().id}-${tab.spaceId}` as WindowSpaceReference; - this.spaceFocusedTabMap.set(windowSpaceReference, tab); - tab.webContents.focus(); // Ensure the tab's web contents is focused - } - - /** - * Remove the focused tab for a space - */ - private removeFocusedTab(windowId: number, spaceId: string) { - const windowSpaceReference = `${windowId}-${spaceId}` as WindowSpaceReference; - this.spaceFocusedTabMap.delete(windowSpaceReference); - } - - /** - * Get the focused tab for a space - */ - public getFocusedTab(windowId: number, spaceId: string): Tab | undefined { - const windowSpaceReference = `${windowId}-${spaceId}` as WindowSpaceReference; - return this.spaceFocusedTabMap.get(windowSpaceReference); - } - - /** - * Remove a tab from the tab manager - */ - public removeTab(tab: Tab) { - const wasActive = this.isTabActive(tab); - const windowId = tab.getWindow().id; - const spaceId = tab.spaceId; - const tabId = tab.id; - - if (!this.tabs.has(tabId)) return; - - this.tabs.delete(tabId); - this.removeFromActivationHistory(tabId); - this.emit("tab-removed", tab); - - if (wasActive) { - // If the removed tab was part of the active element (tab or group) - const activeElement = this.getActiveTab(windowId, spaceId); - if (activeElement instanceof BaseTabGroup) { - // If it was in an active group, the group handles its internal state. - // We might still need to update focus if the removed tab was focused. - if (this.getFocusedTab(windowId, spaceId)?.id === tab.id) { - // If the removed tab was focused, focus the next tab in the group or remove focus - const nextFocus = activeElement.tabs.find((t: Tab) => t.id !== tab.id); - if (nextFocus) { - this.setFocusedTab(nextFocus); - } else { - this.removeFocusedTab(windowId, spaceId); - // If group becomes empty, remove it? Or handled by group itself? Assuming handled by group. - } - } - // Check if group is now empty - group should emit destroy if so - if (activeElement && activeElement.tabs.length === 0) { - this.destroyTabGroup(activeElement.id); // Explicitly destroy if empty - } - } else { - // If the active element was the tab itself, remove it and find the next active. - this.removeActiveTab(windowId, spaceId); - } - } else { - // Tab was not active, just ensure it's removed from any group it might be in - const group = this.getTabGroupByTabId(tab.id); - if (group) { - group.removeTab(tab.id); - if (group.tabs.length === 0) { - this.destroyTabGroup(group.id); // Explicitly destroy if empty - } - } - } - } - - /** - * Get a tab by id - */ - public getTabById(tabId: number): Tab | undefined { - return this.tabs.get(tabId); - } - - /** - * Get a tab by webContents - */ - public getTabByWebContents(webContents: WebContents): Tab | undefined { - for (const tab of this.tabs.values()) { - if (tab.webContents === webContents) { - return tab; - } - } - return undefined; - } - - /** - * Get all tabs in a profile - */ - public getTabsInProfile(profileId: string): Tab[] { - const result: Tab[] = []; - for (const tab of this.tabs.values()) { - if (tab.profileId === profileId) { - result.push(tab); - } - } - return result; - } - - /** - * Get all tabs in a space - */ - public getTabsInSpace(spaceId: string): Tab[] { - const result: Tab[] = []; - for (const tab of this.tabs.values()) { - if (tab.spaceId === spaceId) { - result.push(tab); - } - } - return result; - } - - /** - * Get all tabs in a window space - */ - public getTabsInWindowSpace(windowId: number, spaceId: string): Tab[] { - const result: Tab[] = []; - for (const tab of this.tabs.values()) { - if (tab.getWindow().id === windowId && tab.spaceId === spaceId) { - result.push(tab); - } - } - return result; - } - - /** - * Get all tabs in a window - */ - public getTabsInWindow(windowId: number): Tab[] { - const result: Tab[] = []; - for (const tab of this.tabs.values()) { - if (tab.getWindow().id === windowId) { - result.push(tab); - } - } - return result; - } - - /** - * Get all tab groups in a window - */ - public getTabGroupsInWindow(windowId: number): TabGroup[] { - const result: TabGroup[] = []; - for (const group of this.tabGroups.values()) { - if (group.windowId === windowId) { - result.push(group); - } - } - return result; - } - - /** - * Set the current space for a window - */ - public setCurrentWindowSpace(windowId: number, spaceId: string) { - this.windowActiveSpaceMap.set(windowId, spaceId); - this.emit("current-space-changed", windowId, spaceId); - } - - /** - * Handle page bounds changed - */ - public handlePageBoundsChanged(windowId: number) { - const tabsInWindow = this.getTabsInWindow(windowId); - for (const tab of tabsInWindow) { - tab.updateLayout(); - } - } - - /** - * Get a tab group by tab id - */ - public getTabGroupByTabId(tabId: number): TabGroup | undefined { - const tab = this.getTabById(tabId); - if (tab && tab.groupId !== null) { - return this.tabGroups.get(tab.groupId); - } - return undefined; - } - - /** - * Create a new tab group - */ - public createTabGroup(mode: TabGroupMode, initialTabIds: [number, ...number[]]): TabGroup { - const id = this.tabGroupCounter++; - - const initialTabs: Tab[] = []; - for (const tabId of initialTabIds) { - const tab = this.getTabById(tabId); - if (tab) { - // Remove tab from any existing group it might be in - const existingGroup = this.getTabGroupByTabId(tabId); - existingGroup?.removeTab(tabId); - initialTabs.push(tab); - } - } - - if (initialTabs.length === 0) { - throw new Error("Cannot create a tab group with no valid initial tabs."); - } - - let tabGroup: TabGroup; - switch (mode) { - case "glance": - tabGroup = new GlanceTabGroup(this.browser, this, id, initialTabs as [Tab, ...Tab[]]); - break; - case "split": - tabGroup = new SplitTabGroup(this.browser, this, id, initialTabs as [Tab, ...Tab[]]); - break; - default: - throw new Error(`Invalid tab group mode: ${mode}`); - } - - tabGroup.on("destroy", () => { - // Ensure cleanup happens even if destroyTabGroup isn't called externally - if (this.tabGroups.has(id)) { - this.internalDestroyTabGroup(tabGroup); - } - }); - - this.tabGroups.set(id, tabGroup); - - // If any of the initial tabs were active, make the new group active. - // Use the space/window of the first tab for the group. - const firstTab = initialTabs[0]; - if (this.getActiveTab(firstTab.getWindow().id, firstTab.spaceId)?.id === firstTab.id) { - this.setActiveTab(tabGroup); - } else { - // Ensure layout is updated for grouped tabs - for (const t of tabGroup.tabs) { - t.updateLayout(); - } - } - - return tabGroup; - } - - /** - * Get the smallest position of all tabs - */ - public getSmallestPosition(): number { - let smallestPosition = 999; - for (const tab of this.tabs.values()) { - if (tab.position < smallestPosition) { - smallestPosition = tab.position; - } - } - return smallestPosition; - } - - /** - * Internal method to cleanup destroyed tab group state - */ - private internalDestroyTabGroup(tabGroup: TabGroup) { - const wasActive = this.getActiveTab(tabGroup.windowId, tabGroup.spaceId) === tabGroup; - const groupId = tabGroup.id; - - if (!this.tabGroups.has(groupId)) return; - - this.tabGroups.delete(groupId); - this.removeFromActivationHistory(groupId); - - if (wasActive) { - this.removeActiveTab(tabGroup.windowId, tabGroup.spaceId); - } - // Group should handle destroying its own tabs or returning them to normal state. - } - - /** - * Destroy a tab group - */ - public destroyTabGroup(tabGroupId: number) { - const tabGroup = this.getTabGroupById(tabGroupId); - if (!tabGroup) { - console.warn(`Attempted to destroy non-existent tab group ID: ${tabGroupId}`); - return; // Don't throw, just warn and exit - } - - // Ensure group's destroy logic runs first - if (!tabGroup.isDestroyed) { - tabGroup.destroy(); // This should trigger the "destroy" event handled in createTabGroup - } - - // Cleanup TabManager state (might be redundant if event handler runs, but safe) - this.internalDestroyTabGroup(tabGroup); - } - - /** - * Get a tab group by id - */ - public getTabGroupById(tabGroupId: number): TabGroup | undefined { - return this.tabGroups.get(tabGroupId); - } - - /** - * Destroy the tab manager - */ - public destroy() { - if (this.isDestroyed) { - // Avoid throwing error if already destroyed, just return. - console.warn("TabManager destroy called multiple times."); - return; - } - - this.isDestroyed = true; - this.emit("destroyed"); - this.destroyEmitter(); // Destroys internal event emitter listeners - - // Destroy groups first to handle tab transitions cleanly - // Create a copy of IDs as destroying modifies the map - const groupIds = Array.from(this.tabGroups.keys()); - for (const groupId of groupIds) { - this.destroyTabGroup(groupId); - } - - // Destroy remaining individual tabs - // Create a copy of values as destroying modifies the map - const tabsToDestroy = Array.from(this.tabs.values()); - for (const tab of tabsToDestroy) { - // Check if tab still exists (might have been destroyed by group) - if (this.tabs.has(tab.id) && !tab.isDestroyed) { - tab.destroy(); // Tab destroy should trigger removeTab via 'destroyed' event - } - } - - // Clear maps - this.tabs.clear(); - this.tabGroups.clear(); - this.windowActiveSpaceMap.clear(); - this.spaceActiveTabMap.clear(); - this.spaceFocusedTabMap.clear(); - this.spaceActivationHistory.clear(); - } - - /** - * Helper method to remove an item ID from all activation history lists - */ - private removeFromActivationHistory(itemId: number) { - for (const [key, history] of this.spaceActivationHistory.entries()) { - const initialLength = history.length; - // Filter out the itemId - const newHistory = history.filter((id) => id !== itemId); - if (newHistory.length < initialLength) { - if (newHistory.length === 0) { - this.spaceActivationHistory.delete(key); // Remove entry if history is empty - } else { - this.spaceActivationHistory.set(key, newHistory); // Update with filtered history - } - } - } - // Method doesn't need to return anything, just modifies the map - } -} diff --git a/src/main/browser/tabs/tab.ts b/src/main/browser/tabs/tab.ts deleted file mode 100644 index 6a0d6565..00000000 --- a/src/main/browser/tabs/tab.ts +++ /dev/null @@ -1,996 +0,0 @@ -import { Browser } from "@/browser/browser"; -import { isRectangleEqual, TabBoundsController } from "@/browser/tabs/tab-bounds"; -import { TabGroupMode } from "~/types/tabs"; -import { GlanceTabGroup } from "@/browser/tabs/tab-groups/glance"; -import { TabManager } from "@/browser/tabs/tab-manager"; -import { TabbedBrowserWindow } from "@/browser/window"; -import { cacheFavicon } from "@/modules/favicons"; -import { FLAGS } from "@/modules/flags"; -// import { PATHS } from "@/modules/paths"; -import { TypedEventEmitter } from "@/modules/typed-event-emitter"; -import { NavigationEntry, Rectangle, Session, WebContents, WebContentsView, WebPreferences } from "electron"; -import { createTabContextMenu } from "@/browser/tabs/tab-context-menu"; -import { generateID } from "@/modules/utils"; -import { persistTabToStorage, removeTabFromStorage } from "@/saving/tabs"; -import { LoadedProfile } from "@/browser/profile-manager"; -import { setWindowSpace } from "@/ipc/session/spaces"; - -// Configuration -const GLANCE_FRONT_ZINDEX = 3; -const TAB_ZINDEX = 2; -const GLANCE_BACK_ZINDEX = 0; - -export const SLEEP_MODE_URL = "about:blank?sleep=true"; - -// Interfaces and Types -interface PatchedWebContentsView extends WebContentsView { - destroy: () => void; -} - -type TabStateProperty = - | "visible" - | "isDestroyed" - | "faviconURL" - | "fullScreen" - | "isPictureInPicture" - | "asleep" - | "lastActiveAt" - | "position"; -type TabContentProperty = "title" | "url" | "isLoading" | "audible" | "muted" | "navHistory" | "navHistoryIndex"; - -type TabPublicProperty = TabStateProperty | TabContentProperty; - -type TabEvents = { - "space-changed": []; - "window-changed": []; - focused: []; - // Updated property keys - updated: [TabPublicProperty[]]; - destroyed: []; -}; - -interface TabCreationDetails { - // Controllers - browser: Browser; - tabManager: TabManager; - - // Properties - profileId: string; - spaceId: string; - - // Session - session: Session; - - // Loaded Profile - loadedProfile: LoadedProfile; -} - -export interface TabCreationOptions { - uniqueId?: string; - window: TabbedBrowserWindow; - webContentsViewOptions?: Electron.WebContentsViewConstructorOptions; - - // Options - asleep?: boolean; - position?: number; - - // Old States to be restored - title?: string; - faviconURL?: string; - navHistory?: NavigationEntry[]; - navHistoryIndex?: number; -} - -function createWebContentsView( - session: Session, - options: Electron.WebContentsViewConstructorOptions -): PatchedWebContentsView { - const webContents = options.webContents; - const webPreferences: WebPreferences = { - // Merge with any additional preferences - ...(options.webPreferences || {}), - - // Basic preferences - sandbox: true, - webSecurity: true, - session: session, - scrollBounce: true, - safeDialogs: true, - navigateOnDragDrop: true, - transparent: true - - // Provide access to 'flow' globals (replaced by implementation in protocols.ts) - // preload: PATHS.PRELOAD - }; - - const webContentsView = new WebContentsView({ - webPreferences, - // Only add webContents if it is provided - ...(webContents ? { webContents } : {}) - }); - - webContentsView.setVisible(false); - return webContentsView as PatchedWebContentsView; -} - -// Tab Class -export class Tab extends TypedEventEmitter { - // Public properties - public readonly id: number; - public groupId: number | null = null; - public readonly profileId: string; - public spaceId: string; - - public readonly uniqueId: string; - - // State properties (Recorded) - public visible: boolean = false; - public isDestroyed: boolean = false; - public faviconURL: string | null = null; - public fullScreen: boolean = false; - public isPictureInPicture: boolean = false; - public asleep: boolean = false; - public createdAt: number; - public lastActiveAt: number; - public position: number; - - // Content properties (From WebContents) - public title: string = "New Tab"; - public url: string = ""; - public isLoading: boolean = false; - public audible: boolean = false; - public muted: boolean = false; - public navHistory: NavigationEntry[] = []; - public navHistoryIndex: number = 0; - - // View & content objects - public readonly view: PatchedWebContentsView; - public readonly webContents: WebContents; - private lastTabGroupMode: TabGroupMode | null = null; - - // Private properties - private readonly session: Session; - private readonly browser: Browser; - public readonly loadedProfile: LoadedProfile; - private window: TabbedBrowserWindow; - private readonly tabManager: TabManager; - private readonly bounds: TabBoundsController; - - /** - * Creates a new tab instance - */ - constructor(details: TabCreationDetails, options: TabCreationOptions) { - super(); - - // Create Details - const { - // Controllers - browser, - tabManager, - - // Properties - profileId, - spaceId, - - // Session - session - } = details; - - this.browser = browser; - this.tabManager = tabManager; - - this.profileId = profileId; - this.spaceId = spaceId; - - this.session = session; - - this.bounds = new TabBoundsController(this); - - // Create Options - const { - window, - webContentsViewOptions = {}, - - // Options - asleep = false, - position, - - // Old States to be restored - title, - faviconURL, - navHistory = [], - navHistoryIndex, - uniqueId - } = options; - - if (!uniqueId) { - this.uniqueId = generateID(); - } else { - this.uniqueId = uniqueId; - } - - // Set position - if (position !== undefined) { - this.position = position; - } else { - // Get the smallest position - const smallestPosition = this.tabManager.getSmallestPosition(); - this.position = smallestPosition - 1; - } - - // Create WebContentsView - const webContentsView = createWebContentsView(session, webContentsViewOptions); - const webContents = webContentsView.webContents; - - this.id = webContents.id; - this.view = webContentsView; - this.webContents = webContents; - - // Restore navigation history - const restoreNavHistory = navHistory.length > 0; - if (restoreNavHistory) { - setImmediate(() => { - const restoringEntries = [...navHistory]; - let restoringIndex = navHistoryIndex; - - // Put to sleep if requested - if (asleep) { - this.putToSleep(true); - } - - // Add sleep mode entry if asleep to avoid going to the URL - if (asleep) { - const newIndex = navHistoryIndex !== undefined ? navHistoryIndex + 1 : restoringEntries.length - 1; - - restoringEntries.splice(newIndex, 0, { - url: SLEEP_MODE_URL, - title: "" - }); - - restoringIndex = newIndex; - } - - this.webContents.navigationHistory.restore({ - entries: restoringEntries, - index: restoringIndex - }); - }); - } - - // Restore states - setImmediate(() => { - if (title) { - this.title = title; - } - - if (faviconURL) { - this.updateStateProperty("faviconURL", faviconURL); - } - }); - - // Set creation time - this.createdAt = Math.floor(Date.now() / 1000); - this.lastActiveAt = this.createdAt; - - // Setup window - this.setWindow(window); - this.window = window; - - // Put to sleep if requested - if (!restoreNavHistory) { - setImmediate(() => { - if (asleep) { - this.putToSleep(); - } - }); - } - - // Set window open handler - this.webContents.setWindowOpenHandler((details) => { - switch (details.disposition) { - case "foreground-tab": - case "background-tab": - case "new-window": { - return { - action: "allow", - outlivesOpener: true, - createWindow: (constructorOptions) => { - return this.createNewTab(details.url, details.disposition, constructorOptions, details); - } - }; - } - default: - return { action: "allow" }; - } - }); - - // Setup event listeners - this.setupEventListeners(); - - // Load new tab URL - this.loadedProfile = details.loadedProfile; - if (!restoreNavHistory) { - this.loadURL(this.loadedProfile.newTabUrl); - } - - // Setup extensions - const extensions = this.loadedProfile.extensions; - extensions.addTab(this.webContents, this.window.window); - - this.on("updated", () => { - extensions.tabUpdated(this.webContents); - }); - } - - /** - * Saves the tab to storage - */ - public async saveTabToStorage() { - if (this.isDestroyed) return; - return persistTabToStorage(this); - } - - private setFullScreen(isFullScreen: boolean) { - const updated = this.updateStateProperty("fullScreen", isFullScreen); - if (!updated) return false; - - const tabbedWindow = this.window; - const window = tabbedWindow.window; - if (window.isDestroyed()) return false; - - if (isFullScreen) { - if (!window.fullScreen) { - window.setFullScreen(true); - } - } else { - if (window.fullScreen) { - window.setFullScreen(false); - } - - setTimeout(() => { - this.webContents.executeJavaScript(`if (document.fullscreenElement) { document.exitFullscreen(); }`, true); - }, 100); - } - - this.updateLayout(); - - return true; - } - - private setupEventListeners() { - const { webContents, window: tabbedWindow } = this; - - const window = tabbedWindow.window; - - // Set zoom level limits when webContents is ready - webContents.on("did-finish-load", () => { - webContents.setVisualZoomLevelLimits(1, 5); - }); - - // Handle fullscreen events - webContents.on("enter-html-full-screen", () => { - this.setFullScreen(true); - }); - - webContents.on("leave-html-full-screen", () => { - if (window.fullScreen) { - // Then it will fire "leave-full-screen", which we can use to exit fullscreen for the tab. - // Tried other methods, didn't work as well. - window.setFullScreen(false); - } - }); - - const disconnectLeaveFullScreen = tabbedWindow.connect("leave-full-screen", () => { - this.setFullScreen(false); - }); - this.on("destroyed", () => { - if (tabbedWindow.isEmitterDestroyed()) return; - disconnectLeaveFullScreen(); - }); - - // Used by the tab manager to determine which tab is focused - webContents.on("focus", () => { - this.emit("focused"); - }); - - // Handle favicon updates - webContents.on("page-favicon-updated", (_event, favicons) => { - const faviconURL = favicons[0]; - const url = this.webContents.getURL(); - if (faviconURL && url) { - cacheFavicon(url, faviconURL, this.session); - } - if (faviconURL && faviconURL !== this.faviconURL) { - this.updateStateProperty("faviconURL", faviconURL); - } - }); - - // Handle page load errors - webContents.on("did-fail-load", (event, errorCode, _errorDescription, validatedURL, isMainFrame) => { - event.preventDefault(); - - // Skip aborted operations (user navigation cancellations) - if (isMainFrame && errorCode !== -3) { - this.loadErrorPage(errorCode, validatedURL); - } - }); - - // Handle devtools open url - webContents.on("devtools-open-url", (_event, url) => { - this.tabManager.createTab(this.window.id, this.profileId, undefined).then((tab) => { - tab.loadURL(url); - this.tabManager.setActiveTab(tab); - }); - }); - - // Handle content state changes - const updateEvents = [ - "audio-state-changed", // audible - "page-title-updated", // title - "did-finish-load", // url & isLoading - "did-start-loading", // isLoading - "did-stop-loading", // isLoading - "media-started-playing", // audible - "media-paused", // audible - "did-start-navigation", // url - "did-redirect-navigation", // url - "did-navigate-in-page" // url - ] as const; - - for (const eventName of updateEvents) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - webContents.on(eventName as any, () => { - this.updateTabState(); - }); - } - - // Enable transparent background for whitelisted protocols - const WHITELISTED_PROTOCOLS = ["flow-internal:", "flow:"]; - const COLOR_TRANSPARENT = "#00000000"; - const COLOR_BACKGROUND = "#ffffffff"; - this.on("updated", (properties) => { - if (properties.includes("url") && this.url) { - const url = URL.parse(this.url); - - if (url) { - if (WHITELISTED_PROTOCOLS.includes(url.protocol)) { - this.view.setBackgroundColor(COLOR_TRANSPARENT); - } else { - this.view.setBackgroundColor(COLOR_BACKGROUND); - } - } else { - // Bad URL - this.view.setBackgroundColor(COLOR_BACKGROUND); - } - } - }); - - // Handle context menu - createTabContextMenu(this.browser, this, this.profileId, this.window, this.spaceId); - } - - public createNewTab( - url: string, - disposition: "new-window" | "foreground-tab" | "background-tab" | "default" | "other", - constructorOptions?: Electron.WebContentsViewConstructorOptions, - details?: Electron.HandlerDetails - ) { - let windowId = this.window.id; - - const isNewWindow = disposition === "new-window"; - const isForegroundTab = disposition === "foreground-tab"; - const isBackgroundTab = disposition === "background-tab"; - - // Parse features from details - const parsedFeatures: Record = {}; - if (details?.features) { - const features = details.features.split(","); - for (const feature of features) { - const [key, value] = feature.trim().split("="); - if (key && value) { - parsedFeatures[key] = isNaN(+value) ? value : +value; - } - } - } - - if (isNewWindow) { - const newWindow = this.browser.createWindowInternal("popup", { - window: { - ...(parsedFeatures.width ? { width: +parsedFeatures.width } : {}), - ...(parsedFeatures.height ? { height: +parsedFeatures.height } : {}), - ...(parsedFeatures.top ? { top: +parsedFeatures.top } : {}), - ...(parsedFeatures.left ? { left: +parsedFeatures.left } : {}) - } - }); - windowId = newWindow.id; - - // Set space if the window already loaded - setWindowSpace(newWindow, this.spaceId); - } - - const newTab = this.tabManager.internalCreateTab(windowId, this.profileId, this.spaceId, constructorOptions); - newTab.loadURL(url); - - let glanced = false; - - // Glance if possible - if (isForegroundTab && FLAGS.GLANCE_ENABLED) { - const currentTabGroup = this.tabManager.getTabGroupByTabId(this.id); - if (!currentTabGroup) { - glanced = true; - - const group = this.tabManager.createTabGroup("glance", [newTab.id, this.id]) as GlanceTabGroup; - group.setFrontTab(newTab.id); - - this.tabManager.setActiveTab(group); - } - } - - if ((isForegroundTab && !glanced) || isBackgroundTab || isNewWindow) { - this.tabManager.setActiveTab(newTab); - } - - return newTab.webContents; - } - - /** - * Updates the tab state property - */ - public updateStateProperty(property: T, newValue: this[T]) { - if (this.isDestroyed) return false; - - const currentValue = this[property]; - if (currentValue === newValue) return false; - - this[property] = newValue; - this.emit("updated", [property]); - this.saveTabToStorage(); - return true; - } - - /** - * Updates the tab content state - */ - public updateTabState() { - if (this.isDestroyed) return false; - - // If the tab is asleep, the data from the webContents is not reliable. - if (this.asleep) return false; - - const { webContents } = this; - - const changedKeys: TabContentProperty[] = []; - - const newTitle = webContents.getTitle(); - if (newTitle !== this.title) { - this.title = newTitle; - changedKeys.push("title"); - } - - const newUrl = webContents.getURL(); - if (newUrl !== this.url) { - this.url = newUrl; - changedKeys.push("url"); - } - - const newIsLoading = webContents.isLoading(); - if (newIsLoading !== this.isLoading) { - this.isLoading = newIsLoading; - changedKeys.push("isLoading"); - } - - // Note: webContents.isCurrentlyAudible() might be more accurate than isAudioMuted() sometimes - const newAudible = webContents.isCurrentlyAudible(); - if (newAudible !== this.audible) { - this.audible = newAudible; - changedKeys.push("audible"); - } - - const newMuted = webContents.isAudioMuted(); - if (newMuted !== this.muted) { - this.muted = newMuted; - changedKeys.push("muted"); - } - - const newNavHistory = webContents.navigationHistory.getAllEntries(); - const oldNavHistoryJSON = JSON.stringify(this.navHistory); - const newNavHistoryJSON = JSON.stringify(newNavHistory); - if (oldNavHistoryJSON !== newNavHistoryJSON) { - this.navHistory = newNavHistory; - changedKeys.push("navHistory"); - } - - const newNavHistoryIndex = webContents.navigationHistory.getActiveIndex(); - if (newNavHistoryIndex !== this.navHistoryIndex) { - this.navHistoryIndex = newNavHistoryIndex; - changedKeys.push("navHistoryIndex"); - } - - if (changedKeys.length > 0) { - this.emit("updated", changedKeys); - this.saveTabToStorage(); - return true; - } - return false; - } - - /** - * Puts the tab to sleep - */ - public putToSleep(alreadyLoadedURL: boolean = false) { - if (this.asleep) return; - - this.updateStateProperty("asleep", true); - - if (!alreadyLoadedURL) { - // Save current state (To be safe) - this.updateTabState(); - - // Load about:blank to save resources - this.loadURL(SLEEP_MODE_URL); - } - } - - /** - * Wakes up the tab - */ - public wakeUp() { - if (!this.asleep) return; - - // Load the URL to wake up the tab - const navigationHistory = this.webContents.navigationHistory; - - const activeIndex = navigationHistory.getActiveIndex(); - const currentEntry = navigationHistory.getEntryAtIndex(activeIndex); - if (currentEntry && currentEntry.url === SLEEP_MODE_URL && navigationHistory.canGoBack()) { - navigationHistory.goBack(); - setTimeout(() => { - navigationHistory.removeEntryAtIndex(activeIndex); - this.updateTabState(); - }, 100); - } - - this.updateStateProperty("asleep", false); - } - - /** - * Removes the view from the window - */ - private removeViewFromWindow() { - const oldWindow = this.window; - if (oldWindow) { - oldWindow.viewManager.removeView(this.view); - return true; - } - return false; - } - - /** - * Sets the window for the tab - */ - public setWindow(window: TabbedBrowserWindow, index: number = TAB_ZINDEX) { - const windowChanged = this.window !== window; - if (windowChanged) { - // Remove view from old window - this.removeViewFromWindow(); - } - - // Add view to new window - if (window) { - this.window = window; - window.viewManager.addOrUpdateView(this.view, index); - } - - if (windowChanged) { - this.emit("window-changed"); - } - } - - /** - * Gets the window for the tab - */ - public getWindow() { - return this.window; - } - - /** - * Sets the space for the tab - */ - public setSpace(spaceId: string) { - if (this.spaceId === spaceId) { - return; - } - - this.spaceId = spaceId; - this.emit("space-changed"); - } - - /** - * Loads a URL in the tab - */ - public loadURL(url: string, replace?: boolean) { - if (replace) { - // Replace mode is not very reliable, don't know if this works :) - const sanitizedUrl = JSON.stringify(url); - this.webContents.executeJavaScript(`window.location.replace(${sanitizedUrl})`); - } else { - this.webContents.loadURL(url); - } - } - - /** - * Loads an error page in the tab - */ - public loadErrorPage(errorCode: number, url: string) { - // Errored on error page? Don't show another error page to prevent infinite loop - const parsedURL = URL.parse(url); - if (parsedURL && parsedURL.protocol === "flow:" && parsedURL.hostname === "error") { - return; - } - - // Craft error page URL - const errorPageURL = new URL("flow://error"); - errorPageURL.searchParams.set("errorCode", errorCode.toString()); - errorPageURL.searchParams.set("url", url); - errorPageURL.searchParams.set("initial", "1"); - - // Load error page - const replace = FLAGS.ERROR_PAGE_LOAD_MODE === "replace"; - this.loadURL(errorPageURL.toString(), replace); - } - - /** - * Calculates the bounds for a tab in glance mode. - */ - private _calculateGlanceBounds(pageBounds: Rectangle, isFront: boolean): Rectangle { - const widthPercentage = isFront ? 0.85 : 0.95; - const heightPercentage = isFront ? 1 : 0.975; - - const newWidth = Math.floor(pageBounds.width * widthPercentage); - const newHeight = Math.floor(pageBounds.height * heightPercentage); - - // Calculate new x and y to maintain center position - const xOffset = Math.floor((pageBounds.width - newWidth) / 2); - const yOffset = Math.floor((pageBounds.height - newHeight) / 2); - - return { - x: pageBounds.x + xOffset, - y: pageBounds.y + yOffset, - width: newWidth, - height: newHeight - }; - } - - /** - * Updates the layout of the tab - */ - public updateLayout() { - const { visible, window, tabManager } = this; - - // Ensure visibility is updated first - const wasVisible = this.view.getVisible(); - if (wasVisible !== visible) { - this.view.setVisible(visible); - - // Enter / Exit Picture in Picture mode - if (visible === true) { - // This function must be self-contained: it runs in the actual tab's context - const exitPiP = function () { - if (document.pictureInPictureElement) { - document.exitPictureInPicture(); - return true; - } - return false; - }; - - const exitedPiPPromise = this.webContents - .executeJavaScript(`(${exitPiP})()`, true) - .then((res) => res === true) - .catch((err) => { - console.error("PiP error:", err); - return false; - }); - - exitedPiPPromise.then((result) => { - if (result) { - this.updateStateProperty("isPictureInPicture", false); - } - }); - } else { - // This function must be self-contained: it runs in the actual tab's context - const enterPiP = async function () { - const videos = Array.from(document.querySelectorAll("video")).filter( - (video) => !video.paused && !video.ended && video.readyState > 2 - ); - - if (videos.length > 0 && document.pictureInPictureElement !== videos[0]) { - try { - const video = videos[0]; - - await video.requestPictureInPicture(); - - const onLeavePiP = () => { - // little hack to check if they clicked back to tab or closed PiP - // when going back to tab, the video will continue playing - // when closing PiP, the video will pause - setTimeout(() => { - const goBackToTab = !video.paused && !video.ended; - flow.tabs.disablePictureInPicture(goBackToTab); - }, 50); - video.removeEventListener("leavepictureinpicture", onLeavePiP); - }; - - video.addEventListener("leavepictureinpicture", onLeavePiP); - return true; - } catch (e) { - console.error("Failed to enter Picture in Picture mode:", e); - return false; - } - } - return null; - }; - - const enteredPiPPromise = this.webContents - .executeJavaScript(`(${enterPiP})()`, true) - .then((res) => { - return res === true; - }) - .catch((err) => { - console.error("PiP error:", err); - return false; - }); - - enteredPiPPromise.then((result) => { - if (result) { - this.updateStateProperty("isPictureInPicture", true); - } - }); - } - } - - // Update last active at if the tab was just hidden or is showing - const justHidden = wasVisible && !visible; - const justShown = !wasVisible && visible; - if (justHidden || visible) { - this.updateStateProperty("lastActiveAt", Math.floor(Date.now() / 1000)); - } - - if (!visible) return; - - // Update extensions - const extensions = this.loadedProfile.extensions; - if (justShown) { - extensions.selectTab(this.webContents); - } - - // Automatically wake tab up if it is asleep - this.wakeUp(); - - // Get base bounds and current group state - const pageBounds = window.getPageBounds(); - if (this.fullScreen) { - this.view.setBorderRadius(0); - } else { - this.view.setBorderRadius(8); - } - - const tabGroup = tabManager.getTabGroupByTabId(this.id); - - const lastTabGroupMode = this.lastTabGroupMode; - let newBounds: Rectangle | null = null; - let newTabGroupMode: TabGroupMode | null = null; - - let zIndex = TAB_ZINDEX; - - if (!tabGroup) { - newTabGroupMode = "normal"; - newBounds = pageBounds; - } else if (tabGroup.mode === "glance") { - newTabGroupMode = "glance"; - const isFront = tabGroup.frontTabId === this.id; - - const glanceBounds = this._calculateGlanceBounds(pageBounds, isFront); - newBounds = glanceBounds; - - if (isFront) { - zIndex = GLANCE_FRONT_ZINDEX; - } else { - zIndex = GLANCE_BACK_ZINDEX; - } - } else if (tabGroup.mode === "split") { - newTabGroupMode = "split"; - /* TODO: Implement split tab group layout - const splitConfig = tabGroup.getTabSplitConfig(this.id); // Hypothetical method - - if (splitConfig) { - const { x: xPercentage, y: yPercentage, width: widthPercentage, height: heightPercentage } = splitConfig; - - const xOffset = Math.floor(pageBounds.width * xPercentage); - const yOffset = Math.floor(pageBounds.height * yPercentage); - const newWidth = Math.floor(pageBounds.width * widthPercentage); - const newHeight = Math.floor(pageBounds.height * heightPercentage); - - const splitBounds = { - x: pageBounds.x + xOffset, - y: pageBounds.y + yOffset, - width: newWidth, - height: newHeight - }; - - newBounds = splitBounds; - } - */ - } - - // Update Z-index (via setWindow) - this.setWindow(this.window, zIndex); - - // Update last known mode if changed - if (newTabGroupMode !== lastTabGroupMode) { - this.lastTabGroupMode = newTabGroupMode; - } - - // Apply the calculated bounds - if (newBounds) { - // Use immediate update if mode hasn't changed AND bounds controller is idle - const useImmediateUpdate = - newTabGroupMode === lastTabGroupMode && isRectangleEqual(this.bounds.bounds, this.bounds.targetBounds); - - if (useImmediateUpdate) { - this.bounds.setBoundsImmediate(newBounds); - } else { - this.bounds.setBounds(newBounds); - } - } - } - - /** - * Shows the tab - */ - public show() { - const updated = this.updateStateProperty("visible", true); - // Already visible - if (!updated) return; - this.updateLayout(); - } - - /** - * Hides the tab - */ - public hide() { - const updated = this.updateStateProperty("visible", false); - // Already hidden - if (!updated) return; - this.updateLayout(); - } - - /** - * Destroys the tab and cleans up resources - */ - public destroy() { - if (this.isDestroyed) return; - - this.isDestroyed = true; - this.emit("destroyed"); - - this.bounds.destroy(); - - this.removeViewFromWindow(); - - if (!this.webContents.isDestroyed()) { - this.webContents.close(); - } - - if (this.fullScreen && !this.window.window.isDestroyed()) { - this.window.window.setFullScreen(false); - } - - removeTabFromStorage(this); - - // Should be automatically removed when the webContents is destroyed - // const extensions = this.loadedProfile.extensions; - // extensions.removeTab(this.webContents); - - this.destroyEmitter(); - } -}