From c6f61247ce2e7270baccb944e131fa2f6c0df7f2 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 6 Mar 2026 15:59:55 -0800 Subject: [PATCH 01/18] update about, add sponsor and gradient (#3001) --- frontend/app/modals/about.tsx | 48 +++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/frontend/app/modals/about.tsx b/frontend/app/modals/about.tsx index 51a54d3c8f..08c0e2210e 100644 --- a/frontend/app/modals/about.tsx +++ b/frontend/app/modals/about.tsx @@ -1,13 +1,16 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; +import { OnboardingGradientBg } from "@/app/onboarding/onboarding-common"; import { modalsModel } from "@/app/store/modalmodel"; -import { Modal } from "./modal"; - +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { isDev } from "@/util/isdev"; -import { useState } from "react"; +import { fireAndForget } from "@/util/util"; +import { useEffect, useState } from "react"; import { getApi } from "../store/global"; +import { Modal } from "./modal"; interface AboutModalVProps { versionString: string; @@ -19,13 +22,14 @@ const AboutModalV = ({ versionString, updaterChannel, onClose }: AboutModalVProp const currentDate = new Date(); return ( - -
+ + +
Wave Terminal
- Open-Source AI-Native Terminal + Open-Source AI-Integrated Terminal
Built for Seamless Workflows
@@ -35,20 +39,20 @@ const AboutModalV = ({ versionString, updaterChannel, onClose }: AboutModalVProp
Update Channel: {updaterChannel}
-
+
- Github + GitHub Website @@ -56,9 +60,17 @@ const AboutModalV = ({ versionString, updaterChannel, onClose }: AboutModalVProp href="https://github.com/wavetermdev/waveterm/blob/main/ACKNOWLEDGEMENTS.md" target="_blank" rel="noopener" - className="inline-flex items-center px-4 py-2 rounded border border-border hover:bg-hoverbg transition-colors duration-200" + className="inline-flex items-center justify-center px-4 py-2 rounded border border-border hover:bg-hoverbg transition-colors duration-200" > - Acknowledgements + Open Source + + + Sponsor
@@ -76,6 +88,16 @@ const AboutModal = () => { const [updaterChannel] = useState(() => getApi().getUpdaterChannel()); const versionString = `${details.version} (${isDev() ? "dev-" : ""}${details.buildTime})`; + useEffect(() => { + fireAndForget(async () => { + RpcApi.RecordTEventCommand( + TabRpcClient, + { event: "action:other", props: { "action:type": "about" } }, + { noresponse: true } + ); + }); + }, []); + return ( Date: Fri, 6 Mar 2026 16:07:11 -0800 Subject: [PATCH 02/18] fix failing layout test (#2999) --- frontend/layout/lib/utils.ts | 4 ---- frontend/layout/tests/layoutTree.test.ts | 21 +++++++++++---------- package-lock.json | 4 ++-- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/frontend/layout/lib/utils.ts b/frontend/layout/lib/utils.ts index 96cb71ddbf..e34819b160 100644 --- a/frontend/layout/lib/utils.ts +++ b/frontend/layout/lib/utils.ts @@ -74,10 +74,6 @@ export function setTransform( top: 0, left: 0, transform: translate, - WebkitTransform: translate, - MozTransform: translate, - msTransform: translate, - OTransform: translate, width: setSize ? `${widthRounded}px` : undefined, height: setSize ? `${heightRounded}px` : undefined, position: "absolute", diff --git a/frontend/layout/tests/layoutTree.test.ts b/frontend/layout/tests/layoutTree.test.ts index ec8b6465c2..43350c02b9 100644 --- a/frontend/layout/tests/layoutTree.test.ts +++ b/frontend/layout/tests/layoutTree.test.ts @@ -13,9 +13,11 @@ import { import { newLayoutTreeState } from "./model"; test("layoutTreeStateReducer - compute move", () => { - let treeState = newLayoutTreeState(newLayoutNode(undefined, undefined, undefined, { blockId: "root" })); - assert(treeState.rootNode.data!.blockId === "root", "root should have no children and should have data"); - let node1 = newLayoutNode(undefined, undefined, undefined, { blockId: "node1" }); + const nodeA = newLayoutNode(undefined, undefined, undefined, { blockId: "nodeA" }); + const node1 = newLayoutNode(undefined, undefined, undefined, { blockId: "node1" }); + const node2 = newLayoutNode(undefined, undefined, undefined, { blockId: "node2" }); + const treeState = newLayoutTreeState(newLayoutNode(undefined, undefined, [nodeA, node1, node2])); + assert(treeState.rootNode.children!.length === 3, "root should have three children"); let pendingAction = computeMoveNode(treeState, { type: LayoutTreeActionType.ComputeMove, nodeId: treeState.rootNode.id, @@ -29,12 +31,11 @@ test("layoutTreeStateReducer - compute move", () => { assert(insertOperation.insertAtRoot, "insert operation insertAtRoot should be true"); moveNode(treeState, insertOperation); assert( - treeState.rootNode.data === undefined && treeState.rootNode.children!.length === 2, - "root node should now have no data and should have two children" + treeState.rootNode.data === undefined && treeState.rootNode.children!.length === 3, + "root node should still have three children" ); assert(treeState.rootNode.children![1].data!.blockId === "node1", "root's second child should be node1"); - let node2 = newLayoutNode(undefined, undefined, undefined, { blockId: "node2" }); pendingAction = computeMoveNode(treeState, { type: LayoutTreeActionType.ComputeMove, nodeId: node1.id, @@ -48,15 +49,15 @@ test("layoutTreeStateReducer - compute move", () => { assert(!insertOperation2.insertAtRoot, "insert operation insertAtRoot should be false"); moveNode(treeState, insertOperation2); assert( - treeState.rootNode.data === undefined && treeState.rootNode.children!.length === 2, - "root node should still have three children" + treeState.rootNode.data === undefined && (treeState.rootNode.children!.length as number) === 2, + "root node should now have two children after node2 moved into node1" ); assert(treeState.rootNode.children![1].children!.length === 2, "root's second child should now have two children"); }); test("computeMove - noop action", () => { - let nodeToMove = newLayoutNode(undefined, undefined, undefined, { blockId: "nodeToMove" }); - let treeState = newLayoutTreeState( + const nodeToMove = newLayoutNode(undefined, undefined, undefined, { blockId: "nodeToMove" }); + const treeState = newLayoutTreeState( newLayoutNode(undefined, undefined, [ nodeToMove, newLayoutNode(undefined, undefined, undefined, { blockId: "otherNode" }), diff --git a/package-lock.json b/package-lock.json index 86e9b5f581..57a1861c42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.1", + "version": "0.14.2-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.1", + "version": "0.14.2-beta.0", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ From 3f4484a9e238054e1bedb9e9596df89c9acfccda Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:47:12 -0800 Subject: [PATCH 03/18] =?UTF-8?q?Remove=20dead=20=E2=80=9Cmove=20block=20t?= =?UTF-8?q?o=20new=20window=E2=80=9D=20path=20and=20dependent=20unused=20A?= =?UTF-8?q?PIs=20(#3002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WindowService.MoveBlockToNewWindow` appears to be unreferenced, and its supporting path had become isolated. This change removes that dead RPC surface and the backend/eventbus helpers that existed only for that flow. - **Window service cleanup (backend RPC)** - Removed `MoveBlockToNewWindow_Meta` and `MoveBlockToNewWindow` from `pkg/service/windowservice/windowservice.go`. - Dropped now-unused imports tied to that method (`log`, `eventbus`). - **Store cleanup** - Removed `MoveBlockToTab` from `pkg/wstore/wstore.go`. - Removed now-unused `utilfn` import from the same file. - **Eventbus cleanup** - Removed unused event constant `WSEvent_ElectronNewWindow`. - Removed `getWindowWatchesForWindowId` and `BusyWaitForWindowId`, which were only used by the deleted move-to-new-window path. - Removed now-unused `time` import. - **Generated frontend service surface** - Regenerated service bindings and removed `WindowServiceType.MoveBlockToNewWindow(...)` from `frontend/app/store/services.ts`. Example of removed RPC surface: ```ts // removed from frontend/app/store/services.ts MoveBlockToNewWindow(currentTabId: string, blockId: string): Promise { return WOS.callBackendService("window", "MoveBlockToNewWindow", Array.from(arguments)) } ``` --- ✨ Let Copilot coding agent [set things up for you](https://github.com/wavetermdev/waveterm/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/app/store/services.ts | 6 --- pkg/eventbus/eventbus.go | 29 ----------- pkg/service/windowservice/windowservice.go | 59 ---------------------- pkg/wstore/wstore.go | 29 ----------- 4 files changed, 123 deletions(-) diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index d9d7730e18..f261f7e37b 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -113,12 +113,6 @@ class WindowServiceType { return WOS.callBackendService("window", "GetWindow", 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)) - } - // set window position and size // @returns object updates SetWindowPosAndSize(windowId: string, pos: Point, size: WinSize): Promise { diff --git a/pkg/eventbus/eventbus.go b/pkg/eventbus/eventbus.go index f2570e10f3..377f1503d3 100644 --- a/pkg/eventbus/eventbus.go +++ b/pkg/eventbus/eventbus.go @@ -9,11 +9,9 @@ import ( "log" "os" "sync" - "time" ) const ( - WSEvent_ElectronNewWindow = "electron:newwindow" WSEvent_ElectronCloseWindow = "electron:closewindow" WSEvent_ElectronUpdateActiveTab = "electron:updateactivetab" WSEvent_Rpc = "rpc" @@ -48,33 +46,6 @@ func UnregisterWSChannel(connId string) { delete(wsMap, connId) } -func getWindowWatchesForWindowId(windowId string) []*WindowWatchData { - globalLock.Lock() - defer globalLock.Unlock() - var watches []*WindowWatchData - for _, wdata := range wsMap { - if wdata.RouteId == windowId { - watches = append(watches, wdata) - } - } - 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 SendEventToElectron(event WSEventType) { barr, err := json.Marshal(event) if err != nil { diff --git a/pkg/service/windowservice/windowservice.go b/pkg/service/windowservice/windowservice.go index f94151e584..81713312f3 100644 --- a/pkg/service/windowservice/windowservice.go +++ b/pkg/service/windowservice/windowservice.go @@ -6,10 +6,8 @@ package windowservice import ( "context" "fmt" - "log" "time" - "github.com/wavetermdev/waveterm/pkg/eventbus" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/waveobj" @@ -82,63 +80,6 @@ func (ws *WindowService) SetWindowPosAndSize(ctx context.Context, windowId strin return waveobj.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) (waveobj.UpdatesRtnType, error) { - log.Printf("MoveBlockToNewWindow(%s, %s)", currentTabId, blockId) - ctx = waveobj.ContextWithUpdates(ctx) - tab, err := wstore.DBMustGet[*waveobj.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 := wcore.CreateWindow(ctx, nil, "") - if err != nil { - return nil, fmt.Errorf("error creating window: %w", err) - } - ws, err := wcore.GetWorkspace(ctx, newWindow.WorkspaceId) - if err != nil { - return nil, fmt.Errorf("error getting workspace: %w", err) - } - err = wstore.MoveBlockToTab(ctx, currentTabId, ws.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") - } - wcore.QueueLayoutActionForTab(ctx, currentTabId, waveobj.LayoutActionData{ - ActionType: wcore.LayoutActionDataType_Remove, - BlockId: blockId, - }) - wcore.QueueLayoutActionForTab(ctx, ws.ActiveTabId, waveobj.LayoutActionData{ - ActionType: wcore.LayoutActionDataType_Insert, - BlockId: blockId, - Focused: true, - }) - return waveobj.ContextGetUpdatesRtn(ctx), nil -} - func (svc *WindowService) SwitchWorkspace_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"ctx", "windowId", "workspaceId"}, diff --git a/pkg/wstore/wstore.go b/pkg/wstore/wstore.go index 03ed297bb3..76d271a71e 100644 --- a/pkg/wstore/wstore.go +++ b/pkg/wstore/wstore.go @@ -8,7 +8,6 @@ import ( "fmt" "sync" - "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" ) @@ -74,31 +73,3 @@ func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaM return nil }) } - -func MoveBlockToTab(ctx context.Context, currentTabId string, newTabId string, blockId string) error { - return WithTx(ctx, func(tx *TxWrap) error { - block, _ := DBGet[*waveobj.Block](tx.Context(), blockId) - if block == nil { - return fmt.Errorf("block not found: %q", blockId) - } - currentTab, _ := DBGet[*waveobj.Tab](tx.Context(), currentTabId) - if currentTab == nil { - return fmt.Errorf("current tab not found: %q", currentTabId) - } - newTab, _ := DBGet[*waveobj.Tab](tx.Context(), newTabId) - if newTab == nil { - return fmt.Errorf("new tab not found: %q", newTabId) - } - blockIdx := utilfn.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) - block.ParentORef = waveobj.MakeORef(waveobj.OType_Tab, newTabId).String() - DBUpdate(tx.Context(), block) - DBUpdate(tx.Context(), currentTab) - DBUpdate(tx.Context(), newTab) - return nil - }) -} From 7ef0bcd87fdb333cb06b3208e586ac7b48e2c821 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 6 Mar 2026 16:50:41 -0800 Subject: [PATCH 04/18] preview updates (mock electron api, wos checks) (#2986) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- .kilocode/skills/electron-api/SKILL.md | 14 +++- eslint.config.js | 4 +- frontend/app/onboarding/onboarding.tsx | 12 +++- frontend/app/store/client-model.ts | 4 +- frontend/app/store/global-atoms.ts | 2 +- frontend/app/store/wos.ts | 18 ++++- frontend/preview/preview-electron-api.ts | 68 +++++++++++++++++++ frontend/preview/preview.tsx | 25 +++++-- .../preview/previews/onboarding.preview.tsx | 2 +- frontend/types/custom.d.ts | 1 + 10 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 frontend/preview/preview-electron-api.ts diff --git a/.kilocode/skills/electron-api/SKILL.md b/.kilocode/skills/electron-api/SKILL.md index 57638b2122..0014e82a50 100644 --- a/.kilocode/skills/electron-api/SKILL.md +++ b/.kilocode/skills/electron-api/SKILL.md @@ -7,11 +7,12 @@ description: Guide for adding new Electron APIs to Wave Terminal. Use when imple Electron APIs allow the frontend to call Electron main process functionality directly via IPC. -## Three Files to Edit +## Four Files to Edit 1. [`frontend/types/custom.d.ts`](frontend/types/custom.d.ts) - TypeScript [`ElectronApi`](frontend/types/custom.d.ts:82) type 2. [`emain/preload.ts`](emain/preload.ts) - Expose method via `contextBridge` 3. [`emain/emain-ipc.ts`](emain/emain-ipc.ts) - Implement IPC handler +4. [`frontend/preview/preview-electron-api.ts`](frontend/preview/preview-electron-api.ts) - Add a no-op stub to keep the `previewElectronApi` object in sync with the `ElectronApi` type ## Three Communication Patterns @@ -54,7 +55,15 @@ electron.ipcMain.handle("capture-screenshot", async (event, rect) => { }); ``` -### 4. Call from Frontend +### 4. Add Preview Stub + +In [`frontend/preview/preview-electron-api.ts`](frontend/preview/preview-electron-api.ts): + +```typescript +captureScreenshot: (_rect: Electron.Rectangle) => Promise.resolve(""), +``` + +### 5. Call from Frontend ```typescript import { getApi } from "@/store/global"; @@ -167,6 +176,7 @@ webContents.send("zoom-factor-change", newZoomFactor); - [ ] Include IPC channel name in comment - [ ] Expose in [`preload.ts`](emain/preload.ts) - [ ] Implement in [`emain-ipc.ts`](emain/emain-ipc.ts) +- [ ] Add no-op stub to [`preview-electron-api.ts`](frontend/preview/preview-electron-api.ts) - [ ] IPC channel names match exactly - [ ] **For sync**: Set `event.returnValue` (or browser hangs!) - [ ] Test end-to-end diff --git a/eslint.config.js b/eslint.config.js index 1c72e5f464..d4844a8b64 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -76,8 +76,8 @@ export default [ "@typescript-eslint/no-unused-vars": [ "warn", { - argsIgnorePattern: "^_$", - varsIgnorePattern: "^_$", + argsIgnorePattern: "^_[a-z0-9]*$", + varsIgnorePattern: "^_[a-z0-9]*$", }, ], "prefer-const": "warn", diff --git a/frontend/app/onboarding/onboarding.tsx b/frontend/app/onboarding/onboarding.tsx index 2755db002f..7c95ef27a6 100644 --- a/frontend/app/onboarding/onboarding.tsx +++ b/frontend/app/onboarding/onboarding.tsx @@ -29,7 +29,13 @@ type PageName = "init" | "notelemetrystar" | "features"; const pageNameAtom: PrimitiveAtom = atom("init"); -const InitPage = ({ isCompact }: { isCompact: boolean }) => { +const InitPage = ({ + isCompact, + telemetryUpdateFn, +}: { + isCompact: boolean; + telemetryUpdateFn: (value: boolean) => Promise; +}) => { const telemetrySetting = useSettingsKeyAtom("telemetry:enabled"); const clientData = useAtomValue(ClientModel.getInstance().clientAtom); const [telemetryEnabled, setTelemetryEnabled] = useState(!!telemetrySetting); @@ -63,7 +69,7 @@ const InitPage = ({ isCompact }: { isCompact: boolean }) => { const setTelemetry = (value: boolean) => { fireAndForget(() => - services.ClientService.TelemetryUpdate(value).then(() => { + telemetryUpdateFn(value).then(() => { setTelemetryEnabled(value); }) ); @@ -319,7 +325,7 @@ const NewInstallOnboardingModal = () => { let pageComp: React.JSX.Element = null; switch (pageName) { case "init": - pageComp = ; + pageComp = ; break; case "notelemetrystar": pageComp = ; diff --git a/frontend/app/store/client-model.ts b/frontend/app/store/client-model.ts index 240dc6d03d..4ae250f5bb 100644 --- a/frontend/app/store/client-model.ts +++ b/frontend/app/store/client-model.ts @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc +// Copyright 2026, Command Line Inc // SPDX-License-Identifier: Apache-2.0 import * as WOS from "@/app/store/wos"; @@ -33,4 +33,4 @@ class ClientModel { } } -export { ClientModel }; \ No newline at end of file +export { ClientModel }; diff --git a/frontend/app/store/global-atoms.ts b/frontend/app/store/global-atoms.ts index ac36fcec8e..18f072070f 100644 --- a/frontend/app/store/global-atoms.ts +++ b/frontend/app/store/global-atoms.ts @@ -16,7 +16,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom; const builderIdAtom = atom(initOpts.builderId) as PrimitiveAtom; const builderAppIdAtom = atom(null) as PrimitiveAtom; - setWaveWindowType(initOpts.builderId != null ? "builder" : "tab"); + setWaveWindowType(initOpts.isPreview ? "preview" : initOpts.builderId != null ? "builder" : "tab"); const uiContextAtom = atom((get) => { const uiContext: UIContext = { windowid: initOpts.windowId, diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts index 4ce339acd1..1d3bdeabdb 100644 --- a/frontend/app/store/wos.ts +++ b/frontend/app/store/wos.ts @@ -4,6 +4,7 @@ // WaveObjectStore import { waveEventSubscribeSingle } from "@/app/store/wps"; +import { isPreviewWindow } from "@/app/store/windowtype"; import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { fireAndForget } from "@/util/util"; @@ -57,7 +58,19 @@ function makeORef(otype: string, oid: string): string { return `${otype}:${oid}`; } +const previewMockObjects: Map = new Map(); + +function mockObjectForPreview(oref: string, obj: T): void { + if (!isPreviewWindow()) { + throw new Error("mockObjectForPreview can only be called in a preview window"); + } + previewMockObjects.set(oref, obj); +} + function GetObject(oref: string): Promise { + if (isPreviewWindow()) { + return Promise.resolve((previewMockObjects.get(oref) as T) ?? null); + } return callBackendService("object", "GetObject", [oref], true); } @@ -105,7 +118,9 @@ function callBackendService(service: string, method: string, args: any[], noUICo const usp = new URLSearchParams(); usp.set("service", service); usp.set("method", method); - const url = getWebServerEndpoint() + "/wave/service?" + usp.toString(); + const webEndpoint = getWebServerEndpoint(); + if (webEndpoint == null) throw new Error(`cannot call ${methodName}: no web endpoint`); + const url = webEndpoint + "/wave/service?" + usp.toString(); const fetchPromise = fetch(url, { method: "POST", body: JSON.stringify(waveCall), @@ -315,6 +330,7 @@ export { getWaveObjectLoadingAtom, loadAndPinWaveObject, makeORef, + mockObjectForPreview, reloadWaveObject, setObjectValue, splitORef, diff --git a/frontend/preview/preview-electron-api.ts b/frontend/preview/preview-electron-api.ts new file mode 100644 index 0000000000..807e9e156b --- /dev/null +++ b/frontend/preview/preview-electron-api.ts @@ -0,0 +1,68 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +const previewElectronApi: ElectronApi = { + getAuthKey: () => "", + getIsDev: () => false, + getCursorPoint: () => ({ x: 0, y: 0 }) as Electron.Point, + getPlatform: () => "darwin", + getEnv: (_varName: string) => "", + getUserName: () => "", + getHostName: () => "", + getDataDir: () => "", + getConfigDir: () => "", + getHomeDir: () => "", + getWebviewPreload: () => "", + getAboutModalDetails: () => ({}) as AboutModalDetails, + getZoomFactor: () => 1.0, + showWorkspaceAppMenu: (_workspaceId: string) => {}, + showBuilderAppMenu: (_builderId: string) => {}, + showContextMenu: (_workspaceId: string, _menu: ElectronContextMenuItem[]) => {}, + onContextMenuClick: (_callback: (id: string | null) => void) => {}, + onNavigate: (_callback: (url: string) => void) => {}, + onIframeNavigate: (_callback: (url: string) => void) => {}, + downloadFile: (_path: string) => {}, + openExternal: (_url: string) => {}, + onFullScreenChange: (_callback: (isFullScreen: boolean) => void) => {}, + onZoomFactorChange: (_callback: (zoomFactor: number) => void) => {}, + onUpdaterStatusChange: (_callback: (status: UpdaterStatus) => void) => {}, + getUpdaterStatus: () => "up-to-date", + getUpdaterChannel: () => "", + installAppUpdate: () => {}, + onMenuItemAbout: (_callback: () => void) => {}, + updateWindowControlsOverlay: (_rect: Dimensions) => {}, + onReinjectKey: (_callback: (waveEvent: WaveKeyboardEvent) => void) => {}, + setWebviewFocus: (_focusedId: number) => {}, + registerGlobalWebviewKeys: (_keys: string[]) => {}, + onControlShiftStateUpdate: (_callback: (state: boolean) => void) => {}, + createWorkspace: () => {}, + switchWorkspace: (_workspaceId: string) => {}, + deleteWorkspace: (_workspaceId: string) => {}, + setActiveTab: (_tabId: string) => {}, + createTab: () => {}, + closeTab: (_workspaceId: string, _tabId: string, _confirmClose: boolean) => Promise.resolve(false), + setWindowInitStatus: (_status: "ready" | "wave-ready") => {}, + onWaveInit: (_callback: (initOpts: WaveInitOpts) => void) => {}, + onBuilderInit: (_callback: (initOpts: BuilderInitOpts) => void) => {}, + sendLog: (_log: string) => {}, + onQuicklook: (_filePath: string) => {}, + openNativePath: (_filePath: string) => {}, + captureScreenshot: (_rect: Electron.Rectangle) => Promise.resolve(""), + setKeyboardChordMode: () => {}, + clearWebviewStorage: (_webContentsId: number) => Promise.resolve(), + setWaveAIOpen: (_isOpen: boolean) => {}, + closeBuilderWindow: () => {}, + incrementTermCommands: (_opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => {}, + nativePaste: () => {}, + openBuilder: (_appId?: string) => {}, + setBuilderWindowAppId: (_appId: string) => {}, + doRefresh: () => {}, + saveTextFile: (_fileName: string, _content: string) => Promise.resolve(false), + setIsActive: async () => {}, +}; + +function installPreviewElectronApi() { + (window as any).api = previewElectronApi; +} + +export { installPreviewElectronApi }; diff --git a/frontend/preview/preview.tsx b/frontend/preview/preview.tsx index 3b0e8d7825..daa232a51b 100644 --- a/frontend/preview/preview.tsx +++ b/frontend/preview/preview.tsx @@ -2,11 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; -import { ClientModel } from "@/app/store/client-model"; -import { setWaveWindowType } from "@/app/store/windowtype"; +import { getAtoms, initGlobalAtoms } from "@/app/store/global-atoms"; +import { GlobalModel } from "@/app/store/global-model"; +import { globalStore } from "@/app/store/jotaiStore"; import { loadFonts } from "@/util/fontutil"; import React, { lazy, Suspense } from "react"; import { createRoot } from "react-dom/client"; +import { installPreviewElectronApi } from "./preview-electron-api"; import "../app/app.scss"; @@ -118,10 +120,23 @@ function PreviewApp() { return ; } +const PreviewTabId = crypto.randomUUID(); +const PreviewWindowId = crypto.randomUUID(); +const PreviewClientId = crypto.randomUUID(); + function initPreview() { - setWaveWindowType("preview"); - // Preview mode has no connected backend client object, but onboarding previews read clientAtom. - ClientModel.getInstance().initialize(null); + installPreviewElectronApi(); + const initOpts = { + tabId: PreviewTabId, + windowId: PreviewWindowId, + clientId: PreviewClientId, + environment: "renderer", + platform: "darwin", + isPreview: true, + } as GlobalInitOptions; + initGlobalAtoms(initOpts); + globalStore.set(getAtoms().fullConfigAtom, {} as FullConfigType); + GlobalModel.getInstance().initialize(initOpts); loadFonts(); const root = createRoot(document.getElementById("main")!); root.render(); diff --git a/frontend/preview/previews/onboarding.preview.tsx b/frontend/preview/previews/onboarding.preview.tsx index 18d555dff8..063320bbb9 100644 --- a/frontend/preview/previews/onboarding.preview.tsx +++ b/frontend/preview/previews/onboarding.preview.tsx @@ -24,7 +24,7 @@ function OnboardingFeaturesV() { return (
- + {}} /> diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 25c40eefef..6fbe95a0ea 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -59,6 +59,7 @@ declare global { environment: "electron" | "renderer"; primaryTabStartup?: boolean; builderId?: string; + isPreview?: boolean; }; type WaveInitOpts = { From 56c18291e6027c86eac8fd6d549be238f7c424ed Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:51:40 -0800 Subject: [PATCH 05/18] Update aiusechat read_dir tests for typed entry output (#3007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pkg/aiusechat/tools_readdir_test.go` was still asserting the old `entries` payload shape after `read_dir` moved to returning typed directory entries. This caused the `pkg/aiusechat` test failures even though the tool behavior itself was already correct. - **Align test expectations with current callback output** - Update `TestReadDirCallback` to treat `entries` as `[]fileutil.DirEntryOut` - Assert directory/file classification via the `Dir` field instead of map lookups - **Fix truncation/sorting coverage** - Update `TestReadDirSortBeforeTruncate` to validate the typed slice returned by `readDirCallback` - Preserve the existing intent of the test: directories should still be sorted ahead of files before truncation - **Keep scope limited to stale tests** - No changes to `read_dir` implementation or output contract - Only the broken test assumptions were corrected ```go entries, ok := resultMap["entries"].([]fileutil.DirEntryOut) if !ok { t.Fatalf("entries is not a slice of DirEntryOut") } for _, entry := range entries { if entry.Dir { // directory assertions } } ``` --- ✨ Let Copilot coding agent [set things up for you](https://github.com/wavetermdev/waveterm/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- pkg/aiusechat/tools_readdir_test.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/pkg/aiusechat/tools_readdir_test.go b/pkg/aiusechat/tools_readdir_test.go index 7560a73a4d..7d91f7dfca 100644 --- a/pkg/aiusechat/tools_readdir_test.go +++ b/pkg/aiusechat/tools_readdir_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/util/fileutil" ) func TestReadDirCallback(t *testing.T) { @@ -64,16 +65,16 @@ func TestReadDirCallback(t *testing.T) { t.Errorf("Expected 3 entries, got %d", entryCount) } - entries, ok := resultMap["entries"].([]map[string]any) + entries, ok := resultMap["entries"].([]fileutil.DirEntryOut) if !ok { - t.Fatalf("entries is not a slice of maps") + t.Fatalf("entries is not a slice of DirEntryOut") } // Check that we have the expected entries foundFiles := 0 foundDirs := 0 for _, entry := range entries { - if entry["is_dir"].(bool) { + if entry.Dir { foundDirs++ } else { foundFiles++ @@ -208,12 +209,15 @@ func TestReadDirSortBeforeTruncate(t *testing.T) { } resultMap := result.(map[string]any) - entries := resultMap["entries"].([]map[string]any) + entries, ok := resultMap["entries"].([]fileutil.DirEntryOut) + if !ok { + t.Fatalf("entries is not a slice of DirEntryOut") + } // Count directories in the result dirCount := 0 for _, entry := range entries { - if entry["is_dir"].(bool) { + if entry.Dir { dirCount++ } } @@ -225,7 +229,7 @@ func TestReadDirSortBeforeTruncate(t *testing.T) { // First 3 entries should be directories for i := 0; i < 3; i++ { - if !entries[i]["is_dir"].(bool) { + if !entries[i].Dir { t.Errorf("Expected entry %d to be a directory, but it was a file", i) } } From 46593b9f4aa3ffcb37e8db4f733fa1d61f7d6c07 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:54:22 -0800 Subject: [PATCH 06/18] Add Release Notes entry to the settings menu (#3005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a **Release Notes** action to `SettingsFloatingWindow` in the requested position: after **Secrets** and before **Help**. It also wires that action to open the existing `onboarding-upgrade-patch.tsx` UI as a standalone modal, so release notes remain accessible even when automatic upgrade onboarding would not render for up-to-date clients. - **Settings menu** - Adds a new **Release Notes** item to `frontend/app/workspace/widgets.tsx` - Places it between **Secrets** and **Help** - Uses the existing modal system rather than creating a new view/block path - **Release notes launch path** - Registers `UpgradeOnboardingPatch` in the modal registry - Opens it from the settings menu via `modalsModel.pushModal("UpgradeOnboardingPatch", { isReleaseNotes: true })` - **Standalone modal behavior** - Extends `UpgradeOnboardingPatch` with a lightweight `isReleaseNotes` mode - In release-notes mode, closing the modal pops the stacked modal instead of toggling `upgradeOnboardingOpen` - Preserves the existing automatic upgrade-onboarding flow and version metadata update behavior for the original path ```tsx { icon: "book-open", label: "Release Notes", onClick: () => { modalsModel.pushModal("UpgradeOnboardingPatch", { isReleaseNotes: true }); onClose(); }, } ``` - **** - Release notes modal content: ![Release Notes modal](https://github.com/user-attachments/assets/914041a0-1248-4d1a-8eed-125713f7b067) --- ✨ Let Copilot coding agent [set things up for you](https://github.com/wavetermdev/waveterm/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/app/modals/modalregistry.tsx | 2 ++ .../app/onboarding/onboarding-upgrade-patch.tsx | 16 ++++++++++++++-- frontend/app/workspace/widgets.tsx | 9 +++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/frontend/app/modals/modalregistry.tsx b/frontend/app/modals/modalregistry.tsx index 53fabde064..88d19e732c 100644 --- a/frontend/app/modals/modalregistry.tsx +++ b/frontend/app/modals/modalregistry.tsx @@ -4,6 +4,7 @@ import { MessageModal } from "@/app/modals/messagemodal"; import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding"; import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade"; +import { UpgradeOnboardingPatch } from "@/app/onboarding/onboarding-upgrade-patch"; import { DeleteFileModal, PublishAppModal, RenameFileModal } from "@/builder/builder-apppanel"; import { SetSecretDialog } from "@/builder/tabs/builder-secrettab"; import { AboutModal } from "./about"; @@ -12,6 +13,7 @@ import { UserInputModal } from "./userinputmodal"; const modalRegistry: { [key: string]: React.ComponentType } = { [NewInstallOnboardingModal.displayName || "NewInstallOnboardingModal"]: NewInstallOnboardingModal, [UpgradeOnboardingModal.displayName || "UpgradeOnboardingModal"]: UpgradeOnboardingModal, + [UpgradeOnboardingPatch.displayName || "UpgradeOnboardingPatch"]: UpgradeOnboardingPatch, [UserInputModal.displayName || "UserInputModal"]: UserInputModal, [AboutModal.displayName || "AboutModal"]: AboutModal, [MessageModal.displayName || "MessageModal"]: MessageModal, diff --git a/frontend/app/onboarding/onboarding-upgrade-patch.tsx b/frontend/app/onboarding/onboarding-upgrade-patch.tsx index 5984eeef53..0cea09ac75 100644 --- a/frontend/app/onboarding/onboarding-upgrade-patch.tsx +++ b/frontend/app/onboarding/onboarding-upgrade-patch.tsx @@ -32,6 +32,10 @@ interface VersionConfig { nextText?: string; } +interface UpgradeOnboardingPatchProps { + isReleaseNotes?: boolean; +} + interface UpgradeOnboardingFooterProps { hasPrev: boolean; hasNext: boolean; @@ -131,7 +135,7 @@ export const UpgradeOnboardingVersions: VersionConfig[] = [ }, ]; -const UpgradeOnboardingPatch = () => { +const UpgradeOnboardingPatch = ({ isReleaseNotes = false }: UpgradeOnboardingPatchProps) => { const modalRef = useRef(null); const [isCompact, setIsCompact] = useState(window.innerHeight < 800); const [currentIndex, setCurrentIndex] = useState(UpgradeOnboardingVersions.length - 1); @@ -174,13 +178,21 @@ const UpgradeOnboardingPatch = () => { }, []); const doClose = () => { - globalStore.set(modalsModel.upgradeOnboardingOpen, false); + if (isReleaseNotes) { + modalsModel.popModal(); + } else { + globalStore.set(modalsModel.upgradeOnboardingOpen, false); + } setTimeout(() => { globalRefocus(); }, 10); }; const handleClose = () => { + if (isReleaseNotes) { + doClose(); + return; + } const clientId = ClientModel.getInstance().clientId; RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index 940fb4e96c..6db20fc219 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -7,6 +7,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { shouldIncludeWidgetForWorkspace } from "@/app/workspace/widgetfilter"; import { atoms, createBlock, getApi, isDev } from "@/store/global"; +import { modalsModel } from "@/store/modalmodel"; import { fireAndForget, isBlank, makeIconClass } from "@/util/util"; import { FloatingPortal, @@ -284,6 +285,14 @@ const SettingsFloatingWindow = memo( onClose(); }, }, + { + icon: "book-open", + label: "Release Notes", + onClick: () => { + modalsModel.pushModal("UpgradeOnboardingPatch", { isReleaseNotes: true }); + onClose(); + }, + }, { icon: "circle-question", label: "Help", From 68719988ea1929533a5bb338bbf6acf391f335ce Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:55:55 -0800 Subject: [PATCH 07/18] Fix connparse handling for scheme-less `//...` WSH shorthand URIs (#3006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pkg/remote/connparse` was failing on shorthand WSH inputs that omit the `wsh://` scheme, including remote hosts, WSL targets, and Windows local paths. The parser was splitting on `://` too early and misclassifying leading `//` inputs before WSH shorthand handling ran. - **What changed** - Detect scheme-less WSH shorthand up front with `strings.HasPrefix(uri, "//")` - Route those inputs through the existing WSH path parsing flow instead of the generic `://` split path - Reuse the same shorthand flag when deciding whether to parse as remote/local WSH vs current-path shorthand - **Behavioral impact** - `//conn/path/to/file` now parses as host `conn` with path `path/to/file` - `//wsl://Ubuntu/path/to/file` now preserves the WSL host and absolute path shape - `//local/C:\path\to\file` now parses as local Windows shorthand instead of being treated as a current-path string - **Scope** - Keeps the existing test expectations intact - Limits the change to `pkg/remote/connparse/connparse.go` ```go isWshShorthand := strings.HasPrefix(uri, "//") if isWshShorthand { rest = strings.TrimPrefix(uri, "//") } else if len(split) > 1 { scheme = split[0] rest = strings.TrimPrefix(split[1], "//") } ``` --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> Co-authored-by: sawka --- pkg/remote/connparse/connparse.go | 8 +++++--- pkg/remote/connparse/connparse_test.go | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pkg/remote/connparse/connparse.go b/pkg/remote/connparse/connparse.go index 349003aafd..a4b8e99c3d 100644 --- a/pkg/remote/connparse/connparse.go +++ b/pkg/remote/connparse/connparse.go @@ -92,10 +92,13 @@ func GetConnNameFromContext(ctx context.Context) (string, error) { // ParseURI parses a connection URI and returns the connection type, host/path, and parameters. func ParseURI(uri string) (*Connection, error) { + isWshShorthand := strings.HasPrefix(uri, "//") split := strings.SplitN(uri, "://", 2) var scheme string var rest string - if len(split) > 1 { + if isWshShorthand { + rest = strings.TrimPrefix(uri, "//") + } else if len(split) > 1 { scheme = split[0] rest = strings.TrimPrefix(split[1], "//") } else { @@ -131,8 +134,7 @@ func ParseURI(uri string) (*Connection, error) { if scheme == "" { scheme = ConnectionTypeWsh addPrecedingSlash = false - if len(rest) != len(uri) { - // This accounts for when the uri starts with "//", which would get trimmed in the first split. + if isWshShorthand { parseWshPath() } else if strings.HasPrefix(rest, "/~") { host = wshrpc.LocalConnName diff --git a/pkg/remote/connparse/connparse_test.go b/pkg/remote/connparse/connparse_test.go index e883ef3fb6..82a36387b7 100644 --- a/pkg/remote/connparse/connparse_test.go +++ b/pkg/remote/connparse/connparse_test.go @@ -81,8 +81,9 @@ func TestParseURI_WSHRemoteShorthand(t *testing.T) { if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } - if c.Host != "conn" { - t.Fatalf("expected host to be empty, got \"%q\"", c.Host) + expected = "conn" + if c.Host != expected { + t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "wsh" if c.Scheme != expected { From 71f7e981751329e0b8c19fe4c6c085d4bdf1c21b Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Sat, 7 Mar 2026 14:09:40 -0800 Subject: [PATCH 08/18] create a FE rpc mock (#3014) --- cmd/generatets/main-generatets.go | 8 ++ frontend/app/store/wshclientapi.ts | 183 +++++++++++++++++++++++++++++ pkg/tsgen/tsgen.go | 5 +- 3 files changed, 194 insertions(+), 2 deletions(-) diff --git a/cmd/generatets/main-generatets.go b/cmd/generatets/main-generatets.go index 2202c781fe..5d42f4dfda 100644 --- a/cmd/generatets/main-generatets.go +++ b/cmd/generatets/main-generatets.go @@ -112,6 +112,14 @@ func generateWshClientApiFile(tsTypeMap map[reflect.Type]string) error { fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") fmt.Fprintf(&buf, "import { WshClient } from \"./wshclient\";\n\n") + fmt.Fprintf(&buf, "export interface MockRpcClient {\n") + fmt.Fprintf(&buf, " mockWshRpcCall(client: WshClient, command: string, data: any, opts?: RpcOpts): Promise;\n") + fmt.Fprintf(&buf, " mockWshRpcStream(client: WshClient, command: string, data: any, opts?: RpcOpts): AsyncGenerator;\n") + fmt.Fprintf(&buf, "}\n\n") + fmt.Fprintf(&buf, "let mockClient: MockRpcClient = null;\n\n") + fmt.Fprintf(&buf, "export function setMockRpcClient(client: MockRpcClient): void {\n") + fmt.Fprintf(&buf, " mockClient = client;\n") + fmt.Fprintf(&buf, "}\n\n") orderedKeys := utilfn.GetOrderedMapKeys(declMap) fmt.Fprintf(&buf, "// WshServerCommandToDeclMap\n") fmt.Fprintf(&buf, "class RpcApiType {\n") diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 33f85126d9..670b660cb0 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -5,865 +5,1048 @@ import { WshClient } from "./wshclient"; +export interface MockRpcClient { + mockWshRpcCall(client: WshClient, command: string, data: any, opts?: RpcOpts): Promise; + mockWshRpcStream(client: WshClient, command: string, data: any, opts?: RpcOpts): AsyncGenerator; +} + +let mockClient: MockRpcClient = null; + +export function setMockRpcClient(client: MockRpcClient): void { + mockClient = client; +} + // WshServerCommandToDeclMap class RpcApiType { // command "activity" [call] ActivityCommand(client: WshClient, data: ActivityUpdate, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "activity", data, opts); return client.wshRpcCall("activity", data, opts); } // command "aisendmessage" [call] AiSendMessageCommand(client: WshClient, data: AiMessageData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "aisendmessage", data, opts); return client.wshRpcCall("aisendmessage", data, opts); } // command "authenticate" [call] AuthenticateCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "authenticate", data, opts); return client.wshRpcCall("authenticate", data, opts); } // command "authenticatejobmanager" [call] AuthenticateJobManagerCommand(client: WshClient, data: CommandAuthenticateJobManagerData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatejobmanager", data, opts); return client.wshRpcCall("authenticatejobmanager", data, opts); } // command "authenticatejobmanagerverify" [call] AuthenticateJobManagerVerifyCommand(client: WshClient, data: CommandAuthenticateJobManagerData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatejobmanagerverify", data, opts); return client.wshRpcCall("authenticatejobmanagerverify", data, opts); } // command "authenticatetojobmanager" [call] AuthenticateToJobManagerCommand(client: WshClient, data: CommandAuthenticateToJobData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatetojobmanager", data, opts); return client.wshRpcCall("authenticatetojobmanager", data, opts); } // command "authenticatetoken" [call] AuthenticateTokenCommand(client: WshClient, data: CommandAuthenticateTokenData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatetoken", data, opts); return client.wshRpcCall("authenticatetoken", data, opts); } // command "authenticatetokenverify" [call] AuthenticateTokenVerifyCommand(client: WshClient, data: CommandAuthenticateTokenData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatetokenverify", data, opts); return client.wshRpcCall("authenticatetokenverify", data, opts); } // command "blockinfo" [call] BlockInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "blockinfo", data, opts); return client.wshRpcCall("blockinfo", data, opts); } // command "blockjobstatus" [call] BlockJobStatusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "blockjobstatus", data, opts); return client.wshRpcCall("blockjobstatus", data, opts); } // command "blockslist" [call] BlocksListCommand(client: WshClient, data: BlocksListRequest, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "blockslist", data, opts); return client.wshRpcCall("blockslist", data, opts); } // command "captureblockscreenshot" [call] CaptureBlockScreenshotCommand(client: WshClient, data: CommandCaptureBlockScreenshotData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "captureblockscreenshot", data, opts); return client.wshRpcCall("captureblockscreenshot", data, opts); } // command "checkgoversion" [call] CheckGoVersionCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "checkgoversion", null, opts); return client.wshRpcCall("checkgoversion", null, opts); } // command "connconnect" [call] ConnConnectCommand(client: WshClient, data: ConnRequest, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "connconnect", data, opts); return client.wshRpcCall("connconnect", data, opts); } // command "conndisconnect" [call] ConnDisconnectCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "conndisconnect", data, opts); return client.wshRpcCall("conndisconnect", data, opts); } // command "connensure" [call] ConnEnsureCommand(client: WshClient, data: ConnExtData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "connensure", data, opts); return client.wshRpcCall("connensure", data, opts); } // command "connlist" [call] ConnListCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "connlist", null, opts); return client.wshRpcCall("connlist", null, opts); } // command "connreinstallwsh" [call] ConnReinstallWshCommand(client: WshClient, data: ConnExtData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "connreinstallwsh", data, opts); return client.wshRpcCall("connreinstallwsh", data, opts); } // command "connserverinit" [call] ConnServerInitCommand(client: WshClient, data: CommandConnServerInitData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "connserverinit", data, opts); return client.wshRpcCall("connserverinit", data, opts); } // command "connstatus" [call] ConnStatusCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "connstatus", null, opts); return client.wshRpcCall("connstatus", null, opts); } // command "connupdatewsh" [call] ConnUpdateWshCommand(client: WshClient, data: RemoteInfo, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "connupdatewsh", data, opts); return client.wshRpcCall("connupdatewsh", data, opts); } // command "controlgetrouteid" [call] ControlGetRouteIdCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "controlgetrouteid", null, opts); return client.wshRpcCall("controlgetrouteid", null, opts); } // command "controllerappendoutput" [call] ControllerAppendOutputCommand(client: WshClient, data: CommandControllerAppendOutputData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "controllerappendoutput", data, opts); return client.wshRpcCall("controllerappendoutput", data, opts); } // command "controllerdestroy" [call] ControllerDestroyCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "controllerdestroy", data, opts); return client.wshRpcCall("controllerdestroy", data, opts); } // command "controllerinput" [call] ControllerInputCommand(client: WshClient, data: CommandBlockInputData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "controllerinput", data, opts); return client.wshRpcCall("controllerinput", data, opts); } // command "controllerresync" [call] ControllerResyncCommand(client: WshClient, data: CommandControllerResyncData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "controllerresync", data, opts); return client.wshRpcCall("controllerresync", data, opts); } // command "createblock" [call] CreateBlockCommand(client: WshClient, data: CommandCreateBlockData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "createblock", data, opts); return client.wshRpcCall("createblock", data, opts); } // command "createsubblock" [call] CreateSubBlockCommand(client: WshClient, data: CommandCreateSubBlockData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "createsubblock", data, opts); return client.wshRpcCall("createsubblock", data, opts); } // command "debugterm" [call] DebugTermCommand(client: WshClient, data: CommandDebugTermData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "debugterm", data, opts); return client.wshRpcCall("debugterm", data, opts); } // command "deleteappfile" [call] DeleteAppFileCommand(client: WshClient, data: CommandDeleteAppFileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "deleteappfile", data, opts); return client.wshRpcCall("deleteappfile", data, opts); } // command "deleteblock" [call] DeleteBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "deleteblock", data, opts); return client.wshRpcCall("deleteblock", data, opts); } // command "deletebuilder" [call] DeleteBuilderCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "deletebuilder", data, opts); return client.wshRpcCall("deletebuilder", data, opts); } // command "deletesubblock" [call] DeleteSubBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "deletesubblock", data, opts); return client.wshRpcCall("deletesubblock", data, opts); } // command "dismisswshfail" [call] DismissWshFailCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "dismisswshfail", data, opts); return client.wshRpcCall("dismisswshfail", data, opts); } // command "dispose" [call] DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "dispose", data, opts); return client.wshRpcCall("dispose", data, opts); } // command "disposesuggestions" [call] DisposeSuggestionsCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "disposesuggestions", data, opts); return client.wshRpcCall("disposesuggestions", data, opts); } // command "electrondecrypt" [call] ElectronDecryptCommand(client: WshClient, data: CommandElectronDecryptData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "electrondecrypt", data, opts); return client.wshRpcCall("electrondecrypt", data, opts); } // command "electronencrypt" [call] ElectronEncryptCommand(client: WshClient, data: CommandElectronEncryptData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "electronencrypt", data, opts); return client.wshRpcCall("electronencrypt", data, opts); } // command "electronsystembell" [call] ElectronSystemBellCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "electronsystembell", null, opts); return client.wshRpcCall("electronsystembell", null, opts); } // command "eventpublish" [call] EventPublishCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "eventpublish", data, opts); return client.wshRpcCall("eventpublish", data, opts); } // command "eventreadhistory" [call] EventReadHistoryCommand(client: WshClient, data: CommandEventReadHistoryData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "eventreadhistory", data, opts); return client.wshRpcCall("eventreadhistory", data, opts); } // command "eventrecv" [call] EventRecvCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "eventrecv", data, opts); return client.wshRpcCall("eventrecv", data, opts); } // command "eventsub" [call] EventSubCommand(client: WshClient, data: SubscriptionRequest, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "eventsub", data, opts); return client.wshRpcCall("eventsub", data, opts); } // command "eventunsub" [call] EventUnsubCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "eventunsub", data, opts); return client.wshRpcCall("eventunsub", data, opts); } // command "eventunsuball" [call] EventUnsubAllCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "eventunsuball", null, opts); return client.wshRpcCall("eventunsuball", null, opts); } // command "fetchsuggestions" [call] FetchSuggestionsCommand(client: WshClient, data: FetchSuggestionsData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "fetchsuggestions", data, opts); return client.wshRpcCall("fetchsuggestions", data, opts); } // command "fileappend" [call] FileAppendCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "fileappend", data, opts); return client.wshRpcCall("fileappend", data, opts); } // command "filecopy" [call] FileCopyCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "filecopy", data, opts); return client.wshRpcCall("filecopy", data, opts); } // command "filecreate" [call] FileCreateCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "filecreate", data, opts); return client.wshRpcCall("filecreate", data, opts); } // command "filedelete" [call] FileDeleteCommand(client: WshClient, data: CommandDeleteFileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "filedelete", data, opts); return client.wshRpcCall("filedelete", data, opts); } // command "fileinfo" [call] FileInfoCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "fileinfo", data, opts); return client.wshRpcCall("fileinfo", data, opts); } // command "filejoin" [call] FileJoinCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "filejoin", data, opts); return client.wshRpcCall("filejoin", data, opts); } // command "filelist" [call] FileListCommand(client: WshClient, data: FileListData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "filelist", data, opts); return client.wshRpcCall("filelist", data, opts); } // command "fileliststream" [responsestream] FileListStreamCommand(client: WshClient, data: FileListData, opts?: RpcOpts): AsyncGenerator { + if (mockClient) return mockClient.mockWshRpcStream(client, "fileliststream", data, opts); return client.wshRpcStream("fileliststream", data, opts); } // command "filemkdir" [call] FileMkdirCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "filemkdir", data, opts); return client.wshRpcCall("filemkdir", data, opts); } // command "filemove" [call] FileMoveCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "filemove", data, opts); return client.wshRpcCall("filemove", data, opts); } // command "fileread" [call] FileReadCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "fileread", data, opts); return client.wshRpcCall("fileread", data, opts); } // command "filereadstream" [responsestream] FileReadStreamCommand(client: WshClient, data: FileData, opts?: RpcOpts): AsyncGenerator { + if (mockClient) return mockClient.mockWshRpcStream(client, "filereadstream", data, opts); return client.wshRpcStream("filereadstream", data, opts); } // command "filerestorebackup" [call] FileRestoreBackupCommand(client: WshClient, data: CommandFileRestoreBackupData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "filerestorebackup", data, opts); return client.wshRpcCall("filerestorebackup", data, opts); } // command "filewrite" [call] FileWriteCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "filewrite", data, opts); return client.wshRpcCall("filewrite", data, opts); } // command "findgitbash" [call] FindGitBashCommand(client: WshClient, data: boolean, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "findgitbash", data, opts); return client.wshRpcCall("findgitbash", data, opts); } // command "focuswindow" [call] FocusWindowCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "focuswindow", data, opts); return client.wshRpcCall("focuswindow", data, opts); } // command "getalltabindicators" [call] GetAllTabIndicatorsCommand(client: WshClient, opts?: RpcOpts): Promise<{[key: string]: TabIndicator}> { + if (mockClient) return mockClient.mockWshRpcCall(client, "getalltabindicators", null, opts); return client.wshRpcCall("getalltabindicators", null, opts); } // command "getallvars" [call] GetAllVarsCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getallvars", data, opts); return client.wshRpcCall("getallvars", data, opts); } // command "getbuilderoutput" [call] GetBuilderOutputCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getbuilderoutput", data, opts); return client.wshRpcCall("getbuilderoutput", data, opts); } // command "getbuilderstatus" [call] GetBuilderStatusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getbuilderstatus", data, opts); return client.wshRpcCall("getbuilderstatus", data, opts); } // command "getfocusedblockdata" [call] GetFocusedBlockDataCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getfocusedblockdata", null, opts); return client.wshRpcCall("getfocusedblockdata", null, opts); } // command "getfullconfig" [call] GetFullConfigCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getfullconfig", null, opts); return client.wshRpcCall("getfullconfig", null, opts); } // command "getjwtpublickey" [call] GetJwtPublicKeyCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getjwtpublickey", null, opts); return client.wshRpcCall("getjwtpublickey", null, opts); } // command "getmeta" [call] GetMetaCommand(client: WshClient, data: CommandGetMetaData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getmeta", data, opts); return client.wshRpcCall("getmeta", data, opts); } // command "getrtinfo" [call] GetRTInfoCommand(client: WshClient, data: CommandGetRTInfoData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getrtinfo", data, opts); return client.wshRpcCall("getrtinfo", data, opts); } // command "getsecrets" [call] GetSecretsCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<{[key: string]: string}> { + if (mockClient) return mockClient.mockWshRpcCall(client, "getsecrets", data, opts); return client.wshRpcCall("getsecrets", data, opts); } // command "getsecretslinuxstoragebackend" [call] GetSecretsLinuxStorageBackendCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getsecretslinuxstoragebackend", null, opts); return client.wshRpcCall("getsecretslinuxstoragebackend", null, opts); } // command "getsecretsnames" [call] GetSecretsNamesCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getsecretsnames", null, opts); return client.wshRpcCall("getsecretsnames", null, opts); } // command "gettab" [call] GetTabCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "gettab", data, opts); return client.wshRpcCall("gettab", data, opts); } // command "gettempdir" [call] GetTempDirCommand(client: WshClient, data: CommandGetTempDirData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "gettempdir", data, opts); return client.wshRpcCall("gettempdir", data, opts); } // command "getupdatechannel" [call] GetUpdateChannelCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getupdatechannel", null, opts); return client.wshRpcCall("getupdatechannel", null, opts); } // command "getvar" [call] GetVarCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getvar", data, opts); return client.wshRpcCall("getvar", data, opts); } // command "getwaveaichat" [call] GetWaveAIChatCommand(client: WshClient, data: CommandGetWaveAIChatData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getwaveaichat", data, opts); return client.wshRpcCall("getwaveaichat", data, opts); } // command "getwaveaimodeconfig" [call] GetWaveAIModeConfigCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getwaveaimodeconfig", null, opts); return client.wshRpcCall("getwaveaimodeconfig", null, opts); } // command "getwaveairatelimit" [call] GetWaveAIRateLimitCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getwaveairatelimit", null, opts); return client.wshRpcCall("getwaveairatelimit", null, opts); } // command "jobcmdexited" [call] JobCmdExitedCommand(client: WshClient, data: CommandJobCmdExitedData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobcmdexited", data, opts); return client.wshRpcCall("jobcmdexited", data, opts); } // command "jobcontrollerattachjob" [call] JobControllerAttachJobCommand(client: WshClient, data: CommandJobControllerAttachJobData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerattachjob", data, opts); return client.wshRpcCall("jobcontrollerattachjob", data, opts); } // command "jobcontrollerconnectedjobs" [call] JobControllerConnectedJobsCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerconnectedjobs", null, opts); return client.wshRpcCall("jobcontrollerconnectedjobs", null, opts); } // command "jobcontrollerdeletejob" [call] JobControllerDeleteJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerdeletejob", data, opts); return client.wshRpcCall("jobcontrollerdeletejob", data, opts); } // command "jobcontrollerdetachjob" [call] JobControllerDetachJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerdetachjob", data, opts); return client.wshRpcCall("jobcontrollerdetachjob", data, opts); } // command "jobcontrollerdisconnectjob" [call] JobControllerDisconnectJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerdisconnectjob", data, opts); return client.wshRpcCall("jobcontrollerdisconnectjob", data, opts); } // command "jobcontrollerexitjob" [call] JobControllerExitJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerexitjob", data, opts); return client.wshRpcCall("jobcontrollerexitjob", data, opts); } // command "jobcontrollergetalljobmanagerstatus" [call] JobControllerGetAllJobManagerStatusCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollergetalljobmanagerstatus", null, opts); return client.wshRpcCall("jobcontrollergetalljobmanagerstatus", null, opts); } // command "jobcontrollerlist" [call] JobControllerListCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerlist", null, opts); return client.wshRpcCall("jobcontrollerlist", null, opts); } // command "jobcontrollerreconnectjob" [call] JobControllerReconnectJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerreconnectjob", data, opts); return client.wshRpcCall("jobcontrollerreconnectjob", data, opts); } // command "jobcontrollerreconnectjobsforconn" [call] JobControllerReconnectJobsForConnCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerreconnectjobsforconn", data, opts); return client.wshRpcCall("jobcontrollerreconnectjobsforconn", data, opts); } // command "jobcontrollerstartjob" [call] JobControllerStartJobCommand(client: WshClient, data: CommandJobControllerStartJobData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerstartjob", data, opts); return client.wshRpcCall("jobcontrollerstartjob", data, opts); } // command "jobinput" [call] JobInputCommand(client: WshClient, data: CommandJobInputData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobinput", data, opts); return client.wshRpcCall("jobinput", data, opts); } // command "jobprepareconnect" [call] JobPrepareConnectCommand(client: WshClient, data: CommandJobPrepareConnectData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobprepareconnect", data, opts); return client.wshRpcCall("jobprepareconnect", data, opts); } // command "jobstartstream" [call] JobStartStreamCommand(client: WshClient, data: CommandJobStartStreamData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "jobstartstream", data, opts); return client.wshRpcCall("jobstartstream", data, opts); } // command "listallappfiles" [call] ListAllAppFilesCommand(client: WshClient, data: CommandListAllAppFilesData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "listallappfiles", data, opts); return client.wshRpcCall("listallappfiles", data, opts); } // command "listallapps" [call] ListAllAppsCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "listallapps", null, opts); return client.wshRpcCall("listallapps", null, opts); } // command "listalleditableapps" [call] ListAllEditableAppsCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "listalleditableapps", null, opts); return client.wshRpcCall("listalleditableapps", null, opts); } // command "makedraftfromlocal" [call] MakeDraftFromLocalCommand(client: WshClient, data: CommandMakeDraftFromLocalData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "makedraftfromlocal", data, opts); return client.wshRpcCall("makedraftfromlocal", data, opts); } // command "message" [call] MessageCommand(client: WshClient, data: CommandMessageData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "message", data, opts); return client.wshRpcCall("message", data, opts); } // command "networkonline" [call] NetworkOnlineCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "networkonline", null, opts); return client.wshRpcCall("networkonline", null, opts); } // command "notify" [call] NotifyCommand(client: WshClient, data: WaveNotificationOptions, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "notify", data, opts); return client.wshRpcCall("notify", data, opts); } // command "notifysystemresume" [call] NotifySystemResumeCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "notifysystemresume", null, opts); return client.wshRpcCall("notifysystemresume", null, opts); } // command "path" [call] PathCommand(client: WshClient, data: PathCommandData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "path", data, opts); return client.wshRpcCall("path", data, opts); } // command "publishapp" [call] PublishAppCommand(client: WshClient, data: CommandPublishAppData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "publishapp", data, opts); return client.wshRpcCall("publishapp", data, opts); } // command "readappfile" [call] ReadAppFileCommand(client: WshClient, data: CommandReadAppFileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "readappfile", data, opts); return client.wshRpcCall("readappfile", data, opts); } // command "recordtevent" [call] RecordTEventCommand(client: WshClient, data: TEvent, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "recordtevent", data, opts); return client.wshRpcCall("recordtevent", data, opts); } // command "remotedisconnectfromjobmanager" [call] RemoteDisconnectFromJobManagerCommand(client: WshClient, data: CommandRemoteDisconnectFromJobManagerData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remotedisconnectfromjobmanager", data, opts); return client.wshRpcCall("remotedisconnectfromjobmanager", data, opts); } // command "remotefilecopy" [call] RemoteFileCopyCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remotefilecopy", data, opts); return client.wshRpcCall("remotefilecopy", data, opts); } // command "remotefiledelete" [call] RemoteFileDeleteCommand(client: WshClient, data: CommandDeleteFileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remotefiledelete", data, opts); return client.wshRpcCall("remotefiledelete", data, opts); } // command "remotefileinfo" [call] RemoteFileInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remotefileinfo", data, opts); return client.wshRpcCall("remotefileinfo", data, opts); } // command "remotefilejoin" [call] RemoteFileJoinCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remotefilejoin", data, opts); return client.wshRpcCall("remotefilejoin", data, opts); } // command "remotefilemove" [call] RemoteFileMoveCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remotefilemove", data, opts); return client.wshRpcCall("remotefilemove", data, opts); } // command "remotefilemultiinfo" [call] RemoteFileMultiInfoCommand(client: WshClient, data: CommandRemoteFileMultiInfoData, opts?: RpcOpts): Promise<{[key: string]: FileInfo}> { + if (mockClient) return mockClient.mockWshRpcCall(client, "remotefilemultiinfo", data, opts); return client.wshRpcCall("remotefilemultiinfo", data, opts); } // command "remotefiletouch" [call] RemoteFileTouchCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remotefiletouch", data, opts); return client.wshRpcCall("remotefiletouch", data, opts); } // command "remotegetinfo" [call] RemoteGetInfoCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remotegetinfo", null, opts); return client.wshRpcCall("remotegetinfo", null, opts); } // command "remoteinstallrcfiles" [call] RemoteInstallRcFilesCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remoteinstallrcfiles", null, opts); return client.wshRpcCall("remoteinstallrcfiles", null, opts); } // command "remotelistentries" [responsestream] RemoteListEntriesCommand(client: WshClient, data: CommandRemoteListEntriesData, opts?: RpcOpts): AsyncGenerator { + if (mockClient) return mockClient.mockWshRpcStream(client, "remotelistentries", data, opts); return client.wshRpcStream("remotelistentries", data, opts); } // command "remotemkdir" [call] RemoteMkdirCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remotemkdir", data, opts); return client.wshRpcCall("remotemkdir", data, opts); } // command "remotereconnecttojobmanager" [call] RemoteReconnectToJobManagerCommand(client: WshClient, data: CommandRemoteReconnectToJobManagerData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remotereconnecttojobmanager", data, opts); return client.wshRpcCall("remotereconnecttojobmanager", data, opts); } // command "remotestartjob" [call] RemoteStartJobCommand(client: WshClient, data: CommandRemoteStartJobData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remotestartjob", data, opts); return client.wshRpcCall("remotestartjob", data, opts); } // command "remotestreamcpudata" [responsestream] RemoteStreamCpuDataCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { + if (mockClient) return mockClient.mockWshRpcStream(client, "remotestreamcpudata", null, opts); return client.wshRpcStream("remotestreamcpudata", null, opts); } // command "remotestreamfile" [responsestream] RemoteStreamFileCommand(client: WshClient, data: CommandRemoteStreamFileData, opts?: RpcOpts): AsyncGenerator { + if (mockClient) return mockClient.mockWshRpcStream(client, "remotestreamfile", data, opts); return client.wshRpcStream("remotestreamfile", data, opts); } // command "remoteterminatejobmanager" [call] RemoteTerminateJobManagerCommand(client: WshClient, data: CommandRemoteTerminateJobManagerData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remoteterminatejobmanager", data, opts); return client.wshRpcCall("remoteterminatejobmanager", data, opts); } // command "remotewritefile" [call] RemoteWriteFileCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "remotewritefile", data, opts); return client.wshRpcCall("remotewritefile", data, opts); } // command "renameappfile" [call] RenameAppFileCommand(client: WshClient, data: CommandRenameAppFileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "renameappfile", data, opts); return client.wshRpcCall("renameappfile", data, opts); } // command "resolveids" [call] ResolveIdsCommand(client: WshClient, data: CommandResolveIdsData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "resolveids", data, opts); return client.wshRpcCall("resolveids", data, opts); } // command "restartbuilderandwait" [call] RestartBuilderAndWaitCommand(client: WshClient, data: CommandRestartBuilderAndWaitData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "restartbuilderandwait", data, opts); return client.wshRpcCall("restartbuilderandwait", data, opts); } // command "routeannounce" [call] RouteAnnounceCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "routeannounce", null, opts); return client.wshRpcCall("routeannounce", null, opts); } // command "routeunannounce" [call] RouteUnannounceCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "routeunannounce", null, opts); return client.wshRpcCall("routeunannounce", null, opts); } // command "sendtelemetry" [call] SendTelemetryCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "sendtelemetry", null, opts); return client.wshRpcCall("sendtelemetry", null, opts); } // command "setblockfocus" [call] SetBlockFocusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "setblockfocus", data, opts); return client.wshRpcCall("setblockfocus", data, opts); } // command "setconfig" [call] SetConfigCommand(client: WshClient, data: SettingsType, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "setconfig", data, opts); return client.wshRpcCall("setconfig", data, opts); } // command "setconnectionsconfig" [call] SetConnectionsConfigCommand(client: WshClient, data: ConnConfigRequest, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "setconnectionsconfig", data, opts); return client.wshRpcCall("setconnectionsconfig", data, opts); } // command "setmeta" [call] SetMetaCommand(client: WshClient, data: CommandSetMetaData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "setmeta", data, opts); return client.wshRpcCall("setmeta", data, opts); } // command "setpeerinfo" [call] SetPeerInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "setpeerinfo", data, opts); return client.wshRpcCall("setpeerinfo", data, opts); } // command "setrtinfo" [call] SetRTInfoCommand(client: WshClient, data: CommandSetRTInfoData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "setrtinfo", data, opts); return client.wshRpcCall("setrtinfo", data, opts); } // command "setsecrets" [call] SetSecretsCommand(client: WshClient, data: {[key: string]: string}, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "setsecrets", data, opts); return client.wshRpcCall("setsecrets", data, opts); } // command "setvar" [call] SetVarCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "setvar", data, opts); return client.wshRpcCall("setvar", data, opts); } // command "startbuilder" [call] StartBuilderCommand(client: WshClient, data: CommandStartBuilderData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "startbuilder", data, opts); return client.wshRpcCall("startbuilder", data, opts); } // command "startjob" [call] StartJobCommand(client: WshClient, data: CommandStartJobData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "startjob", data, opts); return client.wshRpcCall("startjob", data, opts); } // command "stopbuilder" [call] StopBuilderCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "stopbuilder", data, opts); return client.wshRpcCall("stopbuilder", data, opts); } // command "streamcpudata" [responsestream] StreamCpuDataCommand(client: WshClient, data: CpuDataRequest, opts?: RpcOpts): AsyncGenerator { + if (mockClient) return mockClient.mockWshRpcStream(client, "streamcpudata", data, opts); return client.wshRpcStream("streamcpudata", data, opts); } // command "streamdata" [call] StreamDataCommand(client: WshClient, data: CommandStreamData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "streamdata", data, opts); return client.wshRpcCall("streamdata", data, opts); } // command "streamdataack" [call] StreamDataAckCommand(client: WshClient, data: CommandStreamAckData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "streamdataack", data, opts); return client.wshRpcCall("streamdataack", data, opts); } // command "streamtest" [responsestream] StreamTestCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { + if (mockClient) return mockClient.mockWshRpcStream(client, "streamtest", null, opts); return client.wshRpcStream("streamtest", null, opts); } // command "streamwaveai" [responsestream] StreamWaveAiCommand(client: WshClient, data: WaveAIStreamRequest, opts?: RpcOpts): AsyncGenerator { + if (mockClient) return mockClient.mockWshRpcStream(client, "streamwaveai", data, opts); return client.wshRpcStream("streamwaveai", data, opts); } // command "termgetscrollbacklines" [call] TermGetScrollbackLinesCommand(client: WshClient, data: CommandTermGetScrollbackLinesData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "termgetscrollbacklines", data, opts); return client.wshRpcCall("termgetscrollbacklines", data, opts); } // command "test" [call] TestCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "test", data, opts); return client.wshRpcCall("test", data, opts); } // command "testmultiarg" [call] TestMultiArgCommand(client: WshClient, arg1: string, arg2: number, arg3: boolean, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "testmultiarg", { args: [arg1, arg2, arg3] }, opts); return client.wshRpcCall("testmultiarg", { args: [arg1, arg2, arg3] }, opts); } // command "vdomasyncinitiation" [call] VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "vdomasyncinitiation", data, opts); return client.wshRpcCall("vdomasyncinitiation", data, opts); } // command "vdomcreatecontext" [call] VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "vdomcreatecontext", data, opts); return client.wshRpcCall("vdomcreatecontext", data, opts); } // command "vdomrender" [responsestream] VDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): AsyncGenerator { + if (mockClient) return mockClient.mockWshRpcStream(client, "vdomrender", data, opts); return client.wshRpcStream("vdomrender", data, opts); } // command "vdomurlrequest" [responsestream] VDomUrlRequestCommand(client: WshClient, data: VDomUrlRequestData, opts?: RpcOpts): AsyncGenerator { + if (mockClient) return mockClient.mockWshRpcStream(client, "vdomurlrequest", data, opts); return client.wshRpcStream("vdomurlrequest", data, opts); } // command "waitforroute" [call] WaitForRouteCommand(client: WshClient, data: CommandWaitForRouteData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "waitforroute", data, opts); return client.wshRpcCall("waitforroute", data, opts); } // command "waveaiaddcontext" [call] WaveAIAddContextCommand(client: WshClient, data: CommandWaveAIAddContextData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "waveaiaddcontext", data, opts); return client.wshRpcCall("waveaiaddcontext", data, opts); } // command "waveaienabletelemetry" [call] WaveAIEnableTelemetryCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "waveaienabletelemetry", null, opts); return client.wshRpcCall("waveaienabletelemetry", null, opts); } // command "waveaigettooldiff" [call] WaveAIGetToolDiffCommand(client: WshClient, data: CommandWaveAIGetToolDiffData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "waveaigettooldiff", data, opts); return client.wshRpcCall("waveaigettooldiff", data, opts); } // command "waveaitoolapprove" [call] WaveAIToolApproveCommand(client: WshClient, data: CommandWaveAIToolApproveData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "waveaitoolapprove", data, opts); return client.wshRpcCall("waveaitoolapprove", data, opts); } // command "wavefilereadstream" [call] WaveFileReadStreamCommand(client: WshClient, data: CommandWaveFileReadStreamData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "wavefilereadstream", data, opts); return client.wshRpcCall("wavefilereadstream", data, opts); } // command "waveinfo" [call] WaveInfoCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "waveinfo", null, opts); return client.wshRpcCall("waveinfo", null, opts); } // command "webselector" [call] WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "webselector", data, opts); return client.wshRpcCall("webselector", data, opts); } // command "workspacelist" [call] WorkspaceListCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "workspacelist", null, opts); return client.wshRpcCall("workspacelist", null, opts); } // command "writeappfile" [call] WriteAppFileCommand(client: WshClient, data: CommandWriteAppFileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "writeappfile", data, opts); return client.wshRpcCall("writeappfile", data, opts); } // command "writeappgofile" [call] WriteAppGoFileCommand(client: WshClient, data: CommandWriteAppGoFileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "writeappgofile", data, opts); return client.wshRpcCall("writeappgofile", data, opts); } // command "writeappsecretbindings" [call] WriteAppSecretBindingsCommand(client: WshClient, data: CommandWriteAppSecretBindingsData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "writeappsecretbindings", data, opts); return client.wshRpcCall("writeappsecretbindings", data, opts); } // command "writetempfile" [call] WriteTempFileCommand(client: WshClient, data: CommandWriteTempFileData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "writetempfile", data, opts); return client.wshRpcCall("writetempfile", data, opts); } // command "wshactivity" [call] WshActivityCommand(client: WshClient, data: {[key: string]: number}, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "wshactivity", data, opts); return client.wshRpcCall("wshactivity", data, opts); } // command "wsldefaultdistro" [call] WslDefaultDistroCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "wsldefaultdistro", null, opts); return client.wshRpcCall("wsldefaultdistro", null, opts); } // command "wsllist" [call] WslListCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "wsllist", null, opts); return client.wshRpcCall("wsllist", null, opts); } // command "wslstatus" [call] WslStatusCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "wslstatus", null, opts); return client.wshRpcCall("wslstatus", null, opts); } diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index 062056e290..4e895499de 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -471,6 +471,7 @@ func generateWshClientApiMethod_ResponseStream(methodDecl *wshrpc.WshRpcMethodDe } else { sb.WriteString(fmt.Sprintf(" %s(client: WshClient, %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, methodSigDataParams, genRespType)) } + sb.WriteString(fmt.Sprintf(" if (mockClient) return mockClient.mockWshRpcStream(client, %q, %s, opts);\n", methodDecl.Command, dataName)) sb.WriteString(fmt.Sprintf(" return client.wshRpcStream(%q, %s, opts);\n", methodDecl.Command, dataName)) sb.WriteString(" }\n") return sb.String() @@ -490,8 +491,8 @@ func generateWshClientApiMethod_Call(methodDecl *wshrpc.WshRpcMethodDecl, tsType } else { sb.WriteString(fmt.Sprintf(" %s(client: WshClient, %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, methodSigDataParams, rtnType)) } - methodBody := fmt.Sprintf(" return client.wshRpcCall(%q, %s, opts);\n", methodDecl.Command, dataName) - sb.WriteString(methodBody) + sb.WriteString(fmt.Sprintf(" if (mockClient) return mockClient.mockWshRpcCall(client, %q, %s, opts);\n", methodDecl.Command, dataName)) + sb.WriteString(fmt.Sprintf(" return client.wshRpcCall(%q, %s, opts);\n", methodDecl.Command, dataName)) sb.WriteString(" }\n") return sb.String() } From e41aabf7580a11f13100ef83ef3ae43e1dbc15e9 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Mon, 9 Mar 2026 13:13:32 -0700 Subject: [PATCH 09/18] Block Level Indicators/Badges, Update TabBar Styling, Add Badges/Flags to Tabs (#3009) --- .roo/rules/rules.md | 3 +- cmd/generatego/main-generatego.go | 11 +- cmd/server/main-server.go | 6 +- cmd/wsh/cmd/wshcmd-badge.go | 129 +++++++++ cmd/wsh/cmd/wshcmd-tabindicator.go | 57 ++-- emain/emain-menu.ts | 2 +- eslint.config.js | 5 +- frontend/app/app.tsx | 74 ++++-- frontend/app/block/blockframe-header.tsx | 9 +- frontend/app/store/badge.ts | 226 ++++++++++++++++ frontend/app/store/global-atoms.ts | 3 +- frontend/app/store/global.ts | 123 +-------- frontend/app/store/keymodel.ts | 2 +- frontend/app/store/wshclientapi.ts | 249 ++++++++++++++---- frontend/app/tab/tab.scss | 98 ++----- frontend/app/tab/tab.tsx | 149 +++++++---- frontend/app/tab/tabbar-model.ts | 13 +- frontend/app/tab/tabbar.scss | 7 +- frontend/app/tab/tabbar.tsx | 18 +- frontend/app/tab/vtab.tsx | 8 +- frontend/app/tab/workspaceswitcher.scss | 3 +- frontend/app/view/term/termwrap.ts | 6 +- frontend/preview/previews/tab.preview.tsx | 96 ++++--- frontend/preview/previews/vtabbar.preview.tsx | 4 +- frontend/tailwindsetup.css | 10 +- frontend/types/gotypes.d.ts | 41 +-- frontend/types/waveevent.d.ts | 26 +- frontend/wave.ts | 14 +- package-lock.json | 22 +- package.json | 1 + pkg/baseds/baseds.go | 18 +- pkg/tsgen/tsgen.go | 1 - pkg/tsgen/tsgenevent.go | 12 +- pkg/util/unixutil/unixutil_unix.go | 12 + pkg/util/unixutil/unixutil_windows.go | 4 + pkg/waveobj/metaconsts.go | 2 + pkg/waveobj/wtypemeta.go | 1 + pkg/wcore/badge.go | 126 +++++++++ pkg/wcore/tabindicator.go | 88 ------- pkg/wps/wpstypes.go | 4 +- pkg/wshrpc/wshclient/wshclient.go | 23 +- pkg/wshrpc/wshremote/wshremote.go | 44 +++- pkg/wshrpc/wshrpctypes.go | 19 +- pkg/wshrpc/wshserver/wshserver.go | 5 +- schema/waveai.json | 2 +- 45 files changed, 1205 insertions(+), 571 deletions(-) create mode 100644 cmd/wsh/cmd/wshcmd-badge.go create mode 100644 frontend/app/store/badge.ts create mode 100644 pkg/wcore/badge.go delete mode 100644 pkg/wcore/tabindicator.go diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index 7efa154ea7..341d328f9e 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -84,8 +84,7 @@ The full API is defined in custom.d.ts as type ElectronApi. - **CRITICAL: Completion format MUST be: "Done: [one-line description]"** - **Keep your Task Completed summaries VERY short** -- **No lengthy pre-completion summaries** - Do not provide detailed explanations of implementation before using attempt_completion -- **No recaps of changes** - Skip explaining what was done before completion +- **No double-summarization** - Put your summary ONLY inside attempt_completion. Do not write a summary in the message body AND then repeat it in attempt_completion. One summary, one place. - **Go directly to completion** - After making changes, proceed directly to attempt_completion without summarizing - The project is currently an un-released POC / MVP. Do not worry about backward compatibility when making changes - With React hooks, always complete all hook calls at the top level before any conditional returns (including jotai hook calls useAtom and useAtomValue); when a user explicitly tells you a function handles null inputs, trust them and stop trying to "protect" it with unnecessary checks or workarounds. diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go index 74767a5bc6..ab7e338439 100644 --- a/cmd/generatego/main-generatego.go +++ b/cmd/generatego/main-generatego.go @@ -24,14 +24,15 @@ func GenerateWshClient() error { fmt.Fprintf(os.Stderr, "generating wshclient file to %s\n", WshClientFileName) var buf strings.Builder gogen.GenerateBoilerplate(&buf, "wshclient", []string{ + "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes", + "github.com/wavetermdev/waveterm/pkg/baseds", "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata", - "github.com/wavetermdev/waveterm/pkg/wshutil", - "github.com/wavetermdev/waveterm/pkg/wshrpc", - "github.com/wavetermdev/waveterm/pkg/wconfig", + "github.com/wavetermdev/waveterm/pkg/vdom", "github.com/wavetermdev/waveterm/pkg/waveobj", + "github.com/wavetermdev/waveterm/pkg/wconfig", "github.com/wavetermdev/waveterm/pkg/wps", - "github.com/wavetermdev/waveterm/pkg/vdom", - "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes", + "github.com/wavetermdev/waveterm/pkg/wshrpc", + "github.com/wavetermdev/waveterm/pkg/wshutil", }) wshDeclMap := wshrpc.GenerateWshCommandDeclMap() for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) { diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index ddbd16889f..70c8b3a005 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -573,7 +573,11 @@ func main() { blocklogger.InitBlockLogger() jobcontroller.InitJobController() blockcontroller.InitBlockController() - wcore.InitTabIndicatorStore() + err = wcore.InitBadgeStore() + if err != nil { + log.Printf("error initializing badge store: %v\n", err) + return + } go func() { defer func() { panichandler.PanicHandler("GetSystemSummary", recover()) diff --git a/cmd/wsh/cmd/wshcmd-badge.go b/cmd/wsh/cmd/wshcmd-badge.go new file mode 100644 index 0000000000..590ed1e40b --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-badge.go @@ -0,0 +1,129 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "runtime" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/baseds" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +var badgeCmd = &cobra.Command{ + Use: "badge [icon]", + Short: "set or clear a block badge", + Args: cobra.MaximumNArgs(1), + RunE: badgeRun, + PreRunE: preRunSetupRpcClient, +} + +var ( + badgeColor string + badgePriority float64 + badgeClear bool + badgeBeep bool + badgePid int +) + +func init() { + rootCmd.AddCommand(badgeCmd) + badgeCmd.Flags().StringVar(&badgeColor, "color", "", "badge color") + badgeCmd.Flags().Float64Var(&badgePriority, "priority", 10, "badge priority") + badgeCmd.Flags().BoolVar(&badgeClear, "clear", false, "clear the badge") + badgeCmd.Flags().BoolVar(&badgeBeep, "beep", false, "play system bell sound") + badgeCmd.Flags().IntVar(&badgePid, "pid", 0, "watch a pid and automatically clear the badge when it exits (default priority 5)") +} + +func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("badge", rtnErr == nil) + }() + + if badgePid > 0 && runtime.GOOS == "windows" { + return fmt.Errorf("--pid flag is not supported on Windows") + } + if badgePid > 0 && !cmd.Flags().Changed("priority") { + badgePriority = 5 + } + + oref, err := resolveBlockArg() + if err != nil { + return fmt.Errorf("resolving block: %v", err) + } + if oref.OType != waveobj.OType_Block && oref.OType != waveobj.OType_Tab { + return fmt.Errorf("badge oref must be a block or tab (got %q)", oref.OType) + } + + var eventData baseds.BadgeEvent + eventData.ORef = oref.String() + + if badgeClear { + eventData.Clear = true + } else { + icon := "circle-small" + if len(args) > 0 { + icon = args[0] + } + badgeId, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("generating badge id: %v", err) + } + eventData.Badge = &baseds.Badge{ + BadgeId: badgeId.String(), + Icon: icon, + Color: badgeColor, + Priority: badgePriority, + PidLinked: badgePid > 0, + } + } + + event := wps.WaveEvent{ + Event: wps.Event_Badge, + Scopes: []string{oref.String()}, + Data: eventData, + } + + err = wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true}) + if err != nil { + return fmt.Errorf("publishing badge event: %v", err) + } + + if badgeBeep { + err = wshclient.ElectronSystemBellCommand(RpcClient, &wshrpc.RpcOpts{Route: "electron"}) + if err != nil { + return fmt.Errorf("playing system bell: %v", err) + } + } + + if badgePid > 0 && eventData.Badge != nil { + conn := RpcContext.Conn + if conn == "" { + conn = wshrpc.LocalConnName + } + connRoute := wshutil.MakeConnectionRouteId(conn) + watchData := wshrpc.CommandBadgeWatchPidData{ + Pid: badgePid, + ORef: *oref, + BadgeId: eventData.Badge.BadgeId, + } + err = wshclient.BadgeWatchPidCommand(RpcClient, watchData, &wshrpc.RpcOpts{Route: connRoute}) + if err != nil { + return fmt.Errorf("watching pid: %v", err) + } + } + + if badgeClear { + fmt.Printf("badge cleared\n") + } else { + fmt.Printf("badge set\n") + } + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-tabindicator.go b/cmd/wsh/cmd/wshcmd-tabindicator.go index f103ee9437..c3fa499cf9 100644 --- a/cmd/wsh/cmd/wshcmd-tabindicator.go +++ b/cmd/wsh/cmd/wshcmd-tabindicator.go @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd @@ -7,7 +7,9 @@ import ( "fmt" "os" + "github.com/google/uuid" "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" @@ -16,28 +18,26 @@ import ( var tabIndicatorCmd = &cobra.Command{ Use: "tabindicator [icon]", - Short: "set or clear a tab indicator", + Short: "set or clear a tab indicator (deprecated: use 'wsh badge')", Args: cobra.MaximumNArgs(1), RunE: tabIndicatorRun, PreRunE: preRunSetupRpcClient, } var ( - tabIndicatorTabId string - tabIndicatorColor string - tabIndicatorPriority float64 - tabIndicatorClear bool - tabIndicatorPersistent bool - tabIndicatorBeep bool + tabIndicatorTabId string + tabIndicatorColor string + tabIndicatorPriority float64 + tabIndicatorClear bool + tabIndicatorBeep bool ) func init() { rootCmd.AddCommand(tabIndicatorCmd) tabIndicatorCmd.Flags().StringVar(&tabIndicatorTabId, "tabid", "", "tab id (defaults to WAVETERM_TABID)") tabIndicatorCmd.Flags().StringVar(&tabIndicatorColor, "color", "", "indicator color") - tabIndicatorCmd.Flags().Float64Var(&tabIndicatorPriority, "priority", 0, "indicator priority") + tabIndicatorCmd.Flags().Float64Var(&tabIndicatorPriority, "priority", 10, "indicator priority") tabIndicatorCmd.Flags().BoolVar(&tabIndicatorClear, "clear", false, "clear the indicator") - tabIndicatorCmd.Flags().BoolVar(&tabIndicatorPersistent, "persistent", false, "make indicator persistent (don't clear on focus)") tabIndicatorCmd.Flags().BoolVar(&tabIndicatorBeep, "beep", false, "play system bell sound") } @@ -46,6 +46,8 @@ func tabIndicatorRun(cmd *cobra.Command, args []string) (rtnErr error) { sendActivity("tabindicator", rtnErr == nil) }() + fmt.Fprintf(os.Stderr, "tabindicator is deprecated, use 'wsh badge' instead\n") + tabId := tabIndicatorTabId if tabId == "" { tabId = os.Getenv("WAVETERM_TABID") @@ -54,34 +56,39 @@ func tabIndicatorRun(cmd *cobra.Command, args []string) (rtnErr error) { return fmt.Errorf("no tab id specified (use --tabid or set WAVETERM_TABID)") } - var indicator *wshrpc.TabIndicator - if !tabIndicatorClear { + oref := waveobj.MakeORef(waveobj.OType_Tab, tabId) + + var eventData baseds.BadgeEvent + eventData.ORef = oref.String() + + if tabIndicatorClear { + eventData.Clear = true + } else { icon := "bell" if len(args) > 0 { icon = args[0] } - indicator = &wshrpc.TabIndicator{ - Icon: icon, - Color: tabIndicatorColor, - Priority: tabIndicatorPriority, - ClearOnFocus: !tabIndicatorPersistent, + badgeId, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("generating badge id: %v", err) + } + eventData.Badge = &baseds.Badge{ + BadgeId: badgeId.String(), + Icon: icon, + Color: tabIndicatorColor, + Priority: tabIndicatorPriority, } - } - - eventData := wshrpc.TabIndicatorEventData{ - TabId: tabId, - Indicator: indicator, } event := wps.WaveEvent{ - Event: wps.Event_TabIndicator, - Scopes: []string{waveobj.MakeORef(waveobj.OType_Tab, tabId).String()}, + Event: wps.Event_Badge, + Scopes: []string{oref.String()}, Data: eventData, } err := wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true}) if err != nil { - return fmt.Errorf("publishing tab indicator event: %v", err) + return fmt.Errorf("publishing badge event: %v", err) } if tabIndicatorBeep { diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index e3de818f80..691e475443 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -227,7 +227,7 @@ function makeViewMenu( label: "Toggle DevTools", accelerator: devToolsAccel, click: (_, window) => { - let wc = getWindowWebContents(window) ?? webContents; + const wc = getWindowWebContents(window) ?? webContents; wc?.toggleDevTools(); }, }, diff --git a/eslint.config.js b/eslint.config.js index d4844a8b64..50fe7ef7c3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -76,8 +76,9 @@ export default [ "@typescript-eslint/no-unused-vars": [ "warn", { - argsIgnorePattern: "^_[a-z0-9]*$", - varsIgnorePattern: "^_[a-z0-9]*$", + argsIgnorePattern: "^(_[a-zA-Z0-9_]*|e|get)$", + varsIgnorePattern: "^(_[a-zA-Z0-9_]*|dlog|e)$", + caughtErrorsIgnorePattern: "^(_[a-zA-Z0-9_]*|e)$", }, ], "prefer-const": "warn", diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 0970b476a1..7e5613d346 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -1,19 +1,19 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { + clearBadgesForBlockOnFocus, + clearBadgesForTabOnFocus, + getBadgeAtom, + getBlockBadgeAtom, +} from "@/app/store/badge"; import { ClientModel } from "@/app/store/client-model"; import { GlobalModel } from "@/app/store/global-model"; import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; import { Workspace } from "@/app/workspace/workspace"; +import { getLayoutModelForStaticTab } from "@/layout/index"; import { ContextMenuModel } from "@/store/contextmenu"; -import { - atoms, - clearTabIndicatorFromFocus, - createBlock, - getSettingsPrefixAtom, - getTabIndicatorAtom, - globalStore, -} from "@/store/global"; +import { atoms, createBlock, getSettingsPrefixAtom, globalStore } from "@/store/global"; import { appHandleKeyDown, keyboardMouseDownHandler } from "@/store/keymodel"; import { getElemAsStr } from "@/util/focusutil"; import * as keyutil from "@/util/keyutil"; @@ -23,7 +23,7 @@ import clsx from "clsx"; import debug from "debug"; import { Provider, useAtomValue } from "jotai"; import "overlayscrollbars/overlayscrollbars.css"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { AppBackground } from "./app-bg"; @@ -103,7 +103,7 @@ async function handleContextMenu(e: React.MouseEvent) { if (!canPaste && !canCopy && !canCut && !clipboardURL) { return; } - let menu: ContextMenuItem[] = []; + const menu: ContextMenuItem[] = []; if (canCut) { menu.push({ label: "Cut", role: "cut" }); } @@ -215,25 +215,57 @@ const AppKeyHandlers = () => { return null; }; -const TabIndicatorAutoClearing = () => { +const BadgeAutoClearing = () => { const tabId = useAtomValue(atoms.staticTabId); - const indicator = useAtomValue(getTabIndicatorAtom(tabId)); const documentHasFocus = useAtomValue(atoms.documentHasFocus); + const layoutModel = getLayoutModelForStaticTab(); + const focusedNode = useAtomValue(layoutModel.focusedNode); + const focusedBlockId = focusedNode?.data?.blockId; + const badge = useAtomValue(getBlockBadgeAtom(focusedBlockId)); + const tabTransientBadge = useAtomValue(getBadgeAtom(tabId != null ? `tab:${tabId}` : null)); + const prevFocusedBlockIdRef = useRef(null); + const prevDocHasFocusRef = useRef(false); + const prevTabDocHasFocusRef = useRef(false); useEffect(() => { - if (!indicator || !documentHasFocus || !indicator.clearonfocus) { + if (!focusedBlockId || !badge || !documentHasFocus) { + prevFocusedBlockIdRef.current = focusedBlockId; + prevDocHasFocusRef.current = documentHasFocus; return; } - + const focusSwitched = + prevFocusedBlockIdRef.current !== focusedBlockId || prevDocHasFocusRef.current !== documentHasFocus; + prevFocusedBlockIdRef.current = focusedBlockId; + prevDocHasFocusRef.current = documentHasFocus; + const delay = focusSwitched ? 500 : 3000; const timeoutId = setTimeout(() => { - const currentIndicator = globalStore.get(getTabIndicatorAtom(tabId)); - if (globalStore.get(atoms.documentHasFocus) && currentIndicator?.clearonfocus) { - clearTabIndicatorFromFocus(tabId); + if (!document.hasFocus()) { + return; } - }, 3000); + const currentFocusedNode = globalStore.get(layoutModel.focusedNode); + if (currentFocusedNode?.data?.blockId === focusedBlockId) { + clearBadgesForBlockOnFocus(focusedBlockId); + } + }, delay); + return () => clearTimeout(timeoutId); + }, [focusedBlockId, badge, documentHasFocus]); + useEffect(() => { + if (!tabId || !tabTransientBadge || !documentHasFocus) { + prevTabDocHasFocusRef.current = documentHasFocus; + return; + } + const focusSwitched = prevTabDocHasFocusRef.current !== documentHasFocus; + prevTabDocHasFocusRef.current = documentHasFocus; + const delay = focusSwitched ? 500 : 3000; + const timeoutId = setTimeout(() => { + if (!document.hasFocus()) { + return; + } + clearBadgesForTabOnFocus(tabId); + }, delay); return () => clearTimeout(timeoutId); - }, [tabId, indicator, documentHasFocus]); + }, [tabId, tabTransientBadge, documentHasFocus]); return null; }; @@ -265,7 +297,7 @@ const AppInner = () => { - + diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index 70f28ff2fe..420a6889c8 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -10,6 +10,7 @@ import { } from "@/app/block/blockutil"; import { ConnectionButton } from "@/app/block/connectionbutton"; import { DurableSessionFlyover } from "@/app/block/durable-session-flyover"; +import { getBlockBadgeAtom } from "@/app/store/badge"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { recordTEvent, refocusNode, WOS } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; @@ -19,7 +20,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { IconButton } from "@/element/iconbutton"; import { NodeModel } from "@/layout/index"; import * as util from "@/util/util"; -import { cn } from "@/util/util"; +import { cn, makeIconClass } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; import { BlockFrameProps } from "./blocktypes"; @@ -177,6 +178,7 @@ const BlockFrame_Header = ({ const useTermHeader = util.useAtomValueSafe(viewModel?.useTermHeader); const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable); const hideViewName = util.useAtomValueSafe(viewModel?.hideViewName); + const badge = jotai.useAtomValue(getBlockBadgeAtom(useTermHeader ? nodeModel.blockId : null)); const magnified = jotai.useAtomValue(nodeModel.isMagnified); const prevMagifiedState = React.useRef(magnified); const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); @@ -229,6 +231,11 @@ const BlockFrame_Header = ({ divClassName="iconbutton disabled text-[13px] ml-[-4px]" /> )} + {useTermHeader && badge && ( +
+ +
+ )}
diff --git a/frontend/app/store/badge.ts b/frontend/app/store/badge.ts new file mode 100644 index 0000000000..e3edb82103 --- /dev/null +++ b/frontend/app/store/badge.ts @@ -0,0 +1,226 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { fireAndForget, NullAtom } from "@/util/util"; +import { atom, Atom, PrimitiveAtom } from "jotai"; +import { v7 as uuidv7, version as uuidVersion } from "uuid"; +import { globalStore } from "./jotaiStore"; +import * as WOS from "./wos"; +import { waveEventSubscribeSingle } from "./wps"; + +const BadgeMap = new Map>(); +const TabBadgeAtomCache = new Map>(); + +function clearBadgeInternal(oref: string) { + const eventData: WaveEvent = { + event: "badge", + scopes: [oref], + data: { + oref: oref, + clear: true, + } as BadgeEvent, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); +} + +function clearBadgesForBlockOnFocus(blockId: string) { + const oref = WOS.makeORef("block", blockId); + const badgeAtom = BadgeMap.get(oref); + const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null; + if (badge != null && !badge.pidlinked) { + clearBadgeInternal(oref); + } +} + +function clearBadgesForTabOnFocus(tabId: string) { + const oref = WOS.makeORef("tab", tabId); + const badgeAtom = BadgeMap.get(oref); + const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null; + if (badge != null && !badge.pidlinked) { + clearBadgeInternal(oref); + } +} + +function clearAllBadges() { + const eventData: WaveEvent = { + event: "badge", + scopes: [], + data: { + oref: "", + clearall: true, + } as BadgeEvent, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); +} + +function clearBadgesForTab(tabId: string) { + const tabAtom = WOS.getWaveObjectAtom(WOS.makeORef("tab", tabId)); + const tab = globalStore.get(tabAtom); + const blockIds = (tab as Tab)?.blockids ?? []; + for (const blockId of blockIds) { + const oref = WOS.makeORef("block", blockId); + const badgeAtom = BadgeMap.get(oref); + if (badgeAtom != null && globalStore.get(badgeAtom) != null) { + clearBadgeInternal(oref); + } + } +} + +function getBadgeAtom(oref: string): PrimitiveAtom { + if (oref == null) { + return NullAtom as PrimitiveAtom; + } + let rtn = BadgeMap.get(oref); + if (rtn == null) { + rtn = atom(null) as PrimitiveAtom; + BadgeMap.set(oref, rtn); + } + return rtn; +} + +function getBlockBadgeAtom(blockId: string): Atom { + if (blockId == null) { + return NullAtom as Atom; + } + const oref = WOS.makeORef("block", blockId); + return getBadgeAtom(oref); +} + +function getTabBadgeAtom(tabId: string): Atom { + if (tabId == null) { + return NullAtom as Atom; + } + let rtn = TabBadgeAtomCache.get(tabId); + if (rtn != null) { + return rtn; + } + const tabOref = WOS.makeORef("tab", tabId); + const tabBadgeAtom = getBadgeAtom(tabOref); + const tabAtom = atom((get) => WOS.getObjectValue(tabOref, get)); + rtn = atom((get) => { + const tab = get(tabAtom); + const blockIds = tab?.blockids ?? []; + const badges: Badge[] = []; + for (const blockId of blockIds) { + const badge = get(getBadgeAtom(WOS.makeORef("block", blockId))); + if (badge != null) { + badges.push(badge); + } + } + const tabBadge = get(tabBadgeAtom); + if (tabBadge != null) { + badges.push(tabBadge); + } + return sortBadgesForTab(badges); + }); + TabBadgeAtomCache.set(tabId, rtn); + return rtn; +} + +async function loadBadges() { + const badges = await RpcApi.GetAllBadgesCommand(TabRpcClient); + if (badges == null) { + return; + } + for (const badgeEvent of badges) { + if (badgeEvent.oref == null) { + continue; + } + const curAtom = getBadgeAtom(badgeEvent.oref); + globalStore.set(curAtom, badgeEvent.badge ?? null); + } +} + +function setBadge(blockId: string, badge: Omit & { badgeid?: string }) { + if (!badge.badgeid) { + badge = { ...badge, badgeid: uuidv7() }; + } else if (uuidVersion(badge.badgeid) !== 7) { + throw new Error(`setBadge: badgeid must be a v7 UUID, got version ${uuidVersion(badge.badgeid)}`); + } + const oref = WOS.makeORef("block", blockId); + const eventData: WaveEvent = { + event: "badge", + scopes: [oref], + data: { + oref: oref, + badge: badge, + } as BadgeEvent, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); +} + +function clearBadgeById(blockId: string, badgeId: string) { + const oref = WOS.makeORef("block", blockId); + const eventData: WaveEvent = { + event: "badge", + scopes: [oref], + data: { + oref: oref, + clearbyid: badgeId, + } as BadgeEvent, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); +} + +function setupBadgesSubscription() { + waveEventSubscribeSingle({ + eventType: "badge", + handler: (event) => { + const data = event.data; + if (data?.clearall) { + for (const atom of BadgeMap.values()) { + globalStore.set(atom, null); + } + return; + } + if (data?.oref == null) { + return; + } + const curAtom = getBadgeAtom(data.oref); + if (data.clearbyid) { + const existing = globalStore.get(curAtom); + if (existing?.badgeid === data.clearbyid) { + globalStore.set(curAtom, null); + } + return; + } + globalStore.set(curAtom, data.clear ? null : (data.badge ?? null)); + }, + }); +} + +function sortBadges(badges: Badge[]): Badge[] { + return [...badges].sort((a, b) => { + if (a.priority !== b.priority) { + return b.priority - a.priority; + } + return b.badgeid < a.badgeid ? -1 : b.badgeid > a.badgeid ? 1 : 0; + }); +} + +function sortBadgesForTab(badges: Badge[]): Badge[] { + return [...badges].sort((a, b) => { + if (a.priority !== b.priority) { + return b.priority - a.priority; + } + return a.badgeid < b.badgeid ? -1 : a.badgeid > b.badgeid ? 1 : 0; + }); +} + +export { + clearAllBadges, + clearBadgeById, + clearBadgesForBlockOnFocus, + clearBadgesForTab, + clearBadgesForTabOnFocus, + getBadgeAtom, + getBlockBadgeAtom, + getTabBadgeAtom, + loadBadges, + setBadge, + setupBadgesSubscription, + sortBadges, + sortBadgesForTab, +}; diff --git a/frontend/app/store/global-atoms.ts b/frontend/app/store/global-atoms.ts index 18f072070f..6d24666ff0 100644 --- a/frontend/app/store/global-atoms.ts +++ b/frontend/app/store/global-atoms.ts @@ -9,7 +9,6 @@ import * as WOS from "./wos"; let atoms!: GlobalAtomsType; const blockComponentModelMap = new Map(); const ConnStatusMapAtom = atom(new Map>()); -const TabIndicatorMap = new Map>(); const orefAtomCache = new Map>>(); function initGlobalAtoms(initOpts: GlobalInitOptions) { @@ -154,4 +153,4 @@ function getApi(): ElectronApi { return (window as any).api; } -export { atoms, blockComponentModelMap, ConnStatusMapAtom, getAtoms, initGlobalAtoms, orefAtomCache, TabIndicatorMap }; +export { atoms, blockComponentModelMap, ConnStatusMapAtom, getAtoms, initGlobalAtoms, orefAtomCache }; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 628ae03626..88b679fb57 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -27,27 +27,19 @@ import { isWslConnName, NullAtom, } from "@/util/util"; -import { isPreviewWindow } from "./windowtype"; import { atom, Atom, PrimitiveAtom, useAtomValue } from "jotai"; -import { - atoms, - blockComponentModelMap, - ConnStatusMapAtom, - initGlobalAtoms, - orefAtomCache, - TabIndicatorMap, -} from "./global-atoms"; +import { setupBadgesSubscription } from "./badge"; +import { atoms, blockComponentModelMap, ConnStatusMapAtom, initGlobalAtoms, orefAtomCache } from "./global-atoms"; import { globalStore } from "./jotaiStore"; import { modalsModel } from "./modalmodel"; import { ClientService, ObjectService } from "./services"; +import { isPreviewWindow } from "./windowtype"; import * as WOS from "./wos"; import { getFileSubject, waveEventSubscribeSingle } from "./wps"; -let globalEnvironment: "electron" | "renderer"; let globalPrimaryTabStartup: boolean = false; function initGlobal(initOpts: GlobalInitOptions) { - globalEnvironment = initOpts.environment; globalPrimaryTabStartup = initOpts.primaryTabStartup ?? false; setPlatform(initOpts.platform); initGlobalAtoms(initOpts); @@ -105,12 +97,7 @@ function initGlobalWaveEventSubs(initOpts: WaveInitOpts) { globalStore.set(atoms.waveAIRateLimitInfoAtom, event.data); }, }); - waveEventSubscribeSingle({ - eventType: "tab:indicator", - handler: (event) => { - setTabIndicatorInternal(event.data.tabid, event.data.indicator); - }, - }); + setupBadgesSubscription(); } const blockCache = new Map>(); @@ -137,8 +124,8 @@ function getBlockMetaKeyAtom(blockId: string, key: T): return metaAtom; } metaAtom = atom((get) => { - let blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); - let blockData = get(blockAtom); + const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); + const blockData = get(blockAtom); return blockData?.meta?.[key]; }); blockCache.set(metaAtomName, metaAtom); @@ -157,8 +144,8 @@ function getOrefMetaKeyAtom(oref: string, key: T): Ato return metaAtom; } metaAtom = atom((get) => { - let objAtom = WOS.getWaveObjectAtom(oref); - let objData = get(objAtom); + const objAtom = WOS.getWaveObjectAtom(oref); + const objData = get(objAtom); return objData?.meta?.[key]; }); orefCache.set(metaAtomName, metaAtom); @@ -171,14 +158,14 @@ function useOrefMetaKeyAtom(oref: string, key: T): Met function getConnConfigKeyAtom(connName: string, key: T): Atom { if (isPreviewWindow()) return NullAtom as Atom; - let connCache = getSingleConnAtomCache(connName); + const connCache = getSingleConnAtomCache(connName); const keyAtomName = "#conn-" + key; let keyAtom = connCache.get(keyAtomName); if (keyAtom != null) { return keyAtom; } keyAtom = atom((get) => { - let fullConfig = get(atoms.fullConfigAtom); + const fullConfig = get(atoms.fullConfigAtom); return fullConfig.connections?.[connName]?.[key]; }); connCache.set(keyAtomName, keyAtom); @@ -608,17 +595,6 @@ async function loadConnStatus() { } } -async function loadTabIndicators() { - const tabIndicators = await RpcApi.GetAllTabIndicatorsCommand(TabRpcClient); - if (tabIndicators == null) { - return; - } - for (const [tabId, indicator] of Object.entries(tabIndicators)) { - const curAtom = getTabIndicatorAtom(tabId); - globalStore.set(curAtom, indicator); - } -} - function subscribeToConnEvents() { waveEventSubscribeSingle({ eventType: "connchange", @@ -629,7 +605,7 @@ function subscribeToConnEvents() { return; } console.log("connstatus update", connStatus); - let curAtom = getConnStatusAtom(connStatus.connection); + const curAtom = getConnStatusAtom(connStatus.connection); globalStore.set(curAtom, connStatus); } catch (e) { console.log("connchange error", e); @@ -672,76 +648,6 @@ function getConnStatusAtom(conn: string): PrimitiveAtom { return rtn; } -function getTabIndicatorAtom(tabId: string): PrimitiveAtom { - let rtn = TabIndicatorMap.get(tabId); - if (rtn == null) { - rtn = atom(null) as PrimitiveAtom; - TabIndicatorMap.set(tabId, rtn); - } - return rtn; -} - -function setTabIndicatorInternal(tabId: string, indicator: TabIndicator) { - if (indicator == null) { - const indicatorAtom = getTabIndicatorAtom(tabId); - globalStore.set(indicatorAtom, null); - return; - } - const indicatorAtom = getTabIndicatorAtom(tabId); - const currentIndicator = globalStore.get(indicatorAtom); - if (currentIndicator == null) { - globalStore.set(indicatorAtom, indicator); - return; - } - if (indicator.priority >= currentIndicator.priority) { - if (indicator.clearonfocus && !currentIndicator.clearonfocus) { - indicator.persistentindicator = currentIndicator; - } - globalStore.set(indicatorAtom, indicator); - } -} - -function setTabIndicator(tabId: string, indicator: TabIndicator) { - setTabIndicatorInternal(tabId, indicator); - - const eventData: WaveEvent = { - event: "tab:indicator", - scopes: [WOS.makeORef("tab", tabId)], - data: { - tabid: tabId, - indicator: indicator, - }, - }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); -} - -function clearTabIndicatorFromFocus(tabId: string) { - const indicatorAtom = getTabIndicatorAtom(tabId); - const currentIndicator = globalStore.get(indicatorAtom); - if (currentIndicator == null) { - return; - } - const persistentIndicator = currentIndicator.persistentindicator; - const eventData: WaveEvent = { - event: "tab:indicator", - scopes: [WOS.makeORef("tab", tabId)], - data: { - tabid: tabId, - indicator: persistentIndicator ?? null, - } as TabIndicatorEventData, - }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); -} - -function clearAllTabIndicators() { - for (const [tabId, indicatorAtom] of TabIndicatorMap.entries()) { - const indicator = globalStore.get(indicatorAtom); - if (indicator != null) { - setTabIndicator(tabId, null); - } - } -} - function createTab() { getApi().createTab(); } @@ -758,12 +664,8 @@ function recordTEvent(event: string, props?: TEventProps) { RpcApi.RecordTEventCommand(TabRpcClient, { event, props }, { noresponse: true }); } -export { ConnStatusMapAtom, getAtoms, initGlobalAtoms, orefAtomCache, TabIndicatorMap, blockComponentModelMap } from "./global-atoms"; - export { atoms, - clearAllTabIndicators, - clearTabIndicatorFromFocus, createBlock, createBlockSplitHorizontally, createBlockSplitVertically, @@ -783,7 +685,6 @@ export { getOverrideConfigAtom, getSettingsKeyAtom, getSettingsPrefixAtom, - getTabIndicatorAtom, getUserName, globalPrimaryTabStartup, globalStore, @@ -791,7 +692,6 @@ export { initGlobalWaveEventSubs, isDev, loadConnStatus, - loadTabIndicators, openLink, readAtom, recordTEvent, @@ -801,7 +701,6 @@ export { setActiveTab, setNodeFocus, setPlatform, - setTabIndicator, subscribeToConnEvents, unregisterBlockComponentModel, useBlockAtom, diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index aa25448a0a..ac4ab94c42 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -65,7 +65,7 @@ export function keyboardMouseDownHandler(e: MouseEvent) { } } -function getFocusedBlockInStaticTab() { +function getFocusedBlockInStaticTab(): string { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); return focusedNode.data?.blockId; diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 670b660cb0..dd8e20e5c7 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -37,35 +37,61 @@ class RpcApiType { } // command "authenticatejobmanager" [call] - AuthenticateJobManagerCommand(client: WshClient, data: CommandAuthenticateJobManagerData, opts?: RpcOpts): Promise { + AuthenticateJobManagerCommand( + client: WshClient, + data: CommandAuthenticateJobManagerData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatejobmanager", data, opts); return client.wshRpcCall("authenticatejobmanager", data, opts); } // command "authenticatejobmanagerverify" [call] - AuthenticateJobManagerVerifyCommand(client: WshClient, data: CommandAuthenticateJobManagerData, opts?: RpcOpts): Promise { + AuthenticateJobManagerVerifyCommand( + client: WshClient, + data: CommandAuthenticateJobManagerData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatejobmanagerverify", data, opts); return client.wshRpcCall("authenticatejobmanagerverify", data, opts); } // command "authenticatetojobmanager" [call] - AuthenticateToJobManagerCommand(client: WshClient, data: CommandAuthenticateToJobData, opts?: RpcOpts): Promise { + AuthenticateToJobManagerCommand( + client: WshClient, + data: CommandAuthenticateToJobData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatetojobmanager", data, opts); return client.wshRpcCall("authenticatetojobmanager", data, opts); } // command "authenticatetoken" [call] - AuthenticateTokenCommand(client: WshClient, data: CommandAuthenticateTokenData, opts?: RpcOpts): Promise { + AuthenticateTokenCommand( + client: WshClient, + data: CommandAuthenticateTokenData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatetoken", data, opts); return client.wshRpcCall("authenticatetoken", data, opts); } // command "authenticatetokenverify" [call] - AuthenticateTokenVerifyCommand(client: WshClient, data: CommandAuthenticateTokenData, opts?: RpcOpts): Promise { + AuthenticateTokenVerifyCommand( + client: WshClient, + data: CommandAuthenticateTokenData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatetokenverify", data, opts); return client.wshRpcCall("authenticatetokenverify", data, opts); } + // command "badgewatchpid" [call] + BadgeWatchPidCommand(client: WshClient, data: CommandBadgeWatchPidData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "badgewatchpid", data, opts); + return client.wshRpcCall("badgewatchpid", data, opts); + } + // command "blockinfo" [call] BlockInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "blockinfo", data, opts); @@ -85,7 +111,11 @@ class RpcApiType { } // command "captureblockscreenshot" [call] - CaptureBlockScreenshotCommand(client: WshClient, data: CommandCaptureBlockScreenshotData, opts?: RpcOpts): Promise { + CaptureBlockScreenshotCommand( + client: WshClient, + data: CommandCaptureBlockScreenshotData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "captureblockscreenshot", data, opts); return client.wshRpcCall("captureblockscreenshot", data, opts); } @@ -151,7 +181,11 @@ class RpcApiType { } // command "controllerappendoutput" [call] - ControllerAppendOutputCommand(client: WshClient, data: CommandControllerAppendOutputData, opts?: RpcOpts): Promise { + ControllerAppendOutputCommand( + client: WshClient, + data: CommandControllerAppendOutputData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "controllerappendoutput", data, opts); return client.wshRpcCall("controllerappendoutput", data, opts); } @@ -235,13 +269,21 @@ class RpcApiType { } // command "electrondecrypt" [call] - ElectronDecryptCommand(client: WshClient, data: CommandElectronDecryptData, opts?: RpcOpts): Promise { + ElectronDecryptCommand( + client: WshClient, + data: CommandElectronDecryptData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "electrondecrypt", data, opts); return client.wshRpcCall("electrondecrypt", data, opts); } // command "electronencrypt" [call] - ElectronEncryptCommand(client: WshClient, data: CommandElectronEncryptData, opts?: RpcOpts): Promise { + ElectronEncryptCommand( + client: WshClient, + data: CommandElectronEncryptData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "electronencrypt", data, opts); return client.wshRpcCall("electronencrypt", data, opts); } @@ -259,7 +301,11 @@ class RpcApiType { } // command "eventreadhistory" [call] - EventReadHistoryCommand(client: WshClient, data: CommandEventReadHistoryData, opts?: RpcOpts): Promise { + EventReadHistoryCommand( + client: WshClient, + data: CommandEventReadHistoryData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "eventreadhistory", data, opts); return client.wshRpcCall("eventreadhistory", data, opts); } @@ -289,7 +335,11 @@ class RpcApiType { } // command "fetchsuggestions" [call] - FetchSuggestionsCommand(client: WshClient, data: FetchSuggestionsData, opts?: RpcOpts): Promise { + FetchSuggestionsCommand( + client: WshClient, + data: FetchSuggestionsData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "fetchsuggestions", data, opts); return client.wshRpcCall("fetchsuggestions", data, opts); } @@ -337,7 +387,11 @@ class RpcApiType { } // command "fileliststream" [responsestream] - FileListStreamCommand(client: WshClient, data: FileListData, opts?: RpcOpts): AsyncGenerator { + FileListStreamCommand( + client: WshClient, + data: FileListData, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "fileliststream", data, opts); return client.wshRpcStream("fileliststream", data, opts); } @@ -361,7 +415,7 @@ class RpcApiType { } // command "filereadstream" [responsestream] - FileReadStreamCommand(client: WshClient, data: FileData, opts?: RpcOpts): AsyncGenerator { + FileReadStreamCommand(client: WshClient, data: FileData, opts?: RpcOpts): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "filereadstream", data, opts); return client.wshRpcStream("filereadstream", data, opts); } @@ -390,10 +444,10 @@ class RpcApiType { return client.wshRpcCall("focuswindow", data, opts); } - // command "getalltabindicators" [call] - GetAllTabIndicatorsCommand(client: WshClient, opts?: RpcOpts): Promise<{[key: string]: TabIndicator}> { - if (mockClient) return mockClient.mockWshRpcCall(client, "getalltabindicators", null, opts); - return client.wshRpcCall("getalltabindicators", null, opts); + // command "getallbadges" [call] + GetAllBadgesCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getallbadges", null, opts); + return client.wshRpcCall("getallbadges", null, opts); } // command "getallvars" [call] @@ -445,7 +499,7 @@ class RpcApiType { } // command "getsecrets" [call] - GetSecretsCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<{[key: string]: string}> { + GetSecretsCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<{ [key: string]: string }> { if (mockClient) return mockClient.mockWshRpcCall(client, "getsecrets", data, opts); return client.wshRpcCall("getsecrets", data, opts); } @@ -511,7 +565,11 @@ class RpcApiType { } // command "jobcontrollerattachjob" [call] - JobControllerAttachJobCommand(client: WshClient, data: CommandJobControllerAttachJobData, opts?: RpcOpts): Promise { + JobControllerAttachJobCommand( + client: WshClient, + data: CommandJobControllerAttachJobData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerattachjob", data, opts); return client.wshRpcCall("jobcontrollerattachjob", data, opts); } @@ -571,7 +629,11 @@ class RpcApiType { } // command "jobcontrollerstartjob" [call] - JobControllerStartJobCommand(client: WshClient, data: CommandJobControllerStartJobData, opts?: RpcOpts): Promise { + JobControllerStartJobCommand( + client: WshClient, + data: CommandJobControllerStartJobData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerstartjob", data, opts); return client.wshRpcCall("jobcontrollerstartjob", data, opts); } @@ -583,7 +645,11 @@ class RpcApiType { } // command "jobprepareconnect" [call] - JobPrepareConnectCommand(client: WshClient, data: CommandJobPrepareConnectData, opts?: RpcOpts): Promise { + JobPrepareConnectCommand( + client: WshClient, + data: CommandJobPrepareConnectData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "jobprepareconnect", data, opts); return client.wshRpcCall("jobprepareconnect", data, opts); } @@ -595,7 +661,11 @@ class RpcApiType { } // command "listallappfiles" [call] - ListAllAppFilesCommand(client: WshClient, data: CommandListAllAppFilesData, opts?: RpcOpts): Promise { + ListAllAppFilesCommand( + client: WshClient, + data: CommandListAllAppFilesData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "listallappfiles", data, opts); return client.wshRpcCall("listallappfiles", data, opts); } @@ -613,7 +683,11 @@ class RpcApiType { } // command "makedraftfromlocal" [call] - MakeDraftFromLocalCommand(client: WshClient, data: CommandMakeDraftFromLocalData, opts?: RpcOpts): Promise { + MakeDraftFromLocalCommand( + client: WshClient, + data: CommandMakeDraftFromLocalData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "makedraftfromlocal", data, opts); return client.wshRpcCall("makedraftfromlocal", data, opts); } @@ -649,13 +723,21 @@ class RpcApiType { } // command "publishapp" [call] - PublishAppCommand(client: WshClient, data: CommandPublishAppData, opts?: RpcOpts): Promise { + PublishAppCommand( + client: WshClient, + data: CommandPublishAppData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "publishapp", data, opts); return client.wshRpcCall("publishapp", data, opts); } // command "readappfile" [call] - ReadAppFileCommand(client: WshClient, data: CommandReadAppFileData, opts?: RpcOpts): Promise { + ReadAppFileCommand( + client: WshClient, + data: CommandReadAppFileData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "readappfile", data, opts); return client.wshRpcCall("readappfile", data, opts); } @@ -667,7 +749,11 @@ class RpcApiType { } // command "remotedisconnectfromjobmanager" [call] - RemoteDisconnectFromJobManagerCommand(client: WshClient, data: CommandRemoteDisconnectFromJobManagerData, opts?: RpcOpts): Promise { + RemoteDisconnectFromJobManagerCommand( + client: WshClient, + data: CommandRemoteDisconnectFromJobManagerData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "remotedisconnectfromjobmanager", data, opts); return client.wshRpcCall("remotedisconnectfromjobmanager", data, opts); } @@ -703,7 +789,11 @@ class RpcApiType { } // command "remotefilemultiinfo" [call] - RemoteFileMultiInfoCommand(client: WshClient, data: CommandRemoteFileMultiInfoData, opts?: RpcOpts): Promise<{[key: string]: FileInfo}> { + RemoteFileMultiInfoCommand( + client: WshClient, + data: CommandRemoteFileMultiInfoData, + opts?: RpcOpts + ): Promise<{ [key: string]: FileInfo }> { if (mockClient) return mockClient.mockWshRpcCall(client, "remotefilemultiinfo", data, opts); return client.wshRpcCall("remotefilemultiinfo", data, opts); } @@ -727,7 +817,11 @@ class RpcApiType { } // command "remotelistentries" [responsestream] - RemoteListEntriesCommand(client: WshClient, data: CommandRemoteListEntriesData, opts?: RpcOpts): AsyncGenerator { + RemoteListEntriesCommand( + client: WshClient, + data: CommandRemoteListEntriesData, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "remotelistentries", data, opts); return client.wshRpcStream("remotelistentries", data, opts); } @@ -739,31 +833,47 @@ class RpcApiType { } // command "remotereconnecttojobmanager" [call] - RemoteReconnectToJobManagerCommand(client: WshClient, data: CommandRemoteReconnectToJobManagerData, opts?: RpcOpts): Promise { + RemoteReconnectToJobManagerCommand( + client: WshClient, + data: CommandRemoteReconnectToJobManagerData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "remotereconnecttojobmanager", data, opts); return client.wshRpcCall("remotereconnecttojobmanager", data, opts); } // command "remotestartjob" [call] - RemoteStartJobCommand(client: WshClient, data: CommandRemoteStartJobData, opts?: RpcOpts): Promise { + RemoteStartJobCommand( + client: WshClient, + data: CommandRemoteStartJobData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "remotestartjob", data, opts); return client.wshRpcCall("remotestartjob", data, opts); } // command "remotestreamcpudata" [responsestream] - RemoteStreamCpuDataCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { + RemoteStreamCpuDataCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "remotestreamcpudata", null, opts); return client.wshRpcStream("remotestreamcpudata", null, opts); } // command "remotestreamfile" [responsestream] - RemoteStreamFileCommand(client: WshClient, data: CommandRemoteStreamFileData, opts?: RpcOpts): AsyncGenerator { + RemoteStreamFileCommand( + client: WshClient, + data: CommandRemoteStreamFileData, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "remotestreamfile", data, opts); return client.wshRpcStream("remotestreamfile", data, opts); } // command "remoteterminatejobmanager" [call] - RemoteTerminateJobManagerCommand(client: WshClient, data: CommandRemoteTerminateJobManagerData, opts?: RpcOpts): Promise { + RemoteTerminateJobManagerCommand( + client: WshClient, + data: CommandRemoteTerminateJobManagerData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "remoteterminatejobmanager", data, opts); return client.wshRpcCall("remoteterminatejobmanager", data, opts); } @@ -781,13 +891,21 @@ class RpcApiType { } // command "resolveids" [call] - ResolveIdsCommand(client: WshClient, data: CommandResolveIdsData, opts?: RpcOpts): Promise { + ResolveIdsCommand( + client: WshClient, + data: CommandResolveIdsData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "resolveids", data, opts); return client.wshRpcCall("resolveids", data, opts); } // command "restartbuilderandwait" [call] - RestartBuilderAndWaitCommand(client: WshClient, data: CommandRestartBuilderAndWaitData, opts?: RpcOpts): Promise { + RestartBuilderAndWaitCommand( + client: WshClient, + data: CommandRestartBuilderAndWaitData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "restartbuilderandwait", data, opts); return client.wshRpcCall("restartbuilderandwait", data, opts); } @@ -847,7 +965,7 @@ class RpcApiType { } // command "setsecrets" [call] - SetSecretsCommand(client: WshClient, data: {[key: string]: string}, opts?: RpcOpts): Promise { + SetSecretsCommand(client: WshClient, data: { [key: string]: string }, opts?: RpcOpts): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "setsecrets", data, opts); return client.wshRpcCall("setsecrets", data, opts); } @@ -877,7 +995,11 @@ class RpcApiType { } // command "streamcpudata" [responsestream] - StreamCpuDataCommand(client: WshClient, data: CpuDataRequest, opts?: RpcOpts): AsyncGenerator { + StreamCpuDataCommand( + client: WshClient, + data: CpuDataRequest, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "streamcpudata", data, opts); return client.wshRpcStream("streamcpudata", data, opts); } @@ -895,19 +1017,27 @@ class RpcApiType { } // command "streamtest" [responsestream] - StreamTestCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { + StreamTestCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "streamtest", null, opts); return client.wshRpcStream("streamtest", null, opts); } // command "streamwaveai" [responsestream] - StreamWaveAiCommand(client: WshClient, data: WaveAIStreamRequest, opts?: RpcOpts): AsyncGenerator { + StreamWaveAiCommand( + client: WshClient, + data: WaveAIStreamRequest, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "streamwaveai", data, opts); return client.wshRpcStream("streamwaveai", data, opts); } // command "termgetscrollbacklines" [call] - TermGetScrollbackLinesCommand(client: WshClient, data: CommandTermGetScrollbackLinesData, opts?: RpcOpts): Promise { + TermGetScrollbackLinesCommand( + client: WshClient, + data: CommandTermGetScrollbackLinesData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "termgetscrollbacklines", data, opts); return client.wshRpcCall("termgetscrollbacklines", data, opts); } @@ -937,13 +1067,21 @@ class RpcApiType { } // command "vdomrender" [responsestream] - VDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): AsyncGenerator { + VDomRenderCommand( + client: WshClient, + data: VDomFrontendUpdate, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "vdomrender", data, opts); return client.wshRpcStream("vdomrender", data, opts); } // command "vdomurlrequest" [responsestream] - VDomUrlRequestCommand(client: WshClient, data: VDomUrlRequestData, opts?: RpcOpts): AsyncGenerator { + VDomUrlRequestCommand( + client: WshClient, + data: VDomUrlRequestData, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "vdomurlrequest", data, opts); return client.wshRpcStream("vdomurlrequest", data, opts); } @@ -967,7 +1105,11 @@ class RpcApiType { } // command "waveaigettooldiff" [call] - WaveAIGetToolDiffCommand(client: WshClient, data: CommandWaveAIGetToolDiffData, opts?: RpcOpts): Promise { + WaveAIGetToolDiffCommand( + client: WshClient, + data: CommandWaveAIGetToolDiffData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "waveaigettooldiff", data, opts); return client.wshRpcCall("waveaigettooldiff", data, opts); } @@ -979,7 +1121,11 @@ class RpcApiType { } // command "wavefilereadstream" [call] - WaveFileReadStreamCommand(client: WshClient, data: CommandWaveFileReadStreamData, opts?: RpcOpts): Promise { + WaveFileReadStreamCommand( + client: WshClient, + data: CommandWaveFileReadStreamData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "wavefilereadstream", data, opts); return client.wshRpcCall("wavefilereadstream", data, opts); } @@ -1009,13 +1155,21 @@ class RpcApiType { } // command "writeappgofile" [call] - WriteAppGoFileCommand(client: WshClient, data: CommandWriteAppGoFileData, opts?: RpcOpts): Promise { + WriteAppGoFileCommand( + client: WshClient, + data: CommandWriteAppGoFileData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "writeappgofile", data, opts); return client.wshRpcCall("writeappgofile", data, opts); } // command "writeappsecretbindings" [call] - WriteAppSecretBindingsCommand(client: WshClient, data: CommandWriteAppSecretBindingsData, opts?: RpcOpts): Promise { + WriteAppSecretBindingsCommand( + client: WshClient, + data: CommandWriteAppSecretBindingsData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "writeappsecretbindings", data, opts); return client.wshRpcCall("writeappsecretbindings", data, opts); } @@ -1027,7 +1181,7 @@ class RpcApiType { } // command "wshactivity" [call] - WshActivityCommand(client: WshClient, data: {[key: string]: number}, opts?: RpcOpts): Promise { + WshActivityCommand(client: WshClient, data: { [key: string]: number }, opts?: RpcOpts): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "wshactivity", data, opts); return client.wshRpcCall("wshactivity", data, opts); } @@ -1049,7 +1203,6 @@ class RpcApiType { if (mockClient) return mockClient.mockWshRpcCall(client, "wslstatus", null, opts); return client.wshRpcCall("wslstatus", null, opts); } - } export const RpcApi = new RpcApiType(); diff --git a/frontend/app/tab/tab.scss b/frontend/app/tab/tab.scss index 3739752eee..ad10fc814e 100644 --- a/frontend/app/tab/tab.scss +++ b/frontend/app/tab/tab.scss @@ -1,10 +1,10 @@ -// Copyright 2024, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .tab { position: absolute; width: 130px; - height: calc(100% - 1px); + height: calc(100% - 3px); padding: 0 0 0 0; box-sizing: border-box; font-weight: bold; @@ -14,13 +14,12 @@ align-items: center; justify-content: center; - &::after { - content: ""; + .tab-divider { position: absolute; left: 0; width: 1px; height: 14px; - border-right: 1px solid rgb(from var(--main-text-color) r g b / 0.2); + background: rgb(from var(--main-text-color) r g b / 0.2); } .tab-inner { @@ -45,19 +44,11 @@ } .name { - color: var(--main-text-color); - } - - & + .tab::after, - &::after { - content: none; + color: rgba(255, 255, 255, 1); + font-weight: 600; } } - &:first-child::after { - content: none; - } - .name { position: absolute; top: 50%; @@ -81,21 +72,6 @@ } } - .tab-indicator { - position: absolute; - top: 50%; - left: 4px; - transform: translate3d(0, -50%, 0); - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - z-index: var(--zindex-tab-name); - padding: 1px 2px; - transition: none !important; - } - .wave-button { position: absolute; top: 50%; @@ -118,11 +94,17 @@ } // Only apply hover effects when not in nohover mode. This prevents the previously-hovered tab from remaining hovered while a tab view is not mounted. +body:not(.nohover) .tab:hover + .tab, +body:not(.nohover) .tab.dragging + .tab { + .tab-divider { + display: none; + } +} + body:not(.nohover) .tab:hover, body:not(.nohover) .tab.dragging { - & + .tab::after, - &::after { - content: none; + .tab-divider { + display: none; } .tab-inner { @@ -157,53 +139,3 @@ body.nohover .tab.active .close { animation: expandWidthAndFadeIn 0.1s forwards; } -@keyframes jigglePinIcon { - 0% { - transform: rotate(0deg); - color: inherit; - } - 10% { - transform: rotate(-30deg); - color: rgb(255, 193, 7); - } - 20% { - transform: rotate(30deg); - color: rgb(255, 193, 7); - } - 30% { - transform: rotate(-30deg); - color: rgb(255, 193, 7); - } - 40% { - transform: rotate(30deg); - color: rgb(255, 193, 7); - } - 50% { - transform: rotate(-15deg); - color: rgb(255, 193, 7); - } - 60% { - transform: rotate(15deg); - color: rgb(255, 193, 7); - } - 70% { - transform: rotate(-15deg); - color: rgb(255, 193, 7); - } - 80% { - transform: rotate(15deg); - color: rgb(255, 193, 7); - } - 90% { - transform: rotate(0deg); - color: rgb(255, 193, 7); - } - 100% { - transform: rotate(0deg); - color: inherit; - } -} - -.pin.jiggling i { - animation: jigglePinIcon 0.5s ease-in-out; -} diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 37a96ca525..01a13bf13e 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -1,24 +1,18 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { - atoms, - clearAllTabIndicators, - clearTabIndicatorFromFocus, - getTabIndicatorAtom, - globalStore, - recordTEvent, - refocusNode, - setTabIndicator, -} from "@/app/store/global"; +import { getTabBadgeAtom, sortBadgesForTab } from "@/app/store/badge"; +import { atoms, getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { Button } from "@/element/button"; import { ContextMenuModel } from "@/store/contextmenu"; +import { validateCssColor } from "@/util/color-validator"; import { fireAndForget, makeIconClass } from "@/util/util"; import clsx from "clsx"; import { useAtomValue } from "jotai"; -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; +import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; +import { v7 as uuidv7 } from "uuid"; import { ObjectService } from "../store/services"; import { makeORef, useWaveObjectValue } from "../store/wos"; import "./tab.scss"; @@ -27,11 +21,12 @@ interface TabVProps { tabId: string; tabName: string; active: boolean; - isBeforeActive: boolean; + showDivider: boolean; isDragging: boolean; tabWidth: number; isNew: boolean; - indicator?: TabIndicator | null; + badges?: Badge[] | null; + flagColor?: string | null; onClick: () => void; onClose: (event: React.MouseEvent | null) => void; onDragStart: (event: React.MouseEvent) => void; @@ -41,16 +36,58 @@ interface TabVProps { renameRef?: React.RefObject<(() => void) | null>; } +interface TabBadgesProps { + badges?: Badge[] | null; + flagColor?: string | null; +} + +function TabBadges({ badges, flagColor }: TabBadgesProps) { + const flagBadgeId = useMemo(() => uuidv7(), []); + const allBadges = useMemo(() => { + const base = badges ?? []; + if (!flagColor) { + return base; + } + const flagBadge: Badge = { icon: "flag", color: flagColor, priority: 0, badgeid: flagBadgeId }; + return sortBadgesForTab([...base, flagBadge]); + }, [badges, flagColor, flagBadgeId]); + if (!allBadges[0]) { + return null; + } + const firstBadge = allBadges[0]; + const extraBadges = allBadges.slice(1, 3); + return ( +
+ + {extraBadges.length > 0 && ( +
+ {extraBadges.map((badge, idx) => ( +
+ ))} +
+ )} +
+ ); +} + const TabV = forwardRef((props, ref) => { const { tabId, tabName, active, - isBeforeActive, + showDivider, isDragging, tabWidth, isNew, - indicator, + badges, + flagColor, onClick, onClose, onDragStart, @@ -167,7 +204,6 @@ const TabV = forwardRef((props, ref) => { className={clsx("tab", { active, dragging: isDragging, - "before-active": isBeforeActive, "new-tab": isNew, })} onMouseDown={onDragStart} @@ -175,6 +211,7 @@ const TabV = forwardRef((props, ref) => { onContextMenu={onContextMenu} data-tab-id={tabId} > + {showDivider &&
}
((props, ref) => { > {tabName}
- {indicator && ( -
- -
- )} + +
+ {displayName} +
+
+ ); + })} +
+ )}
- - ); - } -); - -const SettingsFloatingWindow = memo( - ({ - isOpen, - onClose, - referenceElement, - }: { - isOpen: boolean; - onClose: () => void; - referenceElement: HTMLElement; - }) => { - const { refs, floatingStyles, context } = useFloating({ - open: isOpen, - onOpenChange: onClose, - placement: "left-start", - middleware: [offset(-2), shift({ padding: 12 })], - whileElementsMounted: autoUpdate, - elements: { - reference: referenceElement, - }, - }); - - const dismiss = useDismiss(context); - const { getFloatingProps } = useInteractions([dismiss]); + +
+ + ); +}); - if (!isOpen) return null; +const SettingsFloatingWindow = memo(({ isOpen, onClose, referenceElement }: FloatingWindowPropsType) => { + const env = useWaveEnv(); + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: onClose, + placement: "left-start", + middleware: [offset(-2), shift({ padding: 12 })], + whileElementsMounted: autoUpdate, + elements: { + reference: referenceElement, + }, + }); - const menuItems = [ - { - icon: "gear", - label: "Settings", - onClick: () => { - const blockDef: BlockDef = { - meta: { - view: "waveconfig", - }, - }; - createBlock(blockDef, false, true); - onClose(); - }, + const dismiss = useDismiss(context); + const { getFloatingProps } = useInteractions([dismiss]); + + if (!isOpen) return null; + + const menuItems = [ + { + icon: "gear", + label: "Settings", + onClick: () => { + const blockDef: BlockDef = { + meta: { + view: "waveconfig", + }, + }; + env.createBlock(blockDef, false, true); + onClose(); }, - { - icon: "lightbulb", - label: "Tips", - onClick: () => { - const blockDef: BlockDef = { - meta: { - view: "tips", - }, - }; - createBlock(blockDef, true, true); - onClose(); - }, + }, + { + icon: "lightbulb", + label: "Tips", + onClick: () => { + const blockDef: BlockDef = { + meta: { + view: "tips", + }, + }; + env.createBlock(blockDef, true, true); + onClose(); }, - { - icon: "lock", - label: "Secrets", - onClick: () => { - const blockDef: BlockDef = { - meta: { - view: "waveconfig", - file: "secrets", - }, - }; - createBlock(blockDef, false, true); - onClose(); - }, + }, + { + icon: "lock", + label: "Secrets", + onClick: () => { + const blockDef: BlockDef = { + meta: { + view: "waveconfig", + file: "secrets", + }, + }; + env.createBlock(blockDef, false, true); + onClose(); }, - { - icon: "book-open", - label: "Release Notes", - onClick: () => { - modalsModel.pushModal("UpgradeOnboardingPatch", { isReleaseNotes: true }); - onClose(); - }, + }, + { + icon: "book-open", + label: "Release Notes", + onClick: () => { + modalsModel.pushModal("UpgradeOnboardingPatch", { isReleaseNotes: true }); + onClose(); }, - { - icon: "circle-question", - label: "Help", - onClick: () => { - const blockDef: BlockDef = { - meta: { - view: "help", - }, - }; - createBlock(blockDef); - onClose(); - }, + }, + { + icon: "circle-question", + label: "Help", + onClick: () => { + const blockDef: BlockDef = { + meta: { + view: "help", + }, + }; + env.createBlock(blockDef); + onClose(); }, - ]; + }, + ]; - return ( - -
- {menuItems.map((item, idx) => ( -
-
- -
-
{item.label}
+ return ( + +
+ {menuItems.map((item, idx) => ( +
+
+
- ))} -
- - ); - } -); +
{item.label}
+
+ ))} +
+ + ); +}); SettingsFloatingWindow.displayName = "SettingsFloatingWindow"; const Widgets = memo(() => { - const fullConfig = useAtomValue(atoms.fullConfigAtom); - const workspace = useAtomValue(atoms.workspace); - const hasCustomAIPresets = useAtomValue(atoms.hasCustomAIPresetsAtom); + const env = useWaveEnv(); + const fullConfig = useAtomValue(env.atoms.fullConfigAtom); + const workspace = useAtomValue(env.atoms.workspace); + const hasCustomAIPresets = useAtomValue(env.atoms.hasCustomAIPresetsAtom); const [mode, setMode] = useState<"normal" | "compact" | "supercompact">("normal"); const containerRef = useRef(null); const measurementRef = useRef(null); @@ -419,31 +429,31 @@ const Widgets = memo(() => { file: "widgets.json", }, }; - await createBlock(blockDef, false, true); + await env.createBlock(blockDef, false, true); }); }, }, ]; - ContextMenuModel.getInstance().showContextMenu(menu, e); + env.showContextMenu(menu, e); }; return ( <>
{mode === "supercompact" ? ( <>
{widgets?.map((data, idx) => ( - + ))}
- {isDev() || featureWaveAppBuilder ? ( + {env.isDev() || featureWaveAppBuilder ? (
{ ) : ( <> {widgets?.map((data, idx) => ( - + ))}
- {isDev() || featureWaveAppBuilder ? ( + {env.isDev() || featureWaveAppBuilder ? (
{
)} - {isDev() ? ( + {env.isDev() ? (
{
) : null}
- {(isDev() || featureWaveAppBuilder) && appsButtonRef.current && ( + {(env.isDev() || featureWaveAppBuilder) && appsButtonRef.current && ( setIsAppsOpen(false)} @@ -537,7 +547,7 @@ const Widgets = memo(() => { className="flex flex-col w-12 py-1 -ml-1 select-none absolute -z-10 opacity-0 pointer-events-none" > {widgets?.map((data, idx) => ( - + ))}
@@ -546,7 +556,7 @@ const Widgets = memo(() => {
settings
- {isDev() ? ( + {env.isDev() ? (
@@ -554,7 +564,7 @@ const Widgets = memo(() => {
apps
) : null} - {isDev() ? ( + {env.isDev() ? (
): WaveEnv["configAtoms"] { + const overrideAtoms = new Map>(); + if (overrides) { + for (const key of Object.keys(overrides) as (keyof SettingsType)[]) { + overrideAtoms.set(key, atom(overrides[key])); + } + } + return new Proxy({} as WaveEnv["configAtoms"], { + get(_target: WaveEnv["configAtoms"], key: K) { + if (overrideAtoms.has(key)) { + return overrideAtoms.get(key); + } + return getSettingsKeyAtom(key); + }, + }); +} + +type MockIds = { + tabId?: string; + windowId?: string; + clientId?: string; +}; + +function makeMockGlobalAtoms(ids?: MockIds): GlobalAtomsType { + return { + builderId: atom(""), + builderAppId: atom("") as any, + uiContext: atom({ windowid: ids?.windowId ?? "", activetabid: ids?.tabId ?? "" } as UIContext), + workspace: atom(null as Workspace), + fullConfigAtom: atom(null) as any, + waveaiModeConfigAtom: atom({}) as any, + settingsAtom: atom({} as SettingsType), + hasCustomAIPresetsAtom: atom(false), + staticTabId: atom(ids?.tabId ?? ""), + isFullScreen: atom(false) as any, + zoomFactorAtom: atom(1.0) as any, + controlShiftDelayAtom: atom(false) as any, + prefersReducedMotionAtom: atom(false), + documentHasFocus: atom(true) as any, + updaterStatusAtom: atom("up-to-date" as UpdaterStatus) as any, + modalOpen: atom(false) as any, + allConnStatus: atom([] as ConnStatus[]), + reinitVersion: atom(0) as any, + waveAIRateLimitInfoAtom: atom(null) as any, + }; +} + +type RpcOverrides = { + [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => any; +}; + +export function makeMockRpc(overrides?: RpcOverrides): RpcApiType { + const dispatchMap = new Map any>(); + if (overrides) { + for (const key of Object.keys(overrides) as (keyof RpcOverrides)[]) { + const cmdName = key.slice(0, -"Command".length).toLowerCase(); + dispatchMap.set(cmdName, overrides[key] as (...args: any[]) => any); + } + } + const rpc = new RpcApiType(); + rpc.setMockRpcClient({ + mockWshRpcCall(_client, command, data, _opts) { + const fn = dispatchMap.get(command); + if (fn) { + return fn(_client, data, _opts); + } + console.log("[mock rpc call]", command, data); + return Promise.resolve(null); + }, + async *mockWshRpcStream(_client, command, data, _opts) { + const fn = dispatchMap.get(command); + if (fn) { + yield* fn(_client, data, _opts); + return; + } + console.log("[mock rpc stream]", command, data); + yield null; + }, + }); + return rpc; +} + +export function makeMockWaveEnv(ids?: MockIds): WaveEnv { + return { + electron: previewElectronApi, + rpc: makeMockRpc(), + configAtoms: makeMockConfigAtoms(), + isDev: () => true, + atoms: makeMockGlobalAtoms(ids), + createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => { + console.log("[mock createBlock]", blockDef, { magnified, ephemeral }); + return Promise.resolve(crypto.randomUUID()); + }, + showContextMenu: (menu, e) => { + console.log("[mock showContextMenu]", menu, e); + }, + }; +} diff --git a/frontend/preview/preview-electron-api.ts b/frontend/preview/mock/preview-electron-api.ts similarity index 98% rename from frontend/preview/preview-electron-api.ts rename to frontend/preview/mock/preview-electron-api.ts index 807e9e156b..36c82f26da 100644 --- a/frontend/preview/preview-electron-api.ts +++ b/frontend/preview/mock/preview-electron-api.ts @@ -65,4 +65,4 @@ function installPreviewElectronApi() { (window as any).api = previewElectronApi; } -export { installPreviewElectronApi }; +export { installPreviewElectronApi, previewElectronApi }; diff --git a/frontend/preview/preview.tsx b/frontend/preview/preview.tsx index daa232a51b..a461f137dc 100644 --- a/frontend/preview/preview.tsx +++ b/frontend/preview/preview.tsx @@ -5,10 +5,12 @@ import Logo from "@/app/asset/logo.svg"; import { getAtoms, initGlobalAtoms } from "@/app/store/global-atoms"; import { GlobalModel } from "@/app/store/global-model"; import { globalStore } from "@/app/store/jotaiStore"; +import { WaveEnvContext } from "@/app/waveenv/waveenv"; import { loadFonts } from "@/util/fontutil"; -import React, { lazy, Suspense } from "react"; +import React, { lazy, Suspense, useRef } from "react"; import { createRoot } from "react-dom/client"; -import { installPreviewElectronApi } from "./preview-electron-api"; +import { makeMockWaveEnv } from "./mock/mockwaveenv"; +import { installPreviewElectronApi } from "./mock/preview-electron-api"; import "../app/app.scss"; @@ -86,6 +88,21 @@ function PreviewHeader({ previewName }: { previewName: string }) { ); } +function PreviewRoot() { + const waveEnvRef = useRef( + makeMockWaveEnv({ + tabId: PreviewTabId, + windowId: PreviewWindowId, + clientId: PreviewClientId, + }) + ); + return ( + + + + ); +} + function PreviewApp() { const params = new URLSearchParams(window.location.search); const previewName = params.get("preview"); @@ -139,7 +156,7 @@ function initPreview() { GlobalModel.getInstance().initialize(initOpts); loadFonts(); const root = createRoot(document.getElementById("main")!); - root.render(); + root.render(); } initPreview(); diff --git a/frontend/preview/previews/widgets.preview.tsx b/frontend/preview/previews/widgets.preview.tsx new file mode 100644 index 0000000000..c81afb1bc4 --- /dev/null +++ b/frontend/preview/previews/widgets.preview.tsx @@ -0,0 +1,177 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { useWaveEnv, WaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; +import { Widgets } from "@/app/workspace/widgets"; +import { atom, useAtom } from "jotai"; +import { useRef } from "react"; +import { makeMockRpc } from "../mock/mockwaveenv"; + +const workspaceAtom = atom(null as Workspace); +const resizableHeightAtom = atom(250); + +function makeMockApp(name: string, icon: string, iconcolor: string): AppInfo { + return { + appid: `local/${name.toLowerCase().replace(/\s+/g, "-")}`, + modtime: 0, + manifest: { appmeta: { title: name, shortdesc: "", icon, iconcolor }, configschema: {}, dataschema: {}, secrets: {} }, + }; +} + +const mockApps: AppInfo[] = [ + makeMockApp("Weather", "cloud-sun", "#60a5fa"), + makeMockApp("Stocks", "chart-line", "#34d399"), + makeMockApp("Notes", "note-sticky", "#fbbf24"), + makeMockApp("Pomodoro", "clock", "#f87171"), + makeMockApp("GitHub PRs", "code-pull-request", "#a78bfa"), + makeMockApp("Server Monitor", "server", "#4ade80"), +]; + +const mockWidgets: { [key: string]: WidgetConfigType } = { + "defwidget@term": { + icon: "terminal", + color: "#4ade80", + label: "Terminal", + description: "Open a terminal", + "display:order": 0, + blockdef: { meta: { view: "term", controller: "shell" } }, + }, + "defwidget@editor": { + icon: "code", + color: "#60a5fa", + label: "Editor", + description: "Open a code editor", + "display:order": 1, + blockdef: { meta: { view: "codeeditor" } }, + }, + "defwidget@web": { + icon: "globe", + color: "#f472b6", + label: "Web", + description: "Open a web browser", + "display:order": 2, + blockdef: { meta: { view: "web", url: "https://waveterm.dev" } }, + }, + "defwidget@ai": { + icon: "sparkles", + color: "#a78bfa", + label: "AI", + description: "Open Wave AI", + "display:order": 3, + blockdef: { meta: { view: "waveai" } }, + }, + "defwidget@files": { + icon: "folder", + color: "#fbbf24", + label: "Files", + description: "Open file browser", + "display:order": 4, + blockdef: { meta: { view: "preview", connection: "local" } }, + }, + "defwidget@sysinfo": { + icon: "chart-line", + color: "#34d399", + label: "Sysinfo", + description: "Open system info", + "display:order": 5, + blockdef: { meta: { view: "sysinfo" } }, + }, +}; + +const fullConfigAtom = atom({ settings: {}, widgets: mockWidgets } as unknown as FullConfigType); + +function makeWidgetsEnv(baseEnv: WaveEnv, isDev: boolean, hasCustomAIPresets: boolean, apps?: AppInfo[]) { + return { + ...baseEnv, + rpc: makeMockRpc({ ListAllAppsCommand: () => Promise.resolve(apps ?? []) }), + isDev: () => isDev, + atoms: { + ...baseEnv.atoms, + fullConfigAtom, + workspace: workspaceAtom, + hasCustomAIPresetsAtom: atom(hasCustomAIPresets), + }, + }; +} + +function WidgetsScenario({ + label, + isDev = false, + hasCustomAIPresets = true, + height, + apps, +}: { + label: string; + isDev?: boolean; + hasCustomAIPresets?: boolean; + height?: number; + apps?: AppInfo[]; +}) { + const baseEnv = useWaveEnv(); + const envRef = useRef(makeWidgetsEnv(baseEnv, isDev, hasCustomAIPresets, apps)); + + return ( +
+
{label}
+ +
+
+
+
+ +
+ +
+ ); +} + +function WidgetsResizable() { + const [height, setHeight] = useAtom(resizableHeightAtom); + const baseEnv = useWaveEnv(); + const envRef = useRef(makeWidgetsEnv(baseEnv, true, true, mockApps)); + + return ( +
+
+ compact/supercompact — resizable (dev mode, height: {height}px) + setHeight(Number(e.target.value))} + className="cursor-pointer" + /> +
+ +
+
+
+
+ +
+ +
+ ); +} + +export function WidgetsPreview() { + return ( +
+
+ + + + +
+ +
+ ); +} + diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index d72c4fcd23..f990019ecd 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -470,7 +470,7 @@ func generateWshClientApiMethod_ResponseStream(methodDecl *wshrpc.WshRpcMethodDe } else { sb.WriteString(fmt.Sprintf(" %s(client: WshClient, %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, methodSigDataParams, genRespType)) } - sb.WriteString(fmt.Sprintf(" if (mockClient) return mockClient.mockWshRpcStream(client, %q, %s, opts);\n", methodDecl.Command, dataName)) + sb.WriteString(fmt.Sprintf(" if (this.mockClient) return this.mockClient.mockWshRpcStream(client, %q, %s, opts);\n", methodDecl.Command, dataName)) sb.WriteString(fmt.Sprintf(" return client.wshRpcStream(%q, %s, opts);\n", methodDecl.Command, dataName)) sb.WriteString(" }\n") return sb.String() @@ -490,7 +490,7 @@ func generateWshClientApiMethod_Call(methodDecl *wshrpc.WshRpcMethodDecl, tsType } else { sb.WriteString(fmt.Sprintf(" %s(client: WshClient, %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, methodSigDataParams, rtnType)) } - sb.WriteString(fmt.Sprintf(" if (mockClient) return mockClient.mockWshRpcCall(client, %q, %s, opts);\n", methodDecl.Command, dataName)) + sb.WriteString(fmt.Sprintf(" if (this.mockClient) return this.mockClient.mockWshRpcCall(client, %q, %s, opts);\n", methodDecl.Command, dataName)) sb.WriteString(fmt.Sprintf(" return client.wshRpcCall(%q, %s, opts);\n", methodDecl.Command, dataName)) sb.WriteString(" }\n") return sb.String() From 39b68fbdf6aa10afece68235680a14618a7790a0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:04:33 -0700 Subject: [PATCH 11/18] Remove invalid `forwardRef` from preview directory table row (#3018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave was emitting React’s `forwardRef render functions accept exactly two parameters` warning on startup with no useful stack trace. The warning came from the preview directory row component being wrapped in `React.forwardRef` even though it neither accepted nor used a forwarded ref. - **Root cause** - `frontend/app/view/preview/preview-directory.tsx` defined `TableRow` with `React.forwardRef(...)`, but the render function was effectively a plain props-only component. - **Change** - Removed the unnecessary `forwardRef` wrapper from `TableRow`. - Kept the component behavior unchanged; it still uses its internal drag ref wiring for DnD. - **Impact** - Eliminates the startup warning. - Aligns the component definition with its actual usage: callers render `TableRow` as a normal component and do not pass refs. ```tsx // before const TableRow = React.forwardRef(function ({ row, idx, ...props }: TableRowProps) { return
...
; }); // after function TableRow({ row, idx, ...props }: TableRowProps) { return
...
; } ``` --- ✨ Let Copilot coding agent [set things up for you](https://github.com/wavetermdev/waveterm/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/app/view/preview/preview-directory.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/view/preview/preview-directory.tsx b/frontend/app/view/preview/preview-directory.tsx index acbf06e3bc..797f1f78b4 100644 --- a/frontend/app/view/preview/preview-directory.tsx +++ b/frontend/app/view/preview/preview-directory.tsx @@ -493,7 +493,7 @@ type TableRowProps = { handleFileContextMenu: (e: any, finfo: FileInfo) => Promise; }; -const TableRow = React.forwardRef(function ({ +function TableRow({ model, row, focusIndex, @@ -552,7 +552,7 @@ const TableRow = React.forwardRef(function ({ ))}
); -}); +} const MemoizedTableBody = React.memo( TableBody, From 1aee6e22bf4d18ed43e5a000f09920c430b261a2 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Mon, 9 Mar 2026 16:15:54 -0700 Subject: [PATCH 12/18] Expand WaveEnv to cover all the deps in sysinfo.tsx (#3019) --- frontend/app/block/block.tsx | 25 ++-- frontend/app/store/global.ts | 53 ++++--- frontend/app/store/wos.ts | 11 +- frontend/app/tab/tabbar.tsx | 22 +-- frontend/app/view/aifilediff/aifilediff.tsx | 6 +- frontend/app/view/helpview/helpview.tsx | 8 +- frontend/app/view/launcher/launcher.tsx | 8 +- frontend/app/view/preview/preview-model.tsx | 6 +- .../app/view/quicktipsview/quicktipsview.tsx | 6 +- frontend/app/view/sysinfo/sysinfo.tsx | 89 ++++++------ frontend/app/view/term/term-model.ts | 2 +- frontend/app/view/tsunami/tsunami.tsx | 18 ++- frontend/app/view/vdom/vdom-model.tsx | 6 +- frontend/app/view/waveai/waveai.tsx | 4 +- .../app/view/waveconfig/waveconfig-model.ts | 6 +- frontend/app/view/webview/webview.tsx | 6 +- frontend/app/waveenv/waveenv.ts | 10 +- frontend/app/waveenv/waveenvimpl.ts | 13 +- frontend/layout/lib/layoutAtom.ts | 2 +- frontend/layout/lib/layoutModel.ts | 5 +- frontend/preview/index.html | 1 + frontend/preview/mock/mockwaveenv.ts | 136 ++++++++++++++---- frontend/preview/preview.tsx | 16 ++- frontend/preview/previews/widgets.preview.tsx | 30 ++-- frontend/types/custom.d.ts | 12 +- 25 files changed, 300 insertions(+), 201 deletions(-) diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 19a8529b11..37453473c9 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { @@ -9,12 +9,15 @@ import { FullSubBlockProps, SubBlockProps, } from "@/app/block/blocktypes"; +import type { TabModel } from "@/app/store/tab-model"; +import { useTabModel } from "@/app/store/tab-model"; import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; import { LauncherViewModel } from "@/app/view/launcher/launcher"; import { PreviewModel } from "@/app/view/preview/preview-model"; import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; import { VDomModel } from "@/app/view/vdom/vdom-model"; +import { useWaveEnv, WaveEnv } from "@/app/waveenv/waveenv"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; import { useDebouncedNodeInnerRect } from "@/layout/index"; @@ -26,8 +29,6 @@ import { registerBlockComponentModel, unregisterBlockComponentModel, } from "@/store/global"; -import type { TabModel } from "@/app/store/tab-model"; -import { useTabModel } from "@/app/store/tab-model"; import { getWaveObjectAtom, makeORef, useWaveObjectValue } from "@/store/wos"; import { focusedBlockId, getElemAsStr } from "@/util/focusutil"; import { isBlank, useAtomValueSafe } from "@/util/util"; @@ -59,10 +60,16 @@ BlockRegistry.set("tsunami", TsunamiViewModel); BlockRegistry.set("aifilediff", AiFileDiffViewModel); BlockRegistry.set("waveconfig", WaveConfigViewModel); -function makeViewModel(blockId: string, blockView: string, nodeModel: BlockNodeModel, tabModel: TabModel): ViewModel { +function makeViewModel( + blockId: string, + blockView: string, + nodeModel: BlockNodeModel, + tabModel: TabModel, + waveEnv: WaveEnv +): ViewModel { const ctor = BlockRegistry.get(blockView); if (ctor != null) { - return new ctor(blockId, nodeModel, tabModel); + return new ctor({ blockId, nodeModel, tabModel, waveEnv }); } return makeDefaultViewModel(blockId, blockView); } @@ -86,7 +93,7 @@ function getViewElem( function makeDefaultViewModel(blockId: string, viewType: string): ViewModel { const blockDataAtom = getWaveObjectAtom(makeORef("block", blockId)); - let viewModel: ViewModel = { + const viewModel: ViewModel = { viewType: viewType, viewIcon: atom((get) => { const blockData = get(blockDataAtom); @@ -308,11 +315,12 @@ const Block = memo((props: BlockProps) => { counterInc("render-Block"); counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8)); const tabModel = useTabModel(); + const waveEnv = useWaveEnv(); const [blockData, loading] = useWaveObjectValue(makeORef("block", props.nodeModel.blockId)); const bcm = getBlockComponentModel(props.nodeModel.blockId); let viewModel = bcm?.viewModel; if (viewModel == null || viewModel.viewType != blockData?.meta?.view) { - viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view, props.nodeModel, tabModel); + viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view, props.nodeModel, tabModel, waveEnv); registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); } useEffect(() => { @@ -334,11 +342,12 @@ const SubBlock = memo((props: SubBlockProps) => { counterInc("render-Block"); counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8)); const tabModel = useTabModel(); + const waveEnv = useWaveEnv(); const [blockData, loading] = useWaveObjectValue(makeORef("block", props.nodeModel.blockId)); const bcm = getBlockComponentModel(props.nodeModel.blockId); let viewModel = bcm?.viewModel; if (viewModel == null || viewModel.viewType != blockData?.meta?.view) { - viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view, props.nodeModel, tabModel); + viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view, props.nodeModel, tabModel, waveEnv); registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); } useEffect(() => { diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 88b679fb57..2ae7cb47c6 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -132,10 +132,6 @@ function getBlockMetaKeyAtom(blockId: string, key: T): return metaAtom; } -function useBlockMetaKeyAtom(blockId: string, key: T): MetaType[T] { - return useAtomValue(getBlockMetaKeyAtom(blockId, key)); -} - function getOrefMetaKeyAtom(oref: string, key: T): Atom { const orefCache = getSingleOrefAtomCache(oref); const metaAtomName = "#meta-" + key; @@ -614,33 +610,34 @@ function subscribeToConnEvents() { }); } +function makeDefaultConnStatus(conn: string): ConnStatus { + if (isLocalConnName(conn)) { + return { + connection: conn, + connected: true, + error: null, + status: "connected", + hasconnected: true, + activeconnnum: 0, + wshenabled: false, + }; + } + return { + connection: conn, + connected: false, + error: null, + status: "disconnected", + hasconnected: false, + activeconnnum: 0, + wshenabled: false, + }; +} + function getConnStatusAtom(conn: string): PrimitiveAtom { const connStatusMap = globalStore.get(ConnStatusMapAtom); let rtn = connStatusMap.get(conn); if (rtn == null) { - if (isLocalConnName(conn)) { - const connStatus: ConnStatus = { - connection: conn, - connected: true, - error: null, - status: "connected", - hasconnected: true, - activeconnnum: 0, - wshenabled: false, - }; - rtn = atom(connStatus); - } else { - const connStatus: ConnStatus = { - connection: conn, - connected: false, - error: null, - status: "disconnected", - hasconnected: false, - activeconnnum: 0, - wshenabled: false, - }; - rtn = atom(connStatus); - } + rtn = atom(makeDefaultConnStatus(conn)); const newConnStatusMap = new Map(connStatusMap); newConnStatusMap.set(conn, rtn); globalStore.set(ConnStatusMapAtom, newConnStatusMap); @@ -692,6 +689,7 @@ export { initGlobalWaveEventSubs, isDev, loadConnStatus, + makeDefaultConnStatus, openLink, readAtom, recordTEvent, @@ -706,7 +704,6 @@ export { useBlockAtom, useBlockCache, useBlockDataLoaded, - useBlockMetaKeyAtom, useOrefMetaKeyAtom, useOverrideConfigAtom, useSettingsKeyAtom, diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts index 1d3bdeabdb..f2395e12d0 100644 --- a/frontend/app/store/wos.ts +++ b/frontend/app/store/wos.ts @@ -3,8 +3,8 @@ // WaveObjectStore -import { waveEventSubscribeSingle } from "@/app/store/wps"; import { isPreviewWindow } from "@/app/store/windowtype"; +import { waveEventSubscribeSingle } from "@/app/store/wps"; import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { fireAndForget } from "@/util/util"; @@ -218,14 +218,9 @@ function loadAndPinWaveObject(oref: string): Promise { return wov.pendingPromise; } -function getWaveObjectAtom(oref: string): WritableWaveObjectAtom { +function getWaveObjectAtom(oref: string): Atom { const wov = getWaveObjectValue(oref); - return atom( - (get) => get(wov.dataAtom).value, - (_get, set, value: T) => { - setObjectValue(value, set, true); - } - ); + return atom((get) => get(wov.dataAtom).value); } function getWaveObjectLoadingAtom(oref: string): Atom { diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index c28809a22e..31711c354c 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Button } from "@/app/element/button"; @@ -149,24 +149,6 @@ function strArrayIsEqual(a: string[], b: string[]) { return true; } -function setIsEqual(a: Set | null, b: Set | null): boolean { - if (a == null && b == null) { - return true; - } - if (a == null || b == null) { - return false; - } - if (a.size !== b.size) { - return false; - } - for (const item of a) { - if (!b.has(item)) { - return false; - } - } - return true; -} - const TabBar = memo(({ workspace }: TabBarProps) => { const [tabIds, setTabIds] = useState([]); const [dragStartPositions, setDragStartPositions] = useState([]); @@ -506,7 +488,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { [] ); - const handleMouseUp = (event: MouseEvent) => { + const handleMouseUp = (_event: MouseEvent) => { const { tabIndex, dragged } = draggingTabDataRef.current; // Update the final position of the dragged tab diff --git a/frontend/app/view/aifilediff/aifilediff.tsx b/frontend/app/view/aifilediff/aifilediff.tsx index dfd85f2917..3b853a6eb6 100644 --- a/frontend/app/view/aifilediff/aifilediff.tsx +++ b/frontend/app/view/aifilediff/aifilediff.tsx @@ -1,13 +1,13 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { BlockNodeModel } from "@/app/block/blocktypes"; import type { TabModel } from "@/app/store/tab-model"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { base64ToString } from "@/util/util"; import { DiffViewer } from "@/app/view/codeeditor/diffviewer"; import { globalStore, WOS } from "@/store/global"; +import { base64ToString } from "@/util/util"; import * as jotai from "jotai"; import { useEffect } from "react"; @@ -30,7 +30,7 @@ export class AiFileDiffViewModel implements ViewModel { viewName: jotai.Atom; viewText: jotai.Atom; - constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { + constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; diff --git a/frontend/app/view/helpview/helpview.tsx b/frontend/app/view/helpview/helpview.tsx index 7b9f675bf4..02f4db48b0 100644 --- a/frontend/app/view/helpview/helpview.tsx +++ b/frontend/app/view/helpview/helpview.tsx @@ -1,8 +1,6 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BlockNodeModel } from "@/app/block/blocktypes"; -import type { TabModel } from "@/app/store/tab-model"; import { globalStore, WOS } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; @@ -16,8 +14,8 @@ class HelpViewModel extends WebViewModel { return HelpView; } - constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { - super(blockId, nodeModel, tabModel); + constructor(initOpts: ViewModelInitType) { + super(initOpts); this.viewText = atom((get) => { // force a dependency on meta.url so we re-render the buttons when the url changes void (get(this.blockAtom)?.meta?.url || get(this.homepageUrl)); diff --git a/frontend/app/view/launcher/launcher.tsx b/frontend/app/view/launcher/launcher.tsx index f71f272aa4..7b5a935f19 100644 --- a/frontend/app/view/launcher/launcher.tsx +++ b/frontend/app/view/launcher/launcher.tsx @@ -1,10 +1,10 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import type { BlockNodeModel } from "@/app/block/blocktypes"; -import type { TabModel } from "@/app/store/tab-model"; import logoUrl from "@/app/asset/logo.svg?url"; +import type { BlockNodeModel } from "@/app/block/blocktypes"; import { atoms, globalStore, replaceBlock } from "@/app/store/global"; +import type { TabModel } from "@/app/store/tab-model"; import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; import { isBlank, makeIconClass } from "@/util/util"; import clsx from "clsx"; @@ -35,7 +35,7 @@ export class LauncherViewModel implements ViewModel { containerSize = atom({ width: 0, height: 0 }); gridLayout: GridLayoutType = null; - constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { + constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index ca85bba96e..2bfa643031 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -1,9 +1,9 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; -import type { TabModel } from "@/app/store/tab-model"; import { ContextMenuModel } from "@/app/store/contextmenu"; +import type { TabModel } from "@/app/store/tab-model"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { getConnStatusAtom, getOverrideConfigAtom, getSettingsKeyAtom, globalStore, refocusNode } from "@/store/global"; @@ -168,7 +168,7 @@ export class PreviewModel implements ViewModel { directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; - constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { + constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.viewType = "preview"; this.blockId = blockId; this.nodeModel = nodeModel; diff --git a/frontend/app/view/quicktipsview/quicktipsview.tsx b/frontend/app/view/quicktipsview/quicktipsview.tsx index ec79e4e3f7..c018fdca2a 100644 --- a/frontend/app/view/quicktipsview/quicktipsview.tsx +++ b/frontend/app/view/quicktipsview/quicktipsview.tsx @@ -1,10 +1,10 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { BlockNodeModel } from "@/app/block/blocktypes"; -import type { TabModel } from "@/app/store/tab-model"; import { QuickTips } from "@/app/element/quicktips"; import { globalStore } from "@/app/store/global"; +import type { TabModel } from "@/app/store/tab-model"; import { Atom, atom, PrimitiveAtom } from "jotai"; class QuickTipsViewModel implements ViewModel { @@ -15,7 +15,7 @@ class QuickTipsViewModel implements ViewModel { showTocAtom: PrimitiveAtom; endIconButtons: Atom; - constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { + constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; diff --git a/frontend/app/view/sysinfo/sysinfo.tsx b/frontend/app/view/sysinfo/sysinfo.tsx index 2869f8ac1a..dca9d6d09f 100644 --- a/frontend/app/view/sysinfo/sysinfo.tsx +++ b/frontend/app/view/sysinfo/sysinfo.tsx @@ -1,9 +1,8 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import type { BlockNodeModel } from "@/app/block/blocktypes"; -import type { TabModel } from "@/app/store/tab-model"; -import { getConnStatusAtom, globalStore, WOS } from "@/store/global"; +import { globalStore } from "@/app/store/jotaiStore"; +import { makeORef } from "@/app/store/wos"; import * as util from "@/util/util"; import * as Plot from "@observablehq/plot"; import clsx from "clsx"; @@ -14,11 +13,22 @@ import * as React from "react"; import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions"; import { waveEventSubscribeSingle } from "@/app/store/wps"; -import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { atoms } from "@/store/global"; +import type { BlockMetaKeyAtomFnType, WaveEnv } from "@/app/waveenv/waveenv"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; +export type SysinfoEnv = { + rpc: { + EventReadHistoryCommand: WaveEnv["rpc"]["EventReadHistoryCommand"]; + SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; + }; + atoms: { + fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + }; + getConnStatusAtom: WaveEnv["getConnStatusAtom"]; + getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"graph:numpoints" | "sysinfo:type" | "connection" | "count">; +}; + const DefaultNumPoints = 120; type DataItem = { @@ -49,13 +59,13 @@ function defaultMemMeta(name: string, maxY: string): TimeSeriesMeta { } const PlotTypes: object = { - CPU: function (dataItem: DataItem): Array { + CPU: function (_dataItem: DataItem): Array { return ["cpu"]; }, - Mem: function (dataItem: DataItem): Array { + Mem: function (_dataItem: DataItem): Array { return ["mem:used"]; }, - "CPU + Mem": function (dataItem: DataItem): Array { + "CPU + Mem": function (_dataItem: DataItem): Array { return ["cpu", "mem:used"]; }, "All CPU": function (dataItem: DataItem): Array { @@ -94,9 +104,6 @@ function convertWaveEventToDataItem(event: Extract; termMode: jotai.Atom; htmlElemFocusRef: React.RefObject; blockId: string; @@ -117,13 +124,12 @@ class SysinfoViewModel implements ViewModel { plotMetaAtom: jotai.PrimitiveAtom>; endIconButtons: jotai.Atom; plotTypeSelectedAtom: jotai.Atom; + env: SysinfoEnv; - constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { - this.nodeModel = nodeModel; - this.tabModel = tabModel; + constructor({ blockId, waveEnv }: ViewModelInitType) { this.viewType = "sysinfo"; this.blockId = blockId; - this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.env = waveEnv; this.addInitialDataAtom = jotai.atom(null, (get, set, points) => { const targetLen = get(this.numPoints) + 1; try { @@ -169,7 +175,7 @@ class SysinfoViewModel implements ViewModel { }); this.addContinuousDataAtom = jotai.atom(null, (get, set, newPoint) => { const targetLen = get(this.numPoints) + 1; - let data = get(this.dataAtom); + const data = get(this.dataAtom); try { const latestItemTs = newPoint?.ts ?? 0; const cutoffTs = latestItemTs - 1000 * targetLen; @@ -185,15 +191,14 @@ class SysinfoViewModel implements ViewModel { this.filterOutNowsh = jotai.atom(true); this.loadingAtom = jotai.atom(true); this.numPoints = jotai.atom((get) => { - const blockData = get(this.blockAtom); - const metaNumPoints = blockData?.meta?.["graph:numpoints"]; + const metaNumPoints = get(this.env.getBlockMetaKeyAtom(blockId, "graph:numpoints")); if (metaNumPoints == null || metaNumPoints <= 0) { return DefaultNumPoints; } return metaNumPoints; }); this.metrics = jotai.atom((get) => { - let plotType = get(this.plotTypeSelectedAtom); + const plotType = get(this.plotTypeSelectedAtom); const plotData = get(this.dataAtom); try { const metrics = PlotTypes[plotType](plotData[plotData.length - 1]); @@ -206,8 +211,7 @@ class SysinfoViewModel implements ViewModel { } }); this.plotTypeSelectedAtom = jotai.atom((get) => { - const blockData = get(this.blockAtom); - const plotType = blockData?.meta?.["sysinfo:type"]; + const plotType = get(this.env.getBlockMetaKeyAtom(blockId, "sysinfo:type")); if (plotType == null || typeof plotType != "string") { return "CPU"; } @@ -219,17 +223,15 @@ class SysinfoViewModel implements ViewModel { this.viewName = jotai.atom((get) => { return get(this.plotTypeSelectedAtom); }); - this.incrementCount = jotai.atom(null, async (get, set) => { - const meta = get(this.blockAtom).meta; - const count = meta.count ?? 0; - await RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + this.incrementCount = jotai.atom(null, async (get, _set) => { + const count = get(this.env.getBlockMetaKeyAtom(blockId, "count")) ?? 0; + await this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { count: count + 1 }, }); }); this.connection = jotai.atom((get) => { - const blockData = get(this.blockAtom); - const connValue = blockData?.meta?.connection; + const connValue = get(this.env.getBlockMetaKeyAtom(blockId, "connection")); if (util.isBlank(connValue)) { return "local"; } @@ -238,9 +240,8 @@ class SysinfoViewModel implements ViewModel { this.dataAtom = jotai.atom([]); this.loadInitialData(); this.connStatus = jotai.atom((get) => { - const blockData = get(this.blockAtom); - const connName = blockData?.meta?.connection; - const connAtom = getConnStatusAtom(connName); + const connName = get(this.env.getBlockMetaKeyAtom(blockId, "connection")); + const connAtom = this.env.getConnStatusAtom(connName); return get(connAtom); }); } @@ -254,7 +255,7 @@ class SysinfoViewModel implements ViewModel { try { const numPoints = globalStore.get(this.numPoints); const connName = globalStore.get(this.connection); - const initialData = await RpcApi.EventReadHistoryCommand(TabRpcClient, { + const initialData = await this.env.rpc.EventReadHistoryCommand(TabRpcClient, { event: "sysinfo", scope: connName, maxitems: numPoints, @@ -262,7 +263,7 @@ class SysinfoViewModel implements ViewModel { if (initialData == null) { return; } - const newData = this.getDefaultData(); + this.getDefaultData(); const initialDataItems: DataItem[] = initialData.map(convertWaveEventToDataItem); // splice the initial data into the default data (replacing the newest points) //newData.splice(newData.length - initialDataItems.length, initialDataItems.length, ...initialDataItems); @@ -275,7 +276,7 @@ class SysinfoViewModel implements ViewModel { } getSettingsMenuItems(): ContextMenuItem[] { - const fullConfig = globalStore.get(atoms.fullConfigAtom); + const fullConfig = globalStore.get(this.env.atoms.fullConfigAtom); const termThemes = fullConfig?.termthemes ?? {}; const termThemeKeys = Object.keys(termThemes); const plotData = globalStore.get(this.dataAtom); @@ -296,8 +297,8 @@ class SysinfoViewModel implements ViewModel { type: "radio", checked: currentlySelected == plotType, click: async () => { - await RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + await this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { "graph:metrics": dataTypes, "sysinfo:type": plotType }, }); }, @@ -326,7 +327,7 @@ class SysinfoViewModel implements ViewModel { } } -const plotColors = ["#58C142", "#FFC107", "#FF5722", "#2196F3", "#9C27B0", "#00BCD4", "#FFEB3B", "#795548"]; +const _plotColors = ["#58C142", "#FFC107", "#FF5722", "#2196F3", "#9C27B0", "#00BCD4", "#FFEB3B", "#795548"]; type SysinfoViewProps = { blockId: string; @@ -418,7 +419,7 @@ function SingleLinePlot({ const plotHeight = domRect?.height ?? 0; const plotWidth = domRect?.width ?? 0; const marks: Plot.Markish[] = []; - let decimalPlaces = yvalMeta?.decimalPlaces ?? 0; + const decimalPlaces = yvalMeta?.decimalPlaces ?? 0; let color = yvalMeta?.color; if (!color) { color = defaultColor; @@ -492,10 +493,10 @@ function SingleLinePlot({ Plot.pointerX({ x: "ts", y: yval, fill: color, r: 3, stroke: "var(--main-text-color)", strokeWidth: 1 }) ) ); - let maxY = resolveDomainBound(yvalMeta?.maxy, plotData[plotData.length - 1]) ?? 100; - let minY = resolveDomainBound(yvalMeta?.miny, plotData[plotData.length - 1]) ?? 0; - let maxX = plotData[plotData.length - 1].ts; - let minX = maxX - targetLen * 1000; + const maxY = resolveDomainBound(yvalMeta?.maxy, plotData[plotData.length - 1]) ?? 100; + const minY = resolveDomainBound(yvalMeta?.miny, plotData[plotData.length - 1]) ?? 0; + const maxX = plotData[plotData.length - 1].ts; + const minX = maxX - targetLen * 1000; const plot = Plot.plot({ axis: !sparkline, x: { @@ -549,7 +550,7 @@ const SysinfoViewInner = React.memo(({ model }: SysinfoViewProps) => { > {plotData && plotData.length > 0 && - yvals.map((yval, idx) => { + yvals.map((yval, _idx) => { return ( ; searchAtoms?: SearchAtoms; - constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { + constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.viewType = "term"; this.blockId = blockId; this.tabModel = tabModel; diff --git a/frontend/app/view/tsunami/tsunami.tsx b/frontend/app/view/tsunami/tsunami.tsx index 14ca08574f..c23cd76035 100644 --- a/frontend/app/view/tsunami/tsunami.tsx +++ b/frontend/app/view/tsunami/tsunami.tsx @@ -1,9 +1,7 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BlockNodeModel } from "@/app/block/blocktypes"; import { getApi, globalStore, WOS } from "@/app/store/global"; -import type { TabModel } from "@/app/store/tab-model"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; @@ -21,8 +19,8 @@ class TsunamiViewModel extends WebViewModel { viewIcon: jotai.Atom; viewName: jotai.Atom; - constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { - super(blockId, nodeModel, tabModel); + constructor(initOpts: ViewModelInitType) { + super(initOpts); this.viewType = "tsunami"; this.isRestarting = jotai.atom(false); @@ -30,16 +28,16 @@ class TsunamiViewModel extends WebViewModel { this.hideNav = jotai.atom(true); // Set custom partition for tsunami WebView isolation - this.partitionOverride = jotai.atom(`tsunami:${blockId}`); + this.partitionOverride = jotai.atom(`tsunami:${this.blockId}`); this.shellProcFullStatus = jotai.atom(null) as jotai.PrimitiveAtom; - const initialShellProcStatus = services.BlockService.GetControllerStatus(blockId); + const initialShellProcStatus = services.BlockService.GetControllerStatus(this.blockId); initialShellProcStatus.then((rts) => { this.updateShellProcStatus(rts); }); this.shellProcStatusUnsubFn = waveEventSubscribeSingle({ eventType: "controllerstatus", - scope: WOS.makeORef("block", blockId), + scope: WOS.makeORef("block", this.blockId), handler: (event) => { this.updateShellProcStatus(event.data); }, @@ -61,7 +59,7 @@ class TsunamiViewModel extends WebViewModel { return meta?.title || "WaveApp"; }); const initialRTInfo = RpcApi.GetRTInfoCommand(TabRpcClient, { - oref: WOS.makeORef("block", blockId), + oref: WOS.makeORef("block", this.blockId), }); initialRTInfo.then((rtInfo) => { if (rtInfo && rtInfo["tsunami:appmeta"]) { @@ -70,7 +68,7 @@ class TsunamiViewModel extends WebViewModel { }); this.appMetaUnsubFn = waveEventSubscribeSingle({ eventType: "tsunami:updatemeta", - scope: WOS.makeORef("block", blockId), + scope: WOS.makeORef("block", this.blockId), handler: (event) => { globalStore.set(this.appMeta, event.data); }, diff --git a/frontend/app/view/vdom/vdom-model.tsx b/frontend/app/view/vdom/vdom-model.tsx index 4751ed1d24..77b01495e2 100644 --- a/frontend/app/view/vdom/vdom-model.tsx +++ b/frontend/app/view/vdom/vdom-model.tsx @@ -1,9 +1,9 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; -import type { TabModel } from "@/app/store/tab-model"; import { getBlockMetaKeyAtom, globalStore, WOS } from "@/app/store/global"; +import type { TabModel } from "@/app/store/tab-model"; import { makeORef } from "@/app/store/wos"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; @@ -140,7 +140,7 @@ export class VDomModel { hasBackendWork: boolean = false; noPadding: jotai.PrimitiveAtom; - constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { + constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.viewType = "vdom"; this.blockId = blockId; this.nodeModel = nodeModel; diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index 6d2dd8fc85..630f047265 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; @@ -92,7 +92,7 @@ export class WaveAiModel implements ViewModel { cancel: boolean; aiWshClient: AiWshClient; - constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { + constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; diff --git a/frontend/app/view/waveconfig/waveconfig-model.ts b/frontend/app/view/waveconfig/waveconfig-model.ts index cd7d4e45e3..f41a39eccd 100644 --- a/frontend/app/view/waveconfig/waveconfig-model.ts +++ b/frontend/app/view/waveconfig/waveconfig-model.ts @@ -1,10 +1,10 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; -import type { TabModel } from "@/app/store/tab-model"; import { getApi, getBlockMetaKeyAtom, WOS } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; +import type { TabModel } from "@/app/store/tab-model"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { SecretsContent } from "@/app/view/waveconfig/secretscontent"; @@ -170,7 +170,7 @@ export class WaveConfigViewModel implements ViewModel { storageBackendErrorAtom: PrimitiveAtom; secretValueRef: HTMLTextAreaElement | null = null; - constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { + constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index 7fa1671b26..df50221764 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -1,12 +1,12 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; -import type { TabModel } from "@/app/store/tab-model"; import { Search, useSearch } from "@/app/element/search"; import { createBlock, getApi, getBlockMetaKeyAtom, getSettingsKeyAtom, openLink } from "@/app/store/global"; import { getSimpleControlShiftAtom } from "@/app/store/keymodel"; import { ObjectService } from "@/app/store/services"; +import type { TabModel } from "@/app/store/tab-model"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { @@ -72,7 +72,7 @@ export class WebViewModel implements ViewModel { partitionOverride: PrimitiveAtom | null; userAgentType: Atom; - constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { + constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.nodeModel = nodeModel; this.tabModel = tabModel; this.viewType = "web"; diff --git a/frontend/app/waveenv/waveenv.ts b/frontend/app/waveenv/waveenv.ts index e6fa73a36e..365bf74be9 100644 --- a/frontend/app/waveenv/waveenv.ts +++ b/frontend/app/waveenv/waveenv.ts @@ -2,11 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 import { RpcApiType } from "@/app/store/wshclientapi"; -import { Atom } from "jotai"; +import { Atom, PrimitiveAtom } from "jotai"; import React from "react"; type ConfigAtoms = { [K in keyof SettingsType]: Atom }; +export type BlockMetaKeyAtomFnType = ( + blockId: string, + key: T +) => Atom; + // default implementation for production is in ./waveenvimpl.ts export type WaveEnv = { electron: ElectronApi; @@ -16,6 +21,9 @@ export type WaveEnv = { atoms: GlobalAtomsType; createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => Promise; showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => void; + getConnStatusAtom: (conn: string) => PrimitiveAtom; + getWaveObjectAtom: (oref: string) => Atom; + getBlockMetaKeyAtom: BlockMetaKeyAtomFnType; }; export const WaveEnvContext = React.createContext(null); diff --git a/frontend/app/waveenv/waveenvimpl.ts b/frontend/app/waveenv/waveenvimpl.ts index 85c7bab1f8..50aa4ef7ea 100644 --- a/frontend/app/waveenv/waveenvimpl.ts +++ b/frontend/app/waveenv/waveenvimpl.ts @@ -1,8 +1,16 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { atoms, createBlock, getSettingsKeyAtom, isDev } from "@/app/store/global"; import { ContextMenuModel } from "@/app/store/contextmenu"; +import { + atoms, + createBlock, + getBlockMetaKeyAtom, + getConnStatusAtom, + getSettingsKeyAtom, + isDev, + WOS, +} from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; @@ -23,5 +31,8 @@ export function makeWaveEnvImpl(): WaveEnv { showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => { ContextMenuModel.getInstance().showContextMenu(menu, e); }, + getConnStatusAtom, + getWaveObjectAtom: WOS.getWaveObjectAtom, + getBlockMetaKeyAtom, }; } diff --git a/frontend/layout/lib/layoutAtom.ts b/frontend/layout/lib/layoutAtom.ts index 62890446da..e34cd3e200 100644 --- a/frontend/layout/lib/layoutAtom.ts +++ b/frontend/layout/lib/layoutAtom.ts @@ -4,7 +4,7 @@ import { WOS } from "@/app/store/global"; import { Atom, Getter } from "jotai"; -export function getLayoutStateAtomFromTab(tabAtom: Atom, get: Getter): WritableWaveObjectAtom { +export function getLayoutStateAtomFromTab(tabAtom: Atom, get: Getter): Atom { const tabData = get(tabAtom); if (!tabData) return; const layoutStateOref = WOS.makeORef("layout", tabData.layoutstate); diff --git a/frontend/layout/lib/layoutModel.ts b/frontend/layout/lib/layoutModel.ts index 14b0476c90..0741df9bcb 100644 --- a/frontend/layout/lib/layoutModel.ts +++ b/frontend/layout/lib/layoutModel.ts @@ -4,6 +4,7 @@ import { FocusManager } from "@/app/store/focusManager"; import { getSettingsKeyAtom } from "@/app/store/global"; import { BlockService } from "@/app/store/services"; +import * as WOS from "@/app/store/wos"; import { atomWithThrottle, boundNumber, fireAndForget } from "@/util/util"; import { Atom, atom, Getter, PrimitiveAtom, Setter } from "jotai"; import { splitAtom } from "jotai/utils"; @@ -88,7 +89,7 @@ export class LayoutModel { /** * WaveObject atom for persistence */ - private waveObjectAtom: WritableWaveObjectAtom; + private waveObjectAtom: Atom; /** * Debounce timer for persistence */ @@ -587,7 +588,7 @@ export class LayoutModel { waveObj.leaforder = this.treeState.leafOrder; waveObj.pendingbackendactions = this.treeState.pendingBackendActions; - this.setter(this.waveObjectAtom, waveObj); + WOS.setObjectValue(waveObj, this.setter, true); this.persistDebounceTimer = null; }, 100); } diff --git a/frontend/preview/index.html b/frontend/preview/index.html index cf9e957e37..4c5e76af8a 100644 --- a/frontend/preview/index.html +++ b/frontend/preview/index.html @@ -5,6 +5,7 @@ Wave Preview Server + diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 0718433798..9ed61e2b58 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -1,12 +1,55 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { getSettingsKeyAtom } from "@/app/store/global"; +import { getSettingsKeyAtom, makeDefaultConnStatus } from "@/app/store/global"; import { RpcApiType } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; -import { atom } from "jotai"; +import { Atom, atom, PrimitiveAtom } from "jotai"; import { previewElectronApi } from "./preview-electron-api"; +type RpcOverrides = { + [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => any; +}; + +export type MockEnv = { + isDev?: boolean; + config?: Partial; + rpc?: RpcOverrides; + atoms?: Partial; + electron?: Partial; + createBlock?: WaveEnv["createBlock"]; + showContextMenu?: WaveEnv["showContextMenu"]; + connStatus?: Record; + mockWaveObjs?: Record; +}; + +export type MockWaveEnv = WaveEnv & { mockEnv: MockEnv }; + +function mergeRecords(base: Record, overrides: Record): Record { + if (base == null && overrides == null) { + return undefined; + } + return { ...(base ?? {}), ...(overrides ?? {}) }; +} + +export function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv { + return { + isDev: overrides.isDev ?? base.isDev, + config: mergeRecords(base.config, overrides.config), + rpc: mergeRecords(base.rpc as any, overrides.rpc as any) as RpcOverrides, + atoms: + overrides.atoms != null || base.atoms != null ? { ...base.atoms, ...overrides.atoms } : undefined, + electron: + overrides.electron != null || base.electron != null + ? { ...(base.electron ?? {}), ...(overrides.electron ?? {}) } + : undefined, + createBlock: overrides.createBlock ?? base.createBlock, + showContextMenu: overrides.showContextMenu ?? base.showContextMenu, + connStatus: mergeRecords(base.connStatus, overrides.connStatus), + mockWaveObjs: mergeRecords(base.mockWaveObjs, overrides.mockWaveObjs), + }; +} + function makeMockConfigAtoms(overrides?: Partial): WaveEnv["configAtoms"] { const overrideAtoms = new Map>(); if (overrides) { @@ -24,23 +67,17 @@ function makeMockConfigAtoms(overrides?: Partial): WaveEnv["config }); } -type MockIds = { - tabId?: string; - windowId?: string; - clientId?: string; -}; - -function makeMockGlobalAtoms(ids?: MockIds): GlobalAtomsType { - return { +function makeMockGlobalAtoms(atomOverrides?: Partial): GlobalAtomsType { + const defaults: GlobalAtomsType = { builderId: atom(""), builderAppId: atom("") as any, - uiContext: atom({ windowid: ids?.windowId ?? "", activetabid: ids?.tabId ?? "" } as UIContext), + uiContext: atom({} as UIContext), workspace: atom(null as Workspace), fullConfigAtom: atom(null) as any, waveaiModeConfigAtom: atom({}) as any, settingsAtom: atom({} as SettingsType), hasCustomAIPresetsAtom: atom(false), - staticTabId: atom(ids?.tabId ?? ""), + staticTabId: atom(""), isFullScreen: atom(false) as any, zoomFactorAtom: atom(1.0) as any, controlShiftDelayAtom: atom(false) as any, @@ -52,12 +89,12 @@ function makeMockGlobalAtoms(ids?: MockIds): GlobalAtomsType { reinitVersion: atom(0) as any, waveAIRateLimitInfoAtom: atom(null) as any, }; + if (!atomOverrides) { + return defaults; + } + return { ...defaults, ...atomOverrides }; } -type RpcOverrides = { - [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => any; -}; - export function makeMockRpc(overrides?: RpcOverrides): RpcApiType { const dispatchMap = new Map any>(); if (overrides) { @@ -89,19 +126,62 @@ export function makeMockRpc(overrides?: RpcOverrides): RpcApiType { return rpc; } -export function makeMockWaveEnv(ids?: MockIds): WaveEnv { - return { - electron: previewElectronApi, - rpc: makeMockRpc(), - configAtoms: makeMockConfigAtoms(), - isDev: () => true, - atoms: makeMockGlobalAtoms(ids), - createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => { - console.log("[mock createBlock]", blockDef, { magnified, ephemeral }); - return Promise.resolve(crypto.randomUUID()); +export function applyMockEnvOverrides(env: WaveEnv, newOverrides: MockEnv): MockWaveEnv { + const existing = (env as MockWaveEnv).mockEnv; + const merged = existing != null ? mergeMockEnv(existing, newOverrides) : newOverrides; + return makeMockWaveEnv(merged); +} + +export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { + const overrides: MockEnv = mockEnv ?? {}; + const connStatusAtomCache = new Map>(); + const waveObjectAtomCache = new Map>(); + const blockMetaKeyAtomCache = new Map>(); + const env = { + mockEnv: overrides, + electron: overrides.electron ? { ...previewElectronApi, ...overrides.electron } : previewElectronApi, + rpc: makeMockRpc(overrides.rpc), + configAtoms: makeMockConfigAtoms(overrides.config), + atoms: makeMockGlobalAtoms(overrides.atoms), + isDev: () => overrides.isDev ?? true, + createBlock: + overrides.createBlock ?? + ((blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => { + console.log("[mock createBlock]", blockDef, { magnified, ephemeral }); + return Promise.resolve(crypto.randomUUID()); + }), + showContextMenu: + overrides.showContextMenu ?? + ((menu, e) => { + console.log("[mock showContextMenu]", menu, e); + }), + getConnStatusAtom: (conn: string) => { + if (!connStatusAtomCache.has(conn)) { + const connStatus = overrides.connStatus?.[conn] ?? makeDefaultConnStatus(conn); + connStatusAtomCache.set(conn, atom(connStatus)); + } + return connStatusAtomCache.get(conn); }, - showContextMenu: (menu, e) => { - console.log("[mock showContextMenu]", menu, e); + getWaveObjectAtom: (oref: string) => { + if (!waveObjectAtomCache.has(oref)) { + const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T; + waveObjectAtomCache.set(oref, atom(obj)); + } + return waveObjectAtomCache.get(oref) as PrimitiveAtom; + }, + getBlockMetaKeyAtom: (blockId: string, key: T) => { + const cacheKey = blockId + "#meta-" + key; + if (!blockMetaKeyAtomCache.has(cacheKey)) { + const metaAtom = atom((get) => { + const blockORef = "block:" + blockId; + const blockAtom = env.getWaveObjectAtom(blockORef); + const blockData = get(blockAtom); + return blockData?.meta?.[key] as MetaType[T]; + }); + blockMetaKeyAtomCache.set(cacheKey, metaAtom); + } + return blockMetaKeyAtomCache.get(cacheKey) as Atom; }, }; + return env; } diff --git a/frontend/preview/preview.tsx b/frontend/preview/preview.tsx index a461f137dc..9cb03c0014 100644 --- a/frontend/preview/preview.tsx +++ b/frontend/preview/preview.tsx @@ -7,6 +7,7 @@ import { GlobalModel } from "@/app/store/global-model"; import { globalStore } from "@/app/store/jotaiStore"; import { WaveEnvContext } from "@/app/waveenv/waveenv"; import { loadFonts } from "@/util/fontutil"; +import { atom, Provider } from "jotai"; import React, { lazy, Suspense, useRef } from "react"; import { createRoot } from "react-dom/client"; import { makeMockWaveEnv } from "./mock/mockwaveenv"; @@ -91,15 +92,18 @@ function PreviewHeader({ previewName }: { previewName: string }) { function PreviewRoot() { const waveEnvRef = useRef( makeMockWaveEnv({ - tabId: PreviewTabId, - windowId: PreviewWindowId, - clientId: PreviewClientId, + atoms: { + uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext), + staticTabId: atom(PreviewTabId), + }, }) ); return ( - - - + + + + + ); } diff --git a/frontend/preview/previews/widgets.preview.tsx b/frontend/preview/previews/widgets.preview.tsx index c81afb1bc4..b6970da902 100644 --- a/frontend/preview/previews/widgets.preview.tsx +++ b/frontend/preview/previews/widgets.preview.tsx @@ -5,7 +5,7 @@ import { useWaveEnv, WaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; import { Widgets } from "@/app/workspace/widgets"; import { atom, useAtom } from "jotai"; import { useRef } from "react"; -import { makeMockRpc } from "../mock/mockwaveenv"; +import { applyMockEnvOverrides } from "../mock/mockwaveenv"; const workspaceAtom = atom(null as Workspace); const resizableHeightAtom = atom(250); @@ -14,7 +14,12 @@ function makeMockApp(name: string, icon: string, iconcolor: string): AppInfo { return { appid: `local/${name.toLowerCase().replace(/\s+/g, "-")}`, modtime: 0, - manifest: { appmeta: { title: name, shortdesc: "", icon, iconcolor }, configschema: {}, dataschema: {}, secrets: {} }, + manifest: { + appmeta: { title: name, shortdesc: "", icon, iconcolor }, + configschema: {}, + dataschema: {}, + secrets: {}, + }, }; } @@ -81,17 +86,15 @@ const mockWidgets: { [key: string]: WidgetConfigType } = { const fullConfigAtom = atom({ settings: {}, widgets: mockWidgets } as unknown as FullConfigType); function makeWidgetsEnv(baseEnv: WaveEnv, isDev: boolean, hasCustomAIPresets: boolean, apps?: AppInfo[]) { - return { - ...baseEnv, - rpc: makeMockRpc({ ListAllAppsCommand: () => Promise.resolve(apps ?? []) }), - isDev: () => isDev, + return applyMockEnvOverrides(baseEnv, { + isDev, + rpc: { ListAllAppsCommand: () => Promise.resolve(apps ?? []) }, atoms: { - ...baseEnv.atoms, fullConfigAtom, workspace: workspaceAtom, hasCustomAIPresetsAtom: atom(hasCustomAIPresets), }, - }; + }); } function WidgetsScenario({ @@ -108,7 +111,10 @@ function WidgetsScenario({ apps?: AppInfo[]; }) { const baseEnv = useWaveEnv(); - const envRef = useRef(makeWidgetsEnv(baseEnv, isDev, hasCustomAIPresets, apps)); + const envRef = useRef(null); + if (envRef.current == null) { + envRef.current = makeWidgetsEnv(baseEnv, isDev, hasCustomAIPresets, apps); + } return (
@@ -131,7 +137,10 @@ function WidgetsScenario({ function WidgetsResizable() { const [height, setHeight] = useAtom(resizableHeightAtom); const baseEnv = useWaveEnv(); - const envRef = useRef(makeWidgetsEnv(baseEnv, true, true, mockApps)); + const envRef = useRef(null); + if (envRef.current == null) { + envRef.current = makeWidgetsEnv(baseEnv, true, true, mockApps); + } return (
@@ -174,4 +183,3 @@ export function WidgetsPreview() {
); } - diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 6fbe95a0ea..d0b5f0e3b0 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -4,6 +4,7 @@ import { type Placement } from "@floating-ui/react"; import type * as jotai from "jotai"; import type * as rxjs from "rxjs"; +import type { WaveEnv } from "@/app/waveenv/waveenv"; declare global { type GlobalAtomsType = { @@ -28,8 +29,6 @@ declare global { waveAIRateLimitInfoAtom: jotai.PrimitiveAtom; }; - type WritableWaveObjectAtom = jotai.WritableAtom; - type ThrottledValueAtom = jotai.WritableAtom], void>; type AtomWithThrottle = { @@ -291,7 +290,14 @@ declare global { declare type ViewComponent = React.FC; - type ViewModelClass = new (blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) => ViewModel; + type ViewModelInitType = { + blockId: string; + nodeModel: BlockNodeModel; + tabModel: TabModel; + waveEnv: WaveEnv; + }; + + type ViewModelClass = new (initOpts: ViewModelInitType) => ViewModel; interface ViewModel { // The type of view, used for identifying and rendering the appropriate component. From f5480cbfbc198bd614c4c969dd0d2e490ab61223 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Mon, 9 Mar 2026 17:04:25 -0700 Subject: [PATCH 13/18] mock out settings better (use default config) (#3022) --- frontend/preview/mock/defaultconfig.ts | 22 +++++++++++++++++++ frontend/preview/mock/mockwaveenv.ts | 30 ++++++++++++++++++-------- 2 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 frontend/preview/mock/defaultconfig.ts diff --git a/frontend/preview/mock/defaultconfig.ts b/frontend/preview/mock/defaultconfig.ts new file mode 100644 index 0000000000..0c2ac11b3a --- /dev/null +++ b/frontend/preview/mock/defaultconfig.ts @@ -0,0 +1,22 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import mimetypesJson from "../../../pkg/wconfig/defaultconfig/mimetypes.json"; +import presetsJson from "../../../pkg/wconfig/defaultconfig/presets.json"; +import settingsJson from "../../../pkg/wconfig/defaultconfig/settings.json"; +import termthemesJson from "../../../pkg/wconfig/defaultconfig/termthemes.json"; +import waveaiJson from "../../../pkg/wconfig/defaultconfig/waveai.json"; +import widgetsJson from "../../../pkg/wconfig/defaultconfig/widgets.json"; + +export const DefaultFullConfig: FullConfigType = { + settings: settingsJson as SettingsType, + mimetypes: mimetypesJson as unknown as { [key: string]: MimeTypeConfigType }, + defaultwidgets: widgetsJson as unknown as { [key: string]: WidgetConfigType }, + widgets: {}, + presets: presetsJson as unknown as { [key: string]: MetaType }, + termthemes: termthemesJson as unknown as { [key: string]: TermThemeType }, + connections: {}, + bookmarks: {}, + waveai: waveaiJson as unknown as { [key: string]: AIModeConfigType }, + configerrors: [], +}; diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 9ed61e2b58..913cbc1501 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -5,6 +5,7 @@ import { getSettingsKeyAtom, makeDefaultConnStatus } from "@/app/store/global"; import { RpcApiType } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; import { Atom, atom, PrimitiveAtom } from "jotai"; +import { DefaultFullConfig } from "./defaultconfig"; import { previewElectronApi } from "./preview-electron-api"; type RpcOverrides = { @@ -13,7 +14,7 @@ type RpcOverrides = { export type MockEnv = { isDev?: boolean; - config?: Partial; + settings?: Partial; rpc?: RpcOverrides; atoms?: Partial; electron?: Partial; @@ -35,10 +36,9 @@ function mergeRecords(base: Record, overrides: Record): export function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv { return { isDev: overrides.isDev ?? base.isDev, - config: mergeRecords(base.config, overrides.config), + settings: mergeRecords(base.settings, overrides.settings), rpc: mergeRecords(base.rpc as any, overrides.rpc as any) as RpcOverrides, - atoms: - overrides.atoms != null || base.atoms != null ? { ...base.atoms, ...overrides.atoms } : undefined, + atoms: overrides.atoms != null || base.atoms != null ? { ...base.atoms, ...overrides.atoms } : undefined, electron: overrides.electron != null || base.electron != null ? { ...(base.electron ?? {}), ...(overrides.electron ?? {}) } @@ -67,15 +67,27 @@ function makeMockConfigAtoms(overrides?: Partial): WaveEnv["config }); } -function makeMockGlobalAtoms(atomOverrides?: Partial): GlobalAtomsType { +function makeMockGlobalAtoms( + settingsOverrides?: Partial, + atomOverrides?: Partial +): GlobalAtomsType { + let fullConfig = DefaultFullConfig; + if (settingsOverrides) { + fullConfig = { + ...DefaultFullConfig, + settings: { ...DefaultFullConfig.settings, ...settingsOverrides }, + }; + } + const fullConfigAtom = atom(fullConfig) as PrimitiveAtom; + const settingsAtom = atom((get) => get(fullConfigAtom)?.settings ?? {}) as Atom; const defaults: GlobalAtomsType = { builderId: atom(""), builderAppId: atom("") as any, uiContext: atom({} as UIContext), workspace: atom(null as Workspace), - fullConfigAtom: atom(null) as any, + fullConfigAtom, waveaiModeConfigAtom: atom({}) as any, - settingsAtom: atom({} as SettingsType), + settingsAtom, hasCustomAIPresetsAtom: atom(false), staticTabId: atom(""), isFullScreen: atom(false) as any, @@ -141,8 +153,8 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { mockEnv: overrides, electron: overrides.electron ? { ...previewElectronApi, ...overrides.electron } : previewElectronApi, rpc: makeMockRpc(overrides.rpc), - configAtoms: makeMockConfigAtoms(overrides.config), - atoms: makeMockGlobalAtoms(overrides.atoms), + configAtoms: makeMockConfigAtoms(overrides.settings), + atoms: makeMockGlobalAtoms(overrides.settings, overrides.atoms), isDev: () => overrides.isDev ?? true, createBlock: overrides.createBlock ?? From e087a4cdcfbd2cd869211581c178458cffc7e674 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:34:54 -0700 Subject: [PATCH 14/18] Expose platform metadata on WaveEnv and preview mocks (#3021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WaveEnv did not surface platform information, so platform-aware behavior could not be added through the shared environment contract. This updates the contract, production implementation, and preview mock to carry platform state without wiring it into any consumers yet. - **WaveEnv contract** - Add `platform: NodeJS.Platform` - Add `isWindows()` and `isMacOS()` helpers - **Production implementation** - Populate `platform` from `PLATFORM` in `frontend/util/platformutil.ts` - Forward `isWindows` / `isMacOS` from the same utility into `makeWaveEnvImpl()` - **Preview mock** - Add optional `platform` override to `MockEnv` - Default mock platform to macOS - Expose `platform`, `isWindows()`, and `isMacOS()` on the mock env - Preserve platform overrides through `applyMockEnvOverrides()` Example: ```ts const env = useWaveEnv(); env.platform; // "darwin" | "win32" | ... env.isMacOS(); // boolean env.isWindows(); // boolean ``` --- ✨ Let Copilot coding agent [set things up for you](https://github.com/wavetermdev/waveterm/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> Co-authored-by: sawka --- frontend/app/waveenv/waveenv.ts | 3 +++ frontend/app/waveenv/waveenvimpl.ts | 4 ++++ frontend/preview/mock/mockwaveenv.ts | 13 ++++++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/app/waveenv/waveenv.ts b/frontend/app/waveenv/waveenv.ts index 365bf74be9..f643c24295 100644 --- a/frontend/app/waveenv/waveenv.ts +++ b/frontend/app/waveenv/waveenv.ts @@ -16,8 +16,11 @@ export type BlockMetaKeyAtomFnType export type WaveEnv = { electron: ElectronApi; rpc: RpcApiType; + platform: NodeJS.Platform; configAtoms: ConfigAtoms; isDev: () => boolean; + isWindows: () => boolean; + isMacOS: () => boolean; atoms: GlobalAtomsType; createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => Promise; showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => void; diff --git a/frontend/app/waveenv/waveenvimpl.ts b/frontend/app/waveenv/waveenvimpl.ts index 50aa4ef7ea..0f2461fcf6 100644 --- a/frontend/app/waveenv/waveenvimpl.ts +++ b/frontend/app/waveenv/waveenvimpl.ts @@ -13,6 +13,7 @@ import { } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; +import { isMacOS, isWindows, PLATFORM } from "@/util/platformutil"; const configAtoms = new Proxy({} as WaveEnv["configAtoms"], { get(_target: WaveEnv["configAtoms"], key: K) { @@ -24,8 +25,11 @@ export function makeWaveEnvImpl(): WaveEnv { return { electron: (window as any).api, rpc: RpcApi, + platform: PLATFORM, configAtoms, isDev, + isWindows, + isMacOS, atoms, createBlock, showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => { diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 913cbc1501..43b77c761a 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -4,6 +4,7 @@ import { getSettingsKeyAtom, makeDefaultConnStatus } from "@/app/store/global"; import { RpcApiType } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; +import { PlatformMacOS, PlatformWindows } from "@/util/platformutil"; import { Atom, atom, PrimitiveAtom } from "jotai"; import { DefaultFullConfig } from "./defaultconfig"; import { previewElectronApi } from "./preview-electron-api"; @@ -14,6 +15,7 @@ type RpcOverrides = { export type MockEnv = { isDev?: boolean; + platform?: NodeJS.Platform; settings?: Partial; rpc?: RpcOverrides; atoms?: Partial; @@ -36,6 +38,7 @@ function mergeRecords(base: Record, overrides: Record): export function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv { return { isDev: overrides.isDev ?? base.isDev, + platform: overrides.platform ?? base.platform, settings: mergeRecords(base.settings, overrides.settings), rpc: mergeRecords(base.rpc as any, overrides.rpc as any) as RpcOverrides, atoms: overrides.atoms != null || base.atoms != null ? { ...base.atoms, ...overrides.atoms } : undefined, @@ -146,16 +149,24 @@ export function applyMockEnvOverrides(env: WaveEnv, newOverrides: MockEnv): Mock export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { const overrides: MockEnv = mockEnv ?? {}; + const platform = overrides.platform ?? PlatformMacOS; const connStatusAtomCache = new Map>(); const waveObjectAtomCache = new Map>(); const blockMetaKeyAtomCache = new Map>(); const env = { mockEnv: overrides, - electron: overrides.electron ? { ...previewElectronApi, ...overrides.electron } : previewElectronApi, + electron: { + ...previewElectronApi, + getPlatform: () => platform, + ...overrides.electron, + }, rpc: makeMockRpc(overrides.rpc), + platform, configAtoms: makeMockConfigAtoms(overrides.settings), atoms: makeMockGlobalAtoms(overrides.settings, overrides.atoms), isDev: () => overrides.isDev ?? true, + isWindows: () => platform === PlatformWindows, + isMacOS: () => platform === PlatformMacOS, createBlock: overrides.createBlock ?? ((blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => { From 711997079477329d31072a81f3261aa3d7c0ce88 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Mon, 9 Mar 2026 20:35:22 -0700 Subject: [PATCH 15/18] flip bell-indicator to true by default (#3023) --- pkg/wconfig/defaultconfig/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index 2de1974716..6668d2d050 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -29,7 +29,7 @@ "window:savelastwindow": true, "telemetry:enabled": true, "term:bellsound": false, - "term:bellindicator": false, + "term:bellindicator": true, "term:osc52": "always", "term:cursor": "block", "term:cursorblink": false, From 29f49dc10031b7267ca6ad6808ada10c10cc616c Mon Sep 17 00:00:00 2001 From: Shay12tg Date: Wed, 11 Mar 2026 00:55:21 +0200 Subject: [PATCH 16/18] fix: search bar clipboard and focus improvements (#3025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small fixes to the in-app search feature: - **Skip copy-on-select during search navigation** — when iterating through search results in the terminal, the clipboard kept getting overwritten with the matched text on every step. This was annoying on its own, but also polluted paste history managers. The root cause was that xterm.js updates the terminal selection programmatically to highlight each match, which triggered the copy-on-select handler. The fix skips the clipboard write whenever an element inside `.search-container` is the active element. - **Refocus search input on repeated Cmd+F** — pressing Cmd+F while the search bar was already open was a no-op (setting the `isOpen` atom to `true` again has no effect). The fix detects the already-open case and directly calls `focus()` + `select()` on the input, so the user can immediately type a new query. --- frontend/app/element/search.tsx | 15 +++++++++++++++ frontend/app/store/keymodel.ts | 10 +++++++++- frontend/app/view/term/termwrap.ts | 6 ++++++ frontend/types/custom.d.ts | 3 ++- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/frontend/app/element/search.tsx b/frontend/app/element/search.tsx index 031bcd97d8..e09e8a1078 100644 --- a/frontend/app/element/search.tsx +++ b/frontend/app/element/search.tsx @@ -26,6 +26,7 @@ const SearchComponent = ({ caseSensitive: caseSensitiveAtom, wholeWord: wholeWordAtom, isOpen: isOpenAtom, + focusInput: focusInputAtom, anchorRef, offsetX = 10, offsetY = 10, @@ -37,6 +38,8 @@ const SearchComponent = ({ const [search, setSearch] = useAtom(searchAtom); const [index, setIndex] = useAtom(indexAtom); const [numResults, setNumResults] = useAtom(numResultsAtom); + const [focusInputCounter, setFocusInputCounter] = useAtom(focusInputAtom); + const inputRef = useRef(null); const handleOpenChange = useCallback((open: boolean) => { setIsOpen(open); @@ -47,6 +50,7 @@ const SearchComponent = ({ setSearch(""); setIndex(0); setNumResults(0); + setFocusInputCounter(0); } }, [isOpen]); @@ -56,6 +60,15 @@ const SearchComponent = ({ onSearch?.(search); }, [search]); + // When activateSearch fires while already open, it increments focusInputCounter + // to signal this specific instance to grab focus (avoids global DOM queries). + useEffect(() => { + if (focusInputCounter > 0 && isOpen) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [focusInputCounter]); + const middleware: Middleware[] = []; const offsetCallback = useCallback( ({ rects }) => { @@ -146,6 +159,7 @@ const SearchComponent = ({
0) { navigator.clipboard.writeText(selectedText); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index d0b5f0e3b0..8ee176e151 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -1,10 +1,10 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import type { WaveEnv } from "@/app/waveenv/waveenv"; import { type Placement } from "@floating-ui/react"; import type * as jotai from "jotai"; import type * as rxjs from "rxjs"; -import type { WaveEnv } from "@/app/waveenv/waveenv"; declare global { type GlobalAtomsType = { @@ -276,6 +276,7 @@ declare global { resultsIndex: PrimitiveAtom; resultsCount: PrimitiveAtom; isOpen: PrimitiveAtom; + focusInput: PrimitiveAtom; regex?: PrimitiveAtom; caseSensitive?: PrimitiveAtom; wholeWord?: PrimitiveAtom; From cb8166e6f09a6228f9c1c9b6f2179bf3bf1ef49e Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 10 Mar 2026 16:00:11 -0700 Subject: [PATCH 17/18] Expanding WaveEnv to cover all of frontend/app/block components (#3024) * lots of updates to WaveEnv to make it cover more functionality * Create BlockEnv as a narrowing of WaveEnv that covers all of frontend/app/block functionality * Fixed a lot of dependencies in the block components that caused unnecessarily re-renders * Added atom caching to WOS --- .kilocode/skills/waveenv/SKILL.md | 115 ++++++++++++++++++ frontend/app/aipanel/aipanel.tsx | 10 +- frontend/app/block/block.tsx | 100 ++++++++------- frontend/app/block/blockenv.ts | 48 ++++++++ frontend/app/block/blockframe-header.tsx | 41 ++++--- frontend/app/block/blockframe.tsx | 59 +++++---- frontend/app/block/blockutil.tsx | 20 ++- frontend/app/block/connectionbutton.tsx | 12 +- frontend/app/block/connstatusoverlay.tsx | 35 +++--- .../app/block/durable-session-flyover.tsx | 22 ++-- frontend/app/store/global.ts | 9 +- frontend/app/store/tab-model.ts | 37 ++++-- frontend/app/store/wos.ts | 65 +++++----- frontend/app/waveenv/waveenv.ts | 47 ++++++- frontend/app/waveenv/waveenvimpl.ts | 19 +-- frontend/app/workspace/widgets.tsx | 6 +- frontend/preview/mock/mockwaveenv.ts | 110 ++++++++++++----- 17 files changed, 521 insertions(+), 234 deletions(-) create mode 100644 .kilocode/skills/waveenv/SKILL.md create mode 100644 frontend/app/block/blockenv.ts diff --git a/.kilocode/skills/waveenv/SKILL.md b/.kilocode/skills/waveenv/SKILL.md new file mode 100644 index 0000000000..a78490f449 --- /dev/null +++ b/.kilocode/skills/waveenv/SKILL.md @@ -0,0 +1,115 @@ +--- +name: waveenv +description: Guide for creating WaveEnv narrowings in Wave Terminal. Use when writing a named subset type of WaveEnv for a component tree, documenting environmental dependencies, or enabling mock environments for preview/test server usage. +--- + +# WaveEnv Narrowing Skill + +## Purpose + +A WaveEnv narrowing creates a _named subset type_ of `WaveEnv` that: + +1. Documents exactly which parts of the environment a component tree actually uses. +2. Forms a type contract so callers and tests know what to provide. +3. Enables mocking in the preview/test server — you only need to implement what's listed. + +## When To Create One + +Create a narrowing whenever you are writing a component (or group of components) that you want to test in the preview server, or when you want to make the environmental dependencies of a component tree explicit. + +## Core Principle: Only Include What You Use + +**Only list the fields, methods, atoms, and keys that the component tree actually accesses.** If you don't call `wos`, don't include `wos`. If you only call one RPC command, only list that one command. The narrowing is a precise dependency declaration — not a copy of `WaveEnv`. + +## File Location + +- **Separate file** (preferred for shared/complex envs): name it `env.ts` next to the component, e.g. `frontend/app/block/blockenv.ts`. +- **Inline** (acceptable for small, single-file components): export the type directly from the component file, e.g. `WidgetsEnv` in `frontend/app/workspace/widgets.tsx`. + +## Imports Required + +```ts +import { + BlockMetaKeyAtomFnType, // only if you use getBlockMetaKeyAtom + ConnConfigKeyAtomFnType, // only if you use getConnConfigKeyAtom + SettingsKeyAtomFnType, // only if you use getSettingsKeyAtom + WaveEnv, + WaveEnvSubset, +} from "@/app/waveenv/waveenv"; +``` + +## The Shape + +```ts +export type MyEnv = WaveEnvSubset<{ + // --- Simple WaveEnv properties --- + // Copy the type verbatim from WaveEnv with WaveEnv["key"] syntax. + isDev: WaveEnv["isDev"]; + createBlock: WaveEnv["createBlock"]; + showContextMenu: WaveEnv["showContextMenu"]; + platform: WaveEnv["platform"]; + + // --- electron: list only the methods you call --- + electron: { + openExternal: WaveEnv["electron"]["openExternal"]; + }; + + // --- rpc: list only the commands you call --- + rpc: { + ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; + ConnEnsureCommand: WaveEnv["rpc"]["ConnEnsureCommand"]; + }; + + // --- atoms: list only the atoms you read --- + atoms: { + modalOpen: WaveEnv["atoms"]["modalOpen"]; + fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + }; + + // --- wos: always take the whole thing, no sub-typing needed --- + wos: WaveEnv["wos"]; + + // --- key-parameterized atom factories: enumerate the keys you use --- + getSettingsKeyAtom: SettingsKeyAtomFnType<"app:focusfollowscursor" | "window:magnifiedblockopacity">; + getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"view" | "frame:title" | "connection">; + getConnConfigKeyAtom: ConnConfigKeyAtomFnType<"conn:wshenabled">; + + // --- other atom helpers: copy verbatim --- + getConnStatusAtom: WaveEnv["getConnStatusAtom"]; + getLocalHostDisplayNameAtom: WaveEnv["getLocalHostDisplayNameAtom"]; +}>; +``` + +### Rules for Each Section + +| Section | Pattern | Notes | +| -------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| `electron` | `electron: { method: WaveEnv["electron"]["method"]; }` | List every method called; omit the rest. | +| `rpc` | `rpc: { Cmd: WaveEnv["rpc"]["Cmd"]; }` | List every RPC command called; omit the rest. | +| `atoms` | `atoms: { atom: WaveEnv["atoms"]["atom"]; }` | List every atom read; omit the rest. | +| `wos` | `wos: WaveEnv["wos"]` | Take the whole `wos` object (no sub-typing needed), but **only add it if `wos` is actually used**. | +| `getSettingsKeyAtom` | `SettingsKeyAtomFnType<"key1" \| "key2">` | Union all settings keys accessed. | +| `getBlockMetaKeyAtom` | `BlockMetaKeyAtomFnType<"key1" \| "key2">` | Union all block meta keys accessed. | +| `getConnConfigKeyAtom` | `ConnConfigKeyAtomFnType<"key1">` | Union all conn config keys accessed. | +| All other `WaveEnv` fields | `WaveEnv["fieldName"]` | Copy type verbatim. | + +## Using the Narrowed Type in Components + +```ts +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { MyEnv } from "./myenv"; + +const MyComponent = memo(() => { + const env = useWaveEnv(); + // TypeScript now enforces you only access what's in MyEnv. + const val = useAtomValue(env.getSettingsKeyAtom("app:focusfollowscursor")); + ... +}); +``` + +The generic parameter on `useWaveEnv()` casts the context to your narrowed type. The real production `WaveEnv` satisfies every narrowing; mock envs only need to implement the listed subset. + +## Real Examples + +- `BlockEnv` in `frontend/app/block/blockenv.ts` — complex narrowing with all section types, in a separate file. +- `WidgetsEnv` in `frontend/app/workspace/widgets.tsx` — smaller narrowing defined inline in the component file. diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index dded015f85..112a4cc79e 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { handleWaveAIContextMenu } from "@/app/aipanel/aipanel-contextmenu"; @@ -6,8 +6,8 @@ import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils"; import { ErrorBoundary } from "@/app/element/errorboundary"; import { atoms, getSettingsKeyAtom } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; +import { useTabModelMaybe } from "@/app/store/tab-model"; import { isBuilderWindow } from "@/app/store/windowtype"; -import { maybeUseTabModel } from "@/app/store/tab-model"; import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; import { isMacOS, isWindows } from "@/util/platformutil"; import { cn } from "@/util/util"; @@ -257,7 +257,7 @@ const AIPanelComponentInner = memo(() => { const focusFollowsCursorMode = jotai.useAtomValue(getSettingsKeyAtom("app:focusfollowscursor")) ?? "off"; const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; const isPanelVisible = jotai.useAtomValue(model.getPanelVisibleAtom()); - const tabModel = maybeUseTabModel(); + const tabModel = useTabModelMaybe(); const defaultMode = jotai.useAtomValue(getSettingsKeyAtom("waveai:defaultmode")) ?? "waveai@balanced"; const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs); @@ -268,7 +268,7 @@ const AIPanelComponentInner = memo(() => { const { messages, sendMessage, status, setMessages, error, stop } = useChat({ transport: new DefaultChatTransport({ api: model.getUseChatEndpointUrl(), - prepareSendMessagesRequest: (opts) => { + prepareSendMessagesRequest: (_opts) => { const msg = model.getAndClearMessage(); const body: any = { msg, @@ -503,7 +503,7 @@ const AIPanelComponentInner = memo(() => { }, [drop]); const handleFocusCapture = useCallback( - (event: React.FocusEvent) => { + (_event: React.FocusEvent) => { // console.log("Wave AI focus capture", getElemAsStr(event.target)); model.requestWaveAIFocus(); }, diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 37453473c9..126f208813 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -22,14 +22,8 @@ import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; import { useDebouncedNodeInnerRect } from "@/layout/index"; import { counterInc } from "@/store/counters"; -import { - atoms, - getBlockComponentModel, - getSettingsKeyAtom, - registerBlockComponentModel, - unregisterBlockComponentModel, -} from "@/store/global"; -import { getWaveObjectAtom, makeORef, useWaveObjectValue } from "@/store/wos"; +import { getBlockComponentModel, registerBlockComponentModel, unregisterBlockComponentModel } from "@/store/global"; +import { makeORef } from "@/store/wos"; import { focusedBlockId, getElemAsStr } from "@/util/focusutil"; import { isBlank, useAtomValueSafe } from "@/util/util"; import { HelpViewModel } from "@/view/helpview/helpview"; @@ -42,6 +36,7 @@ import { memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRe import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview"; import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model"; import "./block.scss"; +import { BlockEnv } from "./blockenv"; import { BlockFrame } from "./blockframe"; import { blockViewToIcon, blockViewToName } from "./blockutil"; @@ -71,7 +66,7 @@ function makeViewModel( if (ctor != null) { return new ctor({ blockId, nodeModel, tabModel, waveEnv }); } - return makeDefaultViewModel(blockId, blockView); + return makeDefaultViewModel(blockView); } function getViewElem( @@ -91,18 +86,11 @@ function getViewElem( return ; } -function makeDefaultViewModel(blockId: string, viewType: string): ViewModel { - const blockDataAtom = getWaveObjectAtom(makeORef("block", blockId)); +function makeDefaultViewModel(viewType: string): ViewModel { const viewModel: ViewModel = { viewType: viewType, - viewIcon: atom((get) => { - const blockData = get(blockDataAtom); - return blockViewToIcon(blockData?.meta?.view); - }), - viewName: atom((get) => { - const blockData = get(blockDataAtom); - return blockViewToName(blockData?.meta?.view); - }), + viewIcon: atom(blockViewToIcon(viewType)), + viewName: atom(blockViewToName(viewType)), preIconButton: atom(null), endIconButtons: atom(null), viewComponent: null, @@ -111,8 +99,9 @@ function makeDefaultViewModel(blockId: string, viewType: string): ViewModel { } const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => { - const [blockData] = useWaveObjectValue(makeORef("block", nodeModel.blockId)); - if (!blockData) { + const waveEnv = useWaveEnv(); + const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", nodeModel.blockId))); + if (blockIsNull) { return null; } return ( @@ -127,15 +116,17 @@ const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => { }); const BlockSubBlock = memo(({ nodeModel, viewModel }: FullSubBlockProps) => { - const [blockData] = useWaveObjectValue(makeORef("block", nodeModel.blockId)); + const waveEnv = useWaveEnv(); + const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", nodeModel.blockId))); + const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")) ?? ""; const blockRef = useRef(null); const contentRef = useRef(null); const viewElem = useMemo( - () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockData?.meta?.view, viewModel), - [nodeModel.blockId, blockData?.meta?.view, viewModel] + () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockView, viewModel), + [nodeModel.blockId, blockView, viewModel] ); const noPadding = useAtomValueSafe(viewModel.noPadding); - if (!blockData) { + if (blockIsNull) { return null; } return ( @@ -149,18 +140,19 @@ const BlockSubBlock = memo(({ nodeModel, viewModel }: FullSubBlockProps) => { const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { counterInc("render-BlockFull"); + const waveEnv = useWaveEnv(); const focusElemRef = useRef(null); const blockRef = useRef(null); const contentRef = useRef(null); const [blockClicked, setBlockClicked] = useState(false); - const [blockData] = useWaveObjectValue(makeORef("block", nodeModel.blockId)); + const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")) ?? ""; const isFocused = useAtomValue(nodeModel.isFocused); const disablePointerEvents = useAtomValue(nodeModel.disablePointerEvents); const isResizing = useAtomValue(nodeModel.isResizing); const isMagnified = useAtomValue(nodeModel.isMagnified); const anyMagnified = useAtomValue(nodeModel.anyMagnified); - const modalOpen = useAtomValue(atoms.modalOpen); - const focusFollowsCursorMode = useAtomValue(getSettingsKeyAtom("app:focusfollowscursor")) ?? "off"; + const modalOpen = useAtomValue(waveEnv.atoms.modalOpen); + const focusFollowsCursorMode = useAtomValue(waveEnv.getSettingsKeyAtom("app:focusfollowscursor")) ?? "off"; const innerRect = useDebouncedNodeInnerRect(nodeModel); const noPadding = useAtomValueSafe(viewModel.noPadding); @@ -213,8 +205,8 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { }, [innerRect, disablePointerEvents, blockContentOffset]); const viewElem = useMemo( - () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockData?.meta?.view, viewModel), - [nodeModel.blockId, blockData?.meta?.view, viewModel] + () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockView, viewModel), + [nodeModel.blockId, blockView, viewModel] ); const handleChildFocus = useCallback( @@ -240,7 +232,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { (event: React.PointerEvent) => { const focusFollowsCursorEnabled = focusFollowsCursorMode === "on" || - (focusFollowsCursorMode === "term" && blockData?.meta?.view === "term"); + (focusFollowsCursorMode === "term" && blockView === "term"); if (!focusFollowsCursorEnabled || event.pointerType === "touch" || event.buttons > 0) { return; } @@ -257,7 +249,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { }, [ focusFollowsCursorMode, - blockData?.meta?.view, + blockView, modalOpen, disablePointerEvents, isResizing, @@ -311,16 +303,16 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { ); }); -const Block = memo((props: BlockProps) => { +const BlockInner = memo((props: BlockProps & { viewType: string }) => { counterInc("render-Block"); counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8)); const tabModel = useTabModel(); const waveEnv = useWaveEnv(); - const [blockData, loading] = useWaveObjectValue(makeORef("block", props.nodeModel.blockId)); const bcm = getBlockComponentModel(props.nodeModel.blockId); let viewModel = bcm?.viewModel; - if (viewModel == null || viewModel.viewType != blockData?.meta?.view) { - viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view, props.nodeModel, tabModel, waveEnv); + if (viewModel == null) { + // viewModel gets the full waveEnv + viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv); registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); } useEffect(() => { @@ -329,25 +321,33 @@ const Block = memo((props: BlockProps) => { viewModel?.dispose?.(); }; }, []); - if (loading || isBlank(props.nodeModel.blockId) || blockData == null) { - return null; - } if (props.preview) { return ; } return ; }); +BlockInner.displayName = "BlockInner"; -const SubBlock = memo((props: SubBlockProps) => { +const Block = memo((props: BlockProps) => { + const waveEnv = useWaveEnv(); + const isNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", props.nodeModel.blockId))); + const viewType = useAtomValue(waveEnv.getBlockMetaKeyAtom(props.nodeModel.blockId, "view")) ?? ""; + if (isNull || isBlank(props.nodeModel.blockId)) { + return null; + } + return ; +}); + +const SubBlockInner = memo((props: SubBlockProps & { viewType: string }) => { counterInc("render-Block"); - counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8)); + counterInc("render-Block-" + props.nodeModel.blockId?.substring(0, 8)); const tabModel = useTabModel(); const waveEnv = useWaveEnv(); - const [blockData, loading] = useWaveObjectValue(makeORef("block", props.nodeModel.blockId)); const bcm = getBlockComponentModel(props.nodeModel.blockId); let viewModel = bcm?.viewModel; - if (viewModel == null || viewModel.viewType != blockData?.meta?.view) { - viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view, props.nodeModel, tabModel, waveEnv); + if (viewModel == null) { + // viewModel gets the full waveEnv + viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv); registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); } useEffect(() => { @@ -356,10 +356,18 @@ const SubBlock = memo((props: SubBlockProps) => { viewModel?.dispose?.(); }; }, []); - if (loading || isBlank(props.nodeModel.blockId) || blockData == null) { + return ; +}); +SubBlockInner.displayName = "SubBlockInner"; + +const SubBlock = memo((props: SubBlockProps) => { + const waveEnv = useWaveEnv(); + const isNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", props.nodeModel.blockId))); + const viewType = useAtomValue(waveEnv.getBlockMetaKeyAtom(props.nodeModel.blockId, "view")) ?? ""; + if (isNull || isBlank(props.nodeModel.blockId)) { return null; } - return ; + return ; }); export { Block, SubBlock }; diff --git a/frontend/app/block/blockenv.ts b/frontend/app/block/blockenv.ts new file mode 100644 index 0000000000..b2df51192d --- /dev/null +++ b/frontend/app/block/blockenv.ts @@ -0,0 +1,48 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + BlockMetaKeyAtomFnType, + ConnConfigKeyAtomFnType, + SettingsKeyAtomFnType, + WaveEnv, + WaveEnvSubset, +} from "@/app/waveenv/waveenv"; + +export type BlockEnv = WaveEnvSubset<{ + getSettingsKeyAtom: SettingsKeyAtomFnType< + | "app:focusfollowscursor" + | "app:showoverlayblocknums" + | "window:magnifiedblockblurprimarypx" + | "window:magnifiedblockopacity" + >; + atoms: { + modalOpen: WaveEnv["atoms"]["modalOpen"]; + controlShiftDelayAtom: WaveEnv["atoms"]["controlShiftDelayAtom"]; + }; + electron: { + openExternal: WaveEnv["electron"]["openExternal"]; + }; + rpc: { + ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; + ConnEnsureCommand: WaveEnv["rpc"]["ConnEnsureCommand"]; + ConnDisconnectCommand: WaveEnv["rpc"]["ConnDisconnectCommand"]; + ConnConnectCommand: WaveEnv["rpc"]["ConnConnectCommand"]; + SetConnectionsConfigCommand: WaveEnv["rpc"]["SetConnectionsConfigCommand"]; + DismissWshFailCommand: WaveEnv["rpc"]["DismissWshFailCommand"]; + }; + wos: WaveEnv["wos"]; + getConnStatusAtom: WaveEnv["getConnStatusAtom"]; + getLocalHostDisplayNameAtom: WaveEnv["getLocalHostDisplayNameAtom"]; + getConnConfigKeyAtom: ConnConfigKeyAtomFnType<"conn:wshenabled">; + getBlockMetaKeyAtom: BlockMetaKeyAtomFnType< + | "frame:text" + | "frame:activebordercolor" + | "frame:bordercolor" + | "view" + | "connection" + | "icon:color" + | "frame:title" + | "frame:icon" + >; +}>; diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index 420a6889c8..252f1f8845 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -12,11 +12,12 @@ import { ConnectionButton } from "@/app/block/connectionbutton"; import { DurableSessionFlyover } from "@/app/block/durable-session-flyover"; import { getBlockBadgeAtom } from "@/app/store/badge"; import { ContextMenuModel } from "@/app/store/contextmenu"; -import { recordTEvent, refocusNode, WOS } from "@/app/store/global"; +import { recordTEvent, refocusNode } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { uxCloseBlock } from "@/app/store/keymodel"; -import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { BlockEnv } from "./blockenv"; import { IconButton } from "@/element/iconbutton"; import { NodeModel } from "@/layout/index"; import * as util from "@/util/util"; @@ -34,7 +35,7 @@ function handleHeaderContextMenu( e.preventDefault(); e.stopPropagation(); const magnified = globalStore.get(nodeModel.isMagnified); - let menu: ContextMenuItem[] = [ + const menu: ContextMenuItem[] = [ { label: magnified ? "Un-Magnify Block" : "Magnify Block", click: () => { @@ -63,14 +64,17 @@ function handleHeaderContextMenu( type HeaderTextElemsProps = { viewModel: ViewModel; - blockData: Block; + blockId: string; preview: boolean; error?: Error; }; -const HeaderTextElems = React.memo(({ viewModel, blockData, preview, error }: HeaderTextElemsProps) => { +const HeaderTextElems = React.memo(({ viewModel, blockId, preview, error }: HeaderTextElemsProps) => { + const waveEnv = useWaveEnv(); + const frameTextAtom = waveEnv.getBlockMetaKeyAtom(blockId, "frame:text"); + const frameText = jotai.useAtomValue(frameTextAtom); let headerTextUnion = util.useAtomValueSafe(viewModel?.viewText); - headerTextUnion = blockData?.meta?.["frame:text"] ?? headerTextUnion; + headerTextUnion = frameText ?? headerTextUnion; const headerTextElems: React.ReactElement[] = []; if (typeof headerTextUnion === "string") { @@ -171,9 +175,13 @@ const BlockFrame_Header = ({ changeConnModalAtom, error, }: BlockFrameProps & { changeConnModalAtom: jotai.PrimitiveAtom; error?: Error }) => { - const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); - let viewName = util.useAtomValueSafe(viewModel?.viewName) ?? blockViewToName(blockData?.meta?.view); - let viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(blockData?.meta?.view); + const waveEnv = useWaveEnv(); + const metaView = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")); + const metaFrameTitle = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:title")); + const metaFrameIcon = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:icon")); + const metaConnection = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); + let viewName = util.useAtomValueSafe(viewModel?.viewName) ?? blockViewToName(metaView); + let viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(metaView); const preIconButton = util.useAtomValueSafe(viewModel?.preIconButton); const useTermHeader = util.useAtomValueSafe(viewModel?.useTermHeader); const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable); @@ -182,20 +190,21 @@ const BlockFrame_Header = ({ const magnified = jotai.useAtomValue(nodeModel.isMagnified); const prevMagifiedState = React.useRef(magnified); const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); + const iconColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "icon:color")); const dragHandleRef = preview ? null : nodeModel.dragHandleRef; - const isTerminalBlock = blockData?.meta?.view === "term"; - viewName = blockData?.meta?.["frame:title"] ?? viewName; - viewIconUnion = blockData?.meta?.["frame:icon"] ?? viewIconUnion; + const isTerminalBlock = metaView === "term"; + viewName = metaFrameTitle ?? viewName; + viewIconUnion = metaFrameIcon ?? viewIconUnion; React.useEffect(() => { if (magnified && !preview && !prevMagifiedState.current) { - RpcApi.ActivityCommand(TabRpcClient, { nummagnify: 1 }); + waveEnv.rpc.ActivityCommand(TabRpcClient, { nummagnify: 1 }); recordTEvent("action:magnify", { "block:view": viewName }); } prevMagifiedState.current = magnified; }, [magnified]); - const viewIconElem = getViewIconElem(viewIconUnion, blockData); + const viewIconElem = getViewIconElem(viewIconUnion, iconColor); return (
@@ -236,7 +245,7 @@ const BlockFrame_Header = ({
)} - +
); diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 1ed88fb574..0b4abb755b 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -6,30 +6,36 @@ import { BlockFrame_Header } from "@/app/block/blockframe-header"; import { blockViewToIcon, getViewIconElem } from "@/app/block/blockutil"; import { ConnStatusOverlay } from "@/app/block/connstatusoverlay"; import { ChangeConnectionBlockModal } from "@/app/modals/conntypeahead"; -import { atoms, getBlockComponentModel, getSettingsKeyAtom, globalStore, useBlockAtom, WOS } from "@/app/store/global"; +import { getBlockComponentModel, globalStore, useBlockAtom } from "@/app/store/global"; import { useTabModel } from "@/app/store/tab-model"; -import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { ErrorBoundary } from "@/element/errorboundary"; import { NodeModel } from "@/layout/index"; +import { makeORef } from "@/store/wos"; import * as util from "@/util/util"; import { makeIconClass } from "@/util/util"; import { computeBgStyleFromMeta } from "@/util/waveutil"; import clsx from "clsx"; import * as jotai from "jotai"; import * as React from "react"; +import { BlockEnv } from "./blockenv"; import { BlockFrameProps } from "./blocktypes"; const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { + const waveEnv = useWaveEnv(); const tabModel = useTabModel(); const isFocused = jotai.useAtomValue(nodeModel.isFocused); const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral); const blockNum = jotai.useAtomValue(nodeModel.blockNum); - const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); - const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; + const isLayoutMode = jotai.useAtomValue(waveEnv.atoms.controlShiftDelayAtom); + const showOverlayBlockNums = jotai.useAtomValue(waveEnv.getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; const blockHighlight = jotai.useAtomValue(BlockModel.getInstance().getBlockHighlightAtom(nodeModel.blockId)); - const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); + const frameActiveBorderColor = jotai.useAtomValue( + waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:activebordercolor") + ); + const frameBorderColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:bordercolor")); const tabActiveBorderColor = jotai.useAtomValue(tabModel.getTabMetaAtom("bg:activebordercolor")); const tabBorderColor = jotai.useAtomValue(tabModel.getTabMetaAtom("bg:bordercolor")); const style: React.CSSProperties = {}; @@ -39,15 +45,15 @@ const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { if (tabActiveBorderColor) { style.borderColor = tabActiveBorderColor; } - if (blockData?.meta?.["frame:activebordercolor"]) { - style.borderColor = blockData.meta["frame:activebordercolor"]; + if (frameActiveBorderColor) { + style.borderColor = frameActiveBorderColor; } } else { if (tabBorderColor) { style.borderColor = tabBorderColor; } - if (blockData?.meta?.["frame:bordercolor"]) { - style.borderColor = blockData.meta["frame:bordercolor"]; + if (frameBorderColor) { + style.borderColor = frameBorderColor; } if (isEphemeral && !style.borderColor) { style.borderColor = "rgba(255, 255, 255, 0.7)"; @@ -87,11 +93,12 @@ const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { }); const BlockFrame_Default_Component = (props: BlockFrameProps) => { + const waveEnv = useWaveEnv(); const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props; - const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); const isFocused = jotai.useAtomValue(nodeModel.isFocused); const aiPanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); - const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(blockData?.meta?.view); + const metaView = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")); + const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(metaView); const customBg = util.useAtomValueSafe(viewModel?.blockBg); const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); const changeConnModalAtom = useBlockAtom(nodeModel.blockId, "changeConn", () => { @@ -100,11 +107,13 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { const connModalOpen = jotai.useAtomValue(changeConnModalAtom); const isMagnified = jotai.useAtomValue(nodeModel.isMagnified); const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral); - const [magnifiedBlockBlurAtom] = React.useState(() => getSettingsKeyAtom("window:magnifiedblockblurprimarypx")); + const [magnifiedBlockBlurAtom] = React.useState(() => waveEnv.getSettingsKeyAtom("window:magnifiedblockblurprimarypx")); const magnifiedBlockBlur = jotai.useAtomValue(magnifiedBlockBlurAtom); - const [magnifiedBlockOpacityAtom] = React.useState(() => getSettingsKeyAtom("window:magnifiedblockopacity")); + const [magnifiedBlockOpacityAtom] = React.useState(() => waveEnv.getSettingsKeyAtom("window:magnifiedblockopacity")); const magnifiedBlockOpacity = jotai.useAtomValue(magnifiedBlockOpacityAtom); const connBtnRef = React.useRef(null); + const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); + const iconColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "icon:color")); const noHeader = util.useAtomValueSafe(viewModel?.noHeader); React.useEffect(() => { @@ -126,23 +135,20 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { }, [manageConnection]); React.useEffect(() => { // on mount, if manageConnection, call ConnEnsure - if (!manageConnection || blockData == null || preview) { + if (!manageConnection || preview) { return; } - const connName = blockData?.meta?.connection; if (!util.isLocalConnName(connName)) { console.log("ensure conn", nodeModel.blockId, connName); - RpcApi.ConnEnsureCommand( - TabRpcClient, - { connname: connName, logblockid: nodeModel.blockId }, - { timeout: 60000 } - ).catch((e) => { - console.log("error ensuring connection", nodeModel.blockId, connName, e); - }); + waveEnv.rpc + .ConnEnsureCommand(TabRpcClient, { connname: connName, logblockid: nodeModel.blockId }, { timeout: 60000 }) + .catch((e) => { + console.log("error ensuring connection", nodeModel.blockId, connName, e); + }); } - }, [manageConnection, blockData]); + }, [manageConnection, connName]); - const viewIconElem = getViewIconElem(viewIconUnion, blockData); + const viewIconElem = getViewIconElem(viewIconUnion, iconColor); let innerStyle: React.CSSProperties = {}; if (!preview) { innerStyle = computeBgStyleFromMeta(customBg); @@ -203,11 +209,12 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { const BlockFrame_Default = React.memo(BlockFrame_Default_Component) as typeof BlockFrame_Default_Component; const BlockFrame = React.memo((props: BlockFrameProps) => { + const waveEnv = useWaveEnv(); const tabModel = useTabModel(); const blockId = props.nodeModel.blockId; - const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); + const blockIsNull = jotai.useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", blockId))); const numBlocks = jotai.useAtomValue(tabModel.tabNumBlocksAtom); - if (!blockId || !blockData) { + if (!blockId || blockIsNull) { return null; } return ; diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 542f9f352a..01346183a0 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -66,7 +66,7 @@ export function processTitleString(titleString: string): React.ReactNode[] { const tagRegex = /<(\/)?([a-z]+)(?::([#a-z0-9@-]+))?>/g; let lastIdx = 0; let match; - let partsStack = [[]]; + const partsStack = [[]]; while ((match = tagRegex.exec(titleString)) != null) { const lastPart = partsStack[partsStack.length - 1]; const before = titleString.substring(lastIdx, match.index); @@ -98,7 +98,7 @@ export function processTitleString(titleString: string): React.ReactNode[] { if (!tagParam.match(colorRegex)) { continue; } - let children = []; + const children = []; const rtag = React.createElement("span", { key: match.index, style: { color: tagParam } }, children); lastPart.push(rtag); partsStack.push(children); @@ -112,7 +112,7 @@ export function processTitleString(titleString: string): React.ReactNode[] { partsStack.pop(); continue; } - let children = []; + const children = []; const rtag = React.createElement(tagName, { key: match.index }, children); lastPart.push(rtag); partsStack.push(children); @@ -123,12 +123,12 @@ export function processTitleString(titleString: string): React.ReactNode[] { return partsStack[0]; } -export function getBlockHeaderIcon(blockIcon: string, blockData: Block): React.ReactNode { +export function getBlockHeaderIcon(blockIcon: string, overrideIconColor?: string): React.ReactNode { let blockIconElem: React.ReactNode = null; if (util.isBlank(blockIcon)) { blockIcon = "square"; } - let iconColor = blockData?.meta?.["icon:color"]; + let iconColor = overrideIconColor; if (iconColor && !iconColor.match(colorRegex)) { iconColor = null; } @@ -145,17 +145,11 @@ export function getBlockHeaderIcon(blockIcon: string, blockData: Block): React.R export function getViewIconElem( viewIconUnion: string | IconButtonDecl, - blockData: Block, - iconColor?: string + overrideIconColor?: string ): React.ReactElement { if (viewIconUnion == null || typeof viewIconUnion === "string") { const viewIcon = viewIconUnion as string; - const style: React.CSSProperties = iconColor ? { color: iconColor, opacity: 1.0 } : {}; - return ( -
- {getBlockHeaderIcon(viewIcon, blockData)} -
- ); + return
{getBlockHeaderIcon(viewIcon, overrideIconColor)}
; } else { return ; } diff --git a/frontend/app/block/connectionbutton.tsx b/frontend/app/block/connectionbutton.tsx index c0a37659cb..c5a9b635c3 100644 --- a/frontend/app/block/connectionbutton.tsx +++ b/frontend/app/block/connectionbutton.tsx @@ -2,12 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { computeConnColorNum } from "@/app/block/blockutil"; -import { getConnStatusAtom, getLocalHostDisplayNameAtom, recordTEvent } from "@/app/store/global"; +import { recordTEvent } from "@/app/store/global"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; import { IconButton } from "@/element/iconbutton"; import * as util from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; import DotsSvg from "../asset/dots-anim-4.svg"; +import { BlockEnv } from "./blockenv"; interface ConnectionButtonProps { connection: string; @@ -18,11 +20,11 @@ interface ConnectionButtonProps { export const ConnectionButton = React.memo( React.forwardRef( ({ connection, changeConnModalAtom, isTerminalBlock }: ConnectionButtonProps, ref) => { - const [connModalOpen, setConnModalOpen] = jotai.useAtom(changeConnModalAtom); + const waveEnv = useWaveEnv(); + const [_connModalOpen, setConnModalOpen] = jotai.useAtom(changeConnModalAtom); const isLocal = util.isLocalConnName(connection); - const connStatusAtom = getConnStatusAtom(connection); - const connStatus = jotai.useAtomValue(connStatusAtom); - const localName = jotai.useAtomValue(getLocalHostDisplayNameAtom()); + const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connection)); + const localName = jotai.useAtomValue(waveEnv.getLocalHostDisplayNameAtom()); let showDisconnectedSlash = false; let connIconElem: React.ReactNode = null; const connColorNum = computeConnColorNum(connStatus); diff --git a/frontend/app/block/connstatusoverlay.tsx b/frontend/app/block/connstatusoverlay.tsx index 526dbbae32..d4d6ad14b8 100644 --- a/frontend/app/block/connstatusoverlay.tsx +++ b/frontend/app/block/connstatusoverlay.tsx @@ -4,15 +4,15 @@ import { Button } from "@/app/element/button"; import { CopyButton } from "@/app/element/copybutton"; import { useDimensionsWithCallbackRef } from "@/app/hook/useDimensions"; -import { atoms, getConnStatusAtom, WOS } from "@/app/store/global"; -import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; import { NodeModel } from "@/layout/index"; import * as util from "@/util/util"; import clsx from "clsx"; import * as jotai from "jotai"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import * as React from "react"; +import { BlockEnv } from "./blockenv"; function formatElapsedTime(elapsedMs: number): string { if (elapsedMs <= 0) { @@ -55,10 +55,11 @@ const StalledOverlay = React.memo( }) => { const [elapsedTime, setElapsedTime] = React.useState(""); + const waveEnv = useWaveEnv(); const handleDisconnect = React.useCallback(() => { - const prtn = RpcApi.ConnDisconnectCommand(TabRpcClient, connName, { timeout: 5000 }); + const prtn = waveEnv.rpc.ConnDisconnectCommand(TabRpcClient, connName, { timeout: 5000 }); prtn.catch((e) => console.log("error disconnecting", connName, e)); - }, [connName]); + }, [connName, waveEnv]); React.useEffect(() => { if (!connStatus.lastactivitybeforestalledtime) { @@ -118,15 +119,16 @@ export const ConnStatusOverlay = React.memo( viewModel: ViewModel; changeConnModalAtom: jotai.PrimitiveAtom; }) => { - const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); + const waveEnv = useWaveEnv(); + const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); const [connModalOpen] = jotai.useAtom(changeConnModalAtom); - const connName = blockData?.meta?.connection; - const connStatus = jotai.useAtomValue(getConnStatusAtom(connName)); - const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); + const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connName)); + const isLayoutMode = jotai.useAtomValue(waveEnv.atoms.controlShiftDelayAtom); const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30); const width = domRect?.width; const [showError, setShowError] = React.useState(false); - const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom); + const wshConfigEnabled = + jotai.useAtomValue(waveEnv.getConnConfigKeyAtom(connName, "conn:wshenabled")) ?? true; const [showWshError, setShowWshError] = React.useState(false); React.useEffect(() => { @@ -138,13 +140,13 @@ export const ConnStatusOverlay = React.memo( }, [width, connStatus, setShowError]); const handleTryReconnect = React.useCallback(() => { - const prtn = RpcApi.ConnConnectCommand( + const prtn = waveEnv.rpc.ConnConnectCommand( TabRpcClient, { host: connName, logblockid: nodeModel.blockId }, { timeout: 60000 } ); prtn.catch((e) => console.log("error reconnecting", connName, e)); - }, [connName, nodeModel.blockId]); + }, [connName, nodeModel.blockId, waveEnv]); const handleDisableWsh = React.useCallback(async () => { const metamaptype: unknown = { @@ -155,19 +157,19 @@ export const ConnStatusOverlay = React.memo( metamaptype: metamaptype, }; try { - await RpcApi.SetConnectionsConfigCommand(TabRpcClient, data); + await waveEnv.rpc.SetConnectionsConfigCommand(TabRpcClient, data); } catch (e) { console.log("problem setting connection config: ", e); } - }, [connName]); + }, [connName, waveEnv]); const handleRemoveWshError = React.useCallback(async () => { try { - await RpcApi.DismissWshFailCommand(TabRpcClient, connName); + await waveEnv.rpc.DismissWshFailCommand(TabRpcClient, connName); } catch (e) { console.log("unable to dismiss wsh error: ", e); } - }, [connName]); + }, [connName, waveEnv]); let statusText = `Disconnected from "${connName}"`; let showReconnect = true; @@ -189,7 +191,6 @@ export const ConnStatusOverlay = React.memo( } const showIcon = connStatus.status != "connecting"; - const wshConfigEnabled = fullConfig?.connections?.[connName]?.["conn:wshenabled"] ?? true; React.useEffect(() => { const showWshErrorTemp = connStatus.status == "connected" && @@ -215,7 +216,7 @@ export const ConnStatusOverlay = React.memo( [showError, showWshError, connStatus.error, connStatus.wsherror] ); - let showStalled = connStatus.status == "connected" && connStatus.connhealthstatus == "stalled"; + const showStalled = connStatus.status == "connected" && connStatus.connhealthstatus == "stalled"; if (!showWshError && !showStalled && (isLayoutMode || connStatus.status == "connected" || connModalOpen)) { return null; } diff --git a/frontend/app/block/durable-session-flyover.tsx b/frontend/app/block/durable-session-flyover.tsx index 620c57731f..7ab7fa0b10 100644 --- a/frontend/app/block/durable-session-flyover.tsx +++ b/frontend/app/block/durable-session-flyover.tsx @@ -1,8 +1,9 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { getApi, getConnStatusAtom, recordTEvent, WOS } from "@/app/store/global"; +import { recordTEvent } from "@/app/store/global"; import { TermViewModel } from "@/app/view/term/term-model"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; import * as util from "@/util/util"; import { cn } from "@/util/util"; import { @@ -18,18 +19,19 @@ import { } from "@floating-ui/react"; import * as jotai from "jotai"; import { useEffect, useRef, useState } from "react"; +import { BlockEnv } from "./blockenv"; function isTermViewModel(viewModel: ViewModel): viewModel is TermViewModel { return viewModel?.viewType === "term"; } -function handleLearnMore() { - getApi().openExternal("https://docs.waveterm.dev/durable-sessions"); -} - function LearnMoreButton() { + const waveEnv = useWaveEnv(); return ( - ); @@ -194,7 +196,7 @@ function DurableEndedContent({ doneReason, startupError, viewModel, onClose }: D let titleText = "Durable Session (Ended)"; let descriptionText = "The durable session has ended. This block is still configured for durable sessions."; - let showRestartButton = true; + const showRestartButton = true; if (doneReason === "terminated") { titleText = "Durable Session (Ended, Exited)"; @@ -333,11 +335,11 @@ export function DurableSessionFlyover({ placement = "bottom", divClassName, }: DurableSessionFlyoverProps) { - const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); + const waveEnv = useWaveEnv(); + const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(blockId, "connection")); const termDurableStatus = util.useAtomValueSafe(viewModel?.termDurableStatus); const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable); - const connName = blockData?.meta?.connection; - const connStatus = jotai.useAtomValue(getConnStatusAtom(connName)); + const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connName)); const { color: durableIconColor, iconType: durableIconType } = getIconProps( termDurableStatus, diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 2ae7cb47c6..eea579b8ce 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -324,13 +324,6 @@ function useBlockAtom(blockId: string, name: string, makeFn: () => Atom): return atom as Atom; } -function useBlockDataLoaded(blockId: string): boolean { - const loadedAtom = useBlockAtom(blockId, "block-loaded", () => { - return WOS.getWaveObjectLoadingAtom(WOS.makeORef("block", blockId)); - }); - return useAtomValue(loadedAtom); -} - /** * Safely read an atom value, returning null if the atom is null. */ @@ -672,6 +665,7 @@ export { getApi, getBlockComponentModel, getBlockMetaKeyAtom, + getConnConfigKeyAtom, getBlockTermDurableAtom, getConnStatusAtom, getFocusedBlockId, @@ -703,7 +697,6 @@ export { unregisterBlockComponentModel, useBlockAtom, useBlockCache, - useBlockDataLoaded, useOrefMetaKeyAtom, useOverrideConfigAtom, useSettingsKeyAtom, diff --git a/frontend/app/store/tab-model.ts b/frontend/app/store/tab-model.ts index ec5ab94c16..6c41e2fd84 100644 --- a/frontend/app/store/tab-model.ts +++ b/frontend/app/store/tab-model.ts @@ -1,6 +1,7 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { useWaveEnv, WaveEnv } from "@/app/waveenv/waveenv"; import { atom, Atom, PrimitiveAtom } from "jotai"; import { createContext, useContext } from "react"; import { globalStore } from "./jotaiStore"; @@ -11,14 +12,19 @@ export const activeTabIdAtom = atom(null) as PrimitiveAtom; export class TabModel { tabId: string; + waveEnv: WaveEnv; tabAtom: Atom; tabNumBlocksAtom: Atom; isTermMultiInput = atom(false) as PrimitiveAtom; metaCache: Map> = new Map(); - constructor(tabId: string) { + constructor(tabId: string, waveEnv?: WaveEnv) { this.tabId = tabId; + this.waveEnv = waveEnv; this.tabAtom = atom((get) => { + if (this.waveEnv != null) { + return get(this.waveEnv.wos.getWaveObjectAtom(WOS.makeORef("tab", this.tabId))); + } return WOS.getObjectValue(WOS.makeORef("tab", this.tabId), get); }); this.tabNumBlocksAtom = atom((get) => { @@ -40,33 +46,42 @@ export class TabModel { } } -export function getTabModelByTabId(tabId: string): TabModel { +export function getTabModelByTabId(tabId: string, waveEnv?: WaveEnv): TabModel { let model = tabModelCache.get(tabId); if (model == null) { - model = new TabModel(tabId); + model = new TabModel(tabId, waveEnv); tabModelCache.set(tabId, model); } return model; } -export function getActiveTabModel(): TabModel | null { +export function getActiveTabModel(waveEnv?: WaveEnv): TabModel | null { const activeTabId = globalStore.get(activeTabIdAtom); if (activeTabId == null) { return null; } - return getTabModelByTabId(activeTabId); + return getTabModelByTabId(activeTabId, waveEnv); } export const TabModelContext = createContext(undefined); export function useTabModel(): TabModel { - const model = useContext(TabModelContext); - if (model == null) { + const waveEnv = useWaveEnv(); + const ctxModel = useContext(TabModelContext); + if (waveEnv?.mockTabModel != null) { + return waveEnv.mockTabModel; + } + if (ctxModel == null) { throw new Error("useTabModel must be used within a TabModelProvider"); } - return model; + return ctxModel; } -export function maybeUseTabModel(): TabModel { - return useContext(TabModelContext); +export function useTabModelMaybe(): TabModel { + const waveEnv = useWaveEnv(); + const ctxModel = useContext(TabModelContext); + if (waveEnv?.mockTabModel != null) { + return waveEnv.mockTabModel; + } + return ctxModel; } diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts index f2395e12d0..72ca022750 100644 --- a/frontend/app/store/wos.ts +++ b/frontend/app/store/wos.ts @@ -9,7 +9,6 @@ import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { fireAndForget } from "@/util/util"; import { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from "jotai"; -import { useEffect } from "react"; import { globalStore } from "./jotaiStore"; import { ObjectService } from "./services"; @@ -21,8 +20,6 @@ type WaveObjectDataItemType = { type WaveObjectValue = { pendingPromise: Promise; dataAtom: PrimitiveAtom>; - refCount: number; - holdTime: number; }; function splitORef(oref: string): [string, string] { @@ -151,12 +148,6 @@ function callBackendService(service: string, method: string, args: any[], noUICo const waveObjectValueCache = new Map>(); -function clearWaveObjectCache() { - waveObjectValueCache.clear(); -} - -const defaultHoldTime = 5000; // 5-seconds - function reloadWaveObject(oref: string): Promise { let wov = waveObjectValueCache.get(oref); if (wov === undefined) { @@ -171,7 +162,7 @@ function reloadWaveObject(oref: string): Promise { } function createWaveValueObject(oref: string, shouldFetch: boolean): WaveObjectValue { - const wov = { pendingPromise: null, dataAtom: null, refCount: 0, holdTime: Date.now() + 5000 }; + const wov = { pendingPromise: null, dataAtom: null }; wov.dataAtom = atom({ value: null, loading: true }); if (!shouldFetch) { return wov; @@ -210,7 +201,6 @@ function getWaveObjectValue(oref: string, createIfMissing = t function loadAndPinWaveObject(oref: string): Promise { const wov = getWaveObjectValue(oref); - wov.refCount++; if (wov.pendingPromise == null) { const dataValue = globalStore.get(wov.dataAtom); return Promise.resolve(dataValue.value); @@ -218,30 +208,48 @@ function loadAndPinWaveObject(oref: string): Promise { return wov.pendingPromise; } +const waveObjectDerivedAtomCache = new Map>(); + function getWaveObjectAtom(oref: string): Atom { + const cacheKey = oref + ":value"; + let cachedAtom = waveObjectDerivedAtomCache.get(cacheKey) as Atom; + if (cachedAtom != null) { + return cachedAtom; + } const wov = getWaveObjectValue(oref); - return atom((get) => get(wov.dataAtom).value); + cachedAtom = atom((get) => get(wov.dataAtom).value); + waveObjectDerivedAtomCache.set(cacheKey, cachedAtom); + return cachedAtom; } function getWaveObjectLoadingAtom(oref: string): Atom { + const cacheKey = oref + ":loading"; + let cachedAtom = waveObjectDerivedAtomCache.get(cacheKey) as Atom; + if (cachedAtom != null) { + return cachedAtom; + } const wov = getWaveObjectValue(oref); - return atom((get) => { + cachedAtom = atom((get) => { const dataValue = get(wov.dataAtom); - if (dataValue.loading) { - return null; - } return dataValue.loading; }); + waveObjectDerivedAtomCache.set(cacheKey, cachedAtom); + return cachedAtom; +} + +function isWaveObjectNullAtom(oref: string): Atom { + const cacheKey = oref + ":isnull"; + let cachedAtom = waveObjectDerivedAtomCache.get(cacheKey) as Atom; + if (cachedAtom != null) { + return cachedAtom; + } + cachedAtom = atom((get) => get(getWaveObjectAtom(oref)) == null); + waveObjectDerivedAtomCache.set(cacheKey, cachedAtom); + return cachedAtom; } function useWaveObjectValue(oref: string): [T, boolean] { const wov = getWaveObjectValue(oref); - useEffect(() => { - wov.refCount++; - return () => { - wov.refCount--; - }; - }, [oref]); const atomVal = useAtomValue(wov.dataAtom); return [atomVal.value, atomVal.loading]; } @@ -267,7 +275,6 @@ function updateWaveObject(update: WaveObjUpdate) { console.log("WaveObj updated", oref); globalStore.set(wov.dataAtom, { value: update.obj, loading: false }); } - wov.holdTime = Date.now() + defaultHoldTime; return; } @@ -277,15 +284,6 @@ function updateWaveObjects(vals: WaveObjUpdate[]) { } } -function cleanWaveObjectCache() { - const now = Date.now(); - for (const [oref, wov] of waveObjectValueCache) { - if (wov.refCount == 0 && wov.holdTime < now) { - waveObjectValueCache.delete(oref); - } - } -} - // gets the value of a WaveObject from the cache. // should provide getFn if it is available (e.g. inside of a jotai atom) // otherwise it will use the globalStore.get function @@ -318,11 +316,10 @@ function setObjectValue(value: T, setFn?: Setter, pushToServe export { callBackendService, - cleanWaveObjectCache, - clearWaveObjectCache, getObjectValue, getWaveObjectAtom, getWaveObjectLoadingAtom, + isWaveObjectNullAtom, loadAndPinWaveObject, makeORef, mockObjectForPreview, diff --git a/frontend/app/waveenv/waveenv.ts b/frontend/app/waveenv/waveenv.ts index f643c24295..8a75072d79 100644 --- a/frontend/app/waveenv/waveenv.ts +++ b/frontend/app/waveenv/waveenv.ts @@ -1,23 +1,53 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import type { TabModel } from "@/app/store/tab-model"; import { RpcApiType } from "@/app/store/wshclientapi"; import { Atom, PrimitiveAtom } from "jotai"; import React from "react"; -type ConfigAtoms = { [K in keyof SettingsType]: Atom }; - export type BlockMetaKeyAtomFnType = ( blockId: string, key: T ) => Atom; +export type ConnConfigKeyAtomFnType = ( + connName: string, + key: T +) => Atom; + +export type SettingsKeyAtomFnType = ( + key: T +) => Atom; + +type OmitNever = { + [K in keyof T as [T[K]] extends [never] ? never : K]: T[K]; +}; + +type Subset = OmitNever<{ + [K in keyof T]: K extends keyof U ? T[K] : never; +}>; + +type ComplexWaveEnvKeys = { + rpc: WaveEnv["rpc"]; + electron: WaveEnv["electron"]; + atoms: WaveEnv["atoms"]; + wos: WaveEnv["wos"]; +}; + +export type WaveEnvSubset = OmitNever<{ + [K in keyof T]: K extends keyof ComplexWaveEnvKeys + ? Subset + : K extends keyof WaveEnv + ? T[K] + : never; +}>; + // default implementation for production is in ./waveenvimpl.ts export type WaveEnv = { electron: ElectronApi; rpc: RpcApiType; platform: NodeJS.Platform; - configAtoms: ConfigAtoms; isDev: () => boolean; isWindows: () => boolean; isMacOS: () => boolean; @@ -25,8 +55,17 @@ export type WaveEnv = { createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => Promise; showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => void; getConnStatusAtom: (conn: string) => PrimitiveAtom; - getWaveObjectAtom: (oref: string) => Atom; + getLocalHostDisplayNameAtom: () => Atom; + wos: { + getWaveObjectAtom: (oref: string) => Atom; + getWaveObjectLoadingAtom: (oref: string) => Atom; + isWaveObjectNullAtom: (oref: string) => Atom; + useWaveObjectValue: (oref: string) => [T, boolean]; + }; + getSettingsKeyAtom: SettingsKeyAtomFnType; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType; + getConnConfigKeyAtom: ConnConfigKeyAtomFnType; + mockTabModel?: TabModel; }; export const WaveEnvContext = React.createContext(null); diff --git a/frontend/app/waveenv/waveenvimpl.ts b/frontend/app/waveenv/waveenvimpl.ts index 0f2461fcf6..1d78172d04 100644 --- a/frontend/app/waveenv/waveenvimpl.ts +++ b/frontend/app/waveenv/waveenvimpl.ts @@ -6,7 +6,9 @@ import { atoms, createBlock, getBlockMetaKeyAtom, + getConnConfigKeyAtom, getConnStatusAtom, + getLocalHostDisplayNameAtom, getSettingsKeyAtom, isDev, WOS, @@ -15,18 +17,12 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; import { isMacOS, isWindows, PLATFORM } from "@/util/platformutil"; -const configAtoms = new Proxy({} as WaveEnv["configAtoms"], { - get(_target: WaveEnv["configAtoms"], key: K) { - return getSettingsKeyAtom(key); - }, -}); - export function makeWaveEnvImpl(): WaveEnv { return { electron: (window as any).api, rpc: RpcApi, + getSettingsKeyAtom, platform: PLATFORM, - configAtoms, isDev, isWindows, isMacOS, @@ -36,7 +32,14 @@ export function makeWaveEnvImpl(): WaveEnv { ContextMenuModel.getInstance().showContextMenu(menu, e); }, getConnStatusAtom, - getWaveObjectAtom: WOS.getWaveObjectAtom, + getLocalHostDisplayNameAtom, + wos: { + getWaveObjectAtom: WOS.getWaveObjectAtom, + getWaveObjectLoadingAtom: WOS.getWaveObjectLoadingAtom, + isWaveObjectNullAtom: WOS.isWaveObjectNullAtom, + useWaveObjectValue: WOS.useWaveObjectValue, + }, getBlockMetaKeyAtom, + getConnConfigKeyAtom, }; } diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index bfdc8dc119..2d73119154 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -3,7 +3,7 @@ import { Tooltip } from "@/app/element/tooltip"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { useWaveEnv, WaveEnv } from "@/app/waveenv/waveenv"; +import { useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { shouldIncludeWidgetForWorkspace } from "@/app/workspace/widgetfilter"; import { modalsModel } from "@/store/modalmodel"; import { fireAndForget, isBlank, makeIconClass } from "@/util/util"; @@ -20,7 +20,7 @@ import clsx from "clsx"; import { useAtomValue } from "jotai"; import { memo, useCallback, useEffect, useRef, useState } from "react"; -export type WidgetsEnv = { +export type WidgetsEnv = WaveEnvSubset<{ isDev: WaveEnv["isDev"]; electron: { openBuilder: WaveEnv["electron"]["openBuilder"]; @@ -35,7 +35,7 @@ export type WidgetsEnv = { }; createBlock: WaveEnv["createBlock"]; showContextMenu: WaveEnv["showContextMenu"]; -}; +}>; function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType }): WidgetConfigType[] { if (wmap == null) { diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 43b77c761a..fdcfb02ba3 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -1,7 +1,8 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { getSettingsKeyAtom, makeDefaultConnStatus } from "@/app/store/global"; +import { makeDefaultConnStatus } from "@/app/store/global"; +import { TabModel } from "@/app/store/tab-model"; import { RpcApiType } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; import { PlatformMacOS, PlatformWindows } from "@/util/platformutil"; @@ -15,6 +16,7 @@ type RpcOverrides = { export type MockEnv = { isDev?: boolean; + tabId?: string; platform?: NodeJS.Platform; settings?: Partial; rpc?: RpcOverrides; @@ -38,6 +40,7 @@ function mergeRecords(base: Record, overrides: Record): export function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv { return { isDev: overrides.isDev ?? base.isDev, + tabId: overrides.tabId ?? base.tabId, platform: overrides.platform ?? base.platform, settings: mergeRecords(base.settings, overrides.settings), rpc: mergeRecords(base.rpc as any, overrides.rpc as any) as RpcOverrides, @@ -53,26 +56,26 @@ export function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv { }; } -function makeMockConfigAtoms(overrides?: Partial): WaveEnv["configAtoms"] { - const overrideAtoms = new Map>(); - if (overrides) { - for (const key of Object.keys(overrides) as (keyof SettingsType)[]) { - overrideAtoms.set(key, atom(overrides[key])); +function makeMockSettingsKeyAtom( + settingsAtom: Atom, + overrides?: Partial +): WaveEnv["getSettingsKeyAtom"] { + const keyAtomCache = new Map>(); + return (key: T) => { + if (!keyAtomCache.has(key)) { + keyAtomCache.set( + key, + atom((get) => (overrides?.[key] !== undefined ? overrides[key] : get(settingsAtom)?.[key])) + ); } - } - return new Proxy({} as WaveEnv["configAtoms"], { - get(_target: WaveEnv["configAtoms"], key: K) { - if (overrideAtoms.has(key)) { - return overrideAtoms.get(key); - } - return getSettingsKeyAtom(key); - }, - }); + return keyAtomCache.get(key) as Atom; + }; } function makeMockGlobalAtoms( settingsOverrides?: Partial, - atomOverrides?: Partial + atomOverrides?: Partial, + tabId?: string ): GlobalAtomsType { let fullConfig = DefaultFullConfig; if (settingsOverrides) { @@ -86,13 +89,13 @@ function makeMockGlobalAtoms( const defaults: GlobalAtomsType = { builderId: atom(""), builderAppId: atom("") as any, - uiContext: atom({} as UIContext), + uiContext: atom({ windowid: "", activetabid: tabId ?? "" } as UIContext), workspace: atom(null as Workspace), fullConfigAtom, waveaiModeConfigAtom: atom({}) as any, settingsAtom, hasCustomAIPresetsAtom: atom(false), - staticTabId: atom(""), + staticTabId: atom(tabId ?? ""), isFullScreen: atom(false) as any, zoomFactorAtom: atom(1.0) as any, controlShiftDelayAtom: atom(false) as any, @@ -151,8 +154,17 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { const overrides: MockEnv = mockEnv ?? {}; const platform = overrides.platform ?? PlatformMacOS; const connStatusAtomCache = new Map>(); - const waveObjectAtomCache = new Map>(); + const waveObjectAtomCache = new Map>(); const blockMetaKeyAtomCache = new Map>(); + const connConfigKeyAtomCache = new Map>(); + const atoms = makeMockGlobalAtoms(overrides.settings, overrides.atoms, overrides.tabId); + const localHostDisplayNameAtom = atom((get) => { + const configValue = get(atoms.settingsAtom)?.["conn:localhostdisplayname"]; + if (configValue != null) { + return configValue; + } + return "user@localhost"; + }); const env = { mockEnv: overrides, electron: { @@ -161,9 +173,9 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { ...overrides.electron, }, rpc: makeMockRpc(overrides.rpc), + atoms, + getSettingsKeyAtom: makeMockSettingsKeyAtom(atoms.settingsAtom, overrides.settings), platform, - configAtoms: makeMockConfigAtoms(overrides.settings), - atoms: makeMockGlobalAtoms(overrides.settings, overrides.atoms), isDev: () => overrides.isDev ?? true, isWindows: () => platform === PlatformWindows, isMacOS: () => platform === PlatformMacOS, @@ -178,6 +190,9 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { ((menu, e) => { console.log("[mock showContextMenu]", menu, e); }), + getLocalHostDisplayNameAtom: () => { + return localHostDisplayNameAtom; + }, getConnStatusAtom: (conn: string) => { if (!connStatusAtomCache.has(conn)) { const connStatus = overrides.connStatus?.[conn] ?? makeDefaultConnStatus(conn); @@ -185,19 +200,43 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { } return connStatusAtomCache.get(conn); }, - getWaveObjectAtom: (oref: string) => { - if (!waveObjectAtomCache.has(oref)) { + wos: { + getWaveObjectAtom: (oref: string) => { + const cacheKey = oref + ":value"; + if (!waveObjectAtomCache.has(cacheKey)) { + const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T; + waveObjectAtomCache.set(cacheKey, atom(obj)); + } + return waveObjectAtomCache.get(cacheKey) as PrimitiveAtom; + }, + getWaveObjectLoadingAtom: (oref: string) => { + const cacheKey = oref + ":loading"; + if (!waveObjectAtomCache.has(cacheKey)) { + waveObjectAtomCache.set(cacheKey, atom(false)); + } + return waveObjectAtomCache.get(cacheKey) as Atom; + }, + isWaveObjectNullAtom: (oref: string) => { + const cacheKey = oref + ":isnull"; + if (!waveObjectAtomCache.has(cacheKey)) { + waveObjectAtomCache.set( + cacheKey, + atom((get) => get(env.wos.getWaveObjectAtom(oref)) == null) + ); + } + return waveObjectAtomCache.get(cacheKey) as Atom; + }, + useWaveObjectValue: (oref: string): [T, boolean] => { const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T; - waveObjectAtomCache.set(oref, atom(obj)); - } - return waveObjectAtomCache.get(oref) as PrimitiveAtom; + return [obj, false]; + }, }, getBlockMetaKeyAtom: (blockId: string, key: T) => { const cacheKey = blockId + "#meta-" + key; if (!blockMetaKeyAtomCache.has(cacheKey)) { const metaAtom = atom((get) => { const blockORef = "block:" + blockId; - const blockAtom = env.getWaveObjectAtom(blockORef); + const blockAtom = env.wos.getWaveObjectAtom(blockORef); const blockData = get(blockAtom); return blockData?.meta?.[key] as MetaType[T]; }); @@ -205,6 +244,21 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { } return blockMetaKeyAtomCache.get(cacheKey) as Atom; }, - }; + getConnConfigKeyAtom: (connName: string, key: T) => { + const cacheKey = connName + "#conn-" + key; + if (!connConfigKeyAtomCache.has(cacheKey)) { + const keyAtom = atom((get) => { + const fullConfig = get(atoms.fullConfigAtom); + return fullConfig.connections?.[connName]?.[key]; + }); + connConfigKeyAtomCache.set(cacheKey, keyAtom); + } + return connConfigKeyAtomCache.get(cacheKey) as Atom; + }, + mockTabModel: null as TabModel, + } as MockWaveEnv; + if (overrides.tabId != null) { + env.mockTabModel = new TabModel(overrides.tabId, env); + } return env; } From a327921c15b06c75c3e7c182d411338adeb18ad4 Mon Sep 17 00:00:00 2001 From: "wave-builder[bot]" <181805596+wave-builder[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:03:02 +0000 Subject: [PATCH 18/18] chore: bump package version to 0.14.2-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9b86adf13a..cd727bf39f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "productName": "Wave", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "license": "Apache-2.0", - "version": "0.14.2-beta.0", + "version": "0.14.2-beta.1", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm"