From 74e86ef0ccad02f5c9f27c27cd1f0cad3c0af85e Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Wed, 31 Jul 2024 21:27:46 -0700 Subject: [PATCH] Bootstrap layout on first launch (#186) --- Taskfile.yml | 1 + frontend/app/store/global.ts | 66 +++++++++----- frontend/app/store/services.ts | 6 ++ frontend/layout/lib/layoutNode.ts | 28 ++++++ frontend/layout/lib/layoutState.ts | 30 ++++++- frontend/layout/lib/model.ts | 17 ++++ frontend/types/gotypes.d.ts | 2 + pkg/eventbus/eventbus.go | 2 + pkg/service/clientservice/clientservice.go | 100 +++++++++++++++++++++ pkg/service/objectservice/objectservice.go | 29 ++++-- 10 files changed, 249 insertions(+), 32 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index f5e895f7..4e488b70 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -149,6 +149,7 @@ tasks: - "pkg/wstore/*.go" - "pkg/wshrpc/**/*.go" - "pkg/tsgen/**/*.go" + - "pkg/eventbus/eventbus.go" generates: - frontend/types/gotypes.d.ts - pkg/wshrpc/wshclient/wshclient.go diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 74e6d378..8e65383b 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -11,6 +11,7 @@ import { getLayoutStateAtomForTab } from "frontend/layout/lib/layoutAtom"; import { layoutTreeStateReducer } from "frontend/layout/lib/layoutState"; import { handleIncomingRpcMessage } from "@/app/store/wshrpc"; +import { LayoutTreeInsertNodeAtIndexAction } from "@/layout/lib/model"; import { getWSServerEndpoint, getWebServerEndpoint } from "@/util/endpoints"; import * as layoututil from "@/util/layoututil"; import { produce } from "immer"; @@ -247,28 +248,49 @@ function handleWSEventMessage(msg: WSEventType) { } if (msg.eventtype == "layoutaction") { const layoutAction: WSLayoutActionData = msg.data; - if (layoutAction.actiontype == LayoutTreeActionType.InsertNode) { - const insertNodeAction: LayoutTreeInsertNodeAction = { - type: LayoutTreeActionType.InsertNode, - node: newLayoutNode(undefined, undefined, undefined, { - blockId: layoutAction.blockid, - }), - }; - 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); + switch (layoutAction.actiontype) { + case LayoutTreeActionType.InsertNode: { + const insertNodeAction: LayoutTreeInsertNodeAction = { + type: LayoutTreeActionType.InsertNode, + node: newLayoutNode(undefined, undefined, undefined, { + blockId: layoutAction.blockid, + }), + }; + runLayoutAction(layoutAction.tabid, insertNodeAction); + break; + } + case 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); + break; + } + case LayoutTreeActionType.InsertNodeAtIndex: { + if (!layoutAction.indexarr) { + console.error("Cannot apply eventbus layout action InsertNodeAtIndex, indexarr field is missing."); + break; + } + const insertAction: LayoutTreeInsertNodeAtIndexAction = { + type: LayoutTreeActionType.InsertNodeAtIndex, + node: newLayoutNode(undefined, layoutAction.nodesize, undefined, { + blockId: layoutAction.blockid, + }), + indexArr: layoutAction.indexarr, + }; + runLayoutAction(layoutAction.tabid, insertAction); + break; + } + default: + console.log("unsupported layout action", layoutAction); + break; } return; } diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index b5f064a8..1248f003 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -26,6 +26,9 @@ class ClientServiceType { AgreeTos(): Promise { return WOS.callBackendService("client", "AgreeTos", Array.from(arguments)) } + BootstrapStarterLayout(): Promise { + return WOS.callBackendService("client", "BootstrapStarterLayout", Array.from(arguments)) + } FocusWindow(arg2: string): Promise { return WOS.callBackendService("client", "FocusWindow", Array.from(arguments)) } @@ -89,6 +92,9 @@ class ObjectServiceType { CreateBlock(blockDef: BlockDef, rtOpts: RuntimeOpts): Promise { return WOS.callBackendService("object", "CreateBlock", Array.from(arguments)) } + CreateBlock_NoUI(arg2: string, arg3: BlockDef, arg4: RuntimeOpts): Promise { + return WOS.callBackendService("object", "CreateBlock_NoUI", Array.from(arguments)) + } // @returns object updates DeleteBlock(blockId: string): Promise { diff --git a/frontend/layout/lib/layoutNode.ts b/frontend/layout/lib/layoutNode.ts index ecb8908e..5eae7615 100644 --- a/frontend/layout/lib/layoutNode.ts +++ b/frontend/layout/lib/layoutNode.ts @@ -226,6 +226,34 @@ export function findNextInsertLocation( return { node: insertLoc?.node, index: insertLoc?.index }; } +/** + * Traverse the layout tree using the supplied index array to find the node to insert at. + * @param node The node to start the search from. + * @param indexArr The array of indices to aid in the traversal. + * @returns The node to insert into and the index at which to insert. + */ +export function findInsertLocationFromIndexArr( + node: LayoutNode, + indexArr: number[] +): { node: LayoutNode; index: number } { + function normalizeIndex(index: number) { + const childrenLength = node.children?.length ?? 1; + const lastChildIndex = childrenLength - 1; + if (index < 0) { + return childrenLength - Math.max(index, -childrenLength); + } + return Math.min(index, lastChildIndex); + } + if (indexArr.length == 0) { + return; + } + const nextIndex = normalizeIndex(indexArr.shift()); + if (indexArr.length == 0 || !node.children) { + return { node, index: nextIndex }; + } + return findInsertLocationFromIndexArr(node.children[nextIndex], indexArr); +} + function findNextInsertLocationHelper( node: LayoutNode, maxChildren: number, diff --git a/frontend/layout/lib/layoutState.ts b/frontend/layout/lib/layoutState.ts index ddeee765..a789ebfd 100644 --- a/frontend/layout/lib/layoutState.ts +++ b/frontend/layout/lib/layoutState.ts @@ -6,6 +6,7 @@ import { addChildAt, addIntermediateNode, balanceNode, + findInsertLocationFromIndexArr, findNextInsertLocation, findNode, findParent, @@ -19,6 +20,7 @@ import { LayoutTreeComputeMoveNodeAction, LayoutTreeDeleteNodeAction, LayoutTreeInsertNodeAction, + LayoutTreeInsertNodeAtIndexAction, LayoutTreeMagnifyNodeToggleAction, LayoutTreeMoveNodeAction, LayoutTreeResizeNodeAction, @@ -97,6 +99,10 @@ function layoutTreeStateReducerInner(layoutTreeState: LayoutTreeState, act insertNode(layoutTreeState, action as LayoutTreeInsertNodeAction); layoutTreeState.generation++; break; + case LayoutTreeActionType.InsertNodeAtIndex: + insertNodeAtIndex(layoutTreeState, action as LayoutTreeInsertNodeAtIndexAction); + layoutTreeState.generation++; + break; case LayoutTreeActionType.DeleteNode: deleteNode(layoutTreeState, action as LayoutTreeDeleteNodeAction); layoutTreeState.generation++; @@ -370,7 +376,7 @@ function moveNode(layoutTreeState: LayoutTreeState, action: LayoutTreeMove function insertNode(layoutTreeState: LayoutTreeState, action: LayoutTreeInsertNodeAction) { if (!action?.node) { - console.error("no insert node action provided"); + console.error("insertNode cannot run, no insert node action provided"); return; } if (!layoutTreeState.rootNode) { @@ -386,6 +392,28 @@ function insertNode(layoutTreeState: LayoutTreeState, action: LayoutTreeIn layoutTreeState.leafs = leafs; } +function insertNodeAtIndex(layoutTreeState: LayoutTreeState, action: LayoutTreeInsertNodeAtIndexAction) { + if (!action?.node || !action?.indexArr) { + console.error("insertNodeAtIndex cannot run, either node or indexArr field is missing"); + return; + } + if (!layoutTreeState.rootNode) { + const { node: balancedNode, leafs } = balanceNode(action.node); + layoutTreeState.rootNode = balancedNode; + layoutTreeState.leafs = leafs; + return; + } + const insertLoc = findInsertLocationFromIndexArr(layoutTreeState.rootNode, action.indexArr); + if (!insertLoc) { + console.error("insertNodeAtIndex unable to find insert location"); + return; + } + addChildAt(insertLoc.node, insertLoc.index + 1, action.node); + const { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode); + layoutTreeState.rootNode = newRootNode; + layoutTreeState.leafs = leafs; +} + function swapNode(layoutTreeState: LayoutTreeState, action: LayoutTreeSwapNodeAction) { console.log("swapNode", layoutTreeState, action); diff --git a/frontend/layout/lib/model.ts b/frontend/layout/lib/model.ts index a029d8ef..fde0ccc1 100644 --- a/frontend/layout/lib/model.ts +++ b/frontend/layout/lib/model.ts @@ -41,6 +41,7 @@ export enum LayoutTreeActionType { ClearPendingAction = "clearpending", ResizeNode = "resize", InsertNode = "insert", + InsertNodeAtIndex = "insertatindex", DeleteNode = "delete", MagnifyNodeToggle = "magnify", } @@ -104,6 +105,22 @@ export interface LayoutTreeInsertNodeAction extends LayoutTreeAction { node: LayoutNode; } +/** + * Action for inserting a node into the layout tree at the specified index. + */ +export interface LayoutTreeInsertNodeAtIndexAction extends LayoutTreeAction { + type: LayoutTreeActionType.InsertNodeAtIndex; + /** + * The node to insert. + */ + node: LayoutNode; + /** + * The array of indices to traverse when inserting the node. + * The last index is the index within the parent node where the node should be inserted. + */ + indexArr: number[]; +} + /** * Action for deleting a node from the layout tree. */ diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 51ec5cb0..de049d68 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -472,6 +472,8 @@ declare global { tabid: string; actiontype: string; blockid: string; + nodesize?: number; + indexarr?: number[]; }; // webcmd.WSRpcCommand diff --git a/pkg/eventbus/eventbus.go b/pkg/eventbus/eventbus.go index 1e5b14ec..7c63a31e 100644 --- a/pkg/eventbus/eventbus.go +++ b/pkg/eventbus/eventbus.go @@ -60,6 +60,8 @@ type WSLayoutActionData struct { TabId string `json:"tabid"` ActionType string `json:"actiontype"` BlockId string `json:"blockid"` + NodeSize uint `json:"nodesize,omitempty"` + IndexArr []int `json:"indexarr,omitempty"` } var globalLock = &sync.Mutex{} diff --git a/pkg/service/clientservice/clientservice.go b/pkg/service/clientservice/clientservice.go index 486caa91..d8c90cee 100644 --- a/pkg/service/clientservice/clientservice.go +++ b/pkg/service/clientservice/clientservice.go @@ -6,8 +6,11 @@ package clientservice import ( "context" "fmt" + "log" "time" + "github.com/wavetermdev/thenextwave/pkg/eventbus" + "github.com/wavetermdev/thenextwave/pkg/service/objectservice" "github.com/wavetermdev/thenextwave/pkg/util/utilfn" "github.com/wavetermdev/thenextwave/pkg/wstore" ) @@ -86,5 +89,102 @@ func (cs *ClientService) AgreeTos(ctx context.Context) (wstore.UpdatesRtnType, e if err != nil { return nil, fmt.Errorf("error updating client data: %w", err) } + cs.BootstrapStarterLayout(ctx) return wstore.ContextGetUpdatesRtn(ctx), nil } + +type PortableLayout []struct { + IndexArr []int + Size uint + BlockDef *wstore.BlockDef +} + +func (cs *ClientService) BootstrapStarterLayout(ctx context.Context) error { + ctx, cancelFn := context.WithTimeout(ctx, 2*time.Second) + defer cancelFn() + client, err := wstore.DBGetSingleton[*wstore.Client](ctx) + if err != nil { + log.Printf("unable to find client: %v\n", err) + return fmt.Errorf("unable to find client: %w", err) + } + + if len(client.WindowIds) < 1 { + return fmt.Errorf("error bootstrapping layout, no windows exist") + } + + windowId := client.WindowIds[0] + + window, err := wstore.DBMustGet[*wstore.Window](ctx, windowId) + if err != nil { + return fmt.Errorf("error getting window: %w", err) + } + + tabId := window.ActiveTabId + + starterLayout := PortableLayout{ + {IndexArr: []int{0}, BlockDef: &wstore.BlockDef{ + Meta: wstore.MetaMapType{ + wstore.MetaKey_View: "term", + wstore.MetaKey_Controller: "shell", + }, + }}, + {IndexArr: []int{1}, BlockDef: &wstore.BlockDef{ + Meta: wstore.MetaMapType{ + wstore.MetaKey_View: "cpuplot", + }, + }}, + {IndexArr: []int{1, 1}, BlockDef: &wstore.BlockDef{ + Meta: wstore.MetaMapType{ + wstore.MetaKey_View: "web", + wstore.MetaKey_Url: "https://github.com/wavetermdev/waveterm", + }, + }}, + {IndexArr: []int{1, 2}, BlockDef: &wstore.BlockDef{ + Meta: wstore.MetaMapType{ + wstore.MetaKey_View: "preview", + wstore.MetaKey_File: "~", + }, + }}, + {IndexArr: []int{2}, BlockDef: &wstore.BlockDef{ + Meta: wstore.MetaMapType{ + wstore.MetaKey_View: "term", + wstore.MetaKey_Controller: "shell", + }, + }}, + {IndexArr: []int{2, 1}, BlockDef: &wstore.BlockDef{ + Meta: wstore.MetaMapType{ + wstore.MetaKey_View: "waveai", + }, + }}, + {IndexArr: []int{2, 2}, BlockDef: &wstore.BlockDef{ + Meta: wstore.MetaMapType{ + wstore.MetaKey_View: "web", + wstore.MetaKey_Url: "https://www.youtube.com/embed/cKqsw_sAsU8", + }, + }}, + } + + objsvc := &objectservice.ObjectService{} + + for i := 0; i < len(starterLayout); i++ { + layoutAction := starterLayout[i] + + blockData, err := objsvc.CreateBlock_NoUI(ctx, tabId, layoutAction.BlockDef, &wstore.RuntimeOpts{}) + + if err != nil { + return fmt.Errorf("unable to create block for starter layout: %w", err) + } + + eventbus.SendEventToWindow(windowId, eventbus.WSEventType{ + EventType: eventbus.WSEvent_LayoutAction, + Data: &eventbus.WSLayoutActionData{ + ActionType: "insertatindex", + TabId: tabId, + BlockId: blockData.OID, + IndexArr: layoutAction.IndexArr, + NodeSize: layoutAction.Size, + }, + }) + } + return nil +} diff --git a/pkg/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go index 31a30df6..ef55f53e 100644 --- a/pkg/service/objectservice/objectservice.go +++ b/pkg/service/objectservice/objectservice.go @@ -178,6 +178,22 @@ func (svc *ObjectService) CreateBlock_Meta() tsgenmeta.MethodMeta { } } +func (svc *ObjectService) CreateBlock_NoUI(ctx context.Context, tabId string, blockDef *wstore.BlockDef, rtOpts *wstore.RuntimeOpts) (*wstore.Block, error) { + blockData, err := wstore.CreateBlock(ctx, tabId, blockDef, rtOpts) + if err != nil { + return nil, fmt.Errorf("error creating block: %w", err) + } + controllerName := blockData.Meta.GetString(wstore.MetaKey_Controller, "") + if controllerName != "" { + err = blockcontroller.StartBlockController(ctx, tabId, blockData.OID) + if err != nil { + return nil, fmt.Errorf("error starting block controller: %w", err) + } + } + + return blockData, nil +} + func (svc *ObjectService) CreateBlock(uiContext wstore.UIContext, blockDef *wstore.BlockDef, rtOpts *wstore.RuntimeOpts) (string, wstore.UpdatesRtnType, error) { if uiContext.ActiveTabId == "" { return "", nil, fmt.Errorf("no active tab") @@ -185,17 +201,12 @@ func (svc *ObjectService) CreateBlock(uiContext wstore.UIContext, blockDef *wsto ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = wstore.ContextWithUpdates(ctx) - blockData, err := wstore.CreateBlock(ctx, uiContext.ActiveTabId, blockDef, rtOpts) + + blockData, err := svc.CreateBlock_NoUI(ctx, uiContext.ActiveTabId, blockDef, rtOpts) if err != nil { - return "", nil, fmt.Errorf("error creating block: %w", err) - } - controllerName := blockData.Meta.GetString(wstore.MetaKey_Controller, "") - if controllerName != "" { - err = blockcontroller.StartBlockController(ctx, uiContext.ActiveTabId, blockData.OID) - if err != nil { - return "", nil, fmt.Errorf("error starting block controller: %w", err) - } + return "", nil, err } + return blockData.OID, wstore.ContextGetUpdatesRtn(ctx), nil }