waveterm/frontend/app/view/waveconfig/waveconfig-model.ts

585 lines
20 KiB
TypeScript

// Copyright 2025, Command Line Inc.
// 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 { RpcApi } from "@/app/store/wshclientapi";
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 { base64ToString, stringToBase64 } from "@/util/util";
import { atom, type PrimitiveAtom } from "jotai";
import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api";
import * as React from "react";
type ValidationResult = { success: true } | { error: string };
type ConfigValidator = (parsed: any) => ValidationResult;
export type ConfigFile = {
name: string;
path: string;
language?: string;
deprecated?: boolean;
description?: string;
docsUrl?: string;
validator?: ConfigValidator;
isSecrets?: boolean;
hasJsonView?: boolean;
visualComponent?: React.ComponentType<{ model: WaveConfigViewModel }>;
};
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) {
if (!key.startsWith("ai@")) {
return { error: `Invalid key "${key}": all top-level keys must start with "ai@"` };
}
}
return { success: true };
}
function validateWaveAiJson(parsed: any): ValidationResult {
const keys = Object.keys(parsed);
const keyPattern = /^[a-zA-Z0-9_@.-]+$/;
for (const key of keys) {
if (!keyPattern.test(key)) {
return {
error: `Invalid key "${key}": keys must only contain letters, numbers, underscores, @, dots, and hyphens`,
};
}
}
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,
},
];
const deprecatedConfigFiles: ConfigFile[] = [
{
name: "Presets",
path: "presets.json",
language: "json",
deprecated: true,
hasJsonView: true,
},
{
name: "AI Presets",
path: "presets/ai.json",
language: "json",
deprecated: true,
docsUrl: "https://docs.waveterm.dev/ai-presets",
validator: validateAiJson,
hasJsonView: true,
},
];
export class WaveConfigViewModel implements ViewModel {
blockId: string;
viewType = "waveconfig";
viewIcon = atom("gear");
viewName = atom("Wave Config");
viewComponent = WaveConfigView;
noPadding = atom(true);
nodeModel: BlockNodeModel;
selectedFileAtom: PrimitiveAtom<ConfigFile>;
fileContentAtom: PrimitiveAtom<string>;
originalContentAtom: PrimitiveAtom<string>;
hasEditedAtom: PrimitiveAtom<boolean>;
isLoadingAtom: PrimitiveAtom<boolean>;
isSavingAtom: PrimitiveAtom<boolean>;
errorMessageAtom: PrimitiveAtom<string>;
validationErrorAtom: PrimitiveAtom<string>;
isMenuOpenAtom: PrimitiveAtom<boolean>;
presetsJsonExistsAtom: PrimitiveAtom<boolean>;
activeTabAtom: PrimitiveAtom<"visual" | "json">;
configDir: string;
saveShortcut: string;
editorRef: React.RefObject<MonacoTypes.editor.IStandaloneCodeEditor>;
secretNamesAtom: PrimitiveAtom<string[]>;
selectedSecretAtom: PrimitiveAtom<string | null>;
secretValueAtom: PrimitiveAtom<string>;
secretShownAtom: PrimitiveAtom<boolean>;
isAddingNewAtom: PrimitiveAtom<boolean>;
newSecretNameAtom: PrimitiveAtom<string>;
newSecretValueAtom: PrimitiveAtom<string>;
storageBackendErrorAtom: PrimitiveAtom<string | null>;
secretValueRef: HTMLTextAreaElement | null = null;
constructor(blockId: string, nodeModel: BlockNodeModel) {
this.blockId = blockId;
this.nodeModel = nodeModel;
this.configDir = getApi().getConfigDir();
const platform = getApi().getPlatform();
this.saveShortcut = platform === "darwin" ? "Cmd+S" : "Alt+S";
this.selectedFileAtom = atom(null) as PrimitiveAtom<ConfigFile>;
this.fileContentAtom = atom("");
this.originalContentAtom = atom("");
this.hasEditedAtom = atom(false);
this.isLoadingAtom = atom(false);
this.isSavingAtom = atom(false);
this.errorMessageAtom = atom(null) as PrimitiveAtom<string>;
this.validationErrorAtom = atom(null) as PrimitiveAtom<string>;
this.isMenuOpenAtom = atom(false);
this.presetsJsonExistsAtom = atom(false);
this.activeTabAtom = atom<"visual" | "json">("visual");
this.editorRef = React.createRef();
this.secretNamesAtom = atom<string[]>([]);
this.selectedSecretAtom = atom<string | null>(null) as PrimitiveAtom<string | null>;
this.secretValueAtom = atom<string>("");
this.secretShownAtom = atom<boolean>(false);
this.isAddingNewAtom = atom<boolean>(false);
this.newSecretNameAtom = atom<string>("");
this.newSecretValueAtom = atom<string>("");
this.storageBackendErrorAtom = atom<string | null>(null) as PrimitiveAtom<string | null>;
this.checkPresetsJsonExists();
this.initialize();
}
async checkPresetsJsonExists() {
try {
const fullPath = `${this.configDir}/presets.json`;
const fileInfo = await RpcApi.FileInfoCommand(TabRpcClient, {
info: { path: fullPath },
});
if (!fileInfo.notfound) {
globalStore.set(this.presetsJsonExistsAtom, true);
}
} catch {
// File doesn't exist
}
}
initialize() {
const selectedFile = globalStore.get(this.selectedFileAtom);
if (!selectedFile) {
const metaFileAtom = getBlockMetaKeyAtom(this.blockId, "file");
const savedFilePath = globalStore.get(metaFileAtom);
let fileToLoad: ConfigFile | null = null;
if (savedFilePath) {
fileToLoad =
configFiles.find((f) => f.path === savedFilePath) ||
deprecatedConfigFiles.find((f) => f.path === savedFilePath) ||
null;
}
if (!fileToLoad) {
fileToLoad = configFiles[0];
}
if (fileToLoad) {
this.loadFile(fileToLoad);
}
}
}
getConfigFiles(): ConfigFile[] {
return configFiles;
}
getDeprecatedConfigFiles(): ConfigFile[] {
const presetsJsonExists = globalStore.get(this.presetsJsonExistsAtom);
return deprecatedConfigFiles.filter((f) => {
if (f.path === "presets.json") {
return presetsJsonExists;
}
return true;
});
}
hasChanges(): boolean {
return globalStore.get(this.hasEditedAtom);
}
markAsEdited() {
globalStore.set(this.hasEditedAtom, true);
}
async loadFile(file: ConfigFile) {
globalStore.set(this.isLoadingAtom, true);
globalStore.set(this.errorMessageAtom, null);
globalStore.set(this.hasEditedAtom, false);
if (file.isSecrets) {
globalStore.set(this.selectedFileAtom, file);
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { file: file.path },
});
globalStore.set(this.isLoadingAtom, false);
this.checkStorageBackend();
this.refreshSecrets();
return;
}
try {
const fullPath = `${this.configDir}/${file.path}`;
const fileData = await RpcApi.FileReadCommand(TabRpcClient, {
info: { path: fullPath },
});
const content = fileData?.data64 ? base64ToString(fileData.data64) : "";
globalStore.set(this.originalContentAtom, content);
if (content.trim() === "") {
globalStore.set(this.fileContentAtom, "{\n\n}");
} else {
globalStore.set(this.fileContentAtom, content);
}
globalStore.set(this.selectedFileAtom, file);
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { file: file.path },
});
} catch (err) {
globalStore.set(this.errorMessageAtom, `Failed to load ${file.name}: ${err.message || String(err)}`);
globalStore.set(this.fileContentAtom, "");
globalStore.set(this.originalContentAtom, "");
} finally {
globalStore.set(this.isLoadingAtom, false);
}
}
async saveFile() {
const selectedFile = globalStore.get(this.selectedFileAtom);
if (!selectedFile) return;
const fileContent = globalStore.get(this.fileContentAtom);
if (fileContent.trim() === "") {
globalStore.set(this.isSavingAtom, true);
globalStore.set(this.errorMessageAtom, null);
globalStore.set(this.validationErrorAtom, null);
try {
const fullPath = `${this.configDir}/${selectedFile.path}`;
await RpcApi.FileWriteCommand(TabRpcClient, {
info: { path: fullPath },
data64: stringToBase64(""),
});
globalStore.set(this.fileContentAtom, "");
globalStore.set(this.originalContentAtom, "");
globalStore.set(this.hasEditedAtom, false);
} catch (err) {
globalStore.set(
this.errorMessageAtom,
`Failed to save ${selectedFile.name}: ${err.message || String(err)}`
);
} finally {
globalStore.set(this.isSavingAtom, false);
}
return;
}
try {
const parsed = JSON.parse(fileContent);
if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) {
globalStore.set(this.validationErrorAtom, "JSON must be an object, not an array, primitive, or null");
return;
}
if (selectedFile.validator) {
const validationResult = selectedFile.validator(parsed);
if ("error" in validationResult) {
globalStore.set(this.validationErrorAtom, validationResult.error);
return;
}
}
const formatted = JSON.stringify(parsed, null, 2);
globalStore.set(this.isSavingAtom, true);
globalStore.set(this.errorMessageAtom, null);
globalStore.set(this.validationErrorAtom, null);
try {
const fullPath = `${this.configDir}/${selectedFile.path}`;
await RpcApi.FileWriteCommand(TabRpcClient, {
info: { path: fullPath },
data64: stringToBase64(formatted),
});
globalStore.set(this.fileContentAtom, formatted);
globalStore.set(this.originalContentAtom, formatted);
globalStore.set(this.hasEditedAtom, false);
} catch (err) {
globalStore.set(
this.errorMessageAtom,
`Failed to save ${selectedFile.name}: ${err.message || String(err)}`
);
} finally {
globalStore.set(this.isSavingAtom, false);
}
} catch (err) {
globalStore.set(this.validationErrorAtom, `Invalid JSON: ${err.message || String(err)}`);
}
}
clearError() {
globalStore.set(this.errorMessageAtom, null);
}
clearValidationError() {
globalStore.set(this.validationErrorAtom, null);
}
async checkStorageBackend() {
try {
const backend = await RpcApi.GetSecretsLinuxStorageBackendCommand(TabRpcClient);
if (backend === "basic_text" || backend === "unknown") {
globalStore.set(
this.storageBackendErrorAtom,
"No appropriate secret manager found. Cannot manage secrets securely."
);
} else {
globalStore.set(this.storageBackendErrorAtom, null);
}
} catch (error) {
globalStore.set(this.storageBackendErrorAtom, `Error checking storage backend: ${error.message}`);
}
}
async refreshSecrets() {
globalStore.set(this.isLoadingAtom, true);
globalStore.set(this.errorMessageAtom, null);
try {
const names = await RpcApi.GetSecretsNamesCommand(TabRpcClient);
globalStore.set(this.secretNamesAtom, names || []);
} catch (error) {
globalStore.set(this.errorMessageAtom, `Failed to load secrets: ${error.message}`);
} finally {
globalStore.set(this.isLoadingAtom, false);
}
}
async viewSecret(name: string) {
globalStore.set(this.errorMessageAtom, null);
globalStore.set(this.selectedSecretAtom, name);
globalStore.set(this.secretShownAtom, false);
globalStore.set(this.secretValueAtom, "");
}
closeSecretView() {
globalStore.set(this.selectedSecretAtom, null);
globalStore.set(this.secretValueAtom, "");
globalStore.set(this.errorMessageAtom, null);
}
async showSecret() {
const selectedSecret = globalStore.get(this.selectedSecretAtom);
if (!selectedSecret) {
return;
}
globalStore.set(this.isLoadingAtom, true);
globalStore.set(this.errorMessageAtom, null);
try {
const secrets = await RpcApi.GetSecretsCommand(TabRpcClient, [selectedSecret]);
const value = secrets[selectedSecret];
if (value !== undefined) {
globalStore.set(this.secretValueAtom, value);
globalStore.set(this.secretShownAtom, true);
} else {
globalStore.set(this.errorMessageAtom, `Secret not found: ${selectedSecret}`);
}
} catch (error) {
globalStore.set(this.errorMessageAtom, `Failed to load secret: ${error.message}`);
} finally {
globalStore.set(this.isLoadingAtom, false);
}
}
async saveSecret() {
const selectedSecret = globalStore.get(this.selectedSecretAtom);
const secretValue = globalStore.get(this.secretValueAtom);
if (!selectedSecret) {
return;
}
globalStore.set(this.isLoadingAtom, true);
globalStore.set(this.errorMessageAtom, null);
try {
await RpcApi.SetSecretsCommand(TabRpcClient, { [selectedSecret]: secretValue });
RpcApi.RecordTEventCommand(
TabRpcClient,
{
event: "action:other",
props: {
"action:type": "waveconfig:savesecret",
},
},
{ noresponse: true }
);
this.closeSecretView();
} catch (error) {
globalStore.set(this.errorMessageAtom, `Failed to save secret: ${error.message}`);
} finally {
globalStore.set(this.isLoadingAtom, false);
}
}
async deleteSecret() {
const selectedSecret = globalStore.get(this.selectedSecretAtom);
if (!selectedSecret) {
return;
}
globalStore.set(this.isLoadingAtom, true);
globalStore.set(this.errorMessageAtom, null);
try {
await RpcApi.SetSecretsCommand(TabRpcClient, { [selectedSecret]: null });
this.closeSecretView();
await this.refreshSecrets();
} catch (error) {
globalStore.set(this.errorMessageAtom, `Failed to delete secret: ${error.message}`);
} finally {
globalStore.set(this.isLoadingAtom, false);
}
}
startAddingSecret() {
globalStore.set(this.isAddingNewAtom, true);
globalStore.set(this.newSecretNameAtom, "");
globalStore.set(this.newSecretValueAtom, "");
globalStore.set(this.errorMessageAtom, null);
}
cancelAddingSecret() {
globalStore.set(this.isAddingNewAtom, false);
globalStore.set(this.newSecretNameAtom, "");
globalStore.set(this.newSecretValueAtom, "");
globalStore.set(this.errorMessageAtom, null);
}
async addNewSecret() {
const name = globalStore.get(this.newSecretNameAtom).trim();
const value = globalStore.get(this.newSecretValueAtom);
if (!name) {
globalStore.set(this.errorMessageAtom, "Secret name cannot be empty");
return;
}
if (!SecretNameRegex.test(name)) {
globalStore.set(
this.errorMessageAtom,
"Invalid secret name: must start with a letter and contain only letters, numbers, and underscores"
);
return;
}
const existingNames = globalStore.get(this.secretNamesAtom);
if (existingNames.includes(name)) {
globalStore.set(this.errorMessageAtom, `Secret "${name}" already exists`);
return;
}
globalStore.set(this.isLoadingAtom, true);
globalStore.set(this.errorMessageAtom, null);
try {
await RpcApi.SetSecretsCommand(TabRpcClient, { [name]: value });
RpcApi.RecordTEventCommand(
TabRpcClient,
{
event: "action:other",
props: {
"action:type": "waveconfig:savesecret",
},
},
{ noresponse: true }
);
globalStore.set(this.isAddingNewAtom, false);
globalStore.set(this.newSecretNameAtom, "");
globalStore.set(this.newSecretValueAtom, "");
await this.refreshSecrets();
} catch (error) {
globalStore.set(this.errorMessageAtom, `Failed to add secret: ${error.message}`);
} finally {
globalStore.set(this.isLoadingAtom, false);
}
}
giveFocus(): boolean {
const selectedFile = globalStore.get(this.selectedFileAtom);
if (selectedFile?.isSecrets && this.secretValueRef) {
this.secretValueRef.focus();
return true;
}
if (this.editorRef?.current) {
this.editorRef.current.focus();
return true;
}
return false;
}
}