merge main

This commit is contained in:
Red Adaya 2024-12-05 09:05:22 +08:00
commit 7782d6e59f
66 changed files with 2416 additions and 1319 deletions

View File

@ -4,7 +4,7 @@ root = true
end_of_line = lf
insert_final_newline = true
[*.{js,jsx,ts,tsx,cjs,json,yml,yaml,css,less}]
[*.{js,jsx,ts,tsx,cjs,json,yml,yaml,css,less,scss}]
charset = utf-8
indent_style = space
indent_size = 4

View File

@ -19,7 +19,6 @@ import (
var aiCmd = &cobra.Command{
Use: "ai [-] [message...]",
Short: "Send a message to an AI block",
Args: cobra.MinimumNArgs(1),
RunE: aiRun,
PreRunE: preRunSetupRpcClient,
DisableFlagsInUseLine: true,
@ -53,6 +52,11 @@ func aiRun(cmd *cobra.Command, args []string) (rtnErr error) {
sendActivity("ai", rtnErr == nil)
}()
if len(args) == 0 {
OutputHelpMessage(cmd)
return fmt.Errorf("no message provided")
}
var stdinUsed bool
var message strings.Builder

View File

@ -21,13 +21,12 @@ var editMagnified bool
var editorCmd = &cobra.Command{
Use: "editor",
Short: "edit a file (blocks until editor is closed)",
Args: cobra.ExactArgs(1),
RunE: editorRun,
PreRunE: preRunSetupRpcClient,
}
func init() {
editCmd.Flags().BoolVarP(&editMagnified, "magnified", "m", false, "open view in magnified mode")
editorCmd.Flags().BoolVarP(&editMagnified, "magnified", "m", false, "open view in magnified mode")
rootCmd.AddCommand(editorCmd)
}
@ -35,7 +34,14 @@ func editorRun(cmd *cobra.Command, args []string) (rtnErr error) {
defer func() {
sendActivity("editor", rtnErr == nil)
}()
if len(args) == 0 {
OutputHelpMessage(cmd)
return fmt.Errorf("no arguments. wsh editor requires a file or URL as an argument argument")
}
if len(args) > 1 {
OutputHelpMessage(cmd)
return fmt.Errorf("too many arguments. wsh editor requires exactly one argument")
}
fileArg := args[0]
absFile, err := filepath.Abs(fileArg)
if err != nil {

View File

@ -82,6 +82,7 @@ func getVarRun(cmd *cobra.Command, args []string) error {
// Single variable case - existing logic
if len(args) != 1 {
OutputHelpMessage(cmd)
return fmt.Errorf("requires a key argument")
}

View File

@ -8,7 +8,6 @@ import (
"io"
"os"
"runtime/debug"
"strings"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/waveobj"
@ -27,26 +26,58 @@ var (
)
var WrappedStdin io.Reader = os.Stdin
var WrappedStdout io.Writer = &WrappedWriter{dest: os.Stdout}
var WrappedStderr io.Writer = &WrappedWriter{dest: os.Stderr}
var RpcClient *wshutil.WshRpc
var RpcContext wshrpc.RpcContext
var UsingTermWshMode bool
var blockArg string
var WshExitCode int
func WriteStderr(fmtStr string, args ...interface{}) {
output := fmt.Sprintf(fmtStr, args...)
if UsingTermWshMode {
output = strings.ReplaceAll(output, "\n", "\r\n")
type WrappedWriter struct {
dest io.Writer
}
fmt.Fprint(os.Stderr, output)
func (w *WrappedWriter) Write(p []byte) (n int, err error) {
if !UsingTermWshMode {
return w.dest.Write(p)
}
count := 0
for _, b := range p {
if b == '\n' {
count++
}
}
if count == 0 {
return w.dest.Write(p)
}
buf := make([]byte, len(p)+count) // Each '\n' adds one extra byte for '\r'
writeIdx := 0
for _, b := range p {
if b == '\n' {
buf[writeIdx] = '\r'
buf[writeIdx+1] = '\n'
writeIdx += 2
} else {
buf[writeIdx] = b
writeIdx++
}
}
return w.dest.Write(buf)
}
func WriteStderr(fmtStr string, args ...interface{}) {
WrappedStderr.Write([]byte(fmt.Sprintf(fmtStr, args...)))
}
func WriteStdout(fmtStr string, args ...interface{}) {
output := fmt.Sprintf(fmtStr, args...)
if UsingTermWshMode {
output = strings.ReplaceAll(output, "\n", "\r\n")
WrappedStdout.Write([]byte(fmt.Sprintf(fmtStr, args...)))
}
fmt.Print(output)
func OutputHelpMessage(cmd *cobra.Command) {
cmd.SetOutput(WrappedStderr)
cmd.Help()
WriteStderr("\n")
}
func preRunSetupRpcClient(cmd *cobra.Command, args []string) error {
@ -64,6 +95,15 @@ func getIsTty() bool {
return false
}
func getThisBlockMeta() (waveobj.MetaMapType, error) {
blockORef := waveobj.ORef{OType: waveobj.OType_Block, OID: RpcContext.BlockId}
resp, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ORef: blockORef}, &wshrpc.RpcOpts{Timeout: 2000})
if err != nil {
return nil, fmt.Errorf("getting metadata: %w", err)
}
return resp, nil
}
type RunEFnType = func(*cobra.Command, []string) error
func activityWrap(activityStr string, origRunE RunEFnType) RunEFnType {

154
cmd/wsh/cmd/wshcmd-run.go Normal file
View File

@ -0,0 +1,154 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/util/envutil"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
)
var runCmd = &cobra.Command{
Use: "run [flags] -- command [args...]",
Short: "run a command in a new block",
RunE: runRun,
PreRunE: preRunSetupRpcClient,
TraverseChildren: true,
}
func init() {
flags := runCmd.Flags()
flags.BoolP("magnified", "m", false, "open view in magnified mode")
flags.StringP("command", "c", "", "run command string in shell")
flags.BoolP("exit", "x", false, "close block if command exits successfully (will stay open if there was an error)")
flags.BoolP("forceexit", "X", false, "close block when command exits, regardless of exit status")
flags.IntP("delay", "", 2000, "if -x, delay in milliseconds before closing block")
flags.BoolP("paused", "p", false, "create block in paused state")
flags.String("cwd", "", "set working directory for command")
flags.BoolP("append", "a", false, "append output on restart instead of clearing")
rootCmd.AddCommand(runCmd)
}
func runRun(cmd *cobra.Command, args []string) (rtnErr error) {
defer func() {
sendActivity("run", rtnErr == nil)
}()
flags := cmd.Flags()
magnified, _ := flags.GetBool("magnified")
commandArg, _ := flags.GetString("command")
exit, _ := flags.GetBool("exit")
forceExit, _ := flags.GetBool("forceexit")
paused, _ := flags.GetBool("paused")
cwd, _ := flags.GetString("cwd")
delayMs, _ := flags.GetInt("delay")
appendOutput, _ := flags.GetBool("append")
var cmdArgs []string
var useShell bool
var shellCmd string
for i, arg := range os.Args {
if arg == "--" {
if i+1 >= len(os.Args) {
OutputHelpMessage(cmd)
return fmt.Errorf("no command provided after --")
}
shellCmd = os.Args[i+1]
cmdArgs = os.Args[i+2:]
break
}
}
if shellCmd != "" && commandArg != "" {
OutputHelpMessage(cmd)
return fmt.Errorf("cannot specify both -c and command arguments")
}
if shellCmd == "" && commandArg == "" {
OutputHelpMessage(cmd)
return fmt.Errorf("command must be specified after -- or with -c")
}
if commandArg != "" {
shellCmd = commandArg
useShell = true
}
// Get current working directory
if cwd == "" {
var err error
cwd, err = os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
}
cwd, err := filepath.Abs(cwd)
if err != nil {
return fmt.Errorf("getting absolute path: %w", err)
}
// Get current environment and convert to map
envMap := make(map[string]string)
for _, envStr := range os.Environ() {
env := strings.SplitN(envStr, "=", 2)
if len(env) == 2 {
envMap[env[0]] = env[1]
}
}
// Convert to null-terminated format
envContent := envutil.MapToEnv(envMap)
createMeta := map[string]any{
waveobj.MetaKey_View: "term",
waveobj.MetaKey_CmdCwd: cwd,
waveobj.MetaKey_Controller: "cmd",
waveobj.MetaKey_CmdClearOnStart: true,
}
createMeta[waveobj.MetaKey_Cmd] = shellCmd
createMeta[waveobj.MetaKey_CmdArgs] = cmdArgs
createMeta[waveobj.MetaKey_CmdShell] = useShell
if paused {
createMeta[waveobj.MetaKey_CmdRunOnStart] = false
} else {
createMeta[waveobj.MetaKey_CmdRunOnce] = true
createMeta[waveobj.MetaKey_CmdRunOnStart] = true
}
if forceExit {
createMeta[waveobj.MetaKey_CmdCloseOnExitForce] = true
} else if exit {
createMeta[waveobj.MetaKey_CmdCloseOnExit] = true
}
createMeta[waveobj.MetaKey_CmdCloseOnExitDelay] = float64(delayMs)
if appendOutput {
createMeta[waveobj.MetaKey_CmdClearOnStart] = false
}
if RpcContext.Conn != "" {
createMeta[waveobj.MetaKey_Connection] = RpcContext.Conn
}
createBlockData := wshrpc.CommandCreateBlockData{
BlockDef: &waveobj.BlockDef{
Meta: createMeta,
Files: map[string]*waveobj.FileDef{
"env": {
Content: envContent,
},
},
},
Magnified: magnified,
}
oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil)
if err != nil {
return fmt.Errorf("creating new run block: %w", err)
}
WriteStdout("run block created: %s\n", oref)
return nil
}

View File

@ -55,18 +55,19 @@ func termRun(cmd *cobra.Command, args []string) (rtnErr error) {
if err != nil {
return fmt.Errorf("getting absolute path: %w", err)
}
createBlockData := wshrpc.CommandCreateBlockData{
BlockDef: &waveobj.BlockDef{
Meta: map[string]interface{}{
createMeta := map[string]any{
waveobj.MetaKey_View: "term",
waveobj.MetaKey_CmdCwd: cwd,
waveobj.MetaKey_Controller: "shell",
},
},
Magnified: termMagnified,
}
if RpcContext.Conn != "" {
createBlockData.BlockDef.Meta[waveobj.MetaKey_Connection] = RpcContext.Conn
createMeta[waveobj.MetaKey_Connection] = RpcContext.Conn
}
createBlockData := wshrpc.CommandCreateBlockData{
BlockDef: &waveobj.BlockDef{
Meta: createMeta,
},
Magnified: termMagnified,
}
oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil)
if err != nil {

View File

@ -20,7 +20,6 @@ var viewMagnified bool
var viewCmd = &cobra.Command{
Use: "view {file|directory|URL}",
Short: "preview/edit a file or directory",
Args: cobra.ExactArgs(1),
RunE: viewRun,
PreRunE: preRunSetupRpcClient,
}
@ -28,7 +27,6 @@ var viewCmd = &cobra.Command{
var editCmd = &cobra.Command{
Use: "edit {file}",
Short: "edit a file",
Args: cobra.ExactArgs(1),
RunE: viewRun,
PreRunE: preRunSetupRpcClient,
}
@ -40,9 +38,18 @@ func init() {
}
func viewRun(cmd *cobra.Command, args []string) (rtnErr error) {
cmdName := cmd.Name()
defer func() {
sendActivity("view", rtnErr == nil)
sendActivity(cmdName, rtnErr == nil)
}()
if len(args) == 0 {
OutputHelpMessage(cmd)
return fmt.Errorf("no arguments. wsh %s requires a file or URL as an argument argument", cmdName)
}
if len(args) > 1 {
OutputHelpMessage(cmd)
return fmt.Errorf("too many arguments. wsh %s requires exactly one argument", cmdName)
}
fileArg := args[0]
conn := RpcContext.Conn
var wshCmd *wshrpc.CommandCreateBlockData
@ -81,7 +88,7 @@ func viewRun(cmd *cobra.Command, args []string) (rtnErr error) {
},
Magnified: viewMagnified,
}
if cmd.Use == "edit" {
if cmdName == "edit" {
wshCmd.BlockDef.Meta[waveobj.MetaKey_Edit] = true
}
if conn != "" {

View File

@ -45,6 +45,7 @@ wsh editconfig
| term:scrollback | int | size of terminal scrollback buffer, max is 10000 |
| editor:minimapenabled | bool | set to false to disable editor minimap |
| editor:stickscrollenabled | bool | |
| editor:wordwrap | bool | set to true to enable word wrapping in the editor (defaults to false) |
| web:openlinksinternally | bool | set to false to open web links in external browser |
| web:defaulturl | string | default web page to open in the web widget when no url is provided (homepage) |
| web:defaultsearch | string | search template for web searches. e.g. `https://www.google.com/search?q={query}`. "\{query}" gets replaced by search term |

View File

@ -91,15 +91,18 @@ A terminal widget, or CLI widget, is a widget that simply opens a terminal and r
The `WidgetConfigType` takes the usual options common to all widgets. The `MetaTSType` can include the keys listed below:
| Key | Description |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| "view" | A string that specifies the general type of widget. In the case of custom terminal widgets, this must be set to `"term"`. |
| "controller" | A string that specifies the type of command being used. For more persistent shell sessions, set it to "shell". For one off commands, set it to `"cmd"`. When `"cmd"` is set, the widget has an additional refresh button in its header that allows the command to be re-run. |
| "cmd" | (optional) When the `"controller"` is set to `"cmd"`, this option provides the actual command to be run. Note that because it is run as a command, there is no shell session unless you are launching a command that contains a shell session itself. Defaults to an empty string. |
| "cmd:interactive" | (optional) When the `"controller"` is set to `"term", this boolean adds the interactive flag to the launched terminal. Defaults to false. |
| "cmd:login" | (optional) When the `"controller"` is set to `"term"`, this boolean adds the login flag to the term command. Defaults to false. |
| "cmd:runonstart" | (optional) The command will rerun when the app is started. Without it, you must manually run the command. Defaults to true. |
| "cmd:clearonstart" | (optional) When the cmd starts, the contents of the block are cleared out. Defaults to false. |
| "cmd:clearonrestart" | (optional) When the app restarts, the contents of the block are cleared out. Defaults to false. |
| "cmd:runonce" | (optional) Runs on start, but then sets "cmd:runonce" and "cmd:runonstart" to false (so future runs require manual restarts) |
| "cmd:clearonstart" | (optional) When the cmd runs, the contents of the block are cleared out. Defaults to false. |
| "cmd:closeonexit" | (optional) Automatically closes the block if the command successfully exits (exit code = 0) |
| "cmd:closeonexitforce" | (optional) Automatically closes the block if when the command exits (success or failure) |
| "cmd:closeonexitdelay | (optional) Change the delay between when the command exits and when the block gets closed, in milliseconds, default 2000 |
| "cmd:env" | (optional) A key-value object represting environment variables to be run with the command. Currently only works locally. Defaults to an empty object. |
| "cmd:cwd" | (optional) A string representing the current working directory to be run with the command. Currently only works locally. Defaults to the home directory. |
| "cmd:nowsh" | (optional) A boolean that will turn off wsh integration for the command. Defaults to false. |

View File

@ -145,6 +145,75 @@ wsh editconfig widgets.json
---
## run
The `run` command creates a new terminal command block and executes a specified command within it. The command can be provided either as arguments after `--` or using the `-c` flag. Unless the `-x` or `-X` flags are passed, commands can be re-executed by pressing `Enter` once the command has finished running.
```bash
# Run a command specified after --
wsh run -- ls -la
# Run a command using -c flag
wsh run -c "ls -la"
# Run with working directory specified
wsh run --cwd /path/to/dir -- ./script.sh
# Run in magnified mode
wsh run -m -- make build
# Run and auto-close on successful completion
wsh run -x -- npm test
# Run and auto-close regardless of exit status
wsh run -X -- ./long-running-task.sh
```
The command inherits the current environment variables and working directory by default.
Flags:
- `-m, --magnified` - open the block in magnified mode
- `-c, --command string` - run a command string in _shell_
- `-x, --exit` - close block if command exits successfully (stays open if there was an error)
- `-X, --forceexit` - close block when command exits, regardless of exit status
- `--delay int` - if using -x/-X, delay in milliseconds before closing block (default 2000)
- `-p, --paused` - create block in paused state
- `-a, --append` - append output on command restart instead of clearing
- `--cwd string` - set working directory for command
Examples:
```bash
# Run a build command in magnified mode
wsh run -m -- npm run build
# Execute a script and auto-close after success
wsh run -x -- ./backup-script.sh
# Run a command in a specific directory
wsh run --cwd ./project -- make test
# Run a shell command and force close after completion
wsh run -X -c "find . -name '*.log' -delete"
# Start a command in paused state
wsh run -p -- ./server --dev
# Run with custom close delay
wsh run -x --delay 5000 -- ./deployment.sh
```
When using the `-x` or `-X` flags, the block will automatically close after the command completes. The `-x` flag only closes on successful completion (exit code 0), while `-X` closes regardless of exit status. The `--delay` flag controls how long to wait before closing (default 2000ms).
The `-p` flag creates the block in a paused state, allowing you to review the command before execution.
:::tip
You can use either `--` followed by your command and arguments, or the `-c` flag with a quoted command string. The `--` method is preferred when you want to preserve argument handling, while `-c` is useful for shell commands with pipes or redirections.
:::
---
## deleteblock
```

View File

@ -36,15 +36,15 @@
"@docusaurus/module-type-aliases": "3.6.3",
"@docusaurus/tsconfig": "3.6.3",
"@docusaurus/types": "3.6.3",
"@eslint/js": "^9.15.0",
"@eslint/js": "^9.16.0",
"@mdx-js/typescript-plugin": "^0.0.6",
"@types/eslint": "^9.6.1",
"@types/eslint-config-prettier": "^6.11.3",
"@types/ua-parser-js": "^0.7.39",
"eslint": "^9.15.0",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-mdx": "^3.1.5",
"prettier": "^3.4.1",
"prettier": "^3.4.2",
"prettier-plugin-jsdoc": "^1.3.0",
"prettier-plugin-organize-imports": "^4.1.0",
"remark-cli": "^12.0.1",
@ -53,7 +53,7 @@
"remark-preset-lint-consistent": "^6.0.0",
"remark-preset-lint-recommended": "^7.0.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.16.0"
"typescript-eslint": "^8.17.0"
},
"resolutions": {
"path-to-regexp@npm:2.2.1": "^3",

View File

@ -30,13 +30,6 @@ export default defineConfig({
"process.env.WS_NO_BUFFER_UTIL": "true",
"process.env.WS_NO_UTF_8_VALIDATE": "true",
},
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler", // or "modern"
},
},
},
},
preload: {
root: ".",

View File

@ -294,8 +294,8 @@ export class WaveBrowserWindow extends BaseWindow {
await this.queueTabSwitch(tabView, tabInitialized);
}
async createTab() {
const tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true);
async createTab(pinned = false) {
const tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, pinned);
await this.setActiveTab(tabId, false);
}

View File

@ -75,6 +75,10 @@ a.plain-link {
color: var(--error-color);
}
.error-color {
color: var(--error-color);
}
/* OverlayScrollbars styling */
.os-scrollbar {
--os-handle-bg: var(--scrollbar-thumb-color);

View File

@ -124,6 +124,10 @@
opacity: 0.7;
flex-grow: 1;
&.flex-nogrow {
flex-grow: 0;
}
&.preview-filename {
direction: rtl;
text-align: left;

View File

@ -6,7 +6,6 @@ import {
blockViewToName,
computeConnColorNum,
ConnectionButton,
ControllerStatusIcon,
getBlockHeaderIcon,
Input,
} from "@/app/block/blockutil";
@ -227,7 +226,6 @@ const BlockFrame_Header = ({
} else if (Array.isArray(headerTextUnion)) {
headerTextElems.push(...renderHeaderElements(headerTextUnion, preview));
}
headerTextElems.unshift(<ControllerStatusIcon key="connstatus" blockId={nodeModel.blockId} />);
if (error != null) {
const copyHeaderErr = () => {
navigator.clipboard.writeText(error.message + "\n" + error.stack);
@ -271,7 +269,7 @@ const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; previe
return <Input decl={elem} className={clsx("block-frame-input", elem.className)} preview={preview} />;
} else if (elem.elemtype == "text") {
return (
<div className={clsx("block-frame-text", elem.className)}>
<div className={clsx("block-frame-text", elem.className, { "flex-nogrow": elem.noGrow })}>
<span ref={preview ? null : elem.ref} onClick={(e) => elem?.onClick(e)}>
&lrm;{elem.text}
</span>

View File

@ -2,10 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import { NumActiveConnColors } from "@/app/block/blockframe";
import { getConnStatusAtom, WOS } from "@/app/store/global";
import * as services from "@/app/store/services";
import { makeORef } from "@/app/store/wos";
import { waveEventSubscribe } from "@/store/wps";
import { getConnStatusAtom } from "@/app/store/global";
import * as util from "@/util/util";
import clsx from "clsx";
import * as jotai from "jotai";
@ -150,52 +147,6 @@ interface ConnectionButtonProps {
changeConnModalAtom: jotai.PrimitiveAtom<boolean>;
}
export const ControllerStatusIcon = React.memo(({ blockId }: { blockId: string }) => {
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const hasController = !util.isBlank(blockData?.meta?.controller);
const [controllerStatus, setControllerStatus] = React.useState<BlockControllerRuntimeStatus>(null);
const [gotInitialStatus, setGotInitialStatus] = React.useState(false);
const connection = blockData?.meta?.connection ?? "local";
const connStatusAtom = getConnStatusAtom(connection);
const connStatus = jotai.useAtomValue(connStatusAtom);
React.useEffect(() => {
if (!hasController) {
return;
}
const initialRTStatus = services.BlockService.GetControllerStatus(blockId);
initialRTStatus.then((rts) => {
setGotInitialStatus(true);
setControllerStatus(rts);
});
const unsubFn = waveEventSubscribe({
eventType: "controllerstatus",
scope: makeORef("block", blockId),
handler: (event) => {
const cstatus: BlockControllerRuntimeStatus = event.data;
setControllerStatus(cstatus);
},
});
return () => {
unsubFn();
};
}, [hasController]);
if (!hasController || !gotInitialStatus) {
return null;
}
if (controllerStatus?.shellprocstatus == "running") {
return null;
}
if (connStatus?.status != "connected") {
return null;
}
const controllerStatusElem = (
<div className="iconbutton disabled" key="controller-status">
<i className="fa-sharp fa-solid fa-triangle-exclamation" title="Shell Process Is Not Running" />
</div>
);
return controllerStatusElem;
});
export function computeConnColorNum(connStatus: ConnStatus): number {
// activeconnnum is 1-indexed, so we need to adjust for when mod is 0
const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors;

View File

@ -18,6 +18,10 @@
opacity: 1;
}
&.no-action {
cursor: default;
}
&.disabled {
cursor: default;
opacity: 0.45 !important;

View File

@ -9,15 +9,19 @@ import "./iconbutton.scss";
export const IconButton = memo(({ decl, className }: { decl: IconButtonDecl; className?: string }) => {
const buttonRef = useRef<HTMLDivElement>(null);
const spin = decl.iconSpin ?? false;
useLongClick(buttonRef, decl.click, decl.longClick, decl.disabled);
return (
<div
ref={buttonRef}
className={clsx("iconbutton", className, decl.className, { disabled: decl.disabled })}
className={clsx("iconbutton", className, decl.className, {
disabled: decl.disabled,
"no-action": decl.noAction,
})}
title={decl.title}
style={{ color: decl.iconColor ?? "inherit" }}
>
{typeof decl.icon === "string" ? <i className={makeIconClass(decl.icon, true)} /> : decl.icon}
{typeof decl.icon === "string" ? <i className={makeIconClass(decl.icon, true, { spin })} /> : decl.icon}
</div>
);
});

View File

@ -627,6 +627,7 @@ function createTab() {
}
function setActiveTab(tabId: string) {
// We use this hack to prevent a flicker in the tab bar when switching to a new tab. This class is unset in reinitWave in wave.ts. See tab.scss for where this class is used.
document.body.classList.add("nohover");
getApi().setActiveTab(tabId);
}

View File

@ -99,15 +99,19 @@ function switchBlockInDirection(tabId: string, direction: NavigateDirection) {
layoutModel.switchNodeFocusInDirection(direction);
}
function getAllTabs(ws: Workspace): string[] {
return [...(ws.pinnedtabids ?? []), ...(ws.tabids ?? [])];
}
function switchTabAbs(index: number) {
console.log("switchTabAbs", index);
const ws = globalStore.get(atoms.workspace);
const waveWindow = globalStore.get(atoms.waveWindow);
const newTabIdx = index - 1;
if (newTabIdx < 0 || newTabIdx >= ws.tabids.length) {
const tabids = getAllTabs(ws);
if (newTabIdx < 0 || newTabIdx >= tabids.length) {
return;
}
const newActiveTabId = ws.tabids[newTabIdx];
const newActiveTabId = tabids[newTabIdx];
getApi().setActiveTab(newActiveTabId);
}
@ -116,8 +120,9 @@ function switchTab(offset: number) {
const ws = globalStore.get(atoms.workspace);
const curTabId = globalStore.get(atoms.staticTabId);
let tabIdx = -1;
for (let i = 0; i < ws.tabids.length; i++) {
if (ws.tabids[i] == curTabId) {
const tabids = getAllTabs(ws);
for (let i = 0; i < tabids.length; i++) {
if (tabids[i] == curTabId) {
tabIdx = i;
break;
}
@ -125,8 +130,8 @@ function switchTab(offset: number) {
if (tabIdx == -1) {
return;
}
const newTabIdx = (tabIdx + offset + ws.tabids.length) % ws.tabids.length;
const newActiveTabId = ws.tabids[newTabIdx];
const newTabIdx = (tabIdx + offset + tabids.length) % tabids.length;
const newActiveTabId = tabids[newTabIdx];
getApi().setActiveTab(newActiveTabId);
}
@ -241,7 +246,10 @@ function registerGlobalKeys() {
});
globalKeyMap.set("Cmd:w", () => {
const tabId = globalStore.get(atoms.staticTabId);
const ws = globalStore.get(atoms.workspace);
if (!ws.pinnedtabids?.includes(tabId)) {
genericClose(tabId);
}
return true;
});
globalKeyMap.set("Cmd:m", () => {

View File

@ -168,13 +168,18 @@ export const WindowService = new WindowServiceType();
// workspaceservice.WorkspaceService (workspace)
class WorkspaceServiceType {
// @returns object updates
ChangeTabPinning(workspaceId: string, tabId: string, pinned: boolean): Promise<void> {
return WOS.callBackendService("workspace", "ChangeTabPinning", Array.from(arguments))
}
// @returns object updates
CloseTab(workspaceId: string, tabId: string, fromElectron: boolean): Promise<CloseTabRtnType> {
return WOS.callBackendService("workspace", "CloseTab", Array.from(arguments))
}
// @returns tabId (and object updates)
CreateTab(workspaceId: string, tabName: string, activateTab: boolean): Promise<string> {
CreateTab(workspaceId: string, tabName: string, activateTab: boolean, pinned: boolean): Promise<string> {
return WOS.callBackendService("workspace", "CreateTab", Array.from(arguments))
}
@ -195,7 +200,7 @@ class WorkspaceServiceType {
}
// @returns object updates
UpdateTabIds(workspaceId: string, tabIds: string[]): Promise<void> {
UpdateTabIds(workspaceId: string, tabIds: string[], pinnedTabIds: string[]): Promise<void> {
return WOS.callBackendService("workspace", "UpdateTabIds", Array.from(arguments))
}
}

View File

@ -79,8 +79,7 @@
}
}
.close {
visibility: hidden;
.button {
position: absolute;
top: 50%;
right: 4px;
@ -96,22 +95,21 @@
transition: none !important;
}
&:hover .close {
visibility: visible;
backdrop-filter: blur(3px);
&:hover {
color: var(--main-text-color);
}
.close {
visibility: hidden;
}
}
body:not(.nohover) .tab:hover {
& + .tab::after,
&::after {
content: none;
}
.tab-inner {
border-color: transparent;
background: rgb(from var(--main-text-color) r g b / 0.07);
}
.close {
visibility: visible;
&:hover {

View File

@ -3,8 +3,6 @@
import { Button } from "@/element/button";
import { ContextMenuModel } from "@/store/contextmenu";
import * as services from "@/store/services";
import * as WOS from "@/store/wos";
import { clsx } from "clsx";
import { useAtomValue } from "jotai";
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
@ -12,6 +10,8 @@ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef,
import { atoms, globalStore, refocusNode } from "@/app/store/global";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { ObjectService } from "../store/services";
import { makeORef, useWaveObjectValue } from "../store/wos";
import "./tab.scss";
interface TabProps {
@ -22,11 +22,13 @@ interface TabProps {
isDragging: boolean;
tabWidth: number;
isNew: boolean;
isPinned: boolean;
tabIds: string[];
onClick: () => void;
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void;
onMouseDown: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onLoaded: () => void;
onPinChange: () => void;
onMouseEnter: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onMouseLeave: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
}
@ -38,6 +40,7 @@ const Tab = memo(
id,
isActive,
isFirst,
isPinned,
isBeforeActive,
isDragging,
tabWidth,
@ -49,10 +52,11 @@ const Tab = memo(
onMouseDown,
onMouseEnter,
onMouseLeave,
onPinChange,
},
ref
) => {
const [tabData, tabLoading] = WOS.useWaveObjectValue<Tab>(WOS.makeORef("tab", id));
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
const [originalName, setOriginalName] = useState("");
const [isEditable, setIsEditable] = useState(false);
@ -95,7 +99,7 @@ const Tab = memo(
newText = newText || originalName;
editableRef.current.innerText = newText;
setIsEditable(false);
services.ObjectService.UpdateTabName(id, newText);
ObjectService.UpdateTabName(id, newText);
setTimeout(() => refocusNode(null), 10);
};
@ -153,7 +157,12 @@ const Tab = memo(
function handleContextMenu(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
e.preventDefault();
let menu: ContextMenuItem[] = [];
let menu: ContextMenuItem[] = [
{ label: isPinned ? "Unpin Tab" : "Pin Tab", click: onPinChange },
{ label: "Rename Tab", click: () => handleRenameTab(null) },
{ label: "Copy TabId", click: () => navigator.clipboard.writeText(id) },
{ type: "separator" },
];
const fullConfig = globalStore.get(atoms.fullConfigAtom);
const bgPresets: string[] = [];
for (const key in fullConfig?.presets ?? {}) {
@ -166,12 +175,9 @@ const Tab = memo(
const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
return aOrder - bOrder;
});
menu.push({ label: "Rename Tab", click: () => handleRenameTab(null) });
menu.push({ label: "Copy TabId", click: () => navigator.clipboard.writeText(id) });
menu.push({ type: "separator" });
if (bgPresets.length > 0) {
const submenu: ContextMenuItem[] = [];
const oref = WOS.makeORef("tab", id);
const oref = makeORef("tab", id);
for (const presetName of bgPresets) {
const preset = fullConfig.presets[presetName];
if (preset == null) {
@ -180,13 +186,12 @@ const Tab = memo(
submenu.push({
label: preset["display:name"] ?? presetName,
click: () => {
services.ObjectService.UpdateObjectMeta(oref, preset);
ObjectService.UpdateObjectMeta(oref, preset);
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 });
},
});
}
menu.push({ label: "Backgrounds", type: "submenu", submenu });
menu.push({ type: "separator" });
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
}
menu.push({ label: "Close Tab", click: () => onClose(null) });
ContextMenuModel.showContextMenu(menu, e);
@ -233,9 +238,21 @@ const Tab = memo(
{tabData?.name}
{/* {id.substring(id.length - 3)} */}
</div>
{isPinned ? (
<Button
className="ghost grey pin"
onClick={(e) => {
e.stopPropagation();
onPinChange();
}}
>
<i className="fa fa-solid fa-thumbtack" />
</Button>
) : (
<Button className="ghost grey close" onClick={onClose} onMouseDown={handleMouseDownOnClose}>
<i className="fa fa-solid fa-xmark" />
</Button>
)}
</div>
</div>
);

View File

@ -36,9 +36,18 @@
.tab-bar {
position: relative; // Needed for absolute positioning of child tabs
display: flex;
flex-direction: row;
height: 33px;
}
.pinned-tab-spacer {
display: block;
height: 100%;
margin: 2px;
border: 1px solid var(--border-color);
}
.dev-label,
.app-menu-button {
font-size: 26px;

View File

@ -101,6 +101,7 @@ const ConfigErrorIcon = ({ buttonRef }: { buttonRef: React.RefObject<HTMLElement
const TabBar = memo(({ workspace }: TabBarProps) => {
const [tabIds, setTabIds] = useState([]);
const [pinnedTabIds, setPinnedTabIds] = useState<Set<string>>(new Set());
const [dragStartPositions, setDragStartPositions] = useState<number[]>([]);
const [draggingTab, setDraggingTab] = useState<string>();
const [tabsLoaded, setTabsLoaded] = useState({});
@ -116,6 +117,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
tabId: "",
ref: { current: null },
tabStartX: 0,
tabStartIndex: 0,
tabIndex: 0,
initialOffsetX: null,
totalScrollOffset: null,
@ -148,17 +150,25 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
useEffect(() => {
if (workspace) {
// Compare current tabIds with new workspace.tabids
const currentTabIds = new Set(tabIds);
const newTabIds = new Set(workspace.tabids);
console.log("tabbar workspace", workspace);
const newTabIds = new Set([...(workspace.pinnedtabids ?? []), ...(workspace.tabids ?? [])]);
const newPinnedTabIds = workspace.pinnedtabids ?? [];
const areEqual =
currentTabIds.size === newTabIds.size && [...currentTabIds].every((id) => newTabIds.has(id));
tabIds.length === newTabIds.size &&
tabIds.every((id) => newTabIds.has(id)) &&
newPinnedTabIds.length === pinnedTabIds.size;
if (!areEqual) {
setTabIds(workspace.tabids);
const newPinnedTabIdSet = new Set(newPinnedTabIds);
console.log("newPinnedTabIds", newPinnedTabIds);
const newTabIdList = newPinnedTabIds.concat([...newTabIds].filter((id) => !newPinnedTabIdSet.has(id))); // Corrects for any duplicates between the two lists
console.log("newTabIdList", newTabIdList);
setTabIds(newTabIdList);
setPinnedTabIds(newPinnedTabIdSet);
}
}
}, [workspace, tabIds]);
}, [workspace, tabIds, pinnedTabIds]);
const saveTabsPosition = useCallback(() => {
const tabs = tabRefs.current;
@ -246,9 +256,14 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
}
};
const saveTabsPositionDebounced = useCallback(
debounce(100, () => saveTabsPosition()),
[saveTabsPosition]
);
const handleResizeTabs = useCallback(() => {
setSizeAndPosition();
debounce(100, () => saveTabsPosition())();
saveTabsPositionDebounced();
}, [tabIds, newTabId, isFullScreen]);
const reinitVersion = useAtomValue(atoms.reinitVersion);
@ -278,7 +293,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
}, [tabIds, tabsLoaded, newTabId, saveTabsPosition]);
const getDragDirection = (currentX: number) => {
let dragDirection;
let dragDirection: string;
if (currentX - prevDelta > 0) {
dragDirection = "+";
} else if (currentX - prevDelta === 0) {
@ -433,6 +448,50 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
}
};
// } else if ((tabIndex > pinnedTabCount || (tabIndex === 1 && pinnedTabCount === 1)) && isPinned) {
const setUpdatedTabsDebounced = useCallback(
debounce(300, (tabIndex: number, tabIds: string[], pinnedTabIds: Set<string>) => {
console.log(
"setting updated tabs",
tabIds,
pinnedTabIds,
tabIndex,
draggingTabDataRef.current.tabStartIndex
);
// Reset styles
tabRefs.current.forEach((ref) => {
ref.current.style.zIndex = "0";
ref.current.classList.remove("animate");
});
let pinnedTabCount = pinnedTabIds.size;
const draggedTabId = draggingTabDataRef.current.tabId;
const isPinned = pinnedTabIds.has(draggedTabId);
if (pinnedTabIds.has(tabIds[tabIndex + 1]) && !isPinned) {
pinnedTabIds.add(draggedTabId);
} else if (!pinnedTabIds.has(tabIds[tabIndex - 1]) && isPinned) {
pinnedTabIds.delete(draggedTabId);
}
if (pinnedTabCount != pinnedTabIds.size) {
console.log("updated pinnedTabIds", pinnedTabIds, tabIds);
setPinnedTabIds(pinnedTabIds);
pinnedTabCount = pinnedTabIds.size;
}
// Reset dragging state
setDraggingTab(null);
// Update workspace tab ids
fireAndForget(
async () =>
await WorkspaceService.UpdateTabIds(
workspace.oid,
tabIds.slice(pinnedTabCount),
tabIds.slice(0, pinnedTabCount)
)
);
}),
[]
);
const handleMouseUp = (event: MouseEvent) => {
const { tabIndex, dragged } = draggingTabDataRef.current;
@ -447,17 +506,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
}
if (dragged) {
debounce(300, () => {
// Reset styles
tabRefs.current.forEach((ref) => {
ref.current.style.zIndex = "0";
ref.current.classList.remove("animate");
});
// Reset dragging state
setDraggingTab(null);
// Update workspace tab ids
fireAndForget(async () => await WorkspaceService.UpdateTabIds(workspace.oid, tabIds));
})();
setUpdatedTabsDebounced(tabIndex, tabIds, pinnedTabIds);
} else {
// Reset styles
tabRefs.current.forEach((ref) => {
@ -480,12 +529,14 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
const tabIndex = tabIds.indexOf(tabId);
const tabStartX = dragStartPositions[tabIndex]; // Starting X position of the tab
console.log("handleDragStart", tabId, tabIndex, tabStartX);
if (ref.current) {
draggingTabDataRef.current = {
tabId,
ref,
tabStartX,
tabIndex,
tabStartIndex: tabIndex,
initialOffsetX: null,
totalScrollOffset: 0,
dragged: false,
@ -504,19 +555,31 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
}
};
const handleAddTab = () => {
createTab();
tabsWrapperRef.current.style.transition;
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease");
const updateScrollDebounced = useCallback(
debounce(30, () => {
if (scrollableRef.current) {
const { viewport } = osInstanceRef.current.elements();
viewport.scrollLeft = tabIds.length * tabWidthRef.current;
}
})();
}),
[tabIds]
);
debounce(100, () => setNewTabId(null))();
const setNewTabIdDebounced = useCallback(
debounce(100, (tabId: string) => {
setNewTabId(tabId);
}),
[]
);
const handleAddTab = () => {
createTab();
tabsWrapperRef.current.style.transition;
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease");
updateScrollDebounced();
setNewTabIdDebounced(null);
};
const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => {
@ -526,7 +589,14 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
deleteLayoutModelForTab(tabId);
};
const handleTabLoaded = useCallback((tabId) => {
const handlePinChange = (tabId: string, pinned: boolean) => {
console.log("handlePinChange", tabId, pinned);
fireAndForget(async () => {
await WorkspaceService.ChangeTabPinning(workspace.oid, tabId, pinned);
});
};
const handleTabLoaded = useCallback((tabId: string) => {
setTabsLoaded((prev) => {
if (!prev[tabId]) {
// Only update if the tab isn't already marked as loaded
@ -574,17 +644,20 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
<div className="tab-bar" ref={tabBarRef} data-overlayscrollbars-initialize>
<div className="tabs-wrapper" ref={tabsWrapperRef} style={{ width: `${tabsWrapperWidth}px` }}>
{tabIds.map((tabId, index) => {
const isPinned = pinnedTabIds.has(tabId);
return (
<Tab
key={tabId}
ref={tabRefs.current[index]}
id={tabId}
isFirst={index === 0}
isPinned={isPinned}
onClick={() => handleSelectTab(tabId)}
isActive={activeTabId === tabId}
onMouseDown={(event) => handleDragStart(event, tabId, tabRefs.current[index])}
onClose={(event) => handleCloseTab(event, tabId)}
onLoaded={() => handleTabLoaded(tabId)}
onPinChange={() => handlePinChange(tabId, !isPinned)}
isBeforeActive={isBeforeActive(tabId)}
isDragging={draggingTab === tabId}
tabWidth={tabWidthRef.current}

View File

@ -156,7 +156,10 @@
.color-selector {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15px, 15px)); // Ensures each color circle has a fixed 14px size
grid-template-columns: repeat(
auto-fit,
minmax(15px, 15px)
); // Ensures each color circle has a fixed 14px size
grid-gap: 18.5px; // Space between items
justify-content: center;
align-items: center;
@ -189,7 +192,10 @@
.icon-selector {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16px, 16px)); // Ensures each color circle has a fixed 14px size
grid-template-columns: repeat(
auto-fit,
minmax(16px, 16px)
); // Ensures each color circle has a fixed 14px size
grid-column-gap: 17.5px; // Space between items
grid-row-gap: 13px; // Space between items
justify-content: center;

View File

@ -281,7 +281,7 @@ const WorkspaceSwitcherItem = ({
const windowIconDecl: IconButtonDecl = {
elemtype: "iconbutton",
className: "window",
disabled: true,
noAction: true,
icon: isCurrentWorkspace ? "check" : "window",
title: isCurrentWorkspace ? "This is your current workspace" : "This workspace is open",
};

View File

@ -1,10 +1,9 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { atoms } from "@/app/store/global";
import { useOverrideConfigAtom } from "@/app/store/global";
import loader from "@monaco-editor/loader";
import { Editor, Monaco } from "@monaco-editor/react";
import { atom, useAtomValue } from "jotai";
import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api";
import { configureMonacoYaml } from "monaco-yaml";
import React, { useMemo, useRef } from "react";
@ -108,6 +107,7 @@ function defaultEditorOptions(): MonacoTypes.editor.IEditorOptions {
}
interface CodeEditorProps {
blockId: string;
text: string;
filename: string;
language?: string;
@ -116,21 +116,12 @@ interface CodeEditorProps {
onMount?: (monacoPtr: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => () => void;
}
const minimapEnabledAtom = atom((get) => {
const settings = get(atoms.settingsAtom);
return settings["editor:minimapenabled"] ?? false;
});
const stickyScrollEnabledAtom = atom((get) => {
const settings = get(atoms.settingsAtom);
return settings["editor:stickyscrollenabled"] ?? false;
});
export function CodeEditor({ text, language, filename, meta, onChange, onMount }: CodeEditorProps) {
export function CodeEditor({ blockId, text, language, filename, meta, onChange, onMount }: CodeEditorProps) {
const divRef = useRef<HTMLDivElement>(null);
const unmountRef = useRef<() => void>(null);
const minimapEnabled = useAtomValue(minimapEnabledAtom);
const stickyScrollEnabled = useAtomValue(stickyScrollEnabledAtom);
const minimapEnabled = useOverrideConfigAtom(blockId, "editor:minimapenabled") ?? false;
const stickyScrollEnabled = useOverrideConfigAtom(blockId, "editor:stickyscrollenabled") ?? false;
const wordWrap = useOverrideConfigAtom(blockId, "editor:wordwrap") ?? false;
const theme = "wave-theme-dark";
React.useEffect(() => {
@ -158,9 +149,9 @@ export function CodeEditor({ text, language, filename, meta, onChange, onMount }
const opts = defaultEditorOptions();
opts.minimap.enabled = minimapEnabled;
opts.stickyScroll.enabled = stickyScrollEnabled;
opts.wordWrap = meta?.["editor:wordwrap"] ? "on" : "off";
opts.wordWrap = wordWrap ? "on" : "off";
return opts;
}, [minimapEnabled, stickyScrollEnabled, meta?.["editor:wordwrap"]]);
}, [minimapEnabled, stickyScrollEnabled, wordWrap]);
return (
<div className="code-editor-wrapper">

View File

@ -243,4 +243,10 @@
border: 1px solid rgba(255, 255, 255, 0.15);
background: #212121;
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3);
.entry-manager-buttons {
display: flex;
flex-direction: row;
gap: 10px;
}
}

View File

@ -1,6 +1,7 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Button } from "@/app/element/button";
import { Input } from "@/app/element/input";
import { ContextMenuModel } from "@/app/store/contextmenu";
import { PLATFORM, atoms, createBlock, getApi, globalStore } from "@/app/store/global";
@ -146,12 +147,21 @@ type EntryManagerOverlayProps = {
entryManagerType: EntryManagerType;
startingValue?: string;
onSave: (newValue: string) => void;
onCancel?: () => void;
style?: React.CSSProperties;
getReferenceProps?: () => any;
};
const EntryManagerOverlay = memo(
({ entryManagerType, startingValue, onSave, forwardRef, style, getReferenceProps }: EntryManagerOverlayProps) => {
({
entryManagerType,
startingValue,
onSave,
onCancel,
forwardRef,
style,
getReferenceProps,
}: EntryManagerOverlayProps) => {
const [value, setValue] = useState(startingValue);
return (
<div className="entry-manager-overlay" ref={forwardRef} style={style} {...getReferenceProps()}>
@ -168,7 +178,15 @@ const EntryManagerOverlay = memo(
onSave(value);
}
}}
></Input>
/>
</div>
<div className="entry-manager-buttons">
<Button className="vertical-padding-4" onClick={() => onSave(value)}>
Save
</Button>
<Button className="vertical-padding-4 red outlined" onClick={onCancel}>
Cancel
</Button>
</div>
</div>
);
@ -870,6 +888,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
}}
{...getReferenceProps()}
onContextMenu={(e) => handleFileContextMenu(e)}
onClick={() => setEntryManagerProps(undefined)}
>
<DirectoryTable
model={model}
@ -891,6 +910,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
forwardRef={refs.setFloating}
style={floatingStyles}
getReferenceProps={getFloatingProps}
onCancel={() => setEntryManagerProps(undefined)}
/>
)}
</Fragment>

View File

@ -10,7 +10,15 @@ import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { CodeEditor } from "@/app/view/codeeditor/codeeditor";
import { Markdown } from "@/element/markdown";
import { atoms, createBlock, getConnStatusAtom, getSettingsKeyAtom, globalStore, refocusNode } from "@/store/global";
import {
atoms,
createBlock,
getConnStatusAtom,
getOverrideConfigAtom,
getSettingsKeyAtom,
globalStore,
refocusNode,
} from "@/store/global";
import * as services from "@/store/services";
import * as WOS from "@/store/wos";
import { getWebServerEndpoint } from "@/util/endpoints";
@ -659,6 +667,8 @@ export class PreviewModel implements ViewModel {
});
}
const loadableSV = globalStore.get(this.loadableSpecializedView);
const wordWrapAtom = getOverrideConfigAtom(this.blockId, "editor:wordwrap");
const wordWrap = globalStore.get(wordWrapAtom) ?? false;
if (loadableSV.state == "hasData") {
if (loadableSV.data.specializedView == "codeedit") {
if (globalStore.get(this.newFileContent) != null) {
@ -676,11 +686,11 @@ export class PreviewModel implements ViewModel {
menuItems.push({
label: "Word Wrap",
type: "checkbox",
checked: blockData?.meta?.["editor:wordwrap"] ?? false,
checked: wordWrap,
click: () => {
const blockOref = WOS.makeORef("block", this.blockId);
services.ObjectService.UpdateObjectMeta(blockOref, {
"editor:wordwrap": !blockData?.meta?.["editor:wordwrap"],
"editor:wordwrap": !wordWrap,
});
},
});
@ -865,6 +875,7 @@ function CodeEditPreview({ model }: SpecializedViewProps) {
return (
<CodeEditor
blockId={model.blockId}
text={fileContent}
filename={fileName}
meta={blockMeta}

View File

@ -36,6 +36,24 @@
overflow: hidden;
}
.term-cmd-toolbar {
display: flex;
flex-direction: row;
height: 24px;
border-bottom: 1px solid var(--border-color);
overflow: hidden;
align-items: center;
.term-cmd-toolbar-text {
font: var(--fixed-font);
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 5px;
}
}
.term-connectelem {
flex-grow: 1;
min-height: 0;

View File

@ -56,7 +56,6 @@ class TermViewModel {
manageConnection: jotai.Atom<boolean>;
connStatus: jotai.Atom<ConnStatus>;
termWshClient: TermWshClient;
shellProcStatusRef: React.MutableRefObject<string>;
vdomBlockId: jotai.Atom<string>;
vdomToolbarBlockId: jotai.Atom<string>;
vdomToolbarTarget: jotai.PrimitiveAtom<VDomTargetToolbar>;
@ -64,6 +63,11 @@ class TermViewModel {
termThemeNameAtom: jotai.Atom<string>;
noPadding: jotai.PrimitiveAtom<boolean>;
endIconButtons: jotai.Atom<IconButtonDecl[]>;
shellProcFullStatus: jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;
shellProcStatus: jotai.Atom<string>;
shellProcStatusUnsubFn: () => void;
isCmdController: jotai.Atom<boolean>;
isRestarting: jotai.PrimitiveAtom<boolean>;
constructor(blockId: string, nodeModel: BlockNodeModel) {
this.viewType = "term";
@ -85,11 +89,15 @@ class TermViewModel {
const blockData = get(this.blockAtom);
return blockData?.meta?.["term:mode"] ?? "term";
});
this.isRestarting = jotai.atom(false);
this.viewIcon = jotai.atom((get) => {
const termMode = get(this.termMode);
if (termMode == "vdom") {
return "bolt";
}
const isCmd = get(this.isCmdController);
if (isCmd) {
}
return "terminal";
});
this.viewName = jotai.atom((get) => {
@ -99,7 +107,7 @@ class TermViewModel {
return "Wave App";
}
if (blockData?.meta?.controller == "cmd") {
return "Command";
return "";
}
return "Terminal";
});
@ -116,28 +124,76 @@ class TermViewModel {
},
},
];
} else {
}
const vdomBlockId = get(this.vdomBlockId);
const rtn = [];
if (vdomBlockId) {
return [
{
rtn.push({
elemtype: "iconbutton",
icon: "bolt",
title: "Switch to Wave App",
click: () => {
this.setTermMode("vdom");
},
},
];
});
}
const isCmd = get(this.isCmdController);
if (isCmd) {
const blockMeta = get(this.blockAtom)?.meta;
let cmdText = blockMeta?.["cmd"];
let cmdArgs = blockMeta?.["cmd:args"];
if (cmdArgs != null && Array.isArray(cmdArgs) && cmdArgs.length > 0) {
cmdText += " " + cmdArgs.join(" ");
}
rtn.push({
elemtype: "text",
text: cmdText,
noGrow: true,
});
const isRestarting = get(this.isRestarting);
if (isRestarting) {
rtn.push({
elemtype: "iconbutton",
icon: "refresh",
iconColor: "var(--success-color)",
iconSpin: true,
title: "Restarting Command",
noAction: true,
});
} else {
const fullShellProcStatus = get(this.shellProcFullStatus);
if (fullShellProcStatus?.shellprocstatus == "done") {
if (fullShellProcStatus?.shellprocexitcode == 0) {
rtn.push({
elemtype: "iconbutton",
icon: "check",
iconColor: "var(--success-color)",
title: "Command Exited Successfully",
noAction: true,
});
} else {
rtn.push({
elemtype: "iconbutton",
icon: "xmark-large",
iconColor: "var(--error-color)",
title: "Exit Code: " + fullShellProcStatus?.shellprocexitcode,
noAction: true,
});
}
}
return null;
}
}
return rtn;
});
this.manageConnection = jotai.atom((get) => {
const termMode = get(this.termMode);
if (termMode == "vdom") {
return false;
}
const isCmd = get(this.isCmdController);
if (isCmd) {
return false;
}
return true;
});
this.termThemeNameAtom = useBlockAtom(blockId, "termthemeatom", () => {
@ -175,17 +231,60 @@ class TermViewModel {
this.noPadding = jotai.atom(true);
this.endIconButtons = jotai.atom((get) => {
const blockData = get(this.blockAtom);
if (blockData?.meta?.["controller"] != "cmd") {
const shellProcStatus = get(this.shellProcStatus);
const connStatus = get(this.connStatus);
const isCmd = get(this.isCmdController);
if (blockData?.meta?.["controller"] != "cmd" && shellProcStatus != "done") {
return [];
}
return [
{
if (connStatus?.status != "connected") {
return [];
}
let iconName: string = null;
let title: string = null;
const noun = isCmd ? "Command" : "Shell";
if (shellProcStatus == "init") {
iconName = "play";
title = "Click to Start " + noun;
} else if (shellProcStatus == "running") {
iconName = "refresh";
title = noun + " Running. Click to Restart";
} else if (shellProcStatus == "done") {
iconName = "refresh";
title = noun + " Exited. Click to Restart";
}
if (iconName == null) {
return [];
}
const buttonDecl: IconButtonDecl = {
elemtype: "iconbutton",
icon: "refresh",
icon: iconName,
click: this.forceRestartController.bind(this),
title: "Force Restart Controller",
title: title,
};
const rtn = [buttonDecl];
return rtn;
});
this.isCmdController = jotai.atom((get) => {
const controllerMetaAtom = getBlockMetaKeyAtom(this.blockId, "controller");
return get(controllerMetaAtom) == "cmd";
});
this.shellProcFullStatus = jotai.atom(null) as jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;
const initialShellProcStatus = services.BlockService.GetControllerStatus(blockId);
initialShellProcStatus.then((rts) => {
this.updateShellProcStatus(rts);
});
this.shellProcStatusUnsubFn = waveEventSubscribe({
eventType: "controllerstatus",
scope: WOS.makeORef("block", blockId),
handler: (event) => {
let bcRTS: BlockControllerRuntimeStatus = event.data;
this.updateShellProcStatus(bcRTS);
},
];
});
this.shellProcStatus = jotai.atom((get) => {
const fullStatus = get(this.shellProcFullStatus);
return fullStatus?.shellprocstatus ?? "init";
});
}
@ -199,6 +298,23 @@ class TermViewModel {
});
}
triggerRestartAtom() {
globalStore.set(this.isRestarting, true);
setTimeout(() => {
globalStore.set(this.isRestarting, false);
}, 300);
}
updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) {
globalStore.set(this.shellProcFullStatus, fullStatus);
const status = fullStatus?.shellprocstatus ?? "init";
if (status == "running") {
this.termRef.current?.setIsRunning?.(true);
} else {
this.termRef.current?.setIsRunning?.(false);
}
}
getVDomModel(): VDomModel {
const vdomBlockId = globalStore.get(this.vdomBlockId);
if (!vdomBlockId) {
@ -225,6 +341,9 @@ class TermViewModel {
dispose() {
DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId));
if (this.shellProcStatusUnsubFn) {
this.shellProcStatusUnsubFn();
}
}
giveFocus(): boolean {
@ -284,11 +403,9 @@ class TermViewModel {
event.stopPropagation();
return false;
}
if (this.shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) {
// restart
const tabId = globalStore.get(atoms.staticTabId);
const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: this.blockId });
prtn.catch((e) => console.log("error controller resync (enter)", this.blockId, e));
const shellProcStatus = globalStore.get(this.shellProcStatus);
if ((shellProcStatus == "done" || shellProcStatus == "init") && keyutil.checkKeyPressed(waveEvent, "Enter")) {
this.forceRestartController();
return false;
}
const globalKeys = getAllGlobalKeyBindings();
@ -308,6 +425,10 @@ class TermViewModel {
}
forceRestartController() {
if (globalStore.get(this.isRestarting)) {
return;
}
this.triggerRestartAtom();
const termsize = {
rows: this.termRef.current?.terminal?.rows,
cols: this.termRef.current?.terminal?.cols,
@ -387,6 +508,62 @@ class TermViewModel {
label: "Force Restart Controller",
click: this.forceRestartController.bind(this),
});
const isClearOnStart = blockData?.meta?.["cmd:clearonstart"];
fullMenu.push({
label: "Clear Output On Restart",
submenu: [
{
label: "On",
type: "checkbox",
checked: isClearOnStart,
click: () => {
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { "cmd:clearonstart": true },
});
},
},
{
label: "Off",
type: "checkbox",
checked: !isClearOnStart,
click: () => {
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { "cmd:clearonstart": false },
});
},
},
],
});
const runOnStart = blockData?.meta?.["cmd:runonstart"];
fullMenu.push({
label: "Run On Startup",
submenu: [
{
label: "On",
type: "checkbox",
checked: runOnStart,
click: () => {
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { "cmd:runonstart": true },
});
},
},
{
label: "Off",
type: "checkbox",
checked: !runOnStart,
click: () => {
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { "cmd:runonstart": false },
});
},
},
],
});
if (blockData?.meta?.["term:vdomtoolbarblockid"]) {
fullMenu.push({ type: "separator" });
fullMenu.push({
@ -538,8 +715,6 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
const connectElemRef = React.useRef<HTMLDivElement>(null);
const termRef = React.useRef<TermWrap>(null);
model.termRef = termRef;
const spstatusRef = React.useRef<string>(null);
model.shellProcStatusRef = spstatusRef;
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const termSettingsAtom = useSettingsPrefixAtom("term");
const termSettings = jotai.useAtomValue(termSettingsAtom);
@ -587,6 +762,12 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
useWebGl: !termSettings?.["term:disablewebgl"],
}
);
const shellProcStatus = globalStore.get(model.shellProcStatus);
if (shellProcStatus == "running") {
termWrap.setIsRunning(true);
} else if (shellProcStatus == "done") {
termWrap.setIsRunning(false);
}
(window as any).term = termWrap;
termRef.current = termWrap;
const rszObs = new ResizeObserver(() => {
@ -613,34 +794,6 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
termModeRef.current = termMode;
}, [termMode]);
// set intitial controller status, and then subscribe for updates
React.useEffect(() => {
function updateShellProcStatus(status: string) {
if (status == null) {
return;
}
model.shellProcStatusRef.current = status;
if (status == "running") {
termRef.current?.setIsRunning(true);
} else {
termRef.current?.setIsRunning(false);
}
}
const initialRTStatus = services.BlockService.GetControllerStatus(blockId);
initialRTStatus.then((rts) => {
updateShellProcStatus(rts?.shellprocstatus);
});
return waveEventSubscribe({
eventType: "controllerstatus",
scope: WOS.makeORef("block", blockId),
handler: (event) => {
console.log("term waveEvent handler", event);
let bcRTS: BlockControllerRuntimeStatus = event.data;
updateShellProcStatus(bcRTS?.shellprocstatus);
},
});
}, []);
let stickerConfig = {
charWidth: 8,
charHeight: 16,

View File

@ -186,14 +186,14 @@ export class WaveAiModel implements ViewModel {
elemtype: "iconbutton",
icon: "globe",
title: "Using Remote Antropic API (" + modelName + ")",
disabled: true,
noAction: true,
});
} else if (isCloud) {
viewTextChildren.push({
elemtype: "iconbutton",
icon: "cloud",
title: "Using Wave's AI Proxy (gpt-4o-mini)",
disabled: true,
noAction: true,
});
} else {
const baseUrl = aiOpts.baseurl ?? "OpenAI Default Endpoint";
@ -203,14 +203,14 @@ export class WaveAiModel implements ViewModel {
elemtype: "iconbutton",
icon: "location-dot",
title: "Using Local Model @ " + baseUrl + " (" + modelName + ")",
disabled: true,
noAction: true,
});
} else {
viewTextChildren.push({
elemtype: "iconbutton",
icon: "globe",
title: "Using Remote Model @ " + baseUrl + " (" + modelName + ")",
disabled: true,
noAction: true,
});
}
}

View File

@ -154,11 +154,13 @@ declare global {
elemtype: "iconbutton";
icon: string | React.ReactNode;
iconColor?: string;
iconSpin?: boolean;
className?: string;
title?: string;
click?: (e: React.MouseEvent<any>) => void;
longClick?: (e: React.MouseEvent<any>) => void;
disabled?: boolean;
noAction?: boolean;
};
type HeaderTextButton = {
@ -173,6 +175,7 @@ declare global {
text: string;
ref?: React.MutableRefObject<HTMLDivElement>;
className?: string;
noGrow?: boolean;
onClick?: (e: React.MouseEvent<any>) => void;
};

View File

@ -45,7 +45,6 @@ declare global {
// waveobj.Block
type Block = WaveObj & {
parentoref?: string;
blockdef: BlockDef;
runtimeopts?: RuntimeOpts;
stickers?: StickerType[];
subblockids?: string[];
@ -56,6 +55,7 @@ declare global {
blockid: string;
shellprocstatus?: string;
shellprocconnname?: string;
shellprocexitcode: number;
};
// waveobj.BlockDef
@ -70,6 +70,7 @@ declare global {
tabid: string;
workspaceid: string;
block: Block;
files: WaveFile[];
};
// webcmd.BlockInputWSCommand
@ -329,9 +330,6 @@ declare global {
// waveobj.FileDef
type FileDef = {
filetype?: string;
path?: string;
url?: string;
content?: string;
meta?: {[key: string]: any};
};
@ -430,10 +428,15 @@ declare global {
"cmd:login"?: boolean;
"cmd:runonstart"?: boolean;
"cmd:clearonstart"?: boolean;
"cmd:clearonrestart"?: boolean;
"cmd:runonce"?: boolean;
"cmd:closeonexit"?: boolean;
"cmd:closeonexitforce"?: boolean;
"cmd:closeonexitdelay"?: number;
"cmd:env"?: {[key: string]: string};
"cmd:cwd"?: string;
"cmd:nowsh"?: boolean;
"cmd:args"?: string[];
"cmd:shell"?: boolean;
"ai:*"?: boolean;
"ai:preset"?: string;
"ai:apitype"?: string;
@ -446,6 +449,8 @@ declare global {
"ai:maxtokens"?: number;
"ai:timeoutms"?: number;
"editor:*"?: boolean;
"editor:minimapenabled"?: boolean;
"editor:stickyscrollenabled"?: boolean;
"editor:wordwrap"?: boolean;
"graph:*"?: boolean;
"graph:numpoints"?: number;
@ -603,6 +608,7 @@ declare global {
"term:copyonselect"?: boolean;
"editor:minimapenabled"?: boolean;
"editor:stickyscrollenabled"?: boolean;
"editor:wordwrap"?: boolean;
"web:*"?: boolean;
"web:openlinksinternally"?: boolean;
"web:defaulturl"?: string;
@ -1119,6 +1125,7 @@ declare global {
icon: string;
color: string;
tabids: string[];
pinnedtabids: string[];
activetabid: string;
};

View File

@ -87,11 +87,14 @@ async function initWaveWrap(initOpts: WaveInitOpts) {
async function reinitWave() {
console.log("Reinit Wave");
getApi().sendLog("Reinit Wave");
// We use this hack to prevent a flicker in the tab bar when switching to a new tab. This class is set in setActiveTab in global.ts. See tab.scss for where this class is used.
requestAnimationFrame(() => {
setTimeout(() => {
document.body.classList.remove("nohover");
}, 50);
}, 100);
});
const client = await WOS.reloadWaveObject<Client>(WOS.makeORef("client", savedInitOpts.clientId));
const waveWindow = await WOS.reloadWaveObject<WaveWindow>(WOS.makeORef("window", savedInitOpts.windowId));
const ws = await WOS.reloadWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));

View File

@ -28,7 +28,7 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.2",
"@eslint/js": "^9.15.0",
"@eslint/js": "^9.16.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@storybook/addon-essentials": "^8.4.6",
"@storybook/addon-interactions": "^8.4.6",
@ -46,7 +46,7 @@
"@types/papaparse": "^5",
"@types/pngjs": "^6.0.5",
"@types/prop-types": "^15",
"@types/react": "^18.3.12",
"@types/react": "^18.3.13",
"@types/react-dom": "^18.3.1",
"@types/semver": "^7",
"@types/shell-quote": "^1",
@ -56,17 +56,17 @@
"@types/uuid": "^10.0.0",
"@types/ws": "^8",
"@vitejs/plugin-react-swc": "^3.7.2",
"@vitest/coverage-istanbul": "^2.1.6",
"@vitest/coverage-istanbul": "^2.1.8",
"electron": "^33.2.0",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
"eslint": "^9.15.0",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.4.1",
"prettier": "^3.4.2",
"prettier-plugin-jsdoc": "^1.3.0",
"prettier-plugin-organize-imports": "^4.1.0",
"rollup-plugin-flow": "^1.1.1",
"sass": "^1.81.0",
"sass": "^1.82.0",
"semver": "^7.6.3",
"storybook": "^8.4.6",
"storybook-dark-mode": "^4.0.2",
@ -74,13 +74,13 @@
"tslib": "^2.8.1",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"typescript-eslint": "^8.16.0",
"vite": "^6.0.1",
"typescript-eslint": "^8.17.0",
"vite": "^6.0.2",
"vite-plugin-image-optimizer": "^1.1.8",
"vite-plugin-static-copy": "^2.2.0",
"vite-plugin-svgr": "^4.3.0",
"vite-tsconfig-paths": "^5.1.3",
"vitest": "^2.1.6"
"vitest": "^2.1.8"
},
"dependencies": {
"@floating-ui/react": "^0.26.28",
@ -112,7 +112,7 @@
"jotai": "2.9.3",
"monaco-editor": "^0.52.0",
"monaco-yaml": "^5.2.3",
"overlayscrollbars": "^2.10.0",
"overlayscrollbars": "^2.10.1",
"overlayscrollbars-react": "^0.5.6",
"papaparse": "^5.4.1",
"pngjs": "^7.0.0",
@ -133,7 +133,7 @@
"remark-github-blockquote-alert": "^1.3.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.5",
"shell-quote": "^1.8.1",
"shell-quote": "^1.8.2",
"sprintf-js": "^1.1.3",
"throttle-debounce": "^5.0.2",
"tinycolor2": "^1.6.0",

View File

@ -20,11 +20,14 @@ import (
"github.com/wavetermdev/waveterm/pkg/remote"
"github.com/wavetermdev/waveterm/pkg/remote/conncontroller"
"github.com/wavetermdev/waveterm/pkg/shellexec"
"github.com/wavetermdev/waveterm/pkg/util/envutil"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
"github.com/wavetermdev/waveterm/pkg/wavebase"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wconfig"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
"github.com/wavetermdev/waveterm/pkg/wshutil"
"github.com/wavetermdev/waveterm/pkg/wsl"
"github.com/wavetermdev/waveterm/pkg/wstore"
@ -37,12 +40,14 @@ const (
const (
BlockFile_Term = "term" // used for main pty output
BlockFile_Cache = "cache:term:full" // for cached block
BlockFile_VDom = "vdom" // used for alt html layout
)
const (
Status_Running = "running"
Status_Done = "done"
Status_Init = "init"
)
const (
@ -71,12 +76,14 @@ type BlockController struct {
ShellProc *shellexec.ShellProc
ShellInputCh chan *BlockInputUnion
ShellProcStatus string
ShellProcExitCode int
}
type BlockControllerRuntimeStatus struct {
BlockId string `json:"blockid"`
ShellProcStatus string `json:"shellprocstatus,omitempty"`
ShellProcConnName string `json:"shellprocconnname,omitempty"`
ShellProcExitCode int `json:"shellprocexitcode"`
}
func (bc *BlockController) WithLock(f func()) {
@ -93,6 +100,7 @@ func (bc *BlockController) GetRuntimeStatus() *BlockControllerRuntimeStatus {
if bc.ShellProc != nil {
rtn.ShellProcConnName = bc.ShellProc.ConnName
}
rtn.ShellProcExitCode = bc.ShellProcExitCode
})
return &rtn
}
@ -126,22 +134,29 @@ func (bc *BlockController) UpdateControllerAndSendUpdate(updateFn func() bool) {
}
}
func HandleTruncateBlockFile(blockId string, blockFile string) error {
func HandleTruncateBlockFile(blockId string) error {
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn()
err := filestore.WFS.WriteFile(ctx, blockId, blockFile, nil)
err := filestore.WFS.WriteFile(ctx, blockId, BlockFile_Term, nil)
if err == fs.ErrNotExist {
return nil
}
if err != nil {
return fmt.Errorf("error truncating blockfile: %w", err)
}
err = filestore.WFS.DeleteFile(ctx, blockId, BlockFile_Cache)
if err == fs.ErrNotExist {
err = nil
}
if err != nil {
log.Printf("error deleting cache file (continuing): %v\n", err)
}
wps.Broker.Publish(wps.WaveEvent{
Event: wps.Event_BlockFile,
Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, blockId).String()},
Data: &wps.WSFileEventData{
ZoneId: blockId,
FileName: blockFile,
FileName: BlockFile_Term,
FileOp: wps.FileOp_Truncate,
},
})
@ -174,16 +189,8 @@ 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[*waveobj.Block](ctx, bc.BlockId)
if getBlockDataErr == nil {
shouldTruncate = blockData.Meta.GetBool(waveobj.MetaKey_CmdClearOnRestart, false)
}
if shouldTruncate {
err := HandleTruncateBlockFile(bc.BlockId, BlockFile_Term)
if err != nil {
log.Printf("error truncating term blockfile: %v\n", err)
}
wfile, statErr := filestore.WFS.Stat(ctx, bc.BlockId, BlockFile_Term)
if statErr == fs.ErrNotExist || wfile.Size == 0 {
return
}
// controller type = "shell"
@ -199,6 +206,66 @@ func (bc *BlockController) resetTerminalState() {
}
}
// for "cmd" type blocks
func createCmdStrAndOpts(blockId string, blockMeta waveobj.MetaMapType) (string, *shellexec.CommandOptsType, error) {
var cmdStr string
var cmdOpts shellexec.CommandOptsType
cmdOpts.Env = make(map[string]string)
cmdStr = blockMeta.GetString(waveobj.MetaKey_Cmd, "")
if cmdStr == "" {
return "", nil, fmt.Errorf("missing cmd in block meta")
}
cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "")
if cmdOpts.Cwd != "" {
cwdPath, err := wavebase.ExpandHomeDir(cmdOpts.Cwd)
if err != nil {
return "", nil, err
}
cmdOpts.Cwd = cwdPath
}
useShell := blockMeta.GetBool(waveobj.MetaKey_CmdShell, true)
if !useShell {
if strings.Contains(cmdStr, " ") {
return "", nil, fmt.Errorf("cmd should not have spaces if cmd:shell is false (use cmd:args)")
}
cmdArgs := blockMeta.GetStringList(waveobj.MetaKey_CmdArgs)
// shell escape the args
for _, arg := range cmdArgs {
cmdStr = cmdStr + " " + utilfn.ShellQuote(arg, false, -1)
}
}
// get the "env" file
ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
defer cancelFn()
_, envFileData, err := filestore.WFS.ReadFile(ctx, blockId, "env")
if err == fs.ErrNotExist {
err = nil
}
if err != nil {
return "", nil, fmt.Errorf("error reading command env file: %w", err)
}
if len(envFileData) > 0 {
envMap := envutil.EnvToMap(string(envFileData))
for k, v := range envMap {
cmdOpts.Env[k] = v
}
}
cmdEnv := blockMeta.GetMap(waveobj.MetaKey_CmdEnv)
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)
}
}
return cmdStr, &cmdOpts, nil
}
func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj.MetaMapType) error {
// create a circular blockfile for the output
ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
@ -220,10 +287,9 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
// TODO better sync here (don't let two starts happen at the same times)
remoteName := blockMeta.GetString(waveobj.MetaKey_Connection, "")
var cmdStr string
cmdOpts := shellexec.CommandOptsType{
Env: make(map[string]string),
}
var cmdOpts shellexec.CommandOptsType
if bc.ControllerType == BlockController_Shell {
cmdOpts.Env = make(map[string]string)
cmdOpts.Interactive = true
cmdOpts.Login = true
cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "")
@ -235,32 +301,12 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
cmdOpts.Cwd = cwdPath
}
} else if bc.ControllerType == BlockController_Cmd {
cmdStr = blockMeta.GetString(waveobj.MetaKey_Cmd, "")
if cmdStr == "" {
return fmt.Errorf("missing cmd in block meta")
}
cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "")
if cmdOpts.Cwd != "" {
cwdPath, err := wavebase.ExpandHomeDir(cmdOpts.Cwd)
var cmdOptsPtr *shellexec.CommandOptsType
cmdStr, cmdOptsPtr, err = createCmdStrAndOpts(bc.BlockId, blockMeta)
if err != nil {
return err
}
cmdOpts.Cwd = cwdPath
}
cmdOpts.Interactive = blockMeta.GetBool(waveobj.MetaKey_CmdInteractive, false)
cmdOpts.Login = blockMeta.GetBool(waveobj.MetaKey_CmdLogin, false)
cmdEnv := blockMeta.GetMap(waveobj.MetaKey_CmdEnv)
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)
}
}
cmdOpts = *cmdOptsPtr
} else {
return fmt.Errorf("unknown controller type %q", bc.ControllerType)
}
@ -352,17 +398,21 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
wshProxy := wshutil.MakeRpcProxy()
wshProxy.SetRpcContext(&wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId})
wshutil.DefaultRouter.RegisterRoute(wshutil.MakeControllerRouteId(bc.BlockId), wshProxy, true)
ptyBuffer := wshutil.MakePtyBuffer(wshutil.WaveOSCPrefix, bc.ShellProc.Cmd, wshProxy.FromRemoteCh)
ptyBuffer := wshutil.MakePtyBuffer(wshutil.WaveOSCPrefix, shellProc.Cmd, wshProxy.FromRemoteCh)
go func() {
// handles regular output from the pty (goes to the blockfile and xterm)
defer panichandler.PanicHandler("blockcontroller:shellproc-pty-read-loop")
defer func() {
log.Printf("[shellproc] pty-read loop done\n")
bc.ShellProc.Close()
shellProc.Close()
bc.WithLock(func() {
// so no other events are sent
bc.ShellInputCh = nil
})
shellProc.Cmd.Wait()
exitCode := shellProc.Cmd.ExitCode()
termMsg := fmt.Sprintf("\r\nprocess finished with exit code = %d\r\n\r\n", exitCode)
HandleAppendBlockFile(bc.BlockId, BlockFile_Term, []byte(termMsg))
// to stop the inputCh loop
time.Sleep(100 * time.Millisecond)
close(shellInputCh) // don't use bc.ShellInputCh (it's nil)
@ -391,14 +441,14 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
defer panichandler.PanicHandler("blockcontroller:shellproc-input-loop")
for ic := range shellInputCh {
if len(ic.InputData) > 0 {
bc.ShellProc.Cmd.Write(ic.InputData)
shellProc.Cmd.Write(ic.InputData)
}
if ic.TermSize != nil {
err = setTermSize(ctx, bc.BlockId, *ic.TermSize)
if err != nil {
log.Printf("error setting pty size: %v\n", err)
}
err = bc.ShellProc.Cmd.SetSize(ic.TermSize.Rows, ic.TermSize.Cols)
err = shellProc.Cmd.SetSize(ic.TermSize.Rows, ic.TermSize.Cols)
if err != nil {
log.Printf("error setting pty size: %v\n", err)
}
@ -419,24 +469,49 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
go func() {
defer panichandler.PanicHandler("blockcontroller:shellproc-wait-loop")
// wait for the shell to finish
var exitCode int
defer func() {
wshutil.DefaultRouter.UnregisterRoute(wshutil.MakeControllerRouteId(bc.BlockId))
bc.UpdateControllerAndSendUpdate(func() bool {
bc.ShellProcStatus = Status_Done
bc.ShellProcExitCode = exitCode
return true
})
log.Printf("[shellproc] shell process wait loop done\n")
}()
waitErr := shellProc.Cmd.Wait()
exitCode := shellexec.ExitCodeFromWaitErr(waitErr)
termMsg := fmt.Sprintf("\r\nprocess finished with exit code = %d\r\n\r\n", exitCode)
//HandleAppendBlockFile(bc.BlockId, BlockFile_Term, []byte("\r\n"))
HandleAppendBlockFile(bc.BlockId, BlockFile_Term, []byte(termMsg))
exitCode = shellProc.Cmd.ExitCode()
shellProc.SetWaitErrorAndSignalDone(waitErr)
go checkCloseOnExit(bc.BlockId, exitCode)
}()
return nil
}
func checkCloseOnExit(blockId string, exitCode int) {
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn()
blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)
if err != nil {
log.Printf("error getting block data: %v\n", err)
return
}
closeOnExit := blockData.Meta.GetBool(waveobj.MetaKey_CmdCloseOnExit, false)
closeOnExitForce := blockData.Meta.GetBool(waveobj.MetaKey_CmdCloseOnExitForce, false)
if !closeOnExitForce && !(closeOnExit && exitCode == 0) {
return
}
delayMs := blockData.Meta.GetFloat(waveobj.MetaKey_CmdCloseOnExitDelay, 2000)
if delayMs < 0 {
delayMs = 0
}
time.Sleep(time.Duration(delayMs) * time.Millisecond)
rpcClient := wshclient.GetBareRpcClient()
err = wshclient.DeleteBlockCommand(rpcClient, wshrpc.CommandDeleteBlockData{BlockId: blockId}, nil)
if err != nil {
log.Printf("error deleting block data (close on exit): %v\n", err)
}
}
func getBoolFromMeta(meta map[string]any, key string, def bool) bool {
ival, found := meta[key]
if !found || ival == nil {
@ -474,20 +549,35 @@ func setTermSize(ctx context.Context, blockId string, termSize waveobj.TermSize)
return nil
}
func (bc *BlockController) run(bdata *waveobj.Block, blockMeta map[string]any, rtOpts *waveobj.RuntimeOpts) {
func (bc *BlockController) run(bdata *waveobj.Block, blockMeta map[string]any, rtOpts *waveobj.RuntimeOpts, force bool) {
curStatus := bc.GetRuntimeStatus()
controllerName := bdata.Meta.GetString(waveobj.MetaKey_Controller, "")
if controllerName != BlockController_Shell && controllerName != BlockController_Cmd {
log.Printf("unknown controller %q\n", controllerName)
return
}
runOnce := getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdRunOnce, false)
runOnStart := getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdRunOnStart, true)
if ((runOnStart || runOnce) && curStatus.ShellProcStatus == Status_Init) || force {
if getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdClearOnStart, false) {
err := HandleTruncateBlockFile(bc.BlockId, BlockFile_Term)
err := HandleTruncateBlockFile(bc.BlockId)
if err != nil {
log.Printf("error truncating term blockfile: %v\n", err)
}
}
runOnStart := getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdRunOnStart, true)
if runOnStart {
if runOnce {
ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
defer cancelFn()
metaUpdate := map[string]any{
waveobj.MetaKey_CmdRunOnce: false,
waveobj.MetaKey_CmdRunOnStart: false,
}
err := wstore.UpdateObjectMeta(ctx, waveobj.MakeORef(waveobj.OType_Block, bc.BlockId), metaUpdate)
if err != nil {
log.Printf("error updating block meta (in blockcontroller.run): %v\n", err)
return
}
}
go func() {
defer panichandler.PanicHandler("blockcontroller:run-shell-command")
var termSize waveobj.TermSize
@ -579,7 +669,7 @@ func getOrCreateBlockController(tabId string, blockId string, controllerName str
ControllerType: controllerName,
TabId: tabId,
BlockId: blockId,
ShellProcStatus: Status_Done,
ShellProcStatus: Status_Init,
}
blockControllerMap[blockId] = bc
createdController = true
@ -587,7 +677,7 @@ func getOrCreateBlockController(tabId string, blockId string, controllerName str
return bc
}
func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts *waveobj.RuntimeOpts) error {
func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts *waveobj.RuntimeOpts, force bool) error {
if tabId == "" || blockId == "" {
return fmt.Errorf("invalid tabId or blockId passed to ResyncController")
}
@ -595,6 +685,9 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts
if err != nil {
return fmt.Errorf("error getting block: %w", err)
}
if force {
StopBlockController(blockId)
}
connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "")
controllerName := blockData.Meta.GetString(waveobj.MetaKey_Controller, "")
curBc := GetBlockController(blockId)
@ -619,16 +712,16 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts
}
}
if curBc == nil {
return startBlockController(ctx, tabId, blockId, rtOpts)
return startBlockController(ctx, tabId, blockId, rtOpts, force)
}
bcStatus := curBc.GetRuntimeStatus()
if bcStatus.ShellProcStatus != Status_Running {
return startBlockController(ctx, tabId, blockId, rtOpts)
if bcStatus.ShellProcStatus == Status_Init || bcStatus.ShellProcStatus == Status_Done {
return startBlockController(ctx, tabId, blockId, rtOpts, force)
}
return nil
}
func startBlockController(ctx context.Context, tabId string, blockId string, rtOpts *waveobj.RuntimeOpts) error {
func startBlockController(ctx context.Context, tabId string, blockId string, rtOpts *waveobj.RuntimeOpts, force bool) error {
blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)
if err != nil {
return fmt.Errorf("error getting block: %w", err)
@ -649,8 +742,8 @@ func startBlockController(ctx context.Context, tabId string, blockId string, rtO
}
bc := getOrCreateBlockController(tabId, blockId, controllerName)
bcStatus := bc.GetRuntimeStatus()
if bcStatus.ShellProcStatus == Status_Done {
go bc.run(blockData, blockData.Meta, rtOpts)
if bcStatus.ShellProcStatus == Status_Init || bcStatus.ShellProcStatus == Status_Done {
go bc.run(blockData, blockData.Meta, rtOpts, force)
}
return nil
}

View File

@ -55,7 +55,7 @@ func (svc *WindowService) CreateWindow(ctx context.Context, winSize *waveobj.Win
return nil, fmt.Errorf("error getting workspace: %w", err)
}
if len(ws.TabIds) == 0 {
_, err = wcore.CreateTab(ctx, ws.OID, "", true)
_, err = wcore.CreateTab(ctx, ws.OID, "", true, false)
if err != nil {
return window, fmt.Errorf("error creating tab: %w", err)
}

View File

@ -3,6 +3,7 @@ package workspaceservice
import (
"context"
"fmt"
"log"
"time"
"github.com/wavetermdev/waveterm/pkg/blockcontroller"
@ -68,16 +69,16 @@ func (svg *WorkspaceService) ListWorkspaces() (waveobj.WorkspaceList, error) {
func (svc *WorkspaceService) CreateTab_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{
ArgNames: []string{"workspaceId", "tabName", "activateTab"},
ArgNames: []string{"workspaceId", "tabName", "activateTab", "pinned"},
ReturnDesc: "tabId",
}
}
func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) {
func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activateTab bool, pinned bool) (string, waveobj.UpdatesRtnType, error) {
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn()
ctx = waveobj.ContextWithUpdates(ctx)
tabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab)
tabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab, pinned)
if err != nil {
return "", nil, fmt.Errorf("error creating tab: %w", err)
}
@ -93,17 +94,39 @@ func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activ
return tabId, updates, nil
}
func (svc *WorkspaceService) UpdateTabIds_Meta() tsgenmeta.MethodMeta {
func (svc *WorkspaceService) ChangeTabPinning_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{
ArgNames: []string{"uiContext", "workspaceId", "tabIds"},
ArgNames: []string{"ctx", "workspaceId", "tabId", "pinned"},
}
}
func (svc *WorkspaceService) UpdateTabIds(uiContext waveobj.UIContext, workspaceId string, tabIds []string) (waveobj.UpdatesRtnType, error) {
func (svc *WorkspaceService) ChangeTabPinning(ctx context.Context, workspaceId string, tabId string, pinned bool) (waveobj.UpdatesRtnType, error) {
log.Printf("ChangeTabPinning %s %s %v\n", workspaceId, tabId, pinned)
ctx = waveobj.ContextWithUpdates(ctx)
err := wcore.ChangeTabPinning(ctx, workspaceId, tabId, pinned)
if err != nil {
return nil, fmt.Errorf("error toggling tab pinning: %w", err)
}
updates := waveobj.ContextGetUpdatesRtn(ctx)
go func() {
defer panichandler.PanicHandler("WorkspaceService:ChangeTabPinning:SendUpdateEvents")
wps.Broker.SendUpdateEvents(updates)
}()
return updates, nil
}
func (svc *WorkspaceService) UpdateTabIds_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{
ArgNames: []string{"uiContext", "workspaceId", "tabIds", "pinnedTabIds"},
}
}
func (svc *WorkspaceService) UpdateTabIds(uiContext waveobj.UIContext, workspaceId string, tabIds []string, pinnedTabIds []string) (waveobj.UpdatesRtnType, error) {
log.Printf("UpdateTabIds %s %v %v\n", workspaceId, tabIds, pinnedTabIds)
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn()
ctx = waveobj.ContextWithUpdates(ctx)
err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds)
err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds, pinnedTabIds)
if err != nil {
return nil, fmt.Errorf("error updating workspace tab ids: %w", err)
}

View File

@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"runtime"
"sync"
"syscall"
"time"
@ -19,6 +20,7 @@ type ConnInterface interface {
KillGraceful(time.Duration)
Wait() error
Start() error
ExitCode() int
StdinPipe() (io.WriteCloser, error)
StdoutPipe() (io.ReadCloser, error)
StderrPipe() (io.ReadCloser, error)
@ -28,15 +30,37 @@ type ConnInterface interface {
type CmdWrap struct {
Cmd *exec.Cmd
WaitOnce *sync.Once
WaitErr error
pty.Pty
}
func MakeCmdWrap(cmd *exec.Cmd, cmdPty pty.Pty) CmdWrap {
return CmdWrap{
Cmd: cmd,
WaitOnce: &sync.Once{},
Pty: cmdPty,
}
}
func (cw CmdWrap) Kill() {
cw.Cmd.Process.Kill()
}
func (cw CmdWrap) Wait() error {
return cw.Cmd.Wait()
cw.WaitOnce.Do(func() {
cw.WaitErr = cw.Cmd.Wait()
})
return cw.WaitErr
}
// only valid once Wait() has returned (or you know Cmd is done)
func (cw CmdWrap) ExitCode() int {
state := cw.Cmd.ProcessState
if state == nil {
return -1
}
return state.ExitCode()
}
func (cw CmdWrap) KillGraceful(timeout time.Duration) {
@ -95,9 +119,21 @@ type SessionWrap struct {
Session *ssh.Session
StartCmd string
Tty pty.Tty
WaitOnce *sync.Once
WaitErr error
pty.Pty
}
func MakeSessionWrap(session *ssh.Session, startCmd string, sessionPty pty.Pty) SessionWrap {
return SessionWrap{
Session: session,
StartCmd: startCmd,
Tty: sessionPty,
WaitOnce: &sync.Once{},
Pty: sessionPty,
}
}
func (sw SessionWrap) Kill() {
sw.Tty.Close()
sw.Session.Close()
@ -107,8 +143,19 @@ func (sw SessionWrap) KillGraceful(timeout time.Duration) {
sw.Kill()
}
func (sw SessionWrap) ExitCode() int {
waitErr := sw.WaitErr
if waitErr == nil {
return -1
}
return ExitCodeFromWaitErr(waitErr)
}
func (sw SessionWrap) Wait() error {
return sw.Session.Wait()
sw.WaitOnce.Do(func() {
sw.WaitErr = sw.Session.Wait()
})
return sw.WaitErr
}
func (sw SessionWrap) Start() error {

View File

@ -232,7 +232,8 @@ func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr st
if err != nil {
return nil, err
}
return &ShellProc{Cmd: CmdWrap{ecmd, cmdPty}, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
cmdWrap := MakeCmdWrap(ecmd, cmdPty)
return &ShellProc{Cmd: cmdWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
}
func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) {
@ -270,7 +271,7 @@ func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts Comm
session.Stderr = remoteStdoutWrite
session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil)
sessionWrap := SessionWrap{session, "", pipePty, pipePty}
sessionWrap := MakeSessionWrap(session, "", pipePty)
err = session.Shell()
if err != nil {
pipePty.Close()
@ -381,8 +382,7 @@ func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts Comm
}
session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil)
sessionWrap := SessionWrap{session, cmdCombined, pipePty, pipePty}
sessionWrap := MakeSessionWrap(session, cmdCombined, pipePty)
err = sessionWrap.Start()
if err != nil {
pipePty.Close()
@ -469,7 +469,8 @@ func StartShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOpt
if err != nil {
return nil, err
}
return &ShellProc{Cmd: CmdWrap{ecmd, cmdPty}, CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
cmdWrap := MakeCmdWrap(ecmd, cmdPty)
return &ShellProc{Cmd: cmdWrap, CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
}
func RunSimpleCmdInPty(ecmd *exec.Cmd, termSize waveobj.TermSize) ([]byte, error) {

View File

@ -80,33 +80,25 @@ func GetBool(v interface{}, field string) bool {
var needsQuoteRe = regexp.MustCompile(`[^\w@%:,./=+-]`)
// minimum maxlen=6
// minimum maxlen=6, pass -1 for no max length
func ShellQuote(val string, forceQuote bool, maxLen int) string {
if maxLen < 6 {
if maxLen != -1 && maxLen < 6 {
maxLen = 6
}
rtn := val
if needsQuoteRe.MatchString(val) {
rtn = "'" + strings.ReplaceAll(val, "'", `'"'"'`) + "'"
} else if forceQuote {
rtn = "\"" + rtn + "\""
}
if maxLen == -1 || len(rtn) <= maxLen {
return rtn
}
if strings.HasPrefix(rtn, "\"") || strings.HasPrefix(rtn, "'") {
if len(rtn) > maxLen {
return rtn[0:maxLen-4] + "..." + rtn[0:1]
return rtn[0:maxLen-4] + "..." + rtn[len(rtn)-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 {

View File

@ -43,10 +43,15 @@ const (
MetaKey_CmdLogin = "cmd:login"
MetaKey_CmdRunOnStart = "cmd:runonstart"
MetaKey_CmdClearOnStart = "cmd:clearonstart"
MetaKey_CmdClearOnRestart = "cmd:clearonrestart"
MetaKey_CmdRunOnce = "cmd:runonce"
MetaKey_CmdCloseOnExit = "cmd:closeonexit"
MetaKey_CmdCloseOnExitForce = "cmd:closeonexitforce"
MetaKey_CmdCloseOnExitDelay = "cmd:closeonexitdelay"
MetaKey_CmdEnv = "cmd:env"
MetaKey_CmdCwd = "cmd:cwd"
MetaKey_CmdNoWsh = "cmd:nowsh"
MetaKey_CmdArgs = "cmd:args"
MetaKey_CmdShell = "cmd:shell"
MetaKey_AiClear = "ai:*"
MetaKey_AiPresetKey = "ai:preset"
@ -61,6 +66,8 @@ const (
MetaKey_AiTimeoutMs = "ai:timeoutms"
MetaKey_EditorClear = "editor:*"
MetaKey_EditorMinimapEnabled = "editor:minimapenabled"
MetaKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled"
MetaKey_EditorWordWrap = "editor:wordwrap"
MetaKey_GraphClear = "graph:*"

View File

@ -171,6 +171,7 @@ type Workspace struct {
Icon string `json:"icon"`
Color string `json:"color"`
TabIds []string `json:"tabids"`
PinnedTabIds []string `json:"pinnedtabids"`
ActiveTabId string `json:"activetabid"`
Meta MetaMapType `json:"meta"`
}
@ -230,9 +231,6 @@ func (*LayoutState) GetOType() string {
}
type FileDef struct {
FileType string `json:"filetype,omitempty"`
Path string `json:"path,omitempty"`
Url string `json:"url,omitempty"`
Content string `json:"content,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
}
@ -279,7 +277,6 @@ type Block struct {
OID string `json:"oid"`
ParentORef string `json:"parentoref,omitempty"`
Version int `json:"version"`
BlockDef *BlockDef `json:"blockdef"`
RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"`
Stickers []*StickerType `json:"stickers,omitempty"`
Meta MetaMapType `json:"meta"`

View File

@ -42,10 +42,15 @@ type MetaTSType struct {
CmdLogin bool `json:"cmd:login,omitempty"`
CmdRunOnStart bool `json:"cmd:runonstart,omitempty"`
CmdClearOnStart bool `json:"cmd:clearonstart,omitempty"`
CmdClearOnRestart bool `json:"cmd:clearonrestart,omitempty"`
CmdRunOnce bool `json:"cmd:runonce,omitempty"`
CmdCloseOnExit bool `json:"cmd:closeonexit,omitempty"`
CmdCloseOnExitForce bool `json:"cmd:closeonexitforce,omitempty"`
CmdCloseOnExitDelay float64 `json:"cmd:closeonexitdelay,omitempty"`
CmdEnv map[string]string `json:"cmd:env,omitempty"`
CmdCwd string `json:"cmd:cwd,omitempty"`
CmdNoWsh bool `json:"cmd:nowsh,omitempty"`
CmdArgs []string `json:"cmd:args,omitempty"` // args for cmd (only if cmd:shell is false)
CmdShell bool `json:"cmd:shell,omitempty"` // shell expansion for cmd+args (defaults to true)
// AI options match settings
AiClear bool `json:"ai:*,omitempty"`
@ -61,6 +66,8 @@ type MetaTSType struct {
AiTimeoutMs float64 `json:"ai:timeoutms,omitempty"`
EditorClear bool `json:"editor:*,omitempty"`
EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"`
EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"`
EditorWordWrap bool `json:"editor:wordwrap,omitempty"`
GraphClear bool `json:"graph:*,omitempty"`

View File

@ -30,6 +30,7 @@ const (
ConfigKey_EditorMinimapEnabled = "editor:minimapenabled"
ConfigKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled"
ConfigKey_EditorWordWrap = "editor:wordwrap"
ConfigKey_WebClear = "web:*"
ConfigKey_WebOpenLinksInternally = "web:openlinksinternally"

View File

@ -57,6 +57,7 @@ type SettingsType struct {
EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"`
EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"`
EditorWordWrap bool `json:"editor:wordwrap,omitempty"`
WebClear bool `json:"web:*,omitempty"`
WebOpenLinksInternally bool `json:"web:openlinksinternally,omitempty"`

View File

@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/pkg/blockcontroller"
"github.com/wavetermdev/waveterm/pkg/filestore"
"github.com/wavetermdev/waveterm/pkg/panichandler"
"github.com/wavetermdev/waveterm/pkg/telemetry"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
@ -41,7 +42,6 @@ func createSubBlockObj(ctx context.Context, parentBlockId string, blockDef *wave
blockData := &waveobj.Block{
OID: blockId,
ParentORef: waveobj.MakeORef(waveobj.OType_Block, parentBlockId).String(),
BlockDef: blockDef,
RuntimeOpts: nil,
Meta: blockDef.Meta,
}
@ -52,7 +52,19 @@ func createSubBlockObj(ctx context.Context, parentBlockId string, blockDef *wave
})
}
func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) {
func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (rtnBlock *waveobj.Block, rtnErr error) {
var blockCreated bool
var newBlockOID string
defer func() {
if rtnErr == nil {
return
}
// if there was an error, and we created the block, clean it up since the function failed
if blockCreated && newBlockOID != "" {
deleteBlockObj(ctx, newBlockOID)
filestore.WFS.DeleteZone(ctx, newBlockOID)
}
}()
if blockDef == nil {
return nil, fmt.Errorf("blockDef is nil")
}
@ -63,6 +75,21 @@ func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef,
if err != nil {
return nil, fmt.Errorf("error creating block: %w", err)
}
blockCreated = true
newBlockOID = blockData.OID
// upload the files if present
if len(blockDef.Files) > 0 {
for fileName, fileDef := range blockDef.Files {
err := filestore.WFS.MakeFile(ctx, newBlockOID, fileName, fileDef.Meta, filestore.FileOptsType{})
if err != nil {
return nil, fmt.Errorf("error making blockfile %q: %w", fileName, err)
}
err = filestore.WFS.WriteFile(ctx, newBlockOID, fileName, []byte(fileDef.Content))
if err != nil {
return nil, fmt.Errorf("error writing blockfile %q: %w", fileName, err)
}
}
}
go func() {
defer panichandler.PanicHandler("CreateBlock:telemetry")
blockView := blockDef.Meta.GetString(waveobj.MetaKey_View, "")
@ -88,7 +115,6 @@ func createBlockObj(ctx context.Context, tabId string, blockDef *waveobj.BlockDe
blockData := &waveobj.Block{
OID: blockId,
ParentORef: waveobj.MakeORef(waveobj.OType_Tab, tabId).String(),
BlockDef: blockDef,
RuntimeOpts: rtOpts,
Meta: blockDef.Meta,
}

View File

@ -11,6 +11,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wstore"
)
@ -62,7 +63,7 @@ func EnsureInitialData() error {
if err != nil {
return fmt.Errorf("error creating default workspace: %w", err)
}
_, err = CreateTab(ctx, defaultWs.OID, "", true)
_, err = CreateTab(ctx, defaultWs.OID, "", true, true)
if err != nil {
return fmt.Errorf("error creating tab: %w", err)
}
@ -90,6 +91,5 @@ func GetClientData(ctx context.Context) (*waveobj.Client, error) {
if err != nil {
return nil, fmt.Errorf("error getting client data: %w", err)
}
log.Printf("clientData: %v\n", clientData)
return clientData, nil
}

View File

@ -176,7 +176,7 @@ func CheckAndFixWindow(ctx context.Context, windowId string) *waveobj.Window {
}
if len(ws.TabIds) == 0 {
log.Printf("fixing workspace with no tabs %q (in checkAndFixWindow)\n", ws.OID)
_, err = CreateTab(ctx, ws.OID, "", true)
_, err = CreateTab(ctx, ws.OID, "", true, false)
if err != nil {
log.Printf("error creating tab (in checkAndFixWindow): %v\n", err)
}

View File

@ -20,6 +20,7 @@ func CreateWorkspace(ctx context.Context, name string, icon string, color string
ws := &waveobj.Workspace{
OID: uuid.NewString(),
TabIds: []string{},
PinnedTabIds: []string{},
Name: name,
Icon: icon,
Color: color,
@ -37,11 +38,13 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool,
if err != nil {
return false, fmt.Errorf("error getting workspace: %w", err)
}
if workspace.Name != "" && workspace.Icon != "" && !force && len(workspace.TabIds) > 0 {
if workspace.Name != "" && workspace.Icon != "" && !force && len(workspace.TabIds) > 0 && len(workspace.PinnedTabIds) > 0 {
log.Printf("Ignoring DeleteWorkspace for workspace %s as it is named\n", workspaceId)
return false, nil
}
for _, tabId := range workspace.TabIds {
// delete all pinned and unpinned tabs
for _, tabId := range append(workspace.TabIds, workspace.PinnedTabIds...) {
log.Printf("deleting tab %s\n", tabId)
_, err := DeleteTab(ctx, workspaceId, tabId, false)
if err != nil {
@ -60,7 +63,30 @@ func GetWorkspace(ctx context.Context, wsID string) (*waveobj.Workspace, error)
return wstore.DBMustGet[*waveobj.Workspace](ctx, wsID)
}
func createTabObj(ctx context.Context, workspaceId string, name string) (*waveobj.Tab, error) {
// returns tabid
func CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool, pinned bool) (string, error) {
if tabName == "" {
ws, err := GetWorkspace(ctx, workspaceId)
if err != nil {
return "", fmt.Errorf("workspace %s not found: %w", workspaceId, err)
}
tabName = "T" + fmt.Sprint(len(ws.TabIds)+len(ws.PinnedTabIds)+1)
}
tab, err := createTabObj(ctx, workspaceId, tabName, pinned)
if err != nil {
return "", fmt.Errorf("error creating tab: %w", err)
}
if activateTab {
err = SetActiveTab(ctx, workspaceId, tab.OID)
if err != nil {
return "", fmt.Errorf("error setting active tab: %w", err)
}
}
telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NewTab: 1}, "createtab")
return tab.OID, nil
}
func createTabObj(ctx context.Context, workspaceId string, name string, pinned bool) (*waveobj.Tab, error) {
ws, err := GetWorkspace(ctx, workspaceId)
if err != nil {
return nil, fmt.Errorf("workspace %s not found: %w", workspaceId, err)
@ -75,36 +101,17 @@ func createTabObj(ctx context.Context, workspaceId string, name string) (*waveob
layoutState := &waveobj.LayoutState{
OID: layoutStateId,
}
if pinned {
ws.PinnedTabIds = append(ws.PinnedTabIds, tab.OID)
} else {
ws.TabIds = append(ws.TabIds, tab.OID)
}
wstore.DBInsert(ctx, tab)
wstore.DBInsert(ctx, layoutState)
wstore.DBUpdate(ctx, ws)
return tab, nil
}
// returns tabid
func CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool) (string, error) {
if tabName == "" {
ws, err := GetWorkspace(ctx, workspaceId)
if err != nil {
return "", fmt.Errorf("workspace %s not found: %w", workspaceId, err)
}
tabName = "T" + fmt.Sprint(len(ws.TabIds)+1)
}
tab, err := createTabObj(ctx, workspaceId, tabName)
if err != nil {
return "", fmt.Errorf("error creating tab: %w", err)
}
if activateTab {
err = SetActiveTab(ctx, workspaceId, tab.OID)
if err != nil {
return "", fmt.Errorf("error setting active tab: %w", err)
}
}
telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NewTab: 1}, "createtab")
return tab.OID, nil
}
// Must delete all blocks individually first.
// Also deletes LayoutState.
// recursive: if true, will recursively close parent window, workspace, if they are empty.
@ -114,38 +121,50 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive
if ws == nil {
return "", fmt.Errorf("workspace not found: %q", workspaceId)
}
// ensure tab is in workspace
tabIdx := utilfn.FindStringInSlice(ws.TabIds, tabId)
tabIdxPinned := utilfn.FindStringInSlice(ws.PinnedTabIds, tabId)
if tabIdx != -1 {
ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...)
} else if tabIdxPinned != -1 {
ws.PinnedTabIds = append(ws.PinnedTabIds[:tabIdxPinned], ws.PinnedTabIds[tabIdxPinned+1:]...)
} else {
return "", fmt.Errorf("tab %s not found in workspace %s", tabId, workspaceId)
}
// close blocks (sends events + stops block controllers)
tab, _ := wstore.DBGet[*waveobj.Tab](ctx, tabId)
if tab == nil {
return "", fmt.Errorf("tab not found: %q", tabId)
}
// close blocks (sends events + stops block controllers)
for _, blockId := range tab.BlockIds {
err := DeleteBlock(ctx, blockId, false)
if err != nil {
return "", fmt.Errorf("error deleting block %s: %w", blockId, err)
}
}
tabIdx := utilfn.FindStringInSlice(ws.TabIds, tabId)
if tabIdx == -1 {
return "", nil
}
ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...)
// if the tab is active, determine new active tab
newActiveTabId := ws.ActiveTabId
if len(ws.TabIds) > 0 {
if ws.ActiveTabId == tabId {
if len(ws.TabIds) > 0 && tabIdx != -1 {
newActiveTabId = ws.TabIds[max(0, min(tabIdx-1, len(ws.TabIds)-1))]
}
} else if len(ws.PinnedTabIds) > 0 {
newActiveTabId = ws.PinnedTabIds[0]
} else {
newActiveTabId = ""
}
}
ws.ActiveTabId = newActiveTabId
wstore.DBUpdate(ctx, ws)
wstore.DBDelete(ctx, waveobj.OType_Tab, tabId)
wstore.DBDelete(ctx, waveobj.OType_LayoutState, tab.LayoutState)
// if no tabs remaining, close window
if newActiveTabId == "" && recursive {
log.Printf("no tabs remaining in workspace %s, closing window\n", workspaceId)
windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId)
if err != nil {
return newActiveTabId, fmt.Errorf("unable to find window for workspace id %v: %w", workspaceId, err)
@ -159,7 +178,7 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive
}
func SetActiveTab(ctx context.Context, workspaceId string, tabId string) error {
if tabId != "" {
if tabId != "" && workspaceId != "" {
workspace, err := GetWorkspace(ctx, workspaceId)
if err != nil {
return fmt.Errorf("workspace %s not found: %w", workspaceId, err)
@ -174,6 +193,30 @@ func SetActiveTab(ctx context.Context, workspaceId string, tabId string) error {
return nil
}
func ChangeTabPinning(ctx context.Context, workspaceId string, tabId string, pinned bool) error {
if tabId != "" && workspaceId != "" {
workspace, err := GetWorkspace(ctx, workspaceId)
if err != nil {
return fmt.Errorf("workspace %s not found: %w", workspaceId, err)
}
if pinned && utilfn.FindStringInSlice(workspace.PinnedTabIds, tabId) == -1 {
if utilfn.FindStringInSlice(workspace.TabIds, tabId) == -1 {
return fmt.Errorf("tab %s not found in workspace %s", tabId, workspaceId)
}
workspace.TabIds = utilfn.RemoveElemFromSlice(workspace.TabIds, tabId)
workspace.PinnedTabIds = append(workspace.PinnedTabIds, tabId)
} else if !pinned && utilfn.FindStringInSlice(workspace.PinnedTabIds, tabId) != -1 {
if utilfn.FindStringInSlice(workspace.PinnedTabIds, tabId) == -1 {
return fmt.Errorf("tab %s not found in workspace %s", tabId, workspaceId)
}
workspace.PinnedTabIds = utilfn.RemoveElemFromSlice(workspace.PinnedTabIds, tabId)
workspace.TabIds = append([]string{tabId}, workspace.TabIds...)
}
wstore.DBUpdate(ctx, workspace)
}
return nil
}
func SendActiveTabUpdate(ctx context.Context, workspaceId string, newActiveTabId string) {
eventbus.SendEventToElectron(eventbus.WSEventType{
EventType: eventbus.WSEvent_ElectronUpdateActiveTab,
@ -181,12 +224,13 @@ func SendActiveTabUpdate(ctx context.Context, workspaceId string, newActiveTabId
})
}
func UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []string) error {
func UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []string, pinnedTabIds []string) error {
ws, _ := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId)
if ws == nil {
return fmt.Errorf("workspace not found: %q", workspaceId)
}
ws.TabIds = tabIds
ws.PinnedTabIds = pinnedTabIds
wstore.DBUpdate(ctx, ws)
return nil
}

View File

@ -532,6 +532,7 @@ type BlockInfoData struct {
TabId string `json:"tabid"`
WorkspaceId string `json:"workspaceid"`
Block *waveobj.Block `json:"block"`
Files []*filestore.WaveFile `json:"files"`
}
type WaveNotificationOptions struct {

View File

@ -231,10 +231,7 @@ func (ws *WshServer) ControllerStopCommand(ctx context.Context, blockId string)
}
func (ws *WshServer) ControllerResyncCommand(ctx context.Context, data wshrpc.CommandControllerResyncData) error {
if data.ForceRestart {
blockcontroller.StopBlockController(data.BlockId)
}
return blockcontroller.ResyncController(ctx, data.TabId, data.BlockId, data.RtOpts)
return blockcontroller.ResyncController(ctx, data.TabId, data.BlockId, data.RtOpts, data.ForceRestart)
}
func (ws *WshServer) ControllerInputCommand(ctx context.Context, data wshrpc.CommandBlockInputData) error {
@ -701,11 +698,16 @@ func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wsh
if err != nil {
return nil, fmt.Errorf("error finding window for tab: %w", err)
}
fileList, err := filestore.WFS.ListFiles(ctx, blockId)
if err != nil {
return nil, fmt.Errorf("error listing blockfiles: %w", err)
}
return &wshrpc.BlockInfoData{
BlockId: blockId,
TabId: tabId,
WorkspaceId: workspaceId,
Block: blockData,
Files: fileList,
}, nil
}

View File

@ -46,6 +46,10 @@ func (wc *WslCmd) GetProcessState() *os.ProcessState {
return nil
}
func (wc *WslCmd) ExitCode() int {
return -1
}
func (c *WslCmd) SetStdin(stdin io.Reader) {
c.Stdin = stdin
}

View File

@ -74,6 +74,13 @@ func (c *WslCmd) Wait() (err error) {
}
return c.waitErr
}
func (c *WslCmd) ExitCode() int {
state := c.c.ProcessState
if state == nil {
return -1
}
return state.ExitCode()
}
func (c *WslCmd) GetProcess() *os.Process {
return c.c.Process
}

View File

@ -350,12 +350,28 @@ func DBFindTabForBlockId(ctx context.Context, blockId string) (string, error) {
}
func DBFindWorkspaceForTabId(ctx context.Context, tabId string) (string, error) {
log.Printf("DBFindWorkspaceForTabId tabId: %s\n", tabId)
return WithTxRtn(ctx, func(tx *TxWrap) (string, error) {
query := `
WITH variable(value) AS (
SELECT ?
)
SELECT w.oid
FROM db_workspace w, json_each(data->'tabids') je
WHERE je.value = ?`
return tx.GetString(query, tabId), nil
FROM db_workspace w, variable
WHERE EXISTS (
SELECT 1
FROM json_each(w.data, '$.tabids') AS je
WHERE je.value = variable.value
)
OR EXISTS (
SELECT 1
FROM json_each(w.data, '$.pinnedtabids') AS je
WHERE je.value = variable.value
);
`
wsId := tx.GetString(query, tabId)
log.Printf("DBFindWorkspaceForTabId wsId: %s\n", wsId)
return wsId, nil
})
}

577
yarn.lock

File diff suppressed because it is too large Load Diff