diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 256143ada..dfd1520b6 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -64,10 +64,11 @@ const atoms = { type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void }; -const orefSubjects = new Map>(); +// key is "eventType" or "eventType|oref" +const eventSubjects = new Map>(); -function getORefSubject(oref: string): SubjectWithRef { - let subject = orefSubjects.get(oref); +function getSubjectInternal(subjectKey: string): SubjectWithRef { + let subject = eventSubjects.get(subjectKey); if (subject == null) { subject = new rxjs.Subject() as any; subject.refCount = 0; @@ -75,15 +76,23 @@ function getORefSubject(oref: string): SubjectWithRef { subject.refCount--; if (subject.refCount === 0) { subject.complete(); - orefSubjects.delete(oref); + eventSubjects.delete(subjectKey); } }; - orefSubjects.set(oref, subject); + eventSubjects.set(subjectKey, subject); } subject.refCount++; return subject; } +function getEventSubject(eventType: string): SubjectWithRef { + return getSubjectInternal(eventType); +} + +function getEventORefSubject(eventType: string, oref: string): SubjectWithRef { + return getSubjectInternal(eventType + "|" + oref); +} + const blockCache = new Map>(); function useBlockCache(blockId: string, name: string, makeFn: () => T): T { @@ -129,16 +138,20 @@ function getBackendWSHostPort(): string { let globalWS: WSControl = null; function handleWSEventMessage(msg: WSEventType) { - if (msg.oref == null) { + if (msg.eventtype == null) { console.log("unsupported event", msg); return; } + // we send to two subjects just eventType and eventType|oref // we don't use getORefSubject here because we don't want to create a new subject - const subject = orefSubjects.get(msg.oref); - if (subject == null) { - return; + const eventSubject = eventSubjects.get(msg.eventtype); + if (eventSubject != null) { + eventSubject.next(msg); + } + const eventOrefSubject = eventSubjects.get(msg.eventtype + "|" + msg.oref); + if (eventOrefSubject != null) { + eventOrefSubject.next(msg); } - subject.next(msg.data); } function handleWSMessage(msg: any) { @@ -161,11 +174,20 @@ function sendWSCommand(command: WSCommandType) { globalWS.pushMessage(command); } +// more code that could be moved into an init +// here we want to set up a "waveobj:update" handler +const waveobjUpdateSubject = getEventSubject("waveobj:update"); +waveobjUpdateSubject.subscribe((msg: WSEventType) => { + const update: WaveObjUpdate = msg.data; + WOS.updateWaveObject(update); +}); + export { WOS, atoms, getBackendHostPort, - getORefSubject, + getEventORefSubject, + getEventSubject, globalStore, globalWS, initWS, diff --git a/frontend/app/view/preview.tsx b/frontend/app/view/preview.tsx index 7dd01cf4a..288ff6103 100644 --- a/frontend/app/view/preview.tsx +++ b/frontend/app/view/preview.tsx @@ -18,6 +18,9 @@ const MaxFileSize = 1024 * 1024 * 10; // 10MB function DirNav({ cwdAtom }: { cwdAtom: jotai.WritableAtom }) { const [cwd, setCwd] = jotai.useAtom(cwdAtom); + if (cwd == null || cwd == "") { + return null; + } let splitNav = [cwd]; let remaining = cwd; @@ -170,7 +173,9 @@ function PreviewView({ blockId }: { blockId: string }) { ) { specializedView = ; } else if (fileInfo == null) { - specializedView = File Not Found; + specializedView = ( + File Not Found{util.isBlank(fileName) ? null : JSON.stringify(fileName)} + ); } else if (fileInfo.size > MaxFileSize) { specializedView = File Too Large to Preview; } else if (mimeType === "text/markdown") { diff --git a/frontend/app/view/term.tsx b/frontend/app/view/term.tsx index 0d1b9cc6d..3bab80b43 100644 --- a/frontend/app/view/term.tsx +++ b/frontend/app/view/term.tsx @@ -1,12 +1,13 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { WOS, getBackendHostPort, getORefSubject, sendWSCommand } from "@/store/global"; +import { WOS, getBackendHostPort, getEventORefSubject, sendWSCommand } from "@/store/global"; import * as services from "@/store/services"; import { base64ToArray } from "@/util/util"; import { FitAddon } from "@xterm/addon-fit"; import type { ITheme } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm"; +import clsx from "clsx"; import * as React from "react"; import { debounce } from "throttle-debounce"; @@ -67,6 +68,8 @@ const TerminalView = ({ blockId }: { blockId: string }) => { const connectElemRef = React.useRef(null); const termRef = React.useRef(null); const initialLoadRef = React.useRef({ loaded: false, heldData: [] }); + const htmlElemFocusRef = React.useRef(null); + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); React.useEffect(() => { console.log("terminal created"); const newTerm = new Terminal({ @@ -82,10 +85,6 @@ const TerminalView = ({ blockId }: { blockId: string }) => { newTerm.loadAddon(newFitAddon); newTerm.open(connectElemRef.current); newFitAddon.fit(); - // services.BlockService.SendCommand(blockId, { - // command: "controller:input", - // termsize: { rows: newTerm.rows, cols: newTerm.cols }, - // }); sendWSCommand({ wscommand: "setblocktermsize", blockid: blockId, @@ -98,9 +97,10 @@ const TerminalView = ({ blockId }: { blockId: string }) => { }); // block subject - const blockSubject = getORefSubject(WOS.makeORef("block", blockId)); - blockSubject.subscribe((data) => { + const blockSubject = getEventORefSubject("block:ptydata", WOS.makeORef("block", blockId)); + blockSubject.subscribe((msg: WSEventType) => { // base64 decode + const data = msg.data; const decodedData = base64ToArray(data.ptydata); if (initialLoadRef.current.loaded) { newTerm.write(decodedData); @@ -150,9 +150,39 @@ const TerminalView = ({ blockId }: { blockId: string }) => { }; }, []); + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.code === "Escape" && event.metaKey) { + // reset term:mode + const metaCmd: BlockSetMetaCommand = { command: "setmeta", meta: { "term:mode": null } }; + services.BlockService.SendCommand(blockId, metaCmd); + return false; + } + return true; + }; + + let termMode = blockData?.meta?.["term:mode"] ?? "term"; + if (termMode != "term" && termMode != "html") { + termMode = "term"; + } return ( -
+
+
{ + if (htmlElemFocusRef.current != null) { + htmlElemFocusRef.current.focus(); + } + }} + > +
+ +
+
+ HTML MODE +
+
); }; diff --git a/frontend/app/view/view.less b/frontend/app/view/view.less index 4472921cd..bec72a389 100644 --- a/frontend/app/view/view.less +++ b/frontend/app/view/view.less @@ -7,6 +7,12 @@ width: 100%; height: 100%; overflow: hidden; + border-left: 4px solid transparent; + padding-left: 4px; + + &:focus-within { + border-left: 4px solid var(--accent-color); + } .term-header { display: flex; @@ -25,6 +31,53 @@ overflow: hidden; line-height: 1; } + + .term-htmlelem { + display: flex; + flex-direction: column; + width: 100%; + flex-grow: 1; + min-height: 0; + overflow: hidden; + + .term-htmlelem-focus { + height: 0; + width: 0; + input { + width: 0; + height: 0; + opacity: 0; + pointer-events: none; + } + } + + .term-htmlelem-content { + display: flex; + flex-direction: row; + width: 100%; + flex-grow: 1; + min-height: 0; + overflow: hidden; + } + } + + &.term-mode-term { + .term-connectelem { + display: flex; + } + .term-htmlelem { + display: none; + } + } + + &.term-mode-html { + .term-connectelem { + display: none; + } + .term-htmlelem { + display: flex; + } + } } .view-codeedit { diff --git a/frontend/util/util.ts b/frontend/util/util.ts index 98087c332..f5ee40822 100644 --- a/frontend/util/util.ts +++ b/frontend/util/util.ts @@ -3,7 +3,17 @@ import base64 from "base64-js"; +function isBlank(str: string): boolean { + return str == null || str == ""; +} + function base64ToString(b64: string): string { + if (b64 == null) { + return null; + } + if (b64 == "") { + return ""; + } const stringBytes = base64.toByteArray(b64); return new TextDecoder().decode(stringBytes); } @@ -22,4 +32,4 @@ function base64ToArray(b64: string): Uint8Array { return rtnArr; } -export { base64ToArray, base64ToString, stringToBase64 }; +export { base64ToArray, base64ToString, isBlank, stringToBase64 }; diff --git a/pkg/blockcontroller/blockcommand.go b/pkg/blockcontroller/blockcommand.go index 6679d1285..3e8bb2ec9 100644 --- a/pkg/blockcontroller/blockcommand.go +++ b/pkg/blockcontroller/blockcommand.go @@ -25,6 +25,7 @@ var CommandToTypeMap = map[string]reflect.Type{ BlockCommand_Input: reflect.TypeOf(BlockInputCommand{}), BlockCommand_SetView: reflect.TypeOf(BlockSetViewCommand{}), BlockCommand_SetMeta: reflect.TypeOf(BlockSetMetaCommand{}), + BlockCommand_Message: reflect.TypeOf(BlockMessageCommand{}), } func CommandTypeUnionMeta() tsgenmeta.TypeUnionMeta { @@ -35,6 +36,7 @@ func CommandTypeUnionMeta() tsgenmeta.TypeUnionMeta { reflect.TypeOf(BlockInputCommand{}), reflect.TypeOf(BlockSetViewCommand{}), reflect.TypeOf(BlockSetMetaCommand{}), + reflect.TypeOf(BlockMessageCommand{}), }, } } @@ -96,3 +98,12 @@ type BlockSetMetaCommand struct { func (smc *BlockSetMetaCommand) GetCommand() string { return BlockCommand_SetMeta } + +type BlockMessageCommand struct { + Command string `json:"command" tstype:"\"message\""` + Message string `json:"message"` +} + +func (bmc *BlockMessageCommand) GetCommand() string { + return BlockCommand_Message +} diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 4d9827913..a5dc37727 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "log" + "strings" "sync" "time" @@ -39,6 +40,7 @@ type BlockController struct { InputCh chan BlockCommand Status string + PtyBuffer *PtyBuffer ShellProc *shellexec.ShellProc ShellInputCh chan *BlockInputCommand } @@ -90,7 +92,7 @@ func (bc *BlockController) Close() { const DefaultTermMaxFileSize = 256 * 1024 -func (bc *BlockController) handleShellProcData(data []byte, seqNum int) error { +func (bc *BlockController) handleShellProcData(data []byte) error { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() err := filestore.WFS.AppendData(ctx, bc.BlockId, "main", data) @@ -104,7 +106,6 @@ func (bc *BlockController) handleShellProcData(data []byte, seqNum int) error { "blockid": bc.BlockId, "blockfile": "main", "ptydata": base64.StdEncoding.EncodeToString(data), - "seqnum": seqNum, }, }) return nil @@ -159,15 +160,13 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts) error { bc.ShellProc = nil bc.ShellInputCh = nil }() - seqNum := 0 buf := make([]byte, 4096) for { nr, err := bc.ShellProc.Pty.Read(buf) - seqNum++ if nr > 0 { - handleDataErr := bc.handleShellProcData(buf[:nr], seqNum) - if handleDataErr != nil { - log.Printf("error handling shell data: %v\n", handleDataErr) + bc.PtyBuffer.AppendData(buf[:nr]) + if bc.PtyBuffer.Err != nil { + log.Printf("error processing pty data: %v\n", bc.PtyBuffer.Err) break } } @@ -269,6 +268,15 @@ func StartBlockController(ctx context.Context, blockId string) error { Status: "init", InputCh: make(chan BlockCommand), } + ptyBuffer := MakePtyBuffer(bc.handleShellProcData, func(cmd BlockCommand) error { + if strings.HasPrefix(cmd.GetCommand(), "controller:") { + bc.InputCh <- cmd + } else { + ProcessStaticCommand(blockId, cmd) + } + return nil + }) + bc.PtyBuffer = ptyBuffer blockControllerMap[blockId] = bc go bc.Run(blockData) return nil @@ -304,6 +312,21 @@ func ProcessStaticCommand(blockId string, cmdGen BlockCommand) error { if err != nil { return fmt.Errorf("error updating block: %w", err) } + // send a waveobj:update event + updatedBlock, err := wstore.DBGet[*wstore.Block](ctx, blockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) + } + eventbus.SendEvent(eventbus.WSEventType{ + EventType: "waveobj:update", + ORef: waveobj.MakeORef(wstore.OType_Block, blockId).String(), + Data: wstore.WaveObjUpdate{ + UpdateType: wstore.UpdateType_Update, + OType: wstore.OType_Block, + OID: blockId, + Obj: updatedBlock, + }, + }) return nil case *BlockSetMetaCommand: log.Printf("SETMETA: %s | %v\n", blockId, cmd.Meta) @@ -314,6 +337,9 @@ func ProcessStaticCommand(blockId string, cmdGen BlockCommand) error { if block == nil { return nil } + if block.Meta == nil { + block.Meta = make(map[string]any) + } for k, v := range cmd.Meta { if v == nil { delete(block.Meta, k) @@ -325,6 +351,24 @@ func ProcessStaticCommand(blockId string, cmdGen BlockCommand) error { if err != nil { return fmt.Errorf("error updating block: %w", err) } + // send a waveobj:update event + updatedBlock, err := wstore.DBGet[*wstore.Block](ctx, blockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) + } + eventbus.SendEvent(eventbus.WSEventType{ + EventType: "waveobj:update", + ORef: waveobj.MakeORef(wstore.OType_Block, blockId).String(), + Data: wstore.WaveObjUpdate{ + UpdateType: wstore.UpdateType_Update, + OType: wstore.OType_Block, + OID: blockId, + Obj: updatedBlock, + }, + }) + return nil + case *BlockMessageCommand: + log.Printf("MESSAGE: %s | %q\n", blockId, cmd.Message) return nil default: return fmt.Errorf("unknown command type %T", cmdGen) diff --git a/pkg/blockcontroller/ptybuffer.go b/pkg/blockcontroller/ptybuffer.go new file mode 100644 index 000000000..f093e13f3 --- /dev/null +++ b/pkg/blockcontroller/ptybuffer.go @@ -0,0 +1,119 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package blockcontroller + +import ( + "encoding/json" + "fmt" + + "github.com/wavetermdev/thenextwave/pkg/wshutil" +) + +const ( + Mode_Normal = "normal" + Mode_Esc = "esc" + Mode_WaveEsc = "waveesc" +) + +type PtyBuffer struct { + Mode string + EscSeqBuf []byte + DataOutputFn func([]byte) error + CommandOutputFn func(BlockCommand) error + Err error +} + +func MakePtyBuffer(dataOutputFn func([]byte) error, commandOutputFn func(BlockCommand) error) *PtyBuffer { + return &PtyBuffer{ + Mode: Mode_Normal, + DataOutputFn: dataOutputFn, + CommandOutputFn: commandOutputFn, + } +} + +func (b *PtyBuffer) setErr(err error) { + if b.Err == nil { + b.Err = err + } +} + +func (b *PtyBuffer) processWaveEscSeq(escSeq []byte) { + jmsg := make(map[string]any) + err := json.Unmarshal(escSeq, &jmsg) + if err != nil { + b.setErr(fmt.Errorf("error unmarshalling Wave OSC sequence data: %w", err)) + return + } + cmd, err := ParseCmdMap(jmsg) + if err != nil { + b.setErr(fmt.Errorf("error parsing Wave OSC command: %w", err)) + return + } + err = b.CommandOutputFn(cmd) + if err != nil { + b.setErr(fmt.Errorf("error processing Wave OSC command: %w", err)) + return + } +} + +func (b *PtyBuffer) AppendData(data []byte) { + outputBuf := make([]byte, 0, len(data)) + for _, ch := range data { + if b.Mode == Mode_WaveEsc { + if ch == wshutil.ESC { + // terminates the escape sequence (and the rest was invalid) + b.Mode = Mode_Normal + outputBuf = append(outputBuf, b.EscSeqBuf...) + outputBuf = append(outputBuf, ch) + b.EscSeqBuf = nil + } else if ch == wshutil.BEL || ch == wshutil.ST { + // terminates the escpae sequence (is a valid Wave OSC command) + b.Mode = Mode_Normal + waveEscSeq := b.EscSeqBuf[len(wshutil.WaveOSCPrefix):] + b.EscSeqBuf = nil + b.processWaveEscSeq(waveEscSeq) + } else { + b.EscSeqBuf = append(b.EscSeqBuf, ch) + } + continue + } + if b.Mode == Mode_Esc { + if ch == wshutil.ESC || ch == wshutil.BEL || ch == wshutil.ST { + // these all terminate the escape sequence (invalid, not a Wave OSC) + b.Mode = Mode_Normal + outputBuf = append(outputBuf, b.EscSeqBuf...) + outputBuf = append(outputBuf, ch) + } else { + if ch == wshutil.WaveOSCPrefixBytes[len(b.EscSeqBuf)] { + // we're still building what could be a Wave OSC sequence + b.EscSeqBuf = append(b.EscSeqBuf, ch) + } else { + // this is not a Wave OSC sequence, just an escape sequence + b.Mode = Mode_Normal + outputBuf = append(outputBuf, b.EscSeqBuf...) + outputBuf = append(outputBuf, ch) + continue + } + // check to see if we have a full Wave OSC prefix + if len(b.EscSeqBuf) == len(wshutil.WaveOSCPrefixBytes) { + b.Mode = Mode_WaveEsc + } + } + continue + } + // Mode_Normal + if ch == wshutil.ESC { + b.Mode = Mode_Esc + b.EscSeqBuf = []byte{ch} + continue + } + outputBuf = append(outputBuf, ch) + } + if len(outputBuf) > 0 { + err := b.DataOutputFn(outputBuf) + if err != nil { + b.setErr(fmt.Errorf("error processing data output: %w", err)) + } + } +} diff --git a/pkg/wshutil/wshutil.go b/pkg/wshutil/wshutil.go index 516b63649..615ccfdab 100644 --- a/pkg/wshutil/wshutil.go +++ b/pkg/wshutil/wshutil.go @@ -15,8 +15,10 @@ const WaveOSC = "23198" const WaveOSCPrefix = "\x1b]" + WaveOSC + ";" const HexChars = "0123456789ABCDEF" const BEL = 0x07 +const ST = 0x9c +const ESC = 0x1b -var waveOSCPrefixBytes = []byte(WaveOSCPrefix) +var WaveOSCPrefixBytes = []byte(WaveOSCPrefix) // OSC escape types // OSC 23198 ; (JSON | base64-JSON) ST @@ -50,14 +52,14 @@ func EncodeWaveOSCMessage(cmd Command) ([]byte, error) { // If no control characters, directly construct the output // \x1b] (2) + WaveOSC + ; (1) + message + \x07 (1) output := make([]byte, len(WaveOSCPrefix)+len(barr)+1) - copy(output, waveOSCPrefixBytes) + copy(output, WaveOSCPrefixBytes) copy(output[len(WaveOSCPrefix):], barr) output[len(output)-1] = BEL return output, nil } var buf bytes.Buffer - buf.Write(waveOSCPrefixBytes) + buf.Write(WaveOSCPrefixBytes) escSeq := [6]byte{'\\', 'u', '0', '0', '0', '0'} for _, b := range barr { if b < 0x20 || b == 0x7f {