feat(electron): multi tabs support (#7440)

use https://www.electronjs.org/docs/latest/api/web-contents-view to serve different tab views
added tabs view manager in electron to handle multi-view actions and events.

fix AF-1111
fix AF-999
fix PD-1459
fix AF-964
PD-1458
This commit is contained in:
pengx17
2024-07-29 11:05:22 +00:00
parent 622715d2f3
commit 1efc1d0f5b
88 changed files with 3160 additions and 945 deletions

View File

@@ -1,9 +1,16 @@
import { app, Menu } from 'electron';
import { isMacOS } from '../../shared/utils';
import { revealLogFile } from '../logger';
import { initAndShowMainWindow, showMainWindow } from '../main-window';
import { logger, revealLogFile } from '../logger';
import { checkForUpdates } from '../updater';
import {
addTab,
closeTab,
initAndShowMainWindow,
showDevTools,
showMainWindow,
undoCloseTab,
} from '../windows-manager';
import { applicationMenuSubjects } from './subject';
// Unique id for menuitems
@@ -55,8 +62,6 @@ export function createApplicationMenu() {
applicationMenuSubjects.newPageAction$.next();
},
},
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
],
},
// { role: 'editMenu' }
@@ -89,34 +94,44 @@ export function createApplicationMenu() {
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{
label: 'Open devtools',
accelerator: isMac ? 'Cmd+Option+I' : 'Ctrl+Shift+I',
click: () => {
showDevTools();
},
},
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
// { role: 'windowMenu' }
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
...(isMac
? [
{ type: 'separator' },
{ role: 'front' },
{ type: 'separator' },
{
role: 'window',
click: async () => {
await initAndShowMainWindow();
},
},
]
: [{ role: 'close' }]),
{ type: 'separator' },
{
label: 'New tab',
accelerator: 'CommandOrControl+T',
click() {
logger.info('New tab with shortcut');
addTab().catch(console.error);
},
},
{
label: 'Close tab',
accelerator: 'CommandOrControl+W',
click() {
logger.info('Close tab with shortcut');
closeTab().catch(console.error);
},
},
{
label: 'Undo close tab',
accelerator: 'CommandOrControl+Shift+T',
click() {
logger.info('Undo close tab with shortcut');
undoCloseTab().catch(console.error);
},
},
],
},
{

View File

@@ -1 +1,3 @@
export const mainWindowOrigin = process.env.DEV_SERVER_URL || 'file://.';
export const onboardingViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}onboarding`;
export const shellViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}shell.html`;

View File

