Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion tsunami/app/defaultclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ func SendAsyncInitiation() error {
return engine.GetDefaultClient().SendAsyncInitiation()
}

func TermWrite(ref *vdom.VDomRef, data string) error {
if ref == nil || !ref.HasCurrent.Load() {
return nil
}
return engine.GetDefaultClient().SendTermWrite(ref.RefId, data)
}

func ConfigAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] {
fullName := "$config." + name
client := engine.GetDefaultClient()
Expand Down Expand Up @@ -155,7 +162,7 @@ func DeepCopy[T any](v T) T {
// If the ref is nil or not current, the operation is ignored.
// This function must be called within a component context.
func QueueRefOp(ref *vdom.VDomRef, op vdom.VDomRefOperation) {
if ref == nil || !ref.HasCurrent {
if ref == nil || !ref.HasCurrent.Load() {
return
}
if op.RefId == "" {
Expand Down
32 changes: 32 additions & 0 deletions tsunami/app/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,38 @@ func UseVDomRef() *vdom.VDomRef {
return refVal
}

// TermRef wraps a VDomRef and implements io.Writer by forwarding writes to the terminal.
type TermRef struct {
*vdom.VDomRef
}

// Write implements io.Writer by sending data to the terminal via TermWrite.
func (tr *TermRef) Write(p []byte) (n int, err error) {
if tr.VDomRef == nil || !tr.VDomRef.HasCurrent.Load() {
return 0, fmt.Errorf("TermRef not current")
}
err = TermWrite(tr.VDomRef, string(p))
if err != nil {
return 0, err
}
return len(p), nil
}

// TermSize returns the current terminal size, or nil if not yet set.
func (tr *TermRef) TermSize() *vdom.VDomTermSize {
if tr.VDomRef == nil {
return nil
}
return tr.VDomRef.TermSize
}

// UseTermRef returns a TermRef that can be passed as a ref to "wave:term" elements
// and also implements io.Writer for writing directly to the terminal.
func UseTermRef() *TermRef {
ref := UseVDomRef()
return &TermRef{VDomRef: ref}
}

// 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.
Expand Down
12 changes: 10 additions & 2 deletions tsunami/cmd/main-tsunami.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,22 @@ func validateEnvironmentVars(opts *build.BuildOpts) error {
if scaffoldPath == "" {
return fmt.Errorf("%s environment variable must be set", EnvTsunamiScaffoldPath)
}
absScaffoldPath, err := filepath.Abs(scaffoldPath)
if err != nil {
return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiScaffoldPath, err)
}

sdkReplacePath := os.Getenv(EnvTsunamiSdkReplacePath)
if sdkReplacePath == "" {
return fmt.Errorf("%s environment variable must be set", EnvTsunamiSdkReplacePath)
}
absSdkReplacePath, err := filepath.Abs(sdkReplacePath)
if err != nil {
return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiSdkReplacePath, err)
}

opts.ScaffoldPath = scaffoldPath
opts.SdkReplacePath = sdkReplacePath
opts.ScaffoldPath = absScaffoldPath
opts.SdkReplacePath = absSdkReplacePath

// NodePath is optional
if nodePath := os.Getenv(EnvTsunamiNodePath); nodePath != "" {
Expand Down
13 changes: 13 additions & 0 deletions tsunami/engine/clientimpl.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package engine

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
Expand Down Expand Up @@ -304,6 +305,18 @@ func (c *ClientImpl) SendAsyncInitiation() error {
return c.SendSSEvent(ssEvent{Event: "asyncinitiation", Data: nil})
}

func (c *ClientImpl) SendTermWrite(refId string, data string) error {
payload := rpctypes.TermWritePacket{
RefId: refId,
Data64: base64.StdEncoding.EncodeToString([]byte(data)),
}
jsonData, err := json.Marshal(payload)
if err != nil {
return err
}
return c.SendSSEvent(ssEvent{Event: "termwrite", Data: jsonData})
}

func makeNullRendered() *rpctypes.RenderedElem {
return &rpctypes.RenderedElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag}
}
Expand Down
11 changes: 5 additions & 6 deletions tsunami/engine/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package engine

import (
"fmt"
"log"
"reflect"
"unicode"

Expand Down Expand Up @@ -247,12 +248,6 @@ func convertPropsToVDom(props map[string]any) map[string]any {
vdomProps[k] = vdomFuncPtr
continue
}
if vdomRef, ok := v.(vdom.VDomRef); ok {
// ensure Type is set on all VDomRefs
vdomRef.Type = vdom.ObjectType_Ref
vdomProps[k] = vdomRef
continue
}
if vdomRefPtr, ok := v.(*vdom.VDomRef); ok {
if vdomRefPtr == nil {
continue // handle typed-nil
Expand All @@ -263,6 +258,10 @@ func convertPropsToVDom(props map[string]any) map[string]any {
continue
}
val := reflect.ValueOf(v)
if val.Type() == reflect.TypeOf(vdom.VDomRef{}) {
log.Printf("warning: VDomRef passed as non-pointer for prop %q (VDomRef contains atomics and must be passed as *VDomRef); dropping prop\n", k)
continue
}
if val.Kind() == reflect.Func {
// convert go functions passed to event handlers to VDomFuncs
vdomProps[k] = vdom.VDomFunc{Type: vdom.ObjectType_Func}
Expand Down
6 changes: 4 additions & 2 deletions tsunami/engine/rootelem.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,9 +443,11 @@ func (r *RootElem) UpdateRef(updateRef rpctypes.VDomRefUpdate) {
if !ok {
return
}
ref.HasCurrent = updateRef.HasCurrent
ref.HasCurrent.Store(updateRef.HasCurrent)
ref.Position = updateRef.Position
r.addRenderWork(waveId)
if updateRef.TermSize != nil {
ref.TermSize = updateRef.TermSize
}
}

func (r *RootElem) QueueRefOp(op vdom.VDomRefOperation) {
Expand Down
44 changes: 44 additions & 0 deletions tsunami/engine/serverhandlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/wavetermdev/waveterm/tsunami/rpctypes"
"github.com/wavetermdev/waveterm/tsunami/util"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)

const SSEKeepAliveDuration = 5 * time.Second
Expand Down Expand Up @@ -83,6 +84,7 @@ func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) {
mux.HandleFunc("/api/schemas", h.handleSchemas)
mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile))
mux.HandleFunc("/api/modalresult", h.handleModalResult)
mux.HandleFunc("/api/terminput", h.handleTermInput)
mux.HandleFunc("/dyn/", h.handleDynContent)

// Add handler for static files at /static/ path
Expand Down Expand Up @@ -392,6 +394,48 @@ func (h *httpHandlers) handleModalResult(w http.ResponseWriter, r *http.Request)
json.NewEncoder(w).Encode(map[string]any{"success": true})
}

func (h *httpHandlers) handleTermInput(w http.ResponseWriter, r *http.Request) {
defer func() {
panicErr := util.PanicHandler("handleTermInput", recover())
if panicErr != nil {
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
}
}()

setNoCacheHeaders(w)

if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}

body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest)
return
}

var event vdom.VDomEvent
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest)
return
}
if strings.TrimSpace(event.WaveId) == "" {
http.Error(w, "waveid is required", http.StatusBadRequest)
return
}
if event.TermInput == nil {
http.Error(w, "terminput is required", http.StatusBadRequest)
return
}

h.renderLock.Lock()
h.Client.Root.Event(event, h.Client.GlobalEventHandler)
h.renderLock.Unlock()

w.WriteHeader(http.StatusNoContent)
}

