cmd blocks (#74)

This commit is contained in:
Mike Sawka
2024-06-24 14:34:31 -07:00
committed by GitHub
parent edb8eb25b8
commit 77b5acfc5a
16 changed files with 523 additions and 124 deletions
+12 -8
View File
@@ -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(<i key={match.index} className={`fa fa-solid fa-${tagParam}`} />);
lastPart.push(<i key={match.index} className={iconClass} />);
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 = (
<i key="icon" style={iconStyle} className={`block-frame-icon fa fa-solid fa-${blockIcon}`} />
);
const iconClass = util.makeIconClass(blockIcon, false);
if (iconClass != null) {
blockIconElem = <i key="icon" style={iconStyle} className={clsx(`block-frame-icon`, iconClass)} />;
}
}
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 {
-1
View File
@@ -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);
+3
View File
@@ -7,6 +7,9 @@ import * as WOS from "./wos";
// blockservice.BlockService (block)
class BlockServiceType {
GetControllerStatus(arg2: string): Promise<BlockControllerRuntimeStatus> {
return WOS.callBackendService("block", "GetControllerStatus", Array.from(arguments))
}
SaveTerminalState(arg2: string, arg3: string, arg4: string, arg5: number): Promise<void> {
return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments))
}
+74 -11
View File
@@ -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<HTMLDivElement>(null);
const termRef = React.useRef<TermWrap>(null);
const initialLoadRef = React.useRef<InitialLoadDataType>({ loaded: false, heldData: [] });
const shellProcStatusRef = React.useRef<string>(null);
const blockIconOverrideAtom = useBlockAtom<string>(blockId, "blockicon:override", () => {
return jotai.atom<string>(null);
}) as jotai.PrimitiveAtom<string>;
const htmlElemFocusRef = React.useRef<HTMLInputElement>(null);
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const isFocusedAtom = useBlockAtom<boolean>(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<HTMLInputElement>) => {
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,
+27 -20
View File
@@ -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<void> {
+33 -18
View File
@@ -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 {}
export {}
+19 -1
View File
@@ -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 };
+216 -50
View File
@@ -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)
}
+2 -1
View File
@@ -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)
}
}
+2 -1
View File
@@ -16,7 +16,8 @@ type WSEventType struct {
}
const (
FileOp_Append = "append"
FileOp_Append = "append"
FileOp_Truncate = "truncate"
)
type WSFileEventData struct {
+3 -3
View File
@@ -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))
+12
View File
@@ -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()
+1 -2
View File
@@ -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 {
+84 -8
View File
@@ -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) {
+15
View File
@@ -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\""`
+20
View File
@@ -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"`