diff --git a/.github/workflows/build-helper.yml b/.github/workflows/build-helper.yml index 1d3d38764c..eadb18ce77 100644 --- a/.github/workflows/build-helper.yml +++ b/.github/workflows/build-helper.yml @@ -74,7 +74,7 @@ jobs: run: | git config --global url.https://github.com/.insteadof ssh://git@github.com/ git config --global url.https://github.com/.insteadof git@github.com: - - uses: nick-fields/retry@v3 + - uses: nick-fields/retry@v4 name: npm ci with: command: npm ci --no-audit --no-fund @@ -132,7 +132,7 @@ jobs: USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. SNAPCRAFT_BUILD_ENVIRONMENT: host # Retry Darwin build in case of notarization failures - - uses: nick-fields/retry@v3 + - uses: nick-fields/retry@v4 name: Build (Darwin) if: matrix.platform == 'darwin' with: diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 1fb15b9bf5..fa7f31df6e 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Get App Token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ vars.WAVE_BUILDER_APPID }} @@ -44,7 +44,7 @@ jobs: node-version: ${{env.NODE_VERSION}} cache: npm cache-dependency-path: package-lock.json - - uses: nick-fields/retry@v3 + - uses: nick-fields/retry@v4 name: npm ci with: command: npm ci --no-audit --no-fund diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e47f049cbe..30a8979b9b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -76,7 +76,7 @@ jobs: node-version: ${{env.NODE_VERSION}} cache: npm cache-dependency-path: package-lock.json - - uses: nick-fields/retry@v3 + - uses: nick-fields/retry@v4 name: npm ci with: command: npm ci --no-audit --no-fund diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 4f3b3fc9fc..20f05975b2 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -48,7 +48,7 @@ jobs: git config --global url.https://github.com/.insteadof git@github.com: # Warm caches only (no builds) - - uses: nick-fields/retry@v3 + - uses: nick-fields/retry@v4 name: npm ci with: command: npm ci --no-audit --no-fund diff --git a/.github/workflows/deploy-docsite.yml b/.github/workflows/deploy-docsite.yml index 377a8b6c14..e9ba826c31 100644 --- a/.github/workflows/deploy-docsite.yml +++ b/.github/workflows/deploy-docsite.yml @@ -43,7 +43,7 @@ jobs: with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - - uses: nick-fields/retry@v3 + - uses: nick-fields/retry@v4 name: npm ci with: command: npm ci --no-audit --no-fund @@ -55,7 +55,7 @@ jobs: - name: Upload Build Artifact # Only upload the build artifact when pushed to the main branch if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: docs/build deploy: diff --git a/.github/workflows/testdriver-build.yml b/.github/workflows/testdriver-build.yml index c355b6cbe1..da190073e6 100644 --- a/.github/workflows/testdriver-build.yml +++ b/.github/workflows/testdriver-build.yml @@ -52,7 +52,7 @@ jobs: node-version: ${{env.NODE_VERSION}} cache: npm cache-dependency-path: package-lock.json - - uses: nick-fields/retry@v3 + - uses: nick-fields/retry@v4 name: npm ci with: command: npm ci --no-audit --no-fund diff --git a/.gitignore b/.gitignore index 161db5f191..a1c7240b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ out/ make/ artifacts/ mikework/ +aiplans/ manifests/ .env out diff --git a/.kilocode/skills/create-view/SKILL.md b/.kilocode/skills/create-view/SKILL.md index 70506e1ac6..f39b1ce0d8 100644 --- a/.kilocode/skills/create-view/SKILL.md +++ b/.kilocode/skills/create-view/SKILL.md @@ -110,7 +110,8 @@ Create a new file for your view model (e.g., `frontend/app/view/myview/myview-mo ```typescript import { BlockNodeModel } from "@/app/block/blocktypes"; -import { WOS, globalStore, useBlockAtom } from "@/store/global"; +import { globalStore } from "@/app/store/jotaiStore"; +import { WOS, useBlockAtom } from "@/store/global"; import * as jotai from "jotai"; import { MyView } from "./myview"; diff --git a/.kilocode/skills/waveenv/SKILL.md b/.kilocode/skills/waveenv/SKILL.md index aabda6846d..c5d56af4f1 100644 --- a/.kilocode/skills/waveenv/SKILL.md +++ b/.kilocode/skills/waveenv/SKILL.md @@ -30,7 +30,7 @@ Create a narrowing whenever you are writing a component (or group of components) ```ts import { - BlockMetaKeyAtomFnType, // only if you use getBlockMetaKeyAtom + MetaKeyAtomFnType, // only if you use getBlockMetaKeyAtom or getTabMetaKeyAtom ConnConfigKeyAtomFnType, // only if you use getConnConfigKeyAtom SettingsKeyAtomFnType, // only if you use getSettingsKeyAtom WaveEnv, @@ -77,12 +77,14 @@ export type MyEnv = WaveEnvSubset<{ // --- key-parameterized atom factories: enumerate the keys you use --- getSettingsKeyAtom: SettingsKeyAtomFnType<"app:focusfollowscursor" | "window:magnifiedblockopacity">; - getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"view" | "frame:title" | "connection">; + getBlockMetaKeyAtom: MetaKeyAtomFnType<"view" | "frame:title" | "connection">; + getTabMetaKeyAtom: MetaKeyAtomFnType<"tabid" | "name">; getConnConfigKeyAtom: ConnConfigKeyAtomFnType<"conn:wshenabled">; // --- other atom helpers: copy verbatim --- getConnStatusAtom: WaveEnv["getConnStatusAtom"]; getLocalHostDisplayNameAtom: WaveEnv["getLocalHostDisplayNameAtom"]; + getConfigBackgroundAtom: WaveEnv["getConfigBackgroundAtom"]; }>; ``` @@ -104,7 +106,8 @@ Every `WaveEnvSubset` automatically includes the mock fields — you never ne | `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. | +| `getBlockMetaKeyAtom` | `MetaKeyAtomFnType<"key1" \| "key2">` | Union all block meta keys accessed. | +| `getTabMetaKeyAtom` | `MetaKeyAtomFnType<"key1" \| "key2">` | Union all tab meta keys accessed. | | `getConnConfigKeyAtom` | `ConnConfigKeyAtomFnType<"key1">` | Union all conn config keys accessed. | | All other `WaveEnv` fields | `WaveEnv["fieldName"]` | Copy type verbatim. | diff --git a/Taskfile.yml b/Taskfile.yml index 80903ad60b..106ac99e0b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -282,6 +282,18 @@ tasks: - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wsh*" platforms: [windows] ignore_error: true + - task: build:wsh:parallel + deps: + - go:mod:tidy + - generate + sources: + - "cmd/wsh/**/*.go" + - "pkg/**/*.go" + generates: + - "dist/bin/wsh*" + + build:wsh:parallel: + deps: - task: build:wsh:internal vars: GOOS: darwin @@ -314,14 +326,7 @@ tasks: vars: GOOS: windows GOARCH: arm64 - deps: - - go:mod:tidy - - generate - sources: - - "cmd/wsh/**/*.go" - - "pkg/**/*.go" - generates: - - "dist/bin/wsh*" + internal: true build:wsh:internal: vars: diff --git a/aiprompts/newview.md b/aiprompts/newview.md index d12bfca937..ddb2da57fc 100644 --- a/aiprompts/newview.md +++ b/aiprompts/newview.md @@ -104,7 +104,8 @@ Create a new file for your view model (e.g., `frontend/app/view/myview/myview-mo ```typescript import { BlockNodeModel } from "@/app/block/blocktypes"; -import { WOS, globalStore, useBlockAtom } from "@/store/global"; +import { globalStore } from "@/app/store/jotaiStore"; +import { WOS, useBlockAtom } from "@/store/global"; import * as jotai from "jotai"; import { MyView } from "./myview"; diff --git a/cmd/generateschema/main-generateschema.go b/cmd/generateschema/main-generateschema.go index 2b14a95781..dd24a4df0d 100644 --- a/cmd/generateschema/main-generateschema.go +++ b/cmd/generateschema/main-generateschema.go @@ -20,7 +20,7 @@ const WaveSchemaSettingsFileName = "schema/settings.json" const WaveSchemaConnectionsFileName = "schema/connections.json" const WaveSchemaAiPresetsFileName = "schema/aipresets.json" const WaveSchemaWidgetsFileName = "schema/widgets.json" -const WaveSchemaBgPresetsFileName = "schema/bgpresets.json" +const WaveSchemaBackgroundsFileName = "schema/backgrounds.json" const WaveSchemaWaveAIFileName = "schema/waveai.json" // ViewNameType is a string type whose JSON Schema offers enum suggestions for the most @@ -105,8 +105,26 @@ type WidgetsMetaSchemaHints struct { TermDurable *bool `json:"term:durable,omitempty"` } -func generateSchema(template any, dir string) error { +// allowNullValues wraps the top-level additionalProperties of a map schema with +// anyOf: [originalSchema, {type: "null"}] so that setting a key to null is valid +// (e.g. "bg@foo": null to remove a default entry). +func allowNullValues(schema *jsonschema.Schema) { + if schema.AdditionalProperties != nil && schema.AdditionalProperties != jsonschema.TrueSchema && schema.AdditionalProperties != jsonschema.FalseSchema { + original := schema.AdditionalProperties + schema.AdditionalProperties = &jsonschema.Schema{ + AnyOf: []*jsonschema.Schema{ + original, + {Type: "null"}, + }, + } + } +} + +func generateSchema(template any, dir string, allowNull bool) error { settingsSchema := jsonschema.Reflect(template) + if allowNull { + allowNullValues(settingsSchema) + } jsonSettingsSchema, err := json.MarshalIndent(settingsSchema, "", " ") if err != nil { @@ -147,6 +165,7 @@ func generateWidgetsSchema(dir string) error { widgetsTemplate := make(map[string]wconfig.WidgetConfigType) widgetsSchema := r.Reflect(&widgetsTemplate) + allowNullValues(widgetsSchema) jsonWidgetsSchema, err := json.MarshalIndent(widgetsSchema, "", " ") if err != nil { @@ -163,19 +182,19 @@ func generateWidgetsSchema(dir string) error { } func main() { - err := generateSchema(&wconfig.SettingsType{}, WaveSchemaSettingsFileName) + err := generateSchema(&wconfig.SettingsType{}, WaveSchemaSettingsFileName, false) if err != nil { log.Fatalf("settings schema error: %v", err) } connectionTemplate := make(map[string]wconfig.ConnKeywords) - err = generateSchema(&connectionTemplate, WaveSchemaConnectionsFileName) + err = generateSchema(&connectionTemplate, WaveSchemaConnectionsFileName, false) if err != nil { log.Fatalf("connections schema error: %v", err) } aiPresetsTemplate := make(map[string]wconfig.AiSettingsType) - err = generateSchema(&aiPresetsTemplate, WaveSchemaAiPresetsFileName) + err = generateSchema(&aiPresetsTemplate, WaveSchemaAiPresetsFileName, false) if err != nil { log.Fatalf("ai presets schema error: %v", err) } @@ -185,14 +204,14 @@ func main() { log.Fatalf("widgets schema error: %v", err) } - bgPresetsTemplate := make(map[string]wconfig.BgPresetsType) - err = generateSchema(&bgPresetsTemplate, WaveSchemaBgPresetsFileName) + backgroundsTemplate := make(map[string]wconfig.BackgroundConfigType) + err = generateSchema(&backgroundsTemplate, WaveSchemaBackgroundsFileName, true) if err != nil { - log.Fatalf("bg presets schema error: %v", err) + log.Fatalf("backgrounds schema error: %v", err) } waveAITemplate := make(map[string]wconfig.AIModeConfigType) - err = generateSchema(&waveAITemplate, WaveSchemaWaveAIFileName) + err = generateSchema(&waveAITemplate, WaveSchemaWaveAIFileName, false) if err != nil { log.Fatalf("waveai schema error: %v", err) } diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 70c8b3a005..b204643ee8 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -384,7 +384,6 @@ func shutdownActivityUpdate() { func createMainWshClient() { rpc := wshserver.GetMainRpcClient() - wshfs.RpcClient = rpc wshutil.DefaultRouter.RegisterTrustedLeaf(rpc, wshutil.DefaultRoute) wps.Broker.SetClient(wshutil.DefaultRouter) localInitialEnv := envutil.PruneInitialEnv(envutil.SliceToMap(os.Environ())) @@ -393,6 +392,8 @@ func createMainWshClient() { localConnWsh := wshutil.MakeWshRpc(wshrpc.RpcContext{Conn: wshrpc.LocalConnName}, remoteImpl, "conn:local") go wshremote.RunSysInfoLoop(localConnWsh, wshrpc.LocalConnName) wshutil.DefaultRouter.RegisterTrustedLeaf(localConnWsh, wshutil.MakeConnectionRouteId(wshrpc.LocalConnName)) + wshfs.RpcClient = localConnWsh + wshfs.RpcClientRouteId = wshutil.MakeConnectionRouteId(wshrpc.LocalConnName) } func grabAndRemoveEnvVars() error { @@ -560,6 +561,7 @@ func main() { createMainWshClient() sigutil.InstallShutdownSignalHandlers(doShutdown) sigutil.InstallSIGUSR1Handler() + wconfig.MigratePresetsBackgrounds() startConfigWatcher() aiusechat.InitAIModeConfigWatcher() maybeStartPprofServer() diff --git a/cmd/wsh/cmd/wshcmd-connserver.go b/cmd/wsh/cmd/wshcmd-connserver.go index 8fde91dc3c..1f892a24ce 100644 --- a/cmd/wsh/cmd/wshcmd-connserver.go +++ b/cmd/wsh/cmd/wshcmd-connserver.go @@ -183,7 +183,7 @@ func runListener(listener net.Listener, router *wshutil.WshRouter) { } } -func setupConnServerRpcClientWithRouter(router *wshutil.WshRouter, sockName string) (*wshutil.WshRpc, error) { +func setupConnServerRpcClientWithRouter(router *wshutil.WshRouter, sockName string) (*wshutil.WshRpc, string, error) { routeId := wshutil.MakeConnectionRouteId(connServerConnName) rpcCtx := wshrpc.RpcContext{ RouteId: routeId, @@ -196,7 +196,7 @@ func setupConnServerRpcClientWithRouter(router *wshutil.WshRouter, sockName stri connServerClient := wshutil.MakeWshRpc(rpcCtx, wshremote.MakeRemoteRpcServerImpl(os.Stdout, router, bareClient, false, connServerInitialEnv, sockName), routeId) router.RegisterTrustedLeaf(connServerClient, routeId) - return connServerClient, nil + return connServerClient, routeId, nil } func serverRunRouter() error { @@ -236,11 +236,12 @@ func serverRunRouter() error { sockName := getRemoteDomainSocketName() // setup the connserver rpc client first - client, err := setupConnServerRpcClientWithRouter(router, sockName) + client, bareRouteId, err := setupConnServerRpcClientWithRouter(router, sockName) if err != nil { return fmt.Errorf("error setting up connserver rpc client: %v", err) } wshfs.RpcClient = client + wshfs.RpcClientRouteId = bareRouteId log.Printf("trying to get JWT public key") @@ -360,11 +361,12 @@ func serverRunRouterDomainSocket(jwtToken string) error { log.Printf("got JWT public key") // now setup the connserver rpc client - client, err := setupConnServerRpcClientWithRouter(router, sockName) + client, bareRouteId, err := setupConnServerRpcClientWithRouter(router, sockName) if err != nil { return fmt.Errorf("error setting up connserver rpc client: %v", err) } wshfs.RpcClient = client + wshfs.RpcClientRouteId = bareRouteId // set up the local domain socket listener for local wsh commands unixListener, err := MakeRemoteUnixListener() @@ -402,6 +404,7 @@ func serverRunNormal(jwtToken string) error { return err } wshfs.RpcClient = RpcClient + wshfs.RpcClientRouteId = RpcClientRouteId WriteStdout("running wsh connserver (%s)\n", RpcContext.Conn) go func() { defer func() { diff --git a/cmd/wsh/cmd/wshcmd-file-util.go b/cmd/wsh/cmd/wshcmd-file-util.go index 13ef433ec5..77934c524e 100644 --- a/cmd/wsh/cmd/wshcmd-file-util.go +++ b/cmd/wsh/cmd/wshcmd-file-util.go @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd @@ -12,10 +12,10 @@ import ( "strings" "github.com/wavetermdev/waveterm/pkg/remote/connparse" - "github.com/wavetermdev/waveterm/pkg/remote/fileshare/fsutil" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" ) func convertNotFoundErr(err error) error { @@ -91,8 +91,38 @@ func streamWriteToFile(fileData wshrpc.FileData, reader io.Reader) error { } func streamReadFromFile(ctx context.Context, fileData wshrpc.FileData, writer io.Writer) error { - ch := wshclient.FileReadStreamCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) - return fsutil.ReadFileStreamToWriter(ctx, ch, writer) + broker := RpcClient.StreamBroker + if broker == nil { + return fmt.Errorf("stream broker not available") + } + if fileData.Info == nil { + return fmt.Errorf("file info is required") + } + readerRouteId := RpcClientRouteId + if readerRouteId == "" { + return fmt.Errorf("no route id available") + } + conn, err := connparse.ParseURI(fileData.Info.Path) + if err != nil { + return fmt.Errorf("parsing file path: %w", err) + } + writerRouteId := wshutil.MakeConnectionRouteId(conn.Host) + reader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, 256*1024) + defer reader.Close() + go func() { + <-ctx.Done() + reader.Close() + }() + data := wshrpc.CommandFileStreamData{ + Info: fileData.Info, + StreamMeta: *streamMeta, + } + _, err = wshclient.FileStreamCommand(RpcClient, data, nil) + if err != nil { + return fmt.Errorf("starting file stream: %w", err) + } + _, err = io.Copy(writer, reader) + return err } func fixRelativePaths(path string) (string, error) { diff --git a/cmd/wsh/cmd/wshcmd-file.go b/cmd/wsh/cmd/wshcmd-file.go index 3e2cc5721b..e40eb324d2 100644 --- a/cmd/wsh/cmd/wshcmd-file.go +++ b/cmd/wsh/cmd/wshcmd-file.go @@ -172,11 +172,6 @@ func fileCatRun(cmd *cobra.Command, args []string) error { return err } - _, err = checkFileSize(path, MaxFileSize) - if err != nil { - return err - } - fileData := wshrpc.FileData{ Info: &wshrpc.FileInfo{ Path: path}} diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go index 48a568d69c..9534d2e5f5 100644 --- a/cmd/wsh/cmd/wshcmd-root.go +++ b/cmd/wsh/cmd/wshcmd-root.go @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd @@ -31,6 +31,7 @@ var WrappedStdout io.Writer = &WrappedWriter{dest: os.Stdout} var WrappedStderr io.Writer = &WrappedWriter{dest: os.Stderr} var RpcClient *wshutil.WshRpc var RpcContext wshrpc.RpcContext +var RpcClientRouteId string var UsingTermWshMode bool var blockArg string var WshExitCode int @@ -140,7 +141,12 @@ func setupRpcClientWithToken(swapTokenStr string) (wshrpc.CommandAuthenticateRtn if err != nil { return rtn, fmt.Errorf("error setting up domain socket rpc client: %w", err) } - return wshclient.AuthenticateTokenCommand(RpcClient, wshrpc.CommandAuthenticateTokenData{Token: token.Token}, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) + rtn, err = wshclient.AuthenticateTokenCommand(RpcClient, wshrpc.CommandAuthenticateTokenData{Token: token.Token}, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) + if err != nil { + return rtn, err + } + RpcClientRouteId = rtn.RouteId + return rtn, nil } // returns the wrapped stdin and a new rpc client (that wraps the stdin input and stdout output) @@ -158,10 +164,11 @@ func setupRpcClient(serverImpl wshutil.ServerImpl, jwtToken string) error { if err != nil { return fmt.Errorf("error setting up domain socket rpc client: %v", err) } - _, err = wshclient.AuthenticateCommand(RpcClient, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) + authRtn, err := wshclient.AuthenticateCommand(RpcClient, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) if err != nil { return fmt.Errorf("error authenticating: %v", err) } + RpcClientRouteId = authRtn.RouteId blockId := os.Getenv("WAVETERM_BLOCKID") if blockId != "" { peerInfo := fmt.Sprintf("domain:block:%s", blockId) diff --git a/cmd/wsh/cmd/wshcmd-setbg.go b/cmd/wsh/cmd/wshcmd-setbg.go index fb5cf0fec0..4385409187 100644 --- a/cmd/wsh/cmd/wshcmd-setbg.go +++ b/cmd/wsh/cmd/wshcmd-setbg.go @@ -19,7 +19,7 @@ import ( ) var setBgCmd = &cobra.Command{ - Use: "setbg [--opacity value] [--tile|--center] [--scale value] (image-path|\"#color\"|color-name)", + Use: "setbg [--opacity value] [--tile|--center] [--scale value] [--border-color color] [--active-border-color color] (image-path|\"#color\"|color-name)", Short: "set background image or color for a tab", Long: `Set a background image or color for a tab. Colors can be specified as: - A quoted hex value like "#ff0000" (quotes required to prevent # being interpreted as a shell comment) @@ -31,18 +31,22 @@ You can also: - Use --opacity without other arguments to change just the opacity - Use --center for centered images without scaling (good for logos) - Use --scale with --center to control image size + - Use --border-color to set the block frame border color + - Use --active-border-color to set the block frame focused border color - Use --print to see the metadata without applying it`, RunE: setBgRun, PreRunE: preRunSetupRpcClient, } var ( - setBgOpacity float64 - setBgTile bool - setBgCenter bool - setBgSize string - setBgClear bool - setBgPrint bool + setBgOpacity float64 + setBgTile bool + setBgCenter bool + setBgSize string + setBgClear bool + setBgPrint bool + setBgBorderColor string + setBgActiveBorderColor string ) func init() { @@ -53,8 +57,9 @@ func init() { setBgCmd.Flags().StringVar(&setBgSize, "size", "auto", "size for centered images (px, %, or auto)") setBgCmd.Flags().BoolVar(&setBgClear, "clear", false, "clear the background") setBgCmd.Flags().BoolVar(&setBgPrint, "print", false, "print the metadata without applying it") + setBgCmd.Flags().StringVar(&setBgBorderColor, "border-color", "", "block frame border color (#RRGGBB, #RRGGBBAA, or CSS color name)") + setBgCmd.Flags().StringVar(&setBgActiveBorderColor, "active-border-color", "", "block frame focused border color (#RRGGBB, #RRGGBBAA, or CSS color name)") - // Make tile and center mutually exclusive setBgCmd.MarkFlagsMutuallyExclusive("tile", "center") } @@ -73,17 +78,41 @@ func validateHexColor(color string) error { return nil } +func validateColor(color string) error { + if strings.HasPrefix(color, "#") { + return validateHexColor(color) + } + if !CssColorNames[strings.ToLower(color)] { + return fmt.Errorf("invalid color %q: must be a hex color (#RRGGBB or #RRGGBBAA) or a CSS color name", color) + } + return nil +} + func setBgRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("setbg", rtnErr == nil) }() + borderColorChanged := cmd.Flags().Changed("border-color") + activeBorderColorChanged := cmd.Flags().Changed("active-border-color") + + if borderColorChanged { + if err := validateColor(setBgBorderColor); err != nil { + return fmt.Errorf("--border-color: %v", err) + } + } + if activeBorderColorChanged { + if err := validateColor(setBgActiveBorderColor); err != nil { + return fmt.Errorf("--active-border-color: %v", err) + } + } + // Create base metadata meta := map[string]interface{}{} // Handle opacity-only change or clear if len(args) == 0 { - if !cmd.Flags().Changed("opacity") && !setBgClear { + if !cmd.Flags().Changed("opacity") && !setBgClear && !borderColorChanged && !activeBorderColorChanged { OutputHelpMessage(cmd) return fmt.Errorf("setbg requires an image path or color value") } @@ -92,7 +121,7 @@ func setBgRun(cmd *cobra.Command, args []string) (rtnErr error) { } if setBgClear { meta["bg:*"] = true - } else { + } else if cmd.Flags().Changed("opacity") { meta["bg:opacity"] = setBgOpacity } } else if len(args) > 1 { @@ -101,6 +130,7 @@ func setBgRun(cmd *cobra.Command, args []string) (rtnErr error) { } else { // Handle background setting meta["bg:*"] = true + meta["tab:background"] = nil if setBgOpacity < 0 || setBgOpacity > 1 { return fmt.Errorf("opacity must be between 0.0 and 1.0") } @@ -159,6 +189,13 @@ func setBgRun(cmd *cobra.Command, args []string) (rtnErr error) { meta["bg"] = bgStyle } + if borderColorChanged { + meta["bg:bordercolor"] = setBgBorderColor + } + if activeBorderColorChanged { + meta["bg:activebordercolor"] = setBgActiveBorderColor + } + if setBgPrint { jsonBytes, err := json.MarshalIndent(meta, "", " ") if err != nil { diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 8a8a6330a0..abeca3429f 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -6,7 +6,7 @@ title: "Configuration" import { Kbd } from "@site/src/components/kbd"; import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; -import { VersionBadge } from "@site/src/components/versionbadge"; +import { VersionBadge, DeprecatedBadge } from "@site/src/components/versionbadge"; @@ -44,6 +44,7 @@ wsh editconfig | app:disablectrlshiftarrows | bool | Set to true to disable Ctrl+Shift block-navigation keybindings (`Arrow` and `h/j/k/l`) (defaults to false) | | app:disablectrlshiftdisplay | bool | Set to true to disable the Ctrl+Shift visual indicator display (defaults to false) | | app:focusfollowscursor | string | Controls whether block focus follows cursor movement: `"off"` (default), `"on"` (all blocks), or `"term"` (terminal blocks only) | +| app:tabbar | string | Controls the position of the tab bar: `"top"` (default) for a horizontal tab bar at the top of the window, or `"left"` for a vertical tab bar on the left side of the window | | ai:preset | string | the default AI preset to use | | ai:baseurl | string | Set the AI Base Url (must be OpenAI compatible) | | ai:apitoken | string | your AI api token | @@ -91,7 +92,8 @@ wsh editconfig | autoupdate:intervalms | float64 | time in milliseconds to wait between update checks (requires app restart) | | autoupdate:installonquit | bool | whether to automatically install updates on quit (requires app restart) | | autoupdate:channel | string | the auto update channel "latest" (stable builds), or "beta" (updated more frequently) (requires app restart) | -| tab:preset | string | a "bg@" preset to automatically apply to new tabs. e.g. `bg@green`. should match the preset key | +| tab:preset | string | a "bg@" preset to automatically apply to new tabs. e.g. `bg@green`. should match the preset key. deprecated in favor of `tab:background` | +| tab:background | string | a "bg@" preset to automatically apply to new tabs. e.g. `bg@green`. should match the preset key | | tab:confirmclose | bool | if set to true, a confirmation dialog will be shown before closing a tab (defaults to false) | | widget:showhelp | bool | whether to show help/tips widgets in right sidebar | | window:transparent | bool | set to true to enable window transparency (cannot be combined with `window:blur`) (macOS and Windows only, requires app restart, see [note on Windows compatibility](https://www.electronjs.org/docs/latest/tutorial/custom-window-styles#limitations)) | diff --git a/docs/docs/customization.mdx b/docs/docs/customization.mdx index 02fedca70a..e393c8fdb9 100644 --- a/docs/docs/customization.mdx +++ b/docs/docs/customization.mdx @@ -10,7 +10,9 @@ title: "Customization" Right click on any tab to bring up a menu which allows you to rename the tab and select different backgrounds. -It is also possible to create your own themes using custom colors, gradients, images and more by editing your presets.json config file. To see how Wave's built in tab themes are defined, you can check out our [default presets file](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/presets.json). +It is also possible to create your own background themes using custom colors, gradients, images and more by editing your backgrounds.json config file. To see how Wave's built-in tab backgrounds are defined, you can check out the [default backgrounds.json file](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/backgrounds.json). + +To apply a tab background to all new tabs by default, set the key `tab:background` in your [Wave Config File](/config) to one of the background preset keys (e.g. `"bg@ocean-depths"`). The available built-in background keys can be found in the [default backgrounds.json file](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/backgrounds.json). ## Terminal Customization @@ -26,8 +28,6 @@ in the [default termthemes.json file](https://github.com/wavetermdev/waveterm/bl If you add your own termthemes.json file in the config directory, you can also add your own custom terminal themes (just follow the same format). -You can set the key `tab:preset` in your [Wave Config File](/config) to apply a theme to all new tabs. - #### Font Size From the same context menu you can also change the font-size of the terminal. To change the default font size across all of your (non-overridden) terminals, you can set the config key `term:fontsize` to the size you want. e.g. `{ "term:fontsize": 14}`. @@ -79,6 +79,6 @@ To preview the metadata for any background without applying it, use the `--print wsh setbg --print "#ff0000" ``` -For more advanced customization options including gradients, colors, and saving your own background presets, check out our [Background Configuration](/presets#background-configurations) documentation. +For more advanced customization options including gradients, colors, and saving your own custom backgrounds, check out our [Tab Backgrounds](/tab-backgrounds) documentation. diff --git a/docs/docs/releasenotes.mdx b/docs/docs/releasenotes.mdx index 4d985607d0..db141b5626 100644 --- a/docs/docs/releasenotes.mdx +++ b/docs/docs/releasenotes.mdx @@ -6,6 +6,31 @@ sidebar_position: 200 # Release Notes +### v0.14.4 — Mar 26, 2026 + +Wave v0.14.4 introduces vertical tabs, upgrades to xterm.js v6, and includes a collection of bug fixes and internal improvements. + +**Vertical Tab Bar:** + +- **New Vertical Tab Bar Option** - Tabs can now be displayed vertically along the side of the window, giving you more horizontal space and easier access to tabs when you have many open. Toggle between horizontal and vertical tab layouts in settings. + +**Terminal Improvements:** + +- **xterm.js v6.0.0 Upgrade** - Upgraded to the latest xterm.js v6, bringing improved terminal compatibility and rendering. This should resolve various terminal rendering quirks observed with tools like Claude Code. + +**Other Changes:** + +- **`backgrounds.json`** - Renamed `presets/bg.json` to `backgrounds.json` and moved background config to new `tab:background` key (auto-migrated on startup) +- **Config Errors Moved** - Config errors removed from the tab bar and moved to Settings / WaveConfig view for less clutter +- **Warn on Unsaved Changes** - WaveConfig view now warns before discarding unsaved changes +- **Stream Performance** - Migrated file streaming to new modern interface with flow control, fixing a large time-to-first-byte streaming bug +- **macOS First Click** - Improved first-click handling on macOS (cancel the click but properly set block/WaveAI focus) +- Deprecated legacy AI widget has been removed +- [bugfix] Fixed focus bug for newly created blocks +- [bugfix] Fixed an issue around starting a new durable session by splitting an old one +- Electron upgraded to v41 +- Package updates and dependency upgrades + ### v0.14.2 — Mar 12, 2026 Wave v0.14.2 adds block/tab badges, directory preview improvements, and assorted bug fixes. @@ -479,7 +504,7 @@ New minor release that introduces Wave's connected computing extensions. We've i ### v0.9.2 — Nov 11, 2024 -New minor release with bug fixes and new features! Fixed the bug around making Wave fullscreen (also affecting certain window managers like Hyprland). We've also put a lot of work into the doc site (https://docs.waveterm.dev), including documenting how [Widgets](./widgets) and [Presets](./presets) work! +New minor release with bug fixes and new features! Fixed the bug around making Wave fullscreen (also affecting certain window managers like Hyprland). We've also put a lot of work into the doc site (https://docs.waveterm.dev), including documenting how [Widgets](./widgets) and Presets work! - Updated documentation - Wave AI now supports the Anthropic API! Checkout the [FAQ](./faq) for how to use the Claude models with Wave AI. diff --git a/docs/docs/presets.mdx b/docs/docs/tab-backgrounds.mdx similarity index 54% rename from docs/docs/presets.mdx rename to docs/docs/tab-backgrounds.mdx index 31fc5f57d7..77c02a2bb4 100644 --- a/docs/docs/presets.mdx +++ b/docs/docs/tab-backgrounds.mdx @@ -1,80 +1,57 @@ --- sidebar_position: 3.5 -id: "presets" -title: "Presets" +id: "tab-backgrounds" +title: "Tab Backgrounds" --- -# Presets +# Tab Backgrounds -Wave's preset system allows you to save and apply multiple configuration settings at once. Presets are used for: - -- Tab backgrounds: Apply visual styles to your tabs - -## Managing Presets - -You can store presets in two locations: +Wave's background system harnesses the full power of CSS backgrounds, letting you create rich visual effects through the "background" attribute. You can apply solid colors, gradients (both linear and radial), images, and even blend multiple elements together. -- `~/.config/waveterm/presets.json`: Main presets file -- `~/.config/waveterm/presets/`: Directory for organizing presets into separate files +## Managing Backgrounds -All presets are aggregated regardless of which file they're in, so you can use the `presets` directory to organize them (e.g., `presets/bg.json`). +Custom backgrounds are stored in `~/.config/waveterm/backgrounds.json`. -:::info -You can easily edit your presets using the built-in editor: +**To edit using the UI:** +1. Click the settings (gear) icon in the widget bar +2. Select "Settings" from the menu +3. Choose "Tab Backgrounds" from the settings sidebar +**Or launch from the command line:** ```bash -wsh editconfig presets.json # Edit main presets file -wsh editconfig presets/bg.json # Edit background presets +wsh editconfig backgrounds.json ``` -::: - ## File Format -Presets follow this format: +Backgrounds follow this format: ```json { - "@": { - "display:name": "", - "display:order": "", // optional - "": "" - ... + "bg@": { + "display:name": "", + "display:order": , + "bg": "", + "bg:opacity": } } ``` -The `preset-type` determines where the preset appears in Wave's interface: - -- `bg`: Appears in the "Backgrounds" submenu when right-clicking a tab - -### Common Keys - -| Key Name | Type | Function | -| ------------- | ------ | ----------------------------------------- | -| display:name | string | Name shown in the UI menu (required) | -| display:order | float | Controls the order in the menu (optional) | - -:::info -When a preset is applied, it overrides the default configuration values for that tab or block. Using `bg:*` will clear any previously overridden values, setting them back to defaults. It's recommended to include this key in your presets to ensure a clean slate. -::: - -## Background Presets - -Wave's background system harnesses the full power of CSS backgrounds, letting you create rich visual effects through the "background" attribute. You can apply solid colors, gradients (both linear and radial), images, and even blend multiple elements together. +To see how Wave's built-in backgrounds are defined, check out the [default backgrounds.json file](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/backgrounds.json). -### Configuration Keys +## Configuration Keys | Key Name | Type | Function | | -------------------- | ------ | ------------------------------------------------------------------------------------------------------- | -| bg:\* | bool | Reset all existing bg keys (recommended to prevent any existing background settings from carrying over) | -| bg | string | CSS `background` attribute for the tab (supports colors, gradients images, etc.) | +| display:name | string | Name shown in the UI menu (required) | +| display:order | float | Controls the order in the menu (optional) | +| bg | string | CSS `background` attribute for the tab (supports colors, gradients, images, etc.) | | bg:opacity | float | The opacity of the background (defaults to 0.5) | | bg:blendmode | string | The [blend mode](https://developer.mozilla.org/en-US/docs/Web/CSS/blend-mode) of the background | | bg:bordercolor | string | The color of the border when a block is not active (rarely used) | | bg:activebordercolor | string | The color of the border when a block is active | -### Examples +## Examples #### Simple solid color: @@ -82,7 +59,6 @@ Wave's background system harnesses the full power of CSS backgrounds, letting yo { "bg@blue": { "display:name": "Blue", - "bg:*": true, "bg": "blue", "bg:opacity": 0.3, "bg:activebordercolor": "rgba(0, 0, 255, 1.0)" @@ -96,7 +72,6 @@ Wave's background system harnesses the full power of CSS backgrounds, letting yo { "bg@duskhorizon": { "display:name": "Dusk Horizon", - "bg:*": true, "bg": "linear-gradient(0deg, rgba(128,0,0,1) 0%, rgba(204,85,0,0.7) 20%, rgba(255,140,0,0.6) 45%, rgba(160,90,160,0.5) 65%, rgba(60,60,120,1) 100%), radial-gradient(circle at 30% 30%, rgba(255,255,255,0.1), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.05), transparent 70%)", "bg:opacity": 0.9, "bg:blendmode": "overlay" @@ -110,7 +85,6 @@ Wave's background system harnesses the full power of CSS backgrounds, letting yo { "bg@ocean": { "display:name": "Ocean Scene", - "bg:*": true, "bg": "url('/path/to/ocean.jpg') center/cover no-repeat", "bg:opacity": 0.2 } @@ -122,10 +96,10 @@ Background images support both URLs and local file paths. For better reliability ::: :::tip -The `setbg` command can help generate background preset JSON: +The `setbg` command can help generate background JSON: ```bash -# Preview a solid color preset +# Preview a solid color background wsh setbg --print "#ff0000" { "bg:*": true, @@ -133,7 +107,7 @@ wsh setbg --print "#ff0000" "bg:opacity": 0.5 } -# Preview a centered image preset +# Preview a centered image background wsh setbg --print --center --opacity 0.3 ~/logo.png { "bg:*": true, @@ -142,5 +116,5 @@ wsh setbg --print --center --opacity 0.3 ~/logo.png } ``` -Just add the required `display:name` field to complete your preset! +Just add the required `display:name` field and a `bg@` wrapper to complete your background entry! ::: diff --git a/docs/docs/wsh-reference.mdx b/docs/docs/wsh-reference.mdx index 6ff8c2e8f5..6ed1bcaa3f 100644 --- a/docs/docs/wsh-reference.mdx +++ b/docs/docs/wsh-reference.mdx @@ -200,7 +200,7 @@ wsh editconfig presets/ai.json The `setbg` command allows you to set a background image or color for the current tab with various customization options. ```sh -wsh setbg [--opacity value] [--tile|--center] [--size value] (image-path|"#color"|color-name) +wsh setbg [--opacity value] [--tile|--center] [--size value] [--border-color color] [--active-border-color color] (image-path|"#color"|color-name) ``` You can set a background using: @@ -216,6 +216,8 @@ Flags: - `--center` - center the image without scaling (good for logos) - `--size` - size for centered images (px, %, or auto) - `--clear` - remove the background +- `--border-color color` - set the block frame border color (hex or CSS color name) +- `--active-border-color color` - set the block frame focused border color (hex or CSS color name) - `--print` - show the metadata without applying it Supported image formats: JPEG, PNG, GIF, WebP, and SVG. @@ -243,6 +245,10 @@ wsh setbg forestgreen # CSS color name # Change just the opacity of current background wsh setbg --opacity 0.7 +# Set border colors alongside a background +wsh setbg --border-color "#ff0000" --active-border-color "#00ff00" ~/pictures/background.jpg +wsh setbg --border-color steelblue forestgreen + # Remove background wsh setbg --clear @@ -258,7 +264,7 @@ The command validates that: - The center and tile options are not used together :::tip -Use `--print` to preview the metadata for any background configuration without applying it. You can then copy this JSON representation to use as a [Background Preset](/presets#background-configurations) +Use `--print` to preview the metadata for any background configuration without applying it. You can then copy this JSON representation to use as a [Background entry](/tab-backgrounds) ::: --- diff --git a/docs/src/components/versionbadge.css b/docs/src/components/versionbadge.css index 63ac0b3771..ea09d08480 100644 --- a/docs/src/components/versionbadge.css +++ b/docs/src/components/versionbadge.css @@ -20,3 +20,22 @@ background-color: var(--ifm-color-primary-dark); color: var(--ifm-background-color); } + +.deprecated-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + margin-left: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.5; + border-radius: 0.25rem; + background-color: #9e9e9e; + color: #fff; + vertical-align: middle; + white-space: nowrap; +} + +[data-theme="dark"] .deprecated-badge { + background-color: #616161; + color: #e0e0e0; +} diff --git a/docs/src/components/versionbadge.tsx b/docs/src/components/versionbadge.tsx index 58c616440c..c4af6d479f 100644 --- a/docs/src/components/versionbadge.tsx +++ b/docs/src/components/versionbadge.tsx @@ -7,4 +7,8 @@ interface VersionBadgeProps { export function VersionBadge({ version, noLeftMargin }: VersionBadgeProps) { return {version}; +} + +export function DeprecatedBadge() { + return deprecated; } \ No newline at end of file diff --git a/electron.vite.config.ts b/electron.vite.config.ts index bf0956df67..d94a166659 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -161,7 +161,7 @@ export default defineConfig({ "**/*.md", "**/*.mdx", "**/*.json", - "emain/**", + "**/emain/**", "**/*.txt", "**/*.log", ], diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 1d1ec2108a..09830b9315 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -209,7 +209,7 @@ export function initIpcHandlers() { electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => { const menu = new electron.Menu(); - const win = getWaveWindowByWebContentsId(event.sender.hostWebContents.id); + const win = getWaveWindowByWebContentsId(event.sender.hostWebContents?.id); if (win == null) { return; } @@ -353,6 +353,7 @@ export function initIpcHandlers() { const png = PNG.sync.read(overlayBuffer); const color = fac.prepareResult(fac.getColorFromArray4(png.data)); const ww = getWaveWindowByWebContentsId(event.sender.id); + if (ww == null) return; ww.setTitleBarOverlay({ color: unamePlatform === "linux" ? color.rgba : "#00000000", symbolColor: color.isDark ? "white" : "black", diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index 7bf4cc23f3..753a53adec 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -109,6 +109,9 @@ function computeBgColor(fullConfig: FullConfigType): string { const wcIdToWaveTabMap = new Map(); export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabView { + if (webContentsId == null) { + return null; + } return wcIdToWaveTabMap.get(webContentsId); } @@ -154,14 +157,15 @@ export class WaveTabView extends WebContentsView { this.waveReadyPromise.then(() => { this.isWaveReady = true; }); - wcIdToWaveTabMap.set(this.webContents.id, this); + const wcId = this.webContents.id; + wcIdToWaveTabMap.set(wcId, this); if (isDevVite) { this.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html`); } else { this.webContents.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); } this.webContents.on("destroyed", () => { - wcIdToWaveTabMap.delete(this.webContents.id); + wcIdToWaveTabMap.delete(wcId); removeWaveTabView(this.waveTabId); this.isDestroyed = true; }); @@ -283,7 +287,6 @@ function checkAndEvictCache(): void { // Otherwise, sort by lastUsedTs return a.lastUsedTs - b.lastUsedTs; }); - const now = Date.now(); for (let i = 0; i < sorted.length - MaxCacheSize; i++) { tryEvictEntry(sorted[i].waveTabId); } @@ -313,6 +316,9 @@ export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: stri tabView.webContents.on("will-frame-navigate", shFrameNavHandler); tabView.webContents.on("did-attach-webview", (event, wc) => { wc.setWindowOpenHandler((details) => { + if (wc == null || wc.isDestroyed() || tabView.webContents == null || tabView.webContents.isDestroyed()) { + return { action: "deny" }; + } tabView.webContents.send("webview-new-window", wc.id, details); return { action: "deny" }; }); diff --git a/emain/emain-util.ts b/emain/emain-util.ts index 08f9c3413a..88933ca8f2 100644 --- a/emain/emain-util.ts +++ b/emain/emain-util.ts @@ -119,6 +119,17 @@ export function shNavHandler(event: Electron.Event) { if (!event.frame?.parent) { // only use this handler to process iframe events (non-iframe events go to shNavHandler) @@ -135,8 +146,9 @@ export function shFrameNavHandler(event: Electron.Event { this.removeTabView(tabId, false); - }, 1000); + }, delayMs); } // the queue and this function are used to serialize operations that update the window contents view @@ -674,6 +646,9 @@ export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow { } export function getWaveWindowByWebContentsId(webContentsId: number): WaveBrowserWindow { + if (webContentsId == null) { + return null; + } const tabView = getWaveTabViewByWebContentsId(webContentsId); if (tabView == null) { return null; @@ -741,7 +716,7 @@ ipcMain.on("set-active-tab", async (event, tabId) => { await ww?.setActiveTab(tabId, true); }); -ipcMain.on("create-tab", async (event, opts) => { +ipcMain.on("create-tab", async (event, _opts) => { const senderWc = event.sender; const ww = getWaveWindowByWebContentsId(senderWc.id); if (ww != null) { @@ -815,7 +790,7 @@ ipcMain.on("delete-workspace", (event, workspaceId) => { const workspaceList = await WorkspaceService.ListWorkspaces(); - const workspaceHasWindow = !!workspaceList.find((wse) => wse.workspaceid === workspaceId)?.windowid; + const _workspaceHasWindow = !!workspaceList.find((wse) => wse.workspaceid === workspaceId)?.windowid; const choice = dialog.showMessageBoxSync(this, { type: "question", diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 112a4cc79e..32b8582141 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -3,11 +3,13 @@ import { handleWaveAIContextMenu } from "@/app/aipanel/aipanel-contextmenu"; import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils"; +import { useTabBackground } from "@/app/block/blockutil"; import { ErrorBoundary } from "@/app/element/errorboundary"; import { atoms, getSettingsKeyAtom } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { useTabModelMaybe } from "@/app/store/tab-model"; import { isBuilderWindow } from "@/app/store/windowtype"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; import { isMacOS, isWindows } from "@/util/platformutil"; import { cn } from "@/util/util"; @@ -245,12 +247,17 @@ const ConfigChangeModeFixer = memo(() => { ConfigChangeModeFixer.displayName = "ConfigChangeModeFixer"; -const AIPanelComponentInner = memo(() => { +type AIPanelComponentInnerProps = { + roundTopLeft: boolean; +}; + +const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps) => { const [isDragOver, setIsDragOver] = useState(false); const [isReactDndDragOver, setIsReactDndDragOver] = useState(false); const [initialLoadDone, setInitialLoadDone] = useState(false); const model = WaveAIModel.getInstance(); const containerRef = useRef(null); + const waveEnv = useWaveEnv(); const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; const isFocused = jotai.useAtomValue(model.isWaveAIFocusedAtom); @@ -258,6 +265,7 @@ const AIPanelComponentInner = memo(() => { const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; const isPanelVisible = jotai.useAtomValue(model.getPanelVisibleAtom()); const tabModel = useTabModelMaybe(); + const [tabBorderColor, tabActiveBorderColor] = useTabBackground(waveEnv, tabModel?.tabId); const defaultMode = jotai.useAtomValue(getSettingsKeyAtom("waveai:defaultmode")) ?? "waveai@balanced"; const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs); @@ -542,6 +550,7 @@ const AIPanelComponentInner = memo(() => { }; const showBlockMask = isLayoutMode && showOverlayBlockNums; + const borderColor = isFocused ? (tabActiveBorderColor ?? null) : (tabBorderColor ?? null); return (
{ "@container bg-zinc-900/70 flex flex-col relative", model.inBuilder ? "mt-0 h-full" : "mt-1 h-[calc(100%-4px)]", (isDragOver || isReactDndDragOver) && "bg-zinc-800 border-accent", - isFocused ? "border-2 border-accent" : "border-2 border-transparent" + isFocused && !borderColor ? "border-2 border-accent" : "border-2 border-transparent" )} style={{ + borderTopLeftRadius: roundTopLeft ? 10 : 0, borderTopRightRadius: model.inBuilder ? 0 : 10, borderBottomRightRadius: model.inBuilder ? 0 : 10, borderBottomLeftRadius: 10, + borderColor: borderColor ?? undefined, }} onFocusCapture={handleFocusCapture} onPointerEnter={handlePointerEnter} @@ -566,6 +577,7 @@ const AIPanelComponentInner = memo(() => { onDrop={handleDrop} onClick={handleClick} inert={!isPanelVisible ? true : undefined} + data-aipanel="true" > {(isDragOver || isReactDndDragOver) && allowAccess && } @@ -607,10 +619,14 @@ const AIPanelComponentInner = memo(() => { AIPanelComponentInner.displayName = "AIPanelInner"; -const AIPanelComponent = () => { +type AIPanelComponentProps = { + roundTopLeft: boolean; +}; + +const AIPanelComponent = ({ roundTopLeft }: AIPanelComponentProps) => { return ( - + ); }; diff --git a/frontend/app/app-bg.tsx b/frontend/app/app-bg.tsx index e2e60cb8ad..2956e36d58 100644 --- a/frontend/app/app-bg.tsx +++ b/frontend/app/app-bg.tsx @@ -1,6 +1,7 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { MetaKeyAtomFnType, useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { computeBgStyleFromMeta } from "@/util/waveutil"; import useResizeObserver from "@react-hook/resize-observer"; @@ -10,11 +11,20 @@ import { debounce } from "throttle-debounce"; import { atoms, getApi, WOS } from "./store/global"; import { useWaveObjectValue } from "./store/wos"; +type AppBgEnv = WaveEnvSubset<{ + getTabMetaKeyAtom: MetaKeyAtomFnType<"tab:background">; + getConfigBackgroundAtom: WaveEnv["getConfigBackgroundAtom"]; +}>; + export function AppBackground() { const bgRef = useRef(null); const tabId = useAtomValue(atoms.staticTabId); const [tabData] = useWaveObjectValue(WOS.makeORef("tab", tabId)); - const style: CSSProperties = computeBgStyleFromMeta(tabData?.meta, 0.5) ?? {}; + const env = useWaveEnv(); + const tabBg = useAtomValue(env.getTabMetaKeyAtom(tabId, "tab:background")); + const configBg = useAtomValue(env.getConfigBackgroundAtom(tabBg)); + const resolvedMeta: Omit = tabBg && configBg ? configBg : tabData?.meta; + const style: CSSProperties = computeBgStyleFromMeta(resolvedMeta, 0.5) ?? {}; const getAvgColor = useCallback( debounce(30, () => { if ( @@ -42,5 +52,11 @@ export function AppBackground() { useLayoutEffect(getAvgColor, [getAvgColor]); useResizeObserver(bgRef, getAvgColor); - return
; + return ( +
+ ); } diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 15b09785ef..e9c70a35df 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -8,14 +8,16 @@ import { getBlockBadgeAtom, } from "@/app/store/badge"; import { ClientModel } from "@/app/store/client-model"; +import { FocusManager } from "@/app/store/focusManager"; import { GlobalModel } from "@/app/store/global-model"; +import { globalStore } from "@/app/store/jotaiStore"; import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; import { WaveEnvContext } from "@/app/waveenv/waveenv"; import { makeWaveEnvImpl } from "@/app/waveenv/waveenvimpl"; import { Workspace } from "@/app/workspace/workspace"; import { getLayoutModelForStaticTab } from "@/layout/index"; import { ContextMenuModel } from "@/store/contextmenu"; -import { atoms, createBlock, getSettingsPrefixAtom, globalStore } from "@/store/global"; +import { atoms, createBlock, getSettingsPrefixAtom, refocusNode } from "@/store/global"; import { appHandleKeyDown, keyboardMouseDownHandler } from "@/store/keymodel"; import { getElemAsStr } from "@/util/focusutil"; import * as keyutil from "@/util/keyutil"; @@ -202,6 +204,83 @@ function AppFocusHandler() { return null; } +const MacOSFirstClickHandler = () => { + useEffect(() => { + if (PLATFORM !== "darwin") { + return; + } + let windowFocusTime: number = null; + let cancelNextClick = false; + const handleWindowFocus = (e: FocusEvent) => { + windowFocusTime = Date.now(); + }; + const getBlockIdFromTarget = (target: EventTarget): string => { + let elem = target as HTMLElement; + while (elem != null) { + const blockId = elem.dataset?.blockid; + if (blockId) { + return blockId; + } + elem = elem.parentElement; + } + return null; + }; + const isAIPanelTarget = (target: EventTarget): boolean => { + let elem = target as HTMLElement; + while (elem != null) { + if (elem.dataset?.aipanel) { + return true; + } + elem = elem.parentElement; + } + return false; + }; + const handleMouseDown = (e: MouseEvent) => { + const timeDiff = Date.now() - windowFocusTime; + if (windowFocusTime != null && timeDiff < 50) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + cancelNextClick = true; + const blockId = getBlockIdFromTarget(e.target); + if (blockId != null) { + setTimeout(() => { + console.log("macos first-click, focusing block", blockId); + refocusNode(blockId); + }, 10); + } else if (isAIPanelTarget(e.target)) { + setTimeout(() => { + console.log("macos first-click, focusing AI panel"); + FocusManager.getInstance().setWaveAIFocused(true); + }, 10); + } + console.log("macos first-click detected, canceled", timeDiff + "ms"); + return; + } + cancelNextClick = false; + }; + const handleClick = (e: MouseEvent) => { + if (!cancelNextClick) { + return; + } + cancelNextClick = false; + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + console.log("macos first-click (click event) canceled"); + }; + window.addEventListener("focus", handleWindowFocus); + window.addEventListener("mousedown", handleMouseDown, true); + window.addEventListener("click", handleClick, true); + return () => { + window.removeEventListener("focus", handleWindowFocus); + window.removeEventListener("mousedown", handleMouseDown, true); + window.removeEventListener("click", handleClick, true); + }; + }, []); + return null; +}; + const AppKeyHandlers = () => { useEffect(() => { const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown); @@ -299,6 +378,7 @@ const AppInner = () => { onContextMenu={handleContextMenu} > + diff --git a/frontend/app/asset/claude-color.svg b/frontend/app/asset/claude-color.svg new file mode 100644 index 0000000000..b70e167740 --- /dev/null +++ b/frontend/app/asset/claude-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 126f208813..b09cc1bdcc 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -144,6 +144,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { const focusElemRef = useRef(null); const blockRef = useRef(null); const contentRef = useRef(null); + const pendingFocusRafRef = useRef(null); const [blockClicked, setBlockClicked] = useState(false); const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")) ?? ""; const isFocused = useAtomValue(nodeModel.isFocused); @@ -156,6 +157,14 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { const innerRect = useDebouncedNodeInnerRect(nodeModel); const noPadding = useAtomValueSafe(viewModel.noPadding); + useEffect(() => { + return () => { + if (pendingFocusRafRef.current != null) { + cancelAnimationFrame(pendingFocusRafRef.current); + } + }; + }, []); + useLayoutEffect(() => { setBlockClicked(isFocused); }, [isFocused]); @@ -221,11 +230,21 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { ); const setFocusTarget = useCallback(() => { + if (pendingFocusRafRef.current != null) { + cancelAnimationFrame(pendingFocusRafRef.current); + pendingFocusRafRef.current = null; + } const ok = viewModel?.giveFocus?.(); if (ok) { return; } focusElemRef.current?.focus({ preventScroll: true }); + pendingFocusRafRef.current = requestAnimationFrame(() => { + pendingFocusRafRef.current = null; + if (blockRef.current?.contains(document.activeElement)) { + viewModel?.giveFocus?.(); + } + }); }, [viewModel]); const focusFromPointerEnter = useCallback( diff --git a/frontend/app/block/blockenv.ts b/frontend/app/block/blockenv.ts index 000228c014..f4eebb192d 100644 --- a/frontend/app/block/blockenv.ts +++ b/frontend/app/block/blockenv.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { - BlockMetaKeyAtomFnType, ConnConfigKeyAtomFnType, + MetaKeyAtomFnType, SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset, @@ -36,7 +36,7 @@ export type BlockEnv = WaveEnvSubset<{ getConnStatusAtom: WaveEnv["getConnStatusAtom"]; getLocalHostDisplayNameAtom: WaveEnv["getLocalHostDisplayNameAtom"]; getConnConfigKeyAtom: ConnConfigKeyAtomFnType<"conn:wshenabled">; - getBlockMetaKeyAtom: BlockMetaKeyAtomFnType< + getBlockMetaKeyAtom: MetaKeyAtomFnType< | "frame:text" | "frame:activebordercolor" | "frame:bordercolor" @@ -46,4 +46,6 @@ export type BlockEnv = WaveEnvSubset<{ | "frame:title" | "frame:icon" >; + getTabMetaKeyAtom: MetaKeyAtomFnType<"bg:activebordercolor" | "bg:bordercolor" | "tab:background">; + getConfigBackgroundAtom: WaveEnv["getConfigBackgroundAtom"]; }>; diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 0b4abb755b..8ff2e2d0a7 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -3,7 +3,7 @@ import { BlockModel } from "@/app/block/block-model"; import { BlockFrame_Header } from "@/app/block/blockframe-header"; -import { blockViewToIcon, getViewIconElem } from "@/app/block/blockutil"; +import { blockViewToIcon, getViewIconElem, useTabBackground } from "@/app/block/blockutil"; import { ConnStatusOverlay } from "@/app/block/connstatusoverlay"; import { ChangeConnectionBlockModal } from "@/app/modals/conntypeahead"; import { getBlockComponentModel, globalStore, useBlockAtom } from "@/app/store/global"; @@ -36,8 +36,7 @@ const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:activebordercolor") ); const frameBorderColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:bordercolor")); - const tabActiveBorderColor = jotai.useAtomValue(tabModel.getTabMetaAtom("bg:activebordercolor")); - const tabBorderColor = jotai.useAtomValue(tabModel.getTabMetaAtom("bg:bordercolor")); + const [tabBorderColor, tabActiveBorderColor] = useTabBackground(waveEnv, tabModel.tabId); const style: React.CSSProperties = {}; let showBlockMask = false; @@ -107,9 +106,13 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { const connModalOpen = jotai.useAtomValue(changeConnModalAtom); const isMagnified = jotai.useAtomValue(nodeModel.isMagnified); const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral); - const [magnifiedBlockBlurAtom] = React.useState(() => waveEnv.getSettingsKeyAtom("window:magnifiedblockblurprimarypx")); + const [magnifiedBlockBlurAtom] = React.useState(() => + waveEnv.getSettingsKeyAtom("window:magnifiedblockblurprimarypx") + ); const magnifiedBlockBlur = jotai.useAtomValue(magnifiedBlockBlurAtom); - const [magnifiedBlockOpacityAtom] = React.useState(() => waveEnv.getSettingsKeyAtom("window:magnifiedblockopacity")); + const [magnifiedBlockOpacityAtom] = React.useState(() => + waveEnv.getSettingsKeyAtom("window:magnifiedblockopacity") + ); const magnifiedBlockOpacity = jotai.useAtomValue(magnifiedBlockOpacityAtom); const connBtnRef = React.useRef(null); const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); @@ -141,7 +144,11 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { if (!util.isLocalConnName(connName)) { console.log("ensure conn", nodeModel.blockId, connName); waveEnv.rpc - .ConnEnsureCommand(TabRpcClient, { connname: connName, logblockid: nodeModel.blockId }, { timeout: 60000 }) + .ConnEnsureCommand( + TabRpcClient, + { connname: connName, logblockid: nodeModel.blockId }, + { timeout: 60000 } + ) .catch((e) => { console.log("error ensuring connection", nodeModel.blockId, connName, e); }); diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 01346183a0..92d976400f 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -2,13 +2,24 @@ // SPDX-License-Identifier: Apache-2.0 import { Button } from "@/app/element/button"; +import { + MetaKeyAtomFnType, + WaveEnv, + WaveEnvSubset, +} from "@/app/waveenv/waveenv"; import { IconButton, ToggleIconButton } from "@/element/iconbutton"; import { MagnifyIcon } from "@/element/magnify"; import { MenuButton } from "@/element/menubutton"; import * as util from "@/util/util"; import clsx from "clsx"; +import * as jotai from "jotai"; import * as React from "react"; +export type TabBackgroundEnv = WaveEnvSubset<{ + getTabMetaKeyAtom: MetaKeyAtomFnType<"bg:activebordercolor" | "bg:bordercolor" | "tab:background">; + getConfigBackgroundAtom: WaveEnv["getConfigBackgroundAtom"]; +}>; + export const colorRegex = /^((#[0-9a-f]{6,8})|([a-z]+))$/; export const NumActiveConnColors = 8; @@ -155,6 +166,19 @@ export function getViewIconElem( } } +export function useTabBackground( + waveEnv: TabBackgroundEnv, + tabId: string | null +): [string, string, BackgroundConfigType] { + const tabActiveBorderColorDirect = jotai.useAtomValue(waveEnv.getTabMetaKeyAtom(tabId, "bg:activebordercolor")); + const tabBorderColorDirect = jotai.useAtomValue(waveEnv.getTabMetaKeyAtom(tabId, "bg:bordercolor")); + const tabBg = jotai.useAtomValue(waveEnv.getTabMetaKeyAtom(tabId, "tab:background")); + const configBg = jotai.useAtomValue(waveEnv.getConfigBackgroundAtom(tabBg)); + const tabActiveBorderColor = tabActiveBorderColorDirect ?? configBg?.["bg:activebordercolor"]; + const tabBorderColor = tabBorderColorDirect ?? configBg?.["bg:bordercolor"]; + return [tabBorderColor, tabActiveBorderColor, configBg]; +} + export const Input = React.memo( ({ decl, className, preview }: { decl: HeaderInput; className: string; preview: boolean }) => { const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl; diff --git a/frontend/app/modals/modalsrenderer.tsx b/frontend/app/modals/modalsrenderer.tsx index 218d777831..ebb67899b8 100644 --- a/frontend/app/modals/modalsrenderer.tsx +++ b/frontend/app/modals/modalsrenderer.tsx @@ -5,7 +5,8 @@ import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding"; import { CurrentOnboardingVersion } from "@/app/onboarding/onboarding-common"; import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade"; import { ClientModel } from "@/app/store/client-model"; -import { atoms, globalPrimaryTabStartup, globalStore } from "@/store/global"; +import { globalStore } from "@/app/store/jotaiStore"; +import { atoms, globalPrimaryTabStartup } from "@/store/global"; import { modalsModel } from "@/store/modalmodel"; import * as jotai from "jotai"; import { useEffect } from "react"; diff --git a/frontend/app/monaco/schemaendpoints.ts b/frontend/app/monaco/schemaendpoints.ts index 2b3134e215..5365d1c739 100644 --- a/frontend/app/monaco/schemaendpoints.ts +++ b/frontend/app/monaco/schemaendpoints.ts @@ -1,10 +1,10 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import settingsSchema from "../../../schema/settings.json"; -import connectionsSchema from "../../../schema/connections.json"; import aipresetsSchema from "../../../schema/aipresets.json"; -import bgpresetsSchema from "../../../schema/bgpresets.json"; +import backgroundsSchema from "../../../schema/backgrounds.json"; +import connectionsSchema from "../../../schema/connections.json"; +import settingsSchema from "../../../schema/settings.json"; import waveaiSchema from "../../../schema/waveai.json"; import widgetsSchema from "../../../schema/widgets.json"; @@ -31,9 +31,9 @@ const MonacoSchemas: SchemaInfo[] = [ schema: aipresetsSchema, }, { - uri: "wave://schema/bgpresets.json", - fileMatch: ["*/WAVECONFIGPATH/presets/bg.json"], - schema: bgpresetsSchema, + uri: "wave://schema/backgrounds.json", + fileMatch: ["*/WAVECONFIGPATH/backgrounds.json"], + schema: backgroundsSchema, }, { uri: "wave://schema/waveai.json", diff --git a/frontend/app/onboarding/onboarding-common.tsx b/frontend/app/onboarding/onboarding-common.tsx index 60711746e1..96e49b1a79 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.3"; +export const CurrentOnboardingVersion = "v0.14.4"; export function OnboardingGradientBg() { return ( diff --git a/frontend/app/onboarding/onboarding-upgrade-patch.tsx b/frontend/app/onboarding/onboarding-upgrade-patch.tsx index 0eded88f12..c3dd5004a2 100644 --- a/frontend/app/onboarding/onboarding-upgrade-patch.tsx +++ b/frontend/app/onboarding/onboarding-upgrade-patch.tsx @@ -25,6 +25,7 @@ import { UpgradeOnboardingModal_v0_13_1_Content } from "./onboarding-upgrade-v01 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"; +import { UpgradeOnboardingModal_v0_14_4_Content } from "./onboarding-upgrade-v0144"; interface VersionConfig { version: string; @@ -139,6 +140,12 @@ export const UpgradeOnboardingVersions: VersionConfig[] = [ version: "v0.14.3", content: () => , prevText: "Prev (v0.14.1)", + nextText: "Next (v0.14.4)", + }, + { + version: "v0.14.4", + content: () => , + prevText: "Prev (v0.14.3)", }, ]; diff --git a/frontend/app/onboarding/onboarding-upgrade-v0144.tsx b/frontend/app/onboarding/onboarding-upgrade-v0144.tsx new file mode 100644 index 0000000000..6fc50f8919 --- /dev/null +++ b/frontend/app/onboarding/onboarding-upgrade-v0144.tsx @@ -0,0 +1,84 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +const UpgradeOnboardingModal_v0_14_4_Content = () => { + return ( +
+
+

+ Wave v0.14.4 introduces vertical tabs, upgrades to xterm.js v6, and includes bug fixes and UI + improvements. +

+
+ +
+
+ +
+
+
Vertical Tab Bar
+
+
    +
  • + New Vertical Tab Bar Option - Tabs can now be displayed vertically + along the side of the window for more horizontal space. Toggle between horizontal and + vertical layouts in settings. +
  • +
+
+
+
+ +
+
+ +
+
+
Terminal Improvements
+
+
    +
  • + xterm.js v6.0.0 Upgrade - Improved terminal compatibility and + rendering, resolving quirks with tools like Claude Code +
  • +
+
+
+
+ +
+
+ +
+
+
Other Changes
+
+
    +
  • + macOS First Click - First click now focuses the clicked widget +
  • +
  • + + backgrounds.json + {" "} + - Renamed presets/bg.json to backgrounds.json +
  • +
  • + Config Errors Moved - Config errors to the WaveConfig view for less + clutter +
  • +
  • WaveConfig now warns on Unsaved Changes
  • +
  • Preview streaming fixes for images/videos
  • +
  • Deprecated legacy AI widget has been removed
  • +
  • [bugfix] Fixed focus bug for newly created blocks
  • +
+
+
+
+
+ ); +}; + +UpgradeOnboardingModal_v0_14_4_Content.displayName = "UpgradeOnboardingModal_v0_14_4_Content"; + +export { UpgradeOnboardingModal_v0_14_4_Content }; diff --git a/frontend/app/store/focusManager.ts b/frontend/app/store/focusManager.ts index e0c0c99832..58f78951ee 100644 --- a/frontend/app/store/focusManager.ts +++ b/frontend/app/store/focusManager.ts @@ -1,9 +1,12 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + import { waveAIHasFocusWithin } from "@/app/aipanel/waveai-focus-utils"; import { WaveAIModel } from "@/app/aipanel/waveai-model"; -import { atoms, getBlockComponentModel } from "@/app/store/global"; +import { getBlockComponentModel } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; -import { focusedBlockId } from "@/util/focusutil"; import { getLayoutModelForStaticTab } from "@/layout/index"; +import { focusedBlockId } from "@/util/focusutil"; import { Atom, atom, type PrimitiveAtom } from "jotai"; export type FocusStrType = "node" | "waveai"; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 01d4ebbc96..acc0f4d518 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -132,6 +132,10 @@ function getBlockMetaKeyAtom(blockId: string, key: T): return metaAtom; } +function getTabMetaKeyAtom(tabId: string, key: T): Atom { + return getOrefMetaKeyAtom(WOS.makeORef("tab", tabId), key); +} + function getOrefMetaKeyAtom(oref: string, key: T): Atom { const orefCache = getSingleOrefAtomCache(oref); const metaAtomName = "#meta-" + key; @@ -229,6 +233,21 @@ function useSettingsKeyAtom(key: T): SettingsType[ return useAtomValue(getSettingsKeyAtom(key)); } +const configBackgroundAtomCache = new Map>(); + +function getConfigBackgroundAtom(bgKey: string | null): Atom { + if (isPreviewWindow() || bgKey == null) return NullAtom as Atom; + let bgAtom = configBackgroundAtomCache.get(bgKey); + if (bgAtom == null) { + bgAtom = atom((get) => { + const fullConfig = get(atoms.fullConfigAtom); + return fullConfig.backgrounds?.[bgKey]; + }); + configBackgroundAtomCache.set(bgKey, bgAtom); + } + return bgAtom; +} + function getSettingsPrefixAtom(prefix: string): Atom { if (isPreviewWindow()) return NullAtom as Atom; let settingsPrefixAtom = settingsAtomCache.get(prefix + ":"); @@ -319,7 +338,6 @@ function useBlockAtom(blockId: string, name: string, makeFn: () => Atom): if (atom == null) { atom = makeFn(); blockCache.set(name, atom); - console.log("New BlockAtom", blockId, name); } return atom as Atom; } @@ -666,8 +684,10 @@ export { getApi, getBlockComponentModel, getBlockMetaKeyAtom, - getConnConfigKeyAtom, getBlockTermDurableAtom, + getTabMetaKeyAtom, + getConfigBackgroundAtom, + getConnConfigKeyAtom, getConnStatusAtom, getFocusedBlockId, getHostName, diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 6b9f4a72d4..2f5024f0ef 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -366,18 +366,18 @@ export class RpcApiType { return client.wshRpcCall("fileread", data, opts); } - // command "filereadstream" [responsestream] - FileReadStreamCommand(client: WshClient, data: FileData, opts?: RpcOpts): AsyncGenerator { - if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "filereadstream", data, opts); - return client.wshRpcStream("filereadstream", data, opts); - } - // command "filerestorebackup" [call] FileRestoreBackupCommand(client: WshClient, data: CommandFileRestoreBackupData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filerestorebackup", data, opts); return client.wshRpcCall("filerestorebackup", data, opts); } + // command "filestream" [call] + FileStreamCommand(client: WshClient, data: CommandFileStreamData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filestream", data, opts); + return client.wshRpcCall("filestream", data, opts); + } + // command "filewrite" [call] FileWriteCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filewrite", data, opts); @@ -618,6 +618,12 @@ export class RpcApiType { return client.wshRpcCall("listalleditableapps", null, opts); } + // command "macosversion" [call] + MacOSVersionCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "macosversion", null, opts); + return client.wshRpcCall("macosversion", null, opts); + } + // command "makedraftfromlocal" [call] MakeDraftFromLocalCommand(client: WshClient, data: CommandMakeDraftFromLocalData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "makedraftfromlocal", data, opts); @@ -714,6 +720,12 @@ export class RpcApiType { return client.wshRpcCall("remotefilemultiinfo", data, opts); } + // command "remotefilestream" [call] + RemoteFileStreamCommand(client: WshClient, data: CommandRemoteFileStreamData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefilestream", data, opts); + return client.wshRpcCall("remotefilestream", data, opts); + } + // command "remotefiletouch" [call] RemoteFileTouchCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefiletouch", data, opts); @@ -762,12 +774,6 @@ export class RpcApiType { return client.wshRpcStream("remotestreamcpudata", null, opts); } - // command "remotestreamfile" [responsestream] - RemoteStreamFileCommand(client: WshClient, data: CommandRemoteStreamFileData, opts?: RpcOpts): AsyncGenerator { - if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "remotestreamfile", data, opts); - return client.wshRpcStream("remotestreamfile", data, opts); - } - // command "remoteterminatejobmanager" [call] RemoteTerminateJobManagerCommand(client: WshClient, data: CommandRemoteTerminateJobManagerData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remoteterminatejobmanager", data, opts); diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 6b3679bb37..7b2aa6856e 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { getTabBadgeAtom } from "@/app/store/badge"; -import { getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global"; +import { refocusNode } from "@/app/store/global"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv"; import { Button } from "@/element/button"; @@ -14,10 +14,12 @@ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, import { makeORef } from "../store/wos"; import { TabBadges } from "./tabbadges"; import "./tab.scss"; +import { buildTabContextMenu } from "./tabcontextmenu"; -type TabEnv = WaveEnvSubset<{ +export type TabEnv = WaveEnvSubset<{ rpc: { ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; + SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"]; }; @@ -25,6 +27,7 @@ type TabEnv = WaveEnvSubset<{ fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; }; wos: WaveEnv["wos"]; + getSettingsKeyAtom: WaveEnv["getSettingsKeyAtom"]; showContextMenu: WaveEnv["showContextMenu"]; }>; @@ -216,88 +219,6 @@ const TabV = forwardRef((props, ref) => { TabV.displayName = "TabV"; -const FlagColors: { label: string; value: string }[] = [ - { label: "Green", value: "#58C142" }, - { label: "Teal", value: "#00FFDB" }, - { label: "Blue", value: "#429DFF" }, - { label: "Purple", value: "#BF55EC" }, - { label: "Red", value: "#FF453A" }, - { label: "Orange", value: "#FF9500" }, - { label: "Yellow", value: "#FFE900" }, -]; - -function buildTabContextMenu( - id: string, - renameRef: React.RefObject<(() => void) | null>, - onClose: (event: React.MouseEvent | null) => void, - env: TabEnv -): ContextMenuItem[] { - const menu: ContextMenuItem[] = []; - menu.push( - { label: "Rename Tab", click: () => renameRef.current?.() }, - { - label: "Copy TabId", - click: () => fireAndForget(() => navigator.clipboard.writeText(id)), - }, - { type: "separator" } - ); - const tabORef = makeORef("tab", id); - const currentFlagColor = globalStore.get(getOrefMetaKeyAtom(tabORef, "tab:flagcolor")) ?? null; - const flagSubmenu: ContextMenuItem[] = [ - { - label: "None", - type: "checkbox", - checked: currentFlagColor == null, - click: () => - fireAndForget(() => - env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": null } }) - ), - }, - ...FlagColors.map((fc) => ({ - label: fc.label, - type: "checkbox" as const, - checked: currentFlagColor === fc.value, - click: () => - fireAndForget(() => - env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": fc.value } }) - ), - })), - ]; - menu.push({ label: "Flag Tab", type: "submenu", submenu: flagSubmenu }, { type: "separator" }); - const fullConfig = globalStore.get(env.atoms.fullConfigAtom); - const bgPresets: string[] = []; - for (const key in fullConfig?.presets ?? {}) { - if (key.startsWith("bg@") && fullConfig.presets[key] != null) { - bgPresets.push(key); - } - } - bgPresets.sort((a, b) => { - const aOrder = fullConfig.presets[a]["display:order"] ?? 0; - const bOrder = fullConfig.presets[b]["display:order"] ?? 0; - return aOrder - bOrder; - }); - if (bgPresets.length > 0) { - const submenu: ContextMenuItem[] = []; - const oref = makeORef("tab", id); - for (const presetName of bgPresets) { - // preset cannot be null (filtered above) - const preset = fullConfig.presets[presetName]; - submenu.push({ - label: preset["display:name"] ?? presetName, - click: () => - fireAndForget(async () => { - await env.rpc.SetMetaCommand(TabRpcClient, { oref, meta: preset }); - env.rpc.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true }); - recordTEvent("action:settabtheme"); - }), - }); - } - menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" }); - } - menu.push({ label: "Close Tab", click: () => onClose(null) }); - return menu; -} - interface TabProps { id: string; active: boolean; diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index a5cbff3398..62e9052e31 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -2,11 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; -import { modalsModel } from "@/app/store/modalmodel"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { deleteLayoutModelForTab } from "@/layout/index"; +import { isMacOSTahoeOrLater } from "@/util/platformutil"; import { fireAndForget } from "@/util/util"; import { useAtomValue } from "jotai"; import { OverlayScrollbars } from "overlayscrollbars"; @@ -20,6 +20,9 @@ import { WorkspaceSwitcher } from "./workspaceswitcher"; const TabDefaultWidth = 130; const TabMinWidth = 100; +const MacOSTrafficLightsWidth = 74; +const MacOSTahoeTrafficLightsWidth = 80; + const OSOptions = { overflow: { x: "scroll", @@ -39,6 +42,7 @@ const OSOptions = { interface TabBarProps { workspace: Workspace; + noTabs?: boolean; } const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject }) => { @@ -71,68 +75,6 @@ const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject { - const env = useWaveEnv(); - const fullConfig = useAtomValue(env.atoms.fullConfigAtom); - - if (fullConfig?.configerrors == null || fullConfig?.configerrors.length == 0) { - return ( -
-

Configuration Clean

-

There are no longer any errors detected in your config.

-
- ); - } - if (fullConfig?.configerrors.length == 1) { - const singleError = fullConfig.configerrors[0]; - return ( -
-

Configuration Error

-
- {singleError.file}: {singleError.err} -
-
- ); - } - return ( -
-

Configuration Error

-
    - {fullConfig.configerrors.map((error, index) => ( -
  • - {error.file}: {error.err} -
  • - ))} -
-
- ); -}; - -const ConfigErrorIcon = () => { - const env = useWaveEnv(); - const hasConfigErrors = useAtomValue(env.atoms.hasConfigErrors); - - const handleClick = useCallback(() => { - modalsModel.pushModal("MessageModal", { children: }); - }, []); - - if (!hasConfigErrors) { - return null; - } - return ( - - - - ); -}; - function strArrayIsEqual(a: string[], b: string[]) { // null check if (a == null && b == null) { @@ -152,7 +94,7 @@ function strArrayIsEqual(a: string[], b: string[]) { return true; } -const TabBar = memo(({ workspace }: TabBarProps) => { +const TabBar = memo(({ workspace, noTabs }: TabBarProps) => { const env = useWaveEnv(); const [tabIds, setTabIds] = useState([]); const [dragStartPositions, setDragStartPositions] = useState([]); @@ -192,7 +134,6 @@ const TabBar = memo(({ workspace }: TabBarProps) => { 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; @@ -330,7 +271,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { }; }, [handleResizeTabs]); - // update layout on changed tabIds, tabsLoaded, newTabId, hideAiButton, appUpdateStatus, hasConfigErrors, or zoomFactor + // update layout on changed tabIds, tabsLoaded, newTabId, hideAiButton, appUpdateStatus, or zoomFactor useEffect(() => { // Check if all tabs are loaded const allLoaded = tabIds.length > 0 && tabIds.every((id) => tabsLoaded[id]); @@ -348,7 +289,6 @@ const TabBar = memo(({ workspace }: TabBarProps) => { saveTabsPosition, hideAiButton, appUpdateStatus, - hasConfigErrors, zoomFactor, showMenuBar, ]); @@ -635,10 +575,13 @@ const TabBar = memo(({ workspace }: TabBarProps) => { // Calculate window drag left width based on platform and state let windowDragLeftWidth = 10; if (env.isMacOS() && !isFullScreen) { + const trafficLightsWidth = isMacOSTahoeOrLater() + ? MacOSTahoeTrafficLightsWidth + : MacOSTrafficLightsWidth; if (zoomFactor > 0) { - windowDragLeftWidth = 74 / zoomFactor; + windowDragLeftWidth = trafficLightsWidth / zoomFactor; } else { - windowDragLeftWidth = 74; + windowDragLeftWidth = trafficLightsWidth; } } @@ -680,33 +623,41 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
-
- {tabIds.map((tabId, index) => { - const isActive = activeTabId === tabId; - const showDivider = index !== 0 && !isActive && index !== activeTabIndex + 1; - return ( - handleSelectTab(tabId)} - active={isActive} - onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])} - onClose={(event) => handleCloseTab(event, tabId)} - onLoaded={() => handleTabLoaded(tabId)} - isDragging={draggingTab === tabId} - tabWidth={tabWidthRef.current} - isNew={tabId === newTabId} - /> - ); - })} +
+ {!noTabs && + tabIds.map((tabId, index) => { + const isActive = activeTabId === tabId; + const showDivider = index !== 0 && !isActive && index !== activeTabIndex + 1; + return ( + handleSelectTab(tabId)} + active={isActive} + onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])} + onClose={(event) => handleCloseTab(event, tabId)} + onLoaded={() => handleTabLoaded(tabId)} + isDragging={draggingTab === tabId} + tabWidth={tabWidthRef.current} + isNew={tabId === newTabId} + /> + ); + })}
); } diff --git a/frontend/app/tab/vtabbarenv.ts b/frontend/app/tab/vtabbarenv.ts new file mode 100644 index 0000000000..2533780776 --- /dev/null +++ b/frontend/app/tab/vtabbarenv.ts @@ -0,0 +1,41 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; + +export type VTabBarEnv = WaveEnvSubset<{ + electron: { + createTab: WaveEnv["electron"]["createTab"]; + closeTab: WaveEnv["electron"]["closeTab"]; + setActiveTab: WaveEnv["electron"]["setActiveTab"]; + deleteWorkspace: WaveEnv["electron"]["deleteWorkspace"]; + createWorkspace: WaveEnv["electron"]["createWorkspace"]; + switchWorkspace: WaveEnv["electron"]["switchWorkspace"]; + installAppUpdate: WaveEnv["electron"]["installAppUpdate"]; + }; + rpc: { + UpdateWorkspaceTabIdsCommand: WaveEnv["rpc"]["UpdateWorkspaceTabIdsCommand"]; + UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"]; + ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; + SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; + SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; + }; + atoms: { + staticTabId: WaveEnv["atoms"]["staticTabId"]; + fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + reinitVersion: WaveEnv["atoms"]["reinitVersion"]; + documentHasFocus: WaveEnv["atoms"]["documentHasFocus"]; + workspace: WaveEnv["atoms"]["workspace"]; + updaterStatusAtom: WaveEnv["atoms"]["updaterStatusAtom"]; + isFullScreen: WaveEnv["atoms"]["isFullScreen"]; + }; + services: { + workspace: WaveEnv["services"]["workspace"]; + }; + wos: WaveEnv["wos"]; + showContextMenu: WaveEnv["showContextMenu"]; + getSettingsKeyAtom: SettingsKeyAtomFnType<"tab:confirmclose" | "app:tabbar" | "app:hideaibutton">; + mockSetWaveObj: WaveEnv["mockSetWaveObj"]; + isWindows: WaveEnv["isWindows"]; + isMacOS: WaveEnv["isMacOS"]; +}>; diff --git a/frontend/app/view/aifilediff/aifilediff.tsx b/frontend/app/view/aifilediff/aifilediff.tsx index 3b853a6eb6..9d96d290c2 100644 --- a/frontend/app/view/aifilediff/aifilediff.tsx +++ b/frontend/app/view/aifilediff/aifilediff.tsx @@ -3,10 +3,10 @@ import type { BlockNodeModel } from "@/app/block/blocktypes"; import type { TabModel } from "@/app/store/tab-model"; -import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { DiffViewer } from "@/app/view/codeeditor/diffviewer"; -import { globalStore, WOS } from "@/store/global"; +import type { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; +import { globalStore } from "@/store/jotaiStore"; import { base64ToString } from "@/util/util"; import * as jotai from "jotai"; import { useEffect } from "react"; @@ -17,10 +17,18 @@ type DiffData = { fileName: string; }; +export type AiFileDiffEnv = WaveEnvSubset<{ + rpc: { + WaveAIGetToolDiffCommand: WaveEnv["rpc"]["WaveAIGetToolDiffCommand"]; + }; + wos: WaveEnv["wos"]; +}>; + export class AiFileDiffViewModel implements ViewModel { blockId: string; nodeModel: BlockNodeModel; tabModel: TabModel; + env: AiFileDiffEnv; viewType = "aifilediff"; blockAtom: jotai.Atom; diffDataAtom: jotai.PrimitiveAtom; @@ -30,11 +38,12 @@ export class AiFileDiffViewModel implements ViewModel { viewName: jotai.Atom; viewText: jotai.Atom; - constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { + constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) { this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; - this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.env = waveEnv as AiFileDiffEnv; + this.blockAtom = this.env.wos.getWaveObjectAtom(`block:${blockId}`); this.diffDataAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.errorAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.loadingAtom = jotai.atom(true); @@ -76,7 +85,7 @@ function AiFileDiffView({ blockId, model }: ViewComponentProps +
{ const formattedPath = await model.formatRemoteUri(path, globalStore.get); try { - await RpcApi.FileDeleteCommand(TabRpcClient, { + await model.env.rpc.FileDeleteCommand(TabRpcClient, { path: formattedPath, recursive, }); @@ -154,7 +153,7 @@ export function handleFileDelete( } export function makeDirectoryDefaultMenuItems(model: PreviewModel): ContextMenuItem[] { - const defaultSort = globalStore.get(getSettingsKeyAtom("preview:defaultsort")) ?? "name"; + const defaultSort = globalStore.get(model.env.getSettingsKeyAtom("preview:defaultsort")) ?? "name"; const showHiddenFiles = globalStore.get(model.showHiddenFiles) ?? true; return [ { @@ -165,7 +164,9 @@ export function makeDirectoryDefaultMenuItems(model: PreviewModel): ContextMenuI type: "checkbox", checked: defaultSort === "name", click: () => - fireAndForget(() => RpcApi.SetConfigCommand(TabRpcClient, { "preview:defaultsort": "name" })), + fireAndForget(() => + model.env.rpc.SetConfigCommand(TabRpcClient, { "preview:defaultsort": "name" }) + ), }, { label: "Last Modified", @@ -173,7 +174,7 @@ export function makeDirectoryDefaultMenuItems(model: PreviewModel): ContextMenuI checked: defaultSort === "modtime", click: () => fireAndForget(() => - RpcApi.SetConfigCommand(TabRpcClient, { "preview:defaultsort": "modtime" }) + model.env.rpc.SetConfigCommand(TabRpcClient, { "preview:defaultsort": "modtime" }) ), }, ], @@ -187,7 +188,9 @@ export function makeDirectoryDefaultMenuItems(model: PreviewModel): ContextMenuI checked: showHiddenFiles, click: () => { globalStore.set(model.showHiddenFiles, true); - fireAndForget(() => RpcApi.SetConfigCommand(TabRpcClient, { "preview:showhiddenfiles": true })); + fireAndForget(() => + model.env.rpc.SetConfigCommand(TabRpcClient, { "preview:showhiddenfiles": true }) + ); }, }, { @@ -197,7 +200,7 @@ export function makeDirectoryDefaultMenuItems(model: PreviewModel): ContextMenuI click: () => { globalStore.set(model.showHiddenFiles, false); fireAndForget(() => - RpcApi.SetConfigCommand(TabRpcClient, { "preview:showhiddenfiles": false }) + model.env.rpc.SetConfigCommand(TabRpcClient, { "preview:showhiddenfiles": false }) ); }, }, diff --git a/frontend/app/view/preview/preview-directory.tsx b/frontend/app/view/preview/preview-directory.tsx index 1bd0ab9101..0940ba43b3 100644 --- a/frontend/app/view/preview/preview-directory.tsx +++ b/frontend/app/view/preview/preview-directory.tsx @@ -1,10 +1,10 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { ContextMenuModel } from "@/app/store/contextmenu"; -import { atoms, getApi, getSettingsKeyAtom, globalStore } from "@/app/store/global"; -import { RpcApi } from "@/app/store/wshclientapi"; +import { globalStore } from "@/app/store/jotaiStore"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; import { checkKeyPressed, isCharacterKeyEvent } from "@/util/keyutil"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { addOpenMenuItems } from "@/util/previewutil"; @@ -44,6 +44,7 @@ import { overwriteError, } from "./preview-directory-utils"; import { type PreviewModel } from "./preview-model"; +import type { PreviewEnv } from "./previewenv"; const PageJumpSize = 20; @@ -110,9 +111,9 @@ function DirectoryTable({ newFile, newDirectory, }: DirectoryTableProps) { - const searchActive = useAtomValue(model.directorySearchActive); - const fullConfig = useAtomValue(atoms.fullConfigAtom); - const defaultSort = useAtomValue(getSettingsKeyAtom("preview:defaultsort")) ?? "name"; + const env = useWaveEnv(); + const fullConfig = useAtomValue(env.atoms.fullConfigAtom); + const defaultSort = useAtomValue(env.getSettingsKeyAtom("preview:defaultsort")) ?? "name"; const setErrorMsg = useSetAtom(model.errorMsgAtom); const getIconFromMimeType = useCallback( (mimeType: string): string => { @@ -560,6 +561,7 @@ interface DirectoryPreviewProps { } function DirectoryPreview({ model }: DirectoryPreviewProps) { + const env = useWaveEnv(); const [searchText, setSearchText] = useState(""); const [focusIndex, setFocusIndex] = useState(0); const [unfilteredData, setUnfilteredData] = useState([]); @@ -584,28 +586,26 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { useEffect( () => fireAndForget(async () => { - let entries: FileInfo[]; + const entries: FileInfo[] = []; try { - const file = await RpcApi.FileReadCommand( - TabRpcClient, - { - info: { - path: await model.formatRemoteUri(dirPath, globalStore.get), - }, - }, - null - ); - entries = file.entries ?? []; - if (file?.info && file.info.dir && file.info?.path !== file.info?.dir) { + const remotePath = await model.formatRemoteUri(dirPath, globalStore.get); + const stream = env.rpc.FileListStreamCommand(TabRpcClient, { path: remotePath }, null); + for await (const chunk of stream) { + if (chunk?.fileinfo) { + entries.push(...chunk.fileinfo); + } + } + if (finfo?.dir && finfo?.path !== finfo?.dir) { entries.unshift({ name: "..", - path: file?.info?.dir, + path: finfo.dir, isdir: true, modtime: new Date().getTime(), mimetype: "directory", }); } } catch (e) { + console.error("Directory Read Error", e); setErrorMsg({ status: "Cannot Read Directory", text: `${e}`, @@ -680,7 +680,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { PLATFORM == PlatformMacOS && !blockData?.meta?.connection ) { - getApi().onQuicklook(selectedPath); + env.electron.onQuicklook(selectedPath); return true; } if (isCharacterKeyEvent(waveEvent)) { @@ -714,7 +714,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { const handleDropCopy = useCallback( async (data: CommandFileCopyData, isDir: boolean) => { try { - await RpcApi.FileCopyCommand(TabRpcClient, data, { timeout: data.opts.timeout }); + await env.rpc.FileCopyCommand(TabRpcClient, data, { timeout: data.opts.timeout }); } catch (e) { console.warn("Copy failed:", e); const copyError = `${e}`; @@ -801,7 +801,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { onSave: (newName: string) => { console.log(`newFile: ${newName}`); fireAndForget(async () => { - await RpcApi.FileCreateCommand( + await env.rpc.FileCreateCommand( TabRpcClient, { info: { @@ -822,7 +822,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { onSave: (newName: string) => { console.log(`newDirectory: ${newName}`); fireAndForget(async () => { - await RpcApi.FileMkdirCommand(TabRpcClient, { + await env.rpc.FileMkdirCommand(TabRpcClient, { info: { path: await model.formatRemoteUri(`${dirPath}/${newName}`, globalStore.get), }, diff --git a/frontend/app/view/preview/preview-edit.tsx b/frontend/app/view/preview/preview-edit.tsx index f63e399bba..2961771fa3 100644 --- a/frontend/app/view/preview/preview-edit.tsx +++ b/frontend/app/view/preview/preview-edit.tsx @@ -1,14 +1,14 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { globalStore } from "@/app/store/jotaiStore"; import { tryReinjectKey } from "@/app/store/keymodel"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; -import { globalStore } from "@/store/global"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget } from "@/util/util"; import { useAtomValue, useSetAtom } from "jotai"; -import * as monaco from "monaco-editor"; import type * as MonacoTypes from "monaco-editor"; +import * as monaco from "monaco-editor"; import { useEffect } from "react"; import type { SpecializedViewProps } from "./preview"; diff --git a/frontend/app/view/preview/preview-markdown.tsx b/frontend/app/view/preview/preview-markdown.tsx index 22bba88880..d47248d457 100644 --- a/frontend/app/view/preview/preview-markdown.tsx +++ b/frontend/app/view/preview/preview-markdown.tsx @@ -1,8 +1,9 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { globalStore } from "@/app/store/jotaiStore"; import { Markdown } from "@/element/markdown"; -import { getOverrideConfigAtom, globalStore } from "@/store/global"; +import { getOverrideConfigAtom } from "@/store/global"; import { useAtomValue } from "jotai"; import { useEffect, useMemo } from "react"; import type { SpecializedViewProps } from "./preview"; diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index 59cbbaca4f..8315e48b2a 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -3,11 +3,10 @@ import { BlockNodeModel } from "@/app/block/blocktypes"; import { ContextMenuModel } from "@/app/store/contextmenu"; +import { globalStore } from "@/app/store/jotaiStore"; import type { TabModel } from "@/app/store/tab-model"; -import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { getConnStatusAtom, getOverrideConfigAtom, getSettingsKeyAtom, globalStore, refocusNode } from "@/store/global"; -import * as services from "@/store/services"; +import { getOverrideConfigAtom, refocusNode } from "@/store/global"; import * as WOS from "@/store/wos"; import { goHistory, goHistoryBack, goHistoryForward } from "@/util/historyutil"; import { checkKeyPressed } from "@/util/keyutil"; @@ -21,6 +20,7 @@ import type * as MonacoTypes from "monaco-editor"; import { createRef } from "react"; import { PreviewView } from "./preview"; import { makeDirectoryDefaultMenuItems } from "./preview-directory-utils"; +import type { PreviewEnv } from "./previewenv"; // TODO drive this using config const BOOKMARKS: { label: string; path: string }[] = [ @@ -168,13 +168,15 @@ export class PreviewModel implements ViewModel { refreshCallback: () => void; directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; + env: PreviewEnv; - constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { + constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) { this.viewType = "preview"; this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; - let showHiddenFiles = globalStore.get(getSettingsKeyAtom("preview:showhiddenfiles")) ?? true; + this.env = waveEnv; + let showHiddenFiles = globalStore.get(this.env.getSettingsKeyAtom("preview:showhiddenfiles")) ?? true; this.showHiddenFiles = atom(showHiddenFiles); this.refreshVersion = atom(0); this.directorySearchActive = atom(false); @@ -184,7 +186,7 @@ export class PreviewModel implements ViewModel { this.openFileError = atom(null) as PrimitiveAtom; this.openFileModalGiveFocusRef = createRef(); this.manageConnection = atom(true); - this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.blockAtom = this.env.wos.getWaveObjectAtom(`block:${blockId}`); this.markdownShowToc = atom(false); this.filterOutNowsh = atom(true); this.monacoRef = createRef(); @@ -389,7 +391,7 @@ export class PreviewModel implements ViewModel { this.connection = atom>(async (get) => { const connName = get(this.blockAtom)?.meta?.connection; try { - await RpcApi.ConnEnsureCommand(TabRpcClient, { connname: connName }, { timeout: 60000 }); + await this.env.rpc.ConnEnsureCommand(TabRpcClient, { connname: connName }, { timeout: 60000 }); globalStore.set(this.connectionError, ""); } catch (e) { globalStore.set(this.connectionError, e as string); @@ -406,7 +408,7 @@ export class PreviewModel implements ViewModel { return null; } try { - const statFile = await RpcApi.FileInfoCommand(TabRpcClient, { + const statFile = await this.env.rpc.FileInfoCommand(TabRpcClient, { info: { path, }, @@ -436,7 +438,7 @@ export class PreviewModel implements ViewModel { return null; } try { - const file = await RpcApi.FileReadCommand(TabRpcClient, { + const file = await this.env.rpc.FileReadCommand(TabRpcClient, { info: { path, }, @@ -482,7 +484,7 @@ export class PreviewModel implements ViewModel { this.connStatus = atom((get) => { const blockData = get(this.blockAtom); const connName = blockData?.meta?.connection; - const connAtom = getConnStatusAtom(connName); + const connAtom = this.env.getConnStatusAtom(connName); return get(connAtom); }); @@ -586,7 +588,7 @@ export class PreviewModel implements ViewModel { return; } const blockOref = WOS.makeORef("block", this.blockId); - await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); + await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta); // Clear the saved file buffers globalStore.set(this.fileContentSaved, null); @@ -622,7 +624,7 @@ export class PreviewModel implements ViewModel { } updateMeta.edit = false; const blockOref = WOS.makeORef("block", this.blockId); - await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); + await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta); } async goHistoryForward() { @@ -634,13 +636,13 @@ export class PreviewModel implements ViewModel { } updateMeta.edit = false; const blockOref = WOS.makeORef("block", this.blockId); - await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); + await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta); } async setEditMode(edit: boolean) { const blockMeta = globalStore.get(this.blockAtom)?.meta; const blockOref = WOS.makeORef("block", this.blockId); - await services.ObjectService.UpdateObjectMeta(blockOref, { ...blockMeta, edit }); + await this.env.services.object.UpdateObjectMeta(blockOref, { ...blockMeta, edit }); } async handleFileSave() { @@ -654,7 +656,7 @@ export class PreviewModel implements ViewModel { return; } try { - await RpcApi.FileWriteCommand(TabRpcClient, { + await this.env.rpc.FileWriteCommand(TabRpcClient, { info: { path: await this.formatRemoteUri(filePath, globalStore.get), }, @@ -699,7 +701,7 @@ export class PreviewModel implements ViewModel { } getSettingsMenuItems(): ContextMenuItem[] { - const defaultFontSize = globalStore.get(getSettingsKeyAtom("editor:fontsize")) ?? 12; + const defaultFontSize = globalStore.get(this.env.getSettingsKeyAtom("editor:fontsize")) ?? 12; const blockData = globalStore.get(this.blockAtom); const overrideFontSize = blockData?.meta?.["editor:fontsize"]; const menuItems: ContextMenuItem[] = []; @@ -747,7 +749,7 @@ export class PreviewModel implements ViewModel { type: "checkbox", checked: overrideFontSize == fontSize, click: () => { - RpcApi.SetMetaCommand(TabRpcClient, { + this.env.rpc.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "editor:fontsize": fontSize }, }); @@ -760,7 +762,7 @@ export class PreviewModel implements ViewModel { type: "checkbox", checked: overrideFontSize == null, click: () => { - RpcApi.SetMetaCommand(TabRpcClient, { + this.env.rpc.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "editor:fontsize": null }, }); @@ -789,7 +791,7 @@ export class PreviewModel implements ViewModel { click: () => fireAndForget(async () => { const blockOref = WOS.makeORef("block", this.blockId); - await services.ObjectService.UpdateObjectMeta(blockOref, { + await this.env.services.object.UpdateObjectMeta(blockOref, { "editor:wordwrap": !wordWrap, }); }), diff --git a/frontend/app/view/preview/preview-streaming.tsx b/frontend/app/view/preview/preview-streaming.tsx index 408da54de4..57f7305064 100644 --- a/frontend/app/view/preview/preview-streaming.tsx +++ b/frontend/app/view/preview/preview-streaming.tsx @@ -3,7 +3,7 @@ import { Button } from "@/app/element/button"; import { CenteredDiv } from "@/app/element/quickelems"; -import { globalStore } from "@/store/global"; +import { globalStore } from "@/app/store/jotaiStore"; import { getWebServerEndpoint } from "@/util/endpoints"; import { formatRemoteUri } from "@/util/waveutil"; import { useAtomValue } from "jotai"; @@ -72,18 +72,14 @@ function StreamingPreview({ model }: SpecializedViewProps) { if (fileInfo.mimetype.startsWith("video/")) { return (
- +
); } if (fileInfo.mimetype.startsWith("audio/")) { return (
- +
); } diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 89eee74deb..87cf44678a 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -1,11 +1,11 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { CenteredDiv } from "@/app/element/quickelems"; -import { RpcApi } from "@/app/store/wshclientapi"; +import { globalStore } from "@/app/store/jotaiStore"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion"; -import { globalStore } from "@/store/global"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; import { isBlank, makeConnRoute } from "@/util/util"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { memo, useEffect } from "react"; @@ -16,6 +16,7 @@ import { ErrorOverlay } from "./preview-error-overlay"; import { MarkdownPreview } from "./preview-markdown"; import type { PreviewModel } from "./preview-model"; import { StreamingPreview } from "./preview-streaming"; +import type { PreviewEnv } from "./previewenv"; export type SpecializedViewProps = { model: PreviewModel; @@ -64,6 +65,7 @@ const SpecializedView = memo(({ parentRef, model }: SpecializedViewProps) => { }); const fetchSuggestions = async ( + env: PreviewEnv, model: PreviewModel, query: string, reqContext: SuggestionRequestContext @@ -74,7 +76,7 @@ const fetchSuggestions = async ( route = null; } if (reqContext?.dispose) { - RpcApi.DisposeSuggestionsCommand(TabRpcClient, reqContext.widgetid, { noresponse: true, route: route }); + env.rpc.DisposeSuggestionsCommand(TabRpcClient, reqContext.widgetid, { noresponse: true, route: route }); return null; } const fileInfo = await globalStore.get(model.statFile); @@ -89,7 +91,7 @@ const fetchSuggestions = async ( reqnum: reqContext.reqnum, "file:connection": conn, }; - return await RpcApi.FetchSuggestionsCommand(TabRpcClient, sdata, { + return await env.rpc.FetchSuggestionsCommand(TabRpcClient, sdata, { route: route, }); }; @@ -104,6 +106,7 @@ function PreviewView({ contentRef: React.RefObject; model: PreviewModel; }) { + const env = useWaveEnv(); const connStatus = useAtomValue(model.connStatus); const [errorMsg, setErrorMsg] = useAtom(model.errorMsgAtom); const connection = useAtomValue(model.connectionImmediate); @@ -140,7 +143,7 @@ function PreviewView({ } }; const fetchSuggestionsFn = async (query, ctx) => { - return await fetchSuggestions(model, query, ctx); + return await fetchSuggestions(env, model, query, ctx); }; return ( diff --git a/frontend/app/view/preview/previewenv.ts b/frontend/app/view/preview/previewenv.ts new file mode 100644 index 0000000000..30607b8459 --- /dev/null +++ b/frontend/app/view/preview/previewenv.ts @@ -0,0 +1,35 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; + +export type PreviewEnv = WaveEnvSubset<{ + electron: { + onQuicklook: WaveEnv["electron"]["onQuicklook"]; + }; + rpc: { + ConnEnsureCommand: WaveEnv["rpc"]["ConnEnsureCommand"]; + FileInfoCommand: WaveEnv["rpc"]["FileInfoCommand"]; + FileReadCommand: WaveEnv["rpc"]["FileReadCommand"]; + FileListStreamCommand: WaveEnv["rpc"]["FileListStreamCommand"]; + FileWriteCommand: WaveEnv["rpc"]["FileWriteCommand"]; + FileMoveCommand: WaveEnv["rpc"]["FileMoveCommand"]; + FileDeleteCommand: WaveEnv["rpc"]["FileDeleteCommand"]; + SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; + SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; + FetchSuggestionsCommand: WaveEnv["rpc"]["FetchSuggestionsCommand"]; + DisposeSuggestionsCommand: WaveEnv["rpc"]["DisposeSuggestionsCommand"]; + FileCopyCommand: WaveEnv["rpc"]["FileCopyCommand"]; + FileCreateCommand: WaveEnv["rpc"]["FileCreateCommand"]; + FileMkdirCommand: WaveEnv["rpc"]["FileMkdirCommand"]; + }; + atoms: { + fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + }; + services: { + object: WaveEnv["services"]["object"]; + }; + wos: WaveEnv["wos"]; + getSettingsKeyAtom: SettingsKeyAtomFnType<"preview:showhiddenfiles" | "editor:fontsize" | "preview:defaultsort">; + getConnStatusAtom: WaveEnv["getConnStatusAtom"]; +}>; diff --git a/frontend/app/view/sysinfo/sysinfo.tsx b/frontend/app/view/sysinfo/sysinfo.tsx index 30feead6c8..f283096749 100644 --- a/frontend/app/view/sysinfo/sysinfo.tsx +++ b/frontend/app/view/sysinfo/sysinfo.tsx @@ -14,7 +14,7 @@ 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, WaveEnvSubset } from "@/app/waveenv/waveenv"; +import type { MetaKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; export type SysinfoEnv = WaveEnvSubset<{ @@ -26,7 +26,7 @@ export type SysinfoEnv = WaveEnvSubset<{ fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; }; getConnStatusAtom: WaveEnv["getConnStatusAtom"]; - getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"graph:numpoints" | "sysinfo:type" | "connection" | "count">; + getBlockMetaKeyAtom: MetaKeyAtomFnType<"graph:numpoints" | "sysinfo:type" | "connection" | "count">; }>; const DefaultNumPoints = 120; diff --git a/frontend/app/view/term/fitaddon.ts b/frontend/app/view/term/fitaddon.ts index d22b5577a7..3540a792f9 100644 --- a/frontend/app/view/term/fitaddon.ts +++ b/frontend/app/view/term/fitaddon.ts @@ -8,7 +8,22 @@ import type { FitAddon as IFitApi } from "@xterm/addon-fit"; import type { ITerminalAddon, Terminal } from "@xterm/xterm"; -import { IRenderDimensions } from "@xterm/xterm/src/browser/renderer/shared/Types"; + +interface IDimensions { + width: number; + height: number; +} + +interface IRenderDimensions { + css: { + canvas: IDimensions; + cell: IDimensions; + }; + device: { + canvas: IDimensions; + cell: IDimensions; + }; +} interface ITerminalDimensions { /** diff --git a/frontend/app/view/term/osc-handlers.test.ts b/frontend/app/view/term/osc-handlers.test.ts new file mode 100644 index 0000000000..3955a2bc8f --- /dev/null +++ b/frontend/app/view/term/osc-handlers.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { isClaudeCodeCommand } from "./osc-handlers"; + +describe("isClaudeCodeCommand", () => { + it("matches direct Claude Code invocations", () => { + expect(isClaudeCodeCommand("claude")).toBe(true); + expect(isClaudeCodeCommand("claude --dangerously-skip-permissions")).toBe(true); + }); + + it("matches Claude Code invocations wrapped with env assignments", () => { + expect(isClaudeCodeCommand('ANTHROPIC_API_KEY="test" claude')).toBe(true); + expect(isClaudeCodeCommand("env FOO=bar claude --print")).toBe(true); + }); + + it("ignores other commands", () => { + expect(isClaudeCodeCommand("claudes")).toBe(false); + expect(isClaudeCodeCommand("echo claude")).toBe(false); + expect(isClaudeCodeCommand("ls ~/claude")).toBe(false); + expect(isClaudeCodeCommand("cat /logs/claude")).toBe(false); + expect(isClaudeCodeCommand("")).toBe(false); + }); +}); diff --git a/frontend/app/view/term/osc-handlers.ts b/frontend/app/view/term/osc-handlers.ts index f44659d2c6..7fe7dcd4ee 100644 --- a/frontend/app/view/term/osc-handlers.ts +++ b/frontend/app/view/term/osc-handlers.ts @@ -25,6 +25,8 @@ const Osc52MaxRawLength = 128 * 1024; // includes selector + base64 + whitespace // See aiprompts/wave-osc-16162.md for full documentation export type ShellIntegrationStatus = "ready" | "running-command"; +const ClaudeCodeRegex = /^claude\b/; + type Osc16162Command = | { command: "A"; data: Record } | { command: "C"; data: { cmd64?: string } } @@ -43,41 +45,56 @@ type Osc16162Command = | { command: "I"; data: { inputempty?: boolean } } | { command: "R"; data: Record }; +function normalizeCmd(decodedCmd: string): string { + let normalizedCmd = decodedCmd.trim(); + normalizedCmd = normalizedCmd.replace(/^env\s+/, ""); + normalizedCmd = normalizedCmd.replace(/^(?:\w+=(?:"[^"]*"|'[^']*'|\S+)\s+)*/, ""); + return normalizedCmd; +} + function checkCommandForTelemetry(decodedCmd: string) { if (!decodedCmd) { return; } - if (decodedCmd.startsWith("ssh ")) { + const normalizedCmd = normalizeCmd(decodedCmd); + + if (normalizedCmd.startsWith("ssh ")) { recordTEvent("conn:connect", { "conn:conntype": "ssh-manual" }); return; } const editorsRegex = /^(vim|vi|nano|nvim)\b/; - if (editorsRegex.test(decodedCmd)) { + if (editorsRegex.test(normalizedCmd)) { recordTEvent("action:term", { "action:type": "cli-edit" }); return; } const tailFollowRegex = /(^|\|\s*)tail\s+-[fF]\b/; - if (tailFollowRegex.test(decodedCmd)) { + if (tailFollowRegex.test(normalizedCmd)) { recordTEvent("action:term", { "action:type": "cli-tailf" }); return; } - const claudeRegex = /^claude\b/; - if (claudeRegex.test(decodedCmd)) { + if (ClaudeCodeRegex.test(normalizedCmd)) { recordTEvent("action:term", { "action:type": "claude" }); return; } const opencodeRegex = /^opencode\b/; - if (opencodeRegex.test(decodedCmd)) { + if (opencodeRegex.test(normalizedCmd)) { recordTEvent("action:term", { "action:type": "opencode" }); return; } } +export function isClaudeCodeCommand(decodedCmd: string): boolean { + if (!decodedCmd) { + return false; + } + return ClaudeCodeRegex.test(normalizeCmd(decodedCmd)); +} + function handleShellIntegrationCommandStart( termWrap: TermWrap, blockId: string, @@ -101,16 +118,20 @@ function handleShellIntegrationCommandStart( const decodedCmd = base64ToString(cmd.data.cmd64); rtInfo["shell:lastcmd"] = decodedCmd; globalStore.set(termWrap.lastCommandAtom, decodedCmd); + const isCC = isClaudeCodeCommand(decodedCmd); + globalStore.set(termWrap.claudeCodeActiveAtom, isCC); checkCommandForTelemetry(decodedCmd); } catch (e) { console.error("Error decoding cmd64:", e); rtInfo["shell:lastcmd"] = null; globalStore.set(termWrap.lastCommandAtom, null); + globalStore.set(termWrap.claudeCodeActiveAtom, false); } } } else { rtInfo["shell:lastcmd"] = null; globalStore.set(termWrap.lastCommandAtom, null); + globalStore.set(termWrap.claudeCodeActiveAtom, false); } rtInfo["shell:lastcmdexitcode"] = null; } @@ -287,6 +308,7 @@ export function handleOsc16162Command(data: string, blockId: string, loaded: boo case "A": { rtInfo["shell:state"] = "ready"; globalStore.set(termWrap.shellIntegrationStatusAtom, "ready"); + globalStore.set(termWrap.claudeCodeActiveAtom, false); const marker = terminal.registerMarker(0); if (marker) { termWrap.promptMarkers.push(marker); @@ -324,6 +346,7 @@ export function handleOsc16162Command(data: string, blockId: string, loaded: boo } break; case "D": + globalStore.set(termWrap.claudeCodeActiveAtom, false); if (cmd.data.exitcode != null) { rtInfo["shell:lastcmdexitcode"] = cmd.data.exitcode; } else { @@ -337,6 +360,7 @@ export function handleOsc16162Command(data: string, blockId: string, loaded: boo break; case "R": globalStore.set(termWrap.shellIntegrationStatusAtom, null); + globalStore.set(termWrap.claudeCodeActiveAtom, false); if (terminal.buffer.active.type === "alternate") { terminal.write("\x1b[?1049l"); } diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 9cb1c58720..bf77ef9535 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -10,7 +10,7 @@ import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; -import { TerminalView } from "@/app/view/term/term"; +import { TermClaudeIcon, TerminalView } from "@/app/view/term/term"; import { TermWshClient } from "@/app/view/term/term-wsh"; import { VDomModel } from "@/app/view/vdom/vdom-model"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; @@ -41,7 +41,7 @@ import * as jotai from "jotai"; import * as React from "react"; import { getBlockingCommand } from "./shellblocking"; import { computeTheme, DefaultTermTheme } from "./termutil"; -import { TermWrap } from "./termwrap"; +import { TermWrap, WebGLSupported } from "./termwrap"; export class TermViewModel implements ViewModel { viewType: string; @@ -155,7 +155,7 @@ export class TermViewModel implements ViewModel { if (isCmd) { const blockMeta = get(this.blockAtom)?.meta; let cmdText = blockMeta?.["cmd"]; - let cmdArgs = blockMeta?.["cmd:args"]; + const cmdArgs = blockMeta?.["cmd:args"]; if (cmdArgs != null && Array.isArray(cmdArgs) && cmdArgs.length > 0) { cmdText += " " + cmdArgs.join(" "); } @@ -242,7 +242,7 @@ export class TermViewModel implements ViewModel { }); this.termTransparencyAtom = useBlockAtom(blockId, "termtransparencyatom", () => { return jotai.atom((get) => { - let value = get(getOverrideConfigAtom(this.blockId, "term:transparency")) ?? 0.5; + const value = get(getOverrideConfigAtom(this.blockId, "term:transparency")) ?? 0.5; return boundNumber(value, 0, 1); }); }); @@ -293,6 +293,13 @@ export class TermViewModel implements ViewModel { } } + if (get(getSettingsKeyAtom("debug:webglstatus"))) { + const webglButton = this.getWebGlIconButton(get); + if (webglButton) { + rtn.push(webglButton); + } + } + if (blockData?.meta?.["controller"] != "cmd" && shellProcStatus != "done") { return rtn; } @@ -397,10 +404,12 @@ export class TermViewModel implements ViewModel { return null; } const shellIntegrationStatus = get(this.termRef.current.shellIntegrationStatusAtom); + const claudeCodeActive = get(this.termRef.current.claudeCodeActiveAtom); + const icon = claudeCodeActive ? React.createElement(TermClaudeIcon) : "sparkles"; if (shellIntegrationStatus == null) { return { elemtype: "iconbutton", - icon: "sparkles", + icon, className: "text-muted", title: "No shell integration — Wave AI unable to run commands.", noAction: true, @@ -409,14 +418,16 @@ export class TermViewModel implements ViewModel { if (shellIntegrationStatus === "ready") { return { elemtype: "iconbutton", - icon: "sparkles", + icon, className: "text-accent", title: "Shell ready — Wave AI can run commands in this terminal.", noAction: true, }; } if (shellIntegrationStatus === "running-command") { - let title = "Shell busy — Wave AI unable to run commands while another command is running."; + let title = claudeCodeActive + ? "Claude Code Detected" + : "Shell busy — Wave AI unable to run commands while another command is running."; if (this.termRef.current) { const inAltBuffer = this.termRef.current.terminal?.buffer?.active?.type === "alternate"; @@ -429,7 +440,7 @@ export class TermViewModel implements ViewModel { return { elemtype: "iconbutton", - icon: "sparkles", + icon, className: "text-warning", title: title, noAction: true, @@ -438,6 +449,38 @@ export class TermViewModel implements ViewModel { return null; } + getWebGlIconButton(get: jotai.Getter): IconButtonDecl | null { + if (!WebGLSupported) { + return { + elemtype: "iconbutton", + icon: "microchip", + iconColor: "var(--error-color)", + title: "WebGL not supported", + noAction: true, + }; + } + if (!this.termRef.current?.webglEnabledAtom) { + return null; + } + const webglEnabled = get(this.termRef.current.webglEnabledAtom); + if (webglEnabled) { + return { + elemtype: "iconbutton", + icon: "microchip", + iconColor: "var(--success-color)", + title: "WebGL enabled (click to disable)", + click: () => this.toggleWebGl(), + }; + } + return { + elemtype: "iconbutton", + icon: "microchip", + iconColor: "var(--secondary-text-color)", + title: "WebGL disabled (click to enable)", + click: () => this.toggleWebGl(), + }; + } + get viewComponent(): ViewComponent { return TerminalView as ViewComponent; } @@ -478,6 +521,22 @@ export class TermViewModel implements ViewModel { }); } + getTermRenderer(): "webgl" | "dom" { + return this.termRef.current?.getTermRenderer() ?? "dom"; + } + + isWebGlEnabled(): boolean { + return this.termRef.current?.isWebGlEnabled() ?? false; + } + + toggleWebGl() { + if (!this.termRef.current) { + return; + } + const renderer = this.termRef.current.getTermRenderer() === "webgl" ? "dom" : "webgl"; + this.termRef.current.setTermRenderer(renderer); + } + triggerRestartAtom() { globalStore.set(this.isRestarting, true); setTimeout(() => { @@ -544,7 +603,7 @@ export class TermViewModel implements ViewModel { console.log("search is open, not giving focus"); return true; } - let termMode = globalStore.get(this.termMode); + const termMode = globalStore.get(this.termMode); if (termMode == "term") { if (this.termRef?.current?.terminal) { this.termRef.current.terminal.focus(); @@ -643,14 +702,6 @@ export class TermViewModel implements ViewModel { return true; } - // Handle Escape key during IME composition - if (keyutil.checkKeyPressed(waveEvent, "Escape")) { - if (this.termRef.current?.isComposing) { - // Reset composition state when Escape is pressed during composition - this.termRef.current.resetCompositionState(); - } - } - if (this.keyDownHandler(waveEvent)) { event.preventDefault(); event.stopPropagation(); diff --git a/frontend/app/view/term/term.scss b/frontend/app/view/term/term.scss index e69fc008f5..a2782c490b 100644 --- a/frontend/app/view/term/term.scss +++ b/frontend/app/view/term/term.scss @@ -128,35 +128,12 @@ .terminal { width: 100%; - .xterm-viewport { - &::-webkit-scrollbar { - width: 6px; /* this needs to match fitAddon.scrollbarWidth in termwrap.ts */ - height: 6px; - } - - &::-webkit-scrollbar-track { - background-color: var(--scrollbar-background-color); - } - - &::-webkit-scrollbar-thumb { - background-color: transparent; - border-radius: 4px; - margin: 0 1px 0 1px; - - &:hover { - background-color: var(--scrollbar-thumb-hover-color) !important; - } - - &:active { - background-color: var(--scrollbar-thumb-active-color) !important; - } - } + .xterm-decoration-overview-ruler { + display: none !important; } - &:hover { - .xterm-viewport::-webkit-scrollbar-thumb { - background-color: var(--scrollbar-thumb-color); - } + .xterm-viewport { + background-color: transparent; } } } diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index b167688907..67eb5737c6 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -1,17 +1,19 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import ClaudeColorSvg from "@/app/asset/claude-color.svg"; import { SubBlock } from "@/app/block/block"; import type { BlockNodeModel } from "@/app/block/blocktypes"; import { NullErrorBoundary } from "@/app/element/errorboundary"; import { Search, useSearch } from "@/app/element/search"; import { ContextMenuModel } from "@/app/store/contextmenu"; +import { globalStore } from "@/app/store/jotaiStore"; import { useTabModel } from "@/app/store/tab-model"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import type { TermViewModel } from "@/app/view/term/term-model"; -import { atoms, getOverrideConfigAtom, getSettingsPrefixAtom, globalStore, WOS } from "@/store/global"; +import { atoms, getOverrideConfigAtom, getSettingsPrefixAtom, WOS } from "@/store/global"; import { fireAndForget, useAtomValueSafe } from "@/util/util"; import { computeBgStyleFromMeta } from "@/util/waveutil"; import { ISearchOptions } from "@xterm/addon-search"; @@ -33,6 +35,16 @@ interface TerminalViewProps { model: TermViewModel; } +const TermClaudeIcon = React.memo(() => { + return ( + + ); +}); + +TermClaudeIcon.displayName = "TermClaudeIcon"; + const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => { const connStatus = jotai.useAtomValue(model.connStatus); const [lastConnStatus, setLastConnStatus] = React.useState(connStatus); @@ -60,7 +72,7 @@ const TermVDomToolbarNode = ({ vdomBlockId, blockId, model }: TerminalViewProps const unsub = waveEventSubscribeSingle({ eventType: "blockclose", scope: WOS.makeORef("block", vdomBlockId), - handler: (event) => { + handler: (_event) => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { @@ -103,7 +115,7 @@ const TermVDomNodeSingleId = ({ vdomBlockId, blockId, model }: TerminalViewProps const unsub = waveEventSubscribeSingle({ eventType: "blockclose", scope: WOS.makeORef("block", vdomBlockId), - handler: (event) => { + handler: (_event) => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { @@ -299,6 +311,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => macOptionIsMeta: termMacOptionIsMeta, cursorStyle: termCursorStyle, cursorBlink: termCursorBlink, + overviewRuler: { width: 6 }, }, { keydownHandler: model.handleTerminalKeydown.bind(model), @@ -311,9 +324,6 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => model.termRef.current = termWrap; setTermWrapInst(termWrap); const rszObs = new ResizeObserver(() => { - if (termWrap.cachedAtBottomForResize == null) { - termWrap.cachedAtBottomForResize = termWrap.wasRecentlyAtBottom(); - } termWrap.handleResize_debounced(); }); rszObs.observe(connectElemRef.current); @@ -391,4 +401,4 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => ); }; -export { TerminalView }; +export { TermClaudeIcon, TerminalView }; diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 1cd167c800..d79ce695cd 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { BlockNodeModel } from "@/app/block/blocktypes"; @@ -18,6 +18,7 @@ import { import * as services from "@/store/services"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { base64ToArray, fireAndForget } from "@/util/util"; +import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { SerializeAddon } from "@xterm/addon-serialize"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -27,11 +28,11 @@ import { Terminal } from "@xterm/xterm"; import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "throttle-debounce"; -import { FitAddon } from "./fitaddon"; import { handleOsc16162Command, handleOsc52Command, handleOsc7Command, + isClaudeCodeCommand, type ShellIntegrationStatus, } from "./osc-handlers"; import { bufferLinesToText, createTempFileFromBlob, extractAllClipboardData, normalizeCursorStyle } from "./termutil"; @@ -42,21 +43,20 @@ const TermFileName = "term"; const TermCacheFileName = "cache:term:full"; const MinDataProcessedForCache = 100 * 1024; export const SupportsImageInput = true; -const IMEDedupWindowMs = 20; const MaxRepaintTransactionMs = 2000; // detect webgl support function detectWebGLSupport(): boolean { try { const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("webgl"); + const ctx = canvas.getContext("webgl2"); return !!ctx; } catch (e) { return false; } } -const WebGLSupported = detectWebGLSupport(); +export const WebGLSupported = detectWebGLSupport(); let loggedWebGL = false; type TermWrapOptions = { @@ -84,34 +84,25 @@ export class TermWrap { multiInputCallback: (data: string) => void; sendDataHandler: (data: string) => void; onSearchResultsDidChange?: (result: { resultIndex: number; resultCount: number }) => void; - private toDispose: TermTypes.IDisposable[] = []; + toDispose: TermTypes.IDisposable[] = []; + webglAddon: WebglAddon | null = null; + webglContextLossDisposable: TermTypes.IDisposable | null = null; + webglEnabledAtom: jotai.PrimitiveAtom; pasteActive: boolean = false; lastUpdated: number; promptMarkers: TermTypes.IMarker[] = []; shellIntegrationStatusAtom: jotai.PrimitiveAtom; lastCommandAtom: jotai.PrimitiveAtom; + claudeCodeActiveAtom: jotai.PrimitiveAtom; nodeModel: BlockNodeModel; // this can be null hoveredLinkUri: string | null = null; onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void; - // IME composition state tracking - isComposing: boolean = false; - composingData: string = ""; - lastCompositionEnd: number = 0; - lastComposedText: string = ""; - firstDataAfterCompositionSent: boolean = false; - // Paste deduplication // xterm.js paste() method triggers onData event, which can cause duplicate sends lastPasteData: string = ""; lastPasteTime: number = 0; - // for scrollToBottom support during a resize - lastAtBottomTime: number = Date.now(); - lastScrollAtBottom: boolean = true; - cachedAtBottomForResize: boolean | null = null; - viewportScrollTop: number = 0; - // dev only (for debugging) recentWrites: { idx: number; data: string; ts: number }[] = []; recentWritesCounter: number = 0; @@ -142,9 +133,10 @@ export class TermWrap { this.promptMarkers = []; this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.claudeCodeActiveAtom = jotai.atom(false); + this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom; this.terminal = new Terminal(options); this.fitAddon = new FitAddon(); - this.fitAddon.scrollbarWidth = 6; // this needs to match scrollbar width in term.scss this.serializeAddon = new SerializeAddon(); this.searchAddon = new SearchAddon(); this.terminal.loadAddon(this.searchAddon); @@ -179,31 +171,37 @@ export class TermWrap { } ) ); - if (WebGLSupported && waveOptions.useWebGl) { - const webglAddon = new WebglAddon(); - this.toDispose.push( - webglAddon.onContextLoss(() => { - webglAddon.dispose(); - }) - ); - this.terminal.loadAddon(webglAddon); - if (!loggedWebGL) { - console.log("loaded webgl!"); - loggedWebGL = true; - } - } + this.setTermRenderer(WebGLSupported && waveOptions.useWebGl ? "webgl" : "dom"); // Register OSC handlers this.terminal.parser.registerOscHandler(7, (data: string) => { - return handleOsc7Command(data, this.blockId, this.loaded); + try { + return handleOsc7Command(data, this.blockId, this.loaded); + } catch (e) { + console.error("[termwrap] osc 7 handler error", this.blockId, e); + return false; + } }); this.terminal.parser.registerOscHandler(52, (data: string) => { - return handleOsc52Command(data, this.blockId, this.loaded, this); + try { + return handleOsc52Command(data, this.blockId, this.loaded, this); + } catch (e) { + console.error("[termwrap] osc 52 handler error", this.blockId, e); + return false; + } }); this.terminal.parser.registerOscHandler(16162, (data: string) => { - return handleOsc16162Command(data, this.blockId, this.loaded, this); + try { + return handleOsc16162Command(data, this.blockId, this.loaded, this); + } catch (e) { + console.error("[termwrap] osc 16162 handler error", this.blockId, e); + return false; + } }); this.toDispose.push( this.terminal.parser.registerCsiHandler({ final: "J" }, (params) => { + if (params == null || params.length < 1) { + return false; + } if (params[0] === 3) { this.lastClearScrollbackTs = Date.now(); if (this.inSyncTransaction) { @@ -216,6 +214,9 @@ export class TermWrap { ); this.toDispose.push( this.terminal.parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => { + if (params == null || params.length < 1) { + return false; + } if (params[0] === 2026) { this.lastMode2026SetTs = Date.now(); this.inSyncTransaction = true; @@ -225,6 +226,9 @@ export class TermWrap { ); this.toDispose.push( this.terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => { + if (params == null || params.length < 1) { + return false; + } if (params[0] === 2026) { this.lastMode2026ResetTs = Date.now(); this.inSyncTransaction = false; @@ -260,9 +264,6 @@ export class TermWrap { }) ); this.terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => { - if (e.isComposing && !e.ctrlKey && !e.altKey && !e.metaKey) { - return true; - } if (!waveOptions.keydownHandler) { return true; } @@ -281,18 +282,6 @@ export class TermWrap { this.connectElem.removeEventListener("paste", pasteHandler, true); }, }); - const viewportElem = this.connectElem.querySelector(".xterm-viewport") as HTMLElement; - if (viewportElem) { - const scrollHandler = (e: any) => { - this.handleViewportScroll(viewportElem); - }; - viewportElem.addEventListener("scroll", scrollHandler); - this.toDispose.push({ - dispose: () => { - viewportElem.removeEventListener("scroll", scrollHandler); - }, - }); - } } getZoneId(): string { @@ -307,32 +296,48 @@ export class TermWrap { this.terminal.options.cursorBlink = cursorBlink ?? false; } - resetCompositionState() { - this.isComposing = false; - this.composingData = ""; - this.lastComposedText = ""; - this.lastCompositionEnd = 0; - this.firstDataAfterCompositionSent = false; + setTermRenderer(renderer: "webgl" | "dom") { + if (renderer === "webgl") { + if (this.webglAddon != null) { + return; + } + if (!WebGLSupported) { + renderer = "dom"; + } + } else { + if (this.webglAddon == null) { + return; + } + } + if (this.webglAddon != null) { + this.webglContextLossDisposable?.dispose(); + this.webglContextLossDisposable = null; + this.webglAddon.dispose(); + this.webglAddon = null; + globalStore.set(this.webglEnabledAtom, false); + } + if (renderer === "webgl") { + const addon = new WebglAddon(); + this.webglContextLossDisposable = addon.onContextLoss(() => { + this.setTermRenderer("dom"); + }); + this.terminal.loadAddon(addon); + this.webglAddon = addon; + globalStore.set(this.webglEnabledAtom, true); + if (!loggedWebGL) { + console.log("loaded webgl!"); + loggedWebGL = true; + } + } } - private handleCompositionStart = (e: CompositionEvent) => { - dlog("compositionstart", e.data); - this.isComposing = true; - this.composingData = ""; - }; - - private handleCompositionUpdate = (e: CompositionEvent) => { - dlog("compositionupdate", e.data); - this.composingData = e.data || ""; - }; - - private handleCompositionEnd = (e: CompositionEvent) => { - dlog("compositionend", e.data); - this.isComposing = false; - this.lastComposedText = e.data || ""; - this.lastCompositionEnd = Date.now(); - this.firstDataAfterCompositionSent = false; - }; + getTermRenderer(): "webgl" | "dom" { + return this.webglAddon != null ? "webgl" : "dom"; + } + + isWebGlEnabled(): boolean { + return this.webglAddon != null; + } async initTerminal() { const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect"); @@ -360,32 +365,6 @@ export class TermWrap { this.toDispose.push(this.searchAddon.onDidChangeResults(this.onSearchResultsDidChange.bind(this))); } - // Register IME composition event listeners on the xterm.js textarea - const textareaElem = this.connectElem.querySelector("textarea"); - if (textareaElem) { - textareaElem.addEventListener("compositionstart", this.handleCompositionStart); - textareaElem.addEventListener("compositionupdate", this.handleCompositionUpdate); - textareaElem.addEventListener("compositionend", this.handleCompositionEnd); - - // Handle blur during composition - reset state to avoid stale data - const blurHandler = () => { - if (this.isComposing) { - dlog("Terminal lost focus during composition, resetting IME state"); - this.resetCompositionState(); - } - }; - textareaElem.addEventListener("blur", blurHandler); - - this.toDispose.push({ - dispose: () => { - textareaElem.removeEventListener("compositionstart", this.handleCompositionStart); - textareaElem.removeEventListener("compositionupdate", this.handleCompositionUpdate); - textareaElem.removeEventListener("compositionend", this.handleCompositionEnd); - textareaElem.removeEventListener("blur", blurHandler); - }, - }); - } - this.mainFileSubject = getFileSubject(this.getZoneId(), TermFileName); this.mainFileSubject.subscribe(this.handleNewFileSubjectData.bind(this)); @@ -393,16 +372,19 @@ export class TermWrap { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), }); + let shellState: ShellIntegrationStatus = null; if (rtInfo && rtInfo["shell:integration"]) { - const shellState = rtInfo["shell:state"] as ShellIntegrationStatus; + shellState = rtInfo["shell:state"] as ShellIntegrationStatus; globalStore.set(this.shellIntegrationStatusAtom, shellState || null); } else { globalStore.set(this.shellIntegrationStatusAtom, null); } const lastCmd = rtInfo ? rtInfo["shell:lastcmd"] : null; + const isCC = shellState === "running-command" && isClaudeCodeCommand(lastCmd); globalStore.set(this.lastCommandAtom, lastCmd || null); + globalStore.set(this.claudeCodeActiveAtom, isCC); } catch (e) { console.log("Error loading runtime info:", e); } @@ -419,14 +401,20 @@ export class TermWrap { this.promptMarkers.forEach((marker) => { try { marker.dispose(); - } catch (_) {} + } catch (_) { + /* nothing */ + } }); this.promptMarkers = []; + this.webglContextLossDisposable?.dispose(); + this.webglContextLossDisposable = null; this.terminal.dispose(); this.toDispose.forEach((d) => { try { d.dispose(); - } catch (_) {} + } catch (_) { + /* nothing */ + } }); this.mainFileSubject.release(); } @@ -436,17 +424,6 @@ export class TermWrap { return; } - // IME fix: suppress isComposing=true events unless they immediately follow - // a compositionend (within 20ms). This handles CapsLock input method switching - // where the composition buffer gets flushed as a spurious isComposing=true event - if (this.isComposing) { - const timeSinceCompositionEnd = Date.now() - this.lastCompositionEnd; - if (timeSinceCompositionEnd > IMEDedupWindowMs) { - dlog("Suppressed IME data (composing, not near compositionend):", data); - return; - } - } - this.sendDataHandler?.(data); this.multiInputCallback?.(data); } @@ -481,7 +458,7 @@ export class TermWrap { } } let resolve: () => void = null; - let prtn = new Promise((presolve, _) => { + const prtn = new Promise((presolve, _) => { resolve = presolve; }); this.terminal.write(data, () => { @@ -545,43 +522,9 @@ export class TermWrap { } } - setAtBottom(atBottom: boolean) { - if (this.lastScrollAtBottom && !atBottom) { - this.lastAtBottomTime = Date.now(); - } - this.lastScrollAtBottom = atBottom; - if (atBottom) { - this.lastAtBottomTime = Date.now(); - } - } - - wasRecentlyAtBottom(): boolean { - if (this.lastScrollAtBottom) { - return true; - } - return Date.now() - this.lastAtBottomTime <= 1000; - } - - handleViewportScroll(viewportElem: HTMLElement) { - const { scrollTop, scrollHeight, clientHeight } = viewportElem; - const atBottom = scrollTop + clientHeight >= scrollHeight - clientHeight * 0.5; - this.setAtBottom(atBottom); - const delta = this.viewportScrollTop - scrollTop; - if (isDev() && delta >= 500) { - console.log( - `[termwrap] large-scroll blockId=${this.blockId} delta=${Math.round(delta)}px scrollTop=${scrollTop} wasNearBottom=${atBottom}` - ); - } - this.viewportScrollTop = scrollTop; - } - handleResize() { const oldRows = this.terminal.rows; const oldCols = this.terminal.cols; - const atBottom = this.cachedAtBottomForResize ?? this.wasRecentlyAtBottom(); - if (!atBottom) { - this.cachedAtBottomForResize = null; - } this.fitAddon.fit(); if (oldRows !== this.terminal.rows || oldCols !== this.terminal.cols) { const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; @@ -589,9 +532,7 @@ export class TermWrap { "[termwrap] resize", `${oldRows}x${oldCols}`, "->", - `${this.terminal.rows}x${this.terminal.cols}`, - "atBottom:", - atBottom + `${this.terminal.rows}x${this.terminal.cols}` ); RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, termsize: termSize }); } @@ -600,14 +541,6 @@ export class TermWrap { this.hasResized = true; this.resyncController("initial resize"); } - if (atBottom) { - setTimeout(() => { - console.log("[termwrap] resize scroll-to-bottom"); - this.cachedAtBottomForResize = null; - this.terminal.scrollToBottom(); - this.setAtBottom(true); - }, 20); - } } processAndCacheData() { diff --git a/frontend/app/view/term/xterm.css b/frontend/app/view/term/xterm.css index e48c707564..819654e453 100644 --- a/frontend/app/view/term/xterm.css +++ b/frontend/app/view/term/xterm.css @@ -79,7 +79,7 @@ .xterm .composition-view { /* TODO: Composition position got messed up somewhere */ background: #000; - color: #fff; + color: #FFF; display: none; position: absolute; white-space: nowrap; @@ -96,7 +96,7 @@ overflow-y: scroll; cursor: default; position: absolute; - right: 0; /* if this gets updated, must update fitaddon.ts */ + right: 0; left: 0; top: 0; bottom: 0; @@ -112,10 +112,6 @@ top: 0; } -.xterm .xterm-scroll-area { - visibility: hidden; -} - .xterm-char-measure-element { display: inline-block; visibility: hidden; @@ -153,12 +149,18 @@ } .xterm .xterm-accessibility-tree:not(.debug) *::selection { - color: transparent; + color: transparent; } .xterm .xterm-accessibility-tree { - user-select: text; - white-space: pre; + font-family: monospace; + user-select: text; + white-space: pre; +} + +.xterm .xterm-accessibility-tree > div { + transform-origin: left; + width: fit-content; } .xterm .live-region { @@ -175,53 +177,33 @@ opacity: 1 !important; } -.xterm-underline-1 { - text-decoration: underline; -} -.xterm-underline-2 { - text-decoration: double underline; -} -.xterm-underline-3 { - text-decoration: wavy underline; -} -.xterm-underline-4 { - text-decoration: dotted underline; -} -.xterm-underline-5 { - text-decoration: dashed underline; -} +.xterm-underline-1 { text-decoration: underline; } +.xterm-underline-2 { text-decoration: double underline; } +.xterm-underline-3 { text-decoration: wavy underline; } +.xterm-underline-4 { text-decoration: dotted underline; } +.xterm-underline-5 { text-decoration: dashed underline; } .xterm-overline { text-decoration: overline; } -.xterm-overline.xterm-underline-1 { - text-decoration: overline underline; -} -.xterm-overline.xterm-underline-2 { - text-decoration: overline double underline; -} -.xterm-overline.xterm-underline-3 { - text-decoration: overline wavy underline; -} -.xterm-overline.xterm-underline-4 { - text-decoration: overline dotted underline; -} -.xterm-overline.xterm-underline-5 { - text-decoration: overline dashed underline; -} +.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } +.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } +.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } +.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } +.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } .xterm-strikethrough { text-decoration: line-through; } .xterm-screen .xterm-decoration-container .xterm-decoration { - z-index: 6; - position: absolute; + z-index: 6; + position: absolute; } .xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { - z-index: 7; + z-index: 7; } .xterm-decoration-overview-ruler { @@ -236,3 +218,68 @@ z-index: 2; position: relative; } + + + +/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */ + +/* xterm.js customization: Override xterm's cursor style */ +.xterm .xterm-scrollable-element > .scrollbar { + cursor: default; +} + +/* Arrows */ +.xterm .xterm-scrollable-element > .scrollbar > .scra { + cursor: pointer; + font-size: 11px !important; +} + +.xterm .xterm-scrollable-element > .visible { + opacity: 1; + + /* Background rule added for IE9 - to allow clicks on dom node */ + background:rgba(0,0,0,0); + + transition: opacity 100ms linear; + /* In front of peek view */ + z-index: 11; +} +.xterm .xterm-scrollable-element > .invisible { + opacity: 0; + pointer-events: none; +} +.xterm .xterm-scrollable-element > .invisible.fade { + transition: opacity 800ms linear; +} + +/* Scrollable Content Inset Shadow */ +.xterm .xterm-scrollable-element > .shadow { + position: absolute; + display: none; +} +.xterm .xterm-scrollable-element > .shadow.top { + display: block; + top: 0; + left: 3px; + height: 3px; + width: 100%; + box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset; +} +.xterm .xterm-scrollable-element > .shadow.left { + display: block; + top: 3px; + left: 0; + height: 100%; + width: 3px; + box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset; +} +.xterm .xterm-scrollable-element > .shadow.top-left-corner { + display: block; + top: 0; + left: 0; + height: 3px; + width: 3px; +} +.xterm .xterm-scrollable-element > .shadow.top.left { + box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset; +} diff --git a/frontend/app/view/waveai/waveai.scss b/frontend/app/view/waveai/waveai.scss deleted file mode 100644 index 2d463fd88e..0000000000 --- a/frontend/app/view/waveai/waveai.scss +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.waveai { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - - .waveai-chat { - flex: 1 1 auto; - overflow: hidden; - .chat-window-container { - overflow-y: auto; - margin-bottom: 0; - height: 100%; - - .chat-window { - flex-flow: column nowrap; - display: flex; - gap: 8px; - - // This is the filler that will push the chat messages to the bottom until the chat window is full - .filler { - flex: 1 1 auto; - } - - .chat-msg-container { - display: flex; - gap: 8px; - .chat-msg { - margin: 10px 0; - display: flex; - align-items: flex-start; - border-radius: 8px; - - &.chat-msg-header { - display: flex; - flex-direction: column; - justify-content: flex-start; - - .icon-box { - padding-top: 0; - border-radius: 4px; - background-color: rgb(from var(--highlight-bg-color) r g b / 0.05); - display: flex; - padding: 6px; - } - } - - &.chat-msg-assistant { - color: var(--main-text-color); - background-color: rgb(from var(--highlight-bg-color) r g b / 0.1); - margin-right: auto; - padding: 10px; - max-width: 85%; - - .markdown { - width: 100%; - - pre { - white-space: pre-wrap; - word-break: break-word; - max-width: 100%; - overflow-x: auto; - margin-left: 0; - } - } - } - &.chat-msg-user { - margin-left: auto; - padding: 10px; - max-width: 85%; - background-color: rgb(from var(--accent-color) r g b / 0.15); - } - - &.chat-msg-error { - color: var(--main-text-color); - background-color: rgb(from var(--error-color) r g b / 0.25); - margin-right: auto; - padding: 10px; - max-width: 85%; - - .markdown { - width: 100%; - - pre { - white-space: pre-wrap; - word-break: break-word; - max-width: 100%; - overflow-x: auto; - margin-left: 0; - } - } - } - - &.typing-indicator { - margin-top: 4px; - } - } - } - } - } - } - - .waveai-controls { - flex: 0 0 auto; - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - gap: 10px; - padding: 8px 6px; - - .waveai-input-wrapper { - padding: 8px 12px; - flex: 1 1 auto; - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-start; - border-radius: 6px; - border: 1px solid rgb(from var(--highlight-bg-color) r g b / 0.42); - - .waveai-input { - color: var(--main-text-color); - background-color: inherit; - resize: none; - width: 100%; - border: transparent; - outline: none; - overflow: auto; - overflow-wrap: anywhere; - height: 21px; - } - } - - .waveai-submit-button { - border-radius: 100%; - width: 27px; - aspect-ratio: 1 /1; - display: flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - padding: 0; - } - } -} diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index 630f047265..baf6acf711 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -1,911 +1,40 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BlockNodeModel } from "@/app/block/blocktypes"; import { Button } from "@/app/element/button"; -import { Markdown } from "@/app/element/markdown"; -import { TypingIndicator } from "@/app/element/typingindicator"; -import { ClientModel } from "@/app/store/client-model"; -import type { TabModel } from "@/app/store/tab-model"; -import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { makeFeBlockRouteId } from "@/app/store/wshrouter"; -import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; -import { atoms, createBlock, fetchWaveFile, getApi, globalStore, WOS } from "@/store/global"; -import { BlockService, ObjectService } from "@/store/services"; -import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; -import { fireAndForget, isBlank, makeIconClass, mergeMeta } from "@/util/util"; -import { atom, Atom, PrimitiveAtom, useAtomValue, WritableAtom } from "jotai"; -import { splitAtom } from "jotai/utils"; -import type { OverlayScrollbars } from "overlayscrollbars"; -import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; -import { debounce, throttle } from "throttle-debounce"; -import "./waveai.scss"; - -interface ChatMessageType { - id: string; - user: string; - text: string; - isUpdating?: boolean; -} - -const outline = "2px solid var(--accent-color)"; -const slidingWindowSize = 30; - -interface ChatItemProps { - chatItemAtom: Atom; - model: WaveAiModel; -} - -function promptToMsg(prompt: WaveAIPromptMessageType): ChatMessageType { - return { - id: crypto.randomUUID(), - user: prompt.role, - text: prompt.content, - }; -} - -class AiWshClient extends WshClient { - blockId: string; - model: WaveAiModel; - - constructor(blockId: string, model: WaveAiModel) { - super(makeFeBlockRouteId(blockId)); - this.blockId = blockId; - this.model = model; - } - - handle_aisendmessage(rh: RpcResponseHelper, data: AiMessageData) { - if (isBlank(data.message)) { - return; - } - this.model.sendMessage(data.message); - } -} +import { atom } from "jotai"; +import { useCallback } from "react"; export class WaveAiModel implements ViewModel { - viewType: string; - blockId: string; - nodeModel: BlockNodeModel; - tabModel: TabModel; - blockAtom: Atom; - presetKey: Atom; - presetMap: Atom<{ [k: string]: MetaType }>; - mergedPresets: Atom; - aiOpts: Atom; - viewIcon?: Atom; - viewName?: Atom; - viewText?: Atom; - preIconButton?: Atom; - endIconButtons?: Atom; - messagesAtom: PrimitiveAtom>; - messagesSplitAtom: SplitAtom>; - latestMessageAtom: Atom; - addMessageAtom: WritableAtom; - updateLastMessageAtom: WritableAtom; - removeLastMessageAtom: WritableAtom; - simulateAssistantResponseAtom: WritableAtom>; - textAreaRef: React.RefObject; - locked: PrimitiveAtom; - cancel: boolean; - aiWshClient: AiWshClient; - - constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { - this.blockId = blockId; - this.nodeModel = nodeModel; - this.tabModel = tabModel; - this.aiWshClient = new AiWshClient(blockId, this); - DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.aiWshClient); - this.locked = atom(false); - this.cancel = false; - this.viewType = "waveai"; - this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); - this.viewIcon = atom("sparkles"); - this.viewName = atom("Wave AI"); - this.messagesAtom = atom([]); - this.messagesSplitAtom = splitAtom(this.messagesAtom); - this.latestMessageAtom = atom((get) => get(this.messagesAtom).slice(-1)[0]); - this.presetKey = atom((get) => { - const metaPresetKey = get(this.blockAtom).meta["ai:preset"]; - const globalPresetKey = get(atoms.settingsAtom)["ai:preset"]; - return metaPresetKey ?? globalPresetKey; - }); - this.presetMap = atom((get) => { - const fullConfig = get(atoms.fullConfigAtom); - const presets = fullConfig.presets; - const settings = fullConfig.settings; - return Object.fromEntries( - Object.entries(presets) - .filter(([k]) => k.startsWith("ai@")) - .map(([k, v]) => { - const aiPresetKeys = Object.keys(v).filter((k) => k.startsWith("ai:")); - const newV = { ...v }; - newV["display:name"] = - aiPresetKeys.length == 1 && aiPresetKeys.includes("ai:*") - ? `${newV["display:name"] ?? "Default"} (${settings["ai:model"]})` - : newV["display:name"]; - return [k, newV]; - }) - ); - }); - - this.addMessageAtom = atom(null, (get, set, message: ChatMessageType) => { - const messages = get(this.messagesAtom); - set(this.messagesAtom, [...messages, message]); - }); - - this.updateLastMessageAtom = atom(null, (get, set, text: string, isUpdating: boolean) => { - const messages = get(this.messagesAtom); - const lastMessage = messages[messages.length - 1]; - if (lastMessage.user == "assistant") { - const updatedMessage = { ...lastMessage, text: lastMessage.text + text, isUpdating }; - set(this.messagesAtom, [...messages.slice(0, -1), updatedMessage]); - } - }); - this.removeLastMessageAtom = atom(null, (get, set) => { - const messages = get(this.messagesAtom); - messages.pop(); - set(this.messagesAtom, [...messages]); - }); - this.simulateAssistantResponseAtom = atom(null, async (_, set, userMessage: ChatMessageType) => { - // unused at the moment. can replace the temp() function in the future - const typingMessage: ChatMessageType = { - id: crypto.randomUUID(), - user: "assistant", - text: "", - }; - - // Add a typing indicator - set(this.addMessageAtom, typingMessage); - const parts = userMessage.text.split(" "); - let currentPart = 0; - while (currentPart < parts.length) { - const part = parts[currentPart] + " "; - set(this.updateLastMessageAtom, part, true); - currentPart++; - } - set(this.updateLastMessageAtom, "", false); - }); - - this.mergedPresets = atom((get) => { - const meta = get(this.blockAtom).meta; - let settings = get(atoms.settingsAtom); - let presetKey = get(this.presetKey); - let presets = get(atoms.fullConfigAtom).presets; - let selectedPresets = presets?.[presetKey] ?? {}; - - let mergedPresets: MetaType = {}; - mergedPresets = mergeMeta(settings, selectedPresets, "ai"); - mergedPresets = mergeMeta(mergedPresets, meta, "ai"); - - return mergedPresets; - }); - - this.aiOpts = atom((get) => { - const mergedPresets = get(this.mergedPresets); - - const opts: WaveAIOptsType = { - model: mergedPresets["ai:model"] ?? null, - apitype: mergedPresets["ai:apitype"] ?? null, - orgid: mergedPresets["ai:orgid"] ?? null, - apitoken: mergedPresets["ai:apitoken"] ?? null, - apiversion: mergedPresets["ai:apiversion"] ?? null, - maxtokens: mergedPresets["ai:maxtokens"] ?? null, - timeoutms: mergedPresets["ai:timeoutms"] ?? 60000, - baseurl: mergedPresets["ai:baseurl"] ?? null, - proxyurl: mergedPresets["ai:proxyurl"] ?? null, - }; - return opts; - }); - - this.viewText = atom((get) => { - const viewTextChildren: HeaderElem[] = []; - const aiOpts = get(this.aiOpts); - const presets = get(this.presetMap); - const presetKey = get(this.presetKey); - const presetName = presets[presetKey]?.["display:name"] ?? ""; - const isCloud = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl); - - // Handle known API providers - switch (aiOpts?.apitype) { - case "anthropic": - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "globe", - title: `Using Remote Anthropic API (${aiOpts.model})`, - noAction: true, - }); - break; - case "perplexity": - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "globe", - title: `Using Remote Perplexity API (${aiOpts.model})`, - noAction: true, - }); - break; - default: - if (isCloud) { - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "cloud", - title: "Using Wave's AI Proxy (gpt-5-mini)", - noAction: true, - }); - } else { - const baseUrl = aiOpts.baseurl ?? "OpenAI Default Endpoint"; - const modelName = aiOpts.model; - if (baseUrl.startsWith("http://localhost") || baseUrl.startsWith("http://127.0.0.1")) { - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "location-dot", - title: `Using Local Model @ ${baseUrl} (${modelName})`, - noAction: true, - }); - } else { - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "globe", - title: `Using Remote Model @ ${baseUrl} (${modelName})`, - noAction: true, - }); - } - } - } - - const dropdownItems = Object.entries(presets) - .sort((a, b) => ((a[1]["display:order"] ?? 0) > (b[1]["display:order"] ?? 0) ? 1 : -1)) - .map( - (preset) => - ({ - label: preset[1]["display:name"], - onClick: () => - fireAndForget(() => - ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { - "ai:preset": preset[0], - }) - ), - }) as MenuItem - ); - dropdownItems.push({ - label: "Add AI preset...", - onClick: () => { - fireAndForget(async () => { - const path = `${getApi().getConfigDir()}/presets/ai.json`; - const blockDef: BlockDef = { - meta: { - view: "preview", - file: path, - }, - }; - await createBlock(blockDef, false, true); - }); - }, - }); - viewTextChildren.push({ - elemtype: "menubutton", - text: presetName, - title: "Select AI Configuration", - items: dropdownItems, - }); - return viewTextChildren; - }); - this.endIconButtons = atom((_) => { - let clearButton: IconButtonDecl = { - elemtype: "iconbutton", - icon: "delete-left", - title: "Clear Chat History", - click: this.clearMessages.bind(this), - }; - return [clearButton]; - }); - } + viewType = "waveai"; + viewIcon = atom("sparkles"); + viewName = atom("Wave AI"); + noPadding = atom(true); + viewComponent = WaveAiDeprecatedView; - get viewComponent(): ViewComponent { - return WaveAi; - } - - dispose() { - DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId)); - } - - async populateMessages(): Promise { - const history = await this.fetchAiData(); - globalStore.set(this.messagesAtom, history.map(promptToMsg)); - } - - async fetchAiData(): Promise> { - const { data } = await fetchWaveFile(this.blockId, "aidata"); - if (!data) { - return []; - } - const history: Array = JSON.parse(new TextDecoder().decode(data)); - return history.slice(Math.max(history.length - slidingWindowSize, 0)); - } - - giveFocus(): boolean { - if (this?.textAreaRef?.current) { - this.textAreaRef.current?.focus(); - return true; - } - return false; - } - - getAiName(): string { - const blockMeta = globalStore.get(this.blockAtom)?.meta ?? {}; - const settings = globalStore.get(atoms.settingsAtom) ?? {}; - const name = blockMeta["ai:name"] ?? settings["ai:name"] ?? null; - return name; - } - - setLocked(locked: boolean) { - globalStore.set(this.locked, locked); - } - - sendMessage(text: string, user: string = "user") { - const clientId = ClientModel.getInstance().clientId; - this.setLocked(true); - - const newMessage: ChatMessageType = { - id: crypto.randomUUID(), - user, - text, - }; - globalStore.set(this.addMessageAtom, newMessage); - // send message to backend and get response - const opts = globalStore.get(this.aiOpts); - const newPrompt: WaveAIPromptMessageType = { - role: "user", - content: text, - }; - const handleAiStreamingResponse = async () => { - const typingMessage: ChatMessageType = { - id: crypto.randomUUID(), - user: "assistant", - text: "", - }; - - // Add a typing indicator - globalStore.set(this.addMessageAtom, typingMessage); - const history = await this.fetchAiData(); - const beMsg: WaveAIStreamRequest = { - clientid: clientId, - opts: opts, - prompt: [...history, newPrompt], - }; - let fullMsg = ""; - try { - const aiGen = RpcApi.StreamWaveAiCommand(TabRpcClient, beMsg, { timeout: opts.timeoutms }); - for await (const msg of aiGen) { - fullMsg += msg.text ?? ""; - globalStore.set(this.updateLastMessageAtom, msg.text ?? "", true); - if (this.cancel) { - break; - } - } - if (fullMsg == "") { - // remove a message if empty - globalStore.set(this.removeLastMessageAtom); - // only save the author's prompt - await BlockService.SaveWaveAiData(this.blockId, [...history, newPrompt]); - } else { - const responsePrompt: WaveAIPromptMessageType = { - role: "assistant", - content: fullMsg, - }; - //mark message as complete - globalStore.set(this.updateLastMessageAtom, "", false); - // save a complete message prompt and response - await BlockService.SaveWaveAiData(this.blockId, [...history, newPrompt, responsePrompt]); - } - } catch (error) { - const updatedHist = [...history, newPrompt]; - if (fullMsg == "") { - globalStore.set(this.removeLastMessageAtom); - } else { - globalStore.set(this.updateLastMessageAtom, "", false); - const responsePrompt: WaveAIPromptMessageType = { - role: "assistant", - content: fullMsg, - }; - updatedHist.push(responsePrompt); - } - const errMsg: string = (error as Error).message; - const errorMessage: ChatMessageType = { - id: crypto.randomUUID(), - user: "error", - text: errMsg, - }; - globalStore.set(this.addMessageAtom, errorMessage); - globalStore.set(this.updateLastMessageAtom, "", false); - const errorPrompt: WaveAIPromptMessageType = { - role: "error", - content: errMsg, - }; - updatedHist.push(errorPrompt); - await BlockService.SaveWaveAiData(this.blockId, updatedHist); - } - this.setLocked(false); - this.cancel = false; - }; - fireAndForget(handleAiStreamingResponse); - } - - useWaveAi() { - return { - sendMessage: this.sendMessage.bind(this) as (text: string) => void, - }; - } - - async clearMessages() { - await BlockService.SaveWaveAiData(this.blockId, []); - globalStore.set(this.messagesAtom, []); - } - - keyDownHandler(waveEvent: WaveKeyboardEvent): boolean { - if (checkKeyPressed(waveEvent, "Cmd:l")) { - fireAndForget(this.clearMessages.bind(this)); - return true; - } - return false; - } -} - -const ChatItem = ({ chatItemAtom, model }: ChatItemProps) => { - const chatItem = useAtomValue(chatItemAtom); - const { user, text } = chatItem; - const fontSize = useAtomValue(model.mergedPresets)?.["ai:fontsize"]; - const fixedFontSize = useAtomValue(model.mergedPresets)?.["ai:fixedfontsize"]; - const renderContent = useMemo(() => { - if (user == "error") { - return ( - <> -
-
- -
-
-
- -
- - ); - } - if (user == "assistant") { - return text ? ( - <> -
-
- -
-
-
- -
- - ) : ( - <> -
- -
- - - ); - } - return ( - <> -
- -
- - ); - }, [text, user, fontSize, fixedFontSize]); - - return
{renderContent}
; -}; - -interface ChatWindowProps { - chatWindowRef: React.RefObject; - msgWidths: object; - model: WaveAiModel; + constructor(_: ViewModelInitType) {} } -const ChatWindow = memo( - forwardRef(({ chatWindowRef, msgWidths, model }, ref) => { - const isUserScrolling = useRef(false); - const osRef = useRef(null); - const splitMessages = useAtomValue(model.messagesSplitAtom) as Atom[]; - const latestMessage = useAtomValue(model.latestMessageAtom); - const prevMessagesLenRef = useRef(splitMessages.length); - - useImperativeHandle(ref, () => osRef.current as OverlayScrollbarsComponentRef); - - const handleNewMessage = useCallback( - throttle(100, (messagesLen: number) => { - if (osRef.current?.osInstance()) { - const { viewport } = osRef.current.osInstance().elements(); - if (prevMessagesLenRef.current !== messagesLen || !isUserScrolling.current) { - viewport.scrollTo({ - behavior: "auto", - top: chatWindowRef.current?.scrollHeight || 0, - }); - } - - prevMessagesLenRef.current = messagesLen; - } - }), - [] - ); - - useEffect(() => { - handleNewMessage(splitMessages.length); - }, [splitMessages, latestMessage]); - - // Wait 300 ms after the user stops scrolling to determine if the user is within 300px of the bottom of the chat window. - // If so, unset the user scrolling flag. - const determineUnsetScroll = useCallback( - debounce(300, () => { - const { viewport } = osRef.current.osInstance().elements(); - if (viewport.scrollTop > chatWindowRef.current?.clientHeight - viewport.clientHeight - 100) { - isUserScrolling.current = false; - } - }), - [] - ); - - const handleUserScroll = useCallback( - throttle(100, () => { - isUserScrolling.current = true; - determineUnsetScroll(); - }), - [] - ); - - useEffect(() => { - if (osRef.current?.osInstance()) { - const { viewport } = osRef.current.osInstance().elements(); - - viewport.addEventListener("wheel", handleUserScroll, { passive: true }); - viewport.addEventListener("touchmove", handleUserScroll, { passive: true }); - - return () => { - viewport.removeEventListener("wheel", handleUserScroll); - viewport.removeEventListener("touchmove", handleUserScroll); - if (osRef.current && osRef.current.osInstance()) { - osRef.current.osInstance().destroy(); - } - }; - } - }, []); - - const handleScrollbarInitialized = (instance: OverlayScrollbars) => { - const { viewport } = instance.elements(); - viewport.removeAttribute("tabindex"); - viewport.scrollTo({ - behavior: "auto", - top: chatWindowRef.current?.scrollHeight || 0, - }); - }; - - const handleScrollbarUpdated = (instance: OverlayScrollbars) => { - const { viewport } = instance.elements(); - viewport.removeAttribute("tabindex"); - }; - - return ( - -
-
- {splitMessages.map((chitem, idx) => ( - - ))} -
-
- ); - }) -); - -interface ChatInputProps { - value: string; - baseFontSize: number; - onChange: (e: React.ChangeEvent) => void; - onKeyDown: (e: React.KeyboardEvent) => void; - onMouseDown: (e: React.MouseEvent) => void; - model: WaveAiModel; -} - -const ChatInput = forwardRef( - ({ value, onChange, onKeyDown, onMouseDown, baseFontSize, model }, ref) => { - const textAreaRef = useRef(null); - - useImperativeHandle(ref, () => textAreaRef.current as HTMLTextAreaElement); - - useEffect(() => { - model.textAreaRef = textAreaRef; - }, []); - - const adjustTextAreaHeight = useCallback( - (value: string) => { - if (textAreaRef.current == null) { - return; - } - - // Adjust the height of the textarea to fit the text - const textAreaMaxLines = 5; - const textAreaLineHeight = baseFontSize * 1.5; - const textAreaMinHeight = textAreaLineHeight; - const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines; - - if (value === "") { - textAreaRef.current.style.height = `${textAreaLineHeight}px`; - return; - } - - textAreaRef.current.style.height = `${textAreaLineHeight}px`; - const scrollHeight = textAreaRef.current.scrollHeight; - const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight); - textAreaRef.current.style.height = newHeight + "px"; - }, - [baseFontSize] - ); - - useEffect(() => { - adjustTextAreaHeight(value); - }, [value]); - - return ( - - ); - } -); - -const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { - const { sendMessage } = model.useWaveAi(); - const waveaiRef = useRef(null); - const chatWindowRef = useRef(null); - const osRef = useRef(null); - const inputRef = useRef(null); - - const [value, setValue] = useState(""); - const [selectedBlockIdx, setSelectedBlockIdx] = useState(null); - - const baseFontSize: number = 14; - const msgWidths = {}; - const locked = useAtomValue(model.locked); - const aiOpts = useAtomValue(model.aiOpts); - const isUsingProxy = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl); - - // a weird workaround to initialize ansynchronously - useEffect(() => { - fireAndForget(model.populateMessages.bind(model)); - }, []); - - const handleTextAreaChange = (e: React.ChangeEvent) => { - setValue(e.target.value); - }; - - const updatePreTagOutline = (clickedPre?: HTMLElement | null) => { - const pres = chatWindowRef.current?.querySelectorAll("pre"); - if (!pres) return; - - pres.forEach((preElement, idx) => { - if (preElement === clickedPre) { - setSelectedBlockIdx(idx); - } else { - preElement.style.outline = "none"; - } - }); - - if (clickedPre) { - clickedPre.style.outline = outline; - } - }; - - useEffect(() => { - if (selectedBlockIdx !== null) { - const pres = chatWindowRef.current?.querySelectorAll("pre"); - if (pres && pres[selectedBlockIdx]) { - pres[selectedBlockIdx].style.outline = outline; - } - } - }, [selectedBlockIdx]); - - const handleTextAreaMouseDown = () => { - updatePreTagOutline(); - setSelectedBlockIdx(null); - }; - - const handleEnterKeyPressed = useCallback(() => { - // using globalStore to avoid potential timing problems - // useAtom means the component must rerender once before - // the unlock is detected. this automatically checks on the - // callback firing instead - const locked = globalStore.get(model.locked); - if (locked || value === "") return; - - sendMessage(value); - setValue(""); - setSelectedBlockIdx(null); - }, [value]); - - const updateScrollTop = () => { - const pres = chatWindowRef.current?.querySelectorAll("pre"); - if (!pres || selectedBlockIdx === null) return; - - const block = pres[selectedBlockIdx]; - if (!block || !osRef.current?.osInstance()) return; - - const { viewport, scrollOffsetElement } = osRef.current.osInstance().elements(); - const chatWindowTop = scrollOffsetElement.scrollTop; - const chatWindowHeight = chatWindowRef.current.clientHeight; - const chatWindowBottom = chatWindowTop + chatWindowHeight; - const elemTop = block.offsetTop; - const elemBottom = elemTop + block.offsetHeight; - const elementIsInView = elemBottom <= chatWindowBottom && elemTop >= chatWindowTop; - - if (!elementIsInView) { - let scrollPosition; - if (elemBottom > chatWindowBottom) { - scrollPosition = elemTop - chatWindowHeight + block.offsetHeight + 15; - } else if (elemTop < chatWindowTop) { - scrollPosition = elemTop - 15; - } - viewport.scrollTo({ - behavior: "auto", - top: scrollPosition, - }); - } - }; - - const shouldSelectCodeBlock = (key: "ArrowUp" | "ArrowDown") => { - const textarea = inputRef.current; - const cursorPosition = textarea?.selectionStart || 0; - const textBeforeCursor = textarea?.value.slice(0, cursorPosition) || ""; - - return ( - (textBeforeCursor.indexOf("\n") === -1 && cursorPosition === 0 && key === "ArrowUp") || - selectedBlockIdx !== null - ); - }; - - const handleArrowUpPressed = (e: React.KeyboardEvent) => { - if (shouldSelectCodeBlock("ArrowUp")) { - e.preventDefault(); - const pres = chatWindowRef.current?.querySelectorAll("pre"); - let blockIndex = selectedBlockIdx; - if (!pres) return; - if (blockIndex === null) { - setSelectedBlockIdx(pres.length - 1); - } else if (blockIndex > 0) { - blockIndex--; - setSelectedBlockIdx(blockIndex); - } - updateScrollTop(); - } - }; - - const handleArrowDownPressed = (e: React.KeyboardEvent) => { - if (shouldSelectCodeBlock("ArrowDown")) { - e.preventDefault(); - const pres = chatWindowRef.current?.querySelectorAll("pre"); - let blockIndex = selectedBlockIdx; - if (!pres) return; - if (blockIndex === null) return; - if (blockIndex < pres.length - 1 && blockIndex >= 0) { - setSelectedBlockIdx(++blockIndex); - updateScrollTop(); - } else { - inputRef.current.focus(); - setSelectedBlockIdx(null); - } - updateScrollTop(); - } - }; - - const handleTextAreaKeyDown = (e: React.KeyboardEvent) => { - const waveEvent = adaptFromReactOrNativeKeyEvent(e); - if (checkKeyPressed(waveEvent, "Enter")) { - e.preventDefault(); - handleEnterKeyPressed(); - } else if (checkKeyPressed(waveEvent, "ArrowUp")) { - handleArrowUpPressed(e); - } else if (checkKeyPressed(waveEvent, "ArrowDown")) { - handleArrowDownPressed(e); - } - }; - - let buttonClass = "waveai-submit-button"; - let buttonIcon = makeIconClass("arrow-up", false); - let buttonTitle = "run"; - if (locked) { - buttonClass = "waveai-submit-button stop"; - buttonIcon = makeIconClass("stop", false); - buttonTitle = "stop"; - } - const handleButtonPress = useCallback(() => { - if (locked) { - model.cancel = true; - } else { - handleEnterKeyPressed(); - } - }, [locked, handleEnterKeyPressed]); - +function WaveAiDeprecatedView() { const handleOpenAIPanel = useCallback(() => { WorkspaceLayoutModel.getInstance().setAIPanelVisible(true); }, []); return ( -
- {isUsingProxy && ( -
- - - Wave AI Proxy is deprecated and will be removed. Please use the new{" "} - {" "} - instead (better model, terminal integration, tool support, image uploads). - -
- )} -
- -
-
-
- -
-
+
); -}; - -export { WaveAi }; +} diff --git a/frontend/app/view/waveconfig/waveconfig-model.ts b/frontend/app/view/waveconfig/waveconfig-model.ts index f41a39eccd..73703c2e87 100644 --- a/frontend/app/view/waveconfig/waveconfig-model.ts +++ b/frontend/app/view/waveconfig/waveconfig-model.ts @@ -2,16 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; -import { getApi, getBlockMetaKeyAtom, WOS } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import type { TabModel } from "@/app/store/tab-model"; -import { RpcApi } from "@/app/store/wshclientapi"; +import { makeORef } from "@/app/store/wos"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { SecretsContent } from "@/app/view/waveconfig/secretscontent"; import { WaveConfigView } from "@/app/view/waveconfig/waveconfig"; -import { isWindows } from "@/util/platformutil"; +import type { WaveConfigEnv } from "@/app/view/waveconfig/waveconfigenv"; import { base64ToString, stringToBase64 } from "@/util/util"; -import { atom, type PrimitiveAtom } from "jotai"; +import { atom, type Atom, type PrimitiveAtom } from "jotai"; import type * as MonacoTypes from "monaco-editor"; import * as React from "react"; @@ -33,16 +32,6 @@ export type ConfigFile = { export const SecretNameRegex = /^[A-Za-z][A-Za-z0-9_]*$/; -function validateBgJson(parsed: any): ValidationResult { - const keys = Object.keys(parsed); - for (const key of keys) { - if (!key.startsWith("bg@")) { - return { error: `Invalid key "${key}": all top-level keys must start with "bg@"` }; - } - } - return { success: true }; -} - function validateAiJson(parsed: any): ValidationResult { const keys = Object.keys(parsed); for (const key of keys) { @@ -66,55 +55,56 @@ function validateWaveAiJson(parsed: any): ValidationResult { return { success: true }; } -const configFiles: ConfigFile[] = [ - { - name: "General", - path: "settings.json", - language: "json", - docsUrl: "https://docs.waveterm.dev/config", - hasJsonView: true, - }, - { - name: "Connections", - path: "connections.json", - language: "json", - docsUrl: "https://docs.waveterm.dev/connections", - description: isWindows() ? "SSH hosts and WSL distros" : "SSH hosts", - hasJsonView: true, - }, - { - name: "Sidebar Widgets", - path: "widgets.json", - language: "json", - docsUrl: "https://docs.waveterm.dev/customwidgets", - hasJsonView: true, - }, - { - name: "Wave AI Modes", - path: "waveai.json", - language: "json", - description: "Local models and BYOK", - docsUrl: "https://docs.waveterm.dev/waveai-modes", - validator: validateWaveAiJson, - hasJsonView: true, - // visualComponent: WaveAIVisualContent, - }, - { - name: "Tab Backgrounds", - path: "presets/bg.json", - language: "json", - docsUrl: "https://docs.waveterm.dev/presets#background-configurations", - validator: validateBgJson, - hasJsonView: true, - }, - { - name: "Secrets", - path: "secrets", - isSecrets: true, - hasJsonView: false, - visualComponent: SecretsContent, - }, -]; +function makeConfigFiles(isWindows: boolean): ConfigFile[] { + return [ + { + name: "General", + path: "settings.json", + language: "json", + docsUrl: "https://docs.waveterm.dev/config", + hasJsonView: true, + }, + { + name: "Connections", + path: "connections.json", + language: "json", + docsUrl: "https://docs.waveterm.dev/connections", + description: isWindows ? "SSH hosts and WSL distros" : "SSH hosts", + hasJsonView: true, + }, + { + name: "Sidebar Widgets", + path: "widgets.json", + language: "json", + docsUrl: "https://docs.waveterm.dev/customwidgets", + hasJsonView: true, + }, + { + name: "Wave AI Modes", + path: "waveai.json", + language: "json", + description: "Local models and BYOK", + docsUrl: "https://docs.waveterm.dev/waveai-modes", + validator: validateWaveAiJson, + hasJsonView: true, + // visualComponent: WaveAIVisualContent, + }, + { + name: "Tab Backgrounds", + path: "backgrounds.json", + language: "json", + docsUrl: "https://docs.waveterm.dev/tab-backgrounds", + hasJsonView: true, + }, + { + name: "Secrets", + path: "secrets", + isSecrets: true, + hasJsonView: false, + visualComponent: SecretsContent, + }, + ]; +} const deprecatedConfigFiles: ConfigFile[] = [ { @@ -144,6 +134,7 @@ export class WaveConfigViewModel implements ViewModel { noPadding = atom(true); nodeModel: BlockNodeModel; tabModel: TabModel; + env: WaveConfigEnv; selectedFileAtom: PrimitiveAtom; fileContentAtom: PrimitiveAtom; @@ -156,6 +147,7 @@ export class WaveConfigViewModel implements ViewModel { isMenuOpenAtom: PrimitiveAtom; presetsJsonExistsAtom: PrimitiveAtom; activeTabAtom: PrimitiveAtom<"visual" | "json">; + configErrorFilesAtom: Atom>; configDir: string; saveShortcut: string; editorRef: React.RefObject; @@ -170,12 +162,13 @@ export class WaveConfigViewModel implements ViewModel { storageBackendErrorAtom: PrimitiveAtom; secretValueRef: HTMLTextAreaElement | null = null; - constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { + constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) { this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; - this.configDir = getApi().getConfigDir(); - const platform = getApi().getPlatform(); + this.env = waveEnv as WaveConfigEnv; + this.configDir = this.env.electron.getConfigDir(); + const platform = this.env.electron.getPlatform(); this.saveShortcut = platform === "darwin" ? "Cmd+S" : "Alt+S"; this.selectedFileAtom = atom(null) as PrimitiveAtom; @@ -189,6 +182,14 @@ export class WaveConfigViewModel implements ViewModel { this.isMenuOpenAtom = atom(false); this.presetsJsonExistsAtom = atom(false); this.activeTabAtom = atom<"visual" | "json">("visual"); + this.configErrorFilesAtom = atom((get) => { + const fullConfig = get(this.env.atoms.fullConfigAtom); + const errorSet = new Set(); + for (const cerr of fullConfig?.configerrors ?? []) { + errorSet.add(cerr.file); + } + return errorSet; + }); this.editorRef = React.createRef(); this.secretNamesAtom = atom([]); @@ -207,7 +208,7 @@ export class WaveConfigViewModel implements ViewModel { async checkPresetsJsonExists() { try { const fullPath = `${this.configDir}/presets.json`; - const fileInfo = await RpcApi.FileInfoCommand(TabRpcClient, { + const fileInfo = await this.env.rpc.FileInfoCommand(TabRpcClient, { info: { path: fullPath }, }); if (!fileInfo.notfound) { @@ -221,8 +222,10 @@ export class WaveConfigViewModel implements ViewModel { initialize() { const selectedFile = globalStore.get(this.selectedFileAtom); if (!selectedFile) { - const metaFileAtom = getBlockMetaKeyAtom(this.blockId, "file"); + const metaFileAtom = this.env.getBlockMetaKeyAtom(this.blockId, "file"); const savedFilePath = globalStore.get(metaFileAtom); + const configFiles = this.getConfigFiles(); + const deprecatedConfigFiles = this.getDeprecatedConfigFiles(); let fileToLoad: ConfigFile | null = null; if (savedFilePath) { @@ -243,7 +246,7 @@ export class WaveConfigViewModel implements ViewModel { } getConfigFiles(): ConfigFile[] { - return configFiles; + return makeConfigFiles(this.env.isWindows()); } getDeprecatedConfigFiles(): ConfigFile[] { @@ -260,6 +263,21 @@ export class WaveConfigViewModel implements ViewModel { return globalStore.get(this.hasEditedAtom); } + confirmDiscardChanges(): boolean { + if (!this.hasChanges()) { + return true; + } + return window.confirm("You have unsaved changes. Discard and continue?"); + } + + discardChanges() { + const originalContent = globalStore.get(this.originalContentAtom); + globalStore.set(this.fileContentAtom, originalContent); + globalStore.set(this.hasEditedAtom, false); + globalStore.set(this.validationErrorAtom, null); + globalStore.set(this.errorMessageAtom, null); + } + markAsEdited() { globalStore.set(this.hasEditedAtom, true); } @@ -271,8 +289,8 @@ export class WaveConfigViewModel implements ViewModel { if (file.isSecrets) { globalStore.set(this.selectedFileAtom, file); - RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { file: file.path }, }); globalStore.set(this.isLoadingAtom, false); @@ -283,7 +301,7 @@ export class WaveConfigViewModel implements ViewModel { try { const fullPath = `${this.configDir}/${file.path}`; - const fileData = await RpcApi.FileReadCommand(TabRpcClient, { + const fileData = await this.env.rpc.FileReadCommand(TabRpcClient, { info: { path: fullPath }, }); const content = fileData?.data64 ? base64ToString(fileData.data64) : ""; @@ -294,8 +312,8 @@ export class WaveConfigViewModel implements ViewModel { globalStore.set(this.fileContentAtom, content); } globalStore.set(this.selectedFileAtom, file); - RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { file: file.path }, }); } catch (err) { @@ -320,7 +338,7 @@ export class WaveConfigViewModel implements ViewModel { try { const fullPath = `${this.configDir}/${selectedFile.path}`; - await RpcApi.FileWriteCommand(TabRpcClient, { + await this.env.rpc.FileWriteCommand(TabRpcClient, { info: { path: fullPath }, data64: stringToBase64(""), }); @@ -362,7 +380,7 @@ export class WaveConfigViewModel implements ViewModel { try { const fullPath = `${this.configDir}/${selectedFile.path}`; - await RpcApi.FileWriteCommand(TabRpcClient, { + await this.env.rpc.FileWriteCommand(TabRpcClient, { info: { path: fullPath }, data64: stringToBase64(formatted), }); @@ -392,7 +410,7 @@ export class WaveConfigViewModel implements ViewModel { async checkStorageBackend() { try { - const backend = await RpcApi.GetSecretsLinuxStorageBackendCommand(TabRpcClient); + const backend = await this.env.rpc.GetSecretsLinuxStorageBackendCommand(TabRpcClient); if (backend === "basic_text" || backend === "unknown") { globalStore.set( this.storageBackendErrorAtom, @@ -411,7 +429,7 @@ export class WaveConfigViewModel implements ViewModel { globalStore.set(this.errorMessageAtom, null); try { - const names = await RpcApi.GetSecretsNamesCommand(TabRpcClient); + const names = await this.env.rpc.GetSecretsNamesCommand(TabRpcClient); globalStore.set(this.secretNamesAtom, names || []); } catch (error) { globalStore.set(this.errorMessageAtom, `Failed to load secrets: ${error.message}`); @@ -443,7 +461,7 @@ export class WaveConfigViewModel implements ViewModel { globalStore.set(this.errorMessageAtom, null); try { - const secrets = await RpcApi.GetSecretsCommand(TabRpcClient, [selectedSecret]); + const secrets = await this.env.rpc.GetSecretsCommand(TabRpcClient, [selectedSecret]); const value = secrets[selectedSecret]; if (value !== undefined) { globalStore.set(this.secretValueAtom, value); @@ -470,8 +488,8 @@ export class WaveConfigViewModel implements ViewModel { globalStore.set(this.errorMessageAtom, null); try { - await RpcApi.SetSecretsCommand(TabRpcClient, { [selectedSecret]: secretValue }); - RpcApi.RecordTEventCommand( + await this.env.rpc.SetSecretsCommand(TabRpcClient, { [selectedSecret]: secretValue }); + this.env.rpc.RecordTEventCommand( TabRpcClient, { event: "action:other", @@ -500,7 +518,7 @@ export class WaveConfigViewModel implements ViewModel { globalStore.set(this.errorMessageAtom, null); try { - await RpcApi.SetSecretsCommand(TabRpcClient, { [selectedSecret]: null }); + await this.env.rpc.SetSecretsCommand(TabRpcClient, { [selectedSecret]: null }); this.closeSecretView(); await this.refreshSecrets(); } catch (error) { @@ -551,8 +569,8 @@ export class WaveConfigViewModel implements ViewModel { globalStore.set(this.errorMessageAtom, null); try { - await RpcApi.SetSecretsCommand(TabRpcClient, { [name]: value }); - RpcApi.RecordTEventCommand( + await this.env.rpc.SetSecretsCommand(TabRpcClient, { [name]: value }); + this.env.rpc.RecordTEventCommand( TabRpcClient, { event: "action:other", diff --git a/frontend/app/view/waveconfig/waveconfig.tsx b/frontend/app/view/waveconfig/waveconfig.tsx index 4e515466f4..ca06295bfc 100644 --- a/frontend/app/view/waveconfig/waveconfig.tsx +++ b/frontend/app/view/waveconfig/waveconfig.tsx @@ -6,9 +6,11 @@ import { globalStore } from "@/app/store/jotaiStore"; import { tryReinjectKey } from "@/app/store/keymodel"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import type { ConfigFile, WaveConfigViewModel } from "@/app/view/waveconfig/waveconfig-model"; +import type { WaveConfigEnv } from "@/app/view/waveconfig/waveconfigenv"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed, keydownWrapper } from "@/util/keyutil"; import { cn } from "@/util/util"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import type * as MonacoTypes from "monaco-editor"; import { memo, useCallback, useEffect } from "react"; @@ -18,11 +20,14 @@ interface ConfigSidebarProps { const ConfigSidebar = memo(({ model }: ConfigSidebarProps) => { const selectedFile = useAtomValue(model.selectedFileAtom); - const [isMenuOpen, setIsMenuOpen] = useAtom(model.isMenuOpenAtom); + const setIsMenuOpen = useSetAtom(model.isMenuOpenAtom); const configFiles = model.getConfigFiles(); const deprecatedConfigFiles = model.getDeprecatedConfigFiles(); + const configErrorFiles = useAtomValue(model.configErrorFilesAtom); const handleFileSelect = (file: ConfigFile) => { + if (selectedFile?.path === file.path) return; + if (!model.confirmDiscardChanges()) return; model.loadFile(file); setIsMenuOpen(false); }; @@ -46,7 +51,12 @@ const ConfigSidebar = memo(({ model }: ConfigSidebarProps) => { selectedFile?.path === file.path ? "bg-accentbg text-primary" : "hover:bg-secondary/50" }`} > -
{file.name}
+
+
{file.name}
+ {configErrorFiles.has(file.path) && ( + + )} +
{file.description && (
{file.description} @@ -75,6 +85,9 @@ const ConfigSidebar = memo(({ model }: ConfigSidebarProps) => { > deprecated + {configErrorFiles.has(file.path) && ( + + )}
))} @@ -87,6 +100,7 @@ const ConfigSidebar = memo(({ model }: ConfigSidebarProps) => { ConfigSidebar.displayName = "ConfigSidebar"; const WaveConfigView = memo(({ blockId, model }: ViewComponentProps) => { + const env = useWaveEnv(); const selectedFile = useAtomValue(model.selectedFileAtom); const [fileContent, setFileContent] = useAtom(model.fileContentAtom); const isLoading = useAtomValue(model.isLoadingAtom); @@ -96,6 +110,8 @@ const WaveConfigView = memo(({ blockId, model }: ViewComponentProps { @@ -148,142 +164,162 @@ const WaveConfigView = memo(({ blockId, model }: ViewComponentProps - {isMenuOpen && ( -
setIsMenuOpen(false)} /> - )} -
- -
-
- {selectedFile && ( - <> -
-
- -
- {selectedFile.name} -
- {selectedFile.docsUrl && ( - - - - - - )} -
- {selectedFile.path} -
-
-
- {selectedFile.hasJsonView && ( - <> - {hasChanges && ( - - Unsaved changes - - )} - - +
+ {selectedFile.name} +
+ {selectedFile.docsUrl && ( + + - {isSaving ? "Saving..." : "Save"} - + + - - )} -
-
- {selectedFile.visualComponent && selectedFile.hasJsonView && ( -
- -
+
+ {selectedFile.hasJsonView && ( + <> + {hasChanges && ( + + Unsaved changes + + )} + + + + )} - > - Raw JSON - -
- )} - {errorMessage && ( -
- {errorMessage} - -
- )} - {validationError && ( -
- {validationError} - +
- )} -
- {isLoading ? ( -
- Loading... + {selectedFile.visualComponent && selectedFile.hasJsonView && ( +
+ + {/* No guard needed: visual tab saves changes immediately via RPC */} +
- ) : selectedFile.visualComponent && - (!selectedFile.hasJsonView || activeTab === "visual") ? ( - (() => { - const VisualComponent = selectedFile.visualComponent; - return ; - })() - ) : ( - )} -
- - )} + {errorMessage && ( +
+ {errorMessage} + +
+ )} + {validationError && ( +
+ {validationError} + +
+ )} +
+ {isLoading ? ( +
+ Loading... +
+ ) : selectedFile.visualComponent && + (!selectedFile.hasJsonView || activeTab === "visual") ? ( + (() => { + const VisualComponent = selectedFile.visualComponent; + return ; + })() + ) : ( + + )} +
+ + )} +
+ {configErrors?.length > 0 && ( +
+ {configErrors.map((cerr, i) => ( +
+ Config Error: + {cerr.file}: {cerr.err} +
+ ))} +
+ )}
); }); diff --git a/frontend/app/view/waveconfig/waveconfigenv.ts b/frontend/app/view/waveconfig/waveconfigenv.ts new file mode 100644 index 0000000000..c76352cbde --- /dev/null +++ b/frontend/app/view/waveconfig/waveconfigenv.ts @@ -0,0 +1,27 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { MetaKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; + +export type WaveConfigEnv = WaveEnvSubset<{ + electron: { + getConfigDir: WaveEnv["electron"]["getConfigDir"]; + getPlatform: WaveEnv["electron"]["getPlatform"]; + }; + rpc: { + FileInfoCommand: WaveEnv["rpc"]["FileInfoCommand"]; + FileReadCommand: WaveEnv["rpc"]["FileReadCommand"]; + FileWriteCommand: WaveEnv["rpc"]["FileWriteCommand"]; + SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; + GetSecretsLinuxStorageBackendCommand: WaveEnv["rpc"]["GetSecretsLinuxStorageBackendCommand"]; + GetSecretsNamesCommand: WaveEnv["rpc"]["GetSecretsNamesCommand"]; + GetSecretsCommand: WaveEnv["rpc"]["GetSecretsCommand"]; + SetSecretsCommand: WaveEnv["rpc"]["SetSecretsCommand"]; + RecordTEventCommand: WaveEnv["rpc"]["RecordTEventCommand"]; + }; + atoms: { + fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + }; + getBlockMetaKeyAtom: MetaKeyAtomFnType<"file">; + isWindows: WaveEnv["isWindows"]; +}>; diff --git a/frontend/app/view/webview/webview.test.tsx b/frontend/app/view/webview/webview.test.tsx new file mode 100644 index 0000000000..6114160218 --- /dev/null +++ b/frontend/app/view/webview/webview.test.tsx @@ -0,0 +1,59 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import { makeMockWaveEnv } from "@/preview/mock/mockwaveenv"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { atom } from "jotai"; +import { getWebPreviewDisplayUrl, WebViewModel, WebViewPreviewFallback } from "./webview"; + +describe("webview preview fallback", () => { + it("shows the requested URL", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain("electron webview unavailable"); + expect(markup).toContain("https://waveterm.dev/docs"); + }); + + it("falls back to about:blank when no URL is available", () => { + expect(getWebPreviewDisplayUrl("")).toBe("about:blank"); + expect(getWebPreviewDisplayUrl(null)).toBe("about:blank"); + }); + + it("uses the supplied env for homepage atoms and config updates", async () => { + const blockId = "webview-env-block"; + const env = makeMockWaveEnv({ + settings: { + "web:defaulturl": "https://default.example", + }, + mockWaveObjs: { + [`block:${blockId}`]: { + otype: "block", + oid: blockId, + version: 1, + meta: { + pinnedurl: "https://block.example", + }, + } as Block, + }, + }); + const model = new WebViewModel({ + blockId, + nodeModel: { + isFocused: atom(true), + focusNode: () => {}, + } as any, + tabModel: {} as any, + waveEnv: env, + }); + + expect(globalStore.get(model.homepageUrl)).toBe("https://block.example"); + + await model.setHomepageUrl("https://global.example", "global"); + + expect(globalStore.get(model.homepageUrl)).toBe("https://global.example"); + expect(globalStore.get(env.getSettingsKeyAtom("web:defaulturl"))).toBe("https://global.example"); + expect(globalStore.get(env.wos.getWaveObjectAtom(`block:${blockId}`))?.meta?.pinnedurl).toBeUndefined(); + }); +}); diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index df50221764..551f23bbb7 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -3,18 +3,19 @@ import { BlockNodeModel } from "@/app/block/blocktypes"; import { Search, useSearch } from "@/app/element/search"; -import { createBlock, getApi, getBlockMetaKeyAtom, getSettingsKeyAtom, openLink } from "@/app/store/global"; +import { globalStore } from "@/app/store/jotaiStore"; import { getSimpleControlShiftAtom } from "@/app/store/keymodel"; -import { ObjectService } from "@/app/store/services"; import type { TabModel } from "@/app/store/tab-model"; -import { RpcApi } from "@/app/store/wshclientapi"; +import { makeORef } from "@/app/store/wos"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BlockHeaderSuggestionControl, SuggestionControlNoData, SuggestionControlNoResults, } from "@/app/suggestion/suggestion"; -import { WOS, globalStore } from "@/store/global"; +import { MockBoundary } from "@/app/waveenv/mockboundary"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { openLink } from "@/store/global"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget, useAtomValueSafe } from "@/util/util"; import clsx from "clsx"; @@ -22,6 +23,7 @@ import { WebviewTag } from "electron"; import { Atom, PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai"; import { Fragment, createRef, memo, useCallback, useEffect, useRef, useState } from "react"; import "./webview.scss"; +import type { WebViewEnv } from "./webviewenv"; // User agent strings for mobile emulation const USER_AGENT_IPHONE = @@ -31,9 +33,9 @@ const USER_AGENT_ANDROID = let webviewPreloadUrl = null; -function getWebviewPreloadUrl() { +function getWebviewPreloadUrl(env: WebViewEnv) { if (webviewPreloadUrl == null) { - webviewPreloadUrl = getApi().getWebviewPreload(); + webviewPreloadUrl = env.electron.getWebviewPreload(); console.log("webviewPreloadUrl", webviewPreloadUrl); } if (webviewPreloadUrl == null) { @@ -71,19 +73,22 @@ export class WebViewModel implements ViewModel { typeaheadOpen: PrimitiveAtom; partitionOverride: PrimitiveAtom | null; userAgentType: Atom; + env: WebViewEnv; + ctrlShiftUnsubFn: (() => void) | null = null; - constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { + constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) { this.nodeModel = nodeModel; this.tabModel = tabModel; this.viewType = "web"; this.blockId = blockId; + this.env = waveEnv; this.noPadding = atom(true); - this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.blockAtom = this.env.wos.getWaveObjectAtom(`block:${blockId}`); this.url = atom(); - const defaultUrlAtom = getSettingsKeyAtom("web:defaulturl"); + const defaultUrlAtom = this.env.getSettingsKeyAtom("web:defaulturl"); this.homepageUrl = atom((get) => { const defaultUrl = get(defaultUrlAtom); - const pinnedUrl = get(this.blockAtom).meta.pinnedurl; + const pinnedUrl = get(this.blockAtom)?.meta?.pinnedurl; return pinnedUrl ?? defaultUrl; }); this.urlWrapperClassName = atom(""); @@ -96,10 +101,10 @@ export class WebViewModel implements ViewModel { this.urlInputRef = createRef(); this.webviewRef = createRef(); this.domReady = atom(false); - this.hideNav = getBlockMetaKeyAtom(blockId, "web:hidenav"); + this.hideNav = this.env.getBlockMetaKeyAtom(blockId, "web:hidenav"); this.typeaheadOpen = atom(false); this.partitionOverride = null; - this.userAgentType = getBlockMetaKeyAtom(blockId, "web:useragenttype"); + this.userAgentType = this.env.getBlockMetaKeyAtom(blockId, "web:useragenttype"); this.mediaPlaying = atom(false); this.mediaMuted = atom(false); @@ -112,7 +117,7 @@ export class WebViewModel implements ViewModel { const refreshIcon = get(this.refreshIcon); const mediaPlaying = get(this.mediaPlaying); const mediaMuted = get(this.mediaMuted); - const url = currUrl ?? metaUrl ?? homepageUrl; + const url = currUrl ?? metaUrl ?? homepageUrl ?? ""; const rtn: HeaderElem[] = []; if (get(this.hideNav)) { return rtn; @@ -198,7 +203,7 @@ export class WebViewModel implements ViewModel { console.log("open external", url); if (url != null && url != "") { const externalUrl = this.modifyExternalUrl?.(url) ?? url; - return getApi().openExternal(externalUrl); + return this.env.electron.openExternal(externalUrl); } }, }); @@ -207,6 +212,11 @@ export class WebViewModel implements ViewModel { }); } + dispose() { + this.ctrlShiftUnsubFn?.(); + this.ctrlShiftUnsubFn = null; + } + get viewComponent(): ViewComponent { return WebView; } @@ -279,7 +289,7 @@ export class WebViewModel implements ViewModel { query: string, reqContext: SuggestionRequestContext ): Promise { - const result = await RpcApi.FetchSuggestionsCommand(TabRpcClient, { + const result = await this.env.rpc.FetchSuggestionsCommand(TabRpcClient, { suggestiontype: "bookmark", query, widgetid: reqContext.widgetid, @@ -368,7 +378,12 @@ export class WebViewModel implements ViewModel { * @param url The URL that has been navigated to. */ handleNavigate(url: string) { - fireAndForget(() => ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { url })); + fireAndForget(() => + this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), + meta: { url }, + }) + ); globalStore.set(this.url, url); if (this.searchAtoms) { globalStore.set(this.searchAtoms.isOpen, false); @@ -414,7 +429,7 @@ export class WebViewModel implements ViewModel { * @param newUrl The new URL to load in the webview. */ loadUrl(newUrl: string, reason: string) { - const defaultSearchAtom = getSettingsKeyAtom("web:defaultsearch"); + const defaultSearchAtom = this.env.getSettingsKeyAtom("web:defaultsearch"); const searchTemplate = globalStore.get(defaultSearchAtom); const nextUrl = this.ensureUrlScheme(newUrl, searchTemplate); console.log("webview loadUrl", reason, nextUrl, "cur=", this.webviewRef.current.getURL()); @@ -436,7 +451,7 @@ export class WebViewModel implements ViewModel { * @returns Promise that resolves when the URL is loaded. */ loadUrlPromise(newUrl: string, reason: string): Promise { - const defaultSearchAtom = getSettingsKeyAtom("web:defaultsearch"); + const defaultSearchAtom = this.env.getSettingsKeyAtom("web:defaultsearch"); const searchTemplate = globalStore.get(defaultSearchAtom); const nextUrl = this.ensureUrlScheme(newUrl, searchTemplate); console.log("webview loadUrlPromise", reason, nextUrl, "cur=", this.webviewRef.current?.getURL()); @@ -476,17 +491,17 @@ export class WebViewModel implements ViewModel { if (url != null && url != "") { switch (scope) { case "block": - await RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + await this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { pinnedurl: url }, }); break; case "global": - await RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), - meta: { pinnedurl: "" }, + await this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), + meta: { pinnedurl: null }, }); - await RpcApi.SetConfigCommand(TabRpcClient, { "web:defaulturl": url }); + await this.env.rpc.SetConfigCommand(TabRpcClient, { "web:defaulturl": url }); break; } } @@ -499,18 +514,21 @@ export class WebViewModel implements ViewModel { return true; } const ctrlShiftState = globalStore.get(getSimpleControlShiftAtom()); - if (ctrlShiftState) { + if (ctrlShiftState && !this.ctrlShiftUnsubFn) { // this is really weird, we don't get keyup events from webview - const unsubFn = globalStore.sub(getSimpleControlShiftAtom(), () => { + this.ctrlShiftUnsubFn = globalStore.sub(getSimpleControlShiftAtom(), () => { const state = globalStore.get(getSimpleControlShiftAtom()); if (!state) { - unsubFn(); + this.ctrlShiftUnsubFn?.(); + this.ctrlShiftUnsubFn = null; const isStillFocused = globalStore.get(this.nodeModel.isFocused); if (isStillFocused) { this.webviewRef.current?.focus(); } } }); + } + if (ctrlShiftState) { return false; } this.webviewRef.current?.focus(); @@ -536,7 +554,7 @@ export class WebViewModel implements ViewModel { try { const webContentsId = this.webviewRef.current?.getWebContentsId(); if (webContentsId) { - await getApi().clearWebviewStorage(webContentsId); + await this.env.electron.clearWebviewStorage(webContentsId); } } catch (e) { console.error("Failed to clear cookies and storage", e); @@ -582,8 +600,8 @@ export class WebViewModel implements ViewModel { return; } this.webviewRef.current?.setZoomFactor(factor || 1); - RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { "web:zoom": factor }, // allow null so we can remove the zoom factor here }); } @@ -631,8 +649,8 @@ export class WebViewModel implements ViewModel { type: "checkbox", click: () => { fireAndForget(() => { - return RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + return this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { "web:useragenttype": null }, }); }); @@ -644,8 +662,8 @@ export class WebViewModel implements ViewModel { type: "checkbox", click: () => { fireAndForget(() => { - return RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + return this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { "web:useragenttype": "mobile:iphone" }, }); }); @@ -657,8 +675,8 @@ export class WebViewModel implements ViewModel { type: "checkbox", click: () => { fireAndForget(() => { - return RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + return this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { "web:useragenttype": "mobile:android" }, }); }); @@ -695,8 +713,8 @@ export class WebViewModel implements ViewModel { label: isNavHidden ? "Un-Hide Navigation" : "Hide Navigation", click: () => fireAndForget(() => { - return RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + return this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { "web:hidenav": !isNavHidden }, }); }), @@ -734,16 +752,17 @@ export class WebViewModel implements ViewModel { const BookmarkTypeahead = memo( ({ model, blockRef }: { model: WebViewModel; blockRef: React.RefObject }) => { + const env = useWaveEnv(); const openBookmarksJson = () => { fireAndForget(async () => { - const path = `${getApi().getConfigDir()}/presets/bookmarks.json`; + const path = `${env.electron.getConfigDir()}/presets/bookmarks.json`; const blockDef: BlockDef = { meta: { view: "preview", file: path, }, }; - await createBlock(blockDef, false, true); + await env.createBlock(blockDef, false, true); model.setTypeaheadOpen(false); }); }; @@ -802,17 +821,40 @@ interface WebViewProps { initialSrc?: string; } +function getWebPreviewDisplayUrl(url?: string | null): string { + return url?.trim() || "about:blank"; +} + +function WebViewPreviewFallback({ url }: { url?: string | null }) { + const displayUrl = getWebPreviewDisplayUrl(url); + + return ( +
+
+
preview mock · electron webview unavailable
+
web widget placeholder
+
+ {displayUrl} +
+
+
+ ); +} + const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) => { + const env = useWaveEnv(); const blockData = useAtomValue(model.blockAtom); const defaultUrl = useAtomValue(model.homepageUrl); - const defaultSearchAtom = getSettingsKeyAtom("web:defaultsearch"); + const defaultSearchAtom = env.getSettingsKeyAtom("web:defaultsearch"); const defaultSearch = useAtomValue(defaultSearchAtom); - let metaUrl = blockData?.meta?.url || defaultUrl; - metaUrl = model.ensureUrlScheme(metaUrl, defaultSearch); + let metaUrl = blockData?.meta?.url || defaultUrl || ""; + if (metaUrl) { + metaUrl = model.ensureUrlScheme(metaUrl, defaultSearch); + } const metaUrlRef = useRef(metaUrl); - const zoomFactor = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:zoom")) || 1; + const zoomFactor = useAtomValue(env.getBlockMetaKeyAtom(model.blockId, "web:zoom")) || 1; const partitionOverride = useAtomValueSafe(model.partitionOverride); - const metaPartition = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:partition")); + const metaPartition = useAtomValue(env.getBlockMetaKeyAtom(model.blockId, "web:partition")); const webPartition = partitionOverride || metaPartition || undefined; const userAgentType = useAtomValue(model.userAgentType) || "default"; @@ -1004,11 +1046,11 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) } }; const webviewFocus = () => { - getApi().setWebviewFocus(webview.getWebContentsId()); + env.electron.setWebviewFocus(webview.getWebContentsId()); model.nodeModel.focusNode(); }; const webviewBlur = () => { - getApi().setWebviewFocus(null); + env.electron.setWebviewFocus(null); }; const handleDomReady = () => { globalStore.set(model.domReady, true); @@ -1055,19 +1097,21 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) return ( - + }> + + {errorText && (
{errorText}
@@ -1079,4 +1123,4 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) ); }); -export { WebView }; +export { WebView, WebViewPreviewFallback, getWebPreviewDisplayUrl }; diff --git a/frontend/app/view/webview/webviewenv.ts b/frontend/app/view/webview/webviewenv.ts new file mode 100644 index 0000000000..419b04c4eb --- /dev/null +++ b/frontend/app/view/webview/webviewenv.ts @@ -0,0 +1,25 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { MetaKeyAtomFnType, SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; + +export type WebViewEnv = WaveEnvSubset<{ + electron: { + openExternal: WaveEnv["electron"]["openExternal"]; + getWebviewPreload: WaveEnv["electron"]["getWebviewPreload"]; + clearWebviewStorage: WaveEnv["electron"]["clearWebviewStorage"]; + getConfigDir: WaveEnv["electron"]["getConfigDir"]; + setWebviewFocus: WaveEnv["electron"]["setWebviewFocus"]; + }; + rpc: { + FetchSuggestionsCommand: WaveEnv["rpc"]["FetchSuggestionsCommand"]; + SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; + SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; + }; + wos: WaveEnv["wos"]; + createBlock: WaveEnv["createBlock"]; + getSettingsKeyAtom: SettingsKeyAtomFnType<"web:defaulturl" | "web:defaultsearch">; + getBlockMetaKeyAtom: MetaKeyAtomFnType< + "web:hidenav" | "web:useragenttype" | "web:zoom" | "web:partition" + >; +}>; diff --git a/frontend/app/waveenv/waveenv.ts b/frontend/app/waveenv/waveenv.ts index df1cb01c4a..3b024d2f95 100644 --- a/frontend/app/waveenv/waveenv.ts +++ b/frontend/app/waveenv/waveenv.ts @@ -6,8 +6,8 @@ import { RpcApiType } from "@/app/store/wshclientapi"; import { Atom, PrimitiveAtom } from "jotai"; import React from "react"; -export type BlockMetaKeyAtomFnType = ( - blockId: string, +export type MetaKeyAtomFnType = ( + id: string, key: T ) => Atom; @@ -74,8 +74,10 @@ export type WaveEnv = { useWaveObjectValue: (oref: string) => [T, boolean]; }; getSettingsKeyAtom: SettingsKeyAtomFnType; - getBlockMetaKeyAtom: BlockMetaKeyAtomFnType; + getBlockMetaKeyAtom: MetaKeyAtomFnType; + getTabMetaKeyAtom: MetaKeyAtomFnType; getConnConfigKeyAtom: ConnConfigKeyAtomFnType; + getConfigBackgroundAtom: (bgKey: string | null) => Atom; // 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; diff --git a/frontend/app/waveenv/waveenvimpl.ts b/frontend/app/waveenv/waveenvimpl.ts index 4f9e234eca..6abe00e574 100644 --- a/frontend/app/waveenv/waveenvimpl.ts +++ b/frontend/app/waveenv/waveenvimpl.ts @@ -7,10 +7,12 @@ import { atoms, createBlock, getBlockMetaKeyAtom, + getConfigBackgroundAtom, getConnConfigKeyAtom, getConnStatusAtom, getLocalHostDisplayNameAtom, getSettingsKeyAtom, + getTabMetaKeyAtom, isDev, WOS, } from "@/app/store/global"; @@ -44,6 +46,8 @@ export function makeWaveEnvImpl(): WaveEnv { useWaveObjectValue: WOS.useWaveObjectValue, }, getBlockMetaKeyAtom, + getTabMetaKeyAtom, + getConfigBackgroundAtom, getConnConfigKeyAtom, mockSetWaveObj: (_oref: string, _obj: T) => { diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index 2ec171953e..f11eca91da 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; @@ -30,6 +30,7 @@ export type WidgetsEnv = WaveEnvSubset<{ }; atoms: { fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + hasConfigErrors: WaveEnv["atoms"]["hasConfigErrors"]; workspaceId: WaveEnv["atoms"]["workspaceId"]; hasCustomAIPresetsAtom: WaveEnv["atoms"]["hasCustomAIPresetsAtom"]; }; @@ -107,10 +108,26 @@ function calculateGridSize(appCount: number): number { return 6; } +function SettingsTooltipContent({ hasConfigErrors }: { hasConfigErrors: boolean }) { + if (!hasConfigErrors) { + return "Settings & Help"; + } + return ( +
+
Settings & Help
+
+ + Config Errors +
+
+ ); +} + type FloatingWindowPropsType = { isOpen: boolean; onClose: () => void; referenceElement: HTMLElement; + hasConfigErrors?: boolean; }; const AppsFloatingWindow = memo(({ isOpen, onClose, referenceElement }: FloatingWindowPropsType) => { @@ -236,120 +253,126 @@ const AppsFloatingWindow = memo(({ isOpen, onClose, referenceElement }: Floating ); }); -const SettingsFloatingWindow = memo(({ isOpen, onClose, referenceElement }: FloatingWindowPropsType) => { - const env = useWaveEnv(); - const { refs, floatingStyles, context } = useFloating({ - open: isOpen, - onOpenChange: onClose, - placement: "left-start", - middleware: [offset(-2), shift({ padding: 12 })], - whileElementsMounted: autoUpdate, - elements: { - reference: referenceElement, - }, - }); +const SettingsFloatingWindow = memo( + ({ isOpen, onClose, referenceElement, hasConfigErrors }: FloatingWindowPropsType) => { + const env = useWaveEnv(); + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: onClose, + placement: "left-start", + middleware: [offset(-2), shift({ padding: 12 })], + whileElementsMounted: autoUpdate, + elements: { + reference: referenceElement, + }, + }); - const dismiss = useDismiss(context); - const { getFloatingProps } = useInteractions([dismiss]); + const dismiss = useDismiss(context); + const { getFloatingProps } = useInteractions([dismiss]); - if (!isOpen) return null; + if (!isOpen) return null; - const menuItems = [ - { - icon: "gear", - label: "Settings", - onClick: () => { - const blockDef: BlockDef = { - meta: { - view: "waveconfig", - }, - }; - env.createBlock(blockDef, false, true); - onClose(); + const menuItems = [ + { + icon: "gear", + label: "Settings", + hasError: hasConfigErrors, + onClick: () => { + const blockDef: BlockDef = { + meta: { + view: "waveconfig", + }, + }; + env.createBlock(blockDef, false, true); + onClose(); + }, }, - }, - { - icon: "lightbulb", - label: "Tips", - onClick: () => { - const blockDef: BlockDef = { - meta: { - view: "tips", - }, - }; - env.createBlock(blockDef, true, true); - onClose(); + { + icon: "lightbulb", + label: "Tips", + onClick: () => { + const blockDef: BlockDef = { + meta: { + view: "tips", + }, + }; + env.createBlock(blockDef, true, true); + onClose(); + }, }, - }, - { - icon: "lock", - label: "Secrets", - onClick: () => { - const blockDef: BlockDef = { - meta: { - view: "waveconfig", - file: "secrets", - }, - }; - env.createBlock(blockDef, false, true); - onClose(); + { + icon: "lock", + label: "Secrets", + onClick: () => { + const blockDef: BlockDef = { + meta: { + view: "waveconfig", + file: "secrets", + }, + }; + env.createBlock(blockDef, false, true); + onClose(); + }, }, - }, - { - icon: "book-open", - label: "Release Notes", - onClick: () => { - modalsModel.pushModal("UpgradeOnboardingPatch", { isReleaseNotes: true }); - onClose(); + { + icon: "book-open", + label: "Release Notes", + onClick: () => { + modalsModel.pushModal("UpgradeOnboardingPatch", { isReleaseNotes: true }); + onClose(); + }, }, - }, - { - icon: "circle-question", - label: "Help", - onClick: () => { - const blockDef: BlockDef = { - meta: { - view: "help", - }, - }; - env.createBlock(blockDef); - onClose(); + { + icon: "circle-question", + label: "Help", + onClick: () => { + const blockDef: BlockDef = { + meta: { + view: "help", + }, + }; + env.createBlock(blockDef); + onClose(); + }, }, - }, - ]; + ]; - return ( - -
- {menuItems.map((item, idx) => ( -
-
- + return ( + +
+ {menuItems.map((item, idx) => ( +
+
+ +
+
{item.label}
+ {item.hasError && ( + + )}
-
{item.label}
-
- ))} -
- - ); -}); + ))} +
+ + ); + } +); SettingsFloatingWindow.displayName = "SettingsFloatingWindow"; const Widgets = memo(() => { const env = useWaveEnv(); const fullConfig = useAtomValue(env.atoms.fullConfigAtom); + const hasConfigErrors = useAtomValue(env.atoms.hasConfigErrors); const workspaceId = useAtomValue(env.atoms.workspaceId); - const hasCustomAIPresets = useAtomValue(env.atoms.hasCustomAIPresetsAtom); const [mode, setMode] = useState<"normal" | "compact" | "supercompact">("normal"); const containerRef = useRef(null); const measurementRef = useRef(null); @@ -357,12 +380,7 @@ const Widgets = memo(() => { const featureWaveAppBuilder = fullConfig?.settings?.["feature:waveappbuilder"] ?? false; const widgetsMap = fullConfig?.widgets ?? {}; const filteredWidgets = Object.fromEntries( - Object.entries(widgetsMap).filter(([key, widget]) => { - if (!hasCustomAIPresets && key === "defwidget@ai") { - return false; - } - return shouldIncludeWidgetForWorkspace(widget, workspaceId); - }) + Object.entries(widgetsMap).filter(([_key, widget]) => shouldIncludeWidgetForWorkspace(widget, workspaceId)) ); const widgets = sortByDisplayOrder(filteredWidgets); @@ -471,9 +489,16 @@ const Widgets = memo(() => { className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-sm overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer" onClick={() => setIsSettingsOpen(!isSettingsOpen)} > - -
+ } + placement="left" + disable={isSettingsOpen} + > +
+ {hasConfigErrors && ( + + )}
@@ -510,9 +535,25 @@ const Widgets = memo(() => { className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-lg overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer" onClick={() => setIsSettingsOpen(!isSettingsOpen)} > - -
- + } + placement="left" + disable={isSettingsOpen} + > +
+
+ + {hasConfigErrors && ( + + )} +
+ {mode === "normal" && ( +
+ settings +
+ )}
@@ -539,6 +580,7 @@ const Widgets = memo(() => { isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} referenceElement={settingsButtonRef.current} + hasConfigErrors={hasConfigErrors} /> )} diff --git a/frontend/app/workspace/workspace-layout-model.ts b/frontend/app/workspace/workspace-layout-model.ts index 725c9a17b5..1c86bb8a39 100644 --- a/frontend/app/workspace/workspace-layout-model.ts +++ b/frontend/app/workspace/workspace-layout-model.ts @@ -1,13 +1,14 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { globalStore } from "@/app/store/jotaiStore"; +import { isBuilderWindow } from "@/app/store/windowtype"; import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks"; -import { atoms, getApi, getOrefMetaKeyAtom, recordTEvent, refocusNode } from "@/store/global"; +import { atoms, getApi, getOrefMetaKeyAtom, getSettingsKeyAtom, recordTEvent, refocusNode } from "@/store/global"; import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "lodash-es"; @@ -15,48 +16,92 @@ import { ImperativePanelGroupHandle, ImperativePanelHandle } from "react-resizab const dlog = debug("wave:workspace"); -const AIPANEL_DEFAULTWIDTH = 300; -const AIPANEL_DEFAULTWIDTHRATIO = 0.33; -const AIPANEL_MINWIDTH = 300; -const AIPANEL_MAXWIDTHRATIO = 0.66; +const AIPanel_DefaultWidth = 300; +const AIPanel_DefaultWidthRatio = 0.33; +const AIPanel_MinWidth = 300; +const AIPanel_MaxWidthRatio = 0.66; + +const VTabBar_DefaultWidth = 220; +const VTabBar_MinWidth = 110; +const VTabBar_MaxWidth = 280; + +function clampVTabWidth(w: number): number { + return Math.max(VTabBar_MinWidth, Math.min(w, VTabBar_MaxWidth)); +} + +function clampAIPanelWidth(w: number, windowWidth: number): number { + const maxWidth = Math.floor(windowWidth * AIPanel_MaxWidthRatio); + if (AIPanel_MinWidth > maxWidth) return AIPanel_MinWidth; + return Math.max(AIPanel_MinWidth, Math.min(w, maxWidth)); +} class WorkspaceLayoutModel { private static instance: WorkspaceLayoutModel | null = null; aiPanelRef: ImperativePanelHandle | null; - panelGroupRef: ImperativePanelGroupHandle | null; + vtabPanelRef: ImperativePanelHandle | null; + outerPanelGroupRef: ImperativePanelGroupHandle | null; + innerPanelGroupRef: ImperativePanelGroupHandle | null; panelContainerRef: HTMLDivElement | null; aiPanelWrapperRef: HTMLDivElement | null; - inResize: boolean; // prevents recursive setLayout calls (setLayout triggers onLayout which calls setLayout) + vtabPanelWrapperRef: HTMLDivElement | null; + panelVisibleAtom: jotai.PrimitiveAtom; + + private inResize: boolean; private aiPanelVisible: boolean; private aiPanelWidth: number | null; - private debouncedPersistWidth: (width: number) => void; - private initialized: boolean = false; + private vtabWidth: number; + private vtabVisible: boolean; private transitionTimeoutRef: NodeJS.Timeout | null = null; private focusTimeoutRef: NodeJS.Timeout | null = null; - panelVisibleAtom: jotai.PrimitiveAtom; + private debouncedPersistAIWidth: () => void; + private debouncedPersistVTabWidth: () => void; private constructor() { this.aiPanelRef = null; - this.panelGroupRef = null; + this.vtabPanelRef = null; + this.outerPanelGroupRef = null; + this.innerPanelGroupRef = null; this.panelContainerRef = null; this.aiPanelWrapperRef = null; + this.vtabPanelWrapperRef = null; this.inResize = false; this.aiPanelVisible = false; this.aiPanelWidth = null; - this.panelVisibleAtom = jotai.atom(this.aiPanelVisible); + this.vtabWidth = VTabBar_DefaultWidth; + this.vtabVisible = false; + this.panelVisibleAtom = jotai.atom(false); + this.initializeFromMeta(); this.handleWindowResize = this.handleWindowResize.bind(this); - this.handlePanelLayout = this.handlePanelLayout.bind(this); + this.handleOuterPanelLayout = this.handleOuterPanelLayout.bind(this); + this.handleInnerPanelLayout = this.handleInnerPanelLayout.bind(this); - this.debouncedPersistWidth = debounce((width: number) => { + this.debouncedPersistAIWidth = debounce(() => { + if (!this.aiPanelVisible) return; + const width = this.aiPanelWrapperRef?.offsetWidth; + if (width == null || width <= 0) return; try { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("tab", this.getTabId()), meta: { "waveai:panelwidth": width }, }); } catch (e) { - console.warn("Failed to persist panel width:", e); + console.warn("Failed to persist AI panel width:", e); + } + }, 300); + + this.debouncedPersistVTabWidth = debounce(() => { + if (!this.vtabVisible) return; + const width = this.vtabPanelWrapperRef?.offsetWidth; + if (width == null || width <= 0) return; + try { + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("workspace", this.getWorkspaceId()), + meta: { "layout:vtabbarwidth": width }, + }); + } catch (e) { + console.warn("Failed to persist vtabbar width:", e); } }, 300); } @@ -68,79 +113,220 @@ class WorkspaceLayoutModel { return WorkspaceLayoutModel.instance; } - private initializeFromTabMeta(): void { - if (this.initialized) return; - this.initialized = true; + // ---- Meta / persistence helpers ---- + + private getTabId(): string { + return globalStore.get(atoms.staticTabId); + } + + private getWorkspaceId(): string { + return globalStore.get(atoms.workspace)?.oid ?? ""; + } + + private getPanelOpenAtom(): jotai.Atom { + return getOrefMetaKeyAtom(WOS.makeORef("tab", this.getTabId()), "waveai:panelopen"); + } + + private getPanelWidthAtom(): jotai.Atom { + return getOrefMetaKeyAtom(WOS.makeORef("tab", this.getTabId()), "waveai:panelwidth"); + } + + private getVTabBarWidthAtom(): jotai.Atom { + return getOrefMetaKeyAtom(WOS.makeORef("workspace", this.getWorkspaceId()), "layout:vtabbarwidth"); + } + private initializeFromMeta(): void { try { const savedVisible = globalStore.get(this.getPanelOpenAtom()); - const savedWidth = globalStore.get(this.getPanelWidthAtom()); - + const savedAIWidth = globalStore.get(this.getPanelWidthAtom()); + const savedVTabWidth = globalStore.get(this.getVTabBarWidthAtom()); if (savedVisible != null) { this.aiPanelVisible = savedVisible; globalStore.set(this.panelVisibleAtom, savedVisible); } - if (savedWidth != null) { - this.aiPanelWidth = savedWidth; + if (savedAIWidth != null) { + this.aiPanelWidth = savedAIWidth; + } + if (savedVTabWidth != null && savedVTabWidth > 0) { + this.vtabWidth = savedVTabWidth; } + const tabBarPosition = globalStore.get(getSettingsKeyAtom("app:tabbar")) ?? "top"; + const showLeftTabBar = tabBarPosition === "left" && !isBuilderWindow(); + this.vtabVisible = showLeftTabBar; } catch (e) { console.warn("Failed to initialize from tab meta:", e); } } - private getTabId(): string { - return globalStore.get(atoms.staticTabId); + // ---- Resolved width getters (always clamped) ---- + + private getResolvedAIWidth(windowWidth: number): number { + let w = this.aiPanelWidth; + if (w == null) { + w = Math.max(AIPanel_DefaultWidth, windowWidth * AIPanel_DefaultWidthRatio); + this.aiPanelWidth = w; + } + return clampAIPanelWidth(w, windowWidth); } - private getPanelOpenAtom(): jotai.Atom { - const tabORef = WOS.makeORef("tab", this.getTabId()); - return getOrefMetaKeyAtom(tabORef, "waveai:panelopen"); + private getResolvedVTabWidth(): number { + return clampVTabWidth(this.vtabWidth); } - private getPanelWidthAtom(): jotai.Atom { - const tabORef = WOS.makeORef("tab", this.getTabId()); - return getOrefMetaKeyAtom(tabORef, "waveai:panelwidth"); + // ---- Core layout computation ---- + // All layout decisions flow through computeLayout. + // It takes the current state (visibility flags + stored px widths) + // and produces the two percentage arrays for the panel groups. + + private computeLayout(windowWidth: number): { outer: number[]; inner: number[] } { + const vtabW = this.vtabVisible ? this.getResolvedVTabWidth() : 0; + const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0; + const leftGroupW = vtabW + aiW; + + // outer: [leftGroupPct, contentPct] + const leftPct = windowWidth > 0 ? (leftGroupW / windowWidth) * 100 : 0; + const contentPct = Math.max(0, 100 - leftPct); + + // inner: [vtabPct, aiPanelPct] relative to leftGroupW + let vtabPct: number; + let aiPct: number; + if (leftGroupW > 0) { + vtabPct = (vtabW / leftGroupW) * 100; + aiPct = 100 - vtabPct; + } else { + vtabPct = 50; + aiPct = 50; + } + + return { outer: [leftPct, contentPct], inner: [vtabPct, aiPct] }; + } + + private commitLayouts(windowWidth: number): void { + if (!this.outerPanelGroupRef || !this.innerPanelGroupRef) return; + const { outer, inner } = this.computeLayout(windowWidth); + this.inResize = true; + this.outerPanelGroupRef.setLayout(outer); + this.innerPanelGroupRef.setLayout(inner); + this.inResize = false; + this.updateWrapperWidth(); + } + + // ---- Drag handlers ---- + // These convert the percentage-based callback from react-resizable-panels + // back into pixel widths, update stored state, then re-commit. + + handleOuterPanelLayout(sizes: number[]): void { + if (this.inResize) return; + const windowWidth = window.innerWidth; + const newLeftGroupPx = (sizes[0] / 100) * windowWidth; + + if (this.vtabVisible && this.aiPanelVisible) { + // vtab stays constant, aipanel absorbs the change + const vtabW = this.getResolvedVTabWidth(); + this.aiPanelWidth = clampAIPanelWidth(newLeftGroupPx - vtabW, windowWidth); + this.debouncedPersistAIWidth(); + } else if (this.vtabVisible) { + this.vtabWidth = clampVTabWidth(newLeftGroupPx); + this.debouncedPersistVTabWidth(); + } else if (this.aiPanelVisible) { + this.aiPanelWidth = clampAIPanelWidth(newLeftGroupPx, windowWidth); + this.debouncedPersistAIWidth(); + } + + this.commitLayouts(windowWidth); + } + + handleInnerPanelLayout(sizes: number[]): void { + if (this.inResize) return; + if (!this.vtabVisible || !this.aiPanelVisible) return; + + const windowWidth = window.innerWidth; + const vtabW = this.getResolvedVTabWidth(); + const aiW = this.getResolvedAIWidth(windowWidth); + const leftGroupW = vtabW + aiW; + + const newVTabW = (sizes[0] / 100) * leftGroupW; + const clampedVTab = clampVTabWidth(newVTabW); + const newAIW = clampAIPanelWidth(leftGroupW - clampedVTab, windowWidth); + + if (clampedVTab !== this.vtabWidth) { + this.vtabWidth = clampedVTab; + this.debouncedPersistVTabWidth(); + } + if (newAIW !== this.aiPanelWidth) { + this.aiPanelWidth = newAIW; + this.debouncedPersistAIWidth(); + } + + this.commitLayouts(windowWidth); + } + + handleWindowResize(): void { + this.commitLayouts(window.innerWidth); + } + + // ---- Registration & sync ---- + + syncVTabWidthFromMeta(): void { + const savedVTabWidth = globalStore.get(this.getVTabBarWidthAtom()); + if (savedVTabWidth != null && savedVTabWidth > 0 && savedVTabWidth !== this.vtabWidth) { + this.vtabWidth = savedVTabWidth; + this.commitLayouts(window.innerWidth); + } } registerRefs( aiPanelRef: ImperativePanelHandle, - panelGroupRef: ImperativePanelGroupHandle, + outerPanelGroupRef: ImperativePanelGroupHandle, + innerPanelGroupRef: ImperativePanelGroupHandle, panelContainerRef: HTMLDivElement, - aiPanelWrapperRef: HTMLDivElement + aiPanelWrapperRef: HTMLDivElement, + vtabPanelRef?: ImperativePanelHandle, + vtabPanelWrapperRef?: HTMLDivElement, + showLeftTabBar?: boolean ): void { this.aiPanelRef = aiPanelRef; - this.panelGroupRef = panelGroupRef; + this.vtabPanelRef = vtabPanelRef ?? null; + this.outerPanelGroupRef = outerPanelGroupRef; + this.innerPanelGroupRef = innerPanelGroupRef; this.panelContainerRef = panelContainerRef; this.aiPanelWrapperRef = aiPanelWrapperRef; - this.syncAIPanelRef(); - this.updateWrapperWidth(); + this.vtabPanelWrapperRef = vtabPanelWrapperRef ?? null; + this.vtabVisible = showLeftTabBar ?? false; + this.syncPanelCollapse(); + this.commitLayouts(window.innerWidth); } - updateWrapperWidth(): void { - if (!this.aiPanelWrapperRef) { - return; + private syncPanelCollapse(): void { + if (this.aiPanelRef) { + if (this.aiPanelVisible) { + this.aiPanelRef.expand(); + } else { + this.aiPanelRef.collapse(); + } + } + if (this.vtabPanelRef) { + if (this.vtabVisible) { + this.vtabPanelRef.expand(); + } else { + this.vtabPanelRef.collapse(); + } } - const width = this.getAIPanelWidth(); - const clampedWidth = this.getClampedAIPanelWidth(width, window.innerWidth); - this.aiPanelWrapperRef.style.width = `${clampedWidth}px`; } + // ---- Transitions ---- + enableTransitions(duration: number): void { - if (!this.panelContainerRef) { - return; - } + if (!this.panelContainerRef) return; const panels = this.panelContainerRef.querySelectorAll("[data-panel]"); panels.forEach((panel: HTMLElement) => { panel.style.transition = "flex 0.2s ease-in-out"; }); - if (this.transitionTimeoutRef) { clearTimeout(this.transitionTimeoutRef); } this.transitionTimeoutRef = setTimeout(() => { - if (!this.panelContainerRef) { - return; - } + if (!this.panelContainerRef) return; const panels = this.panelContainerRef.querySelectorAll("[data-panel]"); panels.forEach((panel: HTMLElement) => { panel.style.transition = "none"; @@ -148,77 +334,50 @@ class WorkspaceLayoutModel { }, duration); } - handleWindowResize(): void { - if (!this.panelGroupRef) { - return; - } - const newWindowWidth = window.innerWidth; - const aiPanelPercentage = this.getAIPanelPercentage(newWindowWidth); - const mainContentPercentage = this.getMainContentPercentage(newWindowWidth); - this.inResize = true; - const layout = [aiPanelPercentage, mainContentPercentage]; - this.panelGroupRef.setLayout(layout); - this.inResize = false; - this.updateWrapperWidth(); + // ---- Wrapper width (AI panel inner content width) ---- + + updateWrapperWidth(): void { + if (!this.aiPanelWrapperRef) return; + const width = this.getResolvedAIWidth(window.innerWidth); + this.aiPanelWrapperRef.style.width = `${width}px`; } - handlePanelLayout(sizes: number[]): void { - // dlog("handlePanelLayout", "inResize:", this.inResize, "sizes:", sizes); - if (this.inResize) { - return; - } - if (!this.panelGroupRef) { - return; - } + // ---- Public getters ---- - const currentWindowWidth = window.innerWidth; - const aiPanelPixelWidth = (sizes[0] / 100) * currentWindowWidth; - this.handleAIPanelResize(aiPanelPixelWidth, currentWindowWidth); - const newPercentage = this.getAIPanelPercentage(currentWindowWidth); - const mainContentPercentage = 100 - newPercentage; - this.inResize = true; - const layout = [newPercentage, mainContentPercentage]; - this.panelGroupRef.setLayout(layout); - this.inResize = false; + getAIPanelVisible(): boolean { + return this.aiPanelVisible; } - syncAIPanelRef(): void { - if (!this.aiPanelRef || !this.panelGroupRef) { - return; - } - - const currentWindowWidth = window.innerWidth; - const aiPanelPercentage = this.getAIPanelPercentage(currentWindowWidth); - const mainContentPercentage = this.getMainContentPercentage(currentWindowWidth); + getAIPanelWidth(): number { + return this.getResolvedAIWidth(window.innerWidth); + } - if (this.getAIPanelVisible()) { - this.aiPanelRef.expand(); - } else { - this.aiPanelRef.collapse(); - } + // ---- Initial percentage helpers (used by workspace.tsx for defaultSize) ---- - this.inResize = true; - const layout = [aiPanelPercentage, mainContentPercentage]; - this.panelGroupRef.setLayout(layout); - this.inResize = false; + getLeftGroupInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number { + const vtabW = showLeftTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0; + const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0; + return ((vtabW + aiW) / windowWidth) * 100; } - getMaxAIPanelWidth(windowWidth: number): number { - return Math.floor(windowWidth * AIPANEL_MAXWIDTHRATIO); + getInnerVTabInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number { + if (!showLeftTabBar || isBuilderWindow()) return 0; + const vtabW = this.getResolvedVTabWidth(); + const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0; + const total = vtabW + aiW; + if (total === 0) return 50; + return (vtabW / total) * 100; } - getClampedAIPanelWidth(width: number, windowWidth: number): number { - const maxWidth = this.getMaxAIPanelWidth(windowWidth); - if (AIPANEL_MINWIDTH > maxWidth) { - return AIPANEL_MINWIDTH; - } - return Math.max(AIPANEL_MINWIDTH, Math.min(width, maxWidth)); + getInnerAIPanelInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number { + const vtabW = showLeftTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0; + const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0; + const total = vtabW + aiW; + if (total === 0) return 50; + return (aiW / total) * 100; } - getAIPanelVisible(): boolean { - this.initializeFromTabMeta(); - return this.aiPanelVisible; - } + // ---- Toggle visibility ---- setAIPanelVisible(visible: boolean, opts?: { nofocus?: boolean }): void { if (this.focusTimeoutRef != null) { @@ -237,7 +396,8 @@ class WorkspaceLayoutModel { meta: { "waveai:panelopen": visible }, }); this.enableTransitions(250); - this.syncAIPanelRef(); + this.syncPanelCollapse(); + this.commitLayouts(window.innerWidth); if (visible) { if (!opts?.nofocus) { @@ -260,42 +420,12 @@ class WorkspaceLayoutModel { } } - getAIPanelWidth(): number { - this.initializeFromTabMeta(); - if (this.aiPanelWidth == null) { - this.aiPanelWidth = Math.max(AIPANEL_DEFAULTWIDTH, window.innerWidth * AIPANEL_DEFAULTWIDTHRATIO); - } - return this.aiPanelWidth; - } - - setAIPanelWidth(width: number): void { - this.aiPanelWidth = width; - this.updateWrapperWidth(); - this.debouncedPersistWidth(width); - } - - getAIPanelPercentage(windowWidth: number): number { - const isVisible = this.getAIPanelVisible(); - if (!isVisible) { - return 0; - } - const aiPanelWidth = this.getAIPanelWidth(); - const clampedWidth = this.getClampedAIPanelWidth(aiPanelWidth, windowWidth); - const percentage = (clampedWidth / windowWidth) * 100; - return Math.max(0, Math.min(percentage, 100)); - } - - getMainContentPercentage(windowWidth: number): number { - const aiPanelPercentage = this.getAIPanelPercentage(windowWidth); - return Math.max(0, 100 - aiPanelPercentage); - } - - handleAIPanelResize(width: number, windowWidth: number): void { - if (!this.getAIPanelVisible()) { - return; - } - const clampedWidth = this.getClampedAIPanelWidth(width, windowWidth); - this.setAIPanelWidth(clampedWidth); + setShowLeftTabBar(showLeftTabBar: boolean): void { + if (this.vtabVisible === showLeftTabBar) return; + this.vtabVisible = showLeftTabBar; + this.enableTransitions(250); + this.syncPanelCollapse(); + this.commitLayouts(window.innerWidth); } } diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index fb1d78668f..3e29bfa9f1 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { AIPanel } from "@/app/aipanel/aipanel"; @@ -7,9 +7,11 @@ import { CenteredDiv } from "@/app/element/quickelems"; import { ModalsRenderer } from "@/app/modals/modalsrenderer"; import { TabBar } from "@/app/tab/tabbar"; import { TabContent } from "@/app/tab/tabcontent"; +import { VTabBar } from "@/app/tab/vtabbar"; import { Widgets } from "@/app/workspace/widgets"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; -import { atoms, getApi } from "@/store/global"; +import { atoms, getApi, getSettingsKeyAtom } from "@/store/global"; +import { isMacOS } from "@/util/platformutil"; import { useAtomValue } from "jotai"; import { memo, useEffect, useRef } from "react"; import { @@ -20,23 +22,61 @@ import { PanelResizeHandle, } from "react-resizable-panels"; +const MacOSTabBarSpacer = memo(() => { + return ( +
+ ); +}); +MacOSTabBarSpacer.displayName = "MacOSTabBarSpacer"; + const WorkspaceElem = memo(() => { const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); const tabId = useAtomValue(atoms.staticTabId); const ws = useAtomValue(atoms.workspace); - const initialAiPanelPercentage = workspaceLayoutModel.getAIPanelPercentage(window.innerWidth); - const panelGroupRef = useRef(null); + const tabBarPosition = useAtomValue(getSettingsKeyAtom("app:tabbar")) ?? "top"; + const showLeftTabBar = tabBarPosition === "left"; + const aiPanelVisible = useAtomValue(workspaceLayoutModel.panelVisibleAtom); + const windowWidth = window.innerWidth; + const leftGroupInitialPct = workspaceLayoutModel.getLeftGroupInitialPercentage(windowWidth, showLeftTabBar); + const innerVTabInitialPct = workspaceLayoutModel.getInnerVTabInitialPercentage(windowWidth, showLeftTabBar); + const innerAIPanelInitialPct = workspaceLayoutModel.getInnerAIPanelInitialPercentage(windowWidth, showLeftTabBar); + const outerPanelGroupRef = useRef(null); + const innerPanelGroupRef = useRef(null); const aiPanelRef = useRef(null); + const vtabPanelRef = useRef(null); const panelContainerRef = useRef(null); const aiPanelWrapperRef = useRef(null); + const vtabPanelWrapperRef = useRef(null); + // showLeftTabBar is passed as a seed value only; subsequent changes are handled by setShowLeftTabBar below. + // Do NOT add showLeftTabBar as a dep here — re-registering refs on config changes would redundantly re-run commitLayouts. useEffect(() => { - if (aiPanelRef.current && panelGroupRef.current && panelContainerRef.current && aiPanelWrapperRef.current) { + if ( + aiPanelRef.current && + outerPanelGroupRef.current && + innerPanelGroupRef.current && + panelContainerRef.current && + aiPanelWrapperRef.current + ) { workspaceLayoutModel.registerRefs( aiPanelRef.current, - panelGroupRef.current, + outerPanelGroupRef.current, + innerPanelGroupRef.current, panelContainerRef.current, - aiPanelWrapperRef.current + aiPanelWrapperRef.current, + vtabPanelRef.current ?? undefined, + vtabPanelWrapperRef.current ?? undefined, + showLeftTabBar ); } }, []); @@ -51,34 +91,73 @@ const WorkspaceElem = memo(() => { return () => window.removeEventListener("resize", workspaceLayoutModel.handleWindowResize); }, []); + useEffect(() => { + workspaceLayoutModel.setShowLeftTabBar(showLeftTabBar); + }, [showLeftTabBar]); + + useEffect(() => { + const handleFocus = () => workspaceLayoutModel.syncVTabWidthFromMeta(); + window.addEventListener("focus", handleFocus); + return () => window.removeEventListener("focus", handleFocus); + }, []); + + const innerHandleVisible = showLeftTabBar && aiPanelVisible; + const innerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${innerHandleVisible ? "w-0.5" : "w-0 pointer-events-none"}`; + const outerHandleVisible = showLeftTabBar || aiPanelVisible; + const outerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${outerHandleVisible ? "w-0.5" : "w-0 pointer-events-none"}`; + return (
- + {!(showLeftTabBar && isMacOS()) && } + {showLeftTabBar && isMacOS() && }
- -
- {tabId !== "" && } -
+ + + +
+ {showLeftTabBar && } +
+
+ + +
+ {tabId !== "" && } +
+
+
- - + + {tabId === "" ? ( No Active Tab ) : (
- +
)} diff --git a/frontend/builder/app-selection-modal.tsx b/frontend/builder/app-selection-modal.tsx index 4e49b2f35d..c98133dc43 100644 --- a/frontend/builder/app-selection-modal.tsx +++ b/frontend/builder/app-selection-modal.tsx @@ -2,9 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { FlexiModal } from "@/app/modals/modal"; +import { globalStore } from "@/app/store/jotaiStore"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { atoms, getApi, globalStore } from "@/store/global"; +import { atoms, getApi } from "@/store/global"; import * as WOS from "@/store/wos"; import { formatRelativeTime } from "@/util/util"; import { useEffect, useState } from "react"; diff --git a/frontend/builder/builder-app.tsx b/frontend/builder/builder-app.tsx index e72a0578be..5f78a6b9a7 100644 --- a/frontend/builder/builder-app.tsx +++ b/frontend/builder/builder-app.tsx @@ -1,10 +1,11 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { ModalsRenderer } from "@/app/modals/modalsrenderer"; +import { globalStore } from "@/app/store/jotaiStore"; import { AppSelectionModal } from "@/builder/app-selection-modal"; import { BuilderWorkspace } from "@/builder/builder-workspace"; -import { ModalsRenderer } from "@/app/modals/modalsrenderer"; -import { atoms, globalStore, isDev } from "@/store/global"; +import { atoms, isDev } from "@/store/global"; import { appHandleKeyDown } from "@/store/keymodel"; import * as keyutil from "@/util/keyutil"; import { isBlank } from "@/util/util"; diff --git a/frontend/builder/builder-workspace.tsx b/frontend/builder/builder-workspace.tsx index 94a06fad83..aab14ff458 100644 --- a/frontend/builder/builder-workspace.tsx +++ b/frontend/builder/builder-workspace.tsx @@ -97,7 +97,7 @@ const BuilderWorkspace = memo(() => {
- + diff --git a/frontend/preview/mock/defaultconfig.ts b/frontend/preview/mock/defaultconfig.ts index 0c2ac11b3a..415630b2b6 100644 --- a/frontend/preview/mock/defaultconfig.ts +++ b/frontend/preview/mock/defaultconfig.ts @@ -1,6 +1,7 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import backgroundsJson from "../../../pkg/wconfig/defaultconfig/backgrounds.json"; import mimetypesJson from "../../../pkg/wconfig/defaultconfig/mimetypes.json"; import presetsJson from "../../../pkg/wconfig/defaultconfig/presets.json"; import settingsJson from "../../../pkg/wconfig/defaultconfig/settings.json"; @@ -18,5 +19,6 @@ export const DefaultFullConfig: FullConfigType = { connections: {}, bookmarks: {}, waveai: waveaiJson as unknown as { [key: string]: AIModeConfigType }, + backgrounds: backgroundsJson as { [key: string]: BackgroundConfigType }, configerrors: [], }; diff --git a/frontend/preview/mock/mock-node-model.ts b/frontend/preview/mock/mock-node-model.ts new file mode 100644 index 0000000000..009d25ea2b --- /dev/null +++ b/frontend/preview/mock/mock-node-model.ts @@ -0,0 +1,45 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import type { NodeModel } from "@/layout/index"; +import { atom } from "jotai"; + +export type MockNodeModelOpts = { + nodeId: string; + blockId: string; + innerRect?: { width: string; height: string }; + numLeafs?: number; +}; + +export function makeMockNodeModel(opts: MockNodeModelOpts): NodeModel { + const isFocusedAtom = atom(true); + const isMagnifiedAtom = atom(false); + + return { + additionalProps: atom({} as any), + innerRect: atom(opts.innerRect ?? { width: "1000px", height: "640px" }), + blockNum: atom(1), + numLeafs: atom(opts.numLeafs ?? 1), + nodeId: opts.nodeId, + blockId: opts.blockId, + addEphemeralNodeToLayout: () => {}, + animationTimeS: atom(0), + isResizing: atom(false), + isFocused: isFocusedAtom, + isMagnified: isMagnifiedAtom, + anyMagnified: atom((get) => get(isMagnifiedAtom)), + 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 }, + }; +} diff --git a/frontend/preview/mock/mockfilesystem.ts b/frontend/preview/mock/mockfilesystem.ts index 6652bbb3fe..9767dddf64 100644 --- a/frontend/preview/mock/mockfilesystem.ts +++ b/frontend/preview/mock/mockfilesystem.ts @@ -8,26 +8,23 @@ const MockDirMimeType = "directory"; const MockDirMode = 0o040755; const MockFileMode = 0o100644; const MockDirectoryChunkSize = 128; -const MockFileChunkSize = 64 * 1024; const MockBaseModTime = Date.parse("2026-03-10T09:00:00.000Z"); const TinyPngBytes = Uint8Array.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x04, 0x00, 0x00, 0x00, 0xb5, 0x1c, 0x0c, - 0x02, 0x00, 0x00, 0x00, 0x0b, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xfc, 0xff, 0x1f, 0x00, - 0x03, 0x03, 0x01, 0xff, 0xa5, 0xf8, 0x8f, 0xb1, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, - 0xae, 0x42, 0x60, 0x82, + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x04, 0x00, 0x00, 0x00, 0xb5, 0x1c, 0x0c, 0x02, 0x00, 0x00, 0x00, 0x0b, 0x49, + 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xfc, 0xff, 0x1f, 0x00, 0x03, 0x03, 0x01, 0xff, 0xa5, 0xf8, 0x8f, 0xb1, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); const TinyJpegBytes = Uint8Array.from([ - 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, - 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03, - 0x03, 0x03, 0x03, 0x04, 0x03, 0x03, 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0a, 0x07, - 0x07, 0x06, 0x08, 0x0c, 0x0a, 0x0c, 0x0c, 0x0b, 0x0a, 0x0b, 0x0b, 0x0d, 0x0e, 0x12, 0x10, 0x0d, - 0x0e, 0x11, 0x0e, 0x0b, 0x0b, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15, 0x15, 0x15, 0x0c, 0x0f, - 0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, - 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xff, 0xc4, 0x00, 0x14, - 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0xbf, 0xff, 0xd9, + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x03, 0x03, + 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0a, 0x07, 0x07, 0x06, 0x08, 0x0c, 0x0a, 0x0c, 0x0c, 0x0b, 0x0a, + 0x0b, 0x0b, 0x0d, 0x0e, 0x12, 0x10, 0x0d, 0x0e, 0x11, 0x0e, 0x0b, 0x0b, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15, + 0x15, 0x15, 0x0c, 0x0f, 0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, + 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xff, 0xc4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, + 0x00, 0x00, 0x3f, 0x00, 0xbf, 0xff, 0xd9, ]); type MockFsEntry = { @@ -61,7 +58,6 @@ export type MockFilesystem = { fileRead: (data: FileData) => Promise; fileList: (data: FileListData) => Promise; fileJoin: (paths: string[]) => Promise; - fileReadStream: (data: FileData) => AsyncGenerator; fileListStream: (data: FileListData) => AsyncGenerator; }; @@ -492,33 +488,9 @@ export function makeMockFilesystem(): MockFilesystem { } return toFileInfo(entry); }; - const fileReadStream = async function* (data: FileData): AsyncGenerator { - const info = await fileInfo(data); - yield { info }; - if (info.notfound) { - return; - } - const entry = getEntry(info.path); - if (entry.isdir) { - const dirEntries = (childrenByDir.get(entry.path) ?? []).map((child) => toFileInfo(child)); - for (let idx = 0; idx < dirEntries.length; idx += MockDirectoryChunkSize) { - yield { entries: dirEntries.slice(idx, idx + MockDirectoryChunkSize) }; - } - return; - } - if (entry.content == null || entry.content.byteLength === 0) { - return; - } - const { offset, end } = getReadRange(data, entry.content.byteLength); - for (let currentOffset = offset; currentOffset < end; currentOffset += MockFileChunkSize) { - const chunkEnd = Math.min(currentOffset + MockFileChunkSize, end); - yield { - data64: arrayToBase64(entry.content.slice(currentOffset, chunkEnd)), - at: { offset: currentOffset, size: chunkEnd - currentOffset }, - }; - } - }; - const fileListStream = async function* (data: FileListData): AsyncGenerator { + const fileListStream = async function* ( + data: FileListData + ): AsyncGenerator { const fileInfos = await fileList(data); for (let idx = 0; idx < fileInfos.length; idx += MockDirectoryChunkSize) { yield { fileinfo: fileInfos.slice(idx, idx + MockDirectoryChunkSize) }; @@ -535,7 +507,6 @@ export function makeMockFilesystem(): MockFilesystem { fileRead, fileList, fileJoin, - fileReadStream, fileListStream, }; } diff --git a/frontend/preview/mock/mockwaveenv.test.ts b/frontend/preview/mock/mockwaveenv.test.ts index 25aee22995..031be34588 100644 --- a/frontend/preview/mock/mockwaveenv.test.ts +++ b/frontend/preview/mock/mockwaveenv.test.ts @@ -1,4 +1,4 @@ -import { base64ToArray, base64ToString } from "@/util/util"; +import { base64ToString } from "@/util/util"; import { describe, expect, it, vi } from "vitest"; import { DefaultMockFilesystem } from "./mockfilesystem"; @@ -82,15 +82,31 @@ describe("makeMockWaveEnv", () => { } expect(listPackets).toHaveLength(1); expect(listPackets[0].fileinfo).toHaveLength(4); + }); - const readPackets: FileData[] = []; - for await (const packet of env.rpc.FileReadStreamCommand(null as any, { - info: { path: "/Users/mike/Pictures/beach-sunrise.png" }, - })) { - readPackets.push(packet); - } - expect(readPackets[0].info?.path).toBe("/Users/mike/Pictures/beach-sunrise.png"); - const imageBytes = base64ToArray(readPackets[1].data64); - expect(Array.from(imageBytes.slice(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]); + it("implements secrets commands with in-memory storage", async () => { + const { makeMockWaveEnv } = await import("./mockwaveenv"); + const env = makeMockWaveEnv({ platform: "linux" }); + + await env.rpc.SetSecretsCommand( + null as any, + { + OPENAI_API_KEY: "sk-test", + ANTHROPIC_API_KEY: "anthropic-test", + } as any + ); + + expect(await env.rpc.GetSecretsLinuxStorageBackendCommand(null as any)).toBe("libsecret"); + expect(await env.rpc.GetSecretsNamesCommand(null as any)).toEqual(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]); + expect(await env.rpc.GetSecretsCommand(null as any, ["OPENAI_API_KEY", "MISSING_SECRET"])).toEqual({ + OPENAI_API_KEY: "sk-test", + }); + + await env.rpc.SetSecretsCommand(null as any, { OPENAI_API_KEY: null } as any); + + expect(await env.rpc.GetSecretsNamesCommand(null as any)).toEqual(["ANTHROPIC_API_KEY"]); + expect(await env.rpc.GetSecretsCommand(null as any, ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"])).toEqual({ + ANTHROPIC_API_KEY: "anthropic-test", + }); }); }); diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 5e787610e5..ea5a8b0b90 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -7,36 +7,56 @@ 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 { PlatformLinux, PlatformMacOS, PlatformWindows } from "@/util/platformutil"; +import { NullAtom } from "@/util/util"; import { Atom, atom, PrimitiveAtom, useAtomValue } from "jotai"; +import { showPreviewContextMenu } from "../preview-contextmenu"; +import { MockSysinfoConnection } from "../previews/sysinfo.preview-util"; import { DefaultFullConfig } from "./defaultconfig"; import { DefaultMockFilesystem } from "./mockfilesystem"; -import { showPreviewContextMenu } from "../preview-contextmenu"; import { previewElectronApi } from "./preview-electron-api"; +export const PreviewTabId = crypto.randomUUID(); +export const PreviewWindowId = crypto.randomUUID(); +export const PreviewWorkspaceId = crypto.randomUUID(); +export const PreviewClientId = crypto.randomUUID(); +export const WebBlockId = crypto.randomUUID(); +export const SysinfoBlockId = crypto.randomUUID(); + // 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.GetSecretsCommand -- reads secrets from an in-memory mock secret store +// - rpc.GetSecretsLinuxStorageBackendCommand +// returns "libsecret" on Linux previews and "" elsewhere +// - rpc.GetSecretsNamesCommand -- lists secret names from the in-memory mock secret store // - rpc.SetMetaCommand -- writes .meta to the mock WOS atom (null values delete keys) // - rpc.SetConfigCommand -- merges settings into fullConfigAtom (null values delete keys) +// - rpc.SetSecretsCommand -- writes/deletes secrets in the in-memory mock secret store // - 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"). +// Override specific calls via MockEnv.rpc (keys are Command method names, e.g. "GetMetaCommand"). +// Override specific streaming calls via MockEnv.rpcStreaming (same key names, handler returns AsyncGenerator). // // Backend service calls (handled in callBackendService): // Any call falls through to a console.log and resolves null. // Override specific calls via MockEnv.services: { Service: { Method: impl } } // e.g. { "block": { "GetControllerStatus": (blockId) => myStatus } } -type RpcOverrides = { - [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: ( - ...args: any[] - ) => Promise | AsyncGenerator; +export type RpcHandlerType = (...args: any[]) => Promise; +export type RpcStreamHandlerType = (...args: any[]) => AsyncGenerator; + +export type RpcOverrides = { + [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: RpcHandlerType; +}; + +export type RpcStreamOverrides = { + [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: RpcStreamHandlerType; }; type ServiceOverrides = { @@ -51,6 +71,7 @@ export type MockEnv = { platform?: NodeJS.Platform; settings?: Partial; rpc?: RpcOverrides; + rpcStreaming?: RpcStreamOverrides; services?: ServiceOverrides; atoms?: Partial; electron?: Partial; @@ -60,7 +81,11 @@ export type MockEnv = { mockWaveObjs?: Record; }; -export type MockWaveEnv = WaveEnv & { mockEnv: MockEnv }; +export type MockWaveEnv = WaveEnv & { + mockEnv: MockEnv; + addRpcOverride: (command: K, handler: RpcHandlerType) => void; + addRpcStreamOverride: (command: K, handler: RpcStreamHandlerType) => void; +}; function mergeRecords(base: Record, overrides: Record): Record { if (base == null && overrides == null) { @@ -86,6 +111,7 @@ export function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv { platform: overrides.platform ?? base.platform, settings: mergeRecords(base.settings, overrides.settings), rpc: mergeRecords(base.rpc as any, overrides.rpc as any) as RpcOverrides, + rpcStreaming: mergeRecords(base.rpcStreaming as any, overrides.rpcStreaming as any) as RpcStreamOverrides, services: mergedServices, atoms: overrides.atoms != null || base.atoms != null ? { ...base.atoms, ...overrides.atoms } : undefined, electron: @@ -99,16 +125,13 @@ export function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv { }; } -function makeMockSettingsKeyAtom( - settingsAtom: Atom, - overrides?: Partial -): WaveEnv["getSettingsKeyAtom"] { +function makeMockSettingsKeyAtom(settingsAtom: Atom): WaveEnv["getSettingsKeyAtom"] { const keyAtomCache = new Map>(); return (key: T) => { if (!keyAtomCache.has(key)) { keyAtomCache.set( key, - atom((get) => (overrides?.[key] !== undefined ? overrides[key] : get(settingsAtom)?.[key])) + atom((get) => get(settingsAtom)?.[key]) ); } return keyAtomCache.get(key) as Atom; @@ -178,11 +201,21 @@ type MockWosFns = { getWaveObjectAtom: (oref: string) => PrimitiveAtom; mockSetWaveObj: (oref: string, obj: T) => void; fullConfigAtom: PrimitiveAtom; + platform: NodeJS.Platform; }; -export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiType { +export function makeMockRpc( + overrides: RpcOverrides, + streamOverrides: RpcStreamOverrides, + wos: MockWosFns +): { + rpc: RpcApiType; + setRpcHandler: (command: string, fn: RpcHandlerType) => void; + setRpcStreamHandler: (command: string, fn: RpcStreamHandlerType) => void; +} { const callDispatchMap = new Map Promise>(); const streamDispatchMap = new Map AsyncGenerator>(); + const secrets = new Map(); const setCallHandler = (command: string, fn: (...args: any[]) => Promise) => { callDispatchMap.set(command, fn); }; @@ -236,6 +269,35 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp globalStore.set(wos.fullConfigAtom, { ...current, settings: updatedSettings as SettingsType }); return null; }); + setCallHandler("getsecretslinuxstoragebackend", async () => { + if (wos.platform !== PlatformLinux) { + return ""; + } + return "libsecret"; + }); + setCallHandler("getsecretsnames", async () => { + return Array.from(secrets.keys()).sort(); + }); + setCallHandler("getsecrets", async (_client, data: string[]) => { + const foundSecrets: Record = {}; + for (const name of data ?? []) { + const value = secrets.get(name); + if (value != null) { + foundSecrets[name] = value; + } + } + return foundSecrets; + }); + setCallHandler("setsecrets", async (_client, data: Record) => { + for (const [name, value] of Object.entries(data ?? {})) { + if (value == null) { + secrets.delete(name); + continue; + } + secrets.set(name, value); + } + return null; + }); setCallHandler("updateworkspacetabids", async (_client, data: { args: [string, string[]] }) => { const [workspaceId, tabIds] = data.args; const wsORef = "workspace:" + workspaceId; @@ -249,20 +311,19 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp setCallHandler("fileread", async (_client, data: FileData) => DefaultMockFilesystem.fileRead(data)); setCallHandler("filelist", async (_client, data: FileListData) => DefaultMockFilesystem.fileList(data)); setCallHandler("filejoin", async (_client, data: string[]) => DefaultMockFilesystem.fileJoin(data)); - setStreamHandler("filereadstream", async function* (_client, data: FileData) { - yield* DefaultMockFilesystem.fileReadStream(data); - }); setStreamHandler("fileliststream", async function* (_client, data: FileListData) { yield* DefaultMockFilesystem.fileListStream(data); }); if (overrides) { for (const key of Object.keys(overrides) as (keyof RpcOverrides)[]) { const cmdName = key.slice(0, -"Command".length).toLowerCase(); - if (cmdName === "filereadstream" || cmdName === "fileliststream") { - setStreamHandler(cmdName, overrides[key] as (...args: any[]) => AsyncGenerator); - } else { - setCallHandler(cmdName, overrides[key] as (...args: any[]) => Promise); - } + setCallHandler(cmdName, overrides[key] as RpcHandlerType); + } + } + if (streamOverrides) { + for (const key of Object.keys(streamOverrides) as (keyof RpcStreamOverrides)[]) { + const cmdName = key.slice(0, -"Command".length).toLowerCase(); + setStreamHandler(cmdName, streamOverrides[key] as RpcStreamHandlerType); } } const rpc = new RpcApiType(); @@ -290,7 +351,17 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp yield null; }, }); - return rpc; + return { + rpc, + setRpcHandler: (command: string, fn: RpcHandlerType) => { + const cmdName = command.endsWith("Command") ? command.slice(0, -"Command".length).toLowerCase() : command; + setCallHandler(cmdName, fn); + }, + setRpcStreamHandler: (command: string, fn: RpcStreamHandlerType) => { + const cmdName = command.endsWith("Command") ? command.slice(0, -"Command".length).toLowerCase() : command; + setStreamHandler(cmdName, fn); + }, + }; } export function applyMockEnvOverrides(env: WaveEnv, newOverrides: MockEnv): MockWaveEnv { @@ -301,20 +372,76 @@ export function applyMockEnvOverrides(env: WaveEnv, newOverrides: MockEnv): Mock export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { const overrides: MockEnv = mockEnv ?? {}; - const platform = overrides.platform ?? PlatformMacOS; + const tabId = overrides.tabId ?? PreviewTabId; + const defaultMockWaveObjs: Record = { + [`workspace:${PreviewWorkspaceId}`]: { + otype: "workspace", + oid: PreviewWorkspaceId, + version: 1, + name: "Preview Workspace", + tabids: [PreviewTabId], + activetabid: PreviewTabId, + meta: {}, + } as Workspace, + [`tab:${PreviewTabId}`]: { + otype: "tab", + oid: PreviewTabId, + version: 1, + name: "Preview Tab", + blockids: [WebBlockId, SysinfoBlockId], + meta: {}, + } as Tab, + [`block:${WebBlockId}`]: { + otype: "block", + oid: WebBlockId, + version: 1, + meta: { + view: "web", + }, + } as Block, + [`block:${SysinfoBlockId}`]: { + otype: "block", + oid: SysinfoBlockId, + version: 1, + meta: { + view: "sysinfo", + connection: MockSysinfoConnection, + "sysinfo:type": "CPU + Mem", + "graph:numpoints": 90, + }, + } as Block, + }; + const defaultAtoms: Partial = { + uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext), + staticTabId: atom(PreviewTabId), + workspaceId: atom(PreviewWorkspaceId), + }; + const mergedOverrides: MockEnv = { + ...overrides, + tabId, + mockWaveObjs: { ...defaultMockWaveObjs, ...(overrides.mockWaveObjs ?? {}) }, + atoms: { ...defaultAtoms, ...(overrides.atoms ?? {}) }, + }; + const platform = mergedOverrides.platform ?? PlatformMacOS; const connStatusAtomCache = new Map>(); const waveObjectValueAtomCache = new Map>(); const waveObjectDerivedAtomCache = new Map>(); - const blockMetaKeyAtomCache = new Map>(); + const orefMetaKeyAtomCache = new Map>(); const connConfigKeyAtomCache = new Map>(); + const configBackgroundAtomCache = new Map>(); const getWaveObjectAtom = (oref: string): PrimitiveAtom => { if (!waveObjectValueAtomCache.has(oref)) { - const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T; + const obj = (mergedOverrides.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 atoms = makeMockGlobalAtoms( + mergedOverrides.settings, + mergedOverrides.atoms, + mergedOverrides.tabId, + getWaveObjectAtom + ); const localHostDisplayNameAtom = atom((get) => { const configValue = get(atoms.settingsAtom)?.["conn:localhostdisplayname"]; if (configValue != null) { @@ -325,6 +452,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { const mockWosFns: MockWosFns = { getWaveObjectAtom, fullConfigAtom: atoms.fullConfigAtom, + platform, mockSetWaveObj: (oref: string, obj: T) => { if (!waveObjectValueAtomCache.has(oref)) { waveObjectValueAtomCache.set(oref, atom(null as WaveObj)); @@ -332,38 +460,59 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { globalStore.set(waveObjectValueAtomCache.get(oref), obj); }, }; + const { rpc, setRpcHandler, setRpcStreamHandler } = makeMockRpc( + mergedOverrides.rpc, + mergedOverrides.rpcStreaming, + mockWosFns + ); const env = { isMock: true, - mockEnv: overrides, + mockEnv: mergedOverrides, electron: { ...previewElectronApi, getPlatform: () => platform, openExternal: (url: string) => { window.open(url, "_blank"); }, - ...overrides.electron, + ...mergedOverrides.electron, }, - rpc: makeMockRpc(overrides.rpc, mockWosFns), + rpc, atoms, - getSettingsKeyAtom: makeMockSettingsKeyAtom(atoms.settingsAtom, overrides.settings), + getSettingsKeyAtom: makeMockSettingsKeyAtom(atoms.settingsAtom), platform, - isDev: () => overrides.isDev ?? true, + isDev: () => mergedOverrides.isDev ?? true, isWindows: () => platform === PlatformWindows, isMacOS: () => platform === PlatformMacOS, createBlock: - overrides.createBlock ?? + mergedOverrides.createBlock ?? ((blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => { console.log("[mock createBlock]", blockDef, { magnified, ephemeral }); - return Promise.resolve(crypto.randomUUID()); + const newBlockId = crypto.randomUUID(); + const newBlock: Block = { + otype: "block", + oid: newBlockId, + version: 1, + meta: blockDef.meta ?? {}, + }; + mockWosFns.mockSetWaveObj(`block:${newBlockId}`, newBlock); + const tabORef = `tab:${tabId}`; + const tabAtom = getWaveObjectAtom(tabORef); + const currentTab = globalStore.get(tabAtom); + if (currentTab != null) { + mockWosFns.mockSetWaveObj(tabORef, { + ...currentTab, + blockids: [...(currentTab.blockids ?? []), newBlockId], + }); + } + return Promise.resolve(newBlockId); }), - showContextMenu: - overrides.showContextMenu ?? showPreviewContextMenu, + showContextMenu: mergedOverrides.showContextMenu ?? showPreviewContextMenu, getLocalHostDisplayNameAtom: () => { return localHostDisplayNameAtom; }, getConnStatusAtom: (conn: string) => { if (!connStatusAtomCache.has(conn)) { - const connStatus = overrides.connStatus?.[conn] ?? makeDefaultConnStatus(conn); + const connStatus = mergedOverrides.connStatus?.[conn] ?? makeDefaultConnStatus(conn); connStatusAtomCache.set(conn, atom(connStatus)); } return connStatusAtomCache.get(conn); @@ -393,17 +542,36 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { }, }, getBlockMetaKeyAtom: (blockId: string, key: T) => { - const cacheKey = blockId + "#meta-" + key; - if (!blockMetaKeyAtomCache.has(cacheKey)) { + if (blockId == null) { + return NullAtom as Atom; + } + const oref = "block:" + blockId; + const cacheKey = oref + "#meta-" + key; + if (!orefMetaKeyAtomCache.has(cacheKey)) { const metaAtom = atom((get) => { - const blockORef = "block:" + blockId; - const blockAtom = env.wos.getWaveObjectAtom(blockORef); + const blockAtom = env.wos.getWaveObjectAtom(oref); const blockData = get(blockAtom); return blockData?.meta?.[key] as MetaType[T]; }); - blockMetaKeyAtomCache.set(cacheKey, metaAtom); + orefMetaKeyAtomCache.set(cacheKey, metaAtom); } - return blockMetaKeyAtomCache.get(cacheKey) as Atom; + return orefMetaKeyAtomCache.get(cacheKey) as Atom; + }, + getTabMetaKeyAtom: (tabId: string, key: T) => { + if (tabId == null) { + return NullAtom as Atom; + } + const oref = "tab:" + tabId; + const cacheKey = oref + "#meta-" + key; + if (!orefMetaKeyAtomCache.has(cacheKey)) { + const metaAtom = atom((get) => { + const tabAtom = env.wos.getWaveObjectAtom(oref); + const tabData = get(tabAtom); + return tabData?.meta?.[key] as MetaType[T]; + }); + orefMetaKeyAtomCache.set(cacheKey, metaAtom); + } + return orefMetaKeyAtomCache.get(cacheKey) as Atom; }, getConnConfigKeyAtom: (connName: string, key: T) => { const cacheKey = connName + "#conn-" + key; @@ -416,9 +584,22 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { } return connConfigKeyAtomCache.get(cacheKey) as Atom; }, + getConfigBackgroundAtom: (bgKey: string | null) => { + if (bgKey == null) return NullAtom as Atom; + if (!configBackgroundAtomCache.has(bgKey)) { + configBackgroundAtomCache.set( + bgKey, + atom((get) => { + const fullConfig = get(atoms.fullConfigAtom); + return fullConfig.backgrounds?.[bgKey]; + }) + ); + } + return configBackgroundAtomCache.get(bgKey); + }, services: null as any, callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => { - const fn = overrides.services?.[service]?.[method]; + const fn = mergedOverrides.services?.[service]?.[method]; if (fn) { return fn(...args); } @@ -427,6 +608,12 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { }, mockSetWaveObj: mockWosFns.mockSetWaveObj, mockModels: new Map(), + addRpcOverride: (command: K, handler: RpcHandlerType) => { + setRpcHandler(command as string, handler); + }, + addRpcStreamOverride: (command: K, handler: RpcStreamHandlerType) => { + setRpcStreamHandler(command as string, handler); + }, } as MockWaveEnv; env.services = Object.fromEntries( Object.entries(AllServiceTypes).map(([key, ServiceClass]) => [key, new ServiceClass(env)]) diff --git a/frontend/preview/mock/tabbar-mock.tsx b/frontend/preview/mock/tabbar-mock.tsx new file mode 100644 index 0000000000..c4a811f6e4 --- /dev/null +++ b/frontend/preview/mock/tabbar-mock.tsx @@ -0,0 +1,173 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import { useWaveEnv, WaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; +import { applyMockEnvOverrides, MockWaveEnv } from "@/preview/mock/mockwaveenv"; +import { PlatformMacOS } from "@/util/platformutil"; +import { atom } from "jotai"; +import React, { useMemo, useRef } 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 TabBarMockTabs) { + for (const badge of tab.badges ?? []) { + events.push({ oref: `block:${badgeBlockId(tab.tabId, badge.badgeid)}`, badge }); + } + } + return events; +} + +export const TabBarMockWorkspaceId = "preview-workspace-1"; + +export const TabBarMockTabs: 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" }, +]; + +function makeMockWorkspace(tabIds: string[]): Workspace { + return { + otype: "workspace", + oid: TabBarMockWorkspaceId, + version: 1, + name: "Preview Workspace", + tabids: tabIds, + activetabid: tabIds[1] ?? tabIds[0] ?? "", + meta: {}, + } as Workspace; +} + +export function makeTabBarMockEnv( + baseEnv: WaveEnv, + envRef: React.RefObject, + platform: NodeJS.Platform +): MockWaveEnv { + const initialTabIds = TabBarMockTabs.map((t) => t.tabId); + const mockWaveObjs: Record = { + [`workspace:${TabBarMockWorkspaceId}`]: makeMockWorkspace(initialTabIds), + }; + for (const tab of TabBarMockTabs) { + mockWaveObjs[`tab:${tab.tabId}`] = makeTabWaveObj(tab); + } + const env = applyMockEnvOverrides(baseEnv, { + tabId: TabBarMockTabs[1].tabId, + platform, + mockWaveObjs, + atoms: { + workspaceId: atom(TabBarMockWorkspaceId), + staticTabId: atom(TabBarMockTabs[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:${TabBarMockWorkspaceId}`)); + e.mockSetWaveObj(`workspace:${TabBarMockWorkspaceId}`, { + ...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:${TabBarMockWorkspaceId}`)); + const newTabIds = (ws.tabids ?? []).filter((id) => id !== tabId); + if (newTabIds.length === 0) { + return Promise.resolve(false); + } + e.mockSetWaveObj(`workspace:${TabBarMockWorkspaceId}`, { ...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; +} + +type TabBarMockEnvProviderProps = { + children: React.ReactNode; +}; + +export function TabBarMockEnvProvider({ children }: TabBarMockEnvProviderProps) { + const baseEnv = useWaveEnv(); + const envRef = useRef(null); + const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, PlatformMacOS), []); + return {children}; +} +TabBarMockEnvProvider.displayName = "TabBarMockEnvProvider"; diff --git a/frontend/preview/mock/use-rpc-override.ts b/frontend/preview/mock/use-rpc-override.ts new file mode 100644 index 0000000000..78dd4c8c01 --- /dev/null +++ b/frontend/preview/mock/use-rpc-override.ts @@ -0,0 +1,24 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import * as React from "react"; +import { MockWaveEnv, RpcHandlerType, RpcOverrides, RpcStreamHandlerType, RpcStreamOverrides } from "./mockwaveenv"; + +export function useRpcOverride(command: K, handler: RpcHandlerType): void { + const mockEnv = useWaveEnv() as MockWaveEnv; + const registeredRef = React.useRef(false); + if (!registeredRef.current) { + registeredRef.current = true; + mockEnv.addRpcOverride(command, handler); + } +} + +export function useRpcStreamOverride(command: K, handler: RpcStreamHandlerType): void { + const mockEnv = useWaveEnv() as MockWaveEnv; + const registeredRef = React.useRef(false); + if (!registeredRef.current) { + registeredRef.current = true; + mockEnv.addRpcStreamOverride(command, handler); + } +} diff --git a/frontend/preview/preview.tsx b/frontend/preview/preview.tsx index 9ec47366a0..032fa2a531 100644 --- a/frontend/preview/preview.tsx +++ b/frontend/preview/preview.tsx @@ -6,12 +6,13 @@ import { ErrorBoundary } from "@/app/element/errorboundary"; import { getAtoms, initGlobalAtoms } from "@/app/store/global-atoms"; import { GlobalModel } from "@/app/store/global-model"; import { globalStore } from "@/app/store/jotaiStore"; +import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; import { WaveEnvContext } from "@/app/waveenv/waveenv"; import { loadFonts } from "@/util/fontutil"; -import { atom, Provider } from "jotai"; +import { Provider } from "jotai"; import React, { lazy, Suspense, useRef } from "react"; import { createRoot } from "react-dom/client"; -import { makeMockWaveEnv } from "./mock/mockwaveenv"; +import { makeMockWaveEnv, PreviewClientId, PreviewTabId, PreviewWindowId } from "./mock/mockwaveenv"; import { installPreviewElectronApi } from "./mock/preview-electron-api"; import { PreviewContextMenu } from "./preview-contextmenu"; @@ -93,22 +94,14 @@ function PreviewHeader({ previewName }: { previewName: string }) { } function PreviewRoot() { - const waveEnvRef = useRef( - makeMockWaveEnv({ - atoms: { - uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext), - staticTabId: atom(PreviewTabId), - workspaceId: atom(PreviewWorkspaceId), - }, - }) - ); + const waveEnvRef = useRef(makeMockWaveEnv()); return ( - <> + - + ); @@ -150,11 +143,6 @@ function PreviewApp() { return ; } -const PreviewTabId = crypto.randomUUID(); -const PreviewWindowId = crypto.randomUUID(); -const PreviewWorkspaceId = crypto.randomUUID(); -const PreviewClientId = crypto.randomUUID(); - function initPreview() { installPreviewElectronApi(); const initOpts = { diff --git a/frontend/preview/previews/aifilediff.preview-util.ts b/frontend/preview/previews/aifilediff.preview-util.ts new file mode 100644 index 0000000000..b3a54c8450 --- /dev/null +++ b/frontend/preview/previews/aifilediff.preview-util.ts @@ -0,0 +1,37 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { stringToBase64 } from "@/util/util"; + +export const DefaultAiFileDiffChatId = "preview-aifilediff-chat"; +export const DefaultAiFileDiffToolCallId = "preview-aifilediff-toolcall"; +export const DefaultAiFileDiffFileName = "src/lib/greeting.ts"; + +export const DefaultAiFileDiffOriginal = `export function greet(name: string) { + return "Hello " + name; +} + +export function greetAll(names: string[]) { + return names.map(greet).join("\\n"); +} +`; + +export const DefaultAiFileDiffModified = `export function greet(name: string) { + const normalizedName = name.trim() || "friend"; + return \`Hello, \${normalizedName}!\`; +} + +export function greetAll(names: string[]) { + return names.map(greet).join("\\n"); +} +`; + +export function makeMockAiFileDiffResponse( + original = DefaultAiFileDiffOriginal, + modified = DefaultAiFileDiffModified +): CommandWaveAIGetToolDiffRtnData { + return { + originalcontents64: stringToBase64(original), + modifiedcontents64: stringToBase64(modified), + }; +} diff --git a/frontend/preview/previews/aifilediff.preview.test.ts b/frontend/preview/previews/aifilediff.preview.test.ts new file mode 100644 index 0000000000..633b9f1881 --- /dev/null +++ b/frontend/preview/previews/aifilediff.preview.test.ts @@ -0,0 +1,26 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { base64ToString } from "@/util/util"; +import { describe, expect, it } from "vitest"; +import { + DefaultAiFileDiffModified, + DefaultAiFileDiffOriginal, + makeMockAiFileDiffResponse, +} from "./aifilediff.preview-util"; + +describe("aifilediff preview helpers", () => { + it("encodes the default diff content for the mock rpc response", () => { + const response = makeMockAiFileDiffResponse(); + + expect(base64ToString(response.originalcontents64)).toBe(DefaultAiFileDiffOriginal); + expect(base64ToString(response.modifiedcontents64)).toBe(DefaultAiFileDiffModified); + }); + + it("accepts custom original and modified content", () => { + const response = makeMockAiFileDiffResponse("before", "after"); + + expect(base64ToString(response.originalcontents64)).toBe("before"); + expect(base64ToString(response.modifiedcontents64)).toBe("after"); + }); +}); diff --git a/frontend/preview/previews/aifilediff.preview.tsx b/frontend/preview/previews/aifilediff.preview.tsx new file mode 100644 index 0000000000..12654cb7cc --- /dev/null +++ b/frontend/preview/previews/aifilediff.preview.tsx @@ -0,0 +1,63 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Block } from "@/app/block/block"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import * as React from "react"; +import { makeMockNodeModel } from "../mock/mock-node-model"; +import { useRpcOverride } from "../mock/use-rpc-override"; +import { + DefaultAiFileDiffChatId, + DefaultAiFileDiffFileName, + DefaultAiFileDiffToolCallId, + makeMockAiFileDiffResponse, +} from "./aifilediff.preview-util"; + +const PreviewNodeId = "preview-aifilediff-node"; + +export function AiFileDiffPreview() { + const env = useWaveEnv(); + const [blockId, setBlockId] = React.useState(null); + + useRpcOverride("WaveAIGetToolDiffCommand", async (_client, data) => { + if (data.chatid !== DefaultAiFileDiffChatId || data.toolcallid !== DefaultAiFileDiffToolCallId) { + return null; + } + return makeMockAiFileDiffResponse(); + }); + + React.useEffect(() => { + env.createBlock( + { + meta: { + view: "aifilediff", + file: DefaultAiFileDiffFileName, + "aifilediff:chatid": DefaultAiFileDiffChatId, + "aifilediff:toolcallid": DefaultAiFileDiffToolCallId, + }, + }, + false, + false + ).then((id) => setBlockId(id)); + }, []); + + const nodeModel = React.useMemo( + () => (blockId != null ? makeMockNodeModel({ nodeId: PreviewNodeId, blockId }) : null), + [blockId] + ); + + if (blockId == null || nodeModel == null) { + return null; + } + + return ( +
+
full aifilediff block (mock WOS + mock WaveAI diff RPC)
+
+
+ +
+
+
+ ); +} diff --git a/frontend/preview/previews/sysinfo.preview.tsx b/frontend/preview/previews/sysinfo.preview.tsx index ee4fadb9e1..3d0657ece0 100644 --- a/frontend/preview/previews/sysinfo.preview.tsx +++ b/frontend/preview/previews/sysinfo.preview.tsx @@ -2,14 +2,11 @@ // 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 { makeMockNodeModel } from "../mock/mock-node-model"; +import { SysinfoBlockId } from "../mock/mockwaveenv"; +import { useRpcOverride } from "../mock/use-rpc-override"; import { DefaultSysinfoHistoryPoints, makeMockSysinfoEvent, @@ -17,112 +14,22 @@ import { 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(); +export default function SysinfoPreview() { 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 nodeModel = React.useMemo( + () => makeMockNodeModel({ nodeId: PreviewNodeId, blockId: SysinfoBlockId, innerRect: { width: "920px", height: "560px" }, numLeafs: 2 }), + [] + ); - const tabModel = React.useMemo(() => getTabModelByTabId(PreviewTabId, env), [env]); + useRpcOverride("EventReadHistoryCommand", async (_client, data) => { + if (data.event !== "sysinfo" || data.scope !== MockSysinfoConnection) { + return []; + } + const maxItems = data.maxitems ?? historyRef.current.length; + return historyRef.current.slice(-maxItems); + }); React.useEffect(() => { let nextStep = historyRef.current.length; @@ -141,21 +48,13 @@ function SysinfoPreviewInner() { }, []); return ( - - -
-
full sysinfo block (mock WOS + FE-only WPS events)
-
-
- -
-
+
+
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 index 104ef4f8a6..f2ba2234b7 100644 --- a/frontend/preview/previews/tabbar.preview.tsx +++ b/frontend/preview/previews/tabbar.preview.tsx @@ -2,171 +2,26 @@ // 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 { makeTabBarMockEnv, TabBarMockWorkspaceId } from "@/preview/mock/tabbar-mock"; +import { MockWaveEnv } from "@/preview/mock/mockwaveenv"; import { PlatformLinux, PlatformMacOS, PlatformWindows } from "@/util/platformutil"; -import { atom, useAtom, useAtomValue } from "jotai"; +import { 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]); + const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, platform), [platform]); return ( @@ -190,7 +45,7 @@ function TabBarPreviewInner({ platform, setPlatform }: TabBarPreviewInnerProps) 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}`)); + const workspace = useAtomValue(env.wos.getWaveObjectAtom(`workspace:${TabBarMockWorkspaceId}`)); useEffect(() => { loadBadges(loadBadgesEnv); diff --git a/frontend/preview/previews/vtabbar.preview.tsx b/frontend/preview/previews/vtabbar.preview.tsx index c4739f593d..90b7907be2 100644 --- a/frontend/preview/previews/vtabbar.preview.tsx +++ b/frontend/preview/previews/vtabbar.preview.tsx @@ -1,80 +1,128 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { VTabBar, VTabItem } from "@/app/tab/vtabbar"; -import { useState } from "react"; - -const InitialTabs: VTabItem[] = [ - { id: "vtab-1", name: "Terminal" }, - { - 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", - badges: [{ badgeid: "01957000-0000-7000-0000-000000000003", icon: "solid@terminal", color: "#fbbf24", priority: 3 }], - flagColor: "#BF55EC", - }, -]; +import { loadBadges, LoadBadgesEnv } from "@/app/store/badge"; +import { VTabBar } from "@/app/tab/vtabbar"; +import { VTabBarEnv } from "@/app/tab/vtabbarenv"; +import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; +import { MockWaveEnv } from "@/preview/mock/mockwaveenv"; +import { makeTabBarMockEnv, TabBarMockWorkspaceId } from "@/preview/mock/tabbar-mock"; +import { PlatformLinux, PlatformMacOS, PlatformWindows } from "@/util/platformutil"; +import { useAtom, useAtomValue } from "jotai"; +import { useEffect, useMemo, useRef, useState } from "react"; export function VTabBarPreview() { - const [tabs, setTabs] = useState(InitialTabs); - const [activeTabId, setActiveTabId] = useState(InitialTabs[0].id); + const baseEnv = useWaveEnv(); + const envRef = useRef(null); + const [platform, setPlatform] = useState(PlatformMacOS); + + const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, platform), [platform]); + + return ( + + + + ); +} + +type VTabBarPreviewInnerProps = { + platform: NodeJS.Platform; + setPlatform: (platform: NodeJS.Platform) => void; +}; + +function VTabBarPreviewInner({ platform, setPlatform }: VTabBarPreviewInnerProps) { + const env = useWaveEnv(); + const loadBadgesEnv = useWaveEnv(); + const [hideAiButton, setHideAiButton] = useState(false); + const [isFullScreen, setIsFullScreen] = useAtom(env.atoms.isFullScreen); + const [fullConfig, setFullConfig] = useAtom(env.atoms.fullConfigAtom); + const [updaterStatus, setUpdaterStatus] = useAtom(env.atoms.updaterStatusAtom); const [width, setWidth] = useState(220); + const workspace = useAtomValue(env.wos.getWaveObjectAtom(`workspace:${TabBarMockWorkspaceId}`)); - const handleCloseTab = (tabId: string) => { - setTabs((prevTabs) => { - const nextTabs = prevTabs.filter((tab) => tab.id !== tabId); - if (activeTabId === tabId && nextTabs.length > 0) { - setActiveTabId(nextTabs[0].id); - } - return nextTabs; - }); - }; + useEffect(() => { + loadBadges(loadBadgesEnv); + }, []); + + useEffect(() => { + setFullConfig((prev) => ({ + ...(prev ?? ({} as FullConfigType)), + settings: { + ...(prev?.settings ?? {}), + "app:hideaibutton": hideAiButton, + }, + })); + }, [hideAiButton, setFullConfig]); return ( -
-
-
Width: {width}px
- setWidth(Number(event.target.value))} - className="w-full cursor-pointer" - /> -

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

+
+
+ + + + +
-
- { - setTabs((prevTabs) => - prevTabs.map((tab) => (tab.id === tabId ? { ...tab, name: newName } : tab)) - ); - }} - onReorderTabs={(tabIds) => { - setTabs((prevTabs) => { - const tabById = new Map(prevTabs.map((tab) => [tab.id, tab])); - return tabIds.map((tabId) => tabById.get(tabId)).filter((tab) => tab != null); - }); - }} - /> + +
+
+ {workspace != null && } +
); } +VTabBarPreviewInner.displayName = "VTabBarPreviewInner"; diff --git a/frontend/preview/previews/waveai.preview.tsx b/frontend/preview/previews/waveai.preview.tsx new file mode 100644 index 0000000000..1d5003f0d4 --- /dev/null +++ b/frontend/preview/previews/waveai.preview.tsx @@ -0,0 +1,53 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Block } from "@/app/block/block"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import * as React from "react"; +import { makeMockNodeModel } from "../mock/mock-node-model"; + +const PreviewNodeId = "preview-waveai-node"; + +export default function WaveAIPreview() { + const env = useWaveEnv(); + const [blockId, setBlockId] = React.useState(null); + + React.useEffect(() => { + env.createBlock( + { + meta: { + view: "waveai", + }, + }, + false, + false + ).then((id) => setBlockId(id)); + }, [env]); + + const nodeModel = React.useMemo( + () => + blockId == null + ? null + : makeMockNodeModel({ + nodeId: PreviewNodeId, + blockId, + innerRect: { width: "900px", height: "480px" }, + }), + [blockId] + ); + + if (blockId == null || nodeModel == null) { + return null; + } + + return ( +
+
full deprecated waveai block with the FE-only replacement UI
+
+
+ +
+
+
+ ); +} diff --git a/frontend/preview/previews/web.preview.tsx b/frontend/preview/previews/web.preview.tsx new file mode 100644 index 0000000000..c63331a224 --- /dev/null +++ b/frontend/preview/previews/web.preview.tsx @@ -0,0 +1,27 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Block } from "@/app/block/block"; +import * as React from "react"; +import { makeMockNodeModel } from "../mock/mock-node-model"; +import { WebBlockId } from "../mock/mockwaveenv"; + +const PreviewNodeId = "preview-web-node"; + +export function WebPreview() { + const nodeModel = React.useMemo( + () => makeMockNodeModel({ nodeId: PreviewNodeId, blockId: WebBlockId, innerRect: { width: "1040px", height: "620px" } }), + [] + ); + + return ( +
+
full web block using preview mock fallback
+
+
+ +
+
+
+ ); +} diff --git a/frontend/preview/previews/widgets.preview.tsx b/frontend/preview/previews/widgets.preview.tsx index 144cace174..4b82314510 100644 --- a/frontend/preview/previews/widgets.preview.tsx +++ b/frontend/preview/previews/widgets.preview.tsx @@ -3,11 +3,14 @@ import { useWaveEnv, WaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; import { Widgets } from "@/app/workspace/widgets"; -import { atom, useAtom } from "jotai"; +import { atom, useAtom, useAtomValue } from "jotai"; import { useRef } from "react"; import { applyMockEnvOverrides } from "../mock/mockwaveenv"; const resizableHeightAtom = atom(250); +const hasConfigErrorsAtom = atom(false); +const isDevAtom = atom(true); +const mockVersionAtom = atom(0); function makeMockApp(name: string, icon: string, iconcolor: string): AppInfo { return { @@ -56,20 +59,12 @@ const mockWidgets: { [key: string]: WidgetConfigType } = { "display:order": 2, blockdef: { meta: { view: "web", url: "https://waveterm.dev" } }, }, - "defwidget@ai": { - icon: "sparkles", - color: "#a78bfa", - label: "AI", - description: "Open Wave AI", - "display:order": 3, - blockdef: { meta: { view: "waveai" } }, - }, "defwidget@files": { icon: "folder", color: "#fbbf24", label: "Files", description: "Open file browser", - "display:order": 4, + "display:order": 3, blockdef: { meta: { view: "preview", connection: "local" } }, }, "defwidget@sysinfo": { @@ -77,20 +72,25 @@ const mockWidgets: { [key: string]: WidgetConfigType } = { color: "#34d399", label: "Sysinfo", description: "Open system info", - "display:order": 5, + "display:order": 4, blockdef: { meta: { view: "sysinfo" } }, }, }; const fullConfigAtom = atom({ settings: {}, widgets: mockWidgets } as unknown as FullConfigType); -function makeWidgetsEnv(baseEnv: WaveEnv, isDev: boolean, hasCustomAIPresets: boolean, apps?: AppInfo[]) { +function makeWidgetsEnv( + baseEnv: WaveEnv, + isDev: boolean, + apps?: AppInfo[], + atomOverrides?: Partial +) { return applyMockEnvOverrides(baseEnv, { isDev, rpc: { ListAllAppsCommand: () => Promise.resolve(apps ?? []) }, atoms: { fullConfigAtom, - hasCustomAIPresetsAtom: atom(hasCustomAIPresets), + ...atomOverrides, }, }); } @@ -98,20 +98,20 @@ function makeWidgetsEnv(baseEnv: WaveEnv, isDev: boolean, hasCustomAIPresets: bo function WidgetsScenario({ label, isDev = false, - hasCustomAIPresets = true, height, apps, }: { label: string; isDev?: boolean; - hasCustomAIPresets?: boolean; height?: number; apps?: AppInfo[]; }) { const baseEnv = useWaveEnv(); const envRef = useRef(null); if (envRef.current == null) { - envRef.current = makeWidgetsEnv(baseEnv, isDev, hasCustomAIPresets, apps); + envRef.current = makeWidgetsEnv(baseEnv, isDev, apps, { + hasConfigErrors: hasConfigErrorsAtom, + }); } return ( @@ -132,18 +132,18 @@ function WidgetsScenario({ ); } -function WidgetsResizable() { +function WidgetsResizable({ isDev }: { isDev: boolean }) { const [height, setHeight] = useAtom(resizableHeightAtom); const baseEnv = useWaveEnv(); const envRef = useRef(null); if (envRef.current == null) { - envRef.current = makeWidgetsEnv(baseEnv, true, true, mockApps); + envRef.current = makeWidgetsEnv(baseEnv, isDev, mockApps, { hasConfigErrors: hasConfigErrorsAtom }); } return (
- compact/supercompact — resizable (dev mode, height: {height}px) + compact/supercompact — resizable (height: {height}px) void) { + fn(); + setMockVersion((v) => v + 1); + } + + return ( +
+ preview controls: + + +
+ ); +} + export function WidgetsPreview() { + const isDev = useAtomValue(isDevAtom); + const mockVersion = useAtomValue(mockVersionAtom); + return (
-
- - - - + +
+
+ + + +
+
-
); } diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index ddcb4a63e7..7a60b6877d 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -108,6 +108,17 @@ declare global { iconcolor: string; }; + // wconfig.BackgroundConfigType + type BackgroundConfigType = { + bg?: string; + "bg:opacity"?: number; + "bg:blendmode"?: string; + "bg:bordercolor"?: string; + "bg:activebordercolor"?: string; + "display:name": string; + "display:order"?: number; + }; + // baseds.Badge type Badge = { badgeid: string; @@ -380,6 +391,13 @@ declare global { restoretofilename: string; }; + // wshrpc.CommandFileStreamData + type CommandFileStreamData = { + info: FileInfo; + byterange?: string; + streammeta: StreamMeta; + }; + // wshrpc.CommandGetMetaData type CommandGetMetaData = { oref: ORef; @@ -521,6 +539,13 @@ declare global { paths: string[]; }; + // wshrpc.CommandRemoteFileStreamData + type CommandRemoteFileStreamData = { + path: string; + byterange?: string; + streammeta: StreamMeta; + }; + // wshrpc.CommandRemoteListEntriesData type CommandRemoteListEntriesData = { path: string; @@ -562,12 +587,6 @@ declare global { publickeybase64: string; }; - // wshrpc.CommandRemoteStreamFileData - type CommandRemoteStreamFileData = { - path: string; - byterange?: string; - }; - // wshrpc.CommandRemoteTerminateJobManagerData type CommandRemoteTerminateJobManagerData = { jobid: string; @@ -977,6 +996,7 @@ declare global { defaultwidgets: {[key: string]: WidgetConfigType}; widgets: {[key: string]: WidgetConfigType}; presets: {[key: string]: MetaType}; + backgrounds: {[key: string]: BackgroundConfigType}; termthemes: {[key: string]: TermThemeType}; connections: {[key: string]: ConnKeywords}; bookmarks: {[key: string]: WebBookmark}; @@ -1115,12 +1135,14 @@ declare global { "graph:metrics"?: string[]; "sysinfo:type"?: string; "tab:flagcolor"?: string; + "tab:background"?: string; "bg:*"?: boolean; bg?: string; "bg:opacity"?: number; "bg:blendmode"?: string; "bg:bordercolor"?: string; "bg:activebordercolor"?: string; + "layout:vtabbarwidth"?: number; "waveai:panelopen"?: boolean; "waveai:panelwidth"?: number; "waveai:model"?: string; @@ -1305,6 +1327,7 @@ declare global { "app:disablectrlshiftarrows"?: boolean; "app:disablectrlshiftdisplay"?: boolean; "app:focusfollowscursor"?: string; + "app:tabbar"?: string; "feature:waveappbuilder"?: boolean; "ai:*"?: boolean; "ai:preset"?: string; @@ -1362,6 +1385,7 @@ declare global { "preview:defaultsort"?: string; "tab:preset"?: string; "tab:confirmclose"?: boolean; + "tab:background"?: string; "widget:*"?: boolean; "widget:showhelp"?: boolean; "window:*"?: boolean; @@ -1393,6 +1417,7 @@ declare global { "debug:*"?: boolean; "debug:pprofport"?: number; "debug:pprofmemprofilerate"?: number; + "debug:webglstatus"?: boolean; "tsunami:*"?: boolean; "tsunami:scaffoldpath"?: string; "tsunami:sdkreplacepath"?: string; diff --git a/frontend/util/platformutil.ts b/frontend/util/platformutil.ts index ded79d3394..92fc240b0a 100644 --- a/frontend/util/platformutil.ts +++ b/frontend/util/platformutil.ts @@ -1,15 +1,28 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 export const PlatformMacOS = "darwin"; export const PlatformWindows = "win32"; export const PlatformLinux = "linux"; export let PLATFORM: NodeJS.Platform = PlatformMacOS; +export let MacOSVersion: string = null; export function setPlatform(platform: NodeJS.Platform) { PLATFORM = platform; } +export function setMacOSVersion(version: string) { + MacOSVersion = version; +} + +export function isMacOSTahoeOrLater(): boolean { + if (!isMacOS() || MacOSVersion == null) { + return false; + } + const major = parseInt(MacOSVersion.split(".")[0], 10); + return major >= 16; +} + export function isMacOS(): boolean { return PLATFORM == PlatformMacOS; } diff --git a/frontend/util/util.ts b/frontend/util/util.ts index 2e5a3b5a13..8c2d330580 100644 --- a/frontend/util/util.ts +++ b/frontend/util/util.ts @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0s import base64 from "base64-js"; @@ -108,7 +108,7 @@ function jsonDeepEqual(v1: any, v2: any): boolean { if (keys1.length !== keys2.length) { return false; } - for (let key of keys1) { + for (const key of keys1) { if (!jsonDeepEqual(v1[key], v2[key])) { return false; } diff --git a/frontend/util/waveutil.ts b/frontend/util/waveutil.ts index d22f5a4896..4d0f5952fc 100644 --- a/frontend/util/waveutil.ts +++ b/frontend/util/waveutil.ts @@ -69,7 +69,7 @@ export function processBackgroundUrls(cssText: string): string { return rtnStyle.replace(/^background:\s*/, ""); } -export function computeBgStyleFromMeta(meta: MetaType, defaultOpacity: number = null): React.CSSProperties { +export function computeBgStyleFromMeta(meta: Omit, defaultOpacity: number = null): React.CSSProperties { const bgAttr = meta?.["bg"]; if (isBlank(bgAttr)) { return null; diff --git a/frontend/wave.ts b/frontend/wave.ts index a2ecb8a426..20ee2ba97a 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -32,6 +32,7 @@ import { activeTabIdAtom } from "@/store/tab-model"; import * as WOS from "@/store/wos"; import { loadFonts } from "@/util/fontutil"; import { setKeyUtilPlatform } from "@/util/keyutil"; +import { isMacOS, setMacOSVersion } from "@/util/platformutil"; import { createElement } from "react"; import { createRoot } from "react-dom/client"; @@ -159,13 +160,17 @@ async function initWave(initOpts: WaveInitOpts) { const globalWS = initWshrpc(makeTabRouteId(initOpts.tabId)); (window as any).globalWS = globalWS; (window as any).TabRpcClient = TabRpcClient; - await loadConnStatus(); - await loadBadges(); - initGlobalWaveEventSubs(initOpts); - subscribeToConnEvents(); // ensures client/window/workspace are loaded into the cache before rendering try { + await loadConnStatus(); + await loadBadges(); + initGlobalWaveEventSubs(initOpts); + subscribeToConnEvents(); + if (isMacOS()) { + const macOSVersion = await RpcApi.MacOSVersionCommand(TabRpcClient); + setMacOSVersion(macOSVersion); + } const [_client, waveWindow, initialTab] = await Promise.all([ WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)), WOS.loadAndPinWaveObject(WOS.makeORef("window", initOpts.windowId)), diff --git a/go.mod b/go.mod index 7615a351ee..1e9e2d3663 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/junegunn/fzf v0.65.2 github.com/kevinburke/ssh_config v1.2.0 github.com/launchdarkly/eventsource v1.11.0 - github.com/mattn/go-sqlite3 v1.14.34 + github.com/mattn/go-sqlite3 v1.14.37 github.com/mitchellh/mapstructure v1.5.0 github.com/sashabaranov/go-openai v1.41.2 github.com/sawka/txwrap v0.2.0 @@ -31,12 +31,12 @@ require ( github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b github.com/wavetermdev/htmltoken v0.2.0 github.com/wavetermdev/waveterm/tsunami v0.12.3 - golang.org/x/crypto v0.48.0 - golang.org/x/mod v0.33.0 - golang.org/x/sync v0.19.0 - golang.org/x/sys v0.41.0 - golang.org/x/term v0.40.0 - google.golang.org/api v0.269.0 + golang.org/x/crypto v0.49.0 + golang.org/x/mod v0.34.0 + golang.org/x/sync v0.20.0 + golang.org/x/sys v0.42.0 + golang.org/x/term v0.41.0 + google.golang.org/api v0.271.0 ) require ( @@ -47,7 +47,7 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/longrunning v0.6.7 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/buger/jsonparser v1.1.1 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -55,7 +55,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect @@ -76,13 +76,13 @@ require ( go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/time v0.14.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect - google.golang.org/grpc v1.79.1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 03a89cd1d2..c9d7a83b4d 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/alexflint/go-filemutex v1.3.0 h1:LgE+nTUWnQCyRKbpoceKZsPQbs84LivvgwUy github.com/alexflint/go-filemutex v1.3.0/go.mod h1:U0+VA/i30mGBlLCrFPGtTe9y6wGQfNAWPBTekHQ+c8A= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= -github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= -github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= @@ -68,8 +68,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= -github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -104,8 +104,8 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= -github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= +github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b h1:cLGKfKb1uk0hxI0Q8L83UAJPpeJ+gSpn3cCU/tjd3eg= @@ -176,43 +176,43 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5w go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= -google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= +google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= +google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/package-lock.json b/package-lock.json index 99c2a025b4..00bae215eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.2-beta.1", + "version": "0.14.4-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.2-beta.1", + "version": "0.14.4-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -22,12 +22,12 @@ "@table-nav/react": "^0.0.7", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.19", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-search": "^0.15.0", - "@xterm/addon-serialize": "^0.13.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/addon-webgl": "^0.18.0", - "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "ai": "^5.0.92", "base64-js": "^1.5.1", "class-variance-authority": "^0.7.1", @@ -84,7 +84,7 @@ "uuid": "^13.0.0", "winston": "^3.19.0", "ws": "^8.19.0", - "yaml": "^2.7.1" + "yaml": "^2.8.3" }, "devDependencies": { "@eslint/js": "^9.39", @@ -107,7 +107,7 @@ "@types/ws": "^8", "@vitejs/plugin-react-swc": "4.2.3", "@vitest/coverage-istanbul": "^3.0.9", - "electron": "^40.4.1", + "electron": "^41.0.2", "electron-builder": "^26.8", "electron-vite": "^5.0", "eslint": "^9.39", @@ -3880,9 +3880,9 @@ } }, "node_modules/@docusaurus/core/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -5434,32 +5434,32 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/react": { - "version": "0.27.16", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", - "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.1.6", - "@floating-ui/utils": "^0.2.10", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { @@ -5468,12 +5468,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", @@ -5481,9 +5481,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@hapi/hoek": { @@ -10665,55 +10665,43 @@ } }, "node_modules/@xterm/addon-fit": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", - "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", - "license": "MIT", - "peerDependencies": { - "@xterm/xterm": "^5.0.0" - } + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" }, "node_modules/@xterm/addon-search": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz", - "integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==", - "license": "MIT", - "peerDependencies": { - "@xterm/xterm": "^5.0.0" - } + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" }, "node_modules/@xterm/addon-serialize": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.13.0.tgz", - "integrity": "sha512-kGs8o6LWAmN1l2NpMp01/YkpxbmO4UrfWybeGu79Khw5K9+Krp7XhXbBTOTc3GJRRhd6EmILjpR8k5+odY39YQ==", - "license": "MIT", - "peerDependencies": { - "@xterm/xterm": "^5.0.0" - } + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", + "integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==", + "license": "MIT" }, "node_modules/@xterm/addon-web-links": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", - "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", - "license": "MIT", - "peerDependencies": { - "@xterm/xterm": "^5.0.0" - } + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" }, "node_modules/@xterm/addon-webgl": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz", - "integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==", - "license": "MIT", - "peerDependencies": { - "@xterm/xterm": "^5.0.0" - } + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" }, "node_modules/@xterm/xterm": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", - "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", @@ -11090,9 +11078,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -14902,9 +14890,9 @@ } }, "node_modules/electron": { - "version": "40.4.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-40.4.1.tgz", - "integrity": "sha512-N1ZXybQZL8kYemO8vAeh9nrk4mSvqlAO8xs0QCHkXIvRnuB/7VGwEehjvQbsU5/f4bmTKpG+2GQERe/zmKpudQ==", + "version": "41.0.2", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.0.2.tgz", + "integrity": "sha512-raotm/aO8kOs1jD8SI8ssJ7EKciQOY295AOOprl1TxW7B0At8m5Ae7qNU1xdMxofiHMR8cNEGi9PKD3U+yT/mA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -15194,9 +15182,9 @@ "license": "MIT" }, "node_modules/emoji-regex": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/emojilib": { @@ -15365,9 +15353,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.39.10", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", - "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", "license": "MIT", "workspaces": [ "docs", @@ -16584,9 +16572,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -18776,9 +18764,9 @@ } }, "node_modules/jest-util/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -18960,9 +18948,9 @@ } }, "node_modules/katex": { - "version": "0.16.22", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", - "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "version": "0.16.38", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.38.tgz", + "integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -22370,9 +22358,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -24067,9 +24055,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -29930,13 +29918,13 @@ } }, "node_modules/swr": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", - "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", "license": "MIT", "dependencies": { "dequal": "^2.0.3", - "use-sync-external-store": "^1.4.0" + "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -29959,9 +29947,9 @@ } }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, "node_modules/tailwind-merge": { @@ -30005,9 +29993,9 @@ } }, "node_modules/tar": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", - "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -31354,9 +31342,9 @@ } }, "node_modules/unified-args/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -31879,9 +31867,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -32836,9 +32824,9 @@ } }, "node_modules/webpack-dev-server/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -33322,15 +33310,18 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/package.json b/package.json index 3fb3de6b60..bc39047e2d 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.3", + "version": "0.14.4", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" @@ -48,7 +48,7 @@ "@types/ws": "^8", "@vitejs/plugin-react-swc": "4.2.3", "@vitest/coverage-istanbul": "^3.0.9", - "electron": "^40.4.1", + "electron": "^41.0.2", "electron-builder": "^26.8", "electron-vite": "^5.0", "eslint": "^9.39", @@ -82,12 +82,12 @@ "@table-nav/react": "^0.0.7", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.19", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-search": "^0.15.0", - "@xterm/addon-serialize": "^0.13.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/addon-webgl": "^0.18.0", - "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "ai": "^5.0.92", "base64-js": "^1.5.1", "class-variance-authority": "^0.7.1", @@ -144,7 +144,7 @@ "uuid": "^13.0.0", "winston": "^3.19.0", "ws": "^8.19.0", - "yaml": "^2.7.1" + "yaml": "^2.8.3" }, "packageManager": "npm@10.9.2", "workspaces": [ diff --git a/pkg/blockcontroller/durableshellcontroller.go b/pkg/blockcontroller/durableshellcontroller.go index d3481b172d..a21dac153b 100644 --- a/pkg/blockcontroller/durableshellcontroller.go +++ b/pkg/blockcontroller/durableshellcontroller.go @@ -7,11 +7,13 @@ import ( "context" "encoding/base64" "fmt" + "io/fs" "log" "sync" "time" "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/jobcontroller" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" @@ -163,6 +165,10 @@ func (dsc *DurableShellController) Start(ctx context.Context, blockMeta waveobj. if jobId == "" { log.Printf("block %q starting new durable shell\n", dsc.BlockId) + fsErr := filestore.WFS.MakeFile(ctx, dsc.BlockId, wavebase.BlockFile_Term, nil, wshrpc.FileOpts{MaxSize: DefaultTermMaxFileSize, Circular: true}) + if fsErr != nil && fsErr != fs.ErrExist { + return fmt.Errorf("error creating block term file: %w", fsErr) + } newJobId, err := dsc.startNewJob(ctx, blockMeta, dsc.ConnName, rtOpts) if err != nil { return fmt.Errorf("failed to start new job: %w", err) diff --git a/pkg/remote/fileshare/fspath/fspath_test.go b/pkg/remote/fileshare/fspath/fspath_test.go new file mode 100644 index 0000000000..c634f665ce --- /dev/null +++ b/pkg/remote/fileshare/fspath/fspath_test.go @@ -0,0 +1,21 @@ +package fspath + +import "testing" + +func TestBase(t *testing.T) { + tests := []struct { + path string + want string + }{ + {`D:\package\AA.tar`, "AA.tar"}, + {`D:/package/AA.tar`, "AA.tar"}, + {"/home/user/file.txt", "file.txt"}, + {"file.txt", "file.txt"}, + } + for _, tt := range tests { + got := Base(tt.path) + if got != tt.want { + t.Errorf("Base(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} diff --git a/pkg/remote/fileshare/fsutil/fsutil.go b/pkg/remote/fileshare/fsutil/fsutil.go index a7efd6fdc5..aba9de944e 100644 --- a/pkg/remote/fileshare/fsutil/fsutil.go +++ b/pkg/remote/fileshare/fsutil/fsutil.go @@ -150,13 +150,3 @@ func ReadStreamToFileData(ctx context.Context, readCh <-chan wshrpc.RespOrErrorU } return fileData, nil } - -func ReadFileStreamToWriter(ctx context.Context, readCh <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData], writer io.Writer) error { - return ReadFileStream(ctx, readCh, func(finfo wshrpc.FileInfo) { - }, func(entries []*wshrpc.FileInfo) error { - return nil - }, func(data io.Reader) error { - _, err := io.Copy(writer, data) - return err - }) -} diff --git a/pkg/remote/fileshare/wshfs/wshfs.go b/pkg/remote/fileshare/wshfs/wshfs.go index 06429bc8a2..352f8c1b7b 100644 --- a/pkg/remote/fileshare/wshfs/wshfs.go +++ b/pkg/remote/fileshare/wshfs/wshfs.go @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshfs @@ -7,12 +7,12 @@ import ( "context" "encoding/base64" "fmt" + "io" "log" "os" "time" "github.com/wavetermdev/waveterm/pkg/remote/connparse" - "github.com/wavetermdev/waveterm/pkg/remote/fileshare/fsutil" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" @@ -30,6 +30,7 @@ const ( // This needs to be set by whoever initializes the client, either main-server or wshcmd-connserver var RpcClient *wshutil.WshRpc +var RpcClientRouteId string func parseConnection(ctx context.Context, path string) (*connparse.Connection, error) { conn, err := connparse.ParseURIAndReplaceCurrentHost(ctx, path) @@ -40,31 +41,79 @@ func parseConnection(ctx context.Context, path string) (*connparse.Connection, e } func Read(ctx context.Context, data wshrpc.FileData) (*wshrpc.FileData, error) { + if data.Info == nil { + return nil, fmt.Errorf("file info is required") + } log.Printf("Read: %v", data.Info.Path) conn, err := parseConnection(ctx, data.Info.Path) if err != nil { return nil, err } - rtnCh := readStream(conn, data) - return fsutil.ReadStreamToFileData(ctx, rtnCh) + broker := RpcClient.StreamBroker + if broker == nil { + return nil, fmt.Errorf("stream broker not available") + } + if RpcClientRouteId == "" { + return nil, fmt.Errorf("no route id available") + } + readerRouteId := RpcClientRouteId + writerRouteId := wshutil.MakeConnectionRouteId(conn.Host) + reader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, 256*1024) + defer reader.Close() + go func() { + <-ctx.Done() + reader.Close() + }() + byteRange := "" + if data.At != nil && data.At.Size > 0 { + byteRange = fmt.Sprintf("%d-%d", data.At.Offset, data.At.Offset+int64(data.At.Size)-1) + } + remoteData := wshrpc.CommandRemoteFileStreamData{ + Path: conn.Path, + ByteRange: byteRange, + StreamMeta: *streamMeta, + } + fileInfo, err := wshclient.RemoteFileStreamCommand(RpcClient, remoteData, &wshrpc.RpcOpts{Route: writerRouteId}) + if err != nil { + return nil, fmt.Errorf("starting remote file stream: %w", err) + } + var rawData []byte + if fileInfo != nil && !fileInfo.IsDir { + rawData, err = io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("reading file stream: %w", err) + } + } + rtnData := &wshrpc.FileData{Info: fileInfo} + if len(rawData) > 0 { + rtnData.Data64 = base64.StdEncoding.EncodeToString(rawData) + } + return rtnData, nil } -func ReadStream(ctx context.Context, data wshrpc.FileData) <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData] { - log.Printf("ReadStream: %v", data.Info.Path) - conn, err := parseConnection(ctx, data.Info.Path) +func GetConnectionRouteId(ctx context.Context, path string) (string, error) { + conn, err := parseConnection(ctx, path) if err != nil { - return wshutil.SendErrCh[wshrpc.FileData](err) + return "", err } - return readStream(conn, data) + return wshutil.MakeConnectionRouteId(conn.Host), nil } -func readStream(conn *connparse.Connection, data wshrpc.FileData) <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData] { - byteRange := "" - if data.At != nil && data.At.Size > 0 { - byteRange = fmt.Sprintf("%d-%d", data.At.Offset, data.At.Offset+int64(data.At.Size)) +func FileStream(ctx context.Context, data wshrpc.CommandFileStreamData) (*wshrpc.FileInfo, error) { + if data.Info == nil { + return nil, fmt.Errorf("file info is required") + } + log.Printf("FileStream: %v", data.Info.Path) + conn, err := parseConnection(ctx, data.Info.Path) + if err != nil { + return nil, err + } + remoteData := wshrpc.CommandRemoteFileStreamData{ + Path: conn.Path, + ByteRange: data.ByteRange, + StreamMeta: data.StreamMeta, } - streamFileData := wshrpc.CommandRemoteStreamFileData{Path: conn.Path, ByteRange: byteRange} - return wshclient.RemoteStreamFileCommand(RpcClient, streamFileData, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)}) + return wshclient.RemoteFileStreamCommand(RpcClient, remoteData, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)}) } func ListEntries(ctx context.Context, path string, opts *wshrpc.FileListOpts) ([]*wshrpc.FileInfo, error) { diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index 4850eee1b8..35af5446a3 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -760,5 +760,11 @@ func tryGetPamEnvVars() map[string]string { if runtime_dir, ok := envVars["XDG_RUNTIME_DIR"]; !ok || runtime_dir == "" { envVars["XDG_RUNTIME_DIR"] = "/run/user/" + fmt.Sprint(os.Getuid()) } + if configDirs, ok := envVars["XDG_CONFIG_DIRS"]; !ok || configDirs == "" { + envVars["XDG_CONFIG_DIRS"] = "/etc/xdg" + } + if dataDirs, ok := envVars["XDG_DATA_DIRS"]; !ok || dataDirs == "" { + envVars["XDG_DATA_DIRS"] = "/usr/local/share:/usr/share" + } return envVars } diff --git a/pkg/util/fileutil/fileutil.go b/pkg/util/fileutil/fileutil.go index a26409e5e0..ce9663d521 100644 --- a/pkg/util/fileutil/fileutil.go +++ b/pkg/util/fileutil/fileutil.go @@ -1,10 +1,11 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package fileutil import ( "bytes" + "errors" "fmt" "io" "io/fs" @@ -18,6 +19,38 @@ import ( "github.com/wavetermdev/waveterm/pkg/wavebase" ) +type ByteRangeType struct { + All bool + Start int64 + End int64 // inclusive; only valid when OpenEnd is false + OpenEnd bool // true when range is "N-" (read from Start to EOF) +} + +func ParseByteRange(rangeStr string) (ByteRangeType, error) { + if rangeStr == "" { + return ByteRangeType{All: true}, nil + } + // handle open-ended range "N-" + if len(rangeStr) > 0 && rangeStr[len(rangeStr)-1] == '-' { + var start int64 + _, err := fmt.Sscanf(rangeStr, "%d-", &start) + if err != nil || start < 0 { + return ByteRangeType{}, errors.New("invalid byte range") + } + return ByteRangeType{Start: start, OpenEnd: true}, nil + } + var start, end int64 + _, err := fmt.Sscanf(rangeStr, "%d-%d", &start, &end) + if err != nil { + return ByteRangeType{}, errors.New("invalid byte range") + } + if start < 0 || end < 0 || start > end { + return ByteRangeType{}, errors.New("invalid byte range") + } + // End is inclusive (HTTP byte range semantics: bytes=0-999 means 1000 bytes) + return ByteRangeType{Start: start, End: end}, nil +} + func FixPath(path string) (string, error) { origPath := path var err error diff --git a/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh b/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh index cc002b57a9..6fd70eee14 100644 --- a/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh +++ b/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh @@ -16,6 +16,10 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { return # skip OSC setup entirely } +if ($PSStyle.FileInfo.Directory -eq "`e[44;1m") { + $PSStyle.FileInfo.Directory = "`e[34;1m" +} + $Global:_WAVETERM_SI_FIRSTPROMPT = $true # shell integration diff --git a/pkg/util/utilfn/streamtolines.go b/pkg/util/utilfn/streamtolines.go index 51758465da..5cbde2bb16 100644 --- a/pkg/util/utilfn/streamtolines.go +++ b/pkg/util/utilfn/streamtolines.go @@ -20,7 +20,9 @@ type lineBuf struct { inLongLine bool } -const maxLineLength = 128 * 1024 +// needs to be large enough to read the largest RPC packet +// there are some legacy file transfer packets that can send up to 32m (base64 encoded) +const maxLineLength = 64 * 1024 * 1024 func ReadLineWithTimeout(ch chan LineOutput, timeout time.Duration) (string, error) { select { diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index bf348023ac..f41435954c 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -90,6 +90,7 @@ const ( MetaKey_SysinfoType = "sysinfo:type" MetaKey_TabFlagColor = "tab:flagcolor" + MetaKey_TabBackground = "tab:background" MetaKey_BgClear = "bg:*" MetaKey_Bg = "bg" @@ -98,6 +99,8 @@ const ( MetaKey_BgBorderColor = "bg:bordercolor" MetaKey_BgActiveBorderColor = "bg:activebordercolor" + MetaKey_LayoutVTabBarWidth = "layout:vtabbarwidth" + MetaKey_WaveAiPanelOpen = "waveai:panelopen" MetaKey_WaveAiPanelWidth = "waveai:panelwidth" MetaKey_WaveAiModel = "waveai:model" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index adda079c1f..4a36fdd46f 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -93,6 +93,7 @@ type MetaTSType struct { // for tabs TabFlagColor string `json:"tab:flagcolor,omitempty"` + TabBackground string `json:"tab:background,omitempty"` BgClear bool `json:"bg:*,omitempty"` Bg string `json:"bg,omitempty"` BgOpacity float64 `json:"bg:opacity,omitempty"` @@ -100,6 +101,9 @@ type MetaTSType struct { BgBorderColor string `json:"bg:bordercolor,omitempty"` // frame:bordercolor BgActiveBorderColor string `json:"bg:activebordercolor,omitempty"` // frame:activebordercolor + // for workspace + LayoutVTabBarWidth int `json:"layout:vtabbarwidth,omitempty"` + // for tabs+waveai WaveAiPanelOpen bool `json:"waveai:panelopen,omitempty"` WaveAiPanelWidth int `json:"waveai:panelwidth,omitempty"` diff --git a/pkg/wconfig/defaultconfig/backgrounds.json b/pkg/wconfig/defaultconfig/backgrounds.json new file mode 100644 index 0000000000..ab044b9246 --- /dev/null +++ b/pkg/wconfig/defaultconfig/backgrounds.json @@ -0,0 +1,90 @@ +{ + "bg@rainbow": { + "display:name": "Rainbow", + "display:order": 2.1, + "bg": "linear-gradient( 226.4deg, rgba(255,26,1,1) 28.9%, rgba(254,155,1,1) 33%, rgba(255,241,0,1) 48.6%, rgba(34,218,1,1) 65.3%, rgba(0,141,254,1) 80.6%, rgba(113,63,254,1) 100.1% )", + "bg:opacity": 0.3 + }, + "bg@green": { + "display:name": "Green", + "display:order": 1.2, + "bg": "green", + "bg:opacity": 0.3 + }, + "bg@blue": { + "display:name": "Blue", + "display:order": 1.1, + "bg": "blue", + "bg:opacity": 0.3, + "bg:activebordercolor": "rgba(0, 0, 255, 1.0)" + }, + "bg@red": { + "display:name": "Red", + "display:order": 1.3, + "bg": "red", + "bg:opacity": 0.3, + "bg:activebordercolor": "rgba(255, 0, 0, 1.0)" + }, + "bg@ocean-depths": { + "display:name": "Ocean Depths", + "display:order": 2.2, + "bg": "linear-gradient(135deg, purple, blue, teal)", + "bg:opacity": 0.7 + }, + "bg@aqua-horizon": { + "display:name": "Aqua Horizon", + "display:order": 2.3, + "bg": "linear-gradient(135deg, rgba(15, 30, 50, 1) 0%, rgba(40, 90, 130, 0.85) 30%, rgba(20, 100, 150, 0.75) 60%, rgba(0, 120, 160, 0.65) 80%, rgba(0, 140, 180, 0.55) 100%), linear-gradient(135deg, rgba(100, 80, 255, 0.4), rgba(0, 180, 220, 0.4)), radial-gradient(circle at 70% 70%, rgba(255, 255, 255, 0.05), transparent 70%)", + "bg:opacity": 0.85, + "bg:blendmode": "overlay" + }, + "bg@sunset": { + "display:name": "Sunset", + "display:order": 2.4, + "bg": "linear-gradient(135deg, rgba(128, 0, 0, 1), rgba(255, 69, 0, 0.8), rgba(75, 0, 130, 1))", + "bg:opacity": 0.8, + "bg:blendmode": "normal" + }, + "bg@enchantedforest": { + "display:name": "Enchanted Forest", + "display:order": 2.7, + "bg": "linear-gradient(145deg, rgba(0,50,0,1), rgba(34,139,34,0.7) 20%, rgba(0,100,0,0.5) 40%, rgba(0,200,100,0.3) 60%, rgba(34,139,34,0.8) 80%, rgba(0,50,0,1)), radial-gradient(circle at 30% 30%, rgba(255,255,255,0.1), transparent 80%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.1), transparent 80%)", + "bg:opacity": 0.8, + "bg:blendmode": "soft-light" + }, + "bg@twilight-mist": { + "display:name": "Twilight Mist", + "display:order": 2.9, + "bg": "linear-gradient(180deg, rgba(60,60,90,1) 0%, rgba(90,110,140,0.8) 40%, rgba(120,140,160,0.6) 70%, rgba(60,60,90,1) 100%), radial-gradient(circle at 30% 40%, rgba(255,255,255,0.15), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.1), transparent 70%)", + "bg:opacity": 0.9, + "bg:blendmode": "soft-light" + }, + "bg@duskhorizon": { + "display:name": "Dusk Horizon", + "display:order": 3.1, + "bg": "linear-gradient(0deg, rgba(128,0,0,1) 0%, rgba(204,85,0,0.7) 20%, rgba(255,140,0,0.6) 45%, rgba(160,90,160,0.5) 65%, rgba(60,60,120,1) 100%), radial-gradient(circle at 30% 30%, rgba(255,255,255,0.1), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.05), transparent 70%)", + "bg:opacity": 0.9, + "bg:blendmode": "overlay" + }, + "bg@tropical-radiance": { + "display:name": "Tropical Radiance", + "display:order": 3.3, + "bg": "linear-gradient(135deg, rgba(204, 51, 255, 0.9) 0%, rgba(255, 85, 153, 0.75) 30%, rgba(255, 51, 153, 0.65) 60%, rgba(204, 51, 255, 0.6) 80%, rgba(51, 102, 255, 0.5) 100%), radial-gradient(circle at 30% 40%, rgba(255,255,255,0.1), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.05), transparent 70%)", + "bg:opacity": 0.9, + "bg:blendmode": "overlay" + }, + "bg@twilight-ember": { + "display:name": "Twilight Ember", + "display:order": 3.5, + "bg": "linear-gradient(120deg,hsla(350, 65%, 57%, 1),hsla(30,60%,60%, .75), hsla(208,69%,50%,.15), hsl(230,60%,40%)),radial-gradient(at top right,hsla(300,60%,70%,0.3),transparent),radial-gradient(at top left,hsla(330,100%,70%,.20),transparent),radial-gradient(at top right,hsla(190,100%,40%,.20),transparent),radial-gradient(at bottom left,hsla(323,54%,50%,.5),transparent),radial-gradient(at bottom left,hsla(144,54%,50%,.25),transparent)", + "bg:blendmode": "overlay", + "bg:text": "rgb(200, 200, 200)" + }, + "bg@cosmic-tide": { + "display:name": "Cosmic Tide", + "display:order": 3.6, + "bg:activebordercolor": "#ff55aa", + "bg": "linear-gradient(135deg, #00d9d9, #ff55aa, #1e1e2f, #2f3b57, #ff99ff)", + "bg:opacity": 0.6 + } +} diff --git a/pkg/wconfig/defaultconfig/presets.json b/pkg/wconfig/defaultconfig/presets.json index 3d7cc1ad3b..0967ef424b 100644 --- a/pkg/wconfig/defaultconfig/presets.json +++ b/pkg/wconfig/defaultconfig/presets.json @@ -1,108 +1 @@ -{ - "bg@default": { - "display:name": "Default", - "display:order": -1, - "bg:*": true - }, - "bg@rainbow": { - "display:name": "Rainbow", - "display:order": 2.1, - "bg:*": true, - "bg": "linear-gradient( 226.4deg, rgba(255,26,1,1) 28.9%, rgba(254,155,1,1) 33%, rgba(255,241,0,1) 48.6%, rgba(34,218,1,1) 65.3%, rgba(0,141,254,1) 80.6%, rgba(113,63,254,1) 100.1% )", - "bg:opacity": 0.3 - }, - "bg@green": { - "display:name": "Green", - "display:order": 1.2, - "bg:*": true, - "bg": "green", - "bg:opacity": 0.3 - }, - "bg@blue": { - "display:name": "Blue", - "display:order": 1.1, - "bg:*": true, - "bg": "blue", - "bg:opacity": 0.3, - "bg:activebordercolor": "rgba(0, 0, 255, 1.0)" - }, - "bg@red": { - "display:name": "Red", - "display:order": 1.3, - "bg:*": true, - "bg": "red", - "bg:opacity": 0.3, - "bg:activebordercolor": "rgba(255, 0, 0, 1.0)" - }, - "bg@ocean-depths": { - "display:name": "Ocean Depths", - "display:order": 2.2, - "bg:*": true, - "bg": "linear-gradient(135deg, purple, blue, teal)", - "bg:opacity": 0.7 - }, - "bg@aqua-horizon": { - "display:name": "Aqua Horizon", - "display:order": 2.3, - "bg:*": true, - "bg": "linear-gradient(135deg, rgba(15, 30, 50, 1) 0%, rgba(40, 90, 130, 0.85) 30%, rgba(20, 100, 150, 0.75) 60%, rgba(0, 120, 160, 0.65) 80%, rgba(0, 140, 180, 0.55) 100%), linear-gradient(135deg, rgba(100, 80, 255, 0.4), rgba(0, 180, 220, 0.4)), radial-gradient(circle at 70% 70%, rgba(255, 255, 255, 0.05), transparent 70%)", - "bg:opacity": 0.85, - "bg:blendmode": "overlay" - }, - "bg@sunset": { - "display:name": "Sunset", - "display:order": 2.4, - "bg:*": true, - "bg": "linear-gradient(135deg, rgba(128, 0, 0, 1), rgba(255, 69, 0, 0.8), rgba(75, 0, 130, 1))", - "bg:opacity": 0.8, - "bg:blendmode": "normal" - }, - "bg@enchantedforest": { - "display:name": "Enchanted Forest", - "display:order": 2.7, - "bg:*": true, - "bg": "linear-gradient(145deg, rgba(0,50,0,1), rgba(34,139,34,0.7) 20%, rgba(0,100,0,0.5) 40%, rgba(0,200,100,0.3) 60%, rgba(34,139,34,0.8) 80%, rgba(0,50,0,1)), radial-gradient(circle at 30% 30%, rgba(255,255,255,0.1), transparent 80%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.1), transparent 80%)", - "bg:opacity": 0.8, - "bg:blendmode": "soft-light" - }, - "bg@twilight-mist": { - "display:name": "Twilight Mist", - "display:order": 2.9, - "bg:*": true, - "bg": "linear-gradient(180deg, rgba(60,60,90,1) 0%, rgba(90,110,140,0.8) 40%, rgba(120,140,160,0.6) 70%, rgba(60,60,90,1) 100%), radial-gradient(circle at 30% 40%, rgba(255,255,255,0.15), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.1), transparent 70%)", - "bg:opacity": 0.9, - "bg:blendmode": "soft-light" - }, - "bg@duskhorizon": { - "display:name": "Dusk Horizon", - "display:order": 3.1, - "bg:*": true, - "bg": "linear-gradient(0deg, rgba(128,0,0,1) 0%, rgba(204,85,0,0.7) 20%, rgba(255,140,0,0.6) 45%, rgba(160,90,160,0.5) 65%, rgba(60,60,120,1) 100%), radial-gradient(circle at 30% 30%, rgba(255,255,255,0.1), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.05), transparent 70%)", - "bg:opacity": 0.9, - "bg:blendmode": "overlay" - }, - "bg@tropical-radiance": { - "display:name": "Tropical Radiance", - "display:order": 3.3, - "bg:*": true, - "bg": "linear-gradient(135deg, rgba(204, 51, 255, 0.9) 0%, rgba(255, 85, 153, 0.75) 30%, rgba(255, 51, 153, 0.65) 60%, rgba(204, 51, 255, 0.6) 80%, rgba(51, 102, 255, 0.5) 100%), radial-gradient(circle at 30% 40%, rgba(255,255,255,0.1), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.05), transparent 70%)", - "bg:opacity": 0.9, - "bg:blendmode": "overlay" - }, - "bg@twilight-ember": { - "display:name": "Twilight Ember", - "display:order": 3.5, - "bg:*": true, - "bg": "linear-gradient(120deg,hsla(350, 65%, 57%, 1),hsla(30,60%,60%, .75), hsla(208,69%,50%,.15), hsl(230,60%,40%)),radial-gradient(at top right,hsla(300,60%,70%,0.3),transparent),radial-gradient(at top left,hsla(330,100%,70%,.20),transparent),radial-gradient(at top right,hsla(190,100%,40%,.20),transparent),radial-gradient(at bottom left,hsla(323,54%,50%,.5),transparent),radial-gradient(at bottom left,hsla(144,54%,50%,.25),transparent)", - "bg:blendmode": "overlay", - "bg:text": "rgb(200, 200, 200)" - }, - "bg@cosmic-tide": { - "display:name": "Cosmic Tide", - "display:order": 3.6, - "bg:activebordercolor": "#ff55aa", - "bg:*": true, - "bg": "linear-gradient(135deg, #00d9d9, #ff55aa, #1e1e2f, #2f3b57, #ff99ff)", - "bg:opacity": 0.6 - } -} +{} diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index ab10987fc9..8ed6af7235 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -4,6 +4,7 @@ "ai:maxtokens": 4000, "ai:timeoutms": 60000, "app:defaultnewblock": "term", + "app:tabbar": "top", "app:confirmquit": true, "app:hideaibutton": false, "app:disablectrlshiftarrows": false, diff --git a/pkg/wconfig/defaultconfig/widgets.json b/pkg/wconfig/defaultconfig/widgets.json index 97a3d26c10..2d0524b7dd 100644 --- a/pkg/wconfig/defaultconfig/widgets.json +++ b/pkg/wconfig/defaultconfig/widgets.json @@ -31,18 +31,8 @@ } } }, - "defwidget@ai": { - "display:order": -2, - "icon": "sparkles", - "label": "ai", - "blockdef": { - "meta": { - "view": "waveai" - } - } - }, "defwidget@sysinfo": { - "display:order": -1, + "display:order": -2, "icon": "chart-line", "label": "sysinfo", "blockdef": { diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 084dab1793..df048b304c 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -17,6 +17,7 @@ const ( ConfigKey_AppDisableCtrlShiftArrows = "app:disablectrlshiftarrows" ConfigKey_AppDisableCtrlShiftDisplay = "app:disablectrlshiftdisplay" ConfigKey_AppFocusFollowsCursor = "app:focusfollowscursor" + ConfigKey_AppTabBar = "app:tabbar" ConfigKey_FeatureWaveAppBuilder = "feature:waveappbuilder" @@ -84,6 +85,7 @@ const ( ConfigKey_TabPreset = "tab:preset" ConfigKey_TabConfirmClose = "tab:confirmclose" + ConfigKey_TabBackground = "tab:background" ConfigKey_WidgetClear = "widget:*" ConfigKey_WidgetShowHelp = "widget:showhelp" @@ -120,6 +122,7 @@ const ( ConfigKey_DebugClear = "debug:*" ConfigKey_DebugPprofPort = "debug:pprofport" ConfigKey_DebugPprofMemProfileRate = "debug:pprofmemprofilerate" + ConfigKey_DebugWebGlStatus = "debug:webglstatus" ConfigKey_TsunamiClear = "tsunami:*" ConfigKey_TsunamiScaffoldPath = "tsunami:scaffoldpath" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 17aafa6685..b55cab8cbf 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -68,6 +68,7 @@ type SettingsType struct { AppDisableCtrlShiftArrows bool `json:"app:disablectrlshiftarrows,omitempty"` AppDisableCtrlShiftDisplay bool `json:"app:disablectrlshiftdisplay,omitempty"` AppFocusFollowsCursor string `json:"app:focusfollowscursor,omitempty" jsonschema:"enum=off,enum=on,enum=term"` + AppTabBar string `json:"app:tabbar,omitempty" jsonschema:"enum=top,enum=left"` FeatureWaveAppBuilder bool `json:"feature:waveappbuilder,omitempty"` @@ -135,6 +136,7 @@ type SettingsType struct { TabPreset string `json:"tab:preset,omitempty"` TabConfirmClose bool `json:"tab:confirmclose,omitempty"` + TabBackground string `json:"tab:background,omitempty"` WidgetClear bool `json:"widget:*,omitempty"` WidgetShowHelp *bool `json:"widget:showhelp,omitempty"` @@ -171,6 +173,7 @@ type SettingsType struct { DebugClear bool `json:"debug:*,omitempty"` DebugPprofPort *int `json:"debug:pprofport,omitempty"` DebugPprofMemProfileRate *int `json:"debug:pprofmemprofilerate,omitempty"` + DebugWebGlStatus bool `json:"debug:webglstatus,omitempty"` TsunamiClear bool `json:"tsunami:*,omitempty"` TsunamiScaffoldPath string `json:"tsunami:scaffoldpath,omitempty"` @@ -306,17 +309,72 @@ type AIModeConfigUpdate struct { Configs map[string]AIModeConfigType `json:"configs"` } +type WidgetConfigType struct { + DisplayOrder float64 `json:"display:order,omitempty"` + DisplayHidden bool `json:"display:hidden,omitempty"` + Icon string `json:"icon,omitempty"` + Color string `json:"color,omitempty"` + Label string `json:"label,omitempty"` + Description string `json:"description,omitempty"` + Workspaces []string `json:"workspaces,omitempty"` + Magnified bool `json:"magnified,omitempty"` + BlockDef waveobj.BlockDef `json:"blockdef"` +} + +type BackgroundConfigType struct { + Bg string `json:"bg,omitempty" jsonschema_description:"CSS background property value"` + BgOpacity float64 `json:"bg:opacity,omitempty" jsonschema_description:"Background opacity (0.0-1.0)"` + BgBlendMode string `json:"bg:blendmode,omitempty" jsonschema_description:"CSS background-blend-mode property value"` + BgBorderColor string `json:"bg:bordercolor,omitempty" jsonschema_description:"Block frame border color"` + BgActiveBorderColor string `json:"bg:activebordercolor,omitempty" jsonschema_description:"Block frame focused border color"` + DisplayName string `json:"display:name" jsonschema_description:"The name shown in the context menu"` + DisplayOrder float64 `json:"display:order,omitempty" jsonschema_description:"Determines the order of the background in the context menu"` +} + +type MimeTypeConfigType struct { + Icon string `json:"icon"` + Color string `json:"color"` +} + +type TermThemeType struct { + DisplayName string `json:"display:name"` + DisplayOrder float64 `json:"display:order"` + Black string `json:"black"` + Red string `json:"red"` + Green string `json:"green"` + Yellow string `json:"yellow"` + Blue string `json:"blue"` + Magenta string `json:"magenta"` + Cyan string `json:"cyan"` + White string `json:"white"` + BrightBlack string `json:"brightBlack"` + BrightRed string `json:"brightRed"` + BrightGreen string `json:"brightGreen"` + BrightYellow string `json:"brightYellow"` + BrightBlue string `json:"brightBlue"` + BrightMagenta string `json:"brightMagenta"` + BrightCyan string `json:"brightCyan"` + BrightWhite string `json:"brightWhite"` + Gray string `json:"gray"` + CmdText string `json:"cmdtext"` + Foreground string `json:"foreground"` + SelectionBackground string `json:"selectionBackground"` + Background string `json:"background"` + Cursor string `json:"cursor"` +} + type FullConfigType struct { - Settings SettingsType `json:"settings" merge:"meta"` - MimeTypes map[string]MimeTypeConfigType `json:"mimetypes"` - DefaultWidgets map[string]WidgetConfigType `json:"defaultwidgets"` - Widgets map[string]WidgetConfigType `json:"widgets"` - Presets map[string]waveobj.MetaMapType `json:"presets"` - TermThemes map[string]TermThemeType `json:"termthemes"` - Connections map[string]ConnKeywords `json:"connections"` - Bookmarks map[string]WebBookmark `json:"bookmarks"` - WaveAIModes map[string]AIModeConfigType `json:"waveai"` - ConfigErrors []ConfigError `json:"configerrors" configfile:"-"` + Settings SettingsType `json:"settings" merge:"meta"` + MimeTypes map[string]MimeTypeConfigType `json:"mimetypes"` + DefaultWidgets map[string]WidgetConfigType `json:"defaultwidgets"` + Widgets map[string]WidgetConfigType `json:"widgets"` + Presets map[string]waveobj.MetaMapType `json:"presets"` + Backgrounds map[string]BackgroundConfigType `json:"backgrounds"` + TermThemes map[string]TermThemeType `json:"termthemes"` + Connections map[string]ConnKeywords `json:"connections"` + Bookmarks map[string]WebBookmark `json:"bookmarks"` + WaveAIModes map[string]AIModeConfigType `json:"waveai"` + ConfigErrors []ConfigError `json:"configerrors" configfile:"-"` } type ConnKeywords struct { @@ -835,59 +893,47 @@ func SetConnectionsConfigValue(connName string, toMerge waveobj.MetaMapType) err return WriteWaveHomeConfigFile(ConnectionsFile, m) } -type WidgetConfigType struct { - DisplayOrder float64 `json:"display:order,omitempty"` - DisplayHidden bool `json:"display:hidden,omitempty"` - Icon string `json:"icon,omitempty"` - Color string `json:"color,omitempty"` - Label string `json:"label,omitempty"` - Description string `json:"description,omitempty"` - Workspaces []string `json:"workspaces,omitempty"` - Magnified bool `json:"magnified,omitempty"` - BlockDef waveobj.BlockDef `json:"blockdef"` -} - -type BgPresetsType struct { - BgClear bool `json:"bg:*,omitempty"` - Bg string `json:"bg,omitempty" jsonschema_description:"CSS background property value"` - BgOpacity float64 `json:"bg:opacity,omitempty" jsonschema_description:"Background opacity (0.0-1.0)"` - BgBlendMode string `json:"bg:blendmode,omitempty" jsonschema_description:"CSS background-blend-mode property value"` - BgBorderColor string `json:"bg:bordercolor,omitempty" jsonschema_description:"Block frame border color"` - BgActiveBorderColor string `json:"bg:activebordercolor,omitempty" jsonschema_description:"Block frame focused border color"` - DisplayName string `json:"display:name,omitempty" jsonschema_description:"The name shown in the context menu"` - DisplayOrder float64 `json:"display:order,omitempty" jsonschema_description:"Determines the order of the background in the context menu"` -} - -type MimeTypeConfigType struct { - Icon string `json:"icon"` - Color string `json:"color"` -} - -type TermThemeType struct { - DisplayName string `json:"display:name"` - DisplayOrder float64 `json:"display:order"` - Black string `json:"black"` - Red string `json:"red"` - Green string `json:"green"` - Yellow string `json:"yellow"` - Blue string `json:"blue"` - Magenta string `json:"magenta"` - Cyan string `json:"cyan"` - White string `json:"white"` - BrightBlack string `json:"brightBlack"` - BrightRed string `json:"brightRed"` - BrightGreen string `json:"brightGreen"` - BrightYellow string `json:"brightYellow"` - BrightBlue string `json:"brightBlue"` - BrightMagenta string `json:"brightMagenta"` - BrightCyan string `json:"brightCyan"` - BrightWhite string `json:"brightWhite"` - Gray string `json:"gray"` - CmdText string `json:"cmdtext"` - Foreground string `json:"foreground"` - SelectionBackground string `json:"selectionBackground"` - Background string `json:"background"` - Cursor string `json:"cursor"` +func MigratePresetsBackgrounds() { + configDirAbsPath := wavebase.GetWaveConfigDir() + backgroundsFile := filepath.Join(configDirAbsPath, "backgrounds.json") + if _, err := os.Stat(backgroundsFile); err == nil { + return + } else if !os.IsNotExist(err) { + log.Printf("error checking backgrounds.json during migration: %v\n", err) + return + } + bgFile := filepath.Join(configDirAbsPath, "presets", "bg.json") + bgData, err := os.ReadFile(bgFile) + if err != nil { + if !os.IsNotExist(err) { + log.Printf("error reading presets/bg.json for migration: %v\n", err) + } + return + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(bgData, &rawMap); err != nil { + log.Printf("error parsing presets/bg.json for migration: %v\n", err) + return + } + filtered := make(map[string]json.RawMessage) + for k, v := range rawMap { + if strings.HasPrefix(k, "bg@") { + filtered[k] = v + } + } + if len(filtered) == 0 { + return + } + outBarr, err := json.MarshalIndent(filtered, "", " ") + if err != nil { + log.Printf("error marshaling backgrounds.json during migration: %v\n", err) + return + } + if err := fileutil.AtomicWriteFile(backgroundsFile, outBarr, 0644); err != nil { + log.Printf("error writing backgrounds.json during migration: %v\n", err) + return + } + log.Printf("migrated %d background presets from presets/bg.json to backgrounds.json\n", len(filtered)) } // CountCustomWidgets returns the number of custom widgets the user has defined. diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index b070d31107..c01e509a13 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -187,14 +187,12 @@ func GetWorkspace(ctx context.Context, wsID string) (*waveobj.Workspace, error) return wstore.DBMustGet[*waveobj.Workspace](ctx, wsID) } -func getTabPresetMeta() (waveobj.MetaMapType, error) { - settings := wconfig.GetWatcher().GetFullConfig() - tabPreset := settings.Settings.TabPreset - if tabPreset == "" { - return nil, nil - } - presetMeta := settings.Presets[tabPreset] - return presetMeta, nil +func getTabBackground() string { + config := wconfig.GetWatcher().GetFullConfig() + if config.Settings.TabBackground != "" { + return config.Settings.TabBackground + } + return config.Settings.TabPreset } var tabNameRe = regexp.MustCompile(`^T(\d+)$`) @@ -256,12 +254,10 @@ func CreateTab(ctx context.Context, workspaceId string, tabName string, activate if err != nil { return tab.OID, fmt.Errorf("error applying new tab layout: %w", err) } - presetMeta, presetErr := getTabPresetMeta() - if presetErr != nil { - log.Printf("error getting tab preset meta: %v\n", presetErr) - } else if len(presetMeta) > 0 { + tabBg := getTabBackground() + if tabBg != "" { tabORef := waveobj.ORefFromWaveObj(tab) - wstore.UpdateObjectMeta(ctx, *tabORef, presetMeta, true) + wstore.UpdateObjectMeta(ctx, *tabORef, waveobj.MetaMapType{waveobj.MetaKey_TabBackground: tabBg}, false) } } telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NewTab: 1}, "createtab") diff --git a/pkg/web/web.go b/pkg/web/web.go index 52e48ab48c..106db981e4 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -4,8 +4,6 @@ package web import ( - "bytes" - "context" "encoding/base64" "encoding/json" "fmt" @@ -17,6 +15,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "time" "github.com/google/uuid" @@ -28,12 +27,10 @@ import ( "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" "github.com/wavetermdev/waveterm/pkg/schema" "github.com/wavetermdev/waveterm/pkg/service" - "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver" - "github.com/wavetermdev/waveterm/pkg/wshutil" ) type WebFnType = func(http.ResponseWriter, *http.Request) @@ -220,6 +217,7 @@ func serveTransparentGIF(w http.ResponseWriter) { } func handleLocalStreamFile(w http.ResponseWriter, r *http.Request, path string, no404 bool) { + http.NewResponseController(w).SetWriteDeadline(time.Time{}) if no404 { log.Printf("streaming file w/no404: %q\n", path) // use the custom response writer @@ -246,78 +244,84 @@ func handleLocalStreamFile(w http.ResponseWriter, r *http.Request, path string, } } -func handleRemoteStreamFile(w http.ResponseWriter, req *http.Request, conn string, path string, no404 bool) error { - client := wshserver.GetMainRpcClient() - streamFileData := wshrpc.CommandRemoteStreamFileData{Path: path} - route := wshutil.MakeConnectionRouteId(conn) - rpcOpts := &wshrpc.RpcOpts{Route: route, Timeout: 60 * 1000} - rtnCh := wshclient.RemoteStreamFileCommand(client, streamFileData, rpcOpts) - return handleRemoteStreamFileFromCh(w, req, path, rtnCh, rpcOpts.StreamCancelFn, no404) -} +func handleStreamFileFromReader(w http.ResponseWriter, r *http.Request, path string, no404 bool) error { + startTime := time.Now() + rangeHeader := r.Header.Get("Range") + log.Printf("stream-file path=%q range=%q\n", path, rangeHeader) -func handleRemoteStreamFileFromCh(w http.ResponseWriter, req *http.Request, path string, rtnCh <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData], streamCancelFn func(context.Context) error, no404 bool) error { - firstPk := true - var fileInfo *wshrpc.FileInfo - loopDone := false - defer func() { - if loopDone { - return + writerRouteId, err := wshfs.GetConnectionRouteId(r.Context(), path) + if err != nil { + return err + } + + byteRange := "" + if rangeHeader != "" { + stripped := strings.TrimPrefix(rangeHeader, "bytes=") + br, parseErr := fileutil.ParseByteRange(stripped) + if parseErr != nil || br.All { + http.Error(w, "invalid range", http.StatusRequestedRangeNotSatisfiable) + return nil } - // if loop didn't finish naturally clear it out - utilfn.DrainChannelSafe(rtnCh, "handleRemoteStreamFile") + byteRange = stripped + } + + bareRpc := wshclient.GetBareRpcClient() + readerRouteId := wshclient.GetBareRpcClientRouteId() + reader, streamMeta := bareRpc.StreamBroker.CreateStreamReader(readerRouteId, writerRouteId, 256*1024) + defer reader.Close() + go func() { + <-r.Context().Done() + reader.Close() }() - ctx := req.Context() - for { - select { - case <-ctx.Done(): - if streamCancelFn != nil { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - streamCancelFn(ctx) - } - return ctx.Err() - case respUnion, ok := <-rtnCh: - if !ok { - loopDone = true - return nil - } - if respUnion.Error != nil { - return respUnion.Error - } - if firstPk { - firstPk = false - if respUnion.Response.Info == nil { - return fmt.Errorf("stream file protocol error, fileinfo is empty") - } - fileInfo = respUnion.Response.Info - if fileInfo.NotFound { - if no404 { - serveTransparentGIF(w) - return nil - } else { - return fmt.Errorf("file not found: %q", path) - } - } - if fileInfo.IsDir { - return fmt.Errorf("cannot stream directory: %q", path) - } - w.Header().Set(ContentTypeHeaderKey, fileInfo.MimeType) - w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", fileInfo.Size)) - continue - } - if respUnion.Response.Data64 == "" { - continue - } - decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader([]byte(respUnion.Response.Data64))) - _, err := io.Copy(w, decoder) - if err != nil { - log.Printf("error streaming file %q: %v\n", path, err) - // not sure what to do here, the headers have already been sent. - // just return - return nil - } + + data := wshrpc.CommandFileStreamData{ + Info: &wshrpc.FileInfo{Path: path}, + ByteRange: byteRange, + StreamMeta: *streamMeta, + } + fileInfo, err := wshfs.FileStream(r.Context(), data) + if err != nil { + if no404 { + serveTransparentGIF(w) + return nil + } + return err + } + if fileInfo.NotFound { + if no404 { + serveTransparentGIF(w) + return nil + } + http.Error(w, fmt.Sprintf("file not found: %q", path), http.StatusNotFound) + return nil + } + if fileInfo.IsDir { + http.Error(w, fmt.Sprintf("cannot stream directory: %q", path), http.StatusBadRequest) + return nil + } + log.Printf("stream-file headers-ready path=%q time-to-headers=%v\n", path, time.Since(startTime)) + w.Header().Set(ContentTypeHeaderKey, fileInfo.MimeType) + w.Header().Set("Accept-Ranges", "bytes") + if byteRange != "" { + br, _ := fileutil.ParseByteRange(byteRange) + var rangeEnd int64 + if br.OpenEnd { + rangeEnd = fileInfo.Size - 1 + } else { + rangeEnd = br.End } + w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", rangeEnd-br.Start+1)) + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", br.Start, rangeEnd, fileInfo.Size)) + w.WriteHeader(http.StatusPartialContent) + } else { + w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", fileInfo.Size)) + } + http.NewResponseController(w).SetWriteDeadline(time.Time{}) + _, copyErr := io.Copy(w, reader) + if copyErr != nil && r.Context().Err() == nil { + log.Printf("error streaming file %q: %v\n", path, copyErr) } + return nil } func handleStreamLocalFile(w http.ResponseWriter, r *http.Request) { @@ -338,13 +342,7 @@ func handleStreamFile(w http.ResponseWriter, r *http.Request) { } no404 := r.URL.Query().Get("no404") // path should already be formatted as a wsh:// URI (e.g. wsh://local/path or wsh://connection/path) - data := wshrpc.FileData{ - Info: &wshrpc.FileInfo{ - Path: path, - }, - } - rtnCh := wshfs.ReadStream(r.Context(), data) - err := handleRemoteStreamFileFromCh(w, r, path, rtnCh, nil, no404 != "") + err := handleStreamFileFromReader(w, r, path, no404 != "") if err != nil { log.Printf("error streaming file %q: %v\n", path, err) http.Error(w, fmt.Sprintf("error streaming file: %v", err), http.StatusInternalServerError) @@ -448,11 +446,15 @@ const schemaPrefix = "/schema/" func RunWebServer(listener net.Listener) { gr := mux.NewRouter() - // Create separate routers for different timeout requirements + // Streaming routes must be registered before the /wave/ prefix catch-all to bypass TimeoutHandler. + // http.TimeoutHandler buffers the entire response before flushing, which stalls streaming. + gr.HandleFunc("/wave/stream-local-file", WebFnWrap(WebFnOpts{AllowCaching: true}, handleStreamLocalFile)) + gr.HandleFunc("/wave/stream-file", WebFnWrap(WebFnOpts{AllowCaching: true}, handleStreamFile)) + gr.PathPrefix("/wave/stream-file/").HandlerFunc(WebFnWrap(WebFnOpts{AllowCaching: true}, handleStreamFile)) + gr.HandleFunc("/api/post-chat-message", WebFnWrap(WebFnOpts{AllowCaching: false}, aiusechat.WaveAIPostMessageHandler)) + + // Non-streaming /wave/ routes get timeout protection waveRouter := mux.NewRouter() - waveRouter.HandleFunc("/wave/stream-local-file", WebFnWrap(WebFnOpts{AllowCaching: true}, handleStreamLocalFile)) - waveRouter.HandleFunc("/wave/stream-file", WebFnWrap(WebFnOpts{AllowCaching: true}, handleStreamFile)) - waveRouter.PathPrefix("/wave/stream-file/").HandlerFunc(WebFnWrap(WebFnOpts{AllowCaching: true}, handleStreamFile)) waveRouter.HandleFunc("/wave/file", WebFnWrap(WebFnOpts{AllowCaching: false}, handleWaveFile)) waveRouter.HandleFunc("/wave/service", WebFnWrap(WebFnOpts{JsonErrors: true}, handleService)) waveRouter.HandleFunc("/wave/aichat", WebFnWrap(WebFnOpts{JsonErrors: true, AllowCaching: false}, aiusechat.WaveAIGetChatHandler)) @@ -460,13 +462,9 @@ func RunWebServer(listener net.Listener) { vdomRouter := mux.NewRouter() vdomRouter.HandleFunc("/vdom/{uuid}/{path:.*}", WebFnWrap(WebFnOpts{AllowCaching: true}, handleVDom)) - // Routes that need timeout handling gr.PathPrefix("/wave/").Handler(http.TimeoutHandler(waveRouter, HttpTimeoutDuration, "Timeout")) gr.PathPrefix("/vdom/").Handler(http.TimeoutHandler(vdomRouter, HttpTimeoutDuration, "Timeout")) - // Routes that should NOT have timeout handling (for streaming) - gr.HandleFunc("/api/post-chat-message", WebFnWrap(WebFnOpts{AllowCaching: false}, aiusechat.WaveAIPostMessageHandler)) - // Other routes without timeout gr.PathPrefix(schemaPrefix).Handler(http.StripPrefix(schemaPrefix, schema.GetSchemaHandler())) diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 110e1695ef..2968baa8d7 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -364,17 +364,18 @@ func FileReadCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOp return resp, err } -// command "filereadstream", wshserver.FileReadStreamCommand -func FileReadStreamCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.FileData] { - return sendRpcRequestResponseStreamHelper[wshrpc.FileData](w, "filereadstream", data, opts) -} - // command "filerestorebackup", wshserver.FileRestoreBackupCommand func FileRestoreBackupCommand(w *wshutil.WshRpc, data wshrpc.CommandFileRestoreBackupData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "filerestorebackup", data, opts) return err } +// command "filestream", wshserver.FileStreamCommand +func FileStreamCommand(w *wshutil.WshRpc, data wshrpc.CommandFileStreamData, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, "filestream", data, opts) + return resp, err +} + // command "filewrite", wshserver.FileWriteCommand func FileWriteCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "filewrite", data, opts) @@ -615,6 +616,12 @@ func ListAllEditableAppsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshr return resp, err } +// command "macosversion", wshserver.MacOSVersionCommand +func MacOSVersionCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { + resp, err := sendRpcRequestCallHelper[string](w, "macosversion", nil, opts) + return resp, err +} + // command "makedraftfromlocal", wshserver.MakeDraftFromLocalCommand func MakeDraftFromLocalCommand(w *wshutil.WshRpc, data wshrpc.CommandMakeDraftFromLocalData, opts *wshrpc.RpcOpts) (*wshrpc.CommandMakeDraftFromLocalRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandMakeDraftFromLocalRtnData](w, "makedraftfromlocal", data, opts) @@ -711,6 +718,12 @@ func RemoteFileMultiInfoCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteFile return resp, err } +// command "remotefilestream", wshserver.RemoteFileStreamCommand +func RemoteFileStreamCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteFileStreamData, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, "remotefilestream", data, opts) + return resp, err +} + // command "remotefiletouch", wshserver.RemoteFileTouchCommand func RemoteFileTouchCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "remotefiletouch", data, opts) @@ -757,11 +770,6 @@ func RemoteStreamCpuDataCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) chan ws return sendRpcRequestResponseStreamHelper[wshrpc.TimeSeriesData](w, "remotestreamcpudata", nil, opts) } -// command "remotestreamfile", wshserver.RemoteStreamFileCommand -func RemoteStreamFileCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteStreamFileData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.FileData] { - return sendRpcRequestResponseStreamHelper[wshrpc.FileData](w, "remotestreamfile", data, opts) -} - // command "remoteterminatejobmanager", wshserver.RemoteTerminateJobManagerCommand func RemoteTerminateJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteTerminateJobManagerData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "remoteterminatejobmanager", data, opts) diff --git a/pkg/wshrpc/wshremote/wshremote_file.go b/pkg/wshrpc/wshremote/wshremote_file.go index 015e976b32..3589cc998c 100644 --- a/pkg/wshrpc/wshremote/wshremote_file.go +++ b/pkg/wshrpc/wshremote/wshremote_file.go @@ -16,8 +16,9 @@ import ( "strings" "time" + "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote/connparse" - "github.com/wavetermdev/waveterm/pkg/remote/fileshare/fsutil" + "github.com/wavetermdev/waveterm/pkg/remote/fileshare/fspath" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" @@ -31,165 +32,6 @@ const RemoteFileTransferSizeLimit = 32 * 1024 * 1024 var DisableRecursiveFileOpts = true -type ByteRangeType struct { - All bool - Start int64 - End int64 -} - -func parseByteRange(rangeStr string) (ByteRangeType, error) { - if rangeStr == "" { - return ByteRangeType{All: true}, nil - } - var start, end int64 - _, err := fmt.Sscanf(rangeStr, "%d-%d", &start, &end) - if err != nil { - return ByteRangeType{}, errors.New("invalid byte range") - } - if start < 0 || end < 0 || start > end { - return ByteRangeType{}, errors.New("invalid byte range") - } - return ByteRangeType{Start: start, End: end}, nil -} - -func (impl *ServerImpl) remoteStreamFileDir(ctx context.Context, path string, byteRange ByteRangeType, dataCallback func(fileInfo []*wshrpc.FileInfo, data []byte, byteRange ByteRangeType)) error { - innerFilesEntries, err := os.ReadDir(path) - if err != nil { - return fmt.Errorf("cannot open dir %q: %w", path, err) - } - if byteRange.All { - if len(innerFilesEntries) > wshrpc.MaxDirSize { - innerFilesEntries = innerFilesEntries[:wshrpc.MaxDirSize] - } - } else { - if byteRange.Start < int64(len(innerFilesEntries)) { - realEnd := byteRange.End - if realEnd > int64(len(innerFilesEntries)) { - realEnd = int64(len(innerFilesEntries)) - } - innerFilesEntries = innerFilesEntries[byteRange.Start:realEnd] - } else { - innerFilesEntries = []os.DirEntry{} - } - } - var fileInfoArr []*wshrpc.FileInfo - for _, innerFileEntry := range innerFilesEntries { - if ctx.Err() != nil { - return ctx.Err() - } - innerFileInfoInt, err := innerFileEntry.Info() - if err != nil { - continue - } - innerFileInfo := statToFileInfo(filepath.Join(path, innerFileInfoInt.Name()), innerFileInfoInt, false) - fileInfoArr = append(fileInfoArr, innerFileInfo) - if len(fileInfoArr) >= wshrpc.DirChunkSize { - dataCallback(fileInfoArr, nil, byteRange) - fileInfoArr = nil - } - } - if len(fileInfoArr) > 0 { - dataCallback(fileInfoArr, nil, byteRange) - } - return nil -} - -func (impl *ServerImpl) remoteStreamFileRegular(ctx context.Context, path string, byteRange ByteRangeType, dataCallback func(fileInfo []*wshrpc.FileInfo, data []byte, byteRange ByteRangeType)) error { - fd, err := os.Open(path) - if err != nil { - return fmt.Errorf("cannot open file %q: %w", path, err) - } - defer utilfn.GracefulClose(fd, "remoteStreamFileRegular", path) - var filePos int64 - if !byteRange.All && byteRange.Start > 0 { - _, err := fd.Seek(byteRange.Start, io.SeekStart) - if err != nil { - return fmt.Errorf("seeking file %q: %w", path, err) - } - filePos = byteRange.Start - } - buf := make([]byte, wshrpc.FileChunkSize) - for { - if ctx.Err() != nil { - return ctx.Err() - } - n, err := fd.Read(buf) - if n > 0 { - if !byteRange.All && filePos+int64(n) > byteRange.End { - n = int(byteRange.End - filePos) - } - filePos += int64(n) - dataCallback(nil, buf[:n], byteRange) - } - if !byteRange.All && filePos >= byteRange.End { - break - } - if errors.Is(err, io.EOF) { - break - } - if err != nil { - return fmt.Errorf("reading file %q: %w", path, err) - } - } - return nil -} - -func (impl *ServerImpl) remoteStreamFileInternal(ctx context.Context, data wshrpc.CommandRemoteStreamFileData, dataCallback func(fileInfo []*wshrpc.FileInfo, data []byte, byteRange ByteRangeType)) error { - byteRange, err := parseByteRange(data.ByteRange) - if err != nil { - return err - } - path, err := wavebase.ExpandHomeDir(data.Path) - if err != nil { - return err - } - finfo, err := impl.fileInfoInternal(path, true) - if err != nil { - return fmt.Errorf("cannot stat file %q: %w", path, err) - } - dataCallback([]*wshrpc.FileInfo{finfo}, nil, byteRange) - if finfo.NotFound { - return nil - } - if finfo.IsDir { - return impl.remoteStreamFileDir(ctx, path, byteRange, dataCallback) - } else { - if finfo.Size > RemoteFileTransferSizeLimit { - return fmt.Errorf("file %q size %d exceeds transfer limit of %d bytes", path, finfo.Size, RemoteFileTransferSizeLimit) - } - return impl.remoteStreamFileRegular(ctx, path, byteRange, dataCallback) - } -} - -func (impl *ServerImpl) RemoteStreamFileCommand(ctx context.Context, data wshrpc.CommandRemoteStreamFileData) chan wshrpc.RespOrErrorUnion[wshrpc.FileData] { - ch := make(chan wshrpc.RespOrErrorUnion[wshrpc.FileData], 16) - go func() { - defer close(ch) - firstPk := true - err := impl.remoteStreamFileInternal(ctx, data, func(fileInfo []*wshrpc.FileInfo, data []byte, byteRange ByteRangeType) { - resp := wshrpc.FileData{} - fileInfoLen := len(fileInfo) - if fileInfoLen > 1 || !firstPk { - resp.Entries = fileInfo - } else if fileInfoLen == 1 { - resp.Info = fileInfo[0] - } - if firstPk { - firstPk = false - } - if len(data) > 0 { - resp.Data64 = base64.StdEncoding.EncodeToString(data) - resp.At = &wshrpc.FileDataAt{Offset: byteRange.Start, Size: len(data)} - } - ch <- wshrpc.RespOrErrorUnion[wshrpc.FileData]{Response: resp} - }) - if err != nil { - ch <- wshutil.RespErr[wshrpc.FileData](err) - } - }() - return ch -} - // prepareDestForCopy resolves the final destination path and handles overwrite logic. // destPath is the raw destination path (may be a directory or file path). // srcBaseName is the basename of the source file (used when dest is a directory or ends with slash). @@ -315,7 +157,7 @@ func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.C return false, fmt.Errorf("file %q size %d exceeds transfer limit of %d bytes", data.SrcUri, srcFileInfo.Size, RemoteFileTransferSizeLimit) } - destFilePath, err := prepareDestForCopy(destPathCleaned, filepath.Base(srcConn.Path), destHasSlash, opts.Overwrite) + destFilePath, err := prepareDestForCopy(destPathCleaned, fspath.Base(srcConn.Path), destHasSlash, opts.Overwrite) if err != nil { return false, err } @@ -326,8 +168,25 @@ func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.C } defer destFile.Close() - streamChan := wshclient.RemoteStreamFileCommand(wshfs.RpcClient, wshrpc.CommandRemoteStreamFileData{Path: srcConn.Path}, &wshrpc.RpcOpts{Timeout: opts.Timeout, Route: wshutil.MakeConnectionRouteId(srcConn.Host)}) - if err = fsutil.ReadFileStreamToWriter(readCtx, streamChan, destFile); err != nil { + if wshfs.RpcClientRouteId == "" { + return false, fmt.Errorf("stream broker route id not available for file copy") + } + writerRouteId := wshutil.MakeConnectionRouteId(srcConn.Host) + reader, streamMeta := wshfs.RpcClient.StreamBroker.CreateStreamReader(wshfs.RpcClientRouteId, writerRouteId, 256*1024) + log.Printf("RemoteFileCopyCommand: readroute=%s writeroute=%s", streamMeta.ReaderRouteId, streamMeta.WriterRouteId) + defer reader.Close() + go func() { + <-readCtx.Done() + reader.Close() + }() + streamData := wshrpc.CommandRemoteFileStreamData{ + Path: srcConn.Path, + StreamMeta: *streamMeta, + } + if _, err = wshclient.RemoteFileStreamCommand(wshfs.RpcClient, streamData, &wshrpc.RpcOpts{Route: writerRouteId}); err != nil { + return false, fmt.Errorf("error starting file stream for %q: %w", data.SrcUri, err) + } + if _, err = io.Copy(destFile, reader); err != nil { return false, fmt.Errorf("error copying file %q to %q: %w", data.SrcUri, data.DestUri, err) } @@ -344,12 +203,18 @@ func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.C func (impl *ServerImpl) RemoteListEntriesCommand(ctx context.Context, data wshrpc.CommandRemoteListEntriesData) chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] { ch := make(chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData], 16) go func() { + defer func() { + panichandler.PanicHandler("RemoteListEntriesCommand", recover()) + }() defer close(ch) path, err := wavebase.ExpandHomeDir(data.Path) if err != nil { ch <- wshutil.RespErr[wshrpc.CommandRemoteListEntriesRtnData](err) return } + if data.Opts == nil { + data.Opts = &wshrpc.FileListOpts{} + } innerFilesEntries := []os.DirEntry{} seen := 0 if data.Opts.Limit == 0 { @@ -680,6 +545,89 @@ func (*ServerImpl) RemoteWriteFileCommand(ctx context.Context, data wshrpc.FileD return nil } +func (impl *ServerImpl) RemoteFileStreamCommand(ctx context.Context, data wshrpc.CommandRemoteFileStreamData) (*wshrpc.FileInfo, error) { + wshRpc := wshutil.GetWshRpcFromContext(ctx) + if wshRpc == nil || wshRpc.StreamBroker == nil { + return nil, fmt.Errorf("no stream broker available") + } + + writer, err := wshRpc.StreamBroker.CreateStreamWriter(&data.StreamMeta) + if err != nil { + return nil, fmt.Errorf("error creating stream writer: %w", err) + } + + path, err := wavebase.ExpandHomeDir(data.Path) + if err != nil { + writer.CloseWithError(err) + return nil, err + } + cleanedPath := filepath.Clean(path) + + finfo, err := os.Stat(cleanedPath) + if err != nil { + writer.CloseWithError(err) + return nil, fmt.Errorf("cannot stat file %q: %w", data.Path, err) + } + if finfo.IsDir() { + writer.CloseWithError(fmt.Errorf("path is a directory")) + return nil, fmt.Errorf("cannot stream directory %q", data.Path) + } + + byteRange, err := fileutil.ParseByteRange(data.ByteRange) + if err != nil { + writer.CloseWithError(err) + return nil, err + } + + fileInfo := statToFileInfo(cleanedPath, finfo, true) + fileInfo.Path = data.Path + + go func() { + defer func() { + panichandler.PanicHandler("RemoteFileStreamCommand", recover()) + }() + defer writer.Close() + + file, err := os.Open(cleanedPath) + if err != nil { + writer.CloseWithError(fmt.Errorf("cannot open file %q: %w", data.Path, err)) + return + } + defer utilfn.GracefulClose(file, "RemoteFileStreamCommand", cleanedPath) + + if !byteRange.All && byteRange.Start > 0 { + if _, err := file.Seek(byteRange.Start, io.SeekStart); err != nil { + writer.CloseWithError(fmt.Errorf("cannot seek in file %q: %w", data.Path, err)) + return + } + } + + var src io.Reader = file + if !byteRange.All && !byteRange.OpenEnd { + src = io.LimitReader(file, byteRange.End-byteRange.Start+1) + } + + buf := make([]byte, 32*1024) + for { + n, readErr := src.Read(buf) + if n > 0 { + if _, writeErr := writer.Write(buf[:n]); writeErr != nil { + return + } + } + if readErr == io.EOF { + return + } + if readErr != nil { + writer.CloseWithError(fmt.Errorf("error reading file %q: %w", data.Path, readErr)) + return + } + } + }() + + return fileInfo, nil +} + func (*ServerImpl) RemoteFileDeleteCommand(ctx context.Context, data wshrpc.CommandDeleteFileData) error { expandedPath, err := wavebase.ExpandHomeDir(data.Path) if err != nil { diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 8ddff8128b..2fee3e392e 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -83,6 +83,7 @@ type WshRpcInterface interface { DebugTermCommand(ctx context.Context, data CommandDebugTermData) (*CommandDebugTermRtnData, error) BlocksListCommand(ctx context.Context, data BlocksListRequest) ([]BlocksListEntry, error) WaveInfoCommand(ctx context.Context) (*WaveInfoData, error) + MacOSVersionCommand(ctx context.Context) (string, error) WshActivityCommand(ct context.Context, data map[string]int) error ActivityCommand(ctx context.Context, data ActivityUpdate) error RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error diff --git a/pkg/wshrpc/wshrpctypes_file.go b/pkg/wshrpc/wshrpctypes_file.go index ccb93aa410..3c64bb2289 100644 --- a/pkg/wshrpc/wshrpctypes_file.go +++ b/pkg/wshrpc/wshrpctypes_file.go @@ -18,17 +18,17 @@ type WshRpcFileInterface interface { FileAppendCommand(ctx context.Context, data FileData) error FileWriteCommand(ctx context.Context, data FileData) error FileReadCommand(ctx context.Context, data FileData) (*FileData, error) - FileReadStreamCommand(ctx context.Context, data FileData) <-chan RespOrErrorUnion[FileData] FileMoveCommand(ctx context.Context, data CommandFileCopyData) error FileCopyCommand(ctx context.Context, data CommandFileCopyData) error FileInfoCommand(ctx context.Context, data FileData) (*FileInfo, error) FileListCommand(ctx context.Context, data FileListData) ([]*FileInfo, error) FileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error) FileListStreamCommand(ctx context.Context, data FileListData) <-chan RespOrErrorUnion[CommandRemoteListEntriesRtnData] + FileStreamCommand(ctx context.Context, data CommandFileStreamData) (*FileInfo, error) } type WshRpcRemoteFileInterface interface { - RemoteStreamFileCommand(ctx context.Context, data CommandRemoteStreamFileData) chan RespOrErrorUnion[FileData] + RemoteFileStreamCommand(ctx context.Context, data CommandRemoteFileStreamData) (*FileInfo, error) RemoteFileCopyCommand(ctx context.Context, data CommandFileCopyData) (bool, error) RemoteListEntriesCommand(ctx context.Context, data CommandRemoteListEntriesData) chan RespOrErrorUnion[CommandRemoteListEntriesRtnData] RemoteFileInfoCommand(ctx context.Context, path string) (*FileInfo, error) @@ -130,6 +130,18 @@ type CommandRemoteStreamFileData struct { ByteRange string `json:"byterange,omitempty"` } +type CommandRemoteFileStreamData struct { + Path string `json:"path"` + ByteRange string `json:"byterange,omitempty"` + StreamMeta StreamMeta `json:"streammeta"` +} + +type CommandFileStreamData struct { + Info *FileInfo `json:"info"` + ByteRange string `json:"byterange,omitempty"` + StreamMeta StreamMeta `json:"streammeta"` +} + type CommandRemoteListEntriesData struct { Path string `json:"path"` Opts *FileListOpts `json:"opts,omitempty"` diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 670c949f2e..e66e52320c 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -22,9 +22,9 @@ import ( "github.com/skratchdot/open-golang/open" "github.com/wavetermdev/waveterm/pkg/aiusechat" - "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/buildercontroller" @@ -388,8 +388,8 @@ func (ws *WshServer) FileReadCommand(ctx context.Context, data wshrpc.FileData) return wshfs.Read(ctx, data) } -func (ws *WshServer) FileReadStreamCommand(ctx context.Context, data wshrpc.FileData) <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData] { - return wshfs.ReadStream(ctx, data) +func (ws *WshServer) FileStreamCommand(ctx context.Context, data wshrpc.CommandFileStreamData) (*wshrpc.FileInfo, error) { + return wshfs.FileStream(ctx, data) } func (ws *WshServer) FileCopyCommand(ctx context.Context, data wshrpc.CommandFileCopyData) error { @@ -878,6 +878,10 @@ func (ws *WshServer) WaveInfoCommand(ctx context.Context) (*wshrpc.WaveInfoData, }, nil } +func (ws *WshServer) MacOSVersionCommand(ctx context.Context) (string, error) { + return wavebase.ClientMacOSVersion(), nil +} + // BlocksListCommand returns every block visible in the requested // scope (current workspace by default). func (ws *WshServer) BlocksListCommand( diff --git a/schema/bgpresets.json b/schema/backgrounds.json similarity index 82% rename from schema/bgpresets.json rename to schema/backgrounds.json index 3ab0a8a433..298bdbf2a3 100644 --- a/schema/bgpresets.json +++ b/schema/backgrounds.json @@ -1,11 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { - "BgPresetsType": { + "BackgroundConfigType": { "properties": { - "bg:*": { - "type": "boolean" - }, "bg": { "type": "string", "description": "CSS background property value" @@ -36,11 +33,21 @@ } }, "additionalProperties": false, - "type": "object" + "type": "object", + "required": [ + "display:name" + ] } }, "additionalProperties": { - "$ref": "#/$defs/BgPresetsType" + "anyOf": [ + { + "$ref": "#/$defs/BackgroundConfigType" + }, + { + "type": "null" + } + ] }, "type": "object" } \ No newline at end of file diff --git a/schema/settings.json b/schema/settings.json index 348c937dac..67d8f5b9d4 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -43,6 +43,13 @@ "term" ] }, + "app:tabbar": { + "type": "string", + "enum": [ + "top", + "left" + ] + }, "feature:waveappbuilder": { "type": "boolean" }, @@ -225,6 +232,9 @@ "tab:confirmclose": { "type": "boolean" }, + "tab:background": { + "type": "string" + }, "widget:*": { "type": "boolean" }, @@ -318,6 +328,9 @@ "debug:pprofmemprofilerate": { "type": "integer" }, + "debug:webglstatus": { + "type": "boolean" + }, "tsunami:*": { "type": "boolean" }, diff --git a/schema/widgets.json b/schema/widgets.json index a4e6adb16a..1c55fd8e09 100644 --- a/schema/widgets.json +++ b/schema/widgets.json @@ -224,7 +224,14 @@ } }, "additionalProperties": { - "$ref": "#/$defs/WidgetConfigType" + "anyOf": [ + { + "$ref": "#/$defs/WidgetConfigType" + }, + { + "type": "null" + } + ] }, "type": "object" } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 3ef02e0671..8fd50d2f96 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "include": ["frontend/**/*", "emain/**/*"], + "exclude": ["node_modules"], "compilerOptions": { "target": "es6", "module": "es2020", diff --git a/tsunami/rpctypes/diff.go b/tsunami/rpctypes/diff.go new file mode 100644 index 0000000000..79074e2aa5 --- /dev/null +++ b/tsunami/rpctypes/diff.go @@ -0,0 +1,544 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package rpctypes + +import ( + "fmt" + "reflect" + "sort" + + "github.com/wavetermdev/waveterm/tsunami/util" + "github.com/wavetermdev/waveterm/tsunami/vdom" +) + +// Json is any JSON-compatible value +type Json = any + +// Path is string | number | []any +type Path = any + +// Diff is a list of Ops applied to a map/object +type Diff []Op + +// Op represents a single diff operation on a map/object. +// Exactly one logical operation per Op: +// +// {p, v} - set/replace value at path +// {p, d} - recursive patch at path +// {p, del} - delete key at path +// {p, a} - array diff at path +type Op struct { + Path Path `json:"p,omitempty"` + Val any `json:"v,omitempty"` + Diff Diff `json:"d,omitempty"` + Del bool `json:"del,omitempty"` + Arr []any `json:"a,omitempty"` // []ArrayInsertOp | []ArrayDeleteOp | []ArrayReplaceOp | []ArrayPatchOp | []ArraySwapOp +} + +// Array ops -- must be emitted in phase order: +// +// Phase 1 (original index space, ordered by index): ArrayInsertOp, ArrayDeleteOp +// Phase 2 (new index space): ArrayReplaceOp, ArrayPatchOp, ArraySwapOp +type ArrayInsertOp struct { + Idx int `json:"i"` + Val any `json:"v"` +} + +type ArrayDeleteOp struct { + Idx int `json:"x"` +} + +type ArrayReplaceOp struct { + Idx int `json:"r"` + Val any `json:"v"` +} + +type ArrayPatchOp struct { + Idx int `json:"p"` + Diff Diff `json:"d"` +} + +type ArraySwapOp struct { + Indices []int `json:"s"` // pairs: [from, to, from, to, ...] +} + +// ChildrenInsertOp is the only children-specific op type. +// Delete and swap reuse ArrayDeleteOp and ArraySwapOp. +// No replace or patch -- child updates are separate NodePatches. +type ChildrenInsertOp struct { + Idx int `json:"i"` + Val RenderedElem `json:"v"` +} + +type ElemPatch struct { + Id string `json:"id"` + Props Diff `json:"props,omitempty"` + Children []any `json:"children,omitempty"` // []ChildrenInsertOp | []ArrayDeleteOp | []ArraySwapOp +} + +type VDomPatch []ElemPatch + +type childMatch struct { + oldIdx int + newIdx int + newElem *RenderedElem +} + +func DiffRenderedElems(oldElem, newElem *RenderedElem) VDomPatch { + if oldElem == nil || newElem == nil || newElem.WaveId == "" { + return nil + } + oldIndex := make(map[string]*RenderedElem) + indexRenderedElem(oldElem, oldIndex) + if oldIndex[newElem.WaveId] == nil { + return nil + } + var patch VDomPatch + diffRenderedElemWalk(oldIndex, newElem, &patch) + return patch +} + +func indexRenderedElem(elem *RenderedElem, index map[string]*RenderedElem) { + if elem == nil { + return + } + if elem.WaveId != "" { + index[elem.WaveId] = elem + } + for idx := range elem.Children { + indexRenderedElem(&elem.Children[idx], index) + } +} + +func diffRenderedElemWalk(oldIndex map[string]*RenderedElem, newElem *RenderedElem, patch *VDomPatch) { + if newElem == nil || newElem.WaveId == "" { + return + } + oldElem := oldIndex[newElem.WaveId] + if oldElem == nil || oldElem.Tag != newElem.Tag { + return + } + childrenOps, matchedChildren := diffRenderedChildren(oldElem.Children, newElem.Children) + elemPatch := ElemPatch{ + Id: newElem.WaveId, + Props: DiffJson(oldElem.Props, newElem.Props, nil), + Children: childrenOps, + } + if len(elemPatch.Props) != 0 || len(elemPatch.Children) != 0 { + *patch = append(*patch, elemPatch) + } + for _, match := range matchedChildren { + diffRenderedElemWalk(oldIndex, match.newElem, patch) + } +} + +func DiffJson(old, new any, path Path) []Op { + return diffJson(old, new, pathToSegments(path)) +} + +func diffJson(old, new any, path []any) []Op { + if jsonDeepEqual(old, new) { + return nil + } + oldMap, oldIsMap := old.(map[string]any) + newMap, newIsMap := new.(map[string]any) + if oldIsMap && newIsMap { + return diffJsonMap(oldMap, newMap, path) + } + oldArr, oldIsArr := old.([]any) + newArr, newIsArr := new.([]any) + if oldIsArr && newIsArr && arrayHasKeys(oldArr) && arrayHasKeys(newArr) { + arrOps := diffArray(oldArr, newArr) + if len(arrOps) == 0 { + return nil + } + return Diff{{Path: pathFromSegments(path), Arr: arrOps}} + } + return Diff{{Path: pathFromSegments(path), Val: new}} +} + +func diffJsonMap(oldMap, newMap map[string]any, path []any) []Op { + var diff Diff + oldKeys := sortedMapKeys(oldMap) + newKeys := sortedMapKeys(newMap) + for _, key := range oldKeys { + if _, ok := newMap[key]; ok { + continue + } + diff = append(diff, Op{Path: pathFromSegments(appendPath(path, key)), Del: true}) + } + for _, key := range newKeys { + if _, ok := oldMap[key]; ok { + continue + } + diff = append(diff, Op{Path: pathFromSegments(appendPath(path, key)), Val: newMap[key]}) + } + for _, key := range intersectSortedKeys(oldKeys, newKeys) { + diff = append(diff, diffJson(oldMap[key], newMap[key], appendPath(path, key))...) + } + return diff +} + +func diffArray(oldArr, newArr []any) []any { + matches, oldMatched, newMatched := matchKeyedArrayElems(oldArr, newArr) + var ops []any + ops = append(ops, buildPhase1ArrayOps(oldArr, newArr, matches, oldMatched, newMatched)...) + + matchesByNew := append([]childMatch(nil), matches...) + sort.Slice(matchesByNew, func(i, j int) bool { + return matchesByNew[i].newIdx < matchesByNew[j].newIdx + }) + for _, match := range matchesByNew { + diff := DiffJson(oldArr[match.oldIdx], newArr[match.newIdx], nil) + if len(diff) == 0 { + continue + } + if isRootReplaceDiff(diff) { + ops = append(ops, ArrayReplaceOp{Idx: match.newIdx, Val: newArr[match.newIdx]}) + continue + } + ops = append(ops, ArrayPatchOp{Idx: match.newIdx, Diff: diff}) + } + if swap := buildArraySwapOp(matches); len(swap.Indices) != 0 { + ops = append(ops, swap) + } + return ops +} + +func diffRenderedChildren(oldChildren, newChildren []RenderedElem) ([]any, []childMatch) { + oldBuckets := make(map[string][]int) + for idx := range oldChildren { + key := renderedChildKey(oldChildren[idx], idx) + oldBuckets[key] = append(oldBuckets[key], idx) + } + + var matches []childMatch + oldMatched := make(map[int]bool) + newMatched := make(map[int]bool) + for newIdx := range newChildren { + key := renderedChildKey(newChildren[newIdx], newIdx) + oldIndices := oldBuckets[key] + if len(oldIndices) == 0 { + continue + } + oldIdx := oldIndices[0] + oldBuckets[key] = oldIndices[1:] + if !renderedChildrenMatch(&oldChildren[oldIdx], &newChildren[newIdx]) { + continue + } + matches = append(matches, childMatch{ + oldIdx: oldIdx, + newIdx: newIdx, + newElem: &newChildren[newIdx], + }) + oldMatched[oldIdx] = true + newMatched[newIdx] = true + } + + var ops []any + ops = append(ops, buildPhase1ChildOps(oldChildren, newChildren, matches, oldMatched, newMatched)...) + if swap := buildArraySwapOp(matches); len(swap.Indices) != 0 { + ops = append(ops, swap) + } + + matchesByNew := append([]childMatch(nil), matches...) + sort.Slice(matchesByNew, func(i, j int) bool { + return matchesByNew[i].newIdx < matchesByNew[j].newIdx + }) + return ops, matchesByNew +} + +func matchKeyedArrayElems(oldArr, newArr []any) ([]childMatch, map[int]bool, map[int]bool) { + oldBuckets := make(map[string][]int) + for idx, value := range oldArr { + key, ok := jsonArrayElemKey(value) + if !ok { + continue + } + oldBuckets[key] = append(oldBuckets[key], idx) + } + + var matches []childMatch + oldMatched := make(map[int]bool) + newMatched := make(map[int]bool) + for newIdx, value := range newArr { + key, ok := jsonArrayElemKey(value) + if !ok { + continue + } + oldIndices := oldBuckets[key] + if len(oldIndices) == 0 { + continue + } + oldIdx := oldIndices[0] + oldBuckets[key] = oldIndices[1:] + matches = append(matches, childMatch{oldIdx: oldIdx, newIdx: newIdx}) + oldMatched[oldIdx] = true + newMatched[newIdx] = true + } + return matches, oldMatched, newMatched +} + +func buildPhase1ArrayOps(oldArr, newArr []any, matches []childMatch, oldMatched, newMatched map[int]bool) []any { + insertions, deletions := computePhase1ArrayChanges(len(oldArr), len(newArr), matches, oldMatched, newMatched) + var ops []any + for idx := 0; idx <= len(oldArr); idx++ { + for _, newIdx := range insertions[idx] { + ops = append(ops, ArrayInsertOp{Idx: idx, Val: newArr[newIdx]}) + } + if idx < len(oldArr) && deletions[idx] { + ops = append(ops, ArrayDeleteOp{Idx: idx}) + } + } + return ops +} + +func buildPhase1ChildOps(oldChildren, newChildren []RenderedElem, matches []childMatch, oldMatched, newMatched map[int]bool) []any { + insertions, deletions := computePhase1ArrayChanges(len(oldChildren), len(newChildren), matches, oldMatched, newMatched) + var ops []any + for idx := 0; idx <= len(oldChildren); idx++ { + for _, newIdx := range insertions[idx] { + ops = append(ops, ChildrenInsertOp{Idx: idx, Val: newChildren[newIdx]}) + } + if idx < len(oldChildren) && deletions[idx] { + ops = append(ops, ArrayDeleteOp{Idx: idx}) + } + } + return ops +} + +func computePhase1ArrayChanges(oldLen, newLen int, matches []childMatch, oldMatched, newMatched map[int]bool) (map[int][]int, map[int]bool) { + insertions := make(map[int][]int) + deletions := make(map[int]bool) + + matchesByOld := append([]childMatch(nil), matches...) + sort.Slice(matchesByOld, func(i, j int) bool { + return matchesByOld[i].oldIdx < matchesByOld[j].oldIdx + }) + + oldPtr := 0 + oldCursor := 0 + for newIdx := 0; newIdx < newLen; newIdx++ { + if newMatched[newIdx] { + if oldCursor < len(matchesByOld) { + oldPtr = matchesByOld[oldCursor].oldIdx + 1 + oldCursor++ + } + continue + } + insertions[oldPtr] = append(insertions[oldPtr], newIdx) + } + + for oldIdx := 0; oldIdx < oldLen; oldIdx++ { + if oldMatched[oldIdx] { + continue + } + deletions[oldIdx] = true + } + return insertions, deletions +} + +func buildArraySwapOp(matches []childMatch) ArraySwapOp { + if len(matches) < 2 { + return ArraySwapOp{} + } + matchesByOld := append([]childMatch(nil), matches...) + sort.Slice(matchesByOld, func(i, j int) bool { + return matchesByOld[i].oldIdx < matchesByOld[j].oldIdx + }) + matchesByNew := append([]childMatch(nil), matches...) + sort.Slice(matchesByNew, func(i, j int) bool { + return matchesByNew[i].newIdx < matchesByNew[j].newIdx + }) + + current := make([]string, 0, len(matches)) + target := make([]string, 0, len(matches)) + indices := make([]int, 0, len(matches)) + for _, match := range matchesByOld { + current = append(current, fmt.Sprintf("o:%d", match.oldIdx)) + } + for _, match := range matchesByNew { + target = append(target, fmt.Sprintf("o:%d", match.oldIdx)) + indices = append(indices, match.newIdx) + } + swapIndices := buildSwapIndices(current, target, indices) + if len(swapIndices) == 0 { + return ArraySwapOp{} + } + return ArraySwapOp{Indices: swapIndices} +} + +func buildSwapIndices(current, target []string, fullIndices []int) []int { + if len(current) != len(target) || len(current) != len(fullIndices) { + return nil + } + currentCopy := append([]string(nil), current...) + posMap := make(map[string]int) + for idx, key := range currentCopy { + posMap[key] = idx + } + + var swaps []int + for idx, want := range target { + if currentCopy[idx] == want { + continue + } + swapIdx := posMap[want] + swaps = append(swaps, fullIndices[idx], fullIndices[swapIdx]) + curKey := currentCopy[idx] + currentCopy[idx], currentCopy[swapIdx] = currentCopy[swapIdx], currentCopy[idx] + posMap[curKey] = swapIdx + posMap[want] = idx + } + return swaps +} + +func renderedChildKey(elem RenderedElem, idx int) string { + if elem.Props != nil { + if keyVal, ok := elem.Props[vdom.KeyPropKey]; ok { + return fmt.Sprintf("key:%v", keyVal) + } + } + return fmt.Sprintf("idx:%s:%d", elem.Tag, idx) +} + +func renderedChildrenMatch(oldElem, newElem *RenderedElem) bool { + if oldElem == nil || newElem == nil { + return false + } + if oldElem.Tag == vdom.TextTag || newElem.Tag == vdom.TextTag { + return oldElem.Tag == vdom.TextTag && newElem.Tag == vdom.TextTag && oldElem.Text == newElem.Text + } + if oldElem.WaveId == "" || newElem.WaveId == "" { + return false + } + return oldElem.WaveId == newElem.WaveId && oldElem.Tag == newElem.Tag +} + +func arrayHasKeys(arr []any) bool { + for _, value := range arr { + if _, ok := jsonArrayElemKey(value); !ok { + return false + } + } + return true +} + +func jsonArrayElemKey(value any) (string, bool) { + m, ok := value.(map[string]any) + if !ok { + return "", false + } + if idVal, ok := m["id"]; ok { + return fmt.Sprintf("id:%v", idVal), true + } + if keyVal, ok := m["key"]; ok { + return fmt.Sprintf("key:%v", keyVal), true + } + return "", false +} + +func jsonDeepEqual(old, new any) bool { + if old == nil || new == nil { + return old == nil && new == nil + } + if util.IsNumericType(old) && util.IsNumericType(new) { + return util.CompareAsFloat64(old, new) + } + oldMap, oldIsMap := old.(map[string]any) + newMap, newIsMap := new.(map[string]any) + if oldIsMap || newIsMap { + if !oldIsMap || !newIsMap || len(oldMap) != len(newMap) { + return false + } + for key, oldVal := range oldMap { + newVal, ok := newMap[key] + if !ok || !jsonDeepEqual(oldVal, newVal) { + return false + } + } + return true + } + oldArr, oldIsArr := old.([]any) + newArr, newIsArr := new.([]any) + if oldIsArr || newIsArr { + if !oldIsArr || !newIsArr || len(oldArr) != len(newArr) { + return false + } + for idx := range oldArr { + if !jsonDeepEqual(oldArr[idx], newArr[idx]) { + return false + } + } + return true + } + return reflect.DeepEqual(old, new) +} + +func pathToSegments(path Path) []any { + switch val := path.(type) { + case nil: + return nil + case []any: + return append([]any(nil), val...) + default: + return []any{val} + } +} + +func appendPath(path []any, segment any) []any { + newPath := make([]any, len(path)+1) + copy(newPath, path) + newPath[len(path)] = segment + return newPath +} + +func pathFromSegments(path []any) Path { + switch len(path) { + case 0: + return nil + case 1: + return path[0] + default: + pathCopy := make([]any, len(path)) + copy(pathCopy, path) + return pathCopy + } +} + +func sortedMapKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func intersectSortedKeys(oldKeys, newKeys []string) []string { + var keys []string + oldPos := 0 + newPos := 0 + for oldPos < len(oldKeys) && newPos < len(newKeys) { + switch { + case oldKeys[oldPos] == newKeys[newPos]: + keys = append(keys, oldKeys[oldPos]) + oldPos++ + newPos++ + case oldKeys[oldPos] < newKeys[newPos]: + oldPos++ + default: + newPos++ + } + } + return keys +} + +func isRootReplaceDiff(diff Diff) bool { + if len(diff) != 1 { + return false + } + op := diff[0] + return op.Path == nil && !op.Del && len(op.Diff) == 0 && len(op.Arr) == 0 +} diff --git a/tsunami/rpctypes/diff_test.go b/tsunami/rpctypes/diff_test.go new file mode 100644 index 0000000000..f076449ec8 --- /dev/null +++ b/tsunami/rpctypes/diff_test.go @@ -0,0 +1,141 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package rpctypes + +import ( + "reflect" + "testing" +) + +func TestDiffRenderedElemsPropsAndArrayDiff(t *testing.T) { + oldElem := &RenderedElem{ + WaveId: "root", + Tag: "div", + Props: map[string]any{ + "class": "old", + "items": []any{ + map[string]any{"id": "a", "label": "A"}, + map[string]any{"id": "b", "label": "B"}, + }, + "meta": map[string]any{"enabled": true}, + "remove": "gone", + }, + } + newElem := &RenderedElem{ + WaveId: "root", + Tag: "div", + Props: map[string]any{ + "class": "new", + "items": []any{ + map[string]any{"id": "b", "label": "Bee"}, + map[string]any{"id": "a", "label": "A"}, + map[string]any{"id": "c", "label": "C"}, + }, + "meta": map[string]any{"enabled": false, "extra": "x"}, + }, + } + + patch := DiffRenderedElems(oldElem, newElem) + expected := VDomPatch{ + { + Id: "root", + Props: Diff{ + {Path: "remove", Del: true}, + {Path: "class", Val: "new"}, + {Path: "items", Arr: []any{ + ArrayInsertOp{Idx: 2, Val: map[string]any{"id": "c", "label": "C"}}, + ArrayPatchOp{Idx: 0, Diff: Diff{{Path: "label", Val: "Bee"}}}, + ArraySwapOp{Indices: []int{0, 1}}, + }}, + {Path: []any{"meta", "extra"}, Val: "x"}, + {Path: []any{"meta", "enabled"}, Val: false}, + }, + }, + } + + if !reflect.DeepEqual(patch, expected) { + t.Fatalf("patch mismatch\nactual: %#v\nexpected: %#v", patch, expected) + } +} + +func TestDiffRenderedElemsChildrenOpsStayFlat(t *testing.T) { + oldElem := &RenderedElem{ + WaveId: "root", + Tag: "div", + Children: []RenderedElem{ + {WaveId: "a", Tag: "div", Props: map[string]any{"key": "a"}}, + {WaveId: "b", Tag: "div", Props: map[string]any{"key": "b", "label": "old"}}, + {WaveId: "c", Tag: "div", Props: map[string]any{"key": "c"}}, + }, + } + inserted := RenderedElem{ + WaveId: "x", + Tag: "div", + Props: map[string]any{"key": "x"}, + Children: []RenderedElem{ + {WaveId: "x-child", Tag: "span", Props: map[string]any{"label": "nested"}}, + }, + } + newElem := &RenderedElem{ + WaveId: "root", + Tag: "div", + Children: []RenderedElem{ + {WaveId: "b", Tag: "div", Props: map[string]any{"key": "b", "label": "new"}}, + inserted, + {WaveId: "a", Tag: "div", Props: map[string]any{"key": "a"}}, + }, + } + + patch := DiffRenderedElems(oldElem, newElem) + expected := VDomPatch{ + { + Id: "root", + Children: []any{ + ChildrenInsertOp{Idx: 1, Val: inserted}, + ArrayDeleteOp{Idx: 2}, + ArraySwapOp{Indices: []int{0, 2}}, + }, + }, + { + Id: "b", + Props: Diff{{Path: "label", Val: "new"}}, + }, + } + + if !reflect.DeepEqual(patch, expected) { + t.Fatalf("patch mismatch\nactual: %#v\nexpected: %#v", patch, expected) + } +} + +func TestDiffRenderedElemsTextChildrenAreOpaque(t *testing.T) { + oldElem := &RenderedElem{ + WaveId: "root", + Tag: "div", + Children: []RenderedElem{ + {Tag: "#text", Text: "old"}, + }, + } + newElem := &RenderedElem{ + WaveId: "root", + Tag: "div", + Children: []RenderedElem{ + {Tag: "#text", Text: "new"}, + }, + } + + patch := DiffRenderedElems(oldElem, newElem) + expected := VDomPatch{ + { + Id: "root", + Children: []any{ + ChildrenInsertOp{Idx: 0, Val: RenderedElem{Tag: "#text", Text: "new"}}, + ArrayDeleteOp{Idx: 0}, + }, + }, + } + + if !reflect.DeepEqual(patch, expected) { + t.Fatalf("patch mismatch\nactual: %#v\nexpected: %#v", patch, expected) + } +}