diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..ac71ce785f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: wavetermdev diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c1715117df..397ce1c97f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,8 @@ ## Project Rules -Read and follow all guidelines in [`.roo/rules/rules.md`](./.roo/rules/rules.md). +- See the overview of the project in `.kilocode/rules/overview.md` +- Read and follow all guidelines in `.kilocode/rules/rules.md` --- @@ -10,14 +11,36 @@ Read and follow all guidelines in [`.roo/rules/rules.md`](./.roo/rules/rules.md) This project uses a set of "skill" guides — focused how-to documents for common implementation tasks. When your task matches one of the descriptions below, **read the linked SKILL.md file before proceeding** and follow its instructions precisely. -| Skill | Description | -|-------|-------------| -| [add-config](./.kilocode/skills/add-config/SKILL.md) | Guide for adding new configuration settings to Wave Terminal. Use when adding a new setting to the configuration system, implementing a new config key, or adding user-customizable settings. | -| [add-rpc](./.kilocode/skills/add-rpc/SKILL.md) | Guide for adding new RPC calls to Wave Terminal. Use when implementing new RPC commands, adding server-client communication methods, or extending the RPC interface with new functionality. | -| [add-wshcmd](./.kilocode/skills/add-wshcmd/SKILL.md) | Guide for adding new wsh commands to Wave Terminal. Use when implementing new CLI commands, adding command-line functionality, or extending the wsh command interface. | -| [context-menu](./.kilocode/skills/context-menu/SKILL.md) | Guide for creating and displaying context menus in Wave Terminal. Use when implementing right-click menus, adding context menu items, creating submenus, or handling menu interactions with checkboxes and separators. | -| [create-view](./.kilocode/skills/create-view/SKILL.md) | Guide for implementing a new view type in Wave Terminal. Use when creating a new view component, implementing the ViewModel interface, registering a new view type in BlockRegistry, or adding a new content type to display within blocks. | -| [electron-api](./.kilocode/skills/electron-api/SKILL.md) | Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC. | -| [wps-events](./.kilocode/skills/wps-events/SKILL.md) | Guide for working with Wave Terminal's WPS (Wave PubSub) event system. Use when implementing new event types, publishing events, subscribing to events, or adding asynchronous communication between components. | +| Skill | File | Description | +| ------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| add-config | `.kilocode/skills/add-config/SKILL.md` | Guide for adding new configuration settings to Wave Terminal. Use when adding a new setting to the configuration system, implementing a new config key, or adding user-customizable settings. | +| add-rpc | `.kilocode/skills/add-rpc/SKILL.md` | Guide for adding new RPC calls to Wave Terminal. Use when implementing new RPC commands, adding server-client communication methods, or extending the RPC interface with new functionality. | +| add-wshcmd | `.kilocode/skills/add-wshcmd/SKILL.md` | Guide for adding new wsh commands to Wave Terminal. Use when implementing new CLI commands, adding command-line functionality, or extending the wsh command interface. | +| context-menu | `.kilocode/skills/context-menu/SKILL.md` | Guide for creating and displaying context menus in Wave Terminal. Use when implementing right-click menus, adding context menu items, creating submenus, or handling menu interactions with checkboxes and separators. | +| create-view | `.kilocode/skills/create-view/SKILL.md` | Guide for implementing a new view type in Wave Terminal. Use when creating a new view component, implementing the ViewModel interface, registering a new view type in BlockRegistry, or adding a new content type to display within blocks. | +| electron-api | `.kilocode/skills/electron-api/SKILL.md` | Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC. | +| waveenv | `.kilocode/skills/waveenv/SKILL.md` | 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. | +| wps-events | `.kilocode/skills/wps-events/SKILL.md` | Guide for working with Wave Terminal's WPS (Wave PubSub) event system. Use when implementing new event types, publishing events, subscribing to events, or adding asynchronous communication between components. | > **How skills work:** Each skill is a self-contained guide covering the exact files to edit, patterns to follow, and steps to take for a specific type of task in this codebase. If your task matches a skill's description, open that SKILL.md and treat it as your primary reference for the implementation. + +--- + +## Preview Server + +To run the standalone component preview (no Electron, no backend required): + +``` +task preview +``` + +This runs `cd frontend/preview && npx vite` and serves at **http://localhost:7007** (port configured in `frontend/preview/vite.config.ts`). + +To build a static preview: `task build:preview` + +**Do NOT use any of the following to start the preview — they all launch the full Electron app or serve the wrong content:** + +- `npm run dev` — runs `electron-vite dev`, launches Electron +- `npm run start` — also launches Electron +- `npx vite` from the repo root — uses the Electron-Vite config, not the preview app +- Serving the `dist/` directory — the preview app is never built there; it has its own build output 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/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/.kilocode/skills/waveenv/SKILL.md b/.kilocode/skills/waveenv/SKILL.md new file mode 100644 index 0000000000..aabda6846d --- /dev/null +++ b/.kilocode/skills/waveenv/SKILL.md @@ -0,0 +1,130 @@ +--- +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"]; + + // --- 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">; + getConnConfigKeyAtom: ConnConfigKeyAtomFnType<"conn:wshenabled">; + + // --- other atom helpers: copy verbatim --- + getConnStatusAtom: WaveEnv["getConnStatusAtom"]; + getLocalHostDisplayNameAtom: WaveEnv["getLocalHostDisplayNameAtom"]; +}>; +``` + +### 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 | +| -------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| `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**. | +| `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. | +| 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/.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/README.md b/README.md index c2d4a661d3..2b8e0637ae 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,14 @@ Find more information in our [Contributions Guide](CONTRIBUTING.md), which inclu - [Ways to contribute](CONTRIBUTING.md#contributing-to-wave-terminal) - [Contribution guidelines](CONTRIBUTING.md#before-you-start) +### Sponsoring Wave ❤️ + +If Wave Terminal is useful to you or your company, consider sponsoring development. + +Sponsorship helps support the time spent building and maintaining the project. + +- https://github.com/sponsors/wavetermdev + ## License Wave Terminal is licensed under the Apache-2.0 License. For more information on our dependencies, see [here](./ACKNOWLEDGEMENTS.md). 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/generatets/main-generatets.go b/cmd/generatets/main-generatets.go index 2202c781fe..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) @@ -112,9 +135,17 @@ 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") orderedKeys := utilfn.GetOrderedMapKeys(declMap) fmt.Fprintf(&buf, "// WshServerCommandToDeclMap\n") - fmt.Fprintf(&buf, "class RpcApiType {\n") + fmt.Fprintf(&buf, "export class RpcApiType {\n") + fmt.Fprintf(&buf, " mockClient: MockRpcClient = null;\n\n") + fmt.Fprintf(&buf, " setMockRpcClient(client: MockRpcClient): void {\n") + fmt.Fprintf(&buf, " this.mockClient = client;\n") + fmt.Fprintf(&buf, " }\n\n") for _, methodDecl := range orderedKeys { methodDecl := declMap[methodDecl] methodStr := tsgen.GenerateWshClientApiMethod(methodDecl, tsTypeMap) 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/docs/docs/claude-code.mdx b/docs/docs/claude-code.mdx new file mode 100644 index 0000000000..d16b0f0b0b --- /dev/null +++ b/docs/docs/claude-code.mdx @@ -0,0 +1,131 @@ +--- +sidebar_position: 1.9 +id: "claude-code" +title: "Claude Code Integration" +--- + +import { VersionBadge } from "@site/src/components/versionbadge"; + +# Claude Code Tab Badges + +When you run multiple Claude Code sessions in parallel — one per feature, one per repo, a few long-running tasks — it gets hard to know which tabs need your attention without clicking through each one. Wave's badge system solves this: hooks in Claude Code write a small visual indicator to the tab header whenever something important happens, so you can see at a glance which sessions are waiting, done, or in trouble. + +:::info +tl;dr You can copy and paste this page directly into Claude Code and it will help you set everything up! +::: + +## How it works + +Claude Code supports [lifecycle hooks](https://code.claude.com/docs/en/hooks) — shell commands that run automatically at specific points in a session. Wave's `wsh badge` command sets or clears a visual indicator on the current block or tab. By wiring these together, you get ambient awareness across all your sessions without watching any of them. + +Badges auto-clear when you focus the block, so they're purely a "hey, look over here" signal. Once you click in and read what's happening, the badge disappears on its own. + +Wave already shows a bell icon when a terminal outputs a BEL character. These hooks complement that with semantic badges — *permission needed*, *done* — that survive across tab switches and work across splits. + +### Badge rollup + +If a tab has multiple terminals (block), Wave shows the highest-priority badge on the tab header. Ties at the same priority go to the earliest badge set, so the most urgent signal from any pane in the tab floats to the top. + +## Setup + +These hooks go in your global Claude Code settings so they apply to every session on your machine, not just one project. + +Add the following to `~/.claude/settings.json`. If you already have a `hooks` key, merge the entries in: + +```json +{ + "hooks": { + "Notification": [ + { + "matcher": "permission_prompt", + "hooks": [ + { + "type": "command", + "command": "wsh badge bell-exclamation --color '#e0b956' --priority 20 --beep" + } + ] + }, + { + "matcher": "elicitation_dialog", + "hooks": [ + { + "type": "command", + "command": "wsh badge message-question --color '#e0b956' --priority 20 --beep" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "wsh badge check --color '#58c142' --priority 10" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "AskUserQuestion", + "hooks": [ + { + "type": "command", + "command": "wsh badge message-question --color '#e0b956' --priority 20 --beep" + } + ] + } + ] + } +} +``` + +That's it. Restart any running Claude Code sessions for the hooks to take effect. + +:::warning Known Issue +There is a known issue in Claude Code where `Notification` hooks may be delayed by several seconds before firing. This delay is unrelated to Wave — it occurs in Claude Code itself. See [#5186](https://github.com/anthropics/claude-code/issues/5186) and [#19627](https://github.com/anthropics/claude-code/issues/19627) for details. +::: + +## What each hook does + +### Permission prompt — `bell-exclamation` gold, priority 20 + +Claude Code occasionally needs your approval before it can continue — to run a command, write a file outside the project, or use a tool that requires explicit permission. When it hits one of these, it stops and waits. Without a signal, you might not notice for minutes. + +This hook fires on the `permission_prompt` notification type and sets a high-priority gold badge with an audible beep. Priority 20 means it beats any other badge on that tab, so a waiting session always surfaces above a finished one. + +When you click into the tab and approve or deny the request, the badge clears automatically. + +### Session complete — `check` green, priority 10 + +When Claude Code finishes responding, this hook sets a green check badge. It's a low-key signal: glance at the tab bar, see which sessions are done, review their output in whatever order you like. + +### AskUserQuestion — `message-question` gold, priority 20 + +When Claude Code uses the `AskUserQuestion` tool, it's paused and waiting for you to respond before it can proceed. This `PreToolUse` hook fires just before that tool call and sets the same high-priority gold badge as the permission prompt. + +`PreToolUse` hooks can match any tool by name, so you can add badges for other tools as well — for example, to get a signal whenever Claude runs a shell command (`Bash`) or edits a file (`Edit`). Any tool name Claude Code supports can be used as a matcher. + +## Choosing your own icons and colors + +Icon names are [Font Awesome](https://fontawesome.com/icons) icon names without the `fa-` prefix. Colors are any valid CSS color — hex values, named colors, or anything else CSS accepts. + +Some icon and color ideas: + +| Situation | Icon | Color | +|-----------|------|-------| +| Custom high-priority alert | `triangle-exclamation` | `#FF453A` | +| Blocked / waiting on input | `hourglass-half` | `#FF9500` | +| Neutral / informational | `circle-info` | `#429DFF` | +| Background task running | `spinner` | `#00FFDB` | + +See the [`wsh badge` reference](/wsh-reference#badge) for all available flags. + +## Adjusting priorities + +Priority controls which badge wins when multiple blocks in a tab each have one. Higher numbers take precedence. The defaults above use: + +- **20** for permission prompts — always surfaces above everything else +- **10** for session complete — visible when nothing more urgent is active + +If you add more hooks, keep permission-blocking signals at the high end (15–25) and informational signals at the low end (5–10). \ No newline at end of file diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 6cdd59cdfa..8a8a6330a0 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -73,6 +73,7 @@ wsh editconfig | term:cursorblink | bool | when enabled, terminal cursor blinks (default false) | | term:bellsound | bool | when enabled, plays the system beep sound when the terminal bell (BEL character) is received (default false) | | term:bellindicator | bool | when enabled, shows a visual indicator in the tab when the terminal bell is received (default false) | +| term:osc52 | string | controls OSC 52 clipboard behavior: `always` (default, allows OSC 52 at any time) or `focus` (requires focused window and focused block) | | term:durable | bool | makes remote terminal sessions durable across network disconnects (defaults to false) | | editor:minimapenabled | bool | set to false to disable editor minimap | | editor:stickyscrollenabled | bool | enables monaco editor's stickyScroll feature (pinning headers of current context, e.g. class names, method names, etc.), defaults to false | @@ -80,6 +81,7 @@ wsh editconfig | editor:fontsize | float64 | set the font size for the editor (defaults to 12px) | | editor:inlinediff | bool | set to true to show diffs inline instead of side-by-side, false for side-by-side (defaults to undefined which uses Monaco's responsive behavior) | | preview:showhiddenfiles | bool | set to false to disable showing hidden files in the directory preview (defaults to true) | +| preview:defaultsort | string | sets the default sort column for directory preview. `"name"` (default) sorts alphabetically by name ascending; `"modtime"` sorts by last modified time descending (newest first) | | markdown:fontsize | float64 | font size for the normal text when rendering markdown in preview. headers are scaled up from this size, (default 14px) | | markdown:fixedfontsize | float64 | font size for the code blocks when rendering markdown in preview (default is 12px) | | web:openlinksinternally | bool | set to false to open web links in external browser | @@ -147,12 +149,14 @@ For reference, this is the current default configuration (v0.14.0): "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": false, + "term:osc52": "always", "term:cursor": "block", "term:cursorblink": false, "term:copyonselect": true, "term:durable": false, "waveai:showcloudmodes": true, - "waveai:defaultmode": "waveai@balanced" + "waveai:defaultmode": "waveai@balanced", + "preview:defaultsort": "name" } ``` diff --git a/docs/docs/releasenotes.mdx b/docs/docs/releasenotes.mdx index fb71d99d64..4d985607d0 100644 --- a/docs/docs/releasenotes.mdx +++ b/docs/docs/releasenotes.mdx @@ -6,6 +6,29 @@ sidebar_position: 200 # Release Notes +### v0.14.2 — Mar 12, 2026 + +Wave v0.14.2 adds block/tab badges, directory preview improvements, and assorted bug fixes. + +**Block/Tab Badges:** + +- **Block Level Badges, Rolled up to Tabs** - Blocks can now display icon badges (with color and priority) that roll up and are visible in the tab bar for at-a-glance status +- **Bell Indicator Enabled by Default** - Terminal bell badge is now on by default, lighting up the block and tab when your terminal rings the bell (controlled with `term:bellindicator`) +- **`wsh badge`** - New `wsh badge` command to set or clear badges on blocks from the command line. Supports icons, colors, priorities, beep, and PID-linked badges that auto-clear when a process exits. Great for use with Claude Code hooks to surface notifications in the tab bar ([docs](https://docs.waveterm.dev/wsh-reference#badge)) + +**Other Changes:** + +- **Directory Preview Improvements** - Improved mod time formatting, zebra-striped rows, better default sort, YAML file support, and context menu improvements +- **Search Bar** - Clipboard and focus improvements in the search bar +- [bugfix] Fixed "New Window" hanging/not working on GNOME desktops +- [bugfix] Fixed "Save Session As..." (focused window tracking bug) +- [bugfix] Zoom change notifications were not being properly sent to all tabs (layout inconsistencies) +- Added a Release Notes link in the settings menu +- Working on anthropic-messages Wave AI backend (for native Claude integration) +- Lots of internal work on testing/mock infrastructure to enable quicker async AI edits +- Documention updates +- Package updates and dependency upgrades + ### v0.14.1 — Mar 3, 2026 Wave v0.14.1 fixes several high-impact terminal bugs (Claude Code scrolling, IME input) and adds new config options: focus-follows-cursor, cursor style customization, workspace-scoped widgets, and vim-style block navigation. diff --git a/docs/docs/wsh-reference.mdx b/docs/docs/wsh-reference.mdx index 9dd8bc0b3e..6ff8c2e8f5 100644 --- a/docs/docs/wsh-reference.mdx +++ b/docs/docs/wsh-reference.mdx @@ -6,6 +6,7 @@ title: "wsh reference" import { Kbd } from "@site/src/components/kbd"; import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; +import { VersionBadge } from "@site/src/components/versionbadge"; @@ -23,6 +24,7 @@ You can open a preview block with the contents of any file or directory by runni ```sh wsh view [path] +wsh view -m [path] # opens in magnified block ``` You can use this command to easily preview images, markdown files, and directories. For code/text files this will open @@ -34,9 +36,29 @@ a codeedit block which you can use to quickly edit the file using Wave's embedde ```sh wsh edit [path] +wsh edit -m [path] # opens in magnified block ``` -This will open up codeedit for the specified file. This is useful for quickly editing files on a local or remote machine in our graphical editor. This command will wait until the file is closed before exiting (unlike `view`) so you can set your `$EDITOR` to `wsh editor` for a seamless experience. You can combine this with a `-m` flag to open the editor in magnified mode. +This will open up a codeedit block for the specified file. This is useful for quickly editing files on a local or remote machine in Wave's graphical editor. This command returns immediately after opening the block. + +For `$EDITOR` integration (e.g. with `git commit`), see [`wsh editor`](#editor) which blocks until the editor is closed. + +--- + +## editor + +```sh +wsh editor [path] +wsh editor -m [path] # opens in magnified block +``` + +This opens a codeedit block for the specified file and **blocks until the editor is closed**. This is useful for setting your `$EDITOR` environment variable so that CLI tools (e.g. `git commit`, `crontab -e`) open files in Wave's graphical editor: + +```sh +export EDITOR="wsh editor" +``` + +The file must already exist. Use `-m` to open the editor in magnified mode. --- @@ -241,6 +263,59 @@ Use `--print` to preview the metadata for any background configuration without a --- +## badge + + + +The `badge` command sets or clears a visual badge indicator on a block or tab header. + +```sh +wsh badge [icon] +wsh badge --clear +``` + +Badges are used to draw attention to a block or tab, such as indicating a process has completed or needs attention. If no icon is provided, it defaults to `circle-small`. Icon names are [Font Awesome](https://fontawesome.com/icons) icon names (without the `fa-` prefix). + +Flags: + +- `--color string` - set the badge color (CSS color name or hex) +- `--priority float` - set the badge priority (default 10; higher priority badges take precedence) +- `--clear` - remove the badge from the block or tab +- `--beep` - play the system bell sound when setting the badge +- `--pid int` - watch a PID and automatically clear the badge when it exits (sets default priority to 5) +- `-b, --block` - target a specific block or tab (same format as `getmeta`) + +Examples: + +```sh +# Set a default badge on the current block +wsh badge + +# Set a badge with a custom icon and color +wsh badge circle-check --color green + +# Set a high-priority badge on a specific block +wsh badge triangle-exclamation --color red --priority 20 -b 2 + +# Set a badge that clears when a process exits +wsh badge --pid 12345 + +# Play the bell and set a badge when done +wsh badge circle-check --beep + +# Clear the badge on the current block +wsh badge --clear + +# Clear the badge on a specific tab +wsh badge --clear -b tab +``` + +:::note +The `--pid` flag is not supported on Windows. +::: + +--- + ## run The `run` command creates a new terminal command block and executes a specified command within it. The command can be provided either as arguments after `--` or using the `-c` flag. Unless the `-x` or `-X` flags are passed, commands can be re-executed by pressing `Enter` once the command has finished running. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 626c5e2e6f..bf0956df67 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -159,6 +159,7 @@ export default defineConfig({ "**/go.mod", "**/go.sum", "**/*.md", + "**/*.mdx", "**/*.json", "emain/**", "**/*.txt", diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index aaf3736431..1d1ec2108a 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -17,13 +17,14 @@ import { incrementTermCommandsRemote, incrementTermCommandsRun, incrementTermCommandsWsl, + setWasActive, } from "./emain-activity"; import { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from "./emain-builder"; import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform"; import { getWaveTabViewByWebContentsId } from "./emain-tabview"; import { handleCtrlShiftState } from "./emain-util"; import { getWaveVersion } from "./emain-wavesrv"; -import { createNewWaveWindow, focusedWaveWindow, getWaveWindowByWebContentsId } from "./emain-window"; +import { createNewWaveWindow, getWaveWindowByWebContentsId } from "./emain-window"; import { ElectronWshClient } from "./emain-wsh"; const electronApp = electron.app; @@ -129,12 +130,18 @@ function getUrlInSession(session: Electron.Session, url: string): Promise { if (file.canceled) { + readStream.destroy(); return; } const writeStream = fs.createWriteStream(file.filePath); @@ -212,7 +220,12 @@ export function initIpcHandlers() { const resultP = getUrlInSession(event.sender.session, payload.src); resultP .then((result) => { - saveImageFileWithNativeDialog(result.fileName, result.mimeType, result.stream); + saveImageFileWithNativeDialog( + event.sender.hostWebContents, + result.fileName, + result.mimeType, + result.stream + ); }) .catch((e) => { console.log("error getting image", e); @@ -317,6 +330,10 @@ export function initIpcHandlers() { tabView?.setKeyboardChordMode(true); }); + electron.ipcMain.handle("set-is-active", () => { + setWasActive(true); + }); + const fac = new FastAverageColor(); electron.ipcMain.on("update-window-controls-overlay", async (event, rect: Dimensions) => { if (unamePlatform === "darwin") return; @@ -472,7 +489,7 @@ export function initIpcHandlers() { }); electron.ipcMain.handle("save-text-file", async (event, fileName: string, content: string) => { - const ww = focusedWaveWindow; + const ww = electron.BrowserWindow.fromWebContents(event.sender); if (ww == null) { return false; } diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index 6685d79087..691e475443 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -9,7 +9,7 @@ import { focusedBuilderWindow, getBuilderWindowById } from "./emain-builder"; import { openBuilderWindow } from "./emain-ipc"; import { isDev, unamePlatform } from "./emain-platform"; import { clearTabCache } from "./emain-tabview"; -import { decreaseZoomLevel, increaseZoomLevel } from "./emain-util"; +import { decreaseZoomLevel, increaseZoomLevel, resetZoomLevel } from "./emain-util"; import { createNewWaveWindow, createWorkspace, @@ -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(); }, }, @@ -238,8 +238,7 @@ function makeViewMenu( click: (_, window) => { const wc = getWindowWebContents(window) ?? webContents; if (wc) { - wc.setZoomFactor(1); - wc.send("zoom-factor-change", 1); + resetZoomLevel(wc); } }, }, diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index feb0f029c2..7bf4cc23f3 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -15,6 +15,7 @@ import { handleCtrlShiftFocus, handleCtrlShiftState, increaseZoomLevel, + resetZoomLevel, shFrameNavHandler, shNavHandler, } from "./emain-util"; @@ -48,8 +49,7 @@ function handleWindowsMenuAccelerators( } if (checkKeyPressed(waveEvent, "Ctrl:0")) { - tabView.webContents.setZoomFactor(1); - tabView.webContents.send("zoom-factor-change", 1); + resetZoomLevel(tabView.webContents); return true; } @@ -165,9 +165,6 @@ export class WaveTabView extends WebContentsView { removeWaveTabView(this.waveTabId); this.isDestroyed = true; }); - this.webContents.on("zoom-changed", (_event, zoomDirection) => { - this.webContents.send("zoom-factor-change", this.webContents.getZoomFactor()); - }); this.setBackgroundColor(computeBgColor(fullConfig)); } @@ -339,9 +336,6 @@ export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: stri } } }); - tabView.webContents.on("zoom-changed", (e) => { - tabView.webContents.send("zoom-changed"); - }); tabView.webContents.setWindowOpenHandler(({ url, frameName }) => { if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { console.log("openExternal fallback", url); diff --git a/emain/emain-util.ts b/emain/emain-util.ts index b04fda0dfa..08f9c3413a 100644 --- a/emain/emain-util.ts +++ b/emain/emain-util.ts @@ -12,16 +12,36 @@ const MinZoomLevel = 0.4; const MaxZoomLevel = 2.6; const ZoomDelta = 0.2; +// Note: Chromium automatically syncs zoom factor across all WebContents +// sharing the same origin/session, so we only need to notify renderers +// to update their CSS/state — not call setZoomFactor on each one. +// We broadcast to all WebContents (including devtools, webviews, etc.) but +// that is safe because "zoom-factor-change" is a custom app-defined event +// that only our renderers listen to; unrecognized IPC messages are ignored. +function broadcastZoomFactorChanged(newZoomFactor: number): void { + for (const wc of electron.webContents.getAllWebContents()) { + if (wc.isDestroyed()) { + continue; + } + wc.send("zoom-factor-change", newZoomFactor); + } +} + export function increaseZoomLevel(webContents: electron.WebContents): void { const newZoom = Math.min(MaxZoomLevel, webContents.getZoomFactor() + ZoomDelta); webContents.setZoomFactor(newZoom); - webContents.send("zoom-factor-change", newZoom); + broadcastZoomFactorChanged(newZoom); } export function decreaseZoomLevel(webContents: electron.WebContents): void { const newZoom = Math.max(MinZoomLevel, webContents.getZoomFactor() - ZoomDelta); webContents.setZoomFactor(newZoom); - webContents.send("zoom-factor-change", newZoom); + broadcastZoomFactorChanged(newZoom); +} + +export function resetZoomLevel(webContents: electron.WebContents): void { + webContents.setZoomFactor(1); + broadcastZoomFactorChanged(1); } export function getElectronExecPath(): string { diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 07c0c08a6c..2c34d3a39c 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -273,6 +273,7 @@ export class WaveBrowserWindow extends BaseWindow { if (getGlobalIsRelaunching()) { return; } + focusedWaveWindow = this; // eslint-disable-line @typescript-eslint/no-this-alias console.log("focus win", this.waveWindowId); fireAndForget(() => ClientService.FocusWindow(this.waveWindowId)); setWasInFg(true); diff --git a/emain/emain.ts b/emain/emain.ts index 79b0c2d0ff..7a2b0a0710 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -384,6 +384,10 @@ async function appMain() { electronApp.quit(); return; } + electronApp.on("second-instance", (_event, argv, workingDirectory) => { + console.log("second-instance event, argv:", argv, "workingDirectory:", workingDirectory); + fireAndForget(createNewWaveWindow); + }); try { await runWaveSrv(handleWSEvent); } catch (e) { diff --git a/emain/preload.ts b/emain/preload.ts index 7acdf2e73a..823f99c4cd 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -71,6 +71,7 @@ contextBridge.exposeInMainWorld("api", { setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), doRefresh: () => ipcRenderer.send("do-refresh"), saveTextFile: (fileName: string, content: string) => ipcRenderer.invoke("save-text-file", fileName, content), + setIsActive: () => ipcRenderer.invoke("set-is-active"), }); // Custom event for "new-window" diff --git a/eslint.config.js b/eslint.config.js index 1c72e5f464..6e98b1d805 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -76,8 +76,9 @@ export default [ "@typescript-eslint/no-unused-vars": [ "warn", { - argsIgnorePattern: "^_$", - varsIgnorePattern: "^_$", + argsIgnorePattern: "^(_[a-zA-Z0-9_]*|e|get)$", + varsIgnorePattern: "^(_[a-zA-Z0-9_]*|dlog|e)$", + caughtErrorsIgnorePattern: "^(_[a-zA-Z0-9_]*|e)$", }, ], "prefer-const": "warn", @@ -88,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/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 46780455c2..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, @@ -306,7 +306,7 @@ const AIPanelComponentInner = memo(() => { }; useEffect(() => { - globalStore.set(model.isAIStreaming, status == "streaming"); + globalStore.set(model.isAIStreaming, status === "streaming" || status === "submitted"); }, [status]); useEffect(() => { @@ -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/app.tsx b/frontend/app/app.tsx index 76ad557516..15b09785ef 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -1,19 +1,21 @@ -// 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 { WaveEnvContext } from "@/app/waveenv/waveenv"; +import { makeWaveEnvImpl } from "@/app/waveenv/waveenvimpl"; 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 +25,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"; @@ -39,14 +41,17 @@ const focusLog = debug("wave:focus"); const App = ({ onFirstRender }: { onFirstRender: () => void }) => { const tabId = useAtomValue(atoms.staticTabId); + const waveEnvRef = useRef(makeWaveEnvImpl()); useEffect(() => { onFirstRender(); }, []); return ( - - - + + + + + ); }; @@ -103,7 +108,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" }); } @@ -200,36 +205,72 @@ function AppFocusHandler() { const AppKeyHandlers = () => { useEffect(() => { const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown); + const staticMouseDownHandler = (e: MouseEvent) => { + keyboardMouseDownHandler(e); + GlobalModel.getInstance().setIsActive(); + }; document.addEventListener("keydown", staticKeyDownHandler); - document.addEventListener("mousedown", keyboardMouseDownHandler); + document.addEventListener("mousedown", staticMouseDownHandler); return () => { document.removeEventListener("keydown", staticKeyDownHandler); - document.removeEventListener("mousedown", keyboardMouseDownHandler); + document.removeEventListener("mousedown", staticMouseDownHandler); }; }, []); 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; + } + const currentFocusedNode = globalStore.get(layoutModel.focusedNode); + if (currentFocusedNode?.data?.blockId === focusedBlockId) { + clearBadgesForBlockOnFocus(focusedBlockId); } - }, 3000); + }, 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; }; @@ -261,7 +302,7 @@ const AppInner = () => { - + diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 19a8529b11..126f208813 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,26 +9,21 @@ 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"; import { counterInc } from "@/store/counters"; -import { - atoms, - getBlockComponentModel, - getSettingsKeyAtom, - 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 { 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"; @@ -41,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"; @@ -59,12 +55,18 @@ 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); + return makeDefaultViewModel(blockView); } function getViewElem( @@ -84,18 +86,11 @@ function getViewElem( return ; } -function makeDefaultViewModel(blockId: string, viewType: string): ViewModel { - const blockDataAtom = getWaveObjectAtom(makeORef("block", blockId)); - let viewModel: ViewModel = { +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, @@ -104,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 ( @@ -120,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 ( @@ -142,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); @@ -206,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( @@ -233,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; } @@ -250,7 +249,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { }, [ focusFollowsCursorMode, - blockData?.meta?.view, + blockView, modalOpen, disablePointerEvents, isResizing, @@ -304,15 +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 [blockData, loading] = useWaveObjectValue(makeORef("block", props.nodeModel.blockId)); + const waveEnv = useWaveEnv(); 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); + 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(() => { @@ -321,24 +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 [blockData, loading] = useWaveObjectValue(makeORef("block", props.nodeModel.blockId)); + const waveEnv = useWaveEnv(); 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); + 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(() => { @@ -347,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..000228c014 --- /dev/null +++ b/frontend/app/block/blockenv.ts @@ -0,0 +1,49 @@ +// 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" + >; + showContextMenu: WaveEnv["showContextMenu"]; + 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 70f28ff2fe..319e9b4a49 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -10,30 +10,32 @@ import { } from "@/app/block/blockutil"; import { ConnectionButton } from "@/app/block/connectionbutton"; import { DurableSessionFlyover } from "@/app/block/durable-session-flyover"; -import { ContextMenuModel } from "@/app/store/contextmenu"; -import { recordTEvent, refocusNode, WOS } from "@/app/store/global"; +import { getBlockBadgeAtom } from "@/app/store/badge"; +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 { 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 { BlockEnv } from "./blockenv"; import { BlockFrameProps } from "./blocktypes"; function handleHeaderContextMenu( e: React.MouseEvent, blockId: string, viewModel: ViewModel, - nodeModel: NodeModel + nodeModel: NodeModel, + blockEnv: BlockEnv ) { e.preventDefault(); e.stopPropagation(); const magnified = globalStore.get(nodeModel.isMagnified); - let menu: ContextMenuItem[] = [ + const menu: ContextMenuItem[] = [ { label: magnified ? "Un-Magnify Block" : "Magnify Block", click: () => { @@ -57,19 +59,22 @@ function handleHeaderContextMenu( click: () => uxCloseBlock(blockId), } ); - ContextMenuModel.getInstance().showContextMenu(menu, e); + blockEnv.showContextMenu(menu, e); } 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") { @@ -108,6 +113,7 @@ type HeaderEndIconsProps = { }; const HeaderEndIcons = React.memo(({ viewModel, nodeModel, blockId }: HeaderEndIconsProps) => { + const blockEnv = useWaveEnv(); const endIconButtons = util.useAtomValueSafe(viewModel?.endIconButtons); const magnified = jotai.useAtomValue(nodeModel.isMagnified); const ephemeral = jotai.useAtomValue(nodeModel.isEphemeral); @@ -123,7 +129,7 @@ const HeaderEndIcons = React.memo(({ viewModel, nodeModel, blockId }: HeaderEndI elemtype: "iconbutton", icon: "cog", title: "Settings", - click: (e) => handleHeaderContextMenu(e, blockId, viewModel, nodeModel), + click: (e) => handleHeaderContextMenu(e, blockId, viewModel, nodeModel, blockEnv), }; endIconsElem.push(); if (ephemeral) { @@ -170,37 +176,43 @@ 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); 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); + 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 (
handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel)} + onContextMenu={(e) => handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel, waveEnv)} > {!useTermHeader && ( <> @@ -215,7 +227,7 @@ const BlockFrame_Header = ({ @@ -229,7 +241,12 @@ const BlockFrame_Header = ({ divClassName="iconbutton disabled text-[13px] ml-[-4px]" /> )} - + {useTermHeader && badge && ( +
+ +
+ )} +
); 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/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 = ({
{text} 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 ( } = { [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-common.tsx b/frontend/app/onboarding/onboarding-common.tsx index 9a506b2567..44001aca5d 100644 --- a/frontend/app/onboarding/onboarding-common.tsx +++ b/frontend/app/onboarding/onboarding-common.tsx @@ -1,7 +1,7 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -export const CurrentOnboardingVersion = "v0.14.1"; +export const CurrentOnboardingVersion = "v0.14.2"; export function OnboardingGradientBg() { return ( diff --git a/frontend/app/onboarding/onboarding-upgrade-patch.tsx b/frontend/app/onboarding/onboarding-upgrade-patch.tsx index 5984eeef53..60760ffea1 100644 --- a/frontend/app/onboarding/onboarding-upgrade-patch.tsx +++ b/frontend/app/onboarding/onboarding-upgrade-patch.tsx @@ -24,6 +24,7 @@ import { UpgradeOnboardingModal_v0_13_0_Content } from "./onboarding-upgrade-v01 import { UpgradeOnboardingModal_v0_13_1_Content } from "./onboarding-upgrade-v0131"; import { UpgradeOnboardingModal_v0_14_0_Content } from "./onboarding-upgrade-v0140"; import { UpgradeOnboardingModal_v0_14_1_Content } from "./onboarding-upgrade-v0141"; +import { UpgradeOnboardingModal_v0_14_2_Content } from "./onboarding-upgrade-v0142"; interface VersionConfig { version: string; @@ -32,6 +33,10 @@ interface VersionConfig { nextText?: string; } +interface UpgradeOnboardingPatchProps { + isReleaseNotes?: boolean; +} + interface UpgradeOnboardingFooterProps { hasPrev: boolean; hasNext: boolean; @@ -128,10 +133,16 @@ export const UpgradeOnboardingVersions: VersionConfig[] = [ version: "v0.14.1", content: () => , prevText: "Prev (v0.14.0)", + nextText: "Next (v0.14.2)", + }, + { + version: "v0.14.2", + content: () => , + prevText: "Prev (v0.14.1)", }, ]; -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 +185,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/onboarding/onboarding-upgrade-v0140.tsx b/frontend/app/onboarding/onboarding-upgrade-v0140.tsx index d2b7b18215..0102b691e2 100644 --- a/frontend/app/onboarding/onboarding-upgrade-v0140.tsx +++ b/frontend/app/onboarding/onboarding-upgrade-v0140.tsx @@ -1,9 +1,10 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { getApi } from "@/app/store/global"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; const UpgradeOnboardingModal_v0_14_0_Content = () => { + const waveEnv = useWaveEnv(); return (
@@ -22,7 +23,7 @@ const UpgradeOnboardingModal_v0_14_0_Content = () => {
Durable SSH Sessions{" "} + + +
+
+
+ +
+
+ +
+
+
Other Changes
+
+
    +
  • + Directory Preview - Improved mod time formatting, zebra-striped rows, + better default sort, and YAML file support +
  • +
  • + Search Bar - Clipboard and focus improvements +
  • +
  • [bugfix] Fixed "New Window" hanging on GNOME desktops
  • +
  • [bugfix] Fixed "Save Session As..." focused window tracking bug
  • +
+
+
+
+
+ ); +}; + +UpgradeOnboardingModal_v0_14_2_Content.displayName = "UpgradeOnboardingModal_v0_14_2_Content"; + +export { UpgradeOnboardingModal_v0_14_2_Content }; diff --git a/frontend/app/onboarding/onboarding-upgrade.tsx b/frontend/app/onboarding/onboarding-upgrade.tsx index 11a94ead75..7ab60878c2 100644 --- a/frontend/app/onboarding/onboarding-upgrade.tsx +++ b/frontend/app/onboarding/onboarding-upgrade.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ClientModel } from "@/app/store/client-model"; -import { atoms, globalStore } from "@/app/store/global"; +import { globalStore } from "@/app/store/global"; import { modalsModel } from "@/app/store/modalmodel"; import { useAtomValue } from "jotai"; import { useEffect, useRef } from "react"; 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/badge.ts b/frontend/app/store/badge.ts new file mode 100644 index 0000000000..745a2eb4da --- /dev/null +++ b/frontend/app/store/badge.ts @@ -0,0 +1,267 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +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"; +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 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], + data: { + oref: oref, + clear: true, + } as BadgeEvent, + }; + publishBadgeEvent(eventData, env); +} + +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, env); + } +} + +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, env); + } +} + +function clearAllBadges(env?: BadgeEnv) { + const eventData: WaveEvent = { + event: "badge", + scopes: [], + data: { + oref: "", + clearall: true, + } as BadgeEvent, + }; + publishBadgeEvent(eventData, env); +} + +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 ?? []; + 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, env); + } + } +} + +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, env?: TabBadgesEnv): 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 = env != null ? env.wos.getWaveObjectAtom(tabOref) : WOS.getWaveObjectAtom(tabOref); + 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(env?: LoadBadgesEnv) { + const rpc = env != null ? env.rpc : RpcApi; + const badges = await rpc.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 }, env?: BadgeEnv) { + 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, + }; + publishBadgeEvent(eventData, env); +} + +function clearBadgeById(blockId: string, badgeId: string, env?: BadgeEnv) { + const oref = WOS.makeORef("block", blockId); + const eventData: WaveEvent = { + event: "badge", + scopes: [oref], + data: { + oref: oref, + clearbyid: badgeId, + } as BadgeEvent, + }; + publishBadgeEvent(eventData, env); +} + +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; + } + if (data.clear) { + globalStore.set(curAtom, null); + return; + } + if (data.badge == null) { + return; + } + const existing = globalStore.get(curAtom); + if (existing == null || cmpBadge(data.badge, existing) > 0) { + globalStore.set(curAtom, data.badge); + } + }, + }); +} + +function cmpBadge(a: Badge, b: Badge): number { + if (a.priority !== b.priority) { + return a.priority > b.priority ? 1 : -1; + } + if (a.badgeid !== b.badgeid) { + return a.badgeid > b.badgeid ? 1 : -1; + } + return 0; +} + +function sortBadges(badges: Badge[]): Badge[] { + return [...badges].sort((a, b) => cmpBadge(b, a)); +} + +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/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/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 ac36fcec8e..01fe12800e 100644 --- a/frontend/app/store/global-atoms.ts +++ b/frontend/app/store/global-atoms.ts @@ -9,14 +9,13 @@ 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) { 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, @@ -44,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>; @@ -68,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); @@ -124,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, @@ -154,4 +163,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-model.ts b/frontend/app/store/global-model.ts index 804e3a18f6..05e84e3774 100644 --- a/frontend/app/store/global-model.ts +++ b/frontend/app/store/global-model.ts @@ -3,14 +3,18 @@ import * as WOS from "@/app/store/wos"; import { ClientModel } from "@/app/store/client-model"; +import { getApi } from "@/store/global"; +import * as util from "@/util/util"; import { atom, Atom } from "jotai"; class GlobalModel { private static instance: GlobalModel; + static readonly IsActiveThrottleMs = 5000; windowId: string; builderId: string; platform: NodeJS.Platform; + lastSetIsActiveTs = 0; windowDataAtom!: Atom; workspaceAtom!: Atom; @@ -47,6 +51,15 @@ class GlobalModel { return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get); }); } + + setIsActive(): void { + const now = Date.now(); + if (now - this.lastSetIsActiveTs < GlobalModel.IsActiveThrottleMs) { + return; + } + this.lastSetIsActiveTs = now; + util.fireAndForget(() => getApi().setIsActive()); + } } -export { GlobalModel }; \ No newline at end of file +export { GlobalModel }; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 628ae03626..01d4ebbc96 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,18 +124,14 @@ 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); 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; @@ -157,8 +140,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 +154,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); @@ -341,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. */ @@ -571,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; } @@ -608,17 +585,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 +595,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); @@ -638,33 +604,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); @@ -672,76 +639,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 +655,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, @@ -773,6 +666,7 @@ export { getApi, getBlockComponentModel, getBlockMetaKeyAtom, + getConnConfigKeyAtom, getBlockTermDurableAtom, getConnStatusAtom, getFocusedBlockId, @@ -783,7 +677,6 @@ export { getOverrideConfigAtom, getSettingsKeyAtom, getSettingsPrefixAtom, - getTabIndicatorAtom, getUserName, globalPrimaryTabStartup, globalStore, @@ -791,7 +684,7 @@ export { initGlobalWaveEventSubs, isDev, loadConnStatus, - loadTabIndicators, + makeDefaultConnStatus, openLink, readAtom, recordTEvent, @@ -801,13 +694,10 @@ export { setActiveTab, setNodeFocus, setPlatform, - setTabIndicator, subscribeToConnEvents, unregisterBlockComponentModel, useBlockAtom, useBlockCache, - useBlockDataLoaded, - useBlockMetaKeyAtom, useOrefMetaKeyAtom, useOverrideConfigAtom, useSettingsKeyAtom, diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index aa25448a0a..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"; @@ -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; @@ -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)) { @@ -695,7 +695,15 @@ function registerGlobalKeys() { return false; } if (bcm.viewModel.searchAtoms) { - globalStore.set(bcm.viewModel.searchAtoms.isOpen, true); + if (globalStore.get(bcm.viewModel.searchAtoms.isOpen)) { + // Already open — increment the focusInput counter so this block's + // SearchComponent focuses its own input (avoids a global DOM query + // that could target the wrong block when multiple searches are open). + const cur = globalStore.get(bcm.viewModel.searchAtoms.focusInput) as number; + globalStore.set(bcm.viewModel.searchAtoms.focusInput, cur + 1); + } else { + globalStore.set(bcm.viewModel.searchAtoms.isOpen, true); + } return true; } return false; diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index d9d7730e18..3dad2a3e5c 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -4,188 +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)) - } - - // move block to new window - // @returns object updates - MoveBlockToNewWindow(currentTabId: string, blockId: string): Promise { - return WOS.callBackendService("window", "MoveBlockToNewWindow", 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 ec5ab94c16..a867440820 100644 --- a/frontend/app/store/tab-model.ts +++ b/frontend/app/store/tab-model.ts @@ -1,24 +1,34 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +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: TabModelEnv; tabAtom: Atom; tabNumBlocksAtom: Atom; isTermMultiInput = atom(false) as PrimitiveAtom; metaCache: Map> = new Map(); - constructor(tabId: string) { + constructor(tabId: string, waveEnv?: TabModelEnv) { 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 +50,42 @@ export class TabModel { } } -export function getTabModelByTabId(tabId: string): 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); - tabModelCache.set(tabId, model); + model = new TabModel(tabId, waveEnv); + waveEnv.mockModels.set(key, model); } return model; } -export function getActiveTabModel(): TabModel | null { +export function getActiveTabModel(waveEnv?: TabModelEnv): 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 ctxModel = useContext(TabModelContext); + if (ctxModel == null) { throw new Error("useTabModel must be used within a TabModelProvider"); } - return model; + return ctxModel; } -export function maybeUseTabModel(): TabModel { +export function useTabModelMaybe(): TabModel { return useContext(TabModelContext); } diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts index 4ce339acd1..72ca022750 100644 --- a/frontend/app/store/wos.ts +++ b/frontend/app/store/wos.ts @@ -3,12 +3,12 @@ // WaveObjectStore +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"; import { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from "jotai"; -import { useEffect } from "react"; import { globalStore } from "./jotaiStore"; import { ObjectService } from "./services"; @@ -20,8 +20,6 @@ type WaveObjectDataItemType = { type WaveObjectValue = { pendingPromise: Promise; dataAtom: PrimitiveAtom>; - refCount: number; - holdTime: number; }; function splitORef(oref: string): [string, string] { @@ -57,7 +55,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 +115,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), @@ -136,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) { @@ -156,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; @@ -195,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); @@ -203,35 +208,48 @@ function loadAndPinWaveObject(oref: string): Promise { return wov.pendingPromise; } -function getWaveObjectAtom(oref: string): WritableWaveObjectAtom { +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, - (_get, set, value: T) => { - setObjectValue(value, set, true); - } - ); + 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]; } @@ -257,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; } @@ -267,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 @@ -308,13 +316,13 @@ function setObjectValue(value: T, setFn?: Setter, pushToServe export { callBackendService, - cleanWaveObjectCache, - clearWaveObjectCache, getObjectValue, getWaveObjectAtom, getWaveObjectLoadingAtom, + isWaveObjectNullAtom, loadAndPinWaveObject, makeORef, + mockObjectForPreview, reloadWaveObject, setObjectValue, splitORef, 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 33f85126d9..6b9f4a72d4 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -5,865 +5,1066 @@ 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; +} + // WshServerCommandToDeclMap -class RpcApiType { +export class RpcApiType { + mockClient: MockRpcClient = null; + + setMockRpcClient(client: MockRpcClient): void { + this.mockClient = client; + } + // command "activity" [call] ActivityCommand(client: WshClient, data: ActivityUpdate, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "activity", data, opts); return client.wshRpcCall("activity", data, opts); } // command "aisendmessage" [call] AiSendMessageCommand(client: WshClient, data: AiMessageData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "aisendmessage", data, opts); return client.wshRpcCall("aisendmessage", data, opts); } // command "authenticate" [call] AuthenticateCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "authenticate", data, opts); return client.wshRpcCall("authenticate", data, opts); } // command "authenticatejobmanager" [call] AuthenticateJobManagerCommand(client: WshClient, data: CommandAuthenticateJobManagerData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "authenticatejobmanager", data, opts); return client.wshRpcCall("authenticatejobmanager", data, opts); } // command "authenticatejobmanagerverify" [call] AuthenticateJobManagerVerifyCommand(client: WshClient, data: CommandAuthenticateJobManagerData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "authenticatejobmanagerverify", data, opts); return client.wshRpcCall("authenticatejobmanagerverify", data, opts); } // command "authenticatetojobmanager" [call] AuthenticateToJobManagerCommand(client: WshClient, data: CommandAuthenticateToJobData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "authenticatetojobmanager", data, opts); return client.wshRpcCall("authenticatetojobmanager", data, opts); } // command "authenticatetoken" [call] AuthenticateTokenCommand(client: WshClient, data: CommandAuthenticateTokenData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "authenticatetoken", data, opts); return client.wshRpcCall("authenticatetoken", data, opts); } // command "authenticatetokenverify" [call] AuthenticateTokenVerifyCommand(client: WshClient, data: CommandAuthenticateTokenData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "authenticatetokenverify", data, opts); return client.wshRpcCall("authenticatetokenverify", data, opts); } + // command "badgewatchpid" [call] + BadgeWatchPidCommand(client: WshClient, data: CommandBadgeWatchPidData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "badgewatchpid", data, opts); + return client.wshRpcCall("badgewatchpid", data, opts); + } + // command "blockinfo" [call] BlockInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "blockinfo", data, opts); return client.wshRpcCall("blockinfo", data, opts); } // command "blockjobstatus" [call] BlockJobStatusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "blockjobstatus", data, opts); return client.wshRpcCall("blockjobstatus", data, opts); } // command "blockslist" [call] BlocksListCommand(client: WshClient, data: BlocksListRequest, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "blockslist", data, opts); return client.wshRpcCall("blockslist", data, opts); } // command "captureblockscreenshot" [call] CaptureBlockScreenshotCommand(client: WshClient, data: CommandCaptureBlockScreenshotData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "captureblockscreenshot", data, opts); return client.wshRpcCall("captureblockscreenshot", data, opts); } // command "checkgoversion" [call] CheckGoVersionCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "checkgoversion", null, opts); return client.wshRpcCall("checkgoversion", null, opts); } // command "connconnect" [call] ConnConnectCommand(client: WshClient, data: ConnRequest, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connconnect", data, opts); return client.wshRpcCall("connconnect", data, opts); } // command "conndisconnect" [call] ConnDisconnectCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "conndisconnect", data, opts); return client.wshRpcCall("conndisconnect", data, opts); } // command "connensure" [call] ConnEnsureCommand(client: WshClient, data: ConnExtData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connensure", data, opts); return client.wshRpcCall("connensure", data, opts); } // command "connlist" [call] ConnListCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connlist", null, opts); return client.wshRpcCall("connlist", null, opts); } // command "connreinstallwsh" [call] ConnReinstallWshCommand(client: WshClient, data: ConnExtData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connreinstallwsh", data, opts); return client.wshRpcCall("connreinstallwsh", data, opts); } // command "connserverinit" [call] ConnServerInitCommand(client: WshClient, data: CommandConnServerInitData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connserverinit", data, opts); return client.wshRpcCall("connserverinit", data, opts); } // command "connstatus" [call] ConnStatusCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connstatus", null, opts); return client.wshRpcCall("connstatus", null, opts); } // command "connupdatewsh" [call] ConnUpdateWshCommand(client: WshClient, data: RemoteInfo, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connupdatewsh", data, opts); return client.wshRpcCall("connupdatewsh", data, opts); } // command "controlgetrouteid" [call] ControlGetRouteIdCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "controlgetrouteid", null, opts); return client.wshRpcCall("controlgetrouteid", null, opts); } // command "controllerappendoutput" [call] ControllerAppendOutputCommand(client: WshClient, data: CommandControllerAppendOutputData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "controllerappendoutput", data, opts); return client.wshRpcCall("controllerappendoutput", data, opts); } // command "controllerdestroy" [call] ControllerDestroyCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "controllerdestroy", data, opts); return client.wshRpcCall("controllerdestroy", data, opts); } // command "controllerinput" [call] ControllerInputCommand(client: WshClient, data: CommandBlockInputData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "controllerinput", data, opts); return client.wshRpcCall("controllerinput", data, opts); } // command "controllerresync" [call] ControllerResyncCommand(client: WshClient, data: CommandControllerResyncData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "controllerresync", data, opts); return client.wshRpcCall("controllerresync", data, opts); } // command "createblock" [call] CreateBlockCommand(client: WshClient, data: CommandCreateBlockData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "createblock", data, opts); return client.wshRpcCall("createblock", data, opts); } // command "createsubblock" [call] CreateSubBlockCommand(client: WshClient, data: CommandCreateSubBlockData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "createsubblock", data, opts); return client.wshRpcCall("createsubblock", data, opts); } // command "debugterm" [call] DebugTermCommand(client: WshClient, data: CommandDebugTermData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "debugterm", data, opts); return client.wshRpcCall("debugterm", data, opts); } // command "deleteappfile" [call] DeleteAppFileCommand(client: WshClient, data: CommandDeleteAppFileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "deleteappfile", data, opts); return client.wshRpcCall("deleteappfile", data, opts); } // command "deleteblock" [call] DeleteBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "deleteblock", data, opts); return client.wshRpcCall("deleteblock", data, opts); } // command "deletebuilder" [call] DeleteBuilderCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "deletebuilder", data, opts); return client.wshRpcCall("deletebuilder", data, opts); } // command "deletesubblock" [call] DeleteSubBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "deletesubblock", data, opts); return client.wshRpcCall("deletesubblock", data, opts); } // command "dismisswshfail" [call] DismissWshFailCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "dismisswshfail", data, opts); return client.wshRpcCall("dismisswshfail", data, opts); } // command "dispose" [call] DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "dispose", data, opts); return client.wshRpcCall("dispose", data, opts); } // command "disposesuggestions" [call] DisposeSuggestionsCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "disposesuggestions", data, opts); return client.wshRpcCall("disposesuggestions", data, opts); } // command "electrondecrypt" [call] ElectronDecryptCommand(client: WshClient, data: CommandElectronDecryptData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "electrondecrypt", data, opts); return client.wshRpcCall("electrondecrypt", data, opts); } // command "electronencrypt" [call] ElectronEncryptCommand(client: WshClient, data: CommandElectronEncryptData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "electronencrypt", data, opts); return client.wshRpcCall("electronencrypt", data, opts); } // command "electronsystembell" [call] ElectronSystemBellCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "electronsystembell", null, opts); return client.wshRpcCall("electronsystembell", null, opts); } // command "eventpublish" [call] EventPublishCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "eventpublish", data, opts); return client.wshRpcCall("eventpublish", data, opts); } // command "eventreadhistory" [call] EventReadHistoryCommand(client: WshClient, data: CommandEventReadHistoryData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "eventreadhistory", data, opts); return client.wshRpcCall("eventreadhistory", data, opts); } // command "eventrecv" [call] EventRecvCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "eventrecv", data, opts); return client.wshRpcCall("eventrecv", data, opts); } // command "eventsub" [call] EventSubCommand(client: WshClient, data: SubscriptionRequest, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "eventsub", data, opts); return client.wshRpcCall("eventsub", data, opts); } // command "eventunsub" [call] EventUnsubCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "eventunsub", data, opts); return client.wshRpcCall("eventunsub", data, opts); } // command "eventunsuball" [call] EventUnsubAllCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "eventunsuball", null, opts); return client.wshRpcCall("eventunsuball", null, opts); } // command "fetchsuggestions" [call] FetchSuggestionsCommand(client: WshClient, data: FetchSuggestionsData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "fetchsuggestions", data, opts); return client.wshRpcCall("fetchsuggestions", data, opts); } // command "fileappend" [call] FileAppendCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "fileappend", data, opts); return client.wshRpcCall("fileappend", data, opts); } // command "filecopy" [call] FileCopyCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filecopy", data, opts); return client.wshRpcCall("filecopy", data, opts); } // command "filecreate" [call] FileCreateCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filecreate", data, opts); return client.wshRpcCall("filecreate", data, opts); } // command "filedelete" [call] FileDeleteCommand(client: WshClient, data: CommandDeleteFileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filedelete", data, opts); return client.wshRpcCall("filedelete", data, opts); } // command "fileinfo" [call] FileInfoCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "fileinfo", data, opts); return client.wshRpcCall("fileinfo", data, opts); } // command "filejoin" [call] FileJoinCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filejoin", data, opts); return client.wshRpcCall("filejoin", data, opts); } // command "filelist" [call] FileListCommand(client: WshClient, data: FileListData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filelist", data, opts); return client.wshRpcCall("filelist", data, opts); } // command "fileliststream" [responsestream] FileListStreamCommand(client: WshClient, data: FileListData, opts?: RpcOpts): AsyncGenerator { + if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "fileliststream", data, opts); return client.wshRpcStream("fileliststream", data, opts); } // command "filemkdir" [call] FileMkdirCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filemkdir", data, opts); return client.wshRpcCall("filemkdir", data, opts); } // command "filemove" [call] FileMoveCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filemove", data, opts); return client.wshRpcCall("filemove", data, opts); } // command "fileread" [call] FileReadCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "fileread", data, opts); return client.wshRpcCall("fileread", data, opts); } // command "filereadstream" [responsestream] FileReadStreamCommand(client: WshClient, data: FileData, opts?: RpcOpts): AsyncGenerator { + if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "filereadstream", data, opts); return client.wshRpcStream("filereadstream", data, opts); } // command "filerestorebackup" [call] FileRestoreBackupCommand(client: WshClient, data: CommandFileRestoreBackupData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filerestorebackup", data, opts); return client.wshRpcCall("filerestorebackup", data, opts); } // command "filewrite" [call] FileWriteCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filewrite", data, opts); return client.wshRpcCall("filewrite", data, opts); } // command "findgitbash" [call] FindGitBashCommand(client: WshClient, data: boolean, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "findgitbash", data, opts); return client.wshRpcCall("findgitbash", data, opts); } // command "focuswindow" [call] FocusWindowCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "focuswindow", data, opts); return client.wshRpcCall("focuswindow", data, opts); } - // command "getalltabindicators" [call] - GetAllTabIndicatorsCommand(client: WshClient, opts?: RpcOpts): Promise<{[key: string]: TabIndicator}> { - return client.wshRpcCall("getalltabindicators", null, opts); + // command "getallbadges" [call] + GetAllBadgesCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getallbadges", null, opts); + return client.wshRpcCall("getallbadges", null, opts); } // command "getallvars" [call] GetAllVarsCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getallvars", data, opts); return client.wshRpcCall("getallvars", data, opts); } // command "getbuilderoutput" [call] GetBuilderOutputCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getbuilderoutput", data, opts); return client.wshRpcCall("getbuilderoutput", data, opts); } // command "getbuilderstatus" [call] GetBuilderStatusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getbuilderstatus", data, opts); return client.wshRpcCall("getbuilderstatus", data, opts); } // command "getfocusedblockdata" [call] GetFocusedBlockDataCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getfocusedblockdata", null, opts); return client.wshRpcCall("getfocusedblockdata", null, opts); } // command "getfullconfig" [call] GetFullConfigCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getfullconfig", null, opts); return client.wshRpcCall("getfullconfig", null, opts); } // command "getjwtpublickey" [call] GetJwtPublicKeyCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getjwtpublickey", null, opts); return client.wshRpcCall("getjwtpublickey", null, opts); } // command "getmeta" [call] GetMetaCommand(client: WshClient, data: CommandGetMetaData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getmeta", data, opts); return client.wshRpcCall("getmeta", data, opts); } // command "getrtinfo" [call] GetRTInfoCommand(client: WshClient, data: CommandGetRTInfoData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.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 (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getsecrets", data, opts); return client.wshRpcCall("getsecrets", data, opts); } // command "getsecretslinuxstoragebackend" [call] GetSecretsLinuxStorageBackendCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getsecretslinuxstoragebackend", null, opts); return client.wshRpcCall("getsecretslinuxstoragebackend", null, opts); } // command "getsecretsnames" [call] GetSecretsNamesCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getsecretsnames", null, opts); return client.wshRpcCall("getsecretsnames", null, opts); } // command "gettab" [call] GetTabCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "gettab", data, opts); return client.wshRpcCall("gettab", data, opts); } // command "gettempdir" [call] GetTempDirCommand(client: WshClient, data: CommandGetTempDirData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "gettempdir", data, opts); return client.wshRpcCall("gettempdir", data, opts); } // command "getupdatechannel" [call] GetUpdateChannelCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getupdatechannel", null, opts); return client.wshRpcCall("getupdatechannel", null, opts); } // command "getvar" [call] GetVarCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getvar", data, opts); return client.wshRpcCall("getvar", data, opts); } // command "getwaveaichat" [call] GetWaveAIChatCommand(client: WshClient, data: CommandGetWaveAIChatData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getwaveaichat", data, opts); return client.wshRpcCall("getwaveaichat", data, opts); } // command "getwaveaimodeconfig" [call] GetWaveAIModeConfigCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getwaveaimodeconfig", null, opts); return client.wshRpcCall("getwaveaimodeconfig", null, opts); } // command "getwaveairatelimit" [call] GetWaveAIRateLimitCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getwaveairatelimit", null, opts); return client.wshRpcCall("getwaveairatelimit", null, opts); } // command "jobcmdexited" [call] JobCmdExitedCommand(client: WshClient, data: CommandJobCmdExitedData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcmdexited", data, opts); return client.wshRpcCall("jobcmdexited", data, opts); } // command "jobcontrollerattachjob" [call] JobControllerAttachJobCommand(client: WshClient, data: CommandJobControllerAttachJobData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerattachjob", data, opts); return client.wshRpcCall("jobcontrollerattachjob", data, opts); } // command "jobcontrollerconnectedjobs" [call] JobControllerConnectedJobsCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerconnectedjobs", null, opts); return client.wshRpcCall("jobcontrollerconnectedjobs", null, opts); } // command "jobcontrollerdeletejob" [call] JobControllerDeleteJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerdeletejob", data, opts); return client.wshRpcCall("jobcontrollerdeletejob", data, opts); } // command "jobcontrollerdetachjob" [call] JobControllerDetachJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerdetachjob", data, opts); return client.wshRpcCall("jobcontrollerdetachjob", data, opts); } // command "jobcontrollerdisconnectjob" [call] JobControllerDisconnectJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerdisconnectjob", data, opts); return client.wshRpcCall("jobcontrollerdisconnectjob", data, opts); } // command "jobcontrollerexitjob" [call] JobControllerExitJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerexitjob", data, opts); return client.wshRpcCall("jobcontrollerexitjob", data, opts); } // command "jobcontrollergetalljobmanagerstatus" [call] JobControllerGetAllJobManagerStatusCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollergetalljobmanagerstatus", null, opts); return client.wshRpcCall("jobcontrollergetalljobmanagerstatus", null, opts); } // command "jobcontrollerlist" [call] JobControllerListCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerlist", null, opts); return client.wshRpcCall("jobcontrollerlist", null, opts); } // command "jobcontrollerreconnectjob" [call] JobControllerReconnectJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerreconnectjob", data, opts); return client.wshRpcCall("jobcontrollerreconnectjob", data, opts); } // command "jobcontrollerreconnectjobsforconn" [call] JobControllerReconnectJobsForConnCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerreconnectjobsforconn", data, opts); return client.wshRpcCall("jobcontrollerreconnectjobsforconn", data, opts); } // command "jobcontrollerstartjob" [call] JobControllerStartJobCommand(client: WshClient, data: CommandJobControllerStartJobData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerstartjob", data, opts); return client.wshRpcCall("jobcontrollerstartjob", data, opts); } // command "jobinput" [call] JobInputCommand(client: WshClient, data: CommandJobInputData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobinput", data, opts); return client.wshRpcCall("jobinput", data, opts); } // command "jobprepareconnect" [call] JobPrepareConnectCommand(client: WshClient, data: CommandJobPrepareConnectData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobprepareconnect", data, opts); return client.wshRpcCall("jobprepareconnect", data, opts); } // command "jobstartstream" [call] JobStartStreamCommand(client: WshClient, data: CommandJobStartStreamData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobstartstream", data, opts); return client.wshRpcCall("jobstartstream", data, opts); } // command "listallappfiles" [call] ListAllAppFilesCommand(client: WshClient, data: CommandListAllAppFilesData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "listallappfiles", data, opts); return client.wshRpcCall("listallappfiles", data, opts); } // command "listallapps" [call] ListAllAppsCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "listallapps", null, opts); return client.wshRpcCall("listallapps", null, opts); } // command "listalleditableapps" [call] ListAllEditableAppsCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "listalleditableapps", null, opts); return client.wshRpcCall("listalleditableapps", null, opts); } // command "makedraftfromlocal" [call] MakeDraftFromLocalCommand(client: WshClient, data: CommandMakeDraftFromLocalData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "makedraftfromlocal", data, opts); return client.wshRpcCall("makedraftfromlocal", data, opts); } // command "message" [call] MessageCommand(client: WshClient, data: CommandMessageData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "message", data, opts); return client.wshRpcCall("message", data, opts); } // command "networkonline" [call] NetworkOnlineCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "networkonline", null, opts); return client.wshRpcCall("networkonline", null, opts); } // command "notify" [call] NotifyCommand(client: WshClient, data: WaveNotificationOptions, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "notify", data, opts); return client.wshRpcCall("notify", data, opts); } // command "notifysystemresume" [call] NotifySystemResumeCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "notifysystemresume", null, opts); return client.wshRpcCall("notifysystemresume", null, opts); } // command "path" [call] PathCommand(client: WshClient, data: PathCommandData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "path", data, opts); return client.wshRpcCall("path", data, opts); } // command "publishapp" [call] PublishAppCommand(client: WshClient, data: CommandPublishAppData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "publishapp", data, opts); return client.wshRpcCall("publishapp", data, opts); } // command "readappfile" [call] ReadAppFileCommand(client: WshClient, data: CommandReadAppFileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "readappfile", data, opts); return client.wshRpcCall("readappfile", data, opts); } // command "recordtevent" [call] RecordTEventCommand(client: WshClient, data: TEvent, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "recordtevent", data, opts); return client.wshRpcCall("recordtevent", data, opts); } // command "remotedisconnectfromjobmanager" [call] RemoteDisconnectFromJobManagerCommand(client: WshClient, data: CommandRemoteDisconnectFromJobManagerData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotedisconnectfromjobmanager", data, opts); return client.wshRpcCall("remotedisconnectfromjobmanager", data, opts); } // command "remotefilecopy" [call] RemoteFileCopyCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefilecopy", data, opts); return client.wshRpcCall("remotefilecopy", data, opts); } // command "remotefiledelete" [call] RemoteFileDeleteCommand(client: WshClient, data: CommandDeleteFileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefiledelete", data, opts); return client.wshRpcCall("remotefiledelete", data, opts); } // command "remotefileinfo" [call] RemoteFileInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefileinfo", data, opts); return client.wshRpcCall("remotefileinfo", data, opts); } // command "remotefilejoin" [call] RemoteFileJoinCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefilejoin", data, opts); return client.wshRpcCall("remotefilejoin", data, opts); } // command "remotefilemove" [call] RemoteFileMoveCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.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 (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefilemultiinfo", data, opts); return client.wshRpcCall("remotefilemultiinfo", data, opts); } // command "remotefiletouch" [call] RemoteFileTouchCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefiletouch", data, opts); return client.wshRpcCall("remotefiletouch", data, opts); } // command "remotegetinfo" [call] RemoteGetInfoCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotegetinfo", null, opts); return client.wshRpcCall("remotegetinfo", null, opts); } // command "remoteinstallrcfiles" [call] RemoteInstallRcFilesCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remoteinstallrcfiles", null, opts); return client.wshRpcCall("remoteinstallrcfiles", null, opts); } // command "remotelistentries" [responsestream] RemoteListEntriesCommand(client: WshClient, data: CommandRemoteListEntriesData, opts?: RpcOpts): AsyncGenerator { + if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "remotelistentries", data, opts); return client.wshRpcStream("remotelistentries", data, opts); } // command "remotemkdir" [call] RemoteMkdirCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotemkdir", data, opts); return client.wshRpcCall("remotemkdir", data, opts); } // command "remotereconnecttojobmanager" [call] RemoteReconnectToJobManagerCommand(client: WshClient, data: CommandRemoteReconnectToJobManagerData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotereconnecttojobmanager", data, opts); return client.wshRpcCall("remotereconnecttojobmanager", data, opts); } // command "remotestartjob" [call] RemoteStartJobCommand(client: WshClient, data: CommandRemoteStartJobData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotestartjob", data, opts); return client.wshRpcCall("remotestartjob", data, opts); } // command "remotestreamcpudata" [responsestream] RemoteStreamCpuDataCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { + if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "remotestreamcpudata", null, opts); return client.wshRpcStream("remotestreamcpudata", null, opts); } // command "remotestreamfile" [responsestream] RemoteStreamFileCommand(client: WshClient, data: CommandRemoteStreamFileData, opts?: RpcOpts): AsyncGenerator { + if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "remotestreamfile", data, opts); return client.wshRpcStream("remotestreamfile", data, opts); } // command "remoteterminatejobmanager" [call] RemoteTerminateJobManagerCommand(client: WshClient, data: CommandRemoteTerminateJobManagerData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remoteterminatejobmanager", data, opts); return client.wshRpcCall("remoteterminatejobmanager", data, opts); } // command "remotewritefile" [call] RemoteWriteFileCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotewritefile", data, opts); return client.wshRpcCall("remotewritefile", data, opts); } // command "renameappfile" [call] RenameAppFileCommand(client: WshClient, data: CommandRenameAppFileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "renameappfile", data, opts); return client.wshRpcCall("renameappfile", data, opts); } // command "resolveids" [call] ResolveIdsCommand(client: WshClient, data: CommandResolveIdsData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "resolveids", data, opts); return client.wshRpcCall("resolveids", data, opts); } // command "restartbuilderandwait" [call] RestartBuilderAndWaitCommand(client: WshClient, data: CommandRestartBuilderAndWaitData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "restartbuilderandwait", data, opts); return client.wshRpcCall("restartbuilderandwait", data, opts); } // command "routeannounce" [call] RouteAnnounceCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "routeannounce", null, opts); return client.wshRpcCall("routeannounce", null, opts); } // command "routeunannounce" [call] RouteUnannounceCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "routeunannounce", null, opts); return client.wshRpcCall("routeunannounce", null, opts); } // command "sendtelemetry" [call] SendTelemetryCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "sendtelemetry", null, opts); return client.wshRpcCall("sendtelemetry", null, opts); } // command "setblockfocus" [call] SetBlockFocusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setblockfocus", data, opts); return client.wshRpcCall("setblockfocus", data, opts); } // command "setconfig" [call] SetConfigCommand(client: WshClient, data: SettingsType, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setconfig", data, opts); return client.wshRpcCall("setconfig", data, opts); } // command "setconnectionsconfig" [call] SetConnectionsConfigCommand(client: WshClient, data: ConnConfigRequest, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setconnectionsconfig", data, opts); return client.wshRpcCall("setconnectionsconfig", data, opts); } // command "setmeta" [call] SetMetaCommand(client: WshClient, data: CommandSetMetaData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setmeta", data, opts); return client.wshRpcCall("setmeta", data, opts); } // command "setpeerinfo" [call] SetPeerInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setpeerinfo", data, opts); return client.wshRpcCall("setpeerinfo", data, opts); } // command "setrtinfo" [call] SetRTInfoCommand(client: WshClient, data: CommandSetRTInfoData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.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 (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setsecrets", data, opts); return client.wshRpcCall("setsecrets", data, opts); } // command "setvar" [call] SetVarCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setvar", data, opts); return client.wshRpcCall("setvar", data, opts); } // command "startbuilder" [call] StartBuilderCommand(client: WshClient, data: CommandStartBuilderData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "startbuilder", data, opts); return client.wshRpcCall("startbuilder", data, opts); } // command "startjob" [call] StartJobCommand(client: WshClient, data: CommandStartJobData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "startjob", data, opts); return client.wshRpcCall("startjob", data, opts); } // command "stopbuilder" [call] StopBuilderCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "stopbuilder", data, opts); return client.wshRpcCall("stopbuilder", data, opts); } // command "streamcpudata" [responsestream] StreamCpuDataCommand(client: WshClient, data: CpuDataRequest, opts?: RpcOpts): AsyncGenerator { + if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "streamcpudata", data, opts); return client.wshRpcStream("streamcpudata", data, opts); } // command "streamdata" [call] StreamDataCommand(client: WshClient, data: CommandStreamData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "streamdata", data, opts); return client.wshRpcCall("streamdata", data, opts); } // command "streamdataack" [call] StreamDataAckCommand(client: WshClient, data: CommandStreamAckData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "streamdataack", data, opts); return client.wshRpcCall("streamdataack", data, opts); } // command "streamtest" [responsestream] StreamTestCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { + if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "streamtest", null, opts); return client.wshRpcStream("streamtest", null, opts); } // command "streamwaveai" [responsestream] StreamWaveAiCommand(client: WshClient, data: WaveAIStreamRequest, opts?: RpcOpts): AsyncGenerator { + if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "streamwaveai", data, opts); return client.wshRpcStream("streamwaveai", data, opts); } // command "termgetscrollbacklines" [call] TermGetScrollbackLinesCommand(client: WshClient, data: CommandTermGetScrollbackLinesData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "termgetscrollbacklines", data, opts); return client.wshRpcCall("termgetscrollbacklines", data, opts); } // command "test" [call] TestCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.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 (this.mockClient) return this.mockClient.mockWshRpcCall(client, "testmultiarg", { args: [arg1, arg2, arg3] }, opts); 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); return client.wshRpcCall("vdomasyncinitiation", data, opts); } // command "vdomcreatecontext" [call] VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "vdomcreatecontext", data, opts); return client.wshRpcCall("vdomcreatecontext", data, opts); } // command "vdomrender" [responsestream] VDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): AsyncGenerator { + if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "vdomrender", data, opts); return client.wshRpcStream("vdomrender", data, opts); } // command "vdomurlrequest" [responsestream] VDomUrlRequestCommand(client: WshClient, data: VDomUrlRequestData, opts?: RpcOpts): AsyncGenerator { + if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "vdomurlrequest", data, opts); return client.wshRpcStream("vdomurlrequest", data, opts); } // command "waitforroute" [call] WaitForRouteCommand(client: WshClient, data: CommandWaitForRouteData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waitforroute", data, opts); return client.wshRpcCall("waitforroute", data, opts); } // command "waveaiaddcontext" [call] WaveAIAddContextCommand(client: WshClient, data: CommandWaveAIAddContextData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveaiaddcontext", data, opts); return client.wshRpcCall("waveaiaddcontext", data, opts); } // command "waveaienabletelemetry" [call] WaveAIEnableTelemetryCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveaienabletelemetry", null, opts); return client.wshRpcCall("waveaienabletelemetry", null, opts); } // command "waveaigettooldiff" [call] WaveAIGetToolDiffCommand(client: WshClient, data: CommandWaveAIGetToolDiffData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveaigettooldiff", data, opts); return client.wshRpcCall("waveaigettooldiff", data, opts); } // command "waveaitoolapprove" [call] WaveAIToolApproveCommand(client: WshClient, data: CommandWaveAIToolApproveData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveaitoolapprove", data, opts); return client.wshRpcCall("waveaitoolapprove", data, opts); } // command "wavefilereadstream" [call] WaveFileReadStreamCommand(client: WshClient, data: CommandWaveFileReadStreamData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "wavefilereadstream", data, opts); return client.wshRpcCall("wavefilereadstream", data, opts); } // command "waveinfo" [call] WaveInfoCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveinfo", null, opts); return client.wshRpcCall("waveinfo", null, opts); } // command "webselector" [call] WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "webselector", data, opts); return client.wshRpcCall("webselector", data, opts); } // command "workspacelist" [call] WorkspaceListCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "workspacelist", null, opts); return client.wshRpcCall("workspacelist", null, opts); } // command "writeappfile" [call] WriteAppFileCommand(client: WshClient, data: CommandWriteAppFileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "writeappfile", data, opts); return client.wshRpcCall("writeappfile", data, opts); } // command "writeappgofile" [call] WriteAppGoFileCommand(client: WshClient, data: CommandWriteAppGoFileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "writeappgofile", data, opts); return client.wshRpcCall("writeappgofile", data, opts); } // command "writeappsecretbindings" [call] WriteAppSecretBindingsCommand(client: WshClient, data: CommandWriteAppSecretBindingsData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "writeappsecretbindings", data, opts); return client.wshRpcCall("writeappsecretbindings", data, opts); } // command "writetempfile" [call] WriteTempFileCommand(client: WshClient, data: CommandWriteTempFileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.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 (this.mockClient) return this.mockClient.mockWshRpcCall(client, "wshactivity", data, opts); return client.wshRpcCall("wshactivity", data, opts); } // command "wsldefaultdistro" [call] WslDefaultDistroCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "wsldefaultdistro", null, opts); return client.wshRpcCall("wsldefaultdistro", null, opts); } // command "wsllist" [call] WslListCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "wsllist", null, opts); return client.wshRpcCall("wsllist", null, opts); } // command "wslstatus" [call] WslStatusCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "wslstatus", null, opts); return client.wshRpcCall("wslstatus", null, opts); } 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..6b3679bb37 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -1,37 +1,43 @@ -// 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 { RpcApi } from "@/app/store/wshclientapi"; +import { getTabBadgeAtom } from "@/app/store/badge"; +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 { fireAndForget, makeIconClass } from "@/util/util"; +import { validateCssColor } from "@/util/color-validator"; +import { fireAndForget } from "@/util/util"; import clsx from "clsx"; import { useAtomValue } from "jotai"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; -import { ObjectService } from "../store/services"; -import { makeORef, useWaveObjectValue } from "../store/wos"; +import { makeORef } from "../store/wos"; +import { TabBadges } from "./tabbadges"; 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; 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; @@ -46,11 +52,12 @@ const TabV = forwardRef((props, ref) => { tabId, tabName, active, - isBeforeActive, + showDivider, isDragging, tabWidth, isNew, - indicator, + badges, + flagColor, onClick, onClose, onDragStart, @@ -58,7 +65,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); @@ -68,7 +78,7 @@ const TabV = forwardRef((props, ref) => { useImperativeHandle(ref, () => tabRef.current as HTMLDivElement); useEffect(() => { - setOriginalName(tabName); + setOriginalName(truncateTabName(tabName)); }, [tabName]); useEffect(() => { @@ -144,8 +154,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(); + } } }; @@ -167,7 +180,6 @@ const TabV = forwardRef((props, ref) => { className={clsx("tab", { active, dragging: isDragging, - "before-active": isBeforeActive, "new-tab": isNew, })} onMouseDown={onDragStart} @@ -175,6 +187,7 @@ const TabV = forwardRef((props, ref) => { onContextMenu={onContextMenu} data-tab-id={tabId} > + {showDivider &&
}
((props, ref) => { onKeyDown={handleKeyDown} suppressContentEditableWarning={true} > - {tabName} + {displayName}
- {indicator && ( -
- -
- )} + + + ); }; @@ -150,25 +152,8 @@ 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 env = useWaveEnv(); const [tabIds, setTabIds] = useState([]); const [dragStartPositions, setDragStartPositions] = useState([]); const [draggingTab, setDraggingTab] = useState(); @@ -193,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; @@ -249,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; @@ -325,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]); @@ -349,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; @@ -502,12 +504,12 @@ 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)); }), [] ); - const handleMouseUp = (event: MouseEvent) => { + const handleMouseUp = (_event: MouseEvent) => { const { tabIndex, dragged } = draggingTabDataRef.current; // Update the final position of the dragged tab @@ -566,7 +568,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const handleSelectTab = (tabId: string) => { if (!draggingTabDataRef.current.dragged) { - setActiveTab(tabId); + env.electron.setActiveTab(tabId); } }; @@ -588,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(); @@ -598,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"); @@ -623,20 +623,18 @@ const TabBar = memo(({ workspace }: TabBarProps) => { }); }, []); - const isBeforeActive = (tabId: string) => { - return tabIds.indexOf(tabId) === tabIds.indexOf(activeTabId) - 1; - }; + 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 { @@ -645,8 +643,8 @@ const TabBar = memo(({ workspace }: TabBarProps) => { } // Calculate window drag right width - let windowDragRightWidth = 6; - if (isWindows()) { + let windowDragRightWidth = 12; + if (env.isWindows()) { if (zoomFactor > 0) { windowDragRightWidth = 139 / zoomFactor; } else { @@ -654,12 +652,6 @@ const TabBar = memo(({ workspace }: TabBarProps) => { } } - const addtabButtonDecl: IconButtonDecl = { - elemtype: "iconbutton", - icon: "plus", - click: handleAddTab, - title: "Add Tab", - }; return (
{ placement="bottom" hideOnClick divRef={workspaceSwitcherRef} - divClassName="flex items-center h-full" + divClassName="flex items-center" >
{tabIds.map((tabId, index) => { + const isActive = activeTabId === tabId; + const showDivider = index !== 0 && !isActive && index !== activeTabIndex + 1; return ( handleSelectTab(tabId)} - active={activeTabId === tabId} + active={isActive} onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])} onClose={(event) => handleCloseTab(event, tabId)} onLoaded={() => handleTabLoaded(tabId)} - isBeforeActive={isBeforeActive(tabId)} isDragging={draggingTab === tabId} tabWidth={tabWidthRef.current} isNew={tabId === newTabId} @@ -710,12 +703,20 @@ const TabBar = memo(({ workspace }: TabBarProps) => { })}
- -
- - + +
+
+ +
@@ -724,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/vtab.test.tsx b/frontend/app/tab/vtab.test.tsx new file mode 100644 index 0000000000..b995b6a72a --- /dev/null +++ b/frontend/app/tab/vtab.test.tsx @@ -0,0 +1,63 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { renderToStaticMarkup } from "react-dom/server"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { VTab, VTabItem } from "./vtab"; + +const OriginalCss = globalThis.CSS; +const HexColorRegex = /^#([\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i; + +function renderVTab(tab: VTabItem): string { + return renderToStaticMarkup( + null} + onDragStart={() => null} + onDragOver={() => null} + onDrop={() => null} + onDragEnd={() => null} + /> + ); +} + +describe("VTab badges", () => { + beforeAll(() => { + globalThis.CSS = { + supports: (_property: string, value: string) => HexColorRegex.test(value), + } as typeof CSS; + }); + + afterAll(() => { + globalThis.CSS = OriginalCss; + }); + + it("renders shared badges and a validated flag badge", () => { + const markup = renderVTab({ + id: "tab-1", + name: "Build Logs", + badges: [{ badgeid: "badge-1", icon: "bell", color: "#f59e0b", priority: 2 }], + flagColor: "#429DFF", + }); + + expect(markup).toContain("#429DFF"); + expect(markup).toContain("#f59e0b"); + expect(markup).toContain("rounded-full"); + }); + + it("ignores invalid flag colors", () => { + const markup = renderVTab({ + id: "tab-2", + name: "Deploy", + badges: [{ badgeid: "badge-2", icon: "bell", color: "#4ade80", priority: 2 }], + flagColor: "definitely-not-a-color", + }); + + expect(markup).not.toContain("definitely-not-a-color"); + expect(markup).not.toContain("fa-flag"); + expect(markup).toContain("#4ade80"); + }); +}); diff --git a/frontend/app/tab/vtab.tsx b/frontend/app/tab/vtab.tsx index f3eee3bf76..b6c3a29a54 100644 --- a/frontend/app/tab/vtab.tsx +++ b/frontend/app/tab/vtab.tsx @@ -1,16 +1,19 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { makeIconClass } from "@/util/util"; +import { validateCssColor } from "@/util/color-validator"; import { cn } from "@/util/util"; import { useCallback, useEffect, useRef, useState } from "react"; +import { TabBadges } from "./tabbadges"; const RenameFocusDelayMs = 50; export interface VTabItem { id: string; name: string; - indicator?: TabIndicator | null; + badge?: Badge | null; + badges?: Badge[] | null; + flagColor?: string | null; } interface VTabProps { @@ -44,6 +47,18 @@ export function VTab({ const [isEditable, setIsEditable] = useState(false); const editableRef = useRef(null); const editableTimeoutRef = useRef(null); + const badges = tab.badges ?? (tab.badge ? [tab.badge] : null); + + const rawFlagColor = tab.flagColor; + let flagColor: string | null = null; + if (rawFlagColor) { + try { + validateCssColor(rawFlagColor); + flagColor = rawFlagColor; + } catch { + flagColor = null; + } + } useEffect(() => { setOriginalName(tab.name); @@ -139,11 +154,11 @@ export function VTab({ isDragging && "opacity-50" )} > - {tab.indicator && ( - - - - )} +
; + 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/treeview/treeview.test.ts b/frontend/app/treeview/treeview.test.ts new file mode 100644 index 0000000000..c286be7a49 --- /dev/null +++ b/frontend/app/treeview/treeview.test.ts @@ -0,0 +1,46 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { buildVisibleRows, TreeNodeData } from "@/app/treeview/treeview"; +import { describe, expect, it } from "vitest"; + +function makeNodes(entries: TreeNodeData[]): Map { + return new Map(entries.map((entry) => [entry.id, entry])); +} + +describe("treeview visible rows", () => { + it("sorts directories before files and alphabetically", () => { + const nodes = makeNodes([ + { + id: "root", + isDirectory: true, + childrenStatus: "loaded", + childrenIds: ["c", "a", "b"], + }, + { id: "a", parentId: "root", isDirectory: false, label: "z-last.txt" }, + { id: "b", parentId: "root", isDirectory: true, label: "docs", childrenStatus: "loaded", childrenIds: [] }, + { id: "c", parentId: "root", isDirectory: false, label: "a-first.txt" }, + ]); + const rows = buildVisibleRows(nodes, ["root"], new Set(["root"])); + expect(rows.map((row) => row.id)).toEqual(["root", "b", "c", "a"]); + }); + + it("renders loading and capped synthetic rows", () => { + const nodes = makeNodes([ + { id: "root", isDirectory: true, childrenStatus: "loading" }, + { + id: "dir", + isDirectory: true, + childrenStatus: "capped", + childrenIds: ["f1"], + capInfo: { max: 1 }, + }, + { id: "f1", parentId: "dir", isDirectory: false, label: "one.txt" }, + ]); + const loadingRows = buildVisibleRows(nodes, ["root"], new Set(["root"])); + expect(loadingRows.map((row) => row.kind)).toEqual(["node", "loading"]); + + const cappedRows = buildVisibleRows(nodes, ["dir"], new Set(["dir"])); + expect(cappedRows.map((row) => row.kind)).toEqual(["node", "node", "capped"]); + }); +}); diff --git a/frontend/app/treeview/treeview.tsx b/frontend/app/treeview/treeview.tsx new file mode 100644 index 0000000000..4481d2c68f --- /dev/null +++ b/frontend/app/treeview/treeview.tsx @@ -0,0 +1,522 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { makeIconClass } from "@/util/util"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import clsx from "clsx"; +import React, { + CSSProperties, + KeyboardEvent, + MouseEvent, + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; + +type TreeNodeChildrenStatus = "unloaded" | "loading" | "loaded" | "error" | "capped"; + +export interface TreeNodeData { + id: string; + parentId?: string; + label?: string; + path?: string; + isDirectory: boolean; + mimeType?: string; + icon?: string; + isReadonly?: boolean; + notfound?: boolean; + staterror?: string; + childrenStatus?: TreeNodeChildrenStatus; + childrenIds?: string[]; + capInfo?: { max: number; totalKnown?: number }; +} + +interface FetchDirResult { + nodes: TreeNodeData[]; + capped?: boolean; + totalKnown?: number; +} + +export interface TreeViewVisibleRow { + id: string; + parentId?: string; + depth: number; + kind: "node" | "loading" | "error" | "capped"; + label: string; + isDirectory?: boolean; + isExpanded?: boolean; + hasChildren?: boolean; + icon?: string; + node?: TreeNodeData; +} + +export interface TreeViewProps { + rootIds: string[]; + initialNodes: Record; + fetchDir?: (id: string, limit: number) => Promise; + maxDirEntries?: number; + rowHeight?: number; + indentWidth?: number; + overscan?: number; + minWidth?: number; + maxWidth?: number; + width?: number | string; + height?: number | string; + className?: string; + onOpenFile?: (id: string, node: TreeNodeData) => void; + onSelectionChange?: (id: string, node: TreeNodeData) => void; +} + +export interface TreeViewRef { + scrollToId: (id: string) => void; +} + +const DefaultRowHeight = 24; +const DefaultIndentWidth = 16; +const DefaultOverscan = 10; +const ChevronWidth = 16; + +function normalizeLabel(node: TreeNodeData): string { + if (node.label?.trim()) { + return node.label; + } + const path = node.path ?? node.id; + const chunks = path.split("/").filter(Boolean); + return chunks[chunks.length - 1] ?? path; +} + +function sortIdsByNode(nodesById: Map, ids: string[]): string[] { + return [...ids].sort((leftId, rightId) => { + const left = nodesById.get(leftId); + const right = nodesById.get(rightId); + const leftDir = left?.isDirectory ? 0 : 1; + const rightDir = right?.isDirectory ? 0 : 1; + if (leftDir !== rightDir) { + return leftDir - rightDir; + } + const leftLabel = normalizeLabel(left ?? { id: leftId, isDirectory: false }).toLocaleLowerCase(); + const rightLabel = normalizeLabel(right ?? { id: rightId, isDirectory: false }).toLocaleLowerCase(); + if (leftLabel !== rightLabel) { + return leftLabel.localeCompare(rightLabel); + } + return leftId.localeCompare(rightId); + }); +} + +export function buildVisibleRows( + nodesById: Map, + rootIds: string[], + expandedIds: Set +): TreeViewVisibleRow[] { + const rows: TreeViewVisibleRow[] = []; + + const appendNode = (id: string, depth: number) => { + const node = nodesById.get(id); + if (node == null) { + return; + } + const childIds = node.childrenIds ?? []; + const hasChildren = node.isDirectory && (childIds.length > 0 || node.childrenStatus !== "loaded"); + const isExpanded = expandedIds.has(id); + rows.push({ + id, + parentId: node.parentId, + depth, + kind: "node", + label: normalizeLabel(node), + isDirectory: node.isDirectory, + isExpanded, + hasChildren, + icon: node.icon, + node, + }); + if (!isExpanded || !node.isDirectory) { + return; + } + const status = node.childrenStatus ?? "unloaded"; + if (status === "loading") { + rows.push({ + id: `${id}::__loading`, + parentId: id, + depth: depth + 1, + kind: "loading", + label: "Loading…", + }); + return; + } + if (status === "error") { + rows.push({ + id: `${id}::__error`, + parentId: id, + depth: depth + 1, + kind: "error", + label: node.staterror ? `Error: ${node.staterror}` : "Unable to load directory", + }); + return; + } + + const sortedChildren = sortIdsByNode(nodesById, childIds); + sortedChildren.forEach((childId) => appendNode(childId, depth + 1)); + if (status === "capped") { + const capMax = node.capInfo?.max ?? childIds.length; + rows.push({ + id: `${id}::__capped`, + parentId: id, + depth: depth + 1, + kind: "capped", + label: `Showing first ${capMax} entries`, + }); + } + }; + + sortIdsByNode(nodesById, rootIds).forEach((id) => appendNode(id, 0)); + return rows; +} + +function getNodeIcon(node: TreeNodeData, isExpanded: boolean): string { + if (node.notfound || node.staterror) { + return "triangle-exclamation"; + } + if (node.icon) { + return node.icon; + } + if (node.isDirectory) { + return isExpanded ? "folder-open" : "folder"; + } + const mime = node.mimeType ?? ""; + if (mime.startsWith("image/")) { + return "image"; + } + if (mime === "application/pdf") { + return "file-pdf"; + } + const extension = normalizeLabel(node).split(".").pop()?.toLocaleLowerCase(); + if (["js", "jsx", "ts", "tsx", "go", "py", "java", "c", "cpp", "h", "hpp", "json", "yaml", "yml"].includes(extension)) { + return "file-code"; + } + if (["md", "txt", "log"].includes(extension)) { + return "file-lines"; + } + return "file"; +} + +export const TreeView = forwardRef((props, ref) => { + const { + rootIds, + initialNodes, + fetchDir, + maxDirEntries = 500, + rowHeight = DefaultRowHeight, + indentWidth = DefaultIndentWidth, + overscan = DefaultOverscan, + minWidth = 100, + maxWidth = 400, + width = "100%", + height = 360, + className, + onOpenFile, + onSelectionChange, + } = props; + const [nodesById, setNodesById] = useState>( + () => + new Map( + Object.entries(initialNodes).map(([id, node]) => [id, { ...node, childrenStatus: node.childrenStatus ?? "unloaded" }]) + ) + ); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [selectedId, setSelectedId] = useState(rootIds[0]); + const scrollRef = useRef(null); + + useEffect(() => { + setNodesById( + new Map( + Object.entries(initialNodes).map(([id, node]) => [ + id, + { + ...node, + childrenStatus: node.childrenStatus ?? "unloaded", + }, + ]) + ) + ); + }, [initialNodes]); + + const visibleRows = useMemo(() => buildVisibleRows(nodesById, rootIds, expandedIds), [nodesById, rootIds, expandedIds]); + const idToIndex = useMemo( + () => new Map(visibleRows.map((row, index) => [row.id, index])), + [visibleRows] + ); + const virtualizer = useVirtualizer({ + count: visibleRows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => rowHeight, + overscan, + }); + + const commitSelection = (id: string) => { + const node = nodesById.get(id); + if (node == null) { + return; + } + setSelectedId(id); + onSelectionChange?.(id, node); + }; + + const scrollToId = (id: string) => { + const index = idToIndex.get(id); + if (index == null) { + return; + } + virtualizer.scrollToIndex(index, { align: "auto" }); + }; + + useImperativeHandle( + ref, + () => ({ + scrollToId, + }), + [idToIndex, virtualizer] + ); + + const loadChildren = async (id: string) => { + const currentNode = nodesById.get(id); + if (currentNode == null || !currentNode.isDirectory || currentNode.notfound || currentNode.staterror || fetchDir == null) { + return; + } + const status = currentNode.childrenStatus ?? "unloaded"; + if (status !== "unloaded") { + return; + } + setNodesById((prev) => { + const next = new Map(prev); + next.set(id, { ...currentNode, childrenStatus: "loading" }); + return next; + }); + try { + const result = await fetchDir(id, maxDirEntries); + setNodesById((prev) => { + const next = new Map(prev); + result.nodes.forEach((node) => { + const merged: TreeNodeData = { + ...node, + parentId: node.parentId ?? id, + childrenStatus: node.childrenStatus ?? (node.isDirectory ? "unloaded" : "loaded"), + }; + next.set(merged.id, merged); + }); + const childrenIds = sortIdsByNode( + next, + result.nodes.map((entry) => entry.id) + ); + const source = next.get(id) ?? currentNode; + next.set(id, { + ...source, + childrenIds, + childrenStatus: result.capped ? "capped" : "loaded", + capInfo: result.capped ? { max: maxDirEntries, totalKnown: result.totalKnown } : undefined, + }); + return next; + }); + } catch (error) { + setNodesById((prev) => { + const next = new Map(prev); + const source = next.get(id) ?? currentNode; + next.set(id, { + ...source, + childrenStatus: "error", + staterror: error instanceof Error ? error.message : "Unknown error", + }); + return next; + }); + } + }; + + const toggleExpand = (id: string) => { + const node = nodesById.get(id); + if (node == null || !node.isDirectory || node.notfound || node.staterror) { + return; + } + const expanded = expandedIds.has(id); + if (!expanded) { + loadChildren(id); + } + setExpandedIds((prev) => { + const next = new Set(prev); + if (expanded) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + scrollToId(id); + }; + + const selectVisibleNodeAt = (index: number) => { + if (index < 0 || index >= visibleRows.length) { + return; + } + const row = visibleRows[index]; + if (row.kind !== "node") { + return; + } + commitSelection(row.id); + scrollToId(row.id); + }; + + const onKeyDown = (event: KeyboardEvent) => { + const selectedIndex = selectedId != null ? idToIndex.get(selectedId) : undefined; + if (event.key === "ArrowDown") { + event.preventDefault(); + const nextIndex = (selectedIndex ?? -1) + 1; + for (let idx = nextIndex; idx < visibleRows.length; idx++) { + if (visibleRows[idx].kind === "node") { + selectVisibleNodeAt(idx); + break; + } + } + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + const previousIndex = (selectedIndex ?? visibleRows.length) - 1; + for (let idx = previousIndex; idx >= 0; idx--) { + if (visibleRows[idx].kind === "node") { + selectVisibleNodeAt(idx); + break; + } + } + return; + } + const node = selectedId ? nodesById.get(selectedId) : null; + if (node == null) { + return; + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + if (node.isDirectory && expandedIds.has(node.id)) { + toggleExpand(node.id); + return; + } + if (node.parentId != null) { + commitSelection(node.parentId); + scrollToId(node.parentId); + } + return; + } + if (event.key === "ArrowRight") { + event.preventDefault(); + if (node.isDirectory && !expandedIds.has(node.id)) { + toggleExpand(node.id); + return; + } + if (node.isDirectory && expandedIds.has(node.id) && node.childrenIds?.[0]) { + commitSelection(node.childrenIds[0]); + scrollToId(node.childrenIds[0]); + } + } + }; + + const containerStyle: CSSProperties = { + width, + minWidth, + maxWidth, + height, + }; + + return ( +
+
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = visibleRows[virtualRow.index]; + if (row.kind === "node" && row.node == null) { + return null; + } + const selected = row.id === selectedId; + return ( +
row.kind === "node" && commitSelection(row.id)} + onDoubleClick={() => { + if (row.kind !== "node") { + return; + } + if (row.isDirectory) { + toggleExpand(row.id); + return; + } + if (row.node != null) { + onOpenFile?.(row.id, row.node); + } + }} + > +
+ {row.kind === "node" && row.isDirectory && row.hasChildren ? ( + + ) : ( + + )} +
+ {row.kind === "node" ? ( + <> + + + {row.label} + + + ) : ( + {row.label} + )} +
+ ); + })} +
+
+
+ ); +}); + +TreeView.displayName = "TreeView"; 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/directorypreview.scss b/frontend/app/view/preview/directorypreview.scss index c42ebf8d23..803e0036dd 100644 --- a/frontend/app/view/preview/directorypreview.scss +++ b/frontend/app/view/preview/directorypreview.scss @@ -72,6 +72,17 @@ display: flex; justify-content: center; flex: 0 0 auto; + position: relative; + &::before { + content: ""; + position: absolute; + left: 50%; + top: 10%; + height: 80%; + width: 1px; + background-color: var(--border-color); + pointer-events: none; + } .dir-table-head-resize { cursor: col-resize; user-select: none; @@ -130,6 +141,10 @@ } } + &:nth-child(odd):not(.focused):not(:focus) { + background-color: rgba(255, 255, 255, 0.06); + } + &:hover:not(:focus):not(.focused) { background-color: var(--highlight-bg-color); } @@ -139,7 +154,7 @@ white-space: nowrap; padding: 0.25rem; cursor: default; - font-size: 0.8125rem; + font-size: 12px; flex: 0 0 auto; &.col-size { @@ -148,14 +163,17 @@ .dir-table-lastmod, .dir-table-modestr, - .dir-table-size, .dir-table-type { color: var(--secondary-text-color); margin-right: 12px; } - .dir-table-modestr { + .dir-table-modestr, + .dir-table-size, + .dir-table-lastmod { + color: var(--secondary-text-color); font-family: Hack; + font-size: 11px; } .dir-table-name { diff --git a/frontend/app/view/preview/preview-directory-utils.tsx b/frontend/app/view/preview/preview-directory-utils.tsx index 3c1457ce3c..fac6bfff17 100644 --- a/frontend/app/view/preview/preview-directory-utils.tsx +++ b/frontend/app/view/preview/preview-directory-utils.tsx @@ -1,11 +1,10 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { globalStore } from "@/app/store/global"; +import { getSettingsKeyAtom, globalStore } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { fireAndForget, isBlank } from "@/util/util"; -import { Column } from "@tanstack/react-table"; import dayjs from "dayjs"; import React from "react"; import { type PreviewModel } from "./preview-model"; @@ -40,23 +39,22 @@ export function getBestUnit(bytes: number, si = false, sigfig = 3): string { return `${parseFloat(value.toPrecision(sigfig))}${displaySuffixes[unit] ?? unit}`; } -export function getLastModifiedTime(unixMillis: number, column: Column): string { - const fileDatetime = dayjs(new Date(unixMillis)); - const nowDatetime = dayjs(new Date()); - - let datePortion: string; - if (nowDatetime.isSame(fileDatetime, "date")) { - datePortion = "Today"; - } else if (nowDatetime.subtract(1, "day").isSame(fileDatetime, "date")) { - datePortion = "Yesterday"; - } else { - datePortion = dayjs(fileDatetime).format("M/D/YY"); - } +function padDay(day: number) { + return String(day).padStart(2, " "); +} + +export function getLastModifiedTime(unixMillis: number): string { + const file = dayjs(unixMillis); + const now = dayjs(); + + const day = padDay(file.date()); + const time = file.format("HH:mm"); - if (column.getSize() > 120) { - return `${datePortion}, ${dayjs(fileDatetime).format("h:mm A")}`; + if (now.isSame(file, "year")) { + return `${file.format("MMM")} ${day} ${time}`; } - return datePortion; + + return `${file.format("YYYY-MM-DD")}`; } const iconRegex = /^[a-z0-9- ]+$/; @@ -154,3 +152,56 @@ export function handleFileDelete( model.refreshCallback(); }); } + +export function makeDirectoryDefaultMenuItems(model: PreviewModel): ContextMenuItem[] { + const defaultSort = globalStore.get(getSettingsKeyAtom("preview:defaultsort")) ?? "name"; + const showHiddenFiles = globalStore.get(model.showHiddenFiles) ?? true; + return [ + { + label: "Directory Sort Order", + submenu: [ + { + label: "Name", + type: "checkbox", + checked: defaultSort === "name", + click: () => + fireAndForget(() => RpcApi.SetConfigCommand(TabRpcClient, { "preview:defaultsort": "name" })), + }, + { + label: "Last Modified", + type: "checkbox", + checked: defaultSort === "modtime", + click: () => + fireAndForget(() => + RpcApi.SetConfigCommand(TabRpcClient, { "preview:defaultsort": "modtime" }) + ), + }, + ], + }, + { + label: "Show Hidden Files", + submenu: [ + { + label: "On", + type: "checkbox", + checked: showHiddenFiles, + click: () => { + globalStore.set(model.showHiddenFiles, true); + fireAndForget(() => RpcApi.SetConfigCommand(TabRpcClient, { "preview:showhiddenfiles": true })); + }, + }, + { + label: "Off", + type: "checkbox", + checked: !showHiddenFiles, + click: () => { + globalStore.set(model.showHiddenFiles, false); + fireAndForget(() => + RpcApi.SetConfigCommand(TabRpcClient, { "preview:showhiddenfiles": false }) + ); + }, + }, + ], + }, + ]; +} diff --git a/frontend/app/view/preview/preview-directory.tsx b/frontend/app/view/preview/preview-directory.tsx index acbf06e3bc..1bd0ab9101 100644 --- a/frontend/app/view/preview/preview-directory.tsx +++ b/frontend/app/view/preview/preview-directory.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ContextMenuModel } from "@/app/store/contextmenu"; -import { atoms, getApi, globalStore } from "@/app/store/global"; +import { atoms, getApi, getSettingsKeyAtom, globalStore } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { checkKeyPressed, isCharacterKeyEvent } from "@/util/keyutil"; @@ -39,6 +39,7 @@ import { handleFileDelete, handleRename, isIconValid, + makeDirectoryDefaultMenuItems, mergeError, overwriteError, } from "./preview-directory-utils"; @@ -111,6 +112,7 @@ function DirectoryTable({ }: DirectoryTableProps) { const searchActive = useAtomValue(model.directorySearchActive); const fullConfig = useAtomValue(atoms.fullConfigAtom); + const defaultSort = useAtomValue(getSettingsKeyAtom("preview:defaultsort")) ?? "name"; const setErrorMsg = useSetAtom(model.errorMsgAtom); const getIconFromMimeType = useCallback( (mimeType: string): string => { @@ -158,9 +160,7 @@ function DirectoryTable({ sortingFn: "alphanumeric", }), columnHelper.accessor("modtime", { - cell: (info) => ( - {getLastModifiedTime(info.getValue(), info.column)} - ), + cell: (info) => {getLastModifiedTime(info.getValue())}, header: () => Last Modified, size: 91, minSize: 65, @@ -208,6 +208,8 @@ function DirectoryTable({ [model, setErrorMsg] ); + const initialSorting = defaultSort === "modtime" ? [{ id: "modtime", desc: true }] : [{ id: "name", desc: false }]; + const table = useReactTable({ data, columns, @@ -216,12 +218,7 @@ function DirectoryTable({ getCoreRowModel: getCoreRowModel(), initialState: { - sorting: [ - { - id: "name", - desc: false, - }, - ], + sorting: initialSorting, columnVisibility: { path: false, }, @@ -411,6 +408,13 @@ function TableBody({ ]; addOpenMenuItems(menu, conn, finfo); menu.push( + { + type: "separator", + }, + { + label: "Default Settings", + submenu: makeDirectoryDefaultMenuItems(model), + }, { type: "separator", }, @@ -493,15 +497,7 @@ type TableRowProps = { handleFileContextMenu: (e: any, finfo: FileInfo) => Promise; }; -const TableRow = React.forwardRef(function ({ - model, - row, - focusIndex, - setFocusIndex, - setSearch, - idx, - handleFileContextMenu, -}: TableRowProps) { +function TableRow({ model, row, focusIndex, setFocusIndex, setSearch, idx, handleFileContextMenu }: TableRowProps) { const dirPath = useAtomValue(model.statFilePath); const connection = useAtomValue(model.connection); @@ -552,7 +548,7 @@ const TableRow = React.forwardRef(function ({ ))}
); -}); +} const MemoizedTableBody = React.memo( TableBody, diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index ca85bba96e..59cbbaca4f 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"; @@ -20,6 +20,7 @@ import { loadable } from "jotai/utils"; import type * as MonacoTypes from "monaco-editor"; import { createRef } from "react"; import { PreviewView } from "./preview"; +import { makeDirectoryDefaultMenuItems } from "./preview-directory-utils"; // TODO drive this using config const BOOKMARKS: { label: string; path: string }[] = [ @@ -168,7 +169,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; @@ -335,6 +336,7 @@ export class PreviewModel implements ViewModel { { elemtype: "iconbutton", icon: showHiddenFiles ? "eye" : "eye-slash", + title: showHiddenFiles ? "Hide Hidden Files" : "Show Hidden Files", click: () => { globalStore.set(this.showHiddenFiles, (prev) => !prev); }, @@ -731,68 +733,72 @@ export class PreviewModel implements ViewModel { }), }); menuItems.push({ type: "separator" }); - const fontSizeSubMenu: ContextMenuItem[] = [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].map( - (fontSize: number) => { - return { - label: fontSize.toString() + "px", - type: "checkbox", - checked: overrideFontSize == fontSize, - click: () => { - RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), - meta: { "editor:fontsize": fontSize }, - }); - }, - }; - } - ); - fontSizeSubMenu.unshift({ - label: "Default (" + defaultFontSize + "px)", - type: "checkbox", - checked: overrideFontSize == null, - click: () => { - RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), - meta: { "editor:fontsize": null }, - }); - }, - }); - menuItems.push({ - label: "Editor Font Size", - submenu: fontSizeSubMenu, - }); const finfo = jotaiLoadableValue(globalStore.get(this.loadableFileInfo), null); addOpenMenuItems(menuItems, globalStore.get(this.connectionImmediate), finfo); const loadableSV = globalStore.get(this.loadableSpecializedView); const wordWrapAtom = getOverrideConfigAtom(this.blockId, "editor:wordwrap"); const wordWrap = globalStore.get(wordWrapAtom) ?? false; - if (loadableSV.state == "hasData") { - if (loadableSV.data.specializedView == "codeedit") { - if (globalStore.get(this.newFileContent) != null) { - menuItems.push({ type: "separator" }); - menuItems.push({ - label: "Save File", - click: () => fireAndForget(this.handleFileSave.bind(this)), - }); - menuItems.push({ - label: "Revert File", - click: () => fireAndForget(this.handleFileRevert.bind(this)), - }); + menuItems.push({ type: "separator" }); + if (loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit") { + const fontSizeSubMenu: ContextMenuItem[] = [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].map( + (fontSize: number) => { + return { + label: fontSize.toString() + "px", + type: "checkbox", + checked: overrideFontSize == fontSize, + click: () => { + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "editor:fontsize": fontSize }, + }); + }, + }; } + ); + fontSizeSubMenu.unshift({ + label: "Default (" + defaultFontSize + "px)", + type: "checkbox", + checked: overrideFontSize == null, + click: () => { + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "editor:fontsize": null }, + }); + }, + }); + menuItems.push({ + label: "Editor Font Size", + submenu: fontSizeSubMenu, + }); + if (globalStore.get(this.newFileContent) != null) { menuItems.push({ type: "separator" }); menuItems.push({ - label: "Word Wrap", - type: "checkbox", - checked: wordWrap, - click: () => - fireAndForget(async () => { - const blockOref = WOS.makeORef("block", this.blockId); - await services.ObjectService.UpdateObjectMeta(blockOref, { - "editor:wordwrap": !wordWrap, - }); - }), + label: "Save File", + click: () => fireAndForget(this.handleFileSave.bind(this)), + }); + menuItems.push({ + label: "Revert File", + click: () => fireAndForget(this.handleFileRevert.bind(this)), }); } + menuItems.push({ type: "separator" }); + menuItems.push({ + label: "Word Wrap", + type: "checkbox", + checked: wordWrap, + click: () => + fireAndForget(async () => { + const blockOref = WOS.makeORef("block", this.blockId); + await services.ObjectService.UpdateObjectMeta(blockOref, { + "editor:wordwrap": !wordWrap, + }); + }), + }); + } + if (loadableSV.state == "hasData" && loadableSV.data.specializedView == "directory") { + menuItems.push({ type: "separator" }); + menuItems.push({ label: "Default Settings", enabled: false }); + menuItems.push(...makeDirectoryDefaultMenuItems(this)); } return menuItems; } 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..30feead6c8 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, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; +export type SysinfoEnv = WaveEnvSubset<{ + 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 ( { 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/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index de8a6b9636..9cb1c58720 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -85,7 +85,7 @@ export class TermViewModel implements ViewModel { termConfigedDurable: jotai.Atom; 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/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 7271c4c3a9..1cd167c800 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -2,18 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 import type { BlockNodeModel } from "@/app/block/blocktypes"; +import { setBadge } from "@/app/store/badge"; import { getFileSubject } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { - atoms, fetchWaveFile, getOverrideConfigAtom, getSettingsKeyAtom, globalStore, isDev, openLink, - setTabIndicator, WOS, } from "@/store/global"; import * as services from "@/store/services"; @@ -255,8 +254,7 @@ export class TermWrap { const bellIndicatorEnabled = globalStore.get(getOverrideConfigAtom(this.blockId, "term:bellindicator")) ?? false; if (bellIndicatorEnabled) { - const tabId = globalStore.get(atoms.staticTabId); - setTabIndicator(tabId, { icon: "bell", color: "#fbbf24", clearonfocus: true, priority: 1 }); + setBadge(this.blockId, { icon: "bell", color: "#fbbf24", priority: 1 }); } return true; }) @@ -345,6 +343,12 @@ export class TermWrap { if (!globalStore.get(copyOnSelectAtom)) { return; } + // Don't copy-on-select when the search bar has focus — navigating + // search results changes the terminal selection programmatically. + const active = document.activeElement; + if (active != null && active.closest(".search-container") != null) { + return; + } const selectedText = this.terminal.getSelection(); if (selectedText.length > 0) { navigator.clipboard.writeText(selectedText); 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/mockboundary.tsx b/frontend/app/waveenv/mockboundary.tsx new file mode 100644 index 0000000000..4b05d28b72 --- /dev/null +++ b/frontend/app/waveenv/mockboundary.tsx @@ -0,0 +1,17 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { isPreviewWindow } from "@/app/store/windowtype"; +import React from "react"; + +type MockBoundaryProps = { + fallback: React.ReactNode; + children: React.ReactNode; +}; + +export function MockBoundary({ fallback, children }: MockBoundaryProps) { + if (isPreviewWindow()) { + return <>{fallback}; + } + return <>{children}; +} diff --git a/frontend/app/waveenv/waveenv.ts b/frontend/app/waveenv/waveenv.ts new file mode 100644 index 0000000000..df1cb01c4a --- /dev/null +++ b/frontend/app/waveenv/waveenv.ts @@ -0,0 +1,93 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { AllServiceImpls } from "@/app/store/services"; +import { RpcApiType } from "@/app/store/wshclientapi"; +import { Atom, PrimitiveAtom } from "jotai"; +import React from "react"; + +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"]; + services: WaveEnv["services"]; +}; + +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; + isDev: () => boolean; + isWindows: () => boolean; + 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; + wos: { + getWaveObjectAtom: (oref: string) => Atom; + getWaveObjectLoadingAtom: (oref: string) => Atom; + isWaveObjectNullAtom: (oref: string) => Atom; + useWaveObjectValue: (oref: string) => [T, boolean]; + }; + getSettingsKeyAtom: SettingsKeyAtomFnType; + getBlockMetaKeyAtom: BlockMetaKeyAtomFnType; + getConnConfigKeyAtom: ConnConfigKeyAtomFnType; + + // 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); + +type EnvContract = { + [K in keyof T]?: T[K] extends (...args: any[]) => any ? T[K] : T[K] extends object ? EnvContract : T[K]; +}; + +export function useWaveEnv = WaveEnv>(): T { + return React.useContext(WaveEnvContext) as T; +} diff --git a/frontend/app/waveenv/waveenvimpl.ts b/frontend/app/waveenv/waveenvimpl.ts new file mode 100644 index 0000000000..4f9e234eca --- /dev/null +++ b/frontend/app/waveenv/waveenvimpl.ts @@ -0,0 +1,54 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ContextMenuModel } from "@/app/store/contextmenu"; +import { AllServiceImpls } from "@/app/store/services"; +import { + atoms, + createBlock, + getBlockMetaKeyAtom, + getConnConfigKeyAtom, + getConnStatusAtom, + getLocalHostDisplayNameAtom, + getSettingsKeyAtom, + isDev, + WOS, +} from "@/app/store/global"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { WaveEnv } from "@/app/waveenv/waveenv"; +import { isMacOS, isWindows, PLATFORM } from "@/util/platformutil"; + +export function makeWaveEnvImpl(): WaveEnv { + return { + isMock: false, + electron: (window as any).api, + rpc: RpcApi, + getSettingsKeyAtom, + platform: PLATFORM, + isDev, + isWindows, + isMacOS, + atoms, + createBlock, + services: AllServiceImpls, + callBackendService: WOS.callBackendService, + showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => { + ContextMenuModel.getInstance().showContextMenu(menu, e); + }, + getConnStatusAtom, + getLocalHostDisplayNameAtom, + wos: { + getWaveObjectAtom: WOS.getWaveObjectAtom, + getWaveObjectLoadingAtom: WOS.getWaveObjectLoadingAtom, + isWaveObjectNullAtom: WOS.isWaveObjectNullAtom, + useWaveObjectValue: WOS.useWaveObjectValue, + }, + 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 11067edcda..2ec171953e 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -2,15 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; -import { ContextMenuModel } from "@/app/store/contextmenu"; -import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { shouldIncludeWidgetForWorkspace } from "@/app/workspace/widgetfilter"; -import { atoms, createBlock, isDev } from "@/store/global"; +import { modalsModel } from "@/store/modalmodel"; import { fireAndForget, isBlank, makeIconClass } from "@/util/util"; import { - FloatingPortal, autoUpdate, + FloatingPortal, offset, shift, useDismiss, @@ -21,6 +20,23 @@ import clsx from "clsx"; import { useAtomValue } from "jotai"; import { memo, useCallback, useEffect, useRef, useState } from "react"; +export type WidgetsEnv = WaveEnvSubset<{ + isDev: WaveEnv["isDev"]; + electron: { + openBuilder: WaveEnv["electron"]["openBuilder"]; + }; + rpc: { + ListAllAppsCommand: WaveEnv["rpc"]["ListAllAppsCommand"]; + }; + atoms: { + fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + workspaceId: WaveEnv["atoms"]["workspaceId"]; + hasCustomAIPresetsAtom: WaveEnv["atoms"]["hasCustomAIPresetsAtom"]; + }; + createBlock: WaveEnv["createBlock"]; + showContextMenu: WaveEnv["showContextMenu"]; +}>; + function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType }): WidgetConfigType[] { if (wmap == null) { return []; @@ -32,12 +48,18 @@ function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType }): WidgetCo return wlist; } -async function handleWidgetSelect(widget: WidgetConfigType) { +type WidgetPropsType = { + widget: WidgetConfigType; + mode: "normal" | "compact" | "supercompact"; + env: WidgetsEnv; +}; + +async function handleWidgetSelect(widget: WidgetConfigType, env: WidgetsEnv) { const blockDef = widget.blockdef; - createBlock(blockDef, widget.magnified); + env.createBlock(blockDef, widget.magnified); } -const Widget = memo(({ widget, mode }: { widget: WidgetConfigType; mode: "normal" | "compact" | "supercompact" }) => { +const Widget = memo(({ widget, mode, env }: WidgetPropsType) => { const [isTruncated, setIsTruncated] = useState(false); const labelRef = useRef(null); @@ -60,7 +82,7 @@ const Widget = memo(({ widget, mode }: { widget: WidgetConfigType; mode: "normal mode === "supercompact" ? "text-sm" : "text-lg", widget["display:hidden"] && "hidden" )} - divOnClick={() => handleWidgetSelect(widget)} + divOnClick={() => handleWidgetSelect(widget, env)} >
@@ -85,71 +107,74 @@ function calculateGridSize(appCount: number): number { return 6; } -const AppsFloatingWindow = memo( - ({ - isOpen, - onClose, - referenceElement, - }: { - isOpen: boolean; - onClose: () => void; - referenceElement: HTMLElement; - }) => { - const [apps, setApps] = useState([]); - const [loading, setLoading] = useState(true); - - const { refs, floatingStyles, context } = useFloating({ - open: isOpen, - onOpenChange: onClose, - placement: "left-start", - middleware: [offset(-2), shift({ padding: 12 })], - whileElementsMounted: autoUpdate, - elements: { - reference: referenceElement, - }, - }); +type FloatingWindowPropsType = { + isOpen: boolean; + onClose: () => void; + referenceElement: HTMLElement; +}; + +const AppsFloatingWindow = memo(({ isOpen, onClose, referenceElement }: FloatingWindowPropsType) => { + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(true); + 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 dismiss = useDismiss(context); - const { getFloatingProps } = useInteractions([dismiss]); - - useEffect(() => { - if (!isOpen) return; - - const fetchApps = async () => { - setLoading(true); - try { - const allApps = await RpcApi.ListAllAppsCommand(TabRpcClient); - const localApps = allApps - .filter((app) => !app.appid.startsWith("draft/")) - .sort((a, b) => { - const aName = a.appid.replace(/^local\//, ""); - const bName = b.appid.replace(/^local\//, ""); - return aName.localeCompare(bName); - }); - setApps(localApps); - } catch (error) { - console.error("Failed to fetch apps:", error); - setApps([]); - } finally { - setLoading(false); - } - }; - - fetchApps(); - }, [isOpen]); - - if (!isOpen) return null; - - const gridSize = calculateGridSize(apps.length); - - return ( - -
+ const dismiss = useDismiss(context); + const { getFloatingProps } = useInteractions([dismiss]); + const handleOpenBuilder = useCallback(() => { + env.electron.openBuilder(null); + onClose(); + }, [onClose, env]); + + useEffect(() => { + if (!isOpen) return; + + const fetchApps = async () => { + setLoading(true); + try { + const allApps = await env.rpc.ListAllAppsCommand(TabRpcClient); + const localApps = allApps + .filter((app) => !app.appid.startsWith("draft/")) + .sort((a, b) => { + const aName = a.appid.replace(/^local\//, ""); + const bName = b.appid.replace(/^local\//, ""); + return aName.localeCompare(bName); + }); + setApps(localApps); + } catch (error) { + console.error("Failed to fetch apps:", error); + setApps([]); + } finally { + setLoading(false); + } + }; + + fetchApps(); + }, [isOpen]); + + if (!isOpen) return null; + + const gridSize = calculateGridSize(apps.length); + + return ( + +
+
{loading ? (
@@ -182,7 +207,7 @@ const AppsFloatingWindow = memo( "tsunami:appid": app.appid, }, }; - createBlock(blockDef); + env.createBlock(blockDef); onClose(); }} > @@ -198,125 +223,133 @@ const AppsFloatingWindow = memo(
)}
- - ); - } -); - -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: "circle-question", - label: "Help", - onClick: () => { - const blockDef: BlockDef = { - meta: { - view: "help", - }, - }; - createBlock(blockDef); - 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", + }, + }; + 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 workspaceId = useAtomValue(env.atoms.workspaceId); + const hasCustomAIPresets = useAtomValue(env.atoms.hasCustomAIPresetsAtom); const [mode, setMode] = useState<"normal" | "compact" | "supercompact">("normal"); const containerRef = useRef(null); const measurementRef = useRef(null); @@ -328,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); @@ -396,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)} @@ -514,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) => ( - + ))}
@@ -523,7 +556,7 @@ const Widgets = memo(() => {
settings
- {isDev() ? ( + {env.isDev() ? (
@@ -531,7 +564,7 @@ const Widgets = memo(() => {
apps
) : null} - {isDev() ? ( + {env.isDev() ? (
, 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/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/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/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.test.ts b/frontend/preview/mock/mockwaveenv.test.ts new file mode 100644 index 0000000000..953e8412d4 --- /dev/null +++ b/frontend/preview/mock/mockwaveenv.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; + +const { showPreviewContextMenu } = vi.hoisted(() => ({ + showPreviewContextMenu: vi.fn(), +})); + +vi.mock("../preview-contextmenu", () => ({ + showPreviewContextMenu, +})); + +describe("makeMockWaveEnv", () => { + it("uses the preview context menu by default", async () => { + const { makeMockWaveEnv } = await import("./mockwaveenv"); + const env = makeMockWaveEnv(); + const menu = [{ label: "Open" }]; + const event = { stopPropagation: vi.fn() } as any; + + env.showContextMenu(menu, event); + + expect(showPreviewContextMenu).toHaveBeenCalledWith(menu, event); + }); +}); diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts new file mode 100644 index 0000000000..35b0f7de15 --- /dev/null +++ b/frontend/preview/mock/mockwaveenv.ts @@ -0,0 +1,406 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { makeDefaultConnStatus } from "@/app/store/global"; +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, useAtomValue } from "jotai"; +import { DefaultFullConfig } from "./defaultconfig"; +import { showPreviewContextMenu } from "../preview-contextmenu"; +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.SetConfigCommand -- merges settings into fullConfigAtom (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[]) => Promise; +}; + +type ServiceOverrides = { + [Service: string]: { + [Method: string]: (...args: any[]) => Promise; + }; +}; + +export type MockEnv = { + isDev?: boolean; + tabId?: string; + platform?: NodeJS.Platform; + settings?: Partial; + rpc?: RpcOverrides; + services?: ServiceOverrides; + 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 { + 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 + ? { ...(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 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 keyAtomCache.get(key) as Atom; + }; +} + +function makeMockGlobalAtoms( + settingsOverrides: Partial, + atomOverrides: Partial, + tabId: string, + getWaveObjectAtom: (oref: string) => PrimitiveAtom +): 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 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), + 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, + 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, + }; + if (!atomOverrides) { + return defaults; + } + const merged = { ...defaults, ...atomOverrides }; + if (!atomOverrides.workspace) { + merged.workspace = workspaceAtom; + } + return merged; +} + +type MockWosFns = { + getWaveObjectAtom: (oref: string) => PrimitiveAtom; + mockSetWaveObj: (oref: string, obj: T) => void; + fullConfigAtom: PrimitiveAtom; +}; + +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("setconfig", async (_client, data: SettingsType) => { + const current = globalStore.get(wos.fullConfigAtom); + const updatedSettings = { ...(current?.settings ?? {}) }; + for (const [key, value] of Object.entries(data)) { + if (value === null) { + delete (updatedSettings as any)[key]; + } else { + (updatedSettings as any)[key] = value; + } + } + globalStore.set(wos.fullConfigAtom, { ...current, settings: updatedSettings as SettingsType }); + 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[]) => Promise); + } + } + 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 await fn(_client, data, _opts); + return; + } + console.log("[mock rpc stream]", command, data); + yield null; + }, + }); + return rpc; +} + +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 platform = overrides.platform ?? PlatformMacOS; + const connStatusAtomCache = new Map>(); + const waveObjectValueAtomCache = new Map>(); + const waveObjectDerivedAtomCache = new Map>(); + const blockMetaKeyAtomCache = new Map>(); + const connConfigKeyAtomCache = new Map>(); + 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) { + return configValue; + } + return "user@localhost"; + }); + const mockWosFns: MockWosFns = { + getWaveObjectAtom, + fullConfigAtom: atoms.fullConfigAtom, + 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, + openExternal: (url: string) => { + window.open(url, "_blank"); + }, + ...overrides.electron, + }, + rpc: makeMockRpc(overrides.rpc, mockWosFns), + atoms, + getSettingsKeyAtom: makeMockSettingsKeyAtom(atoms.settingsAtom, overrides.settings), + platform, + isDev: () => overrides.isDev ?? true, + isWindows: () => platform === PlatformWindows, + isMacOS: () => platform === PlatformMacOS, + createBlock: + overrides.createBlock ?? + ((blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => { + console.log("[mock createBlock]", blockDef, { magnified, ephemeral }); + return Promise.resolve(crypto.randomUUID()); + }), + showContextMenu: + overrides.showContextMenu ?? showPreviewContextMenu, + getLocalHostDisplayNameAtom: () => { + return localHostDisplayNameAtom; + }, + getConnStatusAtom: (conn: string) => { + if (!connStatusAtomCache.has(conn)) { + const connStatus = overrides.connStatus?.[conn] ?? makeDefaultConnStatus(conn); + connStatusAtomCache.set(conn, atom(connStatus)); + } + return connStatusAtomCache.get(conn); + }, + wos: { + getWaveObjectAtom: mockWosFns.getWaveObjectAtom, + getWaveObjectLoadingAtom: (oref: string) => { + const cacheKey = oref + ":loading"; + if (!waveObjectDerivedAtomCache.has(cacheKey)) { + waveObjectDerivedAtomCache.set(cacheKey, atom(false)); + } + return waveObjectDerivedAtomCache.get(cacheKey) as Atom; + }, + isWaveObjectNullAtom: (oref: string) => { + const cacheKey = oref + ":isnull"; + if (!waveObjectDerivedAtomCache.has(cacheKey)) { + waveObjectDerivedAtomCache.set( + cacheKey, + atom((get) => get(env.wos.getWaveObjectAtom(oref)) == null) + ); + } + return waveObjectDerivedAtomCache.get(cacheKey) as Atom; + }, + useWaveObjectValue: (oref: string): [T, boolean] => { + const objAtom = env.wos.getWaveObjectAtom(oref); + return [useAtomValue(objAtom), 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.wos.getWaveObjectAtom(blockORef); + const blockData = get(blockAtom); + return blockData?.meta?.[key] as MetaType[T]; + }); + blockMetaKeyAtomCache.set(cacheKey, metaAtom); + } + 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; + }, + 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; + env.services = Object.fromEntries( + Object.entries(AllServiceTypes).map(([key, ServiceClass]) => [key, new ServiceClass(env)]) + ) as any; + return env; +} diff --git a/frontend/preview/mock/preview-electron-api.ts b/frontend/preview/mock/preview-electron-api.ts new file mode 100644 index 0000000000..36c82f26da --- /dev/null +++ b/frontend/preview/mock/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, previewElectronApi }; diff --git a/frontend/preview/preview-contextmenu.tsx b/frontend/preview/preview-contextmenu.tsx new file mode 100644 index 0000000000..0be376c6bb --- /dev/null +++ b/frontend/preview/preview-contextmenu.tsx @@ -0,0 +1,326 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + autoUpdate, + flip, + FloatingPortal, + offset, + shift, + type Placement, + type VirtualElement, + useFloating, +} from "@floating-ui/react"; +import { cn } from "@/util/util"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; + +type PreviewContextMenuState = { + items: ContextMenuItem[]; + x: number; + y: number; +}; + +type PreviewContextMenuPanelProps = { + items: ContextMenuItem[]; + point?: { x: number; y: number }; + referenceElement?: HTMLElement; + placement: Placement; + depth: number; + parentPath: number[]; + openPath: number[]; + setOpenPath: (path: number[]) => void; + closeMenu: () => void; +}; + +type PreviewContextMenuItemProps = { + item: ContextMenuItem; + itemPath: number[]; + depth: number; + parentPath: number[]; + openPath: number[]; + setOpenPath: (path: number[]) => void; + closeMenu: () => void; +}; + +let previewContextMenuListener: ((state: PreviewContextMenuState) => void) | null = null; +const previewContextMenuItemIds = new WeakMap(); + +function makeVirtualElement(x: number, y: number): VirtualElement { + return { + getBoundingClientRect() { + return { + x, + y, + width: 0, + height: 0, + top: y, + right: x, + bottom: y, + left: x, + toJSON: () => ({}), + } as DOMRect; + }, + }; +} + +function isPathOpen(openPath: number[], path: number[]): boolean { + if (path.length > openPath.length) { + return false; + } + return path.every((segment, index) => openPath[index] === segment); +} + +function getVisibleItems(items: ContextMenuItem[]): ContextMenuItem[] { + return items.filter((item) => item.visible !== false); +} + +function activateItem(item: ContextMenuItem, closeMenu: () => void): void { + closeMenu(); + item.click?.(); +} + +function getPreviewContextMenuItemId(item: ContextMenuItem): string { + const existingId = previewContextMenuItemIds.get(item); + if (existingId != null) { + return existingId; + } + const newId = crypto.randomUUID(); + previewContextMenuItemIds.set(item, newId); + return newId; +} + +const PreviewContextMenuItem = memo( + ({ item, itemPath, depth, parentPath, openPath, setOpenPath, closeMenu }: PreviewContextMenuItemProps) => { + const rowRef = useRef(null); + const submenuItems = getVisibleItems(item.submenu ?? []); + const hasSubmenu = submenuItems.length > 0; + const isDisabled = item.enabled === false; + const isHeader = item.type === "header"; + const isSeparator = item.type === "separator"; + const isChecked = item.type === "checkbox" || item.type === "radio" ? item.checked === true : false; + const isSubmenuOpen = hasSubmenu && isPathOpen(openPath, itemPath); + + if (isSeparator) { + return
; + } + + const handleMouseEnter = () => { + if (hasSubmenu) { + setOpenPath(itemPath); + return; + } + setOpenPath(parentPath); + }; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (isDisabled || isHeader) { + return; + } + if (hasSubmenu) { + setOpenPath(itemPath); + return; + } + activateItem(item, closeMenu); + }; + + return ( + <> +
+ {isHeader ? ( + {item.label} + ) : ( + <> + + {isChecked ? : null} + +
+ {item.label} + {item.sublabel ? {item.sublabel} : null} +
+ {hasSubmenu ? ( + + + + ) : null} + + )} +
+ {hasSubmenu && isSubmenuOpen && rowRef.current != null ? ( + + ) : null} + + ); + } +); + +PreviewContextMenuItem.displayName = "PreviewContextMenuItem"; + +const PreviewContextMenuPanel = memo( + ({ items, point, referenceElement, placement, depth, parentPath, openPath, setOpenPath, closeMenu }: PreviewContextMenuPanelProps) => { + const visibleItems = getVisibleItems(items); + const virtualReference = useMemo(() => { + if (point == null) { + return null; + } + return makeVirtualElement(point.x, point.y); + }, [point]); + const { refs, floatingStyles } = useFloating({ + open: true, + placement, + strategy: "fixed", + whileElementsMounted: autoUpdate, + middleware: [ + offset(depth === 0 ? 4 : { mainAxis: -4, crossAxis: -4 }), + flip({ padding: 8 }), + shift({ padding: 8 }), + ], + }); + + useEffect(() => { + if (referenceElement != null) { + refs.setReference(referenceElement); + return; + } + refs.setPositionReference(virtualReference); + }, [referenceElement, refs, virtualReference]); + + if (visibleItems.length === 0) { + return null; + } + + return ( +
+ {visibleItems.map((item, index) => ( + + ))} +
+ ); + } +); + +PreviewContextMenuPanel.displayName = "PreviewContextMenuPanel"; + +export function showPreviewContextMenu(menu: ContextMenuItem[], e: React.MouseEvent): void { + e.stopPropagation(); + e.preventDefault(); + previewContextMenuListener?.({ + items: menu, + x: e.clientX, + y: e.clientY, + }); +} + +export const PreviewContextMenu = memo(() => { + const [menuState, setMenuState] = useState(null); + const [openPath, setOpenPath] = useState([]); + const portalRef = useRef(null); + + const closeMenu = () => { + setMenuState(null); + setOpenPath([]); + }; + + useEffect(() => { + previewContextMenuListener = (state) => { + setMenuState(state); + setOpenPath([]); + }; + return () => { + previewContextMenuListener = null; + }; + }, []); + + useEffect(() => { + if (menuState == null) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + if (portalRef.current?.contains(event.target as Node)) { + return; + } + closeMenu(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closeMenu(); + } + }; + + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("blur", closeMenu); + window.addEventListener("resize", closeMenu); + window.addEventListener("scroll", closeMenu, true); + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("blur", closeMenu); + window.removeEventListener("resize", closeMenu); + window.removeEventListener("scroll", closeMenu, true); + }; + }, [menuState]); + + if (menuState == null) { + return null; + } + + return ( + +
+ +
+
+ ); +}); + +PreviewContextMenu.displayName = "PreviewContextMenu"; diff --git a/frontend/preview/preview.tsx b/frontend/preview/preview.tsx index 3b0e8d7825..9ec47366a0 100644 --- a/frontend/preview/preview.tsx +++ b/frontend/preview/preview.tsx @@ -2,12 +2,20 @@ // 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 { 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"; +import { WaveEnvContext } from "@/app/waveenv/waveenv"; import { loadFonts } from "@/util/fontutil"; -import React, { lazy, Suspense } from "react"; +import { atom, Provider } from "jotai"; +import React, { lazy, Suspense, useRef } from "react"; import { createRoot } from "react-dom/client"; +import { makeMockWaveEnv } from "./mock/mockwaveenv"; +import { installPreviewElectronApi } from "./mock/preview-electron-api"; +import { PreviewContextMenu } from "./preview-contextmenu"; +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) @@ -84,6 +92,28 @@ function PreviewHeader({ previewName }: { previewName: string }) { ); } +function PreviewRoot() { + const waveEnvRef = useRef( + makeMockWaveEnv({ + atoms: { + uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext), + staticTabId: atom(PreviewTabId), + workspaceId: atom(PreviewWorkspaceId), + }, + }) + ); + return ( + + + <> + + + + + + ); +} + function PreviewApp() { const params = new URLSearchParams(window.location.search); const previewName = params.get("preview"); @@ -95,9 +125,11 @@ function PreviewApp() { <>
- - - + + + + +
); @@ -118,13 +150,32 @@ function PreviewApp() { return ; } +const PreviewTabId = crypto.randomUUID(); +const PreviewWindowId = crypto.randomUUID(); +const PreviewWorkspaceId = 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(); + const container = document.getElementById("main")!; + let root = (container as any).__reactRoot; + if (!root) { + root = createRoot(container); + (container as any).__reactRoot = root; + } + root.render(); } initPreview(); 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/preview/previews/sysinfo.preview-util.ts b/frontend/preview/previews/sysinfo.preview-util.ts new file mode 100644 index 0000000000..b577d8607b --- /dev/null +++ b/frontend/preview/previews/sysinfo.preview-util.ts @@ -0,0 +1,61 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +export const DefaultSysinfoHistoryPoints = 140; +export const MockSysinfoConnection = "local"; + +const MockMemoryTotal = 32; +const MockCoreCount = 6; + +function clamp(value: number, minValue: number, maxValue: number): number { + return Math.min(maxValue, Math.max(minValue, value)); +} + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +export function makeMockSysinfoEvent( + ts: number, + step: number, + scope = MockSysinfoConnection +): Extract { + const baseCpu = clamp(42 + 18 * Math.sin(step / 6) + 8 * Math.cos(step / 3.5), 8, 96); + const memUsed = clamp(12 + 4 * Math.sin(step / 10) + 2 * Math.cos(step / 7), 6, MockMemoryTotal - 4); + const memAvailable = clamp(MockMemoryTotal - memUsed + 1.5, 0, MockMemoryTotal); + const values: Record = { + cpu: round1(baseCpu), + "mem:total": MockMemoryTotal, + "mem:used": round1(memUsed), + "mem:free": round1(MockMemoryTotal - memUsed), + "mem:available": round1(memAvailable), + }; + + for (let i = 0; i < MockCoreCount; i++) { + const coreCpu = clamp(baseCpu + 10 * Math.sin(step / 4 + i) + i - 3, 2, 100); + values[`cpu:${i}`] = round1(coreCpu); + } + + return { + event: "sysinfo", + scopes: [scope], + data: { + ts, + values, + }, + }; +} + +export function makeMockSysinfoHistory( + numPoints = DefaultSysinfoHistoryPoints, + endTs = Date.now() +): Extract[] { + const history: Extract[] = []; + const startTs = endTs - (numPoints - 1) * 1000; + + for (let i = 0; i < numPoints; i++) { + history.push(makeMockSysinfoEvent(startTs + i * 1000, i)); + } + + return history; +} diff --git a/frontend/preview/previews/sysinfo.preview.test.ts b/frontend/preview/previews/sysinfo.preview.test.ts new file mode 100644 index 0000000000..6e696ea2a6 --- /dev/null +++ b/frontend/preview/previews/sysinfo.preview.test.ts @@ -0,0 +1,31 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { DefaultSysinfoHistoryPoints, makeMockSysinfoEvent, makeMockSysinfoHistory } from "./sysinfo.preview-util"; + +describe("sysinfo preview helpers", () => { + it("creates sysinfo events with the expected metrics", () => { + const event = makeMockSysinfoEvent(1000, 3); + + expect(event.event).toBe("sysinfo"); + expect(event.scopes).toEqual(["local"]); + expect(event.data.ts).toBe(1000); + expect(event.data.values.cpu).toBeGreaterThanOrEqual(0); + expect(event.data.values.cpu).toBeLessThanOrEqual(100); + expect(event.data.values["mem:used"]).toBeGreaterThan(0); + expect(event.data.values["mem:total"]).toBeGreaterThan(event.data.values["mem:used"]); + expect(event.data.values["cpu:0"]).toBeTypeOf("number"); + }); + + it("creates evenly spaced sysinfo history", () => { + const history = makeMockSysinfoHistory(4, 4000); + + expect(history).toHaveLength(4); + expect(history.map((event) => event.data.ts)).toEqual([1000, 2000, 3000, 4000]); + }); + + it("uses the default history length", () => { + expect(makeMockSysinfoHistory()).toHaveLength(DefaultSysinfoHistoryPoints); + }); +}); diff --git a/frontend/preview/previews/sysinfo.preview.tsx b/frontend/preview/previews/sysinfo.preview.tsx new file mode 100644 index 0000000000..ee4fadb9e1 --- /dev/null +++ b/frontend/preview/previews/sysinfo.preview.tsx @@ -0,0 +1,161 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Block } from "@/app/block/block"; +import { globalStore } from "@/app/store/jotaiStore"; +import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; +import { handleWaveEvent } from "@/app/store/wps"; +import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; +import type { NodeModel } from "@/layout/index"; +import { atom } from "jotai"; +import * as React from "react"; +import { applyMockEnvOverrides, MockWaveEnv } from "../mock/mockwaveenv"; +import { + DefaultSysinfoHistoryPoints, + makeMockSysinfoEvent, + makeMockSysinfoHistory, + MockSysinfoConnection, +} from "./sysinfo.preview-util"; + +const PreviewWorkspaceId = "preview-sysinfo-workspace"; +const PreviewTabId = "preview-sysinfo-tab"; +const PreviewNodeId = "preview-sysinfo-node"; +const PreviewBlockId = "preview-sysinfo-block"; + +function makeMockWorkspace(): Workspace { + return { + otype: "workspace", + oid: PreviewWorkspaceId, + version: 1, + name: "Preview Workspace", + tabids: [PreviewTabId], + activetabid: PreviewTabId, + meta: {}, + } as Workspace; +} + +function makeMockTab(): Tab { + return { + otype: "tab", + oid: PreviewTabId, + version: 1, + name: "Sysinfo Preview", + blockids: [PreviewBlockId], + meta: {}, + } as Tab; +} + +function makeMockBlock(): Block { + return { + otype: "block", + oid: PreviewBlockId, + version: 1, + meta: { + view: "sysinfo", + connection: MockSysinfoConnection, + "sysinfo:type": "CPU + Mem", + "graph:numpoints": 90, + }, + } as Block; +} + +function makePreviewNodeModel(): NodeModel { + const isFocusedAtom = atom(true); + const isMagnifiedAtom = atom(false); + + return { + additionalProps: atom({} as any), + innerRect: atom({ width: "920px", height: "560px" }), + blockNum: atom(1), + numLeafs: atom(2), + nodeId: PreviewNodeId, + blockId: PreviewBlockId, + addEphemeralNodeToLayout: () => {}, + animationTimeS: atom(0), + isResizing: atom(false), + isFocused: isFocusedAtom, + isMagnified: isMagnifiedAtom, + anyMagnified: atom(false), + isEphemeral: atom(false), + ready: atom(true), + disablePointerEvents: atom(false), + toggleMagnify: () => { + globalStore.set(isMagnifiedAtom, !globalStore.get(isMagnifiedAtom)); + }, + focusNode: () => { + globalStore.set(isFocusedAtom, true); + }, + onClose: () => {}, + dragHandleRef: { current: null }, + displayContainerRef: { current: null }, + }; +} + +function SysinfoPreviewInner() { + const baseEnv = useWaveEnv(); + const historyRef = React.useRef(makeMockSysinfoHistory()); + const nodeModel = React.useMemo(() => makePreviewNodeModel(), []); + + const env = React.useMemo(() => { + const mockWaveObjs: Record = { + [`workspace:${PreviewWorkspaceId}`]: makeMockWorkspace(), + [`tab:${PreviewTabId}`]: makeMockTab(), + [`block:${PreviewBlockId}`]: makeMockBlock(), + }; + + return applyMockEnvOverrides(baseEnv, { + tabId: PreviewTabId, + mockWaveObjs, + atoms: { + workspaceId: atom(PreviewWorkspaceId), + staticTabId: atom(PreviewTabId), + }, + rpc: { + EventReadHistoryCommand: async (_client, data) => { + if (data.event !== "sysinfo" || data.scope !== MockSysinfoConnection) { + return []; + } + const maxItems = data.maxitems ?? historyRef.current.length; + return historyRef.current.slice(-maxItems); + }, + }, + }); + }, [baseEnv]); + + const tabModel = React.useMemo(() => getTabModelByTabId(PreviewTabId, env), [env]); + + React.useEffect(() => { + let nextStep = historyRef.current.length; + let nextTs = (historyRef.current[historyRef.current.length - 1]?.data?.ts ?? Date.now()) + 1000; + const intervalId = window.setInterval(() => { + const nextEvent = makeMockSysinfoEvent(nextTs, nextStep); + historyRef.current = [...historyRef.current.slice(-(DefaultSysinfoHistoryPoints - 1)), nextEvent]; + handleWaveEvent(nextEvent); + nextStep++; + nextTs += 1000; + }, 1000); + + return () => { + window.clearInterval(intervalId); + }; + }, []); + + return ( + + +
+
full sysinfo block (mock WOS + FE-only WPS events)
+
+
+ +
+
+
+
+
+ ); +} + +export default function SysinfoPreview() { + return ; +} diff --git a/frontend/preview/previews/tab.preview.tsx b/frontend/preview/previews/tab.preview.tsx index 8be52ec772..11c888254b 100644 --- a/frontend/preview/previews/tab.preview.tsx +++ b/frontend/preview/previews/tab.preview.tsx @@ -11,22 +11,57 @@ interface PreviewTabEntry { tabId: string; tabName: string; active: boolean; - isBeforeActive: boolean; + badges?: Badge[] | null; + flagColor?: string | null; } const tabDefs: PreviewTabEntry[] = [ - { tabId: "preview-tab-1", tabName: "Terminal", active: false, isBeforeActive: true }, - { tabId: "preview-tab-2", tabName: "My Tab", active: true, isBeforeActive: false }, - { tabId: "preview-tab-3", tabName: "T3", active: false, isBeforeActive: false }, + { tabId: "preview-tab-1", tabName: "Terminal", active: false }, + { + tabId: "preview-tab-2", + tabName: "My Tab", + active: true, + badges: [ + { badgeid: "b2", icon: "circle-check", color: "#4ade80", priority: 3 }, + { badgeid: "b1", icon: "circle-small", color: "#fbbf24", priority: 1 }, + { badgeid: "b3", icon: "circle-small", color: "red", priority: 1 }, + ], + }, + { + tabId: "preview-tab-2b", + tabName: "My Tab 2", + active: false, + badges: [ + { badgeid: "b2", icon: "bell", color: "#4ade80", priority: 3 }, + { badgeid: "b1", icon: "circle-small", color: "red", priority: 1 }, + ], + }, + { tabId: "preview-tab-3", tabName: "T3", active: false, flagColor: "#4ade80" }, + { + tabId: "preview-tab-4", + tabName: "1 Badge", + active: false, + badges: [{ badgeid: "b1", icon: "circle-small", color: "#fbbf24", priority: 1 }], + flagColor: "#fbbf24", + }, + { + tabId: "preview-tab-5", + tabName: "3 Badges", + active: false, + badges: [ + { badgeid: "b1", icon: "circle-small", color: "#fbbf24", priority: 1 }, + { badgeid: "b2", icon: "circle-check", color: "#4ade80", priority: 3 }, + { badgeid: "b3", icon: "triangle-exclamation", color: "#f87171", priority: 2 }, + { badgeid: "b4", icon: "bell", color: "#f87171", priority: 2 }, + ], + }, ]; export function TabPreview() { const [tabNames, setTabNames] = useState>( Object.fromEntries(tabDefs.map((t) => [t.tabId, t.tabName])) ); - const [activeTabId, setActiveTabId] = useState( - tabDefs.find((t) => t.active)?.tabId ?? tabDefs[0].tabId - ); + const [activeTabId, setActiveTabId] = useState(tabDefs.find((t) => t.active)?.tabId ?? tabDefs[0].tabId); const tabRefs = useRef>({}); // The real tabbar imperatively sets opacity: 1 and transform after calculating @@ -46,30 +81,31 @@ export function TabPreview() { {tabDefs.map((tab, index) => { const activeIndex = tabDefs.findIndex((t) => t.tabId === activeTabId); const isActive = tab.tabId === activeTabId; - const isBeforeActive = index === activeIndex - 1; + const showDivider = index !== 0 && !isActive && index !== activeIndex + 1; return ( - { - tabRefs.current[tab.tabId] = el; - }} - tabId={tab.tabId} - tabName={tabNames[tab.tabId]} - active={isActive} - isBeforeActive={isBeforeActive} - isDragging={false} - tabWidth={TAB_WIDTH} - isNew={false} - indicator={null} - onClick={() => setActiveTabId(tab.tabId)} - onClose={() => console.log("close", tab.tabId)} - onDragStart={() => {}} - onContextMenu={() => {}} - onRename={(newName) => { - console.log("rename", tab.tabId, newName); - setTabNames((prev) => ({ ...prev, [tab.tabId]: newName })); - }} - /> + { + tabRefs.current[tab.tabId] = el; + }} + tabId={tab.tabId} + tabName={tabNames[tab.tabId]} + active={isActive} + showDivider={showDivider} + isDragging={false} + tabWidth={TAB_WIDTH} + isNew={false} + badges={tab.badges ?? null} + flagColor={tab.flagColor ?? null} + onClick={() => setActiveTabId(tab.tabId)} + onClose={() => console.log("close", tab.tabId)} + onDragStart={() => {}} + onContextMenu={() => {}} + onRename={(newName) => { + console.log("rename", tab.tabId, newName); + setTabNames((prev) => ({ ...prev, [tab.tabId]: newName })); + }} + /> ); })}
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/treeview.preview.tsx b/frontend/preview/previews/treeview.preview.tsx new file mode 100644 index 0000000000..65043ddda4 --- /dev/null +++ b/frontend/preview/previews/treeview.preview.tsx @@ -0,0 +1,97 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { TreeNodeData, TreeView } from "@/app/treeview/treeview"; +import { useMemo, useState } from "react"; + +const RootId = "workspace:/"; +const RootNode: TreeNodeData = { + id: RootId, + path: RootId, + label: "workspace", + isDirectory: true, + childrenStatus: "unloaded", +}; + +const DirectoryData: Record = { + [RootId]: [ + { id: "workspace:/src", path: "workspace:/src", label: "src", parentId: RootId, isDirectory: true }, + { id: "workspace:/docs", path: "workspace:/docs", label: "docs", parentId: RootId, isDirectory: true }, + { id: "workspace:/README.md", path: "workspace:/README.md", label: "README.md", parentId: RootId, isDirectory: false, mimeType: "text/markdown" }, + { id: "workspace:/package.json", path: "workspace:/package.json", label: "package.json", parentId: RootId, isDirectory: false, mimeType: "application/json" }, + ], + "workspace:/src": [ + { id: "workspace:/src/app", path: "workspace:/src/app", label: "app", parentId: "workspace:/src", isDirectory: true }, + { id: "workspace:/src/styles", path: "workspace:/src/styles", label: "styles", parentId: "workspace:/src", isDirectory: true }, + ...Array.from({ length: 200 }).map((_, idx) => ({ + id: `workspace:/src/file-${idx.toString().padStart(3, "0")}.tsx`, + path: `workspace:/src/file-${idx.toString().padStart(3, "0")}.tsx`, + label: `file-${idx.toString().padStart(3, "0")}.tsx`, + parentId: "workspace:/src", + isDirectory: false, + mimeType: "text/typescript", + })), + ], + "workspace:/src/app": [ + { id: "workspace:/src/app/main.tsx", path: "workspace:/src/app/main.tsx", label: "main.tsx", parentId: "workspace:/src/app", isDirectory: false, mimeType: "text/typescript" }, + { id: "workspace:/src/app/router.ts", path: "workspace:/src/app/router.ts", label: "router.ts", parentId: "workspace:/src/app", isDirectory: false, mimeType: "text/typescript" }, + ], + "workspace:/src/styles": [ + { id: "workspace:/src/styles/app.css", path: "workspace:/src/styles/app.css", label: "app.css", parentId: "workspace:/src/styles", isDirectory: false, mimeType: "text/css" }, + ], + "workspace:/docs": Array.from({ length: 25 }).map((_, idx) => ({ + id: `workspace:/docs/page-${idx + 1}.md`, + path: `workspace:/docs/page-${idx + 1}.md`, + label: `page-${idx + 1}.md`, + parentId: "workspace:/docs", + isDirectory: false, + mimeType: "text/markdown", + })), +}; + +export function TreeViewPreview() { + const [width, setWidth] = useState(260); + const [selection, setSelection] = useState(RootId); + const initialNodes = useMemo(() => ({ [RootId]: RootNode }), []); + + return ( +
+
+
Tree width: {width}px
+ setWidth(Number(event.target.value))} + className="mt-2 w-full cursor-pointer" + /> +
Selection: {selection}
+
+ { + await new Promise((resolve) => setTimeout(resolve, 220)); + const entries = DirectoryData[id] ?? []; + return { + nodes: entries.slice(0, limit), + capped: entries.length > limit, + totalKnown: entries.length, + }; + }} + onOpenFile={(id) => { + setSelection(`open:${id}`); + }} + onSelectionChange={(id) => { + setSelection(id); + }} + /> +
+ ); +} diff --git a/frontend/preview/previews/vtabbar.preview.tsx b/frontend/preview/previews/vtabbar.preview.tsx index b576786749..c4739f593d 100644 --- a/frontend/preview/previews/vtabbar.preview.tsx +++ b/frontend/preview/previews/vtabbar.preview.tsx @@ -6,10 +6,22 @@ import { useState } from "react"; const InitialTabs: VTabItem[] = [ { id: "vtab-1", name: "Terminal" }, - { id: "vtab-2", name: "Build Logs", indicator: { icon: "bell", color: "#f59e0b" } }, - { id: "vtab-3", name: "Deploy" }, + { + id: "vtab-2", + name: "Build Logs", + badges: [ + { badgeid: "01957000-0000-7000-0000-000000000001", icon: "bell", color: "#f59e0b", priority: 2 }, + { badgeid: "01957000-0000-7000-0000-000000000002", icon: "circle-small", color: "#4ade80", priority: 3 }, + ], + }, + { id: "vtab-3", name: "Deploy", flagColor: "#429DFF" }, { id: "vtab-4", name: "Wave AI" }, - { id: "vtab-5", name: "A Very Long Tab Name To Show Truncation" }, + { + id: "vtab-5", + name: "A Very Long Tab Name To Show Truncation", + badges: [{ badgeid: "01957000-0000-7000-0000-000000000003", icon: "solid@terminal", color: "#fbbf24", priority: 3 }], + flagColor: "#BF55EC", + }, ]; export function VTabBarPreview() { @@ -40,7 +52,7 @@ export function VTabBarPreview() { className="w-full cursor-pointer" />

- Drag tabs to reorder. Names, indicators, and close buttons remain single-line. + Drag tabs to reorder. Names, badges, and close buttons remain single-line.

diff --git a/frontend/preview/previews/widgets.preview.tsx b/frontend/preview/previews/widgets.preview.tsx new file mode 100644 index 0000000000..144cace174 --- /dev/null +++ b/frontend/preview/previews/widgets.preview.tsx @@ -0,0 +1,183 @@ +// 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 { applyMockEnvOverrides } from "../mock/mockwaveenv"; + +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 applyMockEnvOverrides(baseEnv, { + isDev, + rpc: { ListAllAppsCommand: () => Promise.resolve(apps ?? []) }, + atoms: { + fullConfigAtom, + 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(null); + if (envRef.current == null) { + envRef.current = makeWidgetsEnv(baseEnv, isDev, hasCustomAIPresets, apps); + } + + return ( +
+
{label}
+ +
+
+
+
+ +
+ +
+ ); +} + +function WidgetsResizable() { + const [height, setHeight] = useAtom(resizableHeightAtom); + const baseEnv = useWaveEnv(); + const envRef = useRef(null); + if (envRef.current == null) { + envRef.current = 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/frontend/preview/vite.config.ts b/frontend/preview/vite.config.ts index b42363aeb9..8856c61b1e 100644 --- a/frontend/preview/vite.config.ts +++ b/frontend/preview/vite.config.ts @@ -23,6 +23,9 @@ export default defineConfig({ react(), tailwindcss(), ], + build: { + minify: false, + }, server: { port: 7007, }, diff --git a/frontend/tailwindsetup.css b/frontend/tailwindsetup.css index b9f1933825..ffe6ed471b 100644 --- a/frontend/tailwindsetup.css +++ b/frontend/tailwindsetup.css @@ -1,7 +1,8 @@ -/* Copyright 2025, Command Line Inc. +/* Copyright 2026, Command Line Inc. SPDX-License-Identifier: Apache-2.0 */ @import "tailwindcss"; + @source "../node_modules/streamdown/dist/index.js"; @theme { @@ -75,6 +76,13 @@ --z-window-drag: 100; } +/* Applied when body.nohover is set — used to suppress hover effects during tab remount to prevent ghost-hover flicker */ +@custom-variant nohover { + body.nohover & { + @slot; + } +} + :root { --zoomfactor: 1; --zoomfactor-inv: 1; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 74180391cc..9f7cb15ad3 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -1,6 +1,7 @@ // 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"; @@ -10,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; @@ -28,8 +31,6 @@ declare global { waveAIRateLimitInfoAtom: jotai.PrimitiveAtom; }; - type WritableWaveObjectAtom = jotai.WritableAtom; - type ThrottledValueAtom = jotai.WritableAtom], void>; type AtomWithThrottle = { @@ -59,6 +60,7 @@ declare global { environment: "electron" | "renderer"; primaryTabStartup?: boolean; builderId?: string; + isPreview?: boolean; }; type WaveInitOpts = { @@ -132,6 +134,7 @@ declare global { setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid doRefresh: () => void; // do-refresh saveTextFile: (fileName: string, content: string) => Promise; // save-text-file + setIsActive: () => Promise; // set-is-active }; type ElectronContextMenuItem = { @@ -275,6 +278,7 @@ declare global { resultsIndex: PrimitiveAtom; resultsCount: PrimitiveAtom; isOpen: PrimitiveAtom; + focusInput: PrimitiveAtom; regex?: PrimitiveAtom; caseSensitive?: PrimitiveAtom; wholeWord?: PrimitiveAtom; @@ -289,7 +293,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. diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 2d155a919c..ddcb4a63e7 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -108,6 +108,24 @@ declare global { iconcolor: string; }; + // baseds.Badge + type Badge = { + badgeid: string; + icon: string; + color?: string; + priority: number; + pidlinked?: boolean; + }; + + // baseds.BadgeEvent + type BadgeEvent = { + oref: string; + clear?: boolean; + clearall?: boolean; + clearbyid?: string; + badge?: Badge; + }; + // waveobj.Block type Block = WaveObj & { parentoref?: string; @@ -221,6 +239,13 @@ declare global { token: string; }; + // wshrpc.CommandBadgeWatchPidData + type CommandBadgeWatchPidData = { + pid: number; + oref: ORef; + badgeid: string; + }; + // wshrpc.CommandBlockInputData type CommandBlockInputData = { blockid: string; @@ -1089,6 +1114,7 @@ declare global { "graph:numpoints"?: number; "graph:metrics"?: string[]; "sysinfo:type"?: string; + "tab:flagcolor"?: string; "bg:*"?: boolean; bg?: string; "bg:opacity"?: number; @@ -1119,6 +1145,7 @@ declare global { "term:conndebug"?: string; "term:bellsound"?: boolean; "term:bellindicator"?: boolean; + "term:osc52"?: string; "term:durable"?: boolean; "web:zoom"?: number; "web:hidenav"?: boolean; @@ -1313,6 +1340,7 @@ declare global { "term:cursorblink"?: boolean; "term:bellsound"?: boolean; "term:bellindicator"?: boolean; + "term:osc52"?: string; "term:durable"?: boolean; "editor:minimapenabled"?: boolean; "editor:stickyscrollenabled"?: boolean; @@ -1331,6 +1359,7 @@ declare global { "markdown:fontsize"?: number; "markdown:fixedfontsize"?: number; "preview:showhiddenfiles"?: boolean; + "preview:defaultsort"?: string; "tab:preset"?: string; "tab:confirmclose"?: boolean; "widget:*"?: boolean; @@ -1567,21 +1596,6 @@ declare global { blockids: string[]; }; - // wshrpc.TabIndicator - type TabIndicator = { - icon: string; - color?: string; - priority: number; - clearonfocus?: boolean; - persistentindicator?: TabIndicator; - }; - - // wshrpc.TabIndicatorEventData - type TabIndicatorEventData = { - tabid: string; - indicator: TabIndicator; - }; - // waveobj.TermSize type TermSize = { rows: number; diff --git a/frontend/types/waveevent.d.ts b/frontend/types/waveevent.d.ts index 81eec9bf9d..c3e5bd7822 100644 --- a/frontend/types/waveevent.d.ts +++ b/frontend/types/waveevent.d.ts @@ -6,7 +6,27 @@ declare global { // wps.WaveEvent - type WaveEventName = "blockclose" | "connchange" | "sysinfo" | "controllerstatus" | "builderstatus" | "builderoutput" | "waveobj:update" | "blockfile" | "config" | "userinput" | "route:down" | "route:up" | "workspace:update" | "waveai:ratelimit" | "waveapp:appgoupdated" | "tsunami:updatemeta" | "waveai:modeconfig" | "tab:indicator" | "block:jobstatus"; + type WaveEventName = + | "blockclose" + | "connchange" + | "sysinfo" + | "controllerstatus" + | "builderstatus" + | "builderoutput" + | "waveobj:update" + | "blockfile" + | "config" + | "userinput" + | "route:down" + | "route:up" + | "workspace:update" + | "waveai:ratelimit" + | "waveapp:appgoupdated" + | "tsunami:updatemeta" + | "waveai:modeconfig" + | "block:jobstatus" + | "badge" + ; type WaveEvent = { event: WaveEventName; @@ -32,8 +52,8 @@ declare global { { event: "waveapp:appgoupdated"; data?: null; } | { event: "tsunami:updatemeta"; data?: AppMeta; } | { event: "waveai:modeconfig"; data?: AIModeConfigUpdate; } | - { event: "tab:indicator"; data?: TabIndicatorEventData; } | - { event: "block:jobstatus"; data?: BlockJobStatusData; } + { event: "block:jobstatus"; data?: BlockJobStatusData; } | + { event: "badge"; data?: BadgeEvent; } ); } 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/frontend/wave.ts b/frontend/wave.ts index c380163071..a2ecb8a426 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -3,6 +3,7 @@ import { App } from "@/app/app"; import { loadMonaco } from "@/app/monaco/monaco-env"; +import { loadBadges } from "@/app/store/badge"; import { GlobalModel } from "@/app/store/global-model"; import { globalRefocus, @@ -25,7 +26,6 @@ import { initGlobal, initGlobalWaveEventSubs, loadConnStatus, - loadTabIndicators, subscribeToConnEvents, } from "@/store/global"; import { activeTabIdAtom } from "@/store/tab-model"; @@ -160,18 +160,18 @@ async function initWave(initOpts: WaveInitOpts) { (window as any).globalWS = globalWS; (window as any).TabRpcClient = TabRpcClient; await loadConnStatus(); - await loadTabIndicators(); + await loadBadges(); initGlobalWaveEventSubs(initOpts); subscribeToConnEvents(); // ensures client/window/workspace are loaded into the cache before rendering try { - const [client, waveWindow, initialTab] = await Promise.all([ + const [_client, waveWindow, initialTab] = await Promise.all([ WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)), WOS.loadAndPinWaveObject(WOS.makeORef("window", initOpts.windowId)), WOS.loadAndPinWaveObject(WOS.makeORef("tab", initOpts.tabId)), ]); - const [ws, layoutState] = await Promise.all([ + const [ws, _layoutState] = await Promise.all([ WOS.loadAndPinWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)), WOS.reloadWaveObject(WOS.makeORef("layout", initialTab.layoutstate)), ]); @@ -193,7 +193,7 @@ async function initWave(initOpts: WaveInitOpts) { globalStore.set(atoms.waveaiModeConfigAtom, waveaiModeConfig.configs); console.log("Wave First Render"); let firstRenderResolveFn: () => void = null; - let firstRenderPromise = new Promise((resolve) => { + const firstRenderPromise = new Promise((resolve) => { firstRenderResolveFn = resolve; }); const reactElem = createElement(App, { onFirstRender: firstRenderResolveFn }, null); @@ -252,7 +252,7 @@ async function initBuilder(initOpts: BuilderInitOpts) { globalStore.set(atoms.builderAppId, appIdToUse); - const client = await WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)); + const _client = await WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)); registerBuilderGlobalKeys(); registerElectronReinjectKeyHandler(); @@ -265,7 +265,7 @@ async function initBuilder(initOpts: BuilderInitOpts) { console.log("Tsunami Builder First Render"); let firstRenderResolveFn: () => void = null; - let firstRenderPromise = new Promise((resolve) => { + const firstRenderPromise = new Promise((resolve) => { firstRenderResolveFn = resolve; }); const reactElem = createElement(BuilderApp, { initOpts, onFirstRender: firstRenderResolveFn }, null); diff --git a/go.mod b/go.mod index b07b58b3f8..7615a351ee 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 github.com/sashabaranov/go-openai v1.41.2 github.com/sawka/txwrap v0.2.0 - github.com/shirou/gopsutil/v4 v4.26.1 + github.com/shirou/gopsutil/v4 v4.26.2 github.com/skeema/knownhosts v1.3.1 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/cobra v1.10.2 @@ -49,7 +49,7 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/ebitengine/purego v0.9.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/go.sum b/go.sum index 6d213d65d7..03a89cd1d2 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= @@ -126,8 +126,8 @@ github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TV github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sawka/txwrap v0.2.0 h1:V3LfvKVLULxcYSxdMguLwFyQFMEU9nFDJopg0ZkL+94= github.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA= -github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo= -github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= diff --git a/package-lock.json b/package-lock.json index 269181d32a..99c2a025b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.1-beta.1", + "version": "0.14.2-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.1-beta.1", + "version": "0.14.2-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -21,6 +21,7 @@ "@table-nav/core": "^0.0.7", "@table-nav/react": "^0.0.7", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.19", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/addon-serialize": "^0.13.0", @@ -80,6 +81,7 @@ "tinycolor2": "^1.6.0", "unist-util-visit": "^5.1.0", "use-device-pixel-ratio": "^1.1.2", + "uuid": "^13.0.0", "winston": "^3.19.0", "ws": "^8.19.0", "yaml": "^2.7.1" @@ -110,9 +112,9 @@ "electron-vite": "^5.0", "eslint": "^9.39", "eslint-config-prettier": "^10.1.8", - "globals": "^17.3.0", + "globals": "^17.4.0", "node-abi": "^4.26.0", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "prettier": "^3.8.1", "prettier-plugin-jsdoc": "^1.8.0", "prettier-plugin-organize-imports": "^4.3.0", @@ -9047,6 +9049,23 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.19", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.19.tgz", + "integrity": "sha512-KzwmU1IbE0IvCZSm6OXkS+kRdrgW2c2P3Ho3NC+zZXWK6oObv/L+lcV/2VuJ+snVESRlMJ+w/fg4WXI/JzoNGQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -9060,6 +9079,16 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.19", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.19.tgz", + "integrity": "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -16981,9 +17010,9 @@ } }, "node_modules/globals": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", - "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", "dev": true, "license": "MIT", "engines": { @@ -18088,9 +18117,9 @@ } }, "node_modules/immutable": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", - "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "license": "MIT" }, "node_modules/import-fresh": { @@ -24209,9 +24238,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -29305,6 +29334,15 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -29967,9 +30005,9 @@ } }, "node_modules/tar": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", - "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", + "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -31887,12 +31925,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { diff --git a/package.json b/package.json index b21f8eee1a..bd9fed1052 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.1", + "version": "0.14.2", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" @@ -53,9 +53,9 @@ "electron-vite": "^5.0", "eslint": "^9.39", "eslint-config-prettier": "^10.1.8", - "globals": "^17.3.0", + "globals": "^17.4.0", "node-abi": "^4.26.0", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "prettier": "^3.8.1", "prettier-plugin-jsdoc": "^1.8.0", "prettier-plugin-organize-imports": "^4.3.0", @@ -81,6 +81,7 @@ "@table-nav/core": "^0.0.7", "@table-nav/react": "^0.0.7", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.19", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/addon-serialize": "^0.13.0", @@ -140,6 +141,7 @@ "tinycolor2": "^1.6.0", "unist-util-visit": "^5.1.0", "use-device-pixel-ratio": "^1.1.2", + "uuid": "^13.0.0", "winston": "^3.19.0", "ws": "^8.19.0", "yaml": "^2.7.1" diff --git a/pkg/aiusechat/anthropic/anthropic-backend.go b/pkg/aiusechat/anthropic/anthropic-backend.go index b52b4a6797..02070b1bf8 100644 --- a/pkg/aiusechat/anthropic/anthropic-backend.go +++ b/pkg/aiusechat/anthropic/anthropic-backend.go @@ -10,8 +10,9 @@ import ( "errors" "fmt" "io" - "log" "net/http" + "net/url" + "sort" "strings" "time" @@ -20,6 +21,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/util/logutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/web/sse" ) @@ -56,10 +58,11 @@ func (m *anthropicChatMessage) GetUsage() *uctypes.AIUsage { } return &uctypes.AIUsage{ - APIType: uctypes.APIType_AnthropicMessages, - Model: m.Usage.Model, - InputTokens: m.Usage.InputTokens, - OutputTokens: m.Usage.OutputTokens, + APIType: uctypes.APIType_AnthropicMessages, + Model: m.Usage.Model, + InputTokens: m.Usage.InputTokens, + OutputTokens: m.Usage.OutputTokens, + NativeWebSearchCount: m.Usage.NativeWebSearchCount, } } @@ -95,8 +98,9 @@ type anthropicMessageContentBlock struct { Name string `json:"name,omitempty"` Input interface{} `json:"input,omitempty"` - ToolUseDisplayName string `json:"toolusedisplayname,omitempty"` // internal field (cannot marshal to API, must be stripped) - ToolUseShortDescription string `json:"tooluseshortdescription,omitempty"` // internal field (cannot marshal to API, must be stripped) + ToolUseDisplayName string `json:"toolusedisplayname,omitempty"` // internal field (cannot marshal to API, must be stripped) + ToolUseShortDescription string `json:"tooluseshortdescription,omitempty"` // internal field (cannot marshal to API, must be stripped) + ToolUseData *uctypes.UIMessageDataToolUse `json:"toolusedata,omitempty"` // internal field (cannot marshal to API, must be stripped) // Tool result content ToolUseID string `json:"tool_use_id,omitempty"` @@ -154,6 +158,7 @@ func (b *anthropicMessageContentBlock) Clean() *anthropicMessageContentBlock { rtn.SourcePreviewUrl = "" rtn.ToolUseDisplayName = "" rtn.ToolUseShortDescription = "" + rtn.ToolUseData = nil if rtn.Source != nil { rtn.Source = rtn.Source.Clean() } @@ -177,10 +182,15 @@ type anthropicStreamRequest struct { Stream bool `json:"stream"` System []anthropicMessageContentBlock `json:"system,omitempty"` ToolChoice any `json:"tool_choice,omitempty"` - Tools []uctypes.ToolDefinition `json:"tools,omitempty"` + Tools []any `json:"tools,omitempty"` // *uctypes.ToolDefinition or *anthropicWebSearchTool Thinking *anthropicThinkingOpts `json:"thinking,omitempty"` } +type anthropicWebSearchTool struct { + Type string `json:"type"` // "web_search_20250305" + Name string `json:"name"` // "web_search" +} + type anthropicCacheControl struct { Type string `json:"type"` // "ephemeral" TTL string `json:"ttl"` // "5m" or "1h" @@ -228,8 +238,9 @@ type anthropicUsageType struct { CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` - // internal field for Wave use (not sent to API) - Model string `json:"model,omitempty"` + // internal fields for Wave use (not sent to API) + Model string `json:"model,omitempty"` + NativeWebSearchCount int `json:"nativewebsearchcount,omitempty"` // for reference, but we dont keep thsese up to date or track them CacheCreation *anthropicCacheCreationType `json:"cache_creation,omitempty"` // breakdown of cached tokens by TTL @@ -290,14 +301,16 @@ type partialJSON struct { } type streamingState struct { - blockMap map[int]*blockState - toolCalls []uctypes.WaveToolCall - stopFromDelta string - msgID string - model string - stepStarted bool - rtnMessage *anthropicChatMessage - usage *anthropicUsageType + blockMap map[int]*blockState + toolCalls []uctypes.WaveToolCall + stopFromDelta string + msgID string + model string + stepStarted bool + rtnMessage *anthropicChatMessage + usage *anthropicUsageType + chatOpts uctypes.WaveChatOpts + webSearchCount int } func (p *partialJSON) Write(s string) { @@ -330,6 +343,20 @@ func (p *partialJSON) FinalObject() (json.RawMessage, error) { } } +// sanitizeHostnameInError removes the Wave cloud hostname from error messages +func sanitizeHostnameInError(err error) error { + if err == nil { + return nil + } + errStr := err.Error() + parsedURL, parseErr := url.Parse(uctypes.DefaultAIEndpoint) + if parseErr == nil && parsedURL.Host != "" && strings.Contains(errStr, parsedURL.Host) { + errStr = strings.ReplaceAll(errStr, uctypes.DefaultAIEndpoint, "AI service") + errStr = strings.ReplaceAll(errStr, parsedURL.Host, "host") + } + return fmt.Errorf("%s", errStr) +} + // makeThinkingOpts creates thinking options based on level and max tokens func makeThinkingOpts(thinkingLevel string, maxTokens int) *anthropicThinkingOpts { if thinkingLevel != uctypes.ThinkingLevelMedium && thinkingLevel != uctypes.ThinkingLevelHigh { @@ -373,13 +400,13 @@ func parseAnthropicHTTPError(resp *http.Response) error { // Try to parse as Anthropic error format first var eresp anthropicHTTPErrorResponse if err := json.Unmarshal(slurp, &eresp); err == nil && eresp.Error.Message != "" { - return fmt.Errorf("anthropic %s: %s", resp.Status, eresp.Error.Message) + return sanitizeHostnameInError(fmt.Errorf("anthropic %s: %s", resp.Status, eresp.Error.Message)) } // Try to parse as proxy error format var proxyErr uctypes.ProxyErrorResponse if err := json.Unmarshal(slurp, &proxyErr); err == nil && !proxyErr.Success && proxyErr.Error != "" { - return fmt.Errorf("anthropic %s: %s", resp.Status, proxyErr.Error) + return sanitizeHostnameInError(fmt.Errorf("anthropic %s: %s", resp.Status, proxyErr.Error)) } // Fall back to truncated raw response @@ -387,7 +414,7 @@ func parseAnthropicHTTPError(resp *http.Response) error { if msg == "" { msg = "unknown error" } - return fmt.Errorf("anthropic %s: %s", resp.Status, msg) + return sanitizeHostnameInError(fmt.Errorf("anthropic %s: %s", resp.Status, msg)) } func RunAnthropicChatStep( @@ -426,7 +453,7 @@ func RunAnthropicChatStep( // Validate continuation if provided if cont != nil { - if chatOpts.Config.Model != cont.Model { + if !uctypes.AreModelsCompatible(chat.APIType, chatOpts.Config.Model, cont.Model) { return nil, nil, nil, fmt.Errorf("cannot continue with a different model, model:%q, cont-model:%q", chatOpts.Config.Model, cont.Model) } } @@ -461,7 +488,7 @@ func RunAnthropicChatStep( resp, err := httpClient.Do(req) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, sanitizeHostnameInError(err) } defer resp.Body.Close() @@ -499,7 +526,7 @@ func RunAnthropicChatStep( // Use eventsource decoder for proper SSE parsing decoder := eventsource.NewDecoder(resp.Body) - stopReason, rtnMessage := handleAnthropicStreamingResp(ctx, sse, decoder, cont) + stopReason, rtnMessage := handleAnthropicStreamingResp(ctx, sse, decoder, cont, chatOpts) return stopReason, rtnMessage, rateLimitInfo, nil } @@ -509,6 +536,7 @@ func handleAnthropicStreamingResp( sse *sse.SSEHandlerCh, decoder *eventsource.Decoder, cont *uctypes.WaveContinueResponse, + chatOpts uctypes.WaveChatOpts, ) (*uctypes.WaveStopReason, *anthropicChatMessage) { // Per-response state state := &streamingState{ @@ -518,6 +546,7 @@ func handleAnthropicStreamingResp( Role: "assistant", Content: []anthropicMessageContentBlock{}, }, + chatOpts: chatOpts, } var rtnStopReason *uctypes.WaveStopReason @@ -526,8 +555,10 @@ func handleAnthropicStreamingResp( defer func() { // Set usage in the returned message if state.usage != nil { - // Set model in usage for internal use state.usage.Model = state.model + if state.webSearchCount > 0 { + state.usage.NativeWebSearchCount = state.webSearchCount + } state.rtnMessage.Usage = state.usage } @@ -558,6 +589,13 @@ func handleAnthropicStreamingResp( // Normal end of stream break } + if sse.Err() != nil { + return &uctypes.WaveStopReason{ + Kind: uctypes.StopKindCanceled, + ErrorType: "client_disconnect", + ErrorText: "client disconnected", + }, extractPartialTextFromState(state) + } // transport error mid-stream _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{ @@ -587,6 +625,37 @@ func handleAnthropicStreamingResp( return rtnStopReason, state.rtnMessage } +func extractPartialTextFromState(state *streamingState) *anthropicChatMessage { + var content []anthropicMessageContentBlock + for _, block := range state.rtnMessage.Content { + if block.Type == "text" && block.Text != "" { + content = append(content, block) + } + } + var partialIdx []int + for idx, st := range state.blockMap { + if st.kind == blockText && st.contentBlock != nil && st.contentBlock.Text != "" { + partialIdx = append(partialIdx, idx) + } + } + sort.Ints(partialIdx) + for _, idx := range partialIdx { + st := state.blockMap[idx] + if st.kind == blockText && st.contentBlock != nil && st.contentBlock.Text != "" { + content = append(content, *st.contentBlock) + } + } + if len(content) == 0 { + return nil + } + return &anthropicChatMessage{ + MessageId: state.rtnMessage.MessageId, + Role: "assistant", + Content: content, + Usage: state.rtnMessage.Usage, + } +} + // handleAnthropicEvent processes one SSE event block. It may emit SSE parts // and/or return a StopReason when the stream is complete. // @@ -601,6 +670,13 @@ func handleAnthropicEvent( state *streamingState, cont *uctypes.WaveContinueResponse, ) (stopFromDelta *string, final *uctypes.WaveStopReason) { + if err := sse.Err(); err != nil { + return nil, &uctypes.WaveStopReason{ + Kind: uctypes.StopKindCanceled, + ErrorType: "client_disconnect", + ErrorText: "client disconnected", + } + } eventName := event.Event() data := event.Data() switch eventName { @@ -693,6 +769,10 @@ func handleAnthropicEvent( } state.blockMap[idx] = st _ = sse.AiMsgToolInputStart(tcID, tName) + case "server_tool_use": + if ev.ContentBlock.Name == "web_search" { + state.webSearchCount++ + } default: // ignore other block types gracefully per Anthropic guidance :contentReference[oaicite:18]{index=18} } @@ -732,6 +812,7 @@ func handleAnthropicEvent( if st.kind == blockToolUse { st.accumJSON.Write(ev.Delta.PartialJSON) _ = sse.AiMsgToolInputDelta(st.toolCallID, ev.Delta.PartialJSON) + aiutil.SendToolProgress(st.toolCallID, st.toolName, st.accumJSON.Bytes(), state.chatOpts, sse, true) } case "signature_delta": // Accumulate signature for thinking blocks @@ -784,6 +865,7 @@ func handleAnthropicEvent( } } _ = sse.AiMsgToolInputAvailable(st.toolCallID, st.toolName, raw) + aiutil.SendToolProgress(st.toolCallID, st.toolName, raw, state.chatOpts, sse, false) state.toolCalls = append(state.toolCalls, uctypes.WaveToolCall{ ID: st.toolCallID, Name: st.toolName, @@ -798,6 +880,9 @@ func handleAnthropicEvent( } state.rtnMessage.Content = append(state.rtnMessage.Content, toolUseBlock) } + // extractPartialTextFromState reads blockMap for still-in-flight content, so remove completed blocks + // once they have been appended to rtnMessage.Content to avoid duplicate text on disconnect. + delete(state.blockMap, *ev.Index) return nil, nil case "message_delta": @@ -868,7 +953,7 @@ func handleAnthropicEvent( } default: - log.Printf("unknown anthropic event type: %s", eventName) + logutil.DevPrintf("unknown anthropic event type: %s", eventName) return nil, nil } } diff --git a/pkg/aiusechat/anthropic/anthropic-backend_test.go b/pkg/aiusechat/anthropic/anthropic-backend_test.go index 8d9acb78e2..71e89bfb2f 100644 --- a/pkg/aiusechat/anthropic/anthropic-backend_test.go +++ b/pkg/aiusechat/anthropic/anthropic-backend_test.go @@ -6,6 +6,7 @@ package anthropic import ( "testing" + "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" ) @@ -69,3 +70,97 @@ func TestConvertPartsToAnthropicBlocks_SkipsUnknownTypes(t *testing.T) { t.Errorf("expected second text 'Another valid text', got %v", block2.Text) } } + +func TestGetFunctionCallInputByToolCallId(t *testing.T) { + toolData := &uctypes.UIMessageDataToolUse{ToolCallId: "call-1", ToolName: "read_file", Status: uctypes.ToolUseStatusPending} + chat := uctypes.AIChat{ + NativeMessages: []uctypes.GenAIMessage{ + &anthropicChatMessage{ + MessageId: "m1", + Role: "assistant", + Content: []anthropicMessageContentBlock{ + {Type: "tool_use", ID: "call-1", Name: "read_file", Input: map[string]interface{}{"path": "/tmp/a"}, ToolUseData: toolData}, + }, + }, + }, + } + fnCall := GetFunctionCallInputByToolCallId(chat, "call-1") + if fnCall == nil { + t.Fatalf("expected function call input") + } + if fnCall.CallId != "call-1" || fnCall.Name != "read_file" { + t.Fatalf("unexpected function call input: %#v", fnCall) + } + if fnCall.Arguments != "{\"path\":\"/tmp/a\"}" { + t.Fatalf("unexpected arguments: %s", fnCall.Arguments) + } + if fnCall.ToolUseData == nil || fnCall.ToolUseData.ToolCallId != "call-1" { + t.Fatalf("expected tool use data") + } +} + +func TestUpdateAndRemoveToolUseCall(t *testing.T) { + chatID := "anthropic-test-tooluse" + chatstore.DefaultChatStore.Delete(chatID) + defer chatstore.DefaultChatStore.Delete(chatID) + + aiOpts := &uctypes.AIOptsType{ + APIType: uctypes.APIType_AnthropicMessages, + Model: "claude-sonnet-4-5", + APIVersion: AnthropicDefaultAPIVersion, + } + msg := &anthropicChatMessage{ + MessageId: "m1", + Role: "assistant", + Content: []anthropicMessageContentBlock{ + {Type: "text", Text: "start"}, + {Type: "tool_use", ID: "call-1", Name: "read_file", Input: map[string]interface{}{"path": "/tmp/a"}}, + }, + } + if err := chatstore.DefaultChatStore.PostMessage(chatID, aiOpts, msg); err != nil { + t.Fatalf("failed to seed chat: %v", err) + } + + newData := uctypes.UIMessageDataToolUse{ToolCallId: "call-1", ToolName: "read_file", Status: uctypes.ToolUseStatusCompleted} + if err := UpdateToolUseData(chatID, "call-1", newData); err != nil { + t.Fatalf("update failed: %v", err) + } + + chat := chatstore.DefaultChatStore.Get(chatID) + updated := chat.NativeMessages[0].(*anthropicChatMessage) + if updated.Content[1].ToolUseData == nil || updated.Content[1].ToolUseData.Status != uctypes.ToolUseStatusCompleted { + t.Fatalf("tool use data not updated") + } + + if err := RemoveToolUseCall(chatID, "call-1"); err != nil { + t.Fatalf("remove failed: %v", err) + } + chat = chatstore.DefaultChatStore.Get(chatID) + updated = chat.NativeMessages[0].(*anthropicChatMessage) + if len(updated.Content) != 1 || updated.Content[0].Type != "text" { + t.Fatalf("expected tool_use block removed, got %#v", updated.Content) + } +} + +func TestConvertToUIMessageIncludesToolUseData(t *testing.T) { + msg := &anthropicChatMessage{ + MessageId: "m1", + Role: "assistant", + Content: []anthropicMessageContentBlock{ + { + Type: "tool_use", + ID: "call-1", + Name: "read_file", + Input: map[string]interface{}{"path": "/tmp/a"}, + ToolUseData: &uctypes.UIMessageDataToolUse{ToolCallId: "call-1", ToolName: "read_file", Status: uctypes.ToolUseStatusPending}, + }, + }, + } + ui := msg.ConvertToUIMessage() + if ui == nil || len(ui.Parts) != 2 { + t.Fatalf("expected tool and data-tooluse parts, got %#v", ui) + } + if ui.Parts[0].Type != "tool-read_file" || ui.Parts[1].Type != "data-tooluse" { + t.Fatalf("unexpected part types: %#v", ui.Parts) + } +} diff --git a/pkg/aiusechat/anthropic/anthropic-convertmessage.go b/pkg/aiusechat/anthropic/anthropic-convertmessage.go index 7fec54b1ab..552cc8080c 100644 --- a/pkg/aiusechat/anthropic/anthropic-convertmessage.go +++ b/pkg/aiusechat/anthropic/anthropic-convertmessage.go @@ -13,10 +13,13 @@ import ( "log" "net/http" "regexp" + "slices" "strings" "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/util/logutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" ) @@ -119,24 +122,23 @@ func buildAnthropicHTTPRequest(ctx context.Context, msgs []anthropicInputMessage reqBody.System = systemBlocks } - if len(chatOpts.Tools) > 0 { - cleanedTools := make([]uctypes.ToolDefinition, len(chatOpts.Tools)) - for i, tool := range chatOpts.Tools { - cleanedTools[i] = *tool.Clean() - } - reqBody.Tools = cleanedTools + for _, tool := range chatOpts.Tools { + cleanedTool := tool.Clean() + reqBody.Tools = append(reqBody.Tools, cleanedTool) } for _, tool := range chatOpts.TabTools { - cleanedTool := *tool.Clean() + cleanedTool := tool.Clean() reqBody.Tools = append(reqBody.Tools, cleanedTool) } + if chatOpts.AllowNativeWebSearch { + reqBody.Tools = append(reqBody.Tools, &anthropicWebSearchTool{Type: "web_search_20250305", Name: "web_search"}) + } // Enable extended thinking based on level reqBody.Thinking = makeThinkingOpts(opts.ThinkingLevel, maxTokens) // pretty print json of anthropicMsgs if jsonStr, err := utilfn.MarshalIndentNoHTMLString(convertedMsgs, "", " "); err == nil { - log.Printf("system-prompt: %v\n", chatOpts.SystemPrompt) var toolNames []string for _, tool := range chatOpts.Tools { toolNames = append(toolNames, tool.Name) @@ -144,9 +146,12 @@ func buildAnthropicHTTPRequest(ctx context.Context, msgs []anthropicInputMessage for _, tool := range chatOpts.TabTools { toolNames = append(toolNames, tool.Name) } - log.Printf("tools: %s\n", strings.Join(toolNames, ", ")) - log.Printf("anthropicMsgs JSON:\n%s", jsonStr) - log.Printf("has-api-key: %v\n", opts.APIToken != "") + if chatOpts.AllowNativeWebSearch { + toolNames = append(toolNames, "web_search[server]") + } + logutil.DevPrintf("tools: %s\n", strings.Join(toolNames, ", ")) + logutil.DevPrintf("anthropicMsgs JSON:\n%s", jsonStr) + logutil.DevPrintf("has-api-key: %v\n", opts.APIToken != "") } var buf bytes.Buffer @@ -698,6 +703,13 @@ func (m *anthropicChatMessage) ConvertToUIMessage() *uctypes.UIMessage { ToolCallID: block.ID, Input: block.Input, }) + if block.ToolUseData != nil { + parts = append(parts, uctypes.UIMessagePart{ + Type: "data-tooluse", + ID: block.ID, + Data: *block.ToolUseData, + }) + } } default: // For now, skip all other types (will implement later) @@ -827,3 +839,102 @@ func ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) { Messages: uiMessages, }, nil } + +func GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput { + for _, genMsg := range aiChat.NativeMessages { + chatMsg, ok := genMsg.(*anthropicChatMessage) + if !ok { + continue + } + for _, block := range chatMsg.Content { + if block.Type != "tool_use" || block.ID != toolCallId { + continue + } + argsInput := block.Input + if argsInput == nil { + argsInput = map[string]interface{}{} + } + argsBytes, err := json.Marshal(argsInput) + if err != nil { + continue + } + return &uctypes.AIFunctionCallInput{ + CallId: block.ID, + Name: block.Name, + Arguments: string(argsBytes), + ToolUseData: block.ToolUseData, + } + } + } + return nil +} + +func UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error { + chat := chatstore.DefaultChatStore.Get(chatId) + if chat == nil { + return fmt.Errorf("chat not found: %s", chatId) + } + for _, genMsg := range chat.NativeMessages { + chatMsg, ok := genMsg.(*anthropicChatMessage) + if !ok { + continue + } + for i, block := range chatMsg.Content { + if block.Type != "tool_use" || block.ID != toolCallId { + continue + } + updatedMsg := &anthropicChatMessage{ + MessageId: chatMsg.MessageId, + Usage: chatMsg.Usage, + Role: chatMsg.Role, + Content: slices.Clone(chatMsg.Content), + } + updatedMsg.Content[i].ToolUseData = &toolUseData + aiOpts := &uctypes.AIOptsType{ + APIType: chat.APIType, + Model: chat.Model, + APIVersion: chat.APIVersion, + } + return chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg) + } + } + return fmt.Errorf("tool call with ID %s not found in chat %s", toolCallId, chatId) +} + +func RemoveToolUseCall(chatId string, toolCallId string) error { + chat := chatstore.DefaultChatStore.Get(chatId) + if chat == nil { + return fmt.Errorf("chat not found: %s", chatId) + } + for _, genMsg := range chat.NativeMessages { + chatMsg, ok := genMsg.(*anthropicChatMessage) + if !ok { + continue + } + for i, block := range chatMsg.Content { + if block.Type != "tool_use" || block.ID != toolCallId { + continue + } + updatedMsg := &anthropicChatMessage{ + MessageId: chatMsg.MessageId, + Usage: chatMsg.Usage, + Role: chatMsg.Role, + Content: slices.Delete(slices.Clone(chatMsg.Content), i, i+1), + } + if len(updatedMsg.Content) == 0 { + chatstore.DefaultChatStore.RemoveMessage(chatId, chatMsg.MessageId) + } else { + aiOpts := &uctypes.AIOptsType{ + APIType: chat.APIType, + Model: chat.Model, + APIVersion: chat.APIVersion, + } + if err := chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg); err != nil { + return err + } + } + return nil + } + } + return nil +} 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) } } diff --git a/pkg/aiusechat/usechat-backend.go b/pkg/aiusechat/usechat-backend.go index cb380a457c..37e2f432ec 100644 --- a/pkg/aiusechat/usechat-backend.go +++ b/pkg/aiusechat/usechat-backend.go @@ -186,15 +186,18 @@ func (b *anthropicBackend) RunChatStep( cont *uctypes.WaveContinueResponse, ) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, *uctypes.RateLimitInfo, error) { stopReason, msg, rateLimitInfo, err := anthropic.RunAnthropicChatStep(ctx, sseHandler, chatOpts, cont) + if msg == nil { + return stopReason, nil, rateLimitInfo, err + } return stopReason, []uctypes.GenAIMessage{msg}, rateLimitInfo, err } func (b *anthropicBackend) UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error { - return fmt.Errorf("UpdateToolUseData not implemented for anthropic backend") + return anthropic.UpdateToolUseData(chatId, toolCallId, toolUseData) } func (b *anthropicBackend) RemoveToolUseCall(chatId string, toolCallId string) error { - return fmt.Errorf("RemoveToolUseCall not implemented for anthropic backend") + return anthropic.RemoveToolUseCall(chatId, toolCallId) } func (b *anthropicBackend) ConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error) { @@ -210,7 +213,7 @@ func (b *anthropicBackend) ConvertAIMessageToNativeChatMessage(message uctypes.A } func (b *anthropicBackend) GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput { - return nil + return anthropic.GetFunctionCallInputByToolCallId(aiChat, toolCallId) } func (b *anthropicBackend) ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) { diff --git a/pkg/baseds/baseds.go b/pkg/baseds/baseds.go index 8ede225968..90b339506a 100644 --- a/pkg/baseds/baseds.go +++ b/pkg/baseds/baseds.go @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // used for shared datastructures @@ -12,3 +12,19 @@ type RpcInputChType struct { MsgBytes []byte IngressLinkId LinkId } + +type Badge struct { + BadgeId string `json:"badgeid"` // must be a uuidv7 + Icon string `json:"icon"` + Color string `json:"color,omitempty"` + Priority float64 `json:"priority"` + PidLinked bool `json:"pidlinked,omitempty"` +} + +type BadgeEvent struct { + ORef string `json:"oref"` + Clear bool `json:"clear,omitempty"` + ClearAll bool `json:"clearall,omitempty"` + ClearById string `json:"clearbyid,omitempty"` + Badge *Badge `json:"badge,omitempty"` +} 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/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 { 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/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/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 062056e290..8d92893afc 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -54,7 +54,6 @@ var ExtraTypes = []any{ waveobj.ObjRTInfo{}, uctypes.RateLimitInfo{}, wconfig.AIModeConfigUpdate{}, - wshrpc.TabIndicatorEventData{}, wshrpc.BlockJobStatusData{}, } @@ -413,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 { @@ -421,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) @@ -471,6 +474,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 (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,8 +494,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 (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() } diff --git a/pkg/tsgen/tsgenevent.go b/pkg/tsgen/tsgenevent.go index b232d517c7..6e1c08e981 100644 --- a/pkg/tsgen/tsgenevent.go +++ b/pkg/tsgen/tsgenevent.go @@ -10,6 +10,7 @@ import ( "strconv" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/userinput" "github.com/wavetermdev/waveterm/pkg/waveobj" @@ -38,8 +39,8 @@ var WaveEventDataTypes = map[string]reflect.Type{ wps.Event_WaveAppAppGoUpdated: nil, wps.Event_TsunamiUpdateMeta: reflect.TypeOf(wshrpc.AppMeta{}), wps.Event_AIModeConfig: reflect.TypeOf(wconfig.AIModeConfigUpdate{}), - wps.Event_TabIndicator: reflect.TypeOf(wshrpc.TabIndicatorEventData{}), wps.Event_BlockJobStatus: reflect.TypeOf(wshrpc.BlockJobStatusData{}), + wps.Event_Badge: reflect.TypeOf(baseds.BadgeEvent{}), } func getWaveEventDataTSType(eventName string, tsTypesMap map[reflect.Type]string) string { @@ -66,12 +67,9 @@ func GenerateWaveEventTypes(tsTypesMap map[reflect.Type]string) string { var buf bytes.Buffer buf.WriteString("// wps.WaveEvent\n") - buf.WriteString("type WaveEventName = ") - for idx, eventName := range wps.AllEvents { - if idx > 0 { - buf.WriteString(" | ") - } - buf.WriteString(strconv.Quote(eventName)) + buf.WriteString("type WaveEventName =\n") + for _, eventName := range wps.AllEvents { + buf.WriteString(fmt.Sprintf(" | %s\n", strconv.Quote(eventName))) } buf.WriteString(";\n\n") buf.WriteString("type WaveEvent = {\n") diff --git a/pkg/util/fileutil/mimetypes.go b/pkg/util/fileutil/mimetypes.go index f6baafad89..dade9f2c98 100644 --- a/pkg/util/fileutil/mimetypes.go +++ b/pkg/util/fileutil/mimetypes.go @@ -842,6 +842,7 @@ var StaticMimeTypeMap = map[string]string{ ".xspf": "application/xspf+xml", ".mxml": "application/xv+xml", ".yaml": "application/x-yaml", + ".yml": "application/x-yaml", ".yang": "application/yang", ".yin": "application/yin+xml", ".zip": "application/zip", diff --git a/pkg/util/unixutil/unixutil_unix.go b/pkg/util/unixutil/unixutil_unix.go index 23a9e9e7b0..f47a0e0f3b 100644 --- a/pkg/util/unixutil/unixutil_unix.go +++ b/pkg/util/unixutil/unixutil_unix.go @@ -68,3 +68,15 @@ func SignalTerm(pid int) error { func SignalHup(pid int) error { return syscall.Kill(pid, syscall.SIGHUP) } + +func IsPidRunning(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + // EPERM means no permission, but it exists (ESRCH is not found) + if err == nil || err == syscall.EPERM { + return true + } + return false +} diff --git a/pkg/util/unixutil/unixutil_windows.go b/pkg/util/unixutil/unixutil_windows.go index 15352a0437..5c7f72aba9 100644 --- a/pkg/util/unixutil/unixutil_windows.go +++ b/pkg/util/unixutil/unixutil_windows.go @@ -40,3 +40,7 @@ func SignalTerm(pid int) error { func SignalHup(pid int) error { return nil } + +func IsPidRunning(pid int) bool { + return false +} diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 8399340cfe..bf348023ac 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -89,6 +89,8 @@ const ( MetaKey_SysinfoType = "sysinfo:type" + MetaKey_TabFlagColor = "tab:flagcolor" + MetaKey_BgClear = "bg:*" MetaKey_Bg = "bg" MetaKey_BgOpacity = "bg:opacity" @@ -121,6 +123,7 @@ const ( MetaKey_TermConnDebug = "term:conndebug" MetaKey_TermBellSound = "term:bellsound" MetaKey_TermBellIndicator = "term:bellindicator" + MetaKey_TermOsc52 = "term:osc52" MetaKey_TermDurable = "term:durable" MetaKey_WebZoom = "web:zoom" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index da73892365..adda079c1f 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -92,6 +92,7 @@ type MetaTSType struct { SysinfoType string `json:"sysinfo:type,omitempty"` // for tabs + TabFlagColor string `json:"tab:flagcolor,omitempty"` BgClear bool `json:"bg:*,omitempty"` Bg string `json:"bg,omitempty"` BgOpacity float64 `json:"bg:opacity,omitempty"` @@ -125,6 +126,7 @@ type MetaTSType struct { TermConnDebug string `json:"term:conndebug,omitempty"` // null, info, debug TermBellSound *bool `json:"term:bellsound,omitempty"` TermBellIndicator *bool `json:"term:bellindicator,omitempty"` + TermOsc52 string `json:"term:osc52,omitempty"` TermDurable *bool `json:"term:durable,omitempty"` WebZoom float64 `json:"web:zoom,omitempty"` diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index aead19efbe..ab10987fc9 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -29,11 +29,13 @@ "window:savelastwindow": true, "telemetry:enabled": true, "term:bellsound": false, - "term:bellindicator": false, + "term:bellindicator": true, + "term:osc52": "always", "term:cursor": "block", "term:cursorblink": false, "term:copyonselect": true, "term:durable": false, "waveai:showcloudmodes": true, - "waveai:defaultmode": "waveai@balanced" + "waveai:defaultmode": "waveai@balanced", + "preview:defaultsort": "name" } diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 52dfa4514c..084dab1793 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -56,6 +56,7 @@ const ( ConfigKey_TermCursorBlink = "term:cursorblink" ConfigKey_TermBellSound = "term:bellsound" ConfigKey_TermBellIndicator = "term:bellindicator" + ConfigKey_TermOsc52 = "term:osc52" ConfigKey_TermDurable = "term:durable" ConfigKey_EditorMinimapEnabled = "editor:minimapenabled" @@ -79,6 +80,7 @@ const ( ConfigKey_MarkdownFixedFontSize = "markdown:fixedfontsize" ConfigKey_PreviewShowHiddenFiles = "preview:showhiddenfiles" + ConfigKey_PreviewDefaultSort = "preview:defaultsort" ConfigKey_TabPreset = "tab:preset" ConfigKey_TabConfirmClose = "tab:confirmclose" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 387598e899..17aafa6685 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -107,6 +107,7 @@ type SettingsType struct { TermCursorBlink *bool `json:"term:cursorblink,omitempty"` TermBellSound *bool `json:"term:bellsound,omitempty"` TermBellIndicator *bool `json:"term:bellindicator,omitempty"` + TermOsc52 string `json:"term:osc52,omitempty" jsonschema:"enum=focus,enum=always"` TermDurable *bool `json:"term:durable,omitempty"` EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` @@ -129,7 +130,8 @@ type SettingsType struct { MarkdownFontSize float64 `json:"markdown:fontsize,omitempty"` MarkdownFixedFontSize float64 `json:"markdown:fixedfontsize,omitempty"` - PreviewShowHiddenFiles *bool `json:"preview:showhiddenfiles,omitempty"` + PreviewShowHiddenFiles *bool `json:"preview:showhiddenfiles,omitempty"` + PreviewDefaultSort string `json:"preview:defaultsort,omitempty" jsonschema:"enum=name,enum=modtime"` TabPreset string `json:"tab:preset,omitempty"` TabConfirmClose bool `json:"tab:confirmclose,omitempty"` diff --git a/pkg/wcore/badge.go b/pkg/wcore/badge.go new file mode 100644 index 0000000000..a60ecb8fc1 --- /dev/null +++ b/pkg/wcore/badge.go @@ -0,0 +1,152 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wcore + +import ( + "log" + "sync" + + "github.com/wavetermdev/waveterm/pkg/baseds" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +// BadgeStore is an in-memory store for transient badges. +// Badges are not persisted and are cleared on restart. +// Values are stored by value (not pointer) to prevent external mutation. +type BadgeStore struct { + lock *sync.Mutex + transient map[string]baseds.Badge // keyed by oref string +} + +var globalBadgeStore = &BadgeStore{ + lock: &sync.Mutex{}, + transient: make(map[string]baseds.Badge), +} + +// InitBadgeStore subscribes to incoming badge events. +func InitBadgeStore() error { + log.Printf("initializing badge store\n") + + rpcClient := wshclient.GetBareRpcClient() + rpcClient.EventListener.On(wps.Event_Badge, handleBadgeEvent) + wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{ + Event: wps.Event_Badge, + AllScopes: true, + }, nil) + + return nil +} + +func handleBadgeEvent(event *wps.WaveEvent) { + if event.Event != wps.Event_Badge { + return + } + var data baseds.BadgeEvent + err := utilfn.ReUnmarshal(&data, event.Data) + if err != nil { + log.Printf("badge store: error unmarshaling BadgeEvent: %v\n", err) + return + } + if data.ClearAll { + clearAllBadges() + return + } + if data.ORef == "" { + log.Printf("badge store: received badge event with empty oref\n") + return + } + oref, err := waveobj.ParseORef(data.ORef) + if err != nil { + log.Printf("badge store: error parsing oref %q: %v\n", data.ORef, err) + return + } + if oref.OType != waveobj.OType_Block && oref.OType != waveobj.OType_Tab { + log.Printf("badge store: can only handle block/tab orefs") + return + } + + setBadge(oref, data) +} + +// cmpBadge compares two badges by priority then by badgeid (both descending). +// Returns 1 if a > b, -1 if a < b, 0 if equal. +func cmpBadge(a, b baseds.Badge) int { + if a.Priority != b.Priority { + if a.Priority > b.Priority { + return 1 + } + return -1 + } + if a.BadgeId != b.BadgeId { + if a.BadgeId > b.BadgeId { + return 1 + } + return -1 + } + return 0 +} + +// setBadge updates the in-memory transient map. +func setBadge(oref waveobj.ORef, data baseds.BadgeEvent) { + globalBadgeStore.lock.Lock() + defer globalBadgeStore.lock.Unlock() + + orefStr := oref.String() + if orefStr == "" { + return + } + + if data.ClearById != "" { + existing, ok := globalBadgeStore.transient[orefStr] + if !ok || existing.BadgeId != data.ClearById { + return + } + delete(globalBadgeStore.transient, orefStr) + log.Printf("badge store: badge cleared by id: oref=%s id=%s\n", orefStr, data.ClearById) + return + } + if data.Clear { + delete(globalBadgeStore.transient, orefStr) + log.Printf("badge store: badge cleared: oref=%s\n", orefStr) + return + } + if data.Badge == nil { + return + } + incoming := *data.Badge + existing, hasExisting := globalBadgeStore.transient[orefStr] + if !hasExisting || cmpBadge(incoming, existing) > 0 { + globalBadgeStore.transient[orefStr] = incoming + log.Printf("badge store: badge set: oref=%s badge=%+v\n", orefStr, incoming) + } +} + +// clearAllBadges removes all badges from the transient store. +func clearAllBadges() { + globalBadgeStore.lock.Lock() + defer globalBadgeStore.lock.Unlock() + + count := len(globalBadgeStore.transient) + globalBadgeStore.transient = make(map[string]baseds.Badge) + log.Printf("badge store: cleared all %d badges\n", count) +} + +// GetAllBadges returns a snapshot of all currently active badges. +func GetAllBadges() []baseds.BadgeEvent { + globalBadgeStore.lock.Lock() + defer globalBadgeStore.lock.Unlock() + + result := make([]baseds.BadgeEvent, 0, len(globalBadgeStore.transient)) + for orefStr, badge := range globalBadgeStore.transient { + b := badge // copy + result = append(result, baseds.BadgeEvent{ + ORef: orefStr, + Badge: &b, + }) + } + return result +} diff --git a/pkg/wcore/tabindicator.go b/pkg/wcore/tabindicator.go deleted file mode 100644 index ba737b045a..0000000000 --- a/pkg/wcore/tabindicator.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package wcore - -import ( - "log" - "sync" - - "github.com/wavetermdev/waveterm/pkg/util/utilfn" - "github.com/wavetermdev/waveterm/pkg/wps" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -type TabIndicatorStore struct { - lock *sync.Mutex - indicators map[string]*wshrpc.TabIndicator -} - -var globalTabIndicatorStore = &TabIndicatorStore{ - lock: &sync.Mutex{}, - indicators: make(map[string]*wshrpc.TabIndicator), -} - -func InitTabIndicatorStore() { - log.Printf("initializing tab indicator store\n") - rpcClient := wshclient.GetBareRpcClient() - rpcClient.EventListener.On(wps.Event_TabIndicator, handleTabIndicatorEvent) - wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{ - Event: wps.Event_TabIndicator, - AllScopes: true, - }, nil) -} - -func handleTabIndicatorEvent(event *wps.WaveEvent) { - if event.Event != wps.Event_TabIndicator { - return - } - var data wshrpc.TabIndicatorEventData - err := utilfn.ReUnmarshal(&data, event.Data) - if err != nil { - log.Printf("error unmarshaling TabIndicatorEventData: %v\n", err) - return - } - setTabIndicator(data.TabId, data.Indicator) -} - -func setTabIndicator(tabId string, indicator *wshrpc.TabIndicator) { - globalTabIndicatorStore.lock.Lock() - defer globalTabIndicatorStore.lock.Unlock() - if indicator == nil { - delete(globalTabIndicatorStore.indicators, tabId) - log.Printf("tab indicator cleared: tabId=%s\n", tabId) - return - } - currentIndicator := globalTabIndicatorStore.indicators[tabId] - if currentIndicator == nil { - globalTabIndicatorStore.indicators[tabId] = indicator - log.Printf("tab indicator set: tabId=%s indicator=%v\n", tabId, indicator) - return - } - if indicator.Priority >= currentIndicator.Priority { - if indicator.ClearOnFocus && !currentIndicator.ClearOnFocus { - indicator.PersistentIndicator = currentIndicator - } - globalTabIndicatorStore.indicators[tabId] = indicator - log.Printf("tab indicator updated: tabId=%s indicator=%v\n", tabId, indicator) - } else { - log.Printf("tab indicator not updated (lower priority): tabId=%s currentPriority=%v newPriority=%v\n", tabId, currentIndicator.Priority, indicator.Priority) - } -} - -func GetTabIndicator(tabId string) *wshrpc.TabIndicator { - globalTabIndicatorStore.lock.Lock() - defer globalTabIndicatorStore.lock.Unlock() - return globalTabIndicatorStore.indicators[tabId] -} - -func GetAllTabIndicators() map[string]*wshrpc.TabIndicator { - globalTabIndicatorStore.lock.Lock() - defer globalTabIndicatorStore.lock.Unlock() - result := make(map[string]*wshrpc.TabIndicator) - for tabId, indicator := range globalTabIndicatorStore.indicators { - result[tabId] = indicator - } - return result -} diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index 352f05e2d4..0077ec9d5e 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -32,8 +32,8 @@ const ( Event_WaveAppAppGoUpdated = "waveapp:appgoupdated" // type: none Event_TsunamiUpdateMeta = "tsunami:updatemeta" // type: wshrpc.AppMeta Event_AIModeConfig = "waveai:modeconfig" // type: wconfig.AIModeConfigUpdate - Event_TabIndicator = "tab:indicator" // type: wshrpc.TabIndicatorEventData Event_BlockJobStatus = "block:jobstatus" // type: wshrpc.BlockJobStatusData + Event_Badge = "badge" // type: baseds.BadgeEvent ) var AllEvents []string = []string{ @@ -54,8 +54,8 @@ var AllEvents []string = []string{ Event_WaveAppAppGoUpdated, Event_TsunamiUpdateMeta, Event_AIModeConfig, - Event_TabIndicator, Event_BlockJobStatus, + Event_Badge, } type WaveEvent struct { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 56c738dcaf..110e1695ef 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -6,14 +6,15 @@ package wshclient import ( + "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" ) // command "activity", wshserver.ActivityCommand @@ -64,6 +65,12 @@ func AuthenticateTokenVerifyCommand(w *wshutil.WshRpc, data wshrpc.CommandAuthen return resp, err } +// command "badgewatchpid", wshserver.BadgeWatchPidCommand +func BadgeWatchPidCommand(w *wshutil.WshRpc, data wshrpc.CommandBadgeWatchPidData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "badgewatchpid", data, opts) + return err +} + // command "blockinfo", wshserver.BlockInfoCommand func BlockInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BlockInfoData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.BlockInfoData](w, "blockinfo", data, opts) @@ -386,9 +393,9 @@ func FocusWindowCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) er return err } -// command "getalltabindicators", wshserver.GetAllTabIndicatorsCommand -func GetAllTabIndicatorsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (map[string]*wshrpc.TabIndicator, error) { - resp, err := sendRpcRequestCallHelper[map[string]*wshrpc.TabIndicator](w, "getalltabindicators", nil, opts) +// command "getallbadges", wshserver.GetAllBadgesCommand +func GetAllBadgesCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]baseds.BadgeEvent, error) { + resp, err := sendRpcRequestCallHelper[[]baseds.BadgeEvent](w, "getallbadges", nil, opts) return resp, err } @@ -914,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/wshremote/wshremote.go b/pkg/wshrpc/wshremote/wshremote.go index 530c836263..35dc92d5bb 100644 --- a/pkg/wshrpc/wshremote/wshremote.go +++ b/pkg/wshrpc/wshremote/wshremote.go @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshremote @@ -12,10 +12,16 @@ import ( "os" "path/filepath" "sync" + "time" + "github.com/wavetermdev/waveterm/pkg/baseds" + "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/suggestion" + "github.com/wavetermdev/waveterm/pkg/util/unixutil" "github.com/wavetermdev/waveterm/pkg/wavebase" + "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" ) @@ -131,3 +137,39 @@ func (impl *ServerImpl) getWshPath() (string, error) { } return wshPath, nil } + +func (impl *ServerImpl) BadgeWatchPidCommand(ctx context.Context, data wshrpc.CommandBadgeWatchPidData) error { + if data.Pid <= 0 { + return fmt.Errorf("invalid pid: %d", data.Pid) + } + if data.ORef.IsEmpty() { + return fmt.Errorf("oref is required") + } + if data.BadgeId == "" { + return fmt.Errorf("badgeid is required") + } + go func() { + defer func() { + panichandler.PanicHandler("BadgeWatchPidCommand", recover()) + }() + for { + time.Sleep(time.Second) + if unixutil.IsPidRunning(data.Pid) { + continue + } + orefStr := data.ORef.String() + event := wps.WaveEvent{ + Event: wps.Event_Badge, + Scopes: []string{orefStr}, + Data: baseds.BadgeEvent{ + ORef: orefStr, + ClearById: data.BadgeId, + }, + } + wshclient.EventPublishCommand(impl.RpcClient, event, nil) + log.Printf("BadgeWatchPidCommand: pid %d gone, cleared badge %s for oref %s\n", data.Pid, data.BadgeId, orefStr) + return + } + }() + return nil +} diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index c734b076eb..8ddff8128b 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "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/vdom" "github.com/wavetermdev/waveterm/pkg/waveobj" @@ -93,7 +94,9 @@ 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) - GetAllTabIndicatorsCommand(ctx context.Context) (map[string]*TabIndicator, 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 ConnStatusCommand(ctx context.Context) ([]ConnStatus, error) @@ -123,6 +126,7 @@ type WshRpcInterface interface { RemoteReconnectToJobManagerCommand(ctx context.Context, data CommandRemoteReconnectToJobManagerData) (*CommandRemoteReconnectToJobManagerRtnData, error) RemoteDisconnectFromJobManagerCommand(ctx context.Context, data CommandRemoteDisconnectFromJobManagerData) error RemoteTerminateJobManagerCommand(ctx context.Context, data CommandRemoteTerminateJobManagerData) error + BadgeWatchPidCommand(ctx context.Context, data CommandBadgeWatchPidData) error // emain WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) @@ -874,17 +878,10 @@ type WaveFileInfo struct { Meta FileMeta `json:"meta"` } -type TabIndicator struct { - Icon string `json:"icon"` - Color string `json:"color,omitempty"` - Priority float64 `json:"priority"` - ClearOnFocus bool `json:"clearonfocus,omitempty"` - PersistentIndicator *TabIndicator `json:"persistentindicator,omitempty"` -} - -type TabIndicatorEventData struct { - TabId string `json:"tabid"` - Indicator *TabIndicator `json:"indicator"` +type CommandBadgeWatchPidData struct { + Pid int `json:"pid"` + ORef waveobj.ORef `json:"oref"` + BadgeId string `json:"badgeid"` } type BlockJobStatusData struct { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index bd49c091d0..670c949f2e 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -22,6 +22,7 @@ import ( "github.com/skratchdot/open-golang/open" "github.com/wavetermdev/waveterm/pkg/aiusechat" + "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/blockcontroller" @@ -159,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 @@ -1447,8 +1468,8 @@ func (ws *WshServer) GetTabCommand(ctx context.Context, tabId string) (*waveobj. return tab, nil } -func (ws *WshServer) GetAllTabIndicatorsCommand(ctx context.Context) (map[string]*wshrpc.TabIndicator, error) { - return wcore.GetAllTabIndicators(), nil +func (ws *WshServer) GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error) { + return wcore.GetAllBadges(), nil } func (ws *WshServer) GetSecretsCommand(ctx context.Context, names []string) (map[string]string, error) { 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 - }) -} diff --git a/schema/settings.json b/schema/settings.json index ad4cd83155..348c937dac 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -151,6 +151,13 @@ "term:bellindicator": { "type": "boolean" }, + "term:osc52": { + "type": "string", + "enum": [ + "focus", + "always" + ] + }, "term:durable": { "type": "boolean" }, @@ -205,6 +212,13 @@ "preview:showhiddenfiles": { "type": "boolean" }, + "preview:defaultsort": { + "type": "string", + "enum": [ + "name", + "modtime" + ] + }, "tab:preset": { "type": "string" }, diff --git a/schema/waveai.json b/schema/waveai.json index 8fc96c1528..7279777a4c 100644 --- a/schema/waveai.json +++ b/schema/waveai.json @@ -113,4 +113,4 @@ "$ref": "#/$defs/AIModeConfigType" }, "type": "object" -} +} \ No newline at end of file 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, diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index e033bfd2a6..1359f0f58b 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -64,6 +64,13 @@ func SendAsyncInitiation() error { return engine.GetDefaultClient().SendAsyncInitiation() } +func TermWrite(ref *vdom.VDomRef, data string) error { + if ref == nil || !ref.HasCurrent.Load() { + return nil + } + return engine.GetDefaultClient().SendTermWrite(ref.RefId, data) +} + func ConfigAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] { fullName := "$config." + name client := engine.GetDefaultClient() @@ -155,7 +162,7 @@ func DeepCopy[T any](v T) T { // If the ref is nil or not current, the operation is ignored. // This function must be called within a component context. func QueueRefOp(ref *vdom.VDomRef, op vdom.VDomRefOperation) { - if ref == nil || !ref.HasCurrent { + if ref == nil || !ref.HasCurrent.Load() { return } if op.RefId == "" { diff --git a/tsunami/app/hooks.go b/tsunami/app/hooks.go index 6b6ebbd36c..54418a00e0 100644 --- a/tsunami/app/hooks.go +++ b/tsunami/app/hooks.go @@ -31,6 +31,38 @@ func UseVDomRef() *vdom.VDomRef { return refVal } +// TermRef wraps a VDomRef and implements io.Writer by forwarding writes to the terminal. +type TermRef struct { + *vdom.VDomRef +} + +// Write implements io.Writer by sending data to the terminal via TermWrite. +func (tr *TermRef) Write(p []byte) (n int, err error) { + if tr.VDomRef == nil || !tr.VDomRef.HasCurrent.Load() { + return 0, fmt.Errorf("TermRef not current") + } + err = TermWrite(tr.VDomRef, string(p)) + if err != nil { + return 0, err + } + return len(p), nil +} + +// TermSize returns the current terminal size, or nil if not yet set. +func (tr *TermRef) TermSize() *vdom.VDomTermSize { + if tr.VDomRef == nil { + return nil + } + return tr.VDomRef.TermSize +} + +// UseTermRef returns a TermRef that can be passed as a ref to "wave:term" elements +// and also implements io.Writer for writing directly to the terminal. +func UseTermRef() *TermRef { + ref := UseVDomRef() + return &TermRef{VDomRef: ref} +} + // UseRef is the tsunami analog to React's useRef hook. // It provides a mutable ref object that persists across re-renders. // Unlike UseVDomRef, this is not tied to DOM elements but holds arbitrary values. diff --git a/tsunami/cmd/main-tsunami.go b/tsunami/cmd/main-tsunami.go index 6399aa6d52..f8b85f3e46 100644 --- a/tsunami/cmd/main-tsunami.go +++ b/tsunami/cmd/main-tsunami.go @@ -41,14 +41,22 @@ func validateEnvironmentVars(opts *build.BuildOpts) error { if scaffoldPath == "" { return fmt.Errorf("%s environment variable must be set", EnvTsunamiScaffoldPath) } + absScaffoldPath, err := filepath.Abs(scaffoldPath) + if err != nil { + return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiScaffoldPath, err) + } sdkReplacePath := os.Getenv(EnvTsunamiSdkReplacePath) if sdkReplacePath == "" { return fmt.Errorf("%s environment variable must be set", EnvTsunamiSdkReplacePath) } + absSdkReplacePath, err := filepath.Abs(sdkReplacePath) + if err != nil { + return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiSdkReplacePath, err) + } - opts.ScaffoldPath = scaffoldPath - opts.SdkReplacePath = sdkReplacePath + opts.ScaffoldPath = absScaffoldPath + opts.SdkReplacePath = absSdkReplacePath // NodePath is optional if nodePath := os.Getenv(EnvTsunamiNodePath); nodePath != "" { diff --git a/tsunami/engine/clientimpl.go b/tsunami/engine/clientimpl.go index 79c760e98b..ac9cb29109 100644 --- a/tsunami/engine/clientimpl.go +++ b/tsunami/engine/clientimpl.go @@ -5,6 +5,7 @@ package engine import ( "context" + "encoding/base64" "encoding/json" "fmt" "io/fs" @@ -304,6 +305,18 @@ func (c *ClientImpl) SendAsyncInitiation() error { return c.SendSSEvent(ssEvent{Event: "asyncinitiation", Data: nil}) } +func (c *ClientImpl) SendTermWrite(refId string, data string) error { + payload := rpctypes.TermWritePacket{ + RefId: refId, + Data64: base64.StdEncoding.EncodeToString([]byte(data)), + } + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + return c.SendSSEvent(ssEvent{Event: "termwrite", Data: jsonData}) +} + func makeNullRendered() *rpctypes.RenderedElem { return &rpctypes.RenderedElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} } diff --git a/tsunami/engine/render.go b/tsunami/engine/render.go index 29c39c9cb7..87750f8740 100644 --- a/tsunami/engine/render.go +++ b/tsunami/engine/render.go @@ -5,6 +5,7 @@ package engine import ( "fmt" + "log" "reflect" "unicode" @@ -247,12 +248,6 @@ func convertPropsToVDom(props map[string]any) map[string]any { vdomProps[k] = vdomFuncPtr continue } - if vdomRef, ok := v.(vdom.VDomRef); ok { - // ensure Type is set on all VDomRefs - vdomRef.Type = vdom.ObjectType_Ref - vdomProps[k] = vdomRef - continue - } if vdomRefPtr, ok := v.(*vdom.VDomRef); ok { if vdomRefPtr == nil { continue // handle typed-nil @@ -263,6 +258,10 @@ func convertPropsToVDom(props map[string]any) map[string]any { continue } val := reflect.ValueOf(v) + if val.Type() == reflect.TypeOf(vdom.VDomRef{}) { + log.Printf("warning: VDomRef passed as non-pointer for prop %q (VDomRef contains atomics and must be passed as *VDomRef); dropping prop\n", k) + continue + } if val.Kind() == reflect.Func { // convert go functions passed to event handlers to VDomFuncs vdomProps[k] = vdom.VDomFunc{Type: vdom.ObjectType_Func} diff --git a/tsunami/engine/rootelem.go b/tsunami/engine/rootelem.go index 1d8b93808e..787be044e0 100644 --- a/tsunami/engine/rootelem.go +++ b/tsunami/engine/rootelem.go @@ -443,9 +443,11 @@ func (r *RootElem) UpdateRef(updateRef rpctypes.VDomRefUpdate) { if !ok { return } - ref.HasCurrent = updateRef.HasCurrent + ref.HasCurrent.Store(updateRef.HasCurrent) ref.Position = updateRef.Position - r.addRenderWork(waveId) + if updateRef.TermSize != nil { + ref.TermSize = updateRef.TermSize + } } func (r *RootElem) QueueRefOp(op vdom.VDomRefOperation) { diff --git a/tsunami/engine/serverhandlers.go b/tsunami/engine/serverhandlers.go index 5c5325610c..1e7bc94fda 100644 --- a/tsunami/engine/serverhandlers.go +++ b/tsunami/engine/serverhandlers.go @@ -18,6 +18,7 @@ import ( "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" + "github.com/wavetermdev/waveterm/tsunami/vdom" ) const SSEKeepAliveDuration = 5 * time.Second @@ -83,6 +84,7 @@ func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) { mux.HandleFunc("/api/schemas", h.handleSchemas) mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile)) mux.HandleFunc("/api/modalresult", h.handleModalResult) + mux.HandleFunc("/api/terminput", h.handleTermInput) mux.HandleFunc("/dyn/", h.handleDynContent) // Add handler for static files at /static/ path @@ -392,6 +394,48 @@ func (h *httpHandlers) handleModalResult(w http.ResponseWriter, r *http.Request) json.NewEncoder(w).Encode(map[string]any{"success": true}) } +func (h *httpHandlers) handleTermInput(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleTermInput", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + setNoCacheHeaders(w) + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) + return + } + + var event vdom.VDomEvent + if err := json.Unmarshal(body, &event); err != nil { + http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest) + return + } + if strings.TrimSpace(event.WaveId) == "" { + http.Error(w, "waveid is required", http.StatusBadRequest) + return + } + if event.TermInput == nil { + http.Error(w, "terminput is required", http.StatusBadRequest) + return + } + + h.renderLock.Lock() + h.Client.Root.Event(event, h.Client.GlobalEventHandler) + h.renderLock.Unlock() + + w.WriteHeader(http.StatusNoContent) +} + func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleDynContent", recover()) diff --git a/tsunami/frontend/src/element/tsunamiterm.tsx b/tsunami/frontend/src/element/tsunamiterm.tsx new file mode 100644 index 0000000000..603d4b1889 --- /dev/null +++ b/tsunami/frontend/src/element/tsunamiterm.tsx @@ -0,0 +1,157 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import * as React from "react"; + +import { base64ToArray } from "@/util/base64"; + +export type TsunamiTermElem = HTMLDivElement & { + __termWrite: (data64: string) => void; + __termFocus: () => void; + __termSize: () => VDomTermSize | null; +}; + +type TsunamiTermProps = React.HTMLAttributes & { + onData?: (data: string | null, termsize: VDomTermSize | null) => void; + termFontSize?: number; + termFontFamily?: string; + termScrollback?: number; +}; + +const TsunamiTerm = React.forwardRef(function TsunamiTerm(props, ref) { + const { onData, termFontSize, termFontFamily, termScrollback, ...outerProps } = props; + const outerRef = React.useRef(null); + const termRef = React.useRef(null); + const terminalRef = React.useRef(null); + const onDataRef = React.useRef(onData); + onDataRef.current = onData; + + const setOuterRef = React.useCallback( + (elem: TsunamiTermElem) => { + outerRef.current = elem; + if (elem != null) { + elem.__termWrite = (data64: string) => { + if (data64 == null || data64 === "") { + return; + } + try { + terminalRef.current?.write(base64ToArray(data64)); + } catch (error) { + console.error("Failed to write to terminal:", error); + } + }; + elem.__termFocus = () => { + terminalRef.current?.focus(); + }; + elem.__termSize = () => { + const terminal = terminalRef.current; + if (terminal == null) { + return null; + } + return { rows: terminal.rows, cols: terminal.cols }; + }; + } + if (typeof ref === "function") { + ref(elem); + return; + } + if (ref != null) { + ref.current = elem; + } + }, + [ref] + ); + + React.useEffect(() => { + if (termRef.current == null) { + return; + } + const terminal = new Terminal({ + convertEol: false, + ...(termFontSize != null ? { fontSize: termFontSize } : {}), + ...(termFontFamily != null ? { fontFamily: termFontFamily } : {}), + ...(termScrollback != null ? { scrollback: termScrollback } : {}), + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(termRef.current); + fitAddon.fit(); + terminalRef.current = terminal; + + const onDataDisposable = terminal.onData((data) => { + if (onDataRef.current == null) { + return; + } + onDataRef.current(data, null); + }); + const onResizeDisposable = terminal.onResize((size) => { + if (onDataRef.current == null) { + return; + } + onDataRef.current(null, { rows: size.rows, cols: size.cols }); + }); + if (onDataRef.current != null) { + onDataRef.current(null, { rows: terminal.rows, cols: terminal.cols }); + } + + const resizeObserver = new ResizeObserver(() => { + fitAddon.fit(); + }); + if (outerRef.current != null) { + resizeObserver.observe(outerRef.current); + } + + return () => { + resizeObserver.disconnect(); + onResizeDisposable.dispose(); + onDataDisposable.dispose(); + terminal.dispose(); + terminalRef.current = null; + }; + }, []); + + React.useEffect(() => { + const terminal = terminalRef.current; + if (terminal == null) { + return; + } + if (termFontSize != null) { + terminal.options.fontSize = termFontSize; + } + if (termFontFamily != null) { + terminal.options.fontFamily = termFontFamily; + } + if (termScrollback != null) { + terminal.options.scrollback = termScrollback; + } + }, [termFontSize, termFontFamily, termScrollback]); + + const handleFocus = React.useCallback( + (e: React.FocusEvent) => { + terminalRef.current?.focus(); + outerProps.onFocus?.(e); + }, + [outerProps.onFocus] + ); + + const handleBlur = React.useCallback( + (e: React.FocusEvent) => { + terminalRef.current?.blur(); + outerProps.onBlur?.(e); + }, + [outerProps.onBlur] + ); + + return ( +
} + onFocus={handleFocus} + onBlur={handleBlur} + > +
+
+ ); +}); + +export { TsunamiTerm }; diff --git a/tsunami/frontend/src/model/model-utils.ts b/tsunami/frontend/src/model/model-utils.ts index 9ea4d92982..da14252a6e 100644 --- a/tsunami/frontend/src/model/model-utils.ts +++ b/tsunami/frontend/src/model/model-utils.ts @@ -1,6 +1,8 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import type { TsunamiTermElem } from "@/element/tsunamiterm"; + const TextTag = "#text"; // TODO support binding @@ -79,6 +81,22 @@ export function restoreVDomElems(backendUpdate: VDomBackendUpdate) { }); } +export function isTsunamiTermElem(elem: HTMLElement): elem is TsunamiTermElem { + return elem != null && typeof (elem as TsunamiTermElem).__termWrite === "function"; +} + +export function applyTermOp(elem: TsunamiTermElem, termOp: VDomRefOperation) { + const { op, params } = termOp; + if (op === "termwrite") { + const data64 = params?.[0]; + if (typeof data64 === "string" && data64 !== "") { + elem.__termWrite(data64); + } + } else if (op === "focus") { + elem.__termFocus(); + } +} + export function applyCanvasOp(canvas: HTMLCanvasElement, canvasOp: VDomRefOperation, refStore: Map) { const ctx = canvas.getContext("2d"); if (!ctx) { diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx index 61857dbebe..cc83104b81 100644 --- a/tsunami/frontend/src/model/tsunami-model.tsx +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -9,7 +9,7 @@ import { getOrCreateClientId } from "@/util/clientid"; import { adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { getDefaultStore } from "jotai"; -import { applyCanvasOp, restoreVDomElems } from "./model-utils"; +import { applyCanvasOp, applyTermOp, isTsunamiTermElem, restoreVDomElems } from "./model-utils"; const dlog = debug("wave:vdom"); @@ -236,6 +236,25 @@ export class TsunamiModel { } }); + this.serverEventSource.addEventListener("termwrite", (event: MessageEvent) => { + try { + const packet = JSON.parse(event.data); + if (packet?.refid == null || packet?.data64 == null) { + return; + } + const refOp: VDomRefOperation = { refid: packet.refid, op: "termwrite", params: [packet.data64] }; + const elem = this.getRefElem(refOp.refid); + if (elem == null) { + return; + } + if (isTsunamiTermElem(elem)) { + applyTermOp(elem, refOp); + } + } catch (e) { + console.error("Failed to parse termwrite event:", e); + } + }); + this.serverEventSource.addEventListener("error", (event) => { console.error("SSE connection error:", event); }); @@ -319,6 +338,12 @@ export class TsunamiModel { boundingclientrect: ref.elem.getBoundingClientRect(), }; } + if (isTsunamiTermElem(ref.elem)) { + const termsize = ref.elem.__termSize(); + if (termsize != null) { + ru.termsize = termsize; + } + } updates.push(ru); ref.updated = false; } @@ -606,6 +631,10 @@ export class TsunamiModel { applyCanvasOp(elem, refOp, this.refOutputStore); continue; } + if (isTsunamiTermElem(elem)) { + applyTermOp(elem, refOp); + continue; + } if (refOp.op == "focus") { if (elem == null) { this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`); @@ -718,8 +747,7 @@ export class TsunamiModel { vdomEvent.globaleventtype = fnDecl.globalevent; } const needsAsync = - propName == "onSubmit" || - (propName == "onChange" && (e.target as HTMLInputElement)?.type === "file"); + propName == "onSubmit" || (propName == "onChange" && (e.target as HTMLInputElement)?.type === "file"); if (needsAsync) { asyncAnnotateEvent(vdomEvent, propName, e) .then(() => { diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts index 485ada680b..2ca0f73867 100644 --- a/tsunami/frontend/src/types/vdom.d.ts +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -33,6 +33,18 @@ type VDomElem = { text?: string; }; +// vdom.VDomTermSize +type VDomTermSize = { + rows: number; + cols: number; +}; + +// vdom.VDomTermInputData +type VDomTermInputData = { + termsize?: VDomTermSize; + data?: string; +}; + // vdom.VDomEvent type VDomEvent = { waveid: string; @@ -46,6 +58,7 @@ type VDomEvent = { keydata?: VDomKeyboardEvent; mousedata?: VDomPointerData; formdata?: VDomFormData; + terminput?: VDomTermInputData; }; // vdom.VDomFrontendUpdate @@ -103,7 +116,6 @@ type VDomRef = { type: "ref"; refid: string; trackposition?: boolean; - position?: VDomRefPosition; hascurrent?: boolean; }; @@ -130,6 +142,7 @@ type VDomRefUpdate = { refid: string; hascurrent: boolean; position?: VDomRefPosition; + termsize?: VDomTermSize; }; // rpctypes.VDomRenderContext diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index 37de4c0f1c..a51e119193 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -7,8 +7,9 @@ import * as jotai from "jotai"; import * as React from "react"; import { twMerge } from "tailwind-merge"; -import { AlertModal, ConfirmModal } from "@/element/modals"; import { Markdown } from "@/element/markdown"; +import { AlertModal, ConfirmModal } from "@/element/modals"; +import { TsunamiTerm } from "@/element/tsunamiterm"; import { getTextChildren } from "@/model/model-utils"; import type { TsunamiModel } from "@/model/tsunami-model"; import { RechartsTag } from "@/recharts/recharts"; @@ -30,6 +31,7 @@ type VDomReactTagType = (props: { elem: VDomElem; model: TsunamiModel }) => Reac const WaveTagMap: Record = { "wave:markdown": WaveMarkdown, + "wave:term": WaveTerm, }; const AllowedSimpleTags: { [tagName: string]: boolean } = { @@ -278,6 +280,46 @@ function WaveMarkdown({ elem, model }: { elem: VDomElem; model: TsunamiModel }) ); } +async function sendTermInputEvent(event: VDomEvent) { + const response = await fetch("/api/terminput", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(event), + }); + if (!response.ok) { + throw new Error(`terminal input request failed: ${response.status} ${response.statusText}`); + } +} + +function WaveTerm({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { + const props = useVDom(model, elem); + const hasOnData = props.onData != null; + const onData = React.useCallback( + (data: string | null, termsize: VDomTermSize | null) => { + const terminput: VDomTermInputData = {}; + if (data != null) { + terminput.data = data; + } + if (termsize != null) { + terminput.termsize = termsize; + } + const event: VDomEvent = { + waveid: elem.waveid, + eventtype: "onData", + terminput: terminput, + }; + sendTermInputEvent(event).catch((error) => { + console.error("Failed to send terminal input:", error); + }); + }, + [elem.waveid] + ); + const termProps = { ...props, onData: hasOnData ? onData : undefined }; + return ; +} + function StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { const styleText = getTextChildren(elem); if (styleText == null) { diff --git a/tsunami/rpctypes/protocoltypes.go b/tsunami/rpctypes/protocoltypes.go index f2728f0bb6..bad88a8745 100644 --- a/tsunami/rpctypes/protocoltypes.go +++ b/tsunami/rpctypes/protocoltypes.go @@ -166,6 +166,7 @@ type VDomRefUpdate struct { RefId string `json:"refid"` HasCurrent bool `json:"hascurrent"` Position *vdom.VDomRefPosition `json:"position,omitempty"` + TermSize *vdom.VDomTermSize `json:"termsize,omitempty"` } type VDomBackendOpts struct { @@ -206,3 +207,8 @@ type ModalResult struct { ModalId string `json:"modalid"` // ID of the modal Confirm bool `json:"confirm"` // true = confirmed/ok, false = cancelled } + +type TermWritePacket struct { + RefId string `json:"refid"` + Data64 string `json:"data64"` +} diff --git a/tsunami/templates/package.json.tmpl b/tsunami/templates/package.json.tmpl index a214510649..c8d88dae83 100644 --- a/tsunami/templates/package.json.tmpl +++ b/tsunami/templates/package.json.tmpl @@ -10,7 +10,7 @@ "email": "info@commandline.dev" }, "dependencies": { - "@tailwindcss/cli": "^4.1.13", - "tailwindcss": "^4.1.13" + "@tailwindcss/cli": "^4.2.1", + "tailwindcss": "^4.2.1" } } diff --git a/tsunami/vdom/vdom_types.go b/tsunami/vdom/vdom_types.go index d20a02ac3d..58725e4010 100644 --- a/tsunami/vdom/vdom_types.go +++ b/tsunami/vdom/vdom_types.go @@ -1,8 +1,13 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package vdom +import ( + "encoding/json" + "sync/atomic" +) + const TextTag = "#text" const WaveTextTag = "wave:text" const WaveNullTag = "wave:null" @@ -36,8 +41,42 @@ type VDomRef struct { Type string `json:"type" tstype:"\"ref\""` RefId string `json:"refid"` TrackPosition bool `json:"trackposition,omitempty"` - Position *VDomRefPosition `json:"position,omitempty"` - HasCurrent bool `json:"hascurrent,omitempty"` + Position *VDomRefPosition `json:"-"` + HasCurrent atomic.Bool `json:"-"` + TermSize *VDomTermSize `json:"-"` +} + +func (r *VDomRef) MarshalJSON() ([]byte, error) { + type vdomRefAlias struct { + Type string `json:"type"` + RefId string `json:"refid"` + TrackPosition bool `json:"trackposition,omitempty"` + HasCurrent bool `json:"hascurrent,omitempty"` + } + return json.Marshal(vdomRefAlias{ + Type: r.Type, + RefId: r.RefId, + TrackPosition: r.TrackPosition, + HasCurrent: r.HasCurrent.Load(), + }) +} + +func (r *VDomRef) UnmarshalJSON(data []byte) error { + type vdomRefAlias struct { + Type string `json:"type"` + RefId string `json:"refid"` + TrackPosition bool `json:"trackposition,omitempty"` + HasCurrent bool `json:"hascurrent,omitempty"` + } + var alias vdomRefAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + r.Type = alias.Type + r.RefId = alias.RefId + r.TrackPosition = alias.TrackPosition + r.HasCurrent.Store(alias.HasCurrent) + return nil } type VDomSimpleRef[T any] struct { @@ -62,18 +101,29 @@ type VDomRefPosition struct { BoundingClientRect DomRect `json:"boundingclientrect"` } +type VDomTermInputData struct { + TermSize *VDomTermSize `json:"termsize,omitempty"` + Data string `json:"data,omitempty"` +} + +type VDomTermSize struct { + Rows int `json:"rows"` + Cols int `json:"cols"` +} + type VDomEvent struct { WaveId string `json:"waveid"` EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) GlobalEventType string `json:"globaleventtype,omitempty"` - TargetValue string `json:"targetvalue,omitempty"` // set for onChange events on input/textarea/select + TargetValue string `json:"targetvalue,omitempty"` // set for onChange events on input/textarea/select TargetChecked bool `json:"targetchecked,omitempty"` // set for onChange events on checkbox/radio inputs - TargetName string `json:"targetname,omitempty"` // target element's name attribute - TargetId string `json:"targetid,omitempty"` // target element's id attribute - TargetFiles []VDomFileData `json:"targetfiles,omitempty"` // set for onChange events on file inputs - KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` // set for onKeyDown events - MouseData *VDomPointerData `json:"mousedata,omitempty"` // set for onClick, onMouseDown, onMouseUp, onDoubleClick events - FormData *VDomFormData `json:"formdata,omitempty"` // set for onSubmit events on forms + TargetName string `json:"targetname,omitempty"` // target element's name attribute + TargetId string `json:"targetid,omitempty"` // target element's id attribute + TargetFiles []VDomFileData `json:"targetfiles,omitempty"` // set for onChange events on file inputs + KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` // set for onKeyDown events + MouseData *VDomPointerData `json:"mousedata,omitempty"` // set for onClick, onMouseDown, onMouseUp, onDoubleClick events + FormData *VDomFormData `json:"formdata,omitempty"` // set for onSubmit events on forms + TermInput *VDomTermInputData `json:"terminput,omitempty"` // set for onData events on wave:term elements } type VDomKeyboardEvent struct { @@ -115,13 +165,13 @@ type VDomPointerData struct { } type VDomFormData struct { - Action string `json:"action,omitempty"` - Method string `json:"method"` - Enctype string `json:"enctype"` - FormId string `json:"formid,omitempty"` - FormName string `json:"formname,omitempty"` - Fields map[string][]string `json:"fields"` - Files map[string][]VDomFileData `json:"files"` + Action string `json:"action,omitempty"` + Method string `json:"method"` + Enctype string `json:"enctype"` + FormId string `json:"formid,omitempty"` + FormName string `json:"formname,omitempty"` + Fields map[string][]string `json:"fields"` + Files map[string][]VDomFileData `json:"files"` } func (f *VDomFormData) GetField(fieldName string) string {