mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-11-28 13:10:24 +08:00
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
// Copyright 2025, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import { BlockNodeModel } from "@/app/block/blocktypes";
|
|
import { atoms, getApi, globalStore, WOS } from "@/app/store/global";
|
|
import { waveEventSubscribe } from "@/app/store/wps";
|
|
import { RpcApi } from "@/app/store/wshclientapi";
|
|
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
|
import { WebView, WebViewModel } from "@/app/view/webview/webview";
|
|
import * as services from "@/store/services";
|
|
import * as jotai from "jotai";
|
|
import { memo, useEffect } from "react";
|
|
|
|
class TsunamiViewModel extends WebViewModel {
|
|
shellProcFullStatus: jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;
|
|
shellProcStatusUnsubFn: () => void;
|
|
appMeta: jotai.PrimitiveAtom<AppMeta>;
|
|
appMetaUnsubFn: () => void;
|
|
isRestarting: jotai.PrimitiveAtom<boolean>;
|
|
viewName: jotai.Atom<string>;
|
|
viewIconColor: jotai.Atom<string>;
|
|
|
|
constructor(blockId: string, nodeModel: BlockNodeModel) {
|
|
super(blockId, nodeModel);
|
|
this.viewType = "tsunami";
|
|
this.isRestarting = jotai.atom(false);
|
|
|
|
// Hide navigation bar (URL bar, back/forward/home buttons)
|
|
this.hideNav = jotai.atom(true);
|
|
|
|
// Set custom partition for tsunami WebView isolation
|
|
this.partitionOverride = jotai.atom(`tsunami:${blockId}`);
|
|
|
|
this.shellProcFullStatus = jotai.atom(null) as jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;
|
|
const initialShellProcStatus = services.BlockService.GetControllerStatus(blockId);
|
|
initialShellProcStatus.then((rts) => {
|
|
this.updateShellProcStatus(rts);
|
|
});
|
|
this.shellProcStatusUnsubFn = waveEventSubscribe({
|
|
eventType: "controllerstatus",
|
|
scope: WOS.makeORef("block", blockId),
|
|
handler: (event) => {
|
|
let bcRTS: BlockControllerRuntimeStatus = event.data;
|
|
this.updateShellProcStatus(bcRTS);
|
|
},
|
|
});
|
|
|
|
this.appMeta = jotai.atom(null) as jotai.PrimitiveAtom<AppMeta>;
|
|
this.viewIcon = jotai.atom((get) => {
|
|
const meta = get(this.appMeta);
|
|
return meta?.icon || "cube";
|
|
});
|
|
this.viewIconColor = jotai.atom((get) => {
|
|
const meta = get(this.appMeta);
|
|
return meta?.iconcolor;
|
|
});
|
|
this.viewName = jotai.atom((get) => {
|
|
const meta = get(this.appMeta);
|
|
return meta?.title || "WaveApp";
|
|
});
|
|
const initialRTInfo = RpcApi.GetRTInfoCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", blockId),
|
|
});
|
|
initialRTInfo.then((rtInfo) => {
|
|
if (rtInfo && rtInfo["tsunami:appmeta"]) {
|
|
globalStore.set(this.appMeta, rtInfo["tsunami:appmeta"]);
|
|
}
|
|
});
|
|
this.appMetaUnsubFn = waveEventSubscribe({
|
|
eventType: "tsunami:updatemeta",
|
|
scope: WOS.makeORef("block", blockId),
|
|
handler: (event) => {
|
|
const meta: AppMeta = event.data;
|
|
globalStore.set(this.appMeta, meta);
|
|
},
|
|
});
|
|
}
|
|
|
|
get viewComponent(): ViewComponent {
|
|
return TsunamiView;
|
|
}
|
|
|
|
updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) {
|
|
console.log("tsunami-status", fullStatus);
|
|
if (fullStatus == null) {
|
|
return;
|
|
}
|
|
const curStatus = globalStore.get(this.shellProcFullStatus);
|
|
if (curStatus == null || curStatus.version < fullStatus.version) {
|
|
globalStore.set(this.shellProcFullStatus, fullStatus);
|
|
}
|
|
}
|
|
|
|
triggerRestartAtom() {
|
|
globalStore.set(this.isRestarting, true);
|
|
setTimeout(() => {
|
|
globalStore.set(this.isRestarting, false);
|
|
}, 300);
|
|
}
|
|
|
|
private doControllerResync(forceRestart: boolean, logContext: string, triggerRestart: boolean = true) {
|
|
if (triggerRestart) {
|
|
if (globalStore.get(this.isRestarting)) {
|
|
return;
|
|
}
|
|
this.triggerRestartAtom();
|
|
}
|
|
const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, {
|
|
tabid: globalStore.get(atoms.staticTabId),
|
|
blockid: this.blockId,
|
|
forcerestart: forceRestart,
|
|
});
|
|
prtn.catch((e) => console.log(`error controller resync (${logContext})`, e));
|
|
}
|
|
|
|
resyncController() {
|
|
this.doControllerResync(false, "resync", false);
|
|
}
|
|
|
|
stopController() {
|
|
const prtn = RpcApi.ControllerStopCommand(TabRpcClient, this.blockId);
|
|
prtn.catch((e) => console.log("error stopping controller", e));
|
|
}
|
|
|
|
async restartController() {
|
|
if (globalStore.get(this.isRestarting)) {
|
|
return;
|
|
}
|
|
this.triggerRestartAtom();
|
|
try {
|
|
// Stop the controller first
|
|
await RpcApi.ControllerStopCommand(TabRpcClient, this.blockId);
|
|
// Wait a bit for the controller to fully stop
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
// Then resync to restart it
|
|
await RpcApi.ControllerResyncCommand(TabRpcClient, {
|
|
tabid: globalStore.get(atoms.staticTabId),
|
|
blockid: this.blockId,
|
|
forcerestart: false,
|
|
});
|
|
} catch (e) {
|
|
console.log("error restarting controller", e);
|
|
}
|
|
}
|
|
|
|
restartAndForceRebuild() {
|
|
this.doControllerResync(true, "force rebuild");
|
|
}
|
|
|
|
forceRestartController() {
|
|
// Keep this for backward compatibility with the Start button
|
|
this.doControllerResync(true, "force restart");
|
|
}
|
|
|
|
async remixInBuilder() {
|
|
const blockData = globalStore.get(this.blockAtom);
|
|
const appId = blockData?.meta?.["tsunami:appid"];
|
|
|
|
if (!appId || !appId.startsWith("local/")) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await RpcApi.MakeDraftFromLocalCommand(TabRpcClient, { localappid: appId });
|
|
const draftAppId = result.draftappid;
|
|
|
|
getApi().openBuilder(draftAppId);
|
|
} catch (err) {
|
|
console.error("Failed to create draft from local app:", err);
|
|
}
|
|
}
|
|
|
|
dispose() {
|
|
if (this.shellProcStatusUnsubFn) {
|
|
this.shellProcStatusUnsubFn();
|
|
}
|
|
if (this.appMetaUnsubFn) {
|
|
this.appMetaUnsubFn();
|
|
}
|
|
}
|
|
|
|
getSettingsMenuItems(): ContextMenuItem[] {
|
|
const items = super.getSettingsMenuItems();
|
|
// Filter out homepage and navigation-related menu items for tsunami view
|
|
const filteredItems = items.filter((item) => {
|
|
const label = item.label?.toLowerCase() || "";
|
|
return (
|
|
!label.includes("homepage") &&
|
|
!label.includes("home page") &&
|
|
!label.includes("navigation") &&
|
|
!label.includes("nav")
|
|
);
|
|
});
|
|
|
|
// Check if we should show the Remix option
|
|
const blockData = globalStore.get(this.blockAtom);
|
|
const appId = blockData?.meta?.["tsunami:appid"];
|
|
const showRemixOption = appId && appId.startsWith("local/");
|
|
|
|
// Add tsunami-specific menu items at the beginning
|
|
const tsunamiItems: ContextMenuItem[] = [
|
|
{
|
|
label: "Stop WaveApp",
|
|
click: () => this.stopController(),
|
|
},
|
|
{
|
|
label: "Restart WaveApp",
|
|
click: () => this.restartController(),
|
|
},
|
|
{
|
|
label: "Restart WaveApp and Force Rebuild",
|
|
click: () => this.restartAndForceRebuild(),
|
|
},
|
|
{
|
|
type: "separator",
|
|
},
|
|
];
|
|
|
|
if (showRemixOption) {
|
|
tsunamiItems.push(
|
|
{
|
|
label: "Remix WaveApp in Builder",
|
|
click: () => this.remixInBuilder(),
|
|
},
|
|
{
|
|
type: "separator",
|
|
}
|
|
);
|
|
}
|
|
|
|
return [...tsunamiItems, ...filteredItems];
|
|
}
|
|
}
|
|
|
|
const TsunamiView = memo((props: ViewComponentProps<TsunamiViewModel>) => {
|
|
const { model } = props;
|
|
const shellProcFullStatus = jotai.useAtomValue(model.shellProcFullStatus);
|
|
const blockData = jotai.useAtomValue(model.blockAtom);
|
|
const isRestarting = jotai.useAtomValue(model.isRestarting);
|
|
const domReady = jotai.useAtomValue(model.domReady);
|
|
|
|
useEffect(() => {
|
|
model.resyncController();
|
|
}, [model]);
|
|
|
|
const appPath = blockData?.meta?.["tsunami:apppath"];
|
|
const appId = blockData?.meta?.["tsunami:appid"];
|
|
const controller = blockData?.meta?.controller;
|
|
|
|
// Check for configuration errors
|
|
const errors = [];
|
|
if (!appPath && !appId) {
|
|
errors.push("App path or app ID must be set (tsunami:apppath or tsunami:appid)");
|
|
}
|
|
if (controller !== "tsunami") {
|
|
errors.push("Invalid controller (must be 'tsunami')");
|
|
}
|
|
|
|
// Show errors if any exist
|
|
if (errors.length > 0) {
|
|
return (
|
|
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
|
<h1 className="text-4xl font-bold text-main-text-color">Tsunami</h1>
|
|
<div className="flex flex-col gap-2">
|
|
{errors.map((error, index) => (
|
|
<div key={index} className="text-sm" style={{ color: "var(--color-error)" }}>
|
|
{error}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Check if we should show the webview
|
|
const shouldShowWebView =
|
|
shellProcFullStatus?.shellprocstatus === "running" &&
|
|
shellProcFullStatus?.tsunamiport &&
|
|
shellProcFullStatus.tsunamiport !== 0;
|
|
|
|
if (shouldShowWebView) {
|
|
const tsunamiUrl = `http://localhost:${shellProcFullStatus.tsunamiport}/?clientid=wave:${model.blockId}`;
|
|
return (
|
|
<div className="w-full h-full">
|
|
<WebView {...props} initialSrc={tsunamiUrl} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const status = shellProcFullStatus?.shellprocstatus ?? "init";
|
|
const isNotRunning = status === "done" || status === "init";
|
|
|
|
return (
|
|
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
|
<h1 className="text-4xl font-bold text-main-text-color">Tsunami</h1>
|
|
{(appPath || appId) && <div className="text-sm text-main-text-color opacity-70">{appPath || appId}</div>}
|
|
{isNotRunning && !isRestarting && (
|
|
<button
|
|
onClick={() => model.forceRestartController()}
|
|
className="px-4 py-2 bg-accent-color text-primary-text-color rounded hover:bg-accent-color/80 transition-colors cursor-pointer"
|
|
>
|
|
Start
|
|
</button>
|
|
)}
|
|
{isRestarting && <div className="text-sm text-success-color">Starting...</div>}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
TsunamiView.displayName = "TsunamiView";
|
|
|
|
export { TsunamiViewModel };
|