mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-11-27 20:50:25 +08:00
## 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>
208 lines
7.5 KiB
Go
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
|
|
}
|