browser view (#1005)

This commit is contained in:
Mike Sawka 2024-10-17 14:34:02 -07:00 committed by GitHub
parent 7628e667ca
commit c1c90bb4f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 1608 additions and 846 deletions

View File

@ -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,

54
emain/emain-activity.ts Normal file
View File

@ -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;
}

168
emain/emain-util.ts Normal file
View File

@ -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<void> {
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<Electron.WebContentsWillNavigateEventParams>, 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<Electron.WebContentsWillFrameNavigateEventParams>) {
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;
}

556
emain/emain-viewmgr.ts Normal file
View File

@ -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<string, WaveBrowserWindow>(); // waveWindowId -> WaveBrowserWindow
let focusedWaveWindow = null; // on blur we do not set this to null (but on destroy we do)
const wcvCache = new Map<string, WaveTabView>();
const wcIdToWaveTabMap = new Map<number, WaveTabView>();
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<string, WaveTabView>();
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);
}

118
emain/emain-wavesrv.ts Normal file
View File

@ -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<boolean> = new Promise((resolve, _) => {
waveSrvReadyResolve = resolve;
});
export function getWaveSrvReady(): Promise<boolean> {
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<boolean> {
let pResolve: (value: boolean) => void;
let pReject: (reason?: any) => void;
const rtnPromise = new Promise<boolean>((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;
}

View File

@ -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<WebContents> {
export function getWebContentsByBlockId(ww: WaveBrowserWindow, tabId: string, blockId: string): Promise<WebContents> {
const prtn = new Promise<WebContents>((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);

View File

@ -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<void> };
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,

View File

@ -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<void> };
let waveSrvReadyResolve = (value: boolean) => {};
const waveSrvReady: Promise<boolean> = 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<boolean> {
let pResolve: (value: boolean) => void;
let pReject: (reason?: any) => void;
const rtnPromise = new Promise<boolean>((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<Electron.WebContentsWillNavigateEventParams>, 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<Electron.WebContentsWillFrameNavigateEventParams>) {
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<UrlInS
electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => {
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<number, boolean>();
@ -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<void> {
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<void> {
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<void> {
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<void> {
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();
}
});

View File

@ -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<void>;
relaunchBrowserWindows: () => Promise<void>;
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",
},

View File

@ -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),
});

View File

@ -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();
}
});
}
}

View File

@ -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<HTMLDivElement>(null);
const tabId = useAtomValue(atoms.activeTabId);
const tabId = useAtomValue(atoms.staticTabId);
const [tabData] = useWaveObjectValue<Tab>(WOS.makeORef("tab", tabId));
const bgAttr = tabData?.meta?.bg;
const style: CSSProperties = {};

View File

@ -18,6 +18,10 @@ body {
transform: translateZ(0);
}
.is-transparent {
background-color: transparent;
}
a.plain-link {
color: var(--secondary-text-color);
}

View File

@ -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 (
<Provider store={globalStore}>
<AppInner />
@ -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;

View File

@ -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));
},
};

View File

@ -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();

View File

