From ebbfa323ea4f20004334fc330bfa7568b21fc4c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:30:23 +0000 Subject: [PATCH 1/3] Initial plan From 7a9518599ca6d41f12a9f94b0fb9de4944974e5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:36:06 +0000 Subject: [PATCH 2/3] Implement waveconfig waveenv narrowing Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- .../app/view/waveconfig/waveconfig-model.ts | 153 +++++++++--------- .../app/view/waveconfig/waveconfig.test.tsx | 116 +++++++++++++ frontend/app/view/waveconfig/waveconfig.tsx | 6 +- frontend/app/view/waveconfig/waveconfigenv.ts | 27 ++++ 4 files changed, 226 insertions(+), 76 deletions(-) create mode 100644 frontend/app/view/waveconfig/waveconfig.test.tsx create mode 100644 frontend/app/view/waveconfig/waveconfigenv.ts diff --git a/frontend/app/view/waveconfig/waveconfig-model.ts b/frontend/app/view/waveconfig/waveconfig-model.ts index 94cd114440..35c2d2ea11 100644 --- a/frontend/app/view/waveconfig/waveconfig-model.ts +++ b/frontend/app/view/waveconfig/waveconfig-model.ts @@ -2,14 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; -import { atoms, 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 Atom, type PrimitiveAtom } from "jotai"; import type * as MonacoTypes from "monaco-editor"; @@ -66,55 +65,57 @@ 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: "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, + }, + ]; +} const deprecatedConfigFiles: ConfigFile[] = [ { @@ -144,6 +145,7 @@ export class WaveConfigViewModel implements ViewModel { noPadding = atom(true); nodeModel: BlockNodeModel; tabModel: TabModel; + env: WaveConfigEnv; selectedFileAtom: PrimitiveAtom; fileContentAtom: PrimitiveAtom; @@ -171,12 +173,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; @@ -191,7 +194,7 @@ export class WaveConfigViewModel implements ViewModel { this.presetsJsonExistsAtom = atom(false); this.activeTabAtom = atom<"visual" | "json">("visual"); this.configErrorFilesAtom = atom((get) => { - const fullConfig = get(atoms.fullConfigAtom); + const fullConfig = get(this.env.atoms.fullConfigAtom); const errorSet = new Set(); for (const cerr of fullConfig?.configerrors ?? []) { errorSet.add(cerr.file); @@ -216,7 +219,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) { @@ -230,8 +233,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) { @@ -252,7 +257,7 @@ export class WaveConfigViewModel implements ViewModel { } getConfigFiles(): ConfigFile[] { - return configFiles; + return makeConfigFiles(this.env.isWindows()); } getDeprecatedConfigFiles(): ConfigFile[] { @@ -280,8 +285,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); @@ -292,7 +297,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) : ""; @@ -303,8 +308,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) { @@ -329,7 +334,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(""), }); @@ -371,7 +376,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), }); @@ -401,7 +406,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, @@ -420,7 +425,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}`); @@ -452,7 +457,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); @@ -479,8 +484,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", @@ -509,7 +514,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) { @@ -560,8 +565,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.test.tsx b/frontend/app/view/waveconfig/waveconfig.test.tsx new file mode 100644 index 0000000000..8247acff4e --- /dev/null +++ b/frontend/app/view/waveconfig/waveconfig.test.tsx @@ -0,0 +1,116 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import { WaveEnvContext } from "@/app/waveenv/waveenv"; +import { makeMockWaveEnv } from "@/preview/mock/mockwaveenv"; +import { stringToBase64 } from "@/util/util"; +import { atom } from "jotai"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { type ConfigFile, WaveConfigViewModel } from "./waveconfig-model"; +import { WaveConfigView } from "./waveconfig"; + +vi.mock("@/app/view/codeeditor/codeeditor", () => ({ + CodeEditor: () => null, +})); + +function flushPromises() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("waveconfig waveenv integration", () => { + it("uses the supplied env for model initialization and config file selection", async () => { + const blockId = "waveconfig-env-block"; + const env = makeMockWaveEnv({ + platform: "win32", + electron: { + getConfigDir: () => "/mock/config", + }, + rpc: { + FileInfoCommand: async () => ({ notfound: true }), + FileReadCommand: async (_client, payload) => ({ + data64: stringToBase64(`{"path":"${payload.info.path}"}`), + }), + }, + mockWaveObjs: { + [`block:${blockId}`]: { + otype: "block", + oid: blockId, + version: 1, + meta: {}, + } as Block, + }, + }); + const model = new WaveConfigViewModel({ + blockId, + nodeModel: { + isFocused: atom(false), + focusNode: () => {}, + } as any, + tabModel: {} as any, + waveEnv: env, + }); + + await flushPromises(); + await flushPromises(); + + expect(model.configDir).toBe("/mock/config"); + expect(model.saveShortcut).toBe("Alt+S"); + expect(model.getConfigFiles().find((file) => file.path === "connections.json")?.description).toBe( + "SSH hosts and WSL distros" + ); + expect(globalStore.get(model.selectedFileAtom)?.path).toBe("settings.json"); + expect(globalStore.get(model.fileContentAtom)).toContain("/mock/config/settings.json"); + expect(globalStore.get(env.wos.getWaveObjectAtom(`block:${blockId}`))?.meta?.file).toBe("settings.json"); + }); + + it("renders config errors from the supplied env atom", () => { + const env = makeMockWaveEnv({ + atoms: { + fullConfigAtom: atom({ + configerrors: [{ file: "settings.json", err: "env config error" }], + } as FullConfigType), + }, + }); + + const selectedFile: ConfigFile = { + name: "Secrets", + path: "secrets", + hasJsonView: false, + visualComponent: () =>
secrets content
, + }; + const model = { + selectedFileAtom: atom(selectedFile), + fileContentAtom: atom(""), + isLoadingAtom: atom(false), + isSavingAtom: atom(false), + errorMessageAtom: atom(null) as any, + validationErrorAtom: atom(null) as any, + isMenuOpenAtom: atom(false), + hasEditedAtom: atom(false), + activeTabAtom: atom<"visual" | "json">("visual"), + configErrorFilesAtom: atom(new Set()), + nodeModel: { + isFocused: atom(false), + }, + editorRef: { current: null }, + saveShortcut: "Cmd+S", + getConfigFiles: () => [selectedFile], + getDeprecatedConfigFiles: () => [], + markAsEdited: () => {}, + saveFile: () => {}, + clearError: () => {}, + clearValidationError: () => {}, + } as WaveConfigViewModel; + + const markup = renderToStaticMarkup( + + + + ); + + expect(markup).toContain("Config Error:"); + expect(markup).toContain("settings.json: env config error"); + }); +}); diff --git a/frontend/app/view/waveconfig/waveconfig.tsx b/frontend/app/view/waveconfig/waveconfig.tsx index 00747ff3c1..8dfa6ad25d 100644 --- a/frontend/app/view/waveconfig/waveconfig.tsx +++ b/frontend/app/view/waveconfig/waveconfig.tsx @@ -2,11 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; -import { atoms } from "@/app/store/global"; 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"; @@ -97,6 +98,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); @@ -106,7 +108,7 @@ const WaveConfigView = memo(({ blockId, model }: ViewComponentProps; + isWindows: WaveEnv["isWindows"]; +}>; From 608a36862a696b1e1b0856cbfd603925e5443203 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:44:09 +0000 Subject: [PATCH 3/3] Remove unnecessary waveconfig test Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- .../app/view/waveconfig/waveconfig.test.tsx | 116 ------------------ 1 file changed, 116 deletions(-) delete mode 100644 frontend/app/view/waveconfig/waveconfig.test.tsx diff --git a/frontend/app/view/waveconfig/waveconfig.test.tsx b/frontend/app/view/waveconfig/waveconfig.test.tsx deleted file mode 100644 index 8247acff4e..0000000000 --- a/frontend/app/view/waveconfig/waveconfig.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { globalStore } from "@/app/store/jotaiStore"; -import { WaveEnvContext } from "@/app/waveenv/waveenv"; -import { makeMockWaveEnv } from "@/preview/mock/mockwaveenv"; -import { stringToBase64 } from "@/util/util"; -import { atom } from "jotai"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; -import { type ConfigFile, WaveConfigViewModel } from "./waveconfig-model"; -import { WaveConfigView } from "./waveconfig"; - -vi.mock("@/app/view/codeeditor/codeeditor", () => ({ - CodeEditor: () => null, -})); - -function flushPromises() { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - -describe("waveconfig waveenv integration", () => { - it("uses the supplied env for model initialization and config file selection", async () => { - const blockId = "waveconfig-env-block"; - const env = makeMockWaveEnv({ - platform: "win32", - electron: { - getConfigDir: () => "/mock/config", - }, - rpc: { - FileInfoCommand: async () => ({ notfound: true }), - FileReadCommand: async (_client, payload) => ({ - data64: stringToBase64(`{"path":"${payload.info.path}"}`), - }), - }, - mockWaveObjs: { - [`block:${blockId}`]: { - otype: "block", - oid: blockId, - version: 1, - meta: {}, - } as Block, - }, - }); - const model = new WaveConfigViewModel({ - blockId, - nodeModel: { - isFocused: atom(false), - focusNode: () => {}, - } as any, - tabModel: {} as any, - waveEnv: env, - }); - - await flushPromises(); - await flushPromises(); - - expect(model.configDir).toBe("/mock/config"); - expect(model.saveShortcut).toBe("Alt+S"); - expect(model.getConfigFiles().find((file) => file.path === "connections.json")?.description).toBe( - "SSH hosts and WSL distros" - ); - expect(globalStore.get(model.selectedFileAtom)?.path).toBe("settings.json"); - expect(globalStore.get(model.fileContentAtom)).toContain("/mock/config/settings.json"); - expect(globalStore.get(env.wos.getWaveObjectAtom(`block:${blockId}`))?.meta?.file).toBe("settings.json"); - }); - - it("renders config errors from the supplied env atom", () => { - const env = makeMockWaveEnv({ - atoms: { - fullConfigAtom: atom({ - configerrors: [{ file: "settings.json", err: "env config error" }], - } as FullConfigType), - }, - }); - - const selectedFile: ConfigFile = { - name: "Secrets", - path: "secrets", - hasJsonView: false, - visualComponent: () =>
secrets content
, - }; - const model = { - selectedFileAtom: atom(selectedFile), - fileContentAtom: atom(""), - isLoadingAtom: atom(false), - isSavingAtom: atom(false), - errorMessageAtom: atom(null) as any, - validationErrorAtom: atom(null) as any, - isMenuOpenAtom: atom(false), - hasEditedAtom: atom(false), - activeTabAtom: atom<"visual" | "json">("visual"), - configErrorFilesAtom: atom(new Set()), - nodeModel: { - isFocused: atom(false), - }, - editorRef: { current: null }, - saveShortcut: "Cmd+S", - getConfigFiles: () => [selectedFile], - getDeprecatedConfigFiles: () => [], - markAsEdited: () => {}, - saveFile: () => {}, - clearError: () => {}, - clearValidationError: () => {}, - } as WaveConfigViewModel; - - const markup = renderToStaticMarkup( - - - - ); - - expect(markup).toContain("Config Error:"); - expect(markup).toContain("settings.json: env config error"); - }); -});