@@ -10,7 +10,7 @@ import {
getMainWindow,
handleOpenUrlInHiddenWindow,
setCookie,
} from './main-window';
} from './windows-manager';
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
if (isDev) {

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow } from 'electron';
import { app, BrowserWindow, WebContentsView } from 'electron';
import { applicationMenuEvents } from './application-menu';
import { logger } from './logger';
@@ -21,9 +21,9 @@ export function registerEvents() {
// register events
for (const [namespace, namespaceEvents] of Object.entries(allEvents)) {
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
const subscription = eventRegister((...args: any[]) => {
const unsubscribe = eventRegister((...args: any[]) => {
const chan = `${namespace}:${key}`;
logger.info(
logger.debug(
'[ipc-event]',
chan,
args.filter(
@@ -33,10 +33,31 @@ export function registerEvents() {
typeof a !== 'object'
)
);
getActiveWindows().forEach(win => win.webContents.send(chan, ...args));
// is this efficient?
getActiveWindows().forEach(win => {
if (win.isDestroyed()) {
return;
}
// .webContents could be undefined if the window is destroyed
win.webContents?.send(chan, ...args);
win.contentView.children.forEach(child => {
if (
child instanceof WebContentsView &&
child.webContents &&
!child.webContents.isDestroyed()
) {
child.webContents?.send(chan, ...args);
}
});
});
});
app.on('before-quit', () => {
subscription();
// subscription on quit sometimes crashes the app
try {
unsubscribe();
} catch (err) {
logger.error('unsubscribe error', err);
}
});
}
}

View File

@@ -43,7 +43,7 @@ export const registerHandlers = () => {
const start = performance.now();
try {
const result = await handler(e, ...args);
logger.info(
logger.debug(
'[ipc-api]',
chan,
args.filter(

View File

@@ -1,318 +0,0 @@
import assert from 'node:assert';
import { join } from 'node:path';
import { type CookiesSetDetails } from 'electron';
import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state';
import { isLinux, isMacOS, isWindows } from '../shared/utils';
import { buildType } from './config';
import { mainWindowOrigin } from './constants';
import { ensureHelperProcess } from './helper-process';
import { logger } from './logger';
import { uiSubjects } from './ui/subject';
import { parseCookie } from './utils';
const IS_DEV: boolean =
process.env.NODE_ENV === 'development' && !process.env.CI;
// todo: not all window need all of the exposed meta
const getWindowAdditionalArguments = async () => {
const { getExposedMeta } = await import('./exposed');
const mainExposedMeta = getExposedMeta();
const helperProcessManager = await ensureHelperProcess();
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
return [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
`--helper-exposed-meta=` + JSON.stringify(helperExposedMeta),
`--window-name=main`,
];
};
function closeAllWindows() {
BrowserWindow.getAllWindows().forEach(w => {
if (!w.isDestroyed()) {
w.destroy();
}
});
}
async function createWindow(additionalArguments: string[]) {
logger.info('create window');
const mainWindowState = electronWindowState({
defaultWidth: 1000,
defaultHeight: 800,
});
const helperProcessManager = await ensureHelperProcess();
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
assert(helperExposedMeta, 'helperExposedMeta should be defined');
const browserWindow = new BrowserWindow({
titleBarStyle: isMacOS()
? 'hiddenInset'
: isWindows()
? 'hidden'
: 'default',
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
autoHideMenuBar: isLinux(),
minWidth: 640,
minHeight: 480,
visualEffectState: 'active',
vibrancy: 'under-window',
// backgroundMaterial: 'mica',
height: mainWindowState.height,
show: false, // Use 'ready-to-show' event to show window
webPreferences: {
webgl: true,
contextIsolation: true,
sandbox: false,
webviewTag: false, // The webview tag is not recommended. Consider alternatives like iframe or Electron's BrowserView. https://www.electronjs.org/docs/latest/api/webview-tag#warning
spellcheck: false, // FIXME: enable?
preload: join(__dirname, './preload.js'),
// serialize exposed meta that to be used in preload
additionalArguments: additionalArguments,
},
});
if (isLinux()) {
browserWindow.setIcon(
join(__dirname, `../resources/icons/icon_${buildType}_64x64.png`)
);
}
nativeTheme.themeSource = 'light';
mainWindowState.manage(browserWindow);
let helperConnectionUnsub: (() => void) | undefined;
/**
* If you install `show: true` then it can cause issues when trying to close the window.
* Use `show: false` and listener events `ready-to-show` to fix these issues.
*
* @see https://github.com/electron/electron/issues/25012
*/
browserWindow.on('ready-to-show', () => {
helperConnectionUnsub?.();
helperConnectionUnsub = helperProcessManager.connectRenderer(
browserWindow.webContents
);
logger.info('main window is ready to show');
uiSubjects.onMaximized$.next(browserWindow.isMaximized());
uiSubjects.onFullScreen$.next(browserWindow.isFullScreen());
handleWebContentsResize().catch(logger.error);
});
browserWindow.on('close', e => {
// TODO(@Peng): gracefully close the app, for example, ask user to save unsaved changes
e.preventDefault();
if (!isMacOS()) {
closeAllWindows();
} else {
// hide window on macOS
// application quit will be handled by closing the hidden window
//
// explanation:
// - closing the top window (by clicking close button or CMD-w)
// - will be captured in "close" event here
// - hiding the app to make the app open faster when user click the app icon
// - quit the app by "cmd+q" or right click on the dock icon and select "quit"
// - all browser windows will capture the "close" event
// - the hidden window will close all windows
// - "window-all-closed" event will be emitted and eventually quit the app
if (browserWindow.isFullScreen()) {
browserWindow.once('leave-full-screen', () => {
browserWindow.hide();
});
browserWindow.setFullScreen(false);
} else {
browserWindow.hide();
}
}
helperConnectionUnsub?.();
helperConnectionUnsub = undefined;
});
browserWindow.on('leave-full-screen', () => {
// seems call this too soon may cause the app to crash
setTimeout(() => {
// FIXME: workaround for theme bug in full screen mode
const size = browserWindow.getSize();
browserWindow.setSize(size[0] + 1, size[1] + 1);
browserWindow.setSize(size[0], size[1]);
});
uiSubjects.onMaximized$.next(false);
uiSubjects.onFullScreen$.next(false);
});
browserWindow.on('maximize', () => {
uiSubjects.onMaximized$.next(true);
});
browserWindow.on('unmaximize', () => {
uiSubjects.onMaximized$.next(false);
});
// full-screen == maximized in UI on windows
browserWindow.on('enter-full-screen', () => {
uiSubjects.onFullScreen$.next(true);
});
browserWindow.on('leave-full-screen', () => {
uiSubjects.onFullScreen$.next(false);
});
/**
* URL for main window.
*/
const pageUrl = mainWindowOrigin; // see protocol.ts
logger.info('loading page at', pageUrl);
await browserWindow.loadURL(pageUrl);
logger.info('main window is loaded at', pageUrl);
return browserWindow;
}
// singleton
let browserWindow: Promise<BrowserWindow> | undefined;
// a hidden window that prevents the app from quitting on MacOS
let hiddenMacWindow: BrowserWindow | undefined;
/**
* Init main BrowserWindow. Will create a new window if it's not created yet.
*/
export async function initAndShowMainWindow() {
if (!browserWindow || (await browserWindow.then(w => w.isDestroyed()))) {
browserWindow = (async () => {
const additionalArguments = await getWindowAdditionalArguments();
return createWindow(additionalArguments);
})();
}
const mainWindow = await browserWindow;
if (IS_DEV) {
// do not gain focus in dev mode
mainWindow.showInactive();
} else {
mainWindow.show();
}
if (!hiddenMacWindow && isMacOS()) {
hiddenMacWindow = new BrowserWindow({
show: false,
width: 100,
height: 100,
});
hiddenMacWindow.on('close', () => {
closeAllWindows();
});
}
return mainWindow;
}
export async function getMainWindow() {
if (!browserWindow) return;
const window = await browserWindow;
if (window.isDestroyed()) return;
return window;
}
export async function showMainWindow() {
const window = await getMainWindow();
if (!window) return;
if (window.isMinimized()) {
window.restore();
}
window.focus();
}
export async function handleOpenUrlInHiddenWindow(url: string) {
const win = new BrowserWindow({
width: 1200,
height: 600,
webPreferences: {
preload: join(__dirname, './preload.js'),
},
show: false,
});
win.on('close', e => {
e.preventDefault();
if (!win.isDestroyed()) {
win.destroy();
}
});
logger.info('loading page at', url);
await win.loadURL(url);
return win;
}
export async function setCookie(cookie: CookiesSetDetails): Promise<void>;
export async function setCookie(origin: string, cookie: string): Promise<void>;
export async function setCookie(
arg0: CookiesSetDetails | string,
arg1?: string
) {
const window = await browserWindow;
if (!window) {
// do nothing if window is not ready
return;
}
const details =
typeof arg1 === 'string' && typeof arg0 === 'string'
? parseCookie(arg0, arg1)
: arg0;
logger.info('setting cookie to main window', details);
if (typeof details !== 'object') {
throw new Error('invalid cookie details');
}
await window.webContents.session.cookies.set(details);
}
export async function removeCookie(url: string, name: string): Promise<void> {
const window = await browserWindow;
if (!window) {
// do nothing if window is not ready
return;
}
await window.webContents.session.cookies.remove(url, name);
}
export async function getCookie(url?: string, name?: string) {
const window = await browserWindow;
if (!window) {
// do nothing if window is not ready
return;
}
const cookies = await window.webContents.session.cookies.get({
url,
name,
});
return cookies;
}
// there is no proper way to listen to webContents resize event
// we will rely on window.resize event in renderer instead
export async function handleWebContentsResize() {
// right now when window is resized, we will relocate the traffic light positions
if (isMacOS()) {
const window = await getMainWindow();
const factor = window?.webContents.getZoomFactor() || 1;
window?.setWindowButtonPosition({ x: 20 * factor, y: 24 * factor - 6 });
}
}

View File

@@ -4,7 +4,7 @@ import { net, protocol, session } from 'electron';
import { CLOUD_BASE_URL } from './config';
import { logger } from './logger';
import { getCookie } from './main-window';
import { getCookie } from './windows-manager';
protocol.registerSchemesAsPrivileged([
{

View File

@@ -117,7 +117,7 @@ export class PersistentJSONFileStorage implements Memento {
try {
await fs.promises.writeFile(
this.filepath,
JSON.stringify(this.data),
JSON.stringify(this.data, null, 2),
'utf-8'
);
} catch (err) {

View File

@@ -1,4 +1,12 @@
import type { MainEventRegister } from '../type';
import {
onActiveTabChanged,
onTabAction,
onTabsBoundingRectChanged,
onTabShellViewActiveChange,
onTabsStatusChange,
onTabViewsMetaChanged,
} from '../windows-manager';
import { uiSubjects } from './subject';
/**
@@ -17,4 +25,16 @@ export const uiEvents = {
sub.unsubscribe();
};
},
onTabViewsMetaChanged,
onTabAction,
onToggleRightSidebar: (fn: (tabId: string) => void) => {
const sub = uiSubjects.onToggleRightSidebar$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onTabsStatusChange,
onActiveTabChanged,
onTabShellViewActiveChange,
onTabsBoundingRectChanged,
} satisfies Record<string, MainEventRegister>;

View File

@@ -4,15 +4,29 @@ import { getLinkPreview } from 'link-preview-js';
import { isMacOS } from '../../shared/utils';
import { persistentConfig } from '../config-storage/persist';
import { logger } from '../logger';
import type { NamespaceHandlers } from '../type';
import {
activateView,
addTab,
closeTab,
getMainWindow,
getOnboardingWindow,
getTabsBoundingRect,
getTabsStatus,
getTabViewsMeta,
getWorkbenchMeta,
handleWebContentsResize,
initAndShowMainWindow,
} from '../main-window';
import { getOnboardingWindow } from '../onboarding';
import type { NamespaceHandlers } from '../type';
import { launchStage } from '../windows-manager/stage';
isActiveTab,
launchStage,
showDevTools,
showTab,
showTabContextMenu,
updateTabsBoundingRect,
updateWorkbenchMeta,
} from '../windows-manager';
import { getChallengeResponse } from './challenge';
import { uiSubjects } from './subject';
export let isOnline = true;
@@ -141,4 +155,57 @@ export const uiHandlers = {
openExternal(_, url: string) {
return shell.openExternal(url);
},
// tab handlers
isActiveTab: async e => {
return isActiveTab(e.sender);
},
getWorkbenchMeta: async (_, ...args: Parameters<typeof getWorkbenchMeta>) => {
return getWorkbenchMeta(...args);
},
updateWorkbenchMeta: async (
_,
...args: Parameters<typeof updateWorkbenchMeta>
) => {
return updateWorkbenchMeta(...args);
},
getTabViewsMeta: async () => {
return getTabViewsMeta();
},
getTabsStatus: async () => {
return getTabsStatus();
},
addTab: async (_, ...args: Parameters<typeof addTab>) => {
await addTab(...args);
},
showTab: async (_, ...args: Parameters<typeof showTab>) => {
await showTab(...args);
},
closeTab: async (_, ...args: Parameters<typeof closeTab>) => {
await closeTab(...args);
},
activateView: async (_, ...args: Parameters<typeof activateView>) => {
await activateView(...args);
},
toggleRightSidebar: async (_, tabId?: string) => {
tabId ??= getTabViewsMeta().activeWorkbenchId;
if (tabId) {
uiSubjects.onToggleRightSidebar$.next(tabId);
}
},
getTabsBoundingRect: async () => {
return getTabsBoundingRect();
},
updateTabsBoundingRect: async (
e,
rect: { x: number; y: number; width: number; height: number }
) => {
return updateTabsBoundingRect(e.sender, rect);
},
showDevTools: async (_, ...args: Parameters<typeof showDevTools>) => {
return showDevTools(...args);
},
showTabContextMenu: async (_, tabKey: string, viewIndex: number) => {
return showTabContextMenu(tabKey, viewIndex);
},
} satisfies NamespaceHandlers;

View File

@@ -3,4 +3,5 @@ import { Subject } from 'rxjs';
export const uiSubjects = {
onMaximized$: new Subject<boolean>(),
onFullScreen$: new Subject<boolean>(),
onToggleRightSidebar$: new Subject<string>(),
};

View File

@@ -0,0 +1,6 @@
export * from './launcher';
export * from './main-window';
export * from './onboarding';
export * from './stage';
export * from './tab-views';
export * from './types';

View File

@@ -1,9 +1,6 @@
import { logger } from '../logger';
import { initAndShowMainWindow } from '../main-window';
import {
getOnboardingWindow,
getOrCreateOnboardingWindow,
} from '../onboarding';
import { initAndShowMainWindow } from './main-window';
import { getOnboardingWindow, getOrCreateOnboardingWindow } from './onboarding';
import { launchStage } from './stage';
/**

View File

@@ -0,0 +1,247 @@
import { join } from 'node:path';
import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state';
import { BehaviorSubject } from 'rxjs';
import { isLinux, isMacOS, isWindows } from '../../shared/utils';
import { buildType } from '../config';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
import { uiSubjects } from '../ui/subject';
const IS_DEV: boolean =
process.env.NODE_ENV === 'development' && !process.env.CI;
function closeAllWindows() {
BrowserWindow.getAllWindows().forEach(w => {
if (!w.isDestroyed()) {
w.destroy();
}
});
}
export class MainWindowManager {
static readonly instance = new MainWindowManager();
mainWindowReady: Promise<BrowserWindow> | undefined;
mainWindow$ = new BehaviorSubject<BrowserWindow | undefined>(undefined);
private hiddenMacWindow: BrowserWindow | undefined;
get mainWindow() {
return this.mainWindow$.value;
}
// #region private methods
private preventMacAppQuit() {
if (!this.hiddenMacWindow && isMacOS()) {
this.hiddenMacWindow = new BrowserWindow({
show: false,
width: 100,
height: 100,
});
this.hiddenMacWindow.on('close', () => {
this.cleanupWindows();
});
}
}
private cleanupWindows() {
closeAllWindows();
this.mainWindowReady = undefined;
this.mainWindow$.next(undefined);
this.hiddenMacWindow?.destroy();
this.hiddenMacWindow = undefined;
}
private async createMainWindow() {
logger.info('create window');
const mainWindowState = electronWindowState({
defaultWidth: 1000,
defaultHeight: 800,
});
await ensureHelperProcess();
const browserWindow = new BrowserWindow({
titleBarStyle: isMacOS()
? 'hiddenInset'
: isWindows()
? 'hidden'
: 'default',
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
autoHideMenuBar: isLinux(),
minWidth: 640,
minHeight: 480,
visualEffectState: 'active',
vibrancy: 'under-window',
// backgroundMaterial: 'mica',
height: mainWindowState.height,
show: false, // Use 'ready-to-show' event to show window
webPreferences: {
webgl: true,
contextIsolation: true,
sandbox: false,
},
});
if (isLinux()) {
browserWindow.setIcon(
// __dirname is `packages/frontend/electron/dist` (the bundled output directory)
join(__dirname, `../resources/icons/icon_${buildType}_64x64.png`)
);
}
nativeTheme.themeSource = 'light';
mainWindowState.manage(browserWindow);
this.bindEvents(browserWindow);
return browserWindow;
}
private bindEvents(mainWindow: BrowserWindow) {
/**
* If you install `show: true` then it can cause issues when trying to close the window.
* Use `show: false` and listener events `ready-to-show` to fix these issues.
*
* @see https://github.com/electron/electron/issues/25012
*/
mainWindow.on('ready-to-show', () => {
logger.info('main window is ready to show');
uiSubjects.onMaximized$.next(mainWindow.isMaximized());
uiSubjects.onFullScreen$.next(mainWindow.isFullScreen());
});
mainWindow.on('close', e => {
// TODO(@pengx17): gracefully close the app, for example, ask user to save unsaved changes
e.preventDefault();
if (!isMacOS()) {
closeAllWindows();
} else {
// hide window on macOS
// application quit will be handled by closing the hidden window
//
// explanation:
// - closing the top window (by clicking close button or CMD-w)
// - will be captured in "close" event here
// - hiding the app to make the app open faster when user click the app icon
// - quit the app by "cmd+q" or right click on the dock icon and select "quit"
// - all browser windows will capture the "close" event
// - the hidden window will close all windows
// - "window-all-closed" event will be emitted and eventually quit the app
if (mainWindow.isFullScreen()) {
mainWindow.once('leave-full-screen', () => {
mainWindow.hide();
});
mainWindow.setFullScreen(false);
} else {
mainWindow.hide();
}
}
});
mainWindow.on('leave-full-screen', () => {
// seems call this too soon may cause the app to crash
setTimeout(() => {
// FIXME: workaround for theme bug in full screen mode
const size = mainWindow.getSize();
mainWindow.setSize(size[0] + 1, size[1] + 1);
mainWindow.setSize(size[0], size[1]);
});
uiSubjects.onMaximized$.next(false);
uiSubjects.onFullScreen$.next(false);
});
mainWindow.on('maximize', () => {
uiSubjects.onMaximized$.next(true);
});
mainWindow.on('unmaximize', () => {
uiSubjects.onMaximized$.next(false);
});
// full-screen == maximized in UI on windows
mainWindow.on('enter-full-screen', () => {
uiSubjects.onFullScreen$.next(true);
});
mainWindow.on('leave-full-screen', () => {
uiSubjects.onFullScreen$.next(false);
});
}
// #endregion
async ensureMainWindow(): Promise<BrowserWindow> {
if (
!this.mainWindowReady ||
(await this.mainWindowReady.then(w => w.isDestroyed()))
) {
this.mainWindowReady = this.createMainWindow();
this.mainWindow$.next(await this.mainWindowReady);
this.preventMacAppQuit();
}
return this.mainWindowReady;
}
/**
* Init main BrowserWindow. Will create a new window if it's not created yet.
*/
async initAndShowMainWindow() {
const mainWindow = await this.ensureMainWindow();
if (IS_DEV) {
// do not gain focus in dev mode
mainWindow.showInactive();
} else {
mainWindow.show();
}
this.preventMacAppQuit();
return mainWindow;
}
}
export async function initAndShowMainWindow() {
return MainWindowManager.instance.initAndShowMainWindow();
}
export async function getMainWindow() {
return MainWindowManager.instance.ensureMainWindow();
}
export async function showMainWindow() {
const window = await getMainWindow();
if (!window) return;
if (window.isMinimized()) {
window.restore();
}
window.focus();
}
/**
* Open a URL in a hidden window.
* This is useful for opening a URL in the background without user interaction for *authentication*.
*/
export async function handleOpenUrlInHiddenWindow(url: string) {
const win = new BrowserWindow({
width: 1200,
height: 600,
webPreferences: {
preload: join(__dirname, './preload.js'),
},
show: false,
});
win.on('close', e => {
e.preventDefault();
if (!win.isDestroyed()) {
win.destroy();
}
});
logger.info('loading page at', url);
await win.loadURL(url);
return win;
}

View File

@@ -3,10 +3,10 @@ import { join } from 'node:path';
import type { Display } from 'electron';
import { BrowserWindow, screen } from 'electron';
import { isMacOS } from '../shared/utils';
import { mainWindowOrigin } from './constants';
import { isMacOS } from '../../shared/utils';
import { onboardingViewUrl } from '../constants';
// import { getExposedMeta } from './exposed';
import { logger } from './logger';
import { logger } from '../logger';
const getScreenSize = (display: Display) => {
const { width, height } = isMacOS() ? display.bounds : display.workArea;
@@ -15,7 +15,7 @@ const getScreenSize = (display: Display) => {
// todo: not all window need all of the exposed meta
const getWindowAdditionalArguments = async () => {
const { getExposedMeta } = await import('./exposed');
const { getExposedMeta } = await import('../exposed');
const mainExposedMeta = getExposedMeta();
return [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
@@ -90,9 +90,7 @@ async function createOnboardingWindow(additionalArguments: string[]) {
fullscreenAndCenter(browserWindow);
});
await browserWindow.loadURL(
`${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}onboarding`
);
await browserWindow.loadURL(onboardingViewUrl);
return browserWindow;
}

View File

@@ -0,0 +1,45 @@
import { z } from 'zod';
export const workbenchViewModuleSchema = z.enum([
'trash',
'all',
'collection',
'tag',
'doc', // refers to a doc whose mode is not yet being resolved
'page',
'edgeless',
'journal',
]);
export const workbenchViewMetaSchema = z.object({
id: z.string(),
path: z
.object({
pathname: z.string().optional(),
hash: z.string().optional(),
search: z.string().optional(),
})
.optional(),
// todo: move title/module to cached stated
title: z.string().optional(),
moduleName: workbenchViewModuleSchema.optional(),
});
export const workbenchMetaSchema = z.object({
id: z.string(),
activeViewIndex: z.number(),
pinned: z.boolean().optional(),
basename: z.string(),
views: z.array(workbenchViewMetaSchema),
});
export const tabViewsMetaSchema = z.object({
activeWorkbenchId: z.string().optional(),
workbenches: z.array(workbenchMetaSchema).default([]),
});
export const TabViewsMetaKey = 'tabViewsMetaSchema';
export type TabViewsMetaSchema = z.infer<typeof tabViewsMetaSchema>;
export type WorkbenchMeta = z.infer<typeof workbenchMetaSchema>;
export type WorkbenchViewMeta = z.infer<typeof workbenchViewMetaSchema>;
export type WorkbenchViewModule = z.infer<typeof workbenchViewModuleSchema>;

View File

@@ -0,0 +1,999 @@
import { join } from 'node:path';
import {
app,
type CookiesSetDetails,
globalShortcut,
Menu,
type Rectangle,
type View,
type WebContents,
WebContentsView,
} from 'electron';
import { partition } from 'lodash-es';
import { nanoid } from 'nanoid';
import {
BehaviorSubject,
combineLatest,
filter,
firstValueFrom,
map,
shareReplay,
startWith,
Subject,
type Unsubscribable,
} from 'rxjs';
import { isMacOS } from '../../shared/utils';
import { isDev } from '../config';
import { mainWindowOrigin, shellViewUrl } from '../constants';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
import { globalStateStorage } from '../shared-storage/storage';
import { parseCookie } from '../utils';
import { getMainWindow, MainWindowManager } from './main-window';
import {
TabViewsMetaKey,
type TabViewsMetaSchema,
tabViewsMetaSchema,
type WorkbenchMeta,
type WorkbenchViewMeta,
} from './tab-views-meta-schema';
async function getAdditionalArguments() {
const { getExposedMeta } = await import('../exposed');
const mainExposedMeta = getExposedMeta();
const helperProcessManager = await ensureHelperProcess();
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
return [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
`--helper-exposed-meta=` + JSON.stringify(helperExposedMeta),
`--window-name=main`,
];
}
const TabViewsMetaState = {
$: globalStateStorage.watch<TabViewsMetaSchema>(TabViewsMetaKey).pipe(
map(v => tabViewsMetaSchema.parse(v ?? {})),
shareReplay(1)
),
set value(value: TabViewsMetaSchema) {
globalStateStorage.set(TabViewsMetaKey, value);
},
get value() {
return tabViewsMetaSchema.parse(
globalStateStorage.get(TabViewsMetaKey) ?? {}
);
},
// shallow merge
patch(patch: Partial<TabViewsMetaSchema>) {
this.value = {
...this.value,
...patch,
};
},
};
type AddTabAction = {
type: 'add-tab';
payload: WorkbenchMeta;
};
type CloseTabAction = {
type: 'close-tab';
payload?: string;
};
type PinTabAction = {
type: 'pin-tab';
payload: { key: string; shouldPin: boolean };
};
type ActivateViewAction = {
type: 'activate-view';
payload: { tabId: string; viewIndex: number };
};
type SeparateViewAction = {
type: 'separate-view';
payload: { tabId: string; viewIndex: number };
};
type OpenInSplitViewAction = {
type: 'open-in-split-view';
payload: { tabId: string };
};
type TabAction =
| AddTabAction
| CloseTabAction
| PinTabAction
| ActivateViewAction
| SeparateViewAction
| OpenInSplitViewAction;
type AddTabOption = {
basename: string;
view?: Omit<WorkbenchViewMeta, 'id'> | Array<Omit<WorkbenchViewMeta, 'id'>>;
};
export class WebContentViewsManager {
static readonly instance = new WebContentViewsManager(
MainWindowManager.instance
);
private constructor(public mainWindowManager: MainWindowManager) {
this.setup();
}
readonly tabViewsMeta$ = TabViewsMetaState.$;
readonly tabsBoundingRect$ = new BehaviorSubject<Rectangle | null>(null);
readonly appTabsUIReady$ = new BehaviorSubject(new Set<string>());
// all web views
readonly webViewsMap$ = new BehaviorSubject(
new Map<string, WebContentsView>()
);
readonly tabsStatus$ = combineLatest([
this.tabViewsMeta$.pipe(startWith(TabViewsMetaState.value)),
this.webViewsMap$,
this.appTabsUIReady$,
]).pipe(
map(([viewsMeta, views, ready]) => {
return viewsMeta.workbenches.map(w => {
return {
id: w.id,
pinned: !!w.pinned,
active: viewsMeta.activeWorkbenchId === w.id,
loaded: views.has(w.id),
ready: ready.has(w.id),
activeViewIndex: w.activeViewIndex,
views: w.views,
};
});
}),
shareReplay(1)
);
// all app views (excluding shell view)
readonly workbenchViewsMap$ = this.webViewsMap$.pipe(
map(
views => new Map([...views.entries()].filter(([key]) => key !== 'shell'))
)
);
// a stack of closed workbenches (for undo close tab)
readonly closedWorkbenches: WorkbenchMeta[] = [];
/**
* Emits whenever a tab action is triggered.
*/
readonly tabAction$ = new Subject<TabAction>();
readonly activeWorkbenchId$ = this.tabViewsMeta$.pipe(
map(m => m?.activeWorkbenchId ?? m?.workbenches[0].id)
);
readonly activeWorkbench$ = combineLatest([
this.activeWorkbenchId$,
this.workbenchViewsMap$,
]).pipe(map(([key, views]) => (key ? views.get(key) : undefined)));
readonly shellView$ = this.webViewsMap$.pipe(
map(views => views.get('shell'))
);
readonly webViewKeys$ = this.webViewsMap$.pipe(
map(views => Array.from(views.keys()))
);
get tabViewsMeta() {
return TabViewsMetaState.value;
}
private set tabViewsMeta(meta: TabViewsMetaSchema) {
TabViewsMetaState.value = meta;
}
readonly patchTabViewsMeta = (patch: Partial<TabViewsMetaSchema>) => {
TabViewsMetaState.patch(patch);
};
get tabsBoundingRect() {
return this.tabsBoundingRect$.value;
}
set tabsBoundingRect(rect: Rectangle | null) {
this.tabsBoundingRect$.next(rect);
}
get shellView() {
return this.webViewsMap$.value.get('shell');
}
set activeWorkbenchId(id: string | undefined) {
this.patchTabViewsMeta({
activeWorkbenchId: id,
});
}
get activeWorkbenchId() {
return (
this.tabViewsMeta.activeWorkbenchId ??
this.tabViewsMeta.workbenches.at(0)?.id
);
}
get activeWorkbenchView() {
return this.activeWorkbenchId
? this.webViewsMap$.value.get(this.activeWorkbenchId)
: undefined;
}
get activeWorkbenchMeta() {
return this.tabViewsMeta.workbenches.find(
w => w.id === this.activeWorkbenchId
);
}
get mainWindow() {
return this.mainWindowManager.mainWindow;
}
get tabViewsMap() {
return this.webViewsMap$.value;
}
get allViews() {
return Array.from(this.tabViewsMap.values());
}
setTabUIReady = (tabId: string) => {
this.appTabsUIReady$.next(new Set([...this.appTabsUIReady$.value, tabId]));
this.reorderViews();
};
getViewIdFromWebContentsId = (id: number) => {
return Array.from(this.tabViewsMap.entries()).find(
([, view]) => view.webContents.id === id
)?.[0];
};
updateWorkbenchMeta = (id: string, patch: Partial<WorkbenchMeta>) => {
const workbenches = this.tabViewsMeta.workbenches;
const index = workbenches.findIndex(w => w.id === id);
if (index === -1) {
return;
}
const newWorkbenches = workbenches.toSpliced(index, 1, {
...workbenches[index],
...patch,
});
this.patchTabViewsMeta({
workbenches: newWorkbenches,
});
};
isActiveTab = (id: string) => {
return this.activeWorkbenchId === id;
};
closeTab = async (id?: string) => {
if (!id) {
id = this.activeWorkbenchId;
}
if (!id) {
return;
}
const index = this.tabViewsMeta.workbenches.findIndex(w => w.id === id);
if (index === -1 || this.tabViewsMeta.workbenches.length === 1) {
return;
}
const targetWorkbench = this.tabViewsMeta.workbenches[index];
if (targetWorkbench.pinned) {
return;
}
const workbenches = this.tabViewsMeta.workbenches.toSpliced(index, 1);
// if the active view is closed, switch to the next view (index unchanged)
// if the new index is out of bound, switch to the last view
let activeWorkbenchKey = this.activeWorkbenchId;
if (id === activeWorkbenchKey) {
activeWorkbenchKey = workbenches[index]?.id ?? workbenches.at(-1)?.id;
}
if (!activeWorkbenchKey) {
return;
}
this.showTab(activeWorkbenchKey).catch(logger.error);
this.patchTabViewsMeta({
workbenches,
activeWorkbenchId: activeWorkbenchKey,
});
this.tabAction$.next({
type: 'close-tab',
payload: id,
});
this.closedWorkbenches.push(targetWorkbench);
setTimeout(() => {
const view = this.tabViewsMap.get(id);
this.tabViewsMap.delete(id);
if (this.mainWindow && view) {
this.mainWindow.contentView.removeChildView(view);
view?.webContents.close();
}
}, 500); // delay a bit to get rid of the flicker
};
undoCloseTab = async () => {
if (this.closedWorkbenches.length === 0) {
return;
}
const workbench = this.closedWorkbenches.pop();
if (workbench) {
await this.addTab({
basename: workbench.basename,
view: workbench.views,
});
}
};
addTab = async (option?: AddTabOption) => {
if (!option) {
option = {
basename: '/',
view: {
title: 'New Tab',
},
};
}
const workbenches = this.tabViewsMeta.workbenches;
const newKey = this.generateViewId('app');
const views = (
Array.isArray(option.view) ? option.view : [option.view]
).map(v => {
return {
...v,
id: nanoid(),
};
});
const workbench: WorkbenchMeta = {
basename: option.basename,
activeViewIndex: 0,
views: views,
id: newKey,
pinned: false,
};
this.patchTabViewsMeta({
activeWorkbenchId: newKey,
workbenches: [...workbenches, workbench],
});
await this.showTab(newKey);
this.tabAction$.next({
type: 'add-tab',
payload: workbench,
});
return {
...option,
key: newKey,
};
};
loadTab = async (id: string): Promise<WebContentsView | undefined> => {
if (!this.tabViewsMeta.workbenches.some(w => w.id === id)) {
return;
}
let view = this.tabViewsMap.get(id);
if (!view) {
view = await this.createAndAddView('app', id);
const workbench = this.tabViewsMeta.workbenches.find(w => w.id === id);
const viewMeta = workbench?.views[workbench.activeViewIndex];
if (workbench && viewMeta) {
const url = new URL(
workbench.basename + (viewMeta.path?.pathname ?? ''),
mainWindowOrigin
);
url.hash = viewMeta.path?.hash ?? '';
url.search = viewMeta.path?.search ?? '';
logger.info(`loading tab ${id} at ${url.href}`);
view.webContents.loadURL(url.href).catch(logger.error);
}
}
return view;
};
showTab = async (id: string): Promise<WebContentsView | undefined> => {
if (this.activeWorkbenchId !== id) {
// todo: this will cause the shell view to be on top and flickers the screen
// this.appTabsUIReady$.next(
// new Set([...this.appTabsUIReady$.value].filter(key => key !== id))
// );
this.patchTabViewsMeta({
activeWorkbenchId: id,
});
}
this.reorderViews();
let view = this.tabViewsMap.get(id);
if (!view) {
view = await this.loadTab(id);
}
this.reorderViews();
if (view) {
this.resizeView(view);
}
return view;
};
pinTab = (key: string, shouldPin: boolean) => {
// move the pinned tab to the last index of the pinned tabs
const [pinned, unPinned] = partition(
this.tabViewsMeta.workbenches,
w => w.pinned
);
const workbench = this.tabViewsMeta.workbenches.find(w => w.id === key);
if (!workbench) {
return;
}
this.tabAction$.next({
type: 'pin-tab',
payload: { key, shouldPin },
});
if (workbench.pinned && !shouldPin) {
this.patchTabViewsMeta({
workbenches: [
...pinned.filter(w => w.id !== key),
{ ...workbench, pinned: false },
...unPinned,
],
});
} else if (!workbench.pinned && shouldPin) {
this.patchTabViewsMeta({
workbenches: [
...pinned,
{ ...workbench, pinned: true },
...unPinned.filter(w => w.id !== key),
],
});
}
};
activateView = async (tabId: string, viewIndex: number) => {
this.tabAction$.next({
type: 'activate-view',
payload: { tabId, viewIndex },
});
this.updateWorkbenchMeta(tabId, {
activeViewIndex: viewIndex,
});
await this.showTab(tabId);
};
separateView = (tabId: string, viewIndex: number) => {
const tabMeta = this.tabViewsMeta.workbenches.find(w => w.id === tabId);
if (!tabMeta) {
return;
}
this.tabAction$.next({
type: 'separate-view',
payload: { tabId, viewIndex },
});
const newTabMeta: WorkbenchMeta = {
...tabMeta,
activeViewIndex: 0,
views: [tabMeta.views[viewIndex]],
};
this.updateWorkbenchMeta(tabId, {
views: tabMeta.views.toSpliced(viewIndex, 1),
});
addTab(newTabMeta).catch(logger.error);
};
openInSplitView = (tabId: string) => {
const tabMeta = this.tabViewsMeta.workbenches.find(w => w.id === tabId);
if (!tabMeta) {
return;
}
this.tabAction$.next({
type: 'open-in-split-view',
payload: { tabId },
});
};
reorderViews = () => {
if (this.mainWindow) {
// if tab ui of the current active view is not ready,
// make sure shell view is on top
const activeView = this.activeWorkbenchView;
const ready = this.activeWorkbenchId
? this.appTabsUIReady$.value.has(this.activeWorkbenchId)
: false;
// inactive < active view (not ready) < shell < active view (ready)
const getScore = (view: View) => {
if (view === this.shellView) {
return 2;
}
if (view === activeView) {
return ready ? 3 : 1;
}
return 0;
};
[...this.tabViewsMap.values()]
.toSorted((a, b) => getScore(a) - getScore(b))
.forEach((view, index) => {
this.mainWindow?.contentView.addChildView(view, index);
});
}
};
setup = () => {
const windowReadyToShow$ = this.mainWindowManager.mainWindow$.pipe(
filter(w => !!w)
);
const disposables: Unsubscribable[] = [];
disposables.push(
windowReadyToShow$.subscribe(w => {
handleWebContentsResize().catch(logger.error);
const screenSizeChangeEvents = ['resize', 'maximize', 'unmaximize'];
const onResize = () => {
if (this.activeWorkbenchView) {
this.resizeView(this.activeWorkbenchView);
}
if (this.shellView) {
this.resizeView(this.shellView);
}
};
screenSizeChangeEvents.forEach(event => {
w.on(event as any, onResize);
});
// add shell view
this.createAndAddView('shell').catch(logger.error);
(async () => {
if (this.tabViewsMeta.workbenches.length === 0) {
// create a default view (e.g., on first launch)
await this.addTab();
} else {
const defaultTabId = this.activeWorkbenchId;
if (defaultTabId) await this.showTab(defaultTabId);
}
})().catch(logger.error);
})
);
disposables.push(
this.tabsBoundingRect$.subscribe(rect => {
if (rect) {
this.reorderViews();
}
})
);
app.on('ready', () => {
// bind CMD/CTRL+1~8 to switch tabs
// bind CMD/CTRL+9 to switch to the last tab
[1, 2, 3, 4, 5, 6, 7, 8, 9].forEach(n => {
const shortcut = `CommandOrControl+${n}`;
const listener = () => {
if (!this.mainWindow?.isFocused()) {
return;
}
const item = this.tabViewsMeta.workbenches.at(n === 9 ? -1 : n - 1);
if (item) {
this.showTab(item.id).catch(logger.error);
}
};
globalShortcut.register(shortcut, listener);
disposables.push({
unsubscribe: () => globalShortcut.unregister(shortcut),
});
});
});
app.on('before-quit', () => {
disposables.forEach(d => d.unsubscribe());
});
};
setCookie = async (cookie: CookiesSetDetails) => {
const views = this.allViews;
if (!views) {
return;
}
logger.info('setting cookie to main window view(s)', cookie);
for (const view of views) {
await view.webContents.session.cookies.set(cookie);
}
};
removeCookie = async (url: string, name: string) => {
const views = this.allViews;
if (!views) {
return;
}
logger.info('removing cookie from main window view(s)', { url, name });
for (const view of views) {
await view.webContents.session.cookies.remove(url, name);
}
};
getCookie = (url?: string, name?: string) => {
// all webviews share the same session
const view = this.allViews?.at(0);
if (!view) {
return;
}
return view.webContents.session.cookies.get({
url,
name,
});
};
getViewById = (id: string) => {
if (id === 'shell') {
return this.shellView;
} else {
return this.tabViewsMap.get(id);
}
};
resizeView = (view: View) => {
// app view will take full w/h of the main window
view.setBounds({
x: 0,
y: 0,
width: this.mainWindow?.getContentBounds().width ?? 0,
height: this.mainWindow?.getContentBounds().height ?? 0,
});
};
private readonly generateViewId = (type: 'app' | 'shell') => {
return type === 'shell' ? 'shell' : `app-${nanoid()}`;
};
private readonly createAndAddView = async (
type: 'app' | 'shell',
viewId = this.generateViewId(type)
) => {
if (this.shellView && type === 'shell') {
logger.error('shell view is already created');
}
const start = performance.now();
const additionalArguments = await getAdditionalArguments();
const helperProcessManager = await ensureHelperProcess();
// will be added to appInfo
additionalArguments.push(`--view-id=${viewId}`);
const view = new WebContentsView({
webPreferences: {
webgl: true,
transparent: true,
contextIsolation: true,
sandbox: false,
spellcheck: false, // TODO(@pengx17): enable?
preload: join(__dirname, './preload.js'), // this points to the bundled preload module
// serialize exposed meta that to be used in preload
additionalArguments: additionalArguments,
},
});
this.webViewsMap$.next(this.tabViewsMap.set(viewId, view));
let unsub = () => {};
// shell process do not need to connect to helper process
if (type !== 'shell') {
view.webContents.on('did-finish-load', () => {
unsub = helperProcessManager.connectRenderer(view.webContents);
});
view.webContents.on('did-start-loading', () => {
// means the view is reloaded
this.appTabsUIReady$.next(
new Set([...this.appTabsUIReady$.value].filter(key => key !== viewId))
);
});
} else {
view.webContents.on('focus', () => {
globalThis.setTimeout(() => {
// when shell is focused, focus the active app view instead (to make sure global keybindings work)
this.activeWorkbenchView?.webContents.focus();
});
});
view.webContents.loadURL(shellViewUrl).catch(logger.error);
if (isDev) {
view.webContents.openDevTools();
}
}
view.webContents.on('destroyed', () => {
unsub();
this.webViewsMap$.next(
new Map(
[...this.tabViewsMap.entries()].filter(([key]) => key !== viewId)
)
);
// if all views are destroyed, close the app
// should only happen in tests
if (this.tabViewsMap.size === 0) {
app.quit();
}
});
this.resizeView(view);
// reorder will add to main window when loaded
this.reorderViews();
logger.info(`view ${viewId} created in ${performance.now() - start}ms`);
return view;
};
}
export async function setCookie(cookie: CookiesSetDetails): Promise<void>;
export async function setCookie(origin: string, cookie: string): Promise<void>;
export async function setCookie(
arg0: CookiesSetDetails | string,
arg1?: string
) {
const details =
typeof arg1 === 'string' && typeof arg0 === 'string'
? parseCookie(arg0, arg1)
: arg0;
logger.info('setting cookie to main window', details);
if (typeof details !== 'object') {
throw new Error('invalid cookie details');
}
return WebContentViewsManager.instance.setCookie(details);
}
export async function removeCookie(url: string, name: string): Promise<void> {
return WebContentViewsManager.instance.removeCookie(url, name);
}
export async function getCookie(url?: string, name?: string) {
return WebContentViewsManager.instance.getCookie(url, name);
}
// there is no proper way to listen to webContents resize event
// we will rely on window.resize event in renderer instead
export async function handleWebContentsResize() {
// right now when window is resized, we will relocate the traffic light positions
if (isMacOS()) {
const window = await getMainWindow();
const factor = window?.webContents.getZoomFactor() || 1;
window?.setWindowButtonPosition({ x: 20 * factor, y: 24 * factor - 6 });
}
}
export function onTabViewsMetaChanged(
fn: (appViewMeta: TabViewsMetaSchema) => void
) {
const sub = WebContentViewsManager.instance.tabViewsMeta$.subscribe(meta => {
fn(meta);
});
return () => {
sub.unsubscribe();
};
}
export const onTabShellViewActiveChange = (fn: (active: boolean) => void) => {
const sub = combineLatest([
WebContentViewsManager.instance.appTabsUIReady$,
WebContentViewsManager.instance.activeWorkbenchId$,
]).subscribe(([ready, active]) => {
fn(!ready.has(active));
});
return () => {
sub.unsubscribe();
};
};
export const getTabsStatus = () => {
return firstValueFrom(WebContentViewsManager.instance.tabsStatus$);
};
export const onTabsStatusChange = (
fn: (
tabs: {
id: string;
active: boolean;
loaded: boolean;
ready: boolean;
pinned: boolean;
activeViewIndex: number;
views: WorkbenchViewMeta[];
}[]
) => void
) => {
const sub = WebContentViewsManager.instance.tabsStatus$.subscribe(tabs => {
fn(tabs);
});
return () => {
sub.unsubscribe();
};
};
export const updateWorkbenchMeta = (
id: string,
meta: Partial<Omit<WorkbenchMeta, 'id'>>
) => {
WebContentViewsManager.instance.updateWorkbenchMeta(id, meta);
};
export const getWorkbenchMeta = (id: string) => {
return TabViewsMetaState.value.workbenches.find(w => w.id === id);
};
export const getTabViewsMeta = () => TabViewsMetaState.value;
export const isActiveTab = (wc: WebContents) => {
return (
wc.id ===
WebContentViewsManager.instance.activeWorkbenchView?.webContents.id
);
};
export const addTab = WebContentViewsManager.instance.addTab;
export const showTab = WebContentViewsManager.instance.showTab;
export const closeTab = WebContentViewsManager.instance.closeTab;
export const undoCloseTab = WebContentViewsManager.instance.undoCloseTab;
export const activateView = WebContentViewsManager.instance.activateView;
export const onTabAction = (fn: (event: TabAction) => void) => {
const { unsubscribe } =
WebContentViewsManager.instance.tabAction$.subscribe(fn);
return unsubscribe;
};
export const onActiveTabChanged = (fn: (tabId: string) => void) => {
const sub = WebContentViewsManager.instance.activeWorkbenchId$.subscribe(fn);
return () => {
sub.unsubscribe();
};
};
export const showDevTools = (id?: string) => {
const view = id
? WebContentViewsManager.instance.getViewById(id)
: WebContentViewsManager.instance.activeWorkbenchView;
if (view) {
view.webContents.openDevTools();
}
};
export const onTabsBoundingRectChanged = (
fn: (rect: Rectangle | null) => void
) => {
const sub = WebContentViewsManager.instance.tabsBoundingRect$.subscribe(fn);
return () => {
sub.unsubscribe();
};
};
export const getTabsBoundingRect = () => {
return WebContentViewsManager.instance.tabsBoundingRect;
};
export const updateTabsBoundingRect = (wc: WebContents, rect: Rectangle) => {
try {
if (isActiveTab(wc)) {
WebContentViewsManager.instance.tabsBoundingRect = rect;
}
const viewId = WebContentViewsManager.instance.getViewIdFromWebContentsId(
wc.id
);
if (viewId) {
WebContentViewsManager.instance.setTabUIReady(viewId);
}
} catch (err) {
logger.error(err);
}
};
export const showTabContextMenu = async (tabId: string, viewIndex: number) => {
const workbenches = WebContentViewsManager.instance.tabViewsMeta.workbenches;
const unpinned = workbenches.filter(w => !w.pinned);
const tabMeta = workbenches.find(w => w.id === tabId);
if (!tabMeta) {
return;
}
const template: Parameters<typeof Menu.buildFromTemplate>[0] = [
tabMeta.pinned
? {
label: 'Unpin tab',
click: () => {
WebContentViewsManager.instance.pinTab(tabId, false);
},
}
: {
label: 'Pin tab',
click: () => {
WebContentViewsManager.instance.pinTab(tabId, true);
},
},
{
label: 'Refresh tab',
click: () => {
WebContentViewsManager.instance
.getViewById(tabId)
?.webContents.reload();
},
},
{
label: 'Duplicate tab',
click: () => {
addTab(tabMeta).catch(logger.error);
},
},
{ type: 'separator' },
tabMeta.views.length > 1
? {
label: 'Separate tabs',
click: () => {
WebContentViewsManager.instance.separateView(tabId, viewIndex);
},
}
: {
label: 'Open in split view',
click: () => {
WebContentViewsManager.instance.openInSplitView(tabId);
},
},
...(unpinned.length > 0
? ([
{ type: 'separator' },
{
label: 'Close tab',
click: () => {
closeTab(tabId).catch(logger.error);
},
},
{
label: 'Close other tabs',
click: () => {
const tabsToRetain =
WebContentViewsManager.instance.tabViewsMeta.workbenches.filter(
w => w.id === tabId || w.pinned
);
WebContentViewsManager.instance.patchTabViewsMeta({
workbenches: tabsToRetain,
});
},
},
] as const)
: []),
];
const menu = Menu.buildFromTemplate(template);
menu.popup();
};

View File

@@ -73,10 +73,13 @@ schema = isDev ? 'affine-dev' : schema;
export const appInfo = {
electron: true,
windowName: process.argv
.find(arg => arg.startsWith('--window-name='))
?.split('=')[1],
schema,
windowName:
process.argv.find(arg => arg.startsWith('--window-name='))?.split('=')[1] ??
'unknown',
viewId:
process.argv.find(arg => arg.startsWith('--view-id='))?.split('=')[1] ??
'unknown',
schema: `${schema}`,
};
function getMainAPIs() {