Skip to content

Commit e035595

Browse files
Copilotsawka
andcommitted
feat(tsunami): add wave term component and direct terminal io path
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
1 parent 6b15bf1 commit e035595

7 files changed

Lines changed: 252 additions & 0 deletions

File tree

tsunami/app/defaultclient.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"time"
1717

1818
"github.com/wavetermdev/waveterm/tsunami/engine"
19+
"github.com/wavetermdev/waveterm/tsunami/rpctypes"
1920
"github.com/wavetermdev/waveterm/tsunami/util"
2021
"github.com/wavetermdev/waveterm/tsunami/vdom"
2122
)
@@ -64,6 +65,17 @@ func SendAsyncInitiation() error {
6465
return engine.GetDefaultClient().SendAsyncInitiation()
6566
}
6667

68+
type TermSize = rpctypes.TermSize
69+
type TermInputPacket = rpctypes.TermInputPacket
70+
71+
func SetTermInputHandler(handler func(input TermInputPacket)) {
72+
engine.GetDefaultClient().SetTermInputHandler(handler)
73+
}
74+
75+
func TermWrite(id string, data64 string) error {
76+
return engine.GetDefaultClient().SendTermWrite(id, data64)
77+
}
78+
6779
func ConfigAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] {
6880
fullName := "$config." + name
6981
client := engine.GetDefaultClient()

tsunami/engine/clientimpl.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ type ClientImpl struct {
7272
SSEChannels map[string]chan ssEvent // map of connectionId to SSE channel
7373
SSEChannelsLock *sync.Mutex
7474
GlobalEventHandler func(event vdom.VDomEvent)
75+
TermInputHandler func(input rpctypes.TermInputPacket)
7576
UrlHandlerMux *http.ServeMux
7677
AppInitFn func() error
7778
AssetsFS fs.FS
@@ -157,6 +158,12 @@ func (c *ClientImpl) SetGlobalEventHandler(handler func(event vdom.VDomEvent)) {
157158
c.GlobalEventHandler = handler
158159
}
159160

161+
func (c *ClientImpl) SetTermInputHandler(handler func(input rpctypes.TermInputPacket)) {
162+
c.Lock.Lock()
163+
defer c.Lock.Unlock()
164+
c.TermInputHandler = handler
165+
}
166+
160167
func (c *ClientImpl) getFaviconPath() string {
161168
if c.StaticFS != nil {
162169
faviconNames := []string{"favicon.ico", "favicon.png", "favicon.svg", "favicon.gif", "favicon.jpg"}
@@ -304,6 +311,28 @@ func (c *ClientImpl) SendAsyncInitiation() error {
304311
return c.SendSSEvent(ssEvent{Event: "asyncinitiation", Data: nil})
305312
}
306313

314+
func (c *ClientImpl) SendTermWrite(id string, data64 string) error {
315+
payload := rpctypes.TermWritePacket{
316+
Id: id,
317+
Data64: data64,
318+
}
319+
data, err := json.Marshal(payload)
320+
if err != nil {
321+
return err
322+
}
323+
return c.SendSSEvent(ssEvent{Event: "termwrite", Data: data})
324+
}
325+
326+
func (c *ClientImpl) HandleTermInput(input rpctypes.TermInputPacket) {
327+
c.Lock.Lock()
328+
handler := c.TermInputHandler
329+
c.Lock.Unlock()
330+
if handler == nil {
331+
return
332+
}
333+
handler(input)
334+
}
335+
307336
func makeNullRendered() *rpctypes.RenderedElem {
308337
return &rpctypes.RenderedElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag}
309338
}

tsunami/engine/serverhandlers.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) {
8383
mux.HandleFunc("/api/schemas", h.handleSchemas)
8484
mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile))
8585
mux.HandleFunc("/api/modalresult", h.handleModalResult)
86+
mux.HandleFunc("/api/terminput", h.handleTermInput)
8687
mux.HandleFunc("/dyn/", h.handleDynContent)
8788

8889
// Add handler for static files at /static/ path
@@ -392,6 +393,41 @@ func (h *httpHandlers) handleModalResult(w http.ResponseWriter, r *http.Request)
392393
json.NewEncoder(w).Encode(map[string]any{"success": true})
393394
}
394395

