waveterm/emain/emain-window.ts

622 lines
23 KiB
TypeScript
Raw Normal View History

2024-12-02 19:56:56 +01:00
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { ClientService, FileService, WindowService, WorkspaceService } from "@/app/store/services";
import { fireAndForget } from "@/util/util";
import { BaseWindow, BaseWindowConstructorOptions, dialog, ipcMain, screen } from "electron";
import path from "path";
import { debounce } from "throttle-debounce";
import { getGlobalIsQuitting, getGlobalIsRelaunching, setWasActive, setWasInFg } from "./emain-activity";
import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview";
import { delay, ensureBoundsAreVisible } from "./emain-util";
import { getElectronAppBasePath, unamePlatform } from "./platform";
import { updater } from "./updater";
export type WindowOpts = {
unamePlatform: string;
};
export const waveWindowMap = new Map<string, WaveBrowserWindow>(); // waveWindowId -> WaveBrowserWindow
export let focusedWaveWindow = null; // on blur we do not set this to null (but on destroy we do)
2024-12-06 09:10:17 +01:00
let cachedClientId: string = null;
async function getClientId() {
if (cachedClientId != null) {
return cachedClientId;
}
const clientData = await ClientService.GetClientData();
cachedClientId = clientData?.oid;
return cachedClientId;
}
type TabSwitchQueueEntry =
| {
createTab: false;
tabId: string;
setInBackend: boolean;
}
| {
createTab: true;
pinned: boolean;
};
2024-12-02 19:56:56 +01:00
export class WaveBrowserWindow extends BaseWindow {
waveWindowId: string;
workspaceId: string;
waveReadyPromise: Promise<void>;
allLoadedTabViews: Map<string, WaveTabView>;
2024-12-02 19:56:56 +01:00
activeTabView: WaveTabView;
private canClose: boolean;
private deleteAllowed: boolean;
private tabSwitchQueue: TabSwitchQueueEntry[];
2024-12-02 19:56:56 +01:00
constructor(waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts) {
console.log("create win", waveWindow.oid);
let winWidth = waveWindow?.winsize?.width;
let winHeight = waveWindow?.winsize?.height;
let winPosX = waveWindow.pos.x;
let winPosY = waveWindow.pos.y;
if (winWidth == null || winWidth == 0) {
const primaryDisplay = screen.getPrimaryDisplay();
const { width } = primaryDisplay.workAreaSize;
winWidth = width - winPosX - 100;
if (winWidth > 2000) {
winWidth = 2000;
}
}
if (winHeight == null || winHeight == 0) {
const primaryDisplay = screen.getPrimaryDisplay();
const { height } = primaryDisplay.workAreaSize;
winHeight = height - winPosY - 100;
if (winHeight > 1200) {
winHeight = 1200;
}
}
let winBounds = {
x: winPosX,
y: winPosY,
width: winWidth,
height: winHeight,
};
winBounds = ensureBoundsAreVisible(winBounds);
const settings = fullConfig?.settings;
const winOpts: BaseWindowConstructorOptions = {
titleBarStyle:
opts.unamePlatform === "darwin"
? "hiddenInset"
: settings["window:nativetitlebar"]
? "default"
: "hidden",
titleBarOverlay:
opts.unamePlatform !== "darwin"
? {
symbolColor: "white",
color: "#00000000",
}
: false,
x: winBounds.x,
y: winBounds.y,
width: winBounds.width,
height: winBounds.height,
minWidth: 400,
minHeight: 300,
icon:
opts.unamePlatform == "linux"
? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png")
: undefined,
show: false,
autoHideMenuBar: !settings?.["window:showmenubar"],
};
const isTransparent = settings?.["window:transparent"] ?? false;
const isBlur = !isTransparent && (settings?.["window:blur"] ?? false);
if (isTransparent) {
winOpts.transparent = true;
} else if (isBlur) {
switch (opts.unamePlatform) {
case "win32": {
winOpts.backgroundMaterial = "acrylic";
break;
}
case "darwin": {
winOpts.vibrancy = "fullscreen-ui";
break;
}
}
} else {
winOpts.backgroundColor = "#222222";
}
super(winOpts);
this.tabSwitchQueue = [];
this.waveWindowId = waveWindow.oid;
this.workspaceId = waveWindow.workspaceid;
this.allLoadedTabViews = new Map<string, WaveTabView>();
2024-12-02 19:56:56 +01:00
const winBoundsPoller = setInterval(() => {
if (this.isDestroyed()) {
clearInterval(winBoundsPoller);
return;
}
if (this.tabSwitchQueue.length > 0) {
return;
}
this.finalizePositioning();
}, 1000);
this.on(
// @ts-expect-error
"resize",
debounce(400, (e) => this.mainResizeHandler(e))
);
this.on("resize", () => {
if (this.isDestroyed()) {
return;
}
this.activeTabView?.positionTabOnScreen(this.getContentBounds());
});
this.on(
// @ts-expect-error
"move",
debounce(400, (e) => this.mainResizeHandler(e))
);
this.on("enter-full-screen", async () => {
if (this.isDestroyed()) {
return;
}
console.log("enter-full-screen event", this.getContentBounds());
const tabView = this.activeTabView;
if (tabView) {
tabView.webContents.send("fullscreen-change", true);
}
this.activeTabView?.positionTabOnScreen(this.getContentBounds());
});
this.on("leave-full-screen", async () => {
if (this.isDestroyed()) {
return;
}
const tabView = this.activeTabView;
if (tabView) {
tabView.webContents.send("fullscreen-change", false);
}
this.activeTabView?.positionTabOnScreen(this.getContentBounds());
});
this.on("focus", () => {
if (this.isDestroyed()) {
return;
}
if (getGlobalIsRelaunching()) {
return;
}
focusedWaveWindow = this;
console.log("focus win", this.waveWindowId);
fireAndForget(() => ClientService.FocusWindow(this.waveWindowId));
2024-12-02 19:56:56 +01:00
setWasInFg(true);
setWasActive(true);
});
this.on("blur", () => {
if (this.isDestroyed()) {
return;
}
if (focusedWaveWindow == this) {
focusedWaveWindow = null;
}
});
this.on("close", (e) => {
if (this.canClose) {
return;
}
if (this.isDestroyed()) {
return;
}
console.log("win 'close' handler fired", this.waveWindowId);
if (getGlobalIsQuitting() || updater?.status == "installing" || getGlobalIsRelaunching()) {
return;
}
e.preventDefault();
fireAndForget(async () => {
const numWindows = waveWindowMap.size;
if (numWindows > 1) {
console.log("numWindows > 1", numWindows);
const workspace = await WorkspaceService.GetWorkspace(this.workspaceId);
console.log("workspace", workspace);
if (!workspace.name && !workspace.icon && workspace.tabids.length > 1) {
console.log("workspace has no name, icon, and multiple tabs", workspace);
const choice = dialog.showMessageBoxSync(this, {
type: "question",
buttons: ["Cancel", "Yes"],
title: "Confirm",
message:
"Are you sure you want to close this window (all tabs and blocks will be deleted)?",
});
if (choice === 0) {
console.log("user cancelled close window", this.waveWindowId);
return;
}
}
console.log("deleteAllowed = true", this.waveWindowId);
this.deleteAllowed = true;
}
console.log("canClose = true", this.waveWindowId);
this.canClose = true;
this.close();
});
});
this.on("closed", () => {
console.log("win 'closed' handler fired", this.waveWindowId);
if (getGlobalIsQuitting() || updater?.status == "installing") {
console.log("win quitting or updating", this.waveWindowId);
return;
}
if (getGlobalIsRelaunching()) {
console.log("win relaunching", this.waveWindowId);
this.destroy();
return;
}
const numWindows = waveWindowMap.size;
if (numWindows == 0) {
console.log("win no windows left", this.waveWindowId);
return;
}
if (this.deleteAllowed) {
console.log("win removing window from backend DB", this.waveWindowId);
fireAndForget(() => WindowService.CloseWindow(this.waveWindowId, true));
2024-12-02 19:56:56 +01:00
}
for (const tabView of this.allLoadedTabViews.values()) {
tabView?.destroy();
}
waveWindowMap.delete(this.waveWindowId);
if (focusedWaveWindow == this) {
focusedWaveWindow = null;
}
2024-12-02 19:56:56 +01:00
});
waveWindowMap.set(waveWindow.oid, this);
}
async switchWorkspace(workspaceId: string) {
2024-12-02 23:52:34 +01:00
console.log("switchWorkspace", workspaceId, this.waveWindowId);
2024-12-02 19:56:56 +01:00
const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId);
if (curWorkspace.tabids.length > 1 && (!curWorkspace.name || !curWorkspace.icon)) {
const choice = dialog.showMessageBoxSync(this, {
type: "question",
buttons: ["Cancel", "Open in New Window", "Yes"],
title: "Confirm",
message:
"This window has unsaved tabs, switching workspaces will delete the existing tabs. Would you like to continue?",
});
if (choice === 0) {
console.log("user cancelled switch workspace", this.waveWindowId);
return;
} else if (choice === 1) {
console.log("user chose open in new window", this.waveWindowId);
const newWin = await WindowService.CreateWindow(null, workspaceId);
if (!newWin) {
console.log("error creating new window", this.waveWindowId);
}
const newBwin = await createBrowserWindow(newWin, await FileService.GetFullConfig(), { unamePlatform });
newBwin.show();
return;
}
}
const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, workspaceId);
if (!newWs) {
return;
}
2024-12-02 23:52:34 +01:00
console.log("switchWorkspace newWs", newWs);
if (this.allLoadedTabViews.size) {
for (const tab of this.allLoadedTabViews.values()) {
this.contentView.removeChildView(tab);
2024-12-02 19:56:56 +01:00
tab?.destroy();
}
}
2024-12-02 23:52:34 +01:00
console.log("destroyed all tabs", this.waveWindowId);
2024-12-02 19:56:56 +01:00
this.workspaceId = workspaceId;
this.allLoadedTabViews = new Map();
2024-12-02 23:52:34 +01:00
await this.setActiveTab(newWs.activetabid, false);
2024-12-02 19:56:56 +01:00
}
async setActiveTab(tabId: string, setInBackend: boolean) {
console.log("setActiveTab", tabId, this.waveWindowId, this.workspaceId, setInBackend);
2024-12-06 09:10:17 +01:00
await this.queueTabSwitch(tabId, setInBackend);
2024-12-02 19:56:56 +01:00
}
async closeTab(tabId: string) {
console.log(`closeTab tabid=${tabId} ws=${this.workspaceId} window=${this.waveWindowId}`);
const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true);
if (rtn == null) {
console.log("[error] closeTab: no return value", tabId, this.workspaceId, this.waveWindowId);
return;
2024-12-02 19:56:56 +01:00
}
if (rtn.closewindow) {
this.close();
return;
}
if (!rtn.newactivetabid) {
console.log("[error] closeTab, no new active tab", tabId, this.workspaceId, this.waveWindowId);
return;
}
await this.setActiveTab(rtn.newactivetabid, false);
this.allLoadedTabViews.delete(tabId);
2024-12-02 19:56:56 +01:00
}
2024-12-06 09:10:17 +01:00
async initializeTab(tabView: WaveTabView) {
const clientId = await getClientId();
await tabView.initPromise;
this.contentView.addChildView(tabView);
const initOpts = {
tabId: tabView.waveTabId,
clientId: clientId,
windowId: this.waveWindowId,
activate: true,
};
tabView.savedInitOpts = { ...initOpts };
tabView.savedInitOpts.activate = false;
let startTime = Date.now();
console.log("before wave ready, init tab, sending wave-init", tabView.waveTabId);
tabView.webContents.send("wave-init", initOpts);
await tabView.waveReadyPromise;
console.log("wave-ready init time", Date.now() - startTime + "ms");
}
2024-12-02 19:56:56 +01:00
async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean) {
if (this.activeTabView == tabView) {
return;
}
const oldActiveView = this.activeTabView;
tabView.isActiveTab = true;
if (oldActiveView != null) {
oldActiveView.isActiveTab = false;
}
this.activeTabView = tabView;
this.allLoadedTabViews.set(tabView.waveTabId, tabView);
2024-12-02 19:56:56 +01:00
if (!tabInitialized) {
console.log("initializing a new tab");
2024-12-06 09:10:17 +01:00
const p1 = this.initializeTab(tabView);
const p2 = this.repositionTabsSlowly(100);
await Promise.all([p1, p2]);
2024-12-02 19:56:56 +01:00
} else {
2024-12-06 09:10:17 +01:00
console.log("reusing an existing tab, calling wave-init", tabView.waveTabId);
2024-12-02 19:56:56 +01:00
const p1 = this.repositionTabsSlowly(35);
const p2 = tabView.webContents.send("wave-init", tabView.savedInitOpts); // reinit
await Promise.all([p1, p2]);
}
// something is causing the new tab to lose focus so it requires manual refocusing
tabView.webContents.focus();
setTimeout(() => {
if (this.activeTabView == tabView && !tabView.webContents.isFocused()) {
tabView.webContents.focus();
}
}, 10);
setTimeout(() => {
if (this.activeTabView == tabView && !tabView.webContents.isFocused()) {
tabView.webContents.focus();
}
}, 30);
}
async repositionTabsSlowly(delayMs: number) {
const activeTabView = this.activeTabView;
const winBounds = this.getContentBounds();
if (activeTabView == null) {
return;
}
if (activeTabView.isOnScreen()) {
activeTabView.setBounds({
x: 0,
y: 0,
width: winBounds.width,
height: winBounds.height,
});
} else {
activeTabView.setBounds({
x: winBounds.width - 10,
y: winBounds.height - 10,
width: winBounds.width,
height: winBounds.height,
});
}
await delay(delayMs);
if (this.activeTabView != activeTabView) {
// another tab view has been set, do not finalize this layout
return;
}
this.finalizePositioning();
}
finalizePositioning() {
if (this.isDestroyed()) {
return;
}
const curBounds = this.getContentBounds();
this.activeTabView?.positionTabOnScreen(curBounds);
for (const tabView of this.allLoadedTabViews.values()) {
2024-12-02 19:56:56 +01:00
if (tabView == this.activeTabView) {
continue;
}
tabView?.positionTabOffScreen(curBounds);
}
}
2024-12-06 09:10:17 +01:00
async queueTabSwitch(tabId: string, setInBackend: boolean) {
await this._queueTabSwitchInternal({ createTab: false, tabId, setInBackend });
}
async queueCreateTab(pinned = false) {
await this._queueTabSwitchInternal({ createTab: true, pinned });
}
async _queueTabSwitchInternal(entry: TabSwitchQueueEntry) {
2024-12-06 09:10:17 +01:00
if (this.tabSwitchQueue.length >= 2) {
this.tabSwitchQueue[1] = entry;
2024-12-02 19:56:56 +01:00
return;
}
2024-12-06 09:10:17 +01:00
const wasEmpty = this.tabSwitchQueue.length === 0;
this.tabSwitchQueue.push(entry);
2024-12-06 09:10:17 +01:00
if (wasEmpty) {
2024-12-02 19:56:56 +01:00
await this.processTabSwitchQueue();
}
}
2024-12-06 09:10:17 +01:00
// the queue and this function are used to serialize tab switches
// [0] => the tab that is currently being switched to
// [1] => the tab that will be switched to next
// queueTabSwitch will replace [1] if it is already set
// we don't mess with [0] because it is "in process"
// we replace [1] because there is no point to switching to a tab that will be switched out of immediately
2024-12-02 19:56:56 +01:00
async processTabSwitchQueue() {
2024-12-06 09:10:17 +01:00
while (this.tabSwitchQueue.length > 0) {
try {
const entry = this.tabSwitchQueue[0];
let tabId: string = null;
// have to use "===" here to get the typechecker to work :/
if (entry.createTab === true) {
const { pinned } = entry;
tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, pinned);
} else if (entry.createTab === false) {
let setInBackend: boolean = false;
({ tabId, setInBackend } = entry);
if (this.activeTabView?.waveTabId == tabId) {
continue;
}
if (setInBackend) {
await WorkspaceService.SetActiveTab(this.workspaceId, tabId);
}
2024-12-06 09:10:17 +01:00
}
if (tabId == null) {
return;
2024-12-06 09:10:17 +01:00
}
const [tabView, tabInitialized] = await getOrCreateWebViewForTab(tabId);
await this.setTabViewIntoWindow(tabView, tabInitialized);
} catch (e) {
console.log("error caught in processTabSwitchQueue", e);
} finally {
this.tabSwitchQueue.shift();
}
2024-12-02 19:56:56 +01:00
}
}
async mainResizeHandler(_: any) {
if (this == null || this.isDestroyed() || this.fullScreen) {
return;
}
const bounds = this.getBounds();
try {
await WindowService.SetWindowPosAndSize(
this.waveWindowId,
{ x: bounds.x, y: bounds.y },
{ width: bounds.width, height: bounds.height }
);
} catch (e) {
console.log("error sending new window bounds to backend", e);
}
}
destroy() {
console.log("destroy win", this.waveWindowId);
this.deleteAllowed = true;
2024-12-02 19:56:56 +01:00
super.destroy();
}
}
export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow {
for (const ww of waveWindowMap.values()) {
if (ww.allLoadedTabViews.has(tabId)) {
2024-12-02 19:56:56 +01:00
return ww;
}
}
}
export function getWaveWindowByWebContentsId(webContentsId: number): WaveBrowserWindow {
const tabView = getWaveTabViewByWebContentsId(webContentsId);
if (tabView == null) {
return null;
}
return getWaveWindowByTabId(tabView.waveTabId);
}
export function getWaveWindowById(windowId: string): WaveBrowserWindow {
return waveWindowMap.get(windowId);
}
export function getWaveWindowByWorkspaceId(workspaceId: string): WaveBrowserWindow {
for (const waveWindow of waveWindowMap.values()) {
if (waveWindow.workspaceId === workspaceId) {
return waveWindow;
}
}
}
export function getAllWaveWindows(): WaveBrowserWindow[] {
return Array.from(waveWindowMap.values());
}
// note, this does not *show* the window.
// to show, await win.readyPromise and then win.show()
export async function createBrowserWindow(
waveWindow: WaveWindow,
fullConfig: FullConfigType,
opts: WindowOpts
): Promise<WaveBrowserWindow> {
if (!waveWindow) {
console.log("createBrowserWindow: no waveWindow");
waveWindow = await WindowService.CreateWindow(null, "");
}
let workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid);
if (!workspace) {
console.log("createBrowserWindow: no workspace, creating new window");
await WindowService.CloseWindow(waveWindow.oid, true);
waveWindow = await WindowService.CreateWindow(null, "");
workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid);
}
console.log("createBrowserWindow", waveWindow.oid, workspace.oid, workspace);
const bwin = new WaveBrowserWindow(waveWindow, fullConfig, opts);
if (workspace.activetabid) {
await bwin.setActiveTab(workspace.activetabid, false);
2024-12-02 19:56:56 +01:00
}
return bwin;
}
ipcMain.on("set-active-tab", async (event, tabId) => {
const ww = getWaveWindowByWebContentsId(event.sender.id);
console.log("set-active-tab", tabId, ww?.waveWindowId);
await ww?.setActiveTab(tabId, true);
2024-12-02 19:56:56 +01:00
});
ipcMain.on("create-tab", async (event, opts) => {
const senderWc = event.sender;
const ww = getWaveWindowByWebContentsId(senderWc.id);
if (ww != null) {
await ww.queueCreateTab();
2024-12-02 19:56:56 +01:00
}
event.returnValue = true;
return null;
});
ipcMain.on("close-tab", async (event, workspaceId, tabId) => {
const ww = getWaveWindowByWorkspaceId(workspaceId);
if (ww == null) {
console.log(`close-tab: no window found for workspace ws=${workspaceId} tab=${tabId}`);
return;
}
if (ww != null) {
await ww.closeTab(tabId);
}
2024-12-02 19:56:56 +01:00
event.returnValue = true;
return null;
});
ipcMain.on("switch-workspace", async (event, workspaceId) => {
const ww = getWaveWindowByWebContentsId(event.sender.id);
console.log("switch-workspace", workspaceId, ww?.waveWindowId);
await ww?.switchWorkspace(workspaceId);
});
ipcMain.on("delete-workspace", async (event, workspaceId) => {
const ww = getWaveWindowByWebContentsId(event.sender.id);
console.log("delete-workspace", workspaceId, ww?.waveWindowId);
await WorkspaceService.DeleteWorkspace(workspaceId);
console.log("delete-workspace done", workspaceId, ww?.waveWindowId);
if (ww?.workspaceId == workspaceId) {
console.log("delete-workspace closing window", workspaceId, ww?.waveWindowId);
ww.destroy();
2024-12-02 19:56:56 +01:00
}
});