diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 0dc48463..7d01ae31 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -127,7 +127,8 @@ func main() { if pidStr != "" { _, err := strconv.Atoi(pidStr) if err == nil { - log.Printf("WAVESRV-ESTART\n") + // use fmt instead of log here to make sure it goes directly to stderr + fmt.Fprintf(os.Stderr, "WAVESRV-ESTART\n") } } }() diff --git a/emain/emain.ts b/emain/emain.ts index 3e8c5c4b..c0684429 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -139,11 +139,37 @@ function runWaveSrv(): Promise { 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) { + if (evtMsg.eventtype == "electron:newwindow") { + let windowId: string = evtMsg.data; + let windowData: WaveWindow = (await services.ObjectService.GetObject("window:" + windowId)) as WaveWindow; + if (windowData == null) { + return; + } + let clientData = await services.ClientService.GetClientData(); + const newWin = createBrowserWindow(clientData.oid, windowData); + await newWin.readyPromise; + newWin.show(); + } else { + console.log("unhandled electron ws eventtype", evtMsg.eventtype); + } +} + async function mainResizeHandler(_: any, windowId: string, win: WaveBrowserWindow) { if (win == null || win.isDestroyed() || win.fullScreen) { return; @@ -201,7 +227,9 @@ function shFrameNavHandler(event: Electron.Event { async function createNewWaveWindow() { let clientData = await services.ClientService.GetClientData(); const newWindow = await services.ClientService.MakeWindow(); - createBrowserWindow(clientData, newWindow); + createBrowserWindow(clientData.oid, newWindow); } electron.ipcMain.on("openNewWindow", createNewWaveWindow); @@ -512,7 +540,7 @@ async function appMain() { }); continue; } - const win = createBrowserWindow(clientData, windowData); + const win = createBrowserWindow(clientData.oid, windowData); wins.push(win); } for (let win of wins) { diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index e19f6b4d..4a7a79de 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -3,11 +3,11 @@ import { Workspace } from "@/app/workspace/workspace"; import { getLayoutStateAtomForTab, globalLayoutTransformsMap } from "@/faraday/lib/layoutAtom"; -import type { LayoutTreeState } from "@/faraday/lib/model"; import { ContextMenuModel } from "@/store/contextmenu"; import { WOS, atoms, globalStore, setBlockFocus } from "@/store/global"; import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; +import * as layoututil from "@/util/layoututil"; import * as util from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; @@ -95,18 +95,6 @@ function switchTab(offset: number) { services.ObjectService.SetActiveTab(newActiveTabId); } -function findLeafIdFromBlockId(layoutTree: LayoutTreeState, blockId: string): string { - if (layoutTree?.leafs == null) { - return null; - } - for (let leaf of layoutTree.leafs) { - if (leaf.data.blockId == blockId) { - return leaf.id; - } - } - return null; -} - var transformRegexp = /translate\(\s*([0-9.]+)px\s*,\s*([0-9.]+)px\)/; function parseFloatFromCSS(s: string | number): number { @@ -174,7 +162,7 @@ function switchBlock(tabId: string, offsetX: number, offsetY: number) { } const layoutTreeState = globalStore.get(getLayoutStateAtomForTab(tabId, tabAtom)); const curBlockId = globalStore.get(atoms.waveWindow).activeblockid; - const curBlockLeafId = findLeafIdFromBlockId(layoutTreeState, curBlockId); + const curBlockLeafId = layoututil.findLeafIdFromBlockId(layoutTreeState, curBlockId); if (curBlockLeafId == null) { return; } diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 7e9e4bbc..510b73dc 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -8,7 +8,8 @@ import { TerminalView } from "@/app/view/term/term"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; import { ContextMenuModel } from "@/store/contextmenu"; -import { atoms, setBlockFocus, useBlockAtom } from "@/store/global"; +import { atoms, globalStore, setBlockFocus, useBlockAtom } from "@/store/global"; +import * as services from "@/store/services"; import * as WOS from "@/store/wos"; import * as util from "@/util/util"; import clsx from "clsx"; @@ -144,7 +145,12 @@ function handleHeaderContextMenu(e: React.MouseEvent, blockData: menu.push({ label: "Move to New Window", click: () => { - alert("Not Implemented"); + let currentTabId = globalStore.get(atoms.activeTabId); + try { + services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid); + } catch (e) { + console.error("error moving block to new window", e); + } }, }); menu.push({ type: "separator" }); @@ -173,7 +179,12 @@ const FramelessBlockHeader = ({ blockId, onClose, dragHandleRef }: FramelessBloc const settingsConfig = jotai.useAtomValue(atoms.settingsConfigAtom); return ( -
+
handleHeaderContextMenu(e, blockData, onClose)} + >
{getBlockHeaderText(null, blockData, settingsConfig)}
{onClose && (
diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 05845e65..c62d0d0c 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -5,6 +5,7 @@ import { LayoutTreeAction, LayoutTreeActionType, LayoutTreeInsertNodeAction, new import { getLayoutStateAtomForTab } from "@/faraday/lib/layoutAtom"; import { layoutTreeStateReducer } from "@/faraday/lib/layoutState"; +import * as layoututil from "@/util/layoututil"; import { produce } from "immer"; import * as jotai from "jotai"; import * as rxjs from "rxjs"; @@ -217,7 +218,8 @@ function handleWSEventMessage(msg: WSEventType) { return; } if (msg.eventtype == "layoutaction") { - const layoutAction: WSLayoutAction = msg.data; + console.log("got wslayoutaction", msg); + const layoutAction: WSLayoutActionData = msg.data; if (layoutAction.actiontype == LayoutTreeActionType.InsertNode) { const insertNodeAction: LayoutTreeInsertNodeAction = { type: LayoutTreeActionType.InsertNode, @@ -226,6 +228,18 @@ function handleWSEventMessage(msg: WSEventType) { }), }; runLayoutAction(layoutAction.tabid, insertNodeAction); + } else if (layoutAction.actiontype == LayoutTreeActionType.DeleteNode) { + const layoutStateAtom = getLayoutStateAtomForTab( + layoutAction.tabid, + WOS.getWaveObjectAtom(WOS.makeORef("tab", layoutAction.tabid)) + ); + const curState = globalStore.get(layoutStateAtom); + const leafId = layoututil.findLeafIdFromBlockId(curState, layoutAction.blockid); + const deleteNodeAction = { + type: LayoutTreeActionType.DeleteNode, + nodeId: leafId, + }; + runLayoutAction(layoutAction.tabid, deleteNodeAction); } else { console.log("unsupported layout action", layoutAction); } diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index cd40fb50..c465c942 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -138,6 +138,12 @@ class WindowServiceType { return WOS.callBackendService("window", "CloseWindow", Array.from(arguments)) } + // move block to new window + // @returns object updates + MoveBlockToNewWindow(currentTabId: string, blockId: string): Promise { + return WOS.callBackendService("window", "MoveBlockToNewWindow", Array.from(arguments)) + } + // @returns object updates SetWindowPosAndSize(arg2: string, arg3: Point, arg4: WinSize): Promise { return WOS.callBackendService("window", "SetWindowPosAndSize", Array.from(arguments)) diff --git a/frontend/util/layoututil.ts b/frontend/util/layoututil.ts new file mode 100644 index 00000000..3a27588b --- /dev/null +++ b/frontend/util/layoututil.ts @@ -0,0 +1,18 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { LayoutTreeState } from "@/faraday/index"; + +function findLeafIdFromBlockId(layoutTree: LayoutTreeState, blockId: string): string { + if (layoutTree?.leafs == null) { + return null; + } + for (let leaf of layoutTree.leafs) { + if (leaf.data.blockId == blockId) { + return leaf.id; + } + } + return null; +} + +export { findLeafIdFromBlockId }; diff --git a/frontend/wave.ts b/frontend/wave.ts index 35b0999c..1a0a7e0a 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -20,7 +20,6 @@ console.log("clientid", clientId, "windowid", windowId); keyutil.setKeyUtilPlatform(getApi().getPlatform()); loadFonts(); -initWS(); (window as any).globalWS = globalWS; (window as any).WOS = WOS; (window as any).globalStore = globalStore; @@ -33,6 +32,9 @@ document.addEventListener("DOMContentLoaded", async () => { 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)); + WOS.loadAndPinWaveObject(WOS.makeORef("layout", initialTab.layoutNode)); + initWS(); globalStore.set(atoms.settingsConfigAtom, await services.FileService.GetSettingsConfig()); services.ObjectService.SetActiveTab(waveWindow.activetabid); // no need to wait const reactElem = React.createElement(App, null, null); diff --git a/pkg/eventbus/eventbus.go b/pkg/eventbus/eventbus.go index 18a2ac7a..e8719fa2 100644 --- a/pkg/eventbus/eventbus.go +++ b/pkg/eventbus/eventbus.go @@ -4,7 +4,12 @@ package eventbus import ( + "encoding/json" + "fmt" + "log" + "os" "sync" + "time" "github.com/wavetermdev/thenextwave/pkg/waveobj" ) @@ -15,6 +20,7 @@ const ( WSEvent_Config = "config" WSEvent_BlockControllerStatus = "blockcontroller:status" WSEvent_LayoutAction = "layoutaction" + WSEvent_ElectronNewWindow = "electron:newwindow" ) type WSEventType struct { @@ -41,6 +47,11 @@ type WindowWatchData struct { WatchedORefs map[waveobj.ORef]bool } +const ( + WSLayoutActionType_Insert = "insert" + WSLayoutActionType_Remove = "delete" +) + type WSLayoutActionData struct { TabId string `json:"tabid"` ActionType string `json:"actiontype"` @@ -78,6 +89,21 @@ func getWindowWatchesForWindowId(windowId string) []*WindowWatchData { return watches } +// TODO fix busy wait -- but we need to wait until a new window connects back with a websocket +// returns true if the window is connected +func BusyWaitForWindowId(windowId string, timeout time.Duration) bool { + endTime := time.Now().Add(timeout) + for { + if len(getWindowWatchesForWindowId(windowId)) > 0 { + return true + } + if time.Now().After(endTime) { + return false + } + time.Sleep(20 * time.Millisecond) + } +} + func getAllWatches() []*WindowWatchData { globalLock.Lock() defer globalLock.Unlock() @@ -101,3 +127,14 @@ func SendEvent(event WSEventType) { wdata.WindowWSCh <- event } } + +func SendEventToElectron(event WSEventType) { + barr, err := json.Marshal(event) + if err != nil { + log.Printf("cannot marshal electron message: %v\n", err) + return + } + // send to electron + log.Printf("sending event to electron: %q\n", event.EventType) + fmt.Fprintf(os.Stderr, "\nWAVESRV-EVENT:%s\n", string(barr)) +} diff --git a/pkg/service/windowservice/windowservice.go b/pkg/service/windowservice/windowservice.go index d6e0a651..d7a45b39 100644 --- a/pkg/service/windowservice/windowservice.go +++ b/pkg/service/windowservice/windowservice.go @@ -6,9 +6,12 @@ package windowservice import ( "context" "fmt" + "log" "time" "github.com/wavetermdev/thenextwave/pkg/blockcontroller" + "github.com/wavetermdev/thenextwave/pkg/eventbus" + "github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/thenextwave/pkg/util/utilfn" "github.com/wavetermdev/thenextwave/pkg/wstore" ) @@ -72,6 +75,70 @@ func (svc *WindowService) CloseTab(ctx context.Context, uiContext wstore.UIConte return wstore.ContextGetUpdatesRtn(ctx), nil } +func (svc *WindowService) MoveBlockToNewWindow_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + Desc: "move block to new window", + ArgNames: []string{"ctx", "currentTabId", "blockId"}, + } +} + +func (svc *WindowService) MoveBlockToNewWindow(ctx context.Context, currentTabId string, blockId string) (wstore.UpdatesRtnType, error) { + log.Printf("MoveBlockToNewWindow(%s, %s)", currentTabId, blockId) + ctx = wstore.ContextWithUpdates(ctx) + curWindowId, err := wstore.DBFindWindowForTabId(ctx, currentTabId) + if err != nil { + return nil, fmt.Errorf("error finding window for current-tab: %w", err) + } + tab, err := wstore.DBMustGet[*wstore.Tab](ctx, currentTabId) + if err != nil { + return nil, fmt.Errorf("error getting tab: %w", err) + } + log.Printf("tab.BlockIds[%s]: %v", tab.OID, tab.BlockIds) + var foundBlock bool + for _, tabBlockId := range tab.BlockIds { + if tabBlockId == blockId { + foundBlock = true + break + } + } + if !foundBlock { + return nil, fmt.Errorf("block not found in current tab") + } + newWindow, err := wstore.CreateWindow(ctx) + if err != nil { + return nil, fmt.Errorf("error creating window: %w", err) + } + err = wstore.MoveBlockToTab(ctx, currentTabId, newWindow.ActiveTabId, blockId) + if err != nil { + return nil, fmt.Errorf("error moving block to tab: %w", err) + } + eventbus.SendEventToElectron(eventbus.WSEventType{ + EventType: eventbus.WSEvent_ElectronNewWindow, + Data: newWindow.OID, + }) + windowCreated := eventbus.BusyWaitForWindowId(newWindow.OID, 2*time.Second) + if !windowCreated { + return nil, fmt.Errorf("new window not created") + } + eventbus.SendEventToWindow(curWindowId, eventbus.WSEventType{ + EventType: eventbus.WSEvent_LayoutAction, + Data: eventbus.WSLayoutActionData{ + ActionType: eventbus.WSLayoutActionType_Remove, + TabId: currentTabId, + BlockId: blockId, + }, + }) + eventbus.SendEventToWindow(newWindow.OID, eventbus.WSEventType{ + EventType: eventbus.WSEvent_LayoutAction, + Data: eventbus.WSLayoutActionData{ + ActionType: eventbus.WSLayoutActionType_Insert, + TabId: newWindow.ActiveTabId, + BlockId: blockId, + }, + }) + return wstore.ContextGetUpdatesRtn(ctx), nil +} + func (svc *WindowService) CloseWindow(ctx context.Context, windowId string) error { ctx = wstore.ContextWithUpdates(ctx) window, err := wstore.DBMustGet[*wstore.Window](ctx, windowId) diff --git a/pkg/wstore/wstore.go b/pkg/wstore/wstore.go index b0c5de80..507cb5b5 100644 --- a/pkg/wstore/wstore.go +++ b/pkg/wstore/wstore.go @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/wavetermdev/thenextwave/pkg/util/utilfn" "github.com/wavetermdev/thenextwave/pkg/waveobj" ) @@ -395,6 +396,28 @@ func CreateWindow(ctx context.Context) (*Window, error) { return DBMustGet[*Window](ctx, windowId) } +func MoveBlockToTab(ctx context.Context, currentTabId string, newTabId string, blockId string) error { + return WithTx(ctx, func(tx *TxWrap) error { + currentTab, _ := DBGet[*Tab](tx.Context(), currentTabId) + if currentTab == nil { + return fmt.Errorf("current tab not found: %q", currentTabId) + } + newTab, _ := DBGet[*Tab](tx.Context(), newTabId) + if newTab == nil { + return fmt.Errorf("new tab not found: %q", newTabId) + } + blockIdx := findStringInSlice(currentTab.BlockIds, blockId) + if blockIdx == -1 { + return fmt.Errorf("block not found in current tab: %q", blockId) + } + currentTab.BlockIds = utilfn.RemoveElemFromSlice(currentTab.BlockIds, blockId) + newTab.BlockIds = append(newTab.BlockIds, blockId) + DBUpdate(tx.Context(), currentTab) + DBUpdate(tx.Context(), newTab) + return nil + }) +} + func CreateClient(ctx context.Context) (*Client, error) { client := &Client{ OID: uuid.NewString(),