396+
func (h *httpHandlers) handleTermInput(w http.ResponseWriter, r *http.Request) {
397+
defer func() {
398+
panicErr := util.PanicHandler("handleTermInput", recover())
399+
if panicErr != nil {
400+
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
401+
}
402+
}()
403+
404+
setNoCacheHeaders(w)
405+
406+
if r.Method != http.MethodPost {
407+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
408+
return
409+
}
410+
411+
body, err := io.ReadAll(r.Body)
412+
if err != nil {
413+
http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest)
414+
return
415+
}
416+
417+
var input rpctypes.TermInputPacket
418+
if err := json.Unmarshal(body, &input); err != nil {
419+
http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest)
420+
return
421+
}
422+
if strings.TrimSpace(input.Id) == "" {
423+
http.Error(w, "id is required", http.StatusBadRequest)
424+
return
425+
}
426+
427+
h.Client.HandleTermInput(input)
428+
w.WriteHeader(http.StatusNoContent)
429+
}
430+
395431
func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) {
396432
defer func() {
397433
panicErr := util.PanicHandler("handleDynContent", recover())
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { FitAddon } from "@xterm/addon-fit";
2+
import { Terminal } from "@xterm/xterm";
3+
import "@xterm/xterm/css/xterm.css";
4+
import * as React from "react";
5+
6+
import { base64ToArray, stringToBase64 } from "@/util/base64";
7+
8+
const TermWriteEventName = "tsunami:termwrite";
9+
10+
type TermSize = {
11+
rows: number;
12+
cols: number;
13+
};
14+
15+
type TermInputPayload = {
16+
id: string;
17+
termsize?: TermSize;
18+
data64?: string;
19+
};
20+
21+
type TermWritePayload = {
22+
id: string;
23+
data64: string;
24+
};
25+
26+
async function sendTermInput(payload: TermInputPayload) {
27+
const response = await fetch("/api/terminput", {
28+
method: "POST",
29+
headers: {
30+
"Content-Type": "application/json",
31+
},
32+
body: JSON.stringify(payload),
33+
});
34+
if (!response.ok) {
35+
throw new Error(`terminal input request failed: ${response.status} ${response.statusText}`);
36+
}
37+
}
38+
39+
const TsunamiTerm = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(function TsunamiTerm(
40+
props,
41+
ref
42+
) {
43+
const { id, ...outerProps } = props;
44+
const outerRef = React.useRef<HTMLDivElement>(null);
45+
const termRef = React.useRef<HTMLDivElement>(null);
46+
const terminalRef = React.useRef<Terminal | null>(null);
47+
48+
const setOuterRef = React.useCallback(
49+
(elem: HTMLDivElement) => {
50+
outerRef.current = elem;
51+
if (typeof ref === "function") {
52+
ref(elem);
53+
return;
54+
}
55+
if (ref != null) {
56+
ref.current = elem;
57+
}
58+
},
59+
[ref]
60+
);
61+
62+
React.useEffect(() => {
63+
if (termRef.current == null) {
64+
return;
65+
}
66+
const terminal = new Terminal({
67+
convertEol: false,
68+
});
69+
const fitAddon = new FitAddon();
70+
terminal.loadAddon(fitAddon);
71+
terminal.open(termRef.current);
72+
fitAddon.fit();
73+
terminalRef.current = terminal;
74+
75+
const onDataDisposable = terminal.onData((data) => {
76+
if (id == null || id === "") {
77+
return;
78+
}
79+
sendTermInput({
80+
id,
81+
data64: stringToBase64(data),
82+
}).catch((error) => {
83+
console.error("Failed to send terminal input:", error);
84+
});
85+
});
86+
const onResizeDisposable = terminal.onResize((size) => {
87+
if (id == null || id === "") {
88+
return;
89+
}
90+
sendTermInput({
91+
id,
92+
termsize: {
93+
rows: size.rows,
94+
cols: size.cols,
95+
},
96+
}).catch((error) => {
97+
console.error("Failed to send terminal resize:", error);
98+
});
99+
});
100+
101+
const resizeObserver = new ResizeObserver(() => {
102+
fitAddon.fit();
103+
});
104+
if (outerRef.current != null) {
105+
resizeObserver.observe(outerRef.current);
106+
}
107+
108+
return () => {
109+
resizeObserver.disconnect();
110+
onResizeDisposable.dispose();
111+
onDataDisposable.dispose();
112+
terminal.dispose();
113+
terminalRef.current = null;
114+
};
115+
}, [id]);
116+
117+
React.useEffect(() => {
118+
const handleTermWrite = (event: Event) => {
119+
const detail = (event as CustomEvent<TermWritePayload>).detail;
120+
if (detail == null || detail.id !== id || detail.data64 == null || detail.data64 === "") {
121+
return;
122+
}
123+
try {
124+
terminalRef.current?.write(base64ToArray(detail.data64));
125+
} catch (error) {
126+
console.error("Failed to process term write event:", error);
127+
}
128+
};
129+
window.addEventListener(TermWriteEventName, handleTermWrite);
130+
return () => {
131+
window.removeEventListener(TermWriteEventName, handleTermWrite);
132+
};
133+
}, [id]);
134+
135+
return (
136+
<div {...outerProps} id={id} ref={setOuterRef}>
137+
<div ref={termRef} className="w-full h-full" />
138+
</div>
139+
);
140+
});
141+
142+
export { TermWriteEventName, TsunamiTerm };

tsunami/frontend/src/model/tsunami-model.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import debug from "debug";
55
import * as jotai from "jotai";
66

7+
import { TermWriteEventName } from "@/element/tsunamiterm";
78
import { arrayBufferToBase64 } from "@/util/base64";
89
import { getOrCreateClientId } from "@/util/clientid";
910
import { adaptFromReactOrNativeKeyEvent } from "@/util/keyutil";
@@ -236,6 +237,15 @@ export class TsunamiModel {
236237
}
237238
});
238239

