diff --git a/.kilocode/skills/add-rpc/SKILL.md b/.kilocode/skills/add-rpc/SKILL.md index 8bed6ea6e4..0bf5117f9f 100644 --- a/.kilocode/skills/add-rpc/SKILL.md +++ b/.kilocode/skills/add-rpc/SKILL.md @@ -26,7 +26,7 @@ RPC commands in Wave Terminal follow these conventions: - **Method names** must end with `Command` - **First parameter** must be `context.Context` -- **Second parameter** (optional) is the command data structure +- **Remaining parameters** are a regular Go parameter list (zero or more typed args) - **Return values** can be either just an error, or one return value plus an error - **Streaming commands** return a channel instead of a direct value @@ -49,7 +49,7 @@ type WshRpcInterface interface { - Method name must end with `Command` - First parameter must be `ctx context.Context` -- Optional second parameter for input data +- Remaining parameters are a regular Go parameter list (zero or more) - Return either `error` or `(ReturnType, error)` - For streaming, return `chan RespOrErrorUnion[T]` diff --git a/.kilocode/skills/waveenv/SKILL.md b/.kilocode/skills/waveenv/SKILL.md index a78490f449..aabda6846d 100644 --- a/.kilocode/skills/waveenv/SKILL.md +++ b/.kilocode/skills/waveenv/SKILL.md @@ -69,6 +69,12 @@ export type MyEnv = WaveEnvSubset<{ // --- wos: always take the whole thing, no sub-typing needed --- wos: WaveEnv["wos"]; + // --- services: list only the services you call; no method-level narrowing --- + services: { + block: WaveEnv["services"]["block"]; + workspace: WaveEnv["services"]["workspace"]; + }; + // --- key-parameterized atom factories: enumerate the keys you use --- getSettingsKeyAtom: SettingsKeyAtomFnType<"app:focusfollowscursor" | "window:magnifiedblockopacity">; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"view" | "frame:title" | "connection">; @@ -80,6 +86,14 @@ export type MyEnv = WaveEnvSubset<{ }>; ``` +### Automatically Included Fields + +Every `WaveEnvSubset` automatically includes the mock fields — you never need to declare them: + +- `isMock: boolean` +- `mockSetWaveObj: (oref: string, obj: T) => void` +- `mockModels?: Map` + ### Rules for Each Section | Section | Pattern | Notes | @@ -88,6 +102,7 @@ export type MyEnv = WaveEnvSubset<{ | `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**. | +| `services` | `services: { svc: WaveEnv["services"]["svc"]; }` | List each service used; take the whole service object (no method-level narrowing). | | `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. | diff --git a/cmd/generatets/main-generatets.go b/cmd/generatets/main-generatets.go index 495c7d47f9..f282f9fa19 100644 --- a/cmd/generatets/main-generatets.go +++ b/cmd/generatets/main-generatets.go @@ -88,7 +88,14 @@ func generateServicesFile(tsTypesMap map[reflect.Type]string) error { fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") 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 * as WOS from \"./wos\";\n\n") + fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n") + fmt.Fprintf(&buf, "import type { WaveEnv } from \"@/app/waveenv/waveenv\";\n\n") + fmt.Fprintf(&buf, "function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise {\n") + fmt.Fprintf(&buf, " if (waveEnv != null) {\n") + fmt.Fprintf(&buf, " return waveEnv.callBackendService(service, method, args, noUIContext)\n") + fmt.Fprintf(&buf, " }\n") + fmt.Fprintf(&buf, " return WOS.callBackendService(service, method, args, noUIContext);\n") + fmt.Fprintf(&buf, "}\n\n") orderedKeys := utilfn.GetOrderedMapKeys(service.ServiceMap) for _, serviceName := range orderedKeys { serviceObj := service.ServiceMap[serviceName] @@ -96,6 +103,22 @@ func generateServicesFile(tsTypesMap map[reflect.Type]string) error { fmt.Fprint(&buf, svcStr) fmt.Fprint(&buf, "\n") } + fmt.Fprintf(&buf, "export const AllServiceTypes = {\n") + for _, serviceName := range orderedKeys { + serviceObj := service.ServiceMap[serviceName] + serviceType := reflect.TypeOf(serviceObj) + tsServiceName := serviceType.Elem().Name() + fmt.Fprintf(&buf, " %q: %sType,\n", serviceName, tsServiceName) + } + fmt.Fprintf(&buf, "};\n\n") + fmt.Fprintf(&buf, "export const AllServiceImpls = {\n") + for _, serviceName := range orderedKeys { + serviceObj := service.ServiceMap[serviceName] + serviceType := reflect.TypeOf(serviceObj) + tsServiceName := serviceType.Elem().Name() + fmt.Fprintf(&buf, " %q: %s,\n", serviceName, tsServiceName) + } + fmt.Fprintf(&buf, "};\n") written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) if !written { fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) diff --git a/eslint.config.js b/eslint.config.js index 50fe7ef7c3..6e98b1d805 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -89,6 +89,7 @@ export default [ { files: ["frontend/app/store/services.ts"], rules: { + "@typescript-eslint/no-unused-vars": "off", "prefer-rest-params": "off", }, }, diff --git a/frontend/app/element/streamdown.tsx b/frontend/app/element/streamdown.tsx index 6eddf976ae..2426f385e2 100644 --- a/frontend/app/element/streamdown.tsx +++ b/frontend/app/element/streamdown.tsx @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { CopyButton } from "@/app/element/copybutton"; @@ -314,11 +314,12 @@ export const WaveStreamdown = ({ table: false, mermaid: true, }} - mermaidConfig={{ - theme: "dark", - darkMode: true, + mermaid={{ + config: { + theme: "dark", + darkMode: true, + }, }} - defaultOrigin="http://localhost" components={components} > {text} diff --git a/frontend/app/store/badge.ts b/frontend/app/store/badge.ts index e3edb82103..e1cf8e5fe4 100644 --- a/frontend/app/store/badge.ts +++ b/frontend/app/store/badge.ts @@ -3,6 +3,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { fireAndForget, NullAtom } from "@/util/util"; import { atom, Atom, PrimitiveAtom } from "jotai"; import { v7 as uuidv7, version as uuidVersion } from "uuid"; @@ -10,10 +11,34 @@ import { globalStore } from "./jotaiStore"; import * as WOS from "./wos"; import { waveEventSubscribeSingle } from "./wps"; +export type BadgeEnv = WaveEnvSubset<{ + rpc: { + EventPublishCommand: WaveEnv["rpc"]["EventPublishCommand"]; + }; +}>; + +export type LoadBadgesEnv = WaveEnvSubset<{ + rpc: { + GetAllBadgesCommand: WaveEnv["rpc"]["GetAllBadgesCommand"]; + }; +}>; + +export type TabBadgesEnv = WaveEnvSubset<{ + wos: WaveEnv["wos"]; +}>; + const BadgeMap = new Map>(); const TabBadgeAtomCache = new Map>(); -function clearBadgeInternal(oref: string) { +function publishBadgeEvent(eventData: WaveEvent, env?: BadgeEnv) { + if (env != null) { + fireAndForget(() => env.rpc.EventPublishCommand(TabRpcClient, eventData)); + } else { + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); + } +} + +function clearBadgeInternal(oref: string, env?: BadgeEnv) { const eventData: WaveEvent = { event: "badge", scopes: [oref], @@ -22,28 +47,28 @@ function clearBadgeInternal(oref: string) { clear: true, } as BadgeEvent, }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); + publishBadgeEvent(eventData, env); } -function clearBadgesForBlockOnFocus(blockId: string) { +function clearBadgesForBlockOnFocus(blockId: string, env?: BadgeEnv) { 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); + clearBadgeInternal(oref, env); } } -function clearBadgesForTabOnFocus(tabId: string) { +function clearBadgesForTabOnFocus(tabId: string, env?: BadgeEnv) { 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); + clearBadgeInternal(oref, env); } } -function clearAllBadges() { +function clearAllBadges(env?: BadgeEnv) { const eventData: WaveEvent = { event: "badge", scopes: [], @@ -52,10 +77,10 @@ function clearAllBadges() { clearall: true, } as BadgeEvent, }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); + publishBadgeEvent(eventData, env); } -function clearBadgesForTab(tabId: string) { +function clearBadgesForTab(tabId: string, env?: BadgeEnv) { const tabAtom = WOS.getWaveObjectAtom(WOS.makeORef("tab", tabId)); const tab = globalStore.get(tabAtom); const blockIds = (tab as Tab)?.blockids ?? []; @@ -63,7 +88,7 @@ function clearBadgesForTab(tabId: string) { const oref = WOS.makeORef("block", blockId); const badgeAtom = BadgeMap.get(oref); if (badgeAtom != null && globalStore.get(badgeAtom) != null) { - clearBadgeInternal(oref); + clearBadgeInternal(oref, env); } } } @@ -88,7 +113,7 @@ function getBlockBadgeAtom(blockId: string): Atom { return getBadgeAtom(oref); } -function getTabBadgeAtom(tabId: string): Atom { +function getTabBadgeAtom(tabId: string, env?: TabBadgesEnv): Atom { if (tabId == null) { return NullAtom as Atom; } @@ -98,7 +123,8 @@ function getTabBadgeAtom(tabId: string): Atom { } const tabOref = WOS.makeORef("tab", tabId); const tabBadgeAtom = getBadgeAtom(tabOref); - const tabAtom = atom((get) => WOS.getObjectValue(tabOref, get)); + const tabAtom = + env != null ? env.wos.getWaveObjectAtom(tabOref) : WOS.getWaveObjectAtom(tabOref); rtn = atom((get) => { const tab = get(tabAtom); const blockIds = tab?.blockids ?? []; @@ -119,8 +145,9 @@ function getTabBadgeAtom(tabId: string): Atom { return rtn; } -async function loadBadges() { - const badges = await RpcApi.GetAllBadgesCommand(TabRpcClient); +async function loadBadges(env?: LoadBadgesEnv) { + const rpc = env != null ? env.rpc : RpcApi; + const badges = await rpc.GetAllBadgesCommand(TabRpcClient); if (badges == null) { return; } @@ -133,7 +160,7 @@ async function loadBadges() { } } -function setBadge(blockId: string, badge: Omit & { badgeid?: string }) { +function setBadge(blockId: string, badge: Omit & { badgeid?: string }, env?: BadgeEnv) { if (!badge.badgeid) { badge = { ...badge, badgeid: uuidv7() }; } else if (uuidVersion(badge.badgeid) !== 7) { @@ -148,10 +175,10 @@ function setBadge(blockId: string, badge: Omit & { badgeid?: s badge: badge, } as BadgeEvent, }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); + publishBadgeEvent(eventData, env); } -function clearBadgeById(blockId: string, badgeId: string) { +function clearBadgeById(blockId: string, badgeId: string, env?: BadgeEnv) { const oref = WOS.makeORef("block", blockId); const eventData: WaveEvent = { event: "badge", @@ -161,7 +188,7 @@ function clearBadgeById(blockId: string, badgeId: string) { clearbyid: badgeId, } as BadgeEvent, }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); + publishBadgeEvent(eventData, env); } function setupBadgesSubscription() { diff --git a/frontend/app/store/contextmenu.ts b/frontend/app/store/contextmenu.ts index 89e72c5613..fdad72bd88 100644 --- a/frontend/app/store/contextmenu.ts +++ b/frontend/app/store/contextmenu.ts @@ -74,11 +74,11 @@ class ContextMenuModel { this.activeOpts = opts; const electronMenuItems = this._convertAndRegisterMenu(menu); - const workspace = globalStore.get(atoms.workspace); + const workspaceId = globalStore.get(atoms.workspaceId); let oid: string; - if (workspace != null) { - oid = workspace.oid; + if (workspaceId != null) { + oid = workspaceId; } else { oid = globalStore.get(atoms.builderId); } diff --git a/frontend/app/store/global-atoms.ts b/frontend/app/store/global-atoms.ts index 6d24666ff0..01fe12800e 100644 --- a/frontend/app/store/global-atoms.ts +++ b/frontend/app/store/global-atoms.ts @@ -43,12 +43,16 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { console.log("failed to initialize zoomFactorAtom", e); } - const workspaceAtom: Atom = atom((get) => { + const workspaceIdAtom: Atom = atom((get) => { const windowData = WOS.getObjectValue(WOS.makeORef("window", get(windowIdAtom)), get); - if (windowData == null) { + return windowData?.workspaceid ?? null; + }); + const workspaceAtom: Atom = atom((get) => { + const workspaceId = get(workspaceIdAtom); + if (workspaceId == null) { return null; } - return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get); + return WOS.getObjectValue(WOS.makeORef("workspace", workspaceId), get); }); const fullConfigAtom = atom(null) as PrimitiveAtom; const waveaiModeConfigAtom = atom(null) as PrimitiveAtom>; @@ -67,6 +71,10 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { } return false; }) as Atom; + const hasConfigErrors = atom((get) => { + const fullConfig = get(fullConfigAtom); + return fullConfig?.configerrors != null && fullConfig.configerrors.length > 0; + }) as Atom; // this is *the* tab that this tabview represents. it should never change. const staticTabIdAtom: Atom = atom(initOpts.tabId); const controlShiftDelayAtom = atom(false); @@ -123,11 +131,13 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { builderId: builderIdAtom, builderAppId: builderAppIdAtom, uiContext: uiContextAtom, + workspaceId: workspaceIdAtom, workspace: workspaceAtom, fullConfigAtom, waveaiModeConfigAtom, settingsAtom, hasCustomAIPresetsAtom, + hasConfigErrors, staticTabId: staticTabIdAtom, isFullScreen: isFullScreenAtom, zoomFactorAtom, diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index eea579b8ce..01d4ebbc96 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -547,6 +547,7 @@ function getAllBlockComponentModels(): BlockComponentModel[] { function getFocusedBlockId(): string { const layoutModel = getLayoutModelForStaticTab(); + if (layoutModel?.focusedNode == null) return null; const focusedLayoutNode = globalStore.get(layoutModel.focusedNode); return focusedLayoutNode?.data?.blockId; } diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 97095d4b04..afa5209116 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveAIModel } from "@/app/aipanel/waveai-model"; @@ -129,11 +129,11 @@ function getStaticTabBlockCount(): number { } function simpleCloseStaticTab() { - const ws = globalStore.get(atoms.workspace); + const workspaceId = globalStore.get(atoms.workspaceId); const tabId = globalStore.get(atoms.staticTabId); const confirmClose = globalStore.get(getSettingsKeyAtom("tab:confirmclose")) ?? false; getApi() - .closeTab(ws.oid, tabId, confirmClose) + .closeTab(workspaceId, tabId, confirmClose) .then((didClose) => { if (didClose) { deleteLayoutModelForTab(tabId); @@ -490,7 +490,7 @@ function tryReinjectKey(event: WaveKeyboardEvent): boolean { function countTermBlocks(): number { const allBCMs = getAllBlockComponentModels(); let count = 0; - let gsGetBound = globalStore.get.bind(globalStore); + const gsGetBound = globalStore.get.bind(globalStore); for (const bcm of allBCMs) { const viewModel = bcm.viewModel; if (viewModel.viewType == "term" && viewModel.isBasicTerm?.(gsGetBound)) { diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index f261f7e37b..3dad2a3e5c 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -4,182 +4,233 @@ // generated by cmd/generate/main-generatets.go import * as WOS from "./wos"; +import type { WaveEnv } from "@/app/waveenv/waveenv"; + +function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise { + if (waveEnv != null) { + return waveEnv.callBackendService(service, method, args, noUIContext) + } + return WOS.callBackendService(service, method, args, noUIContext); +} // blockservice.BlockService (block) -class BlockServiceType { +export class BlockServiceType { + waveEnv: WaveEnv; + + constructor(waveEnv?: WaveEnv) { + this.waveEnv = waveEnv; + } + // queue a layout action to cleanup orphaned blocks in the tab // @returns object updates CleanupOrphanedBlocks(tabId: string): Promise { - return WOS.callBackendService("block", "CleanupOrphanedBlocks", Array.from(arguments)) + return callBackendService(this.waveEnv, "block", "CleanupOrphanedBlocks", Array.from(arguments)) } GetControllerStatus(arg2: string): Promise { - return WOS.callBackendService("block", "GetControllerStatus", Array.from(arguments)) + return callBackendService(this.waveEnv, "block", "GetControllerStatus", Array.from(arguments)) } // save the terminal state to a blockfile SaveTerminalState(blockId: string, state: string, stateType: string, ptyOffset: number, termSize: TermSize): Promise { - return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments)) + return callBackendService(this.waveEnv, "block", "SaveTerminalState", Array.from(arguments)) } SaveWaveAiData(arg2: string, arg3: WaveAIPromptMessageType[]): Promise { - return WOS.callBackendService("block", "SaveWaveAiData", Array.from(arguments)) + return callBackendService(this.waveEnv, "block", "SaveWaveAiData", Array.from(arguments)) } } export const BlockService = new BlockServiceType(); // clientservice.ClientService (client) -class ClientServiceType { +export class ClientServiceType { + waveEnv: WaveEnv; + + constructor(waveEnv?: WaveEnv) { + this.waveEnv = waveEnv; + } + // @returns object updates AgreeTos(): Promise { - return WOS.callBackendService("client", "AgreeTos", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "AgreeTos", Array.from(arguments)) } FocusWindow(arg2: string): Promise { - return WOS.callBackendService("client", "FocusWindow", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "FocusWindow", Array.from(arguments)) } GetAllConnStatus(): Promise { - return WOS.callBackendService("client", "GetAllConnStatus", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "GetAllConnStatus", Array.from(arguments)) } GetClientData(): Promise { - return WOS.callBackendService("client", "GetClientData", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "GetClientData", Array.from(arguments)) } GetTab(arg1: string): Promise { - return WOS.callBackendService("client", "GetTab", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "GetTab", Array.from(arguments)) } TelemetryUpdate(arg2: boolean): Promise { - return WOS.callBackendService("client", "TelemetryUpdate", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "TelemetryUpdate", Array.from(arguments)) } } export const ClientService = new ClientServiceType(); // objectservice.ObjectService (object) -class ObjectServiceType { +export class ObjectServiceType { + waveEnv: WaveEnv; + + constructor(waveEnv?: WaveEnv) { + this.waveEnv = waveEnv; + } + // @returns blockId (and object updates) CreateBlock(blockDef: BlockDef, rtOpts: RuntimeOpts): Promise { - return WOS.callBackendService("object", "CreateBlock", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "CreateBlock", Array.from(arguments)) } // @returns object updates DeleteBlock(blockId: string): Promise { - return WOS.callBackendService("object", "DeleteBlock", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "DeleteBlock", Array.from(arguments)) } // get wave object by oref GetObject(oref: string): Promise { - return WOS.callBackendService("object", "GetObject", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "GetObject", Array.from(arguments)) } // @returns objects GetObjects(orefs: string[]): Promise { - return WOS.callBackendService("object", "GetObjects", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "GetObjects", Array.from(arguments)) } // @returns object updates UpdateObject(waveObj: WaveObj, returnUpdates: boolean): Promise { - return WOS.callBackendService("object", "UpdateObject", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "UpdateObject", Array.from(arguments)) } // @returns object updates UpdateObjectMeta(oref: string, meta: MetaType): Promise { - return WOS.callBackendService("object", "UpdateObjectMeta", Array.from(arguments)) - } - - // @returns object updates - UpdateTabName(tabId: string, name: string): Promise { - return WOS.callBackendService("object", "UpdateTabName", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "UpdateObjectMeta", Array.from(arguments)) } } export const ObjectService = new ObjectServiceType(); // userinputservice.UserInputService (userinput) -class UserInputServiceType { +export class UserInputServiceType { + waveEnv: WaveEnv; + + constructor(waveEnv?: WaveEnv) { + this.waveEnv = waveEnv; + } + SendUserInputResponse(arg1: UserInputResponse): Promise { - return WOS.callBackendService("userinput", "SendUserInputResponse", Array.from(arguments)) + return callBackendService(this.waveEnv, "userinput", "SendUserInputResponse", Array.from(arguments)) } } export const UserInputService = new UserInputServiceType(); // windowservice.WindowService (window) -class WindowServiceType { +export class WindowServiceType { + waveEnv: WaveEnv; + + constructor(waveEnv?: WaveEnv) { + this.waveEnv = waveEnv; + } + CloseWindow(windowId: string, fromElectron: boolean): Promise { - return WOS.callBackendService("window", "CloseWindow", Array.from(arguments)) + return callBackendService(this.waveEnv, "window", "CloseWindow", Array.from(arguments)) } CreateWindow(winSize: WinSize, workspaceId: string): Promise { - return WOS.callBackendService("window", "CreateWindow", Array.from(arguments)) + return callBackendService(this.waveEnv, "window", "CreateWindow", Array.from(arguments)) } GetWindow(windowId: string): Promise { - return WOS.callBackendService("window", "GetWindow", Array.from(arguments)) + return callBackendService(this.waveEnv, "window", "GetWindow", Array.from(arguments)) } // set window position and size // @returns object updates SetWindowPosAndSize(windowId: string, pos: Point, size: WinSize): Promise { - return WOS.callBackendService("window", "SetWindowPosAndSize", Array.from(arguments)) + return callBackendService(this.waveEnv, "window", "SetWindowPosAndSize", Array.from(arguments)) } SwitchWorkspace(windowId: string, workspaceId: string): Promise { - return WOS.callBackendService("window", "SwitchWorkspace", Array.from(arguments)) + return callBackendService(this.waveEnv, "window", "SwitchWorkspace", Array.from(arguments)) } } export const WindowService = new WindowServiceType(); // workspaceservice.WorkspaceService (workspace) -class WorkspaceServiceType { +export class WorkspaceServiceType { + waveEnv: WaveEnv; + + constructor(waveEnv?: WaveEnv) { + this.waveEnv = waveEnv; + } + // @returns CloseTabRtn (and object updates) CloseTab(workspaceId: string, tabId: string, fromElectron: boolean): Promise { - return WOS.callBackendService("workspace", "CloseTab", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "CloseTab", Array.from(arguments)) } // @returns tabId (and object updates) CreateTab(workspaceId: string, tabName: string, activateTab: boolean): Promise { - return WOS.callBackendService("workspace", "CreateTab", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "CreateTab", Array.from(arguments)) } // @returns workspaceId CreateWorkspace(name: string, icon: string, color: string, applyDefaults: boolean): Promise { - return WOS.callBackendService("workspace", "CreateWorkspace", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "CreateWorkspace", Array.from(arguments)) } // @returns object updates DeleteWorkspace(workspaceId: string): Promise { - return WOS.callBackendService("workspace", "DeleteWorkspace", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "DeleteWorkspace", Array.from(arguments)) } // @returns colors GetColors(): Promise { - return WOS.callBackendService("workspace", "GetColors", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "GetColors", Array.from(arguments)) } // @returns icons GetIcons(): Promise { - return WOS.callBackendService("workspace", "GetIcons", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "GetIcons", Array.from(arguments)) } // @returns workspace GetWorkspace(workspaceId: string): Promise { - return WOS.callBackendService("workspace", "GetWorkspace", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "GetWorkspace", Array.from(arguments)) } ListWorkspaces(): Promise { - return WOS.callBackendService("workspace", "ListWorkspaces", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "ListWorkspaces", Array.from(arguments)) } // @returns object updates SetActiveTab(workspaceId: string, tabId: string): Promise { - return WOS.callBackendService("workspace", "SetActiveTab", Array.from(arguments)) - } - - // @returns object updates - UpdateTabIds(workspaceId: string, tabIds: string[]): Promise { - return WOS.callBackendService("workspace", "UpdateTabIds", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "SetActiveTab", Array.from(arguments)) } // @returns object updates UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, applyDefaults: boolean): Promise { - return WOS.callBackendService("workspace", "UpdateWorkspace", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "UpdateWorkspace", Array.from(arguments)) } } export const WorkspaceService = new WorkspaceServiceType(); +export const AllServiceTypes = { + "block": BlockServiceType, + "client": ClientServiceType, + "object": ObjectServiceType, + "userinput": UserInputServiceType, + "window": WindowServiceType, + "workspace": WorkspaceServiceType, +}; + +export const AllServiceImpls = { + "block": BlockService, + "client": ClientService, + "object": ObjectService, + "userinput": UserInputService, + "window": WindowService, + "workspace": WorkspaceService, +}; diff --git a/frontend/app/store/tab-model.ts b/frontend/app/store/tab-model.ts index 6c41e2fd84..a867440820 100644 --- a/frontend/app/store/tab-model.ts +++ b/frontend/app/store/tab-model.ts @@ -1,24 +1,28 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { useWaveEnv, WaveEnv } from "@/app/waveenv/waveenv"; +import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { atom, Atom, PrimitiveAtom } from "jotai"; import { createContext, useContext } from "react"; import { globalStore } from "./jotaiStore"; import * as WOS from "./wos"; +export type TabModelEnv = WaveEnvSubset<{ + wos: WaveEnv["wos"]; +}>; + const tabModelCache = new Map(); export const activeTabIdAtom = atom(null) as PrimitiveAtom; export class TabModel { tabId: string; - waveEnv: WaveEnv; + waveEnv: TabModelEnv; tabAtom: Atom; tabNumBlocksAtom: Atom; isTermMultiInput = atom(false) as PrimitiveAtom; metaCache: Map> = new Map(); - constructor(tabId: string, waveEnv?: WaveEnv) { + constructor(tabId: string, waveEnv?: TabModelEnv) { this.tabId = tabId; this.waveEnv = waveEnv; this.tabAtom = atom((get) => { @@ -46,16 +50,25 @@ export class TabModel { } } -export function getTabModelByTabId(tabId: string, waveEnv?: WaveEnv): TabModel { - let model = tabModelCache.get(tabId); +export function getTabModelByTabId(tabId: string, waveEnv?: TabModelEnv): TabModel { + if (!waveEnv?.isMock) { + let model = tabModelCache.get(tabId); + if (model == null) { + model = new TabModel(tabId, waveEnv); + tabModelCache.set(tabId, model); + } + return model; + } + const key = `TabModel:${tabId}`; + let model = waveEnv.mockModels.get(key); if (model == null) { model = new TabModel(tabId, waveEnv); - tabModelCache.set(tabId, model); + waveEnv.mockModels.set(key, model); } return model; } -export function getActiveTabModel(waveEnv?: WaveEnv): TabModel | null { +export function getActiveTabModel(waveEnv?: TabModelEnv): TabModel | null { const activeTabId = globalStore.get(activeTabIdAtom); if (activeTabId == null) { return null; @@ -66,11 +79,7 @@ export function getActiveTabModel(waveEnv?: WaveEnv): TabModel | null { export const TabModelContext = createContext(undefined); export function useTabModel(): TabModel { - 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"); } @@ -78,10 +87,5 @@ export function useTabModel(): TabModel { } export function useTabModelMaybe(): TabModel { - const waveEnv = useWaveEnv(); - const ctxModel = useContext(TabModelContext); - if (waveEnv?.mockTabModel != null) { - return waveEnv.mockTabModel; - } - return ctxModel; + return useContext(TabModelContext); } diff --git a/frontend/app/store/wps.ts b/frontend/app/store/wps.ts index 745734123c..332d2ba0a9 100644 --- a/frontend/app/store/wps.ts +++ b/frontend/app/store/wps.ts @@ -1,8 +1,9 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; +import { isPreviewWindow } from "@/app/store/windowtype"; import { isBlank } from "@/util/util"; import { Subject } from "rxjs"; @@ -43,6 +44,9 @@ function wpsReconnectHandler() { } function updateWaveEventSub(eventType: string) { + if (isPreviewWindow()) { + return; + } const subjects = waveEventSubjects.get(eventType); if (subjects == null) { RpcApi.EventUnsubCommand(WpsRpcClient, eventType, { noresponse: true }); @@ -84,7 +88,7 @@ function waveEventSubscribeSingle(subscription: WaveEve function waveEventUnsubscribe(...unsubscribes: WaveEventUnsubscribe[]) { const eventTypeSet = new Set(); for (const unsubscribe of unsubscribes) { - let subjects = waveEventSubjects.get(unsubscribe.eventType); + const subjects = waveEventSubjects.get(unsubscribe.eventType); if (subjects == null) { return; } diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 377396c5a0..6b9f4a72d4 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -930,6 +930,18 @@ export class RpcApiType { return client.wshRpcCall("testmultiarg", { args: [arg1, arg2, arg3] }, opts); } + // command "updatetabname" [call] + UpdateTabNameCommand(client: WshClient, arg1: string, arg2: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updatetabname", { args: [arg1, arg2] }, opts); + return client.wshRpcCall("updatetabname", { args: [arg1, arg2] }, opts); + } + + // command "updateworkspacetabids" [call] + UpdateWorkspaceTabIdsCommand(client: WshClient, arg1: string, arg2: string[], opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updateworkspacetabids", { args: [arg1, arg2] }, opts); + return client.wshRpcCall("updateworkspacetabids", { args: [arg1, arg2] }, opts); + } + // command "vdomasyncinitiation" [call] VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "vdomasyncinitiation", data, opts); diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 01a13bf13e..b86d06120d 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -2,21 +2,32 @@ // SPDX-License-Identifier: Apache-2.0 import { getTabBadgeAtom, sortBadgesForTab } from "@/app/store/badge"; -import { atoms, getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global"; -import { RpcApi } from "@/app/store/wshclientapi"; +import { getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv"; 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, useMemo, useRef, useState } from "react"; import { v7 as uuidv7 } from "uuid"; -import { ObjectService } from "../store/services"; -import { makeORef, useWaveObjectValue } from "../store/wos"; +import { makeORef } from "../store/wos"; import "./tab.scss"; +type TabEnv = WaveEnvSubset<{ + rpc: { + ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; + SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; + UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"]; + }; + atoms: { + fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + }; + wos: WaveEnv["wos"]; + showContextMenu: WaveEnv["showContextMenu"]; +}>; + interface TabVProps { tabId: string; tabName: string; @@ -95,7 +106,10 @@ const TabV = forwardRef((props, ref) => { onRename, renameRef, } = props; - const [originalName, setOriginalName] = useState(tabName); + const MaxTabNameLength = 14; + const truncateTabName = (name: string) => [...(name ?? "")].slice(0, MaxTabNameLength).join(""); + const displayName = truncateTabName(tabName); + const [originalName, setOriginalName] = useState(displayName); const [isEditable, setIsEditable] = useState(false); const editableRef = useRef(null); @@ -105,7 +119,7 @@ const TabV = forwardRef((props, ref) => { useImperativeHandle(ref, () => tabRef.current as HTMLDivElement); useEffect(() => { - setOriginalName(tabName); + setOriginalName(truncateTabName(tabName)); }, [tabName]); useEffect(() => { @@ -181,8 +195,11 @@ const TabV = forwardRef((props, ref) => { event.preventDefault(); event.stopPropagation(); } else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) { - event.preventDefault(); - event.stopPropagation(); + const selection = window.getSelection(); + if (!selection || selection.isCollapsed) { + event.preventDefault(); + event.stopPropagation(); + } } }; @@ -222,7 +239,7 @@ const TabV = forwardRef((props, ref) => { onKeyDown={handleKeyDown} suppressContentEditableWarning={true} > - {tabName} + {displayName} + + ); }; @@ -150,6 +153,7 @@ function strArrayIsEqual(a: string[], b: string[]) { } const TabBar = memo(({ workspace }: TabBarProps) => { + const env = useWaveEnv(); const [tabIds, setTabIds] = useState([]); const [dragStartPositions, setDragStartPositions] = useState([]); const [draggingTab, setDraggingTab] = useState(); @@ -174,19 +178,21 @@ const TabBar = memo(({ workspace }: TabBarProps) => { }); const osInstanceRef = useRef(null); const draggerLeftRef = useRef(null); - const draggerRightRef = useRef(null); + const rightContainerRef = useRef(null); const workspaceSwitcherRef = useRef(null); const waveAIButtonRef = useRef(null); const appMenuButtonRef = useRef(null); const tabWidthRef = useRef(TabDefaultWidth); const scrollableRef = useRef(false); - const updateStatusBannerRef = useRef(null); - const configErrorButtonRef = useRef(null); const prevAllLoadedRef = useRef(false); - const activeTabId = useAtomValue(atoms.staticTabId); - const isFullScreen = useAtomValue(atoms.isFullScreen); - const zoomFactor = useAtomValue(atoms.zoomFactorAtom); - const settings = useAtomValue(atoms.settingsAtom); + const activeTabId = useAtomValue(env.atoms.staticTabId); + const isFullScreen = useAtomValue(env.atoms.isFullScreen); + const zoomFactor = useAtomValue(env.atoms.zoomFactorAtom); + const showMenuBar = useAtomValue(env.getSettingsKeyAtom("window:showmenubar")); + const confirmClose = useAtomValue(env.getSettingsKeyAtom("tab:confirmclose")) ?? false; + const hideAiButton = useAtomValue(env.getSettingsKeyAtom("app:hideaibutton")); + const appUpdateStatus = useAtomValue(env.atoms.updaterStatusAtom); + const hasConfigErrors = useAtomValue(env.atoms.hasConfigErrors); let prevDelta: number; let prevDragDirection: string; @@ -230,22 +236,24 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const tabBar = tabBarRef.current; if (tabBar === null) return; + const getOuterWidth = (el: HTMLElement): number => { + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + return rect.width + parseFloat(style.marginLeft) + parseFloat(style.marginRight); + }; + const tabbarWrapperWidth = tabbarWrapperRef.current.getBoundingClientRect().width; const windowDragLeftWidth = draggerLeftRef.current.getBoundingClientRect().width; - const windowDragRightWidth = draggerRightRef.current?.getBoundingClientRect().width ?? 0; - const addBtnWidth = addBtnRef.current.getBoundingClientRect().width; - const updateStatusLabelWidth = updateStatusBannerRef.current?.getBoundingClientRect().width ?? 0; - const configErrorWidth = configErrorButtonRef.current?.getBoundingClientRect().width ?? 0; + const rightContainerWidth = rightContainerRef.current?.getBoundingClientRect().width ?? 0; + const addBtnWidth = getOuterWidth(addBtnRef.current); const appMenuButtonWidth = appMenuButtonRef.current?.getBoundingClientRect().width ?? 0; const workspaceSwitcherWidth = workspaceSwitcherRef.current?.getBoundingClientRect().width ?? 0; - const waveAIButtonWidth = waveAIButtonRef.current?.getBoundingClientRect().width ?? 0; + const waveAIButtonWidth = waveAIButtonRef.current != null ? getOuterWidth(waveAIButtonRef.current) : 0; const nonTabElementsWidth = windowDragLeftWidth + - windowDragRightWidth + + rightContainerWidth + addBtnWidth + - updateStatusLabelWidth + - configErrorWidth + appMenuButtonWidth + workspaceSwitcherWidth + waveAIButtonWidth; @@ -306,20 +314,23 @@ const TabBar = memo(({ workspace }: TabBarProps) => { saveTabsPositionDebounced(); }, [tabIds, newTabId, isFullScreen]); - const reinitVersion = useAtomValue(atoms.reinitVersion); + // update layout on reinit version + const reinitVersion = useAtomValue(env.atoms.reinitVersion); useEffect(() => { if (reinitVersion > 0) { setSizeAndPosition(); } }, [reinitVersion]); + // update layout on resize useEffect(() => { - window.addEventListener("resize", () => handleResizeTabs()); + window.addEventListener("resize", handleResizeTabs); return () => { - window.removeEventListener("resize", () => handleResizeTabs()); + window.removeEventListener("resize", handleResizeTabs); }; }, [handleResizeTabs]); + // update layout on changed tabIds, tabsLoaded, newTabId, hideAiButton, appUpdateStatus, hasConfigErrors, or zoomFactor useEffect(() => { // Check if all tabs are loaded const allLoaded = tabIds.length > 0 && tabIds.every((id) => tabsLoaded[id]); @@ -330,7 +341,17 @@ const TabBar = memo(({ workspace }: TabBarProps) => { prevAllLoadedRef.current = true; } } - }, [tabIds, tabsLoaded, newTabId, saveTabsPosition]); + }, [ + tabIds, + tabsLoaded, + newTabId, + saveTabsPosition, + hideAiButton, + appUpdateStatus, + hasConfigErrors, + zoomFactor, + showMenuBar, + ]); const getDragDirection = (currentX: number) => { let dragDirection: string; @@ -483,7 +504,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { // Reset dragging state setDraggingTab(null); // Update workspace tab ids - fireAndForget(() => WorkspaceService.UpdateTabIds(workspace.oid, tabIds)); + fireAndForget(() => env.rpc.UpdateWorkspaceTabIdsCommand(TabRpcClient, workspace.oid, tabIds)); }), [] ); @@ -547,7 +568,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const handleSelectTab = (tabId: string) => { if (!draggingTabDataRef.current.dragged) { - setActiveTab(tabId); + env.electron.setActiveTab(tabId); } }; @@ -569,7 +590,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { ); const handleAddTab = () => { - createTab(); + env.electron.createTab(); tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease"); updateScrollDebounced(); @@ -579,10 +600,8 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const handleCloseTab = (event: React.MouseEvent | null, tabId: string) => { event?.stopPropagation(); - const ws = globalStore.get(atoms.workspace); - const confirmClose = globalStore.get(getSettingsKeyAtom("tab:confirmclose")) ?? false; - getApi() - .closeTab(ws.oid, tabId, confirmClose) + env.electron + .closeTab(workspace.oid, tabId, confirmClose) .then((didClose) => { if (didClose) { tabsWrapperRef.current?.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease"); @@ -607,15 +626,15 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const activeTabIndex = tabIds.indexOf(activeTabId); function onEllipsisClick() { - getApi().showWorkspaceAppMenu(workspace.oid); + env.electron.showWorkspaceAppMenu(workspace.oid); } const tabsWrapperWidth = tabIds.length * tabWidthRef.current; - const showAppMenuButton = isWindows() || (!isMacOS() && !settings["window:showmenubar"]); + const showAppMenuButton = env.isWindows() || (!env.isMacOS() && !showMenuBar); // Calculate window drag left width based on platform and state let windowDragLeftWidth = 10; - if (isMacOS() && !isFullScreen) { + if (env.isMacOS() && !isFullScreen) { if (zoomFactor > 0) { windowDragLeftWidth = 74 / zoomFactor; } else { @@ -625,7 +644,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { // Calculate window drag right width let windowDragRightWidth = 12; - if (isWindows()) { + if (env.isWindows()) { if (zoomFactor > 0) { windowDragRightWidth = 139 / zoomFactor; } else { @@ -633,12 +652,6 @@ const TabBar = memo(({ workspace }: TabBarProps) => { } } - const addtabButtonDecl: IconButtonDecl = { - elemtype: "iconbutton", - icon: "plus", - click: handleAddTab, - title: "Add Tab", - }; return (
{ })}
- -
- - + +
+
+ +
@@ -704,4 +725,4 @@ const TabBar = memo(({ workspace }: TabBarProps) => { ); }); -export { TabBar }; +export { ConfigErrorIcon, ConfigErrorMessage, TabBar, WaveAIButton }; diff --git a/frontend/app/tab/tabbarenv.ts b/frontend/app/tab/tabbarenv.ts new file mode 100644 index 0000000000..240c2585a1 --- /dev/null +++ b/frontend/app/tab/tabbarenv.ts @@ -0,0 +1,31 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; + +export type TabBarEnv = WaveEnvSubset<{ + electron: { + createTab: WaveEnv["electron"]["createTab"]; + closeTab: WaveEnv["electron"]["closeTab"]; + setActiveTab: WaveEnv["electron"]["setActiveTab"]; + showWorkspaceAppMenu: WaveEnv["electron"]["showWorkspaceAppMenu"]; + installAppUpdate: WaveEnv["electron"]["installAppUpdate"]; + }; + rpc: { + UpdateWorkspaceTabIdsCommand: WaveEnv["rpc"]["UpdateWorkspaceTabIdsCommand"]; + }; + atoms: { + fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + hasConfigErrors: WaveEnv["atoms"]["hasConfigErrors"]; + staticTabId: WaveEnv["atoms"]["staticTabId"]; + isFullScreen: WaveEnv["atoms"]["isFullScreen"]; + zoomFactorAtom: WaveEnv["atoms"]["zoomFactorAtom"]; + reinitVersion: WaveEnv["atoms"]["reinitVersion"]; + updaterStatusAtom: WaveEnv["atoms"]["updaterStatusAtom"]; + }; + wos: WaveEnv["wos"]; + getSettingsKeyAtom: SettingsKeyAtomFnType<"app:hideaibutton" | "tab:confirmclose" | "window:showmenubar">; + mockSetWaveObj: WaveEnv["mockSetWaveObj"]; + isWindows: WaveEnv["isWindows"]; + isMacOS: WaveEnv["isMacOS"]; +}>; diff --git a/frontend/app/tab/updatebanner.tsx b/frontend/app/tab/updatebanner.tsx index e14cc561ba..5150c7e338 100644 --- a/frontend/app/tab/updatebanner.tsx +++ b/frontend/app/tab/updatebanner.tsx @@ -1,69 +1,54 @@ -import { Button } from "@/element/button"; -import { atoms, getApi } from "@/store/global"; -import { useAtomValue } from "jotai"; -import { forwardRef, memo, useEffect, useState } from "react"; +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 -const UpdateStatusBannerComponent = forwardRef((_, ref) => { - let appUpdateStatus = useAtomValue(atoms.updaterStatusAtom); - let [updateStatusMessage, setUpdateStatusMessage] = useState(); - const [dismissBannerTimeout, setDismissBannerTimeout] = useState(); +import { Tooltip } from "@/element/tooltip"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { TabBarEnv } from "./tabbarenv"; +import { useAtomValue } from "jotai"; +import { memo, useCallback } from "react"; - useEffect(() => { - let message: string; - let dismissBanner = false; - switch (appUpdateStatus) { - case "ready": - message = "Update Available"; - break; - case "downloading": - message = "Downloading Update"; - break; - case "installing": - message = "Installing Update"; - break; - case "error": - message = "Updater Error: Try Checking Again"; - dismissBanner = true; - break; - default: - break; - } - setUpdateStatusMessage(message); +function getUpdateStatusMessage(status: string): string { + switch (status) { + case "ready": + return "Update"; + case "downloading": + return "Downloading"; + case "installing": + return "Installing"; + default: + return null; + } +} - // Clear any existing timeout - if (dismissBannerTimeout) { - clearTimeout(dismissBannerTimeout); - } +const UpdateStatusBannerComponent = () => { + const env = useWaveEnv(); + const appUpdateStatus = useAtomValue(env.atoms.updaterStatusAtom); + const updateStatusMessage = getUpdateStatusMessage(appUpdateStatus); - // If we want to dismiss the banner, set the new timeout, otherwise clear the state - if (dismissBanner) { - setDismissBannerTimeout( - setTimeout(() => { - setUpdateStatusMessage(null); - setDismissBannerTimeout(null); - }, 10000) - ); - } else { - setDismissBannerTimeout(null); - } - }, [appUpdateStatus]); + const onClick = useCallback(() => { + env.electron.installAppUpdate(); + }, [env]); - function onClick() { - getApi().installAppUpdate(); + if (!updateStatusMessage) { + return null; } - if (updateStatusMessage) { - return ( - - ); - } -}); -export const UpdateStatusBanner = memo(UpdateStatusBannerComponent) as typeof UpdateStatusBannerComponent; + const isReady = appUpdateStatus === "ready"; + const tooltipContent = isReady ? "Click to Install Update" : updateStatusMessage; + + return ( + + + {updateStatusMessage} + + ); +}; +UpdateStatusBannerComponent.displayName = "UpdateStatusBannerComponent"; + +export const UpdateStatusBanner = memo(UpdateStatusBannerComponent); diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index 7a253b81e4..5cc17516ec 100644 --- a/frontend/app/tab/workspaceswitcher.tsx +++ b/frontend/app/tab/workspaceswitcher.tsx @@ -1,6 +1,7 @@ -// Copyright 2025, Command Line +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { ExpandableMenu, ExpandableMenuItem, @@ -18,13 +19,27 @@ import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { CSSProperties, forwardRef, useCallback, useEffect } from "react"; import WorkspaceSVG from "../asset/workspace.svg"; import { IconButton } from "../element/iconbutton"; -import { atoms, getApi } from "../store/global"; -import { WorkspaceService } from "../store/services"; -import { getObjectValue, makeORef } from "../store/wos"; +import { globalStore } from "@/app/store/jotaiStore"; +import { makeORef } from "../store/wos"; import { waveEventSubscribeSingle } from "../store/wps"; import { WorkspaceEditor } from "./workspaceeditor"; import "./workspaceswitcher.scss"; +export type WorkspaceSwitcherEnv = WaveEnvSubset<{ + electron: { + deleteWorkspace: WaveEnv["electron"]["deleteWorkspace"]; + createWorkspace: WaveEnv["electron"]["createWorkspace"]; + switchWorkspace: WaveEnv["electron"]["switchWorkspace"]; + }; + atoms: { + workspace: WaveEnv["atoms"]["workspace"]; + }; + services: { + workspace: WaveEnv["services"]["workspace"]; + }; + wos: WaveEnv["wos"]; +}>; + type WorkspaceListEntry = { windowId: string; workspace: Workspace; @@ -35,23 +50,24 @@ const workspaceMapAtom = atom([]); const workspaceSplitAtom = splitAtom(workspaceMapAtom); const editingWorkspaceAtom = atom(); const WorkspaceSwitcher = forwardRef((_, ref) => { + const env = useWaveEnv(); const setWorkspaceList = useSetAtom(workspaceMapAtom); - const activeWorkspace = useAtomValueSafe(atoms.workspace); + const activeWorkspace = useAtomValueSafe(env.atoms.workspace); const workspaceList = useAtomValue(workspaceSplitAtom); const setEditingWorkspace = useSetAtom(editingWorkspaceAtom); const updateWorkspaceList = useCallback(async () => { - const workspaceList = await WorkspaceService.ListWorkspaces(); + const workspaceList = await env.services.workspace.ListWorkspaces(); if (!workspaceList) { return; } const newList: WorkspaceList = []; for (const entry of workspaceList) { // This just ensures that the atom exists for easier setting of the object - getObjectValue(makeORef("workspace", entry.workspaceid)); + globalStore.get(env.wos.getWaveObjectAtom(makeORef("workspace", entry.workspaceid))); newList.push({ windowId: entry.windowid, - workspace: await WorkspaceService.GetWorkspace(entry.workspaceid), + workspace: await env.services.workspace.GetWorkspace(entry.workspaceid), }); } setWorkspaceList(newList); @@ -71,7 +87,7 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { }, []); const onDeleteWorkspace = useCallback((workspaceId: string) => { - getApi().deleteWorkspace(workspaceId); + env.electron.deleteWorkspace(workspaceId); }, []); const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon); @@ -84,7 +100,7 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { const saveWorkspace = () => { fireAndForget(async () => { - await WorkspaceService.UpdateWorkspace(activeWorkspace.oid, "", "", "", true); + await env.services.workspace.UpdateWorkspace(activeWorkspace.oid, "", "", "", true); await updateWorkspaceList(); setEditingWorkspace(activeWorkspace.oid); }); @@ -118,7 +134,7 @@ const WorkspaceSwitcher = forwardRef((_, ref) => {
{isActiveWorkspaceSaved ? ( - getApi().createWorkspace()}> + env.electron.createWorkspace()}> @@ -145,7 +161,8 @@ const WorkspaceSwitcherItem = ({ entryAtom: PrimitiveAtom; onDeleteWorkspace: (workspaceId: string) => void; }) => { - const activeWorkspace = useAtomValueSafe(atoms.workspace); + const env = useWaveEnv(); + const activeWorkspace = useAtomValueSafe(env.atoms.workspace); const [workspaceEntry, setWorkspaceEntry] = useAtom(entryAtom); const [editingWorkspace, setEditingWorkspace] = useAtom(editingWorkspaceAtom); @@ -156,7 +173,7 @@ const WorkspaceSwitcherItem = ({ setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace }); if (newWorkspace.name != "") { fireAndForget(() => - WorkspaceService.UpdateWorkspace( + env.services.workspace.UpdateWorkspace( workspace.oid, newWorkspace.name, newWorkspace.icon, @@ -200,7 +217,7 @@ const WorkspaceSwitcherItem = ({ > { - getApi().switchWorkspace(workspace.oid); + env.electron.switchWorkspace(workspace.oid); // Create a fake escape key event to close the popover document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); }} diff --git a/frontend/app/view/sysinfo/sysinfo.tsx b/frontend/app/view/sysinfo/sysinfo.tsx index dca9d6d09f..30feead6c8 100644 --- a/frontend/app/view/sysinfo/sysinfo.tsx +++ b/frontend/app/view/sysinfo/sysinfo.tsx @@ -14,10 +14,10 @@ import * as React from "react"; import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import type { BlockMetaKeyAtomFnType, WaveEnv } from "@/app/waveenv/waveenv"; +import type { BlockMetaKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; -export type SysinfoEnv = { +export type SysinfoEnv = WaveEnvSubset<{ rpc: { EventReadHistoryCommand: WaveEnv["rpc"]["EventReadHistoryCommand"]; SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; @@ -27,7 +27,7 @@ export type SysinfoEnv = { }; getConnStatusAtom: WaveEnv["getConnStatusAtom"]; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"graph:numpoints" | "sysinfo:type" | "connection" | "count">; -}; +}>; const DefaultNumPoints = 120; diff --git a/frontend/app/view/term/osc-handlers.ts b/frontend/app/view/term/osc-handlers.ts index 25fdf0e89b..f44659d2c6 100644 --- a/frontend/app/view/term/osc-handlers.ts +++ b/frontend/app/view/term/osc-handlers.ts @@ -12,7 +12,6 @@ import { recordTEvent, WOS, } from "@/store/global"; -import * as services from "@/store/services"; import { base64ToString, fireAndForget, isSshConnName, isWslConnName } from "@/util/util"; import debug from "debug"; import type { TermWrap } from "./termwrap"; @@ -243,8 +242,9 @@ export function handleOsc7Command(data: string, blockId: string, loaded: boolean setTimeout(() => { fireAndForget(async () => { - await services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", blockId), { - "cmd:cwd": pathPart, + await RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", blockId), + meta: { "cmd:cwd": pathPart }, }); const rtInfo = { "shell:hascurcwd": true }; diff --git a/frontend/app/waveenv/waveenv.ts b/frontend/app/waveenv/waveenv.ts index 8a75072d79..df1cb01c4a 100644 --- a/frontend/app/waveenv/waveenv.ts +++ b/frontend/app/waveenv/waveenv.ts @@ -1,7 +1,7 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import type { TabModel } from "@/app/store/tab-model"; +import type { AllServiceImpls } from "@/app/store/services"; import { RpcApiType } from "@/app/store/wshclientapi"; import { Atom, PrimitiveAtom } from "jotai"; import React from "react"; @@ -33,18 +33,27 @@ type ComplexWaveEnvKeys = { electron: WaveEnv["electron"]; atoms: WaveEnv["atoms"]; wos: WaveEnv["wos"]; + services: WaveEnv["services"]; }; -export type WaveEnvSubset = OmitNever<{ - [K in keyof T]: K extends keyof ComplexWaveEnvKeys - ? Subset - : K extends keyof WaveEnv - ? T[K] - : never; -}>; +type WaveEnvMockFields = { + isMock: WaveEnv["isMock"]; + mockSetWaveObj: WaveEnv["mockSetWaveObj"]; + mockModels: WaveEnv["mockModels"]; +}; + +export type WaveEnvSubset = WaveEnvMockFields & + 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 = { + isMock: boolean; electron: ElectronApi; rpc: RpcApiType; platform: NodeJS.Platform; @@ -53,6 +62,8 @@ export type WaveEnv = { isMacOS: () => boolean; atoms: GlobalAtomsType; createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => Promise; + services: typeof AllServiceImpls; + callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => Promise; showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => void; getConnStatusAtom: (conn: string) => PrimitiveAtom; getLocalHostDisplayNameAtom: () => Atom; @@ -65,7 +76,10 @@ export type WaveEnv = { getSettingsKeyAtom: SettingsKeyAtomFnType; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType; getConnConfigKeyAtom: ConnConfigKeyAtomFnType; - mockTabModel?: TabModel; + + // the mock fields are only usable in the preview server (may be be null or throw errors in production) + mockSetWaveObj: (oref: string, obj: T) => void; + mockModels: Map; }; export const WaveEnvContext = React.createContext(null); diff --git a/frontend/app/waveenv/waveenvimpl.ts b/frontend/app/waveenv/waveenvimpl.ts index 1d78172d04..4f9e234eca 100644 --- a/frontend/app/waveenv/waveenvimpl.ts +++ b/frontend/app/waveenv/waveenvimpl.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ContextMenuModel } from "@/app/store/contextmenu"; +import { AllServiceImpls } from "@/app/store/services"; import { atoms, createBlock, @@ -19,6 +20,7 @@ import { isMacOS, isWindows, PLATFORM } from "@/util/platformutil"; export function makeWaveEnvImpl(): WaveEnv { return { + isMock: false, electron: (window as any).api, rpc: RpcApi, getSettingsKeyAtom, @@ -28,6 +30,8 @@ export function makeWaveEnvImpl(): WaveEnv { isMacOS, atoms, createBlock, + services: AllServiceImpls, + callBackendService: WOS.callBackendService, showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => { ContextMenuModel.getInstance().showContextMenu(menu, e); }, @@ -41,5 +45,10 @@ export function makeWaveEnvImpl(): WaveEnv { }, getBlockMetaKeyAtom, getConnConfigKeyAtom, + + mockSetWaveObj: (_oref: string, _obj: T) => { + throw new Error("mockSetWaveObj is only available in the preview server"); + }, + mockModels: new Map(), }; } diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index 2d73119154..2ec171953e 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -30,7 +30,7 @@ export type WidgetsEnv = WaveEnvSubset<{ }; atoms: { fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; - workspace: WaveEnv["atoms"]["workspace"]; + workspaceId: WaveEnv["atoms"]["workspaceId"]; hasCustomAIPresetsAtom: WaveEnv["atoms"]["hasCustomAIPresetsAtom"]; }; createBlock: WaveEnv["createBlock"]; @@ -348,7 +348,7 @@ SettingsFloatingWindow.displayName = "SettingsFloatingWindow"; const Widgets = memo(() => { const env = useWaveEnv(); const fullConfig = useAtomValue(env.atoms.fullConfigAtom); - const workspace = useAtomValue(env.atoms.workspace); + const workspaceId = useAtomValue(env.atoms.workspaceId); const hasCustomAIPresets = useAtomValue(env.atoms.hasCustomAIPresetsAtom); const [mode, setMode] = useState<"normal" | "compact" | "supercompact">("normal"); const containerRef = useRef(null); @@ -361,7 +361,7 @@ const Widgets = memo(() => { if (!hasCustomAIPresets && key === "defwidget@ai") { return false; } - return shouldIncludeWidgetForWorkspace(widget, workspace?.oid); + return shouldIncludeWidgetForWorkspace(widget, workspaceId); }) ); const widgets = sortByDisplayOrder(filteredWidgets); diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index fdcfb02ba3..fdbeb60e4a 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -2,16 +2,42 @@ // SPDX-License-Identifier: Apache-2.0 import { makeDefaultConnStatus } from "@/app/store/global"; -import { TabModel } from "@/app/store/tab-model"; +import { globalStore } from "@/app/store/jotaiStore"; +import { AllServiceTypes } from "@/app/store/services"; +import { handleWaveEvent } from "@/app/store/wps"; 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 { Atom, atom, PrimitiveAtom, useAtomValue } from "jotai"; import { DefaultFullConfig } from "./defaultconfig"; import { previewElectronApi } from "./preview-electron-api"; +// What works "out of the box" in the mock environment (no MockEnv overrides needed): +// +// RPC calls (handled in makeMockRpc): +// - rpc.EventPublishCommand -- dispatches to handleWaveEvent(); works when the subscriber +// is purely FE-based (registered via WPS on the frontend) +// - rpc.GetMetaCommand -- reads .meta from the mock WOS atom for the given oref +// - rpc.SetMetaCommand -- writes .meta to the mock WOS atom (null values delete keys) +// - rpc.UpdateTabNameCommand -- updates .name on the Tab WaveObj in the mock WOS +// - rpc.UpdateWorkspaceTabIdsCommand -- updates .tabids on the Workspace WaveObj in the mock WOS +// +// Any other RPC call falls through to a console.log and resolves null. +// Override specific calls via MockEnv.rpc (keys are the Command method names, e.g. "GetMetaCommand"). +// +// Backend service calls (handled in callBackendService): +// Any call falls through to a console.log and resolves null. +// Override specific calls via MockEnv.services: { Service: { Method: impl } } +// e.g. { "block": { "GetControllerStatus": (blockId) => myStatus } } + type RpcOverrides = { - [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => any; + [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => Promise; +}; + +type ServiceOverrides = { + [Service: string]: { + [Method: string]: (...args: any[]) => Promise; + }; }; export type MockEnv = { @@ -20,6 +46,7 @@ export type MockEnv = { platform?: NodeJS.Platform; settings?: Partial; rpc?: RpcOverrides; + services?: ServiceOverrides; atoms?: Partial; electron?: Partial; createBlock?: WaveEnv["createBlock"]; @@ -38,12 +65,23 @@ function mergeRecords(base: Record, overrides: Record): } export function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv { + let mergedServices: ServiceOverrides; + if (base.services != null || overrides.services != null) { + mergedServices = {}; + for (const svc of Object.keys(base.services ?? {})) { + mergedServices[svc] = { ...(base.services[svc] ?? {}) }; + } + for (const svc of Object.keys(overrides.services ?? {})) { + mergedServices[svc] = { ...(mergedServices[svc] ?? {}), ...(overrides.services[svc] ?? {}) }; + } + } 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, + services: mergedServices, atoms: overrides.atoms != null || base.atoms != null ? { ...base.atoms, ...overrides.atoms } : undefined, electron: overrides.electron != null || base.electron != null @@ -73,9 +111,10 @@ function makeMockSettingsKeyAtom( } function makeMockGlobalAtoms( - settingsOverrides?: Partial, - atomOverrides?: Partial, - tabId?: string + settingsOverrides: Partial, + atomOverrides: Partial, + tabId: string, + getWaveObjectAtom: (oref: string) => PrimitiveAtom ): GlobalAtomsType { let fullConfig = DefaultFullConfig; if (settingsOverrides) { @@ -86,15 +125,28 @@ function makeMockGlobalAtoms( } const fullConfigAtom = atom(fullConfig) as PrimitiveAtom; const settingsAtom = atom((get) => get(fullConfigAtom)?.settings ?? {}) as Atom; + const workspaceIdAtom: Atom = atomOverrides?.workspaceId ?? (atom(null as string) as Atom); + const workspaceAtom: Atom = atom((get) => { + const wsId = get(workspaceIdAtom); + if (wsId == null) { + return null; + } + return get(getWaveObjectAtom("workspace:" + wsId)); + }); const defaults: GlobalAtomsType = { builderId: atom(""), builderAppId: atom("") as any, uiContext: atom({ windowid: "", activetabid: tabId ?? "" } as UIContext), - workspace: atom(null as Workspace), + workspaceId: workspaceIdAtom, + workspace: workspaceAtom, fullConfigAtom, waveaiModeConfigAtom: atom({}) as any, settingsAtom, hasCustomAIPresetsAtom: atom(false), + hasConfigErrors: atom((get) => { + const c = get(fullConfigAtom); + return c?.configerrors != null && c.configerrors.length > 0; + }), staticTabId: atom(tabId ?? ""), isFullScreen: atom(false) as any, zoomFactorAtom: atom(1.0) as any, @@ -110,15 +162,67 @@ function makeMockGlobalAtoms( if (!atomOverrides) { return defaults; } - return { ...defaults, ...atomOverrides }; + const merged = { ...defaults, ...atomOverrides }; + if (!atomOverrides.workspace) { + merged.workspace = workspaceAtom; + } + return merged; } -export function makeMockRpc(overrides?: RpcOverrides): RpcApiType { - const dispatchMap = new Map any>(); +type MockWosFns = { + getWaveObjectAtom: (oref: string) => PrimitiveAtom; + mockSetWaveObj: (oref: string, obj: T) => void; +}; + +export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiType { + const dispatchMap = new Map Promise>(); + dispatchMap.set("eventpublish", async (_client, data: WaveEvent) => { + console.log("[mock eventpublish]", data); + handleWaveEvent(data); + return null; + }); + dispatchMap.set("getmeta", async (_client, data: CommandGetMetaData) => { + const objAtom = wos.getWaveObjectAtom(data.oref); + const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType }; + return current?.meta ?? {}; + }); + dispatchMap.set("setmeta", async (_client, data: CommandSetMetaData) => { + const objAtom = wos.getWaveObjectAtom(data.oref); + const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType }; + const updatedMeta = { ...(current?.meta ?? {}) }; + for (const [key, value] of Object.entries(data.meta)) { + if (value === null) { + delete updatedMeta[key]; + } else { + (updatedMeta as any)[key] = value; + } + } + const updated = { ...current, meta: updatedMeta }; + wos.mockSetWaveObj(data.oref, updated); + return null; + }); + dispatchMap.set("updatetabname", async (_client, data: { args: [string, string] }) => { + const [tabId, newName] = data.args; + const tabORef = "tab:" + tabId; + const objAtom = wos.getWaveObjectAtom(tabORef); + const current = globalStore.get(objAtom) as Tab; + const updated = { ...current, name: newName }; + wos.mockSetWaveObj(tabORef, updated); + return null; + }); + dispatchMap.set("updateworkspacetabids", async (_client, data: { args: [string, string[]] }) => { + const [workspaceId, tabIds] = data.args; + const wsORef = "workspace:" + workspaceId; + const objAtom = wos.getWaveObjectAtom(wsORef); + const current = globalStore.get(objAtom) as Workspace; + const updated = { ...current, tabids: tabIds }; + wos.mockSetWaveObj(wsORef, updated); + return null; + }); 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); + dispatchMap.set(cmdName, overrides[key] as (...args: any[]) => Promise); } } const rpc = new RpcApiType(); @@ -134,7 +238,7 @@ export function makeMockRpc(overrides?: RpcOverrides): RpcApiType { async *mockWshRpcStream(_client, command, data, _opts) { const fn = dispatchMap.get(command); if (fn) { - yield* fn(_client, data, _opts); + yield await fn(_client, data, _opts); return; } console.log("[mock rpc stream]", command, data); @@ -154,10 +258,18 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { const overrides: MockEnv = mockEnv ?? {}; const platform = overrides.platform ?? PlatformMacOS; const connStatusAtomCache = new Map>(); - const waveObjectAtomCache = new Map>(); + const waveObjectValueAtomCache = new Map>(); + const waveObjectDerivedAtomCache = new Map>(); const blockMetaKeyAtomCache = new Map>(); const connConfigKeyAtomCache = new Map>(); - const atoms = makeMockGlobalAtoms(overrides.settings, overrides.atoms, overrides.tabId); + const getWaveObjectAtom = (oref: string): PrimitiveAtom => { + if (!waveObjectValueAtomCache.has(oref)) { + const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T; + waveObjectValueAtomCache.set(oref, atom(obj) as PrimitiveAtom); + } + return waveObjectValueAtomCache.get(oref) as PrimitiveAtom; + }; + const atoms = makeMockGlobalAtoms(overrides.settings, overrides.atoms, overrides.tabId, getWaveObjectAtom); const localHostDisplayNameAtom = atom((get) => { const configValue = get(atoms.settingsAtom)?.["conn:localhostdisplayname"]; if (configValue != null) { @@ -165,14 +277,24 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { } return "user@localhost"; }); + const mockWosFns: MockWosFns = { + getWaveObjectAtom, + mockSetWaveObj: (oref: string, obj: T) => { + if (!waveObjectValueAtomCache.has(oref)) { + waveObjectValueAtomCache.set(oref, atom(null as WaveObj)); + } + globalStore.set(waveObjectValueAtomCache.get(oref), obj); + }, + }; const env = { + isMock: true, mockEnv: overrides, electron: { ...previewElectronApi, getPlatform: () => platform, ...overrides.electron, }, - rpc: makeMockRpc(overrides.rpc), + rpc: makeMockRpc(overrides.rpc, mockWosFns), atoms, getSettingsKeyAtom: makeMockSettingsKeyAtom(atoms.settingsAtom, overrides.settings), platform, @@ -201,34 +323,27 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { return connStatusAtomCache.get(conn); }, 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; - }, + getWaveObjectAtom: mockWosFns.getWaveObjectAtom, getWaveObjectLoadingAtom: (oref: string) => { const cacheKey = oref + ":loading"; - if (!waveObjectAtomCache.has(cacheKey)) { - waveObjectAtomCache.set(cacheKey, atom(false)); + if (!waveObjectDerivedAtomCache.has(cacheKey)) { + waveObjectDerivedAtomCache.set(cacheKey, atom(false)); } - return waveObjectAtomCache.get(cacheKey) as Atom; + return waveObjectDerivedAtomCache.get(cacheKey) as Atom; }, isWaveObjectNullAtom: (oref: string) => { const cacheKey = oref + ":isnull"; - if (!waveObjectAtomCache.has(cacheKey)) { - waveObjectAtomCache.set( + if (!waveObjectDerivedAtomCache.has(cacheKey)) { + waveObjectDerivedAtomCache.set( cacheKey, atom((get) => get(env.wos.getWaveObjectAtom(oref)) == null) ); } - return waveObjectAtomCache.get(cacheKey) as Atom; + return waveObjectDerivedAtomCache.get(cacheKey) as Atom; }, useWaveObjectValue: (oref: string): [T, boolean] => { - const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T; - return [obj, false]; + const objAtom = env.wos.getWaveObjectAtom(oref); + return [useAtomValue(objAtom), false]; }, }, getBlockMetaKeyAtom: (blockId: string, key: T) => { @@ -255,10 +370,20 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { } return connConfigKeyAtomCache.get(cacheKey) as Atom; }, - mockTabModel: null as TabModel, + services: null as any, + callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => { + const fn = overrides.services?.[service]?.[method]; + if (fn) { + return fn(...args); + } + console.log("[mock callBackendService]", service, method, args, noUIContext); + return Promise.resolve(null); + }, + mockSetWaveObj: mockWosFns.mockSetWaveObj, + mockModels: new Map(), } as MockWaveEnv; - if (overrides.tabId != null) { - env.mockTabModel = new TabModel(overrides.tabId, env); - } + env.services = Object.fromEntries( + Object.entries(AllServiceTypes).map(([key, ServiceClass]) => [key, new ServiceClass(env)]) + ) as any; return env; } diff --git a/frontend/preview/preview.tsx b/frontend/preview/preview.tsx index 9cb03c0014..303c9ab443 100644 --- a/frontend/preview/preview.tsx +++ b/frontend/preview/preview.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; +import { ErrorBoundary } from "@/app/element/errorboundary"; import { getAtoms, initGlobalAtoms } from "@/app/store/global-atoms"; import { GlobalModel } from "@/app/store/global-model"; import { globalStore } from "@/app/store/jotaiStore"; @@ -13,6 +14,7 @@ import { createRoot } from "react-dom/client"; import { makeMockWaveEnv } from "./mock/mockwaveenv"; import { installPreviewElectronApi } from "./mock/preview-electron-api"; +import "overlayscrollbars/overlayscrollbars.css"; import "../app/app.scss"; // preview.css should come *after* app.scss (don't remove the newline above otherwise prettier will reorder these imports) @@ -95,6 +97,7 @@ function PreviewRoot() { atoms: { uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext), staticTabId: atom(PreviewTabId), + workspaceId: atom(PreviewWorkspaceId), }, }) ); @@ -118,9 +121,11 @@ function PreviewApp() { <>
- - - + + + + +
); @@ -143,6 +148,7 @@ function PreviewApp() { const PreviewTabId = crypto.randomUUID(); const PreviewWindowId = crypto.randomUUID(); +const PreviewWorkspaceId = crypto.randomUUID(); const PreviewClientId = crypto.randomUUID(); function initPreview() { diff --git a/frontend/preview/previews/tabbar.preview.tsx b/frontend/preview/previews/tabbar.preview.tsx new file mode 100644 index 0000000000..104ef4f8a6 --- /dev/null +++ b/frontend/preview/previews/tabbar.preview.tsx @@ -0,0 +1,306 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { loadBadges, LoadBadgesEnv } from "@/app/store/badge"; +import { globalStore } from "@/app/store/jotaiStore"; +import { TabBar } from "@/app/tab/tabbar"; +import { TabBarEnv } from "@/app/tab/tabbarenv"; +import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; +import { applyMockEnvOverrides, MockWaveEnv } from "@/preview/mock/mockwaveenv"; +import { PlatformLinux, PlatformMacOS, PlatformWindows } from "@/util/platformutil"; +import { atom, useAtom, useAtomValue } from "jotai"; +import { CSSProperties, useEffect, useMemo, useRef, useState } from "react"; + +type PreviewTabEntry = { + tabId: string; + tabName: string; + badges?: Badge[] | null; + flagColor?: string | null; +}; + +function badgeBlockId(tabId: string, badgeId: string): string { + return `${tabId}-badge-${badgeId}`; +} + +function makeTabWaveObj(tab: PreviewTabEntry): Tab { + const blockids = (tab.badges ?? []).map((b) => badgeBlockId(tab.tabId, b.badgeid)); + return { + otype: "tab", + oid: tab.tabId, + version: 1, + name: tab.tabName, + blockids, + meta: tab.flagColor ? { "tab:flagcolor": tab.flagColor } : {}, + } as Tab; +} + +function makeMockBadgeEvents(): BadgeEvent[] { + const events: BadgeEvent[] = []; + for (const tab of InitialTabs) { + for (const badge of tab.badges ?? []) { + events.push({ oref: `block:${badgeBlockId(tab.tabId, badge.badgeid)}`, badge }); + } + } + return events; +} + +const MockWorkspaceId = "preview-workspace-1"; +const InitialTabs: PreviewTabEntry[] = [ + { tabId: "preview-tab-1", tabName: "Terminal" }, + { + tabId: "preview-tab-2", + tabName: "Build Logs", + badges: [ + { + badgeid: "01958000-0000-7000-0000-000000000001", + icon: "triangle-exclamation", + color: "#f59e0b", + priority: 2, + }, + ], + }, + { + tabId: "preview-tab-3", + tabName: "Deploy", + badges: [ + { badgeid: "01958000-0000-7000-0000-000000000002", icon: "circle-check", color: "#4ade80", priority: 3 }, + ], + flagColor: "#429dff", + }, + { + tabId: "preview-tab-4", + tabName: "A Very Long Tab Name To Show Truncation", + badges: [ + { badgeid: "01958000-0000-7000-0000-000000000003", icon: "bell", color: "#f87171", priority: 2 }, + { badgeid: "01958000-0000-7000-0000-000000000004", icon: "circle-small", color: "#fbbf24", priority: 1 }, + ], + }, + { tabId: "preview-tab-5", tabName: "Wave AI" }, + { tabId: "preview-tab-6", tabName: "Preview", flagColor: "#bf55ec" }, +]; + +const MockConfigErrors: ConfigError[] = [ + { file: "~/.waveterm/config.json", err: 'unknown preset "bg@aurora"' }, + { file: "~/.waveterm/settings.json", err: "invalid color for tab theme" }, +]; + +function makeMockWorkspace(tabIds: string[]): Workspace { + return { + otype: "workspace", + oid: MockWorkspaceId, + version: 1, + name: "Preview Workspace", + tabids: tabIds, + activetabid: tabIds[1] ?? tabIds[0] ?? "", + meta: {}, + } as Workspace; +} + +export function TabBarPreview() { + const baseEnv = useWaveEnv(); + const initialTabIds = InitialTabs.map((t) => t.tabId); + const envRef = useRef(null); + const [platform, setPlatform] = useState(PlatformMacOS); + + const tabEnv = useMemo(() => { + const mockWaveObjs: Record = { + [`workspace:${MockWorkspaceId}`]: makeMockWorkspace(initialTabIds), + }; + for (const tab of InitialTabs) { + mockWaveObjs[`tab:${tab.tabId}`] = makeTabWaveObj(tab); + } + const env = applyMockEnvOverrides(baseEnv, { + tabId: InitialTabs[1].tabId, + platform, + mockWaveObjs, + atoms: { + workspaceId: atom(MockWorkspaceId), + staticTabId: atom(InitialTabs[1].tabId), + }, + rpc: { + GetAllBadgesCommand: () => Promise.resolve(makeMockBadgeEvents()), + }, + electron: { + createTab: () => { + const e = envRef.current; + if (e == null) return; + const newTabId = `preview-tab-${crypto.randomUUID()}`; + e.mockSetWaveObj(`tab:${newTabId}`, { + otype: "tab", + oid: newTabId, + version: 1, + name: "New Tab", + blockids: [], + meta: {}, + } as Tab); + const ws = globalStore.get(e.wos.getWaveObjectAtom(`workspace:${MockWorkspaceId}`)); + e.mockSetWaveObj(`workspace:${MockWorkspaceId}`, { + ...ws, + tabids: [...(ws.tabids ?? []), newTabId], + }); + globalStore.set(e.atoms.staticTabId as any, newTabId); + }, + closeTab: (_workspaceId: string, tabId: string) => { + const e = envRef.current; + if (e == null) return Promise.resolve(false); + const ws = globalStore.get(e.wos.getWaveObjectAtom(`workspace:${MockWorkspaceId}`)); + const newTabIds = (ws.tabids ?? []).filter((id) => id !== tabId); + if (newTabIds.length === 0) { + return Promise.resolve(false); + } + e.mockSetWaveObj(`workspace:${MockWorkspaceId}`, { ...ws, tabids: newTabIds }); + if (globalStore.get(e.atoms.staticTabId) === tabId) { + globalStore.set(e.atoms.staticTabId as any, newTabIds[0]); + } + return Promise.resolve(true); + }, + setActiveTab: (tabId: string) => { + const e = envRef.current; + if (e == null) return; + globalStore.set(e.atoms.staticTabId as any, tabId); + }, + showWorkspaceAppMenu: () => { + console.log("[preview] showWorkspaceAppMenu"); + }, + }, + }); + envRef.current = env; + return env; + }, [platform]); + + return ( + + + + ); +} + +type TabBarPreviewInnerProps = { + platform: NodeJS.Platform; + setPlatform: (platform: NodeJS.Platform) => void; +}; + +function TabBarPreviewInner({ platform, setPlatform }: TabBarPreviewInnerProps) { + const env = useWaveEnv(); + const loadBadgesEnv = useWaveEnv(); + const [showConfigErrors, setShowConfigErrors] = useState(false); + const [hideAiButton, setHideAiButton] = useState(false); + const [showMenuBar, setShowMenuBar] = useState(false); + const [isFullScreen, setIsFullScreen] = useAtom(env.atoms.isFullScreen); + const [zoomFactor, setZoomFactor] = useAtom(env.atoms.zoomFactorAtom); + const [fullConfig, setFullConfig] = useAtom(env.atoms.fullConfigAtom); + const [updaterStatus, setUpdaterStatus] = useAtom(env.atoms.updaterStatusAtom); + const workspace = useAtomValue(env.wos.getWaveObjectAtom(`workspace:${MockWorkspaceId}`)); + + useEffect(() => { + loadBadges(loadBadgesEnv); + }, []); + + useEffect(() => { + setFullConfig((prev) => ({ + ...(prev ?? ({} as FullConfigType)), + settings: { + ...(prev?.settings ?? {}), + "app:hideaibutton": hideAiButton, + "window:showmenubar": showMenuBar, + }, + configerrors: showConfigErrors ? MockConfigErrors : [], + })); + }, [hideAiButton, showMenuBar, setFullConfig, showConfigErrors]); + + return ( +
+
+ + + + + + + +
+ Double-click a tab name to rename it. Close/add buttons and drag reordering are fully functional. +
+
+ +
0 ? 1 / zoomFactor : 1 } as CSSProperties} + > + {workspace != null && } +
+ +
+ Tabs: {workspace?.tabids?.length ?? 0} · Config errors: {fullConfig?.configerrors?.length ?? 0} +
+
+ ); +} +TabBarPreviewInner.displayName = "TabBarPreviewInner"; diff --git a/frontend/preview/previews/widgets.preview.tsx b/frontend/preview/previews/widgets.preview.tsx index b6970da902..144cace174 100644 --- a/frontend/preview/previews/widgets.preview.tsx +++ b/frontend/preview/previews/widgets.preview.tsx @@ -7,7 +7,6 @@ import { atom, useAtom } from "jotai"; import { useRef } from "react"; import { applyMockEnvOverrides } from "../mock/mockwaveenv"; -const workspaceAtom = atom(null as Workspace); const resizableHeightAtom = atom(250); function makeMockApp(name: string, icon: string, iconcolor: string): AppInfo { @@ -91,7 +90,6 @@ function makeWidgetsEnv(baseEnv: WaveEnv, isDev: boolean, hasCustomAIPresets: bo rpc: { ListAllAppsCommand: () => Promise.resolve(apps ?? []) }, atoms: { fullConfigAtom, - workspace: workspaceAtom, hasCustomAIPresetsAtom: atom(hasCustomAIPresets), }, }); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 8ee176e151..9f7cb15ad3 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -11,11 +11,13 @@ declare global { builderId: jotai.Atom; // readonly (for builder mode) builderAppId: jotai.PrimitiveAtom; // app being edited in builder mode uiContext: jotai.Atom; // driven from windowId, tabId - workspace: jotai.Atom; // driven from WOS + workspaceId: jotai.Atom; // derived from window WOS object + workspace: jotai.Atom; // driven from workspaceId via WOS fullConfigAtom: jotai.PrimitiveAtom; // driven from WOS, settings -- updated via WebSocket waveaiModeConfigAtom: jotai.PrimitiveAtom>; // resolved AI mode configs -- updated via WebSocket settingsAtom: jotai.Atom; // derrived from fullConfig hasCustomAIPresetsAtom: jotai.Atom; // derived from fullConfig + hasConfigErrors: jotai.Atom; // derived from fullConfig staticTabId: jotai.Atom; isFullScreen: jotai.PrimitiveAtom; zoomFactorAtom: jotai.PrimitiveAtom; diff --git a/frontend/util/platformutil.ts b/frontend/util/platformutil.ts index 1a73fce55d..ded79d3394 100644 --- a/frontend/util/platformutil.ts +++ b/frontend/util/platformutil.ts @@ -3,6 +3,7 @@ export const PlatformMacOS = "darwin"; export const PlatformWindows = "win32"; +export const PlatformLinux = "linux"; export let PLATFORM: NodeJS.Platform = PlatformMacOS; export function setPlatform(platform: NodeJS.Platform) { diff --git a/pkg/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go index 8d6dc15690..0eb228181a 100644 --- a/pkg/service/objectservice/objectservice.go +++ b/pkg/service/objectservice/objectservice.go @@ -72,23 +72,6 @@ func (svc *ObjectService) GetObjects(orefStrArr []string) ([]waveobj.WaveObj, er return wstore.DBSelectORefs(ctx, orefArr) } -func (svc *ObjectService) UpdateTabName_Meta() tsgenmeta.MethodMeta { - return tsgenmeta.MethodMeta{ - ArgNames: []string{"uiContext", "tabId", "name"}, - } -} - -func (svc *ObjectService) UpdateTabName(uiContext waveobj.UIContext, tabId, name string) (waveobj.UpdatesRtnType, error) { - ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) - defer cancelFn() - ctx = waveobj.ContextWithUpdates(ctx) - err := wstore.UpdateTabName(ctx, tabId, name) - if err != nil { - return nil, fmt.Errorf("error updating tab name: %w", err) - } - return waveobj.ContextGetUpdatesRtn(ctx), nil -} - func (svc *ObjectService) CreateBlock_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"uiContext", "blockDef", "rtOpts"}, diff --git a/pkg/service/workspaceservice/workspaceservice.go b/pkg/service/workspaceservice/workspaceservice.go index c0d5072a48..1d7b116bdc 100644 --- a/pkg/service/workspaceservice/workspaceservice.go +++ b/pkg/service/workspaceservice/workspaceservice.go @@ -6,7 +6,6 @@ package workspaceservice import ( "context" "fmt" - "log" "time" "github.com/wavetermdev/waveterm/pkg/blockcontroller" @@ -165,24 +164,6 @@ func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activ return tabId, updates, nil } -func (svc *WorkspaceService) UpdateTabIds_Meta() tsgenmeta.MethodMeta { - return tsgenmeta.MethodMeta{ - ArgNames: []string{"uiContext", "workspaceId", "tabIds"}, - } -} - -func (svc *WorkspaceService) UpdateTabIds(uiContext waveobj.UIContext, workspaceId string, tabIds []string) (waveobj.UpdatesRtnType, error) { - log.Printf("UpdateTabIds %s %v\n", workspaceId, tabIds) - ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) - defer cancelFn() - ctx = waveobj.ContextWithUpdates(ctx) - err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds) - if err != nil { - return nil, fmt.Errorf("error updating workspace tab ids: %w", err) - } - return waveobj.ContextGetUpdatesRtn(ctx), nil -} - func (svc *WorkspaceService) SetActiveTab_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"workspaceId", "tabId"}, diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index f990019ecd..8d92893afc 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -412,7 +412,7 @@ func GenerateMethodSignature(serviceName string, method reflect.Method, meta tsg } func GenerateMethodBody(serviceName string, method reflect.Method, meta tsgenmeta.MethodMeta) string { - return fmt.Sprintf(" return WOS.callBackendService(%q, %q, Array.from(arguments))\n", serviceName, method.Name) + return fmt.Sprintf(" return callBackendService(this.waveEnv, %q, %q, Array.from(arguments))\n", serviceName, method.Name) } func GenerateServiceClass(serviceName string, serviceObj any, tsTypesMap map[reflect.Type]string) string { @@ -420,9 +420,13 @@ func GenerateServiceClass(serviceName string, serviceObj any, tsTypesMap map[ref var sb strings.Builder tsServiceName := serviceType.Elem().Name() sb.WriteString(fmt.Sprintf("// %s (%s)\n", serviceType.Elem().String(), serviceName)) - sb.WriteString("class ") + sb.WriteString("export class ") sb.WriteString(tsServiceName + "Type") sb.WriteString(" {\n") + sb.WriteString(" waveEnv: WaveEnv;\n\n") + sb.WriteString(" constructor(waveEnv?: WaveEnv) {\n") + sb.WriteString(" this.waveEnv = waveEnv;\n") + sb.WriteString(" }\n\n") isFirst := true for midx := 0; midx < serviceType.NumMethod(); midx++ { method := serviceType.Method(midx) diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index c44c9c6abc..110e1695ef 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -921,6 +921,18 @@ func TestMultiArgCommand(w *wshutil.WshRpc, arg1 string, arg2 int, arg3 bool, op return resp, err } +// command "updatetabname", wshserver.UpdateTabNameCommand +func UpdateTabNameCommand(w *wshutil.WshRpc, arg1 string, arg2 string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "updatetabname", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts) + return err +} + +// command "updateworkspacetabids", wshserver.UpdateWorkspaceTabIdsCommand +func UpdateWorkspaceTabIdsCommand(w *wshutil.WshRpc, arg1 string, arg2 []string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "updateworkspacetabids", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts) + return err +} + // command "vdomasyncinitiation", wshserver.VDomAsyncInitiationCommand func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiationRequest, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "vdomasyncinitiation", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 2c69ee0034..8ddff8128b 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -94,6 +94,8 @@ type WshRpcInterface interface { FetchSuggestionsCommand(ctx context.Context, data FetchSuggestionsData) (*FetchSuggestionsResponse, error) DisposeSuggestionsCommand(ctx context.Context, widgetId string) error GetTabCommand(ctx context.Context, tabId string) (*waveobj.Tab, error) + UpdateTabNameCommand(ctx context.Context, tabId string, newName string) error + UpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error) // connection functions diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index e477914fa2..670c949f2e 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -160,6 +160,26 @@ func (ws *WshServer) GetMetaCommand(ctx context.Context, data wshrpc.CommandGetM return waveobj.GetMeta(obj), nil } +func (ws *WshServer) UpdateTabNameCommand(ctx context.Context, tabId string, newName string) error { + oref := waveobj.ORef{OType: waveobj.OType_Tab, OID: tabId} + err := wstore.UpdateTabName(ctx, tabId, newName) + if err != nil { + return fmt.Errorf("error updating tab name: %w", err) + } + wcore.SendWaveObjUpdate(oref) + return nil +} + +func (ws *WshServer) UpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error { + oref := waveobj.ORef{OType: waveobj.OType_Workspace, OID: workspaceId} + err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds) + if err != nil { + return fmt.Errorf("error updating workspace tab ids: %w", err) + } + wcore.SendWaveObjUpdate(oref) + return nil +} + func (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error { log.Printf("SetMetaCommand: %s | %v\n", data.ORef, data.Meta) oref := data.ORef diff --git a/tsconfig.json b/tsconfig.json index cb02488e63..3ef02e0671 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,8 @@ "@/store/*": ["frontend/app/store/*"], "@/view/*": ["frontend/app/view/*"], "@/element/*": ["frontend/app/element/*"], - "@/shadcn/*": ["frontend/app/shadcn/*"] + "@/shadcn/*": ["frontend/app/shadcn/*"], + "@/preview/*": ["frontend/preview/*"] }, "lib": ["dom", "dom.iterable", "es6"], "allowJs": true,