Skip to content

Commit 36c18b0

Browse files
committed
feat: initial tab group implementation
1 parent 4166747 commit 36c18b0

File tree

12 files changed

+326
-46
lines changed

12 files changed

+326
-46
lines changed

src/main/browser/browser.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@ import { app, WebContents } from "electron";
44
import { BrowserEvents } from "@/browser/events";
55
import { ProfileManager, LoadedProfile } from "@/browser/profile-manager";
66
import { WindowManager, BrowserWindowType, BrowserWindowCreationOptions } from "@/browser/window-manager";
7-
import { TabManager } from "@/browser/tabs/tab-manager";
8-
import { Tab } from "@/browser/tabs/tab";
97
import { setupMenu } from "@/browser/utility/menu";
108
import { settings } from "@/settings/main";
119
import { onboarding } from "@/onboarding/main";
1210
import "@/modules/extensions/main";
1311
import { waitForElectronComponentsToBeReady } from "@/modules/electron-components";
1412
import { debugPrint } from "@/modules/output";
13+
import { TabOrchestrator } from "@/browser/tabs";
1514

1615
/**
1716
* Main Browser controller that coordinates browser components
@@ -24,9 +23,8 @@ import { debugPrint } from "@/modules/output";
2423
export class Browser extends TypedEventEmitter<BrowserEvents> {
2524
private readonly profileManager: ProfileManager;
2625
private readonly windowManager: WindowManager;
27-
private readonly tabManager: TabManager;
26+
public readonly tabs: TabOrchestrator;
2827
private _isDestroyed: boolean = false;
29-
public tabs: TabManager;
3028
public updateMenu: () => Promise<void>;
3129

3230
/**
@@ -36,10 +34,7 @@ export class Browser extends TypedEventEmitter<BrowserEvents> {
3634
super();
3735
this.windowManager = new WindowManager(this);
3836
this.profileManager = new ProfileManager(this, this);
39-
this.tabManager = new TabManager(this);
40-
41-
// A public reference to the tab manager
42-
this.tabs = this.tabManager;
37+
this.tabs = new TabOrchestrator(this);
4338

4439
// Load menu
4540
this.updateMenu = setupMenu(this);
@@ -180,13 +175,6 @@ export class Browser extends TypedEventEmitter<BrowserEvents> {
180175
}
181176
}
182177

183-
/**
184-
* Get tab from ID
185-
*/
186-
public getTabFromId(tabId: number): Tab | undefined {
187-
return this.tabManager.getTabById(tabId);
188-
}
189-
190178
/**
191179
* Sends a message to all core WebContents
192180
*/

src/main/browser/tabs/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Browser } from "@/browser/browser";
22
import { TabManager } from "@/browser/tabs/tab-manager";
33

