From 51248e7c8f4cfe37ebed828c2a3f1d275fc618c1 Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Sat, 24 May 2025 22:16:46 +0100 Subject: [PATCH 01/25] initial --- .../browser/tabs/{ => _old}/tab-bounds.ts | 0 .../tabs/{ => _old}/tab-context-menu.ts | 0 .../tabs/{ => _old}/tab-groups/glance.ts | 0 .../tabs/{ => _old}/tab-groups/index.ts | 0 .../tabs/{ => _old}/tab-groups/split.ts | 0 src/main/browser/tabs/_old/tab-manager.ts | 767 +++++++++++++++++ src/main/browser/tabs/{ => _old}/tab.ts | 0 src/main/browser/tabs/index.ts | 1 + src/main/browser/tabs/tab-manager.ts | 768 +----------------- .../browser/tabs/tab/controllers/bounds.ts | 9 + .../tabs/tab/controllers/context-menu.ts | 303 +++++++ src/main/browser/tabs/tab/controllers/data.ts | 9 + .../tabs/tab/controllers/error-page.ts | 44 + .../browser/tabs/tab/controllers/index.ts | 27 + .../tabs/tab/controllers/navigation.ts | 100 +++ src/main/browser/tabs/tab/controllers/pip.ts | 102 +++ .../browser/tabs/tab/controllers/saving.ts | 9 + .../browser/tabs/tab/controllers/space.ts | 24 + .../browser/tabs/tab/controllers/state.ts | 15 + .../browser/tabs/tab/controllers/visiblity.ts | 9 + .../browser/tabs/tab/controllers/webview.ts | 82 ++ .../browser/tabs/tab/controllers/window.ts | 25 + src/main/browser/tabs/tab/index.ts | 89 ++ 23 files changed, 1616 insertions(+), 767 deletions(-) rename src/main/browser/tabs/{ => _old}/tab-bounds.ts (100%) rename src/main/browser/tabs/{ => _old}/tab-context-menu.ts (100%) rename src/main/browser/tabs/{ => _old}/tab-groups/glance.ts (100%) rename src/main/browser/tabs/{ => _old}/tab-groups/index.ts (100%) rename src/main/browser/tabs/{ => _old}/tab-groups/split.ts (100%) create mode 100644 src/main/browser/tabs/_old/tab-manager.ts rename src/main/browser/tabs/{ => _old}/tab.ts (100%) create mode 100644 src/main/browser/tabs/index.ts create mode 100644 src/main/browser/tabs/tab/controllers/bounds.ts create mode 100644 src/main/browser/tabs/tab/controllers/context-menu.ts create mode 100644 src/main/browser/tabs/tab/controllers/data.ts create mode 100644 src/main/browser/tabs/tab/controllers/error-page.ts create mode 100644 src/main/browser/tabs/tab/controllers/index.ts create mode 100644 src/main/browser/tabs/tab/controllers/navigation.ts create mode 100644 src/main/browser/tabs/tab/controllers/pip.ts create mode 100644 src/main/browser/tabs/tab/controllers/saving.ts create mode 100644 src/main/browser/tabs/tab/controllers/space.ts create mode 100644 src/main/browser/tabs/tab/controllers/state.ts create mode 100644 src/main/browser/tabs/tab/controllers/visiblity.ts create mode 100644 src/main/browser/tabs/tab/controllers/webview.ts create mode 100644 src/main/browser/tabs/tab/controllers/window.ts create mode 100644 src/main/browser/tabs/tab/index.ts diff --git a/src/main/browser/tabs/tab-bounds.ts b/src/main/browser/tabs/_old/tab-bounds.ts similarity index 100% rename from src/main/browser/tabs/tab-bounds.ts rename to src/main/browser/tabs/_old/tab-bounds.ts diff --git a/src/main/browser/tabs/tab-context-menu.ts b/src/main/browser/tabs/_old/tab-context-menu.ts similarity index 100% rename from src/main/browser/tabs/tab-context-menu.ts rename to src/main/browser/tabs/_old/tab-context-menu.ts diff --git a/src/main/browser/tabs/tab-groups/glance.ts b/src/main/browser/tabs/_old/tab-groups/glance.ts similarity index 100% rename from src/main/browser/tabs/tab-groups/glance.ts rename to src/main/browser/tabs/_old/tab-groups/glance.ts diff --git a/src/main/browser/tabs/tab-groups/index.ts b/src/main/browser/tabs/_old/tab-groups/index.ts similarity index 100% rename from src/main/browser/tabs/tab-groups/index.ts rename to src/main/browser/tabs/_old/tab-groups/index.ts diff --git a/src/main/browser/tabs/tab-groups/split.ts b/src/main/browser/tabs/_old/tab-groups/split.ts similarity index 100% rename from src/main/browser/tabs/tab-groups/split.ts rename to src/main/browser/tabs/_old/tab-groups/split.ts diff --git a/src/main/browser/tabs/_old/tab-manager.ts b/src/main/browser/tabs/_old/tab-manager.ts new file mode 100644 index 00000000..ca86aaf4 --- /dev/null +++ b/src/main/browser/tabs/_old/tab-manager.ts @@ -0,0 +1,767 @@ +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/_old/tab.ts similarity index 100% rename from src/main/browser/tabs/tab.ts rename to src/main/browser/tabs/_old/tab.ts diff --git a/src/main/browser/tabs/index.ts b/src/main/browser/tabs/index.ts new file mode 100644 index 00000000..d8a1edf0 --- /dev/null +++ b/src/main/browser/tabs/index.ts @@ -0,0 +1 @@ +export class TabsOrchestrator {} diff --git a/src/main/browser/tabs/tab-manager.ts b/src/main/browser/tabs/tab-manager.ts index ca86aaf4..9509df54 100644 --- a/src/main/browser/tabs/tab-manager.ts +++ b/src/main/browser/tabs/tab-manager.ts @@ -1,767 +1 @@ -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 - } -} +export class TabManager {} diff --git a/src/main/browser/tabs/tab/controllers/bounds.ts b/src/main/browser/tabs/tab/controllers/bounds.ts new file mode 100644 index 00000000..d7827da1 --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/bounds.ts @@ -0,0 +1,9 @@ +import { Tab } from "@/browser/tabs/tab"; + +export class TabBoundsController { + private readonly tab: Tab; + + constructor(tab: Tab) { + this.tab = tab; + } +} diff --git a/src/main/browser/tabs/tab/controllers/context-menu.ts b/src/main/browser/tabs/tab/controllers/context-menu.ts new file mode 100644 index 00000000..bd2e74ab --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/context-menu.ts @@ -0,0 +1,303 @@ +import { Tab } from "@/browser/tabs/tab"; +import { Browser } from "@/browser/browser"; +import { TabbedBrowserWindow } from "@/browser/window"; +import contextMenu from "electron-context-menu"; + +// Define types for navigation history +interface NavigationHistory { + canGoBack: () => boolean; + canGoForward: () => boolean; + goBack: () => void; + goForward: () => void; +} + +// Define interface for menu actions +type MenuItemFunction = (options: Record) => Electron.MenuItemConstructorOptions; +type InspectFunction = () => Electron.MenuItemConstructorOptions; + +interface MenuActions { + lookUpSelection: MenuItemFunction; + copyLink: MenuItemFunction; + cut: MenuItemFunction; + copy: MenuItemFunction; + paste: MenuItemFunction; + selectAll: MenuItemFunction; + inspect: InspectFunction; + copyImage: MenuItemFunction; + copyImageAddress: MenuItemFunction; + separator: InspectFunction; + [key: string]: MenuItemFunction | InspectFunction; +} + +function createTabContextMenu( + browser: Browser, + tab: Tab, + profileId: string, + tabbedWindow: TabbedBrowserWindow, + spaceId: string +) { + const webContents = tab.webview.webContents; + if (!webContents) { + return false; + } + + contextMenu({ + window: webContents, + menu(defaultActions, parameters, _browserWindow, dictionarySuggestions): Electron.MenuItemConstructorOptions[] { + const navigationHistory = webContents.navigationHistory as NavigationHistory; + const canGoBack = navigationHistory.canGoBack(); + const canGoForward = navigationHistory.canGoForward(); + const lookUpSelection = defaultActions.lookUpSelection({}); + const searchEngine = "Google"; + + // 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); + }; + + // Create all menu sections + const openLinkItems = createOpenLinkItems(parameters, createNewTab, browser); + const linkItems = createLinkItems(defaultActions as MenuActions); + const navigationItems = createNavigationItems(navigationHistory, webContents, canGoBack, canGoForward); + const extensionItems = createExtensionItems(tab, parameters); + const textHistoryItems = createTextHistoryItems(webContents); + const textEditItems = createTextEditItems(defaultActions as MenuActions, webContents); + const selectionItems = createSelectionItems( + defaultActions as MenuActions, + parameters, + createNewTab, + searchEngine + ); + const devItems = createDevItems(defaultActions as MenuActions); + const imageItems = createImageItems(parameters, createNewTab, defaultActions as MenuActions); + + // Assemble sections in correct order + const sections: Electron.MenuItemConstructorOptions[][] = []; + const hasDictionarySuggestions = dictionarySuggestions.some((suggestion) => suggestion.visible); + if (hasDictionarySuggestions) { + sections.push(dictionarySuggestions); + } + + let noSpecialActions = false; + const hasLink = !!parameters.linkURL; + const hasLookUpSelection = lookUpSelection.visible; + + if (hasLink) { + sections.push(openLinkItems); + sections.push(linkItems); + } else if (hasLookUpSelection && parameters.selectionText.trim()) { + sections.push([lookUpSelection]); + } else if (parameters.hasImageContents) { + sections.push(imageItems); + } else { + noSpecialActions = true; + sections.push(navigationItems); + } + + if (parameters.selectionText.trim() && !parameters.isEditable) { + sections.push(selectionItems); + } + + if (parameters.isEditable) { + sections.push(textHistoryItems); + sections.push(textEditItems); + } + + sections.push(extensionItems); + sections.push([ + { + label: "View Page Source", + click: () => { + createNewTab(`view-source:${webContents.getURL()}`); + }, + visible: noSpecialActions + }, + ...devItems + ]); + + // Combine all sections with separators + return combineSections(sections, defaultActions as MenuActions); + } + }); + + return true; +} + +function createOpenLinkItems( + parameters: Electron.ContextMenuParams, + createNewTab: (url: string, window?: TabbedBrowserWindow) => Promise, + browser: Browser +): Electron.MenuItemConstructorOptions[] { + return [ + { + label: "Open Link in New Tab", + click: () => { + createNewTab(parameters.linkURL); + } + }, + { + label: "Open Link in New Window", + click: async () => { + const newWindow = await browser.createWindow("normal"); + createNewTab(parameters.linkURL, newWindow); + } + } + ]; +} + +function createLinkItems(defaultActions: MenuActions): Electron.MenuItemConstructorOptions[] { + const copyLinkItem = defaultActions.copyLink({}); + copyLinkItem.visible = true; + return [copyLinkItem]; +} + +function createNavigationItems( + navigationHistory: NavigationHistory, + webContents: Electron.WebContents, + canGoBack: boolean, + canGoForward: boolean +): Electron.MenuItemConstructorOptions[] { + return [ + { + label: "Back", + click: () => { + navigationHistory.goBack(); + }, + enabled: canGoBack + }, + { + label: "Forward", + click: () => { + navigationHistory.goForward(); + }, + enabled: canGoForward + }, + { + label: "Reload", + click: () => { + webContents.reload(); + }, + enabled: true + } + ]; +} + +function createExtensionItems(tab: Tab, parameters: Electron.ContextMenuParams): Electron.MenuItemConstructorOptions[] { + const extensions = tab.loadedProfile.extensions; + // @ts-expect-error: ts error, but still works + const items: Electron.MenuItemConstructorOptions[] = extensions.getContextMenuItems(tab.webContents, parameters); + return items; +} + +function createTextHistoryItems(webContents: Electron.WebContents): Electron.MenuItemConstructorOptions[] { + return [ + { + label: "Undo", + click: () => { + webContents.undo(); + }, + enabled: true + }, + { + label: "Redo", + click: () => { + webContents.redo(); + }, + enabled: true + } + ]; +} + +function createTextEditItems( + defaultActions: MenuActions, + webContents: Electron.WebContents +): Electron.MenuItemConstructorOptions[] { + return [ + defaultActions.cut({}), + defaultActions.copy({}), + defaultActions.paste({}), + { + label: "Paste and Match Style", + click: () => { + webContents.pasteAndMatchStyle(); + }, + enabled: true + }, + defaultActions.selectAll({}) + ]; +} + +function createSelectionItems( + defaultActions: MenuActions, + parameters: Electron.ContextMenuParams, + createNewTab: (url: string) => Promise, + searchEngine: string +): Electron.MenuItemConstructorOptions[] { + return [ + defaultActions.copy({}), + { + label: `Search ${searchEngine} for "${parameters.selectionText}"`, + click: () => { + const searchURL = new URL("https://www.google.com/search"); + searchURL.searchParams.set("q", parameters.selectionText); + createNewTab(searchURL.toString()); + } + } + ]; +} + +function createDevItems(defaultActions: MenuActions): Electron.MenuItemConstructorOptions[] { + return [defaultActions.inspect()]; +} + +function createImageItems( + parameters: Electron.ContextMenuParams, + createNewTab: (url: string) => Promise, + defaultActions: MenuActions +): Electron.MenuItemConstructorOptions[] { + return [ + { + label: "Open Image in New Tab", + click: () => { + createNewTab(parameters.srcURL); + } + }, + defaultActions.copyImage({}), + defaultActions.copyImageAddress({}) + ]; +} + +function combineSections( + sections: Electron.MenuItemConstructorOptions[][], + defaultActions: MenuActions +): Electron.MenuItemConstructorOptions[] { + const combinedSections: Electron.MenuItemConstructorOptions[] = []; + + sections.forEach((section, index) => { + // Only add non-empty sections + if (section.length > 0) { + combinedSections.push(...section); + + // Add separator if this isn't the last section + if (index < sections.length - 1) { + combinedSections.push(defaultActions.separator()); + } + } + }); + + return combinedSections; +} + +export class TabContextMenuController { + private readonly tab: Tab; + + constructor(tab: Tab) { + this.tab = tab; + + tab.on("webview-attached", () => { + createTabContextMenu(); + }); + } +} diff --git a/src/main/browser/tabs/tab/controllers/data.ts b/src/main/browser/tabs/tab/controllers/data.ts new file mode 100644 index 00000000..ac7bb7d8 --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/data.ts @@ -0,0 +1,9 @@ +import { Tab } from "@/browser/tabs/tab"; + +export class TabDataController { + private readonly tab: Tab; + + constructor(tab: Tab) { + this.tab = tab; + } +} diff --git a/src/main/browser/tabs/tab/controllers/error-page.ts b/src/main/browser/tabs/tab/controllers/error-page.ts new file mode 100644 index 00000000..5e43e239 --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/error-page.ts @@ -0,0 +1,44 @@ +import { Tab } from "@/browser/tabs/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.loadURL(errorPageURL.toString(), replace); + } +} diff --git a/src/main/browser/tabs/tab/controllers/index.ts b/src/main/browser/tabs/tab/controllers/index.ts new file mode 100644 index 00000000..d90df66f --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/index.ts @@ -0,0 +1,27 @@ +import { TabBoundsController } from "@/browser/tabs/tab/controllers/bounds"; +import { TabPipController } from "@/browser/tabs/tab/controllers/pip"; +import { TabSavingController } from "@/browser/tabs/tab/controllers/saving"; +import { TabSpaceController } from "@/browser/tabs/tab/controllers/space"; +import { TabStateController } from "@/browser/tabs/tab/controllers/state"; +import { TabVisiblityController } from "@/browser/tabs/tab/controllers/visiblity"; +import { TabWebviewController } from "@/browser/tabs/tab/controllers/webview"; +import { TabWindowController } from "@/browser/tabs/tab/controllers/window"; +import { TabContextMenuController } from "@/browser/tabs/tab/controllers/context-menu"; +import { TabErrorPageController } from "@/browser/tabs/tab/controllers/error-page"; +import { TabNavigationController } from "@/browser/tabs/tab/controllers/navigation"; +import { TabDataController } from "@/browser/tabs/tab/controllers/data"; + +export { + TabBoundsController, + TabPipController, + TabSavingController, + TabSpaceController, + TabStateController, + TabVisiblityController, + TabWebviewController, + TabWindowController, + TabContextMenuController, + TabErrorPageController, + TabNavigationController, + TabDataController +}; diff --git a/src/main/browser/tabs/tab/controllers/navigation.ts b/src/main/browser/tabs/tab/controllers/navigation.ts new file mode 100644 index 00000000..d2a22c15 --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/navigation.ts @@ -0,0 +1,100 @@ +import { Tab } from "@/browser/tabs/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 setupNavigation(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; + + 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; + } + 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/tab/controllers/pip.ts b/src/main/browser/tabs/tab/controllers/pip.ts new file mode 100644 index 00000000..24ec7b03 --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/pip.ts @@ -0,0 +1,102 @@ +import { Tab } from "@/browser/tabs/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; + } + + 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.active = true; + this.tab.emit("pip-active-changed", 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.active = false; + this.tab.emit("pip-active-changed", false); + } + + return exitedPiP; + } +} diff --git a/src/main/browser/tabs/tab/controllers/saving.ts b/src/main/browser/tabs/tab/controllers/saving.ts new file mode 100644 index 00000000..f823bc76 --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/saving.ts @@ -0,0 +1,9 @@ +import { Tab } from "@/browser/tabs/tab"; + +export class TabSavingController { + private readonly tab: Tab; + + constructor(tab: Tab) { + this.tab = tab; + } +} diff --git a/src/main/browser/tabs/tab/controllers/space.ts b/src/main/browser/tabs/tab/controllers/space.ts new file mode 100644 index 00000000..293f9fa5 --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/space.ts @@ -0,0 +1,24 @@ +import { Tab } from "@/browser/tabs/tab"; + +export class TabSpaceController { + private readonly tab: Tab; + private spaceId: string | null = null; + + constructor(tab: Tab) { + this.tab = tab; + } + + public get() { + return this.spaceId; + } + + public set(spaceId: string) { + if (this.spaceId === spaceId) { + return false; + } + + this.spaceId = spaceId; + this.tab.emit("space-changed"); + return true; + } +} diff --git a/src/main/browser/tabs/tab/controllers/state.ts b/src/main/browser/tabs/tab/controllers/state.ts new file mode 100644 index 00000000..99aed748 --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/state.ts @@ -0,0 +1,15 @@ +import { Tab } from "@/browser/tabs/tab"; + +export class TabStateController { + private readonly tab: Tab; + + public asleep: boolean; + + constructor(tab: Tab) { + const creationDetails = tab.creationDetails; + + this.tab = tab; + + this.asleep = creationDetails.asleep ?? false; + } +} diff --git a/src/main/browser/tabs/tab/controllers/visiblity.ts b/src/main/browser/tabs/tab/controllers/visiblity.ts new file mode 100644 index 00000000..dc0cd755 --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/visiblity.ts @@ -0,0 +1,9 @@ +import { Tab } from "@/browser/tabs/tab"; + +export class TabVisiblityController { + private readonly tab: Tab; + + constructor(tab: Tab) { + this.tab = tab; + } +} diff --git a/src/main/browser/tabs/tab/controllers/webview.ts b/src/main/browser/tabs/tab/controllers/webview.ts new file mode 100644 index 00000000..91d97f9d --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/webview.ts @@ -0,0 +1,82 @@ +import { Tab } from "@/browser/tabs/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() { + const tab = this.tab; + const creationDetails = tab.creationDetails; + + const webContentsView = createWebContentsView(tab.loadedProfile.session, creationDetails.webContentsViewOptions); + this.webContentsView = webContentsView; + this.webContents = webContentsView.webContents; + + tab.navigation.setupNavigation(this.webContents); + + tab.emit("webview-attached"); + } + + 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/tab/controllers/window.ts b/src/main/browser/tabs/tab/controllers/window.ts new file mode 100644 index 00000000..1846c9b0 --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/window.ts @@ -0,0 +1,25 @@ +import { Tab } from "@/browser/tabs/tab"; +import { TabbedBrowserWindow } from "@/browser/window"; + +export class TabWindowController { + private readonly tab: Tab; + private window: TabbedBrowserWindow | null = null; + + constructor(tab: Tab) { + this.tab = tab; + } + + public get() { + return this.window; + } + + public set(window: TabbedBrowserWindow) { + if (this.window === window) { + return false; + } + + this.window = window; + this.tab.emit("window-changed"); + return true; + } +} diff --git a/src/main/browser/tabs/tab/index.ts b/src/main/browser/tabs/tab/index.ts new file mode 100644 index 00000000..0b6dfcc1 --- /dev/null +++ b/src/main/browser/tabs/tab/index.ts @@ -0,0 +1,89 @@ +import { LoadedProfile } from "@/browser/profile-manager"; +import { + TabBoundsController, + TabPipController, + TabSavingController, + TabSpaceController, + TabStateController, + TabVisiblityController, + TabWebviewController, + TabWindowController, + TabContextMenuController, + TabErrorPageController, + TabNavigationController, + TabDataController +} from "@/browser/tabs/tab/controllers"; +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { generateID } from "@/modules/utils"; +import { NavigationEntry } from "electron"; + +type TabEvents = { + "window-changed": []; + "space-changed": []; + "webview-attached": []; + "webview-detached": []; + "pip-active-changed": [boolean]; + "data-changed": []; + destroyed: []; +}; + +interface TabCreationDetails { + 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 creationDetails: TabCreationDetails; + + public window: TabWindowController; + public space: TabSpaceController; + + public data: TabDataController; + public state: TabStateController; + + public webview: TabWebviewController; + public pip: TabPipController; + public bounds: TabBoundsController; + public visiblity: TabVisiblityController; + public saving: TabSavingController; + public contextMenu: TabContextMenuController; + public errorPage: TabErrorPageController; + public navigation: TabNavigationController; + + constructor(details: TabCreationDetails) { + super(); + + this.id = details.tabId ?? generateID(); + this.loadedProfile = details.loadedProfile; + this.creationDetails = details; + + this.window = new TabWindowController(this); + this.space = new TabSpaceController(this); + + this.data = new TabDataController(this); + this.state = new TabStateController(this); + + this.webview = new TabWebviewController(this); + this.pip = new TabPipController(this); + this.bounds = new TabBoundsController(this); + this.visiblity = new TabVisiblityController(this); + this.saving = new TabSavingController(this); + this.contextMenu = new TabContextMenuController(this); + this.errorPage = new TabErrorPageController(this); + this.navigation = new TabNavigationController(this); + } + + public destroy() { + this.emit("destroyed"); + } +} From 3b7000d3e4023645462952143a9e0f51ffd17c12 Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Mon, 26 May 2025 14:40:41 +0100 Subject: [PATCH 02/25] tab --- .../browser/tabs/tab/controllers/bounds.ts | 43 ++++++++++++++++ .../tabs/tab/controllers/context-menu.ts | 17 +++---- src/main/browser/tabs/tab/controllers/data.ts | 50 +++++++++++++++++++ .../tabs/tab/controllers/error-page.ts | 2 +- .../browser/tabs/tab/controllers/index.ts | 4 +- src/main/browser/tabs/tab/controllers/pip.ts | 24 +++++++-- .../browser/tabs/tab/controllers/sleep.ts | 48 ++++++++++++++++++ .../browser/tabs/tab/controllers/state.ts | 6 --- .../browser/tabs/tab/controllers/visiblity.ts | 31 ++++++++++++ .../browser/tabs/tab/controllers/webview.ts | 10 ++++ .../browser/tabs/tab/controllers/window.ts | 36 +++++++++++++ src/main/browser/tabs/tab/index.ts | 30 ++++++++--- 12 files changed, 274 insertions(+), 27 deletions(-) create mode 100644 src/main/browser/tabs/tab/controllers/sleep.ts diff --git a/src/main/browser/tabs/tab/controllers/bounds.ts b/src/main/browser/tabs/tab/controllers/bounds.ts index d7827da1..77a5e559 100644 --- a/src/main/browser/tabs/tab/controllers/bounds.ts +++ b/src/main/browser/tabs/tab/controllers/bounds.ts @@ -1,9 +1,52 @@ import { Tab } from "@/browser/tabs/tab"; +import { PageBounds } from "~/flow/types"; export class TabBoundsController { private readonly tab: Tab; + private bounds: PageBounds; + constructor(tab: Tab) { this.tab = tab; + + this.bounds = { + x: 0, + y: 0, + width: 0, + height: 0 + }; + } + + 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/controllers/context-menu.ts b/src/main/browser/tabs/tab/controllers/context-menu.ts index bd2e74ab..50977fb7 100644 --- a/src/main/browser/tabs/tab/controllers/context-menu.ts +++ b/src/main/browser/tabs/tab/controllers/context-menu.ts @@ -29,13 +29,7 @@ interface MenuActions { [key: string]: MenuItemFunction | InspectFunction; } -function createTabContextMenu( - browser: Browser, - tab: Tab, - profileId: string, - tabbedWindow: TabbedBrowserWindow, - spaceId: string -) { +function createTabContextMenu(browser: Browser, tab: Tab, profileId: string) { const webContents = tab.webview.webContents; if (!webContents) { return false; @@ -52,7 +46,10 @@ 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); + 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); }; @@ -297,7 +294,9 @@ export class TabContextMenuController { this.tab = tab; tab.on("webview-attached", () => { - createTabContextMenu(); + const browser = tab.browser; + const profileId = tab.profileId; + createTabContextMenu(browser, tab, profileId); }); } } diff --git a/src/main/browser/tabs/tab/controllers/data.ts b/src/main/browser/tabs/tab/controllers/data.ts index ac7bb7d8..8b592292 100644 --- a/src/main/browser/tabs/tab/controllers/data.ts +++ b/src/main/browser/tabs/tab/controllers/data.ts @@ -1,9 +1,59 @@ import { Tab } from "@/browser/tabs/tab"; +import { TabbedBrowserWindow } from "@/browser/window"; export class TabDataController { private readonly tab: Tab; + public window: TabbedBrowserWindow | null = null; + public space: string | null = null; + public pipActive: boolean = false; + constructor(tab: Tab) { this.tab = tab; + + tab.on("window-changed", () => this.refreshData()); + tab.on("space-changed", () => this.refreshData()); + tab.on("pip-active-changed", () => this.refreshData()); + } + + public refreshData() { + let changed = false; + + const tab = this.tab; + + // Window + const window = tab.window.get(); + if (this.window !== window) { + this.window = window; + changed = true; + } + + // Space + const space = tab.space.get(); + if (this.space !== space) { + this.space = space; + changed = true; + } + + // Picture in Picture + const pipActive = tab.pip.active; + if (this.pipActive !== pipActive) { + this.pipActive = pipActive; + changed = true; + } + + // Process changes + if (changed) { + this.tab.emit("data-changed"); + } + return changed; + } + + public getData() { + return { + window: this.window, + space: this.space, + pipActive: this.pipActive + }; } } diff --git a/src/main/browser/tabs/tab/controllers/error-page.ts b/src/main/browser/tabs/tab/controllers/error-page.ts index 5e43e239..acd09ade 100644 --- a/src/main/browser/tabs/tab/controllers/error-page.ts +++ b/src/main/browser/tabs/tab/controllers/error-page.ts @@ -39,6 +39,6 @@ export class TabErrorPageController { // Load error page const replace = FLAGS.ERROR_PAGE_LOAD_MODE === "replace"; - this.loadURL(errorPageURL.toString(), replace); + this.tab.navigation.loadUrl(errorPageURL.toString(), replace); } } diff --git a/src/main/browser/tabs/tab/controllers/index.ts b/src/main/browser/tabs/tab/controllers/index.ts index d90df66f..d9455bf8 100644 --- a/src/main/browser/tabs/tab/controllers/index.ts +++ b/src/main/browser/tabs/tab/controllers/index.ts @@ -10,6 +10,7 @@ import { TabContextMenuController } from "@/browser/tabs/tab/controllers/context import { TabErrorPageController } from "@/browser/tabs/tab/controllers/error-page"; import { TabNavigationController } from "@/browser/tabs/tab/controllers/navigation"; import { TabDataController } from "@/browser/tabs/tab/controllers/data"; +import { TabSleepController } from "@/browser/tabs/tab/controllers/sleep"; export { TabBoundsController, @@ -23,5 +24,6 @@ export { TabContextMenuController, TabErrorPageController, TabNavigationController, - TabDataController + TabDataController, + TabSleepController }; diff --git a/src/main/browser/tabs/tab/controllers/pip.ts b/src/main/browser/tabs/tab/controllers/pip.ts index 24ec7b03..df823e76 100644 --- a/src/main/browser/tabs/tab/controllers/pip.ts +++ b/src/main/browser/tabs/tab/controllers/pip.ts @@ -48,6 +48,24 @@ export class TabPipController { 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() { @@ -69,8 +87,7 @@ export class TabPipController { const enteredPiP = await enteredPiPPromise; if (enteredPiP) { - this.active = true; - this.tab.emit("pip-active-changed", true); + this.setActive(true); } return enteredPiP; @@ -93,8 +110,7 @@ export class TabPipController { const exitedPiP = await exitedPiPPromise; if (exitedPiP) { - this.active = false; - this.tab.emit("pip-active-changed", false); + this.setActive(false); } return exitedPiP; diff --git a/src/main/browser/tabs/tab/controllers/sleep.ts b/src/main/browser/tabs/tab/controllers/sleep.ts new file mode 100644 index 00000000..bd99d379 --- /dev/null +++ b/src/main/browser/tabs/tab/controllers/sleep.ts @@ -0,0 +1,48 @@ +import { Tab } from "@/browser/tabs/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/tab/controllers/state.ts b/src/main/browser/tabs/tab/controllers/state.ts index 99aed748..cfb77ead 100644 --- a/src/main/browser/tabs/tab/controllers/state.ts +++ b/src/main/browser/tabs/tab/controllers/state.ts @@ -3,13 +3,7 @@ import { Tab } from "@/browser/tabs/tab"; export class TabStateController { private readonly tab: Tab; - public asleep: boolean; - constructor(tab: Tab) { - const creationDetails = tab.creationDetails; - this.tab = tab; - - this.asleep = creationDetails.asleep ?? false; } } diff --git a/src/main/browser/tabs/tab/controllers/visiblity.ts b/src/main/browser/tabs/tab/controllers/visiblity.ts index dc0cd755..ed3d683a 100644 --- a/src/main/browser/tabs/tab/controllers/visiblity.ts +++ b/src/main/browser/tabs/tab/controllers/visiblity.ts @@ -3,7 +3,38 @@ import { Tab } from "@/browser/tabs/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/tab/controllers/webview.ts b/src/main/browser/tabs/tab/controllers/webview.ts index 91d97f9d..56dc773a 100644 --- a/src/main/browser/tabs/tab/controllers/webview.ts +++ b/src/main/browser/tabs/tab/controllers/webview.ts @@ -53,6 +53,10 @@ export class TabWebviewController { } public attach() { + if (this.webContentsView) { + return false; + } + const tab = this.tab; const creationDetails = tab.creationDetails; @@ -63,6 +67,12 @@ export class TabWebviewController { tab.navigation.setupNavigation(this.webContents); tab.emit("webview-attached"); + + tab.window.updateWebviewWindow(); + tab.bounds.updateWebviewBounds(); + tab.visiblity.updateWebviewVisiblity(); + + return true; } public detach() { diff --git a/src/main/browser/tabs/tab/controllers/window.ts b/src/main/browser/tabs/tab/controllers/window.ts index 1846c9b0..61bef7f9 100644 --- a/src/main/browser/tabs/tab/controllers/window.ts +++ b/src/main/browser/tabs/tab/controllers/window.ts @@ -1,10 +1,14 @@ import { Tab } from "@/browser/tabs/tab"; import { TabbedBrowserWindow } from "@/browser/window"; +const TAB_ZINDEX = 2; + export class TabWindowController { private readonly tab: Tab; private window: TabbedBrowserWindow | null = null; + private oldWindow: TabbedBrowserWindow | null = null; + constructor(tab: Tab) { this.tab = tab; } @@ -20,6 +24,38 @@ export class TabWindowController { 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/tab/index.ts b/src/main/browser/tabs/tab/index.ts index 0b6dfcc1..22a538ec 100644 --- a/src/main/browser/tabs/tab/index.ts +++ b/src/main/browser/tabs/tab/index.ts @@ -1,3 +1,4 @@ +import { Browser } from "@/browser/browser"; import { LoadedProfile } from "@/browser/profile-manager"; import { TabBoundsController, @@ -11,11 +12,13 @@ import { TabContextMenuController, TabErrorPageController, TabNavigationController, - TabDataController + TabDataController, + TabSleepController } from "@/browser/tabs/tab/controllers"; 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": []; @@ -23,11 +26,16 @@ type TabEvents = { "webview-attached": []; "webview-detached": []; "pip-active-changed": [boolean]; + "bounds-changed": [PageBounds]; + "visiblity-changed": [boolean]; + "sleep-changed": []; "data-changed": []; destroyed: []; }; interface TabCreationDetails { + browser: Browser; + tabId?: string; loadedProfile: LoadedProfile; webContentsViewOptions: Electron.WebContentsViewConstructorOptions; @@ -43,7 +51,10 @@ export class Tab extends TypedEventEmitter { public readonly id: string; public readonly loadedProfile: LoadedProfile; - public creationDetails: TabCreationDetails; + public readonly browser: Browser; + public readonly profileId: string; + + public readonly creationDetails: TabCreationDetails; public window: TabWindowController; public space: TabSpaceController; @@ -51,14 +62,16 @@ export class Tab extends TypedEventEmitter { public data: TabDataController; public state: TabStateController; - public webview: TabWebviewController; - public pip: TabPipController; public bounds: TabBoundsController; public visiblity: TabVisiblityController; + + public webview: TabWebviewController; + public pip: TabPipController; public saving: TabSavingController; public contextMenu: TabContextMenuController; public errorPage: TabErrorPageController; public navigation: TabNavigationController; + public sleep: TabSleepController; constructor(details: TabCreationDetails) { super(); @@ -67,20 +80,25 @@ export class Tab extends TypedEventEmitter { this.loadedProfile = details.loadedProfile; this.creationDetails = details; + this.browser = details.browser; + this.profileId = details.loadedProfile.profileId; + this.window = new TabWindowController(this); this.space = new TabSpaceController(this); this.data = new TabDataController(this); this.state = new TabStateController(this); - this.webview = new TabWebviewController(this); - this.pip = new TabPipController(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() { From 478066dfcb7038e73c230bfd1944afb1a86c178b Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Thu, 29 May 2025 18:58:25 +0100 Subject: [PATCH 03/25] feat: add destroyed state management to Tab class --- .../browser/tabs/tab/controllers/webview.ts | 2 ++ src/main/browser/tabs/tab/index.ts | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main/browser/tabs/tab/controllers/webview.ts b/src/main/browser/tabs/tab/controllers/webview.ts index 56dc773a..97bd9b10 100644 --- a/src/main/browser/tabs/tab/controllers/webview.ts +++ b/src/main/browser/tabs/tab/controllers/webview.ts @@ -53,6 +53,8 @@ export class TabWebviewController { } public attach() { + this.tab.throwIfDestroyed(); + if (this.webContentsView) { return false; } diff --git a/src/main/browser/tabs/tab/index.ts b/src/main/browser/tabs/tab/index.ts index 22a538ec..afe55173 100644 --- a/src/main/browser/tabs/tab/index.ts +++ b/src/main/browser/tabs/tab/index.ts @@ -50,12 +50,12 @@ interface TabCreationDetails { 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 creationDetails: TabCreationDetails; - public window: TabWindowController; public space: TabSpaceController; @@ -79,6 +79,7 @@ export class Tab extends TypedEventEmitter { this.id = details.tabId ?? generateID(); this.loadedProfile = details.loadedProfile; this.creationDetails = details; + this.destroyed = false; this.browser = details.browser; this.profileId = details.loadedProfile.profileId; @@ -102,6 +103,18 @@ export class Tab extends TypedEventEmitter { } public destroy() { + if (this.destroyed) { + throw new Error("Tab already destroyed"); + } + + this.destroyed = true; + this.webview.detach(); this.emit("destroyed"); } + + public throwIfDestroyed() { + if (this.destroyed) { + throw new Error("Tab already destroyed"); + } + } } From f7f2731126765a53a9f4a319ed6fa92e0e943412 Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Sun, 1 Jun 2025 17:05:07 +0100 Subject: [PATCH 04/25] feat: tab manager --- src/main/browser/tabs/tab-manager.ts | 212 +++++++++++++++++- .../browser/tabs/tab/controllers/space.ts | 5 +- .../browser/tabs/tab/controllers/window.ts | 5 +- src/main/browser/tabs/tab/index.ts | 6 +- 4 files changed, 224 insertions(+), 4 deletions(-) diff --git a/src/main/browser/tabs/tab-manager.ts b/src/main/browser/tabs/tab-manager.ts index 9509df54..e3c297ee 100644 --- a/src/main/browser/tabs/tab-manager.ts +++ b/src/main/browser/tabs/tab-manager.ts @@ -1 +1,211 @@ -export class TabManager {} +import { Browser } from "@/browser/browser"; +import { Tab, TabCreationDetails } from "@/browser/tabs/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, spaceId: 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, + spaceId, + ...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 + */ + public _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 all tabs in a window space + */ + public getTabsInWindowSpace(windowId: number, spaceId: string): Tab[] { + const result: Tab[] = []; + for (const tab of this.tabs.values()) { + const tabWindow = tab.window.get(); + const tabSpace = tab.space.get(); + if (tabWindow.id === windowId && tabSpace === spaceId) { + 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/tab/controllers/space.ts b/src/main/browser/tabs/tab/controllers/space.ts index 293f9fa5..f722179d 100644 --- a/src/main/browser/tabs/tab/controllers/space.ts +++ b/src/main/browser/tabs/tab/controllers/space.ts @@ -2,10 +2,13 @@ import { Tab } from "@/browser/tabs/tab"; export class TabSpaceController { private readonly tab: Tab; - private spaceId: string | null = null; + private spaceId: string; constructor(tab: Tab) { this.tab = tab; + + const creationDetails = tab.creationDetails; + this.spaceId = creationDetails.spaceId; } public get() { diff --git a/src/main/browser/tabs/tab/controllers/window.ts b/src/main/browser/tabs/tab/controllers/window.ts index 61bef7f9..f7a423b9 100644 --- a/src/main/browser/tabs/tab/controllers/window.ts +++ b/src/main/browser/tabs/tab/controllers/window.ts @@ -5,12 +5,15 @@ const TAB_ZINDEX = 2; export class TabWindowController { private readonly tab: Tab; - private window: TabbedBrowserWindow | null = null; + private window: TabbedBrowserWindow; private oldWindow: TabbedBrowserWindow | null = null; constructor(tab: Tab) { this.tab = tab; + + const creationDetails = tab.creationDetails; + this.window = creationDetails.window; } public get() { diff --git a/src/main/browser/tabs/tab/index.ts b/src/main/browser/tabs/tab/index.ts index afe55173..c0de8801 100644 --- a/src/main/browser/tabs/tab/index.ts +++ b/src/main/browser/tabs/tab/index.ts @@ -15,6 +15,7 @@ import { TabDataController, TabSleepController } from "@/browser/tabs/tab/controllers"; +import { TabbedBrowserWindow } from "@/browser/window"; import { TypedEventEmitter } from "@/modules/typed-event-emitter"; import { generateID } from "@/modules/utils"; import { NavigationEntry } from "electron"; @@ -33,9 +34,12 @@ type TabEvents = { destroyed: []; }; -interface TabCreationDetails { +export interface TabCreationDetails { browser: Browser; + window: TabbedBrowserWindow; + spaceId: string; + tabId?: string; loadedProfile: LoadedProfile; webContentsViewOptions: Electron.WebContentsViewConstructorOptions; From 9e6b9cb41bd1cb0dede49eb746cebe4373d64c4f Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Sun, 1 Jun 2025 18:07:30 +0100 Subject: [PATCH 05/25] feat: initial tabs orchestrator --- src/main/browser/tabs/index.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/browser/tabs/index.ts b/src/main/browser/tabs/index.ts index d8a1edf0..8d7ec684 100644 --- a/src/main/browser/tabs/index.ts +++ b/src/main/browser/tabs/index.ts @@ -1 +1,16 @@ -export class TabsOrchestrator {} +import { Browser } from "@/browser/browser"; +import { TabManager } from "@/browser/tabs/tab-manager"; + +export class TabsOrchestrator { + private readonly browser: Browser; + public readonly tabManager: TabManager; + + constructor(browser: Browser) { + this.browser = browser; + this.tabManager = new TabManager(browser); + } + + public destroy(): void { + this.tabManager.destroy(); + } +} From 0289c78b22d3732e18301b5624f08c19970439fa Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Tue, 3 Jun 2025 01:09:09 +0100 Subject: [PATCH 06/25] feat: initial tab group implementation --- src/main/browser/browser.ts | 18 +-- src/main/browser/tabs/index.ts | 2 +- .../tabs/tab-group/controllers/focused-tab.ts | 62 ++++++++ .../tabs/tab-group/controllers/index.ts | 4 + .../tabs/tab-group/controllers/tabs.ts | 150 ++++++++++++++++++ src/main/browser/tabs/tab-group/index.ts | 73 +++++++++ .../browser/tabs/tab-group/types/normal.ts | 10 ++ src/main/browser/tabs/tab/controllers/data.ts | 2 +- .../browser/tabs/tab/controllers/index.ts | 2 - .../browser/tabs/tab/controllers/state.ts | 9 -- .../browser/tabs/tab/controllers/webview.ts | 4 + src/main/browser/tabs/tab/index.ts | 36 ++--- 12 files changed, 326 insertions(+), 46 deletions(-) create mode 100644 src/main/browser/tabs/tab-group/controllers/focused-tab.ts create mode 100644 src/main/browser/tabs/tab-group/controllers/index.ts create mode 100644 src/main/browser/tabs/tab-group/controllers/tabs.ts create mode 100644 src/main/browser/tabs/tab-group/index.ts create mode 100644 src/main/browser/tabs/tab-group/types/normal.ts delete mode 100644 src/main/browser/tabs/tab/controllers/state.ts 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/tabs/index.ts b/src/main/browser/tabs/index.ts index 8d7ec684..5d36de20 100644 --- a/src/main/browser/tabs/index.ts +++ b/src/main/browser/tabs/index.ts @@ -1,7 +1,7 @@ import { Browser } from "@/browser/browser"; import { TabManager } from "@/browser/tabs/tab-manager"; -export class TabsOrchestrator { +export class TabOrchestrator { private readonly browser: Browser; public readonly tabManager: TabManager; diff --git a/src/main/browser/tabs/tab-group/controllers/focused-tab.ts b/src/main/browser/tabs/tab-group/controllers/focused-tab.ts new file mode 100644 index 00000000..71373d1a --- /dev/null +++ b/src/main/browser/tabs/tab-group/controllers/focused-tab.ts @@ -0,0 +1,62 @@ +import { Tab } from "@/browser/tabs/tab"; +import { TabGroup } from "@/browser/tabs/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/tab-group/controllers/index.ts b/src/main/browser/tabs/tab-group/controllers/index.ts new file mode 100644 index 00000000..c4ba0b30 --- /dev/null +++ b/src/main/browser/tabs/tab-group/controllers/index.ts @@ -0,0 +1,4 @@ +import { TabGroupFocusedTabController } from "@/browser/tabs/tab-group/controllers/focused-tab"; +import { TabGroupTabsController } from "@/browser/tabs/tab-group/controllers/tabs"; + +export { TabGroupFocusedTabController, TabGroupTabsController }; diff --git a/src/main/browser/tabs/tab-group/controllers/tabs.ts b/src/main/browser/tabs/tab-group/controllers/tabs.ts new file mode 100644 index 00000000..b3efb250 --- /dev/null +++ b/src/main/browser/tabs/tab-group/controllers/tabs.ts @@ -0,0 +1,150 @@ +import { Browser } from "@/browser/browser"; +import { Tab } from "@/browser/tabs/tab"; +import { TabGroup } from "@/browser/tabs/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); + } + + /** + * 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/tab-group/index.ts b/src/main/browser/tabs/tab-group/index.ts new file mode 100644 index 00000000..ad2f9809 --- /dev/null +++ b/src/main/browser/tabs/tab-group/index.ts @@ -0,0 +1,73 @@ +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { generateID } from "@/modules/utils"; +import { Tab } from "@/browser/tabs/tab"; +import { TabGroupFocusedTabController, TabGroupTabsController } from "@/browser/tabs/tab-group/controllers"; +import { Browser } from "@/browser/browser"; +import { TabbedBrowserWindow } from "@/browser/window"; + +type TabGroupTypes = "normal" | "split" | "glance"; + +type TabGroupEvents = { + "window-changed": []; + "space-changed": []; + "tab-added": [Tab]; + "tab-removed": [Tab]; + destroyed: []; +}; + +export interface TabGroupCreationDetails { + browser: Browser; + + window: TabbedBrowserWindow; + spaceId: 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 tabs: TabGroupTabsController; + public focusedTab: TabGroupFocusedTabController; + + constructor(variant: TabGroupVariant, details: TabGroupCreationDetails) { + super(); + + this.id = generateID(); + this.destroyed = false; + + this.type = variant.type; + this.maxTabs = variant.maxTabs; + this.creationDetails = details; + + this.tabs = new TabGroupTabsController(this); + this.focusedTab = new TabGroupFocusedTabController(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"); + } + } +} diff --git a/src/main/browser/tabs/tab-group/types/normal.ts b/src/main/browser/tabs/tab-group/types/normal.ts new file mode 100644 index 00000000..badbe22b --- /dev/null +++ b/src/main/browser/tabs/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/tab/controllers/data.ts b/src/main/browser/tabs/tab/controllers/data.ts index 8b592292..bca97b4a 100644 --- a/src/main/browser/tabs/tab/controllers/data.ts +++ b/src/main/browser/tabs/tab/controllers/data.ts @@ -49,7 +49,7 @@ export class TabDataController { return changed; } - public getData() { + public get() { return { window: this.window, space: this.space, diff --git a/src/main/browser/tabs/tab/controllers/index.ts b/src/main/browser/tabs/tab/controllers/index.ts index d9455bf8..0789a04c 100644 --- a/src/main/browser/tabs/tab/controllers/index.ts +++ b/src/main/browser/tabs/tab/controllers/index.ts @@ -2,7 +2,6 @@ import { TabBoundsController } from "@/browser/tabs/tab/controllers/bounds"; import { TabPipController } from "@/browser/tabs/tab/controllers/pip"; import { TabSavingController } from "@/browser/tabs/tab/controllers/saving"; import { TabSpaceController } from "@/browser/tabs/tab/controllers/space"; -import { TabStateController } from "@/browser/tabs/tab/controllers/state"; import { TabVisiblityController } from "@/browser/tabs/tab/controllers/visiblity"; import { TabWebviewController } from "@/browser/tabs/tab/controllers/webview"; import { TabWindowController } from "@/browser/tabs/tab/controllers/window"; @@ -17,7 +16,6 @@ export { TabPipController, TabSavingController, TabSpaceController, - TabStateController, TabVisiblityController, TabWebviewController, TabWindowController, diff --git a/src/main/browser/tabs/tab/controllers/state.ts b/src/main/browser/tabs/tab/controllers/state.ts deleted file mode 100644 index cfb77ead..00000000 --- a/src/main/browser/tabs/tab/controllers/state.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Tab } from "@/browser/tabs/tab"; - -export class TabStateController { - private readonly tab: Tab; - - constructor(tab: Tab) { - this.tab = tab; - } -} diff --git a/src/main/browser/tabs/tab/controllers/webview.ts b/src/main/browser/tabs/tab/controllers/webview.ts index 97bd9b10..e176e315 100644 --- a/src/main/browser/tabs/tab/controllers/webview.ts +++ b/src/main/browser/tabs/tab/controllers/webview.ts @@ -66,6 +66,10 @@ export class TabWebviewController { this.webContentsView = webContentsView; this.webContents = webContentsView.webContents; + this.webContents.on("focus", () => { + tab.emit("focused"); + }); + tab.navigation.setupNavigation(this.webContents); tab.emit("webview-attached"); diff --git a/src/main/browser/tabs/tab/index.ts b/src/main/browser/tabs/tab/index.ts index c0de8801..e71c7641 100644 --- a/src/main/browser/tabs/tab/index.ts +++ b/src/main/browser/tabs/tab/index.ts @@ -5,7 +5,6 @@ import { TabPipController, TabSavingController, TabSpaceController, - TabStateController, TabVisiblityController, TabWebviewController, TabWindowController, @@ -31,6 +30,9 @@ type TabEvents = { "visiblity-changed": [boolean]; "sleep-changed": []; "data-changed": []; + + focused: []; + destroyed: []; }; @@ -60,22 +62,21 @@ export class Tab extends TypedEventEmitter { public readonly browser: Browser; public readonly profileId: string; - public window: TabWindowController; - public space: TabSpaceController; + public readonly window: TabWindowController; + public readonly space: TabSpaceController; - public data: TabDataController; - public state: TabStateController; + public readonly data: TabDataController; - public bounds: TabBoundsController; - public visiblity: TabVisiblityController; + public readonly bounds: TabBoundsController; + public readonly visiblity: TabVisiblityController; - public webview: TabWebviewController; - public pip: TabPipController; - public saving: TabSavingController; - public contextMenu: TabContextMenuController; - public errorPage: TabErrorPageController; - public navigation: TabNavigationController; - public sleep: TabSleepController; + 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(); @@ -92,7 +93,6 @@ export class Tab extends TypedEventEmitter { this.space = new TabSpaceController(this); this.data = new TabDataController(this); - this.state = new TabStateController(this); this.bounds = new TabBoundsController(this); this.visiblity = new TabVisiblityController(this); @@ -107,13 +107,13 @@ export class Tab extends TypedEventEmitter { } public destroy() { - if (this.destroyed) { - throw new Error("Tab already destroyed"); - } + this.throwIfDestroyed(); this.destroyed = true; this.webview.detach(); this.emit("destroyed"); + + this.destroyEmitter(); } public throwIfDestroyed() { From 45b4d594693185176718731130b7f36431991eb0 Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Sat, 7 Jun 2025 01:40:35 +0100 Subject: [PATCH 07/25] feat: add navigation history change event and enhance webview data handling --- src/main/browser/tabs/tab/controllers/data.ts | 51 ++++++++++++++++++- .../tabs/tab/controllers/navigation.ts | 5 +- .../browser/tabs/tab/controllers/webview.ts | 3 +- src/main/browser/tabs/tab/index.ts | 1 + 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/main/browser/tabs/tab/controllers/data.ts b/src/main/browser/tabs/tab/controllers/data.ts index bca97b4a..dc15111a 100644 --- a/src/main/browser/tabs/tab/controllers/data.ts +++ b/src/main/browser/tabs/tab/controllers/data.ts @@ -1,19 +1,37 @@ import { Tab } from "@/browser/tabs/tab"; import { TabbedBrowserWindow } from "@/browser/window"; +import { WebContents } from "electron"; export class TabDataController { private readonly tab: Tab; + // from other controllers public window: TabbedBrowserWindow | null = null; public space: string | null = null; public pipActive: boolean = false; + // from webview (recorded here) + public audible: boolean = false; + public muted: boolean = false; + public url: string = ""; + public isLoading: boolean = true; + + // recorded here + public asleep: boolean = false; + constructor(tab: Tab) { this.tab = tab; tab.on("window-changed", () => this.refreshData()); tab.on("space-changed", () => this.refreshData()); tab.on("pip-active-changed", () => this.refreshData()); + tab.on("nav-history-changed", () => this.emitDataChanged()); + + tab.on("webview-detached", () => this.onWebviewDetached()); + } + + private emitDataChanged() { + this.tab.emit("data-changed"); } public refreshData() { @@ -49,11 +67,42 @@ export class TabDataController { return changed; } + public setupWebviewData(webContents: WebContents) { + // audible + webContents.on("audio-state-changed", () => {}); + webContents.on("media-started-playing", () => {}); + webContents.on("media-paused", () => {}); + + // title + webContents.on("page-title-updated", () => {}); + + // isLoading + webContents.on("did-finish-load", () => {}); + webContents.on("did-start-loading", () => {}); + webContents.on("did-stop-loading", () => {}); + + // url + webContents.on("did-finish-load", () => {}); + webContents.on("did-start-navigation", () => {}); + webContents.on("did-redirect-navigation", () => {}); + webContents.on("did-navigate-in-page", () => {}); + } + + private onWebviewDetached() { + return false; + } + public get() { + const tab = this.tab; + const navHistory = tab.navigation.navHistory; + const navHistoryIndex = tab.navigation.navHistoryIndex; + return { window: this.window, space: this.space, - pipActive: this.pipActive + pipActive: this.pipActive, + navHistory: navHistory, + navHistoryIndex: navHistoryIndex }; } } diff --git a/src/main/browser/tabs/tab/controllers/navigation.ts b/src/main/browser/tabs/tab/controllers/navigation.ts index d2a22c15..e1e0ce1e 100644 --- a/src/main/browser/tabs/tab/controllers/navigation.ts +++ b/src/main/browser/tabs/tab/controllers/navigation.ts @@ -31,7 +31,7 @@ export class TabNavigationController { return this._navHistoryIndex ?? this.navHistory.length - 1; } - public setupNavigation(webContents: WebContents) { + public setupWebviewNavigation(webContents: WebContents) { // Restore the navigation history webContents.navigationHistory.restore({ entries: this.navHistory, @@ -53,6 +53,8 @@ export class TabNavigationController { this.navHistory = navHistory; this._navHistoryIndex = activeIndex; + tab.emit("nav-history-changed"); + return true; } @@ -79,6 +81,7 @@ export class TabNavigationController { } this._navHistoryIndex = navHistoryIndex + 1; } + tab.emit("nav-history-changed"); return true; } diff --git a/src/main/browser/tabs/tab/controllers/webview.ts b/src/main/browser/tabs/tab/controllers/webview.ts index e176e315..2dee9b5d 100644 --- a/src/main/browser/tabs/tab/controllers/webview.ts +++ b/src/main/browser/tabs/tab/controllers/webview.ts @@ -70,7 +70,8 @@ export class TabWebviewController { tab.emit("focused"); }); - tab.navigation.setupNavigation(this.webContents); + tab.navigation.setupWebviewNavigation(this.webContents); + tab.data.setupWebviewData(this.webContents); tab.emit("webview-attached"); diff --git a/src/main/browser/tabs/tab/index.ts b/src/main/browser/tabs/tab/index.ts index e71c7641..73cd53ce 100644 --- a/src/main/browser/tabs/tab/index.ts +++ b/src/main/browser/tabs/tab/index.ts @@ -29,6 +29,7 @@ type TabEvents = { "bounds-changed": [PageBounds]; "visiblity-changed": [boolean]; "sleep-changed": []; + "nav-history-changed": []; "data-changed": []; focused: []; From a797a45063a18ee70deeec876bc13036aaf0de25 Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Sun, 8 Jun 2025 02:00:46 +0100 Subject: [PATCH 08/25] feat: add TabGroupWindowController to TabGroup and update exports in controllers --- .../tabs/tab-group/controllers/index.ts | 3 +- .../tabs/tab-group/controllers/window.ts | 43 +++++++++++++++++++ src/main/browser/tabs/tab-group/index.ts | 13 ++++-- 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 src/main/browser/tabs/tab-group/controllers/window.ts diff --git a/src/main/browser/tabs/tab-group/controllers/index.ts b/src/main/browser/tabs/tab-group/controllers/index.ts index c4ba0b30..db7275f8 100644 --- a/src/main/browser/tabs/tab-group/controllers/index.ts +++ b/src/main/browser/tabs/tab-group/controllers/index.ts @@ -1,4 +1,5 @@ import { TabGroupFocusedTabController } from "@/browser/tabs/tab-group/controllers/focused-tab"; import { TabGroupTabsController } from "@/browser/tabs/tab-group/controllers/tabs"; +import { TabGroupWindowController } from "@/browser/tabs/tab-group/controllers/window"; -export { TabGroupFocusedTabController, TabGroupTabsController }; +export { TabGroupFocusedTabController, TabGroupTabsController, TabGroupWindowController }; diff --git a/src/main/browser/tabs/tab-group/controllers/window.ts b/src/main/browser/tabs/tab-group/controllers/window.ts new file mode 100644 index 00000000..1eecb2a2 --- /dev/null +++ b/src/main/browser/tabs/tab-group/controllers/window.ts @@ -0,0 +1,43 @@ +import { TabGroup } from "@/browser/tabs/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/tab-group/index.ts b/src/main/browser/tabs/tab-group/index.ts index ad2f9809..691a5db1 100644 --- a/src/main/browser/tabs/tab-group/index.ts +++ b/src/main/browser/tabs/tab-group/index.ts @@ -1,7 +1,11 @@ import { TypedEventEmitter } from "@/modules/typed-event-emitter"; import { generateID } from "@/modules/utils"; import { Tab } from "@/browser/tabs/tab"; -import { TabGroupFocusedTabController, TabGroupTabsController } from "@/browser/tabs/tab-group/controllers"; +import { + TabGroupFocusedTabController, + TabGroupTabsController, + TabGroupWindowController +} from "@/browser/tabs/tab-group/controllers"; import { Browser } from "@/browser/browser"; import { TabbedBrowserWindow } from "@/browser/window"; @@ -9,7 +13,6 @@ type TabGroupTypes = "normal" | "split" | "glance"; type TabGroupEvents = { "window-changed": []; - "space-changed": []; "tab-added": [Tab]; "tab-removed": [Tab]; destroyed: []; @@ -37,8 +40,9 @@ export class TabGroup extends TypedEventEmitter { protected tabIds: string[] = []; - public tabs: TabGroupTabsController; - public focusedTab: TabGroupFocusedTabController; + public readonly window: TabGroupWindowController; + public readonly tabs: TabGroupTabsController; + public readonly focusedTab: TabGroupFocusedTabController; constructor(variant: TabGroupVariant, details: TabGroupCreationDetails) { super(); @@ -50,6 +54,7 @@ export class TabGroup extends TypedEventEmitter { this.maxTabs = variant.maxTabs; this.creationDetails = details; + this.window = new TabGroupWindowController(this); this.tabs = new TabGroupTabsController(this); this.focusedTab = new TabGroupFocusedTabController(this); } From 7e6357c1d00e7ef842ad2cd8464b550a270e4865 Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Sun, 8 Jun 2025 02:01:31 +0100 Subject: [PATCH 09/25] refactor: remove TabSpaceController from Tab and controllers --- .../browser/tabs/tab/controllers/index.ts | 2 -- .../browser/tabs/tab/controllers/space.ts | 27 ------------------- src/main/browser/tabs/tab/index.ts | 3 --- 3 files changed, 32 deletions(-) delete mode 100644 src/main/browser/tabs/tab/controllers/space.ts diff --git a/src/main/browser/tabs/tab/controllers/index.ts b/src/main/browser/tabs/tab/controllers/index.ts index 0789a04c..4d035e4f 100644 --- a/src/main/browser/tabs/tab/controllers/index.ts +++ b/src/main/browser/tabs/tab/controllers/index.ts @@ -1,7 +1,6 @@ import { TabBoundsController } from "@/browser/tabs/tab/controllers/bounds"; import { TabPipController } from "@/browser/tabs/tab/controllers/pip"; import { TabSavingController } from "@/browser/tabs/tab/controllers/saving"; -import { TabSpaceController } from "@/browser/tabs/tab/controllers/space"; import { TabVisiblityController } from "@/browser/tabs/tab/controllers/visiblity"; import { TabWebviewController } from "@/browser/tabs/tab/controllers/webview"; import { TabWindowController } from "@/browser/tabs/tab/controllers/window"; @@ -15,7 +14,6 @@ export { TabBoundsController, TabPipController, TabSavingController, - TabSpaceController, TabVisiblityController, TabWebviewController, TabWindowController, diff --git a/src/main/browser/tabs/tab/controllers/space.ts b/src/main/browser/tabs/tab/controllers/space.ts deleted file mode 100644 index f722179d..00000000 --- a/src/main/browser/tabs/tab/controllers/space.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Tab } from "@/browser/tabs/tab"; - -export class TabSpaceController { - private readonly tab: Tab; - private spaceId: string; - - constructor(tab: Tab) { - this.tab = tab; - - const creationDetails = tab.creationDetails; - this.spaceId = creationDetails.spaceId; - } - - public get() { - return this.spaceId; - } - - public set(spaceId: string) { - if (this.spaceId === spaceId) { - return false; - } - - this.spaceId = spaceId; - this.tab.emit("space-changed"); - return true; - } -} diff --git a/src/main/browser/tabs/tab/index.ts b/src/main/browser/tabs/tab/index.ts index 73cd53ce..89426d65 100644 --- a/src/main/browser/tabs/tab/index.ts +++ b/src/main/browser/tabs/tab/index.ts @@ -4,7 +4,6 @@ import { TabBoundsController, TabPipController, TabSavingController, - TabSpaceController, TabVisiblityController, TabWebviewController, TabWindowController, @@ -64,7 +63,6 @@ export class Tab extends TypedEventEmitter { public readonly profileId: string; public readonly window: TabWindowController; - public readonly space: TabSpaceController; public readonly data: TabDataController; @@ -91,7 +89,6 @@ export class Tab extends TypedEventEmitter { this.profileId = details.loadedProfile.profileId; this.window = new TabWindowController(this); - this.space = new TabSpaceController(this); this.data = new TabDataController(this); From fdd9175025a51d82cf9d121293e0f6c94bb859bc Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Sun, 8 Jun 2025 02:06:13 +0100 Subject: [PATCH 10/25] feat: add asleep state management to TabDataController and update event handling --- src/main/browser/tabs/tab/controllers/data.ts | 24 ++++++++++--------- src/main/browser/tabs/tab/index.ts | 1 - 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/main/browser/tabs/tab/controllers/data.ts b/src/main/browser/tabs/tab/controllers/data.ts index dc15111a..09418303 100644 --- a/src/main/browser/tabs/tab/controllers/data.ts +++ b/src/main/browser/tabs/tab/controllers/data.ts @@ -7,8 +7,8 @@ export class TabDataController { // from other controllers public window: TabbedBrowserWindow | null = null; - public space: string | null = null; public pipActive: boolean = false; + public asleep: boolean = false; // from webview (recorded here) public audible: boolean = false; @@ -17,17 +17,19 @@ export class TabDataController { public isLoading: boolean = true; // recorded here - public asleep: boolean = false; + // none currently constructor(tab: Tab) { this.tab = tab; tab.on("window-changed", () => this.refreshData()); - tab.on("space-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()); + + setImmediate(() => this.refreshData()); } private emitDataChanged() { @@ -46,13 +48,6 @@ export class TabDataController { changed = true; } - // Space - const space = tab.space.get(); - if (this.space !== space) { - this.space = space; - changed = true; - } - // Picture in Picture const pipActive = tab.pip.active; if (this.pipActive !== pipActive) { @@ -60,6 +55,13 @@ export class TabDataController { changed = true; } + // Asleep + const asleep = tab.sleep.asleep; + if (this.asleep !== asleep) { + this.asleep = asleep; + changed = true; + } + // Process changes if (changed) { this.tab.emit("data-changed"); @@ -99,8 +101,8 @@ export class TabDataController { return { window: this.window, - space: this.space, pipActive: this.pipActive, + asleep: this.asleep, navHistory: navHistory, navHistoryIndex: navHistoryIndex }; diff --git a/src/main/browser/tabs/tab/index.ts b/src/main/browser/tabs/tab/index.ts index 89426d65..ff02f64d 100644 --- a/src/main/browser/tabs/tab/index.ts +++ b/src/main/browser/tabs/tab/index.ts @@ -21,7 +21,6 @@ import { PageBounds } from "~/flow/types"; type TabEvents = { "window-changed": []; - "space-changed": []; "webview-attached": []; "webview-detached": []; "pip-active-changed": [boolean]; From df8c680ac045a9954019568453443b0ea96a630f Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Sun, 8 Jun 2025 02:59:11 +0100 Subject: [PATCH 11/25] feat: enhance TabDataController with webview data handling and state management --- src/main/browser/tabs/tab/controllers/data.ts | 83 +++++++++++++++---- 1 file changed, 69 insertions(+), 14 deletions(-) diff --git a/src/main/browser/tabs/tab/controllers/data.ts b/src/main/browser/tabs/tab/controllers/data.ts index 09418303..cb103ed1 100644 --- a/src/main/browser/tabs/tab/controllers/data.ts +++ b/src/main/browser/tabs/tab/controllers/data.ts @@ -11,10 +11,11 @@ export class TabDataController { public asleep: boolean = false; // from webview (recorded here) - public audible: boolean = false; - public muted: boolean = false; + public title: string = ""; public url: string = ""; public isLoading: boolean = true; + public audible: boolean = false; + public muted: boolean = false; // recorded here // none currently @@ -41,6 +42,8 @@ export class TabDataController { const tab = this.tab; + /// From other controllers /// + // Window const window = tab.window.get(); if (this.window !== window) { @@ -62,6 +65,48 @@ export class TabDataController { changed = true; } + /// From webview /// + const webContents = tab.webview.webContents; + + if (webContents) { + // Title + const title = webContents.getTitle(); + if (this.title !== title) { + this.title = title; + changed = true; + } + + // URL + const url = webContents.getURL(); + if (this.url !== url) { + this.url = url; + changed = true; + } + + // isLoading + const isLoading = webContents.isLoading(); + if (this.isLoading !== isLoading) { + this.isLoading = isLoading; + changed = true; + } + + // audible + const audible = webContents.isAudioMuted(); + if (this.audible !== audible) { + this.audible = audible; + changed = true; + } + + // muted + const muted = webContents.isAudioMuted(); + if (this.muted !== muted) { + this.muted = muted; + changed = true; + } + } + + /// Finalise /// + // Process changes if (changed) { this.tab.emit("data-changed"); @@ -71,23 +116,23 @@ export class TabDataController { public setupWebviewData(webContents: WebContents) { // audible - webContents.on("audio-state-changed", () => {}); - webContents.on("media-started-playing", () => {}); - webContents.on("media-paused", () => {}); + 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", () => {}); + webContents.on("page-title-updated", () => this.refreshData()); // isLoading - webContents.on("did-finish-load", () => {}); - webContents.on("did-start-loading", () => {}); - webContents.on("did-stop-loading", () => {}); + 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", () => {}); - webContents.on("did-start-navigation", () => {}); - webContents.on("did-redirect-navigation", () => {}); - webContents.on("did-navigate-in-page", () => {}); + 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() { @@ -100,11 +145,21 @@ export class TabDataController { const navHistoryIndex = tab.navigation.navHistoryIndex; return { + // from other controllers window: this.window, pipActive: this.pipActive, asleep: this.asleep, + + // from navigation navHistory: navHistory, - navHistoryIndex: navHistoryIndex + navHistoryIndex: navHistoryIndex, + + // from webview + title: this.title, + url: this.url, + isLoading: this.isLoading, + audible: this.audible, + muted: this.muted }; } } From 0d899bdd8d61937305634a5299f042559063b33e Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Sun, 8 Jun 2025 03:02:29 +0100 Subject: [PATCH 12/25] refactor: streamline property updates in TabDataController using a generic setProperty function --- src/main/browser/tabs/tab/controllers/data.ts | 54 ++++++++----------- 1 file changed, 21 insertions(+), 33 deletions(-) diff --git a/src/main/browser/tabs/tab/controllers/data.ts b/src/main/browser/tabs/tab/controllers/data.ts index cb103ed1..d087ad85 100644 --- a/src/main/browser/tabs/tab/controllers/data.ts +++ b/src/main/browser/tabs/tab/controllers/data.ts @@ -2,6 +2,11 @@ import { Tab } from "@/browser/tabs/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; @@ -42,67 +47,50 @@ export class TabDataController { 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(); - if (this.window !== window) { - this.window = window; - changed = true; - } + setProperty("window", window); // Picture in Picture const pipActive = tab.pip.active; - if (this.pipActive !== pipActive) { - this.pipActive = pipActive; - changed = true; - } + setProperty("pipActive", pipActive); // Asleep const asleep = tab.sleep.asleep; - if (this.asleep !== asleep) { - this.asleep = asleep; - changed = true; - } + setProperty("asleep", asleep); /// From webview /// - const webContents = tab.webview.webContents; + const webContents = tab.webview.webContents; if (webContents) { // Title const title = webContents.getTitle(); - if (this.title !== title) { - this.title = title; - changed = true; - } + setProperty("title", title); // URL const url = webContents.getURL(); - if (this.url !== url) { - this.url = url; - changed = true; - } + setProperty("url", url); // isLoading const isLoading = webContents.isLoading(); - if (this.isLoading !== isLoading) { - this.isLoading = isLoading; - changed = true; - } + setProperty("isLoading", isLoading); // audible const audible = webContents.isAudioMuted(); - if (this.audible !== audible) { - this.audible = audible; - changed = true; - } + setProperty("audible", audible); // muted const muted = webContents.isAudioMuted(); - if (this.muted !== muted) { - this.muted = muted; - changed = true; - } + setProperty("muted", muted); } /// Finalise /// From 6d7b7e4f948d2104919d81d03a1078aa9e84b872 Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Sun, 8 Jun 2025 03:06:22 +0100 Subject: [PATCH 13/25] refactor: remove spaceId from TabCreationOptions and related tab creation logic --- src/main/browser/tabs/tab-manager.ts | 20 ++------------------ src/main/browser/tabs/tab/index.ts | 1 - 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/src/main/browser/tabs/tab-manager.ts b/src/main/browser/tabs/tab-manager.ts index e3c297ee..29434e3b 100644 --- a/src/main/browser/tabs/tab-manager.ts +++ b/src/main/browser/tabs/tab-manager.ts @@ -11,7 +11,7 @@ type TabManagerEvents = { destroyed: []; }; -type TabCreationOptions = Omit; +type TabCreationOptions = Omit; export class TabManager extends TypedEventEmitter { public tabs: Map = new Map(); @@ -49,7 +49,7 @@ export class TabManager extends TypedEventEmitter { /** * Create a tab */ - public createTab(windowId: number, profileId: string, spaceId: string, options: TabCreationOptions): Tab { + public createTab(windowId: number, profileId: string, options: TabCreationOptions): Tab { if (this.isDestroyed) { throw new Error("TabManager has been destroyed"); } @@ -70,7 +70,6 @@ export class TabManager extends TypedEventEmitter { const tab = new Tab({ browser: this.browser, window, - spaceId, ...options }); @@ -157,21 +156,6 @@ export class TabManager extends TypedEventEmitter { 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()) { - const tabWindow = tab.window.get(); - const tabSpace = tab.space.get(); - if (tabWindow.id === windowId && tabSpace === spaceId) { - result.push(tab); - } - } - return result; - } - /** * Get the count of tabs */ diff --git a/src/main/browser/tabs/tab/index.ts b/src/main/browser/tabs/tab/index.ts index ff02f64d..ac40a334 100644 --- a/src/main/browser/tabs/tab/index.ts +++ b/src/main/browser/tabs/tab/index.ts @@ -39,7 +39,6 @@ export interface TabCreationDetails { browser: Browser; window: TabbedBrowserWindow; - spaceId: string; tabId?: string; loadedProfile: LoadedProfile; From 81dfcb0b2377d57737e92cb8ecbf0bb4f698fd50 Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Sun, 8 Jun 2025 21:34:08 +0100 Subject: [PATCH 14/25] feat: add initial animation state management to TabBoundsController --- .../browser/tabs/tab/controllers/bounds.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/browser/tabs/tab/controllers/bounds.ts b/src/main/browser/tabs/tab/controllers/bounds.ts index 77a5e559..13233947 100644 --- a/src/main/browser/tabs/tab/controllers/bounds.ts +++ b/src/main/browser/tabs/tab/controllers/bounds.ts @@ -4,11 +4,22 @@ 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, @@ -17,6 +28,14 @@ export class TabBoundsController { }; } + public startAnimating() { + this.isAnimating = true; + } + + public stopAnimating() { + this.isAnimating = false; + } + public set(bounds: PageBounds) { this.bounds = bounds; this.tab.emit("bounds-changed", bounds); From f26afcc8dd75eeb831615c3cad7b15decffd919a Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Sat, 5 Jul 2025 22:33:10 +0100 Subject: [PATCH 15/25] docs: add docs for tab-group --- docs/api/tabs/tab-group.md | 253 +++++++++++++++++++++++++++ src/main/browser/tabs/tab-manager.ts | 2 +- 2 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 docs/api/tabs/tab-group.md 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/src/main/browser/tabs/tab-manager.ts b/src/main/browser/tabs/tab-manager.ts index 29434e3b..37e7af1d 100644 --- a/src/main/browser/tabs/tab-manager.ts +++ b/src/main/browser/tabs/tab-manager.ts @@ -104,7 +104,7 @@ export class TabManager extends TypedEventEmitter { * Remove a tab from the tab manager * @internal Should not be used directly, use `tab.destroy()` instead */ - public _removeTab(tab: Tab): void { + private _removeTab(tab: Tab): void { if (!this.tabs.has(tab.id)) { return; } From f6114d00c54e1ae8d601072b2bac13bcc07955be Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Sat, 5 Jul 2025 22:40:35 +0100 Subject: [PATCH 16/25] docs: add docs for tab --- docs/api/tabs/tab.md | 341 +++++++++++++++++++++++++++++++++---------- 1 file changed, 267 insertions(+), 74 deletions(-) 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 From 0e37eb32d9a452236e1d933d2862c9c145b81a7f Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Wed, 9 Jul 2025 00:21:33 +0100 Subject: [PATCH 17/25] feat: Tab Group Manager --- src/main/browser/tabs/index.ts | 4 + src/main/browser/tabs/tab-group-manager.ts | 129 ++++++++++++++++++ .../tabs/tab-group/controllers/index.ts | 3 +- .../tabs/tab-group/controllers/space.ts | 28 ++++ .../tabs/tab-group/controllers/tabs.ts | 9 ++ .../tabs/tab-group/controllers/visiblity.ts | 40 ++++++ src/main/browser/tabs/tab-group/index.ts | 7 +- 7 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 src/main/browser/tabs/tab-group-manager.ts create mode 100644 src/main/browser/tabs/tab-group/controllers/space.ts create mode 100644 src/main/browser/tabs/tab-group/controllers/visiblity.ts diff --git a/src/main/browser/tabs/index.ts b/src/main/browser/tabs/index.ts index 5d36de20..a051960f 100644 --- a/src/main/browser/tabs/index.ts +++ b/src/main/browser/tabs/index.ts @@ -1,16 +1,20 @@ import { Browser } from "@/browser/browser"; +import { TabGroupManager } from "@/browser/tabs/tab-group-manager"; import { TabManager } from "@/browser/tabs/tab-manager"; export class TabOrchestrator { private readonly browser: Browser; public readonly tabManager: TabManager; + public readonly tabGroupManager: TabGroupManager; constructor(browser: Browser) { this.browser = browser; this.tabManager = new TabManager(browser); + this.tabGroupManager = new TabGroupManager(browser); } public destroy(): void { this.tabManager.destroy(); + this.tabGroupManager.destroy(); } } diff --git a/src/main/browser/tabs/tab-group-manager.ts b/src/main/browser/tabs/tab-group-manager.ts new file mode 100644 index 00000000..01b1b687 --- /dev/null +++ b/src/main/browser/tabs/tab-group-manager.ts @@ -0,0 +1,129 @@ +import { Browser } from "@/browser/browser"; +import { Tab } from "@/browser/tabs/tab"; +import { TabGroup } from "@/browser/tabs/tab-group"; +import { NormalTabGroup } from "@/browser/tabs/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/tab-group/controllers/index.ts b/src/main/browser/tabs/tab-group/controllers/index.ts index db7275f8..aaee8b35 100644 --- a/src/main/browser/tabs/tab-group/controllers/index.ts +++ b/src/main/browser/tabs/tab-group/controllers/index.ts @@ -1,5 +1,6 @@ import { TabGroupFocusedTabController } from "@/browser/tabs/tab-group/controllers/focused-tab"; import { TabGroupTabsController } from "@/browser/tabs/tab-group/controllers/tabs"; import { TabGroupWindowController } from "@/browser/tabs/tab-group/controllers/window"; +import { TabGroupVisiblityController } from "@/browser/tabs/tab-group/controllers/visiblity"; -export { TabGroupFocusedTabController, TabGroupTabsController, TabGroupWindowController }; +export { TabGroupFocusedTabController, TabGroupTabsController, TabGroupWindowController, TabGroupVisiblityController }; diff --git a/src/main/browser/tabs/tab-group/controllers/space.ts b/src/main/browser/tabs/tab-group/controllers/space.ts new file mode 100644 index 00000000..657c8942 --- /dev/null +++ b/src/main/browser/tabs/tab-group/controllers/space.ts @@ -0,0 +1,28 @@ +import { TabGroup } from "@/browser/tabs/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/tab-group/controllers/tabs.ts b/src/main/browser/tabs/tab-group/controllers/tabs.ts index b3efb250..e22c2d82 100644 --- a/src/main/browser/tabs/tab-group/controllers/tabs.ts +++ b/src/main/browser/tabs/tab-group/controllers/tabs.ts @@ -132,6 +132,15 @@ export class TabGroupTabsController { 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. diff --git a/src/main/browser/tabs/tab-group/controllers/visiblity.ts b/src/main/browser/tabs/tab-group/controllers/visiblity.ts new file mode 100644 index 00000000..76914ca1 --- /dev/null +++ b/src/main/browser/tabs/tab-group/controllers/visiblity.ts @@ -0,0 +1,40 @@ +import { TabGroup } from "@/browser/tabs/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/tab-group/index.ts b/src/main/browser/tabs/tab-group/index.ts index 691a5db1..84f3a0b7 100644 --- a/src/main/browser/tabs/tab-group/index.ts +++ b/src/main/browser/tabs/tab-group/index.ts @@ -4,6 +4,7 @@ import { Tab } from "@/browser/tabs/tab"; import { TabGroupFocusedTabController, TabGroupTabsController, + TabGroupVisiblityController, TabGroupWindowController } from "@/browser/tabs/tab-group/controllers"; import { Browser } from "@/browser/browser"; @@ -15,14 +16,14 @@ type TabGroupEvents = { "window-changed": []; "tab-added": [Tab]; "tab-removed": [Tab]; + "space-changed": []; destroyed: []; }; export interface TabGroupCreationDetails { browser: Browser; - window: TabbedBrowserWindow; - spaceId: string; + space: string; } export interface TabGroupVariant { @@ -43,6 +44,7 @@ export class TabGroup extends TypedEventEmitter { public readonly window: TabGroupWindowController; public readonly tabs: TabGroupTabsController; public readonly focusedTab: TabGroupFocusedTabController; + public readonly visiblity: TabGroupVisiblityController; constructor(variant: TabGroupVariant, details: TabGroupCreationDetails) { super(); @@ -57,6 +59,7 @@ export class TabGroup extends TypedEventEmitter { this.window = new TabGroupWindowController(this); this.tabs = new TabGroupTabsController(this); this.focusedTab = new TabGroupFocusedTabController(this); + this.visiblity = new TabGroupVisiblityController(this); } public destroy() { From d0af10db4c801539cdcb42bd5b63da9a5b4ee2eb Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Wed, 9 Jul 2025 18:52:11 +0100 Subject: [PATCH 18/25] feat: Active Tab Group Manager --- .../browser/tabs/active-tab-group-manager.ts | 68 +++++++++++++++++++ src/main/browser/tabs/index.ts | 3 + 2 files changed, 71 insertions(+) create mode 100644 src/main/browser/tabs/active-tab-group-manager.ts diff --git a/src/main/browser/tabs/active-tab-group-manager.ts b/src/main/browser/tabs/active-tab-group-manager.ts new file mode 100644 index 00000000..54b8ddba --- /dev/null +++ b/src/main/browser/tabs/active-tab-group-manager.ts @@ -0,0 +1,68 @@ +import { Browser } from "@/browser/browser"; +import { TabGroup } from "@/browser/tabs/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/index.ts b/src/main/browser/tabs/index.ts index a051960f..3d7dbeef 100644 --- a/src/main/browser/tabs/index.ts +++ b/src/main/browser/tabs/index.ts @@ -1,16 +1,19 @@ import { Browser } from "@/browser/browser"; import { TabGroupManager } from "@/browser/tabs/tab-group-manager"; +import { ActiveTabGroupManager } from "@/browser/tabs/active-tab-group-manager"; import { TabManager } from "@/browser/tabs/tab-manager"; export class TabOrchestrator { private readonly browser: Browser; public readonly tabManager: TabManager; public readonly tabGroupManager: TabGroupManager; + public readonly activeTabGroupManager: ActiveTabGroupManager; constructor(browser: Browser) { this.browser = browser; this.tabManager = new TabManager(browser); this.tabGroupManager = new TabGroupManager(browser); + this.activeTabGroupManager = new ActiveTabGroupManager(browser); } public destroy(): void { From a14974685d190227eb9895cb43e9234d5cdbda88 Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Fri, 11 Jul 2025 00:23:12 +0100 Subject: [PATCH 19/25] chore: delete old tab system --- src/main/browser/tabs/_old/tab-bounds.ts | 265 ----- .../browser/tabs/_old/tab-context-menu.ts | 286 ----- .../browser/tabs/_old/tab-groups/glance.ts | 21 - .../browser/tabs/_old/tab-groups/index.ts | 232 ---- .../browser/tabs/_old/tab-groups/split.ts | 7 - src/main/browser/tabs/_old/tab-manager.ts | 767 -------------- src/main/browser/tabs/_old/tab.ts | 996 ------------------ 7 files changed, 2574 deletions(-) delete mode 100644 src/main/browser/tabs/_old/tab-bounds.ts delete mode 100644 src/main/browser/tabs/_old/tab-context-menu.ts delete mode 100644 src/main/browser/tabs/_old/tab-groups/glance.ts delete mode 100644 src/main/browser/tabs/_old/tab-groups/index.ts delete mode 100644 src/main/browser/tabs/_old/tab-groups/split.ts delete mode 100644 src/main/browser/tabs/_old/tab-manager.ts delete mode 100644 src/main/browser/tabs/_old/tab.ts diff --git a/src/main/browser/tabs/_old/tab-bounds.ts b/src/main/browser/tabs/_old/tab-bounds.ts deleted file mode 100644 index de20a79e..00000000 --- a/src/main/browser/tabs/_old/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/_old/tab-context-menu.ts b/src/main/browser/tabs/_old/tab-context-menu.ts deleted file mode 100644 index 186dd679..00000000 --- a/src/main/browser/tabs/_old/tab-context-menu.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { Browser } from "@/browser/browser"; -import { Tab } from "@/browser/tabs/tab"; -import { TabbedBrowserWindow } from "@/browser/window"; -import contextMenu from "electron-context-menu"; - -// Define types for navigation history -interface NavigationHistory { - canGoBack: () => boolean; - canGoForward: () => boolean; - goBack: () => void; - goForward: () => void; -} - -// Define interface for menu actions -type MenuItemFunction = (options: Record) => Electron.MenuItemConstructorOptions; -type InspectFunction = () => Electron.MenuItemConstructorOptions; - -interface MenuActions { - lookUpSelection: MenuItemFunction; - copyLink: MenuItemFunction; - cut: MenuItemFunction; - copy: MenuItemFunction; - paste: MenuItemFunction; - selectAll: MenuItemFunction; - inspect: InspectFunction; - copyImage: MenuItemFunction; - copyImageAddress: MenuItemFunction; - separator: InspectFunction; - [key: string]: MenuItemFunction | InspectFunction; -} - -export function createTabContextMenu( - browser: Browser, - tab: Tab, - profileId: string, - tabbedWindow: TabbedBrowserWindow, - spaceId: string -) { - const webContents = tab.webContents; - - contextMenu({ - window: webContents, - menu(defaultActions, parameters, _browserWindow, dictionarySuggestions): Electron.MenuItemConstructorOptions[] { - const navigationHistory = webContents.navigationHistory as NavigationHistory; - const canGoBack = navigationHistory.canGoBack(); - const canGoForward = navigationHistory.canGoForward(); - const lookUpSelection = defaultActions.lookUpSelection({}); - const searchEngine = "Google"; - - // 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); - }; - - // Create all menu sections - const openLinkItems = createOpenLinkItems(parameters, createNewTab, browser); - const linkItems = createLinkItems(defaultActions as MenuActions); - const navigationItems = createNavigationItems(navigationHistory, webContents, canGoBack, canGoForward); - const extensionItems = createExtensionItems(tab, parameters); - const textHistoryItems = createTextHistoryItems(webContents); - const textEditItems = createTextEditItems(defaultActions as MenuActions, webContents); - const selectionItems = createSelectionItems( - defaultActions as MenuActions, - parameters, - createNewTab, - searchEngine - ); - const devItems = createDevItems(defaultActions as MenuActions); - const imageItems = createImageItems(parameters, createNewTab, defaultActions as MenuActions); - - // Assemble sections in correct order - const sections: Electron.MenuItemConstructorOptions[][] = []; - const hasDictionarySuggestions = dictionarySuggestions.some((suggestion) => suggestion.visible); - if (hasDictionarySuggestions) { - sections.push(dictionarySuggestions); - } - - let noSpecialActions = false; - const hasLink = !!parameters.linkURL; - const hasLookUpSelection = lookUpSelection.visible; - - if (hasLink) { - sections.push(openLinkItems); - sections.push(linkItems); - } else if (hasLookUpSelection && parameters.selectionText.trim()) { - sections.push([lookUpSelection]); - } else if (parameters.hasImageContents) { - sections.push(imageItems); - } else { - noSpecialActions = true; - sections.push(navigationItems); - } - - if (parameters.selectionText.trim() && !parameters.isEditable) { - sections.push(selectionItems); - } - - if (parameters.isEditable) { - sections.push(textHistoryItems); - sections.push(textEditItems); - } - - sections.push(extensionItems); - sections.push([ - { - label: "View Page Source", - click: () => { - createNewTab(`view-source:${webContents.getURL()}`); - }, - visible: noSpecialActions - }, - ...devItems - ]); - - // Combine all sections with separators - return combineSections(sections, defaultActions as MenuActions); - } - }); -} - -function createOpenLinkItems( - parameters: Electron.ContextMenuParams, - createNewTab: (url: string, window?: TabbedBrowserWindow) => Promise, - browser: Browser -): Electron.MenuItemConstructorOptions[] { - return [ - { - label: "Open Link in New Tab", - click: () => { - createNewTab(parameters.linkURL); - } - }, - { - label: "Open Link in New Window", - click: async () => { - const newWindow = await browser.createWindow("normal"); - createNewTab(parameters.linkURL, newWindow); - } - } - ]; -} - -function createLinkItems(defaultActions: MenuActions): Electron.MenuItemConstructorOptions[] { - const copyLinkItem = defaultActions.copyLink({}); - copyLinkItem.visible = true; - return [copyLinkItem]; -} - -function createNavigationItems( - navigationHistory: NavigationHistory, - webContents: Electron.WebContents, - canGoBack: boolean, - canGoForward: boolean -): Electron.MenuItemConstructorOptions[] { - return [ - { - label: "Back", - click: () => { - navigationHistory.goBack(); - }, - enabled: canGoBack - }, - { - label: "Forward", - click: () => { - navigationHistory.goForward(); - }, - enabled: canGoForward - }, - { - label: "Reload", - click: () => { - webContents.reload(); - }, - enabled: true - } - ]; -} - -function createExtensionItems(tab: Tab, parameters: Electron.ContextMenuParams): Electron.MenuItemConstructorOptions[] { - const extensions = tab.loadedProfile.extensions; - // @ts-expect-error: ts error, but still works - const items: Electron.MenuItemConstructorOptions[] = extensions.getContextMenuItems(tab.webContents, parameters); - return items; -} - -function createTextHistoryItems(webContents: Electron.WebContents): Electron.MenuItemConstructorOptions[] { - return [ - { - label: "Undo", - click: () => { - webContents.undo(); - }, - enabled: true - }, - { - label: "Redo", - click: () => { - webContents.redo(); - }, - enabled: true - } - ]; -} - -function createTextEditItems( - defaultActions: MenuActions, - webContents: Electron.WebContents -): Electron.MenuItemConstructorOptions[] { - return [ - defaultActions.cut({}), - defaultActions.copy({}), - defaultActions.paste({}), - { - label: "Paste and Match Style", - click: () => { - webContents.pasteAndMatchStyle(); - }, - enabled: true - }, - defaultActions.selectAll({}) - ]; -} - -function createSelectionItems( - defaultActions: MenuActions, - parameters: Electron.ContextMenuParams, - createNewTab: (url: string) => Promise, - searchEngine: string -): Electron.MenuItemConstructorOptions[] { - return [ - defaultActions.copy({}), - { - label: `Search ${searchEngine} for "${parameters.selectionText}"`, - click: () => { - const searchURL = new URL("https://www.google.com/search"); - searchURL.searchParams.set("q", parameters.selectionText); - createNewTab(searchURL.toString()); - } - } - ]; -} - -function createDevItems(defaultActions: MenuActions): Electron.MenuItemConstructorOptions[] { - return [defaultActions.inspect()]; -} - -function createImageItems( - parameters: Electron.ContextMenuParams, - createNewTab: (url: string) => Promise, - defaultActions: MenuActions -): Electron.MenuItemConstructorOptions[] { - return [ - { - label: "Open Image in New Tab", - click: () => { - createNewTab(parameters.srcURL); - } - }, - defaultActions.copyImage({}), - defaultActions.copyImageAddress({}) - ]; -} - -function combineSections( - sections: Electron.MenuItemConstructorOptions[][], - defaultActions: MenuActions -): Electron.MenuItemConstructorOptions[] { - const combinedSections: Electron.MenuItemConstructorOptions[] = []; - - sections.forEach((section, index) => { - // Only add non-empty sections - if (section.length > 0) { - combinedSections.push(...section); - - // Add separator if this isn't the last section - if (index < sections.length - 1) { - combinedSections.push(defaultActions.separator()); - } - } - }); - - return combinedSections; -} diff --git a/src/main/browser/tabs/_old/tab-groups/glance.ts b/src/main/browser/tabs/_old/tab-groups/glance.ts deleted file mode 100644 index 3cd905b7..00000000 --- a/src/main/browser/tabs/_old/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/_old/tab-groups/index.ts b/src/main/browser/tabs/_old/tab-groups/index.ts deleted file mode 100644 index de968971..00000000 --- a/src/main/browser/tabs/_old/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/_old/tab-groups/split.ts b/src/main/browser/tabs/_old/tab-groups/split.ts deleted file mode 100644 index ca9b3596..00000000 --- a/src/main/browser/tabs/_old/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/_old/tab-manager.ts b/src/main/browser/tabs/_old/tab-manager.ts deleted file mode 100644 index ca86aaf4..00000000 --- a/src/main/browser/tabs/_old/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/_old/tab.ts b/src/main/browser/tabs/_old/tab.ts deleted file mode 100644 index 6a0d6565..00000000 --- a/src/main/browser/tabs/_old/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(); - } -} From 7fd5ba3a61508bda03c77ecc762a92af6986141d Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Fri, 11 Jul 2025 00:42:15 +0100 Subject: [PATCH 20/25] chore: move & reorganise files --- src/main/browser/profile-manager.ts | 2 +- src/main/browser/tabs/index.ts | 6 ++--- .../active-tab-group-manager.ts | 2 +- .../tabs/{ => managers}/tab-group-manager.ts | 6 ++--- .../tabs/{ => managers}/tab-manager.ts | 2 +- .../tab-group/controllers/focused-tab.ts | 4 +-- .../objects/tab-group/controllers/index.ts | 6 +++++ .../tab-group/controllers/space.ts | 2 +- .../tab-group/controllers/tabs.ts | 4 +-- .../tab-group/controllers/visiblity.ts | 2 +- .../tab-group/controllers/window.ts | 2 +- .../tabs/{ => objects}/tab-group/index.ts | 4 +-- .../{ => objects}/tab-group/types/normal.ts | 0 .../{ => objects}/tab/controllers/bounds.ts | 2 +- .../tab/controllers/context-menu.ts | 2 +- .../{ => objects}/tab/controllers/data.ts | 2 +- .../tab/controllers/error-page.ts | 2 +- .../tabs/objects/tab/controllers/index.ts | 25 +++++++++++++++++++ .../tab/controllers/navigation.ts | 2 +- .../tabs/{ => objects}/tab/controllers/pip.ts | 2 +- .../{ => objects}/tab/controllers/saving.ts | 2 +- .../{ => objects}/tab/controllers/sleep.ts | 2 +- .../tab/controllers/visiblity.ts | 2 +- .../{ => objects}/tab/controllers/webview.ts | 2 +- .../{ => objects}/tab/controllers/window.ts | 2 +- .../browser/tabs/{ => objects}/tab/index.ts | 2 +- .../tabs/tab-group/controllers/index.ts | 6 ----- .../browser/tabs/tab/controllers/index.ts | 25 ------------------- 28 files changed, 61 insertions(+), 61 deletions(-) rename src/main/browser/tabs/{ => managers}/active-tab-group-manager.ts (96%) rename src/main/browser/tabs/{ => managers}/tab-group-manager.ts (94%) rename src/main/browser/tabs/{ => managers}/tab-manager.ts (98%) rename src/main/browser/tabs/{ => objects}/tab-group/controllers/focused-tab.ts (92%) create mode 100644 src/main/browser/tabs/objects/tab-group/controllers/index.ts rename src/main/browser/tabs/{ => objects}/tab-group/controllers/space.ts (89%) rename src/main/browser/tabs/{ => objects}/tab-group/controllers/tabs.ts (97%) rename src/main/browser/tabs/{ => objects}/tab-group/controllers/visiblity.ts (93%) rename src/main/browser/tabs/{ => objects}/tab-group/controllers/window.ts (93%) rename src/main/browser/tabs/{ => objects}/tab-group/index.ts (95%) rename src/main/browser/tabs/{ => objects}/tab-group/types/normal.ts (100%) rename src/main/browser/tabs/{ => objects}/tab/controllers/bounds.ts (96%) rename src/main/browser/tabs/{ => objects}/tab/controllers/context-menu.ts (99%) rename src/main/browser/tabs/{ => objects}/tab/controllers/data.ts (98%) rename src/main/browser/tabs/{ => objects}/tab/controllers/error-page.ts (96%) create mode 100644 src/main/browser/tabs/objects/tab/controllers/index.ts rename src/main/browser/tabs/{ => objects}/tab/controllers/navigation.ts (98%) rename src/main/browser/tabs/{ => objects}/tab/controllers/pip.ts (98%) rename src/main/browser/tabs/{ => objects}/tab/controllers/saving.ts (70%) rename src/main/browser/tabs/{ => objects}/tab/controllers/sleep.ts (94%) rename src/main/browser/tabs/{ => objects}/tab/controllers/visiblity.ts (94%) rename src/main/browser/tabs/{ => objects}/tab/controllers/webview.ts (97%) rename src/main/browser/tabs/{ => objects}/tab/controllers/window.ts (96%) rename src/main/browser/tabs/{ => objects}/tab/index.ts (98%) delete mode 100644 src/main/browser/tabs/tab-group/controllers/index.ts delete mode 100644 src/main/browser/tabs/tab/controllers/index.ts 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 index 3d7dbeef..78421167 100644 --- a/src/main/browser/tabs/index.ts +++ b/src/main/browser/tabs/index.ts @@ -1,7 +1,7 @@ import { Browser } from "@/browser/browser"; -import { TabGroupManager } from "@/browser/tabs/tab-group-manager"; -import { ActiveTabGroupManager } from "@/browser/tabs/active-tab-group-manager"; -import { TabManager } from "@/browser/tabs/tab-manager"; +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"; export class TabOrchestrator { private readonly browser: Browser; diff --git a/src/main/browser/tabs/active-tab-group-manager.ts b/src/main/browser/tabs/managers/active-tab-group-manager.ts similarity index 96% rename from src/main/browser/tabs/active-tab-group-manager.ts rename to src/main/browser/tabs/managers/active-tab-group-manager.ts index 54b8ddba..c7d54ec6 100644 --- a/src/main/browser/tabs/active-tab-group-manager.ts +++ b/src/main/browser/tabs/managers/active-tab-group-manager.ts @@ -1,5 +1,5 @@ import { Browser } from "@/browser/browser"; -import { TabGroup } from "@/browser/tabs/tab-group"; +import { TabGroup } from "@/browser/tabs/objects/tab-group"; import { TabbedBrowserWindow } from "@/browser/window"; import { TypedEventEmitter } from "@/modules/typed-event-emitter"; diff --git a/src/main/browser/tabs/tab-group-manager.ts b/src/main/browser/tabs/managers/tab-group-manager.ts similarity index 94% rename from src/main/browser/tabs/tab-group-manager.ts rename to src/main/browser/tabs/managers/tab-group-manager.ts index 01b1b687..a6c9a871 100644 --- a/src/main/browser/tabs/tab-group-manager.ts +++ b/src/main/browser/tabs/managers/tab-group-manager.ts @@ -1,7 +1,7 @@ import { Browser } from "@/browser/browser"; -import { Tab } from "@/browser/tabs/tab"; -import { TabGroup } from "@/browser/tabs/tab-group"; -import { NormalTabGroup } from "@/browser/tabs/tab-group/types/normal"; +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"; diff --git a/src/main/browser/tabs/tab-manager.ts b/src/main/browser/tabs/managers/tab-manager.ts similarity index 98% rename from src/main/browser/tabs/tab-manager.ts rename to src/main/browser/tabs/managers/tab-manager.ts index 37e7af1d..529d24f7 100644 --- a/src/main/browser/tabs/tab-manager.ts +++ b/src/main/browser/tabs/managers/tab-manager.ts @@ -1,5 +1,5 @@ import { Browser } from "@/browser/browser"; -import { Tab, TabCreationDetails } from "@/browser/tabs/tab"; +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"; diff --git a/src/main/browser/tabs/tab-group/controllers/focused-tab.ts b/src/main/browser/tabs/objects/tab-group/controllers/focused-tab.ts similarity index 92% rename from src/main/browser/tabs/tab-group/controllers/focused-tab.ts rename to src/main/browser/tabs/objects/tab-group/controllers/focused-tab.ts index 71373d1a..b86fbca3 100644 --- a/src/main/browser/tabs/tab-group/controllers/focused-tab.ts +++ b/src/main/browser/tabs/objects/tab-group/controllers/focused-tab.ts @@ -1,5 +1,5 @@ -import { Tab } from "@/browser/tabs/tab"; -import { TabGroup } from "@/browser/tabs/tab-group"; +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. 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/tab-group/controllers/space.ts b/src/main/browser/tabs/objects/tab-group/controllers/space.ts similarity index 89% rename from src/main/browser/tabs/tab-group/controllers/space.ts rename to src/main/browser/tabs/objects/tab-group/controllers/space.ts index 657c8942..a1b56c0c 100644 --- a/src/main/browser/tabs/tab-group/controllers/space.ts +++ b/src/main/browser/tabs/objects/tab-group/controllers/space.ts @@ -1,4 +1,4 @@ -import { TabGroup } from "@/browser/tabs/tab-group"; +import { TabGroup } from "@/browser/tabs/objects/tab-group"; export class TabGroupSpaceController { private readonly tabGroup: TabGroup; diff --git a/src/main/browser/tabs/tab-group/controllers/tabs.ts b/src/main/browser/tabs/objects/tab-group/controllers/tabs.ts similarity index 97% rename from src/main/browser/tabs/tab-group/controllers/tabs.ts rename to src/main/browser/tabs/objects/tab-group/controllers/tabs.ts index e22c2d82..0d7bf530 100644 --- a/src/main/browser/tabs/tab-group/controllers/tabs.ts +++ b/src/main/browser/tabs/objects/tab-group/controllers/tabs.ts @@ -1,6 +1,6 @@ import { Browser } from "@/browser/browser"; -import { Tab } from "@/browser/tabs/tab"; -import { TabGroup } from "@/browser/tabs/tab-group"; +import { Tab } from "@/browser/tabs/objects/tab"; +import { TabGroup } from "@/browser/tabs/objects/tab-group"; /** * Controller responsible for managing tabs within a tab group. diff --git a/src/main/browser/tabs/tab-group/controllers/visiblity.ts b/src/main/browser/tabs/objects/tab-group/controllers/visiblity.ts similarity index 93% rename from src/main/browser/tabs/tab-group/controllers/visiblity.ts rename to src/main/browser/tabs/objects/tab-group/controllers/visiblity.ts index 76914ca1..1a04b35d 100644 --- a/src/main/browser/tabs/tab-group/controllers/visiblity.ts +++ b/src/main/browser/tabs/objects/tab-group/controllers/visiblity.ts @@ -1,4 +1,4 @@ -import { TabGroup } from "@/browser/tabs/tab-group"; +import { TabGroup } from "@/browser/tabs/objects/tab-group"; export class TabGroupVisiblityController { private readonly tabGroup: TabGroup; diff --git a/src/main/browser/tabs/tab-group/controllers/window.ts b/src/main/browser/tabs/objects/tab-group/controllers/window.ts similarity index 93% rename from src/main/browser/tabs/tab-group/controllers/window.ts rename to src/main/browser/tabs/objects/tab-group/controllers/window.ts index 1eecb2a2..bdcf2bb9 100644 --- a/src/main/browser/tabs/tab-group/controllers/window.ts +++ b/src/main/browser/tabs/objects/tab-group/controllers/window.ts @@ -1,4 +1,4 @@ -import { TabGroup } from "@/browser/tabs/tab-group"; +import { TabGroup } from "@/browser/tabs/objects/tab-group"; import { TabbedBrowserWindow } from "@/browser/window"; export class TabGroupWindowController { diff --git a/src/main/browser/tabs/tab-group/index.ts b/src/main/browser/tabs/objects/tab-group/index.ts similarity index 95% rename from src/main/browser/tabs/tab-group/index.ts rename to src/main/browser/tabs/objects/tab-group/index.ts index 84f3a0b7..97ad81ed 100644 --- a/src/main/browser/tabs/tab-group/index.ts +++ b/src/main/browser/tabs/objects/tab-group/index.ts @@ -1,12 +1,12 @@ import { TypedEventEmitter } from "@/modules/typed-event-emitter"; import { generateID } from "@/modules/utils"; -import { Tab } from "@/browser/tabs/tab"; +import { Tab } from "@/browser/tabs/objects/tab"; import { TabGroupFocusedTabController, TabGroupTabsController, TabGroupVisiblityController, TabGroupWindowController -} from "@/browser/tabs/tab-group/controllers"; +} from "@/browser/tabs/objects/tab-group/controllers"; import { Browser } from "@/browser/browser"; import { TabbedBrowserWindow } from "@/browser/window"; diff --git a/src/main/browser/tabs/tab-group/types/normal.ts b/src/main/browser/tabs/objects/tab-group/types/normal.ts similarity index 100% rename from src/main/browser/tabs/tab-group/types/normal.ts rename to src/main/browser/tabs/objects/tab-group/types/normal.ts diff --git a/src/main/browser/tabs/tab/controllers/bounds.ts b/src/main/browser/tabs/objects/tab/controllers/bounds.ts similarity index 96% rename from src/main/browser/tabs/tab/controllers/bounds.ts rename to src/main/browser/tabs/objects/tab/controllers/bounds.ts index 13233947..4cec04e7 100644 --- a/src/main/browser/tabs/tab/controllers/bounds.ts +++ b/src/main/browser/tabs/objects/tab/controllers/bounds.ts @@ -1,4 +1,4 @@ -import { Tab } from "@/browser/tabs/tab"; +import { Tab } from "@/browser/tabs/objects/tab"; import { PageBounds } from "~/flow/types"; export class TabBoundsController { diff --git a/src/main/browser/tabs/tab/controllers/context-menu.ts b/src/main/browser/tabs/objects/tab/controllers/context-menu.ts similarity index 99% rename from src/main/browser/tabs/tab/controllers/context-menu.ts rename to src/main/browser/tabs/objects/tab/controllers/context-menu.ts index 50977fb7..029557c3 100644 --- a/src/main/browser/tabs/tab/controllers/context-menu.ts +++ b/src/main/browser/tabs/objects/tab/controllers/context-menu.ts @@ -1,4 +1,4 @@ -import { Tab } from "@/browser/tabs/tab"; +import { Tab } from "@/browser/tabs/objects/tab"; import { Browser } from "@/browser/browser"; import { TabbedBrowserWindow } from "@/browser/window"; import contextMenu from "electron-context-menu"; diff --git a/src/main/browser/tabs/tab/controllers/data.ts b/src/main/browser/tabs/objects/tab/controllers/data.ts similarity index 98% rename from src/main/browser/tabs/tab/controllers/data.ts rename to src/main/browser/tabs/objects/tab/controllers/data.ts index d087ad85..989559ad 100644 --- a/src/main/browser/tabs/tab/controllers/data.ts +++ b/src/main/browser/tabs/objects/tab/controllers/data.ts @@ -1,4 +1,4 @@ -import { Tab } from "@/browser/tabs/tab"; +import { Tab } from "@/browser/tabs/objects/tab"; import { TabbedBrowserWindow } from "@/browser/window"; import { WebContents } from "electron"; diff --git a/src/main/browser/tabs/tab/controllers/error-page.ts b/src/main/browser/tabs/objects/tab/controllers/error-page.ts similarity index 96% rename from src/main/browser/tabs/tab/controllers/error-page.ts rename to src/main/browser/tabs/objects/tab/controllers/error-page.ts index acd09ade..a90a5572 100644 --- a/src/main/browser/tabs/tab/controllers/error-page.ts +++ b/src/main/browser/tabs/objects/tab/controllers/error-page.ts @@ -1,4 +1,4 @@ -import { Tab } from "@/browser/tabs/tab"; +import { Tab } from "@/browser/tabs/objects/tab"; import { FLAGS } from "@/modules/flags"; export class TabErrorPageController { 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/tab/controllers/navigation.ts b/src/main/browser/tabs/objects/tab/controllers/navigation.ts similarity index 98% rename from src/main/browser/tabs/tab/controllers/navigation.ts rename to src/main/browser/tabs/objects/tab/controllers/navigation.ts index e1e0ce1e..1817daf9 100644 --- a/src/main/browser/tabs/tab/controllers/navigation.ts +++ b/src/main/browser/tabs/objects/tab/controllers/navigation.ts @@ -1,4 +1,4 @@ -import { Tab } from "@/browser/tabs/tab"; +import { Tab } from "@/browser/tabs/objects/tab"; import { NavigationEntry, WebContents } from "electron"; export const DEFAULT_URL = "flow://new-tab"; diff --git a/src/main/browser/tabs/tab/controllers/pip.ts b/src/main/browser/tabs/objects/tab/controllers/pip.ts similarity index 98% rename from src/main/browser/tabs/tab/controllers/pip.ts rename to src/main/browser/tabs/objects/tab/controllers/pip.ts index df823e76..f13f2d34 100644 --- a/src/main/browser/tabs/tab/controllers/pip.ts +++ b/src/main/browser/tabs/objects/tab/controllers/pip.ts @@ -1,4 +1,4 @@ -import { Tab } from "@/browser/tabs/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 () { diff --git a/src/main/browser/tabs/tab/controllers/saving.ts b/src/main/browser/tabs/objects/tab/controllers/saving.ts similarity index 70% rename from src/main/browser/tabs/tab/controllers/saving.ts rename to src/main/browser/tabs/objects/tab/controllers/saving.ts index f823bc76..5d9b2f39 100644 --- a/src/main/browser/tabs/tab/controllers/saving.ts +++ b/src/main/browser/tabs/objects/tab/controllers/saving.ts @@ -1,4 +1,4 @@ -import { Tab } from "@/browser/tabs/tab"; +import { Tab } from "@/browser/tabs/objects/tab"; export class TabSavingController { private readonly tab: Tab; diff --git a/src/main/browser/tabs/tab/controllers/sleep.ts b/src/main/browser/tabs/objects/tab/controllers/sleep.ts similarity index 94% rename from src/main/browser/tabs/tab/controllers/sleep.ts rename to src/main/browser/tabs/objects/tab/controllers/sleep.ts index bd99d379..7cbb6d44 100644 --- a/src/main/browser/tabs/tab/controllers/sleep.ts +++ b/src/main/browser/tabs/objects/tab/controllers/sleep.ts @@ -1,4 +1,4 @@ -import { Tab } from "@/browser/tabs/tab"; +import { Tab } from "@/browser/tabs/objects/tab"; export class TabSleepController { private readonly tab: Tab; diff --git a/src/main/browser/tabs/tab/controllers/visiblity.ts b/src/main/browser/tabs/objects/tab/controllers/visiblity.ts similarity index 94% rename from src/main/browser/tabs/tab/controllers/visiblity.ts rename to src/main/browser/tabs/objects/tab/controllers/visiblity.ts index ed3d683a..00d5d071 100644 --- a/src/main/browser/tabs/tab/controllers/visiblity.ts +++ b/src/main/browser/tabs/objects/tab/controllers/visiblity.ts @@ -1,4 +1,4 @@ -import { Tab } from "@/browser/tabs/tab"; +import { Tab } from "@/browser/tabs/objects/tab"; export class TabVisiblityController { private readonly tab: Tab; diff --git a/src/main/browser/tabs/tab/controllers/webview.ts b/src/main/browser/tabs/objects/tab/controllers/webview.ts similarity index 97% rename from src/main/browser/tabs/tab/controllers/webview.ts rename to src/main/browser/tabs/objects/tab/controllers/webview.ts index 2dee9b5d..68fd230c 100644 --- a/src/main/browser/tabs/tab/controllers/webview.ts +++ b/src/main/browser/tabs/objects/tab/controllers/webview.ts @@ -1,4 +1,4 @@ -import { Tab } from "@/browser/tabs/tab"; +import { Tab } from "@/browser/tabs/objects/tab"; import { Session, WebContents, WebContentsView, WebPreferences } from "electron"; interface PatchedWebContentsView extends WebContentsView { diff --git a/src/main/browser/tabs/tab/controllers/window.ts b/src/main/browser/tabs/objects/tab/controllers/window.ts similarity index 96% rename from src/main/browser/tabs/tab/controllers/window.ts rename to src/main/browser/tabs/objects/tab/controllers/window.ts index f7a423b9..0f85c2f8 100644 --- a/src/main/browser/tabs/tab/controllers/window.ts +++ b/src/main/browser/tabs/objects/tab/controllers/window.ts @@ -1,4 +1,4 @@ -import { Tab } from "@/browser/tabs/tab"; +import { Tab } from "@/browser/tabs/objects/tab"; import { TabbedBrowserWindow } from "@/browser/window"; const TAB_ZINDEX = 2; diff --git a/src/main/browser/tabs/tab/index.ts b/src/main/browser/tabs/objects/tab/index.ts similarity index 98% rename from src/main/browser/tabs/tab/index.ts rename to src/main/browser/tabs/objects/tab/index.ts index ac40a334..097a929c 100644 --- a/src/main/browser/tabs/tab/index.ts +++ b/src/main/browser/tabs/objects/tab/index.ts @@ -12,7 +12,7 @@ import { TabNavigationController, TabDataController, TabSleepController -} from "@/browser/tabs/tab/controllers"; +} from "@/browser/tabs/objects/tab/controllers"; import { TabbedBrowserWindow } from "@/browser/window"; import { TypedEventEmitter } from "@/modules/typed-event-emitter"; import { generateID } from "@/modules/utils"; diff --git a/src/main/browser/tabs/tab-group/controllers/index.ts b/src/main/browser/tabs/tab-group/controllers/index.ts deleted file mode 100644 index aaee8b35..00000000 --- a/src/main/browser/tabs/tab-group/controllers/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { TabGroupFocusedTabController } from "@/browser/tabs/tab-group/controllers/focused-tab"; -import { TabGroupTabsController } from "@/browser/tabs/tab-group/controllers/tabs"; -import { TabGroupWindowController } from "@/browser/tabs/tab-group/controllers/window"; -import { TabGroupVisiblityController } from "@/browser/tabs/tab-group/controllers/visiblity"; - -export { TabGroupFocusedTabController, TabGroupTabsController, TabGroupWindowController, TabGroupVisiblityController }; diff --git a/src/main/browser/tabs/tab/controllers/index.ts b/src/main/browser/tabs/tab/controllers/index.ts deleted file mode 100644 index 4d035e4f..00000000 --- a/src/main/browser/tabs/tab/controllers/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { TabBoundsController } from "@/browser/tabs/tab/controllers/bounds"; -import { TabPipController } from "@/browser/tabs/tab/controllers/pip"; -import { TabSavingController } from "@/browser/tabs/tab/controllers/saving"; -import { TabVisiblityController } from "@/browser/tabs/tab/controllers/visiblity"; -import { TabWebviewController } from "@/browser/tabs/tab/controllers/webview"; -import { TabWindowController } from "@/browser/tabs/tab/controllers/window"; -import { TabContextMenuController } from "@/browser/tabs/tab/controllers/context-menu"; -import { TabErrorPageController } from "@/browser/tabs/tab/controllers/error-page"; -import { TabNavigationController } from "@/browser/tabs/tab/controllers/navigation"; -import { TabDataController } from "@/browser/tabs/tab/controllers/data"; -import { TabSleepController } from "@/browser/tabs/tab/controllers/sleep"; - -export { - TabBoundsController, - TabPipController, - TabSavingController, - TabVisiblityController, - TabWebviewController, - TabWindowController, - TabContextMenuController, - TabErrorPageController, - TabNavigationController, - TabDataController, - TabSleepController -}; From eaa1c24e6978ab81b6d3899ded14d5ecf68d949f Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Fri, 11 Jul 2025 16:19:48 +0100 Subject: [PATCH 21/25] feat: tab container --- .../browser/tabs/objects/tab-group/index.ts | 7 ++ .../browser/tabs/objects/tabs-container.ts | 90 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/main/browser/tabs/objects/tabs-container.ts diff --git a/src/main/browser/tabs/objects/tab-group/index.ts b/src/main/browser/tabs/objects/tab-group/index.ts index 97ad81ed..9ff417f8 100644 --- a/src/main/browser/tabs/objects/tab-group/index.ts +++ b/src/main/browser/tabs/objects/tab-group/index.ts @@ -9,6 +9,7 @@ import { } from "@/browser/tabs/objects/tab-group/controllers"; import { Browser } from "@/browser/browser"; import { TabbedBrowserWindow } from "@/browser/window"; +import { ExportedTabGroup } from "@/browser/tabs/objects/tabs-container"; type TabGroupTypes = "normal" | "split" | "glance"; @@ -78,4 +79,10 @@ export class TabGroup extends TypedEventEmitter { throw new Error("Tab group already destroyed"); } } + + public export(): ExportedTabGroup { + return { + type: "tab-group" + }; + } } diff --git a/src/main/browser/tabs/objects/tabs-container.ts b/src/main/browser/tabs/objects/tabs-container.ts new file mode 100644 index 00000000..df9245a0 --- /dev/null +++ b/src/main/browser/tabs/objects/tabs-container.ts @@ -0,0 +1,90 @@ +import { TabGroup } from "@/browser/tabs/objects/tab-group"; +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; + +// Container Child // +type TabGroupChild = { + type: "tab-group"; + item: TabGroup; +}; +type TabContainerChild = { + type: "tab-container"; + item: TabsContainer; +}; +export type ContainerChild = TabGroupChild | TabContainerChild; + +// Exported Tab Data // +export type ExportedTabGroup = { + type: "tab-group"; +}; +export type ExportedTabsContainer = { + type: "tabs-container"; + name: string; + children: ExportedTabData[]; +}; +type ExportedTabData = ExportedTabGroup | ExportedTabsContainer; + +// Tabs Container // +type TabsContainerEvents = { + "child-added": [child: ContainerChild]; + "child-removed": [child: ContainerChild]; + "children-moved": []; +}; + +export class TabsContainer extends TypedEventEmitter { + public name: string; + public children: ContainerChild[]; + + constructor(name: string) { + super(); + + this.name = name; + this.children = []; + } + + public addChild(child: ContainerChild): void { + this.children.push(child); + this.emit("child-added", child); + } + + public 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; + } + + 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; + } + + 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; + } + + public export(): ExportedTabsContainer { + return { + type: "tabs-container", + name: this.name, + children: this.children.map((child) => { + return child.item.export(); + }) + }; + } +} From a1304f2203d8d8542395d6118eace4b6d880778a Mon Sep 17 00:00:00 2001 From: iamEvan <47493765+iamEvanYT@users.noreply.github.com> Date: Mon, 14 Jul 2025 23:54:49 +0100 Subject: [PATCH 22/25] feat: tab containers --- src/main/browser/tabs/index.ts | 5 +- .../base.ts} | 58 ++++++++++++++----- .../objects/tab-containers/tab-container.ts | 27 +++++++++ .../tabs/objects/tab-containers/tab-folder.ts | 23 ++++++++ .../browser/tabs/objects/tab-group/index.ts | 2 +- 5 files changed, 96 insertions(+), 19 deletions(-) rename src/main/browser/tabs/objects/{tabs-container.ts => tab-containers/base.ts} (56%) create mode 100644 src/main/browser/tabs/objects/tab-containers/tab-container.ts create mode 100644 src/main/browser/tabs/objects/tab-containers/tab-folder.ts diff --git a/src/main/browser/tabs/index.ts b/src/main/browser/tabs/index.ts index 78421167..2d8e9317 100644 --- a/src/main/browser/tabs/index.ts +++ b/src/main/browser/tabs/index.ts @@ -2,18 +2,19 @@ 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 { - private readonly browser: Browser; public readonly tabManager: TabManager; public readonly tabGroupManager: TabGroupManager; public readonly activeTabGroupManager: ActiveTabGroupManager; + public readonly tabsContainerManager: TabsContainerManager; constructor(browser: Browser) { - this.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 { diff --git a/src/main/browser/tabs/objects/tabs-container.ts b/src/main/browser/tabs/objects/tab-containers/base.ts similarity index 56% rename from src/main/browser/tabs/objects/tabs-container.ts rename to src/main/browser/tabs/objects/tab-containers/base.ts index df9245a0..603f8aff 100644 --- a/src/main/browser/tabs/objects/tabs-container.ts +++ b/src/main/browser/tabs/objects/tab-containers/base.ts @@ -1,4 +1,5 @@ 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"; // Container Child // @@ -6,41 +7,67 @@ type TabGroupChild = { type: "tab-group"; item: TabGroup; }; -type TabContainerChild = { - type: "tab-container"; - item: TabsContainer; +type TabFolderChild = { + type: "tab-folder"; + item: TabFolder; }; -export type ContainerChild = TabGroupChild | TabContainerChild; +export type ContainerChild = TabGroupChild | TabFolderChild; // Exported Tab Data // +export type ExportedBaseTabContainer = { + type: "tab-container"; + children: ExportedTabData[]; +}; + export type ExportedTabGroup = { type: "tab-group"; }; -export type ExportedTabsContainer = { - type: "tabs-container"; +export type ExportedTabContainer = { + type: "tab-container"; + children: ExportedTabData[]; +}; +export type ExportedTabsFolder = { + type: "tab-folder"; name: string; children: ExportedTabData[]; }; -type ExportedTabData = ExportedTabGroup | ExportedTabsContainer; +// ExportedTabContainer would not be a child: it should only be the root container. +type ExportedTabData = ExportedTabGroup | ExportedTabsFolder; -// Tabs Container // -type TabsContainerEvents = { +// Base Tab Container // +export type BaseTabContainerEvents = { "child-added": [child: ContainerChild]; "child-removed": [child: ContainerChild]; "children-moved": []; }; -export class TabsContainer extends TypedEventEmitter { - public name: string; +export class BaseTabContainer< + TEvents extends BaseTabContainerEvents = BaseTabContainerEvents +> extends TypedEventEmitter { public children: ContainerChild[]; - constructor(name: string) { + constructor() { super(); - this.name = name; this.children = []; } + 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); + } + public addChild(child: ContainerChild): void { this.children.push(child); this.emit("child-added", child); @@ -78,10 +105,9 @@ export class TabsContainer extends TypedEventEmitter { return this.children.length; } - public export(): ExportedTabsContainer { + protected baseExport(): ExportedBaseTabContainer { return { - type: "tabs-container", - name: this.name, + 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..9af5a48c --- /dev/null +++ b/src/main/browser/tabs/objects/tab-containers/tab-container.ts @@ -0,0 +1,27 @@ +import { BaseTabContainer, BaseTabContainerEvents, ExportedTabContainer } from "./base"; + +type TabContainerEvents = BaseTabContainerEvents & { + "space-changed": [spaceId: string]; +}; + +export class TabContainer extends BaseTabContainer { + public spaceId: string; + + constructor(spaceId: string) { + super(); + 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..8480eaea --- /dev/null +++ b/src/main/browser/tabs/objects/tab-containers/tab-folder.ts @@ -0,0 +1,23 @@ +import { BaseTabContainer, ExportedTabsFolder } from "./base"; + +export class TabFolder extends BaseTabContainer { + public name: string; + + constructor(name: string) { + super(); + this.name = name; + } + + 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/index.ts b/src/main/browser/tabs/objects/tab-group/index.ts index 9ff417f8..7e78cafc 100644 --- a/src/main/browser/tabs/objects/tab-group/index.ts +++ b/src/main/browser/tabs/objects/tab-group/index.ts @@ -9,7 +9,7 @@ import { } from "@/browser/tabs/objects/tab-group/controllers"; import { Browser } from "@/browser/browser"; import { TabbedBrowserWindow } from "@/browser/window"; -import { ExportedTabGroup } from "@/browser/tabs/objects/tabs-container"; +import { ExportedTabGroup } from "@/browser/tabs/objects/tab-containers/base"; type TabGroupTypes = "normal" | "split" | "glance"; From 82d48162ca7144f1c6220728dafa2d060c570d45 Mon Sep 17 00:00:00 2001 From: iamEvan Date: Tue, 26 Aug 2025 01:38:30 +0100 Subject: [PATCH 23/25] idk --- docs/api/tabs/general.md | 42 ++++++++ docs/references/glossary.md | 7 ++ .../tabs/managers/tabs-container-manager.ts | 101 ++++++++++++++++++ .../tabs/objects/tab-containers/base.ts | 17 +++ 4 files changed, 167 insertions(+) create mode 100644 docs/api/tabs/general.md create mode 100644 docs/references/glossary.md create mode 100644 src/main/browser/tabs/managers/tabs-container-manager.ts 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/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/tabs/managers/tabs-container-manager.ts b/src/main/browser/tabs/managers/tabs-container-manager.ts new file mode 100644 index 00000000..39e1aee2 --- /dev/null +++ b/src/main/browser/tabs/managers/tabs-container-manager.ts @@ -0,0 +1,101 @@ +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; + + 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); + + const favouritesContainer = new TabContainer(spaces[0]?.id ?? ""); + } + + /** + * 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 normalContainer = new TabContainer(space.id); + + // Add tab group to the correct container on create + const browser = this.browser; + const tabOrchestrator = browser.tabs; + tabOrchestrator.tabGroupManager.on("tab-group-created", (tabGroup) => { + normalContainer.addChild({ + type: "tab-group", + item: tabGroup + }); + }); + } + + /** + * 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 index 603f8aff..c160daa9 100644 --- a/src/main/browser/tabs/objects/tab-containers/base.ts +++ b/src/main/browser/tabs/objects/tab-containers/base.ts @@ -1,6 +1,7 @@ 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"; // Container Child // type TabGroupChild = { @@ -105,6 +106,22 @@ export class BaseTabContainer< return this.children.length; } + public newNormalTabGroup(): NormalTabGroup { + const tabGroup = new NormalTabGroup({ + browser: this.browser, + window: this.window, + space: this.space + }); + this.addChild({ type: "tab-group", item: tabGroup }); + return tabGroup; + } + + public createTabFolder(name: string): TabFolder { + const folder = new TabFolder(name); + this.addChild({ type: "tab-folder", item: folder }); + return folder; + } + protected baseExport(): ExportedBaseTabContainer { return { type: "tab-container", From 88ff34f2e4d730e0d7c677fd36ed480a3e8aeebc Mon Sep 17 00:00:00 2001 From: iamEvan Date: Tue, 26 Aug 2025 22:30:58 +0100 Subject: [PATCH 24/25] idk2 --- src/main/browser/tabs/objects/tab/controllers/data.ts | 6 ++++++ .../browser/tabs/objects/tab/controllers/error-page.ts | 5 +++++ .../browser/tabs/objects/tab/controllers/navigation.ts | 6 ++++++ src/main/browser/tabs/objects/tab/controllers/pip.ts | 5 +++++ src/main/browser/tabs/objects/tab/controllers/saving.ts | 5 +++++ src/main/browser/tabs/objects/tab/controllers/sleep.ts | 7 +++++++ src/main/browser/tabs/objects/tab/controllers/webview.ts | 6 ++++++ 7 files changed, 40 insertions(+) diff --git a/src/main/browser/tabs/objects/tab/controllers/data.ts b/src/main/browser/tabs/objects/tab/controllers/data.ts index 989559ad..1b9d517c 100644 --- a/src/main/browser/tabs/objects/tab/controllers/data.ts +++ b/src/main/browser/tabs/objects/tab/controllers/data.ts @@ -1,3 +1,9 @@ +/* +TabDataController: +- This controller stores all the data that needs to persist between browser restarts +- Other datas will be stored in their respective controllers +*/ + import { Tab } from "@/browser/tabs/objects/tab"; import { TabbedBrowserWindow } from "@/browser/window"; import { WebContents } from "electron"; diff --git a/src/main/browser/tabs/objects/tab/controllers/error-page.ts b/src/main/browser/tabs/objects/tab/controllers/error-page.ts index a90a5572..4d3cc700 100644 --- a/src/main/browser/tabs/objects/tab/controllers/error-page.ts +++ b/src/main/browser/tabs/objects/tab/controllers/error-page.ts @@ -1,3 +1,8 @@ +/* +TabErrorPageController: +- This controller is responsible for loading error pages +*/ + import { Tab } from "@/browser/tabs/objects/tab"; import { FLAGS } from "@/modules/flags"; diff --git a/src/main/browser/tabs/objects/tab/controllers/navigation.ts b/src/main/browser/tabs/objects/tab/controllers/navigation.ts index 1817daf9..c3207ac8 100644 --- a/src/main/browser/tabs/objects/tab/controllers/navigation.ts +++ b/src/main/browser/tabs/objects/tab/controllers/navigation.ts @@ -1,3 +1,9 @@ +/* +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"; diff --git a/src/main/browser/tabs/objects/tab/controllers/pip.ts b/src/main/browser/tabs/objects/tab/controllers/pip.ts index f13f2d34..b5f30fbc 100644 --- a/src/main/browser/tabs/objects/tab/controllers/pip.ts +++ b/src/main/browser/tabs/objects/tab/controllers/pip.ts @@ -1,3 +1,8 @@ +/* +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 diff --git a/src/main/browser/tabs/objects/tab/controllers/saving.ts b/src/main/browser/tabs/objects/tab/controllers/saving.ts index 5d9b2f39..15dab8e4 100644 --- a/src/main/browser/tabs/objects/tab/controllers/saving.ts +++ b/src/main/browser/tabs/objects/tab/controllers/saving.ts @@ -1,3 +1,8 @@ +/* +TabSavingController: +- TBD +*/ + import { Tab } from "@/browser/tabs/objects/tab"; export class TabSavingController { diff --git a/src/main/browser/tabs/objects/tab/controllers/sleep.ts b/src/main/browser/tabs/objects/tab/controllers/sleep.ts index 7cbb6d44..3972eec1 100644 --- a/src/main/browser/tabs/objects/tab/controllers/sleep.ts +++ b/src/main/browser/tabs/objects/tab/controllers/sleep.ts @@ -1,3 +1,10 @@ +/* +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 { diff --git a/src/main/browser/tabs/objects/tab/controllers/webview.ts b/src/main/browser/tabs/objects/tab/controllers/webview.ts index 68fd230c..6ea094d5 100644 --- a/src/main/browser/tabs/objects/tab/controllers/webview.ts +++ b/src/main/browser/tabs/objects/tab/controllers/webview.ts @@ -1,3 +1,9 @@ +/* +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"; From a39b65e332a10fca44ef359c53799e23d4da574f Mon Sep 17 00:00:00 2001 From: iamEvan Date: Fri, 12 Sep 2025 22:36:02 +0100 Subject: [PATCH 25/25] d --- .../tabs/managers/tabs-container-manager.ts | 36 +++++--- .../tabs/objects/tab-containers/base.ts | 91 ++++++++++++++----- .../objects/tab-containers/tab-container.ts | 6 +- .../tabs/objects/tab-containers/tab-folder.ts | 6 +- .../tabs/objects/tab/controllers/bounds.ts | 6 ++ .../objects/tab/controllers/context-menu.ts | 22 +++-- .../tabs/objects/tab/controllers/data.ts | 17 ++-- .../tabs/objects/tab/controllers/visiblity.ts | 6 ++ .../tabs/objects/tab/controllers/webview.ts | 2 +- .../tabs/objects/tab/controllers/window.ts | 6 ++ 10 files changed, 140 insertions(+), 58 deletions(-) diff --git a/src/main/browser/tabs/managers/tabs-container-manager.ts b/src/main/browser/tabs/managers/tabs-container-manager.ts index 39e1aee2..17165867 100644 --- a/src/main/browser/tabs/managers/tabs-container-manager.ts +++ b/src/main/browser/tabs/managers/tabs-container-manager.ts @@ -16,6 +16,7 @@ export class TabsContainerManager extends TypedEventEmitter = new Map(); constructor(browser: Browser) { super(); @@ -58,8 +59,6 @@ export class TabsContainerManager extends TypedEventEmitter { - normalContainer.addChild({ - type: "tab-group", - item: tabGroup - }); - }); + // 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; } /** diff --git a/src/main/browser/tabs/objects/tab-containers/base.ts b/src/main/browser/tabs/objects/tab-containers/base.ts index c160daa9..3a1dc745 100644 --- a/src/main/browser/tabs/objects/tab-containers/base.ts +++ b/src/main/browser/tabs/objects/tab-containers/base.ts @@ -2,6 +2,8 @@ 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 = { @@ -35,6 +37,13 @@ export type ExportedTabsFolder = { // 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]; @@ -47,34 +56,32 @@ export class BaseTabContainer< > extends TypedEventEmitter { public children: ContainerChild[]; - constructor() { + public sharedData: TabContainerSharedData; + + constructor(sharedData: TabContainerSharedData) { super(); this.children = []; + this.sharedData = sharedData; } - 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); - } + // Modify Children // - public addChild(child: ContainerChild): void { + /** + * 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); } - public removeChild(child: ContainerChild): boolean { + /** + * 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; @@ -83,6 +90,12 @@ export class BaseTabContainer< 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; @@ -94,6 +107,8 @@ export class BaseTabContainer< return true; } + // Get Children // + public findChildrenByType(type: "tab-group" | "tab-container"): ContainerChild[] { return this.children.filter((child) => child.type === type); } @@ -106,22 +121,48 @@ export class BaseTabContainer< 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.browser, - window: this.window, - space: this.space + browser: this.sharedData.browser, + window: this.sharedData.window, + space: this.sharedData.space }); this.addChild({ type: "tab-group", item: tabGroup }); return tabGroup; } - public createTabFolder(name: string): TabFolder { - const folder = new TabFolder(name); - this.addChild({ type: "tab-folder", item: folder }); - return folder; + // 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", diff --git a/src/main/browser/tabs/objects/tab-containers/tab-container.ts b/src/main/browser/tabs/objects/tab-containers/tab-container.ts index 9af5a48c..3ac86963 100644 --- a/src/main/browser/tabs/objects/tab-containers/tab-container.ts +++ b/src/main/browser/tabs/objects/tab-containers/tab-container.ts @@ -1,4 +1,4 @@ -import { BaseTabContainer, BaseTabContainerEvents, ExportedTabContainer } from "./base"; +import { BaseTabContainer, BaseTabContainerEvents, ExportedTabContainer, TabContainerSharedData } from "./base"; type TabContainerEvents = BaseTabContainerEvents & { "space-changed": [spaceId: string]; @@ -7,8 +7,8 @@ type TabContainerEvents = BaseTabContainerEvents & { export class TabContainer extends BaseTabContainer { public spaceId: string; - constructor(spaceId: string) { - super(); + constructor(spaceId: string, sharedData: TabContainerSharedData) { + super(sharedData); this.spaceId = spaceId; } diff --git a/src/main/browser/tabs/objects/tab-containers/tab-folder.ts b/src/main/browser/tabs/objects/tab-containers/tab-folder.ts index 8480eaea..e53fa535 100644 --- a/src/main/browser/tabs/objects/tab-containers/tab-folder.ts +++ b/src/main/browser/tabs/objects/tab-containers/tab-folder.ts @@ -2,10 +2,12 @@ import { BaseTabContainer, ExportedTabsFolder } from "./base"; export class TabFolder extends BaseTabContainer { public name: string; + public parent: BaseTabContainer; - constructor(name: string) { - super(); + constructor(name: string, parent: BaseTabContainer) { + super(parent.sharedData); this.name = name; + this.parent = parent; } public setName(name: string): void { diff --git a/src/main/browser/tabs/objects/tab/controllers/bounds.ts b/src/main/browser/tabs/objects/tab/controllers/bounds.ts index 4cec04e7..b98eedf0 100644 --- a/src/main/browser/tabs/objects/tab/controllers/bounds.ts +++ b/src/main/browser/tabs/objects/tab/controllers/bounds.ts @@ -1,3 +1,9 @@ +/* +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"; diff --git a/src/main/browser/tabs/objects/tab/controllers/context-menu.ts b/src/main/browser/tabs/objects/tab/controllers/context-menu.ts index 029557c3..f897f789 100644 --- a/src/main/browser/tabs/objects/tab/controllers/context-menu.ts +++ b/src/main/browser/tabs/objects/tab/controllers/context-menu.ts @@ -1,3 +1,8 @@ +/* +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 { TabbedBrowserWindow } from "@/browser/window"; @@ -46,12 +51,15 @@ function createTabContextMenu(browser: Browser, tab: Tab, profileId: string) { // Helper function to create a new tab const createNewTab = async (url: string, window?: TabbedBrowserWindow) => { - const tabbedWindow = tab.window.get(); - const spaceId = tab.space.get(); + // 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); + // 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 @@ -288,10 +296,10 @@ function combineSections( } export class TabContextMenuController { - private readonly tab: Tab; + // private readonly tab: Tab; constructor(tab: Tab) { - this.tab = tab; + // this.tab = tab; tab.on("webview-attached", () => { const browser = tab.browser; diff --git a/src/main/browser/tabs/objects/tab/controllers/data.ts b/src/main/browser/tabs/objects/tab/controllers/data.ts index 1b9d517c..b621202c 100644 --- a/src/main/browser/tabs/objects/tab/controllers/data.ts +++ b/src/main/browser/tabs/objects/tab/controllers/data.ts @@ -1,7 +1,6 @@ /* TabDataController: -- This controller stores all the data that needs to persist between browser restarts -- Other datas will be stored in their respective controllers +- This controller stores all the data that needs to be synced with the frontend */ import { Tab } from "@/browser/tabs/objects/tab"; @@ -41,6 +40,7 @@ export class TabDataController { tab.on("webview-detached", () => this.onWebviewDetached()); + // Wait for every controller to be ready setImmediate(() => this.refreshData()); } @@ -70,7 +70,7 @@ export class TabDataController { const pipActive = tab.pip.active; setProperty("pipActive", pipActive); - // Asleep + // asleep const asleep = tab.sleep.asleep; setProperty("asleep", asleep); @@ -78,11 +78,11 @@ export class TabDataController { const webContents = tab.webview.webContents; if (webContents) { - // Title + // title const title = webContents.getTitle(); setProperty("title", title); - // URL + // url const url = webContents.getURL(); setProperty("url", url); @@ -91,7 +91,7 @@ export class TabDataController { setProperty("isLoading", isLoading); // audible - const audible = webContents.isAudioMuted(); + const audible = webContents.isCurrentlyAudible(); setProperty("audible", audible); // muted @@ -103,12 +103,12 @@ export class TabDataController { // Process changes if (changed) { - this.tab.emit("data-changed"); + this.emitDataChanged(); } return changed; } - public setupWebviewData(webContents: WebContents) { + public setupWebviewChangeHooks(webContents: WebContents) { // audible webContents.on("audio-state-changed", () => this.refreshData()); webContents.on("media-started-playing", () => this.refreshData()); @@ -142,7 +142,6 @@ export class TabDataController { // from other controllers window: this.window, pipActive: this.pipActive, - asleep: this.asleep, // from navigation navHistory: navHistory, diff --git a/src/main/browser/tabs/objects/tab/controllers/visiblity.ts b/src/main/browser/tabs/objects/tab/controllers/visiblity.ts index 00d5d071..4aaec55d 100644 --- a/src/main/browser/tabs/objects/tab/controllers/visiblity.ts +++ b/src/main/browser/tabs/objects/tab/controllers/visiblity.ts @@ -1,3 +1,9 @@ +/* +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 { diff --git a/src/main/browser/tabs/objects/tab/controllers/webview.ts b/src/main/browser/tabs/objects/tab/controllers/webview.ts index 6ea094d5..ec4fef2b 100644 --- a/src/main/browser/tabs/objects/tab/controllers/webview.ts +++ b/src/main/browser/tabs/objects/tab/controllers/webview.ts @@ -77,7 +77,7 @@ export class TabWebviewController { }); tab.navigation.setupWebviewNavigation(this.webContents); - tab.data.setupWebviewData(this.webContents); + tab.data.setupWebviewChangeHooks(this.webContents); tab.emit("webview-attached"); diff --git a/src/main/browser/tabs/objects/tab/controllers/window.ts b/src/main/browser/tabs/objects/tab/controllers/window.ts index 0f85c2f8..faa17fbb 100644 --- a/src/main/browser/tabs/objects/tab/controllers/window.ts +++ b/src/main/browser/tabs/objects/tab/controllers/window.ts @@ -1,3 +1,9 @@ +/* +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";