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/waveenv/SKILL.md b/.kilocode/skills/waveenv/SKILL.md index a78490f449..aabda6846d 100644 --- a/.kilocode/skills/waveenv/SKILL.md +++ b/.kilocode/skills/waveenv/SKILL.md @@ -69,6 +69,12 @@ export type MyEnv = WaveEnvSubset<{ // --- wos: always take the whole thing, no sub-typing needed --- wos: WaveEnv["wos"]; + // --- services: list only the services you call; no method-level narrowing --- + services: { + block: WaveEnv["services"]["block"]; + workspace: WaveEnv["services"]["workspace"]; + }; + // --- key-parameterized atom factories: enumerate the keys you use --- getSettingsKeyAtom: SettingsKeyAtomFnType<"app:focusfollowscursor" | "window:magnifiedblockopacity">; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"view" | "frame:title" | "connection">; @@ -80,6 +86,14 @@ export type MyEnv = WaveEnvSubset<{ }>; ``` +### Automatically Included Fields + +Every `WaveEnvSubset` automatically includes the mock fields — you never need to declare them: + +- `isMock: boolean` +- `mockSetWaveObj: (oref: string, obj: T) => void` +- `mockModels?: Map` + ### Rules for Each Section | Section | Pattern | Notes | @@ -88,6 +102,7 @@ export type MyEnv = WaveEnvSubset<{ | `rpc` | `rpc: { Cmd: WaveEnv["rpc"]["Cmd"]; }` | List every RPC command called; omit the rest. | | `atoms` | `atoms: { atom: WaveEnv["atoms"]["atom"]; }` | List every atom read; omit the rest. | | `wos` | `wos: WaveEnv["wos"]` | Take the whole `wos` object (no sub-typing needed), but **only add it if `wos` is actually used**. | +| `services` | `services: { svc: WaveEnv["services"]["svc"]; }` | List each service used; take the whole service object (no method-level narrowing). | | `getSettingsKeyAtom` | `SettingsKeyAtomFnType<"key1" \| "key2">` | Union all settings keys accessed. | | `getBlockMetaKeyAtom` | `BlockMetaKeyAtomFnType<"key1" \| "key2">` | Union all block meta keys accessed. | | `getConnConfigKeyAtom` | `ConnConfigKeyAtomFnType<"key1">` | Union all conn config keys accessed. | diff --git a/cmd/generatets/main-generatets.go b/cmd/generatets/main-generatets.go index 495c7d47f9..f282f9fa19 100644 --- a/cmd/generatets/main-generatets.go +++ b/cmd/generatets/main-generatets.go @@ -88,7 +88,14 @@ func generateServicesFile(tsTypesMap map[reflect.Type]string) error { fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") - fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n\n") + fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n") + fmt.Fprintf(&buf, "import type { WaveEnv } from \"@/app/waveenv/waveenv\";\n\n") + fmt.Fprintf(&buf, "function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise {\n") + fmt.Fprintf(&buf, " if (waveEnv != null) {\n") + fmt.Fprintf(&buf, " return waveEnv.callBackendService(service, method, args, noUIContext)\n") + fmt.Fprintf(&buf, " }\n") + fmt.Fprintf(&buf, " return WOS.callBackendService(service, method, args, noUIContext);\n") + fmt.Fprintf(&buf, "}\n\n") orderedKeys := utilfn.GetOrderedMapKeys(service.ServiceMap) for _, serviceName := range orderedKeys { serviceObj := service.ServiceMap[serviceName] @@ -96,6 +103,22 @@ func generateServicesFile(tsTypesMap map[reflect.Type]string) error { fmt.Fprint(&buf, svcStr) fmt.Fprint(&buf, "\n") } + fmt.Fprintf(&buf, "export const AllServiceTypes = {\n") + for _, serviceName := range orderedKeys { + serviceObj := service.ServiceMap[serviceName] + serviceType := reflect.TypeOf(serviceObj) + tsServiceName := serviceType.Elem().Name() + fmt.Fprintf(&buf, " %q: %sType,\n", serviceName, tsServiceName) + } + fmt.Fprintf(&buf, "};\n\n") + fmt.Fprintf(&buf, "export const AllServiceImpls = {\n") + for _, serviceName := range orderedKeys { + serviceObj := service.ServiceMap[serviceName] + serviceType := reflect.TypeOf(serviceObj) + tsServiceName := serviceType.Elem().Name() + fmt.Fprintf(&buf, " %q: %s,\n", serviceName, tsServiceName) + } + fmt.Fprintf(&buf, "};\n") written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) if !written { fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) diff --git a/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 cd29f8a7f4..8a8a6330a0 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -81,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 | @@ -154,7 +155,8 @@ For reference, this is the current default configuration (v0.14.0): "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 90c8d82147..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"; @@ -262,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/eslint.config.js b/eslint.config.js index 50fe7ef7c3..6e98b1d805 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -89,6 +89,7 @@ export default [ { files: ["frontend/app/store/services.ts"], rules: { + "@typescript-eslint/no-unused-vars": "off", "prefer-rest-params": "off", }, }, diff --git a/frontend/app/block/blockenv.ts b/frontend/app/block/blockenv.ts index b2df51192d..000228c014 100644 --- a/frontend/app/block/blockenv.ts +++ b/frontend/app/block/blockenv.ts @@ -16,6 +16,7 @@ export type BlockEnv = WaveEnvSubset<{ | "window:magnifiedblockblurprimarypx" | "window:magnifiedblockopacity" >; + showContextMenu: WaveEnv["showContextMenu"]; atoms: { modalOpen: WaveEnv["atoms"]["modalOpen"]; controlShiftDelayAtom: WaveEnv["atoms"]["controlShiftDelayAtom"]; diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index 252f1f8845..319e9b4a49 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -11,26 +11,26 @@ import { import { ConnectionButton } from "@/app/block/connectionbutton"; import { DurableSessionFlyover } from "@/app/block/durable-session-flyover"; import { getBlockBadgeAtom } from "@/app/store/badge"; -import { ContextMenuModel } from "@/app/store/contextmenu"; import { recordTEvent, refocusNode } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { uxCloseBlock } from "@/app/store/keymodel"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; -import { BlockEnv } from "./blockenv"; import { IconButton } from "@/element/iconbutton"; import { NodeModel } from "@/layout/index"; import * as util from "@/util/util"; 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(); @@ -59,7 +59,7 @@ function handleHeaderContextMenu( click: () => uxCloseBlock(blockId), } ); - ContextMenuModel.getInstance().showContextMenu(menu, e); + blockEnv.showContextMenu(menu, e); } type HeaderTextElemsProps = { @@ -113,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); @@ -128,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) { @@ -211,7 +212,7 @@ const BlockFrame_Header = ({ className={cn("block-frame-default-header", useTermHeader && "!pl-[2px]")} data-role="block-header" ref={dragHandleRef} - onContextMenu={(e) => handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel)} + onContextMenu={(e) => handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel, waveEnv)} > {!useTermHeader && ( <> diff --git a/frontend/app/element/streamdown.tsx b/frontend/app/element/streamdown.tsx index 6eddf976ae..2426f385e2 100644 --- a/frontend/app/element/streamdown.tsx +++ b/frontend/app/element/streamdown.tsx @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { CopyButton } from "@/app/element/copybutton"; @@ -314,11 +314,12 @@ export const WaveStreamdown = ({ table: false, mermaid: true, }} - mermaidConfig={{ - theme: "dark", - darkMode: true, + mermaid={{ + config: { + theme: "dark", + darkMode: true, + }, }} - defaultOrigin="http://localhost" components={components} > {text} diff --git a/frontend/app/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 0cea09ac75..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; @@ -132,6 +133,12 @@ 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)", }, ]; 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/store/badge.ts b/frontend/app/store/badge.ts index e3edb82103..745a2eb4da 100644 --- a/frontend/app/store/badge.ts +++ b/frontend/app/store/badge.ts @@ -3,6 +3,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { fireAndForget, NullAtom } from "@/util/util"; import { atom, Atom, PrimitiveAtom } from "jotai"; import { v7 as uuidv7, version as uuidVersion } from "uuid"; @@ -10,10 +11,34 @@ import { globalStore } from "./jotaiStore"; import * as WOS from "./wos"; import { waveEventSubscribeSingle } from "./wps"; +export type BadgeEnv = WaveEnvSubset<{ + rpc: { + EventPublishCommand: WaveEnv["rpc"]["EventPublishCommand"]; + }; +}>; + +export type LoadBadgesEnv = WaveEnvSubset<{ + rpc: { + GetAllBadgesCommand: WaveEnv["rpc"]["GetAllBadgesCommand"]; + }; +}>; + +export type TabBadgesEnv = WaveEnvSubset<{ + wos: WaveEnv["wos"]; +}>; + const BadgeMap = new Map>(); const TabBadgeAtomCache = new Map>(); -function clearBadgeInternal(oref: string) { +function publishBadgeEvent(eventData: WaveEvent, env?: BadgeEnv) { + if (env != null) { + fireAndForget(() => env.rpc.EventPublishCommand(TabRpcClient, eventData)); + } else { + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); + } +} + +function clearBadgeInternal(oref: string, env?: BadgeEnv) { const eventData: WaveEvent = { event: "badge", scopes: [oref], @@ -22,28 +47,28 @@ function clearBadgeInternal(oref: string) { clear: true, } as BadgeEvent, }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); + publishBadgeEvent(eventData, env); } -function clearBadgesForBlockOnFocus(blockId: string) { +function clearBadgesForBlockOnFocus(blockId: string, env?: BadgeEnv) { const oref = WOS.makeORef("block", blockId); const badgeAtom = BadgeMap.get(oref); const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null; if (badge != null && !badge.pidlinked) { - clearBadgeInternal(oref); + clearBadgeInternal(oref, env); } } -function clearBadgesForTabOnFocus(tabId: string) { +function clearBadgesForTabOnFocus(tabId: string, env?: BadgeEnv) { const oref = WOS.makeORef("tab", tabId); const badgeAtom = BadgeMap.get(oref); const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null; if (badge != null && !badge.pidlinked) { - clearBadgeInternal(oref); + clearBadgeInternal(oref, env); } } -function clearAllBadges() { +function clearAllBadges(env?: BadgeEnv) { const eventData: WaveEvent = { event: "badge", scopes: [], @@ -52,10 +77,10 @@ function clearAllBadges() { clearall: true, } as BadgeEvent, }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); + publishBadgeEvent(eventData, env); } -function clearBadgesForTab(tabId: string) { +function clearBadgesForTab(tabId: string, env?: BadgeEnv) { const tabAtom = WOS.getWaveObjectAtom(WOS.makeORef("tab", tabId)); const tab = globalStore.get(tabAtom); const blockIds = (tab as Tab)?.blockids ?? []; @@ -63,7 +88,7 @@ function clearBadgesForTab(tabId: string) { const oref = WOS.makeORef("block", blockId); const badgeAtom = BadgeMap.get(oref); if (badgeAtom != null && globalStore.get(badgeAtom) != null) { - clearBadgeInternal(oref); + clearBadgeInternal(oref, env); } } } @@ -88,7 +113,7 @@ function getBlockBadgeAtom(blockId: string): Atom { return getBadgeAtom(oref); } -function getTabBadgeAtom(tabId: string): Atom { +function getTabBadgeAtom(tabId: string, env?: TabBadgesEnv): Atom { if (tabId == null) { return NullAtom as Atom; } @@ -98,7 +123,7 @@ function getTabBadgeAtom(tabId: string): Atom { } const tabOref = WOS.makeORef("tab", tabId); const tabBadgeAtom = getBadgeAtom(tabOref); - const tabAtom = atom((get) => WOS.getObjectValue(tabOref, get)); + const tabAtom = env != null ? env.wos.getWaveObjectAtom(tabOref) : WOS.getWaveObjectAtom(tabOref); rtn = atom((get) => { const tab = get(tabAtom); const blockIds = tab?.blockids ?? []; @@ -119,8 +144,9 @@ function getTabBadgeAtom(tabId: string): Atom { return rtn; } -async function loadBadges() { - const badges = await RpcApi.GetAllBadgesCommand(TabRpcClient); +async function loadBadges(env?: LoadBadgesEnv) { + const rpc = env != null ? env.rpc : RpcApi; + const badges = await rpc.GetAllBadgesCommand(TabRpcClient); if (badges == null) { return; } @@ -133,7 +159,7 @@ async function loadBadges() { } } -function setBadge(blockId: string, badge: Omit & { badgeid?: string }) { +function setBadge(blockId: string, badge: Omit & { badgeid?: string }, env?: BadgeEnv) { if (!badge.badgeid) { badge = { ...badge, badgeid: uuidv7() }; } else if (uuidVersion(badge.badgeid) !== 7) { @@ -148,10 +174,10 @@ function setBadge(blockId: string, badge: Omit & { badgeid?: s badge: badge, } as BadgeEvent, }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); + publishBadgeEvent(eventData, env); } -function clearBadgeById(blockId: string, badgeId: string) { +function clearBadgeById(blockId: string, badgeId: string, env?: BadgeEnv) { const oref = WOS.makeORef("block", blockId); const eventData: WaveEvent = { event: "badge", @@ -161,7 +187,7 @@ function clearBadgeById(blockId: string, badgeId: string) { clearbyid: badgeId, } as BadgeEvent, }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); + publishBadgeEvent(eventData, env); } function setupBadgesSubscription() { @@ -186,18 +212,33 @@ function setupBadgesSubscription() { } return; } - globalStore.set(curAtom, data.clear ? null : (data.badge ?? null)); + 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) => { - if (a.priority !== b.priority) { - return b.priority - a.priority; - } - return b.badgeid < a.badgeid ? -1 : b.badgeid > a.badgeid ? 1 : 0; - }); + return [...badges].sort((a, b) => cmpBadge(b, a)); } function sortBadgesForTab(badges: Badge[]): Badge[] { diff --git a/frontend/app/store/contextmenu.ts b/frontend/app/store/contextmenu.ts index 89e72c5613..fdad72bd88 100644 --- a/frontend/app/store/contextmenu.ts +++ b/frontend/app/store/contextmenu.ts @@ -74,11 +74,11 @@ class ContextMenuModel { this.activeOpts = opts; const electronMenuItems = this._convertAndRegisterMenu(menu); - const workspace = globalStore.get(atoms.workspace); + const workspaceId = globalStore.get(atoms.workspaceId); let oid: string; - if (workspace != null) { - oid = workspace.oid; + if (workspaceId != null) { + oid = workspaceId; } else { oid = globalStore.get(atoms.builderId); } diff --git a/frontend/app/store/global-atoms.ts b/frontend/app/store/global-atoms.ts index 6d24666ff0..01fe12800e 100644 --- a/frontend/app/store/global-atoms.ts +++ b/frontend/app/store/global-atoms.ts @@ -43,12 +43,16 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { console.log("failed to initialize zoomFactorAtom", e); } - const workspaceAtom: Atom = atom((get) => { + const workspaceIdAtom: Atom = atom((get) => { const windowData = WOS.getObjectValue(WOS.makeORef("window", get(windowIdAtom)), get); - if (windowData == null) { + return windowData?.workspaceid ?? null; + }); + const workspaceAtom: Atom = atom((get) => { + const workspaceId = get(workspaceIdAtom); + if (workspaceId == null) { return null; } - return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get); + return WOS.getObjectValue(WOS.makeORef("workspace", workspaceId), get); }); const fullConfigAtom = atom(null) as PrimitiveAtom; const waveaiModeConfigAtom = atom(null) as PrimitiveAtom>; @@ -67,6 +71,10 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { } return false; }) as Atom; + const hasConfigErrors = atom((get) => { + const fullConfig = get(fullConfigAtom); + return fullConfig?.configerrors != null && fullConfig.configerrors.length > 0; + }) as Atom; // this is *the* tab that this tabview represents. it should never change. const staticTabIdAtom: Atom = atom(initOpts.tabId); const controlShiftDelayAtom = atom(false); @@ -123,11 +131,13 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { builderId: builderIdAtom, builderAppId: builderAppIdAtom, uiContext: uiContextAtom, + workspaceId: workspaceIdAtom, workspace: workspaceAtom, fullConfigAtom, waveaiModeConfigAtom, settingsAtom, hasCustomAIPresetsAtom, + hasConfigErrors, staticTabId: staticTabIdAtom, isFullScreen: isFullScreenAtom, zoomFactorAtom, diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index eea579b8ce..01d4ebbc96 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -547,6 +547,7 @@ function getAllBlockComponentModels(): BlockComponentModel[] { function getFocusedBlockId(): string { const layoutModel = getLayoutModelForStaticTab(); + if (layoutModel?.focusedNode == null) return null; const focusedLayoutNode = globalStore.get(layoutModel.focusedNode); return focusedLayoutNode?.data?.blockId; } diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 97095d4b04..afa5209116 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveAIModel } from "@/app/aipanel/waveai-model"; @@ -129,11 +129,11 @@ function getStaticTabBlockCount(): number { } function simpleCloseStaticTab() { - const ws = globalStore.get(atoms.workspace); + const workspaceId = globalStore.get(atoms.workspaceId); const tabId = globalStore.get(atoms.staticTabId); const confirmClose = globalStore.get(getSettingsKeyAtom("tab:confirmclose")) ?? false; getApi() - .closeTab(ws.oid, tabId, confirmClose) + .closeTab(workspaceId, tabId, confirmClose) .then((didClose) => { if (didClose) { deleteLayoutModelForTab(tabId); @@ -490,7 +490,7 @@ function tryReinjectKey(event: WaveKeyboardEvent): boolean { function countTermBlocks(): number { const allBCMs = getAllBlockComponentModels(); let count = 0; - let gsGetBound = globalStore.get.bind(globalStore); + const gsGetBound = globalStore.get.bind(globalStore); for (const bcm of allBCMs) { const viewModel = bcm.viewModel; if (viewModel.viewType == "term" && viewModel.isBasicTerm?.(gsGetBound)) { diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index f261f7e37b..3dad2a3e5c 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -4,182 +4,233 @@ // generated by cmd/generate/main-generatets.go import * as WOS from "./wos"; +import type { WaveEnv } from "@/app/waveenv/waveenv"; + +function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise { + if (waveEnv != null) { + return waveEnv.callBackendService(service, method, args, noUIContext) + } + return WOS.callBackendService(service, method, args, noUIContext); +} // blockservice.BlockService (block) -class BlockServiceType { +export class BlockServiceType { + waveEnv: WaveEnv; + + constructor(waveEnv?: WaveEnv) { + this.waveEnv = waveEnv; + } + // queue a layout action to cleanup orphaned blocks in the tab // @returns object updates CleanupOrphanedBlocks(tabId: string): Promise { - return WOS.callBackendService("block", "CleanupOrphanedBlocks", Array.from(arguments)) + return callBackendService(this.waveEnv, "block", "CleanupOrphanedBlocks", Array.from(arguments)) } GetControllerStatus(arg2: string): Promise { - return WOS.callBackendService("block", "GetControllerStatus", Array.from(arguments)) + return callBackendService(this.waveEnv, "block", "GetControllerStatus", Array.from(arguments)) } // save the terminal state to a blockfile SaveTerminalState(blockId: string, state: string, stateType: string, ptyOffset: number, termSize: TermSize): Promise { - return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments)) + return callBackendService(this.waveEnv, "block", "SaveTerminalState", Array.from(arguments)) } SaveWaveAiData(arg2: string, arg3: WaveAIPromptMessageType[]): Promise { - return WOS.callBackendService("block", "SaveWaveAiData", Array.from(arguments)) + return callBackendService(this.waveEnv, "block", "SaveWaveAiData", Array.from(arguments)) } } export const BlockService = new BlockServiceType(); // clientservice.ClientService (client) -class ClientServiceType { +export class ClientServiceType { + waveEnv: WaveEnv; + + constructor(waveEnv?: WaveEnv) { + this.waveEnv = waveEnv; + } + // @returns object updates AgreeTos(): Promise { - return WOS.callBackendService("client", "AgreeTos", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "AgreeTos", Array.from(arguments)) } FocusWindow(arg2: string): Promise { - return WOS.callBackendService("client", "FocusWindow", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "FocusWindow", Array.from(arguments)) } GetAllConnStatus(): Promise { - return WOS.callBackendService("client", "GetAllConnStatus", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "GetAllConnStatus", Array.from(arguments)) } GetClientData(): Promise { - return WOS.callBackendService("client", "GetClientData", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "GetClientData", Array.from(arguments)) } GetTab(arg1: string): Promise { - return WOS.callBackendService("client", "GetTab", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "GetTab", Array.from(arguments)) } TelemetryUpdate(arg2: boolean): Promise { - return WOS.callBackendService("client", "TelemetryUpdate", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "TelemetryUpdate", Array.from(arguments)) } } export const ClientService = new ClientServiceType(); // objectservice.ObjectService (object) -class ObjectServiceType { +export class ObjectServiceType { + waveEnv: WaveEnv; + + constructor(waveEnv?: WaveEnv) { + this.waveEnv = waveEnv; + } + // @returns blockId (and object updates) CreateBlock(blockDef: BlockDef, rtOpts: RuntimeOpts): Promise { - return WOS.callBackendService("object", "CreateBlock", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "CreateBlock", Array.from(arguments)) } // @returns object updates DeleteBlock(blockId: string): Promise { - return WOS.callBackendService("object", "DeleteBlock", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "DeleteBlock", Array.from(arguments)) } // get wave object by oref GetObject(oref: string): Promise { - return WOS.callBackendService("object", "GetObject", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "GetObject", Array.from(arguments)) } // @returns objects GetObjects(orefs: string[]): Promise { - return WOS.callBackendService("object", "GetObjects", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "GetObjects", Array.from(arguments)) } // @returns object updates UpdateObject(waveObj: WaveObj, returnUpdates: boolean): Promise { - return WOS.callBackendService("object", "UpdateObject", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "UpdateObject", Array.from(arguments)) } // @returns object updates UpdateObjectMeta(oref: string, meta: MetaType): Promise { - return WOS.callBackendService("object", "UpdateObjectMeta", Array.from(arguments)) - } - - // @returns object updates - UpdateTabName(tabId: string, name: string): Promise { - return WOS.callBackendService("object", "UpdateTabName", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "UpdateObjectMeta", Array.from(arguments)) } } export const ObjectService = new ObjectServiceType(); // userinputservice.UserInputService (userinput) -class UserInputServiceType { +export class UserInputServiceType { + waveEnv: WaveEnv; + + constructor(waveEnv?: WaveEnv) { + this.waveEnv = waveEnv; + } + SendUserInputResponse(arg1: UserInputResponse): Promise { - return WOS.callBackendService("userinput", "SendUserInputResponse", Array.from(arguments)) + return callBackendService(this.waveEnv, "userinput", "SendUserInputResponse", Array.from(arguments)) } } export const UserInputService = new UserInputServiceType(); // windowservice.WindowService (window) -class WindowServiceType { +export class WindowServiceType { + waveEnv: WaveEnv; + + constructor(waveEnv?: WaveEnv) { + this.waveEnv = waveEnv; + } + CloseWindow(windowId: string, fromElectron: boolean): Promise { - return WOS.callBackendService("window", "CloseWindow", Array.from(arguments)) + return callBackendService(this.waveEnv, "window", "CloseWindow", Array.from(arguments)) } CreateWindow(winSize: WinSize, workspaceId: string): Promise { - return WOS.callBackendService("window", "CreateWindow", Array.from(arguments)) + return callBackendService(this.waveEnv, "window", "CreateWindow", Array.from(arguments)) } GetWindow(windowId: string): Promise { - return WOS.callBackendService("window", "GetWindow", Array.from(arguments)) + return callBackendService(this.waveEnv, "window", "GetWindow", Array.from(arguments)) } // set window position and size // @returns object updates SetWindowPosAndSize(windowId: string, pos: Point, size: WinSize): Promise { - return WOS.callBackendService("window", "SetWindowPosAndSize", Array.from(arguments)) + return callBackendService(this.waveEnv, "window", "SetWindowPosAndSize", Array.from(arguments)) } SwitchWorkspace(windowId: string, workspaceId: string): Promise { - return WOS.callBackendService("window", "SwitchWorkspace", Array.from(arguments)) + return callBackendService(this.waveEnv, "window", "SwitchWorkspace", Array.from(arguments)) } } export const WindowService = new WindowServiceType(); // workspaceservice.WorkspaceService (workspace) -class WorkspaceServiceType { +export class WorkspaceServiceType { + waveEnv: WaveEnv; + + constructor(waveEnv?: WaveEnv) { + this.waveEnv = waveEnv; + } + // @returns CloseTabRtn (and object updates) CloseTab(workspaceId: string, tabId: string, fromElectron: boolean): Promise { - return WOS.callBackendService("workspace", "CloseTab", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "CloseTab", Array.from(arguments)) } // @returns tabId (and object updates) CreateTab(workspaceId: string, tabName: string, activateTab: boolean): Promise { - return WOS.callBackendService("workspace", "CreateTab", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "CreateTab", Array.from(arguments)) } // @returns workspaceId CreateWorkspace(name: string, icon: string, color: string, applyDefaults: boolean): Promise { - return WOS.callBackendService("workspace", "CreateWorkspace", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "CreateWorkspace", Array.from(arguments)) } // @returns object updates DeleteWorkspace(workspaceId: string): Promise { - return WOS.callBackendService("workspace", "DeleteWorkspace", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "DeleteWorkspace", Array.from(arguments)) } // @returns colors GetColors(): Promise { - return WOS.callBackendService("workspace", "GetColors", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "GetColors", Array.from(arguments)) } // @returns icons GetIcons(): Promise { - return WOS.callBackendService("workspace", "GetIcons", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "GetIcons", Array.from(arguments)) } // @returns workspace GetWorkspace(workspaceId: string): Promise { - return WOS.callBackendService("workspace", "GetWorkspace", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "GetWorkspace", Array.from(arguments)) } ListWorkspaces(): Promise { - return WOS.callBackendService("workspace", "ListWorkspaces", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "ListWorkspaces", Array.from(arguments)) } // @returns object updates SetActiveTab(workspaceId: string, tabId: string): Promise { - return WOS.callBackendService("workspace", "SetActiveTab", Array.from(arguments)) - } - - // @returns object updates - UpdateTabIds(workspaceId: string, tabIds: string[]): Promise { - return WOS.callBackendService("workspace", "UpdateTabIds", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "SetActiveTab", Array.from(arguments)) } // @returns object updates UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, applyDefaults: boolean): Promise { - return WOS.callBackendService("workspace", "UpdateWorkspace", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "UpdateWorkspace", Array.from(arguments)) } } export const WorkspaceService = new WorkspaceServiceType(); +export const AllServiceTypes = { + "block": BlockServiceType, + "client": ClientServiceType, + "object": ObjectServiceType, + "userinput": UserInputServiceType, + "window": WindowServiceType, + "workspace": WorkspaceServiceType, +}; + +export const AllServiceImpls = { + "block": BlockService, + "client": ClientService, + "object": ObjectService, + "userinput": UserInputService, + "window": WindowService, + "workspace": WorkspaceService, +}; diff --git a/frontend/app/store/tab-model.ts b/frontend/app/store/tab-model.ts index 6c41e2fd84..a867440820 100644 --- a/frontend/app/store/tab-model.ts +++ b/frontend/app/store/tab-model.ts @@ -1,24 +1,28 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { useWaveEnv, WaveEnv } from "@/app/waveenv/waveenv"; +import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { atom, Atom, PrimitiveAtom } from "jotai"; import { createContext, useContext } from "react"; import { globalStore } from "./jotaiStore"; import * as WOS from "./wos"; +export type TabModelEnv = WaveEnvSubset<{ + wos: WaveEnv["wos"]; +}>; + const tabModelCache = new Map(); export const activeTabIdAtom = atom(null) as PrimitiveAtom; export class TabModel { tabId: string; - waveEnv: WaveEnv; + waveEnv: TabModelEnv; tabAtom: Atom; tabNumBlocksAtom: Atom; isTermMultiInput = atom(false) as PrimitiveAtom; metaCache: Map> = new Map(); - constructor(tabId: string, waveEnv?: WaveEnv) { + constructor(tabId: string, waveEnv?: TabModelEnv) { this.tabId = tabId; this.waveEnv = waveEnv; this.tabAtom = atom((get) => { @@ -46,16 +50,25 @@ export class TabModel { } } -export function getTabModelByTabId(tabId: string, waveEnv?: WaveEnv): TabModel { - let model = tabModelCache.get(tabId); +export function getTabModelByTabId(tabId: string, waveEnv?: TabModelEnv): TabModel { + if (!waveEnv?.isMock) { + let model = tabModelCache.get(tabId); + if (model == null) { + model = new TabModel(tabId, waveEnv); + tabModelCache.set(tabId, model); + } + return model; + } + const key = `TabModel:${tabId}`; + let model = waveEnv.mockModels.get(key); if (model == null) { model = new TabModel(tabId, waveEnv); - tabModelCache.set(tabId, model); + waveEnv.mockModels.set(key, model); } return model; } -export function getActiveTabModel(waveEnv?: WaveEnv): TabModel | null { +export function getActiveTabModel(waveEnv?: TabModelEnv): TabModel | null { const activeTabId = globalStore.get(activeTabIdAtom); if (activeTabId == null) { return null; @@ -66,11 +79,7 @@ export function getActiveTabModel(waveEnv?: WaveEnv): TabModel | null { export const TabModelContext = createContext(undefined); export function useTabModel(): TabModel { - const waveEnv = useWaveEnv(); const ctxModel = useContext(TabModelContext); - if (waveEnv?.mockTabModel != null) { - return waveEnv.mockTabModel; - } if (ctxModel == null) { throw new Error("useTabModel must be used within a TabModelProvider"); } @@ -78,10 +87,5 @@ export function useTabModel(): TabModel { } export function useTabModelMaybe(): TabModel { - const waveEnv = useWaveEnv(); - const ctxModel = useContext(TabModelContext); - if (waveEnv?.mockTabModel != null) { - return waveEnv.mockTabModel; - } - return ctxModel; + return useContext(TabModelContext); } diff --git a/frontend/app/store/wps.ts b/frontend/app/store/wps.ts index 745734123c..332d2ba0a9 100644 --- a/frontend/app/store/wps.ts +++ b/frontend/app/store/wps.ts @@ -1,8 +1,9 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; +import { isPreviewWindow } from "@/app/store/windowtype"; import { isBlank } from "@/util/util"; import { Subject } from "rxjs"; @@ -43,6 +44,9 @@ function wpsReconnectHandler() { } function updateWaveEventSub(eventType: string) { + if (isPreviewWindow()) { + return; + } const subjects = waveEventSubjects.get(eventType); if (subjects == null) { RpcApi.EventUnsubCommand(WpsRpcClient, eventType, { noresponse: true }); @@ -84,7 +88,7 @@ function waveEventSubscribeSingle(subscription: WaveEve function waveEventUnsubscribe(...unsubscribes: WaveEventUnsubscribe[]) { const eventTypeSet = new Set(); for (const unsubscribe of unsubscribes) { - let subjects = waveEventSubjects.get(unsubscribe.eventType); + const subjects = waveEventSubjects.get(unsubscribe.eventType); if (subjects == null) { return; } diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 377396c5a0..6b9f4a72d4 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -930,6 +930,18 @@ export class RpcApiType { return client.wshRpcCall("testmultiarg", { args: [arg1, arg2, arg3] }, opts); } + // command "updatetabname" [call] + UpdateTabNameCommand(client: WshClient, arg1: string, arg2: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updatetabname", { args: [arg1, arg2] }, opts); + return client.wshRpcCall("updatetabname", { args: [arg1, arg2] }, opts); + } + + // command "updateworkspacetabids" [call] + UpdateWorkspaceTabIdsCommand(client: WshClient, arg1: string, arg2: string[], opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updateworkspacetabids", { args: [arg1, arg2] }, opts); + return client.wshRpcCall("updateworkspacetabids", { args: [arg1, arg2] }, opts); + } + // command "vdomasyncinitiation" [call] VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "vdomasyncinitiation", data, opts); diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 01a13bf13e..6b3679bb37 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -1,22 +1,33 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { getTabBadgeAtom, sortBadgesForTab } from "@/app/store/badge"; -import { atoms, getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global"; -import { RpcApi } from "@/app/store/wshclientapi"; +import { 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 { validateCssColor } from "@/util/color-validator"; -import { fireAndForget, makeIconClass } from "@/util/util"; +import { fireAndForget } from "@/util/util"; import clsx from "clsx"; import { useAtomValue } from "jotai"; -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; -import { v7 as uuidv7 } from "uuid"; -import { ObjectService } from "../store/services"; -import { makeORef, useWaveObjectValue } from "../store/wos"; +import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; +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; @@ -36,47 +47,6 @@ interface TabVProps { renameRef?: React.RefObject<(() => void) | null>; } -interface TabBadgesProps { - badges?: Badge[] | null; - flagColor?: string | null; -} - -function TabBadges({ badges, flagColor }: TabBadgesProps) { - const flagBadgeId = useMemo(() => uuidv7(), []); - const allBadges = useMemo(() => { - const base = badges ?? []; - if (!flagColor) { - return base; - } - const flagBadge: Badge = { icon: "flag", color: flagColor, priority: 0, badgeid: flagBadgeId }; - return sortBadgesForTab([...base, flagBadge]); - }, [badges, flagColor, flagBadgeId]); - if (!allBadges[0]) { - return null; - } - const firstBadge = allBadges[0]; - const extraBadges = allBadges.slice(1, 3); - return ( -
- - {extraBadges.length > 0 && ( -
- {extraBadges.map((badge, idx) => ( -
- ))} -
- )} -
- ); -} - const TabV = forwardRef((props, ref) => { const { tabId, @@ -95,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); @@ -105,7 +78,7 @@ const TabV = forwardRef((props, ref) => { useImperativeHandle(ref, () => tabRef.current as HTMLDivElement); useEffect(() => { - setOriginalName(tabName); + setOriginalName(truncateTabName(tabName)); }, [tabName]); useEffect(() => { @@ -181,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(); + } } }; @@ -222,7 +198,7 @@ const TabV = forwardRef((props, ref) => { onKeyDown={handleKeyDown} suppressContentEditableWarning={true} > - {tabName} + {displayName}
+ + ); }; @@ -150,6 +153,7 @@ function strArrayIsEqual(a: string[], b: string[]) { } const TabBar = memo(({ workspace }: TabBarProps) => { + const env = useWaveEnv(); const [tabIds, setTabIds] = useState([]); const [dragStartPositions, setDragStartPositions] = useState([]); const [draggingTab, setDraggingTab] = useState(); @@ -174,19 +178,21 @@ const TabBar = memo(({ workspace }: TabBarProps) => { }); const osInstanceRef = useRef(null); const draggerLeftRef = useRef(null); - const draggerRightRef = useRef(null); + const rightContainerRef = useRef(null); const workspaceSwitcherRef = useRef(null); const waveAIButtonRef = useRef(null); const appMenuButtonRef = useRef(null); const tabWidthRef = useRef(TabDefaultWidth); const scrollableRef = useRef(false); - const updateStatusBannerRef = useRef(null); - const configErrorButtonRef = useRef(null); const prevAllLoadedRef = useRef(false); - const activeTabId = useAtomValue(atoms.staticTabId); - const isFullScreen = useAtomValue(atoms.isFullScreen); - const zoomFactor = useAtomValue(atoms.zoomFactorAtom); - const settings = useAtomValue(atoms.settingsAtom); + const activeTabId = useAtomValue(env.atoms.staticTabId); + const isFullScreen = useAtomValue(env.atoms.isFullScreen); + const zoomFactor = useAtomValue(env.atoms.zoomFactorAtom); + const showMenuBar = useAtomValue(env.getSettingsKeyAtom("window:showmenubar")); + const confirmClose = useAtomValue(env.getSettingsKeyAtom("tab:confirmclose")) ?? false; + const hideAiButton = useAtomValue(env.getSettingsKeyAtom("app:hideaibutton")); + const appUpdateStatus = useAtomValue(env.atoms.updaterStatusAtom); + const hasConfigErrors = useAtomValue(env.atoms.hasConfigErrors); let prevDelta: number; let prevDragDirection: string; @@ -230,22 +236,24 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const tabBar = tabBarRef.current; if (tabBar === null) return; + const getOuterWidth = (el: HTMLElement): number => { + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + return rect.width + parseFloat(style.marginLeft) + parseFloat(style.marginRight); + }; + const tabbarWrapperWidth = tabbarWrapperRef.current.getBoundingClientRect().width; const windowDragLeftWidth = draggerLeftRef.current.getBoundingClientRect().width; - const windowDragRightWidth = draggerRightRef.current?.getBoundingClientRect().width ?? 0; - const addBtnWidth = addBtnRef.current.getBoundingClientRect().width; - const updateStatusLabelWidth = updateStatusBannerRef.current?.getBoundingClientRect().width ?? 0; - const configErrorWidth = configErrorButtonRef.current?.getBoundingClientRect().width ?? 0; + const rightContainerWidth = rightContainerRef.current?.getBoundingClientRect().width ?? 0; + const addBtnWidth = getOuterWidth(addBtnRef.current); const appMenuButtonWidth = appMenuButtonRef.current?.getBoundingClientRect().width ?? 0; const workspaceSwitcherWidth = workspaceSwitcherRef.current?.getBoundingClientRect().width ?? 0; - const waveAIButtonWidth = waveAIButtonRef.current?.getBoundingClientRect().width ?? 0; + const waveAIButtonWidth = waveAIButtonRef.current != null ? getOuterWidth(waveAIButtonRef.current) : 0; const nonTabElementsWidth = windowDragLeftWidth + - windowDragRightWidth + + rightContainerWidth + addBtnWidth + - updateStatusLabelWidth + - configErrorWidth + appMenuButtonWidth + workspaceSwitcherWidth + waveAIButtonWidth; @@ -306,20 +314,23 @@ const TabBar = memo(({ workspace }: TabBarProps) => { saveTabsPositionDebounced(); }, [tabIds, newTabId, isFullScreen]); - const reinitVersion = useAtomValue(atoms.reinitVersion); + // update layout on reinit version + const reinitVersion = useAtomValue(env.atoms.reinitVersion); useEffect(() => { if (reinitVersion > 0) { setSizeAndPosition(); } }, [reinitVersion]); + // update layout on resize useEffect(() => { - window.addEventListener("resize", () => handleResizeTabs()); + window.addEventListener("resize", handleResizeTabs); return () => { - window.removeEventListener("resize", () => handleResizeTabs()); + window.removeEventListener("resize", handleResizeTabs); }; }, [handleResizeTabs]); + // update layout on changed tabIds, tabsLoaded, newTabId, hideAiButton, appUpdateStatus, hasConfigErrors, or zoomFactor useEffect(() => { // Check if all tabs are loaded const allLoaded = tabIds.length > 0 && tabIds.every((id) => tabsLoaded[id]); @@ -330,7 +341,17 @@ const TabBar = memo(({ workspace }: TabBarProps) => { prevAllLoadedRef.current = true; } } - }, [tabIds, tabsLoaded, newTabId, saveTabsPosition]); + }, [ + tabIds, + tabsLoaded, + newTabId, + saveTabsPosition, + hideAiButton, + appUpdateStatus, + hasConfigErrors, + zoomFactor, + showMenuBar, + ]); const getDragDirection = (currentX: number) => { let dragDirection: string; @@ -483,7 +504,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { // Reset dragging state setDraggingTab(null); // Update workspace tab ids - fireAndForget(() => WorkspaceService.UpdateTabIds(workspace.oid, tabIds)); + fireAndForget(() => env.rpc.UpdateWorkspaceTabIdsCommand(TabRpcClient, workspace.oid, tabIds)); }), [] ); @@ -547,7 +568,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const handleSelectTab = (tabId: string) => { if (!draggingTabDataRef.current.dragged) { - setActiveTab(tabId); + env.electron.setActiveTab(tabId); } }; @@ -569,7 +590,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { ); const handleAddTab = () => { - createTab(); + env.electron.createTab(); tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease"); updateScrollDebounced(); @@ -579,10 +600,8 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const handleCloseTab = (event: React.MouseEvent | null, tabId: string) => { event?.stopPropagation(); - const ws = globalStore.get(atoms.workspace); - const confirmClose = globalStore.get(getSettingsKeyAtom("tab:confirmclose")) ?? false; - getApi() - .closeTab(ws.oid, tabId, confirmClose) + env.electron + .closeTab(workspace.oid, tabId, confirmClose) .then((didClose) => { if (didClose) { tabsWrapperRef.current?.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease"); @@ -607,15 +626,15 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const activeTabIndex = tabIds.indexOf(activeTabId); function onEllipsisClick() { - getApi().showWorkspaceAppMenu(workspace.oid); + env.electron.showWorkspaceAppMenu(workspace.oid); } const tabsWrapperWidth = tabIds.length * tabWidthRef.current; - const showAppMenuButton = isWindows() || (!isMacOS() && !settings["window:showmenubar"]); + const showAppMenuButton = env.isWindows() || (!env.isMacOS() && !showMenuBar); // Calculate window drag left width based on platform and state let windowDragLeftWidth = 10; - if (isMacOS() && !isFullScreen) { + if (env.isMacOS() && !isFullScreen) { if (zoomFactor > 0) { windowDragLeftWidth = 74 / zoomFactor; } else { @@ -625,7 +644,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { // Calculate window drag right width let windowDragRightWidth = 12; - if (isWindows()) { + if (env.isWindows()) { if (zoomFactor > 0) { windowDragRightWidth = 139 / zoomFactor; } else { @@ -633,12 +652,6 @@ const TabBar = memo(({ workspace }: TabBarProps) => { } } - const addtabButtonDecl: IconButtonDecl = { - elemtype: "iconbutton", - icon: "plus", - click: handleAddTab, - title: "Add Tab", - }; return (
{ })}
- -
- - + +
+
+ +
@@ -704,4 +725,4 @@ const TabBar = memo(({ workspace }: TabBarProps) => { ); }); -export { TabBar }; +export { ConfigErrorIcon, ConfigErrorMessage, TabBar, WaveAIButton }; diff --git a/frontend/app/tab/tabbarenv.ts b/frontend/app/tab/tabbarenv.ts new file mode 100644 index 0000000000..240c2585a1 --- /dev/null +++ b/frontend/app/tab/tabbarenv.ts @@ -0,0 +1,31 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; + +export type TabBarEnv = WaveEnvSubset<{ + electron: { + createTab: WaveEnv["electron"]["createTab"]; + closeTab: WaveEnv["electron"]["closeTab"]; + setActiveTab: WaveEnv["electron"]["setActiveTab"]; + showWorkspaceAppMenu: WaveEnv["electron"]["showWorkspaceAppMenu"]; + installAppUpdate: WaveEnv["electron"]["installAppUpdate"]; + }; + rpc: { + UpdateWorkspaceTabIdsCommand: WaveEnv["rpc"]["UpdateWorkspaceTabIdsCommand"]; + }; + atoms: { + fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + hasConfigErrors: WaveEnv["atoms"]["hasConfigErrors"]; + staticTabId: WaveEnv["atoms"]["staticTabId"]; + isFullScreen: WaveEnv["atoms"]["isFullScreen"]; + zoomFactorAtom: WaveEnv["atoms"]["zoomFactorAtom"]; + reinitVersion: WaveEnv["atoms"]["reinitVersion"]; + updaterStatusAtom: WaveEnv["atoms"]["updaterStatusAtom"]; + }; + wos: WaveEnv["wos"]; + getSettingsKeyAtom: SettingsKeyAtomFnType<"app:hideaibutton" | "tab:confirmclose" | "window:showmenubar">; + mockSetWaveObj: WaveEnv["mockSetWaveObj"]; + isWindows: WaveEnv["isWindows"]; + isMacOS: WaveEnv["isMacOS"]; +}>; diff --git a/frontend/app/tab/updatebanner.tsx b/frontend/app/tab/updatebanner.tsx index e14cc561ba..5150c7e338 100644 --- a/frontend/app/tab/updatebanner.tsx +++ b/frontend/app/tab/updatebanner.tsx @@ -1,69 +1,54 @@ -import { Button } from "@/element/button"; -import { atoms, getApi } from "@/store/global"; -import { useAtomValue } from "jotai"; -import { forwardRef, memo, useEffect, useState } from "react"; +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 -const UpdateStatusBannerComponent = forwardRef((_, ref) => { - let appUpdateStatus = useAtomValue(atoms.updaterStatusAtom); - let [updateStatusMessage, setUpdateStatusMessage] = useState(); - const [dismissBannerTimeout, setDismissBannerTimeout] = useState(); +import { Tooltip } from "@/element/tooltip"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { TabBarEnv } from "./tabbarenv"; +import { useAtomValue } from "jotai"; +import { memo, useCallback } from "react"; - useEffect(() => { - let message: string; - let dismissBanner = false; - switch (appUpdateStatus) { - case "ready": - message = "Update Available"; - break; - case "downloading": - message = "Downloading Update"; - break; - case "installing": - message = "Installing Update"; - break; - case "error": - message = "Updater Error: Try Checking Again"; - dismissBanner = true; - break; - default: - break; - } - setUpdateStatusMessage(message); +function getUpdateStatusMessage(status: string): string { + switch (status) { + case "ready": + return "Update"; + case "downloading": + return "Downloading"; + case "installing": + return "Installing"; + default: + return null; + } +} - // Clear any existing timeout - if (dismissBannerTimeout) { - clearTimeout(dismissBannerTimeout); - } +const UpdateStatusBannerComponent = () => { + const env = useWaveEnv(); + const appUpdateStatus = useAtomValue(env.atoms.updaterStatusAtom); + const updateStatusMessage = getUpdateStatusMessage(appUpdateStatus); - // If we want to dismiss the banner, set the new timeout, otherwise clear the state - if (dismissBanner) { - setDismissBannerTimeout( - setTimeout(() => { - setUpdateStatusMessage(null); - setDismissBannerTimeout(null); - }, 10000) - ); - } else { - setDismissBannerTimeout(null); - } - }, [appUpdateStatus]); + const onClick = useCallback(() => { + env.electron.installAppUpdate(); + }, [env]); - function onClick() { - getApi().installAppUpdate(); + if (!updateStatusMessage) { + return null; } - if (updateStatusMessage) { - return ( - - ); - } -}); -export const UpdateStatusBanner = memo(UpdateStatusBannerComponent) as typeof UpdateStatusBannerComponent; + const isReady = appUpdateStatus === "ready"; + const tooltipContent = isReady ? "Click to Install Update" : updateStatusMessage; + + return ( + + + {updateStatusMessage} + + ); +}; +UpdateStatusBannerComponent.displayName = "UpdateStatusBannerComponent"; + +export const UpdateStatusBanner = memo(UpdateStatusBannerComponent); diff --git a/frontend/app/tab/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 e5689de8bb..b6c3a29a54 100644 --- a/frontend/app/tab/vtab.tsx +++ b/frontend/app/tab/vtab.tsx @@ -1,9 +1,10 @@ // 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; @@ -11,6 +12,8 @@ export interface VTabItem { id: string; name: string; 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.badge && ( - - - - )} +
; + type WorkspaceListEntry = { windowId: string; workspace: Workspace; @@ -35,23 +50,24 @@ const workspaceMapAtom = atom([]); const workspaceSplitAtom = splitAtom(workspaceMapAtom); const editingWorkspaceAtom = atom(); const WorkspaceSwitcher = forwardRef((_, ref) => { + const env = useWaveEnv(); const setWorkspaceList = useSetAtom(workspaceMapAtom); - const activeWorkspace = useAtomValueSafe(atoms.workspace); + const activeWorkspace = useAtomValueSafe(env.atoms.workspace); const workspaceList = useAtomValue(workspaceSplitAtom); const setEditingWorkspace = useSetAtom(editingWorkspaceAtom); const updateWorkspaceList = useCallback(async () => { - const workspaceList = await WorkspaceService.ListWorkspaces(); + const workspaceList = await env.services.workspace.ListWorkspaces(); if (!workspaceList) { return; } const newList: WorkspaceList = []; for (const entry of workspaceList) { // This just ensures that the atom exists for easier setting of the object - getObjectValue(makeORef("workspace", entry.workspaceid)); + globalStore.get(env.wos.getWaveObjectAtom(makeORef("workspace", entry.workspaceid))); newList.push({ windowId: entry.windowid, - workspace: await WorkspaceService.GetWorkspace(entry.workspaceid), + workspace: await env.services.workspace.GetWorkspace(entry.workspaceid), }); } setWorkspaceList(newList); @@ -71,7 +87,7 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { }, []); const onDeleteWorkspace = useCallback((workspaceId: string) => { - getApi().deleteWorkspace(workspaceId); + env.electron.deleteWorkspace(workspaceId); }, []); const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon); @@ -84,7 +100,7 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { const saveWorkspace = () => { fireAndForget(async () => { - await WorkspaceService.UpdateWorkspace(activeWorkspace.oid, "", "", "", true); + await env.services.workspace.UpdateWorkspace(activeWorkspace.oid, "", "", "", true); await updateWorkspaceList(); setEditingWorkspace(activeWorkspace.oid); }); @@ -118,7 +134,7 @@ const WorkspaceSwitcher = forwardRef((_, ref) => {
{isActiveWorkspaceSaved ? ( - getApi().createWorkspace()}> + env.electron.createWorkspace()}> @@ -145,7 +161,8 @@ const WorkspaceSwitcherItem = ({ entryAtom: PrimitiveAtom; onDeleteWorkspace: (workspaceId: string) => void; }) => { - const activeWorkspace = useAtomValueSafe(atoms.workspace); + const env = useWaveEnv(); + const activeWorkspace = useAtomValueSafe(env.atoms.workspace); const [workspaceEntry, setWorkspaceEntry] = useAtom(entryAtom); const [editingWorkspace, setEditingWorkspace] = useAtom(editingWorkspaceAtom); @@ -156,7 +173,7 @@ const WorkspaceSwitcherItem = ({ setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace }); if (newWorkspace.name != "") { fireAndForget(() => - WorkspaceService.UpdateWorkspace( + env.services.workspace.UpdateWorkspace( workspace.oid, newWorkspace.name, newWorkspace.icon, @@ -200,7 +217,7 @@ const WorkspaceSwitcherItem = ({ > { - getApi().switchWorkspace(workspace.oid); + env.electron.switchWorkspace(workspace.oid); // Create a fake escape key event to close the popover document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); }} diff --git a/frontend/app/view/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 797f1f78b4..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; }; -function TableRow({ - 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); diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index 2bfa643031..59cbbaca4f 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -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 }[] = [ @@ -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/sysinfo/sysinfo.tsx b/frontend/app/view/sysinfo/sysinfo.tsx index dca9d6d09f..30feead6c8 100644 --- a/frontend/app/view/sysinfo/sysinfo.tsx +++ b/frontend/app/view/sysinfo/sysinfo.tsx @@ -14,10 +14,10 @@ import * as React from "react"; import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import type { BlockMetaKeyAtomFnType, WaveEnv } from "@/app/waveenv/waveenv"; +import type { BlockMetaKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; -export type SysinfoEnv = { +export type SysinfoEnv = WaveEnvSubset<{ rpc: { EventReadHistoryCommand: WaveEnv["rpc"]["EventReadHistoryCommand"]; SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; @@ -27,7 +27,7 @@ export type SysinfoEnv = { }; getConnStatusAtom: WaveEnv["getConnStatusAtom"]; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"graph:numpoints" | "sysinfo:type" | "connection" | "count">; -}; +}>; const DefaultNumPoints = 120; diff --git a/frontend/app/view/term/osc-handlers.ts b/frontend/app/view/term/osc-handlers.ts index 25fdf0e89b..f44659d2c6 100644 --- a/frontend/app/view/term/osc-handlers.ts +++ b/frontend/app/view/term/osc-handlers.ts @@ -12,7 +12,6 @@ import { recordTEvent, WOS, } from "@/store/global"; -import * as services from "@/store/services"; import { base64ToString, fireAndForget, isSshConnName, isWslConnName } from "@/util/util"; import debug from "debug"; import type { TermWrap } from "./termwrap"; @@ -243,8 +242,9 @@ export function handleOsc7Command(data: string, blockId: string, loaded: boolean setTimeout(() => { fireAndForget(async () => { - await services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", blockId), { - "cmd:cwd": pathPart, + await RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", blockId), + meta: { "cmd:cwd": pathPart }, }); const rtInfo = { "shell:hascurcwd": true }; diff --git a/frontend/app/waveenv/waveenv.ts b/frontend/app/waveenv/waveenv.ts index 8a75072d79..df1cb01c4a 100644 --- a/frontend/app/waveenv/waveenv.ts +++ b/frontend/app/waveenv/waveenv.ts @@ -1,7 +1,7 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import type { TabModel } from "@/app/store/tab-model"; +import type { AllServiceImpls } from "@/app/store/services"; import { RpcApiType } from "@/app/store/wshclientapi"; import { Atom, PrimitiveAtom } from "jotai"; import React from "react"; @@ -33,18 +33,27 @@ type ComplexWaveEnvKeys = { electron: WaveEnv["electron"]; atoms: WaveEnv["atoms"]; wos: WaveEnv["wos"]; + services: WaveEnv["services"]; }; -export type WaveEnvSubset = OmitNever<{ - [K in keyof T]: K extends keyof ComplexWaveEnvKeys - ? Subset - : K extends keyof WaveEnv - ? T[K] - : never; -}>; +type WaveEnvMockFields = { + isMock: WaveEnv["isMock"]; + mockSetWaveObj: WaveEnv["mockSetWaveObj"]; + mockModels: WaveEnv["mockModels"]; +}; + +export type WaveEnvSubset = WaveEnvMockFields & + OmitNever<{ + [K in keyof T]: K extends keyof ComplexWaveEnvKeys + ? Subset + : K extends keyof WaveEnv + ? T[K] + : never; + }>; // default implementation for production is in ./waveenvimpl.ts export type WaveEnv = { + isMock: boolean; electron: ElectronApi; rpc: RpcApiType; platform: NodeJS.Platform; @@ -53,6 +62,8 @@ export type WaveEnv = { isMacOS: () => boolean; atoms: GlobalAtomsType; createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => Promise; + services: typeof AllServiceImpls; + callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => Promise; showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => void; getConnStatusAtom: (conn: string) => PrimitiveAtom; getLocalHostDisplayNameAtom: () => Atom; @@ -65,7 +76,10 @@ export type WaveEnv = { getSettingsKeyAtom: SettingsKeyAtomFnType; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType; getConnConfigKeyAtom: ConnConfigKeyAtomFnType; - mockTabModel?: TabModel; + + // the mock fields are only usable in the preview server (may be be null or throw errors in production) + mockSetWaveObj: (oref: string, obj: T) => void; + mockModels: Map; }; export const WaveEnvContext = React.createContext(null); diff --git a/frontend/app/waveenv/waveenvimpl.ts b/frontend/app/waveenv/waveenvimpl.ts index 1d78172d04..4f9e234eca 100644 --- a/frontend/app/waveenv/waveenvimpl.ts +++ b/frontend/app/waveenv/waveenvimpl.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ContextMenuModel } from "@/app/store/contextmenu"; +import { AllServiceImpls } from "@/app/store/services"; import { atoms, createBlock, @@ -19,6 +20,7 @@ import { isMacOS, isWindows, PLATFORM } from "@/util/platformutil"; export function makeWaveEnvImpl(): WaveEnv { return { + isMock: false, electron: (window as any).api, rpc: RpcApi, getSettingsKeyAtom, @@ -28,6 +30,8 @@ export function makeWaveEnvImpl(): WaveEnv { isMacOS, atoms, createBlock, + services: AllServiceImpls, + callBackendService: WOS.callBackendService, showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => { ContextMenuModel.getInstance().showContextMenu(menu, e); }, @@ -41,5 +45,10 @@ export function makeWaveEnvImpl(): WaveEnv { }, getBlockMetaKeyAtom, getConnConfigKeyAtom, + + mockSetWaveObj: (_oref: string, _obj: T) => { + throw new Error("mockSetWaveObj is only available in the preview server"); + }, + mockModels: new Map(), }; } diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index 2d73119154..2ec171953e 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -30,7 +30,7 @@ export type WidgetsEnv = WaveEnvSubset<{ }; atoms: { fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; - workspace: WaveEnv["atoms"]["workspace"]; + workspaceId: WaveEnv["atoms"]["workspaceId"]; hasCustomAIPresetsAtom: WaveEnv["atoms"]["hasCustomAIPresetsAtom"]; }; createBlock: WaveEnv["createBlock"]; @@ -348,7 +348,7 @@ SettingsFloatingWindow.displayName = "SettingsFloatingWindow"; const Widgets = memo(() => { const env = useWaveEnv(); const fullConfig = useAtomValue(env.atoms.fullConfigAtom); - const workspace = useAtomValue(env.atoms.workspace); + const workspaceId = useAtomValue(env.atoms.workspaceId); const hasCustomAIPresets = useAtomValue(env.atoms.hasCustomAIPresetsAtom); const [mode, setMode] = useState<"normal" | "compact" | "supercompact">("normal"); const containerRef = useRef(null); @@ -361,7 +361,7 @@ const Widgets = memo(() => { if (!hasCustomAIPresets && key === "defwidget@ai") { return false; } - return shouldIncludeWidgetForWorkspace(widget, workspace?.oid); + return shouldIncludeWidgetForWorkspace(widget, workspaceId); }) ); const widgets = sortByDisplayOrder(filteredWidgets); diff --git a/frontend/preview/mock/mockwaveenv.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 index fdcfb02ba3..35b0f7de15 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -2,16 +2,44 @@ // SPDX-License-Identifier: Apache-2.0 import { makeDefaultConnStatus } from "@/app/store/global"; -import { TabModel } from "@/app/store/tab-model"; +import { globalStore } from "@/app/store/jotaiStore"; +import { AllServiceTypes } from "@/app/store/services"; +import { handleWaveEvent } from "@/app/store/wps"; import { RpcApiType } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; import { PlatformMacOS, PlatformWindows } from "@/util/platformutil"; -import { Atom, atom, PrimitiveAtom } from "jotai"; +import { Atom, atom, PrimitiveAtom, useAtomValue } from "jotai"; import { DefaultFullConfig } from "./defaultconfig"; +import { 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[]) => any; + [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => Promise; +}; + +type ServiceOverrides = { + [Service: string]: { + [Method: string]: (...args: any[]) => Promise; + }; }; export type MockEnv = { @@ -20,6 +48,7 @@ export type MockEnv = { platform?: NodeJS.Platform; settings?: Partial; rpc?: RpcOverrides; + services?: ServiceOverrides; atoms?: Partial; electron?: Partial; createBlock?: WaveEnv["createBlock"]; @@ -38,12 +67,23 @@ function mergeRecords(base: Record, overrides: Record): } export function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv { + let mergedServices: ServiceOverrides; + if (base.services != null || overrides.services != null) { + mergedServices = {}; + for (const svc of Object.keys(base.services ?? {})) { + mergedServices[svc] = { ...(base.services[svc] ?? {}) }; + } + for (const svc of Object.keys(overrides.services ?? {})) { + mergedServices[svc] = { ...(mergedServices[svc] ?? {}), ...(overrides.services[svc] ?? {}) }; + } + } return { isDev: overrides.isDev ?? base.isDev, tabId: overrides.tabId ?? base.tabId, platform: overrides.platform ?? base.platform, settings: mergeRecords(base.settings, overrides.settings), rpc: mergeRecords(base.rpc as any, overrides.rpc as any) as RpcOverrides, + services: mergedServices, atoms: overrides.atoms != null || base.atoms != null ? { ...base.atoms, ...overrides.atoms } : undefined, electron: overrides.electron != null || base.electron != null @@ -73,9 +113,10 @@ function makeMockSettingsKeyAtom( } function makeMockGlobalAtoms( - settingsOverrides?: Partial, - atomOverrides?: Partial, - tabId?: string + settingsOverrides: Partial, + atomOverrides: Partial, + tabId: string, + getWaveObjectAtom: (oref: string) => PrimitiveAtom ): GlobalAtomsType { let fullConfig = DefaultFullConfig; if (settingsOverrides) { @@ -86,15 +127,28 @@ function makeMockGlobalAtoms( } const fullConfigAtom = atom(fullConfig) as PrimitiveAtom; const settingsAtom = atom((get) => get(fullConfigAtom)?.settings ?? {}) as Atom; + const workspaceIdAtom: Atom = atomOverrides?.workspaceId ?? (atom(null as string) as Atom); + const workspaceAtom: Atom = atom((get) => { + const wsId = get(workspaceIdAtom); + if (wsId == null) { + return null; + } + return get(getWaveObjectAtom("workspace:" + wsId)); + }); const defaults: GlobalAtomsType = { builderId: atom(""), builderAppId: atom("") as any, uiContext: atom({ windowid: "", activetabid: tabId ?? "" } as UIContext), - workspace: atom(null as Workspace), + workspaceId: workspaceIdAtom, + workspace: workspaceAtom, fullConfigAtom, waveaiModeConfigAtom: atom({}) as any, settingsAtom, hasCustomAIPresetsAtom: atom(false), + hasConfigErrors: atom((get) => { + const c = get(fullConfigAtom); + return c?.configerrors != null && c.configerrors.length > 0; + }), staticTabId: atom(tabId ?? ""), isFullScreen: atom(false) as any, zoomFactorAtom: atom(1.0) as any, @@ -110,15 +164,81 @@ function makeMockGlobalAtoms( if (!atomOverrides) { return defaults; } - return { ...defaults, ...atomOverrides }; + const merged = { ...defaults, ...atomOverrides }; + if (!atomOverrides.workspace) { + merged.workspace = workspaceAtom; + } + return merged; } -export function makeMockRpc(overrides?: RpcOverrides): RpcApiType { - const dispatchMap = new Map any>(); +type MockWosFns = { + getWaveObjectAtom: (oref: string) => PrimitiveAtom; + mockSetWaveObj: (oref: string, obj: T) => void; + 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[]) => any); + dispatchMap.set(cmdName, overrides[key] as (...args: any[]) => Promise); } } const rpc = new RpcApiType(); @@ -134,7 +254,7 @@ export function makeMockRpc(overrides?: RpcOverrides): RpcApiType { async *mockWshRpcStream(_client, command, data, _opts) { const fn = dispatchMap.get(command); if (fn) { - yield* fn(_client, data, _opts); + yield await fn(_client, data, _opts); return; } console.log("[mock rpc stream]", command, data); @@ -154,10 +274,18 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { const overrides: MockEnv = mockEnv ?? {}; const platform = overrides.platform ?? PlatformMacOS; const connStatusAtomCache = new Map>(); - const waveObjectAtomCache = new Map>(); + const waveObjectValueAtomCache = new Map>(); + const waveObjectDerivedAtomCache = new Map>(); const blockMetaKeyAtomCache = new Map>(); const connConfigKeyAtomCache = new Map>(); - const atoms = makeMockGlobalAtoms(overrides.settings, overrides.atoms, overrides.tabId); + const getWaveObjectAtom = (oref: string): PrimitiveAtom => { + if (!waveObjectValueAtomCache.has(oref)) { + const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T; + waveObjectValueAtomCache.set(oref, atom(obj) as PrimitiveAtom); + } + return waveObjectValueAtomCache.get(oref) as PrimitiveAtom; + }; + const atoms = makeMockGlobalAtoms(overrides.settings, overrides.atoms, overrides.tabId, getWaveObjectAtom); const localHostDisplayNameAtom = atom((get) => { const configValue = get(atoms.settingsAtom)?.["conn:localhostdisplayname"]; if (configValue != null) { @@ -165,14 +293,28 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { } 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), + rpc: makeMockRpc(overrides.rpc, mockWosFns), atoms, getSettingsKeyAtom: makeMockSettingsKeyAtom(atoms.settingsAtom, overrides.settings), platform, @@ -186,10 +328,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { return Promise.resolve(crypto.randomUUID()); }), showContextMenu: - overrides.showContextMenu ?? - ((menu, e) => { - console.log("[mock showContextMenu]", menu, e); - }), + overrides.showContextMenu ?? showPreviewContextMenu, getLocalHostDisplayNameAtom: () => { return localHostDisplayNameAtom; }, @@ -201,34 +340,27 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { return connStatusAtomCache.get(conn); }, wos: { - getWaveObjectAtom: (oref: string) => { - const cacheKey = oref + ":value"; - if (!waveObjectAtomCache.has(cacheKey)) { - const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T; - waveObjectAtomCache.set(cacheKey, atom(obj)); - } - return waveObjectAtomCache.get(cacheKey) as PrimitiveAtom; - }, + getWaveObjectAtom: mockWosFns.getWaveObjectAtom, getWaveObjectLoadingAtom: (oref: string) => { const cacheKey = oref + ":loading"; - if (!waveObjectAtomCache.has(cacheKey)) { - waveObjectAtomCache.set(cacheKey, atom(false)); + if (!waveObjectDerivedAtomCache.has(cacheKey)) { + waveObjectDerivedAtomCache.set(cacheKey, atom(false)); } - return waveObjectAtomCache.get(cacheKey) as Atom; + return waveObjectDerivedAtomCache.get(cacheKey) as Atom; }, isWaveObjectNullAtom: (oref: string) => { const cacheKey = oref + ":isnull"; - if (!waveObjectAtomCache.has(cacheKey)) { - waveObjectAtomCache.set( + if (!waveObjectDerivedAtomCache.has(cacheKey)) { + waveObjectDerivedAtomCache.set( cacheKey, atom((get) => get(env.wos.getWaveObjectAtom(oref)) == null) ); } - return waveObjectAtomCache.get(cacheKey) as Atom; + return waveObjectDerivedAtomCache.get(cacheKey) as Atom; }, useWaveObjectValue: (oref: string): [T, boolean] => { - const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T; - return [obj, false]; + const objAtom = env.wos.getWaveObjectAtom(oref); + return [useAtomValue(objAtom), false]; }, }, getBlockMetaKeyAtom: (blockId: string, key: T) => { @@ -255,10 +387,20 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { } return connConfigKeyAtomCache.get(cacheKey) as Atom; }, - mockTabModel: null as TabModel, + services: null as any, + callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => { + const fn = overrides.services?.[service]?.[method]; + if (fn) { + return fn(...args); + } + console.log("[mock callBackendService]", service, method, args, noUIContext); + return Promise.resolve(null); + }, + mockSetWaveObj: mockWosFns.mockSetWaveObj, + mockModels: new Map(), } as MockWaveEnv; - if (overrides.tabId != null) { - env.mockTabModel = new TabModel(overrides.tabId, env); - } + env.services = Object.fromEntries( + Object.entries(AllServiceTypes).map(([key, ServiceClass]) => [key, new ServiceClass(env)]) + ) as any; return env; } diff --git a/frontend/preview/preview-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 9cb03c0014..9ec47366a0 100644 --- a/frontend/preview/preview.tsx +++ b/frontend/preview/preview.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; +import { ErrorBoundary } from "@/app/element/errorboundary"; import { getAtoms, initGlobalAtoms } from "@/app/store/global-atoms"; import { GlobalModel } from "@/app/store/global-model"; import { globalStore } from "@/app/store/jotaiStore"; @@ -12,7 +13,9 @@ 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) @@ -95,13 +98,17 @@ function PreviewRoot() { atoms: { uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext), staticTabId: atom(PreviewTabId), + workspaceId: atom(PreviewWorkspaceId), }, }) ); return ( - + <> + + + ); @@ -118,9 +125,11 @@ function PreviewApp() { <>
- - - + + + + +
); @@ -143,6 +152,7 @@ function PreviewApp() { const PreviewTabId = crypto.randomUUID(); const PreviewWindowId = crypto.randomUUID(); +const PreviewWorkspaceId = crypto.randomUUID(); const PreviewClientId = crypto.randomUUID(); function initPreview() { @@ -159,7 +169,12 @@ function initPreview() { globalStore.set(getAtoms().fullConfigAtom, {} as FullConfigType); GlobalModel.getInstance().initialize(initOpts); loadFonts(); - const root = createRoot(document.getElementById("main")!); + const container = document.getElementById("main")!; + let root = (container as any).__reactRoot; + if (!root) { + root = createRoot(container); + (container as any).__reactRoot = root; + } root.render(); } 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/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/vtabbar.preview.tsx b/frontend/preview/previews/vtabbar.preview.tsx index 997741b8d9..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", badge: { badgeid: "01957000-0000-7000-0000-000000000001", icon: "bell", color: "#f59e0b", priority: 1 } }, - { 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() { diff --git a/frontend/preview/previews/widgets.preview.tsx b/frontend/preview/previews/widgets.preview.tsx index b6970da902..144cace174 100644 --- a/frontend/preview/previews/widgets.preview.tsx +++ b/frontend/preview/previews/widgets.preview.tsx @@ -7,7 +7,6 @@ import { atom, useAtom } from "jotai"; import { useRef } from "react"; import { applyMockEnvOverrides } from "../mock/mockwaveenv"; -const workspaceAtom = atom(null as Workspace); const resizableHeightAtom = atom(250); function makeMockApp(name: string, icon: string, iconcolor: string): AppInfo { @@ -91,7 +90,6 @@ function makeWidgetsEnv(baseEnv: WaveEnv, isDev: boolean, hasCustomAIPresets: bo rpc: { ListAllAppsCommand: () => Promise.resolve(apps ?? []) }, atoms: { fullConfigAtom, - workspace: workspaceAtom, hasCustomAIPresetsAtom: atom(hasCustomAIPresets), }, }); diff --git a/frontend/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/types/custom.d.ts b/frontend/types/custom.d.ts index 8ee176e151..9f7cb15ad3 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -11,11 +11,13 @@ declare global { builderId: jotai.Atom; // readonly (for builder mode) builderAppId: jotai.PrimitiveAtom; // app being edited in builder mode uiContext: jotai.Atom; // driven from windowId, tabId - workspace: jotai.Atom; // driven from WOS + workspaceId: jotai.Atom; // derived from window WOS object + workspace: jotai.Atom; // driven from workspaceId via WOS fullConfigAtom: jotai.PrimitiveAtom; // driven from WOS, settings -- updated via WebSocket waveaiModeConfigAtom: jotai.PrimitiveAtom>; // resolved AI mode configs -- updated via WebSocket settingsAtom: jotai.Atom; // derrived from fullConfig hasCustomAIPresetsAtom: jotai.Atom; // derived from fullConfig + hasConfigErrors: jotai.Atom; // derived from fullConfig staticTabId: jotai.Atom; isFullScreen: jotai.PrimitiveAtom; zoomFactorAtom: jotai.PrimitiveAtom; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index a53527e346..ddcb4a63e7 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1359,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; 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/package-lock.json b/package-lock.json index fd6edbe6c5..99c2a025b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.2-beta.0", + "version": "0.14.2-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.2-beta.0", + "version": "0.14.2-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/package.json b/package.json index cd727bf39f..b0174b5943 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "productName": "Wave", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "license": "Apache-2.0", - "version": "0.14.2-beta.1", + "version": "0.14.2-beta.2", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" diff --git a/pkg/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go index 8d6dc15690..0eb228181a 100644 --- a/pkg/service/objectservice/objectservice.go +++ b/pkg/service/objectservice/objectservice.go @@ -72,23 +72,6 @@ func (svc *ObjectService) GetObjects(orefStrArr []string) ([]waveobj.WaveObj, er return wstore.DBSelectORefs(ctx, orefArr) } -func (svc *ObjectService) UpdateTabName_Meta() tsgenmeta.MethodMeta { - return tsgenmeta.MethodMeta{ - ArgNames: []string{"uiContext", "tabId", "name"}, - } -} - -func (svc *ObjectService) UpdateTabName(uiContext waveobj.UIContext, tabId, name string) (waveobj.UpdatesRtnType, error) { - ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) - defer cancelFn() - ctx = waveobj.ContextWithUpdates(ctx) - err := wstore.UpdateTabName(ctx, tabId, name) - if err != nil { - return nil, fmt.Errorf("error updating tab name: %w", err) - } - return waveobj.ContextGetUpdatesRtn(ctx), nil -} - func (svc *ObjectService) CreateBlock_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"uiContext", "blockDef", "rtOpts"}, diff --git a/pkg/service/workspaceservice/workspaceservice.go b/pkg/service/workspaceservice/workspaceservice.go index c0d5072a48..1d7b116bdc 100644 --- a/pkg/service/workspaceservice/workspaceservice.go +++ b/pkg/service/workspaceservice/workspaceservice.go @@ -6,7 +6,6 @@ package workspaceservice import ( "context" "fmt" - "log" "time" "github.com/wavetermdev/waveterm/pkg/blockcontroller" @@ -165,24 +164,6 @@ func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activ return tabId, updates, nil } -func (svc *WorkspaceService) UpdateTabIds_Meta() tsgenmeta.MethodMeta { - return tsgenmeta.MethodMeta{ - ArgNames: []string{"uiContext", "workspaceId", "tabIds"}, - } -} - -func (svc *WorkspaceService) UpdateTabIds(uiContext waveobj.UIContext, workspaceId string, tabIds []string) (waveobj.UpdatesRtnType, error) { - log.Printf("UpdateTabIds %s %v\n", workspaceId, tabIds) - ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) - defer cancelFn() - ctx = waveobj.ContextWithUpdates(ctx) - err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds) - if err != nil { - return nil, fmt.Errorf("error updating workspace tab ids: %w", err) - } - return waveobj.ContextGetUpdatesRtn(ctx), nil -} - func (svc *WorkspaceService) SetActiveTab_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"workspaceId", "tabId"}, diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index f990019ecd..8d92893afc 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -412,7 +412,7 @@ func GenerateMethodSignature(serviceName string, method reflect.Method, meta tsg } func GenerateMethodBody(serviceName string, method reflect.Method, meta tsgenmeta.MethodMeta) string { - return fmt.Sprintf(" return WOS.callBackendService(%q, %q, Array.from(arguments))\n", serviceName, method.Name) + return fmt.Sprintf(" return callBackendService(this.waveEnv, %q, %q, Array.from(arguments))\n", serviceName, method.Name) } func GenerateServiceClass(serviceName string, serviceObj any, tsTypesMap map[reflect.Type]string) string { @@ -420,9 +420,13 @@ func GenerateServiceClass(serviceName string, serviceObj any, tsTypesMap map[ref var sb strings.Builder tsServiceName := serviceType.Elem().Name() sb.WriteString(fmt.Sprintf("// %s (%s)\n", serviceType.Elem().String(), serviceName)) - sb.WriteString("class ") + sb.WriteString("export class ") sb.WriteString(tsServiceName + "Type") sb.WriteString(" {\n") + sb.WriteString(" waveEnv: WaveEnv;\n\n") + sb.WriteString(" constructor(waveEnv?: WaveEnv) {\n") + sb.WriteString(" this.waveEnv = waveEnv;\n") + sb.WriteString(" }\n\n") isFirst := true for midx := 0; midx < serviceType.NumMethod(); midx++ { method := serviceType.Method(midx) diff --git a/pkg/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/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index 6668d2d050..ab10987fc9 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -36,5 +36,6 @@ "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 e031a493ea..084dab1793 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -80,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 69c531eb77..17aafa6685 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -130,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 index 9c45950c16..a60ecb8fc1 100644 --- a/pkg/wcore/badge.go +++ b/pkg/wcore/badge.go @@ -72,30 +72,56 @@ func handleBadgeEvent(event *wps.WaveEvent) { 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 + } - shouldClear := data.Clear if data.ClearById != "" { existing, ok := globalBadgeStore.transient[orefStr] if !ok || existing.BadgeId != data.ClearById { return } - shouldClear = true - } else if !data.Clear { - shouldClear = data.Badge == nil + delete(globalBadgeStore.transient, orefStr) + log.Printf("badge store: badge cleared by id: oref=%s id=%s\n", orefStr, data.ClearById) + return } - - if shouldClear { + if data.Clear { delete(globalBadgeStore.transient, orefStr) log.Printf("badge store: badge cleared: oref=%s\n", orefStr) - } else { - globalBadgeStore.transient[orefStr] = *data.Badge - log.Printf("badge store: badge set: oref=%s badge=%+v\n", orefStr, *data.Badge) + 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) } } diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index c44c9c6abc..110e1695ef 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -921,6 +921,18 @@ func TestMultiArgCommand(w *wshutil.WshRpc, arg1 string, arg2 int, arg3 bool, op return resp, err } +// command "updatetabname", wshserver.UpdateTabNameCommand +func UpdateTabNameCommand(w *wshutil.WshRpc, arg1 string, arg2 string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "updatetabname", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts) + return err +} + +// command "updateworkspacetabids", wshserver.UpdateWorkspaceTabIdsCommand +func UpdateWorkspaceTabIdsCommand(w *wshutil.WshRpc, arg1 string, arg2 []string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "updateworkspacetabids", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts) + return err +} + // command "vdomasyncinitiation", wshserver.VDomAsyncInitiationCommand func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiationRequest, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "vdomasyncinitiation", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 2c69ee0034..8ddff8128b 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -94,6 +94,8 @@ type WshRpcInterface interface { FetchSuggestionsCommand(ctx context.Context, data FetchSuggestionsData) (*FetchSuggestionsResponse, error) DisposeSuggestionsCommand(ctx context.Context, widgetId string) error GetTabCommand(ctx context.Context, tabId string) (*waveobj.Tab, error) + UpdateTabNameCommand(ctx context.Context, tabId string, newName string) error + UpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error) // connection functions diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index e477914fa2..670c949f2e 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -160,6 +160,26 @@ func (ws *WshServer) GetMetaCommand(ctx context.Context, data wshrpc.CommandGetM return waveobj.GetMeta(obj), nil } +func (ws *WshServer) UpdateTabNameCommand(ctx context.Context, tabId string, newName string) error { + oref := waveobj.ORef{OType: waveobj.OType_Tab, OID: tabId} + err := wstore.UpdateTabName(ctx, tabId, newName) + if err != nil { + return fmt.Errorf("error updating tab name: %w", err) + } + wcore.SendWaveObjUpdate(oref) + return nil +} + +func (ws *WshServer) UpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error { + oref := waveobj.ORef{OType: waveobj.OType_Workspace, OID: workspaceId} + err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds) + if err != nil { + return fmt.Errorf("error updating workspace tab ids: %w", err) + } + wcore.SendWaveObjUpdate(oref) + return nil +} + func (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error { log.Printf("SetMetaCommand: %s | %v\n", data.ORef, data.Meta) oref := data.ORef diff --git a/schema/settings.json b/schema/settings.json index d60367bea3..348c937dac 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -212,6 +212,13 @@ "preview:showhiddenfiles": { "type": "boolean" }, + "preview:defaultsort": { + "type": "string", + "enum": [ + "name", + "modtime" + ] + }, "tab:preset": { "type": "string" }, 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,