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 end_of_line = lf
insert_final_newline = true 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 charset = utf-8
indent_style = space indent_style = space
indent_size = 4 indent_size = 4

View File

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

View File

@ -21,13 +21,12 @@ var editMagnified bool
var editorCmd = &cobra.Command{ var editorCmd = &cobra.Command{
Use: "editor", Use: "editor",
Short: "edit a file (blocks until editor is closed)", Short: "edit a file (blocks until editor is closed)",
Args: cobra.ExactArgs(1),
RunE: editorRun, RunE: editorRun,
PreRunE: preRunSetupRpcClient, PreRunE: preRunSetupRpcClient,
} }
func init() { 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) rootCmd.AddCommand(editorCmd)
} }
@ -35,7 +34,14 @@ func editorRun(cmd *cobra.Command, args []string) (rtnErr error) {
defer func() { defer func() {
sendActivity("editor", rtnErr == nil) 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] fileArg := args[0]
absFile, err := filepath.Abs(fileArg) absFile, err := filepath.Abs(fileArg)
if err != nil { if err != nil {

View File

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

View File

@ -8,7 +8,6 @@ import (
"io" "io"
"os" "os"
"runtime/debug" "runtime/debug"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/waveobj"
@ -27,26 +26,58 @@ var (
) )
var WrappedStdin io.Reader = os.Stdin 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 RpcClient *wshutil.WshRpc
var RpcContext wshrpc.RpcContext var RpcContext wshrpc.RpcContext
var UsingTermWshMode bool var UsingTermWshMode bool
var blockArg string var blockArg string
var WshExitCode int var WshExitCode int
func WriteStderr(fmtStr string, args ...interface{}) { type WrappedWriter struct {
output := fmt.Sprintf(fmtStr, args...) dest io.Writer
if UsingTermWshMode { }
output = strings.ReplaceAll(output, "\n", "\r\n")
func (w *WrappedWriter) Write(p []byte) (n int, err error) {
if !UsingTermWshMode {
return w.dest.Write(p)
} }
fmt.Fprint(os.Stderr, output) 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{}) { func WriteStdout(fmtStr string, args ...interface{}) {
output := fmt.Sprintf(fmtStr, args...) WrappedStdout.Write([]byte(fmt.Sprintf(fmtStr, args...)))
if UsingTermWshMode { }
output = strings.ReplaceAll(output, "\n", "\r\n")
} func OutputHelpMessage(cmd *cobra.Command) {
fmt.Print(output) cmd.SetOutput(WrappedStderr)
cmd.Help()
WriteStderr("\n")
} }
func preRunSetupRpcClient(cmd *cobra.Command, args []string) error { func preRunSetupRpcClient(cmd *cobra.Command, args []string) error {
@ -64,6 +95,15 @@ func getIsTty() bool {
return false 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 type RunEFnType = func(*cobra.Command, []string) error
func activityWrap(activityStr string, origRunE RunEFnType) RunEFnType { 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 { if err != nil {
return fmt.Errorf("getting absolute path: %w", err) return fmt.Errorf("getting absolute path: %w", err)
} }
createBlockData := wshrpc.CommandCreateBlockData{ createMeta := map[string]any{
BlockDef: &waveobj.BlockDef{
Meta: map[string]interface{}{
waveobj.MetaKey_View: "term", waveobj.MetaKey_View: "term",
waveobj.MetaKey_CmdCwd: cwd, waveobj.MetaKey_CmdCwd: cwd,
waveobj.MetaKey_Controller: "shell", waveobj.MetaKey_Controller: "shell",
},
},
Magnified: termMagnified,
} }
if RpcContext.Conn != "" { 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) oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil)
if err != nil { if err != nil {

View File

@ -20,7 +20,6 @@ var viewMagnified bool
var viewCmd = &cobra.Command{ var viewCmd = &cobra.Command{
Use: "view {file|directory|URL}", Use: "view {file|directory|URL}",
Short: "preview/edit a file or directory", Short: "preview/edit a file or directory",
Args: cobra.ExactArgs(1),
RunE: viewRun, RunE: viewRun,
PreRunE: preRunSetupRpcClient, PreRunE: preRunSetupRpcClient,
} }
@ -28,7 +27,6 @@ var viewCmd = &cobra.Command{
var editCmd = &cobra.Command{ var editCmd = &cobra.Command{
Use: "edit {file}", Use: "edit {file}",
Short: "edit a file", Short: "edit a file",
Args: cobra.ExactArgs(1),
RunE: viewRun, RunE: viewRun,
PreRunE: preRunSetupRpcClient, PreRunE: preRunSetupRpcClient,
} }
@ -40,9 +38,18 @@ func init() {
} }
func viewRun(cmd *cobra.Command, args []string) (rtnErr error) { func viewRun(cmd *cobra.Command, args []string) (rtnErr error) {
cmdName := cmd.Name()
defer func() { 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] fileArg := args[0]
conn := RpcContext.Conn conn := RpcContext.Conn
var wshCmd *wshrpc.CommandCreateBlockData var wshCmd *wshrpc.CommandCreateBlockData
@ -81,7 +88,7 @@ func viewRun(cmd *cobra.Command, args []string) (rtnErr error) {
}, },
Magnified: viewMagnified, Magnified: viewMagnified,
} }
if cmd.Use == "edit" { if cmdName == "edit" {
wshCmd.BlockDef.Meta[waveobj.MetaKey_Edit] = true wshCmd.BlockDef.Meta[waveobj.MetaKey_Edit] = true
} }
if conn != "" { if conn != "" {

View File

@ -45,6 +45,7 @@ wsh editconfig
| term:scrollback | int | size of terminal scrollback buffer, max is 10000 | | term:scrollback | int | size of terminal scrollback buffer, max is 10000 |
| editor:minimapenabled | bool | set to false to disable editor minimap | | editor:minimapenabled | bool | set to false to disable editor minimap |
| editor:stickscrollenabled | bool | | | 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: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: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 | | 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: The `WidgetConfigType` takes the usual options common to all widgets. The `MetaTSType` can include the keys listed below:
| Key | Description | | 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"`. | | "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. | | "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" | (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: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: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: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:runonce" | (optional) Runs on start, but then sets "cmd:runonce" and "cmd:runonstart" to false (so future runs require manual restarts) |
| "cmd:clearonrestart" | (optional) When the app restarts, the contents of the block are cleared out. Defaults to false. | | "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: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: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. | | "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 ## deleteblock
``` ```

View File

@ -36,15 +36,15 @@
"@docusaurus/module-type-aliases": "3.6.3", "@docusaurus/module-type-aliases": "3.6.3",
"@docusaurus/tsconfig": "3.6.3", "@docusaurus/tsconfig": "3.6.3",
"@docusaurus/types": "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", "@mdx-js/typescript-plugin": "^0.0.6",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/eslint-config-prettier": "^6.11.3", "@types/eslint-config-prettier": "^6.11.3",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-mdx": "^3.1.5", "eslint-plugin-mdx": "^3.1.5",
"prettier": "^3.4.1", "prettier": "^3.4.2",
"prettier-plugin-jsdoc": "^1.3.0", "prettier-plugin-jsdoc": "^1.3.0",
"prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-organize-imports": "^4.1.0",
"remark-cli": "^12.0.1", "remark-cli": "^12.0.1",
@ -53,7 +53,7 @@
"remark-preset-lint-consistent": "^6.0.0", "remark-preset-lint-consistent": "^6.0.0",
"remark-preset-lint-recommended": "^7.0.0", "remark-preset-lint-recommended": "^7.0.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"typescript-eslint": "^8.16.0" "typescript-eslint": "^8.17.0"
}, },
"resolutions": { "resolutions": {
"path-to-regexp@npm:2.2.1": "^3", "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_BUFFER_UTIL": "true",
"process.env.WS_NO_UTF_8_VALIDATE": "true", "process.env.WS_NO_UTF_8_VALIDATE": "true",
}, },
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler", // or "modern"
},
},
},
}, },
preload: { preload: {
root: ".", root: ".",

View File

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

View File

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

View File

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

View File

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

View File

@ -2,10 +2,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { NumActiveConnColors } from "@/app/block/blockframe"; import { NumActiveConnColors } from "@/app/block/blockframe";
import { getConnStatusAtom, WOS } from "@/app/store/global"; import { getConnStatusAtom } from "@/app/store/global";
import * as services from "@/app/store/services";
import { makeORef } from "@/app/store/wos";
import { waveEventSubscribe } from "@/store/wps";
import * as util from "@/util/util"; import * as util from "@/util/util";
import clsx from "clsx"; import clsx from "clsx";
import * as jotai from "jotai"; import * as jotai from "jotai";
@ -150,52 +147,6 @@ interface ConnectionButtonProps {
changeConnModalAtom: jotai.PrimitiveAtom<boolean>; 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 { export function computeConnColorNum(connStatus: ConnStatus): number {
// activeconnnum is 1-indexed, so we need to adjust for when mod is 0 // activeconnnum is 1-indexed, so we need to adjust for when mod is 0
const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors; const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors;

View File

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

View File

@ -9,15 +9,19 @@ import "./iconbutton.scss";
export const IconButton = memo(({ decl, className }: { decl: IconButtonDecl; className?: string }) => { export const IconButton = memo(({ decl, className }: { decl: IconButtonDecl; className?: string }) => {
const buttonRef = useRef<HTMLDivElement>(null); const buttonRef = useRef<HTMLDivElement>(null);
const spin = decl.iconSpin ?? false;
useLongClick(buttonRef, decl.click, decl.longClick, decl.disabled); useLongClick(buttonRef, decl.click, decl.longClick, decl.disabled);
return ( return (
<div <div
ref={buttonRef} 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} title={decl.title}
style={{ color: decl.iconColor ?? "inherit" }} 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> </div>
); );
}); });

View File

@ -1,14 +1,14 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
@mixin ellipsis(){ @mixin ellipsis() {
display: block; display: block;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
@mixin border-radius-mixin(){ @mixin border-radius-mixin() {
&.border-radius-2 { &.border-radius-2 {
border-radius: 4px; border-radius: 4px;
} }
@ -38,7 +38,7 @@
} }
} }
@mixin vertical-padding-mixin(){ @mixin vertical-padding-mixin() {
&.vertical-padding-0 { &.vertical-padding-0 {
padding-top: 0px; padding-top: 0px;
padding-bottom: 0px; padding-bottom: 0px;
@ -85,7 +85,7 @@
} }
} }
@mixin horizontal-padding-mixin(){ @mixin horizontal-padding-mixin() {
&.horizontal-padding-0 { &.horizontal-padding-0 {
padding-left: 0px; padding-left: 0px;
padding-right: 0px; padding-right: 0px;
@ -132,7 +132,7 @@
} }
} }
@mixin font-size-mixin(){ @mixin font-size-mixin() {
&.font-size-10 { &.font-size-10 {
font-size: 10px; font-size: 10px;
} }
@ -186,7 +186,7 @@
} }
} }
@mixin font-weight-mixin(){ @mixin font-weight-mixin() {
&.font-weight-100 { &.font-weight-100 {
font-weight: 100; font-weight: 100;
} }
@ -210,7 +210,7 @@
} }
} }
@mixin avatar-dims-mixin(){ @mixin avatar-dims-mixin() {
&.size-xs { &.size-xs {
width: 20px; width: 20px;
height: 20px; height: 20px;

View File

@ -627,6 +627,7 @@ function createTab() {
} }
function setActiveTab(tabId: string) { 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"); document.body.classList.add("nohover");
getApi().setActiveTab(tabId); getApi().setActiveTab(tabId);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -156,7 +156,10 @@
.color-selector { .color-selector {
display: grid; 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 grid-gap: 18.5px; // Space between items
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -189,7 +192,10 @@
.icon-selector { .icon-selector {
display: grid; 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-column-gap: 17.5px; // Space between items
grid-row-gap: 13px; // Space between items grid-row-gap: 13px; // Space between items
justify-content: center; justify-content: center;

View File

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

View File

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

View File

@ -243,4 +243,10 @@
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.15);
background: #212121; background: #212121;
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3); 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. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { Button } from "@/app/element/button";
import { Input } from "@/app/element/input"; import { Input } from "@/app/element/input";
import { ContextMenuModel } from "@/app/store/contextmenu"; import { ContextMenuModel } from "@/app/store/contextmenu";
import { PLATFORM, atoms, createBlock, getApi, globalStore } from "@/app/store/global"; import { PLATFORM, atoms, createBlock, getApi, globalStore } from "@/app/store/global";
@ -146,12 +147,21 @@ type EntryManagerOverlayProps = {
entryManagerType: EntryManagerType; entryManagerType: EntryManagerType;
startingValue?: string; startingValue?: string;
onSave: (newValue: string) => void; onSave: (newValue: string) => void;
onCancel?: () => void;
style?: React.CSSProperties; style?: React.CSSProperties;
getReferenceProps?: () => any; getReferenceProps?: () => any;
}; };
const EntryManagerOverlay = memo( const EntryManagerOverlay = memo(
({ entryManagerType, startingValue, onSave, forwardRef, style, getReferenceProps }: EntryManagerOverlayProps) => { ({
entryManagerType,
startingValue,
onSave,
onCancel,
forwardRef,
style,
getReferenceProps,
}: EntryManagerOverlayProps) => {
const [value, setValue] = useState(startingValue); const [value, setValue] = useState(startingValue);
return ( return (
<div className="entry-manager-overlay" ref={forwardRef} style={style} {...getReferenceProps()}> <div className="entry-manager-overlay" ref={forwardRef} style={style} {...getReferenceProps()}>
@ -168,7 +178,15 @@ const EntryManagerOverlay = memo(
onSave(value); 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>
</div> </div>
); );
@ -870,6 +888,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
}} }}
{...getReferenceProps()} {...getReferenceProps()}
onContextMenu={(e) => handleFileContextMenu(e)} onContextMenu={(e) => handleFileContextMenu(e)}
onClick={() => setEntryManagerProps(undefined)}
> >
<DirectoryTable <DirectoryTable
model={model} model={model}
@ -891,6 +910,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
forwardRef={refs.setFloating} forwardRef={refs.setFloating}
style={floatingStyles} style={floatingStyles}
getReferenceProps={getFloatingProps} getReferenceProps={getFloatingProps}
onCancel={() => setEntryManagerProps(undefined)}
/> />
)} )}
</Fragment> </Fragment>

View File

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

View File

@ -36,6 +36,24 @@
overflow: hidden; 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 { .term-connectelem {
flex-grow: 1; flex-grow: 1;
min-height: 0; min-height: 0;

View File

@ -56,7 +56,6 @@ class TermViewModel {
manageConnection: jotai.Atom<boolean>; manageConnection: jotai.Atom<boolean>;
connStatus: jotai.Atom<ConnStatus>; connStatus: jotai.Atom<ConnStatus>;
termWshClient: TermWshClient; termWshClient: TermWshClient;
shellProcStatusRef: React.MutableRefObject<string>;
vdomBlockId: jotai.Atom<string>; vdomBlockId: jotai.Atom<string>;
vdomToolbarBlockId: jotai.Atom<string>; vdomToolbarBlockId: jotai.Atom<string>;
vdomToolbarTarget: jotai.PrimitiveAtom<VDomTargetToolbar>; vdomToolbarTarget: jotai.PrimitiveAtom<VDomTargetToolbar>;
@ -64,6 +63,11 @@ class TermViewModel {
termThemeNameAtom: jotai.Atom<string>; termThemeNameAtom: jotai.Atom<string>;
noPadding: jotai.PrimitiveAtom<boolean>; noPadding: jotai.PrimitiveAtom<boolean>;
endIconButtons: jotai.Atom<IconButtonDecl[]>; 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) { constructor(blockId: string, nodeModel: BlockNodeModel) {
this.viewType = "term"; this.viewType = "term";
@ -85,11 +89,15 @@ class TermViewModel {
const blockData = get(this.blockAtom); const blockData = get(this.blockAtom);
return blockData?.meta?.["term:mode"] ?? "term"; return blockData?.meta?.["term:mode"] ?? "term";
}); });
this.isRestarting = jotai.atom(false);
this.viewIcon = jotai.atom((get) => { this.viewIcon = jotai.atom((get) => {
const termMode = get(this.termMode); const termMode = get(this.termMode);
if (termMode == "vdom") { if (termMode == "vdom") {
return "bolt"; return "bolt";
} }
const isCmd = get(this.isCmdController);
if (isCmd) {
}
return "terminal"; return "terminal";
}); });
this.viewName = jotai.atom((get) => { this.viewName = jotai.atom((get) => {
@ -99,7 +107,7 @@ class TermViewModel {
return "Wave App"; return "Wave App";
} }
if (blockData?.meta?.controller == "cmd") { if (blockData?.meta?.controller == "cmd") {
return "Command"; return "";
} }
return "Terminal"; return "Terminal";
}); });
@ -116,28 +124,76 @@ class TermViewModel {
}, },
}, },
]; ];
} else { }
const vdomBlockId = get(this.vdomBlockId); const vdomBlockId = get(this.vdomBlockId);
const rtn = [];
if (vdomBlockId) { if (vdomBlockId) {
return [ rtn.push({
{
elemtype: "iconbutton", elemtype: "iconbutton",
icon: "bolt", icon: "bolt",
title: "Switch to Wave App", title: "Switch to Wave App",
click: () => { click: () => {
this.setTermMode("vdom"); 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) => { this.manageConnection = jotai.atom((get) => {
const termMode = get(this.termMode); const termMode = get(this.termMode);
if (termMode == "vdom") { if (termMode == "vdom") {
return false; return false;
} }
const isCmd = get(this.isCmdController);
if (isCmd) {
return false;
}
return true; return true;
}); });
this.termThemeNameAtom = useBlockAtom(blockId, "termthemeatom", () => { this.termThemeNameAtom = useBlockAtom(blockId, "termthemeatom", () => {
@ -175,17 +231,60 @@ class TermViewModel {
this.noPadding = jotai.atom(true); this.noPadding = jotai.atom(true);
this.endIconButtons = jotai.atom((get) => { this.endIconButtons = jotai.atom((get) => {
const blockData = get(this.blockAtom); 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 [];
} }
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", elemtype: "iconbutton",
icon: "refresh", icon: iconName,
click: this.forceRestartController.bind(this), 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 { getVDomModel(): VDomModel {
const vdomBlockId = globalStore.get(this.vdomBlockId); const vdomBlockId = globalStore.get(this.vdomBlockId);
if (!vdomBlockId) { if (!vdomBlockId) {
@ -225,6 +341,9 @@ class TermViewModel {
dispose() { dispose() {
DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId)); DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId));
if (this.shellProcStatusUnsubFn) {
this.shellProcStatusUnsubFn();
}
} }
giveFocus(): boolean { giveFocus(): boolean {
@ -284,11 +403,9 @@ class TermViewModel {
event.stopPropagation(); event.stopPropagation();
return false; return false;
} }
if (this.shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) { const shellProcStatus = globalStore.get(this.shellProcStatus);
// restart if ((shellProcStatus == "done" || shellProcStatus == "init") && keyutil.checkKeyPressed(waveEvent, "Enter")) {
const tabId = globalStore.get(atoms.staticTabId); this.forceRestartController();
const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: this.blockId });
prtn.catch((e) => console.log("error controller resync (enter)", this.blockId, e));
return false; return false;
} }
const globalKeys = getAllGlobalKeyBindings(); const globalKeys = getAllGlobalKeyBindings();
@ -308,6 +425,10 @@ class TermViewModel {
} }
forceRestartController() { forceRestartController() {
if (globalStore.get(this.isRestarting)) {
return;
}
this.triggerRestartAtom();
const termsize = { const termsize = {
rows: this.termRef.current?.terminal?.rows, rows: this.termRef.current?.terminal?.rows,
cols: this.termRef.current?.terminal?.cols, cols: this.termRef.current?.terminal?.cols,
@ -387,6 +508,62 @@ class TermViewModel {
label: "Force Restart Controller", label: "Force Restart Controller",
click: this.forceRestartController.bind(this), 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"]) { if (blockData?.meta?.["term:vdomtoolbarblockid"]) {
fullMenu.push({ type: "separator" }); fullMenu.push({ type: "separator" });
fullMenu.push({ fullMenu.push({
@ -538,8 +715,6 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
const connectElemRef = React.useRef<HTMLDivElement>(null); const connectElemRef = React.useRef<HTMLDivElement>(null);
const termRef = React.useRef<TermWrap>(null); const termRef = React.useRef<TermWrap>(null);
model.termRef = termRef; model.termRef = termRef;
const spstatusRef = React.useRef<string>(null);
model.shellProcStatusRef = spstatusRef;
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId)); const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const termSettingsAtom = useSettingsPrefixAtom("term"); const termSettingsAtom = useSettingsPrefixAtom("term");
const termSettings = jotai.useAtomValue(termSettingsAtom); const termSettings = jotai.useAtomValue(termSettingsAtom);
@ -587,6 +762,12 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
useWebGl: !termSettings?.["term:disablewebgl"], 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; (window as any).term = termWrap;
termRef.current = termWrap; termRef.current = termWrap;
const rszObs = new ResizeObserver(() => { const rszObs = new ResizeObserver(() => {
@ -613,34 +794,6 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
termModeRef.current = termMode; termModeRef.current = termMode;
}, [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 = { let stickerConfig = {
charWidth: 8, charWidth: 8,
charHeight: 16, charHeight: 16,

View File

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

View File

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

View File

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

View File

@ -87,11 +87,14 @@ async function initWaveWrap(initOpts: WaveInitOpts) {
async function reinitWave() { async function reinitWave() {
console.log("Reinit Wave"); console.log("Reinit Wave");
getApi().sendLog("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(() => { requestAnimationFrame(() => {
setTimeout(() => { setTimeout(() => {
document.body.classList.remove("nohover"); document.body.classList.remove("nohover");
}, 50); }, 100);
}); });
const client = await WOS.reloadWaveObject<Client>(WOS.makeORef("client", savedInitOpts.clientId)); const client = await WOS.reloadWaveObject<Client>(WOS.makeORef("client", savedInitOpts.clientId));
const waveWindow = await WOS.reloadWaveObject<WaveWindow>(WOS.makeORef("window", savedInitOpts.windowId)); const waveWindow = await WOS.reloadWaveObject<WaveWindow>(WOS.makeORef("window", savedInitOpts.windowId));
const ws = await WOS.reloadWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid)); const ws = await WOS.reloadWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));

View File

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

View File

@ -20,11 +20,14 @@ import (
"github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote"
"github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller"
"github.com/wavetermdev/waveterm/pkg/shellexec" "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/wavebase"
"github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wconfig"
"github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
"github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wshutil"
"github.com/wavetermdev/waveterm/pkg/wsl" "github.com/wavetermdev/waveterm/pkg/wsl"
"github.com/wavetermdev/waveterm/pkg/wstore" "github.com/wavetermdev/waveterm/pkg/wstore"
@ -37,12 +40,14 @@ const (
const ( const (
BlockFile_Term = "term" // used for main pty output BlockFile_Term = "term" // used for main pty output
BlockFile_Cache = "cache:term:full" // for cached block
BlockFile_VDom = "vdom" // used for alt html layout BlockFile_VDom = "vdom" // used for alt html layout
) )
const ( const (
Status_Running = "running" Status_Running = "running"
Status_Done = "done" Status_Done = "done"
Status_Init = "init"
) )
const ( const (
@ -71,12 +76,14 @@ type BlockController struct {
ShellProc *shellexec.ShellProc ShellProc *shellexec.ShellProc
ShellInputCh chan *BlockInputUnion ShellInputCh chan *BlockInputUnion
ShellProcStatus string ShellProcStatus string
ShellProcExitCode int
} }
type BlockControllerRuntimeStatus struct { type BlockControllerRuntimeStatus struct {
BlockId string `json:"blockid"` BlockId string `json:"blockid"`
ShellProcStatus string `json:"shellprocstatus,omitempty"` ShellProcStatus string `json:"shellprocstatus,omitempty"`
ShellProcConnName string `json:"shellprocconnname,omitempty"` ShellProcConnName string `json:"shellprocconnname,omitempty"`
ShellProcExitCode int `json:"shellprocexitcode"`
} }
func (bc *BlockController) WithLock(f func()) { func (bc *BlockController) WithLock(f func()) {
@ -93,6 +100,7 @@ func (bc *BlockController) GetRuntimeStatus() *BlockControllerRuntimeStatus {
if bc.ShellProc != nil { if bc.ShellProc != nil {
rtn.ShellProcConnName = bc.ShellProc.ConnName rtn.ShellProcConnName = bc.ShellProc.ConnName
} }
rtn.ShellProcExitCode = bc.ShellProcExitCode
}) })
return &rtn 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) ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn() defer cancelFn()
err := filestore.WFS.WriteFile(ctx, blockId, blockFile, nil) err := filestore.WFS.WriteFile(ctx, blockId, BlockFile_Term, nil)
if err == fs.ErrNotExist { if err == fs.ErrNotExist {
return nil return nil
} }
if err != nil { if err != nil {
return fmt.Errorf("error truncating blockfile: %w", err) 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{ wps.Broker.Publish(wps.WaveEvent{
Event: wps.Event_BlockFile, Event: wps.Event_BlockFile,
Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, blockId).String()}, Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, blockId).String()},
Data: &wps.WSFileEventData{ Data: &wps.WSFileEventData{
ZoneId: blockId, ZoneId: blockId,
FileName: blockFile, FileName: BlockFile_Term,
FileOp: wps.FileOp_Truncate, FileOp: wps.FileOp_Truncate,
}, },
}) })
@ -174,16 +189,8 @@ func HandleAppendBlockFile(blockId string, blockFile string, data []byte) error
func (bc *BlockController) resetTerminalState() { func (bc *BlockController) resetTerminalState() {
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn() defer cancelFn()
var shouldTruncate bool wfile, statErr := filestore.WFS.Stat(ctx, bc.BlockId, BlockFile_Term)
blockData, getBlockDataErr := wstore.DBMustGet[*waveobj.Block](ctx, bc.BlockId) if statErr == fs.ErrNotExist || wfile.Size == 0 {
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)
}
return return
} }
// controller type = "shell" // 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 { func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj.MetaMapType) error {
// create a circular blockfile for the output // create a circular blockfile for the output
ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) 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) // TODO better sync here (don't let two starts happen at the same times)
remoteName := blockMeta.GetString(waveobj.MetaKey_Connection, "") remoteName := blockMeta.GetString(waveobj.MetaKey_Connection, "")
var cmdStr string var cmdStr string
cmdOpts := shellexec.CommandOptsType{ var cmdOpts shellexec.CommandOptsType
Env: make(map[string]string),
}
if bc.ControllerType == BlockController_Shell { if bc.ControllerType == BlockController_Shell {
cmdOpts.Env = make(map[string]string)
cmdOpts.Interactive = true cmdOpts.Interactive = true
cmdOpts.Login = true cmdOpts.Login = true
cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "") cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "")
@ -235,32 +301,12 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
cmdOpts.Cwd = cwdPath cmdOpts.Cwd = cwdPath
} }
} else if bc.ControllerType == BlockController_Cmd { } else if bc.ControllerType == BlockController_Cmd {
cmdStr = blockMeta.GetString(waveobj.MetaKey_Cmd, "") var cmdOptsPtr *shellexec.CommandOptsType
if cmdStr == "" { cmdStr, cmdOptsPtr, err = createCmdStrAndOpts(bc.BlockId, blockMeta)
return 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 { if err != nil {
return err return err
} }
cmdOpts.Cwd = cwdPath cmdOpts = *cmdOptsPtr
}
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)
}
}
} else { } else {
return fmt.Errorf("unknown controller type %q", bc.ControllerType) 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 := wshutil.MakeRpcProxy()
wshProxy.SetRpcContext(&wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId}) wshProxy.SetRpcContext(&wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId})
wshutil.DefaultRouter.RegisterRoute(wshutil.MakeControllerRouteId(bc.BlockId), wshProxy, true) 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() { go func() {
// handles regular output from the pty (goes to the blockfile and xterm) // handles regular output from the pty (goes to the blockfile and xterm)
defer panichandler.PanicHandler("blockcontroller:shellproc-pty-read-loop") defer panichandler.PanicHandler("blockcontroller:shellproc-pty-read-loop")
defer func() { defer func() {
log.Printf("[shellproc] pty-read loop done\n") log.Printf("[shellproc] pty-read loop done\n")
bc.ShellProc.Close() shellProc.Close()
bc.WithLock(func() { bc.WithLock(func() {
// so no other events are sent // so no other events are sent
bc.ShellInputCh = nil 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 // to stop the inputCh loop
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
close(shellInputCh) // don't use bc.ShellInputCh (it's nil) 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") defer panichandler.PanicHandler("blockcontroller:shellproc-input-loop")
for ic := range shellInputCh { for ic := range shellInputCh {
if len(ic.InputData) > 0 { if len(ic.InputData) > 0 {
bc.ShellProc.Cmd.Write(ic.InputData) shellProc.Cmd.Write(ic.InputData)
} }
if ic.TermSize != nil { if ic.TermSize != nil {
err = setTermSize(ctx, bc.BlockId, *ic.TermSize) err = setTermSize(ctx, bc.BlockId, *ic.TermSize)
if err != nil { if err != nil {
log.Printf("error setting pty size: %v\n", err) 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 { if err != nil {
log.Printf("error setting pty size: %v\n", err) log.Printf("error setting pty size: %v\n", err)
} }
@ -419,24 +469,49 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
go func() { go func() {
defer panichandler.PanicHandler("blockcontroller:shellproc-wait-loop") defer panichandler.PanicHandler("blockcontroller:shellproc-wait-loop")
// wait for the shell to finish // wait for the shell to finish
var exitCode int
defer func() { defer func() {
wshutil.DefaultRouter.UnregisterRoute(wshutil.MakeControllerRouteId(bc.BlockId)) wshutil.DefaultRouter.UnregisterRoute(wshutil.MakeControllerRouteId(bc.BlockId))
bc.UpdateControllerAndSendUpdate(func() bool { bc.UpdateControllerAndSendUpdate(func() bool {
bc.ShellProcStatus = Status_Done bc.ShellProcStatus = Status_Done
bc.ShellProcExitCode = exitCode
return true return true
}) })
log.Printf("[shellproc] shell process wait loop done\n") log.Printf("[shellproc] shell process wait loop done\n")
}() }()
waitErr := shellProc.Cmd.Wait() waitErr := shellProc.Cmd.Wait()
exitCode := shellexec.ExitCodeFromWaitErr(waitErr) 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("\r\n"))
HandleAppendBlockFile(bc.BlockId, BlockFile_Term, []byte(termMsg))
shellProc.SetWaitErrorAndSignalDone(waitErr) shellProc.SetWaitErrorAndSignalDone(waitErr)
go checkCloseOnExit(bc.BlockId, exitCode)
}() }()
return nil 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 { func getBoolFromMeta(meta map[string]any, key string, def bool) bool {
ival, found := meta[key] ival, found := meta[key]
if !found || ival == nil { if !found || ival == nil {
@ -474,20 +549,35 @@ func setTermSize(ctx context.Context, blockId string, termSize waveobj.TermSize)
return nil 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, "") controllerName := bdata.Meta.GetString(waveobj.MetaKey_Controller, "")
if controllerName != BlockController_Shell && controllerName != BlockController_Cmd { if controllerName != BlockController_Shell && controllerName != BlockController_Cmd {
log.Printf("unknown controller %q\n", controllerName) log.Printf("unknown controller %q\n", controllerName)
return 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) { if getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdClearOnStart, false) {
err := HandleTruncateBlockFile(bc.BlockId, BlockFile_Term) err := HandleTruncateBlockFile(bc.BlockId)
if err != nil { if err != nil {
log.Printf("error truncating term blockfile: %v\n", err) log.Printf("error truncating term blockfile: %v\n", err)
} }
} }
runOnStart := getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdRunOnStart, true) if runOnce {
if runOnStart { 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() { go func() {
defer panichandler.PanicHandler("blockcontroller:run-shell-command") defer panichandler.PanicHandler("blockcontroller:run-shell-command")
var termSize waveobj.TermSize var termSize waveobj.TermSize
@ -579,7 +669,7 @@ func getOrCreateBlockController(tabId string, blockId string, controllerName str
ControllerType: controllerName, ControllerType: controllerName,
TabId: tabId, TabId: tabId,
BlockId: blockId, BlockId: blockId,
ShellProcStatus: Status_Done, ShellProcStatus: Status_Init,
} }
blockControllerMap[blockId] = bc blockControllerMap[blockId] = bc
createdController = true createdController = true
@ -587,7 +677,7 @@ func getOrCreateBlockController(tabId string, blockId string, controllerName str
return bc 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 == "" { if tabId == "" || blockId == "" {
return fmt.Errorf("invalid tabId or blockId passed to ResyncController") 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 { if err != nil {
return fmt.Errorf("error getting block: %w", err) return fmt.Errorf("error getting block: %w", err)
} }
if force {
StopBlockController(blockId)
}
connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "") connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "")
controllerName := blockData.Meta.GetString(waveobj.MetaKey_Controller, "") controllerName := blockData.Meta.GetString(waveobj.MetaKey_Controller, "")
curBc := GetBlockController(blockId) curBc := GetBlockController(blockId)
@ -619,16 +712,16 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts
} }
} }
if curBc == nil { if curBc == nil {
return startBlockController(ctx, tabId, blockId, rtOpts) return startBlockController(ctx, tabId, blockId, rtOpts, force)
} }
bcStatus := curBc.GetRuntimeStatus() bcStatus := curBc.GetRuntimeStatus()
if bcStatus.ShellProcStatus != Status_Running { if bcStatus.ShellProcStatus == Status_Init || bcStatus.ShellProcStatus == Status_Done {
return startBlockController(ctx, tabId, blockId, rtOpts) return startBlockController(ctx, tabId, blockId, rtOpts, force)
} }
return nil 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) blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)
if err != nil { if err != nil {
return fmt.Errorf("error getting block: %w", err) 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) bc := getOrCreateBlockController(tabId, blockId, controllerName)
bcStatus := bc.GetRuntimeStatus() bcStatus := bc.GetRuntimeStatus()
if bcStatus.ShellProcStatus == Status_Done { if bcStatus.ShellProcStatus == Status_Init || bcStatus.ShellProcStatus == Status_Done {
go bc.run(blockData, blockData.Meta, rtOpts) go bc.run(blockData, blockData.Meta, rtOpts, force)
} }
return nil 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) return nil, fmt.Errorf("error getting workspace: %w", err)
} }
if len(ws.TabIds) == 0 { if len(ws.TabIds) == 0 {
_, err = wcore.CreateTab(ctx, ws.OID, "", true) _, err = wcore.CreateTab(ctx, ws.OID, "", true, false)
if err != nil { if err != nil {
return window, fmt.Errorf("error creating tab: %w", err) return window, fmt.Errorf("error creating tab: %w", err)
} }

View File

@ -3,6 +3,7 @@ package workspaceservice
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"time" "time"
"github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/blockcontroller"
@ -68,16 +69,16 @@ func (svg *WorkspaceService) ListWorkspaces() (waveobj.WorkspaceList, error) {
func (svc *WorkspaceService) CreateTab_Meta() tsgenmeta.MethodMeta { func (svc *WorkspaceService) CreateTab_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{ return tsgenmeta.MethodMeta{
ArgNames: []string{"workspaceId", "tabName", "activateTab"}, ArgNames: []string{"workspaceId", "tabName", "activateTab", "pinned"},
ReturnDesc: "tabId", 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) ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn() defer cancelFn()
ctx = waveobj.ContextWithUpdates(ctx) ctx = waveobj.ContextWithUpdates(ctx)
tabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab) tabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab, pinned)
if err != nil { if err != nil {
return "", nil, fmt.Errorf("error creating tab: %w", err) 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 return tabId, updates, nil
} }
func (svc *WorkspaceService) UpdateTabIds_Meta() tsgenmeta.MethodMeta { func (svc *WorkspaceService) ChangeTabPinning_Meta() tsgenmeta.MethodMeta {
return 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) ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn() defer cancelFn()
ctx = waveobj.ContextWithUpdates(ctx) ctx = waveobj.ContextWithUpdates(ctx)
err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds) err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds, pinnedTabIds)
if err != nil { if err != nil {
return nil, fmt.Errorf("error updating workspace tab ids: %w", err) return nil, fmt.Errorf("error updating workspace tab ids: %w", err)
} }

View File

@ -5,6 +5,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"runtime" "runtime"
"sync"
"syscall" "syscall"
"time" "time"
@ -19,6 +20,7 @@ type ConnInterface interface {
KillGraceful(time.Duration) KillGraceful(time.Duration)
Wait() error Wait() error
Start() error Start() error
ExitCode() int
StdinPipe() (io.WriteCloser, error) StdinPipe() (io.WriteCloser, error)
StdoutPipe() (io.ReadCloser, error) StdoutPipe() (io.ReadCloser, error)
StderrPipe() (io.ReadCloser, error) StderrPipe() (io.ReadCloser, error)
@ -28,15 +30,37 @@ type ConnInterface interface {
type CmdWrap struct { type CmdWrap struct {
Cmd *exec.Cmd Cmd *exec.Cmd
WaitOnce *sync.Once
WaitErr error
pty.Pty pty.Pty
} }
func MakeCmdWrap(cmd *exec.Cmd, cmdPty pty.Pty) CmdWrap {
return CmdWrap{
Cmd: cmd,
WaitOnce: &sync.Once{},
Pty: cmdPty,
}
}
func (cw CmdWrap) Kill() { func (cw CmdWrap) Kill() {
cw.Cmd.Process.Kill() cw.Cmd.Process.Kill()
} }
func (cw CmdWrap) Wait() error { 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) { func (cw CmdWrap) KillGraceful(timeout time.Duration) {
@ -95,9 +119,21 @@ type SessionWrap struct {
Session *ssh.Session Session *ssh.Session
StartCmd string StartCmd string
Tty pty.Tty Tty pty.Tty
WaitOnce *sync.Once
WaitErr error
pty.Pty 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() { func (sw SessionWrap) Kill() {
sw.Tty.Close() sw.Tty.Close()
sw.Session.Close() sw.Session.Close()
@ -107,8 +143,19 @@ func (sw SessionWrap) KillGraceful(timeout time.Duration) {
sw.Kill() sw.Kill()
} }
func (sw SessionWrap) ExitCode() int {
waitErr := sw.WaitErr
if waitErr == nil {
return -1
}
return ExitCodeFromWaitErr(waitErr)
}
func (sw SessionWrap) Wait() error { 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 { func (sw SessionWrap) Start() error {

View File

@ -232,7 +232,8 @@ func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr st
if err != nil { if err != nil {
return nil, err 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) { 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.Stderr = remoteStdoutWrite
session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil) session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil)
sessionWrap := SessionWrap{session, "", pipePty, pipePty} sessionWrap := MakeSessionWrap(session, "", pipePty)
err = session.Shell() err = session.Shell()
if err != nil { if err != nil {
pipePty.Close() pipePty.Close()
@ -381,8 +382,7 @@ func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts Comm
} }
session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil) session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil)
sessionWrap := MakeSessionWrap(session, cmdCombined, pipePty)
sessionWrap := SessionWrap{session, cmdCombined, pipePty, pipePty}
err = sessionWrap.Start() err = sessionWrap.Start()
if err != nil { if err != nil {
pipePty.Close() pipePty.Close()
@ -469,7 +469,8 @@ func StartShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOpt
if err != nil { if err != nil {
return nil, err 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) { func RunSimpleCmdInPty(ecmd *exec.Cmd, termSize waveobj.TermSize) ([]byte, error) {

View File

@ -80,32 +80,24 @@ func GetBool(v interface{}, field string) bool {
var needsQuoteRe = regexp.MustCompile(`[^\w@%:,./=+-]`) 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 { func ShellQuote(val string, forceQuote bool, maxLen int) string {
if maxLen < 6 { if maxLen != -1 && maxLen < 6 {
maxLen = 6 maxLen = 6
} }
rtn := val rtn := val
if needsQuoteRe.MatchString(val) { if needsQuoteRe.MatchString(val) {
rtn = "'" + strings.ReplaceAll(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 strings.HasPrefix(rtn, "\"") || strings.HasPrefix(rtn, "'") {
if len(rtn) > maxLen { return rtn[0:maxLen-4] + "..." + rtn[len(rtn)-1:]
return rtn[0:maxLen-4] + "..." + rtn[0:1]
} }
return rtn
}
if forceQuote {
if len(rtn) > maxLen-2 {
return "\"" + rtn[0:maxLen-5] + "...\""
}
return "\"" + rtn + "\""
} else {
if len(rtn) > maxLen {
return rtn[0:maxLen-3] + "..." return rtn[0:maxLen-3] + "..."
}
return rtn
}
} }
func EllipsisStr(s string, maxLen int) string { func EllipsisStr(s string, maxLen int) string {

View File

@ -43,10 +43,15 @@ const (
MetaKey_CmdLogin = "cmd:login" MetaKey_CmdLogin = "cmd:login"
MetaKey_CmdRunOnStart = "cmd:runonstart" MetaKey_CmdRunOnStart = "cmd:runonstart"
MetaKey_CmdClearOnStart = "cmd:clearonstart" 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_CmdEnv = "cmd:env"
MetaKey_CmdCwd = "cmd:cwd" MetaKey_CmdCwd = "cmd:cwd"
MetaKey_CmdNoWsh = "cmd:nowsh" MetaKey_CmdNoWsh = "cmd:nowsh"
MetaKey_CmdArgs = "cmd:args"
MetaKey_CmdShell = "cmd:shell"
MetaKey_AiClear = "ai:*" MetaKey_AiClear = "ai:*"
MetaKey_AiPresetKey = "ai:preset" MetaKey_AiPresetKey = "ai:preset"
@ -61,6 +66,8 @@ const (
MetaKey_AiTimeoutMs = "ai:timeoutms" MetaKey_AiTimeoutMs = "ai:timeoutms"
MetaKey_EditorClear = "editor:*" MetaKey_EditorClear = "editor:*"
MetaKey_EditorMinimapEnabled = "editor:minimapenabled"
MetaKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled"
MetaKey_EditorWordWrap = "editor:wordwrap" MetaKey_EditorWordWrap = "editor:wordwrap"
MetaKey_GraphClear = "graph:*" MetaKey_GraphClear = "graph:*"

View File

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

View File

@ -42,10 +42,15 @@ type MetaTSType struct {
CmdLogin bool `json:"cmd:login,omitempty"` CmdLogin bool `json:"cmd:login,omitempty"`
CmdRunOnStart bool `json:"cmd:runonstart,omitempty"` CmdRunOnStart bool `json:"cmd:runonstart,omitempty"`
CmdClearOnStart bool `json:"cmd:clearonstart,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"` CmdEnv map[string]string `json:"cmd:env,omitempty"`
CmdCwd string `json:"cmd:cwd,omitempty"` CmdCwd string `json:"cmd:cwd,omitempty"`
CmdNoWsh bool `json:"cmd:nowsh,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 // AI options match settings
AiClear bool `json:"ai:*,omitempty"` AiClear bool `json:"ai:*,omitempty"`
@ -61,6 +66,8 @@ type MetaTSType struct {
AiTimeoutMs float64 `json:"ai:timeoutms,omitempty"` AiTimeoutMs float64 `json:"ai:timeoutms,omitempty"`
EditorClear bool `json:"editor:*,omitempty"` EditorClear bool `json:"editor:*,omitempty"`
EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"`
EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"`
EditorWordWrap bool `json:"editor:wordwrap,omitempty"` EditorWordWrap bool `json:"editor:wordwrap,omitempty"`
GraphClear bool `json:"graph:*,omitempty"` GraphClear bool `json:"graph:*,omitempty"`

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/blockcontroller"
"github.com/wavetermdev/waveterm/pkg/filestore"
"github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/panichandler"
"github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry"
"github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/util/utilfn"
@ -41,7 +42,6 @@ func createSubBlockObj(ctx context.Context, parentBlockId string, blockDef *wave
blockData := &waveobj.Block{ blockData := &waveobj.Block{
OID: blockId, OID: blockId,
ParentORef: waveobj.MakeORef(waveobj.OType_Block, parentBlockId).String(), ParentORef: waveobj.MakeORef(waveobj.OType_Block, parentBlockId).String(),
BlockDef: blockDef,
RuntimeOpts: nil, RuntimeOpts: nil,
Meta: blockDef.Meta, 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 { if blockDef == nil {
return nil, fmt.Errorf("blockDef is 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 { if err != nil {
return nil, fmt.Errorf("error creating block: %w", err) 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() { go func() {
defer panichandler.PanicHandler("CreateBlock:telemetry") defer panichandler.PanicHandler("CreateBlock:telemetry")
blockView := blockDef.Meta.GetString(waveobj.MetaKey_View, "") blockView := blockDef.Meta.GetString(waveobj.MetaKey_View, "")
@ -88,7 +115,6 @@ func createBlockObj(ctx context.Context, tabId string, blockDef *waveobj.BlockDe
blockData := &waveobj.Block{ blockData := &waveobj.Block{
OID: blockId, OID: blockId,
ParentORef: waveobj.MakeORef(waveobj.OType_Tab, tabId).String(), ParentORef: waveobj.MakeORef(waveobj.OType_Tab, tabId).String(),
BlockDef: blockDef,
RuntimeOpts: rtOpts, RuntimeOpts: rtOpts,
Meta: blockDef.Meta, Meta: blockDef.Meta,
} }

View File

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

View File

@ -176,7 +176,7 @@ func CheckAndFixWindow(ctx context.Context, windowId string) *waveobj.Window {
} }
if len(ws.TabIds) == 0 { if len(ws.TabIds) == 0 {
log.Printf("fixing workspace with no tabs %q (in checkAndFixWindow)\n", ws.OID) 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 { if err != nil {
log.Printf("error creating tab (in checkAndFixWindow): %v\n", err) 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{ ws := &waveobj.Workspace{
OID: uuid.NewString(), OID: uuid.NewString(),
TabIds: []string{}, TabIds: []string{},
PinnedTabIds: []string{},
Name: name, Name: name,
Icon: icon, Icon: icon,
Color: color, Color: color,
@ -37,11 +38,13 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool,
if err != nil { if err != nil {
return false, fmt.Errorf("error getting workspace: %w", err) 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) log.Printf("Ignoring DeleteWorkspace for workspace %s as it is named\n", workspaceId)
return false, nil 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) log.Printf("deleting tab %s\n", tabId)
_, err := DeleteTab(ctx, workspaceId, tabId, false) _, err := DeleteTab(ctx, workspaceId, tabId, false)
if err != nil { if err != nil {
@ -60,7 +63,30 @@ func GetWorkspace(ctx context.Context, wsID string) (*waveobj.Workspace, error)
return wstore.DBMustGet[*waveobj.Workspace](ctx, wsID) 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) ws, err := GetWorkspace(ctx, workspaceId)
if err != nil { if err != nil {
return nil, fmt.Errorf("workspace %s not found: %w", workspaceId, err) 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{ layoutState := &waveobj.LayoutState{
OID: layoutStateId, OID: layoutStateId,
} }
if pinned {
ws.PinnedTabIds = append(ws.PinnedTabIds, tab.OID)
} else {
ws.TabIds = append(ws.TabIds, tab.OID) ws.TabIds = append(ws.TabIds, tab.OID)
}
wstore.DBInsert(ctx, tab) wstore.DBInsert(ctx, tab)
wstore.DBInsert(ctx, layoutState) wstore.DBInsert(ctx, layoutState)
wstore.DBUpdate(ctx, ws) wstore.DBUpdate(ctx, ws)
return tab, nil 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. // Must delete all blocks individually first.
// Also deletes LayoutState. // Also deletes LayoutState.
// recursive: if true, will recursively close parent window, workspace, if they are empty. // 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 { if ws == nil {
return "", fmt.Errorf("workspace not found: %q", workspaceId) 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) tab, _ := wstore.DBGet[*waveobj.Tab](ctx, tabId)
if tab == nil { if tab == nil {
return "", fmt.Errorf("tab not found: %q", tabId) return "", fmt.Errorf("tab not found: %q", tabId)
} }
// close blocks (sends events + stops block controllers)
for _, blockId := range tab.BlockIds { for _, blockId := range tab.BlockIds {
err := DeleteBlock(ctx, blockId, false) err := DeleteBlock(ctx, blockId, false)
if err != nil { if err != nil {
return "", fmt.Errorf("error deleting block %s: %w", blockId, err) return "", fmt.Errorf("error deleting block %s: %w", blockId, err)
} }
} }
tabIdx := utilfn.FindStringInSlice(ws.TabIds, tabId)
if tabIdx == -1 { // if the tab is active, determine new active tab
return "", nil
}
ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...)
newActiveTabId := ws.ActiveTabId newActiveTabId := ws.ActiveTabId
if len(ws.TabIds) > 0 {
if ws.ActiveTabId == tabId { if ws.ActiveTabId == tabId {
if len(ws.TabIds) > 0 && tabIdx != -1 {
newActiveTabId = ws.TabIds[max(0, min(tabIdx-1, len(ws.TabIds)-1))] newActiveTabId = ws.TabIds[max(0, min(tabIdx-1, len(ws.TabIds)-1))]
} } else if len(ws.PinnedTabIds) > 0 {
newActiveTabId = ws.PinnedTabIds[0]
} else { } else {
newActiveTabId = "" newActiveTabId = ""
} }
}
ws.ActiveTabId = newActiveTabId ws.ActiveTabId = newActiveTabId
wstore.DBUpdate(ctx, ws) wstore.DBUpdate(ctx, ws)
wstore.DBDelete(ctx, waveobj.OType_Tab, tabId) wstore.DBDelete(ctx, waveobj.OType_Tab, tabId)
wstore.DBDelete(ctx, waveobj.OType_LayoutState, tab.LayoutState) wstore.DBDelete(ctx, waveobj.OType_LayoutState, tab.LayoutState)
// if no tabs remaining, close window
if newActiveTabId == "" && recursive { if newActiveTabId == "" && recursive {
log.Printf("no tabs remaining in workspace %s, closing window\n", workspaceId)
windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId) windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId)
if err != nil { if err != nil {
return newActiveTabId, fmt.Errorf("unable to find window for workspace id %v: %w", workspaceId, err) 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 { func SetActiveTab(ctx context.Context, workspaceId string, tabId string) error {
if tabId != "" { if tabId != "" && workspaceId != "" {
workspace, err := GetWorkspace(ctx, workspaceId) workspace, err := GetWorkspace(ctx, workspaceId)
if err != nil { if err != nil {
return fmt.Errorf("workspace %s not found: %w", workspaceId, err) 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 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) { func SendActiveTabUpdate(ctx context.Context, workspaceId string, newActiveTabId string) {
eventbus.SendEventToElectron(eventbus.WSEventType{ eventbus.SendEventToElectron(eventbus.WSEventType{
EventType: eventbus.WSEvent_ElectronUpdateActiveTab, 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) ws, _ := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId)
if ws == nil { if ws == nil {
return fmt.Errorf("workspace not found: %q", workspaceId) return fmt.Errorf("workspace not found: %q", workspaceId)
} }
ws.TabIds = tabIds ws.TabIds = tabIds
ws.PinnedTabIds = pinnedTabIds
wstore.DBUpdate(ctx, ws) wstore.DBUpdate(ctx, ws)
return nil return nil
} }

View File

@ -532,6 +532,7 @@ type BlockInfoData struct {
TabId string `json:"tabid"` TabId string `json:"tabid"`
WorkspaceId string `json:"workspaceid"` WorkspaceId string `json:"workspaceid"`
Block *waveobj.Block `json:"block"` Block *waveobj.Block `json:"block"`
Files []*filestore.WaveFile `json:"files"`
} }
type WaveNotificationOptions struct { 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 { func (ws *WshServer) ControllerResyncCommand(ctx context.Context, data wshrpc.CommandControllerResyncData) error {
if data.ForceRestart { return blockcontroller.ResyncController(ctx, data.TabId, data.BlockId, data.RtOpts, data.ForceRestart)
blockcontroller.StopBlockController(data.BlockId)
}
return blockcontroller.ResyncController(ctx, data.TabId, data.BlockId, data.RtOpts)
} }
func (ws *WshServer) ControllerInputCommand(ctx context.Context, data wshrpc.CommandBlockInputData) error { 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 { if err != nil {
return nil, fmt.Errorf("error finding window for tab: %w", err) 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{ return &wshrpc.BlockInfoData{
BlockId: blockId, BlockId: blockId,
TabId: tabId, TabId: tabId,
WorkspaceId: workspaceId, WorkspaceId: workspaceId,
Block: blockData, Block: blockData,
Files: fileList,
}, nil }, nil
} }

View File

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

View File

@ -74,6 +74,13 @@ func (c *WslCmd) Wait() (err error) {
} }
return c.waitErr 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 { func (c *WslCmd) GetProcess() *os.Process {
return c.c.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) { func DBFindWorkspaceForTabId(ctx context.Context, tabId string) (string, error) {
log.Printf("DBFindWorkspaceForTabId tabId: %s\n", tabId)
return WithTxRtn(ctx, func(tx *TxWrap) (string, error) { return WithTxRtn(ctx, func(tx *TxWrap) (string, error) {
query := ` query := `
WITH variable(value) AS (
SELECT ?
)
SELECT w.oid SELECT w.oid
FROM db_workspace w, json_each(data->'tabids') je FROM db_workspace w, variable
WHERE je.value = ?` WHERE EXISTS (
return tx.GetString(query, tabId), nil 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