diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 07c90e93..7999fd45 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -32,7 +32,7 @@ function processTitleString(titleString: string): React.ReactNode[] { if (titleString == null) { return null; } - const tagRegex = /<(\/)?([a-z]+)(?::([#a-z0-9-]+))?>/g; + const tagRegex = /<(\/)?([a-z]+)(?::([#a-z0-9@-]+))?>/g; let lastIdx = 0; let match; let partsStack = [[]]; @@ -46,10 +46,11 @@ function processTitleString(titleString: string): React.ReactNode[] { if (tagParam == null) { continue; } - if (!tagParam.match(/^[a-z0-9-]+$/)) { + const iconClass = util.makeIconClass(tagParam, false); + if (iconClass == null) { continue; } - lastPart.push(); + lastPart.push(); continue; } if (tagName == "c" || tagName == "color") { @@ -105,10 +106,9 @@ function getBlockHeaderText(blockIcon: string, blockData: Block): React.ReactNod if (!util.isBlank(iconColor)) { iconStyle = { color: iconColor }; } - if (blockIcon.match(/^[a-z0-9-]+$/)) { - blockIconElem = ( - - ); + const iconClass = util.makeIconClass(blockIcon, false); + if (iconClass != null) { + blockIconElem = ; } } if (!util.isBlank(blockData?.meta?.title)) { @@ -123,7 +123,11 @@ function getBlockHeaderText(blockIcon: string, blockData: Block): React.ReactNod return [blockIconElem, blockData.meta.title]; } } - return [blockIconElem, `${blockData?.view} [${blockData.oid.substring(0, 8)}]`]; + let viewString = blockData?.view; + if (blockData.controller == "cmd") { + viewString = "cmd"; + } + return [blockIconElem, `${viewString} [${blockData.oid.substring(0, 8)}]`]; } interface FramelessBlockHeaderProps { diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 5a48a7be..a869ad3f 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -216,7 +216,6 @@ function handleWSEventMessage(msg: WSEventType) { } 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 eventSubject = eventSubjects.get(msg.eventtype); diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index dc1e083e..8098d9f2 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -7,6 +7,9 @@ import * as WOS from "./wos"; // blockservice.BlockService (block) class BlockServiceType { + GetControllerStatus(arg2: string): Promise { + return WOS.callBackendService("block", "GetControllerStatus", Array.from(arguments)) + } SaveTerminalState(arg2: string, arg3: string, arg4: string, arg5: number): Promise { return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments)) } diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index d1dad220..337f0f66 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -1,8 +1,17 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { WOS, atoms, globalStore, sendWSCommand, useBlockAtom, useSettingsAtom } from "@/store/global"; +import { + WOS, + atoms, + getEventORefSubject, + globalStore, + sendWSCommand, + useBlockAtom, + useSettingsAtom, +} from "@/store/global"; import * as services from "@/store/services"; +import * as keyutil from "@/util/keyutil"; import { FitAddon } from "@xterm/addon-fit"; import type { ITheme } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm"; @@ -142,7 +151,10 @@ function setBlockFocus(blockId: string) { const TerminalView = ({ blockId }: { blockId: string }) => { const connectElemRef = React.useRef(null); const termRef = React.useRef(null); - const initialLoadRef = React.useRef({ loaded: false, heldData: [] }); + const shellProcStatusRef = React.useRef(null); + const blockIconOverrideAtom = useBlockAtom(blockId, "blockicon:override", () => { + return jotai.atom(null); + }) as jotai.PrimitiveAtom; const htmlElemFocusRef = React.useRef(null); const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); const isFocusedAtom = useBlockAtom(blockId, "isFocused", () => { @@ -157,14 +169,38 @@ const TerminalView = ({ blockId }: { blockId: string }) => { const termSettings = jotai.useAtomValue(termSettingsAtom); const isFocused = jotai.useAtomValue(isFocusedAtom); React.useEffect(() => { - const termWrap = new TermWrap(blockId, connectElemRef.current, { - theme: getThemeFromCSSVars(connectElemRef.current), - fontSize: termSettings?.fontsize ?? 12, - fontFamily: termSettings?.fontfamily ?? "Hack", - drawBoldTextInBrightColors: false, - fontWeight: "normal", - fontWeightBold: "bold", - }); + function handleTerminalKeydown(event: KeyboardEvent) { + const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); + if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { + event.preventDefault(); + event.stopPropagation(); + const metaCmd: BlockSetMetaCommand = { command: "setmeta", meta: { "term:mode": "html" } }; + services.BlockService.SendCommand(this.blockId, metaCmd); + return false; + } + if (shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) { + // restart + const restartCmd: BlockRestartCommand = { command: "controller:restart", blockid: blockId }; + services.BlockService.SendCommand(blockId, restartCmd); + return false; + } + } + + const termWrap = new TermWrap( + blockId, + connectElemRef.current, + { + theme: getThemeFromCSSVars(connectElemRef.current), + fontSize: termSettings?.fontsize ?? 12, + fontFamily: termSettings?.fontfamily ?? "Hack", + drawBoldTextInBrightColors: false, + fontWeight: "normal", + fontWeightBold: "bold", + }, + { + keydownHandler: handleTerminalKeydown, + } + ); (window as any).term = termWrap; termRef.current = termWrap; termWrap.addFocusListener(() => { @@ -181,7 +217,8 @@ const TerminalView = ({ blockId }: { blockId: string }) => { }, []); const handleHtmlKeyDown = (event: React.KeyboardEvent) => { - if (event.code === "Escape" && event.metaKey) { + const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); + if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { // reset term:mode const metaCmd: BlockSetMetaCommand = { command: "setmeta", meta: { "term:mode": null } }; services.BlockService.SendCommand(blockId, metaCmd); @@ -211,6 +248,32 @@ const TerminalView = ({ blockId }: { blockId: string }) => { } }); + React.useEffect(() => { + function updateShellProcStatus(status: string) { + if (status == null) { + return; + } + shellProcStatusRef.current = status; + if (status == "running") { + termRef.current?.setIsRunning(true); + globalStore.set(blockIconOverrideAtom, "square-terminal"); + } else { + termRef.current?.setIsRunning(false); + globalStore.set(blockIconOverrideAtom, "regular@square-terminal"); + } + } + const initialRTStatus = services.BlockService.GetControllerStatus(blockId); + initialRTStatus.then((rts) => { + updateShellProcStatus(rts?.shellprocstatus); + }); + const bcSubject = getEventORefSubject("blockcontroller:status", WOS.makeORef("block", blockId)); + bcSubject.subscribe((data: WSEventType) => { + let bcRTS: BlockControllerRuntimeStatus = data.data; + updateShellProcStatus(bcRTS?.shellprocstatus); + }); + return undefined; + }); + let stickerConfig = { charWidth: 8, charHeight: 16, diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 31ae5897..3b4e7ac0 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -22,11 +22,14 @@ export class TermWrap { loaded: boolean; heldData: Uint8Array[]; handleResize_debounced: () => void; + isRunning: boolean; + keydownHandler: (e: KeyboardEvent) => void; constructor( blockId: string, connectElem: HTMLDivElement, - options?: TermTypes.ITerminalOptions & TermTypes.ITerminalInitOnlyOptions + options: TermTypes.ITerminalOptions & TermTypes.ITerminalInitOnlyOptions, + waveOptions: { keydownHandler?: (e: KeyboardEvent) => void } ) { this.blockId = blockId; this.ptyOffset = 0; @@ -43,10 +46,12 @@ export class TermWrap { this.handleResize_debounced = debounce(50, this.handleResize.bind(this)); this.terminal.open(this.connectElem); this.handleResize(); + this.isRunning = true; + this.keydownHandler = waveOptions.keydownHandler; } async initTerminal() { - this.connectElem.addEventListener("keydown", this.keydownListener.bind(this), true); + this.connectElem.addEventListener("keydown", this.keydownHandler, true); this.terminal.onData(this.handleTermData.bind(this)); this.mainFileSubject = getFileSubject(this.blockId, "main"); this.mainFileSubject.subscribe(this.handleNewFileSubjectData.bind(this)); @@ -58,6 +63,10 @@ export class TermWrap { this.runProcessIdleTimeout(); } + setIsRunning(isRunning: boolean) { + this.isRunning = isRunning; + } + dispose() { this.terminal.dispose(); this.mainFileSubject.release(); @@ -69,7 +78,11 @@ export class TermWrap { const wsCmd: BlockInputWSCommand = { wscommand: "blockinput", blockid: this.blockId, inputdata64: b64data }; sendWSCommand(wsCmd); } else { - const inputCmd: BlockInputCommand = { command: "controller:input", inputdata64: b64data }; + const inputCmd: BlockInputCommand = { + command: "controller:input", + blockid: this.blockId, + inputdata64: b64data, + }; services.BlockService.SendCommand(this.blockId, inputCmd); } } @@ -78,27 +91,21 @@ export class TermWrap { this.terminal.textarea.addEventListener("focus", focusFn); } - keydownListener(ev: KeyboardEvent) { - if (ev.code == "Escape" && ev.metaKey) { - ev.preventDefault(); - ev.stopPropagation(); - const metaCmd: BlockSetMetaCommand = { command: "setmeta", meta: { "term:mode": "html" } }; - services.BlockService.SendCommand(this.blockId, metaCmd); - return false; - } - } - handleNewFileSubjectData(msg: WSFileEventData) { - if (msg.fileop != "append") { + if (msg.fileop == "truncate") { + this.terminal.clear(); + this.heldData = []; + } else if (msg.fileop == "append") { + const decodedData = base64ToArray(msg.data64); + if (this.loaded) { + this.doTerminalWrite(decodedData, null); + } else { + this.heldData.push(decodedData); + } + } else { console.log("bad fileop for terminal", msg); return; } - const decodedData = base64ToArray(msg.data64); - if (this.loaded) { - this.doTerminalWrite(decodedData, null); - } else { - this.heldData.push(decodedData); - } } doTerminalWrite(data: string | Uint8Array, setPtyOffset?: number): Promise { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 5079dd4f..56209dd5 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -31,7 +31,14 @@ declare global { type BlockCommand = { command: string; - } & ( BlockAppendFileCommand | BlockAppendIJsonCommand | BlockInputCommand | CreateBlockCommand | BlockGetMetaCommand | BlockMessageCommand | ResolveIdsCommand | BlockSetMetaCommand | BlockSetViewCommand ); + } & ( BlockAppendFileCommand | BlockAppendIJsonCommand | BlockInputCommand | BlockRestartCommand | CreateBlockCommand | BlockGetMetaCommand | BlockMessageCommand | ResolveIdsCommand | BlockSetMetaCommand | BlockSetViewCommand ); + + // blockcontroller.BlockControllerRuntimeStatus + type BlockControllerRuntimeStatus = { + blockid: string; + status: string; + shellprocstatus?: string; + }; // wstore.BlockDef type BlockDef = { @@ -69,6 +76,12 @@ declare global { message: string; }; + // wshutil.BlockRestartCommand + type BlockRestartCommand = { + command: "controller:restart"; + blockid: string; + }; + // wshutil.BlockSetMetaCommand type BlockSetMetaCommand = { command: "setmeta"; @@ -97,15 +110,17 @@ declare global { rtopts?: RuntimeOpts; }; - type DateTimeConfigType = { - locale: string; - format: DateTimeFormatConfigType; - } + // wconfig.DateTimeConfigType + type DateTimeConfigType = { + locale: string; + format: DateTimeFormatConfigType; + }; - type DateTimeFormatConfigType = { - dateStyle: "full" | "long" | "medium" | "short"; - timeStyle: "full" | "long" | "medium" | "short"; - } + // wconfig.DateTimeFormatConfigType + type DateTimeFormatConfigType = { + dateStyle: number; + timeStyle: number; + }; // wstore.FileDef type FileDef = { @@ -122,7 +137,7 @@ declare global { notfound?: boolean; size: number; mode: number; - modestr: string; + modestr: string; modtime: number; isdir?: boolean; mimetype?: string; @@ -157,10 +172,10 @@ declare global { ReturnDesc: string; }; - //wconfig.MimeTypeConfigType - type MimeTypeConfigType = { - icon: string; - } + // wconfig.MimeTypeConfigType + type MimeTypeConfigType = { + icon: string; + }; // waveobj.ORef type ORef = { @@ -195,10 +210,10 @@ declare global { // wconfig.SettingsConfigType type SettingsConfigType = { - datetime: DateTimeConfigType; - mimetypes: {[key:string]: MimeTypeConfigType} - widgets: WidgetsConfigType[]; + mimetypes: {[key: string]: MimeTypeConfigType}; + datetime: DateTimeConfigType; term: TerminalConfigType; + widgets: WidgetsConfigType[]; }; // wstore.StickerClickOptsType @@ -352,4 +367,4 @@ declare global { } -export {} \ No newline at end of file +export {} diff --git a/frontend/util/util.ts b/frontend/util/util.ts index e64aa062..cd5dbc46 100644 --- a/frontend/util/util.ts +++ b/frontend/util/util.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0s import base64 from "base64-js"; +import clsx from "clsx"; function isBlank(str: string): boolean { return str == null || str == ""; @@ -71,4 +72,21 @@ function jsonDeepEqual(v1: any, v2: any): boolean { return false; } -export { base64ToArray, base64ToString, isBlank, jsonDeepEqual, stringToBase64 }; +function makeIconClass(icon: string, fw: boolean): string { + if (icon == null) { + return null; + } + if (icon.match(/^(solid@)?[a-z0-9-]+$/)) { + // strip off "solid@" prefix if it exists + icon = icon.replace(/^solid@/, ""); + return clsx(`fa fa-sharp fa-solid fa-${icon}`, fw ? "fa-fw" : null); + } + if (icon.match(/^regular@[a-z0-9-]+$/)) { + // strip off the "regular@" prefix if it exists + icon = icon.replace(/^regular@/, ""); + return clsx(`fa fa-sharp fa-regular fa-${icon}`, fw ? "fa-fw" : null); + } + return null; +} + +export { base64ToArray, base64ToString, isBlank, jsonDeepEqual, makeIconClass, stringToBase64 }; diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index f6f32de3..248e2099 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "log" "strings" "sync" @@ -19,6 +20,7 @@ import ( "github.com/wavetermdev/thenextwave/pkg/eventbus" "github.com/wavetermdev/thenextwave/pkg/filestore" "github.com/wavetermdev/thenextwave/pkg/shellexec" + "github.com/wavetermdev/thenextwave/pkg/wavebase" "github.com/wavetermdev/thenextwave/pkg/waveobj" "github.com/wavetermdev/thenextwave/pkg/wshutil" "github.com/wavetermdev/thenextwave/pkg/wstore" @@ -34,6 +36,12 @@ const ( BlockFile_Html = "html" // used for alt html layout ) +const ( + Status_Init = "init" + Status_Running = "running" + Status_Done = "done" +) + const ( DefaultTermMaxFileSize = 256 * 1024 DefaultHtmlMaxFileSize = 256 * 1024 @@ -63,15 +71,32 @@ type BlockController struct { CreatedHtmlFile bool ShellProc *shellexec.ShellProc ShellInputCh chan *BlockInputUnion + ShellProcStatus string RunCmdFn RunCmdFnType } +type BlockControllerRuntimeStatus struct { + BlockId string `json:"blockid"` + Status string `json:"status"` + ShellProcStatus string `json:"shellprocstatus,omitempty"` +} + func (bc *BlockController) WithLock(f func()) { bc.Lock.Lock() defer bc.Lock.Unlock() f() } +func (bc *BlockController) GetRuntimeStatus() *BlockControllerRuntimeStatus { + var rtn BlockControllerRuntimeStatus + bc.WithLock(func() { + rtn.BlockId = bc.BlockId + rtn.Status = bc.Status + rtn.ShellProcStatus = bc.ShellProcStatus + }) + return &rtn +} + func jsonDeepCopy(val map[string]any) (map[string]any, error) { barr, err := json.Marshal(val) if err != nil { @@ -85,16 +110,6 @@ func jsonDeepCopy(val map[string]any) (map[string]any, error) { return rtn, nil } -func (bc *BlockController) setShellProc(shellProc *shellexec.ShellProc) error { - bc.Lock.Lock() - defer bc.Lock.Unlock() - if bc.ShellProc != nil { - return fmt.Errorf("shell process already running") - } - bc.ShellProc = shellProc - return nil -} - func (bc *BlockController) getShellProc() *shellexec.ShellProc { bc.Lock.Lock() defer bc.Lock.Unlock() @@ -105,12 +120,44 @@ type RunShellOpts struct { TermSize shellexec.TermSize `json:"termsize,omitempty"` } -func (bc *BlockController) Close() { - if bc.getShellProc() != nil { - bc.ShellProc.Close() +func (bc *BlockController) UpdateControllerAndSendUpdate(updateFn func() bool) { + var sendUpdate bool + bc.WithLock(func() { + sendUpdate = updateFn() + }) + if sendUpdate { + log.Printf("sending blockcontroller update %#v\n", bc.GetRuntimeStatus()) + go eventbus.SendEvent(eventbus.WSEventType{ + EventType: "blockcontroller:status", + ORef: waveobj.MakeORef(wstore.OType_Block, bc.BlockId).String(), + Data: bc.GetRuntimeStatus(), + }) } } +func HandleTruncateBlockFile(blockId string, blockFile string) error { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + err := filestore.WFS.WriteFile(ctx, blockId, blockFile, nil) + if err == fs.ErrNotExist { + return nil + } + if err != nil { + return fmt.Errorf("error truncating blockfile: %w", err) + } + eventbus.SendEvent(eventbus.WSEventType{ + EventType: "blockfile", + ORef: waveobj.MakeORef(wstore.OType_Block, blockId).String(), + Data: &eventbus.WSFileEventData{ + ZoneId: blockId, + FileName: blockFile, + FileOp: eventbus.FileOp_Truncate, + }, + }) + return nil + +} + func HandleAppendBlockFile(blockId string, blockFile string, data []byte) error { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() @@ -134,6 +181,19 @@ func HandleAppendBlockFile(blockId string, blockFile string, data []byte) error func (bc *BlockController) resetTerminalState() { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() + var shouldTruncate bool + blockData, getBlockDataErr := wstore.DBMustGet[*wstore.Block](ctx, bc.BlockId) + if getBlockDataErr == nil { + shouldTruncate = getBoolFromMeta(blockData.Meta, wstore.MetaKey_CmdClearOnRestart, false) + } + if shouldTruncate { + err := HandleTruncateBlockFile(bc.BlockId, BlockFile_Main) + if err != nil { + log.Printf("error truncating main blockfile: %v\n", err) + } + return + } + // controller type = "shell" var buf bytes.Buffer // buf.WriteString("\x1b[?1049l") // disable alternative buffer buf.WriteString("\x1b[0m") // reset attributes @@ -154,30 +214,86 @@ func (bc *BlockController) waveOSCMessageHandler(ctx context.Context, cmd wshuti return bc.RunCmdFn(ctx, cmd, wshutil.CmdContextType{BlockId: bc.BlockId, TabId: bc.TabId}) } -func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts) error { +func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta map[string]any) error { // create a circular blockfile for the output ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() err := filestore.WFS.MakeFile(ctx, bc.BlockId, "main", nil, filestore.FileOptsType{MaxSize: DefaultTermMaxFileSize, Circular: true}) - if err != nil && err != filestore.ErrAlreadyExists { + if err != nil && err != fs.ErrExist { + err = fs.ErrExist return fmt.Errorf("error creating blockfile: %w", err) } - if err == filestore.ErrAlreadyExists { + if err == fs.ErrExist { // reset the terminal state bc.resetTerminalState() } + err = nil if bc.getShellProc() != nil { return nil } - shellProc, err := shellexec.StartShellProc(rc.TermSize) + var shellProcErr error + bc.WithLock(func() { + if bc.ShellProc != nil { + shellProcErr = fmt.Errorf("shell process already running") + return + } + }) + if shellProcErr != nil { + return shellProcErr + } + var cmdStr string + var cmdOpts shellexec.CommandOptsType + if bc.ControllerType == BlockController_Shell { + cmdOpts = shellexec.CommandOptsType{Interactive: true, Login: true} + } else if bc.ControllerType == BlockController_Cmd { + if _, ok := blockMeta["cmd"].(string); ok { + cmdStr = blockMeta["cmd"].(string) + } else { + return fmt.Errorf("missing cmd in block meta") + } + if _, ok := blockMeta["cwd"].(string); ok { + cmdOpts.Cwd = blockMeta["cwd"].(string) + if cmdOpts.Cwd != "" { + cmdOpts.Cwd = wavebase.ExpandHomeDir(cmdOpts.Cwd) + } + } + if _, ok := blockMeta["cmd:interactive"]; ok { + if blockMeta["cmd:interactive"].(bool) { + cmdOpts.Interactive = true + } + } + if _, ok := blockMeta["cmd:login"]; ok { + if blockMeta["cmd:login"].(bool) { + cmdOpts.Login = true + } + } + if _, ok := blockMeta["cmd:env"].(map[string]any); ok { + cmdEnv := blockMeta["cmd:env"].(map[string]any) + cmdOpts.Env = make(map[string]string) + for k, v := range cmdEnv { + if v == nil { + continue + } + if _, ok := v.(string); ok { + cmdOpts.Env[k] = v.(string) + } + if _, ok := v.(float64); ok { + cmdOpts.Env[k] = fmt.Sprintf("%v", v) + } + } + } + } else { + return fmt.Errorf("unknown controller type %q", bc.ControllerType) + } + shellProc, err := shellexec.StartShellProc(rc.TermSize, cmdStr, cmdOpts) if err != nil { return err } - err = bc.setShellProc(shellProc) - if err != nil { - bc.ShellProc.Close() - return err - } + bc.UpdateControllerAndSendUpdate(func() bool { + bc.ShellProc = shellProc + bc.ShellProcStatus = Status_Running + return true + }) shellInputCh := make(chan *BlockInputUnion, 32) bc.ShellInputCh = shellInputCh messageCh := make(chan wshutil.RpcMessage, 32) @@ -186,6 +302,8 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts) error { go func() { // handles regular output from the pty (goes to the blockfile and xterm) defer func() { + log.Printf("[shellproc] pty-read loop done\n") + // needs synchronization bc.ShellProc.Close() close(bc.ShellInputCh) @@ -211,6 +329,9 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts) error { } }() go func() { + defer func() { + log.Printf("[shellproc] shellInputCh loop done\n") + }() // handles input from the shellInputCh, sent to pty for ic := range shellInputCh { if len(ic.InputData) > 0 { @@ -232,37 +353,71 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts) error { shellInputCh <- &BlockInputUnion{InputData: out} } }() + go func() { + // wait for the shell to finish + defer func() { + bc.UpdateControllerAndSendUpdate(func() bool { + bc.ShellProcStatus = Status_Done + return true + }) + log.Printf("[shellproc] shell process wait loop done\n") + }() + waitErr := shellProc.Cmd.Wait() + shellProc.SetWaitErrorAndSignalDone(waitErr) + exitCode := shellexec.ExitCodeFromWaitErr(waitErr) + termMsg := fmt.Sprintf("\r\nprocess finished with exit code = %d\r\n\r\n", exitCode) + HandleAppendBlockFile(bc.BlockId, BlockFile_Main, []byte(termMsg)) + }() return nil } -func (bc *BlockController) Run(bdata *wstore.Block) { +func getBoolFromMeta(meta map[string]any, key string, def bool) bool { + ival, found := meta[key] + if !found || ival == nil { + return def + } + if val, ok := ival.(bool); ok { + return val + } + return def +} + +func (bc *BlockController) run(bdata *wstore.Block, blockMeta map[string]any) { defer func() { - bc.WithLock(func() { - // if the controller had an error status, don't change it - if bc.Status == "running" { - bc.Status = "done" + bc.UpdateControllerAndSendUpdate(func() bool { + if bc.Status == Status_Running { + bc.Status = Status_Done + return true } - }) - eventbus.SendEvent(eventbus.WSEventType{ - EventType: "block:done", - ORef: waveobj.MakeORef(wstore.OType_Block, bc.BlockId).String(), - Data: nil, + return false }) globalLock.Lock() defer globalLock.Unlock() delete(blockControllerMap, bc.BlockId) }() - bc.WithLock(func() { - bc.Status = "running" + bc.UpdateControllerAndSendUpdate(func() bool { + bc.Status = Status_Running + return true }) - - // only controller is "shell" for now - go func() { - err := bc.DoRunShellCommand(&RunShellOpts{TermSize: bdata.RuntimeOpts.TermSize}) + if bdata.Controller != BlockController_Shell && bdata.Controller != BlockController_Cmd { + log.Printf("unknown controller %q\n", bdata.Controller) + return + } + if getBoolFromMeta(blockMeta, wstore.MetaKey_CmdClearOnStart, false) { + err := HandleTruncateBlockFile(bc.BlockId, BlockFile_Main) if err != nil { - log.Printf("error running shell: %v\n", err) + log.Printf("error truncating main blockfile: %v\n", err) } - }() + } + runOnStart := getBoolFromMeta(blockMeta, wstore.MetaKey_CmdRunOnStart, true) + if runOnStart { + go func() { + err := bc.DoRunShellCommand(&RunShellOpts{TermSize: bdata.RuntimeOpts.TermSize}, bdata.Meta) + if err != nil { + log.Printf("error running shell: %v\n", err) + } + }() + } for genCmd := range bc.InputCh { switch cmd := genCmd.(type) { @@ -284,6 +439,14 @@ func (bc *BlockController) Run(bdata *wstore.Block) { inputUnion.InputData = inputBuf[:nw] } bc.ShellInputCh <- inputUnion + case *wshutil.BlockRestartCommand: + // TODO: if shell command is already running + // we probably want to kill it off, wait, and then restart it + err := bc.DoRunShellCommand(&RunShellOpts{TermSize: bdata.RuntimeOpts.TermSize}, bdata.Meta) + if err != nil { + log.Printf("error running shell command: %v\n", err) + } + default: log.Printf("unknown command type %T\n", cmd) } @@ -299,7 +462,7 @@ func StartBlockController(ctx context.Context, tabId string, blockId string, run // nothing to start return nil } - if blockData.Controller != BlockController_Shell { + if blockData.Controller != BlockController_Shell && blockData.Controller != BlockController_Cmd { return fmt.Errorf("unknown controller %q", blockData.Controller) } globalLock.Lock() @@ -309,16 +472,17 @@ func StartBlockController(ctx context.Context, tabId string, blockId string, run return nil } bc := &BlockController{ - Lock: &sync.Mutex{}, - ControllerType: blockData.Controller, - TabId: tabId, - BlockId: blockId, - Status: "init", - InputCh: make(chan wshutil.BlockCommand), - RunCmdFn: runCmdFn, + Lock: &sync.Mutex{}, + ControllerType: blockData.Controller, + TabId: tabId, + BlockId: blockId, + Status: Status_Init, + InputCh: make(chan wshutil.BlockCommand), + RunCmdFn: runCmdFn, + ShellProcStatus: Status_Init, } blockControllerMap[blockId] = bc - go bc.Run(blockData) + go bc.run(blockData, blockData.Meta) return nil } @@ -327,7 +491,9 @@ func StopBlockController(blockId string) { if bc == nil { return } - bc.Close() + if bc.getShellProc() != nil { + bc.ShellProc.Close() + } close(bc.InputCh) } diff --git a/pkg/cmdqueue/cmdqueue.go b/pkg/cmdqueue/cmdqueue.go index 315243d0..d7dbc292 100644 --- a/pkg/cmdqueue/cmdqueue.go +++ b/pkg/cmdqueue/cmdqueue.go @@ -7,6 +7,7 @@ import ( "context" "encoding/base64" "fmt" + "io/fs" "log" "runtime/debug" "strings" @@ -226,7 +227,7 @@ func handleAppendIJsonFile(blockId string, blockFile string, cmd map[string]any, defer cancelFn() if blockFile == blockcontroller.BlockFile_Html && tryCreate { err := filestore.WFS.MakeFile(ctx, blockId, blockFile, nil, filestore.FileOptsType{MaxSize: blockcontroller.DefaultHtmlMaxFileSize, IJson: true}) - if err != nil && err != filestore.ErrAlreadyExists { + if err != nil && err != fs.ErrExist { return fmt.Errorf("error creating blockfile[html]: %w", err) } } diff --git a/pkg/eventbus/eventbus.go b/pkg/eventbus/eventbus.go index f2da20b6..741c91d0 100644 --- a/pkg/eventbus/eventbus.go +++ b/pkg/eventbus/eventbus.go @@ -16,7 +16,8 @@ type WSEventType struct { } const ( - FileOp_Append = "append" + FileOp_Append = "append" + FileOp_Truncate = "truncate" ) type WSFileEventData struct { diff --git a/pkg/filestore/blockstore_dbops.go b/pkg/filestore/blockstore_dbops.go index e82de5b4..9ff4ccd9 100644 --- a/pkg/filestore/blockstore_dbops.go +++ b/pkg/filestore/blockstore_dbops.go @@ -6,19 +6,19 @@ package filestore import ( "context" "fmt" + "io/fs" "os" "github.com/wavetermdev/waveterm/wavesrv/pkg/dbutil" ) -var ErrAlreadyExists = fmt.Errorf("file already exists") - +// can return fs.ErrExist func dbInsertFile(ctx context.Context, file *WaveFile) error { // will fail if file already exists return WithTx(ctx, func(tx *TxWrap) error { query := "SELECT zoneid FROM db_wave_file WHERE zoneid = ? AND name = ?" if tx.Exists(query, file.ZoneId, file.Name) { - return ErrAlreadyExists + return fs.ErrExist } query = "INSERT INTO db_wave_file (zoneid, name, size, createdts, modts, opts, meta) VALUES (?, ?, ?, ?, ?, ?, ?)" tx.Exec(query, file.ZoneId, file.Name, file.Size, file.CreatedTs, file.ModTs, dbutil.QuickJson(file.Opts), dbutil.QuickJson(file.Meta)) diff --git a/pkg/service/blockservice/blockservice.go b/pkg/service/blockservice/blockservice.go index 2f0a1550..b307c517 100644 --- a/pkg/service/blockservice/blockservice.go +++ b/pkg/service/blockservice/blockservice.go @@ -8,6 +8,7 @@ import ( "fmt" "time" + "github.com/wavetermdev/thenextwave/pkg/blockcontroller" "github.com/wavetermdev/thenextwave/pkg/cmdqueue" "github.com/wavetermdev/thenextwave/pkg/filestore" "github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta" @@ -28,6 +29,17 @@ func (bs *BlockService) SendCommand_Meta() tsgenmeta.MethodMeta { } } +func (bs *BlockService) GetControllerStatus(ctx context.Context, blockId string) (*blockcontroller.BlockControllerRuntimeStatus, error) { + bc := blockcontroller.GetBlockController(blockId) + if bc == nil { + return &blockcontroller.BlockControllerRuntimeStatus{ + BlockId: blockId, + Status: "stopped", + }, nil + } + return bc.GetRuntimeStatus(), nil +} + func (bs *BlockService) SendCommand(uiContext wstore.UIContext, blockId string, cmd wshutil.BlockCommand) error { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() diff --git a/pkg/service/fileservice/fileservice.go b/pkg/service/fileservice/fileservice.go index 89a40c0a..6caaaa75 100644 --- a/pkg/service/fileservice/fileservice.go +++ b/pkg/service/fileservice/fileservice.go @@ -76,9 +76,8 @@ func (fs *FileService) ReadFile(path string) (*FullFile, error) { if err != nil { return nil, fmt.Errorf("unable to parse directory %s", finfo.Path) } - if len(innerFilesEntries) > 1000 { - innerFilesEntries = innerFilesEntries[:1001] + innerFilesEntries = innerFilesEntries[:1000] } var innerFilesInfo []FileInfo for _, innerFileEntry := range innerFilesEntries { diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index 15bccf31..450d7681 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "reflect" + "sync" "syscall" "github.com/creack/pty" @@ -22,19 +23,65 @@ type TermSize struct { Cols int `json:"cols"` } +type CommandOptsType struct { + Interactive bool `json:"interactive,omitempty"` + Login bool `json:"login,omitempty"` + Cwd string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + type ShellProc struct { - Cmd *exec.Cmd - Pty *os.File + Cmd *exec.Cmd + Pty *os.File + CloseOnce *sync.Once + DoneCh chan any // closed after proc.Wait() returns + WaitErr error // WaitErr is synchronized by DoneCh (written before DoneCh is closed) and CloseOnce } func (sp *ShellProc) Close() { sp.Cmd.Process.Kill() go func() { - sp.Cmd.Process.Wait() + _, waitErr := sp.Cmd.Process.Wait() + sp.SetWaitErrorAndSignalDone(waitErr) sp.Pty.Close() }() } +func (sp *ShellProc) SetWaitErrorAndSignalDone(waitErr error) { + sp.CloseOnce.Do(func() { + sp.WaitErr = waitErr + close(sp.DoneCh) + }) +} + +func (sp *ShellProc) Wait() error { + <-sp.DoneCh + return sp.WaitErr +} + +// returns (done, waitError) +func (sp *ShellProc) WaitNB() (bool, error) { + select { + case <-sp.DoneCh: + return true, sp.WaitErr + default: + return false, nil + } +} + +func ExitCodeFromWaitErr(err error) int { + if err == nil { + return 0 + } + if exitErr, ok := err.(*exec.ExitError); ok { + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { + return status.ExitStatus() + } + } + return -1 + +} + func setBoolConditionally(rval reflect.Value, field string, value bool) { if rval.Elem().FieldByName(field).IsValid() { rval.Elem().FieldByName(field).SetBool(value) @@ -47,11 +94,40 @@ func setSysProcAttrs(cmd *exec.Cmd) { setBoolConditionally(rval, "Setctty", true) } -func StartShellProc(termSize TermSize) (*ShellProc, error) { - shellPath := shellutil.DetectLocalShellPath() - ecmd := exec.Command(shellPath, "-i", "-l") +func checkCwd(cwd string) error { + if cwd == "" { + return fmt.Errorf("cwd is empty") + } + if _, err := os.Stat(cwd); err != nil { + return fmt.Errorf("error statting cwd %q: %w", cwd, err) + } + return nil +} + +func StartShellProc(termSize TermSize, cmdStr string, cmdOpts CommandOptsType) (*ShellProc, error) { + var ecmd *exec.Cmd + var shellOpts []string + if cmdOpts.Login { + shellOpts = append(shellOpts, "-l") + } + if cmdOpts.Interactive { + shellOpts = append(shellOpts, "-i") + } + if cmdStr == "" { + shellPath := shellutil.DetectLocalShellPath() + ecmd = exec.Command(shellPath, shellOpts...) + } else { + shellPath := shellutil.DetectLocalShellPath() + shellOpts = append(shellOpts, "-c", cmdStr) + ecmd = exec.Command(shellPath, shellOpts...) + } ecmd.Env = os.Environ() - ecmd.Dir = wavebase.GetHomeDir() + if cmdOpts.Cwd != "" { + ecmd.Dir = cmdOpts.Cwd + } + if cwdErr := checkCwd(ecmd.Dir); cwdErr != nil { + ecmd.Dir = wavebase.GetHomeDir() + } envToAdd := shellutil.WaveshellEnvVars(shellutil.DefaultTermType) if os.Getenv("LANG") == "" { envToAdd["LANG"] = wavebase.DetermineLang() @@ -80,7 +156,7 @@ func StartShellProc(termSize TermSize) (*ShellProc, error) { cmdPty.Close() return nil, err } - return &ShellProc{Cmd: ecmd, Pty: cmdPty}, nil + return &ShellProc{Cmd: ecmd, Pty: cmdPty, CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil } func RunSimpleCmdInPty(ecmd *exec.Cmd, termSize TermSize) ([]byte, error) { diff --git a/pkg/wshutil/wshcommands.go b/pkg/wshutil/wshcommands.go index 6cd02e07..30167a58 100644 --- a/pkg/wshutil/wshcommands.go +++ b/pkg/wshutil/wshcommands.go @@ -23,6 +23,7 @@ const ( BlockCommand_SetMeta = "setmeta" BlockCommand_GetMeta = "getmeta" BlockCommand_Input = "controller:input" + BlockCommand_Restart = "controller:restart" BlockCommand_AppendBlockFile = "blockfile:append" BlockCommand_AppendIJson = "blockfile:appendijson" Command_ResolveIds = "resolveids" @@ -31,6 +32,7 @@ const ( var CommandToTypeMap = map[string]reflect.Type{ BlockCommand_Input: reflect.TypeOf(BlockInputCommand{}), + BlockCommand_Restart: reflect.TypeOf(BlockRestartCommand{}), BlockCommand_SetView: reflect.TypeOf(BlockSetViewCommand{}), BlockCommand_SetMeta: reflect.TypeOf(BlockSetMetaCommand{}), BlockCommand_GetMeta: reflect.TypeOf(BlockGetMetaCommand{}), @@ -97,6 +99,19 @@ func ParseCmdMap(cmdMap map[string]any) (BlockCommand, error) { return cmd.(BlockCommand), nil } +type BlockRestartCommand struct { + Command string `json:"command" tstype:"\"controller:restart\""` + BlockId string `json:"blockid"` +} + +func (rc *BlockRestartCommand) GetCommand() string { + return BlockCommand_Restart +} + +func (rc *BlockRestartCommand) GetBlockId() string { + return rc.BlockId +} + type BlockInputCommand struct { BlockId string `json:"blockid"` Command string `json:"command" tstype:"\"controller:input\""` diff --git a/pkg/wstore/wstore_types.go b/pkg/wstore/wstore_types.go index ec296e9a..c0e5194a 100644 --- a/pkg/wstore/wstore_types.go +++ b/pkg/wstore/wstore_types.go @@ -12,6 +12,26 @@ import ( "github.com/wavetermdev/thenextwave/pkg/waveobj" ) +// well known meta keys +const ( + MetaKey_Title = "title" + MetaKey_File = "file" + MetaKey_Url = "url" + MetaKey_Icon = "icon" + MetaKey_IconColor = "icon:color" + MetaKey_Frame = "frame" + MetaKey_FrameBorderColor = "frame:bordercolor" + MetaKey_FrameBorderColor_Focused = "frame:bordercolor:focused" + MetaKey_Cmd = "cmd" + MetaKey_CmdInteractive = "cmd:interactive" + MetaKey_CmdLogin = "cmd:login" + MetaKey_CmdRunOnStart = "cmd:runonstart" + MetaKey_CmdClearOnStart = "cmd:clearonstart" + MetaKey_CmdClearOnRestart = "cmd:clearonrestart" + MetaKey_CmdEnv = "env" + MetaKey_CmdCwd = "cwd" +) + type UIContext struct { WindowId string `json:"windowid"` ActiveTabId string `json:"activetabid"`