mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
VDom 9 (#1205)
Lots of quality of life improvements (and bug fixes): * Class(), ClassIf(), ClassIfElse() methods * <wave:style> that can use a file vdom:/// url * UseStateWithFn() to allow for functional setters * If, IfElse, and ForEach for vdom construction * batched updates for large updates -- streaming -- to get around packet size issues * more flexible file sending code, for []byte, io.Reader, fs.File, and a fileName (with optional MimeType) * fix the vdom:// protocol handler to work with fetch * updated wshcmd-html for new best practices
This commit is contained in:
parent
7476c2f700
commit
61d6b4d8eb
42
cmd/wsh/cmd/htmlstyle.css
Normal file
42
cmd/wsh/cmd/htmlstyle.css
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
.root {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-inner {
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-item {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-item:hover {
|
||||||
|
background-color: var(--button-grey-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-preview {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-right: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-label {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
@ -5,6 +5,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
_ "embed"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@ -16,6 +17,9 @@ import (
|
|||||||
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed htmlstyle.css
|
||||||
|
var htmlStyleCSS []byte
|
||||||
|
|
||||||
var htmlCmdNewBlock bool
|
var htmlCmdNewBlock bool
|
||||||
var HtmlVDomClient *vdomclient.Client = vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true})
|
var HtmlVDomClient *vdomclient.Client = vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true})
|
||||||
|
|
||||||
@ -50,66 +54,25 @@ type BgItem struct {
|
|||||||
// Components
|
// Components
|
||||||
var Style = vdomclient.DefineComponent[struct{}](HtmlVDomClient, "Style",
|
var Style = vdomclient.DefineComponent[struct{}](HtmlVDomClient, "Style",
|
||||||
func(ctx context.Context, _ struct{}) any {
|
func(ctx context.Context, _ struct{}) any {
|
||||||
return vdom.E("style", nil, `
|
return vdom.E("wave:style",
|
||||||
.root {
|
vdom.P("src", "vdom:///style.css"),
|
||||||
padding: 10px;
|
)
|
||||||
}
|
|
||||||
|
|
||||||
.background {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.background-inner {
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-item {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-item:hover {
|
|
||||||
background-color: var(--button-grey-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-preview {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
margin-right: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid #777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-label {
|
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
var BgItemTag = vdomclient.DefineComponent[BgItemProps](HtmlVDomClient, "BgItem",
|
var BgItemTag = vdomclient.DefineComponent[BgItemProps](HtmlVDomClient, "BgItem",
|
||||||
func(ctx context.Context, props BgItemProps) any {
|
func(ctx context.Context, props BgItemProps) any {
|
||||||
return vdom.E("div",
|
return vdom.E("div",
|
||||||
vdom.P("className", "bg-item"),
|
vdom.Class("bg-item"),
|
||||||
vdom.P("onClick", props.OnClick),
|
|
||||||
vdom.E("div",
|
vdom.E("div",
|
||||||
vdom.P("className", "bg-preview"),
|
vdom.Class("bg-preview"),
|
||||||
vdom.PStyle("background", props.Bg),
|
vdom.PStyle("background", props.Bg),
|
||||||
),
|
),
|
||||||
vdom.E("div",
|
vdom.E("div",
|
||||||
vdom.P("className", "bg-label"),
|
vdom.Class("bg-label"),
|
||||||
props.Label,
|
props.Label,
|
||||||
),
|
),
|
||||||
|
vdom.P("onClick", props.OnClick),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -133,20 +96,17 @@ var BgList = vdomclient.DefineComponent[BgListProps](HtmlVDomClient, "BgList",
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]*vdom.VDomElem, 0, len(props.Items))
|
|
||||||
for _, item := range props.Items {
|
|
||||||
items = append(items, BgItemTag(BgItemProps{
|
|
||||||
Bg: item.Bg,
|
|
||||||
Label: item.Label,
|
|
||||||
OnClick: setBackground(item.Bg),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return vdom.E("div",
|
return vdom.E("div",
|
||||||
vdom.P("className", "background"),
|
vdom.Class("background"),
|
||||||
vdom.E("div",
|
vdom.E("div",
|
||||||
vdom.P("className", "background-inner"),
|
vdom.Class("background-inner"),
|
||||||
items,
|
vdom.ForEach(props.Items, func(item BgItem) any {
|
||||||
|
return BgItemTag(BgItemProps{
|
||||||
|
Bg: item.Bg,
|
||||||
|
Label: item.Label,
|
||||||
|
OnClick: setBackground(item.Bg),
|
||||||
|
})
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -164,7 +124,7 @@ var App = vdomclient.DefineComponent[struct{}](HtmlVDomClient, "App",
|
|||||||
}
|
}
|
||||||
|
|
||||||
return vdom.E("div",
|
return vdom.E("div",
|
||||||
vdom.P("className", "root"),
|
vdom.Class("root"),
|
||||||
Style(struct{}{}),
|
Style(struct{}{}),
|
||||||
vdom.E("h1", nil, "Set Background"),
|
vdom.E("h1", nil, "Set Background"),
|
||||||
vdom.E("div", nil,
|
vdom.E("div", nil,
|
||||||
@ -177,7 +137,11 @@ var App = vdomclient.DefineComponent[struct{}](HtmlVDomClient, "App",
|
|||||||
),
|
),
|
||||||
vdom.E("div", nil,
|
vdom.E("div", nil,
|
||||||
vdom.E("img",
|
vdom.E("img",
|
||||||
vdom.P("style", "width: 100%; height: 100%; max-width: 300px; max-height: 300px; object-fit: contain;"),
|
vdom.PStyle("width", "100%"),
|
||||||
|
vdom.PStyle("height", "100%"),
|
||||||
|
vdom.PStyle("maxWidth", "300px"),
|
||||||
|
vdom.PStyle("maxHeight", "300px"),
|
||||||
|
vdom.PStyle("objectFit", "contain"),
|
||||||
vdom.P("src", "vdom:///test.png"),
|
vdom.P("src", "vdom:///test.png"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -189,9 +153,7 @@ var App = vdomclient.DefineComponent[struct{}](HtmlVDomClient, "App",
|
|||||||
setInputText(e.TargetValue)
|
setInputText(e.TargetValue)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
vdom.E("div", nil,
|
vdom.E("div", nil, "text ", inputText),
|
||||||
"text ", inputText,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -205,19 +167,20 @@ func htmlRun(cmd *cobra.Command, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up the root component
|
|
||||||
client.SetRootElem(App(struct{}{}))
|
client.SetRootElem(App(struct{}{}))
|
||||||
|
client.RegisterFileHandler("/style.css", vdomclient.FileHandlerOption{
|
||||||
|
Data: htmlStyleCSS,
|
||||||
|
MimeType: "text/css",
|
||||||
|
})
|
||||||
|
client.RegisterFileHandler("/test.png", vdomclient.FileHandlerOption{
|
||||||
|
FilePath: "~/Downloads/IMG_1939.png",
|
||||||
|
})
|
||||||
|
|
||||||
// Set up file handler
|
|
||||||
client.RegisterFileHandler("/test.png", "~/Downloads/IMG_1939.png")
|
|
||||||
|
|
||||||
// Create the VDOM context
|
|
||||||
err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: htmlCmdNewBlock})
|
err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: htmlCmdNewBlock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle shutdown
|
|
||||||
go func() {
|
go func() {
|
||||||
<-client.DoneCh
|
<-client.DoneCh
|
||||||
wshutil.DoShutdown("vdom closed by FE", 0, true)
|
wshutil.DoShutdown("vdom closed by FE", 0, true)
|
||||||
|
@ -3,6 +3,18 @@ import { RpcApi } from "../frontend/app/store/wshclientapi";
|
|||||||
import { base64ToArray } from "../frontend/util/util";
|
import { base64ToArray } from "../frontend/util/util";
|
||||||
import { ElectronWshClient } from "./emain-wsh";
|
import { ElectronWshClient } from "./emain-wsh";
|
||||||
|
|
||||||
|
export function registerVDomProtocol() {
|
||||||
|
protocol.registerSchemesAsPrivileged([
|
||||||
|
{
|
||||||
|
scheme: "vdom",
|
||||||
|
privileges: {
|
||||||
|
standard: true,
|
||||||
|
supportFetchAPI: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
export function setupVdomUrlHandler() {
|
export function setupVdomUrlHandler() {
|
||||||
protocol.handle("vdom", async (request) => {
|
protocol.handle("vdom", async (request) => {
|
||||||
// Only handle GET requests for now
|
// Only handle GET requests for now
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
import * as electron from "electron";
|
import * as electron from "electron";
|
||||||
import { setupVdomUrlHandler } from "emain/emain-vdomhandler";
|
import { registerVDomProtocol, setupVdomUrlHandler } from "emain/emain-vdomhandler";
|
||||||
import { FastAverageColor } from "fast-average-color";
|
import { FastAverageColor } from "fast-average-color";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import * as child_process from "node:child_process";
|
import * as child_process from "node:child_process";
|
||||||
@ -700,6 +700,7 @@ async function appMain() {
|
|||||||
electronApp.quit();
|
electronApp.quit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
registerVDomProtocol();
|
||||||
makeAppMenu();
|
makeAppMenu();
|
||||||
try {
|
try {
|
||||||
await runWaveSrv(handleWSEvent);
|
await runWaveSrv(handleWSEvent);
|
||||||
|
@ -247,9 +247,9 @@ class RpcApiType {
|
|||||||
return client.wshRpcCall("vdomcreatecontext", data, opts);
|
return client.wshRpcCall("vdomcreatecontext", data, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// command "vdomrender" [call]
|
// command "vdomrender" [responsestream]
|
||||||
VDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): Promise<VDomBackendUpdate> {
|
VDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): AsyncGenerator<VDomBackendUpdate, void, boolean> {
|
||||||
return client.wshRpcCall("vdomrender", data, opts);
|
return client.wshRpcStream("vdomrender", data, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// command "vdomurlrequest" [responsestream]
|
// command "vdomurlrequest" [responsestream]
|
||||||
|
@ -9,6 +9,7 @@ import { RpcResponseHelper, WshClient } from "@/app/store/wshclient";
|
|||||||
import { RpcApi } from "@/app/store/wshclientapi";
|
import { RpcApi } from "@/app/store/wshclientapi";
|
||||||
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
|
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
|
||||||
import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
|
import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
|
||||||
|
import { mergeBackendUpdates, restoreVDomElems } from "@/app/view/vdom/vdom-utils";
|
||||||
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
|
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
|
||||||
import debug from "debug";
|
import debug from "debug";
|
||||||
import * as jotai from "jotai";
|
import * as jotai from "jotai";
|
||||||
@ -330,8 +331,21 @@ export class VDomModel {
|
|||||||
try {
|
try {
|
||||||
const feUpdate = this.createFeUpdate();
|
const feUpdate = this.createFeUpdate();
|
||||||
dlog("fe-update", feUpdate);
|
dlog("fe-update", feUpdate);
|
||||||
const beUpdate = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: backendRoute });
|
const beUpdateGen = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: backendRoute });
|
||||||
this.handleBackendUpdate(beUpdate);
|
let baseUpdate: VDomBackendUpdate = null;
|
||||||
|
for await (const beUpdate of beUpdateGen) {
|
||||||
|
if (baseUpdate === null) {
|
||||||
|
baseUpdate = beUpdate;
|
||||||
|
} else {
|
||||||
|
mergeBackendUpdates(baseUpdate, beUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (baseUpdate !== null) {
|
||||||
|
restoreVDomElems(baseUpdate);
|
||||||
|
dlog("be-update", baseUpdate);
|
||||||
|
this.handleBackendUpdate(baseUpdate);
|
||||||
|
}
|
||||||
|
dlog("update cycle done");
|
||||||
} finally {
|
} finally {
|
||||||
this.lastUpdateTs = Date.now();
|
this.lastUpdateTs = Date.now();
|
||||||
this.hasPendingRequest = false;
|
this.hasPendingRequest = false;
|
||||||
|
@ -137,48 +137,63 @@ export function validateAndWrapReactStyle(model: VDomModel, style: Record<string
|
|||||||
return sanitizedStyle;
|
return sanitizedStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
type VDomTransferElem = {
|
export function restoreVDomElems(backendUpdate: VDomBackendUpdate) {
|
||||||
root?: boolean;
|
if (!backendUpdate.transferelems || !backendUpdate.renderupdates) {
|
||||||
waveid?: string;
|
return;
|
||||||
tag: string;
|
}
|
||||||
props?: { [key: string]: any };
|
|
||||||
children?: string[]; // References to child WaveIds
|
|
||||||
text?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function UnmarshalTransferElems(transferElems: VDomTransferElem[]): VDomElem[] {
|
// Step 1: Map of waveid to VDomElem, skipping any without a waveid
|
||||||
const elemMap: { [id: string]: VDomElem } = {};
|
const elemMap = new Map<string, VDomElem>();
|
||||||
const roots: VDomElem[] = [];
|
backendUpdate.transferelems.forEach((transferElem) => {
|
||||||
|
|
||||||
// Initialize each VDomTransferElem in the map without children, as we'll link them after
|
|
||||||
transferElems.forEach((transferElem) => {
|
|
||||||
if (!transferElem.waveid) {
|
if (!transferElem.waveid) {
|
||||||
return; // Skip elements without waveid
|
return;
|
||||||
}
|
}
|
||||||
const elem: VDomElem = {
|
elemMap.set(transferElem.waveid, {
|
||||||
waveid: transferElem.tag !== "#text" ? transferElem.waveid : undefined,
|
waveid: transferElem.waveid,
|
||||||
tag: transferElem.tag,
|
tag: transferElem.tag,
|
||||||
props: transferElem.props,
|
props: transferElem.props,
|
||||||
|
children: [], // Will populate children later
|
||||||
text: transferElem.text,
|
text: transferElem.text,
|
||||||
children: [], // Placeholder to be populated later
|
});
|
||||||
};
|
});
|
||||||
elemMap[transferElem.waveid] = elem;
|
|
||||||
|
|
||||||
// Collect root elements
|
// Step 2: Build VDomElem trees by linking children
|
||||||
if (transferElem.root) {
|
backendUpdate.transferelems.forEach((transferElem) => {
|
||||||
roots.push(elem);
|
const parent = elemMap.get(transferElem.waveid);
|
||||||
|
if (!parent || !transferElem.children || transferElem.children.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parent.children = transferElem.children.map((childId) => elemMap.get(childId)).filter((child) => child != null); // Explicit null check
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Update renderupdates with rebuilt VDomElem trees
|
||||||
|
backendUpdate.renderupdates.forEach((update) => {
|
||||||
|
if (update.vdomwaveid) {
|
||||||
|
update.vdom = elemMap.get(update.vdomwaveid);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Now populate children for each element
|
|
||||||
transferElems.forEach((transferElem) => {
|
export function mergeBackendUpdates(baseUpdate: VDomBackendUpdate, nextUpdate: VDomBackendUpdate) {
|
||||||
if (!transferElem.waveid || !transferElem.children) return;
|
// Verify the updates are from the same block/sequence
|
||||||
|
if (baseUpdate.blockid !== nextUpdate.blockid || baseUpdate.ts !== nextUpdate.ts) {
|
||||||
const currentElem = elemMap[transferElem.waveid];
|
console.error("Attempted to merge updates from different blocks or timestamps");
|
||||||
currentElem.children = transferElem.children
|
return;
|
||||||
.map((childId) => elemMap[childId])
|
}
|
||||||
.filter((child) => child !== undefined); // Filter out any undefined children
|
|
||||||
});
|
// Merge TransferElems
|
||||||
|
if (nextUpdate.transferelems?.length > 0) {
|
||||||
return roots;
|
if (!baseUpdate.transferelems) {
|
||||||
|
baseUpdate.transferelems = [];
|
||||||
|
}
|
||||||
|
baseUpdate.transferelems.push(...nextUpdate.transferelems);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge StateSync
|
||||||
|
if (nextUpdate.statesync?.length > 0) {
|
||||||
|
if (!baseUpdate.statesync) {
|
||||||
|
baseUpdate.statesync = [];
|
||||||
|
}
|
||||||
|
baseUpdate.statesync.push(...nextUpdate.statesync);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,7 @@ const FragmentTag = "#fragment";
|
|||||||
const WaveTextTag = "wave:text";
|
const WaveTextTag = "wave:text";
|
||||||
const WaveNullTag = "wave:null";
|
const WaveNullTag = "wave:null";
|
||||||
const StyleTagName = "style";
|
const StyleTagName = "style";
|
||||||
|
const WaveStyleTagName = "wave:style";
|
||||||
|
|
||||||
const VDomObjType_Ref = "ref";
|
const VDomObjType_Ref = "ref";
|
||||||
const VDomObjType_Binding = "binding";
|
const VDomObjType_Binding = "binding";
|
||||||
@ -367,6 +368,36 @@ function StyleTag({ elem, model }: { elem: VDomElem; model: VDomModel }) {
|
|||||||
return <style>{sanitizedCss}</style>;
|
return <style>{sanitizedCss}</style>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WaveStyle({ src, model }: { src: string; model: VDomModel }) {
|
||||||
|
const [styleContent, setStyleContent] = React.useState<string | null>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
async function fetchAndSanitizeCss() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(src);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Failed to load CSS from ${src}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cssText = await response.text();
|
||||||
|
const wrapperClassName = "vdom-" + model.blockId;
|
||||||
|
const sanitizedCss = validateAndWrapCss(model, cssText, wrapperClassName);
|
||||||
|
if (sanitizedCss) {
|
||||||
|
setStyleContent(sanitizedCss);
|
||||||
|
} else {
|
||||||
|
console.error("Failed to sanitize CSS");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching CSS:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchAndSanitizeCss();
|
||||||
|
}, [src, model]);
|
||||||
|
if (!styleContent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <style>{styleContent}</style>;
|
||||||
|
}
|
||||||
|
|
||||||
function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) {
|
function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) {
|
||||||
const props = useVDom(model, elem);
|
const props = useVDom(model, elem);
|
||||||
if (elem.tag == WaveNullTag) {
|
if (elem.tag == WaveNullTag) {
|
||||||
@ -382,6 +413,9 @@ function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) {
|
|||||||
if (elem.tag == StyleTagName) {
|
if (elem.tag == StyleTagName) {
|
||||||
return <StyleTag elem={elem} model={model} />;
|
return <StyleTag elem={elem} model={model} />;
|
||||||
}
|
}
|
||||||
|
if (elem.tag == WaveStyleTagName) {
|
||||||
|
return <WaveStyle src={props.src} model={model} />;
|
||||||
|
}
|
||||||
if (!AllowedSimpleTags[elem.tag] && !AllowedSvgTags[elem.tag]) {
|
if (!AllowedSimpleTags[elem.tag] && !AllowedSvgTags[elem.tag]) {
|
||||||
return <div>{"Invalid Tag <" + elem.tag + ">"}</div>;
|
return <div>{"Invalid Tag <" + elem.tag + ">"}</div>;
|
||||||
}
|
}
|
||||||
|
13
frontend/types/gotypes.d.ts
vendored
13
frontend/types/gotypes.d.ts
vendored
@ -652,6 +652,7 @@ declare global {
|
|||||||
opts?: VDomBackendOpts;
|
opts?: VDomBackendOpts;
|
||||||
haswork?: boolean;
|
haswork?: boolean;
|
||||||
renderupdates?: VDomRenderUpdate[];
|
renderupdates?: VDomRenderUpdate[];
|
||||||
|
transferelems?: VDomTransferElem[];
|
||||||
statesync?: VDomStateSync[];
|
statesync?: VDomStateSync[];
|
||||||
refoperations?: VDomRefOperation[];
|
refoperations?: VDomRefOperation[];
|
||||||
messages?: VDomMessage[];
|
messages?: VDomMessage[];
|
||||||
@ -773,7 +774,8 @@ declare global {
|
|||||||
type VDomRenderUpdate = {
|
type VDomRenderUpdate = {
|
||||||
updatetype: "root"|"append"|"replace"|"remove"|"insert";
|
updatetype: "root"|"append"|"replace"|"remove"|"insert";
|
||||||
waveid?: string;
|
waveid?: string;
|
||||||
vdom: VDomElem;
|
vdomwaveid?: string;
|
||||||
|
vdom?: VDomElem;
|
||||||
index?: number;
|
index?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -789,6 +791,15 @@ declare global {
|
|||||||
magnified?: boolean;
|
magnified?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// vdom.VDomTransferElem
|
||||||
|
type VDomTransferElem = {
|
||||||
|
waveid?: string;
|
||||||
|
tag: string;
|
||||||
|
props?: {[key: string]: any};
|
||||||
|
children?: string[];
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
// wshrpc.VDomUrlRequestData
|
// wshrpc.VDomUrlRequestData
|
||||||
type VDomUrlRequestData = {
|
type VDomUrlRequestData = {
|
||||||
method: string;
|
method: string;
|
||||||
|
126
pkg/vdom/vdom.go
126
pkg/vdom/vdom.go
@ -35,6 +35,11 @@ type styleAttrWrapper struct {
|
|||||||
Val any
|
Val any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type classAttrWrapper struct {
|
||||||
|
ClassName string
|
||||||
|
Cond bool
|
||||||
|
}
|
||||||
|
|
||||||
type styleAttrMapWrapper struct {
|
type styleAttrMapWrapper struct {
|
||||||
StyleAttrMap map[string]any
|
StyleAttrMap map[string]any
|
||||||
}
|
}
|
||||||
@ -82,6 +87,47 @@ func mergeStyleAttr(props *map[string]any, styleAttr styleAttrWrapper) {
|
|||||||
styleMap[styleAttr.StyleAttr] = styleAttr.Val
|
styleMap[styleAttr.StyleAttr] = styleAttr.Val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mergeClassAttr(props *map[string]any, classAttr classAttrWrapper) {
|
||||||
|
if *props == nil {
|
||||||
|
*props = make(map[string]any)
|
||||||
|
}
|
||||||
|
if classAttr.Cond {
|
||||||
|
if (*props)["className"] == nil {
|
||||||
|
(*props)["className"] = classAttr.ClassName
|
||||||
|
return
|
||||||
|
}
|
||||||
|
classVal, ok := (*props)["className"].(string)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// check if class already exists (must split, contains won't work)
|
||||||
|
splitArr := strings.Split(classVal, " ")
|
||||||
|
for _, class := range splitArr {
|
||||||
|
if class == classAttr.ClassName {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(*props)["className"] = classVal + " " + classAttr.ClassName
|
||||||
|
} else {
|
||||||
|
classVal, ok := (*props)["className"].(string)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
splitArr := strings.Split(classVal, " ")
|
||||||
|
for i, class := range splitArr {
|
||||||
|
if class == classAttr.ClassName {
|
||||||
|
splitArr = append(splitArr[:i], splitArr[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(splitArr) == 0 {
|
||||||
|
delete(*props, "className")
|
||||||
|
} else {
|
||||||
|
(*props)["className"] = strings.Join(splitArr, " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func E(tag string, parts ...any) *VDomElem {
|
func E(tag string, parts ...any) *VDomElem {
|
||||||
rtn := &VDomElem{Tag: tag}
|
rtn := &VDomElem{Tag: tag}
|
||||||
for _, part := range parts {
|
for _, part := range parts {
|
||||||
@ -103,12 +149,54 @@ func E(tag string, parts ...any) *VDomElem {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if classAttr, ok := part.(classAttrWrapper); ok {
|
||||||
|
mergeClassAttr(&rtn.Props, classAttr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
elems := partToElems(part)
|
elems := partToElems(part)
|
||||||
rtn.Children = append(rtn.Children, elems...)
|
rtn.Children = append(rtn.Children, elems...)
|
||||||
}
|
}
|
||||||
return rtn
|
return rtn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Class(name string) classAttrWrapper {
|
||||||
|
return classAttrWrapper{ClassName: name, Cond: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClassIf(cond bool, name string) classAttrWrapper {
|
||||||
|
return classAttrWrapper{ClassName: name, Cond: cond}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClassIfElse(cond bool, name string, elseName string) classAttrWrapper {
|
||||||
|
if cond {
|
||||||
|
return classAttrWrapper{ClassName: name, Cond: true}
|
||||||
|
}
|
||||||
|
return classAttrWrapper{ClassName: elseName, Cond: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func If(cond bool, part any) any {
|
||||||
|
if cond {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func IfElse(cond bool, part any, elsePart any) any {
|
||||||
|
if cond {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
return elsePart
|
||||||
|
}
|
||||||
|
|
||||||
|
func ForEach[T any](items []T, fn func(T) any) []any {
|
||||||
|
var elems []any
|
||||||
|
for _, item := range items {
|
||||||
|
fnResult := fn(item)
|
||||||
|
elems = append(elems, fnResult)
|
||||||
|
}
|
||||||
|
return elems
|
||||||
|
}
|
||||||
|
|
||||||
func Props(props any) map[string]any {
|
func Props(props any) map[string]any {
|
||||||
m, err := utilfn.StructToMap(props)
|
m, err := utilfn.StructToMap(props)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -173,6 +261,31 @@ func UseState[T any](ctx context.Context, initialVal T) (T, func(T)) {
|
|||||||
return rtnVal, setVal
|
return rtnVal, setVal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func UseStateWithFn[T any](ctx context.Context, initialVal T) (T, func(T), func(func(T) T)) {
|
||||||
|
vc, hookVal := getHookFromCtx(ctx)
|
||||||
|
if !hookVal.Init {
|
||||||
|
hookVal.Init = true
|
||||||
|
hookVal.Val = initialVal
|
||||||
|
}
|
||||||
|
var rtnVal T
|
||||||
|
rtnVal, ok := hookVal.Val.(T)
|
||||||
|
if !ok {
|
||||||
|
panic("UseState hook value is not a state (possible out of order or conditional hooks)")
|
||||||
|
}
|
||||||
|
|
||||||
|
setVal := func(newVal T) {
|
||||||
|
hookVal.Val = newVal
|
||||||
|
vc.Root.AddRenderWork(vc.Comp.WaveId)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFuncVal := func(updateFunc func(T) T) {
|
||||||
|
hookVal.Val = updateFunc(hookVal.Val.(T))
|
||||||
|
vc.Root.AddRenderWork(vc.Comp.WaveId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtnVal, setVal, setFuncVal
|
||||||
|
}
|
||||||
|
|
||||||
func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) {
|
func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) {
|
||||||
vc, hookVal := getHookFromCtx(ctx)
|
vc, hookVal := getHookFromCtx(ctx)
|
||||||
if !hookVal.Init {
|
if !hookVal.Init {
|
||||||
@ -212,6 +325,19 @@ func UseVDomRef(ctx context.Context) *VDomRef {
|
|||||||
return refVal
|
return refVal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func UseRef[T any](ctx context.Context, val T) *VDomSimpleRef[T] {
|
||||||
|
_, hookVal := getHookFromCtx(ctx)
|
||||||
|
if !hookVal.Init {
|
||||||
|
hookVal.Init = true
|
||||||
|
hookVal.Val = &VDomSimpleRef[T]{Current: val}
|
||||||
|
}
|
||||||
|
refVal, ok := hookVal.Val.(*VDomSimpleRef[T])
|
||||||
|
if !ok {
|
||||||
|
panic("UseRef hook value is not a ref (possible out of order or conditional hooks)")
|
||||||
|
}
|
||||||
|
return refVal
|
||||||
|
}
|
||||||
|
|
||||||
func UseId(ctx context.Context) string {
|
func UseId(ctx context.Context) string {
|
||||||
vc := getRenderContext(ctx)
|
vc := getRenderContext(ctx)
|
||||||
if vc == nil {
|
if vc == nil {
|
||||||
|
@ -12,6 +12,11 @@ import (
|
|||||||
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
|
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BackendUpdate_InitialChunkSize = 50 // Size for initial chunks that contain both TransferElems and StateSync
|
||||||
|
BackendUpdate_ChunkSize = 100 // Size for subsequent chunks
|
||||||
|
)
|
||||||
|
|
||||||
type vdomContextKeyType struct{}
|
type vdomContextKeyType struct{}
|
||||||
|
|
||||||
var vdomContextKey = vdomContextKeyType{}
|
var vdomContextKey = vdomContextKeyType{}
|
||||||
@ -470,14 +475,13 @@ func ConvertElemsToTransferElems(elems []VDomElem) []VDomTransferElem {
|
|||||||
textCounter := 0 // Counter for generating unique IDs for #text nodes
|
textCounter := 0 // Counter for generating unique IDs for #text nodes
|
||||||
|
|
||||||
// Helper function to recursively process each VDomElem in preorder
|
// Helper function to recursively process each VDomElem in preorder
|
||||||
var processElem func(elem VDomElem, isRoot bool) string
|
var processElem func(elem VDomElem) string
|
||||||
processElem = func(elem VDomElem, isRoot bool) string {
|
processElem = func(elem VDomElem) string {
|
||||||
// Handle #text nodes by generating a unique placeholder ID
|
// Handle #text nodes by generating a unique placeholder ID
|
||||||
if elem.Tag == "#text" {
|
if elem.Tag == "#text" {
|
||||||
textId := fmt.Sprintf("text-%d", textCounter)
|
textId := fmt.Sprintf("text-%d", textCounter)
|
||||||
textCounter++
|
textCounter++
|
||||||
transferElems = append(transferElems, VDomTransferElem{
|
transferElems = append(transferElems, VDomTransferElem{
|
||||||
Root: isRoot,
|
|
||||||
WaveId: textId,
|
WaveId: textId,
|
||||||
Tag: elem.Tag,
|
Tag: elem.Tag,
|
||||||
Text: elem.Text,
|
Text: elem.Text,
|
||||||
@ -490,12 +494,11 @@ func ConvertElemsToTransferElems(elems []VDomElem) []VDomTransferElem {
|
|||||||
// Convert children to WaveId references, handling potential #text nodes
|
// Convert children to WaveId references, handling potential #text nodes
|
||||||
childrenIds := make([]string, len(elem.Children))
|
childrenIds := make([]string, len(elem.Children))
|
||||||
for i, child := range elem.Children {
|
for i, child := range elem.Children {
|
||||||
childrenIds[i] = processElem(child, false) // Children are not roots
|
childrenIds[i] = processElem(child) // Children are not roots
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the VDomTransferElem for the current element
|
// Create the VDomTransferElem for the current element
|
||||||
transferElem := VDomTransferElem{
|
transferElem := VDomTransferElem{
|
||||||
Root: isRoot,
|
|
||||||
WaveId: elem.WaveId,
|
WaveId: elem.WaveId,
|
||||||
Tag: elem.Tag,
|
Tag: elem.Tag,
|
||||||
Props: elem.Props,
|
Props: elem.Props,
|
||||||
@ -509,8 +512,99 @@ func ConvertElemsToTransferElems(elems []VDomElem) []VDomTransferElem {
|
|||||||
|
|
||||||
// Start processing each top-level element, marking them as roots
|
// Start processing each top-level element, marking them as roots
|
||||||
for _, elem := range elems {
|
for _, elem := range elems {
|
||||||
processElem(elem, true)
|
processElem(elem)
|
||||||
}
|
}
|
||||||
|
|
||||||
return transferElems
|
return transferElems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DedupTransferElems(elems []VDomTransferElem) []VDomTransferElem {
|
||||||
|
seen := make(map[string]int) // maps WaveId to its index in the result slice
|
||||||
|
var result []VDomTransferElem
|
||||||
|
|
||||||
|
for _, elem := range elems {
|
||||||
|
if idx, exists := seen[elem.WaveId]; exists {
|
||||||
|
// Overwrite the previous element with the latest one
|
||||||
|
result[idx] = elem
|
||||||
|
} else {
|
||||||
|
// Add new element and store its index
|
||||||
|
seen[elem.WaveId] = len(result)
|
||||||
|
result = append(result, elem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (beUpdate *VDomBackendUpdate) CreateTransferElems() {
|
||||||
|
var vdomElems []VDomElem
|
||||||
|
for idx, reUpdate := range beUpdate.RenderUpdates {
|
||||||
|
if reUpdate.VDom == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vdomElems = append(vdomElems, *reUpdate.VDom)
|
||||||
|
beUpdate.RenderUpdates[idx].VDomWaveId = reUpdate.VDom.WaveId
|
||||||
|
beUpdate.RenderUpdates[idx].VDom = nil
|
||||||
|
}
|
||||||
|
transferElems := ConvertElemsToTransferElems(vdomElems)
|
||||||
|
transferElems = DedupTransferElems(transferElems)
|
||||||
|
beUpdate.TransferElems = transferElems
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitBackendUpdate splits a large VDomBackendUpdate into multiple smaller updates
|
||||||
|
// The first update contains all the core fields, while subsequent updates only contain
|
||||||
|
// array elements that need to be appended
|
||||||
|
func SplitBackendUpdate(update *VDomBackendUpdate) []*VDomBackendUpdate {
|
||||||
|
// If the update is small enough, return it as is
|
||||||
|
if len(update.TransferElems) <= BackendUpdate_InitialChunkSize && len(update.StateSync) <= BackendUpdate_InitialChunkSize {
|
||||||
|
return []*VDomBackendUpdate{update}
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates []*VDomBackendUpdate
|
||||||
|
|
||||||
|
// First update contains core fields and initial chunks
|
||||||
|
firstUpdate := &VDomBackendUpdate{
|
||||||
|
Type: update.Type,
|
||||||
|
Ts: update.Ts,
|
||||||
|
BlockId: update.BlockId,
|
||||||
|
Opts: update.Opts,
|
||||||
|
HasWork: update.HasWork,
|
||||||
|
RenderUpdates: update.RenderUpdates,
|
||||||
|
RefOperations: update.RefOperations,
|
||||||
|
Messages: update.Messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add initial chunks of arrays
|
||||||
|
if len(update.TransferElems) > 0 {
|
||||||
|
firstUpdate.TransferElems = update.TransferElems[:min(BackendUpdate_InitialChunkSize, len(update.TransferElems))]
|
||||||
|
}
|
||||||
|
if len(update.StateSync) > 0 {
|
||||||
|
firstUpdate.StateSync = update.StateSync[:min(BackendUpdate_InitialChunkSize, len(update.StateSync))]
|
||||||
|
}
|
||||||
|
|
||||||
|
updates = append(updates, firstUpdate)
|
||||||
|
|
||||||
|
// Create subsequent updates for remaining TransferElems
|
||||||
|
for i := BackendUpdate_InitialChunkSize; i < len(update.TransferElems); i += BackendUpdate_ChunkSize {
|
||||||
|
end := min(i+BackendUpdate_ChunkSize, len(update.TransferElems))
|
||||||
|
updates = append(updates, &VDomBackendUpdate{
|
||||||
|
Type: update.Type,
|
||||||
|
Ts: update.Ts,
|
||||||
|
BlockId: update.BlockId,
|
||||||
|
TransferElems: update.TransferElems[i:end],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create subsequent updates for remaining StateSync
|
||||||
|
for i := BackendUpdate_InitialChunkSize; i < len(update.StateSync); i += BackendUpdate_ChunkSize {
|
||||||
|
end := min(i+BackendUpdate_ChunkSize, len(update.StateSync))
|
||||||
|
updates = append(updates, &VDomBackendUpdate{
|
||||||
|
Type: update.Type,
|
||||||
|
Ts: update.Ts,
|
||||||
|
BlockId: update.BlockId,
|
||||||
|
StateSync: update.StateSync[i:end],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
@ -33,7 +33,6 @@ type VDomElem struct {
|
|||||||
|
|
||||||
// the over the wire format for a vdom element
|
// the over the wire format for a vdom element
|
||||||
type VDomTransferElem struct {
|
type VDomTransferElem struct {
|
||||||
Root bool `json:"root,omitempty"`
|
|
||||||
WaveId string `json:"waveid,omitempty"` // required, except for #text nodes
|
WaveId string `json:"waveid,omitempty"` // required, except for #text nodes
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
Props map[string]any `json:"props,omitempty"`
|
Props map[string]any `json:"props,omitempty"`
|
||||||
@ -86,6 +85,7 @@ type VDomBackendUpdate struct {
|
|||||||
Opts *VDomBackendOpts `json:"opts,omitempty"`
|
Opts *VDomBackendOpts `json:"opts,omitempty"`
|
||||||
HasWork bool `json:"haswork,omitempty"`
|
HasWork bool `json:"haswork,omitempty"`
|
||||||
RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"`
|
RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"`
|
||||||
|
TransferElems []VDomTransferElem `json:"transferelems,omitempty"`
|
||||||
StateSync []VDomStateSync `json:"statesync,omitempty"`
|
StateSync []VDomStateSync `json:"statesync,omitempty"`
|
||||||
RefOperations []VDomRefOperation `json:"refoperations,omitempty"`
|
RefOperations []VDomRefOperation `json:"refoperations,omitempty"`
|
||||||
Messages []VDomMessage `json:"messages,omitempty"`
|
Messages []VDomMessage `json:"messages,omitempty"`
|
||||||
@ -118,6 +118,10 @@ type VDomRef struct {
|
|||||||
HasCurrent bool `json:"hascurrent,omitempty"`
|
HasCurrent bool `json:"hascurrent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VDomSimpleRef[T any] struct {
|
||||||
|
Current T `json:"current"`
|
||||||
|
}
|
||||||
|
|
||||||
type DomRect struct {
|
type DomRect struct {
|
||||||
Top float64 `json:"top"`
|
Top float64 `json:"top"`
|
||||||
Left float64 `json:"left"`
|
Left float64 `json:"left"`
|
||||||
@ -176,10 +180,11 @@ type VDomBackendOpts struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type VDomRenderUpdate struct {
|
type VDomRenderUpdate struct {
|
||||||
UpdateType string `json:"updatetype" tstype:"\"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\""`
|
UpdateType string `json:"updatetype" tstype:"\"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\""`
|
||||||
WaveId string `json:"waveid,omitempty"`
|
WaveId string `json:"waveid,omitempty"`
|
||||||
VDom VDomElem `json:"vdom"`
|
VDomWaveId string `json:"vdomwaveid,omitempty"`
|
||||||
Index *int `json:"index,omitempty"`
|
VDom *VDomElem `json:"vdom,omitempty"` // these get removed for transfer (encoded to transferelems)
|
||||||
|
Index *int `json:"index,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VDomRefOperation struct {
|
type VDomRefOperation struct {
|
||||||
|
@ -6,6 +6,8 @@ package vdomclient
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@ -207,7 +209,7 @@ func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) {
|
|||||||
HasWork: len(c.Root.EffectWorkQueue) > 0,
|
HasWork: len(c.Root.EffectWorkQueue) > 0,
|
||||||
Opts: &c.Opts,
|
Opts: &c.Opts,
|
||||||
RenderUpdates: []vdom.VDomRenderUpdate{
|
RenderUpdates: []vdom.VDomRenderUpdate{
|
||||||
{UpdateType: "root", VDom: *renderedVDom},
|
{UpdateType: "root", VDom: renderedVDom},
|
||||||
},
|
},
|
||||||
StateSync: c.Root.GetStateSync(true),
|
StateSync: c.Root.GetStateSync(true),
|
||||||
}, nil
|
}, nil
|
||||||
@ -224,7 +226,7 @@ func (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) {
|
|||||||
Ts: time.Now().UnixMilli(),
|
Ts: time.Now().UnixMilli(),
|
||||||
BlockId: c.RpcContext.BlockId,
|
BlockId: c.RpcContext.BlockId,
|
||||||
RenderUpdates: []vdom.VDomRenderUpdate{
|
RenderUpdates: []vdom.VDomRenderUpdate{
|
||||||
{UpdateType: "root", VDom: *renderedVDom},
|
{UpdateType: "root", VDom: renderedVDom},
|
||||||
},
|
},
|
||||||
StateSync: c.Root.GetStateSync(false),
|
StateSync: c.Root.GetStateSync(false),
|
||||||
}, nil
|
}, nil
|
||||||
@ -234,9 +236,111 @@ func (c *Client) RegisterUrlPathHandler(path string, handler http.Handler) {
|
|||||||
c.UrlHandlerMux.Handle(path, handler)
|
c.UrlHandlerMux.Handle(path, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) RegisterFileHandler(path string, fileName string) {
|
type FileHandlerOption struct {
|
||||||
fileName = wavebase.ExpandHomeDirSafe(fileName)
|
FilePath string // optional file path on disk
|
||||||
|
Data []byte // optional byte slice content
|
||||||
|
Reader io.Reader // optional reader for content
|
||||||
|
File fs.File // optional embedded or opened file
|
||||||
|
MimeType string // optional mime type
|
||||||
|
}
|
||||||
|
|
||||||
|
func determineMimeType(option FileHandlerOption) (string, []byte) {
|
||||||
|
// If MimeType is set, use it directly
|
||||||
|
if option.MimeType != "" {
|
||||||
|
return option.MimeType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect from Data if available, no need to buffer
|
||||||
|
if option.Data != nil {
|
||||||
|
return http.DetectContentType(option.Data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect from FilePath, no buffering necessary
|
||||||
|
if option.FilePath != "" {
|
||||||
|
filePath := wavebase.ExpandHomeDirSafe(option.FilePath)
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "application/octet-stream", nil // Fallback on error
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Read first 512 bytes for MIME detection
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
_, err = file.Read(buf)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return "application/octet-stream", nil
|
||||||
|
}
|
||||||
|
return http.DetectContentType(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer for File (fs.File), since it lacks Seek
|
||||||
|
if option.File != nil {
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
n, err := option.File.Read(buf)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return "application/octet-stream", nil
|
||||||
|
}
|
||||||
|
return http.DetectContentType(buf[:n]), buf[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer for Reader (io.Reader), same as File
|
||||||
|
if option.Reader != nil {
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
n, err := option.Reader.Read(buf)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return "application/octet-stream", nil
|
||||||
|
}
|
||||||
|
return http.DetectContentType(buf[:n]), buf[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default MIME type if none specified
|
||||||
|
return "application/octet-stream", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) RegisterFileHandler(path string, option FileHandlerOption) {
|
||||||
c.UrlHandlerMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
c.UrlHandlerMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.ServeFile(w, r, fileName)
|
// Determine MIME type and get buffered data if needed
|
||||||
|
contentType, bufferedData := determineMimeType(option)
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
|
||||||
|
if option.FilePath != "" {
|
||||||
|
// Serve file from path
|
||||||
|
filePath := wavebase.ExpandHomeDirSafe(option.FilePath)
|
||||||
|
http.ServeFile(w, r, filePath)
|
||||||
|
} else if option.Data != nil {
|
||||||
|
// Set content length and serve content from in-memory data
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(option.Data)))
|
||||||
|
w.WriteHeader(http.StatusOK) // Ensure headers are sent before writing body
|
||||||
|
if _, err := w.Write(option.Data); err != nil {
|
||||||
|
http.Error(w, "Failed to serve content", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
} else if option.File != nil {
|
||||||
|
// Write buffered data if available, then continue with remaining File content
|
||||||
|
if bufferedData != nil {
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(bufferedData)))
|
||||||
|
if _, err := w.Write(bufferedData); err != nil {
|
||||||
|
http.Error(w, "Failed to serve content", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Serve remaining content from File
|
||||||
|
if _, err := io.Copy(w, option.File); err != nil {
|
||||||
|
http.Error(w, "Failed to serve content", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
} else if option.Reader != nil {
|
||||||
|
// Write buffered data if available, then continue with remaining Reader content
|
||||||
|
if bufferedData != nil {
|
||||||
|
if _, err := w.Write(bufferedData); err != nil {
|
||||||
|
http.Error(w, "Failed to serve content", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Serve remaining content from Reader
|
||||||
|
if _, err := io.Copy(w, option.Reader); err != nil {
|
||||||
|
http.Error(w, "Failed to serve content", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
http.Error(w, "No content available", http.StatusNotFound)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -21,15 +21,31 @@ type VDomServerImpl struct {
|
|||||||
|
|
||||||
func (*VDomServerImpl) WshServerImpl() {}
|
func (*VDomServerImpl) WshServerImpl() {}
|
||||||
|
|
||||||
func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) {
|
func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) chan wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate] {
|
||||||
|
respChan := make(chan wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate], 5)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("panic in VDomRenderCommand: %v\n", r)
|
||||||
|
respChan <- wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{
|
||||||
|
Error: fmt.Errorf("internal error: %v", r),
|
||||||
|
}
|
||||||
|
close(respChan)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if feUpdate.Dispose {
|
if feUpdate.Dispose {
|
||||||
|
defer close(respChan)
|
||||||
log.Printf("got dispose from frontend\n")
|
log.Printf("got dispose from frontend\n")
|
||||||
impl.Client.doShutdown("got dispose from frontend")
|
impl.Client.doShutdown("got dispose from frontend")
|
||||||
return nil, nil
|
return respChan
|
||||||
}
|
}
|
||||||
|
|
||||||
if impl.Client.GetIsDone() {
|
if impl.Client.GetIsDone() {
|
||||||
return nil, nil
|
close(respChan)
|
||||||
|
return respChan
|
||||||
}
|
}
|
||||||
|
|
||||||
// set atoms
|
// set atoms
|
||||||
for _, ss := range feUpdate.StateSync {
|
for _, ss := range feUpdate.StateSync {
|
||||||
impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false)
|
impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false)
|
||||||
@ -44,10 +60,37 @@ func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom
|
|||||||
impl.Client.Root.Event(event.WaveId, event.EventType, event)
|
impl.Client.Root.Event(event.WaveId, event.EventType, event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var update *vdom.VDomBackendUpdate
|
||||||
|
var err error
|
||||||
|
|
||||||
if feUpdate.Resync || true {
|
if feUpdate.Resync || true {
|
||||||
return impl.Client.fullRender()
|
update, err = impl.Client.fullRender()
|
||||||
|
} else {
|
||||||
|
update, err = impl.Client.incrementalRender()
|
||||||
}
|
}
|
||||||
return impl.Client.incrementalRender()
|
update.CreateTransferElems()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
respChan <- wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{
|
||||||
|
Error: err,
|
||||||
|
}
|
||||||
|
close(respChan)
|
||||||
|
return respChan
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the update into chunks and send them sequentially
|
||||||
|
updates := vdom.SplitBackendUpdate(update)
|
||||||
|
go func() {
|
||||||
|
defer close(respChan)
|
||||||
|
for _, splitUpdate := range updates {
|
||||||
|
respChan <- wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{
|
||||||
|
Response: splitUpdate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return respChan
|
||||||
}
|
}
|
||||||
|
|
||||||
func (impl *VDomServerImpl) VDomUrlRequestCommand(ctx context.Context, data wshrpc.VDomUrlRequestData) chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse] {
|
func (impl *VDomServerImpl) VDomUrlRequestCommand(ctx context.Context, data wshrpc.VDomUrlRequestData) chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse] {
|
||||||
@ -56,13 +99,14 @@ func (impl *VDomServerImpl) VDomUrlRequestCommand(ctx context.Context, data wshr
|
|||||||
writer := NewStreamingResponseWriter(respChan)
|
writer := NewStreamingResponseWriter(respChan)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
defer close(respChan) // Declared first, so it executes last
|
||||||
|
defer writer.Close() // Ensures writer is closed before the channel is closed
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
// On panic, send 500 status code
|
|
||||||
writer.WriteHeader(http.StatusInternalServerError)
|
writer.WriteHeader(http.StatusInternalServerError)
|
||||||
writer.Write([]byte(fmt.Sprintf("internal server error: %v", r)))
|
writer.Write([]byte(fmt.Sprintf("internal server error: %v", r)))
|
||||||
}
|
}
|
||||||
close(respChan)
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Create an HTTP request from the RPC request data
|
// Create an HTTP request from the RPC request data
|
||||||
|
@ -298,9 +298,8 @@ func VDomCreateContextCommand(w *wshutil.WshRpc, data vdom.VDomCreateContext, op
|
|||||||
}
|
}
|
||||||
|
|
||||||
// command "vdomrender", wshserver.VDomRenderCommand
|
// command "vdomrender", wshserver.VDomRenderCommand
|
||||||
func VDomRenderCommand(w *wshutil.WshRpc, data vdom.VDomFrontendUpdate, opts *wshrpc.RpcOpts) (*vdom.VDomBackendUpdate, error) {
|
func VDomRenderCommand(w *wshutil.WshRpc, data vdom.VDomFrontendUpdate, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate] {
|
||||||
resp, err := sendRpcRequestCallHelper[*vdom.VDomBackendUpdate](w, "vdomrender", data, opts)
|
return sendRpcRequestResponseStreamHelper[*vdom.VDomBackendUpdate](w, "vdomrender", data, opts)
|
||||||
return resp, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// command "vdomurlrequest", wshserver.VDomUrlRequestCommand
|
// command "vdomurlrequest", wshserver.VDomUrlRequestCommand
|
||||||
|
@ -157,7 +157,7 @@ type WshRpcInterface interface {
|
|||||||
VDomAsyncInitiationCommand(ctx context.Context, data vdom.VDomAsyncInitiationRequest) error
|
VDomAsyncInitiationCommand(ctx context.Context, data vdom.VDomAsyncInitiationRequest) error
|
||||||
|
|
||||||
// proc
|
// proc
|
||||||
VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error)
|
VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) chan RespOrErrorUnion[*vdom.VDomBackendUpdate]
|
||||||
VDomUrlRequestCommand(ctx context.Context, data VDomUrlRequestData) chan RespOrErrorUnion[VDomUrlRequestResponse]
|
VDomUrlRequestCommand(ctx context.Context, data VDomUrlRequestData) chan RespOrErrorUnion[VDomUrlRequestResponse]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user