import { BrowserView, BrowserWindow } from 'electron' import { randomUUID } from 'crypto' type TabId = string type TabInfo = { id: TabId; url: string; title: string; isLoading: boolean; canGoBack: boolean; canGoForward: boolean } const UI_HEIGHT = 88 export class TabManager { private win: BrowserWindow private views: Map = new Map() private activeId: TabId | null = null private skipNextNavigate: Map = new Map() constructor(win: BrowserWindow) { this.win = win this.win.on('resize', () => this.updateActiveBounds()) } list(): TabInfo[] { return Array.from(this.views.entries()).map(([id, view]) => this.info(id, view)) } create(url?: string): TabInfo { const id = randomUUID() const view = new BrowserView({ webPreferences: { sandbox: true } }) this.views.set(id, view) this.attach(id) const target = url && url.length > 0 ? url : 'about:blank' view.webContents.loadURL(target) this.bindEvents(id, view) const info = this.info(id, view) this.win.webContents.send('tab-created', info) return info } switch(tabId: TabId): void { if (!this.views.has(tabId)) return this.attach(tabId) this.win.webContents.send('tab-switched', { tabId }) } close(tabId: TabId): void { const view = this.views.get(tabId) if (!view) return if (this.activeId === tabId) { this.win.removeBrowserView(view) this.activeId = null } // @ts-ignore view.webContents.destroy() this.views.delete(tabId) this.win.webContents.send('tab-closed', { tabId }) const next = this.views.keys().next().value as TabId | undefined if (next) this.switch(next) } navigate(tabId: TabId, url: string): void { const view = this.views.get(tabId) if (!view) return this.skipNextNavigate.set(tabId, true) view.webContents.loadURL(url) } reload(tabId: TabId): void { const view = this.views.get(tabId) if (!view) return view.webContents.reload() } goBack(tabId: TabId): void { const view = this.views.get(tabId) if (!view) return if (view.webContents.canGoBack()) view.webContents.goBack() } goForward(tabId: TabId): void { const view = this.views.get(tabId) if (!view) return if (view.webContents.canGoForward()) view.webContents.goForward() } private attach(tabId: TabId): void { const view = this.views.get(tabId) if (!view) return if (this.activeId && this.views.get(this.activeId)) { const prev = this.views.get(this.activeId)! this.win.removeBrowserView(prev) } this.activeId = tabId this.win.addBrowserView(view) this.updateActiveBounds() } private updateActiveBounds(): void { if (!this.activeId) return const view = this.views.get(this.activeId) if (!view) return const [width, height] = this.win.getContentSize() view.setBounds({ x: 0, y: UI_HEIGHT, width, height: Math.max(0, height - UI_HEIGHT) }) } private bindEvents(id: TabId, view: BrowserView): void { const send = () => this.win.webContents.send('tab-updated', this.info(id, view)) view.webContents.on('did-start-loading', send) view.webContents.on('did-stop-loading', send) view.webContents.on('did-finish-load', send) view.webContents.on('page-title-updated', send) view.webContents.on('did-navigate', send) view.webContents.on('did-navigate-in-page', send) view.webContents.on('will-navigate', (event, url) => { if (this.skipNextNavigate.get(id)) { this.skipNextNavigate.set(id, false) return } event.preventDefault() this.create(url) }) view.webContents.setWindowOpenHandler(({ url }) => { this.create(url) return { action: 'deny' } }) } private info(id: TabId, view: BrowserView): TabInfo { const wc = view.webContents return { id, url: wc.getURL(), title: wc.getTitle(), isLoading: wc.isLoading(), canGoBack: wc.canGoBack(), canGoForward: wc.canGoForward() } } }