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

hello vdom world

+
| num[]
+
+ +
+
+ ` + elem := vdom.Bind(vdomStr, nil) + return elem +} + +func GlobalEventHandler(client *vdomclient.Client, event vdom.VDomEvent) { + if event.PropName == "clickinc" { + client.SetAtomVal("num", client.GetAtomVal("num").(int)+1) + return } } + +func htmlRun(cmd *cobra.Command, args []string) error { + WriteStderr("running wsh html %q\n", RpcContext.BlockId) + + client, err := vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true}) + if err != nil { + return err + } + client.SetGlobalEventHandler(GlobalEventHandler) + log.Printf("created client: %v\n", client) + client.SetAtomVal("bgcolor", "#0000ff77") + client.SetAtomVal("text", "initial text") + client.SetAtomVal("num", 0) + client.SetRootElem(MakeVDom()) + err = client.CreateVDomContext() + if err != nil { + return err + } + log.Printf("created context\n") + go func() { + <-client.DoneCh + wshutil.DoShutdown("vdom closed by FE", 0, true) + }() + log.Printf("created vdom context\n") + go func() { + time.Sleep(5 * time.Second) + client.SetAtomVal("text", "updated text") + client.SendAsyncInitiation() + }() + <-client.DoneCh + return nil +} diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 3b5129039..1f7f9827a 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -36,7 +36,7 @@ type FullBlockProps = { function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel { if (blockView === "term") { - return makeTerminalModel(blockId); + return makeTerminalModel(blockId, nodeModel); } if (blockView === "preview") { return makePreviewModel(blockId, nodeModel); @@ -253,7 +253,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { const Block = memo((props: BlockProps) => { counterInc("render-Block"); - counterInc("render-Block-" + props.nodeModel.blockId.substring(0, 8)); + counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8)); const [blockData, loading] = useWaveObjectValue(makeORef("block", props.nodeModel.blockId)); const bcm = getBlockComponentModel(props.nodeModel.blockId); let viewModel = bcm?.viewModel; @@ -264,6 +264,7 @@ const Block = memo((props: BlockProps) => { useEffect(() => { return () => { unregisterBlockComponentModel(props.nodeModel.blockId); + viewModel?.dispose?.(); }; }, []); if (loading || isBlank(props.nodeModel.blockId) || blockData == null) { diff --git a/frontend/app/store/wshclient.ts b/frontend/app/store/wshclient.ts index 93e7a7e38..3ec87ab00 100644 --- a/frontend/app/store/wshclient.ts +++ b/frontend/app/store/wshclient.ts @@ -18,6 +18,10 @@ class RpcResponseHelper { this.done = cmdMsg.reqid == null; } + getSource(): string { + return this.cmdMsg?.source; + } + sendResponse(msg: RpcMessage) { if (this.done || util.isBlank(this.cmdMsg.reqid)) { return; diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 44a9923c4..733dd6df0 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -217,6 +217,21 @@ class RpcApiType { return client.wshRpcCall("test", data, opts); } + // command "vdomasyncinitiation" [call] + VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise { + return client.wshRpcCall("vdomasyncinitiation", data, opts); + } + + // command "vdomcreatecontext" [call] + VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise { + return client.wshRpcCall("vdomcreatecontext", data, opts); + } + + // command "vdomrender" [call] + VDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): Promise { + return client.wshRpcCall("vdomrender", data, opts); + } + // command "webselector" [call] WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise { return client.wshRpcCall("webselector", data, opts); diff --git a/frontend/app/view/term/term-wsh.tsx b/frontend/app/view/term/term-wsh.tsx new file mode 100644 index 000000000..1eaca2b92 --- /dev/null +++ b/frontend/app/view/term/term-wsh.tsx @@ -0,0 +1,52 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { WOS } from "@/app/store/global"; +import { waveEventSubscribe } from "@/app/store/wps"; +import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { makeFeBlockRouteId } from "@/app/store/wshrouter"; +import { TermViewModel } from "@/app/view/term/term"; +import debug from "debug"; + +const dlog = debug("wave:vdom"); + +export class TermWshClient extends WshClient { + blockId: string; + model: TermViewModel; + + constructor(blockId: string, model: TermViewModel) { + super(makeFeBlockRouteId(blockId)); + this.blockId = blockId; + this.model = model; + } + + handle_vdomcreatecontext(rh: RpcResponseHelper, data: VDomCreateContext) { + console.log("vdom-create", rh.getSource(), data); + this.model.vdomModel.reset(); + this.model.vdomModel.backendRoute = rh.getSource(); + if (!data.persist) { + const unsubFn = waveEventSubscribe({ + eventType: "route:gone", + scope: rh.getSource(), + handler: () => { + RpcApi.SetMetaCommand(this, { + oref: WOS.makeORef("block", this.blockId), + meta: { "term:mode": null }, + }); + unsubFn(); + }, + }); + } + RpcApi.SetMetaCommand(this, { + oref: WOS.makeORef("block", this.blockId), + meta: { "term:mode": "html" }, + }); + this.model.vdomModel.queueUpdate(true); + } + + handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) { + console.log("async-initiation", rh.getSource(), data); + this.model.vdomModel.queueUpdate(true); + } +} diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index bbf0ef28a..d68c0e6da 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -4,12 +4,15 @@ import { getAllGlobalKeyBindings } from "@/app/store/keymodel"; import { waveEventSubscribe } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { makeFeBlockRouteId } from "@/app/store/wshrouter"; +import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; +import { TermWshClient } from "@/app/view/term/term-wsh"; import { VDomView } from "@/app/view/term/vdom"; +import { VDomModel } from "@/app/view/term/vdom-model"; +import { NodeModel } from "@/layout/index"; import { WOS, atoms, getConnStatusAtom, getSettingsKeyAtom, globalStore, useSettingsPrefixAtom } from "@/store/global"; import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; -import * as util from "@/util/util"; import clsx from "clsx"; import * as jotai from "jotai"; import * as React from "react"; @@ -19,102 +22,35 @@ import { computeTheme } from "./termutil"; import { TermWrap } from "./termwrap"; import "./xterm.css"; -const keyMap = { - Enter: "\r", - Backspace: "\x7f", - Tab: "\t", - Escape: "\x1b", - ArrowUp: "\x1b[A", - ArrowDown: "\x1b[B", - ArrowRight: "\x1b[C", - ArrowLeft: "\x1b[D", - Insert: "\x1b[2~", - Delete: "\x1b[3~", - Home: "\x1b[1~", - End: "\x1b[4~", - PageUp: "\x1b[5~", - PageDown: "\x1b[6~", -}; - -function keyboardEventToASCII(event: React.KeyboardEvent): string { - // check modifiers - // if no modifiers are set, just send the key - if (!event.altKey && !event.ctrlKey && !event.metaKey) { - if (event.key == null || event.key == "") { - return ""; - } - if (keyMap[event.key] != null) { - return keyMap[event.key]; - } - if (event.key.length == 1) { - return event.key; - } else { - console.log("not sending keyboard event", event.key, event); - } - } - // if meta or alt is set, there is no ASCII representation - if (event.metaKey || event.altKey) { - return ""; - } - // if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value - if (event.ctrlKey) { - if ( - (event.key.length === 1 && event.key >= "A" && event.key <= "Z") || - (event.key >= "a" && event.key <= "z") - ) { - const key = event.key.toUpperCase(); - return String.fromCharCode(key.charCodeAt(0) - 64); - } - } - return ""; -} - type InitialLoadDataType = { loaded: boolean; heldData: Uint8Array[]; }; -function vdomText(text: string): VDomElem { - return { - tag: "#text", - text: text, - }; -} - -const testVDom: VDomElem = { - id: "testid1", - tag: "div", - children: [ - { - id: "testh1", - tag: "h1", - children: [vdomText("Hello World")], - }, - { - id: "testp", - tag: "p", - children: [vdomText("This is a paragraph (from VDOM)")], - }, - ], -}; - class TermViewModel { viewType: string; + nodeModel: NodeModel; connected: boolean; termRef: React.RefObject; blockAtom: jotai.Atom; termMode: jotai.Atom; - htmlElemFocusRef: React.RefObject; blockId: string; viewIcon: jotai.Atom; viewName: jotai.Atom; blockBg: jotai.Atom; manageConnection: jotai.Atom; connStatus: jotai.Atom; + termWshClient: TermWshClient; + shellProcStatusRef: React.MutableRefObject; + vdomModel: VDomModel; - constructor(blockId: string) { + constructor(blockId: string, nodeModel: NodeModel) { this.viewType = "term"; this.blockId = blockId; + this.termWshClient = new TermWshClient(blockId, this); + DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.termWshClient); + this.nodeModel = nodeModel; + this.vdomModel = new VDomModel(blockId, nodeModel, null, this.termWshClient); this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); this.termMode = jotai.atom((get) => { const blockData = get(this.blockAtom); @@ -152,6 +88,10 @@ class TermViewModel { }); } + dispose() { + DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId)); + } + giveFocus(): boolean { let termMode = globalStore.get(this.termMode); if (termMode == "term") { @@ -159,15 +99,70 @@ class TermViewModel { this.termRef.current.terminal.focus(); return true; } - } else { - if (this.htmlElemFocusRef?.current) { - this.htmlElemFocusRef.current.focus(); - return true; - } } return false; } + keyDownHandler(waveEvent: WaveKeyboardEvent): boolean { + if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { + const blockAtom = WOS.getWaveObjectAtom(`block:${this.blockId}`); + const blockData = globalStore.get(blockAtom); + const newTermMode = blockData?.meta?.["term:mode"] == "html" ? null : "html"; + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "term:mode": newTermMode }, + }); + return true; + } + const blockData = globalStore.get(this.blockAtom); + if (blockData.meta?.["term:mode"] == "html") { + return this.vdomModel?.globalKeydownHandler(waveEvent); + } + return false; + } + + handleTerminalKeydown(event: KeyboardEvent): boolean { + const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); + if (waveEvent.type != "keydown") { + return true; + } + if (this.keyDownHandler(waveEvent)) { + event.preventDefault(); + event.stopPropagation(); + return false; + } + // deal with terminal specific keybindings + if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) { + const p = navigator.clipboard.readText(); + p.then((text) => { + this.termRef.current?.terminal.paste(text); + }); + event.preventDefault(); + event.stopPropagation(); + return false; + } else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) { + const sel = this.termRef.current?.terminal.getSelection(); + navigator.clipboard.writeText(sel); + event.preventDefault(); + event.stopPropagation(); + return false; + } + if (this.shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) { + // restart + const tabId = globalStore.get(atoms.staticTabId); + const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: this.blockId }); + prtn.catch((e) => console.log("error controller resync (enter)", this.blockId, e)); + return false; + } + const globalKeys = getAllGlobalKeyBindings(); + for (const key of globalKeys) { + if (keyutil.checkKeyPressed(waveEvent, key)) { + return false; + } + } + return true; + } + setTerminalTheme(themeName: string) { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), @@ -215,8 +210,8 @@ class TermViewModel { } } -function makeTerminalModel(blockId: string): TermViewModel { - return new TermViewModel(blockId); +function makeTerminalModel(blockId: string, nodeModel: NodeModel): TermViewModel { + return new TermViewModel(blockId, nodeModel); } interface TerminalViewProps { @@ -247,63 +242,22 @@ const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => }); const TerminalView = ({ blockId, model }: TerminalViewProps) => { - const viewRef = React.createRef(); + const viewRef = React.useRef(null); const connectElemRef = React.useRef(null); const termRef = React.useRef(null); model.termRef = termRef; - const shellProcStatusRef = React.useRef(null); - const htmlElemFocusRef = React.useRef(null); - model.htmlElemFocusRef = htmlElemFocusRef; + const spstatusRef = React.useRef(null); + model.shellProcStatusRef = spstatusRef; const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); const termSettingsAtom = useSettingsPrefixAtom("term"); const termSettings = jotai.useAtomValue(termSettingsAtom); + let termMode = blockData?.meta?.["term:mode"] ?? "term"; + if (termMode != "term" && termMode != "html") { + termMode = "term"; + } + const termModeRef = React.useRef(termMode); React.useEffect(() => { - function handleTerminalKeydown(event: KeyboardEvent): boolean { - const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); - if (waveEvent.type != "keydown") { - return true; - } - // deal with terminal specific keybindings - if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { - event.preventDefault(); - event.stopPropagation(); - RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", blockId), - meta: { "term:mode": null }, - }); - return false; - } - if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) { - const p = navigator.clipboard.readText(); - p.then((text) => { - termRef.current?.terminal.paste(text); - }); - event.preventDefault(); - event.stopPropagation(); - return false; - } else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) { - const sel = termRef.current?.terminal.getSelection(); - navigator.clipboard.writeText(sel); - event.preventDefault(); - event.stopPropagation(); - return false; - } - if (shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) { - // restart - 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; - } - const globalKeys = getAllGlobalKeyBindings(); - for (const key of globalKeys) { - if (keyutil.checkKeyPressed(waveEvent, key)) { - return false; - } - } - return true; - } const fullConfig = globalStore.get(atoms.fullConfigAtom); const termTheme = computeTheme(fullConfig, blockData?.meta?.["term:theme"]); const themeCopy = { ...termTheme }; @@ -335,7 +289,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { scrollback: termScrollback, }, { - keydownHandler: handleTerminalKeydown, + keydownHandler: model.handleTerminalKeydown.bind(model), useWebGl: !termSettings?.["term:disablewebgl"], } ); @@ -352,29 +306,13 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { }; }, [blockId, termSettings]); - const handleHtmlKeyDown = (event: React.KeyboardEvent) => { - const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); - if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { - // reset term:mode - RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", blockId), - meta: { "term:mode": null }, - }); - return false; + React.useEffect(() => { + if (termModeRef.current == "html" && termMode == "term") { + // focus the terminal + model.giveFocus(); } - const asciiVal = keyboardEventToASCII(event); - if (asciiVal.length == 0) { - return false; - } - const b64data = util.stringToBase64(asciiVal); - RpcApi.ControllerInputCommand(TabRpcClient, { blockid: blockId, inputdata64: b64data }); - return true; - }; - - let termMode = blockData?.meta?.["term:mode"] ?? "term"; - if (termMode != "term" && termMode != "html") { - termMode = "term"; - } + termModeRef.current = termMode; + }, [termMode]); // set intitial controller status, and then subscribe for updates React.useEffect(() => { @@ -382,7 +320,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { if (status == null) { return; } - shellProcStatusRef.current = status; + model.shellProcStatusRef.current = status; if (status == "running") { termRef.current?.setIsRunning(true); } else { @@ -418,26 +356,9 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
-
{ - if (htmlElemFocusRef.current != null) { - htmlElemFocusRef.current.focus(); - } - }} - > -
- {}} - /> -
+
- +
diff --git a/frontend/app/view/term/vdom-model.tsx b/frontend/app/view/term/vdom-model.tsx new file mode 100644 index 000000000..6eaf71858 --- /dev/null +++ b/frontend/app/view/term/vdom-model.tsx @@ -0,0 +1,528 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore, WOS } from "@/app/store/global"; +import { makeORef } from "@/app/store/wos"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { TermWshClient } from "@/app/view/term/term-wsh"; +import { NodeModel } from "@/layout/index"; +import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; +import debug from "debug"; +import * as jotai from "jotai"; + +const dlog = debug("wave:vdom"); + +type AtomContainer = { + val: any; + beVal: any; + usedBy: Set; +}; + +type RefContainer = { + refFn: (elem: HTMLElement) => void; + vdomRef: VDomRef; + elem: HTMLElement; + updated: boolean; +}; + +function makeVDomIdMap(vdom: VDomElem, idMap: Map) { + if (vdom == null) { + return; + } + if (vdom.waveid != null) { + idMap.set(vdom.waveid, vdom); + } + if (vdom.children == null) { + return; + } + for (let child of vdom.children) { + makeVDomIdMap(child, idMap); + } +} + +function convertEvent(e: React.SyntheticEvent, fromProp: string): any { + if (e == null) { + return null; + } + if (fromProp == "onClick") { + return { type: "click" }; + } + if (fromProp == "onKeyDown") { + const waveKeyEvent = adaptFromReactOrNativeKeyEvent(e as React.KeyboardEvent); + return waveKeyEvent; + } + if (fromProp == "onFocus") { + return { type: "focus" }; + } + if (fromProp == "onBlur") { + return { type: "blur" }; + } + return { type: "unknown" }; +} + +export class VDomModel { + blockId: string; + nodeModel: NodeModel; + viewRef: React.RefObject; + vdomRoot: jotai.PrimitiveAtom = jotai.atom(); + atoms: Map = new Map(); // key is atomname + refs: Map = new Map(); // key is refid + batchedEvents: VDomEvent[] = []; + messages: VDomMessage[] = []; + needsInitialization: boolean = true; + needsResync: boolean = true; + vdomNodeVersion: WeakMap> = new WeakMap(); + compoundAtoms: Map> = new Map(); + rootRefId: string = crypto.randomUUID(); + termWshClient: TermWshClient; + backendRoute: string; + backendOpts: VDomBackendOpts; + shouldDispose: boolean; + disposed: boolean; + hasPendingRequest: boolean; + needsUpdate: boolean; + maxNormalUpdateIntervalMs: number = 100; + needsImmediateUpdate: boolean; + lastUpdateTs: number = 0; + queuedUpdate: { timeoutId: any; ts: number; quick: boolean }; + + constructor( + blockId: string, + nodeModel: NodeModel, + viewRef: React.RefObject, + termWshClient: TermWshClient + ) { + this.blockId = blockId; + this.nodeModel = nodeModel; + this.viewRef = viewRef; + this.termWshClient = termWshClient; + this.reset(); + } + + reset() { + globalStore.set(this.vdomRoot, null); + this.atoms.clear(); + this.refs.clear(); + this.batchedEvents = []; + this.messages = []; + this.needsResync = true; + this.needsInitialization = true; + this.vdomNodeVersion = new WeakMap(); + this.compoundAtoms.clear(); + this.rootRefId = crypto.randomUUID(); + this.backendRoute = null; + this.backendOpts = {}; + this.shouldDispose = false; + this.disposed = false; + this.hasPendingRequest = false; + this.needsUpdate = false; + this.maxNormalUpdateIntervalMs = 100; + this.needsImmediateUpdate = false; + this.lastUpdateTs = 0; + this.queuedUpdate = null; + } + + globalKeydownHandler(e: WaveKeyboardEvent): boolean { + if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, "Ctrl:c")) { + this.shouldDispose = true; + this.queueUpdate(true); + return true; + } + if (this.backendOpts?.globalkeyboardevents) { + if (e.cmd || e.meta) { + return false; + } + this.batchedEvents.push({ + waveid: null, + propname: "onKeyDown", + eventdata: e, + }); + this.queueUpdate(); + return true; + } + return false; + } + + hasRefUpdates() { + for (let ref of this.refs.values()) { + if (ref.updated) { + return true; + } + } + return false; + } + + getRefUpdates(): VDomRefUpdate[] { + let updates: VDomRefUpdate[] = []; + for (let ref of this.refs.values()) { + if (ref.updated || (ref.vdomRef.trackposition && ref.elem != null)) { + const ru: VDomRefUpdate = { + refid: ref.vdomRef.refid, + hascurrent: ref.vdomRef.hascurrent, + }; + if (ref.vdomRef.trackposition && ref.elem != null) { + ru.position = { + offsetheight: ref.elem.offsetHeight, + offsetwidth: ref.elem.offsetWidth, + scrollheight: ref.elem.scrollHeight, + scrollwidth: ref.elem.scrollWidth, + scrolltop: ref.elem.scrollTop, + boundingclientrect: ref.elem.getBoundingClientRect(), + }; + } + updates.push(ru); + ref.updated = false; + } + } + return updates; + } + + queueUpdate(quick: boolean = false, delay: number = 10) { + this.needsUpdate = true; + let nowTs = Date.now(); + if (delay > this.maxNormalUpdateIntervalMs) { + delay = this.maxNormalUpdateIntervalMs; + } + if (quick) { + if (this.queuedUpdate) { + if (this.queuedUpdate.quick || this.queuedUpdate.ts <= nowTs) { + return; + } + clearTimeout(this.queuedUpdate.timeoutId); + this.queuedUpdate = null; + } + let timeoutId = setTimeout(() => { + this._sendRenderRequest(true); + }, 0); + this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs, quick: true }; + return; + } + if (this.queuedUpdate) { + return; + } + let lastUpdateDiff = nowTs - this.lastUpdateTs; + let timeoutMs: number = null; + if (lastUpdateDiff >= this.maxNormalUpdateIntervalMs) { + // it has been a while since the last update, so use delay + timeoutMs = delay; + } else { + timeoutMs = this.maxNormalUpdateIntervalMs - lastUpdateDiff; + } + if (timeoutMs < delay) { + timeoutMs = delay; + } + let timeoutId = setTimeout(() => { + this._sendRenderRequest(false); + }, timeoutMs); + this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs + timeoutMs, quick: false }; + } + + async _sendRenderRequest(force: boolean) { + this.queuedUpdate = null; + if (this.disposed) { + return; + } + if (this.hasPendingRequest) { + if (force) { + this.needsImmediateUpdate = true; + } + return; + } + if (!force && !this.needsUpdate) { + return; + } + if (this.backendRoute == null) { + console.log("vdom-model", "no backend route"); + return; + } + this.hasPendingRequest = true; + this.needsImmediateUpdate = false; + try { + const feUpdate = this.createFeUpdate(); + dlog("fe-update", feUpdate); + const beUpdate = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: this.backendRoute }); + this.handleBackendUpdate(beUpdate); + } finally { + this.lastUpdateTs = Date.now(); + this.hasPendingRequest = false; + } + if (this.needsImmediateUpdate) { + this.queueUpdate(true); + } + } + + getAtomContainer(atomName: string): AtomContainer { + let container = this.atoms.get(atomName); + if (container == null) { + container = { + val: null, + beVal: null, + usedBy: new Set(), + }; + this.atoms.set(atomName, container); + } + return container; + } + + getOrCreateRefContainer(vdomRef: VDomRef): RefContainer { + let container = this.refs.get(vdomRef.refid); + if (container == null) { + container = { + refFn: (elem: HTMLElement) => { + container.elem = elem; + const hasElem = elem != null; + if (vdomRef.hascurrent != hasElem) { + container.updated = true; + vdomRef.hascurrent = hasElem; + } + }, + vdomRef: vdomRef, + elem: null, + updated: false, + }; + this.refs.set(vdomRef.refid, container); + } + return container; + } + + tagUseAtoms(waveId: string, atomNames: Set) { + for (let atomName of atomNames) { + let container = this.getAtomContainer(atomName); + container.usedBy.add(waveId); + } + } + + tagUnuseAtoms(waveId: string, atomNames: Set) { + for (let atomName of atomNames) { + let container = this.getAtomContainer(atomName); + container.usedBy.delete(waveId); + } + } + + getVDomNodeVersionAtom(vdom: VDomElem) { + let atom = this.vdomNodeVersion.get(vdom); + if (atom == null) { + atom = jotai.atom(0); + this.vdomNodeVersion.set(vdom, atom); + } + return atom; + } + + incVDomNodeVersion(vdom: VDomElem) { + if (vdom == null) { + return; + } + const atom = this.getVDomNodeVersionAtom(vdom); + globalStore.set(atom, globalStore.get(atom) + 1); + } + + addErrorMessage(message: string) { + this.messages.push({ + messagetype: "error", + message: message, + }); + } + + handleRenderUpdates(update: VDomBackendUpdate, idMap: Map) { + if (!update.renderupdates) { + return; + } + for (let renderUpdate of update.renderupdates) { + if (renderUpdate.updatetype == "root") { + globalStore.set(this.vdomRoot, renderUpdate.vdom); + continue; + } + if (renderUpdate.updatetype == "append") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (parent.children == null) { + parent.children = []; + } + parent.children.push(renderUpdate.vdom); + this.incVDomNodeVersion(parent); + continue; + } + if (renderUpdate.updatetype == "replace") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) { + this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); + continue; + } + parent.children[renderUpdate.index] = renderUpdate.vdom; + this.incVDomNodeVersion(parent); + continue; + } + if (renderUpdate.updatetype == "remove") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) { + this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); + continue; + } + parent.children.splice(renderUpdate.index, 1); + this.incVDomNodeVersion(parent); + continue; + } + if (renderUpdate.updatetype == "insert") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (parent.children == null) { + parent.children = []; + } + if (renderUpdate.index < 0 || parent.children.length < renderUpdate.index) { + this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); + continue; + } + parent.children.splice(renderUpdate.index, 0, renderUpdate.vdom); + this.incVDomNodeVersion(parent); + continue; + } + this.addErrorMessage(`Unknown updatetype ${renderUpdate.updatetype}`); + } + } + + setAtomValue(atomName: string, value: any, fromBe: boolean, idMap: Map) { + dlog("setAtomValue", atomName, value, fromBe); + let container = this.getAtomContainer(atomName); + container.val = value; + if (fromBe) { + container.beVal = value; + } + for (let id of container.usedBy) { + this.incVDomNodeVersion(idMap.get(id)); + } + } + + handleStateSync(update: VDomBackendUpdate, idMap: Map) { + if (update.statesync == null) { + return; + } + for (let sync of update.statesync) { + this.setAtomValue(sync.atom, sync.value, true, idMap); + } + } + + getRefElem(refId: string): HTMLElement { + if (refId == this.rootRefId) { + return this.viewRef.current; + } + const ref = this.refs.get(refId); + return ref?.elem; + } + + handleRefOperations(update: VDomBackendUpdate, idMap: Map) { + if (update.refoperations == null) { + return; + } + for (let refOp of update.refoperations) { + const elem = this.getRefElem(refOp.refid); + if (elem == null) { + this.addErrorMessage(`Could not find ref with id ${refOp.refid}`); + continue; + } + if (refOp.op == "focus") { + if (elem == null) { + this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`); + continue; + } + try { + elem.focus(); + } catch (e) { + this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: ${e.message}`); + } + } else { + this.addErrorMessage(`Unknown ref operation ${refOp.refid} ${refOp.op}`); + } + } + } + + handleBackendUpdate(update: VDomBackendUpdate) { + if (update == null) { + return; + } + const idMap = new Map(); + const vdomRoot = globalStore.get(this.vdomRoot); + if (update.opts != null) { + this.backendOpts = update.opts; + } + makeVDomIdMap(vdomRoot, idMap); + this.handleRenderUpdates(update, idMap); + this.handleStateSync(update, idMap); + this.handleRefOperations(update, idMap); + if (update.messages) { + for (let message of update.messages) { + console.log("vdom-message", this.blockId, message.messagetype, message.message); + if (message.stacktrace) { + console.log("vdom-message-stacktrace", message.stacktrace); + } + } + } + } + + callVDomFunc(fnDecl: VDomFunc, e: any, compId: string, propName: string) { + const eventData = convertEvent(e, propName); + if (fnDecl.globalevent) { + const waveEvent: VDomEvent = { + waveid: null, + propname: fnDecl.globalevent, + eventdata: eventData, + }; + this.batchedEvents.push(waveEvent); + } else { + const vdomEvent: VDomEvent = { + waveid: compId, + propname: propName, + eventdata: eventData, + }; + this.batchedEvents.push(vdomEvent); + } + this.queueUpdate(); + } + + createFeUpdate(): VDomFrontendUpdate { + const blockORef = makeORef("block", this.blockId); + const blockAtom = WOS.getWaveObjectAtom(blockORef); + const blockData = globalStore.get(blockAtom); + const isBlockFocused = globalStore.get(this.nodeModel.isFocused); + const renderContext: VDomRenderContext = { + blockid: this.blockId, + focused: isBlockFocused, + width: this.viewRef?.current?.offsetWidth ?? 0, + height: this.viewRef?.current?.offsetHeight ?? 0, + rootrefid: this.rootRefId, + background: false, + }; + const feUpdate: VDomFrontendUpdate = { + type: "frontendupdate", + ts: Date.now(), + blockid: this.blockId, + initialize: this.needsInitialization, + rendercontext: renderContext, + dispose: this.shouldDispose, + resync: this.needsResync, + events: this.batchedEvents, + refupdates: this.getRefUpdates(), + }; + this.needsResync = false; + this.needsInitialization = false; + this.batchedEvents = []; + if (this.shouldDispose) { + this.disposed = true; + } + return feUpdate; + } +} diff --git a/frontend/app/view/term/vdom.tsx b/frontend/app/view/term/vdom.tsx index 4e87ed46d..718a392ab 100644 --- a/frontend/app/view/term/vdom.tsx +++ b/frontend/app/view/term/vdom.tsx @@ -1,9 +1,25 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { VDomModel } from "@/app/view/term/vdom-model"; +import { NodeModel } from "@/layout/index"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; +import { useAtomValueSafe } from "@/util/util"; +import debug from "debug"; +import * as jotai from "jotai"; import * as React from "react"; +const TextTag = "#text"; +const FragmentTag = "#fragment"; +const WaveTextTag = "wave:text"; +const WaveNullTag = "wave:null"; + +const VDomObjType_Ref = "ref"; +const VDomObjType_Binding = "binding"; +const VDomObjType_Func = "func"; + +const dlog = debug("wave:vdom"); + const AllowedTags: { [tagName: string]: boolean } = { div: true, b: true, @@ -30,38 +46,38 @@ const AllowedTags: { [tagName: string]: boolean } = { form: true, }; -function convertVDomFunc(fnDecl: VDomFuncType, compId: string, propName: string): (e: any) => void { +function convertVDomFunc(model: VDomModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void { return (e: any) => { if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) { let waveEvent = adaptFromReactOrNativeKeyEvent(e); - for (let keyDesc of fnDecl["#keys"]) { + for (let keyDesc of fnDecl.keys || []) { if (checkKeyPressed(waveEvent, keyDesc)) { e.preventDefault(); e.stopPropagation(); - callFunc(e, compId, propName); + model.callVDomFunc(fnDecl, e, compId, propName); return; } } return; } - if (fnDecl["#preventDefault"]) { + if (fnDecl.preventdefault) { e.preventDefault(); } - if (fnDecl["#stopPropagation"]) { + if (fnDecl.stoppropagation) { e.stopPropagation(); } - callFunc(e, compId, propName); + model.callVDomFunc(fnDecl, e, compId, propName); }; } -function convertElemToTag(elem: VDomElem): JSX.Element | string { +function convertElemToTag(elem: VDomElem, model: VDomModel): JSX.Element | string { if (elem == null) { return null; } - if (elem.tag == "#text") { + if (elem.tag == TextTag) { return elem.text; } - return React.createElement(VDomTag, { elem: elem, key: elem.id }); + return React.createElement(VDomTag, { key: elem.waveid, elem, model }); } function isObject(v: any): boolean { @@ -72,19 +88,35 @@ function isArray(v: any): boolean { return Array.isArray(v); } -function callFunc(e: any, compId: string, propName: string) { - console.log("callfunc", compId, propName); -} - -function updateRefFunc(elem: any, ref: VDomRefType) { - console.log("updateref", ref["#ref"], elem); -} - -function VDomTag({ elem }: { elem: VDomElem }) { - if (!AllowedTags[elem.tag]) { - return
{"Invalid Tag <" + elem.tag + ">"}
; +function resolveBinding(binding: VDomBinding, model: VDomModel): [any, string[]] { + const bindName = binding.bind; + if (bindName == null || bindName == "") { + return [null, []]; + } + // for now we only recognize $.[atomname] bindings + if (!bindName.startsWith("$.")) { + return [null, []]; + } + const atomName = bindName.substring(2); + if (atomName == "") { + return [null, []]; + } + const atom = model.getAtomContainer(atomName); + if (atom == null) { + return [null, []]; + } + return [atom.val, [atomName]]; +} + +type GenericPropsType = { [key: string]: any }; + +// returns props, and a set of atom keys used in the props +function convertProps(elem: VDomElem, model: VDomModel): [GenericPropsType, Set] { + let props: GenericPropsType = {}; + let atomKeys = new Set(); + if (elem.props == null) { + return [props, atomKeys]; } - let props = {}; for (let key in elem.props) { let val = elem.props[key]; if (val == null) { @@ -94,35 +126,149 @@ function VDomTag({ elem }: { elem: VDomElem }) { if (val == null) { continue; } - if (isObject(val) && "#ref" in val) { - props[key] = (elem: HTMLElement) => { - updateRefFunc(elem, val); - }; + if (isObject(val) && val.type == VDomObjType_Ref) { + const valRef = val as VDomRef; + const refContainer = model.getOrCreateRefContainer(valRef); + props[key] = refContainer.refFn; } continue; } - if (isObject(val) && "#func" in val) { - props[key] = convertVDomFunc(val, elem.id, key); + if (isObject(val) && val.type == VDomObjType_Func) { + const valFunc = val as VDomFunc; + props[key] = convertVDomFunc(model, valFunc, elem.waveid, key); continue; } + if (isObject(val) && val.type == VDomObjType_Binding) { + const [propVal, atomDeps] = resolveBinding(val as VDomBinding, model); + props[key] = propVal; + for (let atomDep of atomDeps) { + atomKeys.add(atomDep); + } + continue; + } + if (key == "style" && isObject(val)) { + // assuming the entire style prop wasn't bound, look through the individual keys and bind them + for (let styleKey in val) { + let styleVal = val[styleKey]; + if (isObject(styleVal) && styleVal.type == VDomObjType_Binding) { + const [stylePropVal, styleAtomDeps] = resolveBinding(styleVal as VDomBinding, model); + val[styleKey] = stylePropVal; + for (let styleAtomDep of styleAtomDeps) { + atomKeys.add(styleAtomDep); + } + } + } + // fallthrough to set props[key] = val + } + props[key] = val; } + return [props, atomKeys]; +} + +function convertChildren(elem: VDomElem, model: VDomModel): (string | JSX.Element)[] { let childrenComps: (string | JSX.Element)[] = []; - if (elem.children) { - for (let child of elem.children) { - if (child == null) { - continue; - } - childrenComps.push(convertElemToTag(child)); - } - } - if (elem.tag == "#fragment") { + if (elem.children == null) { return childrenComps; } + for (let child of elem.children) { + if (child == null) { + continue; + } + childrenComps.push(convertElemToTag(child, model)); + } + return childrenComps; +} + +function stringSetsEqual(set1: Set, set2: Set): boolean { + if (set1.size != set2.size) { + return false; + } + for (let elem of set1) { + if (!set2.has(elem)) { + return false; + } + } + return true; +} + +function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { + const version = jotai.useAtomValue(model.getVDomNodeVersionAtom(elem)); + const [oldAtomKeys, setOldAtomKeys] = React.useState>(new Set()); + let [props, atomKeys] = convertProps(elem, model); + React.useEffect(() => { + if (stringSetsEqual(atomKeys, oldAtomKeys)) { + return; + } + model.tagUnuseAtoms(elem.waveid, oldAtomKeys); + model.tagUseAtoms(elem.waveid, atomKeys); + setOldAtomKeys(atomKeys); + }, [atomKeys]); + React.useEffect(() => { + return () => { + model.tagUnuseAtoms(elem.waveid, oldAtomKeys); + }; + }, []); + + if (elem.tag == WaveNullTag) { + return null; + } + if (elem.tag == WaveTextTag) { + return props.text; + } + if (!AllowedTags[elem.tag]) { + return
{"Invalid Tag <" + elem.tag + ">"}
; + } + let childrenComps = convertChildren(elem, model); + dlog("children", childrenComps); + if (elem.tag == FragmentTag) { + return childrenComps; + } + props.key = "e-" + elem.waveid; return React.createElement(elem.tag, props, childrenComps); } -function VDomView({ rootNode }: { rootNode: VDomElem }) { - let rtn = convertElemToTag(rootNode); +function vdomText(text: string): VDomElem { + return { + tag: "#text", + text: text, + }; +} + +const testVDom: VDomElem = { + waveid: "testid1", + tag: "div", + children: [ + { + waveid: "testh1", + tag: "h1", + children: [vdomText("Hello World")], + }, + { + waveid: "testp", + tag: "p", + children: [vdomText("This is a paragraph (from VDOM)")], + }, + ], +}; + +function VDomView({ + blockId, + nodeModel, + viewRef, + model, +}: { + blockId: string; + nodeModel: NodeModel; + viewRef: React.RefObject; + model: VDomModel; +}) { + let rootNode = useAtomValueSafe(model?.vdomRoot); + if (!model || viewRef.current == null || rootNode == null) { + return null; + } + dlog("render", rootNode); + model.viewRef = viewRef; + let rtn = convertElemToTag(rootNode, model); return
{rtn}
; } diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index bc9d44164..22139cd81 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -274,6 +274,7 @@ declare global { getSettingsMenuItems?: () => ContextMenuItem[]; giveFocus?: () => boolean; keyDownHandler?: (e: WaveKeyboardEvent) => boolean; + dispose?: () => void; } type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error" | "installing"; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 740c38dd8..971bdc96a 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -192,6 +192,16 @@ declare global { count: number; }; + // vdom.DomRect + type DomRect = { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; + }; + // waveobj.FileDef type FileDef = { filetype?: string; @@ -324,6 +334,9 @@ declare global { "term:localshellpath"?: string; "term:localshellopts"?: string[]; "term:scrollback"?: number; + "vdom:*"?: boolean; + "vdom:initialized"?: boolean; + "vdom:correlationid"?: string; count?: number; }; @@ -588,27 +601,150 @@ declare global { checkboxstat?: boolean; }; - // vdom.Elem + // vdom.VDomAsyncInitiationRequest + type VDomAsyncInitiationRequest = { + type: "asyncinitiationrequest"; + ts: number; + blockid?: string; + }; + + // vdom.VDomBackendOpts + type VDomBackendOpts = { + closeonctrlc?: boolean; + globalkeyboardevents?: boolean; + }; + + // vdom.VDomBackendUpdate + type VDomBackendUpdate = { + type: "backendupdate"; + ts: number; + blockid: string; + opts?: VDomBackendOpts; + renderupdates?: VDomRenderUpdate[]; + statesync?: VDomStateSync[]; + refoperations?: VDomRefOperation[]; + messages?: VDomMessage[]; + }; + + // vdom.VDomBinding + type VDomBinding = { + type: "binding"; + bind: string; + }; + + // vdom.VDomCreateContext + type VDomCreateContext = { + type: "createcontext"; + ts: number; + meta?: MetaType; + newblock?: boolean; + persist?: boolean; + }; + + // vdom.VDomElem type VDomElem = { - id?: string; + waveid?: string; tag: string; props?: {[key: string]: any}; children?: VDomElem[]; text?: string; }; - // vdom.VDomFuncType - type VDomFuncType = { - #func: string; - #stopPropagation?: boolean; - #preventDefault?: boolean; - #keys?: string[]; + // vdom.VDomEvent + type VDomEvent = { + waveid: string; + propname: string; + eventdata: any; }; - // vdom.VDomRefType - type VDomRefType = { - #ref: string; - current: any; + // vdom.VDomFrontendUpdate + type VDomFrontendUpdate = { + type: "frontendupdate"; + ts: number; + blockid: string; + correlationid?: string; + initialize?: boolean; + dispose?: boolean; + resync?: boolean; + rendercontext?: VDomRenderContext; + events?: VDomEvent[]; + statesync?: VDomStateSync[]; + refupdates?: VDomRefUpdate[]; + messages?: VDomMessage[]; + }; + + // vdom.VDomFunc + type VDomFunc = { + type: "func"; + stoppropagation?: boolean; + preventdefault?: boolean; + globalevent?: string; + keys?: string[]; + }; + + // vdom.VDomMessage + type VDomMessage = { + messagetype: string; + message: string; + stacktrace?: string; + params?: any[]; + }; + + // vdom.VDomRef + type VDomRef = { + type: "ref"; + refid: string; + trackposition?: boolean; + position?: VDomRefPosition; + hascurrent?: boolean; + }; + + // vdom.VDomRefOperation + type VDomRefOperation = { + refid: string; + op: string; + params?: any[]; + }; + + // vdom.VDomRefPosition + type VDomRefPosition = { + offsetheight: number; + offsetwidth: number; + scrollheight: number; + scrollwidth: number; + scrolltop: number; + boundingclientrect: DomRect; + }; + + // vdom.VDomRefUpdate + type VDomRefUpdate = { + refid: string; + hascurrent: boolean; + position?: VDomRefPosition; + }; + + // vdom.VDomRenderContext + type VDomRenderContext = { + blockid: string; + focused: boolean; + width: number; + height: number; + rootrefid: string; + background?: boolean; + }; + + // vdom.VDomRenderUpdate + type VDomRenderUpdate = { + updatetype: "root"|"append"|"replace"|"remove"|"insert"; + waveid?: string; + vdom: VDomElem; + index?: number; + }; + + // vdom.VDomStateSync + type VDomStateSync = { + atom: string; + value: any; }; type WSCommandType = { diff --git a/frontend/util/keyutil.ts b/frontend/util/keyutil.ts index b1fb5ccdf..daf50840e 100644 --- a/frontend/util/keyutil.ts +++ b/frontend/util/keyutil.ts @@ -241,6 +241,56 @@ function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent { return rtn; } +const keyMap = { + Enter: "\r", + Backspace: "\x7f", + Tab: "\t", + Escape: "\x1b", + ArrowUp: "\x1b[A", + ArrowDown: "\x1b[B", + ArrowRight: "\x1b[C", + ArrowLeft: "\x1b[D", + Insert: "\x1b[2~", + Delete: "\x1b[3~", + Home: "\x1b[1~", + End: "\x1b[4~", + PageUp: "\x1b[5~", + PageDown: "\x1b[6~", +}; + +function keyboardEventToASCII(event: WaveKeyboardEvent): string { + // check modifiers + // if no modifiers are set, just send the key + if (!event.alt && !event.control && !event.meta) { + if (event.key == null || event.key == "") { + return ""; + } + if (keyMap[event.key] != null) { + return keyMap[event.key]; + } + if (event.key.length == 1) { + return event.key; + } else { + console.log("not sending keyboard event", event.key, event); + } + } + // if meta or alt is set, there is no ASCII representation + if (event.meta || event.alt) { + return ""; + } + // if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value + if (event.control) { + if ( + (event.key.length === 1 && event.key >= "A" && event.key <= "Z") || + (event.key >= "a" && event.key <= "z") + ) { + const key = event.key.toUpperCase(); + return String.fromCharCode(key.charCodeAt(0) - 64); + } + } + return ""; +} + export { adaptFromElectronKeyEvent, adaptFromReactOrNativeKeyEvent, @@ -248,6 +298,7 @@ export { getKeyUtilPlatform, isCharacterKeyEvent, isInputEvent, + keyboardEventToASCII, keydownWrapper, parseKeyDescription, setKeyUtilPlatform, diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index cbde00941..503e4e890 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -42,9 +42,13 @@ var ExtraTypes = []any{ wshutil.RpcMessage{}, wshrpc.WshServerCommandMeta{}, userinput.UserInputRequest{}, - vdom.Elem{}, - vdom.VDomFuncType{}, - vdom.VDomRefType{}, + vdom.VDomCreateContext{}, + vdom.VDomElem{}, + vdom.VDomFunc{}, + vdom.VDomRef{}, + vdom.VDomBinding{}, + vdom.VDomFrontendUpdate{}, + vdom.VDomBackendUpdate{}, waveobj.MetaTSType{}, } diff --git a/pkg/util/utilfn/compare.go b/pkg/util/utilfn/compare.go new file mode 100644 index 000000000..d9c96a24e --- /dev/null +++ b/pkg/util/utilfn/compare.go @@ -0,0 +1,89 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package utilfn + +import ( + "reflect" +) + +// this is a shallow equal, but with special handling for numeric types +// it will up convert to float64 and compare +func JsonValEqual(a, b any) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + typeA := reflect.TypeOf(a) + typeB := reflect.TypeOf(b) + if typeA == typeB && typeA.Comparable() { + return a == b + } + if IsNumericType(a) && IsNumericType(b) { + return CompareAsFloat64(a, b) + } + if typeA != typeB { + return false + } + // for slices and maps, compare their pointers + valA := reflect.ValueOf(a) + valB := reflect.ValueOf(b) + switch valA.Kind() { + case reflect.Slice, reflect.Map: + return valA.Pointer() == valB.Pointer() + } + return false +} + +// Helper to check if a value is a numeric type +func IsNumericType(val any) bool { + switch val.(type) { + case int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64: + return true + default: + return false + } +} + +// Helper to handle numeric comparisons as float64 +func CompareAsFloat64(a, b any) bool { + valA, okA := ToFloat64(a) + valB, okB := ToFloat64(b) + return okA && okB && valA == valB +} + +// Convert various numeric types to float64 for comparison +func ToFloat64(val any) (float64, bool) { + switch v := val.(type) { + case int: + return float64(v), true + case int8: + return float64(v), true + case int16: + return float64(v), true + case int32: + return float64(v), true + case int64: + return float64(v), true + case uint: + return float64(v), true + case uint8: + return float64(v), true + case uint16: + return float64(v), true + case uint32: + return float64(v), true + case uint64: + return float64(v), true + case float32: + return float64(v), true + case float64: + return v, true + default: + return 0, false + } +} diff --git a/pkg/vdom/cssparser/cssparser.go b/pkg/vdom/cssparser/cssparser.go new file mode 100644 index 000000000..2960d61d5 --- /dev/null +++ b/pkg/vdom/cssparser/cssparser.go @@ -0,0 +1,159 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cssparser + +import ( + "fmt" + "strings" + "unicode" +) + +type Parser struct { + Input string + Pos int + Length int + InQuote bool + QuoteChar rune + OpenParens int + Debug bool +} + +func MakeParser(input string) *Parser { + return &Parser{ + Input: input, + Length: len(input), + } +} + +func (p *Parser) Parse() (map[string]string, error) { + result := make(map[string]string) + lastProp := "" + for { + p.skipWhitespace() + if p.eof() { + break + } + propName, err := p.parseIdentifierColon(lastProp) + if err != nil { + return nil, err + } + lastProp = propName + p.skipWhitespace() + value, err := p.parseValue(propName) + if err != nil { + return nil, err + } + result[propName] = value + p.skipWhitespace() + if p.eof() { + break + } + if !p.expectChar(';') { + break + } + } + p.skipWhitespace() + if !p.eof() { + return nil, fmt.Errorf("bad style attribute, unexpected character %q at pos %d", string(p.Input[p.Pos]), p.Pos+1) + } + return result, nil +} + +func (p *Parser) parseIdentifierColon(lastProp string) (string, error) { + start := p.Pos + for !p.eof() { + c := p.peekChar() + if isIdentChar(c) || c == '-' { + p.advance() + } else { + break + } + } + attrName := p.Input[start:p.Pos] + p.skipWhitespace() + if p.eof() { + return "", fmt.Errorf("bad style attribute, expected colon after property %q, got EOF, at pos %d", attrName, p.Pos+1) + } + if attrName == "" { + return "", fmt.Errorf("bad style attribute, invalid property name after property %q, at pos %d", lastProp, p.Pos+1) + } + if !p.expectChar(':') { + return "", fmt.Errorf("bad style attribute, bad property name starting with %q, expected colon, got %q, at pos %d", attrName, string(p.Input[p.Pos]), p.Pos+1) + } + return attrName, nil +} + +func (p *Parser) parseValue(propName string) (string, error) { + start := p.Pos + quotePos := 0 + parenPosStack := make([]int, 0) + for !p.eof() { + c := p.peekChar() + if p.InQuote { + if c == p.QuoteChar { + p.InQuote = false + } else if c == '\\' { + p.advance() + } + } else { + if c == '"' || c == '\'' { + p.InQuote = true + p.QuoteChar = c + quotePos = p.Pos + } else if c == '(' { + p.OpenParens++ + parenPosStack = append(parenPosStack, p.Pos) + } else if c == ')' { + if p.OpenParens == 0 { + return "", fmt.Errorf("unmatched ')' at pos %d", p.Pos+1) + } + p.OpenParens-- + parenPosStack = parenPosStack[:len(parenPosStack)-1] + } else if c == ';' && p.OpenParens == 0 { + break + } + } + p.advance() + } + if p.eof() && p.InQuote { + return "", fmt.Errorf("bad style attribute, while parsing attribute %q, unmatched quote at pos %d", propName, quotePos+1) + } + if p.eof() && p.OpenParens > 0 { + return "", fmt.Errorf("bad style attribute, while parsing property %q, unmatched '(' at pos %d", propName, parenPosStack[len(parenPosStack)-1]+1) + } + return strings.TrimSpace(p.Input[start:p.Pos]), nil +} + +func isIdentChar(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) +} + +func (p *Parser) skipWhitespace() { + for !p.eof() && unicode.IsSpace(p.peekChar()) { + p.advance() + } +} + +func (p *Parser) expectChar(expected rune) bool { + if !p.eof() && p.peekChar() == expected { + p.advance() + return true + } + return false +} + +func (p *Parser) peekChar() rune { + if p.Pos >= p.Length { + return 0 + } + return rune(p.Input[p.Pos]) +} + +func (p *Parser) advance() { + p.Pos++ +} + +func (p *Parser) eof() bool { + return p.Pos >= p.Length +} diff --git a/pkg/vdom/cssparser/cssparser_test.go b/pkg/vdom/cssparser/cssparser_test.go new file mode 100644 index 000000000..669d05aa2 --- /dev/null +++ b/pkg/vdom/cssparser/cssparser_test.go @@ -0,0 +1,81 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cssparser + +import ( + "fmt" + "log" + "testing" +) + +func compareMaps(a, b map[string]string) error { + if len(a) != len(b) { + return fmt.Errorf("map length mismatch: %d != %d", len(a), len(b)) + } + for k, v := range a { + if b[k] != v { + return fmt.Errorf("value mismatch for key %s: %q != %q", k, v, b[k]) + } + } + return nil +} + +func TestParse1(t *testing.T) { + style := `background: url("example;with;semicolons.jpg"); color: red; margin-right: 5px; content: "hello;world";` + p := MakeParser(style) + parsed, err := p.Parse() + if err != nil { + t.Fatalf("Parse failed: %v", err) + return + } + expected := map[string]string{ + "background": `url("example;with;semicolons.jpg")`, + "color": "red", + "margin-right": "5px", + "content": `"hello;world"`, + } + if err := compareMaps(parsed, expected); err != nil { + t.Fatalf("Parsed map does not match expected: %v", err) + } + + style = `margin-right: calc(10px + 5px); color: red; font-family: "Arial";` + p = MakeParser(style) + parsed, err = p.Parse() + if err != nil { + t.Fatalf("Parse failed: %v", err) + return + } + expected = map[string]string{ + "margin-right": `calc(10px + 5px)`, + "color": "red", + "font-family": `"Arial"`, + } + if err := compareMaps(parsed, expected); err != nil { + t.Fatalf("Parsed map does not match expected: %v", err) + } +} + +func TestParserErrors(t *testing.T) { + style := `hello more: bad;` + p := MakeParser(style) + _, err := p.Parse() + if err == nil { + t.Fatalf("expected error, got nil") + } + log.Printf("got expected error: %v\n", err) + style = `background: url("example.jpg` + p = MakeParser(style) + _, err = p.Parse() + if err == nil { + t.Fatalf("expected error, got nil") + } + log.Printf("got expected error: %v\n", err) + style = `foo: url(...` + p = MakeParser(style) + _, err = p.Parse() + if err == nil { + t.Fatalf("expected error, got nil") + } + log.Printf("got expected error: %v\n", err) +} diff --git a/pkg/vdom/vdom.go b/pkg/vdom/vdom.go index 4cab277b7..0503c46b5 100644 --- a/pkg/vdom/vdom.go +++ b/pkg/vdom/vdom.go @@ -15,35 +15,6 @@ import ( // ReactNode types = nil | string | Elem -const TextTag = "#text" -const FragmentTag = "#fragment" - -const ChildrenPropKey = "children" -const KeyPropKey = "key" - -// doubles as VDOM structure -type Elem struct { - Id string `json:"id,omitempty"` // used for vdom - Tag string `json:"tag"` - Props map[string]any `json:"props,omitempty"` - Children []Elem `json:"children,omitempty"` - Text string `json:"text,omitempty"` -} - -type VDomRefType struct { - RefId string `json:"#ref"` - Current any `json:"current"` -} - -// can be used to set preventDefault/stopPropagation -type VDomFuncType struct { - Fn any `json:"-"` // the actual function to call (called via reflection) - FuncType string `json:"#func"` - StopPropagation bool `json:"#stopPropagation,omitempty"` - PreventDefault bool `json:"#preventDefault,omitempty"` - Keys []string `json:"#keys,omitempty"` // special for keyDown events a list of keys to "capture" -} - // generic hook structure type Hook struct { Init bool // is initialized @@ -56,7 +27,7 @@ type Hook struct { type CFunc = func(ctx context.Context, props map[string]any) any -func (e *Elem) Key() string { +func (e *VDomElem) Key() string { keyVal, ok := e.Props[KeyPropKey] if !ok { return "" @@ -68,8 +39,8 @@ func (e *Elem) Key() string { return "" } -func TextElem(text string) Elem { - return Elem{Tag: TextTag, Text: text} +func TextElem(text string) VDomElem { + return VDomElem{Tag: TextTag, Text: text} } func mergeProps(props *map[string]any, newProps map[string]any) { @@ -85,8 +56,8 @@ func mergeProps(props *map[string]any, newProps map[string]any) { } } -func E(tag string, parts ...any) *Elem { - rtn := &Elem{Tag: tag} +func E(tag string, parts ...any) *VDomElem { + rtn := &VDomElem{Tag: tag} for _, part := range parts { if part == nil { continue @@ -135,19 +106,44 @@ func UseState[T any](ctx context.Context, initialVal T) (T, func(T)) { } setVal := func(newVal T) { hookVal.Val = newVal - vc.Root.AddRenderWork(vc.Comp.Id) + vc.Root.AddRenderWork(vc.Comp.WaveId) } return rtnVal, setVal } -func UseRef(ctx context.Context, initialVal any) *VDomRefType { +func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) { vc, hookVal := getHookFromCtx(ctx) if !hookVal.Init { hookVal.Init = true - refId := vc.Comp.Id + ":" + strconv.Itoa(hookVal.Idx) - hookVal.Val = &VDomRefType{RefId: refId, Current: initialVal} + closedWaveId := vc.Comp.WaveId + hookVal.UnmountFn = func() { + atom := vc.Root.GetAtom(atomName) + delete(atom.UsedBy, closedWaveId) + } } - refVal, ok := hookVal.Val.(*VDomRefType) + atom := vc.Root.GetAtom(atomName) + atom.UsedBy[vc.Comp.WaveId] = true + atomVal, ok := atom.Val.(T) + if !ok { + panic(fmt.Sprintf("UseAtom %q value type mismatch (expected %T, got %T)", atomName, atomVal, atom.Val)) + } + setVal := func(newVal T) { + atom.Val = newVal + for waveId := range atom.UsedBy { + vc.Root.AddRenderWork(waveId) + } + } + return atomVal, setVal +} + +func UseVDomRef(ctx context.Context) *VDomRef { + vc, hookVal := getHookFromCtx(ctx) + if !hookVal.Init { + hookVal.Init = true + refId := vc.Comp.WaveId + ":" + strconv.Itoa(hookVal.Idx) + hookVal.Val = &VDomRef{Type: ObjectType_Ref, RefId: refId} + } + refVal, ok := hookVal.Val.(*VDomRef) if !ok { panic("UseRef hook value is not a ref (possible out of order or conditional hooks)") } @@ -159,7 +155,7 @@ func UseId(ctx context.Context) string { if vc == nil { panic("UseId must be called within a component (no context)") } - return vc.Comp.Id + return vc.Comp.WaveId } func depsEqual(deps1 []any, deps2 []any) bool { @@ -181,7 +177,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) { hookVal.Init = true hookVal.Fn = fn hookVal.Deps = deps - vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) + vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx) return } if depsEqual(hookVal.Deps, deps) { @@ -189,7 +185,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) { } hookVal.Fn = fn hookVal.Deps = deps - vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) + vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx) } func numToString[T any](value T) (string, bool) { @@ -207,24 +203,24 @@ func numToString[T any](value T) (string, bool) { } } -func partToElems(part any) []Elem { +func partToElems(part any) []VDomElem { if part == nil { return nil } switch part := part.(type) { case string: - return []Elem{TextElem(part)} - case *Elem: + return []VDomElem{TextElem(part)} + case *VDomElem: if part == nil { return nil } - return []Elem{*part} - case Elem: - return []Elem{part} - case []Elem: + return []VDomElem{*part} + case VDomElem: + return []VDomElem{part} + case []VDomElem: return part - case []*Elem: - var rtn []Elem + case []*VDomElem: + var rtn []VDomElem for _, e := range part { if e == nil { continue @@ -235,11 +231,11 @@ func partToElems(part any) []Elem { } sval, ok := numToString(part) if ok { - return []Elem{TextElem(sval)} + return []VDomElem{TextElem(sval)} } partVal := reflect.ValueOf(part) if partVal.Kind() == reflect.Slice { - var rtn []Elem + var rtn []VDomElem for i := 0; i < partVal.Len(); i++ { subPart := partVal.Index(i).Interface() rtn = append(rtn, partToElems(subPart)...) @@ -248,14 +244,14 @@ func partToElems(part any) []Elem { } stringer, ok := part.(fmt.Stringer) if ok { - return []Elem{TextElem(stringer.String())} + return []VDomElem{TextElem(stringer.String())} } jsonStr, jsonErr := json.Marshal(part) if jsonErr == nil { - return []Elem{TextElem(string(jsonStr))} + return []VDomElem{TextElem(string(jsonStr))} } typeText := "invalid:" + reflect.TypeOf(part).String() - return []Elem{TextElem(typeText)} + return []VDomElem{TextElem(typeText)} } func isWaveTag(tag string) bool { diff --git a/pkg/vdom/vdom_comp.go b/pkg/vdom/vdom_comp.go index 3b51701a5..118375b36 100644 --- a/pkg/vdom/vdom_comp.go +++ b/pkg/vdom/vdom_comp.go @@ -13,10 +13,10 @@ type ChildKey struct { } type Component struct { - Id string + WaveId string Tag string Key string - Elem *Elem + Elem *VDomElem Mounted bool // hooks diff --git a/pkg/vdom/vdom_html.go b/pkg/vdom/vdom_html.go index 6e8586d2e..ca06658d1 100644 --- a/pkg/vdom/vdom_html.go +++ b/pkg/vdom/vdom_html.go @@ -10,11 +10,18 @@ import ( "strings" "github.com/wavetermdev/htmltoken" + "github.com/wavetermdev/waveterm/pkg/vdom/cssparser" ) // can tokenize and bind HTML to Elems -func appendChildToStack(stack []*Elem, child *Elem) { +const Html_BindPrefix = "#bind:" +const Html_ParamPrefix = "#param:" +const Html_GlobalEventPrefix = "#globalevent" +const Html_BindParamTagName = "bindparam" +const Html_BindTagName = "bind" + +func appendChildToStack(stack []*VDomElem, child *VDomElem) { if child == nil { return } @@ -25,14 +32,14 @@ func appendChildToStack(stack []*Elem, child *Elem) { parent.Children = append(parent.Children, *child) } -func pushElemStack(stack []*Elem, elem *Elem) []*Elem { +func pushElemStack(stack []*VDomElem, elem *VDomElem) []*VDomElem { if elem == nil { return stack } return append(stack, elem) } -func popElemStack(stack []*Elem) []*Elem { +func popElemStack(stack []*VDomElem) []*VDomElem { if len(stack) <= 1 { return stack } @@ -41,14 +48,14 @@ func popElemStack(stack []*Elem) []*Elem { return stack[:len(stack)-1] } -func curElemTag(stack []*Elem) string { +func curElemTag(stack []*VDomElem) string { if len(stack) == 0 { return "" } return stack[len(stack)-1].Tag } -func finalizeStack(stack []*Elem) *Elem { +func finalizeStack(stack []*VDomElem) *VDomElem { if len(stack) == 0 { return nil } @@ -74,8 +81,38 @@ func getAttr(token htmltoken.Token, key string) string { return "" } -func tokenToElem(token htmltoken.Token, data map[string]any) *Elem { - elem := &Elem{Tag: token.Data} +func attrToProp(attrVal string, params map[string]any) any { + if strings.HasPrefix(attrVal, Html_ParamPrefix) { + bindKey := attrVal[len(Html_ParamPrefix):] + bindVal, ok := params[bindKey] + if !ok { + return nil + } + return bindVal + } + if strings.HasPrefix(attrVal, Html_BindPrefix) { + bindKey := attrVal[len(Html_BindPrefix):] + if bindKey == "" { + return nil + } + return &VDomBinding{Type: ObjectType_Binding, Bind: bindKey} + } + if strings.HasPrefix(attrVal, Html_GlobalEventPrefix) { + splitArr := strings.Split(attrVal, ":") + if len(splitArr) < 2 { + return nil + } + eventName := splitArr[1] + if eventName == "" { + return nil + } + return &VDomFunc{Type: ObjectType_Func, GlobalEvent: eventName} + } + return attrVal +} + +func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem { + elem := &VDomElem{Tag: token.Data} if len(token.Attr) > 0 { elem.Props = make(map[string]any) } @@ -83,16 +120,8 @@ func tokenToElem(token htmltoken.Token, data map[string]any) *Elem { if attr.Key == "" || attr.Val == "" { continue } - if strings.HasPrefix(attr.Val, "#bind:") { - bindKey := attr.Val[6:] - bindVal, ok := data[bindKey] - if !ok { - continue - } - elem.Props[attr.Key] = bindVal - continue - } - elem.Props[attr.Key] = attr.Val + propVal := attrToProp(attr.Val, params) + elem.Props[attr.Key] = propVal } return elem } @@ -177,12 +206,101 @@ func processTextStr(s string) string { return strings.TrimSpace(s) } -func Bind(htmlStr string, data map[string]any) *Elem { +func makePathStr(elemPath []string) string { + return strings.Join(elemPath, " ") +} + +func capitalizeAscii(s string) string { + if s == "" || s[0] < 'a' || s[0] > 'z' { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func toReactName(input string) string { + // Check for CSS custom properties (variables) which start with '--' + if strings.HasPrefix(input, "--") { + return input + } + parts := strings.Split(input, "-") + result := "" + index := 0 + if parts[0] == "" && len(parts) > 1 { + // handle vendor prefixes + prefix := parts[1] + if prefix == "ms" { + result += "ms" + } else { + result += capitalizeAscii(prefix) + } + index = 2 // Skip the empty string and prefix + } else { + result += parts[0] + index = 1 + } + // Convert remaining parts to CamelCase + for ; index < len(parts); index++ { + if parts[index] != "" { + result += capitalizeAscii(parts[index]) + } + } + return result +} + +func convertStyleToReactStyles(styleMap map[string]string, params map[string]any) map[string]any { + if len(styleMap) == 0 { + return nil + } + rtn := make(map[string]any) + for key, val := range styleMap { + rtn[toReactName(key)] = attrToProp(val, params) + } + return rtn +} + +func fixStyleAttribute(elem *VDomElem, params map[string]any, elemPath []string) error { + styleText, ok := elem.Props["style"].(string) + if !ok { + return nil + } + parser := cssparser.MakeParser(styleText) + m, err := parser.Parse() + if err != nil { + return fmt.Errorf("%v (at %s)", err, makePathStr(elemPath)) + } + elem.Props["style"] = convertStyleToReactStyles(m, params) + return nil +} + +func fixupStyleAttributes(elem *VDomElem, params map[string]any, elemPath []string) { + if elem == nil { + return + } + // call fixStyleAttribute, and walk children + elemCountMap := make(map[string]int) + if len(elemPath) == 0 { + elemPath = append(elemPath, elem.Tag) + } + fixStyleAttribute(elem, params, elemPath) + for i := range elem.Children { + child := &elem.Children[i] + elemCountMap[child.Tag]++ + subPath := child.Tag + if elemCountMap[child.Tag] > 1 { + subPath = fmt.Sprintf("%s[%d]", child.Tag, elemCountMap[child.Tag]) + } + elemPath = append(elemPath, subPath) + fixupStyleAttributes(&elem.Children[i], params, elemPath) + elemPath = elemPath[:len(elemPath)-1] + } +} + +func Bind(htmlStr string, params map[string]any) *VDomElem { htmlStr = processWhitespace(htmlStr) r := strings.NewReader(htmlStr) iter := htmltoken.NewTokenizer(r) - var elemStack []*Elem - elemStack = append(elemStack, &Elem{Tag: FragmentTag}) + var elemStack []*VDomElem + elemStack = append(elemStack, &VDomElem{Tag: FragmentTag}) var tokenErr error outer: for { @@ -190,15 +308,15 @@ outer: token := iter.Token() switch tokenType { case htmltoken.StartTagToken: - if token.Data == "bind" { - tokenErr = errors.New("bind tag must be self closing") + if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName { + tokenErr = errors.New("bind tags must be self closing") break outer } - elem := tokenToElem(token, data) + elem := tokenToElem(token, params) elemStack = pushElemStack(elemStack, elem) case htmltoken.EndTagToken: - if token.Data == "bind" { - tokenErr = errors.New("bind tag must be self closing") + if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName { + tokenErr = errors.New("bind tags must be self closing") break outer } if len(elemStack) <= 1 { @@ -211,16 +329,22 @@ outer: } elemStack = popElemStack(elemStack) case htmltoken.SelfClosingTagToken: - if token.Data == "bind" { + if token.Data == Html_BindParamTagName { keyAttr := getAttr(token, "key") - dataVal := data[keyAttr] + dataVal := params[keyAttr] elemList := partToElems(dataVal) for _, elem := range elemList { appendChildToStack(elemStack, &elem) } continue } - elem := tokenToElem(token, data) + if token.Data == Html_BindTagName { + keyAttr := getAttr(token, "key") + binding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr} + appendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{"text": binding}}) + continue + } + elem := tokenToElem(token, params) appendChildToStack(elemStack, elem) case htmltoken.TextToken: if token.Data == "" { @@ -249,5 +373,7 @@ outer: errTextElem := TextElem(tokenErr.Error()) appendChildToStack(elemStack, &errTextElem) } - return finalizeStack(elemStack) + rtn := finalizeStack(elemStack) + fixupStyleAttributes(rtn, params, nil) + return rtn } diff --git a/pkg/vdom/vdom_root.go b/pkg/vdom/vdom_root.go index 898ab61f8..acbe67fd0 100644 --- a/pkg/vdom/vdom_root.go +++ b/pkg/vdom/vdom_root.go @@ -10,6 +10,7 @@ import ( "reflect" "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) type vdomContextKeyType struct{} @@ -22,13 +23,20 @@ type VDomContextVal struct { HookIdx int } +type Atom struct { + Val any + Dirty bool + UsedBy map[string]bool // component waveid -> true +} + type RootElem struct { OuterCtx context.Context Root *Component CFuncs map[string]CFunc - CompMap map[string]*Component // component id -> component + CompMap map[string]*Component // component waveid -> component EffectWorkQueue []*EffectWorkElem NeedsRenderMap map[string]bool + Atoms map[string]*Atom } const ( @@ -57,9 +65,49 @@ func MakeRoot() *RootElem { Root: nil, CFuncs: make(map[string]CFunc), CompMap: make(map[string]*Component), + Atoms: make(map[string]*Atom), } } +func (r *RootElem) GetAtom(name string) *Atom { + atom, ok := r.Atoms[name] + if !ok { + atom = &Atom{UsedBy: make(map[string]bool)} + r.Atoms[name] = atom + } + return atom +} + +func (r *RootElem) GetAtomVal(name string) any { + atom := r.GetAtom(name) + return atom.Val +} + +func (r *RootElem) GetStateSync(full bool) []VDomStateSync { + stateSync := make([]VDomStateSync, 0) + for atomName, atom := range r.Atoms { + if atom.Dirty || full { + stateSync = append(stateSync, VDomStateSync{Atom: atomName, Value: atom.Val}) + atom.Dirty = false + } + } + return stateSync +} + +func (r *RootElem) SetAtomVal(name string, val any, markDirty bool) { + atom := r.GetAtom(name) + if !markDirty { + atom.Val = val + return + } + // try to avoid setting the value and marking as dirty if it's the "same" + if utilfn.JsonValEqual(val, atom.Val) { + return + } + atom.Val = val + atom.Dirty = true +} + func (r *RootElem) SetOuterCtx(ctx context.Context) { r.OuterCtx = ctx } @@ -68,30 +116,60 @@ func (r *RootElem) RegisterComponent(name string, cfunc CFunc) { r.CFuncs[name] = cfunc } -func (r *RootElem) Render(elem *Elem) { +func (r *RootElem) Render(elem *VDomElem) { log.Printf("Render %s\n", elem.Tag) r.render(elem, &r.Root) } -func (r *RootElem) Event(id string, propName string) { +func (vdf *VDomFunc) CallFn() { + if vdf.Fn == nil { + return + } + rval := reflect.ValueOf(vdf.Fn) + if rval.Kind() != reflect.Func { + return + } + rval.Call(nil) +} + +func callVDomFn(fnVal any, data any) { + if fnVal == nil { + return + } + fn := fnVal + if vdf, ok := fnVal.(*VDomFunc); ok { + fn = vdf.Fn + } + if fn == nil { + return + } + rval := reflect.ValueOf(fn) + if rval.Kind() != reflect.Func { + return + } + rtype := rval.Type() + if rtype.NumIn() == 0 { + rval.Call(nil) + return + } + if rtype.NumIn() == 1 { + rval.Call([]reflect.Value{reflect.ValueOf(data)}) + return + } +} + +func (r *RootElem) Event(id string, propName string, data any) { comp := r.CompMap[id] if comp == nil || comp.Elem == nil { return } fnVal := comp.Elem.Props[propName] - if fnVal == nil { - return - } - fn, ok := fnVal.(func()) - if !ok { - return - } - fn() + callVDomFn(fnVal, data) } // this will be called by the frontend to say the DOM has been mounted // it will eventually send any updated "refs" to the backend as well -func (r *RootElem) runWork() { +func (r *RootElem) RunWork() { workQueue := r.EffectWorkQueue r.EffectWorkQueue = nil // first, run effect cleanups @@ -123,7 +201,7 @@ func (r *RootElem) runWork() { } } -func (r *RootElem) render(elem *Elem, comp **Component) { +func (r *RootElem) render(elem *VDomElem, comp **Component) { if elem == nil || elem.Tag == "" { r.unmount(comp) return @@ -171,13 +249,13 @@ func (r *RootElem) unmount(comp **Component) { r.unmount(&child) } } - delete(r.CompMap, (*comp).Id) + delete(r.CompMap, (*comp).WaveId) *comp = nil } func (r *RootElem) createComp(tag string, key string, comp **Component) { - *comp = &Component{Id: uuid.New().String(), Tag: tag, Key: key} - r.CompMap[(*comp).Id] = *comp + *comp = &Component{WaveId: uuid.New().String(), Tag: tag, Key: key} + r.CompMap[(*comp).WaveId] = *comp } func (r *RootElem) renderText(text string, comp **Component) { @@ -186,7 +264,7 @@ func (r *RootElem) renderText(text string, comp **Component) { } } -func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Component { +func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*Component) []*Component { newChildren := make([]*Component, len(elems)) curCM := make(map[ChildKey]*Component) usedMap := make(map[*Component]bool) @@ -217,7 +295,7 @@ func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Com return newChildren } -func (r *RootElem) renderSimple(elem *Elem, comp **Component) { +func (r *RootElem) renderSimple(elem *VDomElem, comp **Component) { if (*comp).Comp != nil { r.unmount(&(*comp).Comp) } @@ -243,7 +321,7 @@ func getRenderContext(ctx context.Context) *VDomContextVal { return v.(*VDomContextVal) } -func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) { +func (r *RootElem) renderComponent(cfunc CFunc, elem *VDomElem, comp **Component) { if (*comp).Children != nil { for _, child := range (*comp).Children { r.unmount(&child) @@ -262,11 +340,11 @@ func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) { r.unmount(&(*comp).Comp) return } - var rtnElem *Elem + var rtnElem *VDomElem if len(rtnElemArr) == 1 { rtnElem = &rtnElemArr[0] } else { - rtnElem = &Elem{Tag: FragmentTag, Children: rtnElemArr} + rtnElem = &VDomElem{Tag: FragmentTag, Children: rtnElemArr} } r.render(rtnElem, &(*comp).Comp) } @@ -282,7 +360,7 @@ func convertPropsToVDom(props map[string]any) map[string]any { } val := reflect.ValueOf(v) if val.Kind() == reflect.Func { - vdomProps[k] = VDomFuncType{FuncType: "server"} + vdomProps[k] = VDomFunc{Type: ObjectType_Func} continue } vdomProps[k] = v @@ -290,8 +368,8 @@ func convertPropsToVDom(props map[string]any) map[string]any { return vdomProps } -func convertBaseToVDom(c *Component) *Elem { - elem := &Elem{Id: c.Id, Tag: c.Tag} +func convertBaseToVDom(c *Component) *VDomElem { + elem := &VDomElem{WaveId: c.WaveId, Tag: c.Tag} if c.Elem != nil { elem.Props = convertPropsToVDom(c.Elem.Props) } @@ -304,12 +382,12 @@ func convertBaseToVDom(c *Component) *Elem { return elem } -func convertToVDom(c *Component) *Elem { +func convertToVDom(c *Component) *VDomElem { if c == nil { return nil } if c.Tag == TextTag { - return &Elem{Tag: TextTag, Text: c.Text} + return &VDomElem{Tag: TextTag, Text: c.Text} } if isBaseTag(c.Tag) { return convertBaseToVDom(c) @@ -318,11 +396,11 @@ func convertToVDom(c *Component) *Elem { } } -func (r *RootElem) makeVDom(comp *Component) *Elem { +func (r *RootElem) makeVDom(comp *Component) *VDomElem { vdomElem := convertToVDom(comp) return vdomElem } -func (r *RootElem) MakeVDom() *Elem { +func (r *RootElem) MakeVDom() *VDomElem { return r.makeVDom(r.Root) } diff --git a/pkg/vdom/vdom_test.go b/pkg/vdom/vdom_test.go index 430e07ff3..2be63fa41 100644 --- a/pkg/vdom/vdom_test.go +++ b/pkg/vdom/vdom_test.go @@ -18,7 +18,7 @@ type TestContext struct { func Page(ctx context.Context, props map[string]any) any { clicked, setClicked := UseState(ctx, false) - var clickedDiv *Elem + var clickedDiv *VDomElem if clicked { clickedDiv = Bind(`
clicked
`, nil) } @@ -30,8 +30,8 @@ func Page(ctx context.Context, props map[string]any) any { `

hello world

- - + +
`, map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv}, @@ -39,7 +39,7 @@ func Page(ctx context.Context, props map[string]any) any { } func Button(ctx context.Context, props map[string]any) any { - ref := UseRef(ctx, nil) + ref := UseVDomRef(ctx) clName, setClName := UseState(ctx, "button") UseEffect(ctx, func() func() { fmt.Printf("Button useEffect\n") @@ -52,8 +52,8 @@ func Button(ctx context.Context, props map[string]any) any { testContext.ButtonId = compId } return Bind(` -
- +
+
`, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]}) } @@ -85,10 +85,10 @@ func Test1(t *testing.T) { t.Fatalf("root.Root is nil") } printVDom(root) - root.runWork() + root.RunWork() printVDom(root) - root.Event(testContext.ButtonId, "onClick") - root.runWork() + root.Event(testContext.ButtonId, "onClick", nil) + root.RunWork() printVDom(root) } @@ -111,8 +111,8 @@ func TestBind(t *testing.T) { elem = Bind(`

hello world

- - + +
`, nil) jsonBytes, _ = json.MarshalIndent(elem, "", " ") diff --git a/pkg/vdom/vdom_types.go b/pkg/vdom/vdom_types.go new file mode 100644 index 000000000..2230e8f21 --- /dev/null +++ b/pkg/vdom/vdom_types.go @@ -0,0 +1,195 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "time" + + "github.com/wavetermdev/waveterm/pkg/waveobj" +) + +const TextTag = "#text" +const WaveTextTag = "wave:text" +const WaveNullTag = "wave:null" +const FragmentTag = "#fragment" +const BindTag = "#bind" + +const ChildrenPropKey = "children" +const KeyPropKey = "key" + +const ObjectType_Ref = "ref" +const ObjectType_Binding = "binding" +const ObjectType_Func = "func" + +// vdom element +type VDomElem struct { + WaveId string `json:"waveid,omitempty"` // required, except for #text nodes + Tag string `json:"tag"` + Props map[string]any `json:"props,omitempty"` + Children []VDomElem `json:"children,omitempty"` + Text string `json:"text,omitempty"` +} + +//// protocol messages + +type VDomCreateContext struct { + Type string `json:"type" tstype:"\"createcontext\""` + Ts int64 `json:"ts"` + Meta waveobj.MetaMapType `json:"meta,omitempty"` + NewBlock bool `json:"newblock,omitempty"` + Persist bool `json:"persist,omitempty"` +} + +type VDomAsyncInitiationRequest struct { + Type string `json:"type" tstype:"\"asyncinitiationrequest\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid,omitempty"` +} + +func MakeAsyncInitiationRequest(blockId string) VDomAsyncInitiationRequest { + return VDomAsyncInitiationRequest{ + Type: "asyncinitiationrequest", + Ts: time.Now().UnixMilli(), + BlockId: blockId, + } +} + +type VDomFrontendUpdate struct { + Type string `json:"type" tstype:"\"frontendupdate\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid"` + CorrelationId string `json:"correlationid,omitempty"` + Initialize bool `json:"initialize,omitempty"` // initialize the app + Dispose bool `json:"dispose,omitempty"` // the vdom context was closed + Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads + RenderContext VDomRenderContext `json:"rendercontext,omitempty"` + Events []VDomEvent `json:"events,omitempty"` + StateSync []VDomStateSync `json:"statesync,omitempty"` + RefUpdates []VDomRefUpdate `json:"refupdates,omitempty"` + Messages []VDomMessage `json:"messages,omitempty"` +} + +type VDomBackendUpdate struct { + Type string `json:"type" tstype:"\"backendupdate\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid"` + Opts *VDomBackendOpts `json:"opts,omitempty"` + RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"` + StateSync []VDomStateSync `json:"statesync,omitempty"` + RefOperations []VDomRefOperation `json:"refoperations,omitempty"` + Messages []VDomMessage `json:"messages,omitempty"` +} + +///// prop types + +// used in props +type VDomBinding struct { + Type string `json:"type" tstype:"\"binding\""` + Bind string `json:"bind"` +} + +// used in props +type VDomFunc struct { + Fn any `json:"-"` // server side function (called with reflection) + Type string `json:"type" tstype:"\"func\""` + StopPropagation bool `json:"stoppropagation,omitempty"` + PreventDefault bool `json:"preventdefault,omitempty"` + GlobalEvent string `json:"globalevent,omitempty"` + Keys []string `json:"keys,omitempty"` // special for keyDown events a list of keys to "capture" +} + +// used in props +type VDomRef struct { + Type string `json:"type" tstype:"\"ref\""` + RefId string `json:"refid"` + TrackPosition bool `json:"trackposition,omitempty"` + Position *VDomRefPosition `json:"position,omitempty"` + HasCurrent bool `json:"hascurrent,omitempty"` +} + +type DomRect struct { + Top float64 `json:"top"` + Left float64 `json:"left"` + Right float64 `json:"right"` + Bottom float64 `json:"bottom"` + Width float64 `json:"width"` + Height float64 `json:"height"` +} + +type VDomRefPosition struct { + OffsetHeight int `json:"offsetheight"` + OffsetWidth int `json:"offsetwidth"` + ScrollHeight int `json:"scrollheight"` + ScrollWidth int `json:"scrollwidth"` + ScrollTop int `json:"scrolltop"` + BoundingClientRect DomRect `json:"boundingclientrect"` +} + +///// subbordinate protocol types + +type VDomEvent struct { + WaveId string `json:"waveid"` + PropName string `json:"propname"` + EventData any `json:"eventdata"` +} + +type VDomRenderContext struct { + BlockId string `json:"blockid"` + Focused bool `json:"focused"` + Width int `json:"width"` + Height int `json:"height"` + RootRefId string `json:"rootrefid"` + Background bool `json:"background,omitempty"` +} + +type VDomStateSync struct { + Atom string `json:"atom"` + Value any `json:"value"` +} + +type VDomRefUpdate struct { + RefId string `json:"refid"` + HasCurrent bool `json:"hascurrent"` + Position *VDomRefPosition `json:"position,omitempty"` +} + +type VDomBackendOpts struct { + CloseOnCtrlC bool `json:"closeonctrlc,omitempty"` + GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"` +} + +type VDomRenderUpdate struct { + UpdateType string `json:"updatetype" tstype:"\"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\""` + WaveId string `json:"waveid,omitempty"` + VDom VDomElem `json:"vdom"` + Index *int `json:"index,omitempty"` +} + +type VDomRefOperation struct { + RefId string `json:"refid"` + Op string `json:"op" tsype:"\"focus\""` + Params []any `json:"params,omitempty"` +} + +type VDomMessage struct { + MessageType string `json:"messagetype"` + Message string `json:"message"` + StackTrace string `json:"stacktrace,omitempty"` + Params []any `json:"params,omitempty"` +} + +// matches WaveKeyboardEvent +type VDomKeyboardEvent struct { + Type string `json:"type"` + Key string `json:"key"` + Code string `json:"code"` + Shift bool `json:"shift,omitempty"` + Control bool `json:"ctrl,omitempty"` + Alt bool `json:"alt,omitempty"` + Meta bool `json:"meta,omitempty"` + Cmd bool `json:"cmd,omitempty"` + Option bool `json:"option,omitempty"` + Repeat bool `json:"repeat,omitempty"` + Location int `json:"location,omitempty"` +} diff --git a/pkg/vdom/vdomclient/vdomclient.go b/pkg/vdom/vdomclient/vdomclient.go new file mode 100644 index 000000000..740802c1d --- /dev/null +++ b/pkg/vdom/vdomclient/vdomclient.go @@ -0,0 +1,199 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdomclient + +import ( + "context" + "fmt" + "log" + "os" + "sync" + "time" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +type Client struct { + Root *vdom.RootElem + RootElem *vdom.VDomElem + RpcClient *wshutil.WshRpc + RpcContext *wshrpc.RpcContext + ServerImpl *VDomServerImpl + IsDone bool + RouteId string + DoneReason string + DoneOnce *sync.Once + DoneCh chan struct{} + Opts vdom.VDomBackendOpts + GlobalEventHandler func(client *Client, event vdom.VDomEvent) +} + +type VDomServerImpl struct { + Client *Client + BlockId string +} + +func (*VDomServerImpl) WshServerImpl() {} + +func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) { + if feUpdate.Dispose { + log.Printf("got dispose from frontend\n") + impl.Client.doShutdown("got dispose from frontend") + return nil, nil + } + if impl.Client.IsDone { + return nil, nil + } + // set atoms + for _, ss := range feUpdate.StateSync { + impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false) + } + // run events + for _, event := range feUpdate.Events { + if event.WaveId == "" { + if impl.Client.GlobalEventHandler != nil { + impl.Client.GlobalEventHandler(impl.Client, event) + } + } else { + impl.Client.Root.Event(event.WaveId, event.PropName, event.EventData) + } + } + if feUpdate.Initialize || feUpdate.Resync { + return impl.Client.fullRender() + } + return impl.Client.incrementalRender() +} + +func (c *Client) doShutdown(reason string) { + c.DoneOnce.Do(func() { + c.DoneReason = reason + c.IsDone = true + close(c.DoneCh) + }) +} + +func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) { + c.GlobalEventHandler = handler +} + +func MakeClient(opts *vdom.VDomBackendOpts) (*Client, error) { + client := &Client{ + Root: vdom.MakeRoot(), + DoneCh: make(chan struct{}), + DoneOnce: &sync.Once{}, + } + if opts != nil { + client.Opts = *opts + } + jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) + if jwtToken == "" { + return nil, fmt.Errorf("no %s env var set", wshutil.WaveJwtTokenVarName) + } + rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken) + if err != nil { + return nil, fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err) + } + client.RpcContext = rpcCtx + if client.RpcContext == nil || client.RpcContext.BlockId == "" { + return nil, fmt.Errorf("no block id in rpc context") + } + client.ServerImpl = &VDomServerImpl{BlockId: client.RpcContext.BlockId, Client: client} + sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) + if err != nil { + return nil, fmt.Errorf("error extracting socket name from %s: %v", wshutil.WaveJwtTokenVarName, err) + } + rpcClient, err := wshutil.SetupDomainSocketRpcClient(sockName, client.ServerImpl) + if err != nil { + return nil, fmt.Errorf("error setting up domain socket rpc client: %v", err) + } + client.RpcClient = rpcClient + authRtn, err := wshclient.AuthenticateCommand(client.RpcClient, jwtToken, &wshrpc.RpcOpts{NoResponse: true}) + if err != nil { + return nil, fmt.Errorf("error authenticating rpc connection: %v", err) + } + client.RouteId = authRtn.RouteId + return client, nil +} + +func (c *Client) SetRootElem(elem *vdom.VDomElem) { + c.RootElem = elem +} + +func (c *Client) CreateVDomContext() error { + err := wshclient.VDomCreateContextCommand(c.RpcClient, vdom.VDomCreateContext{}, &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)}) + if err != nil { + return err + } + wshclient.EventSubCommand(c.RpcClient, wps.SubscriptionRequest{Event: "blockclose", Scopes: []string{ + waveobj.MakeORef("block", c.RpcContext.BlockId).String(), + }}, nil) + c.RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) { + c.doShutdown("got blockclose event") + }) + return nil +} + +func (c *Client) SendAsyncInitiation() { + wshclient.VDomAsyncInitiationCommand(c.RpcClient, vdom.MakeAsyncInitiationRequest(c.RpcContext.BlockId), &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)}) +} + +func (c *Client) SetAtomVals(m map[string]any) { + for k, v := range m { + c.Root.SetAtomVal(k, v, true) + } +} + +func (c *Client) SetAtomVal(name string, val any) { + c.Root.SetAtomVal(name, val, true) +} + +func (c *Client) GetAtomVal(name string) any { + return c.Root.GetAtomVal(name) +} + +func makeNullVDom() *vdom.VDomElem { + return &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} +} + +func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) { + c.Root.RunWork() + c.Root.Render(c.RootElem) + renderedVDom := c.Root.MakeVDom() + if renderedVDom == nil { + renderedVDom = makeNullVDom() + } + return &vdom.VDomBackendUpdate{ + Type: "backendupdate", + Ts: time.Now().UnixMilli(), + BlockId: c.RpcContext.BlockId, + Opts: &c.Opts, + RenderUpdates: []vdom.VDomRenderUpdate{ + {UpdateType: "root", VDom: *renderedVDom}, + }, + StateSync: c.Root.GetStateSync(true), + }, nil +} + +func (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) { + c.Root.RunWork() + renderedVDom := c.Root.MakeVDom() + if renderedVDom == nil { + renderedVDom = makeNullVDom() + } + return &vdom.VDomBackendUpdate{ + Type: "backendupdate", + Ts: time.Now().UnixMilli(), + BlockId: c.RpcContext.BlockId, + RenderUpdates: []vdom.VDomRenderUpdate{ + {UpdateType: "root", VDom: *renderedVDom}, + }, + StateSync: c.Root.GetStateSync(false), + }, nil +} diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 41fdce4dd..21fa3b2f0 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -78,6 +78,10 @@ const ( MetaKey_TermLocalShellOpts = "term:localshellopts" MetaKey_TermScrollback = "term:scrollback" + MetaKey_VDomClear = "vdom:*" + MetaKey_VDomInitialized = "vdom:initialized" + MetaKey_VDomCorrelationId = "vdom:correlationid" + MetaKey_Count = "count" ) diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 2b52e897a..d6dc35f59 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -79,6 +79,10 @@ type MetaTSType struct { TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` // matches settings TermScrollback *int `json:"term:scrollback,omitempty"` + VDomClear bool `json:"vdom:*,omitempty"` + VDomInitialized bool `json:"vdom:initialized,omitempty"` + VDomCorrelationId string `json:"vdom:correlationid,omitempty"` + Count int `json:"count,omitempty"` // temp for cpu plot. will remove later } diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index d925f3eeb..d0e6d4202 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -11,6 +11,7 @@ const ( Event_BlockFile = "blockfile" Event_Config = "config" Event_UserInput = "userinput" + Event_RouteGone = "route:gone" ) type WaveEvent struct { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 382f1041a..e8743ade3 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -11,6 +11,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/vdom" ) // command "authenticate", wshserver.AuthenticateCommand @@ -260,6 +261,24 @@ func TestCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { return err } +// command "vdomasyncinitiation", wshserver.VDomAsyncInitiationCommand +func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiationRequest, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "vdomasyncinitiation", data, opts) + return err +} + +// command "vdomcreatecontext", wshserver.VDomCreateContextCommand +func VDomCreateContextCommand(w *wshutil.WshRpc, data vdom.VDomCreateContext, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "vdomcreatecontext", data, opts) + return err +} + +// command "vdomrender", wshserver.VDomRenderCommand +func VDomRenderCommand(w *wshutil.WshRpc, data vdom.VDomFrontendUpdate, opts *wshrpc.RpcOpts) (*vdom.VDomBackendUpdate, error) { + resp, err := sendRpcRequestCallHelper[*vdom.VDomBackendUpdate](w, "vdomrender", data, opts) + return resp, err +} + // command "webselector", wshserver.WebSelectorCommand func WebSelectorCommand(w *wshutil.WshRpc, data wshrpc.CommandWebSelectorData, opts *wshrpc.RpcOpts) ([]string, error) { resp, err := sendRpcRequestCallHelper[[]string](w, "webselector", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index fbd06a27d..55dbc18ce 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -11,6 +11,7 @@ import ( "reflect" "github.com/wavetermdev/waveterm/pkg/ijson" + "github.com/wavetermdev/waveterm/pkg/vdom" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" @@ -69,6 +70,10 @@ const ( Command_WebSelector = "webselector" Command_Notify = "notify" + + Command_VDomCreateContext = "vdomcreatecontext" + Command_VDomAsyncInitiation = "vdomasyncinitiation" + Command_VDomRender = "vdomrender" ) type RespOrErrorUnion[T any] struct { @@ -126,8 +131,16 @@ type WshRpcInterface interface { RemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error) RemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData] + // emain WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) NotifyCommand(ctx context.Context, notificationOptions WaveNotificationOptions) error + + // terminal + VDomCreateContextCommand(ctx context.Context, data vdom.VDomCreateContext) error + VDomAsyncInitiationCommand(ctx context.Context, data vdom.VDomAsyncInitiationRequest) error + + // proc + VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) } // for frontend diff --git a/pkg/wshutil/wshrouter.go b/pkg/wshutil/wshrouter.go index 63a53ef0d..5a30ac6e2 100644 --- a/pkg/wshutil/wshrouter.go +++ b/pkg/wshutil/wshrouter.go @@ -60,6 +60,10 @@ func MakeTabRouteId(tabId string) string { return "tab:" + tabId } +func MakeFeBlockRouteId(blockId string) string { + return "feblock:" + blockId +} + var DefaultRouter = NewWshRouter() func NewWshRouter() *WshRouter { @@ -322,6 +326,7 @@ func (router *WshRouter) UnregisterRoute(routeId string) { } go func() { wps.Broker.UnsubscribeAll(routeId) + wps.Broker.Publish(wps.WaveEvent{Event: wps.Event_RouteGone, Scopes: []string{routeId}}) }() } From 7eeb6e89f5e87897273be9886caf189881890838 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Thu, 17 Oct 2024 14:52:51 -0700 Subject: [PATCH 03/14] fix expect-error in viewmgr --- emain/emain-viewmgr.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/emain/emain-viewmgr.ts b/emain/emain-viewmgr.ts index 3a9065186..0afe7a0f1 100644 --- a/emain/emain-viewmgr.ts +++ b/emain/emain-viewmgr.ts @@ -401,6 +401,7 @@ function createBaseWaveBrowserWindow( win.alreadyClosed = false; win.allTabViews = new Map(); win.on( + // @ts-expect-error "resize", debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) ); @@ -411,6 +412,7 @@ function createBaseWaveBrowserWindow( positionTabOnScreen(win.activeTabView, win.getContentBounds()); }); win.on( + // @ts-expect-error "move", debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) ); From 3c94669d93ff2a31927da4b7ac8cd12405a19745 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 18 Oct 2024 17:07:44 -0700 Subject: [PATCH 04/14] metakeyatom and overrideconfigatom (#1078) --- frontend/app/store/global.ts | 59 +++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 9be2262a4..54830fb20 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -219,8 +219,56 @@ function useBlockCache(blockId: string, name: string, makeFn: () => T): T { return value as T; } +function getBlockMetaKeyAtom(blockId: string, key: T): Atom { + const blockCache = getSingleBlockAtomCache(blockId); + const metaAtomName = "#meta-" + key; + let metaAtom = blockCache.get(metaAtomName); + if (metaAtom != null) { + return metaAtom; + } + metaAtom = atom((get) => { + let blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); + let blockData = get(blockAtom); + return blockData?.meta?.[key]; + }); + blockCache.set(metaAtomName, metaAtom); + return metaAtom; +} + +function useBlockMetaKeyAtom(blockId: string, key: T): MetaType[T] { + return useAtomValue(getBlockMetaKeyAtom(blockId, key)); +} + const settingsAtomCache = new Map>(); +function makeOverrideConfigAtom(blockId: string, key: T): Atom { + const blockCache = getSingleBlockAtomCache(blockId); + const overrideAtomName = "#settingsoverride-" + key; + let overrideAtom = blockCache.get(overrideAtomName); + if (overrideAtom != null) { + return overrideAtom; + } + overrideAtom = atom((get) => { + const blockMetaKeyAtom = getBlockMetaKeyAtom(blockId, key as any); + const metaKeyVal = get(blockMetaKeyAtom); + if (metaKeyVal != null) { + return metaKeyVal; + } + const settingsKeyAtom = getSettingsKeyAtom(key); + const settingsVal = get(settingsKeyAtom); + if (settingsVal != null) { + return settingsVal; + } + return null; + }); + blockCache.set(overrideAtomName, overrideAtom); + return overrideAtom; +} + +function useOverrideConfigAtom(blockId: string, key: T): SettingsType[T] { + return useAtomValue(makeOverrideConfigAtom(blockId, key)); +} + function getSettingsKeyAtom(key: T): Atom { let settingsKeyAtom = settingsAtomCache.get(key) as Atom; if (settingsKeyAtom == null) { @@ -254,12 +302,17 @@ function useSettingsPrefixAtom(prefix: string): Atom { const blockAtomCache = new Map>>(); -function useBlockAtom(blockId: string, name: string, makeFn: () => Atom): Atom { +function getSingleBlockAtomCache(blockId: string): Map> { let blockCache = blockAtomCache.get(blockId); if (blockCache == null) { blockCache = new Map>(); blockAtomCache.set(blockId, blockCache); } + return blockCache; +} + +function useBlockAtom(blockId: string, name: string, makeFn: () => Atom): Atom { + const blockCache = getSingleBlockAtomCache(blockId); let atom = blockCache.get(name); if (atom == null) { atom = makeFn(); @@ -527,6 +580,7 @@ export { fetchWaveFile, getApi, getBlockComponentModel, + getBlockMetaKeyAtom, getConnStatusAtom, getHostName, getObjectId, @@ -537,6 +591,7 @@ export { initGlobalWaveEventSubs, isDev, loadConnStatus, + makeOverrideConfigAtom, openLink, PLATFORM, pushFlashError, @@ -550,6 +605,8 @@ export { useBlockAtom, useBlockCache, useBlockDataLoaded, + useBlockMetaKeyAtom, + useOverrideConfigAtom, useSettingsPrefixAtom, WOS, }; From 613a5835134c7d6ee28069f0405d7ce828013079 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 18 Oct 2024 17:21:18 -0700 Subject: [PATCH 05/14] add the use version of settingskeyatom --- frontend/app/store/global.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 54830fb20..4439efd93 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -284,6 +284,10 @@ function getSettingsKeyAtom(key: T): Atom(key: T): SettingsType[T] { + return useAtomValue(getSettingsKeyAtom(key)); +} + function useSettingsPrefixAtom(prefix: string): Atom { // TODO: use a shallow equal here to make this more efficient let settingsPrefixAtom = settingsAtomCache.get(prefix + ":") as Atom; @@ -607,6 +611,7 @@ export { useBlockDataLoaded, useBlockMetaKeyAtom, useOverrideConfigAtom, + useSettingsKeyAtom, useSettingsPrefixAtom, WOS, }; From 39fff9ecfd2e66090fb209702cdd4cc1af79a47d Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 21 Oct 2024 16:51:18 -0700 Subject: [PATCH 06/14] Allow separate directories for each config part, add dropdown for editing AI presets (#1074) Adds new functionality on the backend that will merge any file from the config directory that matches `.json` or `/*.json` into the corresponding config part (presets, termthemes, etc.). This lets us separate the AI presets into `presets/ai.json` so that we can add a dropdown in the AI preset selector that will directly open the file so a user can edit it more easily. Right now, this will create a preview block in the layout, but in the future we can look into making this block disconnected from the layout. If you put AI presets in the regular presets.json file, it will still work, since all the presets get merged. Same for any other config part. --- cmd/server/main-server.go | 9 +- emain/platform.ts | 37 ++++--- emain/preload.ts | 1 + frontend/app/store/global.ts | 1 - frontend/app/view/waveai/waveai.tsx | 49 ++++++--- frontend/types/custom.d.ts | 1 + pkg/wavebase/wavebase.go | 4 - pkg/wconfig/defaultconfig/defaultconfig.go | 2 +- pkg/wconfig/defaultconfig/presets.json | 18 --- pkg/wconfig/defaultconfig/presets/ai.json | 20 ++++ pkg/wconfig/filewatcher.go | 11 +- pkg/wconfig/settingsconfig.go | 121 ++++++++++++++++++--- 12 files changed, 199 insertions(+), 75 deletions(-) create mode 100644 pkg/wconfig/defaultconfig/presets/ai.json diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 6f149ff0c..5ab88bd59 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -192,11 +192,18 @@ func main() { log.Printf("error ensuring wave db dir: %v\n", err) return } - err = wavebase.EnsureWaveConfigDir() + err = wconfig.EnsureWaveConfigDir() if err != nil { log.Printf("error ensuring wave config dir: %v\n", err) return } + + // TODO: rather than ensure this dir exists, we should let the editor recursively create parent dirs on save + err = wconfig.EnsureWavePresetsDir() + if err != nil { + log.Printf("error ensuring wave presets dir: %v\n", err) + return + } waveLock, err := wavebase.AcquireWaveLock() if err != nil { log.Printf("error acquiring wave lock (another instance of Wave is likely running): %v\n", err) diff --git a/emain/platform.ts b/emain/platform.ts index 1d6d06076..36089982e 100644 --- a/emain/platform.ts +++ b/emain/platform.ts @@ -23,23 +23,6 @@ const unamePlatform = process.platform; const unameArch: string = process.arch; keyutil.setKeyUtilPlatform(unamePlatform); -ipcMain.on("get-is-dev", (event) => { - event.returnValue = isDev; -}); -ipcMain.on("get-platform", (event, url) => { - event.returnValue = unamePlatform; -}); -ipcMain.on("get-user-name", (event) => { - const userInfo = os.userInfo(); - event.returnValue = userInfo.username; -}); -ipcMain.on("get-host-name", (event) => { - event.returnValue = os.hostname(); -}); -ipcMain.on("get-webview-preload", (event) => { - event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs"); -}); - // must match golang function getWaveHomeDir() { const override = process.env[WaveHomeVarName]; @@ -72,6 +55,26 @@ function getWaveSrvCwd(): string { return getWaveHomeDir(); } +ipcMain.on("get-is-dev", (event) => { + event.returnValue = isDev; +}); +ipcMain.on("get-platform", (event, url) => { + event.returnValue = unamePlatform; +}); +ipcMain.on("get-user-name", (event) => { + const userInfo = os.userInfo(); + event.returnValue = userInfo.username; +}); +ipcMain.on("get-host-name", (event) => { + event.returnValue = os.hostname(); +}); +ipcMain.on("get-webview-preload", (event) => { + event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs"); +}); +ipcMain.on("get-config-dir", (event) => { + event.returnValue = path.join(getWaveHomeDir(), "config"); +}); + export { getElectronAppBasePath, getElectronAppUnpackedBasePath, diff --git a/emain/preload.ts b/emain/preload.ts index 6b3e2317d..ba7027c7e 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -10,6 +10,7 @@ contextBridge.exposeInMainWorld("api", { getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"), getUserName: () => ipcRenderer.sendSync("get-user-name"), getHostName: () => ipcRenderer.sendSync("get-host-name"), + getConfigDir: () => ipcRenderer.sendSync("get-config-dir"), getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"), getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"), getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"), diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 4439efd93..bcc29d3c9 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -63,7 +63,6 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { // do nothing } - const showAboutModalAtom = atom(false) as PrimitiveAtom; try { getApi().onMenuItemAbout(() => { modalsModel.pushModal("AboutModal"); diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index c88d35b7c..7c100542d 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -6,7 +6,7 @@ import { Markdown } from "@/app/element/markdown"; import { TypingIndicator } from "@/app/element/typingindicator"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { atoms, fetchWaveFile, globalStore, WOS } from "@/store/global"; +import { atoms, createBlock, fetchWaveFile, getApi, globalStore, WOS } from "@/store/global"; import { BlockService, ObjectService } from "@/store/services"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget, isBlank, makeIconClass } from "@/util/util"; @@ -182,26 +182,41 @@ export class WaveAiModel implements ViewModel { }); } } - + const dropdownItems = Object.entries(presets) + .sort((a, b) => (a[1]["display:order"] > b[1]["display:order"] ? 1 : -1)) + .map( + (preset) => + ({ + label: preset[1]["display:name"], + onClick: () => + fireAndForget(async () => { + await ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { + ...preset[1], + "ai:preset": preset[0], + }); + }), + }) as MenuItem + ); + dropdownItems.push({ + label: "Add AI preset...", + onClick: () => { + fireAndForget(async () => { + const path = `${getApi().getConfigDir()}/presets/ai.json`; + const blockDef: BlockDef = { + meta: { + view: "preview", + file: path, + }, + }; + await createBlock(blockDef, true); + }); + }, + }); viewTextChildren.push({ elemtype: "menubutton", text: presetName, title: "Select AI Configuration", - items: Object.entries(presets) - .sort((a, b) => (a[1]["display:order"] > b[1]["display:order"] ? 1 : -1)) - .map( - (preset) => - ({ - label: preset[1]["display:name"], - onClick: () => - fireAndForget(async () => { - await ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { - ...preset[1], - "ai:preset": preset[0], - }); - }), - }) as MenuItem - ), + items: dropdownItems, }); return viewTextChildren; }); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 6cd1c8e55..32d770b99 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -64,6 +64,7 @@ declare global { getEnv: (varName: string) => string; getUserName: () => string; getHostName: () => string; + getConfigDir: () => string; getWebviewPreload: () => string; getAboutModalDetails: () => AboutModalDetails; getDocsiteUrl: () => string; diff --git a/pkg/wavebase/wavebase.go b/pkg/wavebase/wavebase.go index fd0cb5738..da61e4a6f 100644 --- a/pkg/wavebase/wavebase.go +++ b/pkg/wavebase/wavebase.go @@ -119,10 +119,6 @@ func EnsureWaveDBDir() error { return CacheEnsureDir(filepath.Join(GetWaveHomeDir(), WaveDBDir), "wavedb", 0700, "wave db directory") } -func EnsureWaveConfigDir() error { - return CacheEnsureDir(filepath.Join(GetWaveHomeDir(), ConfigDir), "waveconfig", 0700, "wave config directory") -} - func CacheEnsureDir(dirName string, cacheKey string, perm os.FileMode, dirDesc string) error { baseLock.Lock() ok := ensureDirCache[cacheKey] diff --git a/pkg/wconfig/defaultconfig/defaultconfig.go b/pkg/wconfig/defaultconfig/defaultconfig.go index bc28a9557..9527a069c 100644 --- a/pkg/wconfig/defaultconfig/defaultconfig.go +++ b/pkg/wconfig/defaultconfig/defaultconfig.go @@ -5,5 +5,5 @@ package defaultconfig import "embed" -//go:embed *.json +//go:embed *.json all:*/*.json var ConfigFS embed.FS diff --git a/pkg/wconfig/defaultconfig/presets.json b/pkg/wconfig/defaultconfig/presets.json index 30dc05942..3f1a38135 100644 --- a/pkg/wconfig/defaultconfig/presets.json +++ b/pkg/wconfig/defaultconfig/presets.json @@ -94,23 +94,5 @@ "bg": "linear-gradient(120deg,hsla(350, 65%, 57%, 1),hsla(30,60%,60%, .75), hsla(208,69%,50%,.15), hsl(230,60%,40%)),radial-gradient(at top right,hsla(300,60%,70%,0.3),transparent),radial-gradient(at top left,hsla(330,100%,70%,.20),transparent),radial-gradient(at top right,hsla(190,100%,40%,.20),transparent),radial-gradient(at bottom left,hsla(323,54%,50%,.5),transparent),radial-gradient(at bottom left,hsla(144,54%,50%,.25),transparent)", "bg:blendmode": "overlay", "bg:text": "rgb(200, 200, 200)" - }, - "ai@global": { - "display:name": "Global default", - "display:order": -1, - "ai:*": true - }, - "ai@wave": { - "display:name": "Wave Proxy - gpt-4o-mini", - "display:order": 0, - "ai:*": true, - "ai:apitype": "", - "ai:baseurl": "", - "ai:apitoken": "", - "ai:name": "", - "ai:orgid": "", - "ai:model": "gpt-4o-mini", - "ai:maxtokens": 2048, - "ai:timeoutms": 60000 } } diff --git a/pkg/wconfig/defaultconfig/presets/ai.json b/pkg/wconfig/defaultconfig/presets/ai.json new file mode 100644 index 000000000..11c0b848e --- /dev/null +++ b/pkg/wconfig/defaultconfig/presets/ai.json @@ -0,0 +1,20 @@ +{ + "ai@global": { + "display:name": "Global default", + "display:order": -1, + "ai:*": true + }, + "ai@wave": { + "display:name": "Wave Proxy - gpt-4o-mini", + "display:order": 0, + "ai:*": true, + "ai:apitype": "", + "ai:baseurl": "", + "ai:apitoken": "", + "ai:name": "", + "ai:orgid": "", + "ai:model": "gpt-4o-mini", + "ai:maxtokens": 2048, + "ai:timeoutms": 60000 + } +} diff --git a/pkg/wconfig/filewatcher.go b/pkg/wconfig/filewatcher.go index f8ce9fd93..c8d344d62 100644 --- a/pkg/wconfig/filewatcher.go +++ b/pkg/wconfig/filewatcher.go @@ -40,8 +40,17 @@ func GetWatcher() *Watcher { } instance = &Watcher{watcher: watcher} err = instance.watcher.Add(configDirAbsPath) + const failedStr = "failed to add path %s to watcher: %v" if err != nil { - log.Printf("failed to add path %s to watcher: %v", configDirAbsPath, err) + log.Printf(failedStr, configDirAbsPath, err) + } + + subdirs := GetConfigSubdirs() + for _, dir := range subdirs { + err = instance.watcher.Add(dir) + if err != nil { + log.Printf(failedStr, dir, err) + } } }) return instance diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index cf9eeaee4..abcb22383 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -7,6 +7,8 @@ import ( "bytes" "encoding/json" "fmt" + "io/fs" + "log" "os" "path/filepath" "reflect" @@ -14,6 +16,7 @@ import ( "strings" "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig/defaultconfig" ) @@ -181,15 +184,19 @@ func readConfigHelper(fileName string, barr []byte, readErr error) (waveobj.Meta return rtn, cerrs } +var configDirFsys = os.DirFS(configDirAbsPath) + +func readConfigFileFS(fsys fs.FS, logPrefix string, fileName string) (waveobj.MetaMapType, []ConfigError) { + barr, readErr := fs.ReadFile(fsys, fileName) + return readConfigHelper(logPrefix+fileName, barr, readErr) +} + func ReadDefaultsConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) { - barr, readErr := defaultconfig.ConfigFS.ReadFile(fileName) - return readConfigHelper("defaults:"+fileName, barr, readErr) + return readConfigFileFS(defaultconfig.ConfigFS, "defaults:", fileName) } func ReadWaveHomeConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) { - fullFileName := filepath.Join(configDirAbsPath, fileName) - barr, err := os.ReadFile(fullFileName) - return readConfigHelper(fullFileName, barr, err) + return readConfigFileFS(configDirFsys, "", fileName) } func WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error { @@ -222,17 +229,69 @@ func mergeMetaMapSimple(m waveobj.MetaMapType, toMerge waveobj.MetaMapType) wave return m } -func ReadConfigPart(partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { - defConfig, cerrs1 := ReadDefaultsConfigFile(partName) - userConfig, cerrs2 := ReadWaveHomeConfigFile(partName) - allErrs := append(cerrs1, cerrs2...) +func mergeMetaMap(m waveobj.MetaMapType, toMerge waveobj.MetaMapType, simpleMerge bool) waveobj.MetaMapType { if simpleMerge { - return mergeMetaMapSimple(defConfig, userConfig), allErrs + return mergeMetaMapSimple(m, toMerge) } else { - return waveobj.MergeMeta(defConfig, userConfig, true), allErrs + return waveobj.MergeMeta(m, toMerge, true) } } +func selectDirEntsBySuffix(dirEnts []fs.DirEntry, fileNameSuffix string) []fs.DirEntry { + var rtn []fs.DirEntry + for _, ent := range dirEnts { + if ent.IsDir() { + continue + } + if !strings.HasSuffix(ent.Name(), fileNameSuffix) { + continue + } + rtn = append(rtn, ent) + } + return rtn +} + +func SortFileNameDescend(files []fs.DirEntry) { + sort.Slice(files, func(i, j int) bool { + return files[i].Name() > files[j].Name() + }) +} + +// Read and merge all files in the specified directory matching the supplied suffix +func readConfigFilesForDir(fsys fs.FS, logPrefix string, dirName string, fileName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { + dirEnts, _ := fs.ReadDir(fsys, dirName) + suffixEnts := selectDirEntsBySuffix(dirEnts, fileName+".json") + SortFileNameDescend(suffixEnts) + var rtn waveobj.MetaMapType + var errs []ConfigError + for _, ent := range suffixEnts { + fileVal, cerrs := readConfigFileFS(fsys, logPrefix, filepath.Join(dirName, ent.Name())) + rtn = mergeMetaMap(rtn, fileVal, simpleMerge) + errs = append(errs, cerrs...) + } + return rtn, errs +} + +// Read and merge all files in the specified config filesystem matching the patterns `.json` and `/*.json` +func readConfigPartForFS(fsys fs.FS, logPrefix string, partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { + config, errs := readConfigFilesForDir(fsys, logPrefix, partName, "", simpleMerge) + allErrs := errs + rtn := config + config, errs = readConfigFileFS(fsys, logPrefix, partName+".json") + allErrs = append(allErrs, errs...) + return mergeMetaMap(rtn, config, simpleMerge), allErrs +} + +// Combine files from the defaults and home directory for the specified config part name +func readConfigPart(partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { + defaultConfigs, cerrs := readConfigPartForFS(defaultconfig.ConfigFS, "defaults:", partName, simpleMerge) + homeConfigs, cerrs1 := readConfigPartForFS(configDirFsys, "", partName, simpleMerge) + + rtn := defaultConfigs + allErrs := append(cerrs, cerrs1...) + return mergeMetaMap(rtn, homeConfigs, simpleMerge), allErrs +} + func ReadFullConfig() FullConfigType { var fullConfig FullConfigType configRType := reflect.TypeOf(fullConfig) @@ -247,13 +306,15 @@ func ReadFullConfig() FullConfigType { continue } jsonTag := utilfn.GetJsonTag(field) + simpleMerge := field.Tag.Get("merge") == "" + var configPart waveobj.MetaMapType + var errs []ConfigError if jsonTag == "-" || jsonTag == "" { continue + } else { + configPart, errs = readConfigPart(jsonTag, simpleMerge) } - simpleMerge := field.Tag.Get("merge") == "" - fileName := jsonTag + ".json" - configPart, cerrs := ReadConfigPart(fileName, simpleMerge) - fullConfig.ConfigErrors = append(fullConfig.ConfigErrors, cerrs...) + fullConfig.ConfigErrors = append(fullConfig.ConfigErrors, errs...) if configPart != nil { fieldPtr := configRVal.Field(fieldIdx).Addr().Interface() utilfn.ReUnmarshal(fieldPtr, configPart) @@ -262,6 +323,28 @@ func ReadFullConfig() FullConfigType { return fullConfig } +func GetConfigSubdirs() []string { + var fullConfig FullConfigType + configRType := reflect.TypeOf(fullConfig) + var retVal []string + for fieldIdx := 0; fieldIdx < configRType.NumField(); fieldIdx++ { + field := configRType.Field(fieldIdx) + if field.PkgPath != "" { + continue + } + configFile := field.Tag.Get("configfile") + if configFile == "-" { + continue + } + jsonTag := utilfn.GetJsonTag(field) + if jsonTag != "-" && jsonTag != "" && jsonTag != "settings" { + retVal = append(retVal, filepath.Join(configDirAbsPath, jsonTag)) + } + } + log.Printf("subdirs: %v\n", retVal) + return retVal +} + func getConfigKeyType(configKey string) reflect.Type { ctype := reflect.TypeOf(SettingsType{}) for i := 0; i < ctype.NumField(); i++ { @@ -415,6 +498,14 @@ func SetBaseConfigValue(toMerge waveobj.MetaMapType) error { return WriteWaveHomeConfigFile(SettingsFile, m) } +func EnsureWaveConfigDir() error { + return wavebase.CacheEnsureDir(configDirAbsPath, "waveconfig", 0700, "wave config directory") +} + +func EnsureWavePresetsDir() error { + return wavebase.CacheEnsureDir(filepath.Join(configDirAbsPath, "presets"), "wavepresets", 0700, "wave presets directory") +} + type WidgetConfigType struct { DisplayOrder float64 `json:"display:order,omitempty"` Icon string `json:"icon,omitempty"` From 33f05c6e0cf974eabc8de32b45bae47e97994b7a Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Tue, 22 Oct 2024 09:26:58 -0700 Subject: [PATCH 07/14] Update data and config paths to match platform defaults (#1047) Going forward for new installations, config and data files will be stored at the platform default paths, as defined by [env-paths](https://www.npmjs.com/package/env-paths). For backwards compatibility, if the `~/.waveterm` or `WAVETERM_HOME` directory exists and contains valid data, it will be used. If this check fails, then `WAVETERM_DATA_HOME` and `WAVETERM_CONFIG_HOME` will be used. If these are not defined, then `XDG_DATA_HOME` and `XDG_CONFIG_HOME` will be used. Finally, if none of these are defined, the [env-paths](https://www.npmjs.com/package/env-paths) defaults will be used. As with the existing app, dev instances will write to `waveterm-dev` directories, while all others will write to `waveterm`. --- Taskfile.yml | 4 +- cmd/server/main-server.go | 9 ++- emain/docsite.ts | 4 +- emain/emain-viewmgr.ts | 14 ++-- emain/emain-wavesrv.ts | 22 ++++-- emain/emain-wsh.ts | 2 +- emain/emain.ts | 68 ++++++++--------- emain/launchsettings.ts | 4 +- emain/menu.ts | 2 +- emain/platform.ts | 114 +++++++++++++++++++++++++--- emain/preload.ts | 1 + emain/updater.ts | 4 +- frontend/types/custom.d.ts | 1 + package.json | 1 + pkg/filestore/blockstore_dbsetup.go | 2 +- pkg/shellexec/shellexec.go | 2 +- pkg/util/panic/panic.go | 15 ++++ pkg/util/shellutil/shellutil.go | 14 ++-- pkg/wavebase/wavebase-posix.go | 4 +- pkg/wavebase/wavebase-win.go | 4 +- pkg/wavebase/wavebase.go | 44 +++++++---- pkg/wconfig/filewatcher.go | 2 +- pkg/wconfig/settingsconfig.go | 9 --- pkg/web/web.go | 2 +- pkg/wstore/wstore_dbsetup.go | 2 +- yarn.lock | 8 ++ 26 files changed, 247 insertions(+), 111 deletions(-) create mode 100644 pkg/util/panic/panic.go diff --git a/Taskfile.yml b/Taskfile.yml index 29a37bd42..829704db3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -184,8 +184,8 @@ tasks: generate: desc: Generate Typescript bindings for the Go backend. cmds: - - go run cmd/generatets/main-generatets.go - - go run cmd/generatego/main-generatego.go + - NO_PANIC=1 go run cmd/generatets/main-generatets.go + - NO_PANIC=1 go run cmd/generatego/main-generatego.go sources: - "cmd/generatego/*.go" - "cmd/generatets/*.go" diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 5ab88bd59..b560b2766 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -182,7 +182,7 @@ func main() { log.Printf("error validating service map: %v\n", err) return } - err = wavebase.EnsureWaveHomeDir() + err = wavebase.EnsureWaveDataDir() if err != nil { log.Printf("error ensuring wave home dir: %v\n", err) return @@ -192,14 +192,14 @@ func main() { log.Printf("error ensuring wave db dir: %v\n", err) return } - err = wconfig.EnsureWaveConfigDir() + err = wavebase.EnsureWaveConfigDir() if err != nil { log.Printf("error ensuring wave config dir: %v\n", err) return } // TODO: rather than ensure this dir exists, we should let the editor recursively create parent dirs on save - err = wconfig.EnsureWavePresetsDir() + err = wavebase.EnsureWavePresetsDir() if err != nil { log.Printf("error ensuring wave presets dir: %v\n", err) return @@ -216,7 +216,8 @@ func main() { } }() log.Printf("wave version: %s (%s)\n", WaveVersion, BuildTime) - log.Printf("wave home dir: %s\n", wavebase.GetWaveHomeDir()) + log.Printf("wave data dir: %s\n", wavebase.GetWaveDataDir()) + log.Printf("wave config dir: %s\n", wavebase.GetWaveConfigDir()) err = filestore.InitFilestore() if err != nil { log.Printf("error initializing filestore: %v\n", err) diff --git a/emain/docsite.ts b/emain/docsite.ts index ddf9d21b8..37818b954 100644 --- a/emain/docsite.ts +++ b/emain/docsite.ts @@ -1,6 +1,6 @@ -import { getWebServerEndpoint } from "@/util/endpoints"; -import { fetch } from "@/util/fetchutil"; import { ipcMain } from "electron"; +import { getWebServerEndpoint } from "../frontend/util/endpoints"; +import { fetch } from "../frontend/util/fetchutil"; const docsiteWebUrl = "https://docs.waveterm.dev/"; let docsiteUrl: string; diff --git a/emain/emain-viewmgr.ts b/emain/emain-viewmgr.ts index 0afe7a0f1..7818e69ac 100644 --- a/emain/emain-viewmgr.ts +++ b/emain/emain-viewmgr.ts @@ -1,8 +1,13 @@ // 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 * as path from "path"; +import { debounce } from "throttle-debounce"; +import { ClientService, FileService, ObjectService, WindowService } from "../frontend/app/store/services"; +import * as keyutil from "../frontend/util/keyutil"; +import { configureAuthKeyRequestInjection } from "./authkey"; +import { getGlobalIsQuitting, getGlobalIsStarting, setWasActive, setWasInFg } from "./emain-activity"; import { delay, ensureBoundsAreVisible, @@ -10,12 +15,7 @@ import { 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"; +} from "./emain-util"; import { getElectronAppBasePath, isDevVite } from "./platform"; import { updater } from "./updater"; diff --git a/emain/emain-wavesrv.ts b/emain/emain-wavesrv.ts index 597e2f032..e8ade2475 100644 --- a/emain/emain-wavesrv.ts +++ b/emain/emain-wavesrv.ts @@ -1,15 +1,23 @@ // 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"; +import { WebServerEndpointVarName, WSServerEndpointVarName } from "../frontend/util/endpoints"; +import { AuthKey, AuthKeyEnv } from "./authkey"; +import { setForceQuit } from "./emain-activity"; +import { WaveAppPathVarName } from "./emain-util"; +import { + getElectronAppUnpackedBasePath, + getWaveConfigDir, + getWaveDataDir, + getWaveSrvCwd, + getWaveSrvPath, + WaveConfigHomeVarName, + WaveDataHomeVarName, +} from "./platform"; +import { updater } from "./updater"; export const WaveSrvReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID"; @@ -50,6 +58,8 @@ export function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promis envCopy[WaveAppPathVarName] = getElectronAppUnpackedBasePath(); envCopy[WaveSrvReadySignalPidVarName] = process.pid.toString(); envCopy[AuthKeyEnv] = AuthKey; + envCopy[WaveDataHomeVarName] = getWaveDataDir(); + envCopy[WaveConfigHomeVarName] = getWaveConfigDir(); const waveSrvCmd = getWaveSrvPath(); console.log("trying to run local server", waveSrvCmd); const proc = child_process.spawn(getWaveSrvPath(), { diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index fcdeb253a..a354f2676 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { Notification } from "electron"; -import { getWaveWindowById } from "emain/emain-viewmgr"; import { RpcResponseHelper, WshClient } from "../frontend/app/store/wshclient"; +import { getWaveWindowById } from "./emain-viewmgr"; import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; export class ElectronWshClientType extends WshClient { diff --git a/emain/emain.ts b/emain/emain.ts index 86b0009a8..40f8a11e0 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -2,31 +2,6 @@ // 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"; @@ -44,13 +19,39 @@ import * as keyutil from "../frontend/util/keyutil"; import { fireAndForget } from "../frontend/util/util"; import { AuthKey, configureAuthKeyRequestInjection } from "./authkey"; import { initDocsite } from "./docsite"; +import { + getActivityState, + getForceQuit, + getGlobalIsRelaunching, + setForceQuit, + setGlobalIsQuitting, + setGlobalIsRelaunching, + setGlobalIsStarting, + setWasActive, + setWasInFg, +} from "./emain-activity"; +import { handleCtrlShiftState } from "./emain-util"; +import { + createBrowserWindow, + ensureHotSpareTab, + getAllWaveWindows, + getFocusedWaveWindow, + getLastFocusedWaveWindow, + getWaveTabViewByWebContentsId, + getWaveWindowById, + getWaveWindowByWebContentsId, + setActiveTab, + setMaxTabCacheSize, +} from "./emain-viewmgr"; +import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, getWaveVersion, runWaveSrv } from "./emain-wavesrv"; import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; import { getLaunchSettings } from "./launchsettings"; import { getAppMenu } from "./menu"; import { getElectronAppBasePath, getElectronAppUnpackedBasePath, - getWaveHomeDir, + getWaveConfigDir, + getWaveDataDir, isDev, unameArch, unamePlatform, @@ -59,15 +60,17 @@ import { configureAutoUpdater, updater } from "./updater"; const electronApp = electron.app; +const waveDataDir = getWaveDataDir(); +const waveConfigDir = getWaveConfigDir(); + electron.nativeTheme.themeSource = "dark"; 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 -const waveHome = getWaveHomeDir(); const oldConsoleLog = console.log; const loggerTransports: winston.transport[] = [ - new winston.transports.File({ filename: path.join(getWaveHomeDir(), "waveapp.log"), level: "info" }), + new winston.transports.File({ filename: path.join(waveDataDir, "waveapp.log"), level: "info" }), ]; if (isDev) { loggerTransports.push(new winston.transports.Console()); @@ -91,8 +94,9 @@ function log(...msg: any[]) { console.log = log; console.log( sprintf( - "waveterm-app starting, WAVETERM_HOME=%s, electronpath=%s gopath=%s arch=%s/%s", - waveHome, + "waveterm-app starting, data_dir=%s, config_dir=%s electronpath=%s gopath=%s arch=%s/%s", + waveDataDir, + waveConfigDir, getElectronAppBasePath(), getElectronAppUnpackedBasePath(), unamePlatform, @@ -676,10 +680,6 @@ async function appMain() { electronApp.quit(); return; } - const waveHomeDir = getWaveHomeDir(); - if (!fs.existsSync(waveHomeDir)) { - fs.mkdirSync(waveHomeDir); - } makeAppMenu(); try { await runWaveSrv(handleWSEvent); diff --git a/emain/launchsettings.ts b/emain/launchsettings.ts index 92339c915..cb0c253ad 100644 --- a/emain/launchsettings.ts +++ b/emain/launchsettings.ts @@ -1,6 +1,6 @@ import fs from "fs"; import path from "path"; -import { getWaveHomeDir } from "./platform"; +import { getWaveConfigDir } from "./platform"; /** * Get settings directly from the Wave Home directory on launch. @@ -8,7 +8,7 @@ import { getWaveHomeDir } from "./platform"; * @returns The initial launch settings for the application. */ export function getLaunchSettings(): SettingsType { - const settingsPath = path.join(getWaveHomeDir(), "config", "settings.json"); + const settingsPath = path.join(getWaveConfigDir(), "settings.json"); try { const settingsContents = fs.readFileSync(settingsPath, "utf8"); return JSON.parse(settingsContents); diff --git a/emain/menu.ts b/emain/menu.ts index b23094287..0a38e7752 100644 --- a/emain/menu.ts +++ b/emain/menu.ts @@ -2,8 +2,8 @@ // 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 { clearTabCache, getFocusedWaveWindow } from "./emain-viewmgr"; import { unamePlatform } from "./platform"; import { updater } from "./updater"; diff --git a/emain/platform.ts b/emain/platform.ts index 36089982e..712c82ad2 100644 --- a/emain/platform.ts +++ b/emain/platform.ts @@ -2,12 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import { app, ipcMain } from "electron"; +import envPaths from "env-paths"; +import { existsSync, mkdirSync } from "fs"; import os from "os"; import path from "path"; import { WaveDevVarName, WaveDevViteVarName } from "../frontend/util/isdev"; import * as keyutil from "../frontend/util/keyutil"; -const WaveHomeVarName = "WAVETERM_HOME"; +// This is a little trick to ensure that Electron puts all its runtime data into a subdirectory to avoid conflicts with our own data. +// On macOS, it will store to ~/Library/Application \Support/waveterm/electron +// On Linux, it will store to ~/.config/waveterm/electron +// On Windows, it will store to %LOCALAPPDATA%/waveterm/electron +app.setName("waveterm/electron"); const isDev = !app.isPackaged; const isDevVite = isDev && process.env.ELECTRON_RENDERER_URL; @@ -18,18 +24,100 @@ if (isDevVite) { process.env[WaveDevViteVarName] = "1"; } +const waveDirNamePrefix = "waveterm"; +const waveDirNameSuffix = isDev ? "dev" : ""; +const waveDirName = `${waveDirNamePrefix}${waveDirNameSuffix ? `-${waveDirNameSuffix}` : ""}`; + +const paths = envPaths("waveterm", { suffix: waveDirNameSuffix }); + app.setName(isDev ? "Wave (Dev)" : "Wave"); const unamePlatform = process.platform; const unameArch: string = process.arch; keyutil.setKeyUtilPlatform(unamePlatform); -// must match golang -function getWaveHomeDir() { - const override = process.env[WaveHomeVarName]; - if (override) { - return override; +const WaveConfigHomeVarName = "WAVETERM_CONFIG_HOME"; +const WaveDataHomeVarName = "WAVETERM_DATA_HOME"; +const WaveHomeVarName = "WAVETERM_HOME"; + +/** + * Gets the path to the old Wave home directory (defaults to `~/.waveterm`). + * @returns The path to the directory if it exists and contains valid data for the current app, otherwise null. + */ +function getWaveHomeDir(): string { + let home = process.env[WaveHomeVarName]; + if (!home) { + const homeDir = process.env.HOME; + if (homeDir) { + home = path.join(homeDir, `.${waveDirName}`); + } } - return path.join(os.homedir(), isDev ? ".waveterm-dev" : ".waveterm"); + // If home exists and it has `wave.lock` in it, we know it has valid data from Wave >=v0.8. Otherwise, it could be for WaveLegacy ( { @@ -71,18 +159,24 @@ ipcMain.on("get-host-name", (event) => { ipcMain.on("get-webview-preload", (event) => { event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs"); }); +ipcMain.on("get-data-dir", (event) => { + event.returnValue = getWaveDataDir(); +}); ipcMain.on("get-config-dir", (event) => { - event.returnValue = path.join(getWaveHomeDir(), "config"); + event.returnValue = getWaveConfigDir(); }); export { getElectronAppBasePath, getElectronAppUnpackedBasePath, - getWaveHomeDir, + getWaveConfigDir, + getWaveDataDir, getWaveSrvCwd, getWaveSrvPath, isDev, isDevVite, unameArch, unamePlatform, + WaveConfigHomeVarName, + WaveDataHomeVarName, }; diff --git a/emain/preload.ts b/emain/preload.ts index ba7027c7e..d0e0acfa9 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -10,6 +10,7 @@ contextBridge.exposeInMainWorld("api", { getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"), getUserName: () => ipcRenderer.sendSync("get-user-name"), getHostName: () => ipcRenderer.sendSync("get-host-name"), + getDataDir: () => ipcRenderer.sendSync("get-data-dir"), getConfigDir: () => ipcRenderer.sendSync("get-config-dir"), getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"), getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"), diff --git a/emain/updater.ts b/emain/updater.ts index 4c8e440d0..f3849d144 100644 --- a/emain/updater.ts +++ b/emain/updater.ts @@ -1,16 +1,16 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { RpcApi } from "@/app/store/wshclientapi"; 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"; import { FileService } from "../frontend/app/store/services"; +import { RpcApi } from "../frontend/app/store/wshclientapi"; import { isDev } from "../frontend/util/isdev"; import { fireAndForget } from "../frontend/util/util"; +import { getAllWaveWindows, getFocusedWaveWindow } from "./emain-viewmgr"; import { ElectronWshClient } from "./emain-wsh"; export let updater: Updater; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 32d770b99..209405d49 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -64,6 +64,7 @@ declare global { getEnv: (varName: string) => string; getUserName: () => string; getHostName: () => string; + getDataDir: () => string; getConfigDir: () => string; getWebviewPreload: () => string; getAboutModalDetails: () => AboutModalDetails; diff --git a/package.json b/package.json index f77e05cea..8c52ad3db 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "dayjs": "^1.11.13", "debug": "^4.3.7", "electron-updater": "6.3.9", + "env-paths": "^3.0.0", "fast-average-color": "^9.4.0", "htl": "^0.3.1", "html-to-image": "^1.11.11", diff --git a/pkg/filestore/blockstore_dbsetup.go b/pkg/filestore/blockstore_dbsetup.go index d828c4173..8ceb5b366 100644 --- a/pkg/filestore/blockstore_dbsetup.go +++ b/pkg/filestore/blockstore_dbsetup.go @@ -50,7 +50,7 @@ func InitFilestore() error { } func GetDBName() string { - waveHome := wavebase.GetWaveHomeDir() + waveHome := wavebase.GetWaveDataDir() return filepath.Join(waveHome, wavebase.WaveDBDir, FilestoreDBName) } diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index a58433ded..c11faf619 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -289,7 +289,7 @@ func StartShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOpt // cant set -l or -i with --rcfile shellOpts = append(shellOpts, "--rcfile", shellutil.GetBashRcFileOverride()) } else if isFishShell(shellPath) { - wshBinDir := filepath.Join(wavebase.GetWaveHomeDir(), shellutil.WaveHomeBinDir) + wshBinDir := filepath.Join(wavebase.GetWaveDataDir(), shellutil.WaveHomeBinDir) quotedWshBinDir := utilfn.ShellQuote(wshBinDir, false, 300) shellOpts = append(shellOpts, "-C", fmt.Sprintf("set -x PATH %s $PATH", quotedWshBinDir)) } else if remote.IsPowershell(shellPath) { diff --git a/pkg/util/panic/panic.go b/pkg/util/panic/panic.go new file mode 100644 index 000000000..a7dde8bf5 --- /dev/null +++ b/pkg/util/panic/panic.go @@ -0,0 +1,15 @@ +package panic + +import ( + "log" + "os" +) + +var shouldPanic = len(os.Getenv("NO_PANIC")) == 0 + +// Wraps log.Panic, ignored if NO_PANIC is set +func Panic(message string) { + if shouldPanic { + log.Panic(message) + } +} diff --git a/pkg/util/shellutil/shellutil.go b/pkg/util/shellutil/shellutil.go index 1e6e6a3ab..91ac6d76b 100644 --- a/pkg/util/shellutil/shellutil.go +++ b/pkg/util/shellutil/shellutil.go @@ -149,7 +149,7 @@ func WaveshellLocalEnvVars(termType string) map[string]string { rtn["TERM_PROGRAM"] = "waveterm" rtn["WAVETERM"], _ = os.Executable() rtn["WAVETERM_VERSION"] = wavebase.WaveVersion - rtn["WAVETERM_WSHBINDIR"] = filepath.Join(wavebase.GetWaveHomeDir(), WaveHomeBinDir) + rtn["WAVETERM_WSHBINDIR"] = filepath.Join(wavebase.GetWaveDataDir(), WaveHomeBinDir) return rtn } @@ -202,15 +202,15 @@ func InitCustomShellStartupFiles() error { } func GetBashRcFileOverride() string { - return filepath.Join(wavebase.GetWaveHomeDir(), BashIntegrationDir, ".bashrc") + return filepath.Join(wavebase.GetWaveDataDir(), BashIntegrationDir, ".bashrc") } func GetWavePowershellEnv() string { - return filepath.Join(wavebase.GetWaveHomeDir(), PwshIntegrationDir, "wavepwsh.ps1") + return filepath.Join(wavebase.GetWaveDataDir(), PwshIntegrationDir, "wavepwsh.ps1") } func GetZshZDotDir() string { - return filepath.Join(wavebase.GetWaveHomeDir(), ZshIntegrationDir) + return filepath.Join(wavebase.GetWaveDataDir(), ZshIntegrationDir) } func GetWshBaseName(version string, goos string, goarch string) string { @@ -289,9 +289,9 @@ func InitRcFiles(waveHome string, wshBinDir string) error { func initCustomShellStartupFilesInternal() error { log.Printf("initializing wsh and shell startup files\n") - waveHome := wavebase.GetWaveHomeDir() - binDir := filepath.Join(waveHome, WaveHomeBinDir) - err := InitRcFiles(waveHome, `$WAVETERM_WSHBINDIR`) + waveDataHome := wavebase.GetWaveDataDir() + binDir := filepath.Join(waveDataHome, WaveHomeBinDir) + err := InitRcFiles(waveDataHome, `$WAVETERM_WSHBINDIR`) if err != nil { return err } diff --git a/pkg/wavebase/wavebase-posix.go b/pkg/wavebase/wavebase-posix.go index 352302221..12d2e7f75 100644 --- a/pkg/wavebase/wavebase-posix.go +++ b/pkg/wavebase/wavebase-posix.go @@ -14,8 +14,8 @@ import ( ) func AcquireWaveLock() (FDLock, error) { - homeDir := GetWaveHomeDir() - lockFileName := filepath.Join(homeDir, WaveLockFile) + dataHomeDir := GetWaveDataDir() + lockFileName := filepath.Join(dataHomeDir, WaveLockFile) log.Printf("[base] acquiring lock on %s\n", lockFileName) fd, err := os.OpenFile(lockFileName, os.O_RDWR|os.O_CREATE, 0600) if err != nil { diff --git a/pkg/wavebase/wavebase-win.go b/pkg/wavebase/wavebase-win.go index a22ac2f85..31bdab821 100644 --- a/pkg/wavebase/wavebase-win.go +++ b/pkg/wavebase/wavebase-win.go @@ -14,8 +14,8 @@ import ( ) func AcquireWaveLock() (FDLock, error) { - homeDir := GetWaveHomeDir() - lockFileName := filepath.Join(homeDir, WaveLockFile) + dataHomeDir := GetWaveDataDir() + lockFileName := filepath.Join(dataHomeDir, WaveLockFile) log.Printf("[base] acquiring lock on %s\n", lockFileName) m, err := filemutex.New(lockFileName) if err != nil { diff --git a/pkg/wavebase/wavebase.go b/pkg/wavebase/wavebase.go index da61e4a6f..44f80b7a7 100644 --- a/pkg/wavebase/wavebase.go +++ b/pkg/wavebase/wavebase.go @@ -17,15 +17,16 @@ import ( "strings" "sync" "time" + + "github.com/wavetermdev/waveterm/pkg/util/panic" ) // set by main-server.go var WaveVersion = "0.0.0" var BuildTime = "0" -const DefaultWaveHome = "~/.waveterm" -const DevWaveHome = "~/.waveterm-dev" -const WaveHomeVarName = "WAVETERM_HOME" +const WaveConfigHomeEnvVar = "WAVETERM_CONFIG_HOME" +const WaveDataHomeEnvVar = "WAVETERM_DATA_HOME" const WaveDevVarName = "WAVETERM_DEV" const WaveLockFile = "wave.lock" const DomainSocketBaseName = "wave.sock" @@ -97,26 +98,39 @@ func ReplaceHomeDir(pathStr string) string { } func GetDomainSocketName() string { - return filepath.Join(GetWaveHomeDir(), DomainSocketBaseName) + return filepath.Join(GetWaveDataDir(), DomainSocketBaseName) } -func GetWaveHomeDir() string { - homeVar := os.Getenv(WaveHomeVarName) - if homeVar != "" { - return ExpandHomeDirSafe(homeVar) +func GetWaveDataDir() string { + retVal, found := os.LookupEnv(WaveDataHomeEnvVar) + if !found { + panic.Panic(WaveDataHomeEnvVar + " not set") } - if IsDevMode() { - return ExpandHomeDirSafe(DevWaveHome) - } - return ExpandHomeDirSafe(DefaultWaveHome) + return retVal } -func EnsureWaveHomeDir() error { - return CacheEnsureDir(GetWaveHomeDir(), "wavehome", 0700, "wave home directory") +func GetWaveConfigDir() string { + retVal, found := os.LookupEnv(WaveConfigHomeEnvVar) + if !found { + panic.Panic(WaveConfigHomeEnvVar + " not set") + } + return retVal +} + +func EnsureWaveDataDir() error { + return CacheEnsureDir(GetWaveDataDir(), "wavehome", 0700, "wave home directory") } func EnsureWaveDBDir() error { - return CacheEnsureDir(filepath.Join(GetWaveHomeDir(), WaveDBDir), "wavedb", 0700, "wave db directory") + return CacheEnsureDir(filepath.Join(GetWaveDataDir(), WaveDBDir), "wavedb", 0700, "wave db directory") +} + +func EnsureWaveConfigDir() error { + return CacheEnsureDir(GetWaveConfigDir(), "waveconfig", 0700, "wave config directory") +} + +func EnsureWavePresetsDir() error { + return CacheEnsureDir(filepath.Join(GetWaveConfigDir(), "presets"), "wavepresets", 0700, "wave presets directory") } func CacheEnsureDir(dirName string, cacheKey string, perm os.FileMode, dirDesc string) error { diff --git a/pkg/wconfig/filewatcher.go b/pkg/wconfig/filewatcher.go index c8d344d62..7286c3c32 100644 --- a/pkg/wconfig/filewatcher.go +++ b/pkg/wconfig/filewatcher.go @@ -14,7 +14,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wps" ) -var configDirAbsPath = filepath.Join(wavebase.GetWaveHomeDir(), wavebase.ConfigDir) +var configDirAbsPath = wavebase.GetWaveConfigDir() var instance *Watcher var once sync.Once diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index abcb22383..21386675e 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -16,7 +16,6 @@ import ( "strings" "github.com/wavetermdev/waveterm/pkg/util/utilfn" - "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig/defaultconfig" ) @@ -498,14 +497,6 @@ func SetBaseConfigValue(toMerge waveobj.MetaMapType) error { return WriteWaveHomeConfigFile(SettingsFile, m) } -func EnsureWaveConfigDir() error { - return wavebase.CacheEnsureDir(configDirAbsPath, "waveconfig", 0700, "wave config directory") -} - -func EnsureWavePresetsDir() error { - return wavebase.CacheEnsureDir(filepath.Join(configDirAbsPath, "presets"), "wavepresets", 0700, "wave presets directory") -} - type WidgetConfigType struct { DisplayOrder float64 `json:"display:order,omitempty"` Icon string `json:"icon,omitempty"` diff --git a/pkg/web/web.go b/pkg/web/web.go index 0e470490b..92d99ed77 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -431,7 +431,7 @@ func MakeTCPListener(serviceName string) (net.Listener, error) { } func MakeUnixListener() (net.Listener, error) { - serverAddr := wavebase.GetWaveHomeDir() + "/wave.sock" + serverAddr := wavebase.GetWaveDataDir() + "/wave.sock" os.Remove(serverAddr) // ignore error rtn, err := net.Listen("unix", serverAddr) if err != nil { diff --git a/pkg/wstore/wstore_dbsetup.go b/pkg/wstore/wstore_dbsetup.go index 7df15a021..3a4f83585 100644 --- a/pkg/wstore/wstore_dbsetup.go +++ b/pkg/wstore/wstore_dbsetup.go @@ -42,7 +42,7 @@ func InitWStore() error { } func GetDBName() string { - waveHome := wavebase.GetWaveHomeDir() + waveHome := wavebase.GetWaveDataDir() return filepath.Join(waveHome, wavebase.WaveDBDir, WStoreDBName) } diff --git a/yarn.lock b/yarn.lock index f63fa4aa4..9740c1282 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5297,6 +5297,13 @@ __metadata: languageName: node linkType: hard +"env-paths@npm:^3.0.0": + version: 3.0.0 + resolution: "env-paths@npm:3.0.0" + checksum: 10c0/76dec878cee47f841103bacd7fae03283af16f0702dad65102ef0a556f310b98a377885e0f32943831eb08b5ab37842a323d02529f3dfd5d0a40ca71b01b435f + languageName: node + linkType: hard + "err-code@npm:^2.0.2": version: 2.0.3 resolution: "err-code@npm:2.0.3" @@ -11685,6 +11692,7 @@ __metadata: electron-builder: "npm:^25.1.7" electron-updater: "npm:6.3.9" electron-vite: "npm:^2.3.0" + env-paths: "npm:^3.0.0" eslint: "npm:^9.12.0" eslint-config-prettier: "npm:^9.1.0" fast-average-color: "npm:^9.4.0" From bba49fb6db91fbab057ae7d02632852af6fcd51f Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 22 Oct 2024 10:02:15 -0700 Subject: [PATCH 08/14] remove global variables from wconfig so we don't call wavebase (#1098) --- pkg/wconfig/filewatcher.go | 3 +-- pkg/wconfig/settingsconfig.go | 9 +++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pkg/wconfig/filewatcher.go b/pkg/wconfig/filewatcher.go index 7286c3c32..3478b4a4a 100644 --- a/pkg/wconfig/filewatcher.go +++ b/pkg/wconfig/filewatcher.go @@ -14,8 +14,6 @@ import ( "github.com/wavetermdev/waveterm/pkg/wps" ) -var configDirAbsPath = wavebase.GetWaveConfigDir() - var instance *Watcher var once sync.Once @@ -38,6 +36,7 @@ func GetWatcher() *Watcher { log.Printf("failed to create file watcher: %v", err) return } + configDirAbsPath := wavebase.GetWaveConfigDir() instance = &Watcher{watcher: watcher} err = instance.watcher.Add(configDirAbsPath) const failedStr = "failed to add path %s to watcher: %v" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 21386675e..8593af40b 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -16,6 +16,7 @@ import ( "strings" "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig/defaultconfig" ) @@ -183,8 +184,6 @@ func readConfigHelper(fileName string, barr []byte, readErr error) (waveobj.Meta return rtn, cerrs } -var configDirFsys = os.DirFS(configDirAbsPath) - func readConfigFileFS(fsys fs.FS, logPrefix string, fileName string) (waveobj.MetaMapType, []ConfigError) { barr, readErr := fs.ReadFile(fsys, fileName) return readConfigHelper(logPrefix+fileName, barr, readErr) @@ -195,10 +194,13 @@ func ReadDefaultsConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError } func ReadWaveHomeConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) { + configDirAbsPath := wavebase.GetWaveConfigDir() + configDirFsys := os.DirFS(configDirAbsPath) return readConfigFileFS(configDirFsys, "", fileName) } func WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error { + configDirAbsPath := wavebase.GetWaveConfigDir() fullFileName := filepath.Join(configDirAbsPath, fileName) barr, err := jsonMarshalConfigInOrder(m) if err != nil { @@ -283,6 +285,8 @@ func readConfigPartForFS(fsys fs.FS, logPrefix string, partName string, simpleMe // Combine files from the defaults and home directory for the specified config part name func readConfigPart(partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { + configDirAbsPath := wavebase.GetWaveConfigDir() + configDirFsys := os.DirFS(configDirAbsPath) defaultConfigs, cerrs := readConfigPartForFS(defaultconfig.ConfigFS, "defaults:", partName, simpleMerge) homeConfigs, cerrs1 := readConfigPartForFS(configDirFsys, "", partName, simpleMerge) @@ -326,6 +330,7 @@ func GetConfigSubdirs() []string { var fullConfig FullConfigType configRType := reflect.TypeOf(fullConfig) var retVal []string + configDirAbsPath := wavebase.GetWaveConfigDir() for fieldIdx := 0; fieldIdx < configRType.NumField(); fieldIdx++ { field := configRType.Field(fieldIdx) if field.PkgPath != "" { From fa160313ea9475ca4710a24d765ed29b496d4172 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 22 Oct 2024 12:50:44 -0700 Subject: [PATCH 09/14] resize terminal on restoring state (#1100) --- frontend/app/store/services.ts | 4 +++- frontend/app/view/term/termwrap.ts | 19 +++++++++++++++++-- pkg/service/blockservice/blockservice.go | 15 +++++++++++++-- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 738629f78..19d762189 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -10,7 +10,9 @@ class BlockServiceType { GetControllerStatus(arg2: string): Promise { return WOS.callBackendService("block", "GetControllerStatus", Array.from(arguments)) } - SaveTerminalState(arg2: string, arg3: string, arg4: string, arg5: number): Promise { + + // save the terminal state to a blockfile + SaveTerminalState(blockId: string, state: string, stateType: string, ptyOffset: number, termSize: TermSize): Promise { return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments)) } SaveWaveAiData(arg2: string, arg3: OpenAIPromptMessageType[]): Promise { diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 77a7acdfe..270850c6c 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -216,7 +216,21 @@ export class TermWrap { if (cacheFile != null) { ptyOffset = cacheFile.meta["ptyoffset"] ?? 0; if (cacheData.byteLength > 0) { + const curTermSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; + const fileTermSize: TermSize = cacheFile.meta["termsize"]; + let didResize = false; + if ( + fileTermSize != null && + (fileTermSize.rows != curTermSize.rows || fileTermSize.cols != curTermSize.cols) + ) { + console.log("terminal restore size mismatch, temp resize", fileTermSize, curTermSize); + this.terminal.resize(fileTermSize.cols, fileTermSize.rows); + didResize = true; + } this.doTerminalWrite(cacheData, ptyOffset); + if (didResize) { + this.terminal.resize(curTermSize.cols, curTermSize.rows); + } } } const { data: mainData, fileInfo: mainFile } = await fetchWaveFile(this.blockId, TermFileName, ptyOffset); @@ -268,8 +282,9 @@ export class TermWrap { return; } const serializedOutput = this.serializeAddon.serialize(); - console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length); - services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset); + const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; + console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length, termSize); + services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset, termSize); this.dataBytesProcessed = 0; } diff --git a/pkg/service/blockservice/blockservice.go b/pkg/service/blockservice/blockservice.go index 8dc6d942f..92a6ba149 100644 --- a/pkg/service/blockservice/blockservice.go +++ b/pkg/service/blockservice/blockservice.go @@ -38,7 +38,14 @@ func (bs *BlockService) GetControllerStatus(ctx context.Context, blockId string) return bc.GetRuntimeStatus(), nil } -func (bs *BlockService) SaveTerminalState(ctx context.Context, blockId string, state string, stateType string, ptyOffset int64) error { +func (*BlockService) SaveTerminalState_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + Desc: "save the terminal state to a blockfile", + ArgNames: []string{"ctx", "blockId", "state", "stateType", "ptyOffset", "termSize"}, + } +} + +func (bs *BlockService) SaveTerminalState(ctx context.Context, blockId string, state string, stateType string, ptyOffset int64, termSize waveobj.TermSize) error { _, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) if err != nil { return err @@ -52,7 +59,11 @@ func (bs *BlockService) SaveTerminalState(ctx context.Context, blockId string, s if err != nil { return fmt.Errorf("cannot save terminal state: %w", err) } - err = filestore.WFS.WriteMeta(ctx, blockId, "cache:term:"+stateType, filestore.FileMeta{"ptyoffset": ptyOffset}, true) + fileMeta := filestore.FileMeta{ + "ptyoffset": ptyOffset, + "termsize": termSize, + } + err = filestore.WFS.WriteMeta(ctx, blockId, "cache:term:"+stateType, fileMeta, true) if err != nil { return fmt.Errorf("cannot save terminal state meta: %w", err) } From e831f84711dbe58134556f6ffb00eff01f379dfb Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Tue, 22 Oct 2024 21:23:24 -0700 Subject: [PATCH 10/14] fix for ~/.waveterm getting ignored --- emain/platform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emain/platform.ts b/emain/platform.ts index 712c82ad2..d7360cad5 100644 --- a/emain/platform.ts +++ b/emain/platform.ts @@ -46,7 +46,7 @@ const WaveHomeVarName = "WAVETERM_HOME"; function getWaveHomeDir(): string { let home = process.env[WaveHomeVarName]; if (!home) { - const homeDir = process.env.HOME; + const homeDir = app.getPath("home"); if (homeDir) { home = path.join(homeDir, `.${waveDirName}`); } From 8b2805b7fbdf7a496764d78a89dc3ca5372d952b Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Wed, 23 Oct 2024 14:04:16 -0700 Subject: [PATCH 11/14] Add step to bump Winget package version on publish (#1103) This will only bump the version when we publish a latest release, not a beta one. It'll automatically create a PR to the [winget-pkgs ](https://github.com/microsoft/winget-pkgs) repo. Draft until my New Package PR gets merged: https://github.com/microsoft/winget-pkgs/pull/185133 --- .github/workflows/publish-release.yml | 17 ++++++++++++++++- Taskfile.yml | 11 +++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 3d7fdc978..b455f5480 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -7,6 +7,7 @@ on: types: [published] jobs: publish: + if: ${{ startsWith(github.ref, 'refs/tags/') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -16,10 +17,24 @@ jobs: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Publish from staging - if: startsWith(github.ref, 'refs/tags/') run: "task artifacts:publish:${{ github.ref_name }}" env: AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}" AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}" AWS_DEFAULT_REGION: us-west-2 shell: bash + bump-winget: + if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, 'beta') }} + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Submit Winget version bump + run: "task artifacts:publish:winget:${{ github.ref_name }}" + env: + GITHUB_TOKEN: ${{ secrets.WINGET_BUMP_PAT }} + shell: pwsh diff --git a/Taskfile.yml b/Taskfile.yml index 29a37bd42..2fc161a36 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -13,6 +13,7 @@ vars: DATE: '{{if eq OS "windows"}}powershell Get-Date -UFormat{{else}}date{{end}}' ARTIFACTS_BUCKET: waveterm-github-artifacts/staging-w2 RELEASES_BUCKET: dl.waveterm.dev/releases-w2 + WINGET_PACKAGE: CommandLine.Wave tasks: electron:dev: @@ -230,6 +231,16 @@ tasks: fi done + artifacts:publish:winget:*: + desc: Submits a version bump request to WinGet for the latest release. + status: + - exit {{if contains UP_VERSION "beta"}}0{{else}}1{{end}} + vars: + UP_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' + cmd: | + iwr https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe + .\wingetcreate.exe update {{.WINGET_PACKAGE}} -s -v {{.UP_VERSION}} -u "https://{{.RELEASES_BUCKET}}/{{.APP_NAME}}-win32-x64-{{.UP_VERSION}}.exe" -t $env:GITHUB_TOKEN + yarn: desc: Runs `yarn` internal: true From fe7d849bfe57d4ac47446c82553aa5328360721d Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Wed, 23 Oct 2024 14:45:42 -0700 Subject: [PATCH 12/14] Add winget to the readme (#1109) --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index ec9ca44e7..ddf6bd837 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ Also available as a Homebrew Cask for macOS: brew install --cask wave ``` +Also available via the Windows Package Manager: + +```Powershell +winget install CommandLine.Wave +``` + ### Minimum requirements Wave Terminal and WSH run on the following platforms: From 8248637e00139896b985eec01b21c7ba39bb96d1 Mon Sep 17 00:00:00 2001 From: Sylvie Crowe <107814465+oneirocosm@users.noreply.github.com> Date: Wed, 23 Oct 2024 22:43:17 -0700 Subject: [PATCH 13/14] WSL Integration (#1031) Adds support for connecting to local WSL installations on Windows. (also adds wshrpcmmultiproxy / connserver router) --- .gitattributes | 2 +- cmd/server/main-server.go | 4 +- cmd/wsh/cmd/wshcmd-conn.go | 18 +- cmd/wsh/cmd/wshcmd-connserver.go | 175 +++++++- cmd/wsh/cmd/wshcmd-wsl.go | 60 +++ frontend/app/block/blockframe.tsx | 36 ++ frontend/app/store/wshclientapi.ts | 20 + frontend/types/gotypes.d.ts | 7 + go.mod | 3 + go.sum | 11 + pkg/blockcontroller/blockcontroller.go | 38 +- pkg/remote/connutil.go | 3 + pkg/service/clientservice/clientservice.go | 5 +- pkg/shellexec/conninterface.go | 40 ++ pkg/shellexec/shellexec.go | 92 ++++ pkg/util/packetparser/packetparser.go | 58 +++ pkg/wavebase/wavebase.go | 7 + pkg/web/web.go | 2 +- pkg/web/ws.go | 2 +- pkg/wshrpc/wshclient/wshclient.go | 24 + pkg/wshrpc/wshrpctypes.go | 15 + pkg/wshrpc/wshserver/wshserver.go | 63 +++ pkg/wshutil/wshmultiproxy.go | 151 +++++++ pkg/wshutil/wshproxy.go | 82 +++- pkg/wshutil/wshrouter.go | 133 +++++- pkg/wshutil/wshrpc.go | 66 ++- pkg/wshutil/wshutil.go | 77 +++- pkg/wsl/wsl-unix.go | 67 +++ pkg/wsl/wsl-util.go | 296 ++++++++++++ pkg/wsl/wsl-win.go | 125 ++++++ pkg/wsl/wsl.go | 494 +++++++++++++++++++++ 31 files changed, 2101 insertions(+), 75 deletions(-) create mode 100644 cmd/wsh/cmd/wshcmd-wsl.go create mode 100644 pkg/util/packetparser/packetparser.go create mode 100644 pkg/wshutil/wshmultiproxy.go create mode 100644 pkg/wsl/wsl-unix.go create mode 100644 pkg/wsl/wsl-util.go create mode 100644 pkg/wsl/wsl-win.go create mode 100644 pkg/wsl/wsl.go diff --git a/.gitattributes b/.gitattributes index 212566614..94f480de9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -* text=auto \ No newline at end of file +* text=auto eol=lf \ No newline at end of file diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index b560b2766..07c2a66d3 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -159,11 +159,11 @@ func shutdownActivityUpdate() { func createMainWshClient() { rpc := wshserver.GetMainRpcClient() - wshutil.DefaultRouter.RegisterRoute(wshutil.DefaultRoute, rpc) + wshutil.DefaultRouter.RegisterRoute(wshutil.DefaultRoute, rpc, true) wps.Broker.SetClient(wshutil.DefaultRouter) localConnWsh := wshutil.MakeWshRpc(nil, nil, wshrpc.RpcContext{Conn: wshrpc.LocalConnName}, &wshremote.ServerImpl{}) go wshremote.RunSysInfoLoop(localConnWsh, wshrpc.LocalConnName) - wshutil.DefaultRouter.RegisterRoute(wshutil.MakeConnectionRouteId(wshrpc.LocalConnName), localConnWsh) + wshutil.DefaultRouter.RegisterRoute(wshutil.MakeConnectionRouteId(wshrpc.LocalConnName), localConnWsh, true) } func main() { diff --git a/cmd/wsh/cmd/wshcmd-conn.go b/cmd/wsh/cmd/wshcmd-conn.go index c7f991056..73d3ee565 100644 --- a/cmd/wsh/cmd/wshcmd-conn.go +++ b/cmd/wsh/cmd/wshcmd-conn.go @@ -5,6 +5,7 @@ package cmd import ( "fmt" + "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/remote" @@ -25,17 +26,24 @@ func init() { } func connStatus() error { - resp, err := wshclient.ConnStatusCommand(RpcClient, nil) + var allResp []wshrpc.ConnStatus + sshResp, err := wshclient.ConnStatusCommand(RpcClient, nil) if err != nil { - return fmt.Errorf("getting connection status: %w", err) + return fmt.Errorf("getting ssh connection status: %w", err) } - if len(resp) == 0 { + allResp = append(allResp, sshResp...) + wslResp, err := wshclient.WslStatusCommand(RpcClient, nil) + if err != nil { + return fmt.Errorf("getting wsl connection status: %w", err) + } + allResp = append(allResp, wslResp...) + if len(allResp) == 0 { WriteStdout("no connections\n") return nil } WriteStdout("%-30s %-12s\n", "connection", "status") WriteStdout("----------------------------------------------\n") - for _, conn := range resp { + for _, conn := range allResp { str := fmt.Sprintf("%-30s %-12s", conn.Connection, conn.Status) if conn.Error != "" { str += fmt.Sprintf(" (%s)", conn.Error) @@ -110,7 +118,7 @@ func connRun(cmd *cobra.Command, args []string) error { } connName = args[1] _, err := remote.ParseOpts(connName) - if err != nil { + if err != nil && !strings.HasPrefix(connName, "wsl://") { return fmt.Errorf("cannot parse connection name: %w", err) } } diff --git a/cmd/wsh/cmd/wshcmd-connserver.go b/cmd/wsh/cmd/wshcmd-connserver.go index cc00a694e..4f82c1067 100644 --- a/cmd/wsh/cmd/wshcmd-connserver.go +++ b/cmd/wsh/cmd/wshcmd-connserver.go @@ -4,29 +4,186 @@ package cmd import ( + "encoding/json" + "fmt" + "io" + "log" + "net" "os" + "sync/atomic" + "time" "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/util/packetparser" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote" + "github.com/wavetermdev/waveterm/pkg/wshutil" ) var serverCmd = &cobra.Command{ - Use: "connserver", - Hidden: true, - Short: "remote server to power wave blocks", - Args: cobra.NoArgs, - Run: serverRun, - PreRunE: preRunSetupRpcClient, + Use: "connserver", + Hidden: true, + Short: "remote server to power wave blocks", + Args: cobra.NoArgs, + RunE: serverRun, } +var connServerRouter bool + func init() { + serverCmd.Flags().BoolVar(&connServerRouter, "router", false, "run in local router mode") rootCmd.AddCommand(serverCmd) } -func serverRun(cmd *cobra.Command, args []string) { +func MakeRemoteUnixListener() (net.Listener, error) { + serverAddr := wavebase.GetRemoteDomainSocketName() + os.Remove(serverAddr) // ignore error + rtn, err := net.Listen("unix", serverAddr) + if err != nil { + return nil, fmt.Errorf("error creating listener at %v: %v", serverAddr, err) + } + os.Chmod(serverAddr, 0700) + log.Printf("Server [unix-domain] listening on %s\n", serverAddr) + return rtn, nil +} + +func handleNewListenerConn(conn net.Conn, router *wshutil.WshRouter) { + var routeIdContainer atomic.Pointer[string] + proxy := wshutil.MakeRpcProxy() + go func() { + writeErr := wshutil.AdaptOutputChToStream(proxy.ToRemoteCh, conn) + if writeErr != nil { + log.Printf("error writing to domain socket: %v\n", writeErr) + } + }() + go func() { + // when input is closed, close the connection + defer func() { + conn.Close() + routeIdPtr := routeIdContainer.Load() + if routeIdPtr != nil && *routeIdPtr != "" { + router.UnregisterRoute(*routeIdPtr) + disposeMsg := &wshutil.RpcMessage{ + Command: wshrpc.Command_Dispose, + Data: wshrpc.CommandDisposeData{ + RouteId: *routeIdPtr, + }, + Source: *routeIdPtr, + AuthToken: proxy.GetAuthToken(), + } + disposeBytes, _ := json.Marshal(disposeMsg) + router.InjectMessage(disposeBytes, *routeIdPtr) + } + }() + wshutil.AdaptStreamToMsgCh(conn, proxy.FromRemoteCh) + }() + routeId, err := proxy.HandleClientProxyAuth(router) + if err != nil { + log.Printf("error handling client proxy auth: %v\n", err) + conn.Close() + return + } + router.RegisterRoute(routeId, proxy, false) + routeIdContainer.Store(&routeId) +} + +func runListener(listener net.Listener, router *wshutil.WshRouter) { + defer func() { + log.Printf("listener closed, exiting\n") + time.Sleep(500 * time.Millisecond) + wshutil.DoShutdown("", 1, true) + }() + for { + conn, err := listener.Accept() + if err == io.EOF { + break + } + if err != nil { + log.Printf("error accepting connection: %v\n", err) + continue + } + go handleNewListenerConn(conn, router) + } +} + +func setupConnServerRpcClientWithRouter(router *wshutil.WshRouter) (*wshutil.WshRpc, error) { + jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) + if jwtToken == "" { + return nil, fmt.Errorf("no jwt token found for connserver") + } + rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken) + if err != nil { + return nil, fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err) + } + authRtn, err := router.HandleProxyAuth(jwtToken) + if err != nil { + return nil, fmt.Errorf("error handling proxy auth: %v", err) + } + inputCh := make(chan []byte, wshutil.DefaultInputChSize) + outputCh := make(chan []byte, wshutil.DefaultOutputChSize) + connServerClient := wshutil.MakeWshRpc(inputCh, outputCh, *rpcCtx, &wshremote.ServerImpl{LogWriter: os.Stdout}) + connServerClient.SetAuthToken(authRtn.AuthToken) + router.RegisterRoute(authRtn.RouteId, connServerClient, false) + wshclient.RouteAnnounceCommand(connServerClient, nil) + return connServerClient, nil +} + +func serverRunRouter() error { + router := wshutil.NewWshRouter() + termProxy := wshutil.MakeRpcProxy() + rawCh := make(chan []byte, wshutil.DefaultOutputChSize) + go packetparser.Parse(os.Stdin, termProxy.FromRemoteCh, rawCh) + go func() { + for msg := range termProxy.ToRemoteCh { + packetparser.WritePacket(os.Stdout, msg) + } + }() + go func() { + // just ignore and drain the rawCh (stdin) + // when stdin is closed, shutdown + defer wshutil.DoShutdown("", 0, true) + for range rawCh { + // ignore + } + }() + go func() { + for msg := range termProxy.FromRemoteCh { + // send this to the router + router.InjectMessage(msg, wshutil.UpstreamRoute) + } + }() + router.SetUpstreamClient(termProxy) + // now set up the domain socket + unixListener, err := MakeRemoteUnixListener() + if err != nil { + return fmt.Errorf("cannot create unix listener: %v", err) + } + client, err := setupConnServerRpcClientWithRouter(router) + if err != nil { + return fmt.Errorf("error setting up connserver rpc client: %v", err) + } + go runListener(unixListener, router) + // run the sysinfo loop + wshremote.RunSysInfoLoop(client, client.GetRpcContext().Conn) + select {} +} + +func serverRunNormal() error { + err := setupRpcClient(&wshremote.ServerImpl{LogWriter: os.Stdout}) + if err != nil { + return err + } WriteStdout("running wsh connserver (%s)\n", RpcContext.Conn) go wshremote.RunSysInfoLoop(RpcClient, RpcContext.Conn) - RpcClient.SetServerImpl(&wshremote.ServerImpl{LogWriter: os.Stdout}) - select {} // run forever } + +func serverRun(cmd *cobra.Command, args []string) error { + if connServerRouter { + return serverRunRouter() + } else { + return serverRunNormal() + } +} diff --git a/cmd/wsh/cmd/wshcmd-wsl.go b/cmd/wsh/cmd/wshcmd-wsl.go new file mode 100644 index 000000000..bad95ba21 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-wsl.go @@ -0,0 +1,60 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "strings" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var distroName string + +var wslCmd = &cobra.Command{ + Use: "wsl [-d ]", + Short: "connect this terminal to a local wsl connection", + Args: cobra.NoArgs, + Run: wslRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + wslCmd.Flags().StringVarP(&distroName, "distribution", "d", "", "Run the specified distribution") + rootCmd.AddCommand(wslCmd) +} + +func wslRun(cmd *cobra.Command, args []string) { + var err error + if distroName == "" { + // get default distro from the host + distroName, err = wshclient.WslDefaultDistroCommand(RpcClient, nil) + if err != nil { + WriteStderr("[error] %s\n", err) + return + } + } + if !strings.HasPrefix(distroName, "wsl://") { + distroName = "wsl://" + distroName + } + blockId := RpcContext.BlockId + if blockId == "" { + WriteStderr("[error] cannot determine blockid (not in JWT)\n") + return + } + data := wshrpc.CommandSetMetaData{ + ORef: waveobj.MakeORef(waveobj.OType_Block, blockId), + Meta: map[string]any{ + waveobj.MetaKey_Connection: distroName, + }, + } + err = wshclient.SetMetaCommand(RpcClient, data, nil) + if err != nil { + WriteStderr("[error] setting switching connection: %v\n", err) + return + } + WriteStderr("switched connection to %q\n", distroName) +} diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 0eb791a7c..b713bc88c 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -521,6 +521,7 @@ const ChangeConnectionBlockModal = React.memo( const connStatusAtom = getConnStatusAtom(connection); const connStatus = jotai.useAtomValue(connStatusAtom); const [connList, setConnList] = React.useState>([]); + const [wslList, setWslList] = React.useState>([]); const allConnStatus = jotai.useAtomValue(atoms.allConnStatus); const [rowIndex, setRowIndex] = React.useState(0); const connStatusMap = new Map(); @@ -540,6 +541,18 @@ const ChangeConnectionBlockModal = React.memo( prtn.then((newConnList) => { setConnList(newConnList ?? []); }).catch((e) => console.log("unable to load conn list from backend. using blank list: ", e)); + const p2rtn = RpcApi.WslListCommand(TabRpcClient, { timeout: 2000 }); + p2rtn + .then((newWslList) => { + console.log(newWslList); + setWslList(newWslList ?? []); + }) + .catch((e) => { + // removing this log and failing silentyly since it will happen + // if a system isn't using the wsl. and would happen every time the + // typeahead was opened. good candidate for verbose log level. + //console.log("unable to load wsl list from backend. using blank list: ", e) + }); }, [changeConnModalOpen, setConnList]); const changeConnection = React.useCallback( @@ -588,6 +601,15 @@ const ChangeConnectionBlockModal = React.memo( filteredList.push(conn); } } + const filteredWslList: Array = []; + for (const conn of wslList) { + if (conn === connSelected) { + createNew = false; + } + if (conn.includes(connSelected)) { + filteredWslList.push(conn); + } + } // priority handles special suggestions when necessary // for instance, when reconnecting const newConnectionSuggestion: SuggestionConnectionItem = { @@ -637,6 +659,20 @@ const ChangeConnectionBlockModal = React.memo( label: localName, }); } + for (const wslConn of filteredWslList) { + const connStatus = connStatusMap.get(wslConn); + const connColorNum = computeConnColorNum(connStatus); + localSuggestion.items.push({ + status: "connected", + icon: "arrow-right-arrow-left", + iconColor: + connStatus?.status == "connected" + ? `var(--conn-icon-color-${connColorNum})` + : "var(--grey-text-color)", + value: "wsl://" + wslConn, + label: "wsl://" + wslConn, + }); + } const remoteItems = filteredList.map((connName) => { const connStatus = connStatusMap.get(connName); const connColorNum = computeConnColorNum(connStatus); diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 733dd6df0..f4a949ce0 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -72,6 +72,11 @@ class RpcApiType { return client.wshRpcCall("deleteblock", data, opts); } + // command "dispose" [call] + DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise { + return client.wshRpcCall("dispose", data, opts); + } + // command "eventpublish" [call] EventPublishCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise { return client.wshRpcCall("eventpublish", data, opts); @@ -237,6 +242,21 @@ class RpcApiType { return client.wshRpcCall("webselector", data, opts); } + // command "wsldefaultdistro" [call] + WslDefaultDistroCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("wsldefaultdistro", null, opts); + } + + // command "wsllist" [call] + WslListCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("wsllist", null, opts); + } + + // command "wslstatus" [call] + WslStatusCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("wslstatus", null, opts); + } + } export const RpcApi = new RpcApiType(); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 6a0e57363..0a8652b36 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -63,6 +63,7 @@ declare global { // wshrpc.CommandAuthenticateRtnData type CommandAuthenticateRtnData = { routeid: string; + authtoken?: string; }; // wshrpc.CommandBlockInputData @@ -100,6 +101,11 @@ declare global { blockid: string; }; + // wshrpc.CommandDisposeData + type CommandDisposeData = { + routeid: string; + }; + // wshrpc.CommandEventReadHistoryData type CommandEventReadHistoryData = { event: string; @@ -416,6 +422,7 @@ declare global { resid?: string; timeout?: number; route?: string; + authtoken?: string; source?: string; cont?: boolean; cancel?: boolean; diff --git a/go.mod b/go.mod index 202dccedf..9c8cd95b3 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/shirou/gopsutil/v4 v4.24.9 github.com/skeema/knownhosts v1.3.0 github.com/spf13/cobra v1.8.1 + github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b github.com/wavetermdev/htmltoken v0.1.0 golang.org/x/crypto v0.28.0 golang.org/x/sys v0.26.0 @@ -36,9 +37,11 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/atomic v1.7.0 // indirect golang.org/x/net v0.29.0 // indirect diff --git a/go.sum b/go.sum index 590fed68d..1ccb890df 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/0xrawsec/golang-utils v1.3.2 h1:ww4jrtHRSnX9xrGzJYbalx5nXoZewy4zPxiY+ubJgtg= +github.com/0xrawsec/golang-utils v1.3.2/go.mod h1:m7AzHXgdSAkFCD9tWWsApxNVxMlyy7anpPVOyT/yM7E= github.com/alexflint/go-filemutex v1.3.0 h1:LgE+nTUWnQCyRKbpoceKZsPQbs84LivvgwUymZXdOcM= github.com/alexflint/go-filemutex v1.3.0/go.mod h1:U0+VA/i30mGBlLCrFPGtTe9y6wGQfNAWPBTekHQ+c8A= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -62,6 +64,8 @@ github.com/sawka/txwrap v0.2.0 h1:V3LfvKVLULxcYSxdMguLwFyQFMEU9nFDJopg0ZkL+94= github.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA= github.com/shirou/gopsutil/v4 v4.24.9 h1:KIV+/HaHD5ka5f570RZq+2SaeFsb/pq+fp2DGNWYoOI= github.com/shirou/gopsutil/v4 v4.24.9/go.mod h1:3fkaHNeYsUFCGZ8+9vZVWtbyM1k2eRnlL+bWO8Bxa/Q= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= @@ -71,12 +75,17 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 h1:XQpsQG5lqRJlx4mUVHcJvyyc1rdTI9nHvwrdfcuy8aM= +github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117/go.mod h1:mx0TjbqsaDD9DUT5gA1s3hw47U6RIbbIBfvGzR85K0g= +github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b h1:wFBKF5k5xbJQU8bYgcSoQ/ScvmYyq6KHUabAuVUjOWM= +github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b/go.mod h1:N1CYNinssZru+ikvYTgVbVeSi21thHUTCoJ9xMvWe+s= github.com/wavetermdev/htmltoken v0.1.0 h1:RMdA9zTfnYa5jRC4RRG3XNoV5NOP8EDxpaVPjuVz//Q= github.com/wavetermdev/htmltoken v0.1.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= github.com/wavetermdev/ssh_config v0.0.0-20240306041034-17e2087ebde2 h1:onqZrJVap1sm15AiIGTfWzdr6cEF0KdtddeuuOVhzyY= @@ -91,6 +100,7 @@ golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -102,5 +112,6 @@ golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 6b187a342..09a86b2c4 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -11,6 +11,7 @@ import ( "io" "io/fs" "log" + "strings" "sync" "time" @@ -24,6 +25,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" + "github.com/wavetermdev/waveterm/pkg/wsl" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -262,7 +264,30 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj return fmt.Errorf("unknown controller type %q", bc.ControllerType) } var shellProc *shellexec.ShellProc - if remoteName != "" { + if strings.HasPrefix(remoteName, "wsl://") { + wslName := strings.TrimPrefix(remoteName, "wsl://") + credentialCtx, cancelFunc := context.WithTimeout(context.Background(), 60*time.Second) + defer cancelFunc() + + wslConn := wsl.GetWslConn(credentialCtx, wslName, false) + connStatus := wslConn.DeriveConnStatus() + if connStatus.Status != conncontroller.Status_Connected { + return fmt.Errorf("not connected, cannot start shellproc") + } + + // create jwt + if !blockMeta.GetBool(waveobj.MetaKey_CmdNoWsh, false) { + jwtStr, err := wshutil.MakeClientJWTToken(wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId, Conn: wslConn.GetName()}, wslConn.GetDomainSocketName()) + if err != nil { + return fmt.Errorf("error making jwt token: %w", err) + } + cmdOpts.Env[wshutil.WaveJwtTokenVarName] = jwtStr + } + shellProc, err = shellexec.StartWslShellProc(ctx, rc.TermSize, cmdStr, cmdOpts, wslConn) + if err != nil { + return err + } + } else if remoteName != "" { credentialCtx, cancelFunc := context.WithTimeout(context.Background(), 60*time.Second) defer cancelFunc() @@ -325,7 +350,7 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj // we don't need to authenticate this wshProxy since it is coming direct wshProxy := wshutil.MakeRpcProxy() wshProxy.SetRpcContext(&wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId}) - wshutil.DefaultRouter.RegisterRoute(wshutil.MakeControllerRouteId(bc.BlockId), wshProxy) + wshutil.DefaultRouter.RegisterRoute(wshutil.MakeControllerRouteId(bc.BlockId), wshProxy, true) ptyBuffer := wshutil.MakePtyBuffer(wshutil.WaveOSCPrefix, bc.ShellProc.Cmd, wshProxy.FromRemoteCh) go func() { // handles regular output from the pty (goes to the blockfile and xterm) @@ -494,6 +519,15 @@ func CheckConnStatus(blockId string) error { if connName == "" { return nil } + if strings.HasPrefix(connName, "wsl://") { + distroName := strings.TrimPrefix(connName, "wsl://") + conn := wsl.GetWslConn(context.Background(), distroName, false) + connStatus := conn.DeriveConnStatus() + if connStatus.Status != conncontroller.Status_Connected { + return fmt.Errorf("not connected: %s", connStatus.Status) + } + return nil + } opts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("error parsing connection name: %w", err) diff --git a/pkg/remote/connutil.go b/pkg/remote/connutil.go index 5c224e880..b5d1841b8 100644 --- a/pkg/remote/connutil.go +++ b/pkg/remote/connutil.go @@ -1,3 +1,6 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + package remote import ( diff --git a/pkg/service/clientservice/clientservice.go b/pkg/service/clientservice/clientservice.go index f2a8d9b68..b8ec51fbd 100644 --- a/pkg/service/clientservice/clientservice.go +++ b/pkg/service/clientservice/clientservice.go @@ -17,6 +17,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wlayout" "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wsl" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -77,7 +78,9 @@ func (cs *ClientService) MakeWindow(ctx context.Context) (*waveobj.Window, error } func (cs *ClientService) GetAllConnStatus(ctx context.Context) ([]wshrpc.ConnStatus, error) { - return conncontroller.GetAllConnStatus(), nil + sshStatuses := conncontroller.GetAllConnStatus() + wslStatuses := wsl.GetAllConnStatus() + return append(sshStatuses, wslStatuses...), nil } // moves the window to the front of the windowId stack diff --git a/pkg/shellexec/conninterface.go b/pkg/shellexec/conninterface.go index e601f8e1d..fce23f242 100644 --- a/pkg/shellexec/conninterface.go +++ b/pkg/shellexec/conninterface.go @@ -7,6 +7,7 @@ import ( "time" "github.com/creack/pty" + "github.com/wavetermdev/waveterm/pkg/wsl" "golang.org/x/crypto/ssh" ) @@ -129,3 +130,42 @@ func (sw SessionWrap) StderrPipe() (io.ReadCloser, error) { func (sw SessionWrap) SetSize(h int, w int) error { return sw.Session.WindowChange(h, w) } + +type WslCmdWrap struct { + *wsl.WslCmd + Tty pty.Tty + pty.Pty +} + +func (wcw WslCmdWrap) Kill() { + wcw.Tty.Close() + wcw.Close() +} + +func (wcw WslCmdWrap) KillGraceful(timeout time.Duration) { + process := wcw.WslCmd.GetProcess() + if process == nil { + return + } + processState := wcw.WslCmd.GetProcessState() + if processState != nil && processState.Exited() { + return + } + process.Signal(os.Interrupt) + go func() { + time.Sleep(timeout) + process := wcw.WslCmd.GetProcess() + processState := wcw.WslCmd.GetProcessState() + if processState == nil || !processState.Exited() { + process.Kill() // force kill if it is already not exited + } + }() +} + +/** + * SetSize does nothing for WslCmdWrap as there + * is no pty to manage. +**/ +func (wcw WslCmdWrap) SetSize(w int, h int) error { + return nil +} diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index c11faf619..ff985352d 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -5,6 +5,7 @@ package shellexec import ( "bytes" + "context" "fmt" "io" "log" @@ -25,6 +26,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshutil" + "github.com/wavetermdev/waveterm/pkg/wsl" ) const DefaultGracefulKillWait = 400 * time.Millisecond @@ -141,6 +143,96 @@ func (pp *PipePty) WriteString(s string) (n int, err error) { return pp.Write([]byte(s)) } +func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *wsl.WslConn) (*ShellProc, error) { + client := conn.GetClient() + shellPath := cmdOpts.ShellPath + if shellPath == "" { + remoteShellPath, err := wsl.DetectShell(conn.Context, client) + if err != nil { + return nil, err + } + shellPath = remoteShellPath + } + var shellOpts []string + log.Printf("detected shell: %s", shellPath) + + err := wsl.InstallClientRcFiles(conn.Context, client) + if err != nil { + log.Printf("error installing rc files: %v", err) + return nil, err + } + + homeDir := wsl.GetHomeDir(conn.Context, client) + shellOpts = append(shellOpts, "~", "-d", client.Name()) + + if isZshShell(shellPath) { + shellOpts = append(shellOpts, fmt.Sprintf(`ZDOTDIR="%s/.waveterm/%s"`, homeDir, shellutil.ZshIntegrationDir)) + } + var subShellOpts []string + + if cmdStr == "" { + /* transform command in order to inject environment vars */ + if isBashShell(shellPath) { + log.Printf("recognized as bash shell") + // add --rcfile + // cant set -l or -i with --rcfile + subShellOpts = append(subShellOpts, "--rcfile", fmt.Sprintf(`%s/.waveterm/%s/.bashrc`, homeDir, shellutil.BashIntegrationDir)) + } else if isFishShell(shellPath) { + carg := fmt.Sprintf(`"set -x PATH \"%s\"/.waveterm/%s $PATH"`, homeDir, shellutil.WaveHomeBinDir) + subShellOpts = append(subShellOpts, "-C", carg) + } else if wsl.IsPowershell(shellPath) { + // powershell is weird about quoted path executables and requires an ampersand first + shellPath = "& " + shellPath + subShellOpts = append(subShellOpts, "-ExecutionPolicy", "Bypass", "-NoExit", "-File", homeDir+fmt.Sprintf("/.waveterm/%s/wavepwsh.ps1", shellutil.PwshIntegrationDir)) + } else { + if cmdOpts.Login { + subShellOpts = append(subShellOpts, "-l") + } + if cmdOpts.Interactive { + subShellOpts = append(subShellOpts, "-i") + } + // can't set environment vars this way + // will try to do later if possible + } + } else { + shellPath = cmdStr + if cmdOpts.Login { + subShellOpts = append(subShellOpts, "-l") + } + if cmdOpts.Interactive { + subShellOpts = append(subShellOpts, "-i") + } + subShellOpts = append(subShellOpts, "-c", cmdStr) + } + + jwtToken, ok := cmdOpts.Env[wshutil.WaveJwtTokenVarName] + if !ok { + return nil, fmt.Errorf("no jwt token provided to connection") + } + if remote.IsPowershell(shellPath) { + shellOpts = append(shellOpts, "--", fmt.Sprintf(`$env:%s=%s;`, wshutil.WaveJwtTokenVarName, jwtToken)) + } else { + shellOpts = append(shellOpts, "--", fmt.Sprintf(`%s=%s`, wshutil.WaveJwtTokenVarName, jwtToken)) + } + shellOpts = append(shellOpts, shellPath) + shellOpts = append(shellOpts, subShellOpts...) + log.Printf("full cmd is: %s %s", "wsl.exe", strings.Join(shellOpts, " ")) + + ecmd := exec.Command("wsl.exe", shellOpts...) + if termSize.Rows == 0 || termSize.Cols == 0 { + termSize.Rows = shellutil.DefaultTermRows + termSize.Cols = shellutil.DefaultTermCols + } + if termSize.Rows <= 0 || termSize.Cols <= 0 { + return nil, fmt.Errorf("invalid term size: %v", termSize) + } + cmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)}) + if err != nil { + return nil, err + } + return &ShellProc{Cmd: CmdWrap{ecmd, cmdPty}, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil +} + func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) { client := conn.GetClient() shellPath := cmdOpts.ShellPath diff --git a/pkg/util/packetparser/packetparser.go b/pkg/util/packetparser/packetparser.go new file mode 100644 index 000000000..51df1666d --- /dev/null +++ b/pkg/util/packetparser/packetparser.go @@ -0,0 +1,58 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package packetparser + +import ( + "bufio" + "bytes" + "fmt" + "io" +) + +type PacketParser struct { + Reader io.Reader + Ch chan []byte +} + +func Parse(input io.Reader, packetCh chan []byte, rawCh chan []byte) error { + bufReader := bufio.NewReader(input) + defer close(packetCh) + defer close(rawCh) + for { + line, err := bufReader.ReadBytes('\n') + if err == io.EOF { + return nil + } + if err != nil { + return err + } + if len(line) <= 1 { + // just a blank line + continue + } + if bytes.HasPrefix(line, []byte{'#', '#', 'N', '{'}) && bytes.HasSuffix(line, []byte{'}', '\n'}) { + // strip off the leading "##" and trailing "\n" (single byte) + packetCh <- line[3 : len(line)-1] + } else { + rawCh <- line + } + } +} + +func WritePacket(output io.Writer, packet []byte) error { + if len(packet) < 2 { + return nil + } + if packet[0] != '{' || packet[len(packet)-1] != '}' { + return fmt.Errorf("invalid packet, must start with '{' and end with '}'") + } + fullPacket := make([]byte, 0, len(packet)+5) + // we add the extra newline to make sure the ## appears at the beginning of the line + // since writer isn't buffered, we want to send this all at once + fullPacket = append(fullPacket, '\n', '#', '#', 'N') + fullPacket = append(fullPacket, packet...) + fullPacket = append(fullPacket, '\n') + _, err := output.Write(fullPacket) + return err +} diff --git a/pkg/wavebase/wavebase.go b/pkg/wavebase/wavebase.go index 44f80b7a7..805386d52 100644 --- a/pkg/wavebase/wavebase.go +++ b/pkg/wavebase/wavebase.go @@ -30,10 +30,13 @@ const WaveDataHomeEnvVar = "WAVETERM_DATA_HOME" const WaveDevVarName = "WAVETERM_DEV" const WaveLockFile = "wave.lock" const DomainSocketBaseName = "wave.sock" +const RemoteDomainSocketBaseName = "wave-remote.sock" const WaveDBDir = "db" const JwtSecret = "waveterm" // TODO generate and store this const ConfigDir = "config" +var RemoteWaveHome = ExpandHomeDirSafe("~/.waveterm") + const WaveAppPathVarName = "WAVETERM_APP_PATH" const AppPathBinDir = "bin" @@ -101,6 +104,10 @@ func GetDomainSocketName() string { return filepath.Join(GetWaveDataDir(), DomainSocketBaseName) } +func GetRemoteDomainSocketName() string { + return filepath.Join(RemoteWaveHome, RemoteDomainSocketBaseName) +} + func GetWaveDataDir() string { retVal, found := os.LookupEnv(WaveDataHomeEnvVar) if !found { diff --git a/pkg/web/web.go b/pkg/web/web.go index 92d99ed77..695c73bbf 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -431,7 +431,7 @@ func MakeTCPListener(serviceName string) (net.Listener, error) { } func MakeUnixListener() (net.Listener, error) { - serverAddr := wavebase.GetWaveDataDir() + "/wave.sock" + serverAddr := wavebase.GetDomainSocketName() os.Remove(serverAddr) // ignore error rtn, err := net.Listen("unix", serverAddr) if err != nil { diff --git a/pkg/web/ws.go b/pkg/web/ws.go index 4cb6c1dcf..c0374efff 100644 --- a/pkg/web/ws.go +++ b/pkg/web/ws.go @@ -252,7 +252,7 @@ func registerConn(wsConnId string, routeId string, wproxy *wshutil.WshRpcProxy) wshutil.DefaultRouter.UnregisterRoute(routeId) } RouteToConnMap[routeId] = wsConnId - wshutil.DefaultRouter.RegisterRoute(routeId, wproxy) + wshutil.DefaultRouter.RegisterRoute(routeId, wproxy, true) } func unregisterConn(wsConnId string, routeId string) { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index e8743ade3..5a8d553df 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -92,6 +92,12 @@ func DeleteBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, o return err } +// command "dispose", wshserver.DisposeCommand +func DisposeCommand(w *wshutil.WshRpc, data wshrpc.CommandDisposeData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "dispose", data, opts) + return err +} + // command "eventpublish", wshserver.EventPublishCommand func EventPublishCommand(w *wshutil.WshRpc, data wps.WaveEvent, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "eventpublish", data, opts) @@ -285,4 +291,22 @@ func WebSelectorCommand(w *wshutil.WshRpc, data wshrpc.CommandWebSelectorData, o return resp, err } +// command "wsldefaultdistro", wshserver.WslDefaultDistroCommand +func WslDefaultDistroCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { + resp, err := sendRpcRequestCallHelper[string](w, "wsldefaultdistro", nil, opts) + return resp, err +} + +// command "wsllist", wshserver.WslListCommand +func WslListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) { + resp, err := sendRpcRequestCallHelper[[]string](w, "wsllist", nil, opts) + return resp, err +} + +// command "wslstatus", wshserver.WslStatusCommand +func WslStatusCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.ConnStatus, error) { + resp, err := sendRpcRequestCallHelper[[]wshrpc.ConnStatus](w, "wslstatus", nil, opts) + return resp, err +} + diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 55dbc18ce..40ae627de 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -28,6 +28,7 @@ const ( const ( Command_Authenticate = "authenticate" // special + Command_Dispose = "dispose" // special (disposes of the route, for multiproxy only) Command_RouteAnnounce = "routeannounce" // special (for routing) Command_RouteUnannounce = "routeunannounce" // special (for routing) Command_Message = "message" @@ -62,11 +63,15 @@ const ( Command_RemoteFileDelete = "remotefiledelete" Command_RemoteFileJoiin = "remotefilejoin" + Command_ConnStatus = "connstatus" + Command_WslStatus = "wslstatus" Command_ConnEnsure = "connensure" Command_ConnReinstallWsh = "connreinstallwsh" Command_ConnConnect = "connconnect" Command_ConnDisconnect = "conndisconnect" Command_ConnList = "connlist" + Command_WslList = "wsllist" + Command_WslDefaultDistro = "wsldefaultdistro" Command_WebSelector = "webselector" Command_Notify = "notify" @@ -83,6 +88,7 @@ type RespOrErrorUnion[T any] struct { type WshRpcInterface interface { AuthenticateCommand(ctx context.Context, data string) (CommandAuthenticateRtnData, error) + DisposeCommand(ctx context.Context, data CommandDisposeData) error RouteAnnounceCommand(ctx context.Context) error // (special) announces a new route to the main router RouteUnannounceCommand(ctx context.Context) error // (special) unannounces a route to the main router @@ -114,11 +120,14 @@ type WshRpcInterface interface { // connection functions ConnStatusCommand(ctx context.Context) ([]ConnStatus, error) + WslStatusCommand(ctx context.Context) ([]ConnStatus, error) ConnEnsureCommand(ctx context.Context, connName string) error ConnReinstallWshCommand(ctx context.Context, connName string) error ConnConnectCommand(ctx context.Context, connName string) error ConnDisconnectCommand(ctx context.Context, connName string) error ConnListCommand(ctx context.Context) ([]string, error) + WslListCommand(ctx context.Context) ([]string, error) + WslDefaultDistroCommand(ctx context.Context) (string, error) // eventrecv is special, it's handled internally by WshRpc with EventListener EventRecvCommand(ctx context.Context, data wps.WaveEvent) error @@ -200,7 +209,13 @@ func HackRpcContextIntoData(dataPtr any, rpcContext RpcContext) { } type CommandAuthenticateRtnData struct { + RouteId string `json:"routeid"` + AuthToken string `json:"authtoken,omitempty"` +} + +type CommandDisposeData struct { RouteId string `json:"routeid"` + // auth token travels in the packet directly } type CommandMessageData struct { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index d9afe7889..df1670b19 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -21,6 +21,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveai" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" @@ -29,6 +30,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" + "github.com/wavetermdev/waveterm/pkg/wsl" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -36,6 +38,7 @@ const SimpleId_This = "this" const SimpleId_Tab = "tab" var SimpleId_BlockNum_Regex = regexp.MustCompile(`^\d+$`) +var InvalidWslDistroNames = []string{"docker-desktop", "docker-desktop-data"} type WshServer struct{} @@ -463,11 +466,28 @@ func (ws *WshServer) ConnStatusCommand(ctx context.Context) ([]wshrpc.ConnStatus return rtn, nil } +func (ws *WshServer) WslStatusCommand(ctx context.Context) ([]wshrpc.ConnStatus, error) { + rtn := wsl.GetAllConnStatus() + return rtn, nil +} + func (ws *WshServer) ConnEnsureCommand(ctx context.Context, connName string) error { + if strings.HasPrefix(connName, "wsl://") { + distroName := strings.TrimPrefix(connName, "wsl://") + return wsl.EnsureConnection(ctx, distroName) + } return conncontroller.EnsureConnection(ctx, connName) } func (ws *WshServer) ConnDisconnectCommand(ctx context.Context, connName string) error { + if strings.HasPrefix(connName, "wsl://") { + distroName := strings.TrimPrefix(connName, "wsl://") + conn := wsl.GetWslConn(ctx, distroName, false) + if conn == nil { + return fmt.Errorf("distro not found: %s", connName) + } + return conn.Close() + } connOpts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("error parsing connection name: %w", err) @@ -480,6 +500,14 @@ func (ws *WshServer) ConnDisconnectCommand(ctx context.Context, connName string) } func (ws *WshServer) ConnConnectCommand(ctx context.Context, connName string) error { + if strings.HasPrefix(connName, "wsl://") { + distroName := strings.TrimPrefix(connName, "wsl://") + conn := wsl.GetWslConn(ctx, distroName, false) + if conn == nil { + return fmt.Errorf("connection not found: %s", connName) + } + return conn.Connect(ctx) + } connOpts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("error parsing connection name: %w", err) @@ -492,6 +520,14 @@ func (ws *WshServer) ConnConnectCommand(ctx context.Context, connName string) er } func (ws *WshServer) ConnReinstallWshCommand(ctx context.Context, connName string) error { + if strings.HasPrefix(connName, "wsl://") { + distroName := strings.TrimPrefix(connName, "wsl://") + conn := wsl.GetWslConn(ctx, distroName, false) + if conn == nil { + return fmt.Errorf("connection not found: %s", connName) + } + return conn.CheckAndInstallWsh(ctx, connName, &wsl.WshInstallOpts{Force: true, NoUserPrompt: true}) + } connOpts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("error parsing connection name: %w", err) @@ -507,6 +543,33 @@ func (ws *WshServer) ConnListCommand(ctx context.Context) ([]string, error) { return conncontroller.GetConnectionsList() } +func (ws *WshServer) WslListCommand(ctx context.Context) ([]string, error) { + distros, err := wsl.RegisteredDistros(ctx) + if err != nil { + return nil, err + } + var distroNames []string + for _, distro := range distros { + distroName := distro.Name() + if utilfn.ContainsStr(InvalidWslDistroNames, distroName) { + continue + } + distroNames = append(distroNames, distroName) + } + return distroNames, nil +} + +func (ws *WshServer) WslDefaultDistroCommand(ctx context.Context) (string, error) { + distro, ok, err := wsl.DefaultDistro(ctx) + if err != nil { + return "", fmt.Errorf("unable to determine default distro: %w", err) + } + if !ok { + return "", fmt.Errorf("unable to determine default distro") + } + return distro.Name(), nil +} + func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wshrpc.BlockInfoData, error) { blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) if err != nil { diff --git a/pkg/wshutil/wshmultiproxy.go b/pkg/wshutil/wshmultiproxy.go new file mode 100644 index 000000000..be2888bf1 --- /dev/null +++ b/pkg/wshutil/wshmultiproxy.go @@ -0,0 +1,151 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshutil + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +type multiProxyRouteInfo struct { + RouteId string + AuthToken string + Proxy *WshRpcProxy + RpcContext *wshrpc.RpcContext +} + +// handles messages from multiple unauthenitcated clients +type WshRpcMultiProxy struct { + Lock *sync.Mutex + RouteInfo map[string]*multiProxyRouteInfo // authtoken to info + ToRemoteCh chan []byte + FromRemoteRawCh chan []byte // raw message from the remote +} + +func MakeRpcMultiProxy() *WshRpcMultiProxy { + return &WshRpcMultiProxy{ + Lock: &sync.Mutex{}, + RouteInfo: make(map[string]*multiProxyRouteInfo), + ToRemoteCh: make(chan []byte, DefaultInputChSize), + FromRemoteRawCh: make(chan []byte, DefaultOutputChSize), + } +} + +func (p *WshRpcMultiProxy) DisposeRoutes() { + p.Lock.Lock() + defer p.Lock.Unlock() + for authToken, routeInfo := range p.RouteInfo { + DefaultRouter.UnregisterRoute(routeInfo.RouteId) + delete(p.RouteInfo, authToken) + } +} + +func (p *WshRpcMultiProxy) getRouteInfo(authToken string) *multiProxyRouteInfo { + p.Lock.Lock() + defer p.Lock.Unlock() + return p.RouteInfo[authToken] +} + +func (p *WshRpcMultiProxy) setRouteInfo(authToken string, routeInfo *multiProxyRouteInfo) { + p.Lock.Lock() + defer p.Lock.Unlock() + p.RouteInfo[authToken] = routeInfo +} + +func (p *WshRpcMultiProxy) removeRouteInfo(authToken string) { + p.Lock.Lock() + defer p.Lock.Unlock() + delete(p.RouteInfo, authToken) +} + +func (p *WshRpcMultiProxy) sendResponseError(msg RpcMessage, sendErr error) { + if msg.ReqId == "" { + // no response needed + return + } + resp := RpcMessage{ + ResId: msg.ReqId, + Error: sendErr.Error(), + } + respBytes, _ := json.Marshal(resp) + p.ToRemoteCh <- respBytes +} + +func (p *WshRpcMultiProxy) sendAuthResponse(msg RpcMessage, routeId string, authToken string) { + if msg.ReqId == "" { + // no response needed + return + } + resp := RpcMessage{ + ResId: msg.ReqId, + Data: wshrpc.CommandAuthenticateRtnData{RouteId: routeId, AuthToken: authToken}, + } + respBytes, _ := json.Marshal(resp) + p.ToRemoteCh <- respBytes +} + +func (p *WshRpcMultiProxy) handleUnauthMessage(msgBytes []byte) { + var msg RpcMessage + err := json.Unmarshal(msgBytes, &msg) + if err != nil { + // nothing to do here, malformed message + return + } + if msg.Command == wshrpc.Command_Authenticate { + rpcContext, routeId, err := handleAuthenticationCommand(msg) + if err != nil { + p.sendResponseError(msg, err) + return + } + routeInfo := &multiProxyRouteInfo{ + RouteId: routeId, + AuthToken: uuid.New().String(), + RpcContext: rpcContext, + } + routeInfo.Proxy = MakeRpcProxy() + routeInfo.Proxy.SetRpcContext(rpcContext) + p.setRouteInfo(routeInfo.AuthToken, routeInfo) + p.sendAuthResponse(msg, routeId, routeInfo.AuthToken) + go func() { + for msgBytes := range routeInfo.Proxy.ToRemoteCh { + p.ToRemoteCh <- msgBytes + } + }() + DefaultRouter.RegisterRoute(routeId, routeInfo.Proxy, true) + return + } + if msg.AuthToken == "" { + p.sendResponseError(msg, fmt.Errorf("no auth token")) + return + } + routeInfo := p.getRouteInfo(msg.AuthToken) + if routeInfo == nil { + p.sendResponseError(msg, fmt.Errorf("invalid auth token")) + return + } + if msg.Command != "" && msg.Source != routeInfo.RouteId { + p.sendResponseError(msg, fmt.Errorf("invalid source route for auth token")) + return + } + if msg.Command == wshrpc.Command_Dispose { + DefaultRouter.UnregisterRoute(routeInfo.RouteId) + p.removeRouteInfo(msg.AuthToken) + close(routeInfo.Proxy.ToRemoteCh) + close(routeInfo.Proxy.FromRemoteCh) + return + } + routeInfo.Proxy.FromRemoteCh <- msgBytes +} + +func (p *WshRpcMultiProxy) RunUnauthLoop() { + // loop over unauthenticated message + // handle Authenicate commands, and pass authenticated messages to the AuthCh + for msgBytes := range p.FromRemoteRawCh { + p.handleUnauthMessage(msgBytes) + } +} diff --git a/pkg/wshutil/wshproxy.go b/pkg/wshutil/wshproxy.go index c6a1ecf9f..c919b5d07 100644 --- a/pkg/wshutil/wshproxy.go +++ b/pkg/wshutil/wshproxy.go @@ -6,7 +6,6 @@ package wshutil import ( "encoding/json" "fmt" - "log" "sync" "github.com/google/uuid" @@ -18,6 +17,7 @@ type WshRpcProxy struct { RpcContext *wshrpc.RpcContext ToRemoteCh chan []byte FromRemoteCh chan []byte + AuthToken string } func MakeRpcProxy() *WshRpcProxy { @@ -40,6 +40,18 @@ func (p *WshRpcProxy) GetRpcContext() *wshrpc.RpcContext { return p.RpcContext } +func (p *WshRpcProxy) SetAuthToken(authToken string) { + p.Lock.Lock() + defer p.Lock.Unlock() + p.AuthToken = authToken +} + +func (p *WshRpcProxy) GetAuthToken() string { + p.Lock.Lock() + defer p.Lock.Unlock() + return p.AuthToken +} + func (p *WshRpcProxy) sendResponseError(msg RpcMessage, sendErr error) { if msg.ReqId == "" { // no response needed @@ -54,7 +66,7 @@ func (p *WshRpcProxy) sendResponseError(msg RpcMessage, sendErr error) { p.SendRpcMessage(respBytes) } -func (p *WshRpcProxy) sendResponse(msg RpcMessage, routeId string) { +func (p *WshRpcProxy) sendAuthenticateResponse(msg RpcMessage, routeId string) { if msg.ReqId == "" { // no response needed return @@ -98,6 +110,49 @@ func handleAuthenticationCommand(msg RpcMessage) (*wshrpc.RpcContext, string, er return newCtx, routeId, nil } +// runs on the client (stdio client) +func (p *WshRpcProxy) HandleClientProxyAuth(router *WshRouter) (string, error) { + for { + msgBytes, ok := <-p.FromRemoteCh + if !ok { + return "", fmt.Errorf("remote closed, not authenticated") + } + var origMsg RpcMessage + err := json.Unmarshal(msgBytes, &origMsg) + if err != nil { + // nothing to do, can't even send a response since we don't have Source or ReqId + continue + } + if origMsg.Command == "" { + // this message is not allowed (protocol error at this point), ignore + continue + } + // we only allow one command "authenticate", everything else returns an error + if origMsg.Command != wshrpc.Command_Authenticate { + respErr := fmt.Errorf("connection not authenticated") + p.sendResponseError(origMsg, respErr) + continue + } + authRtn, err := router.HandleProxyAuth(origMsg.Data) + if err != nil { + respErr := fmt.Errorf("error handling proxy auth: %w", err) + p.sendResponseError(origMsg, respErr) + return "", respErr + } + p.SetAuthToken(authRtn.AuthToken) + announceMsg := RpcMessage{ + Command: wshrpc.Command_RouteAnnounce, + Source: authRtn.RouteId, + AuthToken: authRtn.AuthToken, + } + announceBytes, _ := json.Marshal(announceMsg) + router.InjectMessage(announceBytes, authRtn.RouteId) + p.sendAuthenticateResponse(origMsg, authRtn.RouteId) + return authRtn.RouteId, nil + } +} + +// runs on the server func (p *WshRpcProxy) HandleAuthentication() (*wshrpc.RpcContext, error) { for { msgBytes, ok := <-p.FromRemoteCh @@ -122,11 +177,10 @@ func (p *WshRpcProxy) HandleAuthentication() (*wshrpc.RpcContext, error) { } newCtx, routeId, err := handleAuthenticationCommand(msg) if err != nil { - log.Printf("error handling authentication: %v\n", err) p.sendResponseError(msg, err) continue } - p.sendResponse(msg, routeId) + p.sendAuthenticateResponse(msg, routeId) return newCtx, nil } } @@ -136,9 +190,10 @@ func (p *WshRpcProxy) SendRpcMessage(msg []byte) { } func (p *WshRpcProxy) RecvRpcMessage() ([]byte, bool) { - msgBytes, ok := <-p.FromRemoteCh - if !ok || p.RpcContext == nil { - return msgBytes, ok + msgBytes, more := <-p.FromRemoteCh + authToken := p.GetAuthToken() + if !more || (p.RpcContext == nil && authToken == "") { + return msgBytes, more } var msg RpcMessage err := json.Unmarshal(msgBytes, &msg) @@ -146,10 +201,15 @@ func (p *WshRpcProxy) RecvRpcMessage() ([]byte, bool) { // nothing to do here -- will error out at another level return msgBytes, true } - msg.Data, err = recodeCommandData(msg.Command, msg.Data, p.RpcContext) - if err != nil { - // nothing to do here -- will error out at another level - return msgBytes, true + if p.RpcContext != nil { + msg.Data, err = recodeCommandData(msg.Command, msg.Data, p.RpcContext) + if err != nil { + // nothing to do here -- will error out at another level + return msgBytes, true + } + } + if msg.AuthToken == "" { + msg.AuthToken = authToken } newBytes, err := json.Marshal(msg) if err != nil { diff --git a/pkg/wshutil/wshrouter.go b/pkg/wshutil/wshrouter.go index da213943b..64479f498 100644 --- a/pkg/wshutil/wshrouter.go +++ b/pkg/wshutil/wshrouter.go @@ -12,11 +12,14 @@ import ( "sync" "time" + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) const DefaultRoute = "wavesrv" +const UpstreamRoute = "upstream" const SysRoute = "sys" // this route doesn't exist, just a placeholder for system messages const ElectronRoute = "electron" @@ -36,12 +39,13 @@ type msgAndRoute struct { } type WshRouter struct { - Lock *sync.Mutex - RouteMap map[string]AbstractRpcClient // routeid => client - UpstreamClient AbstractRpcClient // upstream client (if we are not the terminal router) - AnnouncedRoutes map[string]string // routeid => local routeid - RpcMap map[string]*routeInfo // rpcid => routeinfo - InputCh chan msgAndRoute + Lock *sync.Mutex + RouteMap map[string]AbstractRpcClient // routeid => client + UpstreamClient AbstractRpcClient // upstream client (if we are not the terminal router) + AnnouncedRoutes map[string]string // routeid => local routeid + RpcMap map[string]*routeInfo // rpcid => routeinfo + SimpleRequestMap map[string]chan *RpcMessage // simple reqid => response channel + InputCh chan msgAndRoute } func MakeConnectionRouteId(connId string) string { @@ -68,11 +72,12 @@ var DefaultRouter = NewWshRouter() func NewWshRouter() *WshRouter { rtn := &WshRouter{ - Lock: &sync.Mutex{}, - RouteMap: make(map[string]AbstractRpcClient), - AnnouncedRoutes: make(map[string]string), - RpcMap: make(map[string]*routeInfo), - InputCh: make(chan msgAndRoute, DefaultInputChSize), + Lock: &sync.Mutex{}, + RouteMap: make(map[string]AbstractRpcClient), + AnnouncedRoutes: make(map[string]string), + RpcMap: make(map[string]*routeInfo), + SimpleRequestMap: make(map[string]chan *RpcMessage), + InputCh: make(chan msgAndRoute, DefaultInputChSize), } go rtn.runServer() return rtn @@ -237,6 +242,10 @@ func (router *WshRouter) runServer() { router.sendRoutedMessage(msgBytes, routeInfo.DestRouteId) continue } else if msg.ResId != "" { + ok := router.trySimpleResponse(&msg) + if ok { + continue + } routeInfo := router.getRouteInfo(msg.ResId) if routeInfo == nil { // no route info, nothing to do @@ -269,10 +278,10 @@ func (router *WshRouter) WaitForRegister(ctx context.Context, routeId string) er } // this will also consume the output channel of the abstract client -func (router *WshRouter) RegisterRoute(routeId string, rpc AbstractRpcClient) { - if routeId == SysRoute { +func (router *WshRouter) RegisterRoute(routeId string, rpc AbstractRpcClient, shouldAnnounce bool) { + if routeId == SysRoute || routeId == UpstreamRoute { // cannot register sys route - log.Printf("error: WshRouter cannot register sys route\n") + log.Printf("error: WshRouter cannot register %s route\n", routeId) return } log.Printf("[router] registering wsh route %q\n", routeId) @@ -285,7 +294,7 @@ func (router *WshRouter) RegisterRoute(routeId string, rpc AbstractRpcClient) { router.RouteMap[routeId] = rpc go func() { // announce - if !alreadyExists && router.GetUpstreamClient() != nil { + if shouldAnnounce && !alreadyExists && router.GetUpstreamClient() != nil { announceMsg := RpcMessage{Command: wshrpc.Command_RouteAnnounce, Source: routeId} announceBytes, _ := json.Marshal(announceMsg) router.GetUpstreamClient().SendRpcMessage(announceBytes) @@ -352,3 +361,97 @@ func (router *WshRouter) GetUpstreamClient() AbstractRpcClient { defer router.Lock.Unlock() return router.UpstreamClient } + +func (router *WshRouter) InjectMessage(msgBytes []byte, fromRouteId string) { + router.InputCh <- msgAndRoute{msgBytes: msgBytes, fromRouteId: fromRouteId} +} + +func (router *WshRouter) registerSimpleRequest(reqId string) chan *RpcMessage { + router.Lock.Lock() + defer router.Lock.Unlock() + rtn := make(chan *RpcMessage, 1) + router.SimpleRequestMap[reqId] = rtn + return rtn +} + +func (router *WshRouter) trySimpleResponse(msg *RpcMessage) bool { + router.Lock.Lock() + defer router.Lock.Unlock() + respCh := router.SimpleRequestMap[msg.ResId] + if respCh == nil { + return false + } + respCh <- msg + delete(router.SimpleRequestMap, msg.ResId) + return true +} + +func (router *WshRouter) clearSimpleRequest(reqId string) { + router.Lock.Lock() + defer router.Lock.Unlock() + delete(router.SimpleRequestMap, reqId) +} + +func (router *WshRouter) RunSimpleRawCommand(ctx context.Context, msg RpcMessage, fromRouteId string) (*RpcMessage, error) { + if msg.Command == "" { + return nil, errors.New("no command") + } + msgBytes, err := json.Marshal(msg) + if err != nil { + return nil, err + } + var respCh chan *RpcMessage + if msg.ReqId != "" { + respCh = router.registerSimpleRequest(msg.ReqId) + } + router.InjectMessage(msgBytes, fromRouteId) + if respCh == nil { + return nil, nil + } + select { + case <-ctx.Done(): + router.clearSimpleRequest(msg.ReqId) + return nil, ctx.Err() + case resp := <-respCh: + if resp.Error != "" { + return nil, errors.New(resp.Error) + } + return resp, nil + } +} + +func (router *WshRouter) HandleProxyAuth(jwtTokenAny any) (*wshrpc.CommandAuthenticateRtnData, error) { + if jwtTokenAny == nil { + return nil, errors.New("no jwt token") + } + jwtToken, ok := jwtTokenAny.(string) + if !ok { + return nil, errors.New("jwt token not a string") + } + if jwtToken == "" { + return nil, errors.New("empty jwt token") + } + msg := RpcMessage{ + Command: wshrpc.Command_Authenticate, + ReqId: uuid.New().String(), + Data: jwtToken, + } + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeoutMs*time.Millisecond) + defer cancelFn() + resp, err := router.RunSimpleRawCommand(ctx, msg, "") + if err != nil { + return nil, err + } + if resp == nil || resp.Data == nil { + return nil, errors.New("no data in authenticate response") + } + var respData wshrpc.CommandAuthenticateRtnData + err = utilfn.ReUnmarshal(&respData, resp.Data) + if err != nil { + return nil, fmt.Errorf("error unmarshalling authenticate response: %v", err) + } + if respData.AuthToken == "" { + return nil, errors.New("no auth token in authenticate response") + } + return &respData, nil +} diff --git a/pkg/wshutil/wshrpc.go b/pkg/wshutil/wshrpc.go index cccc353e7..7d31246ac 100644 --- a/pkg/wshutil/wshrpc.go +++ b/pkg/wshutil/wshrpc.go @@ -45,10 +45,13 @@ type WshRpc struct { InputCh chan []byte OutputCh chan []byte RpcContext *atomic.Pointer[wshrpc.RpcContext] + AuthToken string RpcMap map[string]*rpcData ServerImpl ServerImpl EventListener *EventListener ResponseHandlerMap map[string]*RpcResponseHandler // reqId => handler + Debug bool + DebugName string } type wshRpcContextKey struct{} @@ -104,17 +107,18 @@ func (w *WshRpc) RecvRpcMessage() ([]byte, bool) { } type RpcMessage struct { - Command string `json:"command,omitempty"` - ReqId string `json:"reqid,omitempty"` - ResId string `json:"resid,omitempty"` - Timeout int `json:"timeout,omitempty"` - Route string `json:"route,omitempty"` // to route/forward requests to alternate servers - Source string `json:"source,omitempty"` // source route id - Cont bool `json:"cont,omitempty"` // flag if additional requests/responses are forthcoming - Cancel bool `json:"cancel,omitempty"` // used to cancel a streaming request or response (sent from the side that is not streaming) - Error string `json:"error,omitempty"` - DataType string `json:"datatype,omitempty"` - Data any `json:"data,omitempty"` + Command string `json:"command,omitempty"` + ReqId string `json:"reqid,omitempty"` + ResId string `json:"resid,omitempty"` + Timeout int `json:"timeout,omitempty"` + Route string `json:"route,omitempty"` // to route/forward requests to alternate servers + AuthToken string `json:"authtoken,omitempty"` // needed for routing unauthenticated requests (WshRpcMultiProxy) + Source string `json:"source,omitempty"` // source route id + Cont bool `json:"cont,omitempty"` // flag if additional requests/responses are forthcoming + Cancel bool `json:"cancel,omitempty"` // used to cancel a streaming request or response (sent from the side that is not streaming) + Error string `json:"error,omitempty"` + DataType string `json:"datatype,omitempty"` + Data any `json:"data,omitempty"` } func (r *RpcMessage) IsRpcRequest() bool { @@ -226,6 +230,14 @@ func (w *WshRpc) SetRpcContext(ctx wshrpc.RpcContext) { w.RpcContext.Store(&ctx) } +func (w *WshRpc) SetAuthToken(token string) { + w.AuthToken = token +} + +func (w *WshRpc) GetAuthToken() string { + return w.AuthToken +} + func (w *WshRpc) registerResponseHandler(reqId string, handler *RpcResponseHandler) { w.Lock.Lock() defer w.Lock.Unlock() @@ -323,6 +335,9 @@ func (w *WshRpc) handleRequest(req *RpcMessage) { func (w *WshRpc) runServer() { defer close(w.OutputCh) for msgBytes := range w.InputCh { + if w.Debug { + log.Printf("[%s] received message: %s\n", w.DebugName, string(msgBytes)) + } var msg RpcMessage err := json.Unmarshal(msgBytes, &msg) if err != nil { @@ -455,8 +470,9 @@ func (handler *RpcRequestHandler) SendCancel() { } }() msg := &RpcMessage{ - Cancel: true, - ReqId: handler.reqId, + Cancel: true, + ReqId: handler.reqId, + AuthToken: handler.w.GetAuthToken(), } barr, _ := json.Marshal(msg) // will never fail handler.w.OutputCh <- barr @@ -550,6 +566,7 @@ func (handler *RpcResponseHandler) SendMessage(msg string) { Data: wshrpc.CommandMessageData{ Message: msg, }, + AuthToken: handler.w.GetAuthToken(), } msgBytes, _ := json.Marshal(rpcMsg) // will never fail handler.w.OutputCh <- msgBytes @@ -573,9 +590,10 @@ func (handler *RpcResponseHandler) SendResponse(data any, done bool) error { defer handler.close() } msg := &RpcMessage{ - ResId: handler.reqId, - Data: data, - Cont: !done, + ResId: handler.reqId, + Data: data, + Cont: !done, + AuthToken: handler.w.GetAuthToken(), } barr, err := json.Marshal(msg) if err != nil { @@ -598,8 +616,9 @@ func (handler *RpcResponseHandler) SendResponseError(err error) { } defer handler.close() msg := &RpcMessage{ - ResId: handler.reqId, - Error: err.Error(), + ResId: handler.reqId, + Error: err.Error(), + AuthToken: handler.w.GetAuthToken(), } barr, _ := json.Marshal(msg) // will never fail handler.w.OutputCh <- barr @@ -660,11 +679,12 @@ func (w *WshRpc) SendComplexRequest(command string, data any, opts *wshrpc.RpcOp handler.reqId = uuid.New().String() } req := &RpcMessage{ - Command: command, - ReqId: handler.reqId, - Data: data, - Timeout: timeoutMs, - Route: opts.Route, + Command: command, + ReqId: handler.reqId, + Data: data, + Timeout: timeoutMs, + Route: opts.Route, + AuthToken: w.GetAuthToken(), } barr, err := json.Marshal(req) if err != nil { diff --git a/pkg/wshutil/wshutil.go b/pkg/wshutil/wshutil.go index 79cdc6080..8be9c908a 100644 --- a/pkg/wshutil/wshutil.go +++ b/pkg/wshutil/wshutil.go @@ -19,6 +19,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/util/packetparser" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" "golang.org/x/term" @@ -204,11 +205,26 @@ func SetupTerminalRpcClient(serverImpl ServerImpl) (*WshRpc, io.Reader) { continue } os.Stdout.Write(barr) + os.Stdout.Write([]byte{'\n'}) } }() return rpcClient, ptyBuf } +func SetupPacketRpcClient(input io.Reader, output io.Writer, serverImpl ServerImpl) (*WshRpc, chan []byte) { + messageCh := make(chan []byte, DefaultInputChSize) + outputCh := make(chan []byte, DefaultOutputChSize) + rawCh := make(chan []byte, DefaultOutputChSize) + rpcClient := MakeWshRpc(messageCh, outputCh, wshrpc.RpcContext{}, serverImpl) + go packetparser.Parse(input, messageCh, rawCh) + go func() { + for msg := range outputCh { + packetparser.WritePacket(output, msg) + } + }() + return rpcClient, rawCh +} + func SetupConnRpcClient(conn net.Conn, serverImpl ServerImpl) (*WshRpc, chan error, error) { inputCh := make(chan []byte, DefaultInputChSize) outputCh := make(chan []byte, DefaultOutputChSize) @@ -229,10 +245,22 @@ func SetupConnRpcClient(conn net.Conn, serverImpl ServerImpl) (*WshRpc, chan err return rtn, writeErrCh, nil } -func SetupDomainSocketRpcClient(sockName string, serverImpl ServerImpl) (*WshRpc, error) { - conn, err := net.Dial("unix", sockName) +func tryTcpSocket(sockName string) (net.Conn, error) { + addr, err := net.ResolveTCPAddr("tcp", sockName) if err != nil { - return nil, fmt.Errorf("failed to connect to Unix domain socket: %w", err) + return nil, err + } + return net.DialTCP("tcp", nil, addr) +} + +func SetupDomainSocketRpcClient(sockName string, serverImpl ServerImpl) (*WshRpc, error) { + conn, tcpErr := tryTcpSocket(sockName) + var unixErr error + if tcpErr != nil { + conn, unixErr = net.Dial("unix", sockName) + } + if tcpErr != nil && unixErr != nil { + return nil, fmt.Errorf("failed to connect to tcp or unix domain socket: tcp err:%w: unix socket err: %w", tcpErr, unixErr) } rtn, errCh, err := SetupConnRpcClient(conn, serverImpl) go func() { @@ -363,6 +391,46 @@ func MakeRouteIdFromCtx(rpcCtx *wshrpc.RpcContext) (string, error) { return MakeProcRouteId(procId), nil } +type WriteFlusher interface { + Write([]byte) (int, error) + Flush() error +} + +// blocking, returns if there is an error, or on EOF of input +func HandleStdIOClient(logName string, input io.Reader, output io.Writer) { + proxy := MakeRpcMultiProxy() + rawCh := make(chan []byte, DefaultInputChSize) + go packetparser.Parse(input, proxy.FromRemoteRawCh, rawCh) + doneCh := make(chan struct{}) + var doneOnce sync.Once + closeDoneCh := func() { + doneOnce.Do(func() { + close(doneCh) + }) + proxy.DisposeRoutes() + } + go func() { + proxy.RunUnauthLoop() + }() + go func() { + defer closeDoneCh() + for msg := range proxy.ToRemoteCh { + err := packetparser.WritePacket(output, msg) + if err != nil { + log.Printf("[%s] error writing to output: %v\n", logName, err) + break + } + } + }() + go func() { + defer closeDoneCh() + for msg := range rawCh { + log.Printf("[%s:stdout] %s", logName, msg) + } + }() + <-doneCh +} + func handleDomainSocketClient(conn net.Conn) { var routeIdContainer atomic.Pointer[string] proxy := MakeRpcProxy() @@ -399,7 +467,7 @@ func handleDomainSocketClient(conn net.Conn) { return } routeIdContainer.Store(&routeId) - DefaultRouter.RegisterRoute(routeId, proxy) + DefaultRouter.RegisterRoute(routeId, proxy, true) } // only for use on client @@ -433,5 +501,6 @@ func ExtractUnverifiedSocketName(tokenStr string) (string, error) { if !ok { return "", fmt.Errorf("sock claim is missing or invalid") } + sockName = wavebase.ExpandHomeDirSafe(sockName) return sockName, nil } diff --git a/pkg/wsl/wsl-unix.go b/pkg/wsl/wsl-unix.go new file mode 100644 index 000000000..055e46669 --- /dev/null +++ b/pkg/wsl/wsl-unix.go @@ -0,0 +1,67 @@ +//go:build !windows + +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wsl + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" +) + +func RegisteredDistros(ctx context.Context) (distros []Distro, err error) { + return nil, fmt.Errorf("RegisteredDistros not implemented on this system") +} + +func DefaultDistro(ctx context.Context) (d Distro, ok bool, err error) { + return d, false, fmt.Errorf("DefaultDistro not implemented on this system") +} + +type Distro struct{} + +func (d *Distro) Name() string { + return "" +} + +func (d *Distro) WslCommand(ctx context.Context, cmd string) *WslCmd { + return nil +} + +// just use the regular cmd since it's +// similar enough to not cause issues +// type WslCmd = exec.Cmd +type WslCmd struct { + exec.Cmd +} + +func (wc *WslCmd) GetProcess() *os.Process { + return nil +} + +func (wc *WslCmd) GetProcessState() *os.ProcessState { + return nil +} + +func (c *WslCmd) SetStdin(stdin io.Reader) { + c.Stdin = stdin +} + +func (c *WslCmd) SetStdout(stdout io.Writer) { + c.Stdout = stdout +} + +func (c *WslCmd) SetStderr(stderr io.Writer) { + c.Stdout = stderr +} + +func GetDistroCmd(ctx context.Context, wslDistroName string, cmd string) (*WslCmd, error) { + return nil, fmt.Errorf("GetDistroCmd not implemented on this system") +} + +func GetDistro(ctx context.Context, wslDistroName WslName) (*Distro, error) { + return nil, fmt.Errorf("GetDistro not implemented on this system") +} diff --git a/pkg/wsl/wsl-util.go b/pkg/wsl/wsl-util.go new file mode 100644 index 000000000..5d1f70d35 --- /dev/null +++ b/pkg/wsl/wsl-util.go @@ -0,0 +1,296 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wsl + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" +) + +func DetectShell(ctx context.Context, client *Distro) (string, error) { + wshPath := GetWshPath(ctx, client) + + cmd := client.WslCommand(ctx, wshPath+" shell") + log.Printf("shell detecting using command: %s shell", wshPath) + out, err := cmd.Output() + if err != nil { + log.Printf("unable to determine shell. defaulting to /bin/bash: %s", err) + return "/bin/bash", nil + } + log.Printf("detecting shell: %s", out) + + // quoting breaks this particular case + return strings.TrimSpace(string(out)), nil +} + +func GetWshVersion(ctx context.Context, client *Distro) (string, error) { + wshPath := GetWshPath(ctx, client) + + cmd := client.WslCommand(ctx, wshPath+" version") + out, err := cmd.Output() + if err != nil { + return "", err + } + + return strings.TrimSpace(string(out)), nil +} + +func GetWshPath(ctx context.Context, client *Distro) string { + defaultPath := "~/.waveterm/bin/wsh" + + cmd := client.WslCommand(ctx, "which wsh") + out, whichErr := cmd.Output() + if whichErr == nil { + return strings.TrimSpace(string(out)) + } + + cmd = client.WslCommand(ctx, "where.exe wsh") + out, whereErr := cmd.Output() + if whereErr == nil { + return strings.TrimSpace(string(out)) + } + + // check cmd on windows since it requires an absolute path with backslashes + cmd = client.WslCommand(ctx, "(dir 2>&1 *``|echo %userprofile%\\.waveterm%\\.waveterm\\bin\\wsh.exe);&<# rem #>echo none") + out, cmdErr := cmd.Output() + if cmdErr == nil && strings.TrimSpace(string(out)) != "none" { + return strings.TrimSpace(string(out)) + } + + // no custom install, use default path + return defaultPath +} + +func hasBashInstalled(ctx context.Context, client *Distro) (bool, error) { + cmd := client.WslCommand(ctx, "which bash") + out, whichErr := cmd.Output() + if whichErr == nil && len(out) != 0 { + return true, nil + } + + cmd = client.WslCommand(ctx, "where.exe bash") + out, whereErr := cmd.Output() + if whereErr == nil && len(out) != 0 { + return true, nil + } + + // note: we could also check in /bin/bash explicitly + // just in case that wasn't added to the path. but if + // that's true, we will most likely have worse + // problems going forward + + return false, nil +} + +func GetClientOs(ctx context.Context, client *Distro) (string, error) { + cmd := client.WslCommand(ctx, "uname -s") + out, unixErr := cmd.Output() + if unixErr == nil { + formatted := strings.ToLower(string(out)) + formatted = strings.TrimSpace(formatted) + return formatted, nil + } + + cmd = client.WslCommand(ctx, "echo %OS%") + out, cmdErr := cmd.Output() + if cmdErr == nil && strings.TrimSpace(string(out)) != "%OS%" { + formatted := strings.ToLower(string(out)) + formatted = strings.TrimSpace(formatted) + return strings.Split(formatted, "_")[0], nil + } + + cmd = client.WslCommand(ctx, "echo $env:OS") + out, psErr := cmd.Output() + if psErr == nil && strings.TrimSpace(string(out)) != "$env:OS" { + formatted := strings.ToLower(string(out)) + formatted = strings.TrimSpace(formatted) + return strings.Split(formatted, "_")[0], nil + } + return "", fmt.Errorf("unable to determine os: {unix: %s, cmd: %s, powershell: %s}", unixErr, cmdErr, psErr) +} + +func GetClientArch(ctx context.Context, client *Distro) (string, error) { + cmd := client.WslCommand(ctx, "uname -m") + out, unixErr := cmd.Output() + if unixErr == nil { + formatted := strings.ToLower(string(out)) + formatted = strings.TrimSpace(formatted) + if formatted == "x86_64" { + return "x64", nil + } + return formatted, nil + } + + cmd = client.WslCommand(ctx, "echo %PROCESSOR_ARCHITECTURE%") + out, cmdErr := cmd.Output() + if cmdErr == nil && strings.TrimSpace(string(out)) != "%PROCESSOR_ARCHITECTURE%" { + formatted := strings.ToLower(string(out)) + return strings.TrimSpace(formatted), nil + } + + cmd = client.WslCommand(ctx, "echo $env:PROCESSOR_ARCHITECTURE") + out, psErr := cmd.Output() + if psErr == nil && strings.TrimSpace(string(out)) != "$env:PROCESSOR_ARCHITECTURE" { + formatted := strings.ToLower(string(out)) + return strings.TrimSpace(formatted), nil + } + return "", fmt.Errorf("unable to determine architecture: {unix: %s, cmd: %s, powershell: %s}", unixErr, cmdErr, psErr) +} + +type CancellableCmd struct { + Cmd *WslCmd + Cancel func() +} + +var installTemplatesRawBash = map[string]string{ + "mkdir": `bash -c 'mkdir -p {{.installDir}}'`, + "cat": `bash -c 'cat > {{.tempPath}}'`, + "mv": `bash -c 'mv {{.tempPath}} {{.installPath}}'`, + "chmod": `bash -c 'chmod a+x {{.installPath}}'`, +} + +var installTemplatesRawDefault = map[string]string{ + "mkdir": `mkdir -p {{.installDir}}`, + "cat": `cat > {{.tempPath}}`, + "mv": `mv {{.tempPath}} {{.installPath}}`, + "chmod": `chmod a+x {{.installPath}}`, +} + +func makeCancellableCommand(ctx context.Context, client *Distro, cmdTemplateRaw string, words map[string]string) (*CancellableCmd, error) { + cmdContext, cmdCancel := context.WithCancel(ctx) + + cmdStr := &bytes.Buffer{} + cmdTemplate, err := template.New("").Parse(cmdTemplateRaw) + if err != nil { + cmdCancel() + return nil, err + } + cmdTemplate.Execute(cmdStr, words) + + cmd := client.WslCommand(cmdContext, cmdStr.String()) + return &CancellableCmd{cmd, cmdCancel}, nil +} + +func CpHostToRemote(ctx context.Context, client *Distro, sourcePath string, destPath string) error { + // warning: does not work on windows remote yet + bashInstalled, err := hasBashInstalled(ctx, client) + if err != nil { + return err + } + + var selectedTemplatesRaw map[string]string + if bashInstalled { + selectedTemplatesRaw = installTemplatesRawBash + } else { + log.Printf("bash is not installed on remote. attempting with default shell") + selectedTemplatesRaw = installTemplatesRawDefault + } + + // I need to use toSlash here to force unix keybindings + // this means we can't guarantee it will work on a remote windows machine + var installWords = map[string]string{ + "installDir": filepath.ToSlash(filepath.Dir(destPath)), + "tempPath": destPath + ".temp", + "installPath": destPath, + } + + installStepCmds := make(map[string]*CancellableCmd) + for cmdName, selectedTemplateRaw := range selectedTemplatesRaw { + cancellableCmd, err := makeCancellableCommand(ctx, client, selectedTemplateRaw, installWords) + if err != nil { + return err + } + installStepCmds[cmdName] = cancellableCmd + } + + _, err = installStepCmds["mkdir"].Cmd.Output() + if err != nil { + return err + } + + // the cat part of this is complicated since it requires stdin + catCmd := installStepCmds["cat"].Cmd + catStdin, err := catCmd.StdinPipe() + if err != nil { + return err + } + err = catCmd.Start() + if err != nil { + return err + } + input, err := os.Open(sourcePath) + if err != nil { + return fmt.Errorf("cannot open local file %s to send to host: %v", sourcePath, err) + } + go func() { + io.Copy(catStdin, input) + installStepCmds["cat"].Cancel() + + // backup just in case something weird happens + // could cause potential race condition, but very + // unlikely + time.Sleep(time.Second * 1) + process := catCmd.GetProcess() + if process != nil { + process.Kill() + } + }() + catErr := catCmd.Wait() + if catErr != nil && !errors.Is(catErr, context.Canceled) { + return catErr + } + + _, err = installStepCmds["mv"].Cmd.Output() + if err != nil { + return err + } + + _, err = installStepCmds["chmod"].Cmd.Output() + if err != nil { + return err + } + + return nil +} + +func InstallClientRcFiles(ctx context.Context, client *Distro) error { + path := GetWshPath(ctx, client) + log.Printf("path to wsh searched is: %s", path) + + cmd := client.WslCommand(ctx, path+" rcfiles") + _, err := cmd.Output() + return err +} + +func GetHomeDir(ctx context.Context, client *Distro) string { + // note: also works for powershell + cmd := client.WslCommand(ctx, `echo "$HOME"`) + out, err := cmd.Output() + if err == nil { + return strings.TrimSpace(string(out)) + } + + cmd = client.WslCommand(ctx, `echo %userprofile%`) + out, err = cmd.Output() + if err == nil { + return strings.TrimSpace(string(out)) + } + + return "~" +} + +func IsPowershell(shellPath string) bool { + // get the base path, and then check contains + shellBase := filepath.Base(shellPath) + return strings.Contains(shellBase, "powershell") || strings.Contains(shellBase, "pwsh") +} diff --git a/pkg/wsl/wsl-win.go b/pkg/wsl/wsl-win.go new file mode 100644 index 000000000..782e15719 --- /dev/null +++ b/pkg/wsl/wsl-win.go @@ -0,0 +1,125 @@ +//go:build windows + +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wsl + +import ( + "context" + "fmt" + "io" + "os" + "sync" + + "github.com/ubuntu/gowsl" +) + +var RegisteredDistros = gowsl.RegisteredDistros +var DefaultDistro = gowsl.DefaultDistro + +type Distro struct { + gowsl.Distro +} + +type WslCmd struct { + c *gowsl.Cmd + wg *sync.WaitGroup + once *sync.Once + lock *sync.Mutex + waitErr error +} + +func (d *Distro) WslCommand(ctx context.Context, cmd string) *WslCmd { + if ctx == nil { + panic("nil Context") + } + innerCmd := d.Command(ctx, cmd) + var wg sync.WaitGroup + var lock *sync.Mutex + return &WslCmd{innerCmd, &wg, new(sync.Once), lock, nil} +} + +func (c *WslCmd) CombinedOutput() (out []byte, err error) { + return c.c.CombinedOutput() +} +func (c *WslCmd) Output() (out []byte, err error) { + return c.c.Output() +} +func (c *WslCmd) Run() error { + return c.c.Run() +} +func (c *WslCmd) Start() (err error) { + return c.c.Start() +} +func (c *WslCmd) StderrPipe() (r io.ReadCloser, err error) { + return c.c.StderrPipe() +} +func (c *WslCmd) StdinPipe() (w io.WriteCloser, err error) { + return c.c.StdinPipe() +} +func (c *WslCmd) StdoutPipe() (r io.ReadCloser, err error) { + return c.c.StdoutPipe() +} +func (c *WslCmd) Wait() (err error) { + c.wg.Add(1) + c.once.Do(func() { + c.waitErr = c.c.Wait() + }) + c.wg.Done() + c.wg.Wait() + if c.waitErr != nil && c.waitErr.Error() == "not started" { + c.once = new(sync.Once) + return c.waitErr + } + return c.waitErr +} +func (c *WslCmd) GetProcess() *os.Process { + return c.c.Process +} + +func (c *WslCmd) GetProcessState() *os.ProcessState { + return c.c.ProcessState +} + +func (c *WslCmd) SetStdin(stdin io.Reader) { + c.c.Stdin = stdin +} + +func (c *WslCmd) SetStdout(stdout io.Writer) { + c.c.Stdout = stdout +} + +func (c *WslCmd) SetStderr(stderr io.Writer) { + c.c.Stdout = stderr +} + +func GetDistroCmd(ctx context.Context, wslDistroName string, cmd string) (*WslCmd, error) { + distros, err := RegisteredDistros(ctx) + if err != nil { + return nil, err + } + for _, distro := range distros { + if distro.Name() != wslDistroName { + continue + } + wrappedDistro := Distro{distro} + return wrappedDistro.WslCommand(ctx, cmd), nil + } + return nil, fmt.Errorf("wsl distro %s not found", wslDistroName) +} + +func GetDistro(ctx context.Context, wslDistroName WslName) (*Distro, error) { + distros, err := RegisteredDistros(ctx) + if err != nil { + return nil, err + } + for _, distro := range distros { + if distro.Name() != wslDistroName.Distro { + continue + } + wrappedDistro := Distro{distro} + return &wrappedDistro, nil + } + return nil, fmt.Errorf("wsl distro %s not found", wslDistroName) +} diff --git a/pkg/wsl/wsl.go b/pkg/wsl/wsl.go new file mode 100644 index 000000000..0f5927ebb --- /dev/null +++ b/pkg/wsl/wsl.go @@ -0,0 +1,494 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wsl + +import ( + "context" + "fmt" + "io" + "log" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/wavetermdev/waveterm/pkg/userinput" + "github.com/wavetermdev/waveterm/pkg/util/shellutil" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +const ( + Status_Init = "init" + Status_Connecting = "connecting" + Status_Connected = "connected" + Status_Disconnected = "disconnected" + Status_Error = "error" +) + +const DefaultConnectionTimeout = 60 * time.Second + +var globalLock = &sync.Mutex{} +var clientControllerMap = make(map[string]*WslConn) +var activeConnCounter = &atomic.Int32{} + +type WslConn struct { + Lock *sync.Mutex + Status string + Name WslName + Client *Distro + SockName string + DomainSockListener net.Listener + ConnController *WslCmd + Error string + HasWaiter *atomic.Bool + LastConnectTime int64 + ActiveConnNum int + Context context.Context + cancelFn func() +} + +type WslName struct { + Distro string `json:"distro"` +} + +func GetAllConnStatus() []wshrpc.ConnStatus { + globalLock.Lock() + defer globalLock.Unlock() + + var connStatuses []wshrpc.ConnStatus + for _, conn := range clientControllerMap { + connStatuses = append(connStatuses, conn.DeriveConnStatus()) + } + return connStatuses +} + +func (conn *WslConn) DeriveConnStatus() wshrpc.ConnStatus { + conn.Lock.Lock() + defer conn.Lock.Unlock() + return wshrpc.ConnStatus{ + Status: conn.Status, + Connected: conn.Status == Status_Connected, + Connection: conn.GetName(), + HasConnected: (conn.LastConnectTime > 0), + ActiveConnNum: conn.ActiveConnNum, + Error: conn.Error, + } +} + +func (conn *WslConn) FireConnChangeEvent() { + status := conn.DeriveConnStatus() + event := wps.WaveEvent{ + Event: wps.Event_ConnChange, + Scopes: []string{ + fmt.Sprintf("connection:%s", conn.GetName()), + }, + Data: status, + } + log.Printf("sending event: %+#v", event) + wps.Broker.Publish(event) +} + +func (conn *WslConn) Close() error { + defer conn.FireConnChangeEvent() + conn.WithLock(func() { + if conn.Status == Status_Connected || conn.Status == Status_Connecting { + // if status is init, disconnected, or error don't change it + conn.Status = Status_Disconnected + } + conn.close_nolock() + }) + // we must wait for the waiter to complete + startTime := time.Now() + for conn.HasWaiter.Load() { + time.Sleep(10 * time.Millisecond) + if time.Since(startTime) > 2*time.Second { + return fmt.Errorf("timeout waiting for waiter to complete") + } + } + return nil +} + +func (conn *WslConn) close_nolock() { + // does not set status (that should happen at another level) + if conn.DomainSockListener != nil { + conn.DomainSockListener.Close() + conn.DomainSockListener = nil + } + if conn.ConnController != nil { + conn.cancelFn() // this suspends the conn controller + conn.ConnController = nil + } + if conn.Client != nil { + // conn.Client.Close() is not relevant here + // we do not want to completely close the wsl in case + // other applications are using it + conn.Client = nil + } +} + +func (conn *WslConn) GetDomainSocketName() string { + conn.Lock.Lock() + defer conn.Lock.Unlock() + return conn.SockName +} + +func (conn *WslConn) GetStatus() string { + conn.Lock.Lock() + defer conn.Lock.Unlock() + return conn.Status +} + +func (conn *WslConn) GetName() string { + // no lock required because opts is immutable + return "wsl://" + conn.Name.Distro +} + +/** + * This function is does not set a listener for WslConn + * It is still required in order to set SockName +**/ +func (conn *WslConn) OpenDomainSocketListener() error { + var allowed bool + conn.WithLock(func() { + if conn.Status != Status_Connecting { + allowed = false + } else { + allowed = true + } + }) + if !allowed { + return fmt.Errorf("cannot open domain socket for %q when status is %q", conn.GetName(), conn.GetStatus()) + } + conn.WithLock(func() { + conn.SockName = "~/.waveterm/wave-remote.sock" + }) + return nil +} + +func (conn *WslConn) StartConnServer() error { + var allowed bool + conn.WithLock(func() { + if conn.Status != Status_Connecting { + allowed = false + } else { + allowed = true + } + }) + if !allowed { + return fmt.Errorf("cannot start conn server for %q when status is %q", conn.GetName(), conn.GetStatus()) + } + client := conn.GetClient() + wshPath := GetWshPath(conn.Context, client) + rpcCtx := wshrpc.RpcContext{ + ClientType: wshrpc.ClientType_ConnServer, + Conn: conn.GetName(), + } + sockName := conn.GetDomainSocketName() + jwtToken, err := wshutil.MakeClientJWTToken(rpcCtx, sockName) + if err != nil { + return fmt.Errorf("unable to create jwt token for conn controller: %w", err) + } + shellPath, err := DetectShell(conn.Context, client) + if err != nil { + return err + } + var cmdStr string + if IsPowershell(shellPath) { + cmdStr = fmt.Sprintf("$env:%s=\"%s\"; %s connserver --router", wshutil.WaveJwtTokenVarName, jwtToken, wshPath) + } else { + cmdStr = fmt.Sprintf("%s=\"%s\" %s connserver --router", wshutil.WaveJwtTokenVarName, jwtToken, wshPath) + } + log.Printf("starting conn controller: %s\n", cmdStr) + cmd := client.WslCommand(conn.Context, cmdStr) + pipeRead, pipeWrite := io.Pipe() + inputPipeRead, inputPipeWrite := io.Pipe() + cmd.SetStdout(pipeWrite) + cmd.SetStderr(pipeWrite) + cmd.SetStdin(inputPipeRead) + err = cmd.Start() + if err != nil { + return fmt.Errorf("unable to start conn controller: %w", err) + } + conn.WithLock(func() { + conn.ConnController = cmd + }) + // service the I/O + go func() { + // wait for termination, clear the controller + defer conn.WithLock(func() { + conn.ConnController = nil + }) + waitErr := cmd.Wait() + log.Printf("conn controller (%q) terminated: %v", conn.GetName(), waitErr) + }() + go func() { + logName := fmt.Sprintf("conncontroller:%s", conn.GetName()) + wshutil.HandleStdIOClient(logName, pipeRead, inputPipeWrite) + }() + regCtx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + err = wshutil.DefaultRouter.WaitForRegister(regCtx, wshutil.MakeConnectionRouteId(rpcCtx.Conn)) + if err != nil { + return fmt.Errorf("timeout waiting for connserver to register") + } + time.Sleep(300 * time.Millisecond) // TODO remove this sleep (but we need to wait until connserver is "ready") + return nil +} + +type WshInstallOpts struct { + Force bool + NoUserPrompt bool +} + +func (conn *WslConn) CheckAndInstallWsh(ctx context.Context, clientDisplayName string, opts *WshInstallOpts) error { + if opts == nil { + opts = &WshInstallOpts{} + } + client := conn.GetClient() + if client == nil { + return fmt.Errorf("client is nil") + } + // check that correct wsh extensions are installed + expectedVersion := fmt.Sprintf("wsh v%s", wavebase.WaveVersion) + clientVersion, err := GetWshVersion(ctx, client) + if err == nil && clientVersion == expectedVersion && !opts.Force { + return nil + } + var queryText string + var title string + if opts.Force { + queryText = fmt.Sprintf("ReInstalling Wave Shell Extensions (%s) on `%s`\n", wavebase.WaveVersion, clientDisplayName) + title = "Install Wave Shell Extensions" + } else if err != nil { + queryText = fmt.Sprintf("Wave requires Wave Shell Extensions to be \n"+ + "installed on `%s` \n"+ + "to ensure a seamless experience. \n\n"+ + "Would you like to install them?", clientDisplayName) + title = "Install Wave Shell Extensions" + } else { + // don't ask for upgrading the version + opts.NoUserPrompt = true + } + if !opts.NoUserPrompt { + request := &userinput.UserInputRequest{ + ResponseType: "confirm", + QueryText: queryText, + Title: title, + Markdown: true, + CheckBoxMsg: "Don't show me this again", + } + response, err := userinput.GetUserInput(ctx, request) + if err != nil || !response.Confirm { + return err + } + if response.CheckboxStat { + meta := waveobj.MetaMapType{ + wconfig.ConfigKey_ConnAskBeforeWshInstall: false, + } + err := wconfig.SetBaseConfigValue(meta) + if err != nil { + return fmt.Errorf("error setting conn:askbeforewshinstall value: %w", err) + } + } + } + log.Printf("attempting to install wsh to `%s`", clientDisplayName) + clientOs, err := GetClientOs(ctx, client) + if err != nil { + return err + } + clientArch, err := GetClientArch(ctx, client) + if err != nil { + return err + } + // attempt to install extension + wshLocalPath := shellutil.GetWshBinaryPath(wavebase.WaveVersion, clientOs, clientArch) + err = CpHostToRemote(ctx, client, wshLocalPath, "~/.waveterm/bin/wsh") + if err != nil { + return err + } + log.Printf("successfully installed wsh on %s\n", conn.GetName()) + return nil +} + +func (conn *WslConn) GetClient() *Distro { + conn.Lock.Lock() + defer conn.Lock.Unlock() + return conn.Client +} + +func (conn *WslConn) Reconnect(ctx context.Context) error { + err := conn.Close() + if err != nil { + return err + } + return conn.Connect(ctx) +} + +func (conn *WslConn) WaitForConnect(ctx context.Context) error { + for { + status := conn.DeriveConnStatus() + if status.Status == Status_Connected { + return nil + } + if status.Status == Status_Connecting { + select { + case <-ctx.Done(): + return fmt.Errorf("context timeout") + case <-time.After(100 * time.Millisecond): + continue + } + } + if status.Status == Status_Init || status.Status == Status_Disconnected { + return fmt.Errorf("disconnected") + } + if status.Status == Status_Error { + return fmt.Errorf("error: %v", status.Error) + } + return fmt.Errorf("unknown status: %q", status.Status) + } +} + +// does not return an error since that error is stored inside of WslConn +func (conn *WslConn) Connect(ctx context.Context) error { + var connectAllowed bool + conn.WithLock(func() { + if conn.Status == Status_Connecting || conn.Status == Status_Connected { + connectAllowed = false + } else { + conn.Status = Status_Connecting + conn.Error = "" + connectAllowed = true + } + }) + log.Printf("Connect %s\n", conn.GetName()) + if !connectAllowed { + return fmt.Errorf("cannot connect to %q when status is %q", conn.GetName(), conn.GetStatus()) + } + conn.FireConnChangeEvent() + err := conn.connectInternal(ctx) + conn.WithLock(func() { + if err != nil { + conn.Status = Status_Error + conn.Error = err.Error() + conn.close_nolock() + } else { + conn.Status = Status_Connected + conn.LastConnectTime = time.Now().UnixMilli() + if conn.ActiveConnNum == 0 { + conn.ActiveConnNum = int(activeConnCounter.Add(1)) + } + } + }) + conn.FireConnChangeEvent() + return err +} + +func (conn *WslConn) WithLock(fn func()) { + conn.Lock.Lock() + defer conn.Lock.Unlock() + fn() +} + +func (conn *WslConn) connectInternal(ctx context.Context) error { + client, err := GetDistro(ctx, conn.Name) + if err != nil { + return err + } + conn.WithLock(func() { + conn.Client = client + }) + err = conn.OpenDomainSocketListener() + if err != nil { + return err + } + config := wconfig.ReadFullConfig() + installErr := conn.CheckAndInstallWsh(ctx, conn.GetName(), &WshInstallOpts{NoUserPrompt: !config.Settings.ConnAskBeforeWshInstall}) + if installErr != nil { + return fmt.Errorf("conncontroller %s wsh install error: %v", conn.GetName(), installErr) + } + csErr := conn.StartConnServer() + if csErr != nil { + return fmt.Errorf("conncontroller %s start wsh connserver error: %v", conn.GetName(), csErr) + } + conn.HasWaiter.Store(true) + go conn.waitForDisconnect() + return nil +} + +func (conn *WslConn) waitForDisconnect() { + defer conn.FireConnChangeEvent() + defer conn.HasWaiter.Store(false) + err := conn.ConnController.Wait() + conn.WithLock(func() { + // disconnects happen for a variety of reasons (like network, etc. and are typically transient) + // so we just set the status to "disconnected" here (not error) + // don't overwrite any existing error (or error status) + if err != nil && conn.Error == "" { + conn.Error = err.Error() + } + if conn.Status != Status_Error { + conn.Status = Status_Disconnected + } + conn.close_nolock() + }) +} + +func getConnInternal(name string) *WslConn { + globalLock.Lock() + defer globalLock.Unlock() + connName := WslName{Distro: name} + rtn := clientControllerMap[name] + if rtn == nil { + ctx, cancelFn := context.WithCancel(context.Background()) + rtn = &WslConn{Lock: &sync.Mutex{}, Status: Status_Init, Name: connName, HasWaiter: &atomic.Bool{}, Context: ctx, cancelFn: cancelFn} + clientControllerMap[name] = rtn + } + return rtn +} + +func GetWslConn(ctx context.Context, name string, shouldConnect bool) *WslConn { + conn := getConnInternal(name) + if conn.Client == nil && shouldConnect { + conn.Connect(ctx) + } + return conn +} + +// Convenience function for ensuring a connection is established +func EnsureConnection(ctx context.Context, connName string) error { + if connName == "" { + return nil + } + conn := GetWslConn(ctx, connName, false) + if conn == nil { + return fmt.Errorf("connection not found: %s", connName) + } + connStatus := conn.DeriveConnStatus() + switch connStatus.Status { + case Status_Connected: + return nil + case Status_Connecting: + return conn.WaitForConnect(ctx) + case Status_Init, Status_Disconnected: + return conn.Connect(ctx) + case Status_Error: + return fmt.Errorf("connection error: %s", connStatus.Error) + default: + return fmt.Errorf("unknown connection status %q", connStatus.Status) + } +} + +func DisconnectClient(connName string) error { + conn := getConnInternal(connName) + if conn == nil { + return fmt.Errorf("client %q not found", connName) + } + err := conn.Close() + return err +} From 701d93884dd66f300f3d37b965a12a2d4049aa5e Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 23 Oct 2024 22:47:29 -0700 Subject: [PATCH 14/14] vdom 4 (#1110) --- cmd/wsh/cmd/wshcmd-debug.go | 47 +++++ cmd/wsh/cmd/wshcmd-editor.go | 4 +- cmd/wsh/cmd/wshcmd-html.go | 18 +- cmd/wsh/cmd/wshcmd-root.go | 18 +- cmd/wsh/cmd/wshcmd-web.go | 4 +- .../000005_blockparent.down.sql | 1 + .../000005_blockparent.up.sql | 4 + frontend/app/block/block.tsx | 32 ++++ frontend/app/block/blockframe.tsx | 23 ++- frontend/app/block/blocktypes.ts | 1 + frontend/app/store/wos.ts | 1 + frontend/app/store/wshclientapi.ts | 17 +- frontend/app/view/term/term-wsh.tsx | 76 +++++--- frontend/app/view/term/term.less | 2 +- frontend/app/view/term/term.tsx | 163 ++++++++++++++++-- frontend/app/view/term/vdom-model.tsx | 119 ++++++++++--- frontend/app/view/term/vdom.tsx | 46 +++-- frontend/app/view/vdom/vdom-view.tsx | 28 +++ frontend/types/gotypes.d.ts | 30 +++- pkg/blockcontroller/blockcontroller.go | 2 +- pkg/service/objectservice/objectservice.go | 2 +- pkg/vdom/vdom_html.go | 26 ++- pkg/vdom/vdom_types.go | 26 +-- pkg/vdom/vdomclient/vdomclient.go | 73 ++++++-- pkg/waveobj/metaconsts.go | 3 + pkg/waveobj/waveobj.go | 8 + pkg/waveobj/wtype.go | 2 + pkg/waveobj/wtypemeta.go | 3 + pkg/wcore/wcore.go | 40 ++++- pkg/wshrpc/wshclient/wshclient.go | 24 ++- pkg/wshrpc/wshrpctypes.go | 23 ++- pkg/wshrpc/wshserver/wshserver.go | 33 +++- pkg/wshutil/wshrouter.go | 3 + pkg/wstore/wstore.go | 75 ++++++-- 34 files changed, 787 insertions(+), 190 deletions(-) create mode 100644 cmd/wsh/cmd/wshcmd-debug.go create mode 100644 db/migrations-wstore/000005_blockparent.down.sql create mode 100644 db/migrations-wstore/000005_blockparent.up.sql create mode 100644 frontend/app/view/vdom/vdom-view.tsx diff --git a/cmd/wsh/cmd/wshcmd-debug.go b/cmd/wsh/cmd/wshcmd-debug.go new file mode 100644 index 000000000..48379d3e4 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-debug.go @@ -0,0 +1,47 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var debugCmd = &cobra.Command{ + Use: "debug", + Short: "debug commands", + PersistentPreRunE: preRunSetupRpcClient, + Hidden: true, +} + +var debugBlockIdsCmd = &cobra.Command{ + Use: "block", + Short: "list sub-blockids for block", + RunE: debugBlockIdsRun, + Hidden: true, +} + +func init() { + debugCmd.AddCommand(debugBlockIdsCmd) + rootCmd.AddCommand(debugCmd) +} + +func debugBlockIdsRun(cmd *cobra.Command, args []string) error { + oref, err := resolveBlockArg() + if err != nil { + return err + } + blockInfo, err := wshclient.BlockInfoCommand(RpcClient, oref.OID, nil) + if err != nil { + return err + } + barr, err := json.MarshalIndent(blockInfo, "", " ") + if err != nil { + return err + } + WriteStdout("%s\n", string(barr)) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-editor.go b/cmd/wsh/cmd/wshcmd-editor.go index 0a32af9fd..16a346591 100644 --- a/cmd/wsh/cmd/wshcmd-editor.go +++ b/cmd/wsh/cmd/wshcmd-editor.go @@ -65,11 +65,11 @@ func editorRun(cmd *cobra.Command, args []string) { return } doneCh := make(chan bool) - RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) { + RpcClient.EventListener.On(wps.Event_BlockClose, func(event *wps.WaveEvent) { if event.HasScope(blockRef.String()) { close(doneCh) } }) - wshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: "blockclose", Scopes: []string{blockRef.String()}}, nil) + wshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{blockRef.String()}}, nil) <-doneCh } diff --git a/cmd/wsh/cmd/wshcmd-html.go b/cmd/wsh/cmd/wshcmd-html.go index 968995aa8..b30ad3603 100644 --- a/cmd/wsh/cmd/wshcmd-html.go +++ b/cmd/wsh/cmd/wshcmd-html.go @@ -13,7 +13,10 @@ import ( "github.com/wavetermdev/waveterm/pkg/wshutil" ) +var htmlCmdNewBlock bool + func init() { + htmlCmd.Flags().BoolVarP(&htmlCmdNewBlock, "newblock", "n", false, "create a new block") rootCmd.AddCommand(htmlCmd) } @@ -30,7 +33,10 @@ func MakeVDom() *vdom.VDomElem {

hello vdom world

| num[]
- + +
+
+
` @@ -39,7 +45,7 @@ func MakeVDom() *vdom.VDomElem { } func GlobalEventHandler(client *vdomclient.Client, event vdom.VDomEvent) { - if event.PropName == "clickinc" { + if event.EventType == "clickinc" { client.SetAtomVal("num", client.GetAtomVal("num").(int)+1) return } @@ -58,7 +64,7 @@ func htmlRun(cmd *cobra.Command, args []string) error { client.SetAtomVal("text", "initial text") client.SetAtomVal("num", 0) client.SetRootElem(MakeVDom()) - err = client.CreateVDomContext() + err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: htmlCmdNewBlock}) if err != nil { return err } @@ -70,8 +76,12 @@ func htmlRun(cmd *cobra.Command, args []string) error { log.Printf("created vdom context\n") go func() { time.Sleep(5 * time.Second) + log.Printf("updating text\n") client.SetAtomVal("text", "updated text") - client.SendAsyncInitiation() + err := client.SendAsyncInitiation() + if err != nil { + log.Printf("error sending async initiation: %v\n", err) + } }() <-client.DoneCh return nil diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go index 77cc2ee30..6f29f65a5 100644 --- a/cmd/wsh/cmd/wshcmd-root.go +++ b/cmd/wsh/cmd/wshcmd-root.go @@ -71,6 +71,22 @@ func preRunSetupRpcClient(cmd *cobra.Command, args []string) error { return nil } +func resolveBlockArg() (*waveobj.ORef, error) { + oref := blockArg + if oref == "" { + return nil, fmt.Errorf("blockid is required") + } + err := validateEasyORef(oref) + if err != nil { + return nil, err + } + fullORef, err := resolveSimpleId(oref) + if err != nil { + return nil, fmt.Errorf("resolving blockid: %w", err) + } + return fullORef, nil +} + // returns the wrapped stdin and a new rpc client (that wraps the stdin input and stdout output) func setupRpcClient(serverImpl wshutil.ServerImpl) error { jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) @@ -101,7 +117,7 @@ func setupRpcClient(serverImpl wshutil.ServerImpl) error { func setTermHtmlMode() { wshutil.SetExtraShutdownFunc(extraShutdownFn) cmd := &wshrpc.CommandSetMetaData{ - Meta: map[string]any{"term:mode": "html"}, + Meta: map[string]any{"term:mode": "vdom"}, } err := RpcClient.SendCommand(wshrpc.Command_SetMeta, cmd, nil) if err != nil { diff --git a/cmd/wsh/cmd/wshcmd-web.go b/cmd/wsh/cmd/wshcmd-web.go index 91e1e8945..775a0cfef 100644 --- a/cmd/wsh/cmd/wshcmd-web.go +++ b/cmd/wsh/cmd/wshcmd-web.go @@ -28,7 +28,7 @@ var webOpenCmd = &cobra.Command{ } var webGetCmd = &cobra.Command{ - Use: "get [--inner] [--all] [--json] blockid css-selector", + Use: "get [--inner] [--all] [--json] css-selector", Short: "get the html for a css selector", Args: cobra.ExactArgs(1), Hidden: true, @@ -67,7 +67,7 @@ func webGetRun(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("getting block info: %w", err) } - if blockInfo.Meta.GetString(waveobj.MetaKey_View, "") != "web" { + if blockInfo.Block.Meta.GetString(waveobj.MetaKey_View, "") != "web" { return fmt.Errorf("block %s is not a web block", fullORef.OID) } data := wshrpc.CommandWebSelectorData{ diff --git a/db/migrations-wstore/000005_blockparent.down.sql b/db/migrations-wstore/000005_blockparent.down.sql new file mode 100644 index 000000000..5aed013ca --- /dev/null +++ b/db/migrations-wstore/000005_blockparent.down.sql @@ -0,0 +1 @@ +-- we don't need to remove parentoref \ No newline at end of file diff --git a/db/migrations-wstore/000005_blockparent.up.sql b/db/migrations-wstore/000005_blockparent.up.sql new file mode 100644 index 000000000..f81864ff8 --- /dev/null +++ b/db/migrations-wstore/000005_blockparent.up.sql @@ -0,0 +1,4 @@ +UPDATE db_block +SET data = json_set(db_block.data, '$.parentoref', 'tab:' || db_tab.oid) +FROM db_tab +WHERE db_block.oid IN (SELECT value FROM json_each(db_tab.data, '$.blockids')); diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index f785e6c28..741fa66ef 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -5,6 +5,8 @@ import { BlockComponentModel2, BlockProps } from "@/app/block/blocktypes"; import { PlotView } from "@/app/view/plotview/plotview"; import { PreviewModel, PreviewView, makePreviewModel } from "@/app/view/preview/preview"; import { SysinfoView, SysinfoViewModel, makeSysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; +import { VDomModel } from "@/app/view/term/vdom-model"; +import { VDomView, makeVDomModel } from "@/app/view/vdom/vdom-view"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index"; @@ -29,6 +31,7 @@ import { BlockFrame } from "./blockframe"; import { blockViewToIcon, blockViewToName } from "./blockutil"; type FullBlockProps = { + isSubBlock?: boolean; preview: boolean; nodeModel: NodeModel; viewModel: ViewModel; @@ -51,6 +54,9 @@ function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel) // "cpuplot" is for backwards compatibility with already-opened widgets return makeSysinfoViewModel(blockId, blockView); } + if (blockView == "vdom") { + return makeVDomModel(blockId, nodeModel); + } if (blockView === "help") { return makeHelpViewModel(blockId, nodeModel); } @@ -100,6 +106,9 @@ function getViewElem( if (blockView == "tips") { return ; } + if (blockView == "vdom") { + return ; + } return Invalid View "{blockView}"; } @@ -137,6 +146,26 @@ const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => { ); }); +const BlockSubBlock = memo(({ nodeModel, viewModel }: FullBlockProps) => { + const [blockData] = useWaveObjectValue(makeORef("block", nodeModel.blockId)); + const blockRef = useRef(null); + const contentRef = useRef(null); + const viewElem = useMemo( + () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockData?.meta?.view, viewModel), + [nodeModel.blockId, blockData?.meta?.view, viewModel] + ); + if (!blockData) { + return null; + } + return ( +
+ + Loading...}>{viewElem} + +
+ ); +}); + const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { counterInc("render-BlockFull"); const focusElemRef = useRef(null); @@ -275,6 +304,9 @@ const Block = memo((props: BlockProps) => { if (props.preview) { return ; } + if (props.isSubBlock) { + return ; + } return ; }); diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index b713bc88c..199544727 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -26,7 +26,6 @@ import { useBlockAtom, WOS, } from "@/app/store/global"; -import * as services from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { ErrorBoundary } from "@/element/errorboundary"; @@ -60,17 +59,17 @@ function handleHeaderContextMenu( onMagnifyToggle(); }, }, - { - label: "Move to New Window", - click: () => { - const currentTabId = globalStore.get(atoms.staticTabId); - try { - services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid); - } catch (e) { - console.error("error moving block to new window", e); - } - }, - }, + // { + // label: "Move to New Window", + // click: () => { + // const currentTabId = globalStore.get(atoms.staticTabId); + // try { + // services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid); + // } catch (e) { + // console.error("error moving block to new window", e); + // } + // }, + // }, { type: "separator" }, { label: "Copy BlockId", diff --git a/frontend/app/block/blocktypes.ts b/frontend/app/block/blocktypes.ts index 2340f9d17..03322348b 100644 --- a/frontend/app/block/blocktypes.ts +++ b/frontend/app/block/blocktypes.ts @@ -3,6 +3,7 @@ import { NodeModel } from "@/layout/index"; export interface BlockProps { + isSubBlock?: boolean; preview: boolean; nodeModel: NodeModel; } diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts index 97fe3259f..dadb43d49 100644 --- a/frontend/app/store/wos.ts +++ b/frontend/app/store/wos.ts @@ -316,6 +316,7 @@ export { makeORef, reloadWaveObject, setObjectValue, + splitORef, updateWaveObject, updateWaveObjects, useWaveObjectValue, diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index f4a949ce0..a9b4950b5 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -67,11 +67,21 @@ class RpcApiType { return client.wshRpcCall("createblock", data, opts); } + // command "createsubblock" [call] + CreateSubBlockCommand(client: WshClient, data: CommandCreateSubBlockData, opts?: RpcOpts): Promise { + return client.wshRpcCall("createsubblock", data, opts); + } + // command "deleteblock" [call] DeleteBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { return client.wshRpcCall("deleteblock", data, opts); } + // command "deletesubblock" [call] + DeleteSubBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { + return client.wshRpcCall("deletesubblock", data, opts); + } + // command "dispose" [call] DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise { return client.wshRpcCall("dispose", data, opts); @@ -228,7 +238,7 @@ class RpcApiType { } // command "vdomcreatecontext" [call] - VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise { + VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise { return client.wshRpcCall("vdomcreatecontext", data, opts); } @@ -237,6 +247,11 @@ class RpcApiType { return client.wshRpcCall("vdomrender", data, opts); } + // command "waitforroute" [call] + WaitForRouteCommand(client: WshClient, data: CommandWaitForRouteData, opts?: RpcOpts): Promise { + return client.wshRpcCall("waitforroute", data, opts); + } + // command "webselector" [call] WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise { return client.wshRpcCall("webselector", data, opts); diff --git a/frontend/app/view/term/term-wsh.tsx b/frontend/app/view/term/term-wsh.tsx index 1eaca2b92..275abd3ce 100644 --- a/frontend/app/view/term/term-wsh.tsx +++ b/frontend/app/view/term/term-wsh.tsx @@ -1,12 +1,13 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { WOS } from "@/app/store/global"; -import { waveEventSubscribe } from "@/app/store/wps"; +import { atoms, globalStore } from "@/app/store/global"; +import { makeORef, splitORef } from "@/app/store/wos"; import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { TermViewModel } from "@/app/view/term/term"; +import { isBlank } from "@/util/util"; import debug from "debug"; const dlog = debug("wave:vdom"); @@ -21,32 +22,55 @@ export class TermWshClient extends WshClient { this.model = model; } - handle_vdomcreatecontext(rh: RpcResponseHelper, data: VDomCreateContext) { - console.log("vdom-create", rh.getSource(), data); - this.model.vdomModel.reset(); - this.model.vdomModel.backendRoute = rh.getSource(); - if (!data.persist) { - const unsubFn = waveEventSubscribe({ - eventType: "route:gone", - scope: rh.getSource(), - handler: () => { - RpcApi.SetMetaCommand(this, { - oref: WOS.makeORef("block", this.blockId), - meta: { "term:mode": null }, - }); - unsubFn(); + async handle_vdomcreatecontext(rh: RpcResponseHelper, data: VDomCreateContext) { + const source = rh.getSource(); + if (isBlank(source)) { + throw new Error("source cannot be blank"); + } + console.log("vdom-create", source, data); + const tabId = globalStore.get(atoms.staticTabId); + if (data.target?.newblock) { + const oref = await RpcApi.CreateBlockCommand(this, { + tabid: tabId, + blockdef: { + meta: { + view: "vdom", + "vdom:route": rh.getSource(), + }, + }, + magnified: data.target?.magnified, + }); + return oref; + } else { + // in the terminal + // check if there is a current active vdom block + const oldVDomBlockId = globalStore.get(this.model.vdomBlockId); + const oref = await RpcApi.CreateSubBlockCommand(this, { + parentblockid: this.blockId, + blockdef: { + meta: { + view: "vdom", + "vdom:route": rh.getSource(), + }, }, }); + const [_, newVDomBlockId] = splitORef(oref); + if (!isBlank(oldVDomBlockId)) { + // dispose of the old vdom block + setTimeout(() => { + RpcApi.DeleteSubBlockCommand(this, { blockid: oldVDomBlockId }); + }, 500); + } + setTimeout(() => { + RpcApi.SetMetaCommand(this, { + oref: makeORef("block", this.model.blockId), + meta: { + "term:mode": "vdom", + "term:vdomblockid": newVDomBlockId, + }, + }); + }, 50); + return oref; } - RpcApi.SetMetaCommand(this, { - oref: WOS.makeORef("block", this.blockId), - meta: { "term:mode": "html" }, - }); - this.model.vdomModel.queueUpdate(true); - } - - handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) { - console.log("async-initiation", rh.getSource(), data); - this.model.vdomModel.queueUpdate(true); } } diff --git a/frontend/app/view/term/term.less b/frontend/app/view/term/term.less index 18e1f26b5..4351ca11c 100644 --- a/frontend/app/view/term/term.less +++ b/frontend/app/view/term/term.less @@ -76,7 +76,7 @@ } } - &.term-mode-html { + &.term-mode-vdom { .term-connectelem { display: none; } diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index d68c0e6da..b7e5a4c47 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -1,19 +1,28 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { Block } from "@/app/block/block"; import { getAllGlobalKeyBindings } from "@/app/store/keymodel"; import { waveEventSubscribe } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; import { TermWshClient } from "@/app/view/term/term-wsh"; -import { VDomView } from "@/app/view/term/vdom"; import { VDomModel } from "@/app/view/term/vdom-model"; import { NodeModel } from "@/layout/index"; -import { WOS, atoms, getConnStatusAtom, getSettingsKeyAtom, globalStore, useSettingsPrefixAtom } from "@/store/global"; +import { + WOS, + atoms, + getBlockComponentModel, + getConnStatusAtom, + getSettingsKeyAtom, + globalStore, + useSettingsPrefixAtom, +} from "@/store/global"; import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; import clsx from "clsx"; +import debug from "debug"; import * as jotai from "jotai"; import * as React from "react"; import { TermStickers } from "./termsticker"; @@ -22,6 +31,8 @@ import { computeTheme } from "./termutil"; import { TermWrap } from "./termwrap"; import "./xterm.css"; +const dlog = debug("wave:term"); + type InitialLoadDataType = { loaded: boolean; heldData: Uint8Array[]; @@ -37,12 +48,13 @@ class TermViewModel { blockId: string; viewIcon: jotai.Atom; viewName: jotai.Atom; + viewText: jotai.Atom; blockBg: jotai.Atom; manageConnection: jotai.Atom; connStatus: jotai.Atom; termWshClient: TermWshClient; shellProcStatusRef: React.MutableRefObject; - vdomModel: VDomModel; + vdomBlockId: jotai.Atom; constructor(blockId: string, nodeModel: NodeModel) { this.viewType = "term"; @@ -50,23 +62,70 @@ class TermViewModel { this.termWshClient = new TermWshClient(blockId, this); DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.termWshClient); this.nodeModel = nodeModel; - this.vdomModel = new VDomModel(blockId, nodeModel, null, this.termWshClient); this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.vdomBlockId = jotai.atom((get) => { + const blockData = get(this.blockAtom); + return blockData?.meta?.["term:vdomblockid"]; + }); this.termMode = jotai.atom((get) => { const blockData = get(this.blockAtom); return blockData?.meta?.["term:mode"] ?? "term"; }); this.viewIcon = jotai.atom((get) => { + const termMode = get(this.termMode); + if (termMode == "vdom") { + return "bolt"; + } return "terminal"; }); this.viewName = jotai.atom((get) => { const blockData = get(this.blockAtom); + const termMode = get(this.termMode); + if (termMode == "vdom") { + return "Wave App"; + } if (blockData?.meta?.controller == "cmd") { return "Command"; } return "Terminal"; }); - this.manageConnection = jotai.atom(true); + this.viewText = jotai.atom((get) => { + const termMode = get(this.termMode); + if (termMode == "vdom") { + return [ + { + elemtype: "iconbutton", + icon: "square-terminal", + title: "Switch back to Terminal", + click: () => { + this.setTermMode("term"); + }, + }, + ]; + } else { + const vdomBlockId = get(this.vdomBlockId); + if (vdomBlockId) { + return [ + { + elemtype: "iconbutton", + icon: "bolt", + title: "Switch to Wave App", + click: () => { + this.setTermMode("vdom"); + }, + }, + ]; + } + } + return null; + }); + this.manageConnection = jotai.atom((get) => { + const termMode = get(this.termMode); + if (termMode == "vdom") { + return false; + } + return true; + }); this.blockBg = jotai.atom((get) => { const blockData = get(this.blockAtom); const fullConfig = get(atoms.fullConfigAtom); @@ -88,6 +147,28 @@ class TermViewModel { }); } + setTermMode(mode: "term" | "vdom") { + if (mode == "term") { + mode = null; + } + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "term:mode": mode }, + }); + } + + getVDomModel(): VDomModel { + const vdomBlockId = globalStore.get(this.vdomBlockId); + if (!vdomBlockId) { + return null; + } + const bcm = getBlockComponentModel(vdomBlockId); + if (!bcm) { + return null; + } + return bcm.viewModel as VDomModel; + } + dispose() { DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId)); } @@ -107,16 +188,18 @@ class TermViewModel { if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { const blockAtom = WOS.getWaveObjectAtom(`block:${this.blockId}`); const blockData = globalStore.get(blockAtom); - const newTermMode = blockData?.meta?.["term:mode"] == "html" ? null : "html"; - RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), - meta: { "term:mode": newTermMode }, - }); + const newTermMode = blockData?.meta?.["term:mode"] == "vdom" ? null : "vdom"; + const vdomBlockId = globalStore.get(this.vdomBlockId); + if (newTermMode == "vdom" && !vdomBlockId) { + return; + } + this.setTermMode(newTermMode); return true; } const blockData = globalStore.get(this.blockAtom); - if (blockData.meta?.["term:mode"] == "html") { - return this.vdomModel?.globalKeydownHandler(waveEvent); + if (blockData.meta?.["term:mode"] == "vdom") { + const vdomModel = this.getVDomModel(); + return vdomModel?.keyDownHandler(waveEvent); } return false; } @@ -241,6 +324,52 @@ const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => return null; }); +const TermVDomNodeSingleId = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => { + React.useEffect(() => { + const unsub = waveEventSubscribe({ + eventType: "blockclose", + scope: WOS.makeORef("block", vdomBlockId), + handler: (event) => { + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", blockId), + meta: { + "term:mode": null, + "term:vdomblockid": null, + }, + }); + }, + }); + return () => { + unsub(); + }; + }, []); + const isFocusedAtom = jotai.atom((get) => { + return get(model.nodeModel.isFocused) && get(model.termMode) == "vdom"; + }); + let vdomNodeModel = { + blockId: vdomBlockId, + isFocused: isFocusedAtom, + onClose: () => { + if (vdomBlockId != null) { + RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId }); + } + }, + }; + return ( +
+ +
+ ); +}; + +const TermVDomNode = ({ blockId, model }: TerminalViewProps) => { + const vdomBlockId = jotai.useAtomValue(model.vdomBlockId); + if (vdomBlockId == null) { + return null; + } + return ; +}; + const TerminalView = ({ blockId, model }: TerminalViewProps) => { const viewRef = React.useRef(null); const connectElemRef = React.useRef(null); @@ -252,7 +381,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { const termSettingsAtom = useSettingsPrefixAtom("term"); const termSettings = jotai.useAtomValue(termSettingsAtom); let termMode = blockData?.meta?.["term:mode"] ?? "term"; - if (termMode != "term" && termMode != "html") { + if (termMode != "term" && termMode != "vdom") { termMode = "term"; } const termModeRef = React.useRef(termMode); @@ -307,7 +436,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { }, [blockId, termSettings]); React.useEffect(() => { - if (termModeRef.current == "html" && termMode == "term") { + if (termModeRef.current == "vdom" && termMode == "term") { // focus the terminal model.giveFocus(); } @@ -356,11 +485,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
-
-
- -
-
+
); }; diff --git a/frontend/app/view/term/vdom-model.tsx b/frontend/app/view/term/vdom-model.tsx index 6eaf71858..c4ce4d974 100644 --- a/frontend/app/view/term/vdom-model.tsx +++ b/frontend/app/view/term/vdom-model.tsx @@ -1,11 +1,13 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { globalStore, WOS } from "@/app/store/global"; +import { getBlockMetaKeyAtom, globalStore, WOS } from "@/app/store/global"; import { makeORef } from "@/app/store/wos"; +import { waveEventSubscribe } from "@/app/store/wps"; +import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { TermWshClient } from "@/app/view/term/term-wsh"; +import { makeFeBlockRouteId } from "@/app/store/wshrouter"; +import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; import { NodeModel } from "@/layout/index"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import debug from "debug"; @@ -61,22 +63,37 @@ function convertEvent(e: React.SyntheticEvent, fromProp: string): any { return { type: "unknown" }; } +class VDomWshClient extends WshClient { + model: VDomModel; + + constructor(model: VDomModel) { + super(makeFeBlockRouteId(model.blockId)); + this.model = model; + } + + handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) { + console.log("async-initiation", rh.getSource(), data); + this.model.queueUpdate(true); + } +} + export class VDomModel { blockId: string; nodeModel: NodeModel; - viewRef: React.RefObject; + viewType: string; + viewIcon: jotai.Atom; + viewName: jotai.Atom; + viewRef: React.RefObject = { current: null }; vdomRoot: jotai.PrimitiveAtom = jotai.atom(); atoms: Map = new Map(); // key is atomname refs: Map = new Map(); // key is refid batchedEvents: VDomEvent[] = []; messages: VDomMessage[] = []; - needsInitialization: boolean = true; needsResync: boolean = true; vdomNodeVersion: WeakMap> = new WeakMap(); compoundAtoms: Map> = new Map(); rootRefId: string = crypto.randomUUID(); - termWshClient: TermWshClient; - backendRoute: string; + backendRoute: jotai.Atom; backendOpts: VDomBackendOpts; shouldDispose: boolean; disposed: boolean; @@ -86,18 +103,61 @@ export class VDomModel { needsImmediateUpdate: boolean; lastUpdateTs: number = 0; queuedUpdate: { timeoutId: any; ts: number; quick: boolean }; + contextActive: jotai.PrimitiveAtom; + wshClient: VDomWshClient; + persist: jotai.Atom; + routeGoneUnsub: () => void; + routeConfirmed: boolean = false; - constructor( - blockId: string, - nodeModel: NodeModel, - viewRef: React.RefObject, - termWshClient: TermWshClient - ) { + constructor(blockId: string, nodeModel: NodeModel) { + this.viewType = "vdom"; this.blockId = blockId; this.nodeModel = nodeModel; - this.viewRef = viewRef; - this.termWshClient = termWshClient; + this.contextActive = jotai.atom(false); this.reset(); + this.viewIcon = jotai.atom("bolt"); + this.viewName = jotai.atom("Wave App"); + this.backendRoute = jotai.atom((get) => { + const blockData = get(WOS.getWaveObjectAtom(makeORef("block", this.blockId))); + return blockData?.meta?.["vdom:route"]; + }); + this.persist = getBlockMetaKeyAtom(this.blockId, "vdom:persist"); + this.wshClient = new VDomWshClient(this); + DefaultRouter.registerRoute(this.wshClient.routeId, this.wshClient); + const curBackendRoute = globalStore.get(this.backendRoute); + if (curBackendRoute) { + this.queueUpdate(true); + } + this.routeGoneUnsub = waveEventSubscribe({ + eventType: "route:gone", + scope: curBackendRoute, + handler: (event: WaveEvent) => { + this.disposed = true; + const shouldPersist = globalStore.get(this.persist); + if (!shouldPersist) { + this.nodeModel?.onClose?.(); + } + }, + }); + RpcApi.WaitForRouteCommand(TabRpcClient, { routeid: curBackendRoute, waitms: 4000 }, { timeout: 5000 }).then( + (routeOk: boolean) => { + if (routeOk) { + this.routeConfirmed = true; + this.queueUpdate(true); + } else { + this.disposed = true; + const shouldPersist = globalStore.get(this.persist); + if (!shouldPersist) { + this.nodeModel?.onClose?.(); + } + } + } + ); + } + + dispose() { + DefaultRouter.unregisterRoute(this.wshClient.routeId); + this.routeGoneUnsub?.(); } reset() { @@ -107,11 +167,9 @@ export class VDomModel { this.batchedEvents = []; this.messages = []; this.needsResync = true; - this.needsInitialization = true; this.vdomNodeVersion = new WeakMap(); this.compoundAtoms.clear(); this.rootRefId = crypto.randomUUID(); - this.backendRoute = null; this.backendOpts = {}; this.shouldDispose = false; this.disposed = false; @@ -121,9 +179,15 @@ export class VDomModel { this.needsImmediateUpdate = false; this.lastUpdateTs = 0; this.queuedUpdate = null; + globalStore.set(this.contextActive, false); } - globalKeydownHandler(e: WaveKeyboardEvent): boolean { + getBackendRoute(): string { + const blockData = globalStore.get(WOS.getWaveObjectAtom(makeORef("block", this.blockId))); + return blockData?.meta?.["vdom:route"]; + } + + keyDownHandler(e: WaveKeyboardEvent): boolean { if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, "Ctrl:c")) { this.shouldDispose = true; this.queueUpdate(true); @@ -135,7 +199,7 @@ export class VDomModel { } this.batchedEvents.push({ waveid: null, - propname: "onKeyDown", + eventtype: "onKeyDown", eventdata: e, }); this.queueUpdate(); @@ -179,6 +243,9 @@ export class VDomModel { } queueUpdate(quick: boolean = false, delay: number = 10) { + if (this.disposed) { + return; + } this.needsUpdate = true; let nowTs = Date.now(); if (delay > this.maxNormalUpdateIntervalMs) { @@ -220,7 +287,7 @@ export class VDomModel { async _sendRenderRequest(force: boolean) { this.queuedUpdate = null; - if (this.disposed) { + if (this.disposed || !this.routeConfirmed) { return; } if (this.hasPendingRequest) { @@ -232,7 +299,8 @@ export class VDomModel { if (!force && !this.needsUpdate) { return; } - if (this.backendRoute == null) { + const backendRoute = globalStore.get(this.backendRoute); + if (backendRoute == null) { console.log("vdom-model", "no backend route"); return; } @@ -241,7 +309,7 @@ export class VDomModel { try { const feUpdate = this.createFeUpdate(); dlog("fe-update", feUpdate); - const beUpdate = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: this.backendRoute }); + const beUpdate = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: backendRoute }); this.handleBackendUpdate(beUpdate); } finally { this.lastUpdateTs = Date.now(); @@ -454,6 +522,7 @@ export class VDomModel { if (update == null) { return; } + globalStore.set(this.contextActive, true); const idMap = new Map(); const vdomRoot = globalStore.get(this.vdomRoot); if (update.opts != null) { @@ -478,14 +547,14 @@ export class VDomModel { if (fnDecl.globalevent) { const waveEvent: VDomEvent = { waveid: null, - propname: fnDecl.globalevent, + eventtype: fnDecl.globalevent, eventdata: eventData, }; this.batchedEvents.push(waveEvent); } else { const vdomEvent: VDomEvent = { waveid: compId, - propname: propName, + eventtype: propName, eventdata: eventData, }; this.batchedEvents.push(vdomEvent); @@ -510,7 +579,6 @@ export class VDomModel { type: "frontendupdate", ts: Date.now(), blockid: this.blockId, - initialize: this.needsInitialization, rendercontext: renderContext, dispose: this.shouldDispose, resync: this.needsResync, @@ -518,7 +586,6 @@ export class VDomModel { refupdates: this.getRefUpdates(), }; this.needsResync = false; - this.needsInitialization = false; this.batchedEvents = []; if (this.shouldDispose) { this.disposed = true; diff --git a/frontend/app/view/term/vdom.tsx b/frontend/app/view/term/vdom.tsx index 718a392ab..fcea0714e 100644 --- a/frontend/app/view/term/vdom.tsx +++ b/frontend/app/view/term/vdom.tsx @@ -1,10 +1,9 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { Markdown } from "@/app/element/markdown"; import { VDomModel } from "@/app/view/term/vdom-model"; -import { NodeModel } from "@/layout/index"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; -import { useAtomValueSafe } from "@/util/util"; import debug from "debug"; import * as jotai from "jotai"; import * as React from "react"; @@ -20,6 +19,12 @@ const VDomObjType_Func = "func"; const dlog = debug("wave:vdom"); +type VDomReactTagType = (props: { elem: VDomElem; model: VDomModel }) => JSX.Element; + +const WaveTagMap: Record = { + "wave:markdown": WaveMarkdown, +}; + const AllowedTags: { [tagName: string]: boolean } = { div: true, b: true, @@ -191,7 +196,7 @@ function stringSetsEqual(set1: Set, set2: Set): boolean { return true; } -function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { +function useVDom(model: VDomModel, elem: VDomElem): GenericPropsType { const version = jotai.useAtomValue(model.getVDomNodeVersionAtom(elem)); const [oldAtomKeys, setOldAtomKeys] = React.useState>(new Set()); let [props, atomKeys] = convertProps(elem, model); @@ -208,18 +213,32 @@ function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { model.tagUnuseAtoms(elem.waveid, oldAtomKeys); }; }, []); + return props; +} +function WaveMarkdown({ elem, model }: { elem: VDomElem; model: VDomModel }) { + const props = useVDom(model, elem); + return ( + + ); +} + +function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { + const props = useVDom(model, elem); if (elem.tag == WaveNullTag) { return null; } if (elem.tag == WaveTextTag) { return props.text; } + const waveTag = WaveTagMap[elem.tag]; + if (waveTag) { + return waveTag({ elem, model }); + } if (!AllowedTags[elem.tag]) { return
{"Invalid Tag <" + elem.tag + ">"}
; } let childrenComps = convertChildren(elem, model); - dlog("children", childrenComps); if (elem.tag == FragmentTag) { return childrenComps; } @@ -251,25 +270,14 @@ const testVDom: VDomElem = { ], }; -function VDomView({ - blockId, - nodeModel, - viewRef, - model, -}: { - blockId: string; - nodeModel: NodeModel; - viewRef: React.RefObject; - model: VDomModel; -}) { - let rootNode = useAtomValueSafe(model?.vdomRoot); - if (!model || viewRef.current == null || rootNode == null) { +function VDomRoot({ model }: { model: VDomModel }) { + let rootNode = jotai.useAtomValue(model.vdomRoot); + if (model.viewRef.current == null || rootNode == null) { return null; } dlog("render", rootNode); - model.viewRef = viewRef; let rtn = convertElemToTag(rootNode, model); return
{rtn}
; } -export { VDomView }; +export { VDomRoot }; diff --git a/frontend/app/view/vdom/vdom-view.tsx b/frontend/app/view/vdom/vdom-view.tsx new file mode 100644 index 000000000..124149078 --- /dev/null +++ b/frontend/app/view/vdom/vdom-view.tsx @@ -0,0 +1,28 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { VDomRoot } from "@/app/view/term/vdom"; +import { VDomModel } from "@/app/view/term/vdom-model"; +import { NodeModel } from "@/layout/index"; +import { useRef } from "react"; + +function makeVDomModel(blockId: string, nodeModel: NodeModel): VDomModel { + return new VDomModel(blockId, nodeModel); +} + +type VDomViewProps = { + model: VDomModel; + blockId: string; +}; + +function VDomView({ blockId, model }: VDomViewProps) { + let viewRef = useRef(null); + model.viewRef = viewRef; + return ( +
+ +
+ ); +} + +export { makeVDomModel, VDomView }; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 0a8652b36..2e35e8ac4 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -7,9 +7,11 @@ declare global { // waveobj.Block type Block = WaveObj & { + parentoref?: string; blockdef: BlockDef; runtimeopts?: RuntimeOpts; stickers?: StickerType[]; + subblockids?: string[]; }; // blockcontroller.BlockControllerRuntimeStatus @@ -30,7 +32,7 @@ declare global { blockid: string; tabid: string; windowid: string; - meta: MetaType; + block: Block; }; // webcmd.BlockInputWSCommand @@ -96,6 +98,12 @@ declare global { magnified?: boolean; }; + // wshrpc.CommandCreateSubBlockData + type CommandCreateSubBlockData = { + parentblockid: string; + blockdef: BlockDef; + }; + // wshrpc.CommandDeleteBlockData type CommandDeleteBlockData = { blockid: string; @@ -167,6 +175,12 @@ declare global { meta: MetaType; }; + // wshrpc.CommandWaitForRouteData + type CommandWaitForRouteData = { + routeid: string; + waitms: number; + }; + // wshrpc.CommandWebSelectorData type CommandWebSelectorData = { windowid: string; @@ -341,9 +355,12 @@ declare global { "term:localshellpath"?: string; "term:localshellopts"?: string[]; "term:scrollback"?: number; + "term:vdomblockid"?: string; "vdom:*"?: boolean; "vdom:initialized"?: boolean; "vdom:correlationid"?: string; + "vdom:route"?: string; + "vdom:persist"?: boolean; count?: number; }; @@ -645,7 +662,7 @@ declare global { type: "createcontext"; ts: number; meta?: MetaType; - newblock?: boolean; + target?: VDomTarget; persist?: boolean; }; @@ -661,7 +678,7 @@ declare global { // vdom.VDomEvent type VDomEvent = { waveid: string; - propname: string; + eventtype: string; eventdata: any; }; @@ -671,7 +688,6 @@ declare global { ts: number; blockid: string; correlationid?: string; - initialize?: boolean; dispose?: boolean; resync?: boolean; rendercontext?: VDomRenderContext; @@ -755,6 +771,12 @@ declare global { value: any; }; + // vdom.VDomTarget + type VDomTarget = { + newblock?: boolean; + magnified?: boolean; + }; + type WSCommandType = { wscommand: string; } & ( SetBlockTermSizeWSCommand | BlockInputWSCommand | WSRpcCommand ); diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 09a86b2c4..f2d6a2751 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -36,7 +36,7 @@ const ( const ( BlockFile_Term = "term" // used for main pty output - BlockFile_Html = "html" // used for alt html layout + BlockFile_VDom = "vdom" // used for alt html layout ) const ( diff --git a/pkg/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go index 2584d142d..08338da1f 100644 --- a/pkg/service/objectservice/objectservice.go +++ b/pkg/service/objectservice/objectservice.go @@ -201,7 +201,7 @@ func (svc *ObjectService) DeleteBlock(uiContext waveobj.UIContext, blockId strin ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) - err := wcore.DeleteBlock(ctx, uiContext.ActiveTabId, blockId) + err := wcore.DeleteBlock(ctx, blockId) if err != nil { return nil, fmt.Errorf("error deleting block: %w", err) } diff --git a/pkg/vdom/vdom_html.go b/pkg/vdom/vdom_html.go index ca06658d1..f6ab228bb 100644 --- a/pkg/vdom/vdom_html.go +++ b/pkg/vdom/vdom_html.go @@ -4,6 +4,7 @@ package vdom import ( + "encoding/json" "errors" "fmt" "io" @@ -72,7 +73,20 @@ func finalizeStack(stack []*VDomElem) *VDomElem { return rtnElem } -func getAttr(token htmltoken.Token, key string) string { +func attrVal(attr htmltoken.Attribute) (any, error) { + // if !attr.IsJson { + // return attr.Val, nil + // } + var val any + err := json.Unmarshal([]byte(attr.Val), &val) + if err != nil { + return nil, fmt.Errorf("error parsing json attr %q: %v", attr.Key, err) + } + return val, nil +} + +// returns value, isjson +func getAttrString(token htmltoken.Token, key string) string { for _, attr := range token.Attr { if attr.Key == key { return attr.Val @@ -81,7 +95,7 @@ func getAttr(token htmltoken.Token, key string) string { return "" } -func attrToProp(attrVal string, params map[string]any) any { +func attrToProp(attrVal string, isJson bool, params map[string]any) any { if strings.HasPrefix(attrVal, Html_ParamPrefix) { bindKey := attrVal[len(Html_ParamPrefix):] bindVal, ok := params[bindKey] @@ -120,7 +134,7 @@ func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem { if attr.Key == "" || attr.Val == "" { continue } - propVal := attrToProp(attr.Val, params) + propVal := attrToProp(attr.Val, false, params) elem.Props[attr.Key] = propVal } return elem @@ -253,7 +267,7 @@ func convertStyleToReactStyles(styleMap map[string]string, params map[string]any } rtn := make(map[string]any) for key, val := range styleMap { - rtn[toReactName(key)] = attrToProp(val, params) + rtn[toReactName(key)] = attrToProp(val, false, params) } return rtn } @@ -330,7 +344,7 @@ outer: elemStack = popElemStack(elemStack) case htmltoken.SelfClosingTagToken: if token.Data == Html_BindParamTagName { - keyAttr := getAttr(token, "key") + keyAttr := getAttrString(token, "key") dataVal := params[keyAttr] elemList := partToElems(dataVal) for _, elem := range elemList { @@ -339,7 +353,7 @@ outer: continue } if token.Data == Html_BindTagName { - keyAttr := getAttr(token, "key") + keyAttr := getAttrString(token, "key") binding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr} appendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{"text": binding}}) continue diff --git a/pkg/vdom/vdom_types.go b/pkg/vdom/vdom_types.go index 2230e8f21..1c09d2817 100644 --- a/pkg/vdom/vdom_types.go +++ b/pkg/vdom/vdom_types.go @@ -34,11 +34,11 @@ type VDomElem struct { //// protocol messages type VDomCreateContext struct { - Type string `json:"type" tstype:"\"createcontext\""` - Ts int64 `json:"ts"` - Meta waveobj.MetaMapType `json:"meta,omitempty"` - NewBlock bool `json:"newblock,omitempty"` - Persist bool `json:"persist,omitempty"` + Type string `json:"type" tstype:"\"createcontext\""` + Ts int64 `json:"ts"` + Meta waveobj.MetaMapType `json:"meta,omitempty"` + Target *VDomTarget `json:"target,omitempty"` + Persist bool `json:"persist,omitempty"` } type VDomAsyncInitiationRequest struct { @@ -60,9 +60,8 @@ type VDomFrontendUpdate struct { Ts int64 `json:"ts"` BlockId string `json:"blockid"` CorrelationId string `json:"correlationid,omitempty"` - Initialize bool `json:"initialize,omitempty"` // initialize the app - Dispose bool `json:"dispose,omitempty"` // the vdom context was closed - Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads + Dispose bool `json:"dispose,omitempty"` // the vdom context was closed + Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads RenderContext VDomRenderContext `json:"rendercontext,omitempty"` Events []VDomEvent `json:"events,omitempty"` StateSync []VDomStateSync `json:"statesync,omitempty"` @@ -129,8 +128,8 @@ type VDomRefPosition struct { ///// subbordinate protocol types type VDomEvent struct { - WaveId string `json:"waveid"` - PropName string `json:"propname"` + WaveId string `json:"waveid"` // empty for global events + EventType string `json:"eventtype"` EventData any `json:"eventdata"` } @@ -179,6 +178,13 @@ type VDomMessage struct { Params []any `json:"params,omitempty"` } +// target -- to support new targets in the future, like toolbars, partial blocks, splits, etc. +// default is vdom context inside of a terminal block +type VDomTarget struct { + NewBlock bool `json:"newblock,omitempty"` + Magnified bool `json:"magnified,omitempty"` +} + // matches WaveKeyboardEvent type VDomKeyboardEvent struct { Type string `json:"type"` diff --git a/pkg/vdom/vdomclient/vdomclient.go b/pkg/vdom/vdomclient/vdomclient.go index 740802c1d..b0a0c9761 100644 --- a/pkg/vdom/vdomclient/vdomclient.go +++ b/pkg/vdom/vdomclient/vdomclient.go @@ -13,7 +13,6 @@ import ( "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/vdom" - "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" @@ -21,6 +20,7 @@ import ( ) type Client struct { + Lock *sync.Mutex Root *vdom.RootElem RootElem *vdom.VDomElem RpcClient *wshutil.WshRpc @@ -28,8 +28,8 @@ type Client struct { ServerImpl *VDomServerImpl IsDone bool RouteId string + VDomContextBlockId string DoneReason string - DoneOnce *sync.Once DoneCh chan struct{} Opts vdom.VDomBackendOpts GlobalEventHandler func(client *Client, event vdom.VDomEvent) @@ -48,7 +48,7 @@ func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom impl.Client.doShutdown("got dispose from frontend") return nil, nil } - if impl.Client.IsDone { + if impl.Client.GetIsDone() { return nil, nil } // set atoms @@ -62,21 +62,30 @@ func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom impl.Client.GlobalEventHandler(impl.Client, event) } } else { - impl.Client.Root.Event(event.WaveId, event.PropName, event.EventData) + impl.Client.Root.Event(event.WaveId, event.EventType, event.EventData) } } - if feUpdate.Initialize || feUpdate.Resync { + if feUpdate.Resync { return impl.Client.fullRender() } return impl.Client.incrementalRender() } +func (c *Client) GetIsDone() bool { + c.Lock.Lock() + defer c.Lock.Unlock() + return c.IsDone +} + func (c *Client) doShutdown(reason string) { - c.DoneOnce.Do(func() { - c.DoneReason = reason - c.IsDone = true - close(c.DoneCh) - }) + c.Lock.Lock() + defer c.Lock.Unlock() + if c.IsDone { + return + } + c.DoneReason = reason + c.IsDone = true + close(c.DoneCh) } func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) { @@ -85,9 +94,9 @@ func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.V func MakeClient(opts *vdom.VDomBackendOpts) (*Client, error) { client := &Client{ - Root: vdom.MakeRoot(), - DoneCh: make(chan struct{}), - DoneOnce: &sync.Once{}, + Lock: &sync.Mutex{}, + Root: vdom.MakeRoot(), + DoneCh: make(chan struct{}), } if opts != nil { client.Opts = *opts @@ -126,13 +135,29 @@ func (c *Client) SetRootElem(elem *vdom.VDomElem) { c.RootElem = elem } -func (c *Client) CreateVDomContext() error { - err := wshclient.VDomCreateContextCommand(c.RpcClient, vdom.VDomCreateContext{}, &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)}) +func (c *Client) CreateVDomContext(target *vdom.VDomTarget) error { + blockORef, err := wshclient.VDomCreateContextCommand( + c.RpcClient, + vdom.VDomCreateContext{Target: target}, + &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)}, + ) if err != nil { return err } - wshclient.EventSubCommand(c.RpcClient, wps.SubscriptionRequest{Event: "blockclose", Scopes: []string{ - waveobj.MakeORef("block", c.RpcContext.BlockId).String(), + c.VDomContextBlockId = blockORef.OID + log.Printf("created vdom context: %v\n", blockORef) + gotRoute, err := wshclient.WaitForRouteCommand(c.RpcClient, wshrpc.CommandWaitForRouteData{ + RouteId: wshutil.MakeFeBlockRouteId(blockORef.OID), + WaitMs: 4000, + }, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + return fmt.Errorf("error waiting for vdom context route: %v", err) + } + if !gotRoute { + return fmt.Errorf("vdom context route could not be established") + } + wshclient.EventSubCommand(c.RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{ + blockORef.String(), }}, nil) c.RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) { c.doShutdown("got blockclose event") @@ -140,8 +165,18 @@ func (c *Client) CreateVDomContext() error { return nil } -func (c *Client) SendAsyncInitiation() { - wshclient.VDomAsyncInitiationCommand(c.RpcClient, vdom.MakeAsyncInitiationRequest(c.RpcContext.BlockId), &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)}) +func (c *Client) SendAsyncInitiation() error { + if c.VDomContextBlockId == "" { + return fmt.Errorf("no vdom context block id") + } + if c.GetIsDone() { + return fmt.Errorf("client is done") + } + return wshclient.VDomAsyncInitiationCommand( + c.RpcClient, + vdom.MakeAsyncInitiationRequest(c.RpcContext.BlockId), + &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.VDomContextBlockId)}, + ) } func (c *Client) SetAtomVals(m map[string]any) { diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 0aba6694c..fc3a73584 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -79,10 +79,13 @@ const ( MetaKey_TermLocalShellPath = "term:localshellpath" MetaKey_TermLocalShellOpts = "term:localshellopts" MetaKey_TermScrollback = "term:scrollback" + MetaKey_TermVDomSubBlockId = "term:vdomblockid" MetaKey_VDomClear = "vdom:*" MetaKey_VDomInitialized = "vdom:initialized" MetaKey_VDomCorrelationId = "vdom:correlationid" + MetaKey_VDomRoute = "vdom:route" + MetaKey_VDomPersist = "vdom:persist" MetaKey_Count = "count" ) diff --git a/pkg/waveobj/waveobj.go b/pkg/waveobj/waveobj.go index ba5931316..13111b54a 100644 --- a/pkg/waveobj/waveobj.go +++ b/pkg/waveobj/waveobj.go @@ -94,6 +94,14 @@ func ParseORef(orefStr string) (ORef, error) { return ORef{OType: otype, OID: oid}, nil } +func ParseORefNoErr(orefStr string) *ORef { + oref, err := ParseORef(orefStr) + if err != nil { + return nil + } + return &oref +} + type WaveObj interface { GetOType() string // should not depend on object state (should work with nil value) } diff --git a/pkg/waveobj/wtype.go b/pkg/waveobj/wtype.go index 2f3e87717..3e1df9e00 100644 --- a/pkg/waveobj/wtype.go +++ b/pkg/waveobj/wtype.go @@ -252,11 +252,13 @@ type WinSize struct { type Block struct { OID string `json:"oid"` + ParentORef string `json:"parentoref,omitempty"` Version int `json:"version"` BlockDef *BlockDef `json:"blockdef"` RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"` Stickers []*StickerType `json:"stickers,omitempty"` Meta MetaMapType `json:"meta"` + SubBlockIds []string `json:"subblockids,omitempty"` } func (*Block) GetOType() string { diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index d983942b4..367f44bc4 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -80,10 +80,13 @@ type MetaTSType struct { TermLocalShellPath string `json:"term:localshellpath,omitempty"` // matches settings TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` // matches settings TermScrollback *int `json:"term:scrollback,omitempty"` + TermVDomSubBlockId string `json:"term:vdomblockid,omitempty"` VDomClear bool `json:"vdom:*,omitempty"` VDomInitialized bool `json:"vdom:initialized,omitempty"` VDomCorrelationId string `json:"vdom:correlationid,omitempty"` + VDomRoute string `json:"vdom:route,omitempty"` + VDomPersist bool `json:"vdom:persist,omitempty"` Count int `json:"count,omitempty"` // temp for cpu plot. will remove later } diff --git a/pkg/wcore/wcore.go b/pkg/wcore/wcore.go index f282b4f53..784a735d8 100644 --- a/pkg/wcore/wcore.go +++ b/pkg/wcore/wcore.go @@ -26,21 +26,35 @@ import ( const DefaultTimeout = 2 * time.Second const DefaultActivateBlockTimeout = 60 * time.Second -func DeleteBlock(ctx context.Context, tabId string, blockId string) error { - err := wstore.DeleteBlock(ctx, tabId, blockId) +func DeleteBlock(ctx context.Context, blockId string) error { + block, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) + } + if block == nil { + return nil + } + if len(block.SubBlockIds) > 0 { + for _, subBlockId := range block.SubBlockIds { + err := DeleteBlock(ctx, subBlockId) + if err != nil { + return fmt.Errorf("error deleting subblock %s: %w", subBlockId, err) + } + } + } + err = wstore.DeleteBlock(ctx, blockId) if err != nil { return fmt.Errorf("error deleting block: %w", err) } go blockcontroller.StopBlockController(blockId) - sendBlockCloseEvent(tabId, blockId) + sendBlockCloseEvent(blockId) return nil } -func sendBlockCloseEvent(tabId string, blockId string) { +func sendBlockCloseEvent(blockId string) { waveEvent := wps.WaveEvent{ Event: wps.Event_BlockClose, Scopes: []string{ - waveobj.MakeORef(waveobj.OType_Tab, tabId).String(), waveobj.MakeORef(waveobj.OType_Block, blockId).String(), }, Data: blockId, @@ -58,7 +72,7 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string) error { } // close blocks (sends events + stops block controllers) for _, blockId := range tabData.BlockIds { - err := DeleteBlock(ctx, tabId, blockId) + err := DeleteBlock(ctx, blockId) if err != nil { return fmt.Errorf("error deleting block %s: %w", blockId, err) } @@ -205,6 +219,20 @@ func CreateClient(ctx context.Context) (*waveobj.Client, error) { return client, nil } +func CreateSubBlock(ctx context.Context, blockId string, blockDef *waveobj.BlockDef) (*waveobj.Block, error) { + if blockDef == nil { + return nil, fmt.Errorf("blockDef is nil") + } + if blockDef.Meta == nil || blockDef.Meta.GetString(waveobj.MetaKey_View, "") == "" { + return nil, fmt.Errorf("no view provided for new block") + } + blockData, err := wstore.CreateSubBlock(ctx, blockId, blockDef) + if err != nil { + return nil, fmt.Errorf("error creating sub block: %w", err) + } + return blockData, nil +} + func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) { if blockDef == nil { return nil, fmt.Errorf("blockDef is nil") diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 5a8d553df..2539742e7 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -86,12 +86,24 @@ func CreateBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateBlockData, o return resp, err } +// command "createsubblock", wshserver.CreateSubBlockCommand +func CreateSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateSubBlockData, opts *wshrpc.RpcOpts) (waveobj.ORef, error) { + resp, err := sendRpcRequestCallHelper[waveobj.ORef](w, "createsubblock", data, opts) + return resp, err +} + // command "deleteblock", wshserver.DeleteBlockCommand func DeleteBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "deleteblock", data, opts) return err } +// command "deletesubblock", wshserver.DeleteSubBlockCommand +func DeleteSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "deletesubblock", data, opts) + return err +} + // command "dispose", wshserver.DisposeCommand func DisposeCommand(w *wshutil.WshRpc, data wshrpc.CommandDisposeData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "dispose", data, opts) @@ -274,9 +286,9 @@ func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiation } // command "vdomcreatecontext", wshserver.VDomCreateContextCommand -func VDomCreateContextCommand(w *wshutil.WshRpc, data vdom.VDomCreateContext, opts *wshrpc.RpcOpts) error { - _, err := sendRpcRequestCallHelper[any](w, "vdomcreatecontext", data, opts) - return err +func VDomCreateContextCommand(w *wshutil.WshRpc, data vdom.VDomCreateContext, opts *wshrpc.RpcOpts) (*waveobj.ORef, error) { + resp, err := sendRpcRequestCallHelper[*waveobj.ORef](w, "vdomcreatecontext", data, opts) + return resp, err } // command "vdomrender", wshserver.VDomRenderCommand @@ -285,6 +297,12 @@ func VDomRenderCommand(w *wshutil.WshRpc, data vdom.VDomFrontendUpdate, opts *ws return resp, err } +// command "waitforroute", wshserver.WaitForRouteCommand +func WaitForRouteCommand(w *wshutil.WshRpc, data wshrpc.CommandWaitForRouteData, opts *wshrpc.RpcOpts) (bool, error) { + resp, err := sendRpcRequestCallHelper[bool](w, "waitforroute", data, opts) + return resp, err +} + // command "webselector", wshserver.WebSelectorCommand func WebSelectorCommand(w *wshutil.WshRpc, data wshrpc.CommandWebSelectorData, opts *wshrpc.RpcOpts) ([]string, error) { resp, err := sendRpcRequestCallHelper[[]string](w, "webselector", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 40ae627de..19754bec7 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -103,7 +103,10 @@ type WshRpcInterface interface { FileAppendIJsonCommand(ctx context.Context, data CommandAppendIJsonData) error ResolveIdsCommand(ctx context.Context, data CommandResolveIdsData) (CommandResolveIdsRtnData, error) CreateBlockCommand(ctx context.Context, data CommandCreateBlockData) (waveobj.ORef, error) + CreateSubBlockCommand(ctx context.Context, data CommandCreateSubBlockData) (waveobj.ORef, error) DeleteBlockCommand(ctx context.Context, data CommandDeleteBlockData) error + DeleteSubBlockCommand(ctx context.Context, data CommandDeleteBlockData) error + WaitForRouteCommand(ctx context.Context, data CommandWaitForRouteData) (bool, error) FileWriteCommand(ctx context.Context, data CommandFileData) error FileReadCommand(ctx context.Context, data CommandFileData) (string, error) EventPublishCommand(ctx context.Context, data wps.WaveEvent) error @@ -145,7 +148,7 @@ type WshRpcInterface interface { NotifyCommand(ctx context.Context, notificationOptions WaveNotificationOptions) error // terminal - VDomCreateContextCommand(ctx context.Context, data vdom.VDomCreateContext) error + VDomCreateContextCommand(ctx context.Context, data vdom.VDomCreateContext) (*waveobj.ORef, error) VDomAsyncInitiationCommand(ctx context.Context, data vdom.VDomAsyncInitiationRequest) error // proc @@ -248,6 +251,11 @@ type CommandCreateBlockData struct { Magnified bool `json:"magnified,omitempty"` } +type CommandCreateSubBlockData struct { + ParentBlockId string `json:"parentblockid"` + BlockDef *waveobj.BlockDef `json:"blockdef"` +} + type CommandBlockSetViewData struct { BlockId string `json:"blockid" wshcontext:"BlockId"` View string `json:"view"` @@ -279,6 +287,11 @@ type CommandAppendIJsonData struct { Data ijson.Command `json:"data"` } +type CommandWaitForRouteData struct { + RouteId string `json:"routeid"` + WaitMs int `json:"waitms"` +} + type CommandDeleteBlockData struct { BlockId string `json:"blockid" wshcontext:"BlockId"` } @@ -402,10 +415,10 @@ type CommandWebSelectorData struct { } type BlockInfoData struct { - BlockId string `json:"blockid"` - TabId string `json:"tabid"` - WindowId string `json:"windowid"` - Meta waveobj.MetaMapType `json:"meta"` + BlockId string `json:"blockid"` + TabId string `json:"tabid"` + WindowId string `json:"windowid"` + Block *waveobj.Block `json:"block"` } type WaveNotificationOptions struct { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index df1670b19..b08de1f45 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -250,6 +250,16 @@ func (ws *WshServer) CreateBlockCommand(ctx context.Context, data wshrpc.Command return &waveobj.ORef{OType: waveobj.OType_Block, OID: blockRef.OID}, nil } +func (ws *WshServer) CreateSubBlockCommand(ctx context.Context, data wshrpc.CommandCreateSubBlockData) (*waveobj.ORef, error) { + parentBlockId := data.ParentBlockId + blockData, err := wcore.CreateSubBlock(ctx, parentBlockId, data.BlockDef) + if err != nil { + return nil, fmt.Errorf("error creating block: %w", err) + } + blockRef := &waveobj.ORef{OType: waveobj.OType_Block, OID: blockData.OID} + return blockRef, nil +} + func (ws *WshServer) SetViewCommand(ctx context.Context, data wshrpc.CommandBlockSetViewData) error { log.Printf("SETVIEW: %s | %q\n", data.BlockId, data.View) ctx = waveobj.ContextWithUpdates(ctx) @@ -356,10 +366,10 @@ func (ws *WshServer) FileAppendCommand(ctx context.Context, data wshrpc.CommandF func (ws *WshServer) FileAppendIJsonCommand(ctx context.Context, data wshrpc.CommandAppendIJsonData) error { tryCreate := true - if data.FileName == blockcontroller.BlockFile_Html && tryCreate { + if data.FileName == blockcontroller.BlockFile_VDom && tryCreate { err := filestore.WFS.MakeFile(ctx, data.ZoneId, data.FileName, nil, filestore.FileOptsType{MaxSize: blockcontroller.DefaultHtmlMaxFileSize, IJson: true}) if err != nil && err != fs.ErrExist { - return fmt.Errorf("error creating blockfile[html]: %w", err) + return fmt.Errorf("error creating blockfile[vdom]: %w", err) } } err := filestore.WFS.AppendIJson(ctx, data.ZoneId, data.FileName, data.Data) @@ -379,6 +389,14 @@ func (ws *WshServer) FileAppendIJsonCommand(ctx context.Context, data wshrpc.Com return nil } +func (ws *WshServer) DeleteSubBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { + err := wcore.DeleteBlock(ctx, data.BlockId) + if err != nil { + return fmt.Errorf("error deleting block: %w", err) + } + return nil +} + func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { ctx = waveobj.ContextWithUpdates(ctx) tabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId) @@ -395,7 +413,7 @@ func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.Command if windowId == "" { return fmt.Errorf("no window found for tab") } - err = wcore.DeleteBlock(ctx, tabId, data.BlockId) + err = wcore.DeleteBlock(ctx, data.BlockId) if err != nil { return fmt.Errorf("error deleting block: %w", err) } @@ -408,6 +426,13 @@ func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.Command return nil } +func (ws *WshServer) WaitForRouteCommand(ctx context.Context, data wshrpc.CommandWaitForRouteData) (bool, error) { + waitCtx, cancelFn := context.WithTimeout(ctx, time.Duration(data.WaitMs)*time.Millisecond) + defer cancelFn() + err := wshutil.DefaultRouter.WaitForRegister(waitCtx, data.RouteId) + return err == nil, nil +} + func (ws *WshServer) EventRecvCommand(ctx context.Context, data wps.WaveEvent) error { return nil } @@ -587,6 +612,6 @@ func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wsh BlockId: blockId, TabId: tabId, WindowId: windowId, - Meta: blockData.Meta, + Block: blockData, }, nil } diff --git a/pkg/wshutil/wshrouter.go b/pkg/wshutil/wshrouter.go index 64479f498..10b5df517 100644 --- a/pkg/wshutil/wshrouter.go +++ b/pkg/wshutil/wshrouter.go @@ -268,6 +268,9 @@ func (router *WshRouter) WaitForRegister(ctx context.Context, routeId string) er if router.GetRpc(routeId) != nil { return nil } + if router.getAnnouncedRoute(routeId) != "" { + return nil + } select { case <-ctx.Done(): return ctx.Err() diff --git a/pkg/wstore/wstore.go b/pkg/wstore/wstore.go index 1c9824350..0872ec45b 100644 --- a/pkg/wstore/wstore.go +++ b/pkg/wstore/wstore.go @@ -95,6 +95,27 @@ func UpdateTabName(ctx context.Context, tabId, name string) error { }) } +func CreateSubBlock(ctx context.Context, parentBlockId string, blockDef *waveobj.BlockDef) (*waveobj.Block, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (*waveobj.Block, error) { + parentBlock, _ := DBGet[*waveobj.Block](tx.Context(), parentBlockId) + if parentBlock == nil { + return nil, fmt.Errorf("parent block not found: %q", parentBlockId) + } + blockId := uuid.NewString() + blockData := &waveobj.Block{ + OID: blockId, + ParentORef: waveobj.MakeORef(waveobj.OType_Block, parentBlockId).String(), + BlockDef: blockDef, + RuntimeOpts: nil, + Meta: blockDef.Meta, + } + DBInsert(tx.Context(), blockData) + parentBlock.SubBlockIds = append(parentBlock.SubBlockIds, blockId) + DBUpdate(tx.Context(), parentBlock) + return blockData, nil + }) +} + func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) { return WithTxRtn(ctx, func(tx *TxWrap) (*waveobj.Block, error) { tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) @@ -104,6 +125,7 @@ func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, blockId := uuid.NewString() blockData := &waveobj.Block{ OID: blockId, + ParentORef: waveobj.MakeORef(waveobj.OType_Tab, tabId).String(), BlockDef: blockDef, RuntimeOpts: rtOpts, Meta: blockDef.Meta, @@ -124,18 +146,34 @@ func findStringInSlice(slice []string, val string) int { return -1 } -func DeleteBlock(ctx context.Context, tabId string, blockId string) error { +func DeleteBlock(ctx context.Context, blockId string) error { return WithTx(ctx, func(tx *TxWrap) error { - tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) - if tab == nil { - return fmt.Errorf("tab not found: %q", tabId) + block, err := DBGet[*waveobj.Block](tx.Context(), blockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) } - blockIdx := findStringInSlice(tab.BlockIds, blockId) - if blockIdx == -1 { + if block == nil { return nil } - tab.BlockIds = append(tab.BlockIds[:blockIdx], tab.BlockIds[blockIdx+1:]...) - DBUpdate(tx.Context(), tab) + if len(block.SubBlockIds) > 0 { + return fmt.Errorf("block has subblocks, must delete subblocks first") + } + parentORef := waveobj.ParseORefNoErr(block.ParentORef) + if parentORef != nil { + if parentORef.OType == waveobj.OType_Tab { + tab, _ := DBGet[*waveobj.Tab](tx.Context(), parentORef.OID) + if tab != nil { + tab.BlockIds = utilfn.RemoveElemFromSlice(tab.BlockIds, blockId) + DBUpdate(tx.Context(), tab) + } + } else if parentORef.OType == waveobj.OType_Block { + parentBlock, _ := DBGet[*waveobj.Block](tx.Context(), parentORef.OID) + if parentBlock != nil { + parentBlock.SubBlockIds = utilfn.RemoveElemFromSlice(parentBlock.SubBlockIds, blockId) + DBUpdate(tx.Context(), parentBlock) + } + } + } DBDelete(tx.Context(), waveobj.OType_Block, blockId) return nil }) @@ -145,23 +183,18 @@ func DeleteBlock(ctx context.Context, tabId string, blockId string) error { // also deletes LayoutState func DeleteTab(ctx context.Context, workspaceId string, tabId string) error { return WithTx(ctx, func(tx *TxWrap) error { - ws, _ := DBGet[*waveobj.Workspace](tx.Context(), workspaceId) - if ws == nil { - return fmt.Errorf("workspace not found: %q", workspaceId) - } tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) if tab == nil { - return fmt.Errorf("tab not found: %q", tabId) + return nil } if len(tab.BlockIds) != 0 { return fmt.Errorf("tab has blocks, must delete blocks first") } - tabIdx := findStringInSlice(ws.TabIds, tabId) - if tabIdx == -1 { - return nil + ws, _ := DBGet[*waveobj.Workspace](tx.Context(), workspaceId) + if ws != nil { + ws.TabIds = utilfn.RemoveElemFromSlice(ws.TabIds, tabId) + DBUpdate(tx.Context(), ws) } - ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...) - DBUpdate(tx.Context(), ws) DBDelete(tx.Context(), waveobj.OType_Tab, tabId) DBDelete(tx.Context(), waveobj.OType_LayoutState, tab.LayoutState) return nil @@ -190,6 +223,10 @@ func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaM func MoveBlockToTab(ctx context.Context, currentTabId string, newTabId string, blockId string) error { return WithTx(ctx, func(tx *TxWrap) error { + block, _ := DBGet[*waveobj.Block](tx.Context(), blockId) + if block == nil { + return fmt.Errorf("block not found: %q", blockId) + } currentTab, _ := DBGet[*waveobj.Tab](tx.Context(), currentTabId) if currentTab == nil { return fmt.Errorf("current tab not found: %q", currentTabId) @@ -204,6 +241,8 @@ func MoveBlockToTab(ctx context.Context, currentTabId string, newTabId string, b } currentTab.BlockIds = utilfn.RemoveElemFromSlice(currentTab.BlockIds, blockId) newTab.BlockIds = append(newTab.BlockIds, blockId) + block.ParentORef = waveobj.MakeORef(waveobj.OType_Tab, newTabId).String() + DBUpdate(tx.Context(), block) DBUpdate(tx.Context(), currentTab) DBUpdate(tx.Context(), newTab) return nil