diff --git a/aiprompts/newview.md b/aiprompts/newview.md new file mode 100644 index 0000000000..d12bfca937 --- /dev/null +++ b/aiprompts/newview.md @@ -0,0 +1,525 @@ +# Creating a New View in Wave Terminal + +This guide explains how to implement a new view type in Wave Terminal. Views are the core content components displayed within blocks in the terminal interface. + +## Architecture Overview + +Wave Terminal uses a **Model-View architecture** where: +- **ViewModel** - Contains all state, logic, and UI configuration as Jotai atoms +- **ViewComponent** - Pure React component that renders the UI using the model +- **BlockFrame** - Wraps views with a header, connection management, and standard controls + +The separation between model and component ensures: +- Models can update state without React hooks +- Components remain pure and testable +- State is centralized in Jotai atoms for easy access + +## ViewModel Interface + +Every view must implement the `ViewModel` interface defined in [`frontend/types/custom.d.ts`](../frontend/types/custom.d.ts:285-341): + +```typescript +interface ViewModel { + // Required: The type identifier for this view (e.g., "term", "web", "preview") + viewType: string; + + // Required: The React component that renders this view + viewComponent: ViewComponent; + + // Optional: Icon shown in block header (FontAwesome icon name or IconButtonDecl) + viewIcon?: jotai.Atom; + + // Optional: Display name shown in block header (e.g., "Terminal", "Web", "Preview") + viewName?: jotai.Atom; + + // Optional: Additional header elements (text, buttons, inputs) shown after the name + viewText?: jotai.Atom; + + // Optional: Icon button shown before the view name in header + preIconButton?: jotai.Atom; + + // Optional: Icon buttons shown at the end of the header (before settings/close) + endIconButtons?: jotai.Atom; + + // Optional: Custom background styling for the block + blockBg?: jotai.Atom; + + // Optional: If true, completely hides the block header + noHeader?: jotai.Atom; + + // Optional: If true, shows connection picker in header for remote connections + manageConnection?: jotai.Atom; + + // Optional: If true, filters out 'nowsh' connections from connection picker + filterOutNowsh?: jotai.Atom; + + // Optional: If true, shows S3 connections in connection picker + showS3?: jotai.Atom; + + // Optional: If true, removes default padding from content area + noPadding?: jotai.Atom; + + // Optional: Atoms for managing in-block search functionality + searchAtoms?: SearchAtoms; + + // Optional: Returns whether this is a basic terminal (for multi-input feature) + isBasicTerm?: (getFn: jotai.Getter) => boolean; + + // Optional: Returns context menu items for the settings dropdown + getSettingsMenuItems?: () => ContextMenuItem[]; + + // Optional: Focuses the view when called, returns true if successful + giveFocus?: () => boolean; + + // Optional: Handles keyboard events, returns true if handled + keyDownHandler?: (e: WaveKeyboardEvent) => boolean; + + // Optional: Cleanup when block is closed + dispose?: () => void; +} +``` + +### Key Concepts + +**Atoms**: All UI-related properties must be Jotai atoms. This enables: +- Reactive updates when state changes +- Access from anywhere via `globalStore.get()`/`globalStore.set()` +- Derived atoms that compute values from other atoms + +**ViewComponent**: The React component receives these props: +```typescript +type ViewComponentProps = { + blockId: string; // Unique ID for this block + blockRef: React.RefObject; // Ref to block container + contentRef: React.RefObject; // Ref to content area + model: T; // Your ViewModel instance +}; +``` + +## Step-by-Step Guide + +### 1. Create the View Model Class + +Create a new file for your view model (e.g., `frontend/app/view/myview/myview-model.ts`): + +```typescript +import { BlockNodeModel } from "@/app/block/blocktypes"; +import { WOS, globalStore, useBlockAtom } from "@/store/global"; +import * as jotai from "jotai"; +import { MyView } from "./myview"; + +export class MyViewModel implements ViewModel { + viewType: string; + blockId: string; + nodeModel: BlockNodeModel; + blockAtom: jotai.Atom; + + // Define your atoms (simple field initializers) + viewIcon = jotai.atom("circle"); + viewName = jotai.atom("My View"); + noPadding = jotai.atom(true); + + // Derived atom (created in constructor) + viewText!: jotai.Atom; + + constructor(blockId: string, nodeModel: BlockNodeModel) { + this.viewType = "myview"; + this.blockId = blockId; + this.nodeModel = nodeModel; + this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + + // Create derived atoms that depend on block data or other atoms + this.viewText = jotai.atom((get) => { + const blockData = get(this.blockAtom); + const rtn: HeaderElem[] = []; + + // Add header buttons/text based on state + rtn.push({ + elemtype: "iconbutton", + icon: "refresh", + title: "Refresh", + click: () => this.refresh(), + }); + + return rtn; + }); + } + + get viewComponent(): ViewComponent { + return MyView; + } + + refresh() { + // Update state using globalStore + // Never use React hooks in model methods + console.log("refreshing..."); + } + + giveFocus(): boolean { + // Focus your view component + return true; + } + + dispose() { + // Cleanup resources (unsubscribe from events, etc.) + } +} +``` + +### 2. Create the View Component + +Create your React component (e.g., `frontend/app/view/myview/myview.tsx`): + +```typescript +import { ViewComponentProps } from "@/app/block/blocktypes"; +import { MyViewModel } from "./myview-model"; +import { useAtomValue } from "jotai"; +import "./myview.scss"; + +export const MyView: React.FC> = ({ + blockId, + model, + contentRef +}) => { + // Use atoms from the model (these are React hooks - call at top level!) + const blockData = useAtomValue(model.blockAtom); + + return ( +
+
Block ID: {blockId}
+
View: {model.viewType}
+ {/* Your view content here */} +
+ ); +}; +``` + +### 3. Register the View + +Add your view to the `BlockRegistry` in [`frontend/app/block/block.tsx`](../frontend/app/block/block.tsx:42-55): + +```typescript +const BlockRegistry: Map = new Map(); +BlockRegistry.set("term", TermViewModel); +BlockRegistry.set("preview", PreviewModel); +BlockRegistry.set("web", WebViewModel); +// ... existing registrations ... +BlockRegistry.set("myview", MyViewModel); // Add your view here +``` + +The registry key (e.g., `"myview"`) becomes the view type used in block metadata. + +### 4. Create Blocks with Your View + +Users can create blocks with your view type: +- Via CLI: `wsh view myview` +- Via RPC: Use the block's `meta.view` field set to `"myview"` + +## Real-World Examples + +### Example 1: Terminal View ([`term-model.ts`](../frontend/app/view/term/term-model.ts)) + +The terminal view demonstrates: +- **Connection management** via `manageConnection` atom +- **Dynamic header buttons** showing shell status (play/restart) +- **Mode switching** between terminal and vdom views +- **Custom keyboard handling** for terminal-specific shortcuts +- **Focus management** to focus the xterm.js instance +- **Shell integration status** showing AI capability indicators + +Key features: +```typescript +this.manageConnection = jotai.atom((get) => { + const termMode = get(this.termMode); + if (termMode == "vdom") return false; + return true; // Show connection picker for regular terminal mode +}); + +this.endIconButtons = jotai.atom((get) => { + const shellProcStatus = get(this.shellProcStatus); + const buttons: IconButtonDecl[] = []; + + if (shellProcStatus == "running") { + buttons.push({ + elemtype: "iconbutton", + icon: "refresh", + title: "Restart Shell", + click: this.forceRestartController.bind(this), + }); + } + return buttons; +}); +``` + +### Example 2: Web View ([`webview.tsx`](../frontend/app/view/webview/webview.tsx)) + +The web view shows: +- **Complex header controls** (back/forward/home/URL input) +- **State management** for loading, URL, and navigation +- **Event handling** for webview navigation events +- **Custom styling** with `noPadding` for full-bleed content +- **Media controls** showing play/pause/mute when media is active + +Key features: +```typescript +this.viewText = jotai.atom((get) => { + const url = get(this.url); + const rtn: HeaderElem[] = []; + + // Navigation buttons + rtn.push({ + elemtype: "iconbutton", + icon: "chevron-left", + click: this.handleBack.bind(this), + disabled: this.shouldDisableBackButton(), + }); + + // URL input with nested controls + rtn.push({ + elemtype: "div", + className: "block-frame-div-url", + children: [ + { + elemtype: "input", + value: url, + onChange: this.handleUrlChange.bind(this), + onKeyDown: this.handleKeyDown.bind(this), + }, + { + elemtype: "iconbutton", + icon: "rotate-right", + click: this.handleRefresh.bind(this), + } + ], + }); + + return rtn; +}); +``` + +## Header Elements (`HeaderElem`) + +The `viewText` atom can return an array of these element types: + +```typescript +// Icon button +{ + elemtype: "iconbutton", + icon: "refresh", + title: "Tooltip text", + click: () => { /* handler */ }, + disabled?: boolean, + iconColor?: string, + iconSpin?: boolean, + noAction?: boolean, // Shows icon but no click action +} + +// Text element +{ + elemtype: "text", + text: "Display text", + className?: string, + noGrow?: boolean, + ref?: React.RefObject, + onClick?: (e: React.MouseEvent) => void, +} + +// Text button +{ + elemtype: "textbutton", + text: "Button text", + className?: string, + title: "Tooltip", + onClick: (e: React.MouseEvent) => void, +} + +// Input field +{ + elemtype: "input", + value: string, + className?: string, + onChange: (e: React.ChangeEvent) => void, + onKeyDown?: (e: React.KeyboardEvent) => void, + onFocus?: (e: React.FocusEvent) => void, + onBlur?: (e: React.FocusEvent) => void, + ref?: React.RefObject, +} + +// Container with children +{ + elemtype: "div", + className?: string, + children: HeaderElem[], + onMouseOver?: (e: React.MouseEvent) => void, + onMouseOut?: (e: React.MouseEvent) => void, +} + +// Menu button (dropdown) +{ + elemtype: "menubutton", + // ... MenuButtonProps ... +} +``` + +## Best Practices + +### Jotai Model Pattern + +Follow these rules for Jotai atoms in models: + +1. **Simple atoms as field initializers**: + ```typescript + viewIcon = jotai.atom("circle"); + noPadding = jotai.atom(true); + ``` + +2. **Derived atoms in constructor** (need dependency on other atoms): + ```typescript + constructor(blockId: string, nodeModel: BlockNodeModel) { + this.viewText = jotai.atom((get) => { + const blockData = get(this.blockAtom); + return [/* computed based on blockData */]; + }); + } + ``` + +3. **Models never use React hooks** - Use `globalStore.get()`/`set()`: + ```typescript + refresh() { + const currentData = globalStore.get(this.blockAtom); + globalStore.set(this.dataAtom, newData); + } + ``` + +4. **Components use hooks for atoms**: + ```typescript + const data = useAtomValue(model.dataAtom); + const [value, setValue] = useAtom(model.valueAtom); + ``` + +### State Management + +- All view state should live in atoms on the model +- Use `useBlockAtom()` helper for block-scoped atoms that persist +- Use `globalStore` for imperative access outside React components +- Subscribe to Wave events using `waveEventSubscribe()` + +### Styling + +- Create a `.scss` file for your view styles +- Use Tailwind utilities where possible (v4) +- Add `noPadding: atom(true)` for full-bleed content +- Use `blockBg` atom to customize block background + +### Focus Management + +Implement `giveFocus()` to focus your view when: +- Block gains focus via keyboard navigation +- User clicks the block +- Return `true` if successfully focused, `false` otherwise + +### Keyboard Handling + +Implement `keyDownHandler(e: WaveKeyboardEvent)` for: +- View-specific keyboard shortcuts +- Return `true` if event was handled (prevents propagation) +- Use `keyutil.checkKeyPressed(waveEvent, "Cmd:K")` for shortcut checks + +### Cleanup + +Implement `dispose()` to: +- Unsubscribe from Wave events +- Unregister routes/handlers +- Clear timers/intervals +- Release resources + +### Connection Management + +For views that need remote connections: +```typescript +this.manageConnection = jotai.atom(true); // Show connection picker +this.filterOutNowsh = jotai.atom(true); // Hide nowsh connections +this.showS3 = jotai.atom(true); // Show S3 connections +``` + +Access connection status: +```typescript +const connStatus = jotai.atom((get) => { + const blockData = get(this.blockAtom); + const connName = blockData?.meta?.connection; + return get(getConnStatusAtom(connName)); +}); +``` + +## Common Patterns + +### Reading Block Metadata + +```typescript +import { getBlockMetaKeyAtom } from "@/store/global"; + +// In constructor: +this.someFlag = getBlockMetaKeyAtom(blockId, "myview:flag"); + +// In component: +const flag = useAtomValue(model.someFlag); +``` + +### Configuration Overrides + +Wave has a hierarchical config system (global → connection → block): + +```typescript +import { getOverrideConfigAtom } from "@/store/global"; + +this.settingAtom = jotai.atom((get) => { + // Checks block meta, then connection config, then global settings + return get(getOverrideConfigAtom(this.blockId, "myview:setting")) ?? defaultValue; +}); +``` + +### Updating Block Metadata + +```typescript +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WOS } from "@/store/global"; + +await RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "myview:key": value }, +}); +``` + +### Search Integration + +To add in-block search: + +```typescript +import { useSearch } from "@/app/element/search"; + +// In model: +this.searchAtoms = useSearch(); // Call in component, not model! + +// In component: +const searchAtoms = useSearch(); +// Pass to model or use directly +``` + +## Testing Your View + +1. Build the frontend: `task build:dev` or `task electron:dev` +2. Create a block with your view type +3. Test all interactive elements (buttons, inputs, etc.) +4. Test keyboard shortcuts +5. Test focus behavior +6. Test cleanup (close block and check console for errors) +7. Test with different block configurations via metadata + +## Additional Resources + +- [`frontend/app/block/blockframe.tsx`](../frontend/app/block/blockframe.tsx) - Block header rendering +- [`frontend/app/view/term/term-model.ts`](../frontend/app/view/term/term-model.ts) - Complex view example +- [`frontend/app/view/webview/webview.tsx`](../frontend/app/view/webview/webview.tsx) - Navigation UI example +- [`frontend/types/custom.d.ts`](../frontend/types/custom.d.ts) - Type definitions +- Project coding rules in [`.roo/rules/`](../.roo/rules/) \ No newline at end of file diff --git a/cmd/wsh/cmd/wshcmd-secret.go b/cmd/wsh/cmd/wshcmd-secret.go index 14b0ea2781..bce1d0beb9 100644 --- a/cmd/wsh/cmd/wshcmd-secret.go +++ b/cmd/wsh/cmd/wshcmd-secret.go @@ -46,11 +46,20 @@ var secretListCmd = &cobra.Command{ PreRunE: preRunSetupRpcClient, } +var secretDeleteCmd = &cobra.Command{ + Use: "delete [name]", + Short: "delete a secret", + Args: cobra.ExactArgs(1), + RunE: secretDeleteRun, + PreRunE: preRunSetupRpcClient, +} + func init() { rootCmd.AddCommand(secretCmd) secretCmd.AddCommand(secretGetCmd) secretCmd.AddCommand(secretSetCmd) secretCmd.AddCommand(secretListCmd) + secretCmd.AddCommand(secretDeleteCmd) } func secretGetRun(cmd *cobra.Command, args []string) (rtnErr error) { @@ -103,7 +112,7 @@ func secretSetRun(cmd *cobra.Command, args []string) (rtnErr error) { return fmt.Errorf("No appropriate secret manager found, cannot set secrets") } - secrets := map[string]string{name: value} + secrets := map[string]*string{name: &value} err = wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("setting secret: %w", err) @@ -127,4 +136,24 @@ func secretListRun(cmd *cobra.Command, args []string) (rtnErr error) { WriteStdout("%s\n", name) } return nil +} + +func secretDeleteRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("secret", rtnErr == nil) + }() + + name := args[0] + if !secretNameRegex.MatchString(name) { + return fmt.Errorf("invalid secret name: must start with a letter and contain only letters, numbers, and underscores") + } + + secrets := map[string]*string{name: nil} + err := wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("deleting secret: %w", err) + } + + WriteStdout("secret deleted: %s\n", name) + return nil } \ No newline at end of file diff --git a/docs/docs/wsh-reference.mdx b/docs/docs/wsh-reference.mdx index 3136fcc06d..a6006010d0 100644 --- a/docs/docs/wsh-reference.mdx +++ b/docs/docs/wsh-reference.mdx @@ -895,4 +895,90 @@ wsh blocks list --workspace=12d0c067-378e-454c-872e-77a314248114 wsh blocks list --json ``` + +--- + +## secret + +The `secret` command provides secure storage and management of sensitive information like API keys, passwords, and tokens. Secrets are stored using your system's native secure storage backend (Keychain on macOS, Secret Service on Linux, Credential Manager on Windows). + +Secret names must start with a letter and contain only letters, numbers, and underscores. + +### get + +```sh +wsh secret get [name] +``` + +Retrieve and display the value of a stored secret. + +Examples: + +```sh +# Get an API key +wsh secret get github_token + +# Use in scripts +export API_KEY=$(wsh secret get my_api_key) +``` + +### set + +```sh +wsh secret set [name]=[value] +``` + +Store a secret value securely. This command requires an appropriate system secret manager to be available and will fail if only basic text storage is available. + +Examples: + +```sh +# Set an API token +wsh secret set github_token=ghp_abc123xyz + +# Set a database password +wsh secret set db_password=mySecurePassword123 +``` + +:::warning +The `set` command requires a proper system secret manager (Keychain, Secret Service, or Credential Manager). It will not work with basic text storage for security reasons. +::: + +### list + +```sh +wsh secret list +``` + +Display all stored secret names (values are not shown). + +Example: + +```sh +# List all secrets +wsh secret list +``` + +### delete + +```sh +wsh secret delete [name] +``` + +Remove a secret from secure storage. + +Examples: + +```sh +# Delete an API key +wsh secret delete github_token + +# Delete multiple secrets +wsh secret delete old_api_key +wsh secret delete temp_token +``` + +:::tip +Use secrets in your scripts to avoid hardcoding sensitive values. Secrets work across remote machines - store an API key locally with `wsh secret set`, then access it from any SSH or WSL connection with `wsh secret get`. The secret is securely retrieved from your local machine without needing to duplicate it on remote systems. +::: diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index f71260a738..d8260965d4 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -12,6 +12,7 @@ import { import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; import { LauncherViewModel } from "@/app/view/launcher/launcher"; import { PreviewModel } from "@/app/view/preview/preview-model"; +import { SecretStoreViewModel } from "@/app/view/secretstore/secretstore-model"; import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; import { VDomModel } from "@/app/view/vdom/vdom-model"; @@ -52,6 +53,7 @@ BlockRegistry.set("help", HelpViewModel); BlockRegistry.set("launcher", LauncherViewModel); BlockRegistry.set("tsunami", TsunamiViewModel); BlockRegistry.set("aifilediff", AiFileDiffViewModel); +BlockRegistry.set("secretstore", SecretStoreViewModel); function makeViewModel(blockId: string, blockView: string, nodeModel: BlockNodeModel): ViewModel { const ctor = BlockRegistry.get(blockView); diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index a31d9a799b..3cfd2343c2 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -30,6 +30,9 @@ export function blockViewToIcon(view: string): string { if (view == "tips") { return "lightbulb"; } + if (view == "secretstore") { + return "key"; + } return "square"; } @@ -55,6 +58,9 @@ export function blockViewToName(view: string): string { if (view == "tips") { return "Tips"; } + if (view == "secretstore") { + return "Secret Store"; + } return view; } diff --git a/frontend/app/view/secretstore/secretstore-model.ts b/frontend/app/view/secretstore/secretstore-model.ts new file mode 100644 index 0000000000..4e0e40e83a --- /dev/null +++ b/frontend/app/view/secretstore/secretstore-model.ts @@ -0,0 +1,251 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BlockNodeModel } from "@/app/block/blocktypes"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WOS, globalStore } from "@/store/global"; +import * as jotai from "jotai"; +import { SecretStoreView } from "./secretstore"; + +const SECRET_NAME_REGEX = /^[A-Za-z][A-Za-z0-9_]*$/; + +export class SecretStoreViewModel implements ViewModel { + viewType: string; + blockId: string; + blockAtom: jotai.Atom; + nodeModel: BlockNodeModel; + + viewIcon = jotai.atom("key"); + viewName = jotai.atom("Secret Store"); + + secretNames: jotai.PrimitiveAtom; + selectedSecret: jotai.PrimitiveAtom; + secretValue: jotai.PrimitiveAtom; + isLoading: jotai.PrimitiveAtom; + errorMessage: jotai.PrimitiveAtom; + storageBackendError: jotai.PrimitiveAtom; + isEditing: jotai.PrimitiveAtom; + secretShown: jotai.PrimitiveAtom; + isAddingNew: jotai.PrimitiveAtom; + newSecretName: jotai.PrimitiveAtom; + newSecretValue: jotai.PrimitiveAtom; + + secretValueRef: HTMLTextAreaElement | null = null; + + endIconButtons!: jotai.Atom; + + constructor(blockId: string, nodeModel: BlockNodeModel) { + this.viewType = "secretstore"; + this.blockId = blockId; + this.nodeModel = nodeModel; + this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + + this.secretNames = jotai.atom([]); + this.selectedSecret = jotai.atom(null) as jotai.PrimitiveAtom; + this.secretValue = jotai.atom(""); + this.isLoading = jotai.atom(false); + this.errorMessage = jotai.atom(null) as jotai.PrimitiveAtom; + this.storageBackendError = jotai.atom(null) as jotai.PrimitiveAtom; + this.isEditing = jotai.atom(false); + this.secretShown = jotai.atom(false); + this.isAddingNew = jotai.atom(false); + this.newSecretName = jotai.atom(""); + this.newSecretValue = jotai.atom(""); + + this.endIconButtons = jotai.atom((get) => { + const buttons: IconButtonDecl[] = []; + + buttons.push({ + elemtype: "iconbutton", + icon: "rotate-right", + title: "Refresh", + click: () => this.refreshSecrets(), + }); + + return buttons; + }); + + this.checkStorageBackend(); + this.refreshSecrets(); + } + + get viewComponent() { + return SecretStoreView; + } + + async checkStorageBackend() { + try { + const backend = await RpcApi.GetSecretsLinuxStorageBackendCommand(TabRpcClient); + if (backend === "basic_text" || backend === "unknown") { + globalStore.set( + this.storageBackendError, + "No appropriate secret manager found. Cannot manage secrets securely." + ); + } else { + globalStore.set(this.storageBackendError, null); + } + } catch (error) { + globalStore.set(this.storageBackendError, `Error checking storage backend: ${error.message}`); + } + } + + async refreshSecrets() { + globalStore.set(this.isLoading, true); + globalStore.set(this.errorMessage, null); + + try { + const names = await RpcApi.GetSecretsNamesCommand(TabRpcClient); + globalStore.set(this.secretNames, names || []); + } catch (error) { + globalStore.set(this.errorMessage, `Failed to load secrets: ${error.message}`); + } finally { + globalStore.set(this.isLoading, false); + } + } + + async viewSecret(name: string) { + globalStore.set(this.errorMessage, null); + globalStore.set(this.selectedSecret, name); + globalStore.set(this.isEditing, true); + globalStore.set(this.secretShown, false); + globalStore.set(this.secretValue, ""); + } + + closeSecretView() { + globalStore.set(this.selectedSecret, null); + globalStore.set(this.secretValue, ""); + globalStore.set(this.isEditing, false); + globalStore.set(this.errorMessage, null); + } + + async showSecret() { + const selectedSecret = globalStore.get(this.selectedSecret); + if (!selectedSecret) { + return; + } + + globalStore.set(this.isLoading, true); + globalStore.set(this.errorMessage, null); + + try { + const secrets = await RpcApi.GetSecretsCommand(TabRpcClient, [selectedSecret]); + const value = secrets[selectedSecret]; + if (value !== undefined) { + globalStore.set(this.secretValue, value); + globalStore.set(this.secretShown, true); + } else { + globalStore.set(this.errorMessage, `Secret not found: ${selectedSecret}`); + } + } catch (error) { + globalStore.set(this.errorMessage, `Failed to load secret: ${error.message}`); + } finally { + globalStore.set(this.isLoading, false); + } + } + + async saveSecret() { + const selectedSecret = globalStore.get(this.selectedSecret); + const secretValue = globalStore.get(this.secretValue); + + if (!selectedSecret) { + return; + } + + globalStore.set(this.isLoading, true); + globalStore.set(this.errorMessage, null); + + try { + await RpcApi.SetSecretsCommand(TabRpcClient, { [selectedSecret]: secretValue }); + this.closeSecretView(); + } catch (error) { + globalStore.set(this.errorMessage, `Failed to save secret: ${error.message}`); + } finally { + globalStore.set(this.isLoading, false); + } + } + + async deleteSecret() { + const selectedSecret = globalStore.get(this.selectedSecret); + + if (!selectedSecret) { + return; + } + + globalStore.set(this.isLoading, true); + globalStore.set(this.errorMessage, null); + + try { + await RpcApi.SetSecretsCommand(TabRpcClient, { [selectedSecret]: null }); + this.closeSecretView(); + await this.refreshSecrets(); + } catch (error) { + globalStore.set(this.errorMessage, `Failed to delete secret: ${error.message}`); + } finally { + globalStore.set(this.isLoading, false); + } + } + + startAddingSecret() { + globalStore.set(this.isAddingNew, true); + globalStore.set(this.newSecretName, ""); + globalStore.set(this.newSecretValue, ""); + globalStore.set(this.errorMessage, null); + } + + cancelAddingSecret() { + globalStore.set(this.isAddingNew, false); + globalStore.set(this.newSecretName, ""); + globalStore.set(this.newSecretValue, ""); + globalStore.set(this.errorMessage, null); + } + + async addNewSecret() { + const name = globalStore.get(this.newSecretName).trim(); + const value = globalStore.get(this.newSecretValue); + + if (!name) { + globalStore.set(this.errorMessage, "Secret name cannot be empty"); + return; + } + + if (!SECRET_NAME_REGEX.test(name)) { + globalStore.set( + this.errorMessage, + "Invalid secret name: must start with a letter and contain only letters, numbers, and underscores" + ); + return; + } + + const existingNames = globalStore.get(this.secretNames); + if (existingNames.includes(name)) { + globalStore.set(this.errorMessage, `Secret "${name}" already exists`); + return; + } + + globalStore.set(this.isLoading, true); + globalStore.set(this.errorMessage, null); + + try { + await RpcApi.SetSecretsCommand(TabRpcClient, { [name]: value }); + globalStore.set(this.isAddingNew, false); + globalStore.set(this.newSecretName, ""); + globalStore.set(this.newSecretValue, ""); + await this.refreshSecrets(); + } catch (error) { + globalStore.set(this.errorMessage, `Failed to add secret: ${error.message}`); + } finally { + globalStore.set(this.isLoading, false); + } + } + + giveFocus(): boolean { + if (this.secretValueRef) { + this.secretValueRef.focus(); + return true; + } + return true; + } + + dispose() {} +} diff --git a/frontend/app/view/secretstore/secretstore.tsx b/frontend/app/view/secretstore/secretstore.tsx new file mode 100644 index 0000000000..d695522ea3 --- /dev/null +++ b/frontend/app/view/secretstore/secretstore.tsx @@ -0,0 +1,379 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { useAtomValue, useSetAtom } from "jotai"; +import { memo } from "react"; +import { SecretStoreViewModel } from "./secretstore-model"; + +interface ErrorDisplayProps { + message: string; + variant?: "error" | "warning"; +} + +const ErrorDisplay = memo(({ message, variant = "error" }: ErrorDisplayProps) => { + const icon = variant === "error" ? "fa-circle-exclamation" : "fa-triangle-exclamation"; + const baseClasses = "flex items-center gap-2 p-4 border rounded-lg"; + const variantClasses = + variant === "error" + ? "bg-red-500/10 border-red-500/20 text-red-400" + : "bg-yellow-500/10 border-yellow-500/20 text-yellow-400"; + + return ( +
+ + {message} +
+ ); +}); +ErrorDisplay.displayName = "ErrorDisplay"; + +const LoadingSpinner = memo(({ message }: { message: string }) => { + return ( +
+ + {message} +
+ ); +}); +LoadingSpinner.displayName = "LoadingSpinner"; + +const EmptyState = memo(() => { + return ( +
+ +

No Secrets

+

Add a secret to get started

+
+ ); +}); +EmptyState.displayName = "EmptyState"; + +const CLIInfoBubble = memo(() => { + return ( +
+
+ +
CLI Access
+
+
+ wsh secret list +
+ wsh secret get [name] +
+ wsh secret set [name]=[value] +
+
+ ); +}); +CLIInfoBubble.displayName = "CLIInfoBubble"; + +interface SecretListViewProps { + secretNames: string[]; + onSelectSecret: (name: string) => void; + onAddSecret: () => void; +} + +const SecretListView = memo(({ secretNames, onSelectSecret, onAddSecret }: SecretListViewProps) => { + return ( +
+
+

Secrets

+ {secretNames.length} +
+
+ {secretNames.map((name) => ( +
onSelectSecret(name)} + > + + {name} + +
+ ))} +
+ + Add New Secret +
+
+ +
+ ); +}); +SecretListView.displayName = "SecretListView"; + +interface AddSecretFormProps { + newSecretName: string; + newSecretValue: string; + isLoading: boolean; + onNameChange: (name: string) => void; + onValueChange: (value: string) => void; + onCancel: () => void; + onSubmit: () => void; +} + +const AddSecretForm = memo( + ({ + newSecretName, + newSecretValue, + isLoading, + onNameChange, + onValueChange, + onCancel, + onSubmit, + }: AddSecretFormProps) => { + const secretNameRegex = /^[A-Za-z][A-Za-z0-9_]*$/; + const isNameInvalid = newSecretName !== "" && !secretNameRegex.test(newSecretName); + + return ( +
+

Add New Secret

+
+ + onNameChange(e.target.value)} + placeholder="MY_SECRET_NAME" + disabled={isLoading} + /> +
+ Must start with a letter and contain only letters, numbers, and underscores +
+
+
+ +