func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) {
defer func() {
panicErr := util.PanicHandler("handleDynContent", recover())
Expand Down
157 changes: 157 additions & 0 deletions tsunami/frontend/src/element/tsunamiterm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import * as React from "react";

import { base64ToArray } from "@/util/base64";

export type TsunamiTermElem = HTMLDivElement & {
__termWrite: (data64: string) => void;
__termFocus: () => void;
__termSize: () => VDomTermSize | null;
};

type TsunamiTermProps = React.HTMLAttributes<HTMLDivElement> & {
onData?: (data: string | null, termsize: VDomTermSize | null) => void;
termFontSize?: number;
termFontFamily?: string;
termScrollback?: number;
};

const TsunamiTerm = React.forwardRef<HTMLDivElement, TsunamiTermProps>(function TsunamiTerm(props, ref) {
const { onData, termFontSize, termFontFamily, termScrollback, ...outerProps } = props;
const outerRef = React.useRef<TsunamiTermElem>(null);
const termRef = React.useRef<HTMLDivElement>(null);
const terminalRef = React.useRef<Terminal | null>(null);
const onDataRef = React.useRef(onData);
onDataRef.current = onData;

const setOuterRef = React.useCallback(
(elem: TsunamiTermElem) => {
outerRef.current = elem;
if (elem != null) {
elem.__termWrite = (data64: string) => {
if (data64 == null || data64 === "") {
return;
}
try {
terminalRef.current?.write(base64ToArray(data64));
} catch (error) {
console.error("Failed to write to terminal:", error);
}
};
elem.__termFocus = () => {
terminalRef.current?.focus();
};
elem.__termSize = () => {
const terminal = terminalRef.current;
if (terminal == null) {
return null;
}
return { rows: terminal.rows, cols: terminal.cols };
};
}
if (typeof ref === "function") {
ref(elem);
return;
}
if (ref != null) {
ref.current = elem;
}
},
[ref]
);

React.useEffect(() => {
if (termRef.current == null) {
return;
}
const terminal = new Terminal({
convertEol: false,
...(termFontSize != null ? { fontSize: termFontSize } : {}),
...(termFontFamily != null ? { fontFamily: termFontFamily } : {}),
...(termScrollback != null ? { scrollback: termScrollback } : {}),
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(termRef.current);
fitAddon.fit();
terminalRef.current = terminal;

const onDataDisposable = terminal.onData((data) => {
if (onDataRef.current == null) {
return;
}
onDataRef.current(data, null);
});
const onResizeDisposable = terminal.onResize((size) => {
if (onDataRef.current == null) {
return;
}
onDataRef.current(null, { rows: size.rows, cols: size.cols });
});
if (onDataRef.current != null) {
onDataRef.current(null, { rows: terminal.rows, cols: terminal.cols });
}

const resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
});
if (outerRef.current != null) {
resizeObserver.observe(outerRef.current);
}

return () => {
resizeObserver.disconnect();
onResizeDisposable.dispose();
onDataDisposable.dispose();
terminal.dispose();
terminalRef.current = null;
};
}, []);

React.useEffect(() => {
const terminal = terminalRef.current;
if (terminal == null) {
return;
}
if (termFontSize != null) {
terminal.options.fontSize = termFontSize;
}
if (termFontFamily != null) {
terminal.options.fontFamily = termFontFamily;
}
if (termScrollback != null) {
terminal.options.scrollback = termScrollback;
}
}, [termFontSize, termFontFamily, termScrollback]);

const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
terminalRef.current?.focus();
outerProps.onFocus?.(e);
},
[outerProps.onFocus]
);

const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
terminalRef.current?.blur();
outerProps.onBlur?.(e);
},
[outerProps.onBlur]
);

return (
<div
{...outerProps}
ref={setOuterRef as React.RefCallback<HTMLDivElement>}
onFocus={handleFocus}
onBlur={handleBlur}
>
<div ref={termRef} className="w-full h-full" />
</div>
);
});

export { TsunamiTerm };
Loading
Loading