240+
this.serverEventSource.addEventListener("termwrite", (event: MessageEvent) => {
241+
try {
242+
const detail = JSON.parse(event.data);
243+
window.dispatchEvent(new CustomEvent(TermWriteEventName, { detail }));
244+
} catch (e) {
245+
console.error("Failed to parse termwrite event:", e);
246+
}
247+
});
248+
239249
this.serverEventSource.addEventListener("error", (event) => {
240250
console.error("SSE connection error:", event);
241251
});

tsunami/frontend/src/vdom.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge";
99

1010
import { AlertModal, ConfirmModal } from "@/element/modals";
1111
import { Markdown } from "@/element/markdown";
12+
import { TsunamiTerm } from "@/element/tsunamiterm";
1213
import { getTextChildren } from "@/model/model-utils";
1314
import type { TsunamiModel } from "@/model/tsunami-model";
1415
import { RechartsTag } from "@/recharts/recharts";
@@ -30,6 +31,7 @@ type VDomReactTagType = (props: { elem: VDomElem; model: TsunamiModel }) => Reac
3031

3132
const WaveTagMap: Record<string, VDomReactTagType> = {
3233
"wave:markdown": WaveMarkdown,
34+
"wave:term": WaveTerm,
3335
};
3436

3537
const AllowedSimpleTags: { [tagName: string]: boolean } = {
@@ -278,6 +280,11 @@ function WaveMarkdown({ elem, model }: { elem: VDomElem; model: TsunamiModel })
278280
);
279281
}
280282

283+
function WaveTerm({ elem, model }: { elem: VDomElem; model: TsunamiModel }) {
284+
const props = useVDom(model, elem);
285+
return <TsunamiTerm {...props} />;
286+
}
287+
281288
function StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) {
282289
const styleText = getTextChildren(elem);
283290
if (styleText == null) {

tsunami/rpctypes/protocoltypes.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,19 @@ type ModalResult struct {
206206
ModalId string `json:"modalid"` // ID of the modal
207207
Confirm bool `json:"confirm"` // true = confirmed/ok, false = cancelled
208208
}
209+
210+
type TermSize struct {
211+
Rows int `json:"rows"`
212+
Cols int `json:"cols"`
213+
}
214+
215+
type TermInputPacket struct {
216+
Id string `json:"id"`
217+
TermSize *TermSize `json:"termsize,omitempty"`
218+
Data64 string `json:"data64,omitempty"`
219+
}
220+
221+
type TermWritePacket struct {
222+
Id string `json:"id"`
223+
Data64 string `json:"data64"`
224+
}

0 commit comments

Comments
 (0)