mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
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:
@@ -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);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getMainWindow,
|
||||
handleOpenUrlInHiddenWindow,
|
||||
setCookie,
|
||||
} from './main-window';
|
||||
} from './windows-manager';
|
||||
|
||||
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
|
||||
if (isDev) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,4 +3,5 @@ import { Subject } from 'rxjs';
|
||||
export const uiSubjects = {
|
||||
onMaximized$: new Subject<boolean>(),
|
||||
onFullScreen$: new Subject<boolean>(),
|
||||
onToggleRightSidebar$: new Subject<string>(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './launcher';
|
||||
export * from './main-window';
|
||||
export * from './onboarding';
|
||||
export * from './stage';
|
||||
export * from './tab-views';
|
||||
export * from './types';
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
999
packages/frontend/electron/src/main/windows-manager/tab-views.ts
Normal file
999
packages/frontend/electron/src/main/windows-manager/tab-views.ts
Normal 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();
|
||||
};
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user