From cdc34fee1e54c66706e0a934da0ff8a670af4499 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 5 Nov 2025 16:14:41 -0800 Subject: [PATCH 1/4] working on a new UI for the secretstore --- aiprompts/newview.md | 525 ++++++++++++++++++ frontend/app/block/block.tsx | 2 + frontend/app/block/blockutil.tsx | 6 + .../app/view/secretstore/secretstore-model.ts | 228 ++++++++ frontend/app/view/secretstore/secretstore.tsx | 376 +++++++++++++ 5 files changed, 1137 insertions(+) create mode 100644 aiprompts/newview.md create mode 100644 frontend/app/view/secretstore/secretstore-model.ts create mode 100644 frontend/app/view/secretstore/secretstore.tsx 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/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..1c35de1022 --- /dev/null +++ b/frontend/app/view/secretstore/secretstore-model.ts @@ -0,0 +1,228 @@ +// 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; + isAddingNew: jotai.PrimitiveAtom; + newSecretName: jotai.PrimitiveAtom; + newSecretValue: jotai.PrimitiveAtom; + + 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.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.isLoading, true); + globalStore.set(this.errorMessage, null); + globalStore.set(this.selectedSecret, name); + globalStore.set(this.isEditing, false); + + try { + const secrets = await RpcApi.GetSecretsCommand(TabRpcClient, [name]); + const value = secrets[name]; + if (value !== undefined) { + globalStore.set(this.secretValue, value); + } else { + globalStore.set(this.errorMessage, `Secret not found: ${name}`); + globalStore.set(this.secretValue, ""); + } + } catch (error) { + globalStore.set(this.errorMessage, `Failed to load secret: ${error.message}`); + globalStore.set(this.secretValue, ""); + } finally { + globalStore.set(this.isLoading, false); + } + } + + closeSecretView() { + globalStore.set(this.selectedSecret, null); + globalStore.set(this.secretValue, ""); + globalStore.set(this.isEditing, false); + globalStore.set(this.errorMessage, null); + } + + startEditingSecret() { + globalStore.set(this.isEditing, true); + } + + cancelEditingSecret() { + globalStore.set(this.isEditing, false); + const selectedSecret = globalStore.get(this.selectedSecret); + if (selectedSecret) { + this.viewSecret(selectedSecret); + } + } + + 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 }); + globalStore.set(this.isEditing, false); + } catch (error) { + globalStore.set(this.errorMessage, `Failed to save 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 { + return true; + } + + dispose() { + } +} \ No newline at end of file diff --git a/frontend/app/view/secretstore/secretstore.tsx b/frontend/app/view/secretstore/secretstore.tsx new file mode 100644 index 0000000000..cb8c5b41c5 --- /dev/null +++ b/frontend/app/view/secretstore/secretstore.tsx @@ -0,0 +1,376 @@ +// 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 +
+
+
+ +