@ -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<T extends HTMLElement>(
return [refCallback, ref, domRect];
}
export function useOnResize<T extends HTMLElement>(
ref: React.RefObject<T>,
callback: (domRect: DOMRectReadOnly) => void,
debounceMs: number = null
) {
const isFirst = React.useRef(true);
const rszObjRef = React.useRef<ResizeObserver>(null);
const oldHtmlElem = React.useRef<T>(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<T extends HTMLElement>(

View File

@ -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<string, number>();
const ConnStatusMap = new Map<string, PrimitiveAtom<ConnStatus>>();
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<string>;
const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom<string>;
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<UIContext>;
@ -99,18 +99,10 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
return get(fullConfigAtom)?.settings ?? {};
}) as Atom<SettingsType>;
const tabAtom: Atom<Tab> = 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<string> = atom((get) => {
const windowData = get(windowDataAtom);
if (windowData == null) {
return null;
}
return windowData.activetabid;
const staticTabIdAtom: Atom<string> = atom((get) => {
return initOpts.tabId;
});
const controlShiftDelayAtom = atom(false);
const updaterStatusAtom = atom<UpdaterStatus>("up-to-date") as PrimitiveAtom<UpdaterStatus>;
@ -151,7 +143,6 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
const flashErrorsAtom = atom<FlashErrorType[]>([]);
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<strin
magnified,
focused: true,
};
const activeTabId = globalStore.get(atoms.uiContext).activetabid;
const layoutModel = getLayoutModelForTabById(activeTabId);
const tabId = globalStore.get(atoms.staticTabId);
const layoutModel = getLayoutModelForTabById(tabId);
layoutModel.treeReducer(insertNodeAction);
return blockId;
}
@ -339,7 +330,7 @@ async function fetchWaveFile(
}
function setNodeFocus(nodeId: string) {
const layoutModel = getLayoutModelForActiveTab();
const layoutModel = getLayoutModelForStaticTab();
layoutModel.focusNode(nodeId);
}
@ -414,7 +405,7 @@ function refocusNode(blockId: string) {
if (blockId == null) {
return;
}
const layoutModel = getLayoutModelForActiveTab();
const layoutModel = getLayoutModelForStaticTab();
const layoutNodeId = layoutModel.getNodeByBlockId(blockId);
if (layoutNodeId?.id == null) {
return;
@ -522,12 +513,17 @@ function removeFlashError(id: string) {
});
}
async function createTab(): Promise<void> {
await getApi().createTab();
}
export {
atoms,
counterInc,
countersClear,
countersPrint,
createBlock,
createTab,
fetchWaveFile,
getApi,
getBlockComponentModel,

View File

@ -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<string, (waveEvent: WaveKeyboardEvent) => 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<Block>(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;

View File

@ -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<string> {
AddTabToWorkspace(windowId: string, tabName: string, activateTab: boolean): Promise<string> {
return WOS.callBackendService("object", "AddTabToWorkspace", Array.from(arguments))
}
@ -113,7 +113,7 @@ class ObjectServiceType {
}
// @returns object updates
SetActiveTab(tabId: string): Promise<void> {
SetActiveTab(uiContext: string, tabId: string): Promise<void> {
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<void> {
CloseTab(arg2: string, arg3: string, arg4: boolean): Promise<CloseTabRtnType> {
return WOS.callBackendService("window", "CloseTab", Array.from(arguments))
}
CloseWindow(arg2: string): Promise<void> {
CloseWindow(arg2: string, arg3: boolean): Promise<void> {
return WOS.callBackendService("window", "CloseWindow", Array.from(arguments))
}

View File

@ -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<any> {
const startTs = Date.now();
let uiContext: UIContext = null;
@ -130,6 +141,19 @@ function clearWaveObjectCache() {
const defaultHoldTime = 5000; // 5-seconds
function reloadWaveObject<T extends WaveObj>(oref: string): Promise<T> {
let wov = waveObjectValueCache.get(oref);
if (wov === undefined) {
wov = getWaveObjectValue<T>(oref, true);
return wov.pendingPromise;
}
const prtn = GetObject<T>(oref);
prtn.then((val) => {
globalStore.set(wov.dataAtom, { value: val, loading: false });
});
return prtn;
}
function createWaveValueObject<T extends WaveObj>(oref: string, shouldFetch: boolean): WaveObjectValue<T> {
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,
};

View File

@ -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) {

View File

@ -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<string, AbstractWshClient>; // routeid -> client
upstreamClient: AbstractWshClient;
@ -149,4 +149,4 @@ class WshRouter {
}
}
export { makeFeBlockRouteId, makeWindowRouteId, WshRouter };
export { makeFeBlockRouteId, makeTabRouteId, WshRouter };

View File

@ -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<string, ClientRpcEntry>,
@ -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 };

View File

@ -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<HTMLButtonElement>(null);
const configErrorButtonRef = useRef<HTMLElement>(null);
const prevAllLoadedRef = useRef<boolean>(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<HTMLButtonElement, 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)}

View File

@ -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,

View File

@ -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<FileInfo | undefined> {
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);

View File

@ -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;
};

View File

@ -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);

View File

@ -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,

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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 (
<div className="workspace">
<TabBar key={ws.oid} workspace={ws} />
<div className="workspace-tabcontent">
<ErrorBoundary key={activeTabId}>
{activeTabId == "" ? (
<ErrorBoundary key={tabId}>
{tabId == "" ? (
<CenteredDiv>No Active Tab</CenteredDiv>
) : (
<>
<TabContent key={activeTabId} tabId={activeTabId} />
<TabContent key={tabId} tabId={tabId} />
<Widgets />
<ModalsRenderer />
</>

View File

@ -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,

View File

@ -128,7 +128,6 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr
</Suspense>
);
}
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}

View File

@ -39,6 +39,10 @@ export function withLayoutTreeStateAtomFromTab(tabAtom: Atom<Tab>): 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;

View File

@ -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<Tab>, 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)), []);

View File

@ -7,16 +7,15 @@ import type * as rxjs from "rxjs";
declare global {
type GlobalAtomsType = {
windowId: jotai.Atom<string>; // readonly
clientId: jotai.Atom<string>; // readonly
client: jotai.Atom<Client>; // driven from WOS
uiContext: jotai.Atom<UIContext>; // driven from windowId, activetabid, etc.
uiContext: jotai.Atom<UIContext>; // driven from windowId, tabId
waveWindow: jotai.Atom<WaveWindow>; // driven from WOS
workspace: jotai.Atom<Workspace>; // driven from WOS
fullConfigAtom: jotai.PrimitiveAtom<FullConfigType>; // driven from WOS, settings -- updated via WebSocket
settingsAtom: jotai.Atom<SettingsType>; // derrived from fullConfig
tabAtom: jotai.Atom<Tab>; // driven from WOS
activeTabId: jotai.Atom<string>; // derrived from windowDataAtom
staticTabId: jotai.Atom<string>;
isFullScreen: jotai.PrimitiveAtom<boolean>;
controlShiftDelayAtom: jotai.PrimitiveAtom<boolean>;
prefersReducedMotionAtom: jotai.Atom<boolean>;
@ -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<void>;
allTabViews: Map<string, WaveTabView>;
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<void>;
savedInitOpts: WaveInitOpts;
waveReadyPromise: Promise<void>;
initResolve: () => void;
waveReadyResolve: () => void;
};
}
export {};

View File

@ -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;

View File

@ -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<Client>(WOS.makeORef("client", savedInitOpts.clientId));
const waveWindow = await WOS.reloadWaveObject<WaveWindow>(WOS.makeORef("window", savedInitOpts.windowId));
await WOS.reloadWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));
const initialTab = await WOS.reloadWaveObject<Tab>(WOS.makeORef("tab", savedInitOpts.tabId));
await WOS.reloadWaveObject<LayoutState>(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<Tab>(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<Client>(WOS.makeORef("client", clientId));
const waveWindow = await WOS.loadAndPinWaveObject<WaveWindow>(WOS.makeORef("window", windowId));
await WOS.loadAndPinWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));
const initialTab = await WOS.loadAndPinWaveObject<Tab>(WOS.makeORef("tab", waveWindow.activetabid));
await WOS.loadAndPinWaveObject<LayoutState>(WOS.makeORef("layout", initialTab.layoutstate));
const [client, waveWindow, initialTab] = await Promise.all([
WOS.loadAndPinWaveObject<Client>(WOS.makeORef("client", initOpts.clientId)),
WOS.loadAndPinWaveObject<WaveWindow>(WOS.makeORef("window", initOpts.windowId)),
WOS.loadAndPinWaveObject<Tab>(WOS.makeORef("tab", initOpts.tabId)),
]);
const [ws, layoutState] = await Promise.all([
WOS.loadAndPinWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid)),
WOS.reloadWaveObject<LayoutState>(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<void>((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");
}

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color scheme" content="light dark" />
<title>Wave</title>
<link rel="stylesheet" href="/fontawesome/css/fontawesome.min.css" />
<link rel="stylesheet" href="/fontawesome/css/brands.min.css" />
@ -11,7 +12,7 @@
<link rel="stylesheet" href="/fontawesome/css/sharp-regular.min.css" />
<script type="module" src="frontend/wave.ts"></script>
</head>
<body>
<body class="init">
<div id="main"></div>
</body>
</html>

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -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
}

View File

@ -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"

View File

@ -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"`

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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")

View File

@ -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 {

View File

@ -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
})
}