round trip a message to the backend that updates the terminal fe component

This commit is contained in:
sawka 2024-05-14 16:53:03 -07:00
parent 50ccd66d49
commit 35c6b232fc
16 changed files with 1135 additions and 27 deletions

View File

@ -0,0 +1,135 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.wave-button {
background: none;
border: none;
cursor: pointer;
outline: inherit;
display: flex;
padding: 6px 16px;
align-items: center;
gap: 4px;
border-radius: 6px;
height: auto;
line-height: 1.5;
white-space: nowrap;
user-select: none;
color: var(--main-text-color);
background: var(--accent-color);
i {
fill: var(--main-text-color);
}
&.primary {
color: var(--main-text-color);
background: var(--accent-color);
i {
fill: var(--main-text-color);
}
}
&.primary.danger {
background: var(--error-color);
}
&.primary.outlined {
background: none;
border: 1px solid var(--accent-color);
i {
fill: var(--accent-color);
}
}
&.primary.greyoutlined {
background: none;
border: 1px solid var(--secondary-text-color);
i {
fill: var(--secondary-text-color);
}
}
&.primary.outlined,
&.primary.greyoutlined {
&.hover-danger:hover {
color: var(--main-text-color);
border: 1px solid var(--error-color);
background: var(--error-color);
}
}
&.primary.outlined.danger {
background: none;
border: 1px solid var(--error-color);
i {
fill: var(--error-color);
}
}
&.greytext {
color: var(--secondary-text-color);
}
&.primary.ghost {
background: none;
i {
fill: var(--accent-color);
}
}
&.primary.ghost.danger {
background: none;
i {
fill: var(--app-error-color);
}
}
&.secondary {
color: var(--main-text-color);
background: var(--highlight-bg-color);
i {
fill: var(--main-text-color);
}
}
&.secondary.outlined {
background: none;
border: 1px solid var(--main-text-color);
}
&.secondary.outlined.danger {
background: none;
border: 1px solid var(--error-color);
}
&.secondary.ghost {
background: none;
}
&.secondary.danger {
color: var(--error-color);
}
&.small {
padding: 4px 8px;
font-size: 12px;
border-radius: 3.6px;
}
&.term-inline {
padding: 2px 8px;
border-radius: 3px;
}
&.disabled {
opacity: 0.5;
}
&.link-button {
cursor: pointer;
}
}

View File

@ -0,0 +1,55 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import { clsx } from "clsx";
import "./button.less";
interface ButtonProps {
children: React.ReactNode;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
style?: React.CSSProperties;
autoFocus?: boolean;
className?: string;
termInline?: boolean;
title?: string;
}
class Button extends React.Component<ButtonProps> {
static defaultProps = {
style: {},
className: "primary",
};
handleClick(e) {
if (this.props.onClick && !this.props.disabled) {
this.props.onClick(e);
}
}
render() {
const { leftIcon, rightIcon, children, disabled, style, autoFocus, termInline, className, title } = this.props;
return (
<button
className={clsx("wave-button", { disabled }, { "term-inline": termInline }, className)}
onClick={this.handleClick.bind(this)}
disabled={disabled}
style={style}
autoFocus={autoFocus}
title={title}
>
{leftIcon && <span className="icon-left">{leftIcon}</span>}
{children}
{rightIcon && <span className="icon-right">{rightIcon}</span>}
</button>
);
}
}
export { Button };
export type { ButtonProps };

View File

