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>
301 lines
9.4 KiB
Go
301 lines
9.4 KiB
Go
// Copyright 2025, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/wavetermdev/waveterm/tsunami/engine"
|
|
"github.com/wavetermdev/waveterm/tsunami/rpctypes"
|
|
"github.com/wavetermdev/waveterm/tsunami/util"
|
|
"github.com/wavetermdev/waveterm/tsunami/vdom"
|
|
)
|
|
|
|
// UseVDomRef provides a reference to a DOM element in the VDOM tree.
|
|
// It returns a VDomRef that can be attached to elements for direct DOM access.
|
|
// The ref will not be current on the first render - refs are set and become
|
|
// current after client-side mounting.
|
|
// This hook must be called within a component context.
|
|
func UseVDomRef() *vdom.VDomRef {
|
|
rc := engine.GetGlobalRenderContext()
|
|
val := engine.UseVDomRef(rc)
|
|
refVal, ok := val.(*vdom.VDomRef)
|
|
if !ok {
|
|
panic("UseVDomRef hook value is not a ref (possible out of order or conditional hooks)")
|
|
}
|
|
return refVal
|
|
}
|
|
|
|
// UseRef is the tsunami analog to React's useRef hook.
|
|
// It provides a mutable ref object that persists across re-renders.
|
|
// Unlike UseVDomRef, this is not tied to DOM elements but holds arbitrary values.
|
|
// This hook must be called within a component context.
|
|
func UseRef[T any](val T) *vdom.VDomSimpleRef[T] {
|
|
rc := engine.GetGlobalRenderContext()
|
|
refVal := engine.UseRef(rc, &vdom.VDomSimpleRef[T]{Current: val})
|
|
typedRef, ok := refVal.(*vdom.VDomSimpleRef[T])
|
|
if !ok {
|
|
panic("UseRef hook value is not a ref (possible out of order or conditional hooks)")
|
|
}
|
|
return typedRef
|
|
}
|
|
|
|
// UseId returns the underlying component's unique identifier (UUID).
|
|
// The ID persists across re-renders but is recreated when the component
|
|
// is recreated, following React component lifecycle.
|
|
// This hook must be called within a component context.
|
|
func UseId() string {
|
|
rc := engine.GetGlobalRenderContext()
|
|
if rc == nil {
|
|
panic("UseId must be called within a component (no context)")
|
|
}
|
|
return engine.UseId(rc)
|
|
}
|
|
|
|
// UseRenderTs returns the timestamp of the current render.
|
|
// This hook must be called within a component context.
|
|
func UseRenderTs() int64 {
|
|
rc := engine.GetGlobalRenderContext()
|
|
if rc == nil {
|
|
panic("UseRenderTs must be called within a component (no context)")
|
|
}
|
|
return engine.UseRenderTs(rc)
|
|
}
|
|
|
|
// UseResync returns whether the current render is a resync operation.
|
|
// Resyncs happen on initial app loads or full refreshes, as opposed to
|
|
// incremental renders which happen otherwise.
|
|
// This hook must be called within a component context.
|
|
func UseResync() bool {
|
|
rc := engine.GetGlobalRenderContext()
|
|
if rc == nil {
|
|
panic("UseResync must be called within a component (no context)")
|
|
}
|
|
return engine.UseResync(rc)
|
|
}
|
|
|
|
// UseEffect is the tsunami analog to React's useEffect hook.
|
|
// It queues effects to run after the render cycle completes.
|
|
// The function can return a cleanup function that runs before the next effect
|
|
// or when the component unmounts. Dependencies use shallow comparison, just like React.
|
|
// This hook must be called within a component context.
|
|
func UseEffect(fn func() func(), deps []any) {
|
|
// note UseEffect never actually runs anything, it just queues the effect to run later
|
|
rc := engine.GetGlobalRenderContext()
|
|
if rc == nil {
|
|
panic("UseEffect must be called within a component (no context)")
|
|
}
|
|
engine.UseEffect(rc, fn, deps)
|
|
}
|
|
|
|
// UseLocal creates a component-local atom that is automatically cleaned up when the component unmounts.
|
|
// The atom is created with a unique name based on the component's wave ID and hook index.
|
|
// This hook must be called within a component context.
|
|
func UseLocal[T any](initialVal T) Atom[T] {
|
|
rc := engine.GetGlobalRenderContext()
|
|
if rc == nil {
|
|
panic("UseLocal must be called within a component (no context)")
|
|
}
|
|
atomName := engine.UseLocal(rc, initialVal)
|
|
return Atom[T]{
|
|
name: atomName,
|
|
client: engine.GetDefaultClient(),
|
|
}
|
|
}
|
|
|
|
// UseGoRoutine manages a goroutine lifecycle within a component.
|
|
// It spawns a new goroutine with the provided function when dependencies change,
|
|
// and automatically cancels the context on dependency changes or component unmount.
|
|
// This hook must be called within a component context.
|
|
func UseGoRoutine(fn func(ctx context.Context), deps []any) {
|
|
rc := engine.GetGlobalRenderContext()
|
|
if rc == nil {
|
|
panic("UseGoRoutine must be called within a component (no context)")
|
|
}
|
|
|
|
// Use UseRef to store the cancel function
|
|
cancelRef := UseRef[context.CancelFunc](nil)
|
|
|
|
UseEffect(func() func() {
|
|
// Cancel any existing goroutine
|
|
if cancelRef.Current != nil {
|
|
cancelRef.Current()
|
|
}
|
|
|
|
// Create new context and start goroutine
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancelRef.Current = cancel
|
|
|
|
componentName := "unknown"
|
|
if rc.Comp != nil && rc.Comp.Elem != nil {
|
|
componentName = rc.Comp.Elem.Tag
|
|
}
|
|
|
|
go func() {
|
|
defer func() {
|
|
util.PanicHandler(fmt.Sprintf("UseGoRoutine in component '%s'", componentName), recover())
|
|
}()
|
|
fn(ctx)
|
|
}()
|
|
|
|
// Return cleanup function that cancels the context
|
|
return func() {
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
}
|
|
}, deps)
|
|
}
|
|
|
|
// UseTicker manages a ticker lifecycle within a component.
|
|
// It creates a ticker that calls the provided function at regular intervals.
|
|
// The ticker is automatically stopped on dependency changes or component unmount.
|
|
// This hook must be called within a component context.
|
|
func UseTicker(interval time.Duration, tickFn func(), deps []any) {
|
|
UseGoRoutine(func(ctx context.Context) {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
tickFn()
|
|
}
|
|
}
|
|
}, deps)
|
|
}
|
|
|
|
// UseAfter manages a timeout lifecycle within a component.
|
|
// It creates a timer that calls the provided function after the specified duration.
|
|
// The timer is automatically canceled on dependency changes or component unmount.
|
|
// This hook must be called within a component context.
|
|
func UseAfter(duration time.Duration, timeoutFn func(), deps []any) {
|
|
UseGoRoutine(func(ctx context.Context) {
|
|
timer := time.NewTimer(duration)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-timer.C:
|
|
timeoutFn()
|
|
}
|
|
}, deps)
|
|
}
|
|
|
|
// ModalConfig contains all configuration options for modals
|
|
type ModalConfig struct {
|
|
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")
|
|
OnClose func() `json:"-"` // Optional callback for alert modals when dismissed
|
|
OnResult func(bool) `json:"-"` // Optional callback for confirm modals with the result (true = confirmed, false = cancelled)
|
|
}
|
|
|
|
// UseAlertModal returns a boolean indicating if the modal is open and a function to trigger it
|
|
func UseAlertModal() (modalOpen bool, triggerAlert func(config ModalConfig)) {
|
|
isOpen := UseLocal(false)
|
|
|
|
trigger := func(config ModalConfig) {
|
|
if isOpen.Get() {
|
|
log.Printf("warning: UseAlertModal trigger called while modal is already open")
|
|
if config.OnClose != nil {
|
|
go func() {
|
|
defer func() {
|
|
util.PanicHandler("UseAlertModal callback goroutine", recover())
|
|
}()
|
|
time.Sleep(10 * time.Millisecond)
|
|
config.OnClose()
|
|
}()
|
|
}
|
|
return
|
|
}
|
|
isOpen.Set(true)
|
|
|
|
// Create modal config for backend
|
|
modalId := uuid.New().String()
|
|
backendConfig := rpctypes.ModalConfig{
|
|
ModalId: modalId,
|
|
ModalType: "alert",
|
|
Icon: config.Icon,
|
|
Title: config.Title,
|
|
Text: config.Text,
|
|
OkText: config.OkText,
|
|
CancelText: config.CancelText,
|
|
}
|
|
|
|
// Show modal and wait for result in a goroutine
|
|
go func() {
|
|
defer func() {
|
|
util.PanicHandler("UseAlertModal goroutine", recover())
|
|
}()
|
|
resultChan := engine.GetDefaultClient().ShowModal(backendConfig)
|
|
<-resultChan // Wait for result (always dismissed for alerts)
|
|
isOpen.Set(false)
|
|
if config.OnClose != nil {
|
|
config.OnClose()
|
|
}
|
|
}()
|
|
}
|
|
|
|
return isOpen.Get(), trigger
|
|
}
|
|
|
|
// UseConfirmModal returns a boolean indicating if the modal is open and a function to trigger it
|
|
func UseConfirmModal() (modalOpen bool, triggerConfirm func(config ModalConfig)) {
|
|
isOpen := UseLocal(false)
|
|
|
|
trigger := func(config ModalConfig) {
|
|
if isOpen.Get() {
|
|
log.Printf("warning: UseConfirmModal trigger called while modal is already open")
|
|
if config.OnResult != nil {
|
|
go func() {
|
|
defer func() {
|
|
util.PanicHandler("UseConfirmModal callback goroutine", recover())
|
|
}()
|
|
time.Sleep(10 * time.Millisecond)
|
|
config.OnResult(false)
|
|
}()
|
|
}
|
|
return
|
|
}
|
|
isOpen.Set(true)
|
|
|
|
// Create modal config for backend
|
|
modalId := uuid.New().String()
|
|
backendConfig := rpctypes.ModalConfig{
|
|
ModalId: modalId,
|
|
ModalType: "confirm",
|
|
Icon: config.Icon,
|
|
Title: config.Title,
|
|
Text: config.Text,
|
|
OkText: config.OkText,
|
|
CancelText: config.CancelText,
|
|
}
|
|
|
|
// Show modal and wait for result in a goroutine
|
|
go func() {
|
|
defer func() {
|
|
util.PanicHandler("UseConfirmModal goroutine", recover())
|
|
}()
|
|
resultChan := engine.GetDefaultClient().ShowModal(backendConfig)
|
|
result := <-resultChan
|
|
isOpen.Set(false)
|
|
if config.OnResult != nil {
|
|
config.OnResult(result)
|
|
}
|
|
}()
|
|
}
|
|
|
|
return isOpen.Get(), trigger
|
|
}
|
|
|