From c1c90bb4f82462927ae6297ac8f7e24dd0894714 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 17 Oct 2024 14:34:02 -0700 Subject: [PATCH] browser view (#1005) --- cmd/wsh/cmd/wshcmd-web.go | 6 +- emain/emain-activity.ts | 54 ++ emain/emain-util.ts | 168 +++++ emain/emain-viewmgr.ts | 556 ++++++++++++++++ emain/emain-wavesrv.ts | 118 ++++ emain/emain-web.ts | 6 +- emain/emain-wsh.ts | 14 +- emain/emain.ts | 731 +++++---------------- emain/menu.ts | 57 +- emain/preload.ts | 6 + emain/updater.ts | 27 +- frontend/app/app-bg.tsx | 5 +- frontend/app/app.less | 4 + frontend/app/app.tsx | 15 +- frontend/app/block/blockframe.tsx | 16 +- frontend/app/element/markdown.tsx | 4 +- frontend/app/hook/useDimensions.tsx | 46 ++ frontend/app/store/global.ts | 38 +- frontend/app/store/keymodel.ts | 59 +- frontend/app/store/services.ts | 8 +- frontend/app/store/wos.ts | 26 + frontend/app/store/ws.ts | 12 +- frontend/app/store/wshrouter.ts | 10 +- frontend/app/store/wshrpcutil.ts | 22 +- frontend/app/tab/tabbar.tsx | 22 +- frontend/app/view/cpuplot/cpuplot.tsx | 6 +- frontend/app/view/preview/preview.tsx | 8 +- frontend/app/view/term/term.tsx | 18 +- frontend/app/view/term/termsticker.tsx | 4 +- frontend/app/view/term/termwrap.ts | 8 +- frontend/app/view/waveai/waveai.tsx | 4 +- frontend/app/view/webview/webview.tsx | 8 +- frontend/app/workspace/workspace.tsx | 10 +- frontend/layout/index.ts | 4 +- frontend/layout/lib/TileLayout.tsx | 2 +- frontend/layout/lib/layoutAtom.ts | 4 + frontend/layout/lib/layoutModelHooks.ts | 9 +- frontend/types/custom.d.ts | 39 +- frontend/types/gotypes.d.ts | 7 + frontend/wave.ts | 140 +++- index.html | 3 +- pkg/blockcontroller/blockcontroller.go | 3 - pkg/eventbus/eventbus.go | 16 +- pkg/service/objectservice/objectservice.go | 29 +- pkg/service/windowservice/windowservice.go | 50 +- pkg/wconfig/defaultconfig/settings.json | 1 + pkg/wconfig/metaconsts.go | 1 + pkg/wconfig/settingsconfig.go | 1 + pkg/wcore/wcore.go | 7 + pkg/web/ws.go | 15 +- pkg/wps/wps.go | 5 +- pkg/wshrpc/wshserver/wshserver.go | 4 +- pkg/wshutil/wshrouter.go | 8 +- pkg/wstore/wstore_dbops.go | 10 + 54 files changed, 1608 insertions(+), 846 deletions(-) create mode 100644 emain/emain-activity.ts create mode 100644 emain/emain-util.ts create mode 100644 emain/emain-viewmgr.ts create mode 100644 emain/emain-wavesrv.ts diff --git a/cmd/wsh/cmd/wshcmd-web.go b/cmd/wsh/cmd/wshcmd-web.go index bd2985931..91e1e8945 100644 --- a/cmd/wsh/cmd/wshcmd-web.go +++ b/cmd/wsh/cmd/wshcmd-web.go @@ -30,7 +30,7 @@ var webOpenCmd = &cobra.Command{ var webGetCmd = &cobra.Command{ Use: "get [--inner] [--all] [--json] blockid css-selector", Short: "get the html for a css selector", - Args: cobra.ExactArgs(2), + Args: cobra.ExactArgs(1), Hidden: true, RunE: webGetRun, } @@ -51,7 +51,7 @@ func init() { } func webGetRun(cmd *cobra.Command, args []string) error { - oref := args[0] + oref := blockArg if oref == "" { return fmt.Errorf("blockid not specified") } @@ -74,7 +74,7 @@ func webGetRun(cmd *cobra.Command, args []string) error { WindowId: blockInfo.WindowId, BlockId: fullORef.OID, TabId: blockInfo.TabId, - Selector: args[1], + Selector: args[0], Opts: &wshrpc.WebSelectorOpts{ Inner: webGetInner, All: webGetAll, diff --git a/emain/emain-activity.ts b/emain/emain-activity.ts new file mode 100644 index 000000000..fab7425d9 --- /dev/null +++ b/emain/emain-activity.ts @@ -0,0 +1,54 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// for activity updates +let wasActive = true; +let wasInFg = true; +let globalIsQuitting = false; +let globalIsStarting = true; +let globalIsRelaunching = false; +let forceQuit = false; + +export function setWasActive(val: boolean) { + wasActive = val; +} + +export function setWasInFg(val: boolean) { + wasInFg = val; +} + +export function getActivityState(): { wasActive: boolean; wasInFg: boolean } { + return { wasActive, wasInFg }; +} + +export function setGlobalIsQuitting(val: boolean) { + globalIsQuitting = val; +} + +export function getGlobalIsQuitting(): boolean { + return globalIsQuitting; +} + +export function setGlobalIsStarting(val: boolean) { + globalIsStarting = val; +} + +export function getGlobalIsStarting(): boolean { + return globalIsStarting; +} + +export function setGlobalIsRelaunching(val: boolean) { + globalIsRelaunching = val; +} + +export function getGlobalIsRelaunching(): boolean { + return globalIsRelaunching; +} + +export function setForceQuit(val: boolean) { + forceQuit = val; +} + +export function getForceQuit(): boolean { + return forceQuit; +} diff --git a/emain/emain-util.ts b/emain/emain-util.ts new file mode 100644 index 000000000..ffdffb370 --- /dev/null +++ b/emain/emain-util.ts @@ -0,0 +1,168 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as electron from "electron"; +import { getWebServerEndpoint } from "../frontend/util/endpoints"; + +export const WaveAppPathVarName = "WAVETERM_APP_PATH"; + +export function delay(ms): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function setCtrlShift(wc: Electron.WebContents, state: boolean) { + wc.send("control-shift-state-update", state); +} + +export function handleCtrlShiftFocus(sender: Electron.WebContents, focused: boolean) { + if (!focused) { + setCtrlShift(sender, false); + } +} + +export function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: WaveKeyboardEvent) { + if (waveEvent.type == "keyup") { + if (waveEvent.key === "Control" || waveEvent.key === "Shift") { + setCtrlShift(sender, false); + } + if (waveEvent.key == "Meta") { + if (waveEvent.control && waveEvent.shift) { + setCtrlShift(sender, true); + } + } + return; + } + if (waveEvent.type == "keydown") { + if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") { + if (waveEvent.control && waveEvent.shift && !waveEvent.meta) { + // Set the control and shift without the Meta key + setCtrlShift(sender, true); + } else { + // Unset if Meta is pressed + setCtrlShift(sender, false); + } + } + return; + } +} + +export function shNavHandler(event: Electron.Event, url: string) { + if (url.startsWith("http://127.0.0.1:5173/index.html") || url.startsWith("http://localhost:5173/index.html")) { + // this is a dev-mode hot-reload, ignore it + console.log("allowing hot-reload of index.html"); + return; + } + event.preventDefault(); + if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) { + console.log("open external, shNav", url); + electron.shell.openExternal(url); + } else { + console.log("navigation canceled", url); + } +} + +export function shFrameNavHandler(event: Electron.Event) { + if (!event.frame?.parent) { + // only use this handler to process iframe events (non-iframe events go to shNavHandler) + return; + } + const url = event.url; + console.log(`frame-navigation url=${url} frame=${event.frame.name}`); + if (event.frame.name == "webview") { + // "webview" links always open in new window + // this will *not* effect the initial load because srcdoc does not count as an electron navigation + console.log("open external, frameNav", url); + event.preventDefault(); + electron.shell.openExternal(url); + return; + } + if ( + event.frame.name == "pdfview" && + (url.startsWith("blob:file:///") || url.startsWith(getWebServerEndpoint() + "/wave/stream-file?")) + ) { + // allowed + return; + } + event.preventDefault(); + console.log("frame navigation canceled"); +} + +function isWindowFullyVisible(bounds: electron.Rectangle): boolean { + const displays = electron.screen.getAllDisplays(); + + // Helper function to check if a point is inside any display + function isPointInDisplay(x: number, y: number) { + for (const display of displays) { + const { x: dx, y: dy, width, height } = display.bounds; + if (x >= dx && x < dx + width && y >= dy && y < dy + height) { + return true; + } + } + return false; + } + + // Check all corners of the window + const topLeft = isPointInDisplay(bounds.x, bounds.y); + const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y); + const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height); + const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height); + + return topLeft && topRight && bottomLeft && bottomRight; +} + +function findDisplayWithMostArea(bounds: electron.Rectangle): electron.Display { + const displays = electron.screen.getAllDisplays(); + let maxArea = 0; + let bestDisplay = null; + + for (let display of displays) { + const { x, y, width, height } = display.bounds; + const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x)); + const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y)); + const overlapArea = overlapX * overlapY; + + if (overlapArea > maxArea) { + maxArea = overlapArea; + bestDisplay = display; + } + } + + return bestDisplay; +} + +function adjustBoundsToFitDisplay(bounds: electron.Rectangle, display: electron.Display): electron.Rectangle { + const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea; + let { x, y, width, height } = bounds; + + // Adjust width and height to fit within the display's work area + width = Math.min(width, dWidth); + height = Math.min(height, dHeight); + + // Adjust x to ensure the window fits within the display + if (x < dx) { + x = dx; + } else if (x + width > dx + dWidth) { + x = dx + dWidth - width; + } + + // Adjust y to ensure the window fits within the display + if (y < dy) { + y = dy; + } else if (y + height > dy + dHeight) { + y = dy + dHeight - height; + } + return { x, y, width, height }; +} + +export function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle { + if (!isWindowFullyVisible(bounds)) { + let targetDisplay = findDisplayWithMostArea(bounds); + + if (!targetDisplay) { + targetDisplay = electron.screen.getPrimaryDisplay(); + } + + return adjustBoundsToFitDisplay(bounds, targetDisplay); + } + return bounds; +} diff --git a/emain/emain-viewmgr.ts b/emain/emain-viewmgr.ts new file mode 100644 index 000000000..3a9065186 --- /dev/null +++ b/emain/emain-viewmgr.ts @@ -0,0 +1,556 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ClientService, FileService, ObjectService, WindowService } from "@/app/store/services"; +import * as electron from "electron"; +import { + delay, + ensureBoundsAreVisible, + handleCtrlShiftFocus, + handleCtrlShiftState, + shFrameNavHandler, + shNavHandler, +} from "emain/emain-util"; +import * as keyutil from "frontend/util/keyutil"; +import * as path from "path"; +import { debounce } from "throttle-debounce"; +import { configureAuthKeyRequestInjection } from "./authkey"; +import { getGlobalIsQuitting, getGlobalIsStarting, setWasActive, setWasInFg } from "./emain-activity"; +import { getElectronAppBasePath, isDevVite } from "./platform"; +import { updater } from "./updater"; + +let MaxCacheSize = 10; +let HotSpareTab: WaveTabView = null; + +const waveWindowMap = new Map(); // waveWindowId -> WaveBrowserWindow +let focusedWaveWindow = null; // on blur we do not set this to null (but on destroy we do) +const wcvCache = new Map(); +const wcIdToWaveTabMap = new Map(); + +export function setMaxTabCacheSize(size: number) { + console.log("setMaxTabCacheSize", size); + MaxCacheSize = size; +} + +function computeBgColor(fullConfig: FullConfigType): string { + const settings = fullConfig?.settings; + const isTransparent = settings?.["window:transparent"] ?? false; + const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); + if (isTransparent) { + return "#00000000"; + } else if (isBlur) { + return "#00000000"; + } else { + return "#222222"; + } +} + +function createBareTabView(fullConfig: FullConfigType): WaveTabView { + console.log("createBareTabView"); + const tabView = new electron.WebContentsView({ + webPreferences: { + preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), + webviewTag: true, + }, + }) as WaveTabView; + tabView.createdTs = Date.now(); + tabView.savedInitOpts = null; + tabView.initPromise = new Promise((resolve, _) => { + tabView.initResolve = resolve; + }); + tabView.initPromise.then(() => { + console.log("tabview init", Date.now() - tabView.createdTs + "ms"); + }); + tabView.waveReadyPromise = new Promise((resolve, _) => { + tabView.waveReadyResolve = resolve; + }); + wcIdToWaveTabMap.set(tabView.webContents.id, tabView); + if (isDevVite) { + tabView.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html}`); + } else { + tabView.webContents.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); + } + tabView.webContents.on("destroyed", () => { + wcIdToWaveTabMap.delete(tabView.webContents.id); + removeWaveTabView(tabView.waveWindowId, tabView.waveTabId); + }); + tabView.setBackgroundColor(computeBgColor(fullConfig)); + return tabView; +} + +function positionTabOffScreen(tabView: WaveTabView, winBounds: Electron.Rectangle) { + if (tabView == null) { + return; + } + tabView.setBounds({ + x: -10000, + y: -10000, + width: winBounds.width, + height: winBounds.height, + }); +} + +async function repositionTabsSlowly( + newTabView: WaveTabView, + oldTabView: WaveTabView, + delayMs: number, + winBounds: Electron.Rectangle +) { + if (newTabView == null) { + return; + } + newTabView.setBounds({ + x: winBounds.width - 10, + y: winBounds.height - 10, + width: winBounds.width, + height: winBounds.height, + }); + await delay(delayMs); + newTabView.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height }); + oldTabView?.setBounds({ + x: -10000, + y: -10000, + width: winBounds.width, + height: winBounds.height, + }); +} + +function positionTabOnScreen(tabView: WaveTabView, winBounds: Electron.Rectangle) { + if (tabView == null) { + return; + } + tabView.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height }); +} + +export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabView { + return wcIdToWaveTabMap.get(webContentsId); +} + +export function getWaveWindowByWebContentsId(webContentsId: number): WaveBrowserWindow { + const tabView = wcIdToWaveTabMap.get(webContentsId); + if (tabView == null) { + return null; + } + return waveWindowMap.get(tabView.waveWindowId); +} + +export function getWaveWindowById(windowId: string): WaveBrowserWindow { + return waveWindowMap.get(windowId); +} + +export function getAllWaveWindows(): WaveBrowserWindow[] { + return Array.from(waveWindowMap.values()); +} + +export function getFocusedWaveWindow(): WaveBrowserWindow { + return focusedWaveWindow; +} + +export function ensureHotSpareTab(fullConfig: FullConfigType) { + console.log("ensureHotSpareTab"); + if (HotSpareTab == null) { + HotSpareTab = createBareTabView(fullConfig); + } +} + +export function destroyWindow(waveWindow: WaveBrowserWindow) { + if (waveWindow == null) { + return; + } + for (const tabView of waveWindow.allTabViews.values()) { + destroyTab(tabView); + } + waveWindowMap.delete(waveWindow.waveWindowId); +} + +export function destroyTab(tabView: WaveTabView) { + if (tabView == null) { + return; + } + tabView.webContents.close(); + wcIdToWaveTabMap.delete(tabView.webContents.id); + removeWaveTabView(tabView.waveWindowId, tabView.waveTabId); + const waveWindow = waveWindowMap.get(tabView.waveWindowId); + if (waveWindow) { + waveWindow.allTabViews.delete(tabView.waveTabId); + } +} + +function getSpareTab(fullConfig: FullConfigType): WaveTabView { + setTimeout(ensureHotSpareTab, 500); + if (HotSpareTab != null) { + const rtn = HotSpareTab; + HotSpareTab = null; + console.log("getSpareTab: returning hotspare"); + return rtn; + } else { + console.log("getSpareTab: creating new tab"); + return createBareTabView(fullConfig); + } +} + +function getWaveTabView(waveWindowId: string, waveTabId: string): WaveTabView | undefined { + const cacheKey = waveWindowId + "|" + waveTabId; + const rtn = wcvCache.get(cacheKey); + if (rtn) { + rtn.lastUsedTs = Date.now(); + } + return rtn; +} + +function setWaveTabView(waveWindowId: string, waveTabId: string, wcv: WaveTabView): void { + const cacheKey = waveWindowId + "|" + waveTabId; + wcvCache.set(cacheKey, wcv); + checkAndEvictCache(); +} + +function removeWaveTabView(waveWindowId: string, waveTabId: string): void { + const cacheKey = waveWindowId + "|" + waveTabId; + wcvCache.delete(cacheKey); +} + +function forceRemoveAllTabsForWindow(waveWindowId: string): void { + const keys = Array.from(wcvCache.keys()); + for (const key of keys) { + if (key.startsWith(waveWindowId)) { + wcvCache.delete(key); + } + } +} + +function checkAndEvictCache(): void { + if (wcvCache.size <= MaxCacheSize) { + return; + } + const sorted = Array.from(wcvCache.values()).sort((a, b) => { + // Prioritize entries which are active + if (a.isActiveTab && !b.isActiveTab) { + return -1; + } + // Otherwise, sort by lastUsedTs + return a.lastUsedTs - b.lastUsedTs; + }); + for (let i = 0; i < sorted.length - MaxCacheSize; i++) { + if (sorted[i].isActiveTab) { + // don't evict WaveTabViews that are currently showing in a window + continue; + } + const tabView = sorted[i]; + destroyTab(tabView); + } +} + +export function clearTabCache() { + const wcVals = Array.from(wcvCache.values()); + for (let i = 0; i < wcVals.length; i++) { + const tabView = wcVals[i]; + if (tabView.isActiveTab) { + continue; + } + destroyTab(tabView); + } +} + +// returns [tabview, initialized] +function getOrCreateWebViewForTab(fullConfig: FullConfigType, windowId: string, tabId: string): [WaveTabView, boolean] { + let tabView = getWaveTabView(windowId, tabId); + if (tabView) { + return [tabView, true]; + } + tabView = getSpareTab(fullConfig); + tabView.lastUsedTs = Date.now(); + tabView.waveTabId = tabId; + tabView.waveWindowId = windowId; + setWaveTabView(windowId, tabId, tabView); + tabView.webContents.on("will-navigate", shNavHandler); + tabView.webContents.on("will-frame-navigate", shFrameNavHandler); + tabView.webContents.on("did-attach-webview", (event, wc) => { + wc.setWindowOpenHandler((details) => { + tabView.webContents.send("webview-new-window", wc.id, details); + return { action: "deny" }; + }); + }); + tabView.webContents.on("before-input-event", (e, input) => { + const waveEvent = keyutil.adaptFromElectronKeyEvent(input); + // console.log("WIN bie", tabView.waveTabId.substring(0, 8), waveEvent.type, waveEvent.code); + handleCtrlShiftState(tabView.webContents, waveEvent); + setWasActive(true); + }); + tabView.webContents.on("zoom-changed", (e) => { + tabView.webContents.send("zoom-changed"); + }); + tabView.webContents.setWindowOpenHandler(({ url, frameName }) => { + if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { + console.log("openExternal fallback", url); + electron.shell.openExternal(url); + } + console.log("window-open denied", url); + return { action: "deny" }; + }); + tabView.webContents.on("focus", () => { + setWasInFg(true); + setWasActive(true); + if (getGlobalIsStarting()) { + return; + } + }); + tabView.webContents.on("blur", () => { + handleCtrlShiftFocus(tabView.webContents, false); + }); + configureAuthKeyRequestInjection(tabView.webContents.session); + return [tabView, false]; +} + +async function mainResizeHandler(_: any, windowId: string, win: WaveBrowserWindow) { + if (win == null || win.isDestroyed() || win.fullScreen) { + return; + } + const bounds = win.getBounds(); + try { + await WindowService.SetWindowPosAndSize( + windowId, + { x: bounds.x, y: bounds.y }, + { width: bounds.width, height: bounds.height } + ); + } catch (e) { + console.log("error resizing window", e); + } +} + +type WindowOpts = { + unamePlatform: string; +}; + +function createBaseWaveBrowserWindow( + waveWindow: WaveWindow, + fullConfig: FullConfigType, + opts: WindowOpts +): WaveBrowserWindow { + 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 = electron.screen.getPrimaryDisplay(); + const { width } = primaryDisplay.workAreaSize; + winWidth = width - winPosX - 100; + if (winWidth > 2000) { + winWidth = 2000; + } + } + if (winHeight == null || winHeight == 0) { + const primaryDisplay = electron.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: Electron.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"; + } + const bwin = new electron.BaseWindow(winOpts); + const win: WaveBrowserWindow = bwin as WaveBrowserWindow; + win.waveWindowId = waveWindow.oid; + win.alreadyClosed = false; + win.allTabViews = new Map(); + win.on( + "resize", + debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) + ); + win.on("resize", () => { + if (win.isDestroyed() || win.fullScreen) { + return; + } + positionTabOnScreen(win.activeTabView, win.getContentBounds()); + }); + win.on( + "move", + debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) + ); + win.on("enter-full-screen", async () => { + const tabView = win.activeTabView; + if (tabView) { + tabView.webContents.send("fullscreen-change", true); + } + }); + win.on("leave-full-screen", async () => { + const tabView = win.activeTabView; + if (tabView) { + tabView.webContents.send("fullscreen-change", false); + } + }); + win.on("focus", () => { + focusedWaveWindow = win; + console.log("focus win", win.waveWindowId); + ClientService.FocusWindow(win.waveWindowId); + }); + win.on("blur", () => { + if (focusedWaveWindow == win) { + focusedWaveWindow = null; + } + }); + win.on("close", (e) => { + if (getGlobalIsQuitting() || updater?.status == "installing") { + return; + } + const numWindows = waveWindowMap.size; + if (numWindows == 1) { + return; + } + const choice = electron.dialog.showMessageBoxSync(win, { + 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) { + e.preventDefault(); + } + }); + win.on("closed", () => { + if (getGlobalIsQuitting() || updater?.status == "installing") { + return; + } + const numWindows = waveWindowMap.size; + if (numWindows == 0) { + return; + } + if (!win.alreadyClosed) { + WindowService.CloseWindow(waveWindow.oid, true); + } + destroyWindow(win); + }); + waveWindowMap.set(waveWindow.oid, win); + return win; +} + +export function getLastFocusedWaveWindow(): WaveBrowserWindow { + return focusedWaveWindow; +} + +// note, this does not *show* the window. +// to show, await win.readyPromise and then win.show() +export function createBrowserWindow( + clientId: string, + waveWindow: WaveWindow, + fullConfig: FullConfigType, + opts: WindowOpts +): WaveBrowserWindow { + const bwin = createBaseWaveBrowserWindow(waveWindow, fullConfig, opts); + // TODO fix null activetabid if it exists + if (waveWindow.activetabid != null) { + setActiveTab(bwin, waveWindow.activetabid); + } + return bwin; +} + +export async function setActiveTab(waveWindow: WaveBrowserWindow, tabId: string) { + const windowId = waveWindow.waveWindowId; + await ObjectService.SetActiveTab(waveWindow.waveWindowId, tabId); + const fullConfig = await FileService.GetFullConfig(); + const [tabView, tabInitialized] = getOrCreateWebViewForTab(fullConfig, windowId, tabId); + setTabViewIntoWindow(waveWindow, tabView, tabInitialized); +} + +async function setTabViewIntoWindow(bwin: WaveBrowserWindow, tabView: WaveTabView, tabInitialized: boolean) { + const curTabView: WaveTabView = bwin.getContentView() as any; + const clientData = await ClientService.GetClientData(); + if (curTabView != null) { + curTabView.isActiveTab = false; + } + if (bwin.activeTabView == tabView) { + return; + } + const oldActiveView = bwin.activeTabView; + tabView.isActiveTab = true; + if (oldActiveView != null) { + oldActiveView.isActiveTab = false; + } + bwin.activeTabView = tabView; + bwin.allTabViews.set(tabView.waveTabId, tabView); + if (!tabInitialized) { + console.log("initializing a new tab"); + await tabView.initPromise; + bwin.contentView.addChildView(tabView); + const initOpts = { + tabId: tabView.waveTabId, + clientId: clientData.oid, + windowId: bwin.waveWindowId, + activate: true, + }; + tabView.savedInitOpts = { ...initOpts }; + tabView.savedInitOpts.activate = false; + let startTime = Date.now(); + tabView.webContents.send("wave-init", initOpts); + console.log("before wave ready"); + await tabView.waveReadyPromise; + // positionTabOnScreen(tabView, bwin.getContentBounds()); + console.log("wave-ready init time", Date.now() - startTime + "ms"); + // positionTabOffScreen(oldActiveView, bwin.getContentBounds()); + repositionTabsSlowly(tabView, oldActiveView, 100, bwin.getContentBounds()); + } else { + console.log("reusing an existing tab"); + repositionTabsSlowly(tabView, oldActiveView, 35, bwin.getContentBounds()); + tabView.webContents.send("wave-init", tabView.savedInitOpts); // reinit + } + + // something is causing the new tab to lose focus so it requires manual refocusing + tabView.webContents.focus(); + setTimeout(() => { + if (bwin.activeTabView == tabView && !tabView.webContents.isFocused()) { + tabView.webContents.focus(); + } + }, 10); + setTimeout(() => { + if (bwin.activeTabView == tabView && !tabView.webContents.isFocused()) { + tabView.webContents.focus(); + } + }, 30); +} diff --git a/emain/emain-wavesrv.ts b/emain/emain-wavesrv.ts new file mode 100644 index 000000000..597e2f032 --- /dev/null +++ b/emain/emain-wavesrv.ts @@ -0,0 +1,118 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { WebServerEndpointVarName, WSServerEndpointVarName } from "@/util/endpoints"; +import * as electron from "electron"; +import { AuthKey, AuthKeyEnv } from "emain/authkey"; +import { setForceQuit } from "emain/emain-activity"; +import { WaveAppPathVarName } from "emain/emain-util"; +import { getElectronAppUnpackedBasePath, getWaveSrvCwd, getWaveSrvPath } from "emain/platform"; +import { updater } from "emain/updater"; +import * as child_process from "node:child_process"; +import * as readline from "readline"; + +export const WaveSrvReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID"; + +let isWaveSrvDead = false; +let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null; +let WaveVersion = "unknown"; // set by WAVESRV-ESTART +let WaveBuildTime = 0; // set by WAVESRV-ESTART + +export function getWaveVersion(): { version: string; buildTime: number } { + return { version: WaveVersion, buildTime: WaveBuildTime }; +} + +let waveSrvReadyResolve = (value: boolean) => {}; +const waveSrvReady: Promise = new Promise((resolve, _) => { + waveSrvReadyResolve = resolve; +}); + +export function getWaveSrvReady(): Promise { + return waveSrvReady; +} + +export function getWaveSrvProc(): child_process.ChildProcessWithoutNullStreams | null { + return waveSrvProc; +} + +export function getIsWaveSrvDead(): boolean { + return isWaveSrvDead; +} + +export function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promise { + let pResolve: (value: boolean) => void; + let pReject: (reason?: any) => void; + const rtnPromise = new Promise((argResolve, argReject) => { + pResolve = argResolve; + pReject = argReject; + }); + const envCopy = { ...process.env }; + envCopy[WaveAppPathVarName] = getElectronAppUnpackedBasePath(); + envCopy[WaveSrvReadySignalPidVarName] = process.pid.toString(); + envCopy[AuthKeyEnv] = AuthKey; + const waveSrvCmd = getWaveSrvPath(); + console.log("trying to run local server", waveSrvCmd); + const proc = child_process.spawn(getWaveSrvPath(), { + cwd: getWaveSrvCwd(), + env: envCopy, + }); + proc.on("exit", (e) => { + if (updater?.status == "installing") { + return; + } + console.log("wavesrv exited, shutting down"); + setForceQuit(true); + isWaveSrvDead = true; + electron.app.quit(); + }); + proc.on("spawn", (e) => { + console.log("spawned wavesrv"); + waveSrvProc = proc; + pResolve(true); + }); + proc.on("error", (e) => { + console.log("error running wavesrv", e); + pReject(e); + }); + const rlStdout = readline.createInterface({ + input: proc.stdout, + terminal: false, + }); + rlStdout.on("line", (line) => { + console.log(line); + }); + const rlStderr = readline.createInterface({ + input: proc.stderr, + terminal: false, + }); + rlStderr.on("line", (line) => { + if (line.includes("WAVESRV-ESTART")) { + const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.\-]+) buildtime:(\d+)/gm.exec( + line + ); + if (startParams == null) { + console.log("error parsing WAVESRV-ESTART line", line); + electron.app.quit(); + return; + } + process.env[WSServerEndpointVarName] = startParams[1]; + process.env[WebServerEndpointVarName] = startParams[2]; + WaveVersion = startParams[3]; + WaveBuildTime = parseInt(startParams[4]); + waveSrvReadyResolve(true); + return; + } + if (line.startsWith("WAVESRV-EVENT:")) { + const evtJson = line.slice("WAVESRV-EVENT:".length); + try { + const evtMsg: WSEventType = JSON.parse(evtJson); + handleWSEvent(evtMsg); + } catch (e) { + console.log("error handling WAVESRV-EVENT", e); + } + return; + } + console.log(line); + }); + return rtnPromise; +} diff --git a/emain/emain-web.ts b/emain/emain-web.ts index 842e266bf..f1fbd9aeb 100644 --- a/emain/emain-web.ts +++ b/emain/emain-web.ts @@ -1,13 +1,13 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BrowserWindow, ipcMain, webContents, WebContents } from "electron"; +import { ipcMain, webContents, WebContents } from "electron"; -export function getWebContentsByBlockId(win: BrowserWindow, tabId: string, blockId: string): Promise { +export function getWebContentsByBlockId(ww: WaveBrowserWindow, tabId: string, blockId: string): Promise { const prtn = new Promise((resolve, reject) => { const randId = Math.floor(Math.random() * 1000000000).toString(); const respCh = `getWebContentsByBlockId-${randId}`; - win.webContents.send("webcontentsid-from-blockid", blockId, respCh); + ww?.activeTabView?.webContents.send("webcontentsid-from-blockid", blockId, respCh); ipcMain.once(respCh, (event, webContentsId) => { if (webContentsId == null) { resolve(null); diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index 60828fe01..fcdeb253a 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -1,12 +1,11 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import electron from "electron"; +import { Notification } from "electron"; +import { getWaveWindowById } from "emain/emain-viewmgr"; import { RpcResponseHelper, WshClient } from "../frontend/app/store/wshclient"; import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; -type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise }; - export class ElectronWshClientType extends WshClient { constructor() { super("electron"); @@ -16,12 +15,11 @@ export class ElectronWshClientType extends WshClient { if (!data.tabid || !data.blockid || !data.windowid) { throw new Error("tabid and blockid are required"); } - const windows = electron.BrowserWindow.getAllWindows(); - const win = windows.find((w) => (w as WaveBrowserWindow).waveWindowId === data.windowid); - if (win == null) { + const ww = getWaveWindowById(data.windowid); + if (ww == null) { throw new Error(`no window found with id ${data.windowid}`); } - const wc = await getWebContentsByBlockId(win, data.tabid, data.blockid); + const wc = await getWebContentsByBlockId(ww, data.tabid, data.blockid); if (wc == null) { throw new Error(`no webcontents found with blockid ${data.blockid}`); } @@ -30,7 +28,7 @@ export class ElectronWshClientType extends WshClient { } async handle_notify(rh: RpcResponseHelper, notificationOptions: WaveNotificationOptions) { - new electron.Notification({ + new Notification({ title: notificationOptions.title, body: notificationOptions.body, silent: notificationOptions.silent, diff --git a/emain/emain.ts b/emain/emain.ts index de447b415..634139e9b 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -2,24 +2,47 @@ // SPDX-License-Identifier: Apache-2.0 import * as electron from "electron"; +import { + getActivityState, + getForceQuit, + getGlobalIsRelaunching, + setForceQuit, + setGlobalIsQuitting, + setGlobalIsRelaunching, + setGlobalIsStarting, + setWasActive, + setWasInFg, +} from "emain/emain-activity"; +import { handleCtrlShiftState } from "emain/emain-util"; +import { + createBrowserWindow, + ensureHotSpareTab, + getAllWaveWindows, + getFocusedWaveWindow, + getLastFocusedWaveWindow, + getWaveTabViewByWebContentsId, + getWaveWindowById, + getWaveWindowByWebContentsId, + setActiveTab, + setMaxTabCacheSize, +} from "emain/emain-viewmgr"; +import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, getWaveVersion, runWaveSrv } from "emain/emain-wavesrv"; import { FastAverageColor } from "fast-average-color"; import fs from "fs"; import * as child_process from "node:child_process"; import * as path from "path"; import { PNG } from "pngjs"; -import * as readline from "readline"; import { sprintf } from "sprintf-js"; import { Readable } from "stream"; -import { debounce } from "throttle-debounce"; import * as util from "util"; import winston from "winston"; import * as services from "../frontend/app/store/services"; import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil"; -import { WSServerEndpointVarName, WebServerEndpointVarName, getWebServerEndpoint } from "../frontend/util/endpoints"; +import { getWebServerEndpoint } from "../frontend/util/endpoints"; import { fetch } from "../frontend/util/fetchutil"; import * as keyutil from "../frontend/util/keyutil"; import { fireAndForget } from "../frontend/util/util"; -import { AuthKey, AuthKeyEnv, configureAuthKeyRequestInjection } from "./authkey"; +import { AuthKey, configureAuthKeyRequestInjection } from "./authkey"; import { initDocsite } from "./docsite"; import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; import { getLaunchSettings } from "./launchsettings"; @@ -28,46 +51,19 @@ import { getElectronAppBasePath, getElectronAppUnpackedBasePath, getWaveHomeDir, - getWaveSrvCwd, - getWaveSrvPath, isDev, - isDevVite, unameArch, unamePlatform, } from "./platform"; import { configureAutoUpdater, updater } from "./updater"; const electronApp = electron.app; -let WaveVersion = "unknown"; // set by WAVESRV-ESTART -let WaveBuildTime = 0; // set by WAVESRV-ESTART -let forceQuit = false; -let isWaveSrvDead = false; -const WaveAppPathVarName = "WAVETERM_APP_PATH"; -const WaveSrvReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID"; electron.nativeTheme.themeSource = "dark"; -type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise }; - -let waveSrvReadyResolve = (value: boolean) => {}; -const waveSrvReady: Promise = new Promise((resolve, _) => { - waveSrvReadyResolve = resolve; -}); -let globalIsQuitting = false; -let globalIsStarting = true; -let globalIsRelaunching = false; - -// for activity updates -let wasActive = true; -let wasInFg = true; - let webviewFocusId: number = null; // set to the getWebContentsId of the webview that has focus (null if not focused) let webviewKeys: string[] = []; // the keys to trap when webview has focus - -let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null; - const waveHome = getWaveHomeDir(); - const oldConsoleLog = console.log; const loggerTransports: winston.transport[] = [ @@ -79,7 +75,7 @@ if (isDev) { const loggerConfig = { level: "info", format: winston.format.combine( - winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), + winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }), winston.format.printf((info) => `${info.timestamp} ${info.message}`) ), transports: loggerTransports, @@ -107,125 +103,6 @@ if (isDev) { console.log("waveterm-app WAVETERM_DEV set"); } -function getWindowForEvent(event: Electron.IpcMainEvent): Electron.BrowserWindow { - const windowId = event.sender.id; - return electron.BrowserWindow.fromId(windowId); -} - -function setCtrlShift(wc: Electron.WebContents, state: boolean) { - wc.send("control-shift-state-update", state); -} - -function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: WaveKeyboardEvent) { - if (waveEvent.type == "keyup") { - if (waveEvent.key === "Control" || waveEvent.key === "Shift") { - setCtrlShift(sender, false); - } - if (waveEvent.key == "Meta") { - if (waveEvent.control && waveEvent.shift) { - setCtrlShift(sender, true); - } - } - return; - } - if (waveEvent.type == "keydown") { - if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") { - if (waveEvent.control && waveEvent.shift && !waveEvent.meta) { - // Set the control and shift without the Meta key - setCtrlShift(sender, true); - } else { - // Unset if Meta is pressed - setCtrlShift(sender, false); - } - } - return; - } -} - -function handleCtrlShiftFocus(sender: Electron.WebContents, focused: boolean) { - if (!focused) { - setCtrlShift(sender, false); - } -} - -function runWaveSrv(): Promise { - let pResolve: (value: boolean) => void; - let pReject: (reason?: any) => void; - const rtnPromise = new Promise((argResolve, argReject) => { - pResolve = argResolve; - pReject = argReject; - }); - const envCopy = { ...process.env }; - envCopy[WaveAppPathVarName] = getElectronAppUnpackedBasePath(); - envCopy[WaveSrvReadySignalPidVarName] = process.pid.toString(); - envCopy[AuthKeyEnv] = AuthKey; - const waveSrvCmd = getWaveSrvPath(); - console.log("trying to run local server", waveSrvCmd); - const proc = child_process.spawn(getWaveSrvPath(), { - cwd: getWaveSrvCwd(), - env: envCopy, - }); - proc.on("exit", (e) => { - if (updater?.status == "installing") { - return; - } - console.log("wavesrv exited, shutting down"); - forceQuit = true; - isWaveSrvDead = true; - electronApp.quit(); - }); - proc.on("spawn", (e) => { - console.log("spawned wavesrv"); - waveSrvProc = proc; - pResolve(true); - }); - proc.on("error", (e) => { - console.log("error running wavesrv", e); - pReject(e); - }); - const rlStdout = readline.createInterface({ - input: proc.stdout, - terminal: false, - }); - rlStdout.on("line", (line) => { - console.log(line); - }); - const rlStderr = readline.createInterface({ - input: proc.stderr, - terminal: false, - }); - rlStderr.on("line", (line) => { - if (line.includes("WAVESRV-ESTART")) { - const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.\-]+) buildtime:(\d+)/gm.exec( - line - ); - if (startParams == null) { - console.log("error parsing WAVESRV-ESTART line", line); - electronApp.quit(); - return; - } - process.env[WSServerEndpointVarName] = startParams[1]; - process.env[WebServerEndpointVarName] = startParams[2]; - WaveVersion = startParams[3]; - WaveBuildTime = parseInt(startParams[4]); - waveSrvReadyResolve(true); - return; - } - if (line.startsWith("WAVESRV-EVENT:")) { - const evtJson = line.slice("WAVESRV-EVENT:".length); - try { - const evtMsg: WSEventType = JSON.parse(evtJson); - handleWSEvent(evtMsg); - } catch (e) { - console.log("error handling WAVESRV-EVENT", e); - } - return; - } - console.log(line); - }); - return rtnPromise; -} - async function handleWSEvent(evtMsg: WSEventType) { console.log("handleWSEvent", evtMsg?.eventtype); if (evtMsg.eventtype == "electron:newwindow") { @@ -236,391 +113,21 @@ async function handleWSEvent(evtMsg: WSEventType) { } const clientData = await services.ClientService.GetClientData(); const fullConfig = await services.FileService.GetFullConfig(); - const newWin = createBrowserWindow(clientData.oid, windowData, fullConfig); - await newWin.readyPromise; + const newWin = createBrowserWindow(clientData.oid, windowData, fullConfig, { unamePlatform }); + await newWin.waveReadyPromise; newWin.show(); } else if (evtMsg.eventtype == "electron:closewindow") { if (evtMsg.data === undefined) return; - const windows = electron.BrowserWindow.getAllWindows(); - for (const window of windows) { - if ((window as any).waveWindowId === evtMsg.data) { - // Bypass the "Are you sure?" dialog, since this event is called when there's no more tabs for the window. - window.destroy(); - } + const ww = getWaveWindowById(evtMsg.data); + if (ww != null) { + ww.alreadyClosed = true; + ww.destroy(); // bypass the "are you sure?" dialog } } else { console.log("unhandled electron ws eventtype", evtMsg.eventtype); } } -async function persistWindowBounds(windowId: string, bounds: electron.Rectangle) { - try { - await services.WindowService.SetWindowPosAndSize( - windowId, - { x: bounds.x, y: bounds.y }, - { width: bounds.width, height: bounds.height } - ); - } catch (e) { - console.log("error resizing window", e); - } -} - -async function mainResizeHandler(_: any, windowId: string, win: WaveBrowserWindow) { - if (win == null || win.isDestroyed() || win.fullScreen) { - return; - } - const bounds = win.getBounds(); - await persistWindowBounds(windowId, bounds); -} - -function shNavHandler(event: Electron.Event, url: string) { - if (url.startsWith("http://127.0.0.1:5173/index.html") || url.startsWith("http://localhost:5173/index.html")) { - // this is a dev-mode hot-reload, ignore it - console.log("allowing hot-reload of index.html"); - return; - } - event.preventDefault(); - if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) { - console.log("open external, shNav", url); - electron.shell.openExternal(url); - } else { - console.log("navigation canceled", url); - } -} - -function shFrameNavHandler(event: Electron.Event) { - if (!event.frame?.parent) { - // only use this handler to process iframe events (non-iframe events go to shNavHandler) - return; - } - const url = event.url; - console.log(`frame-navigation url=${url} frame=${event.frame.name}`); - if (event.frame.name == "webview") { - // "webview" links always open in new window - // this will *not* effect the initial load because srcdoc does not count as an electron navigation - console.log("open external, frameNav", url); - event.preventDefault(); - electron.shell.openExternal(url); - return; - } - if ( - event.frame.name == "pdfview" && - (url.startsWith("blob:file:///") || url.startsWith(getWebServerEndpoint() + "/wave/stream-file?")) - ) { - // allowed - return; - } - event.preventDefault(); - console.log("frame navigation canceled"); -} - -function computeNewWinBounds(waveWindow: WaveWindow): Electron.Rectangle { - const targetWidth = waveWindow.winsize?.width || 2000; - const targetHeight = waveWindow.winsize?.height || 1080; - const primaryDisplay = electron.screen.getPrimaryDisplay(); - const workArea = primaryDisplay.workArea; - const targetPadding = 100; - const minPadding = 10; - let rtn = { - x: workArea.x + targetPadding, - y: workArea.y + targetPadding, - width: targetWidth, - height: targetHeight, - }; - const spareWidth = workArea.width - targetWidth; - if (spareWidth < 2 * minPadding) { - rtn.x = workArea.x + minPadding; - rtn.width = workArea.width - 2 * minPadding; - } else if (spareWidth > 2 * targetPadding) { - rtn.x = workArea.x + targetPadding; - } else { - rtn.x = workArea.y + Math.floor(spareWidth / 2); - } - const spareHeight = workArea.height - targetHeight; - if (spareHeight < 2 * minPadding) { - rtn.y = workArea.y + minPadding; - rtn.height = workArea.height - 2 * minPadding; - } else if (spareHeight > 2 * targetPadding) { - rtn.y = workArea.y + targetPadding; - } else { - rtn.y = workArea.y + Math.floor(spareHeight / 2); - } - return rtn; -} - -function computeWinBounds(waveWindow: WaveWindow): Electron.Rectangle { - if (waveWindow.isnew) { - return computeNewWinBounds(waveWindow); - } - 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 = electron.screen.getPrimaryDisplay(); - const { width } = primaryDisplay.workAreaSize; - winWidth = width - winPosX - 100; - if (winWidth > 2000) { - winWidth = 2000; - } - } - if (winHeight == null || winHeight == 0) { - const primaryDisplay = electron.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, - }; - return winBounds; -} - -// note, this does not *show* the window. -// to show, await win.readyPromise and then win.show() -function createBrowserWindow(clientId: string, waveWindow: WaveWindow, fullConfig: FullConfigType): WaveBrowserWindow { - let winBounds = computeWinBounds(waveWindow); - winBounds = ensureBoundsAreVisible(winBounds); - persistWindowBounds(waveWindow.oid, winBounds); - const settings = fullConfig?.settings; - const winOpts: Electron.BrowserWindowConstructorOptions = { - titleBarStyle: - unamePlatform === "darwin" ? "hiddenInset" : settings["window:nativetitlebar"] ? "default" : "hidden", - titleBarOverlay: - unamePlatform !== "darwin" - ? { - symbolColor: "white", - color: "#00000000", - } - : false, - x: winBounds.x, - y: winBounds.y, - width: winBounds.width, - height: winBounds.height, - minWidth: 400, - minHeight: 300, - icon: - unamePlatform == "linux" - ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") - : undefined, - webPreferences: { - preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), - webviewTag: true, - }, - 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 (unamePlatform) { - case "win32": { - winOpts.backgroundMaterial = "acrylic"; - break; - } - case "darwin": { - winOpts.vibrancy = "fullscreen-ui"; - break; - } - } - } else { - winOpts.backgroundColor = "#222222"; - } - const bwin = new electron.BrowserWindow(winOpts); - (bwin as any).waveWindowId = waveWindow.oid; - let readyResolve: (value: void) => void; - (bwin as any).readyPromise = new Promise((resolve, _) => { - readyResolve = resolve; - }); - const win: WaveBrowserWindow = bwin as WaveBrowserWindow; - const usp = new URLSearchParams(); - usp.set("clientid", clientId); - usp.set("windowid", waveWindow.oid); - const indexHtml = "index.html"; - if (isDevVite) { - console.log("running as dev server"); - win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html?${usp.toString()}`); - } else { - console.log("running as file"); - win.loadFile(path.join(getElectronAppBasePath(), "frontend", indexHtml), { search: usp.toString() }); - } - win.once("ready-to-show", () => { - readyResolve(); - }); - win.webContents.on("will-navigate", shNavHandler); - win.webContents.on("will-frame-navigate", shFrameNavHandler); - win.webContents.on("did-attach-webview", (event, wc) => { - wc.setWindowOpenHandler((details) => { - win.webContents.send("webview-new-window", wc.id, details); - return { action: "deny" }; - }); - }); - win.webContents.on("before-input-event", (e, input) => { - const waveEvent = keyutil.adaptFromElectronKeyEvent(input); - // console.log("WIN bie", waveEvent.type, waveEvent.code); - handleCtrlShiftState(win.webContents, waveEvent); - if (win.isFocused()) { - wasActive = true; - } - }); - win.on( - // @ts-expect-error - "resize", - debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) - ); - win.on( - // @ts-expect-error - "move", - debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) - ); - win.on("focus", () => { - wasInFg = true; - wasActive = true; - if (globalIsStarting) { - return; - } - console.log("focus", waveWindow.oid); - services.ClientService.FocusWindow(waveWindow.oid); - }); - win.on("blur", () => { - handleCtrlShiftFocus(win.webContents, false); - }); - win.on("enter-full-screen", async () => { - win.webContents.send("fullscreen-change", true); - }); - win.on("leave-full-screen", async () => { - win.webContents.send("fullscreen-change", false); - }); - win.on("close", (e) => { - if (globalIsQuitting || updater?.status == "installing") { - return; - } - const numWindows = electron.BrowserWindow.getAllWindows().length; - if (numWindows == 1) { - return; - } - const choice = electron.dialog.showMessageBoxSync(win, { - 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) { - e.preventDefault(); - } - }); - win.on("closed", () => { - if (globalIsQuitting || updater?.status == "installing") { - return; - } - const numWindows = electron.BrowserWindow.getAllWindows().length; - if (numWindows == 0) { - return; - } - services.WindowService.CloseWindow(waveWindow.oid); - }); - win.webContents.on("zoom-changed", (e) => { - win.webContents.send("zoom-changed"); - }); - win.webContents.setWindowOpenHandler(({ url, frameName }) => { - if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { - console.log("openExternal fallback", url); - electron.shell.openExternal(url); - } - console.log("window-open denied", url); - return { action: "deny" }; - }); - configureAuthKeyRequestInjection(win.webContents.session); - return win; -} - -function isWindowFullyVisible(bounds: electron.Rectangle): boolean { - const displays = electron.screen.getAllDisplays(); - - // Helper function to check if a point is inside any display - function isPointInDisplay(x: number, y: number) { - for (const display of displays) { - const { x: dx, y: dy, width, height } = display.bounds; - if (x >= dx && x < dx + width && y >= dy && y < dy + height) { - return true; - } - } - return false; - } - - // Check all corners of the window - const topLeft = isPointInDisplay(bounds.x, bounds.y); - const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y); - const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height); - const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height); - - return topLeft && topRight && bottomLeft && bottomRight; -} - -function findDisplayWithMostArea(bounds: electron.Rectangle): electron.Display { - const displays = electron.screen.getAllDisplays(); - let maxArea = 0; - let bestDisplay = null; - - for (let display of displays) { - const { x, y, width, height } = display.bounds; - const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x)); - const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y)); - const overlapArea = overlapX * overlapY; - - if (overlapArea > maxArea) { - maxArea = overlapArea; - bestDisplay = display; - } - } - - return bestDisplay; -} - -function adjustBoundsToFitDisplay(bounds: electron.Rectangle, display: electron.Display): electron.Rectangle { - const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea; - let { x, y, width, height } = bounds; - - // Adjust width and height to fit within the display's work area - width = Math.min(width, dWidth); - height = Math.min(height, dHeight); - - // Adjust x to ensure the window fits within the display - if (x < dx) { - x = dx; - } else if (x + width > dx + dWidth) { - x = dx + dWidth - width; - } - - // Adjust y to ensure the window fits within the display - if (y < dy) { - y = dy; - } else if (y + height > dy + dHeight) { - y = dy + dHeight - height; - } - return { x, y, width, height }; -} - -function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle { - if (!isWindowFullyVisible(bounds)) { - let targetDisplay = findDisplayWithMostArea(bounds); - - if (!targetDisplay) { - targetDisplay = electron.screen.getPrimaryDisplay(); - } - - return adjustBoundsToFitDisplay(bounds, targetDisplay); - } - return bounds; -} - // Listen for the open-external event from the renderer process electron.ipcMain.on("open-external", (event, url) => { if (url && typeof url === "string") { @@ -712,7 +219,7 @@ function getUrlInSession(session: Electron.Session, url: string): Promise { const menu = new electron.Menu(); - const win = electron.BrowserWindow.fromWebContents(event.sender.hostWebContents); + const win = getWaveWindowByWebContentsId(event.sender.hostWebContents.id); if (win == null) { return; } @@ -733,19 +240,66 @@ electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, ); const { x, y } = electron.screen.getCursorScreenPoint(); const windowPos = win.getPosition(); - menu.popup({ window: win, x: x - windowPos[0], y: y - windowPos[1] }); + menu.popup(); }); electron.ipcMain.on("download", (event, payload) => { - const window = electron.BrowserWindow.fromWebContents(event.sender); const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?path=" + encodeURIComponent(payload.filePath); - window.webContents.downloadURL(streamingUrl); + event.sender.downloadURL(streamingUrl); +}); + +electron.ipcMain.on("set-active-tab", async (event, tabId) => { + const ww = getWaveWindowByWebContentsId(event.sender.id); + console.log("set-active-tab", tabId, ww?.waveWindowId); + await setActiveTab(ww, tabId); +}); + +electron.ipcMain.on("create-tab", async (event, opts) => { + const senderWc = event.sender; + const tabView = getWaveTabViewByWebContentsId(senderWc.id); + if (tabView == null) { + return; + } + const waveWindowId = tabView.waveWindowId; + const waveWindow = (await services.ObjectService.GetObject("window:" + waveWindowId)) as WaveWindow; + if (waveWindow == null) { + return; + } + const newTabId = await services.ObjectService.AddTabToWorkspace(waveWindowId, null, true); + const ww = getWaveWindowById(waveWindowId); + if (ww == null) { + return; + } + await setActiveTab(ww, newTabId); + event.returnValue = true; + return null; +}); + +electron.ipcMain.on("close-tab", async (event, tabId) => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (tabView == null) { + return; + } + const rtn = await services.WindowService.CloseTab(tabView.waveWindowId, tabId, true); + if (rtn?.closewindow) { + const ww = getWaveWindowById(tabView.waveWindowId); + ww.alreadyClosed = true; + ww?.destroy(); // bypass the "are you sure?" dialog + } else if (rtn?.newactivetabid) { + setActiveTab(getWaveWindowById(tabView.waveWindowId), rtn.newactivetabid); + } + event.returnValue = true; + return null; }); electron.ipcMain.on("get-cursor-point", (event) => { - const window = electron.BrowserWindow.fromWebContents(event.sender); + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (tabView == null) { + event.returnValue = null; + return; + } const screenPoint = electron.screen.getCursorScreenPoint(); - const windowRect = window.getContentBounds(); + const windowRect = tabView.getBounds(); const retVal: Electron.Point = { x: screenPoint.x - windowRect.x, y: screenPoint.y - windowRect.y, @@ -758,7 +312,7 @@ electron.ipcMain.on("get-env", (event, varName) => { }); electron.ipcMain.on("get-about-modal-details", (event) => { - event.returnValue = { version: WaveVersion, buildTime: WaveBuildTime } as AboutModalDetails; + event.returnValue = getWaveVersion() as AboutModalDetails; }); const hasBeforeInputRegisteredMap = new Map(); @@ -825,8 +379,8 @@ if (unamePlatform !== "darwin") { const overlayBuffer = overlay.toPNG(); const png = PNG.sync.read(overlayBuffer); const color = fac.prepareResult(fac.getColorFromArray4(png.data)); - const window = electron.BrowserWindow.fromWebContents(event.sender); - window.setTitleBarOverlay({ + const ww = getWaveWindowByWebContentsId(event.sender.id); + ww.setTitleBarOverlay({ color: unamePlatform === "linux" ? color.rgba : "#00000000", // Windows supports a true transparent overlay, so we don't need to set a background color. symbolColor: color.isDark ? "white" : "black", }); @@ -848,13 +402,14 @@ async function createNewWaveWindow(): Promise { const clientData = await services.ClientService.GetClientData(); const fullConfig = await services.FileService.GetFullConfig(); let recreatedWindow = false; - if (electron.BrowserWindow.getAllWindows().length === 0 && clientData?.windowids?.length >= 1) { + const allWindows = getAllWaveWindows(); + if (allWindows.length === 0 && clientData?.windowids?.length >= 1) { // reopen the first window const existingWindowId = clientData.windowids[0]; const existingWindowData = (await services.ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow; if (existingWindowData != null) { - const win = createBrowserWindow(clientData.oid, existingWindowData, fullConfig); - await win.readyPromise; + const win = createBrowserWindow(clientData.oid, existingWindowData, fullConfig, { unamePlatform }); + await win.waveReadyPromise; win.show(); recreatedWindow = true; } @@ -863,16 +418,37 @@ async function createNewWaveWindow(): Promise { return; } const newWindow = await services.ClientService.MakeWindow(); - const newBrowserWindow = createBrowserWindow(clientData.oid, newWindow, fullConfig); - await newBrowserWindow.readyPromise; + const newBrowserWindow = createBrowserWindow(clientData.oid, newWindow, fullConfig, { unamePlatform }); + await newBrowserWindow.waveReadyPromise; newBrowserWindow.show(); } +electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-ready") => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (tabView == null || tabView.initResolve == null) { + return; + } + if (status === "ready") { + console.log("initResolve"); + tabView.initResolve(); + if (tabView.savedInitOpts) { + tabView.webContents.send("wave-init", tabView.savedInitOpts); + } + } else if (status === "wave-ready") { + console.log("waveReadyResolve"); + tabView.waveReadyResolve(); + } +}); + +electron.ipcMain.on("fe-log", (event, logStr: string) => { + console.log("fe-log", logStr); +}); + function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string, readStream: Readable) { if (defaultFileName == null || defaultFileName == "") { defaultFileName = "image"; } - const window = electron.BrowserWindow.getFocusedWindow(); // Get the current window context + const ww = getFocusedWaveWindow(); const mimeToExtension: { [key: string]: string } = { "image/png": "png", "image/jpeg": "jpg", @@ -891,7 +467,7 @@ function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string } defaultFileName = addExtensionIfNeeded(defaultFileName, mimeType); electron.dialog - .showSaveDialog(window, { + .showSaveDialog(ww, { title: "Save Image", defaultPath: defaultFileName, filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "heic"] }], @@ -922,20 +498,19 @@ function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => { - const window = electron.BrowserWindow.fromWebContents(event.sender); if (menuDefArr?.length === 0) { return; } const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : instantiateAppMenu(); - const { x, y } = electron.screen.getCursorScreenPoint(); - const windowPos = window.getPosition(); - - menu.popup({ window, x: x - windowPos[0], y: y - windowPos[1] }); + // const { x, y } = electron.screen.getCursorScreenPoint(); + // const windowPos = window.getPosition(); + menu.popup(); event.returnValue = true; }); async function logActiveState() { - const activeState = { fg: wasInFg, active: wasActive, open: true }; + const astate = getActivityState(); + const activeState = { fg: astate.wasInFg, active: astate.wasActive, open: true }; const url = new URL(getWebServerEndpoint() + "/wave/log-active-state"); try { const resp = await fetch(url, { method: "post", body: JSON.stringify(activeState) }); @@ -947,8 +522,9 @@ async function logActiveState() { console.log("error logging active state", e); } finally { // for next iteration - wasInFg = electron.BrowserWindow.getFocusedWindow()?.isFocused() ?? false; - wasActive = false; + const ww = getFocusedWaveWindow(); + setWasInFg(ww?.isFocused() ?? false); + setWasActive(false); } } @@ -966,7 +542,9 @@ function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electro label: menuDef.label, type: menuDef.type, click: (_, window) => { - (window as electron.BrowserWindow)?.webContents?.send("contextmenu-click", menuDef.id); + const ww = window as WaveBrowserWindow; + const tabView = ww.activeTabView; + tabView?.webContents?.send("contextmenu-click", menuDef.id); }, checked: menuDef.checked, }; @@ -980,7 +558,11 @@ function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electro } function instantiateAppMenu(): electron.Menu { - return getAppMenu({ createNewWaveWindow, relaunchBrowserWindows }); + return getAppMenu({ + createNewWaveWindow, + relaunchBrowserWindows, + getLastFocusedWaveWindow: getLastFocusedWaveWindow, + }); } function makeAppMenu() { @@ -989,7 +571,7 @@ function makeAppMenu() { } electronApp.on("window-all-closed", () => { - if (globalIsRelaunching) { + if (getGlobalIsRelaunching()) { return; } if (unamePlatform !== "darwin") { @@ -997,32 +579,32 @@ electronApp.on("window-all-closed", () => { } }); electronApp.on("before-quit", (e) => { - globalIsQuitting = true; + setGlobalIsQuitting(true); updater?.stop(); if (unamePlatform == "win32") { // win32 doesn't have a SIGINT, so we just let electron die, which // ends up killing wavesrv via closing it's stdin. return; } - waveSrvProc?.kill("SIGINT"); + getWaveSrvProc()?.kill("SIGINT"); shutdownWshrpc(); - if (forceQuit) { + if (getForceQuit()) { return; } e.preventDefault(); - const allWindows = electron.BrowserWindow.getAllWindows(); + const allWindows = getAllWaveWindows(); for (const window of allWindows) { window.hide(); } - if (isWaveSrvDead) { + if (getIsWaveSrvDead()) { console.log("wavesrv is dead, quitting immediately"); - forceQuit = true; + setForceQuit(true); electronApp.quit(); return; } setTimeout(() => { console.log("waiting for wavesrv to exit..."); - forceQuit = true; + setForceQuit(true); electronApp.quit(); }, 3000); }); @@ -1051,13 +633,13 @@ process.on("uncaughtException", (error) => { }); async function relaunchBrowserWindows(): Promise { - globalIsRelaunching = true; - const windows = electron.BrowserWindow.getAllWindows(); + setGlobalIsRelaunching(true); + const windows = getAllWaveWindows(); for (const window of windows) { window.removeAllListeners(); window.close(); } - globalIsRelaunching = false; + setGlobalIsRelaunching(false); const clientData = await services.ClientService.GetClientData(); const fullConfig = await services.FileService.GetFullConfig(); @@ -1065,16 +647,16 @@ async function relaunchBrowserWindows(): Promise { for (const windowId of clientData.windowids.slice().reverse()) { const windowData: WaveWindow = (await services.ObjectService.GetObject("window:" + windowId)) as WaveWindow; if (windowData == null) { - services.WindowService.CloseWindow(windowId).catch((e) => { + services.WindowService.CloseWindow(windowId, true).catch((e) => { /* ignore */ }); continue; } - const win = createBrowserWindow(clientData.oid, windowData, fullConfig); + const win = createBrowserWindow(clientData.oid, windowData, fullConfig, { unamePlatform }); wins.push(win); } for (const win of wins) { - await win.readyPromise; + await win.waveReadyPromise; console.log("show", win.waveWindowId); win.show(); } @@ -1087,7 +669,6 @@ async function appMain() { console.log("disabling hardware acceleration, per launch settings"); electronApp.disableHardwareAcceleration(); } - const startTs = Date.now(); const instanceLock = electronApp.requestSingleInstanceLock(); if (!instanceLock) { @@ -1101,14 +682,16 @@ async function appMain() { } makeAppMenu(); try { - await runWaveSrv(); + await runWaveSrv(handleWSEvent); } catch (e) { console.log(e.toString()); } - const ready = await waveSrvReady; + const ready = await getWaveSrvReady(); console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms"); await electronApp.whenReady(); configureAuthKeyRequestInjection(electron.session.defaultSession); + const fullConfig = await services.FileService.GetFullConfig(); + ensureHotSpareTab(fullConfig); await relaunchBrowserWindows(); await initDocsite(); setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe @@ -1120,10 +703,14 @@ async function appMain() { } await configureAutoUpdater(); - globalIsStarting = false; + setGlobalIsStarting(false); + if (fullConfig?.settings?.["window:maxtabcachesize"] != null) { + setMaxTabCacheSize(fullConfig.settings["window:maxtabcachesize"]); + } electronApp.on("activate", async () => { - if (electron.BrowserWindow.getAllWindows().length === 0) { + const allWindows = getAllWaveWindows(); + if (allWindows.length === 0) { await createNewWaveWindow(); } }); diff --git a/emain/menu.ts b/emain/menu.ts index e5406a8de..b23094287 100644 --- a/emain/menu.ts +++ b/emain/menu.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import * as electron from "electron"; +import { clearTabCache, getFocusedWaveWindow } from "emain/emain-viewmgr"; import { fireAndForget } from "../frontend/util/util"; import { unamePlatform } from "./platform"; import { updater } from "./updater"; @@ -9,14 +10,19 @@ import { updater } from "./updater"; type AppMenuCallbacks = { createNewWaveWindow: () => Promise; relaunchBrowserWindows: () => Promise; + getLastFocusedWaveWindow: () => WaveBrowserWindow; }; function getWindowWebContents(window: electron.BaseWindow): electron.WebContents { if (window == null) { return null; } - if (window instanceof electron.BrowserWindow) { - return window.webContents; + if (window instanceof electron.BaseWindow) { + const waveWin = window as WaveBrowserWindow; + if (waveWin.activeTabView) { + return waveWin.activeTabView.webContents; + } + return null; } return null; } @@ -32,7 +38,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { role: "close", accelerator: "", // clear the accelerator click: () => { - electron.BrowserWindow.getFocusedWindow()?.close(); + getFocusedWaveWindow()?.close(); }, }, ]; @@ -112,9 +118,14 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { }, ]; + const devToolsAccel = unamePlatform === "darwin" ? "Option+Command+I" : "Alt+Meta+I"; const viewMenu: Electron.MenuItemConstructorOptions[] = [ { - role: "forceReload", + label: "Reload Tab", + accelerator: "Shift+CommandOrControl+R", + click: (_, window) => { + getWindowWebContents(window)?.reloadIgnoringCache(); + }, }, { label: "Relaunch All Windows", @@ -123,7 +134,18 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { }, }, { - role: "toggleDevTools", + label: "Clear Tab Cache", + click: () => { + clearTabCache(); + }, + }, + { + label: "Toggle DevTools", + accelerator: devToolsAccel, + click: (_, window) => { + let wc = getWindowWebContents(window); + wc?.toggleDevTools(); + }, }, { type: "separator", @@ -143,6 +165,9 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { if (wc == null) { return; } + if (wc.getZoomFactor() >= 5) { + return; + } wc.setZoomFactor(wc.getZoomFactor() + 0.2); }, }, @@ -154,6 +179,9 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { if (wc == null) { return; } + if (wc.getZoomFactor() >= 5) { + return; + } wc.setZoomFactor(wc.getZoomFactor() + 0.2); }, visible: false, @@ -167,9 +195,28 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { if (wc == null) { return; } + if (wc.getZoomFactor() <= 0.2) { + return; + } wc.setZoomFactor(wc.getZoomFactor() - 0.2); }, }, + { + label: "Zoom Out (hidden)", + accelerator: "CommandOrControl+Shift+-", + click: (_, window) => { + const wc = getWindowWebContents(window); + if (wc == null) { + return; + } + if (wc.getZoomFactor() <= 0.2) { + return; + } + wc.setZoomFactor(wc.getZoomFactor() - 0.2); + }, + visible: false, + acceleratorWorksWhenHidden: true, + }, { type: "separator", }, diff --git a/emain/preload.ts b/emain/preload.ts index 6fbec0d59..6b3e2317d 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -38,6 +38,12 @@ contextBridge.exposeInMainWorld("api", { registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys), onControlShiftStateUpdate: (callback) => ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)), + setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId), + createTab: () => ipcRenderer.send("create-tab"), + closeTab: (tabId) => ipcRenderer.send("close-tab", tabId), + setWindowInitStatus: (status) => ipcRenderer.send("set-window-init-status", status), + onWaveInit: (callback) => ipcRenderer.on("wave-init", (_event, initOpts) => callback(initOpts)), + sendLog: (log) => ipcRenderer.send("fe-log", log), onQuicklook: (filePath: string) => ipcRenderer.send("quicklook", filePath), }); diff --git a/emain/updater.ts b/emain/updater.ts index 22d737c29..4c8e440d0 100644 --- a/emain/updater.ts +++ b/emain/updater.ts @@ -2,8 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { RpcApi } from "@/app/store/wshclientapi"; -import { BrowserWindow, dialog, ipcMain, Notification } from "electron"; +import { dialog, ipcMain, Notification } from "electron"; import { autoUpdater } from "electron-updater"; +import { getAllWaveWindows, getFocusedWaveWindow } from "emain/emain-viewmgr"; import { readFileSync } from "fs"; import path from "path"; import YAML from "yaml"; @@ -109,8 +110,11 @@ export class Updater { private set status(value: UpdaterStatus) { this._status = value; - BrowserWindow.getAllWindows().forEach((window) => { - window.webContents.send("app-update-status", value); + getAllWaveWindows().forEach((window) => { + const allTabs = Array.from(window.allTabViews.values()); + allTabs.forEach((tab) => { + tab.webContents.send("app-update-status", value); + }); }); } @@ -159,7 +163,7 @@ export class Updater { type: "info", message: "There are currently no updates available.", }; - dialog.showMessageBox(BrowserWindow.getFocusedWindow(), dialogOpts); + dialog.showMessageBox(getFocusedWaveWindow(), dialogOpts); } // Only update the last check time if this is an automatic check. This ensures the interval remains consistent. @@ -179,15 +183,14 @@ export class Updater { detail: "A new version has been downloaded. Restart the application to apply the updates.", }; - const allWindows = BrowserWindow.getAllWindows(); + const allWindows = getAllWaveWindows(); if (allWindows.length > 0) { - await dialog - .showMessageBox(BrowserWindow.getFocusedWindow() ?? allWindows[0], dialogOpts) - .then(({ response }) => { - if (response === 0) { - this.installUpdate(); - } - }); + const focusedWindow = getFocusedWaveWindow(); + await dialog.showMessageBox(focusedWindow ?? allWindows[0], dialogOpts).then(({ response }) => { + if (response === 0) { + this.installUpdate(); + } + }); } } diff --git a/frontend/app/app-bg.tsx b/frontend/app/app-bg.tsx index 5cc4a929f..f4939e61d 100644 --- a/frontend/app/app-bg.tsx +++ b/frontend/app/app-bg.tsx @@ -1,3 +1,6 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + import { getWebServerEndpoint } from "@/util/endpoints"; import * as util from "@/util/util"; import useResizeObserver from "@react-hook/resize-observer"; @@ -72,7 +75,7 @@ function processBackgroundUrls(cssText: string): string { export function AppBackground() { const bgRef = useRef(null); - const tabId = useAtomValue(atoms.activeTabId); + const tabId = useAtomValue(atoms.staticTabId); const [tabData] = useWaveObjectValue(WOS.makeORef("tab", tabId)); const bgAttr = tabData?.meta?.bg; const style: CSSProperties = {}; diff --git a/frontend/app/app.less b/frontend/app/app.less index c726dbc35..f208d85d9 100644 --- a/frontend/app/app.less +++ b/frontend/app/app.less @@ -18,6 +18,10 @@ body { transform: translateZ(0); } +.is-transparent { + background-color: transparent; +} + a.plain-link { color: var(--secondary-text-color); } diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 02ce0da0d..45a9081cf 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -23,7 +23,10 @@ import { CenteredDiv } from "./element/quickelems"; const dlog = debug("wave:app"); const focusLog = debug("wave:focus"); -const App = () => { +const App = ({ onFirstRender }: { onFirstRender: () => void }) => { + useEffect(() => { + onFirstRender(); + }, []); return ( @@ -115,18 +118,20 @@ function AppSettingsUpdater() { (windowSettings?.["window:transparent"] || windowSettings?.["window:blur"]) ?? false; const opacity = util.boundNumber(windowSettings?.["window:opacity"] ?? 0.8, 0, 1); let baseBgColor = windowSettings?.["window:bgcolor"]; + let mainDiv = document.getElementById("main"); + // console.log("window settings", windowSettings, isTransparentOrBlur, opacity, baseBgColor, mainDiv); if (isTransparentOrBlur) { - document.body.classList.add("is-transparent"); + mainDiv.classList.add("is-transparent"); const rootStyles = getComputedStyle(document.documentElement); if (baseBgColor == null) { baseBgColor = rootStyles.getPropertyValue("--main-bg-color").trim(); } const color = new Color(baseBgColor); const rgbaColor = color.alpha(opacity).string(); - document.body.style.backgroundColor = rgbaColor; + mainDiv.style.backgroundColor = rgbaColor; } else { - document.body.classList.remove("is-transparent"); - document.body.style.opacity = null; + mainDiv.classList.remove("is-transparent"); + mainDiv.style.opacity = null; } }, [windowSettings]); return null; diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 6c2f5fd52..0eb791a7c 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -28,7 +28,7 @@ import { } from "@/app/store/global"; import * as services from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { ErrorBoundary } from "@/element/errorboundary"; import { IconButton } from "@/element/iconbutton"; import { MagnifyIcon } from "@/element/magnify"; @@ -63,7 +63,7 @@ function handleHeaderContextMenu( { label: "Move to New Window", click: () => { - const currentTabId = globalStore.get(atoms.activeTabId); + const currentTabId = globalStore.get(atoms.staticTabId); try { services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid); } catch (e) { @@ -321,7 +321,7 @@ const ConnStatusOverlay = React.memo( }, [width, connStatus, setShowError]); const handleTryReconnect = React.useCallback(() => { - const prtn = RpcApi.ConnConnectCommand(WindowRpcClient, connName, { timeout: 60000 }); + const prtn = RpcApi.ConnConnectCommand(TabRpcClient, connName, { timeout: 60000 }); prtn.catch((e) => console.log("error reconnecting", connName, e)); }, [connName]); @@ -437,7 +437,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { const connName = blockData?.meta?.connection; if (!util.isBlank(connName)) { console.log("ensure conn", nodeModel.blockId, connName); - RpcApi.ConnEnsureCommand(WindowRpcClient, connName, { timeout: 60000 }).catch((e) => { + RpcApi.ConnEnsureCommand(TabRpcClient, connName, { timeout: 60000 }).catch((e) => { console.log("error ensuring connection", nodeModel.blockId, connName, e); }); } @@ -536,7 +536,7 @@ const ChangeConnectionBlockModal = React.memo( setConnList([]); return; } - const prtn = RpcApi.ConnListCommand(WindowRpcClient, { timeout: 2000 }); + const prtn = RpcApi.ConnListCommand(TabRpcClient, { timeout: 2000 }); prtn.then((newConnList) => { setConnList(newConnList ?? []); }).catch((e) => console.log("unable to load conn list from backend. using blank list: ", e)); @@ -557,12 +557,12 @@ const ChangeConnectionBlockModal = React.memo( } else { newCwd = "~"; } - await RpcApi.SetMetaCommand(WindowRpcClient, { + await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { connection: connName, file: newCwd }, }); try { - await RpcApi.ConnEnsureCommand(WindowRpcClient, connName, { timeout: 60000 }); + await RpcApi.ConnEnsureCommand(TabRpcClient, connName, { timeout: 60000 }); } catch (e) { console.log("error connecting", blockId, connName, e); } @@ -608,7 +608,7 @@ const ChangeConnectionBlockModal = React.memo( label: `Reconnect to ${connStatus.connection}`, value: "", onSelect: async (_: string) => { - const prtn = RpcApi.ConnConnectCommand(WindowRpcClient, connStatus.connection, { timeout: 60000 }); + const prtn = RpcApi.ConnConnectCommand(TabRpcClient, connStatus.connection, { timeout: 60000 }); prtn.catch((e) => console.log("error reconnecting", connStatus.connection, e)); }, }; diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index 07f745546..ccbdf1004 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -3,7 +3,7 @@ import { CopyButton } from "@/app/element/copybutton"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { getWebServerEndpoint } from "@/util/endpoints"; import { isBlank, makeConnRoute, useAtomValueSafe } from "@/util/util"; import { clsx } from "clsx"; @@ -143,7 +143,7 @@ const MarkdownImg = ({ } const resolveFn = async () => { const route = makeConnRoute(resolveOpts.connName); - const fileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [resolveOpts.baseDir, props.src], { + const fileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [resolveOpts.baseDir, props.src], { route: route, }); const usp = new URLSearchParams(); diff --git a/frontend/app/hook/useDimensions.tsx b/frontend/app/hook/useDimensions.tsx index 2824661c8..6c172905f 100644 --- a/frontend/app/hook/useDimensions.tsx +++ b/frontend/app/hook/useDimensions.tsx @@ -1,3 +1,6 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + import * as React from "react"; import { useCallback, useState } from "react"; import { debounce } from "throttle-debounce"; @@ -56,6 +59,49 @@ export function useDimensionsWithCallbackRef( return [refCallback, ref, domRect]; } +export function useOnResize( + ref: React.RefObject, + callback: (domRect: DOMRectReadOnly) => void, + debounceMs: number = null +) { + const isFirst = React.useRef(true); + const rszObjRef = React.useRef(null); + const oldHtmlElem = React.useRef(null); + const setDomRectDebounced = React.useCallback(debounceMs == null ? callback : debounce(debounceMs, callback), [ + debounceMs, + callback, + ]); + React.useEffect(() => { + if (!rszObjRef.current) { + rszObjRef.current = new ResizeObserver((entries) => { + for (const entry of entries) { + if (isFirst.current) { + isFirst.current = false; + callback(entry.contentRect); + } else { + setDomRectDebounced(entry.contentRect); + } + } + }); + } + if (ref.current) { + rszObjRef.current.observe(ref.current); + oldHtmlElem.current = ref.current; + } + return () => { + if (oldHtmlElem.current) { + rszObjRef.current?.unobserve(oldHtmlElem.current); + oldHtmlElem.current = null; + } + }; + }, [ref.current, callback]); + React.useEffect(() => { + return () => { + rszObjRef.current?.disconnect(); + }; + }, []); +} + // will not react to ref changes // pass debounceMs of null to not debounce export function useDimensionsWithExistingRef( diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 7309e77b3..9be2262a4 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -2,12 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { - getLayoutModelForActiveTab, getLayoutModelForTabById, LayoutTreeActionType, LayoutTreeInsertNodeAction, newLayoutNode, } from "@/layout/index"; +import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks"; import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { getPrefixedSettings, isBlank } from "@/util/util"; @@ -26,6 +26,7 @@ const Counters = new Map(); const ConnStatusMap = new Map>(); type GlobalInitOptions = { + tabId: string; platform: NodeJS.Platform; windowId: string; clientId: string; @@ -46,10 +47,9 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom; const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom; const uiContextAtom = atom((get) => { - const windowData = get(windowDataAtom); const uiContext: UIContext = { - windowid: get(atoms.windowId), - activetabid: windowData?.activetabid, + windowid: initOpts.windowId, + activetabid: initOpts.tabId, }; return uiContext; }) as Atom; @@ -99,18 +99,10 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { return get(fullConfigAtom)?.settings ?? {}; }) as Atom; const tabAtom: Atom = atom((get) => { - const windowData = get(windowDataAtom); - if (windowData == null) { - return null; - } - return WOS.getObjectValue(WOS.makeORef("tab", windowData.activetabid), get); + return WOS.getObjectValue(WOS.makeORef("tab", initOpts.tabId), get); }); - const activeTabIdAtom: Atom = atom((get) => { - const windowData = get(windowDataAtom); - if (windowData == null) { - return null; - } - return windowData.activetabid; + const staticTabIdAtom: Atom = atom((get) => { + return initOpts.tabId; }); const controlShiftDelayAtom = atom(false); const updaterStatusAtom = atom("up-to-date") as PrimitiveAtom; @@ -151,7 +143,6 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { const flashErrorsAtom = atom([]); atoms = { // initialized in wave.ts (will not be null inside of application) - windowId: windowIdAtom, clientId: clientIdAtom, uiContext: uiContextAtom, client: clientAtom, @@ -160,7 +151,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { fullConfigAtom, settingsAtom, tabAtom, - activeTabId: activeTabIdAtom, + staticTabId: staticTabIdAtom, isFullScreen: isFullScreenAtom, controlShiftDelayAtom, updaterStatusAtom, @@ -301,8 +292,8 @@ async function createBlock(blockDef: BlockDef, magnified = false): Promise { + await getApi().createTab(); +} + export { atoms, counterInc, countersClear, countersPrint, createBlock, + createTab, fetchWaveFile, getApi, getBlockComponentModel, diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 048786f2e..be54f9ed7 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -1,24 +1,32 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { atoms, createBlock, getApi, getBlockComponentModel, globalStore, refocusNode, WOS } from "@/app/store/global"; -import * as services from "@/app/store/services"; +import { + atoms, + createBlock, + createTab, + getApi, + getBlockComponentModel, + globalStore, + refocusNode, + WOS, +} from "@/app/store/global"; import { deleteLayoutModelForTab, - getLayoutModelForActiveTab, getLayoutModelForTab, getLayoutModelForTabById, NavigateDirection, } from "@/layout/index"; +import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks"; import * as keyutil from "@/util/keyutil"; import * as jotai from "jotai"; const simpleControlShiftAtom = jotai.atom(false); const globalKeyMap = new Map boolean>(); -function getFocusedBlockInActiveTab() { - const activeTabId = globalStore.get(atoms.activeTabId); - const layoutModel = getLayoutModelForTabById(activeTabId); +function getFocusedBlockInStaticTab() { + const tabId = globalStore.get(atoms.staticTabId); + const layoutModel = getLayoutModelForTabById(tabId); const focusedNode = globalStore.get(layoutModel.focusedNode); return focusedNode.data?.blockId; } @@ -70,7 +78,7 @@ function genericClose(tabId: string) { } if (tabData.blockids == null || tabData.blockids.length == 0) { // close tab - services.WindowService.CloseTab(tabId); + getApi().closeTab(tabId); deleteLayoutModelForTab(tabId); return; } @@ -79,7 +87,7 @@ function genericClose(tabId: string) { } function switchBlockByBlockNum(index: number) { - const layoutModel = getLayoutModelForActiveTab(); + const layoutModel = getLayoutModelForStaticTab(); if (!layoutModel) { return; } @@ -92,21 +100,24 @@ function switchBlockInDirection(tabId: string, direction: NavigateDirection) { } function switchTabAbs(index: number) { + console.log("switchTabAbs", index); const ws = globalStore.get(atoms.workspace); + const waveWindow = globalStore.get(atoms.waveWindow); const newTabIdx = index - 1; if (newTabIdx < 0 || newTabIdx >= ws.tabids.length) { return; } const newActiveTabId = ws.tabids[newTabIdx]; - services.ObjectService.SetActiveTab(newActiveTabId); + getApi().setActiveTab(newActiveTabId); } function switchTab(offset: number) { + console.log("switchTab", offset); const ws = globalStore.get(atoms.workspace); - const activeTabId = globalStore.get(atoms.tabAtom).oid; + const curTabId = globalStore.get(atoms.staticTabId); let tabIdx = -1; for (let i = 0; i < ws.tabids.length; i++) { - if (ws.tabids[i] == activeTabId) { + if (ws.tabids[i] == curTabId) { tabIdx = i; break; } @@ -116,11 +127,11 @@ function switchTab(offset: number) { } const newTabIdx = (tabIdx + offset + ws.tabids.length) % ws.tabids.length; const newActiveTabId = ws.tabids[newTabIdx]; - services.ObjectService.SetActiveTab(newActiveTabId); + getApi().setActiveTab(newActiveTabId); } function handleCmdI() { - const layoutModel = getLayoutModelForActiveTab(); + const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { // focus a node @@ -141,7 +152,7 @@ async function handleCmdN() { controller: "shell", }, }; - const layoutModel = getLayoutModelForActiveTab(); + const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode != null) { const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", focusedNode.data?.blockId)); @@ -163,7 +174,7 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean { if (handled) { return true; } - const layoutModel = getLayoutModelForActiveTab(); + const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); const blockId = focusedNode?.data?.blockId; if (blockId != null && shouldDispatchToBlock(waveEvent)) { @@ -225,18 +236,16 @@ function registerGlobalKeys() { return true; }); globalKeyMap.set("Cmd:t", () => { - const workspace = globalStore.get(atoms.workspace); - const newTabName = `T${workspace.tabids.length + 1}`; - services.ObjectService.AddTabToWorkspace(newTabName, true); + createTab(); return true; }); globalKeyMap.set("Cmd:w", () => { - const tabId = globalStore.get(atoms.activeTabId); + const tabId = globalStore.get(atoms.staticTabId); genericClose(tabId); return true; }); globalKeyMap.set("Cmd:m", () => { - const layoutModel = getLayoutModelForActiveTab(); + const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode != null) { layoutModel.magnifyNodeToggle(focusedNode.id); @@ -244,27 +253,27 @@ function registerGlobalKeys() { return true; }); globalKeyMap.set("Ctrl:Shift:ArrowUp", () => { - const tabId = globalStore.get(atoms.activeTabId); + const tabId = globalStore.get(atoms.staticTabId); switchBlockInDirection(tabId, NavigateDirection.Up); return true; }); globalKeyMap.set("Ctrl:Shift:ArrowDown", () => { - const tabId = globalStore.get(atoms.activeTabId); + const tabId = globalStore.get(atoms.staticTabId); switchBlockInDirection(tabId, NavigateDirection.Down); return true; }); globalKeyMap.set("Ctrl:Shift:ArrowLeft", () => { - const tabId = globalStore.get(atoms.activeTabId); + const tabId = globalStore.get(atoms.staticTabId); switchBlockInDirection(tabId, NavigateDirection.Left); return true; }); globalKeyMap.set("Ctrl:Shift:ArrowRight", () => { - const tabId = globalStore.get(atoms.activeTabId); + const tabId = globalStore.get(atoms.staticTabId); switchBlockInDirection(tabId, NavigateDirection.Right); return true; }); globalKeyMap.set("Cmd:g", () => { - const bcm = getBlockComponentModel(getFocusedBlockInActiveTab()); + const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); if (bcm.openSwitchConnection != null) { bcm.openSwitchConnection(); return true; diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index b158c858b..738629f78 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -88,7 +88,7 @@ export const FileService = new FileServiceType(); // objectservice.ObjectService (object) class ObjectServiceType { // @returns tabId (and object updates) - AddTabToWorkspace(tabName: string, activateTab: boolean): Promise { + AddTabToWorkspace(windowId: string, tabName: string, activateTab: boolean): Promise { return WOS.callBackendService("object", "AddTabToWorkspace", Array.from(arguments)) } @@ -113,7 +113,7 @@ class ObjectServiceType { } // @returns object updates - SetActiveTab(tabId: string): Promise { + SetActiveTab(uiContext: string, tabId: string): Promise { return WOS.callBackendService("object", "SetActiveTab", Array.from(arguments)) } @@ -152,10 +152,10 @@ export const UserInputService = new UserInputServiceType(); // windowservice.WindowService (window) class WindowServiceType { // @returns object updates - CloseTab(arg3: string): Promise { + CloseTab(arg2: string, arg3: string, arg4: boolean): Promise { return WOS.callBackendService("window", "CloseTab", Array.from(arguments)) } - CloseWindow(arg2: string): Promise { + CloseWindow(arg2: string, arg3: boolean): Promise { return WOS.callBackendService("window", "CloseWindow", Array.from(arguments)) } diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts index 6b2c01c12..97fe3259f 100644 --- a/frontend/app/store/wos.ts +++ b/frontend/app/store/wos.ts @@ -3,6 +3,7 @@ // WaveObjectStore +import { waveEventSubscribe } from "@/app/store/wps"; import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from "jotai"; @@ -76,6 +77,16 @@ function debugLogBackendCall(methodName: string, durationStr: string, args: any[ console.log("[service]", methodName, durationStr); } +function wpsSubscribeToObject(oref: string): () => void { + return waveEventSubscribe({ + eventType: "waveobj:update", + scope: oref, + handler: (event) => { + updateWaveObject(event.data); + }, + }); +} + function callBackendService(service: string, method: string, args: any[], noUIContext?: boolean): Promise { const startTs = Date.now(); let uiContext: UIContext = null; @@ -130,6 +141,19 @@ function clearWaveObjectCache() { const defaultHoldTime = 5000; // 5-seconds +function reloadWaveObject(oref: string): Promise { + let wov = waveObjectValueCache.get(oref); + if (wov === undefined) { + wov = getWaveObjectValue(oref, true); + return wov.pendingPromise; + } + const prtn = GetObject(oref); + prtn.then((val) => { + globalStore.set(wov.dataAtom, { value: val, loading: false }); + }); + return prtn; +} + function createWaveValueObject(oref: string, shouldFetch: boolean): WaveObjectValue { const wov = { pendingPromise: null, dataAtom: null, refCount: 0, holdTime: Date.now() + 5000 }; wov.dataAtom = atom({ value: null, loading: true }); @@ -290,8 +314,10 @@ export { getWaveObjectLoadingAtom, loadAndPinWaveObject, makeORef, + reloadWaveObject, setObjectValue, updateWaveObject, updateWaveObjects, useWaveObjectValue, + wpsSubscribeToObject, }; diff --git a/frontend/app/store/ws.ts b/frontend/app/store/ws.ts index 1b11ab326..7fc828d8a 100644 --- a/frontend/app/store/ws.ts +++ b/frontend/app/store/ws.ts @@ -36,7 +36,7 @@ class WSControl { opening: boolean = false; reconnectTimes: number = 0; msgQueue: any[] = []; - windowId: string; + tabId: string; messageCallback: WSEventCallback; watchSessionId: string = null; watchScreenId: string = null; @@ -48,13 +48,13 @@ class WSControl { constructor( baseHostPort: string, - windowId: string, + tabId: string, messageCallback: WSEventCallback, electronOverrideOpts?: ElectronOverrideOpts ) { this.baseHostPort = baseHostPort; this.messageCallback = messageCallback; - this.windowId = windowId; + this.tabId = tabId; this.open = false; this.eoOpts = electronOverrideOpts; setInterval(this.sendPing.bind(this), 5000); @@ -73,7 +73,7 @@ class WSControl { dlog("try reconnect:", desc); this.opening = true; this.wsConn = newWebSocket( - this.baseHostPort + "/ws?windowid=" + this.windowId, + this.baseHostPort + "/ws?tabid=" + this.tabId, this.eoOpts ? { [AuthKeyHeader]: this.eoOpts.authKey, @@ -221,11 +221,11 @@ class WSControl { let globalWS: WSControl; function initGlobalWS( baseHostPort: string, - windowId: string, + tabId: string, messageCallback: WSEventCallback, electronOverrideOpts?: ElectronOverrideOpts ) { - globalWS = new WSControl(baseHostPort, windowId, messageCallback, electronOverrideOpts); + globalWS = new WSControl(baseHostPort, tabId, messageCallback, electronOverrideOpts); } function sendRawRpcMessage(msg: RpcMessage) { diff --git a/frontend/app/store/wshrouter.ts b/frontend/app/store/wshrouter.ts index 9acb0442e..30fbbc441 100644 --- a/frontend/app/store/wshrouter.ts +++ b/frontend/app/store/wshrouter.ts @@ -15,14 +15,14 @@ type RouteInfo = { destRouteId: string; }; -function makeWindowRouteId(windowId: string): string { - return `window:${windowId}`; -} - function makeFeBlockRouteId(feBlockId: string): string { return `feblock:${feBlockId}`; } +function makeTabRouteId(tabId: string): string { + return `tab:${tabId}`; +} + class WshRouter { routeMap: Map; // routeid -> client upstreamClient: AbstractWshClient; @@ -149,4 +149,4 @@ class WshRouter { } } -export { makeFeBlockRouteId, makeWindowRouteId, WshRouter }; +export { makeFeBlockRouteId, makeTabRouteId, WshRouter }; diff --git a/frontend/app/store/wshrpcutil.ts b/frontend/app/store/wshrpcutil.ts index 868725ca6..b0d40bf2e 100644 --- a/frontend/app/store/wshrpcutil.ts +++ b/frontend/app/store/wshrpcutil.ts @@ -3,12 +3,12 @@ import { wpsReconnectHandler } from "@/app/store/wps"; import { WshClient } from "@/app/store/wshclient"; -import { makeWindowRouteId, WshRouter } from "@/app/store/wshrouter"; +import { makeTabRouteId, WshRouter } from "@/app/store/wshrouter"; import { getWSServerEndpoint } from "@/util/endpoints"; import { addWSReconnectHandler, ElectronOverrideOpts, globalWS, initGlobalWS, WSControl } from "./ws"; let DefaultRouter: WshRouter; -let WindowRpcClient: WshClient; +let TabRpcClient: WshClient; async function* rpcResponseGenerator( openRpcs: Map, @@ -126,15 +126,15 @@ function shutdownWshrpc() { globalWS?.shutdown(); } -function initWshrpc(windowId: string): WSControl { +function initWshrpc(tabId: string): WSControl { DefaultRouter = new WshRouter(new UpstreamWshRpcProxy()); const handleFn = (event: WSEventType) => { DefaultRouter.recvRpcMessage(event.data); }; - initGlobalWS(getWSServerEndpoint(), windowId, handleFn); + initGlobalWS(getWSServerEndpoint(), tabId, handleFn); globalWS.connectNow("connectWshrpc"); - WindowRpcClient = new WshClient(makeWindowRouteId(windowId)); - DefaultRouter.registerRoute(WindowRpcClient.routeId, WindowRpcClient); + TabRpcClient = new WshClient(makeTabRouteId(tabId)); + DefaultRouter.registerRoute(TabRpcClient.routeId, TabRpcClient); addWSReconnectHandler(() => { DefaultRouter.reannounceRoutes(); }); @@ -149,12 +149,4 @@ class UpstreamWshRpcProxy implements AbstractWshClient { } } -export { - DefaultRouter, - initElectronWshrpc, - initWshrpc, - sendRpcCommand, - sendRpcResponse, - shutdownWshrpc, - WindowRpcClient, -}; +export { DefaultRouter, initElectronWshrpc, initWshrpc, sendRpcCommand, sendRpcResponse, shutdownWshrpc, TabRpcClient }; diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index eb5b7d863..bcf92d5fc 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -5,7 +5,7 @@ import { Button } from "@/app/element/button"; import { modalsModel } from "@/app/store/modalmodel"; import { WindowDrag } from "@/element/windowdrag"; import { deleteLayoutModelForTab } from "@/layout/index"; -import { atoms, getApi, isDev, PLATFORM } from "@/store/global"; +import { atoms, createTab, getApi, isDev, PLATFORM } from "@/store/global"; import * as services from "@/store/services"; import { useAtomValue } from "jotai"; import { OverlayScrollbars } from "overlayscrollbars"; @@ -134,10 +134,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { const updateStatusButtonRef = useRef(null); const configErrorButtonRef = useRef(null); const prevAllLoadedRef = useRef(false); - - const windowData = useAtomValue(atoms.waveWindow); - const { activetabid } = windowData; - + const activeTabId = useAtomValue(atoms.staticTabId); const isFullScreen = useAtomValue(atoms.isFullScreen); const settings = useAtomValue(atoms.settingsAtom); @@ -483,17 +480,12 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { const handleSelectTab = (tabId: string) => { if (!draggingTabDataRef.current.dragged) { - services.ObjectService.SetActiveTab(tabId); + getApi().setActiveTab(tabId); } }; const handleAddTab = () => { - const newTabName = `T${tabIds.length + 1}`; - services.ObjectService.AddTabToWorkspace(newTabName, true).then((tabId) => { - setTabIds([...tabIds, tabId]); - setNewTabId(tabId); - }); - services.ObjectService.GetObject; + createTab(); tabsWrapperRef.current.style.transition; tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease"); @@ -509,7 +501,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { const handleCloseTab = (event: React.MouseEvent | null, tabId: string) => { event?.stopPropagation(); - services.WindowService.CloseTab(tabId); + getApi().closeTab(tabId); tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease"); deleteLayoutModelForTab(tabId); }; @@ -525,7 +517,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { }, []); const isBeforeActive = (tabId: string) => { - return tabIds.indexOf(tabId) === tabIds.indexOf(activetabid) - 1; + return tabIds.indexOf(tabId) === tabIds.indexOf(activeTabId) - 1; }; function onEllipsisClick() { @@ -560,7 +552,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { id={tabId} isFirst={index === 0} onSelect={() => handleSelectTab(tabId)} - active={activetabid === tabId} + active={activeTabId === tabId} onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])} onClose={(event) => handleCloseTab(event, tabId)} onLoaded={() => handleTabLoaded(tabId)} diff --git a/frontend/app/view/cpuplot/cpuplot.tsx b/frontend/app/view/cpuplot/cpuplot.tsx index e773f9a3a..bd783ffbe 100644 --- a/frontend/app/view/cpuplot/cpuplot.tsx +++ b/frontend/app/view/cpuplot/cpuplot.tsx @@ -12,7 +12,7 @@ import * as React from "react"; import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions"; import { waveEventSubscribe } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import "./cpuplot.less"; const DefaultNumPoints = 120; @@ -112,7 +112,7 @@ class CpuPlotViewModel { this.incrementCount = jotai.atom(null, async (get, set) => { const meta = get(this.blockAtom).meta; const count = meta.count ?? 0; - await RpcApi.SetMetaCommand(WindowRpcClient, { + await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { count: count + 1 }, }); @@ -140,7 +140,7 @@ class CpuPlotViewModel { try { const numPoints = globalStore.get(this.numPoints); const connName = globalStore.get(this.connection); - const initialData = await RpcApi.EventReadHistoryCommand(WindowRpcClient, { + const initialData = await RpcApi.EventReadHistoryCommand(TabRpcClient, { event: "sysinfo", scope: connName, maxitems: numPoints, diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 4c485a1a2..66598b829 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -6,7 +6,7 @@ import { TypeAheadModal } from "@/app/modals/typeaheadmodal"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { tryReinjectKey } from "@/app/store/keymodel"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { Markdown } from "@/element/markdown"; import { NodeModel } from "@/layout/index"; @@ -496,7 +496,7 @@ export class PreviewModel implements ViewModel { async getParentInfo(fileInfo: FileInfo): Promise { const conn = globalStore.get(this.connection); try { - const parentFileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [fileInfo.path, ".."], { + const parentFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.path, ".."], { route: makeConnRoute(conn), }); return parentFileInfo; @@ -517,7 +517,7 @@ export class PreviewModel implements ViewModel { } const conn = globalStore.get(this.connection); try { - const newFileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [fileInfo.path, ".."], { + const newFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.path, ".."], { route: makeConnRoute(conn), }); if (newFileInfo.path != "" && newFileInfo.notfound) { @@ -600,7 +600,7 @@ export class PreviewModel implements ViewModel { } const conn = globalStore.get(this.connection); try { - const newFileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [fileInfo.dir, filePath], { + const newFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.dir, filePath], { route: makeConnRoute(conn), }); this.updateOpenFileModalAndError(false); diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index bbba038b3..bbf0ef28a 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -4,7 +4,7 @@ import { getAllGlobalKeyBindings } from "@/app/store/keymodel"; import { waveEventSubscribe } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { VDomView } from "@/app/view/term/vdom"; import { WOS, atoms, getConnStatusAtom, getSettingsKeyAtom, globalStore, useSettingsPrefixAtom } from "@/store/global"; import * as services from "@/store/services"; @@ -169,7 +169,7 @@ class TermViewModel { } setTerminalTheme(themeName: string) { - RpcApi.SetMetaCommand(WindowRpcClient, { + RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:theme": themeName }, }); @@ -202,8 +202,8 @@ class TermViewModel { rows: this.termRef.current?.terminal?.rows, cols: this.termRef.current?.terminal?.cols, }; - const prtn = RpcApi.ControllerResyncCommand(WindowRpcClient, { - tabid: globalStore.get(atoms.activeTabId), + const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { + tabid: globalStore.get(atoms.staticTabId), blockid: this.blockId, forcerestart: true, rtopts: { termsize: termsize }, @@ -268,7 +268,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { event.preventDefault(); event.stopPropagation(); - RpcApi.SetMetaCommand(WindowRpcClient, { + RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { "term:mode": null }, }); @@ -291,8 +291,8 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { } if (shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) { // restart - const tabId = globalStore.get(atoms.activeTabId); - const prtn = RpcApi.ControllerResyncCommand(WindowRpcClient, { tabid: tabId, blockid: blockId }); + const tabId = globalStore.get(atoms.staticTabId); + const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: blockId }); prtn.catch((e) => console.log("error controller resync (enter)", blockId, e)); return false; } @@ -356,7 +356,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { // reset term:mode - RpcApi.SetMetaCommand(WindowRpcClient, { + RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { "term:mode": null }, }); @@ -367,7 +367,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { return false; } const b64data = util.stringToBase64(asciiVal); - RpcApi.ControllerInputCommand(WindowRpcClient, { blockid: blockId, inputdata64: b64data }); + RpcApi.ControllerInputCommand(TabRpcClient, { blockid: blockId, inputdata64: b64data }); return true; }; diff --git a/frontend/app/view/term/termsticker.tsx b/frontend/app/view/term/termsticker.tsx index b0126174e..1d262b31e 100644 --- a/frontend/app/view/term/termsticker.tsx +++ b/frontend/app/view/term/termsticker.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { createBlock } from "@/store/global"; import { getWebServerEndpoint } from "@/util/endpoints"; import { stringToBase64 } from "@/util/util"; @@ -101,7 +101,7 @@ function TermSticker({ sticker, config }: { sticker: StickerType; config: Sticke console.log("clickHandler", sticker.clickcmd, sticker.clickblockdef); if (sticker.clickcmd) { const b64data = stringToBase64(sticker.clickcmd); - RpcApi.ControllerInputCommand(WindowRpcClient, { blockid: config.blockId, inputdata64: b64data }); + RpcApi.ControllerInputCommand(TabRpcClient, { blockid: config.blockId, inputdata64: b64data }); } if (sticker.clickblockdef) { createBlock(sticker.clickblockdef); diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 55c68fbda..77a7acdfe 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -4,7 +4,7 @@ import { getFileSubject } from "@/app/store/wps"; import { sendWSCommand } from "@/app/store/ws"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { PLATFORM, WOS, atoms, fetchWaveFile, getSettingsKeyAtom, globalStore, openLink } from "@/store/global"; import * as services from "@/store/services"; import * as util from "@/util/util"; @@ -168,7 +168,7 @@ export class TermWrap { handleTermData(data: string) { const b64data = util.stringToBase64(data); - RpcApi.ControllerInputCommand(WindowRpcClient, { blockid: this.blockId, inputdata64: b64data }); + RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); } addFocusListener(focusFn: () => void) { @@ -230,10 +230,10 @@ export class TermWrap { async resyncController(reason: string) { dlog("resync controller", this.blockId, reason); - const tabId = globalStore.get(atoms.activeTabId); + const tabId = globalStore.get(atoms.staticTabId); const rtOpts: RuntimeOpts = { termsize: { rows: this.terminal.rows, cols: this.terminal.cols } }; try { - await RpcApi.ControllerResyncCommand(WindowRpcClient, { + await RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: this.blockId, rtopts: rtOpts, diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index db6bb67ef..c88d35b7c 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -5,7 +5,7 @@ import { Button } from "@/app/element/button"; import { Markdown } from "@/app/element/markdown"; import { TypingIndicator } from "@/app/element/typingindicator"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { atoms, fetchWaveFile, globalStore, WOS } from "@/store/global"; import { BlockService, ObjectService } from "@/store/services"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; @@ -274,7 +274,7 @@ export class WaveAiModel implements ViewModel { }; let fullMsg = ""; try { - const aiGen = RpcApi.StreamWaveAiCommand(WindowRpcClient, beMsg, { timeout: opts.timeoutms }); + const aiGen = RpcApi.StreamWaveAiCommand(TabRpcClient, beMsg, { timeout: opts.timeoutms }); for await (const msg of aiGen) { fullMsg += msg.text ?? ""; globalStore.set(this.updateLastMessageAtom, msg.text ?? "", true); diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index ed3cb33e2..6f0e5a27f 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -5,7 +5,7 @@ import { getApi, getSettingsKeyAtom, openLink } from "@/app/store/global"; import { getSimpleControlShiftAtom } from "@/app/store/keymodel"; import { ObjectService } from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { NodeModel } from "@/layout/index"; import { WOS, globalStore } from "@/store/global"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; @@ -370,17 +370,17 @@ export class WebViewModel implements ViewModel { if (url != null && url != "") { switch (scope) { case "block": - await RpcApi.SetMetaCommand(WindowRpcClient, { + await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { pinnedurl: url }, }); break; case "global": - await RpcApi.SetMetaCommand(WindowRpcClient, { + await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { pinnedurl: "" }, }); - await RpcApi.SetConfigCommand(WindowRpcClient, { "web:defaulturl": url }); + await RpcApi.SetConfigCommand(TabRpcClient, { "web:defaulturl": url }); break; } } diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index 343e9d602..157ab070c 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -94,20 +94,18 @@ const Widget = memo(({ widget }: { widget: WidgetConfigType }) => { }); const WorkspaceElem = memo(() => { - const windowData = useAtomValue(atoms.waveWindow); - const activeTabId = windowData?.activetabid; + const tabId = useAtomValue(atoms.staticTabId); const ws = useAtomValue(atoms.workspace); - return (
- - {activeTabId == "" ? ( + + {tabId == "" ? ( No Active Tab ) : ( <> - + diff --git a/frontend/layout/index.ts b/frontend/layout/index.ts index 57ce26312..44514e0ef 100644 --- a/frontend/layout/index.ts +++ b/frontend/layout/index.ts @@ -5,7 +5,7 @@ import { TileLayout } from "./lib/TileLayout"; import { LayoutModel } from "./lib/layoutModel"; import { deleteLayoutModelForTab, - getLayoutModelForActiveTab, + getLayoutModelForStaticTab, getLayoutModelForTab, getLayoutModelForTabById, useDebouncedNodeInnerRect, @@ -37,7 +37,7 @@ import { DropDirection, LayoutTreeActionType, NavigateDirection } from "./lib/ty export { deleteLayoutModelForTab, DropDirection, - getLayoutModelForActiveTab, + getLayoutModelForStaticTab, getLayoutModelForTab, getLayoutModelForTabById, LayoutModel, diff --git a/frontend/layout/lib/TileLayout.tsx b/frontend/layout/lib/TileLayout.tsx index eefdfa03c..e8cfb6104 100644 --- a/frontend/layout/lib/TileLayout.tsx +++ b/frontend/layout/lib/TileLayout.tsx @@ -128,7 +128,6 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr ); } - export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent; interface DisplayNodesWrapperProps { @@ -247,6 +246,7 @@ const DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => { magnified: addlProps?.isMagnifiedNode, "last-magnified": addlProps?.isLastMagnifiedNode, })} + key={node.id} ref={tileNodeRef} id={node.id} style={addlProps?.transform} diff --git a/frontend/layout/lib/layoutAtom.ts b/frontend/layout/lib/layoutAtom.ts index d732ab146..3c1bdcd3f 100644 --- a/frontend/layout/lib/layoutAtom.ts +++ b/frontend/layout/lib/layoutAtom.ts @@ -39,6 +39,10 @@ export function withLayoutTreeStateAtomFromTab(tabAtom: Atom): WritableLayo const stateAtom = getLayoutStateAtomFromTab(tabAtom, get); if (!stateAtom) return; const waveObjVal = get(stateAtom); + if (waveObjVal == null) { + console.log("in withLayoutTreeStateAtomFromTab, waveObjVal is null", value); + return; + } waveObjVal.rootnode = value.rootNode; waveObjVal.magnifiednodeid = value.magnifiedNodeId; waveObjVal.focusednodeid = value.focusedNodeId; diff --git a/frontend/layout/lib/layoutModelHooks.ts b/frontend/layout/lib/layoutModelHooks.ts index 81c9efd06..42597a492 100644 --- a/frontend/layout/lib/layoutModelHooks.ts +++ b/frontend/layout/lib/layoutModelHooks.ts @@ -1,9 +1,9 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { useOnResize } from "@/app/hook/useDimensions"; import { atoms, globalStore, WOS } from "@/app/store/global"; import { fireAndForget } from "@/util/util"; -import useResizeObserver from "@react-hook/resize-observer"; import { Atom, useAtomValue } from "jotai"; import { CSSProperties, useCallback, useEffect, useLayoutEffect, useState } from "react"; import { debounce } from "throttle-debounce"; @@ -36,8 +36,8 @@ export function getLayoutModelForTabById(tabId: string) { return getLayoutModelForTab(tabAtom); } -export function getLayoutModelForActiveTab() { - const tabId = globalStore.get(atoms.activeTabId); +export function getLayoutModelForStaticTab() { + const tabId = globalStore.get(atoms.staticTabId); return getLayoutModelForTabById(tabId); } @@ -53,7 +53,8 @@ export function useTileLayout(tabAtom: Atom, tileContent: TileLayoutContent // Use tab data to ensure we can reload if the tab is disposed and remade (such as during Hot Module Reloading) useAtomValue(tabAtom); const layoutModel = useLayoutModel(tabAtom); - useResizeObserver(layoutModel?.displayContainerRef, layoutModel?.onContainerResize); + + useOnResize(layoutModel?.displayContainerRef, layoutModel?.onContainerResize); // Once the TileLayout is mounted, re-run the state update to get all the nodes to flow in the layout. useEffect(() => fireAndForget(async () => layoutModel.onTreeStateAtomUpdated(true)), []); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index eee4dae5c..bc9d44164 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -7,16 +7,15 @@ import type * as rxjs from "rxjs"; declare global { type GlobalAtomsType = { - windowId: jotai.Atom; // readonly clientId: jotai.Atom; // readonly client: jotai.Atom; // driven from WOS - uiContext: jotai.Atom; // driven from windowId, activetabid, etc. + uiContext: jotai.Atom; // driven from windowId, tabId waveWindow: jotai.Atom; // driven from WOS workspace: jotai.Atom; // driven from WOS fullConfigAtom: jotai.PrimitiveAtom; // driven from WOS, settings -- updated via WebSocket settingsAtom: jotai.Atom; // derrived from fullConfig tabAtom: jotai.Atom; // driven from WOS - activeTabId: jotai.Atom; // derrived from windowDataAtom + staticTabId: jotai.Atom; isFullScreen: jotai.PrimitiveAtom; controlShiftDelayAtom: jotai.PrimitiveAtom; prefersReducedMotionAtom: jotai.Atom; @@ -50,6 +49,13 @@ declare global { blockId: string; }; + type WaveInitOpts = { + tabId: string; + clientId: string; + windowId: string; + activate: boolean; + }; + type ElectronApi = { getAuthKey(): string; getIsDev(): boolean; @@ -78,6 +84,12 @@ declare global { setWebviewFocus: (focusedId: number) => void; // focusedId si the getWebContentsId of the webview registerGlobalWebviewKeys: (keys: string[]) => void; onControlShiftStateUpdate: (callback: (state: boolean) => void) => void; + setActiveTab: (tabId: string) => void; + createTab: () => void; + closeTab: (tabId: string) => void; + setWindowInitStatus: (status: "ready" | "wave-ready") => void; + onWaveInit: (callback: (initOpts: WaveInitOpts) => void) => void; + sendLog: (log: string) => void; onQuicklook: (filePath: string) => void; }; @@ -332,6 +344,27 @@ declare global { command: string; msgFn: (msg: RpcMessage) => void; }; + + type WaveBrowserWindow = Electron.BaseWindow & { + waveWindowId: string; + waveReadyPromise: Promise; + allTabViews: Map; + activeTabView: WaveTabView; + alreadyClosed: boolean; + }; + + type WaveTabView = Electron.WebContentsView & { + isActiveTab: boolean; + waveWindowId: string; // set when showing in an active window + waveTabId: string; // always set, WaveTabViews are unique per tab + lastUsedTs: number; // ts milliseconds + createdTs: number; // ts milliseconds + initPromise: Promise; + savedInitOpts: WaveInitOpts; + waveReadyPromise: Promise; + initResolve: () => void; + waveReadyResolve: () => void; + }; } export {}; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index a3e6eba1b..740c38dd8 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -47,6 +47,12 @@ declare global { hasoldhistory?: boolean; }; + // windowservice.CloseTabRtnType + type CloseTabRtnType = { + closewindow?: boolean; + newactivetabid?: string; + }; + // wshrpc.CommandAppendIJsonData type CommandAppendIJsonData = { zoneid: string; @@ -472,6 +478,7 @@ declare global { "window:showmenubar"?: boolean; "window:nativetitlebar"?: boolean; "window:disablehardwareacceleration"?: boolean; + "window:maxtabcachesize"?: number; "telemetry:*"?: boolean; "telemetry:enabled"?: boolean; "conn:*"?: boolean; diff --git a/frontend/wave.ts b/frontend/wave.ts index d94bf14b7..4e05f7e12 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -8,11 +8,11 @@ import { registerGlobalKeys, } from "@/app/store/keymodel"; import { modalsModel } from "@/app/store/modalmodel"; -import { FileService, ObjectService } from "@/app/store/services"; +import { FileService } from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; -import { initWshrpc, WindowRpcClient } from "@/app/store/wshrpcutil"; +import { initWshrpc, TabRpcClient } from "@/app/store/wshrpcutil"; import { loadMonaco } from "@/app/view/codeeditor/codeeditor"; -import { getLayoutModelForActiveTab } from "@/layout/index"; +import { getLayoutModelForStaticTab } from "@/layout/index"; import { atoms, countersClear, @@ -32,18 +32,9 @@ import { createElement } from "react"; import { createRoot } from "react-dom/client"; const platform = getApi().getPlatform(); -const urlParams = new URLSearchParams(window.location.search); -const windowId = urlParams.get("windowid"); -const clientId = urlParams.get("clientid"); +document.title = `Wave Terminal`; +let savedInitOpts: WaveInitOpts = null; -console.log("Wave Starting"); -console.log("clientid", clientId, "windowid", windowId); - -initGlobal({ clientId, windowId, platform, environment: "renderer" }); - -setKeyUtilPlatform(platform); - -loadFonts(); (window as any).WOS = WOS; (window as any).globalStore = globalStore; (window as any).globalAtoms = atoms; @@ -51,29 +42,109 @@ loadFonts(); (window as any).isFullScreen = false; (window as any).countersPrint = countersPrint; (window as any).countersClear = countersClear; -(window as any).getLayoutModelForActiveTab = getLayoutModelForActiveTab; +(window as any).getLayoutModelForStaticTab = getLayoutModelForStaticTab; (window as any).pushFlashError = pushFlashError; (window as any).modalsModel = modalsModel; -document.title = `Wave (${windowId.substring(0, 8)})`; +async function initBare() { + getApi().sendLog("Init Bare"); + document.body.style.visibility = "hidden"; + document.body.style.opacity = "0"; + document.body.classList.add("is-transparent"); + getApi().onWaveInit(initWaveWrap); + setKeyUtilPlatform(platform); + loadFonts(); + document.fonts.ready.then(() => { + console.log("Init Bare Done"); + getApi().setWindowInitStatus("ready"); + }); +} -document.addEventListener("DOMContentLoaded", async () => { - console.log("DOMContentLoaded"); +document.addEventListener("DOMContentLoaded", initBare); + +async function initWaveWrap(initOpts: WaveInitOpts) { + try { + if (savedInitOpts) { + await reinitWave(); + return; + } + savedInitOpts = initOpts; + await initWave(initOpts); + } catch (e) { + getApi().sendLog("Error in initWave " + e.message); + console.error("Error in initWave", e); + } finally { + document.body.style.visibility = null; + document.body.style.opacity = null; + document.body.classList.remove("is-transparent"); + } +} + +async function reinitWave() { + console.log("Reinit Wave"); + getApi().sendLog("Reinit Wave"); + const client = await WOS.reloadWaveObject(WOS.makeORef("client", savedInitOpts.clientId)); + const waveWindow = await WOS.reloadWaveObject(WOS.makeORef("window", savedInitOpts.windowId)); + await WOS.reloadWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)); + const initialTab = await WOS.reloadWaveObject(WOS.makeORef("tab", savedInitOpts.tabId)); + await WOS.reloadWaveObject(WOS.makeORef("layout", initialTab.layoutstate)); + document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change + getApi().setWindowInitStatus("wave-ready"); +} + +function loadAllWorkspaceTabs(ws: Workspace) { + if (ws == null || ws.tabids == null) { + return; + } + ws.tabids.forEach((tabid) => { + WOS.getObjectValue(WOS.makeORef("tab", tabid)); + }); +} + +async function initWave(initOpts: WaveInitOpts) { + getApi().sendLog("Init Wave " + JSON.stringify(initOpts)); + console.log( + "Wave Init", + "tabid", + initOpts.tabId, + "clientid", + initOpts.clientId, + "windowid", + initOpts.windowId, + "platform", + platform + ); + initGlobal({ + tabId: initOpts.tabId, + clientId: initOpts.clientId, + windowId: initOpts.windowId, + platform, + environment: "renderer", + }); + (window as any).globalAtoms = atoms; // Init WPS event handlers - const globalWS = initWshrpc(windowId); + const globalWS = initWshrpc(initOpts.tabId); (window as any).globalWS = globalWS; - (window as any).WindowRpcClient = WindowRpcClient; + (window as any).TabRpcClient = TabRpcClient; await loadConnStatus(); initGlobalWaveEventSubs(); subscribeToConnEvents(); // ensures client/window/workspace are loaded into the cache before rendering - const client = await WOS.loadAndPinWaveObject(WOS.makeORef("client", clientId)); - const waveWindow = await WOS.loadAndPinWaveObject(WOS.makeORef("window", windowId)); - await WOS.loadAndPinWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)); - const initialTab = await WOS.loadAndPinWaveObject(WOS.makeORef("tab", waveWindow.activetabid)); - await WOS.loadAndPinWaveObject(WOS.makeORef("layout", initialTab.layoutstate)); + const [client, waveWindow, initialTab] = await Promise.all([ + WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)), + WOS.loadAndPinWaveObject(WOS.makeORef("window", initOpts.windowId)), + WOS.loadAndPinWaveObject(WOS.makeORef("tab", initOpts.tabId)), + ]); + const [ws, layoutState] = await Promise.all([ + WOS.loadAndPinWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)), + WOS.reloadWaveObject(WOS.makeORef("layout", initialTab.layoutstate)), + ]); + loadAllWorkspaceTabs(ws); + WOS.wpsSubscribeToObject(WOS.makeORef("workspace", waveWindow.workspaceid)); + + document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change registerGlobalKeys(); registerElectronReinjectKeyHandler(); @@ -82,15 +153,16 @@ document.addEventListener("DOMContentLoaded", async () => { const fullConfig = await FileService.GetFullConfig(); console.log("fullconfig", fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig); - const prtn = ObjectService.SetActiveTab(waveWindow.activetabid); // no need to wait - prtn.catch((e) => { - console.log("error on initial SetActiveTab", e); + console.log("Wave First Render"); + let firstRenderResolveFn: () => void = null; + let firstRenderPromise = new Promise((resolve) => { + firstRenderResolveFn = resolve; }); - const reactElem = createElement(App, null, null); + const reactElem = createElement(App, { onFirstRender: firstRenderResolveFn }, null); const elem = document.getElementById("main"); const root = createRoot(elem); - document.fonts.ready.then(() => { - console.log("Wave First Render"); - root.render(reactElem); - }); -}); + root.render(reactElem); + await firstRenderPromise; + console.log("Wave First Render Done"); + getApi().setWindowInitStatus("wave-ready"); +} diff --git a/index.html b/index.html index 9d0cc2941..5100477ba 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@ + Wave @@ -11,7 +12,7 @@ - +
diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index bd55082a3..4ba6fb176 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -359,9 +359,6 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj } }() go func() { - defer func() { - log.Printf("[shellproc] shellInputCh loop done\n") - }() // handles input from the shellInputCh, sent to pty // use shellInputCh instead of bc.ShellInputCh (because we want to be attached to *this* ch. bc.ShellInputCh can be updated) for ic := range shellInputCh { diff --git a/pkg/eventbus/eventbus.go b/pkg/eventbus/eventbus.go index 9c216f8ad..2a98f2e36 100644 --- a/pkg/eventbus/eventbus.go +++ b/pkg/eventbus/eventbus.go @@ -10,8 +10,6 @@ import ( "os" "sync" "time" - - "github.com/wavetermdev/waveterm/pkg/waveobj" ) const ( @@ -27,21 +25,19 @@ type WSEventType struct { } type WindowWatchData struct { - WindowWSCh chan any - WaveWindowId string - WatchedORefs map[waveobj.ORef]bool + WindowWSCh chan any + TabId string } var globalLock = &sync.Mutex{} var wsMap = make(map[string]*WindowWatchData) // websocketid => WindowWatchData -func RegisterWSChannel(connId string, windowId string, ch chan any) { +func RegisterWSChannel(connId string, tabId string, ch chan any) { globalLock.Lock() defer globalLock.Unlock() wsMap[connId] = &WindowWatchData{ - WindowWSCh: ch, - WaveWindowId: windowId, - WatchedORefs: make(map[waveobj.ORef]bool), + WindowWSCh: ch, + TabId: tabId, } } @@ -56,7 +52,7 @@ func getWindowWatchesForWindowId(windowId string) []*WindowWatchData { defer globalLock.Unlock() var watches []*WindowWatchData for _, wdata := range wsMap { - if wdata.WaveWindowId == windowId { + if wdata.TabId == windowId { watches = append(watches, wdata) } } diff --git a/pkg/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go index 7a6e4b298..2584d142d 100644 --- a/pkg/service/objectservice/objectservice.go +++ b/pkg/service/objectservice/objectservice.go @@ -13,6 +13,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wlayout" + "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -74,25 +75,28 @@ func (svc *ObjectService) GetObjects(orefStrArr []string) ([]waveobj.WaveObj, er func (svc *ObjectService) AddTabToWorkspace_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ - ArgNames: []string{"uiContext", "tabName", "activateTab"}, + ArgNames: []string{"windowId", "tabName", "activateTab"}, ReturnDesc: "tabId", } } -func (svc *ObjectService) AddTabToWorkspace(uiContext waveobj.UIContext, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) { +func (svc *ObjectService) AddTabToWorkspace(windowId string, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) - tabId, err := wcore.CreateTab(ctx, uiContext.WindowId, tabName, activateTab) + tabId, err := wcore.CreateTab(ctx, windowId, tabName, activateTab) if err != nil { return "", nil, fmt.Errorf("error creating tab: %w", err) } - err = wlayout.ApplyPortableLayout(ctx, tabId, wlayout.GetNewTabLayout()) if err != nil { return "", nil, fmt.Errorf("error applying new tab layout: %w", err) } - return tabId, waveobj.ContextGetUpdatesRtn(ctx), nil + updates := waveobj.ContextGetUpdatesRtn(ctx) + go func() { + wps.Broker.SendUpdateEvents(updates) + }() + return tabId, updates, nil } func (svc *ObjectService) UpdateWorkspaceTabIds_Meta() tsgenmeta.MethodMeta { @@ -118,11 +122,11 @@ func (svc *ObjectService) SetActiveTab_Meta() tsgenmeta.MethodMeta { } } -func (svc *ObjectService) SetActiveTab(uiContext waveobj.UIContext, tabId string) (waveobj.UpdatesRtnType, error) { +func (svc *ObjectService) SetActiveTab(windowId string, tabId string) (waveobj.UpdatesRtnType, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) - err := wstore.SetActiveTab(ctx, uiContext.WindowId, tabId) + err := wstore.SetActiveTab(ctx, windowId, tabId) if err != nil { return nil, fmt.Errorf("error setting active tab: %w", err) } @@ -137,9 +141,14 @@ func (svc *ObjectService) SetActiveTab(uiContext waveobj.UIContext, tabId string return nil, fmt.Errorf("error getting tab blocks: %w", err) } updates := waveobj.ContextGetUpdatesRtn(ctx) - updates = append(updates, waveobj.MakeUpdate(tab)) - updates = append(updates, waveobj.MakeUpdates(blocks)...) - return updates, nil + go func() { + wps.Broker.SendUpdateEvents(updates) + }() + var extraUpdates waveobj.UpdatesRtnType + extraUpdates = append(extraUpdates, updates...) + extraUpdates = append(extraUpdates, waveobj.MakeUpdate(tab)) + extraUpdates = append(extraUpdates, waveobj.MakeUpdates(blocks)...) + return extraUpdates, nil } func (svc *ObjectService) UpdateTabName_Meta() tsgenmeta.MethodMeta { diff --git a/pkg/service/windowservice/windowservice.go b/pkg/service/windowservice/windowservice.go index f3702690f..2735e7dd8 100644 --- a/pkg/service/windowservice/windowservice.go +++ b/pkg/service/windowservice/windowservice.go @@ -16,6 +16,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wlayout" + "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -46,19 +47,25 @@ func (ws *WindowService) SetWindowPosAndSize(ctx context.Context, windowId strin return waveobj.ContextGetUpdatesRtn(ctx), nil } -func (svc *WindowService) CloseTab(ctx context.Context, uiContext waveobj.UIContext, tabId string) (waveobj.UpdatesRtnType, error) { +type CloseTabRtnType struct { + CloseWindow bool `json:"closewindow,omitempty"` + NewActiveTabId string `json:"newactivetabid,omitempty"` +} + +// returns the new active tabid +func (svc *WindowService) CloseTab(ctx context.Context, windowId string, tabId string, fromElectron bool) (*CloseTabRtnType, waveobj.UpdatesRtnType, error) { ctx = waveobj.ContextWithUpdates(ctx) - window, err := wstore.DBMustGet[*waveobj.Window](ctx, uiContext.WindowId) + window, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId) if err != nil { - return nil, fmt.Errorf("error getting window: %w", err) + return nil, nil, fmt.Errorf("error getting window: %w", err) } tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId) if err != nil { - return nil, fmt.Errorf("error getting tab: %w", err) + return nil, nil, fmt.Errorf("error getting tab: %w", err) } ws, err := wstore.DBMustGet[*waveobj.Workspace](ctx, window.WorkspaceId) if err != nil { - return nil, fmt.Errorf("error getting workspace: %w", err) + return nil, nil, fmt.Errorf("error getting workspace: %w", err) } tabIndex := -1 for i, id := range ws.TabIds { @@ -73,26 +80,36 @@ func (svc *WindowService) CloseTab(ctx context.Context, uiContext waveobj.UICont } }() if err := wcore.DeleteTab(ctx, window.WorkspaceId, tabId); err != nil { - return nil, fmt.Errorf("error closing tab: %w", err) + return nil, nil, fmt.Errorf("error closing tab: %w", err) } + rtn := &CloseTabRtnType{} if window.ActiveTabId == tabId && tabIndex != -1 { if len(ws.TabIds) == 1 { - svc.CloseWindow(ctx, uiContext.WindowId) - eventbus.SendEventToElectron(eventbus.WSEventType{ - EventType: eventbus.WSEvent_ElectronCloseWindow, - Data: uiContext.WindowId, - }) + rtn.CloseWindow = true + svc.CloseWindow(ctx, windowId, fromElectron) + if !fromElectron { + eventbus.SendEventToElectron(eventbus.WSEventType{ + EventType: eventbus.WSEvent_ElectronCloseWindow, + Data: windowId, + }) + } } else { if tabIndex < len(ws.TabIds)-1 { newActiveTabId := ws.TabIds[tabIndex+1] - wstore.SetActiveTab(ctx, uiContext.WindowId, newActiveTabId) + wstore.SetActiveTab(ctx, windowId, newActiveTabId) + rtn.NewActiveTabId = newActiveTabId } else { newActiveTabId := ws.TabIds[tabIndex-1] - wstore.SetActiveTab(ctx, uiContext.WindowId, newActiveTabId) + wstore.SetActiveTab(ctx, windowId, newActiveTabId) + rtn.NewActiveTabId = newActiveTabId } } } - return waveobj.ContextGetUpdatesRtn(ctx), nil + updates := waveobj.ContextGetUpdatesRtn(ctx) + go func() { + wps.Broker.SendUpdateEvents(updates) + }() + return rtn, updates, nil } func (svc *WindowService) MoveBlockToNewWindow_Meta() tsgenmeta.MethodMeta { @@ -148,7 +165,7 @@ func (svc *WindowService) MoveBlockToNewWindow(ctx context.Context, currentTabId return waveobj.ContextGetUpdatesRtn(ctx), nil } -func (svc *WindowService) CloseWindow(ctx context.Context, windowId string) error { +func (svc *WindowService) CloseWindow(ctx context.Context, windowId string, fromElectron bool) error { ctx = waveobj.ContextWithUpdates(ctx) window, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId) if err != nil { @@ -159,8 +176,7 @@ func (svc *WindowService) CloseWindow(ctx context.Context, windowId string) erro return fmt.Errorf("error getting workspace: %w", err) } for _, tabId := range workspace.TabIds { - uiContext := waveobj.UIContext{WindowId: windowId} - _, err := svc.CloseTab(ctx, uiContext, tabId) + _, _, err := svc.CloseTab(ctx, windowId, tabId, fromElectron) if err != nil { return fmt.Errorf("error closing tab: %w", err) } diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index b5c821ecf..a8f591d0a 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -11,6 +11,7 @@ "web:defaulturl": "https://github.com/wavetermdev/waveterm", "web:defaultsearch": "https://www.google.com/search?q={query}", "window:tilegapsize": 3, + "window:maxtabcachesize": 10, "telemetry:enabled": true, "term:copyonselect": true } diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 57518e734..16442a692 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -60,6 +60,7 @@ const ( ConfigKey_WindowShowMenuBar = "window:showmenubar" ConfigKey_WindowNativeTitleBar = "window:nativetitlebar" ConfigKey_WindowDisableHardwareAcceleration = "window:disablehardwareacceleration" + ConfigKey_WindowMaxTabCacheSize = "window:maxtabcachesize" ConfigKey_TelemetryClear = "telemetry:*" ConfigKey_TelemetryEnabled = "telemetry:enabled" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index c54de90e9..cf9eeaee4 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -101,6 +101,7 @@ type SettingsType struct { WindowShowMenuBar bool `json:"window:showmenubar,omitempty"` WindowNativeTitleBar bool `json:"window:nativetitlebar,omitempty"` WindowDisableHardwareAcceleration bool `json:"window:disablehardwareacceleration,omitempty"` + WindowMaxTabCacheSize int `json:"window:maxtabcachesize,omitempty"` TelemetryClear bool `json:"telemetry:*,omitempty"` TelemetryEnabled bool `json:"telemetry:enabled,omitempty"` diff --git a/pkg/wcore/wcore.go b/pkg/wcore/wcore.go index e2967b7f0..f282b4f53 100644 --- a/pkg/wcore/wcore.go +++ b/pkg/wcore/wcore.go @@ -78,6 +78,13 @@ func CreateTab(ctx context.Context, windowId string, tabName string, activateTab if err != nil { return "", fmt.Errorf("error getting window: %w", err) } + if tabName == "" { + ws, err := wstore.DBMustGet[*waveobj.Workspace](ctx, windowData.WorkspaceId) + if err != nil { + return "", fmt.Errorf("error getting workspace: %w", err) + } + tabName = "T" + fmt.Sprint(len(ws.TabIds)+1) + } tab, err := wstore.CreateTab(ctx, windowData.WorkspaceId, tabName) if err != nil { return "", fmt.Errorf("error creating tab: %w", err) diff --git a/pkg/web/ws.go b/pkg/web/ws.go index 4c605fa27..612aeac77 100644 --- a/pkg/web/ws.go +++ b/pkg/web/ws.go @@ -241,11 +241,10 @@ func WriteLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any) { } func HandleWsInternal(w http.ResponseWriter, r *http.Request) error { - windowId := r.URL.Query().Get("windowid") - if windowId == "" { - return fmt.Errorf("windowid is required") + tabId := r.URL.Query().Get("tabid") + if tabId == "" { + return fmt.Errorf("tabid is required") } - err := authkey.ValidateIncomingRequest(r) if err != nil { w.WriteHeader(http.StatusUnauthorized) @@ -258,15 +257,15 @@ func HandleWsInternal(w http.ResponseWriter, r *http.Request) error { } defer conn.Close() wsConnId := uuid.New().String() - log.Printf("New websocket connection: windowid:%s connid:%s\n", windowId, wsConnId) + log.Printf("New websocket connection: tabid:%s connid:%s\n", tabId, wsConnId) outputCh := make(chan any, 100) closeCh := make(chan any) - eventbus.RegisterWSChannel(wsConnId, windowId, outputCh) + eventbus.RegisterWSChannel(wsConnId, tabId, outputCh) var routeId string - if windowId == wshutil.ElectronRoute { + if tabId == wshutil.ElectronRoute { routeId = wshutil.ElectronRoute } else { - routeId = wshutil.MakeWindowRouteId(windowId) + routeId = wshutil.MakeTabRouteId(tabId) } defer eventbus.UnregisterWSChannel(wsConnId) // we create a wshproxy to handle rpc messages to/from the window diff --git a/pkg/wps/wps.go b/pkg/wps/wps.go index 87e00faa2..c742dc3fd 100644 --- a/pkg/wps/wps.go +++ b/pkg/wps/wps.go @@ -5,7 +5,6 @@ package wps import ( - "log" "strings" "sync" @@ -76,7 +75,7 @@ func (b *BrokerType) GetClient() Client { // if already subscribed, this will *resubscribe* with the new subscription (remove the old one, and replace with this one) func (b *BrokerType) Subscribe(subRouteId string, sub SubscriptionRequest) { - log.Printf("[wps] sub %s %s\n", subRouteId, sub.Event) + // log.Printf("[wps] sub %s %s\n", subRouteId, sub.Event) if sub.Event == "" { return } @@ -138,7 +137,7 @@ func addStrToScopeMap(scopeMap map[string][]string, scope string, routeId string } func (b *BrokerType) Unsubscribe(subRouteId string, eventName string) { - log.Printf("[wps] unsub %s %s\n", subRouteId, eventName) + // log.Printf("[wps] unsub %s %s\n", subRouteId, eventName) b.Lock.Lock() defer b.Lock.Unlock() b.unsubscribe_nolock(subRouteId, eventName) diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index baed1bd3e..53a6cff09 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -120,7 +120,7 @@ func (ws *WshServer) GetMetaCommand(ctx context.Context, data wshrpc.CommandGetM } func (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error { - log.Printf("SETMETA: %s | %v\n", data.ORef, data.Meta) + log.Printf("SetMetaCommand: %s | %v\n", data.ORef, data.Meta) oref := data.ORef err := wstore.UpdateObjectMeta(ctx, oref, data.Meta) if err != nil { @@ -422,7 +422,7 @@ func (ws *WshServer) EventPublishCommand(ctx context.Context, data wps.WaveEvent } func (ws *WshServer) EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error { - log.Printf("EventSubCommand: %v\n", data) + // log.Printf("EventSubCommand: %v\n", data) rpcSource := wshutil.GetRpcSourceFromContext(ctx) if rpcSource == "" { return fmt.Errorf("no rpc source set") diff --git a/pkg/wshutil/wshrouter.go b/pkg/wshutil/wshrouter.go index 26fdb8a39..63a53ef0d 100644 --- a/pkg/wshutil/wshrouter.go +++ b/pkg/wshutil/wshrouter.go @@ -52,14 +52,14 @@ func MakeControllerRouteId(blockId string) string { return "controller:" + blockId } -func MakeWindowRouteId(windowId string) string { - return "window:" + windowId -} - func MakeProcRouteId(procId string) string { return "proc:" + procId } +func MakeTabRouteId(tabId string) string { + return "tab:" + tabId +} + var DefaultRouter = NewWshRouter() func NewWshRouter() *WshRouter { diff --git a/pkg/wstore/wstore_dbops.go b/pkg/wstore/wstore_dbops.go index c4cd17ba2..602842320 100644 --- a/pkg/wstore/wstore_dbops.go +++ b/pkg/wstore/wstore_dbops.go @@ -277,3 +277,13 @@ func DBFindTabForBlockId(ctx context.Context, blockId string) (string, error) { return tx.GetString(query, blockId), nil }) } + +func DBFindWorkspaceForTabId(ctx context.Context, tabId string) (string, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (string, error) { + query := ` + SELECT w.oid + FROM db_workspace w, json_each(data->'tabids') je + WHERE je.value = ?` + return tx.GetString(query, tabId), nil + }) +}