@ -1,5 +1,8 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.markdown { .markdown {
color: var(--main-color); color: var(--main-text-color);
font-family: var(--markdown-font); font-family: var(--markdown-font);
font-size: 14px; font-size: 14px;
overflow-wrap: break-word; overflow-wrap: break-word;
@ -9,13 +12,13 @@
} }
.title { .title {
color: var(--main-color); color: var(--main-text-color);
margin-top: 16px; margin-top: 16px;
margin-bottom: 8px; margin-bottom: 8px;
} }
strong { strong {
color: var(--main-color); color: var(--main-text-color);
} }
a { a {
@ -24,7 +27,7 @@
table { table {
tr th { tr th {
color: var(--main-color); color: var(--main-text-color);
} }
} }
@ -61,7 +64,7 @@
code { code {
font: var(--fixed-font); font: var(--fixed-font);
color: var(--main-color); color: var(--main-text-color);
border-radius: 4px; border-radius: 4px;
background-color: var(--panel-bg-color); background-color: var(--panel-bg-color);
padding: 0.15em 0.5em; padding: 0.15em 0.5em;

View File

@ -4,6 +4,9 @@
import * as jotai from "jotai"; import * as jotai from "jotai";
import { atomFamily } from "jotai/utils"; import { atomFamily } from "jotai/utils";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import * as rxjs from "rxjs";
import type { WailsEvent } from "@wailsio/runtime/types/events";
import { Events } from "@wailsio/runtime";
const globalStore = jotai.createStore(); const globalStore = jotai.createStore();
@ -42,4 +45,40 @@ const atoms = {
blockAtomFamily, blockAtomFamily,
}; };
export { globalStore, atoms }; type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void };
const blockSubjects = new Map<string, SubjectWithRef<any>>();
function getBlockSubject(blockId: string): SubjectWithRef<any> {
let subject = blockSubjects.get(blockId);
if (subject == null) {
subject = new rxjs.Subject<any>() as any;
subject.refCount = 0;
subject.release = () => {
subject.refCount--;
if (subject.refCount === 0) {
subject.complete();
blockSubjects.delete(blockId);
}
};
blockSubjects.set(blockId, subject);
}
subject.refCount++;
return subject;
}
Events.On("block:ptydata", (event: any) => {
const data = event?.data;
if (data?.blockid == null) {
console.log("block:ptydata with null blockid");
return;
}
// we don't use getBlockSubject here because we don't want to create a new subject
const subject = blockSubjects.get(data.blockid);
if (subject == null) {
return;
}
subject.next(data);
});
export { globalStore, atoms, getBlockSubject };

View File

@ -6,6 +6,10 @@ import * as jotai from "jotai";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import type { ITheme } from "@xterm/xterm"; import type { ITheme } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { Button } from "@/element/button";
import * as BlockService from "@/bindings/pkg/service/blockservice/BlockService";
import { getBlockSubject } from "@/store/global";
import { base64ToArray } from "@/util/util";
import "./view.less"; import "./view.less";
import "/public/xterm.css"; import "/public/xterm.css";
@ -40,6 +44,7 @@ function getThemeFromCSSVars(el: Element): ITheme {
const TerminalView = ({ blockId }: { blockId: string }) => { const TerminalView = ({ blockId }: { blockId: string }) => {
const connectElemRef = React.useRef<HTMLDivElement>(null); const connectElemRef = React.useRef<HTMLDivElement>(null);
const [term, setTerm] = React.useState<Terminal>(null);
React.useEffect(() => { React.useEffect(() => {
if (!connectElemRef.current) { if (!connectElemRef.current) {
@ -57,13 +62,47 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
term.open(connectElemRef.current); term.open(connectElemRef.current);
fitAddon.fit(); fitAddon.fit();
term.write("Hello, world!"); term.write("Hello, world!\r\n");
setTerm(term);
return () => { return () => {
term.dispose(); term.dispose();
}; };
}, [connectElemRef.current]); }, [connectElemRef.current]);
React.useEffect(() => {
if (!term) {
return;
}
const blockSubject = getBlockSubject(blockId);
blockSubject.subscribe((data) => {
// base64 decode
const decodedData = base64ToArray(data.ptydata);
term.write(decodedData);
});
return () => {
blockSubject.release();
};
}, [term]);
return <div key="conntectElem" className="view-term term-connectelem" ref={connectElemRef}></div>; async function handleRunClick() {
try {
await BlockService.StartBlock(blockId);
await BlockService.SendCommand(blockId, { command: "message", message: "Run clicked" });
} catch (e) {
console.log("run click error: ", e);
}
}
return (
<div className="view-term">
<div className="term-header">
<div>Terminal</div>
<Button className="term-inline" onClick={() => handleRunClick()}>
Run
</Button>
</div>
<div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div>
</div>
);
}; };
export { TerminalView }; export { TerminalView };

View File

@ -7,6 +7,23 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
.term-header {
display: flex;
flex-direction: row;
padding: 4px 10px;
height: 35px;
gap: 10px;
align-items: center;
flex-shrink: 0;
border-bottom: 1px solid var(--border-color);
}
.term-connectelem {
flex-grow: 1;
min-height: 0;
overflow: hidden;
}
} }
.view-preview { .view-preview {

1
go.mod
View File

@ -5,6 +5,7 @@ go 1.22
toolchain go1.22.1 toolchain go1.22.1
require ( require (
github.com/creack/pty v1.1.18
github.com/golang-migrate/migrate/v4 v4.17.1 github.com/golang-migrate/migrate/v4 v4.17.1
github.com/google/uuid v1.4.0 github.com/google/uuid v1.4.0
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0

2
go.sum
View File

@ -17,6 +17,8 @@ github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7N
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View File

@ -4,6 +4,7 @@
package blockcontroller package blockcontroller
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"sync" "sync"
@ -16,14 +17,15 @@ var globalLock = &sync.Mutex{}
var blockControllerMap = make(map[string]*BlockController) var blockControllerMap = make(map[string]*BlockController)
type BlockCommand interface { type BlockCommand interface {
GetType() string GetCommand() string
} }
type MessageCommand struct { type MessageCommand struct {
Command string `json:"command"`
Message string `json:"message"` Message string `json:"message"`
} }
func (mc *MessageCommand) GetType() string { func (mc *MessageCommand) GetCommand() string {
return "message" return "message"
} }
@ -33,9 +35,9 @@ type BlockController struct {
} }
func ParseCmdMap(cmdMap map[string]any) (BlockCommand, error) { func ParseCmdMap(cmdMap map[string]any) (BlockCommand, error) {
cmdType, ok := cmdMap["type"].(string) cmdType, ok := cmdMap["command"].(string)
if !ok { if !ok {
return nil, fmt.Errorf("no type field in command map") return nil, fmt.Errorf("no command field in command map")
} }
mapJson, err := json.Marshal(cmdMap) mapJson, err := json.Marshal(cmdMap)
if err != nil { if err != nil {
@ -65,10 +67,20 @@ func (bc *BlockController) Run() {
delete(blockControllerMap, bc.BlockId) delete(blockControllerMap, bc.BlockId)
}() }()
messageCount := 0
for genCmd := range bc.InputCh { for genCmd := range bc.InputCh {
switch cmd := genCmd.(type) { switch cmd := genCmd.(type) {
case *MessageCommand: case *MessageCommand:
fmt.Printf("MESSAGE: %s | %q\n", bc.BlockId, cmd.Message) fmt.Printf("MESSAGE: %s | %q\n", bc.BlockId, cmd.Message)
messageCount++
eventbus.SendEvent(application.WailsEvent{
Name: "block:ptydata",
Data: map[string]any{
"blockid": bc.BlockId,
"blockfile": "main",
"ptydata": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("message %d\r\n", messageCount))),
},
})
default: default:
fmt.Printf("unknown command type %T\n", cmd) fmt.Printf("unknown command type %T\n", cmd)
@ -76,9 +88,12 @@ func (bc *BlockController) Run() {
} }
} }
func NewBlockController(blockId string) *BlockController { func StartBlockController(blockId string) *BlockController {
globalLock.Lock() globalLock.Lock()
defer globalLock.Unlock() defer globalLock.Unlock()
if existingBC, ok := blockControllerMap[blockId]; ok {
return existingBC
}
bc := &BlockController{ bc := &BlockController{
BlockId: blockId, BlockId: blockId,
InputCh: make(chan BlockCommand), InputCh: make(chan BlockCommand),

View File

@ -11,6 +11,11 @@ import (
type BlockService struct{} type BlockService struct{}
func (bs *BlockService) StartBlock(blockId string) error {
blockcontroller.StartBlockController(blockId)
return nil
}
func (bs *BlockService) SendCommand(blockId string, cmdMap map[string]any) error { func (bs *BlockService) SendCommand(blockId string, cmdMap map[string]any) error {
bc := blockcontroller.GetBlockController(blockId) bc := blockcontroller.GetBlockController(blockId)
if bc == nil { if bc == nil {

View File

@ -0,0 +1,52 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package shellexec
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"syscall"
"github.com/creack/pty"
"github.com/wavetermdev/thenextwave/pkg/util/shellutil"
)
func RunSimpleCmdInPty(ecmd *exec.Cmd) ([]byte, error) {
ecmd.Env = os.Environ()
shellutil.UpdateCmdEnv(ecmd, shellutil.WaveshellEnvVars(shellutil.DefaultTermType))
cmdPty, cmdTty, err := pty.Open()
if err != nil {
return nil, fmt.Errorf("opening new pty: %w", err)
}
pty.Setsize(cmdPty, &pty.Winsize{Rows: shellutil.DefaultTermRows, Cols: shellutil.DefaultTermCols})
ecmd.Stdin = cmdTty
ecmd.Stdout = cmdTty
ecmd.Stderr = cmdTty
ecmd.SysProcAttr = &syscall.SysProcAttr{}
ecmd.SysProcAttr.Setsid = true
ecmd.SysProcAttr.Setctty = true
err = ecmd.Start()
cmdTty.Close()
if err != nil {
cmdPty.Close()
return nil, err
}
defer cmdPty.Close()
ioDone := make(chan bool)
var outputBuf bytes.Buffer
go func() {
// ignore error (/dev/ptmx has read error when process is done)
defer close(ioDone)
io.Copy(&outputBuf, cmdPty)
}()
exitErr := ecmd.Wait()
if exitErr != nil {
return nil, exitErr
}
<-ioDone
return outputBuf.Bytes(), nil
}

View File

@ -0,0 +1,62 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package shellutil
import (
"os"
"os/exec"
"strings"
"github.com/wavetermdev/thenextwave/pkg/wavebase"
)
const DefaultTermType = "xterm-256color"
const DefaultTermRows = 24
const DefaultTermCols = 80
func WaveshellEnvVars(termType string) map[string]string {
rtn := make(map[string]string)
if termType != "" {
rtn["TERM"] = termType
}
rtn["WAVETERM"], _ = os.Executable()
rtn["WAVETERM_VERSION"] = wavebase.WaveVersion
return rtn
}
func UpdateCmdEnv(cmd *exec.Cmd, envVars map[string]string) {
if len(envVars) == 0 {
return
}
found := make(map[string]bool)
var newEnv []string
for _, envStr := range cmd.Env {
envKey := GetEnvStrKey(envStr)
newEnvVal, ok := envVars[envKey]
if ok {
if newEnvVal == "" {
continue
}
newEnv = append(newEnv, envKey+"="+newEnvVal)
found[envKey] = true
} else {
newEnv = append(newEnv, envStr)
}
}
for envKey, envVal := range envVars {
if found[envKey] {
continue
}
newEnv = append(newEnv, envKey+"="+envVal)
}
cmd.Env = newEnv
}
func GetEnvStrKey(envStr string) string {
eqIdx := strings.Index(envStr, "=")
if eqIdx == -1 {
return envStr
}
return envStr[0:eqIdx]
}

675
pkg/util/utilfn/utilfn.go Normal file
View File

@ -0,0 +1,675 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package utilfn
import (
"bytes"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math"
mathrand "math/rand"
"net/http"
"os"
"os/exec"
"regexp"
"sort"
"strings"
"syscall"
"unicode/utf8"
)
var HexDigits = []byte{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}
func GetStrArr(v interface{}, field string) []string {
if v == nil {
return nil
}
m, ok := v.(map[string]interface{})
if !ok {
return nil
}
fieldVal := m[field]
if fieldVal == nil {
return nil
}
iarr, ok := fieldVal.([]interface{})
if !ok {
return nil
}
var sarr []string
for _, iv := range iarr {
if sv, ok := iv.(string); ok {
sarr = append(sarr, sv)
}
}
return sarr
}
func GetBool(v interface{}, field string) bool {
if v == nil {
return false
}
m, ok := v.(map[string]interface{})
if !ok {
return false
}
fieldVal := m[field]
if fieldVal == nil {
return false
}
bval, ok := fieldVal.(bool)
if !ok {
return false
}
return bval
}
var needsQuoteRe = regexp.MustCompile(`[^\w@%:,./=+-]`)
// minimum maxlen=6
func ShellQuote(val string, forceQuote bool, maxLen int) string {
if maxLen < 6 {
maxLen = 6
}
rtn := val
if needsQuoteRe.MatchString(val) {
rtn = "'" + strings.ReplaceAll(val, "'", `'"'"'`) + "'"
}
if strings.HasPrefix(rtn, "\"") || strings.HasPrefix(rtn, "'") {
if len(rtn) > maxLen {
return rtn[0:maxLen-4] + "..." + rtn[0:1]
}
return rtn
}
if forceQuote {
if len(rtn) > maxLen-2 {
return "\"" + rtn[0:maxLen-5] + "...\""
}
return "\"" + rtn + "\""
} else {
if len(rtn) > maxLen {
return rtn[0:maxLen-3] + "..."
}
return rtn
}
}
func EllipsisStr(s string, maxLen int) string {
if maxLen < 4 {
maxLen = 4
}
if len(s) > maxLen {
return s[0:maxLen-3] + "..."
}
return s
}
func LongestPrefix(root string, strs []string) string {
if len(strs) == 0 {
return root
}
if len(strs) == 1 {
comp := strs[0]
if len(comp) >= len(root) && strings.HasPrefix(comp, root) {
if strings.HasSuffix(comp, "/") {
return strs[0]
}
return strs[0]
}
}
lcp := strs[0]
for i := 1; i < len(strs); i++ {
s := strs[i]
for j := 0; j < len(lcp); j++ {
if j >= len(s) || lcp[j] != s[j] {
lcp = lcp[0:j]
break
}
}
}
if len(lcp) < len(root) || !strings.HasPrefix(lcp, root) {
return root
}
return lcp
}
func ContainsStr(strs []string, test string) bool {
for _, s := range strs {
if s == test {
return true
}
}
return false
}
func IsPrefix(strs []string, test string) bool {
for _, s := range strs {
if len(s) > len(test) && strings.HasPrefix(s, test) {
return true
}
}
return false
}
// sentinel value for StrWithPos.Pos to indicate no position
const NoStrPos = -1
type StrWithPos struct {
Str string `json:"str"`
Pos int `json:"pos"` // this is a 'rune' position (not a byte position)
}
func (sp StrWithPos) String() string {
return strWithCursor(sp.Str, sp.Pos)
}
func ParseToSP(s string) StrWithPos {
idx := strings.Index(s, "[*]")
if idx == -1 {
return StrWithPos{Str: s, Pos: NoStrPos}
}
return StrWithPos{Str: s[0:idx] + s[idx+3:], Pos: utf8.RuneCountInString(s[0:idx])}
}
func strWithCursor(str string, pos int) string {
if pos == NoStrPos {
return str
}
if pos < 0 {
// invalid position
return "[*]_" + str
}
if pos > len(str) {
// invalid position
return str + "_[*]"
}
if pos == len(str) {
return str + "[*]"
}
var rtn []rune
for _, ch := range str {
if len(rtn) == pos {
rtn = append(rtn, '[', '*', ']')
}
rtn = append(rtn, ch)
}
return string(rtn)
}
func (sp StrWithPos) Prepend(str string) StrWithPos {
return StrWithPos{Str: str + sp.Str, Pos: utf8.RuneCountInString(str) + sp.Pos}
}
func (sp StrWithPos) Append(str string) StrWithPos {
return StrWithPos{Str: sp.Str + str, Pos: sp.Pos}
}
// returns base64 hash of data
func Sha1Hash(data []byte) string {
hvalRaw := sha1.Sum(data)
hval := base64.StdEncoding.EncodeToString(hvalRaw[:])
return hval
}
func ChunkSlice[T any](s []T, chunkSize int) [][]T {
var rtn [][]T
for len(rtn) > 0 {
if len(s) <= chunkSize {
rtn = append(rtn, s)
break
}
rtn = append(rtn, s[:chunkSize])
s = s[chunkSize:]
}
return rtn
}
var ErrOverflow = errors.New("integer overflow")
// Add two int values, returning an error if the result overflows.
func AddInt(left, right int) (int, error) {
if right > 0 {
if left > math.MaxInt-right {
return 0, ErrOverflow
}
} else {
if left < math.MinInt-right {
return 0, ErrOverflow
}
}
return left + right, nil
}
// Add a slice of ints, returning an error if the result overflows.
func AddIntSlice(vals ...int) (int, error) {
var rtn int
for _, v := range vals {
var err error
rtn, err = AddInt(rtn, v)
if err != nil {
return 0, err
}
}
return rtn, nil
}
func StrsEqual(s1arr []string, s2arr []string) bool {
if len(s1arr) != len(s2arr) {
return false
}
for i, s1 := range s1arr {
s2 := s2arr[i]
if s1 != s2 {
return false
}
}
return true
}
func StrMapsEqual(m1 map[string]string, m2 map[string]string) bool {
if len(m1) != len(m2) {
return false
}
for key, val1 := range m1 {
val2, found := m2[key]
if !found || val1 != val2 {
return false
}
}
for key := range m2 {
_, found := m1[key]
if !found {
return false
}
}
return true
}
func ByteMapsEqual(m1 map[string][]byte, m2 map[string][]byte) bool {
if len(m1) != len(m2) {
return false
}
for key, val1 := range m1 {
val2, found := m2[key]
if !found || !bytes.Equal(val1, val2) {
return false
}
}
for key := range m2 {
_, found := m1[key]
if !found {
return false
}
}
return true
}
func GetOrderedStringerMapKeys[K interface {
comparable
fmt.Stringer
}, V any](m map[K]V) []K {
keyStrMap := make(map[K]string)
keys := make([]K, 0, len(m))
for key := range m {
keys = append(keys, key)
keyStrMap[key] = key.String()
}
sort.Slice(keys, func(i, j int) bool {
return keyStrMap[keys[i]] < keyStrMap[keys[j]]
})
return keys
}
func GetOrderedMapKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
const (
nullEncodeEscByte = '\\'
nullEncodeSepByte = '|'
nullEncodeEqByte = '='
nullEncodeZeroByteEsc = '0'
nullEncodeEscByteEsc = '\\'
nullEncodeSepByteEsc = 's'
nullEncodeEqByteEsc = 'e'
)
func EncodeStringMap(m map[string]string) []byte {
var buf bytes.Buffer
for idx, key := range GetOrderedMapKeys(m) {
val := m[key]
buf.Write(NullEncodeStr(key))
buf.WriteByte(nullEncodeEqByte)
buf.Write(NullEncodeStr(val))
if idx < len(m)-1 {
buf.WriteByte(nullEncodeSepByte)
}
}
return buf.Bytes()
}
func DecodeStringMap(barr []byte) (map[string]string, error) {
if len(barr) == 0 {
return nil, nil
}
var rtn = make(map[string]string)
for _, b := range bytes.Split(barr, []byte{nullEncodeSepByte}) {
keyVal := bytes.SplitN(b, []byte{nullEncodeEqByte}, 2)
if len(keyVal) != 2 {
return nil, fmt.Errorf("invalid null encoding: %s", string(b))
}
key, err := NullDecodeStr(keyVal[0])
if err != nil {
return nil, err
}
val, err := NullDecodeStr(keyVal[1])
if err != nil {
return nil, err
}
rtn[key] = val
}
return rtn, nil
}
func EncodeStringArray(arr []string) []byte {
var buf bytes.Buffer
for idx, s := range arr {
buf.Write(NullEncodeStr(s))
if idx < len(arr)-1 {
buf.WriteByte(nullEncodeSepByte)
}
}
return buf.Bytes()
}
func DecodeStringArray(barr []byte) ([]string, error) {
if len(barr) == 0 {
return nil, nil
}
var rtn []string
for _, b := range bytes.Split(barr, []byte{nullEncodeSepByte}) {
s, err := NullDecodeStr(b)
if err != nil {
return nil, err
}
rtn = append(rtn, s)
}
return rtn, nil
}
func EncodedStringArrayHasFirstVal(encoded []byte, firstKey string) bool {
firstKeyBytes := NullEncodeStr(firstKey)
if !bytes.HasPrefix(encoded, firstKeyBytes) {
return false
}
if len(encoded) == len(firstKeyBytes) || encoded[len(firstKeyBytes)] == nullEncodeSepByte {
return true
}
return false
}
// on encoding error returns ""
// this is used to perform logic on first value without decoding the entire array
func EncodedStringArrayGetFirstVal(encoded []byte) string {
sepIdx := bytes.IndexByte(encoded, nullEncodeSepByte)
if sepIdx == -1 {
str, _ := NullDecodeStr(encoded)
return str
}
str, _ := NullDecodeStr(encoded[0:sepIdx])
return str
}
// encodes a string, removing null/zero bytes (and separators '|')
// a zero byte is encoded as "\0", a '\' is encoded as "\\", sep is encoded as "\s"
// allows for easy double splitting (first on \x00, and next on "|")
func NullEncodeStr(s string) []byte {
strBytes := []byte(s)
if bytes.IndexByte(strBytes, 0) == -1 &&
bytes.IndexByte(strBytes, nullEncodeEscByte) == -1 &&
bytes.IndexByte(strBytes, nullEncodeSepByte) == -1 &&
bytes.IndexByte(strBytes, nullEncodeEqByte) == -1 {
return strBytes
}
var rtn []byte
for _, b := range strBytes {
if b == 0 {
rtn = append(rtn, nullEncodeEscByte, nullEncodeZeroByteEsc)
} else if b == nullEncodeEscByte {
rtn = append(rtn, nullEncodeEscByte, nullEncodeEscByteEsc)
} else if b == nullEncodeSepByte {
rtn = append(rtn, nullEncodeEscByte, nullEncodeSepByteEsc)
} else if b == nullEncodeEqByte {
rtn = append(rtn, nullEncodeEscByte, nullEncodeEqByteEsc)
} else {
rtn = append(rtn, b)
}
}
return rtn
}
func NullDecodeStr(barr []byte) (string, error) {
if bytes.IndexByte(barr, nullEncodeEscByte) == -1 {
return string(barr), nil
}
var rtn []byte
for i := 0; i < len(barr); i++ {
curByte := barr[i]
if curByte == nullEncodeEscByte {
i++
nextByte := barr[i]
if nextByte == nullEncodeZeroByteEsc {
rtn = append(rtn, 0)
} else if nextByte == nullEncodeEscByteEsc {
rtn = append(rtn, nullEncodeEscByte)
} else if nextByte == nullEncodeSepByteEsc {
rtn = append(rtn, nullEncodeSepByte)
} else if nextByte == nullEncodeEqByteEsc {
rtn = append(rtn, nullEncodeEqByte)
} else {
// invalid encoding
return "", fmt.Errorf("invalid null encoding: %d", nextByte)
}
} else {
rtn = append(rtn, curByte)
}
}
return string(rtn), nil
}
func SortStringRunes(s string) string {
runes := []rune(s)
sort.Slice(runes, func(i, j int) bool {
return runes[i] < runes[j]
})
return string(runes)
}
// will overwrite m1 with m2's values
func CombineMaps[V any](m1 map[string]V, m2 map[string]V) {
for key, val := range m2 {
m1[key] = val
}
}
// returns hex escaped string (\xNN for each byte)
func ShellHexEscape(s string) string {
var rtn []byte
for _, ch := range []byte(s) {
rtn = append(rtn, []byte(fmt.Sprintf("\\x%02x", ch))...)
}
return string(rtn)
}
func GetMapKeys[K comparable, V any](m map[K]V) []K {
var rtn []K
for key := range m {
rtn = append(rtn, key)
}
return rtn
}
// combines string arrays and removes duplicates (returns a new array)
func CombineStrArrays(sarr1 []string, sarr2 []string) []string {
var rtn []string
m := make(map[string]struct{})
for _, s := range sarr1 {
if _, found := m[s]; found {
continue
}
m[s] = struct{}{}
rtn = append(rtn, s)
}
for _, s := range sarr2 {
if _, found := m[s]; found {
continue
}
m[s] = struct{}{}
rtn = append(rtn, s)
}
return rtn
}
func QuickJson(v interface{}) string {
barr, _ := json.Marshal(v)
return string(barr)
}
func QuickParseJson[T any](s string) T {
var v T
_ = json.Unmarshal([]byte(s), &v)
return v
}
func StrArrayToMap(sarr []string) map[string]bool {
m := make(map[string]bool)
for _, s := range sarr {
m[s] = true
}
return m
}
func AppendNonZeroRandomBytes(b []byte, randLen int) []byte {
if randLen <= 0 {
return b
}
numAdded := 0
for numAdded < randLen {
rn := mathrand.Intn(256)
if rn > 0 && rn < 256 { // exclude 0, also helps to suppress security warning to have a guard here
b = append(b, byte(rn))
numAdded++
}
}
return b
}
// returns (isEOF, error)
func CopyWithEndBytes(outputBuf *bytes.Buffer, reader io.Reader, endBytes []byte) (bool, error) {
buf := make([]byte, 4096)
for {
n, err := reader.Read(buf)
if n > 0 {
outputBuf.Write(buf[:n])
obytes := outputBuf.Bytes()
if bytes.HasSuffix(obytes, endBytes) {
outputBuf.Truncate(len(obytes) - len(endBytes))
return (err == io.EOF), nil
}
}
if err == io.EOF {
return true, nil
}
if err != nil {
return false, err
}
}
}
// does *not* close outputCh on EOF or error
func CopyToChannel(outputCh chan<- []byte, reader io.Reader) error {
buf := make([]byte, 4096)
for {
n, err := reader.Read(buf)
if n > 0 {
// copy so client can use []byte without it being overwritten
bufCopy := make([]byte, n)
copy(bufCopy, buf[:n])
outputCh <- bufCopy
}
if err == io.EOF {
return nil
}
if err != nil {
return err
}
}
}
// on error just returns ""
// does not return "application/octet-stream" as this is considered a detection failure
func DetectMimeType(path string) string {
fd, err := os.Open(path)
if err != nil {
return ""
}
defer fd.Close()
buf := make([]byte, 512)
// ignore the error (EOF / UnexpectedEOF is fine, just process how much we got back)
n, _ := io.ReadAtLeast(fd, buf, 512)
if n == 0 {
return ""
}
buf = buf[:n]
rtn := http.DetectContentType(buf)
if rtn == "application/octet-stream" {
return ""
}
return rtn
}
func GetCmdExitCode(cmd *exec.Cmd, err error) int {
if cmd == nil || cmd.ProcessState == nil {
return GetExitCode(err)
}
status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus)
if !ok {
return cmd.ProcessState.ExitCode()
}
signaled := status.Signaled()
if signaled {
signal := status.Signal()
return 128 + int(signal)
}
exitStatus := status.ExitStatus()
return exitStatus
}
func GetExitCode(err error) int {
if err == nil {
return 0
}
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
} else {
return -1
}
}
func GetFirstLine(s string) string {
idx := strings.Index(s, "\n")
if idx == -1 {
return s
}
return s[0:idx]
}

View File

@ -13,6 +13,7 @@ import (
"sync" "sync"
) )
const WaveVersion = "v0.1.0"
const DefaultWaveHome = "~/.w2" const DefaultWaveHome = "~/.w2"
const WaveHomeVarName = "WAVETERM_HOME" const WaveHomeVarName = "WAVETERM_HOME"
const WaveDevVarName = "WAVETERM_DEV" const WaveDevVarName = "WAVETERM_DEV"

View File

@ -2,19 +2,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
@import "./reset.less"; @import "./reset.less";
@import "./theme.less";
:root {
--main-bg-color: #000000;
--border-color: #333333;
--main-color: #f7f7f7;
--base-font: normal 15px / normal "Lato", sans-serif;
--fixed-font: normal 12px / normal "Hack", monospace;
--app-accent-color: rgb(88, 193, 66);
--panel-bg-color: rgba(31, 33, 31, 1);
--highlight-bg-color: rgba(255, 255, 255, 0.2);
--markdown-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji";
}
body { body {
display: flex; display: flex;
@ -22,7 +10,7 @@ body {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background-color: var(--main-bg-color); background-color: var(--main-bg-color);
color: var(--main-color); color: var(--main-text-color);
font: var(--base-font); font: var(--base-font);
overflow: hidden; overflow: hidden;
} }

19
public/theme.less Normal file
View File

@ -0,0 +1,19 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
:root {
--main-text-color: #f7f7f7;
--secondary-text-color: rgb(195, 200, 194);
--main-bg-color: #000000;
--border-color: #333333;
--base-font: normal 15px / normal "Lato", sans-serif;
--fixed-font: normal 12px / normal "Hack", monospace;
--accent-color: rgb(88, 193, 66);
--panel-bg-color: rgba(31, 33, 31, 1);
--highlight-bg-color: rgba(255, 255, 255, 0.2);
--markdown-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji";
--error-color: rgb(229, 77, 46);
--warning-color: rgb(224, 185, 86);
--success-color: rgb(78, 154, 6);
}