waveterm/tsunami/app/hooks.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

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
}