waveterm/tsunami/rpctypes/protocoltypes.go
Copilot 58e000bf12
Add alert and confirm modal system for tsunami apps (#2484)
## Alert and Confirm Modal System for Tsunami

This PR implements a complete modal system for the tsunami app framework
as specified in the requirements.

### Implementation Summary

**Backend (Go) - 574 lines changed across 11 files:**

1. **Type Definitions** (`rpctypes/protocoltypes.go`):
- `ModalConfig`: Configuration for modal display with icon, title, text,
and button labels
- `ModalResult`: Result structure containing modal ID and confirmation
status

2. **Client State Management** (`engine/clientimpl.go`):
   - Added `ModalState` to track open modals with result channels
   - `OpenModals` map to track all currently open modals
- `ShowModal()`: Sends SSE event to display modal and returns result
channel
   - `CloseModal()`: Processes modal result from frontend
- `CloseAllModals()`: Automatically cancels all modals when frontend
sends Resync flag (page refresh)

3. **API Endpoint** (`engine/serverhandlers.go`):
- `/api/modalresult` POST endpoint to receive modal results from
frontend
   - Validates and processes `ModalResult` JSON payload
   - Closes all modals on Resync (page refresh) before processing events

4. **User-Facing Hooks** (`app/hooks.go`):
   - `UseAlertModal()`: Returns (isOpen, triggerAlert) for alert modals
- `UseConfirmModal()`: Returns (isOpen, triggerConfirm) for confirm
modals
   - Both hooks manage local state and handle async modal lifecycle

**Frontend (TypeScript/React):**

1. **Type Definitions** (`types/vdom.d.ts`):
   - Added `ModalConfig` and `ModalResult` TypeScript types

2. **Modal Components** (`element/modals.tsx`):
- `AlertModal`: Dark-mode styled alert with icon, title, text, and OK
button
- `ConfirmModal`: Dark-mode styled confirm with icon, title, text, OK
and Cancel buttons
   - Both support keyboard (ESC) and backdrop click dismissal
   - Fully accessible with focus management

3. **Model Integration** (`model/tsunami-model.tsx`):
   - Added `currentModal` atom to track displayed modal
   - SSE event handler for `showmodal` events
- `sendModalResult()`: Sends result to `/api/modalresult` and clears
modal

4. **UI Integration** (`vdom.tsx`):
   - Integrated modal display in `VDomView` component
   - Conditionally renders alert or confirm modal based on type

**Demo Application** (`demo/modaltest/`):
- Comprehensive demonstration of modal functionality
- Shows 4 different modal configurations:
  - Alert with icon
  - Simple alert with custom button text
  - Confirm modal
  - Delete confirmation with custom buttons
- Displays modal state and results in real-time

### Key Features

 **SSE-Based Modal Display**: Modals are pushed from backend to
frontend via SSE
 **API-Based Result Handling**: Results sent back via
`/api/modalresult` endpoint
 **Automatic Cleanup**: All open modals auto-cancel on page refresh
(when Resync flag is set)
 **Type-Safe Hooks**: Full TypeScript and Go type safety  
 **Dark Mode UI**: Components styled for Wave Terminal's dark theme  
 **Accessibility**: Keyboard navigation, ESC to dismiss, backdrop click
support
 **Zero Security Issues**: Passed CodeQL security analysis  
 **Zero Code Review Issues**: Clean implementation following best
practices

### Testing

-  Go code compiles without errors
-  TypeScript/React builds without errors  
-  All existing tests pass
-  Demo app created and compiles successfully
-  CodeQL security scan: 0 vulnerabilities
-  Code review: 0 issues

### Security Summary

No security vulnerabilities were introduced. All modal operations are
properly scoped to the client's SSE connection, and modal IDs are
generated server-side to prevent tampering.


Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
2025-10-27 18:11:19 -07:00

208 lines
7.5 KiB
Go

// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package rpctypes
import (
"fmt"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// rendered element (output from rendering pipeline)
type RenderedElem struct {
WaveId string `json:"waveid,omitempty"` // required, except for #text nodes
Tag string `json:"tag"`
Props map[string]any `json:"props,omitempty"`
Children []RenderedElem `json:"children,omitempty"`
Text string `json:"text,omitempty"`
}
type VDomUrlRequestResponse struct {
StatusCode int `json:"statuscode,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Body []byte `json:"body,omitempty"`
}
type VDomFrontendUpdate struct {
Type string `json:"type" tstype:"\"frontendupdate\""`
Ts int64 `json:"ts"`
ClientId string `json:"clientid"`
ForceTakeover bool `json:"forcetakeover,omitempty"`
CorrelationId string `json:"correlationid,omitempty"`
Reason string `json:"reason,omitempty"`
Dispose bool `json:"dispose,omitempty"` // the vdom context was closed
Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads
RenderContext VDomRenderContext `json:"rendercontext,omitempty"`
Events []vdom.VDomEvent `json:"events,omitempty"`
RefUpdates []VDomRefUpdate `json:"refupdates,omitempty"`
Messages []VDomMessage `json:"messages,omitempty"`
}
type VDomBackendUpdate struct {
Type string `json:"type" tstype:"\"backendupdate\""`
Ts int64 `json:"ts"`
ServerId string `json:"serverid"`
Opts *VDomBackendOpts `json:"opts,omitempty"`
HasWork bool `json:"haswork,omitempty"`
FullUpdate bool `json:"fullupdate,omitempty"`
RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"`
TransferElems []VDomTransferElem `json:"transferelems,omitempty"`
TransferText []VDomText `json:"transfertext,omitempty"`
RefOperations []vdom.VDomRefOperation `json:"refoperations,omitempty"`
Messages []VDomMessage `json:"messages,omitempty"`
}
// the over the wire format for a vdom element
type VDomTransferElem struct {
WaveId string `json:"waveid,omitempty"` // required, except for #text nodes
Tag string `json:"tag"`
Props map[string]any `json:"props,omitempty"`
Children []string `json:"children,omitempty"`
Text string `json:"text,omitempty"`
}
type VDomText struct {
Id int `json:"id"`
Text string `json:"text"`
}
func (beUpdate *VDomBackendUpdate) CreateTransferElems() {
var renderedElems []RenderedElem
for idx, reUpdate := range beUpdate.RenderUpdates {
if reUpdate.VDom == nil {
continue
}
renderedElems = append(renderedElems, *reUpdate.VDom)
beUpdate.RenderUpdates[idx].VDomWaveId = reUpdate.VDom.WaveId
beUpdate.RenderUpdates[idx].VDom = nil
}
transferElems, transferText := ConvertElemsToTransferElems(renderedElems)
transferElems = DedupTransferElems(transferElems)
beUpdate.TransferElems = transferElems
beUpdate.TransferText = transferText
}
func ConvertElemsToTransferElems(elems []RenderedElem) ([]VDomTransferElem, []VDomText) {
var transferElems []VDomTransferElem
var transferText []VDomText
textMap := make(map[string]int) // map text content to ID for deduplication
// Helper function to recursively process each RenderedElem in preorder
var processElem func(elem RenderedElem) string
processElem = func(elem RenderedElem) string {
// Handle #text nodes with deduplication
if elem.Tag == "#text" {
textId, exists := textMap[elem.Text]
if !exists {
// New text content, create new entry
textId = len(textMap) + 1
textMap[elem.Text] = textId
transferText = append(transferText, VDomText{
Id: textId,
Text: elem.Text,
})
}
// Return sentinel string with ID (no VDomTransferElem created)
textIdStr := fmt.Sprintf("t:%d", textId)
return textIdStr
}
// Convert children to WaveId references, handling potential #text nodes
childrenIds := make([]string, len(elem.Children))
for i, child := range elem.Children {
childrenIds[i] = processElem(child) // Children are not roots
}
// Create the VDomTransferElem for the current element
transferElem := VDomTransferElem{
WaveId: elem.WaveId,
Tag: elem.Tag,
Props: elem.Props,
Children: childrenIds,
Text: elem.Text,
}
transferElems = append(transferElems, transferElem)
return elem.WaveId
}
// Start processing each top-level element, marking them as roots
for _, elem := range elems {
processElem(elem)
}
return transferElems, transferText
}
func DedupTransferElems(elems []VDomTransferElem) []VDomTransferElem {
seen := make(map[string]int) // maps WaveId to its index in the result slice
var result []VDomTransferElem
for _, elem := range elems {
if idx, exists := seen[elem.WaveId]; exists {
// Overwrite the previous element with the latest one
result[idx] = elem
} else {
// Add new element and store its index
seen[elem.WaveId] = len(result)
result = append(result, elem)
}
}
return result
}
type VDomRenderContext struct {
Focused bool `json:"focused"`
Width int `json:"width"`
Height int `json:"height"`
RootRefId string `json:"rootrefid"`
Background bool `json:"background,omitempty"`
}
type VDomRefUpdate struct {
RefId string `json:"refid"`
HasCurrent bool `json:"hascurrent"`
Position *vdom.VDomRefPosition `json:"position,omitempty"`
}
type VDomBackendOpts struct {
Title string `json:"title,omitempty"`
ShortDesc string `json:"shortdesc,omitempty"`
GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"`
FaviconPath string `json:"faviconpath,omitempty"`
}
type VDomRenderUpdate struct {
UpdateType string `json:"updatetype" tstype:"\"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\""`
WaveId string `json:"waveid,omitempty"`
VDomWaveId string `json:"vdomwaveid,omitempty"`
VDom *RenderedElem `json:"vdom,omitempty"` // these get removed for transfer (encoded to transferelems)
Index *int `json:"index,omitempty"`
}
type VDomMessage struct {
MessageType string `json:"messagetype"`
Message string `json:"message"`
StackTrace string `json:"stacktrace,omitempty"`
Params []any `json:"params,omitempty"`
}
// ModalConfig contains all configuration options for modals
type ModalConfig struct {
ModalId string `json:"modalid"` // Unique identifier for the modal
ModalType string `json:"modaltype"` // "alert" or "confirm"
Icon string `json:"icon,omitempty"` // Optional icon to display (emoji or icon name)
Title string `json:"title"` // Modal title
Text string `json:"text,omitempty"` // Optional body text
OkText string `json:"oktext,omitempty"` // Optional OK button text (defaults to "OK")
CancelText string `json:"canceltext,omitempty"` // Optional Cancel button text for confirm modals (defaults to "Cancel")
}
// ModalResult contains the result of a modal interaction
type ModalResult struct {
ModalId string `json:"modalid"` // ID of the modal
Confirm bool `json:"confirm"` // true = confirmed/ok, false = cancelled
}