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"`