4-
export class TabsOrchestrator {
4+
export class TabOrchestrator {
55
private readonly browser: Browser;
66
public readonly tabManager: TabManager;
77

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Tab } from "@/browser/tabs/tab";
2+
import { TabGroup } from "@/browser/tabs/tab-group";
3+
4+
/**
5+
* Controller responsible for managing the focused tab within a tab group.
6+
* Handles setting/removing the focused tab.
7+
*/
8+
export class TabGroupFocusedTabController {
9+
/** Reference to the tab group this controller manages */
10+
private readonly tabGroup: TabGroup;
11+
12+
/** ID of the focused tab in this tab group. */
13+
private focusedTabId: string | null = null;
14+
15+
/**
16+
* Creates a new TabGroupFocusedTabController instance.
17+
* @param tabGroup - The tab group this controller will manage
18+
*/
19+
constructor(tabGroup: TabGroup) {
20+
this.tabGroup = tabGroup;
21+
22+
this._setupEventListeners();
23+
}
24+
25+
private _setupEventListeners() {
26+
this.tabGroup.connect("tab-added", (tab) => {
27+
if (!this.focusedTabId) {
28+
this.set(tab);
29+
}
30+
});
31+
32+
this.tabGroup.connect("tab-removed", (tab) => {
33+
if (this.focusedTabId === tab.id) {
34+
this.remove();
35+
36+
const tabGroup = this.tabGroup;
37+
const tabs = tabGroup.tabs.get();
38+
if (tabs.length > 0) {
39+
this.set(tabs[0]);
40+
}
41+
}
42+
});
43+
}
44+
45+
public set(tab: Tab) {
46+
if (this.focusedTabId === tab.id) {
47+
return false;
48+
}
49+
50+
this.remove();
51+
this.focusedTabId = tab.id;
52+
return true;
53+
}
54+
55+
public remove() {
56+
if (this.focusedTabId) {
57+
this.focusedTabId = null;
58+
return true;
59+
}
60+
return false;
61+
}
62+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { TabGroupFocusedTabController } from "@/browser/tabs/tab-group/controllers/focused-tab";
2+
import { TabGroupTabsController } from "@/browser/tabs/tab-group/controllers/tabs";
3+
4+
export { TabGroupFocusedTabController, TabGroupTabsController };
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { Browser } from "@/browser/browser";
2+
import { Tab } from "@/browser/tabs/tab";
3+
import { TabGroup } from "@/browser/tabs/tab-group";
4+
5+
/**
6+
* Controller responsible for managing tabs within a tab group.
7+
* Handles adding/removing tabs, maintaining tab state, and cleaning up event listeners.
8+
*/
9+
export class TabGroupTabsController {
10+
/** Reference to the browser instance that owns this tab group */
11+
private readonly browser: Browser;
12+
13+
/** Reference to the tab group this controller manages */
14+
private readonly tabGroup: TabGroup;
15+
16+
/** Set of tab IDs that belong to this tab group. Uses Set for O(1) operations. */
17+
private readonly tabIds: Set<string>;
18+
19+
/**
20+
* Map of tab ID to array of listener disconnector functions.
21+
* Used to clean up event listeners when tabs are removed.
22+
*/
23+
private readonly tabListenersDisconnectors: Map<string, (() => void)[]> = new Map();
24+
25+
/**
26+
* Creates a new TabGroupTabsController instance.
27+
* @param tabGroup - The tab group this controller will manage
28+
*/
29+
constructor(tabGroup: TabGroup) {
30+
this.browser = tabGroup.creationDetails.browser;
31+
this.tabGroup = tabGroup;
32+
33+
// Initialize empty set of tab IDs
34+
this.tabIds = new Set();
35+
36+
// Setup event listeners for focused tab
37+
tabGroup.connect("tab-removed", () => {
38+
if (this.tabIds.size === 0) {
39+
// Destroy the tab group
40+
this.tabGroup.destroy();
41+
}
42+
});
43+
}
44+
45+
/**
46+
* Adds a tab to this tab group.
47+
* Sets up event listeners and emits the "tab-added" event.
48+
* Respects the maximum number of tabs allowed in the group.
49+
*
50+
* @param tab - The tab to add to the group
51+
* @returns true if the tab was added successfully, false if it was already in the group or would exceed the maximum tab limit
52+
*/
53+
public addTab(tab: Tab) {
54+
// Check if tab is already in this group
55+
const hasTab = this.tabIds.has(tab.id);
56+
if (hasTab) {
57+
return false;
58+
}
59+
60+
// Check if adding this tab would exceed the maximum allowed tabs
61+
// -1 means no limit
62+
if (this.tabGroup.maxTabs !== -1 && this.tabIds.size >= this.tabGroup.maxTabs) {
63+
return false;
64+
}
65+
66+
// Add tab ID to our set
67+
this.tabIds.add(tab.id);
68+
69+
// Setup event listeners for tab lifecycle management
70+
const disconnectDestroyListener = tab.connect("destroyed", () => {
71+
this.removeTab(tab);
72+
});
73+
74+
const disconnectFocusedListener = tab.connect("focused", () => {
75+
this.tabGroup.focusedTab.set(tab);
76+
});
77+
78+
// Store the disconnector function for cleanup later
79+
this.tabListenersDisconnectors.set(tab.id, [disconnectDestroyListener, disconnectFocusedListener]);
80+
81+
// Notify tab group that a new tab was added
82+
this.tabGroup.emit("tab-added", tab);
83+
return true;
84+
}
85+
86+
/**
87+
* Removes a tab from this tab group.
88+
* Cleans up event listeners and emits the "tab-removed" event.
89+
*
90+
* @param tab - The tab to remove from the group
91+
* @returns true if the tab was removed successfully, false if it wasn't in the group
92+
*/
93+
public removeTab(tab: Tab) {
94+
// Check if tab exists in this group
95+
const hasTab = this.tabIds.has(tab.id);
96+
if (!hasTab) {
97+
return false;
98+
}
99+
100+
// Remove tab ID from our set
101+
this.tabIds.delete(tab.id);
102+
103+
// Clean up event listeners to prevent memory leaks
104+
const disconnectors = this.tabListenersDisconnectors.get(tab.id);
105+
if (disconnectors) {
106+
disconnectors.forEach((disconnector) => {
107+
disconnector();
108+
});
109+
}
110+
this.tabListenersDisconnectors.delete(tab.id);
111+
112+
// Notify tab group that a tab was removed
113+
this.tabGroup.emit("tab-removed", tab);
114+
return true;
115+
}
116+
117+
/**
118+
* Gets all tabs that belong to this tab group.
119+
* Filters out any tabs that may have been destroyed but not properly cleaned up.
120+
*
121+
* @returns Array of Tab instances that are currently in this group
122+
*/
123+
public get(): Tab[] {
124+
const tabOrchestrator = this.browser.tabs;
125+
126+
// Convert Set to Array and map tab IDs to actual Tab instances
127+
const tabs = Array.from(this.tabIds).map((id) => {
128+
return tabOrchestrator.tabManager.getTabById(id);
129+
});
130+
131+
// Filter out any undefined tabs (in case a tab was destroyed but not properly removed)
132+
return tabs.filter((tab) => tab !== undefined);
133+
}
134+
135+
/**
136+
* Cleans up all event listeners for all tabs in this group.
137+
* Should be called when the tab group is being destroyed to prevent memory leaks.
138+
*/
139+
public cleanupListeners() {
140+
// Disconnect all event listeners
141+
this.tabListenersDisconnectors.forEach((disconnectors) => {
142+
disconnectors.forEach((disconnector) => {
143+
disconnector();
144+
});
145+
});
146+
147+
// Clear the disconnectors map
148+
this.tabListenersDisconnectors.clear();
149+
}
150+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { TypedEventEmitter } from "@/modules/typed-event-emitter";
2+
import { generateID } from "@/modules/utils";
3+
import { Tab } from "@/browser/tabs/tab";
4+
import { TabGroupFocusedTabController, TabGroupTabsController } from "@/browser/tabs/tab-group/controllers";
5+
import { Browser } from "@/browser/browser";
6+
import { TabbedBrowserWindow } from "@/browser/window";
7+
8+
type TabGroupTypes = "normal" | "split" | "glance";
9+
10+
type TabGroupEvents = {
11+
"window-changed": [];
12+
"space-changed": [];
13+
"tab-added": [Tab];
14+
"tab-removed": [Tab];
15+
destroyed: [];
16+
};
17+
18+
export interface TabGroupCreationDetails {
19+
browser: Browser;
20+
21+
window: TabbedBrowserWindow;
22+
spaceId: string;
23+
}
24+
25+
export interface TabGroupVariant {
26+
type: TabGroupTypes;
27+
maxTabs: number;
28+
}
29+
30+
export class TabGroup extends TypedEventEmitter<TabGroupEvents> {
31+
public readonly id: string;
32+
public destroyed: boolean;
33+
34+
public readonly type: TabGroupTypes;
35+
public readonly maxTabs: number;
36+
public readonly creationDetails: TabGroupCreationDetails;
37+
38+
protected tabIds: string[] = [];
39+
40+
public tabs: TabGroupTabsController;
41+
public focusedTab: TabGroupFocusedTabController;
42+
43+
constructor(variant: TabGroupVariant, details: TabGroupCreationDetails) {
44+
super();
45+
46+
this.id = generateID();
47+
this.destroyed = false;
48+
49+
this.type = variant.type;
50+
this.maxTabs = variant.maxTabs;
51+
this.creationDetails = details;
52+
53+
this.tabs = new TabGroupTabsController(this);
54+
this.focusedTab = new TabGroupFocusedTabController(this);
55+
}
56+
57+
public destroy() {
58+
this.throwIfDestroyed();
59+
60+
this.destroyed = true;
61+
this.emit("destroyed");
62+
63+
this.tabs.cleanupListeners();
64+
65+
this.destroyEmitter();
66+
}
67+
68+
public throwIfDestroyed() {
69+
if (this.destroyed) {
70+
throw new Error("Tab group already destroyed");
71+
}
72+
}
73+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { TabGroup, TabGroupCreationDetails } from "../index";
2+
3+
/**
4+
* A tab group that can only have one tab.
5+
*/
6+
export class NormalTabGroup extends TabGroup {
7+
constructor(details: TabGroupCreationDetails) {
8+
super({ type: "normal", maxTabs: 1 }, details);
9+
}
10+
}

src/main/browser/tabs/tab/controllers/data.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export class TabDataController {
4949
return changed;
5050
}
5151

52-
public getData() {
52+
public get() {
5353
return {
5454
window: this.window,
5555
space: this.space,

src/main/browser/tabs/tab/controllers/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { TabBoundsController } from "@/browser/tabs/tab/controllers/bounds";
22
import { TabPipController } from "@/browser/tabs/tab/controllers/pip";
33
import { TabSavingController } from "@/browser/tabs/tab/controllers/saving";
44
import { TabSpaceController } from "@/browser/tabs/tab/controllers/space";
5-
import { TabStateController } from "@/browser/tabs/tab/controllers/state";
65
import { TabVisiblityController } from "@/browser/tabs/tab/controllers/visiblity";
76
import { TabWebviewController } from "@/browser/tabs/tab/controllers/webview";
87
import { TabWindowController } from "@/browser/tabs/tab/controllers/window";
@@ -17,7 +16,6 @@ export {
1716
TabPipController,
1817
TabSavingController,
1918
TabSpaceController,
20-
TabStateController,
2119
TabVisiblityController,
2220
TabWebviewController,
2321
TabWindowController,

0 commit comments

Comments
 (0)