mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
merge main
This commit is contained in:
commit
7782d6e59f
@ -4,7 +4,7 @@ root = true
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{js,jsx,ts,tsx,cjs,json,yml,yaml,css,less}]
|
||||
[*.{js,jsx,ts,tsx,cjs,json,yml,yaml,css,less,scss}]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
@ -19,7 +19,6 @@ import (
|
||||
var aiCmd = &cobra.Command{
|
||||
Use: "ai [-] [message...]",
|
||||
Short: "Send a message to an AI block",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: aiRun,
|
||||
PreRunE: preRunSetupRpcClient,
|
||||
DisableFlagsInUseLine: true,
|
||||
@ -53,6 +52,11 @@ func aiRun(cmd *cobra.Command, args []string) (rtnErr error) {
|
||||
sendActivity("ai", rtnErr == nil)
|
||||
}()
|
||||
|
||||
if len(args) == 0 {
|
||||
OutputHelpMessage(cmd)
|
||||
return fmt.Errorf("no message provided")
|
||||
}
|
||||
|
||||
var stdinUsed bool
|
||||
var message strings.Builder
|
||||
|
||||
|
@ -21,13 +21,12 @@ var editMagnified bool
|
||||
var editorCmd = &cobra.Command{
|
||||
Use: "editor",
|
||||
Short: "edit a file (blocks until editor is closed)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: editorRun,
|
||||
PreRunE: preRunSetupRpcClient,
|
||||
}
|
||||
|
||||
func init() {
|
||||
editCmd.Flags().BoolVarP(&editMagnified, "magnified", "m", false, "open view in magnified mode")
|
||||
editorCmd.Flags().BoolVarP(&editMagnified, "magnified", "m", false, "open view in magnified mode")
|
||||
rootCmd.AddCommand(editorCmd)
|
||||
}
|
||||
|
||||
@ -35,7 +34,14 @@ func editorRun(cmd *cobra.Command, args []string) (rtnErr error) {
|
||||
defer func() {
|
||||
sendActivity("editor", rtnErr == nil)
|
||||
}()
|
||||
|
||||
if len(args) == 0 {
|
||||
OutputHelpMessage(cmd)
|
||||
return fmt.Errorf("no arguments. wsh editor requires a file or URL as an argument argument")
|
||||
}
|
||||
if len(args) > 1 {
|
||||
OutputHelpMessage(cmd)
|
||||
return fmt.Errorf("too many arguments. wsh editor requires exactly one argument")
|
||||
}
|
||||
fileArg := args[0]
|
||||
absFile, err := filepath.Abs(fileArg)
|
||||
if err != nil {
|
||||
|
@ -82,6 +82,7 @@ func getVarRun(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Single variable case - existing logic
|
||||
if len(args) != 1 {
|
||||
OutputHelpMessage(cmd)
|
||||
return fmt.Errorf("requires a key argument")
|
||||
}
|
||||
|
||||
|
@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
@ -27,26 +26,58 @@ var (
|
||||
)
|
||||
|
||||
var WrappedStdin io.Reader = os.Stdin
|
||||
var WrappedStdout io.Writer = &WrappedWriter{dest: os.Stdout}
|
||||
var WrappedStderr io.Writer = &WrappedWriter{dest: os.Stderr}
|
||||
var RpcClient *wshutil.WshRpc
|
||||
var RpcContext wshrpc.RpcContext
|
||||
var UsingTermWshMode bool
|
||||
var blockArg string
|
||||
var WshExitCode int
|
||||
|
||||
func WriteStderr(fmtStr string, args ...interface{}) {
|
||||
output := fmt.Sprintf(fmtStr, args...)
|
||||
if UsingTermWshMode {
|
||||
output = strings.ReplaceAll(output, "\n", "\r\n")
|
||||
type WrappedWriter struct {
|
||||
dest io.Writer
|
||||
}
|
||||
fmt.Fprint(os.Stderr, output)
|
||||
|
||||
func (w *WrappedWriter) Write(p []byte) (n int, err error) {
|
||||
if !UsingTermWshMode {
|
||||
return w.dest.Write(p)
|
||||
}
|
||||
count := 0
|
||||
for _, b := range p {
|
||||
if b == '\n' {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return w.dest.Write(p)
|
||||
}
|
||||
buf := make([]byte, len(p)+count) // Each '\n' adds one extra byte for '\r'
|
||||
writeIdx := 0
|
||||
for _, b := range p {
|
||||
if b == '\n' {
|
||||
buf[writeIdx] = '\r'
|
||||
buf[writeIdx+1] = '\n'
|
||||
writeIdx += 2
|
||||
} else {
|
||||
buf[writeIdx] = b
|
||||
writeIdx++
|
||||
}
|
||||
}
|
||||
return w.dest.Write(buf)
|
||||
}
|
||||
|
||||
func WriteStderr(fmtStr string, args ...interface{}) {
|
||||
WrappedStderr.Write([]byte(fmt.Sprintf(fmtStr, args...)))
|
||||
}
|
||||
|
||||
func WriteStdout(fmtStr string, args ...interface{}) {
|
||||
output := fmt.Sprintf(fmtStr, args...)
|
||||
if UsingTermWshMode {
|
||||
output = strings.ReplaceAll(output, "\n", "\r\n")
|
||||
WrappedStdout.Write([]byte(fmt.Sprintf(fmtStr, args...)))
|
||||
}
|
||||
fmt.Print(output)
|
||||
|
||||
func OutputHelpMessage(cmd *cobra.Command) {
|
||||
cmd.SetOutput(WrappedStderr)
|
||||
cmd.Help()
|
||||
WriteStderr("\n")
|
||||
}
|
||||
|
||||
func preRunSetupRpcClient(cmd *cobra.Command, args []string) error {
|
||||
@ -64,6 +95,15 @@ func getIsTty() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func getThisBlockMeta() (waveobj.MetaMapType, error) {
|
||||
blockORef := waveobj.ORef{OType: waveobj.OType_Block, OID: RpcContext.BlockId}
|
||||
resp, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ORef: blockORef}, &wshrpc.RpcOpts{Timeout: 2000})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting metadata: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type RunEFnType = func(*cobra.Command, []string) error
|
||||
|
||||
func activityWrap(activityStr string, origRunE RunEFnType) RunEFnType {
|
||||
|
154
cmd/wsh/cmd/wshcmd-run.go
Normal file
154
cmd/wsh/cmd/wshcmd-run.go
Normal 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
|
||||
}
|
@ -55,18 +55,19 @@ func termRun(cmd *cobra.Command, args []string) (rtnErr error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting absolute path: %w", err)
|
||||
}
|
||||
createBlockData := wshrpc.CommandCreateBlockData{
|
||||
BlockDef: &waveobj.BlockDef{
|
||||
Meta: map[string]interface{}{
|
||||
createMeta := map[string]any{
|
||||
waveobj.MetaKey_View: "term",
|
||||
waveobj.MetaKey_CmdCwd: cwd,
|
||||
waveobj.MetaKey_Controller: "shell",
|
||||
},
|
||||
},
|
||||
Magnified: termMagnified,
|
||||
}
|
||||
if RpcContext.Conn != "" {
|
||||
createBlockData.BlockDef.Meta[waveobj.MetaKey_Connection] = RpcContext.Conn
|
||||
createMeta[waveobj.MetaKey_Connection] = RpcContext.Conn
|
||||
}
|
||||
createBlockData := wshrpc.CommandCreateBlockData{
|
||||
BlockDef: &waveobj.BlockDef{
|
||||
Meta: createMeta,
|
||||
},
|
||||
Magnified: termMagnified,
|
||||
}
|
||||
oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil)
|
||||
if err != nil {
|
||||
|
@ -20,7 +20,6 @@ var viewMagnified bool
|
||||
var viewCmd = &cobra.Command{
|
||||
Use: "view {file|directory|URL}",
|
||||
Short: "preview/edit a file or directory",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: viewRun,
|
||||
PreRunE: preRunSetupRpcClient,
|
||||
}
|
||||
@ -28,7 +27,6 @@ var viewCmd = &cobra.Command{
|
||||
var editCmd = &cobra.Command{
|
||||
Use: "edit {file}",
|
||||
Short: "edit a file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: viewRun,
|
||||
PreRunE: preRunSetupRpcClient,
|
||||
}
|
||||
@ -40,9 +38,18 @@ func init() {
|
||||
}
|
||||
|
||||
func viewRun(cmd *cobra.Command, args []string) (rtnErr error) {
|
||||
cmdName := cmd.Name()
|
||||
defer func() {
|
||||
sendActivity("view", rtnErr == nil)
|
||||
sendActivity(cmdName, rtnErr == nil)
|
||||
}()
|
||||
if len(args) == 0 {
|
||||
OutputHelpMessage(cmd)
|
||||
return fmt.Errorf("no arguments. wsh %s requires a file or URL as an argument argument", cmdName)
|
||||
}
|
||||
if len(args) > 1 {
|
||||
OutputHelpMessage(cmd)
|
||||
return fmt.Errorf("too many arguments. wsh %s requires exactly one argument", cmdName)
|
||||
}
|
||||
fileArg := args[0]
|
||||
conn := RpcContext.Conn
|
||||
var wshCmd *wshrpc.CommandCreateBlockData
|
||||
@ -81,7 +88,7 @@ func viewRun(cmd *cobra.Command, args []string) (rtnErr error) {
|
||||
},
|
||||
Magnified: viewMagnified,
|
||||
}
|
||||
if cmd.Use == "edit" {
|
||||
if cmdName == "edit" {
|
||||
wshCmd.BlockDef.Meta[waveobj.MetaKey_Edit] = true
|
||||
}
|
||||
if conn != "" {
|
||||
|
@ -45,6 +45,7 @@ wsh editconfig
|
||||
| term:scrollback | int | size of terminal scrollback buffer, max is 10000 |
|
||||
| editor:minimapenabled | bool | set to false to disable editor minimap |
|
||||
| editor:stickscrollenabled | bool | |
|
||||
| editor:wordwrap | bool | set to true to enable word wrapping in the editor (defaults to false) |
|
||||
| web:openlinksinternally | bool | set to false to open web links in external browser |
|
||||
| web:defaulturl | string | default web page to open in the web widget when no url is provided (homepage) |
|
||||
| web:defaultsearch | string | search template for web searches. e.g. `https://www.google.com/search?q={query}`. "\{query}" gets replaced by search term |
|
||||
|
@ -91,15 +91,18 @@ A terminal widget, or CLI widget, is a widget that simply opens a terminal and r
|
||||
The `WidgetConfigType` takes the usual options common to all widgets. The `MetaTSType` can include the keys listed below:
|
||||
|
||||
| Key | Description |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| "view" | A string that specifies the general type of widget. In the case of custom terminal widgets, this must be set to `"term"`. |
|
||||
| "controller" | A string that specifies the type of command being used. For more persistent shell sessions, set it to "shell". For one off commands, set it to `"cmd"`. When `"cmd"` is set, the widget has an additional refresh button in its header that allows the command to be re-run. |
|
||||
| "cmd" | (optional) When the `"controller"` is set to `"cmd"`, this option provides the actual command to be run. Note that because it is run as a command, there is no shell session unless you are launching a command that contains a shell session itself. Defaults to an empty string. |
|
||||
| "cmd:interactive" | (optional) When the `"controller"` is set to `"term", this boolean adds the interactive flag to the launched terminal. Defaults to false. |
|
||||
| "cmd:login" | (optional) When the `"controller"` is set to `"term"`, this boolean adds the login flag to the term command. Defaults to false. |
|
||||
| "cmd:runonstart" | (optional) The command will rerun when the app is started. Without it, you must manually run the command. Defaults to true. |
|
||||
| "cmd:clearonstart" | (optional) When the cmd starts, the contents of the block are cleared out. Defaults to false. |
|
||||
| "cmd:clearonrestart" | (optional) When the app restarts, the contents of the block are cleared out. Defaults to false. |
|
||||
| "cmd:runonce" | (optional) Runs on start, but then sets "cmd:runonce" and "cmd:runonstart" to false (so future runs require manual restarts) |
|
||||
| "cmd:clearonstart" | (optional) When the cmd runs, the contents of the block are cleared out. Defaults to false. |
|
||||
| "cmd:closeonexit" | (optional) Automatically closes the block if the command successfully exits (exit code = 0) |
|
||||
| "cmd:closeonexitforce" | (optional) Automatically closes the block if when the command exits (success or failure) |
|
||||
| "cmd:closeonexitdelay | (optional) Change the delay between when the command exits and when the block gets closed, in milliseconds, default 2000 |
|
||||
| "cmd:env" | (optional) A key-value object represting environment variables to be run with the command. Currently only works locally. Defaults to an empty object. |
|
||||
| "cmd:cwd" | (optional) A string representing the current working directory to be run with the command. Currently only works locally. Defaults to the home directory. |
|
||||
| "cmd:nowsh" | (optional) A boolean that will turn off wsh integration for the command. Defaults to false. |
|
||||
|
@ -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
|
||||
|
||||
```
|
||||
|
@ -36,15 +36,15 @@
|
||||
"@docusaurus/module-type-aliases": "3.6.3",
|
||||
"@docusaurus/tsconfig": "3.6.3",
|
||||
"@docusaurus/types": "3.6.3",
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@eslint/js": "^9.16.0",
|
||||
"@mdx-js/typescript-plugin": "^0.0.6",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/eslint-config-prettier": "^6.11.3",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-mdx": "^3.1.5",
|
||||
"prettier": "^3.4.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-jsdoc": "^1.3.0",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"remark-cli": "^12.0.1",
|
||||
@ -53,7 +53,7 @@
|
||||
"remark-preset-lint-consistent": "^6.0.0",
|
||||
"remark-preset-lint-recommended": "^7.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.16.0"
|
||||
"typescript-eslint": "^8.17.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"path-to-regexp@npm:2.2.1": "^3",
|
||||
|
@ -30,13 +30,6 @@ export default defineConfig({
|
||||
"process.env.WS_NO_BUFFER_UTIL": "true",
|
||||
"process.env.WS_NO_UTF_8_VALIDATE": "true",
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: "modern-compiler", // or "modern"
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
root: ".",
|
||||
|
@ -294,8 +294,8 @@ export class WaveBrowserWindow extends BaseWindow {
|
||||
await this.queueTabSwitch(tabView, tabInitialized);
|
||||
}
|
||||
|
||||
async createTab() {
|
||||
const tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true);
|
||||
async createTab(pinned = false) {
|
||||
const tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, pinned);
|
||||
await this.setActiveTab(tabId, false);
|
||||
}
|
||||
|
||||
|
@ -75,6 +75,10 @@ a.plain-link {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.error-color {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
/* OverlayScrollbars styling */
|
||||
.os-scrollbar {
|
||||
--os-handle-bg: var(--scrollbar-thumb-color);
|
||||
|
@ -124,6 +124,10 @@
|
||||
opacity: 0.7;
|
||||
flex-grow: 1;
|
||||
|
||||
&.flex-nogrow {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
&.preview-filename {
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
|
@ -6,7 +6,6 @@ import {
|
||||
blockViewToName,
|
||||
computeConnColorNum,
|
||||
ConnectionButton,
|
||||
ControllerStatusIcon,
|
||||
getBlockHeaderIcon,
|
||||
Input,
|
||||
} from "@/app/block/blockutil";
|
||||
@ -227,7 +226,6 @@ const BlockFrame_Header = ({
|
||||
} else if (Array.isArray(headerTextUnion)) {
|
||||
headerTextElems.push(...renderHeaderElements(headerTextUnion, preview));
|
||||
}
|
||||
headerTextElems.unshift(<ControllerStatusIcon key="connstatus" blockId={nodeModel.blockId} />);
|
||||
if (error != null) {
|
||||
const copyHeaderErr = () => {
|
||||
navigator.clipboard.writeText(error.message + "\n" + error.stack);
|
||||
@ -271,7 +269,7 @@ const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; previe
|
||||
return <Input decl={elem} className={clsx("block-frame-input", elem.className)} preview={preview} />;
|
||||
} else if (elem.elemtype == "text") {
|
||||
return (
|
||||
<div className={clsx("block-frame-text", elem.className)}>
|
||||
<div className={clsx("block-frame-text", elem.className, { "flex-nogrow": elem.noGrow })}>
|
||||
<span ref={preview ? null : elem.ref} onClick={(e) => elem?.onClick(e)}>
|
||||
‎{elem.text}
|
||||
</span>
|
||||
|
@ -2,10 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { NumActiveConnColors } from "@/app/block/blockframe";
|
||||
import { getConnStatusAtom, WOS } from "@/app/store/global";
|
||||
import * as services from "@/app/store/services";
|
||||
import { makeORef } from "@/app/store/wos";
|
||||
import { waveEventSubscribe } from "@/store/wps";
|
||||
import { getConnStatusAtom } from "@/app/store/global";
|
||||
import * as util from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
import * as jotai from "jotai";
|
||||
@ -150,52 +147,6 @@ interface ConnectionButtonProps {
|
||||
changeConnModalAtom: jotai.PrimitiveAtom<boolean>;
|
||||
}
|
||||
|
||||
export const ControllerStatusIcon = React.memo(({ blockId }: { blockId: string }) => {
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
|
||||
const hasController = !util.isBlank(blockData?.meta?.controller);
|
||||
const [controllerStatus, setControllerStatus] = React.useState<BlockControllerRuntimeStatus>(null);
|
||||
const [gotInitialStatus, setGotInitialStatus] = React.useState(false);
|
||||
const connection = blockData?.meta?.connection ?? "local";
|
||||
const connStatusAtom = getConnStatusAtom(connection);
|
||||
const connStatus = jotai.useAtomValue(connStatusAtom);
|
||||
React.useEffect(() => {
|
||||
if (!hasController) {
|
||||
return;
|
||||
}
|
||||
const initialRTStatus = services.BlockService.GetControllerStatus(blockId);
|
||||
initialRTStatus.then((rts) => {
|
||||
setGotInitialStatus(true);
|
||||
setControllerStatus(rts);
|
||||
});
|
||||
const unsubFn = waveEventSubscribe({
|
||||
eventType: "controllerstatus",
|
||||
scope: makeORef("block", blockId),
|
||||
handler: (event) => {
|
||||
const cstatus: BlockControllerRuntimeStatus = event.data;
|
||||
setControllerStatus(cstatus);
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
unsubFn();
|
||||
};
|
||||
}, [hasController]);
|
||||
if (!hasController || !gotInitialStatus) {
|
||||
return null;
|
||||
}
|
||||
if (controllerStatus?.shellprocstatus == "running") {
|
||||
return null;
|
||||
}
|
||||
if (connStatus?.status != "connected") {
|
||||
return null;
|
||||
}
|
||||
const controllerStatusElem = (
|
||||
<div className="iconbutton disabled" key="controller-status">
|
||||
<i className="fa-sharp fa-solid fa-triangle-exclamation" title="Shell Process Is Not Running" />
|
||||
</div>
|
||||
);
|
||||
return controllerStatusElem;
|
||||
});
|
||||
|
||||
export function computeConnColorNum(connStatus: ConnStatus): number {
|
||||
// activeconnnum is 1-indexed, so we need to adjust for when mod is 0
|
||||
const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors;
|
||||
|
@ -18,6 +18,10 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.no-action {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45 !important;
|
||||
|
@ -9,15 +9,19 @@ import "./iconbutton.scss";
|
||||
|
||||
export const IconButton = memo(({ decl, className }: { decl: IconButtonDecl; className?: string }) => {
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
const spin = decl.iconSpin ?? false;
|
||||
useLongClick(buttonRef, decl.click, decl.longClick, decl.disabled);
|
||||
return (
|
||||
<div
|
||||
ref={buttonRef}
|
||||
className={clsx("iconbutton", className, decl.className, { disabled: decl.disabled })}
|
||||
className={clsx("iconbutton", className, decl.className, {
|
||||
disabled: decl.disabled,
|
||||
"no-action": decl.noAction,
|
||||
})}
|
||||
title={decl.title}
|
||||
style={{ color: decl.iconColor ?? "inherit" }}
|
||||
>
|
||||
{typeof decl.icon === "string" ? <i className={makeIconClass(decl.icon, true)} /> : decl.icon}
|
||||
{typeof decl.icon === "string" ? <i className={makeIconClass(decl.icon, true, { spin })} /> : decl.icon}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -627,6 +627,7 @@ function createTab() {
|
||||
}
|
||||
|
||||
function setActiveTab(tabId: string) {
|
||||
// We use this hack to prevent a flicker in the tab bar when switching to a new tab. This class is unset in reinitWave in wave.ts. See tab.scss for where this class is used.
|
||||
document.body.classList.add("nohover");
|
||||
getApi().setActiveTab(tabId);
|
||||
}
|
||||
|
@ -99,15 +99,19 @@ function switchBlockInDirection(tabId: string, direction: NavigateDirection) {
|
||||
layoutModel.switchNodeFocusInDirection(direction);
|
||||
}
|
||||
|
||||
function getAllTabs(ws: Workspace): string[] {
|
||||
return [...(ws.pinnedtabids ?? []), ...(ws.tabids ?? [])];
|
||||
}
|
||||
|
||||
function switchTabAbs(index: number) {
|
||||
console.log("switchTabAbs", index);
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
const waveWindow = globalStore.get(atoms.waveWindow);
|
||||
const newTabIdx = index - 1;
|
||||
if (newTabIdx < 0 || newTabIdx >= ws.tabids.length) {
|
||||
const tabids = getAllTabs(ws);
|
||||
if (newTabIdx < 0 || newTabIdx >= tabids.length) {
|
||||
return;
|
||||
}
|
||||
const newActiveTabId = ws.tabids[newTabIdx];
|
||||
const newActiveTabId = tabids[newTabIdx];
|
||||
getApi().setActiveTab(newActiveTabId);
|
||||
}
|
||||
|
||||
@ -116,8 +120,9 @@ function switchTab(offset: number) {
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
const curTabId = globalStore.get(atoms.staticTabId);
|
||||
let tabIdx = -1;
|
||||
for (let i = 0; i < ws.tabids.length; i++) {
|
||||
if (ws.tabids[i] == curTabId) {
|
||||
const tabids = getAllTabs(ws);
|
||||
for (let i = 0; i < tabids.length; i++) {
|
||||
if (tabids[i] == curTabId) {
|
||||
tabIdx = i;
|
||||
break;
|
||||
}
|
||||
@ -125,8 +130,8 @@ function switchTab(offset: number) {
|
||||
if (tabIdx == -1) {
|
||||
return;
|
||||
}
|
||||
const newTabIdx = (tabIdx + offset + ws.tabids.length) % ws.tabids.length;
|
||||
const newActiveTabId = ws.tabids[newTabIdx];
|
||||
const newTabIdx = (tabIdx + offset + tabids.length) % tabids.length;
|
||||
const newActiveTabId = tabids[newTabIdx];
|
||||
getApi().setActiveTab(newActiveTabId);
|
||||
}
|
||||
|
||||
@ -241,7 +246,10 @@ function registerGlobalKeys() {
|
||||
});
|
||||
globalKeyMap.set("Cmd:w", () => {
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
if (!ws.pinnedtabids?.includes(tabId)) {
|
||||
genericClose(tabId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
globalKeyMap.set("Cmd:m", () => {
|
||||
|
@ -168,13 +168,18 @@ export const WindowService = new WindowServiceType();
|
||||
|
||||
// workspaceservice.WorkspaceService (workspace)
|
||||
class WorkspaceServiceType {
|
||||
// @returns object updates
|
||||
ChangeTabPinning(workspaceId: string, tabId: string, pinned: boolean): Promise<void> {
|
||||
return WOS.callBackendService("workspace", "ChangeTabPinning", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
CloseTab(workspaceId: string, tabId: string, fromElectron: boolean): Promise<CloseTabRtnType> {
|
||||
return WOS.callBackendService("workspace", "CloseTab", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns tabId (and object updates)
|
||||
CreateTab(workspaceId: string, tabName: string, activateTab: boolean): Promise<string> {
|
||||
CreateTab(workspaceId: string, tabName: string, activateTab: boolean, pinned: boolean): Promise<string> {
|
||||
return WOS.callBackendService("workspace", "CreateTab", Array.from(arguments))
|
||||
}
|
||||
|
||||
@ -195,7 +200,7 @@ class WorkspaceServiceType {
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
UpdateTabIds(workspaceId: string, tabIds: string[]): Promise<void> {
|
||||
UpdateTabIds(workspaceId: string, tabIds: string[], pinnedTabIds: string[]): Promise<void> {
|
||||
return WOS.callBackendService("workspace", "UpdateTabIds", Array.from(arguments))
|
||||
}
|
||||
}
|
||||
|
@ -79,8 +79,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
visibility: hidden;
|
||||
.button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 4px;
|
||||
@ -96,22 +95,21 @@
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
&:hover .close {
|
||||
visibility: visible;
|
||||
backdrop-filter: blur(3px);
|
||||
|
||||
&:hover {
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
.close {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
body:not(.nohover) .tab:hover {
|
||||
& + .tab::after,
|
||||
&::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.tab-inner {
|
||||
border-color: transparent;
|
||||
background: rgb(from var(--main-text-color) r g b / 0.07);
|
||||
}
|
||||
|
||||
.close {
|
||||
visibility: visible;
|
||||
&:hover {
|
||||
|
@ -3,8 +3,6 @@
|
||||
|
||||
import { Button } from "@/element/button";
|
||||
import { ContextMenuModel } from "@/store/contextmenu";
|
||||
import * as services from "@/store/services";
|
||||
import * as WOS from "@/store/wos";
|
||||
import { clsx } from "clsx";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
@ -12,6 +10,8 @@ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef,
|
||||
import { atoms, globalStore, refocusNode } from "@/app/store/global";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { ObjectService } from "../store/services";
|
||||
import { makeORef, useWaveObjectValue } from "../store/wos";
|
||||
import "./tab.scss";
|
||||
|
||||
interface TabProps {
|
||||
@ -22,11 +22,13 @@ interface TabProps {
|
||||
isDragging: boolean;
|
||||
tabWidth: number;
|
||||
isNew: boolean;
|
||||
isPinned: boolean;
|
||||
tabIds: string[];
|
||||
onClick: () => void;
|
||||
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void;
|
||||
onMouseDown: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
onLoaded: () => void;
|
||||
onPinChange: () => void;
|
||||
onMouseEnter: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
onMouseLeave: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
}
|
||||
@ -38,6 +40,7 @@ const Tab = memo(
|
||||
id,
|
||||
isActive,
|
||||
isFirst,
|
||||
isPinned,
|
||||
isBeforeActive,
|
||||
isDragging,
|
||||
tabWidth,
|
||||
@ -49,10 +52,11 @@ const Tab = memo(
|
||||
onMouseDown,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onPinChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [tabData, tabLoading] = WOS.useWaveObjectValue<Tab>(WOS.makeORef("tab", id));
|
||||
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
|
||||
const [originalName, setOriginalName] = useState("");
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
|
||||
@ -95,7 +99,7 @@ const Tab = memo(
|
||||
newText = newText || originalName;
|
||||
editableRef.current.innerText = newText;
|
||||
setIsEditable(false);
|
||||
services.ObjectService.UpdateTabName(id, newText);
|
||||
ObjectService.UpdateTabName(id, newText);
|
||||
setTimeout(() => refocusNode(null), 10);
|
||||
};
|
||||
|
||||
@ -153,7 +157,12 @@ const Tab = memo(
|
||||
|
||||
function handleContextMenu(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
e.preventDefault();
|
||||
let menu: ContextMenuItem[] = [];
|
||||
let menu: ContextMenuItem[] = [
|
||||
{ label: isPinned ? "Unpin Tab" : "Pin Tab", click: onPinChange },
|
||||
{ label: "Rename Tab", click: () => handleRenameTab(null) },
|
||||
{ label: "Copy TabId", click: () => navigator.clipboard.writeText(id) },
|
||||
{ type: "separator" },
|
||||
];
|
||||
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
||||
const bgPresets: string[] = [];
|
||||
for (const key in fullConfig?.presets ?? {}) {
|
||||
@ -166,12 +175,9 @@ const Tab = memo(
|
||||
const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
menu.push({ label: "Rename Tab", click: () => handleRenameTab(null) });
|
||||
menu.push({ label: "Copy TabId", click: () => navigator.clipboard.writeText(id) });
|
||||
menu.push({ type: "separator" });
|
||||
if (bgPresets.length > 0) {
|
||||
const submenu: ContextMenuItem[] = [];
|
||||
const oref = WOS.makeORef("tab", id);
|
||||
const oref = makeORef("tab", id);
|
||||
for (const presetName of bgPresets) {
|
||||
const preset = fullConfig.presets[presetName];
|
||||
if (preset == null) {
|
||||
@ -180,13 +186,12 @@ const Tab = memo(
|
||||
submenu.push({
|
||||
label: preset["display:name"] ?? presetName,
|
||||
click: () => {
|
||||
services.ObjectService.UpdateObjectMeta(oref, preset);
|
||||
ObjectService.UpdateObjectMeta(oref, preset);
|
||||
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 });
|
||||
},
|
||||
});
|
||||
}
|
||||
menu.push({ label: "Backgrounds", type: "submenu", submenu });
|
||||
menu.push({ type: "separator" });
|
||||
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
|
||||
}
|
||||
menu.push({ label: "Close Tab", click: () => onClose(null) });
|
||||
ContextMenuModel.showContextMenu(menu, e);
|
||||
@ -233,9 +238,21 @@ const Tab = memo(
|
||||
{tabData?.name}
|
||||
{/* {id.substring(id.length - 3)} */}
|
||||
</div>
|
||||
{isPinned ? (
|
||||
<Button
|
||||
className="ghost grey pin"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPinChange();
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-solid fa-thumbtack" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="ghost grey close" onClick={onClose} onMouseDown={handleMouseDownOnClose}>
|
||||
<i className="fa fa-solid fa-xmark" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -36,9 +36,18 @@
|
||||
|
||||
.tab-bar {
|
||||
position: relative; // Needed for absolute positioning of child tabs
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 33px;
|
||||
}
|
||||
|
||||
.pinned-tab-spacer {
|
||||
display: block;
|
||||
height: 100%;
|
||||
margin: 2px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.dev-label,
|
||||
.app-menu-button {
|
||||
font-size: 26px;
|
||||
|
@ -101,6 +101,7 @@ const ConfigErrorIcon = ({ buttonRef }: { buttonRef: React.RefObject<HTMLElement
|
||||
|
||||
const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
const [tabIds, setTabIds] = useState([]);
|
||||
const [pinnedTabIds, setPinnedTabIds] = useState<Set<string>>(new Set());
|
||||
const [dragStartPositions, setDragStartPositions] = useState<number[]>([]);
|
||||
const [draggingTab, setDraggingTab] = useState<string>();
|
||||
const [tabsLoaded, setTabsLoaded] = useState({});
|
||||
@ -116,6 +117,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
tabId: "",
|
||||
ref: { current: null },
|
||||
tabStartX: 0,
|
||||
tabStartIndex: 0,
|
||||
tabIndex: 0,
|
||||
initialOffsetX: null,
|
||||
totalScrollOffset: null,
|
||||
@ -148,17 +150,25 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
useEffect(() => {
|
||||
if (workspace) {
|
||||
// Compare current tabIds with new workspace.tabids
|
||||
const currentTabIds = new Set(tabIds);
|
||||
const newTabIds = new Set(workspace.tabids);
|
||||
console.log("tabbar workspace", workspace);
|
||||
const newTabIds = new Set([...(workspace.pinnedtabids ?? []), ...(workspace.tabids ?? [])]);
|
||||
const newPinnedTabIds = workspace.pinnedtabids ?? [];
|
||||
|
||||
const areEqual =
|
||||
currentTabIds.size === newTabIds.size && [...currentTabIds].every((id) => newTabIds.has(id));
|
||||
tabIds.length === newTabIds.size &&
|
||||
tabIds.every((id) => newTabIds.has(id)) &&
|
||||
newPinnedTabIds.length === pinnedTabIds.size;
|
||||
|
||||
if (!areEqual) {
|
||||
setTabIds(workspace.tabids);
|
||||
const newPinnedTabIdSet = new Set(newPinnedTabIds);
|
||||
console.log("newPinnedTabIds", newPinnedTabIds);
|
||||
const newTabIdList = newPinnedTabIds.concat([...newTabIds].filter((id) => !newPinnedTabIdSet.has(id))); // Corrects for any duplicates between the two lists
|
||||
console.log("newTabIdList", newTabIdList);
|
||||
setTabIds(newTabIdList);
|
||||
setPinnedTabIds(newPinnedTabIdSet);
|
||||
}
|
||||
}
|
||||
}, [workspace, tabIds]);
|
||||
}, [workspace, tabIds, pinnedTabIds]);
|
||||
|
||||
const saveTabsPosition = useCallback(() => {
|
||||
const tabs = tabRefs.current;
|
||||
@ -246,9 +256,14 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveTabsPositionDebounced = useCallback(
|
||||
debounce(100, () => saveTabsPosition()),
|
||||
[saveTabsPosition]
|
||||
);
|
||||
|
||||
const handleResizeTabs = useCallback(() => {
|
||||
setSizeAndPosition();
|
||||
debounce(100, () => saveTabsPosition())();
|
||||
saveTabsPositionDebounced();
|
||||
}, [tabIds, newTabId, isFullScreen]);
|
||||
|
||||
const reinitVersion = useAtomValue(atoms.reinitVersion);
|
||||
@ -278,7 +293,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
}, [tabIds, tabsLoaded, newTabId, saveTabsPosition]);
|
||||
|
||||
const getDragDirection = (currentX: number) => {
|
||||
let dragDirection;
|
||||
let dragDirection: string;
|
||||
if (currentX - prevDelta > 0) {
|
||||
dragDirection = "+";
|
||||
} else if (currentX - prevDelta === 0) {
|
||||
@ -433,6 +448,50 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
// } else if ((tabIndex > pinnedTabCount || (tabIndex === 1 && pinnedTabCount === 1)) && isPinned) {
|
||||
|
||||
const setUpdatedTabsDebounced = useCallback(
|
||||
debounce(300, (tabIndex: number, tabIds: string[], pinnedTabIds: Set<string>) => {
|
||||
console.log(
|
||||
"setting updated tabs",
|
||||
tabIds,
|
||||
pinnedTabIds,
|
||||
tabIndex,
|
||||
draggingTabDataRef.current.tabStartIndex
|
||||
);
|
||||
// Reset styles
|
||||
tabRefs.current.forEach((ref) => {
|
||||
ref.current.style.zIndex = "0";
|
||||
ref.current.classList.remove("animate");
|
||||
});
|
||||
let pinnedTabCount = pinnedTabIds.size;
|
||||
const draggedTabId = draggingTabDataRef.current.tabId;
|
||||
const isPinned = pinnedTabIds.has(draggedTabId);
|
||||
if (pinnedTabIds.has(tabIds[tabIndex + 1]) && !isPinned) {
|
||||
pinnedTabIds.add(draggedTabId);
|
||||
} else if (!pinnedTabIds.has(tabIds[tabIndex - 1]) && isPinned) {
|
||||
pinnedTabIds.delete(draggedTabId);
|
||||
}
|
||||
if (pinnedTabCount != pinnedTabIds.size) {
|
||||
console.log("updated pinnedTabIds", pinnedTabIds, tabIds);
|
||||
setPinnedTabIds(pinnedTabIds);
|
||||
pinnedTabCount = pinnedTabIds.size;
|
||||
}
|
||||
// Reset dragging state
|
||||
setDraggingTab(null);
|
||||
// Update workspace tab ids
|
||||
fireAndForget(
|
||||
async () =>
|
||||
await WorkspaceService.UpdateTabIds(
|
||||
workspace.oid,
|
||||
tabIds.slice(pinnedTabCount),
|
||||
tabIds.slice(0, pinnedTabCount)
|
||||
)
|
||||
);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleMouseUp = (event: MouseEvent) => {
|
||||
const { tabIndex, dragged } = draggingTabDataRef.current;
|
||||
|
||||
@ -447,17 +506,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
}
|
||||
|
||||
if (dragged) {
|
||||
debounce(300, () => {
|
||||
// Reset styles
|
||||
tabRefs.current.forEach((ref) => {
|
||||
ref.current.style.zIndex = "0";
|
||||
ref.current.classList.remove("animate");
|
||||
});
|
||||
// Reset dragging state
|
||||
setDraggingTab(null);
|
||||
// Update workspace tab ids
|
||||
fireAndForget(async () => await WorkspaceService.UpdateTabIds(workspace.oid, tabIds));
|
||||
})();
|
||||
setUpdatedTabsDebounced(tabIndex, tabIds, pinnedTabIds);
|
||||
} else {
|
||||
// Reset styles
|
||||
tabRefs.current.forEach((ref) => {
|
||||
@ -480,12 +529,14 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
const tabIndex = tabIds.indexOf(tabId);
|
||||
const tabStartX = dragStartPositions[tabIndex]; // Starting X position of the tab
|
||||
|
||||
console.log("handleDragStart", tabId, tabIndex, tabStartX);
|
||||
if (ref.current) {
|
||||
draggingTabDataRef.current = {
|
||||
tabId,
|
||||
ref,
|
||||
tabStartX,
|
||||
tabIndex,
|
||||
tabStartIndex: tabIndex,
|
||||
initialOffsetX: null,
|
||||
totalScrollOffset: 0,
|
||||
dragged: false,
|
||||
@ -504,19 +555,31 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTab = () => {
|
||||
createTab();
|
||||
tabsWrapperRef.current.style.transition;
|
||||
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease");
|
||||
|
||||
const updateScrollDebounced = useCallback(
|
||||
debounce(30, () => {
|
||||
if (scrollableRef.current) {
|
||||
const { viewport } = osInstanceRef.current.elements();
|
||||
viewport.scrollLeft = tabIds.length * tabWidthRef.current;
|
||||
}
|
||||
})();
|
||||
}),
|
||||
[tabIds]
|
||||
);
|
||||
|
||||
debounce(100, () => setNewTabId(null))();
|
||||
const setNewTabIdDebounced = useCallback(
|
||||
debounce(100, (tabId: string) => {
|
||||
setNewTabId(tabId);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAddTab = () => {
|
||||
createTab();
|
||||
tabsWrapperRef.current.style.transition;
|
||||
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease");
|
||||
|
||||
updateScrollDebounced();
|
||||
|
||||
setNewTabIdDebounced(null);
|
||||
};
|
||||
|
||||
const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => {
|
||||
@ -526,7 +589,14 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
deleteLayoutModelForTab(tabId);
|
||||
};
|
||||
|
||||
const handleTabLoaded = useCallback((tabId) => {
|
||||
const handlePinChange = (tabId: string, pinned: boolean) => {
|
||||
console.log("handlePinChange", tabId, pinned);
|
||||
fireAndForget(async () => {
|
||||
await WorkspaceService.ChangeTabPinning(workspace.oid, tabId, pinned);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTabLoaded = useCallback((tabId: string) => {
|
||||
setTabsLoaded((prev) => {
|
||||
if (!prev[tabId]) {
|
||||
// Only update if the tab isn't already marked as loaded
|
||||
@ -574,17 +644,20 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
<div className="tab-bar" ref={tabBarRef} data-overlayscrollbars-initialize>
|
||||
<div className="tabs-wrapper" ref={tabsWrapperRef} style={{ width: `${tabsWrapperWidth}px` }}>
|
||||
{tabIds.map((tabId, index) => {
|
||||
const isPinned = pinnedTabIds.has(tabId);
|
||||
return (
|
||||
<Tab
|
||||
key={tabId}
|
||||
ref={tabRefs.current[index]}
|
||||
id={tabId}
|
||||
isFirst={index === 0}
|
||||
isPinned={isPinned}
|
||||
onClick={() => handleSelectTab(tabId)}
|
||||
isActive={activeTabId === tabId}
|
||||
onMouseDown={(event) => handleDragStart(event, tabId, tabRefs.current[index])}
|
||||
onClose={(event) => handleCloseTab(event, tabId)}
|
||||
onLoaded={() => handleTabLoaded(tabId)}
|
||||
onPinChange={() => handlePinChange(tabId, !isPinned)}
|
||||
isBeforeActive={isBeforeActive(tabId)}
|
||||
isDragging={draggingTab === tabId}
|
||||
tabWidth={tabWidthRef.current}
|
||||
|
@ -156,7 +156,10 @@
|
||||
|
||||
.color-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15px, 15px)); // Ensures each color circle has a fixed 14px size
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(15px, 15px)
|
||||
); // Ensures each color circle has a fixed 14px size
|
||||
grid-gap: 18.5px; // Space between items
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@ -189,7 +192,10 @@
|
||||
|
||||
.icon-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16px, 16px)); // Ensures each color circle has a fixed 14px size
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(16px, 16px)
|
||||
); // Ensures each color circle has a fixed 14px size
|
||||
grid-column-gap: 17.5px; // Space between items
|
||||
grid-row-gap: 13px; // Space between items
|
||||
justify-content: center;
|
||||
|
@ -281,7 +281,7 @@ const WorkspaceSwitcherItem = ({
|
||||
const windowIconDecl: IconButtonDecl = {
|
||||
elemtype: "iconbutton",
|
||||
className: "window",
|
||||
disabled: true,
|
||||
noAction: true,
|
||||
icon: isCurrentWorkspace ? "check" : "window",
|
||||
title: isCurrentWorkspace ? "This is your current workspace" : "This workspace is open",
|
||||
};
|
||||
|
@ -1,10 +1,9 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { atoms } from "@/app/store/global";
|
||||
import { useOverrideConfigAtom } from "@/app/store/global";
|
||||
import loader from "@monaco-editor/loader";
|
||||
import { Editor, Monaco } from "@monaco-editor/react";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api";
|
||||
import { configureMonacoYaml } from "monaco-yaml";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
@ -108,6 +107,7 @@ function defaultEditorOptions(): MonacoTypes.editor.IEditorOptions {
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
blockId: string;
|
||||
text: string;
|
||||
filename: string;
|
||||
language?: string;
|
||||
@ -116,21 +116,12 @@ interface CodeEditorProps {
|
||||
onMount?: (monacoPtr: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => () => void;
|
||||
}
|
||||
|
||||
const minimapEnabledAtom = atom((get) => {
|
||||
const settings = get(atoms.settingsAtom);
|
||||
return settings["editor:minimapenabled"] ?? false;
|
||||
});
|
||||
|
||||
const stickyScrollEnabledAtom = atom((get) => {
|
||||
const settings = get(atoms.settingsAtom);
|
||||
return settings["editor:stickyscrollenabled"] ?? false;
|
||||
});
|
||||
|
||||
export function CodeEditor({ text, language, filename, meta, onChange, onMount }: CodeEditorProps) {
|
||||
export function CodeEditor({ blockId, text, language, filename, meta, onChange, onMount }: CodeEditorProps) {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const unmountRef = useRef<() => void>(null);
|
||||
const minimapEnabled = useAtomValue(minimapEnabledAtom);
|
||||
const stickyScrollEnabled = useAtomValue(stickyScrollEnabledAtom);
|
||||
const minimapEnabled = useOverrideConfigAtom(blockId, "editor:minimapenabled") ?? false;
|
||||
const stickyScrollEnabled = useOverrideConfigAtom(blockId, "editor:stickyscrollenabled") ?? false;
|
||||
const wordWrap = useOverrideConfigAtom(blockId, "editor:wordwrap") ?? false;
|
||||
const theme = "wave-theme-dark";
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -158,9 +149,9 @@ export function CodeEditor({ text, language, filename, meta, onChange, onMount }
|
||||
const opts = defaultEditorOptions();
|
||||
opts.minimap.enabled = minimapEnabled;
|
||||
opts.stickyScroll.enabled = stickyScrollEnabled;
|
||||
opts.wordWrap = meta?.["editor:wordwrap"] ? "on" : "off";
|
||||
opts.wordWrap = wordWrap ? "on" : "off";
|
||||
return opts;
|
||||
}, [minimapEnabled, stickyScrollEnabled, meta?.["editor:wordwrap"]]);
|
||||
}, [minimapEnabled, stickyScrollEnabled, wordWrap]);
|
||||
|
||||
return (
|
||||
<div className="code-editor-wrapper">
|
||||
|
@ -243,4 +243,10 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: #212121;
|
||||
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.entry-manager-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Button } from "@/app/element/button";
|
||||
import { Input } from "@/app/element/input";
|
||||
import { ContextMenuModel } from "@/app/store/contextmenu";
|
||||
import { PLATFORM, atoms, createBlock, getApi, globalStore } from "@/app/store/global";
|
||||
@ -146,12 +147,21 @@ type EntryManagerOverlayProps = {
|
||||
entryManagerType: EntryManagerType;
|
||||
startingValue?: string;
|
||||
onSave: (newValue: string) => void;
|
||||
onCancel?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
getReferenceProps?: () => any;
|
||||
};
|
||||
|
||||
const EntryManagerOverlay = memo(
|
||||
({ entryManagerType, startingValue, onSave, forwardRef, style, getReferenceProps }: EntryManagerOverlayProps) => {
|
||||
({
|
||||
entryManagerType,
|
||||
startingValue,
|
||||
onSave,
|
||||
onCancel,
|
||||
forwardRef,
|
||||
style,
|
||||
getReferenceProps,
|
||||
}: EntryManagerOverlayProps) => {
|
||||
const [value, setValue] = useState(startingValue);
|
||||
return (
|
||||
<div className="entry-manager-overlay" ref={forwardRef} style={style} {...getReferenceProps()}>
|
||||
@ -168,7 +178,15 @@ const EntryManagerOverlay = memo(
|
||||
onSave(value);
|
||||
}
|
||||
}}
|
||||
></Input>
|
||||
/>
|
||||
</div>
|
||||
<div className="entry-manager-buttons">
|
||||
<Button className="vertical-padding-4" onClick={() => onSave(value)}>
|
||||
Save
|
||||
</Button>
|
||||
<Button className="vertical-padding-4 red outlined" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -870,6 +888,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
|
||||
}}
|
||||
{...getReferenceProps()}
|
||||
onContextMenu={(e) => handleFileContextMenu(e)}
|
||||
onClick={() => setEntryManagerProps(undefined)}
|
||||
>
|
||||
<DirectoryTable
|
||||
model={model}
|
||||
@ -891,6 +910,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
|
||||
forwardRef={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
getReferenceProps={getFloatingProps}
|
||||
onCancel={() => setEntryManagerProps(undefined)}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
|
@ -10,7 +10,15 @@ import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { CodeEditor } from "@/app/view/codeeditor/codeeditor";
|
||||
import { Markdown } from "@/element/markdown";
|
||||
import { atoms, createBlock, getConnStatusAtom, getSettingsKeyAtom, globalStore, refocusNode } from "@/store/global";
|
||||
import {
|
||||
atoms,
|
||||
createBlock,
|
||||
getConnStatusAtom,
|
||||
getOverrideConfigAtom,
|
||||
getSettingsKeyAtom,
|
||||
globalStore,
|
||||
refocusNode,
|
||||
} from "@/store/global";
|
||||
import * as services from "@/store/services";
|
||||
import * as WOS from "@/store/wos";
|
||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||
@ -659,6 +667,8 @@ export class PreviewModel implements ViewModel {
|
||||
});
|
||||
}
|
||||
const loadableSV = globalStore.get(this.loadableSpecializedView);
|
||||
const wordWrapAtom = getOverrideConfigAtom(this.blockId, "editor:wordwrap");
|
||||
const wordWrap = globalStore.get(wordWrapAtom) ?? false;
|
||||
if (loadableSV.state == "hasData") {
|
||||
if (loadableSV.data.specializedView == "codeedit") {
|
||||
if (globalStore.get(this.newFileContent) != null) {
|
||||
@ -676,11 +686,11 @@ export class PreviewModel implements ViewModel {
|
||||
menuItems.push({
|
||||
label: "Word Wrap",
|
||||
type: "checkbox",
|
||||
checked: blockData?.meta?.["editor:wordwrap"] ?? false,
|
||||
checked: wordWrap,
|
||||
click: () => {
|
||||
const blockOref = WOS.makeORef("block", this.blockId);
|
||||
services.ObjectService.UpdateObjectMeta(blockOref, {
|
||||
"editor:wordwrap": !blockData?.meta?.["editor:wordwrap"],
|
||||
"editor:wordwrap": !wordWrap,
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -865,6 +875,7 @@ function CodeEditPreview({ model }: SpecializedViewProps) {
|
||||
|
||||
return (
|
||||
<CodeEditor
|
||||
blockId={model.blockId}
|
||||
text={fileContent}
|
||||
filename={fileName}
|
||||
meta={blockMeta}
|
||||
|
@ -36,6 +36,24 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.term-cmd-toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
|
||||
.term-cmd-toolbar-text {
|
||||
font: var(--fixed-font);
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.term-connectelem {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
|
@ -56,7 +56,6 @@ class TermViewModel {
|
||||
manageConnection: jotai.Atom<boolean>;
|
||||
connStatus: jotai.Atom<ConnStatus>;
|
||||
termWshClient: TermWshClient;
|
||||
shellProcStatusRef: React.MutableRefObject<string>;
|
||||
vdomBlockId: jotai.Atom<string>;
|
||||
vdomToolbarBlockId: jotai.Atom<string>;
|
||||
vdomToolbarTarget: jotai.PrimitiveAtom<VDomTargetToolbar>;
|
||||
@ -64,6 +63,11 @@ class TermViewModel {
|
||||
termThemeNameAtom: jotai.Atom<string>;
|
||||
noPadding: jotai.PrimitiveAtom<boolean>;
|
||||
endIconButtons: jotai.Atom<IconButtonDecl[]>;
|
||||
shellProcFullStatus: jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;
|
||||
shellProcStatus: jotai.Atom<string>;
|
||||
shellProcStatusUnsubFn: () => void;
|
||||
isCmdController: jotai.Atom<boolean>;
|
||||
isRestarting: jotai.PrimitiveAtom<boolean>;
|
||||
|
||||
constructor(blockId: string, nodeModel: BlockNodeModel) {
|
||||
this.viewType = "term";
|
||||
@ -85,11 +89,15 @@ class TermViewModel {
|
||||
const blockData = get(this.blockAtom);
|
||||
return blockData?.meta?.["term:mode"] ?? "term";
|
||||
});
|
||||
this.isRestarting = jotai.atom(false);
|
||||
this.viewIcon = jotai.atom((get) => {
|
||||
const termMode = get(this.termMode);
|
||||
if (termMode == "vdom") {
|
||||
return "bolt";
|
||||
}
|
||||
const isCmd = get(this.isCmdController);
|
||||
if (isCmd) {
|
||||
}
|
||||
return "terminal";
|
||||
});
|
||||
this.viewName = jotai.atom((get) => {
|
||||
@ -99,7 +107,7 @@ class TermViewModel {
|
||||
return "Wave App";
|
||||
}
|
||||
if (blockData?.meta?.controller == "cmd") {
|
||||
return "Command";
|
||||
return "";
|
||||
}
|
||||
return "Terminal";
|
||||
});
|
||||
@ -116,28 +124,76 @@ class TermViewModel {
|
||||
},
|
||||
},
|
||||
];
|
||||
} else {
|
||||
}
|
||||
const vdomBlockId = get(this.vdomBlockId);
|
||||
const rtn = [];
|
||||
if (vdomBlockId) {
|
||||
return [
|
||||
{
|
||||
rtn.push({
|
||||
elemtype: "iconbutton",
|
||||
icon: "bolt",
|
||||
title: "Switch to Wave App",
|
||||
click: () => {
|
||||
this.setTermMode("vdom");
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
const isCmd = get(this.isCmdController);
|
||||
if (isCmd) {
|
||||
const blockMeta = get(this.blockAtom)?.meta;
|
||||
let cmdText = blockMeta?.["cmd"];
|
||||
let cmdArgs = blockMeta?.["cmd:args"];
|
||||
if (cmdArgs != null && Array.isArray(cmdArgs) && cmdArgs.length > 0) {
|
||||
cmdText += " " + cmdArgs.join(" ");
|
||||
}
|
||||
rtn.push({
|
||||
elemtype: "text",
|
||||
text: cmdText,
|
||||
noGrow: true,
|
||||
});
|
||||
const isRestarting = get(this.isRestarting);
|
||||
if (isRestarting) {
|
||||
rtn.push({
|
||||
elemtype: "iconbutton",
|
||||
icon: "refresh",
|
||||
iconColor: "var(--success-color)",
|
||||
iconSpin: true,
|
||||
title: "Restarting Command",
|
||||
noAction: true,
|
||||
});
|
||||
} else {
|
||||
const fullShellProcStatus = get(this.shellProcFullStatus);
|
||||
if (fullShellProcStatus?.shellprocstatus == "done") {
|
||||
if (fullShellProcStatus?.shellprocexitcode == 0) {
|
||||
rtn.push({
|
||||
elemtype: "iconbutton",
|
||||
icon: "check",
|
||||
iconColor: "var(--success-color)",
|
||||
title: "Command Exited Successfully",
|
||||
noAction: true,
|
||||
});
|
||||
} else {
|
||||
rtn.push({
|
||||
elemtype: "iconbutton",
|
||||
icon: "xmark-large",
|
||||
iconColor: "var(--error-color)",
|
||||
title: "Exit Code: " + fullShellProcStatus?.shellprocexitcode,
|
||||
noAction: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return rtn;
|
||||
});
|
||||
this.manageConnection = jotai.atom((get) => {
|
||||
const termMode = get(this.termMode);
|
||||
if (termMode == "vdom") {
|
||||
return false;
|
||||
}
|
||||
const isCmd = get(this.isCmdController);
|
||||
if (isCmd) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
this.termThemeNameAtom = useBlockAtom(blockId, "termthemeatom", () => {
|
||||
@ -175,17 +231,60 @@ class TermViewModel {
|
||||
this.noPadding = jotai.atom(true);
|
||||
this.endIconButtons = jotai.atom((get) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
if (blockData?.meta?.["controller"] != "cmd") {
|
||||
const shellProcStatus = get(this.shellProcStatus);
|
||||
const connStatus = get(this.connStatus);
|
||||
const isCmd = get(this.isCmdController);
|
||||
if (blockData?.meta?.["controller"] != "cmd" && shellProcStatus != "done") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
if (connStatus?.status != "connected") {
|
||||
return [];
|
||||
}
|
||||
let iconName: string = null;
|
||||
let title: string = null;
|
||||
const noun = isCmd ? "Command" : "Shell";
|
||||
if (shellProcStatus == "init") {
|
||||
iconName = "play";
|
||||
title = "Click to Start " + noun;
|
||||
} else if (shellProcStatus == "running") {
|
||||
iconName = "refresh";
|
||||
title = noun + " Running. Click to Restart";
|
||||
} else if (shellProcStatus == "done") {
|
||||
iconName = "refresh";
|
||||
title = noun + " Exited. Click to Restart";
|
||||
}
|
||||
if (iconName == null) {
|
||||
return [];
|
||||
}
|
||||
const buttonDecl: IconButtonDecl = {
|
||||
elemtype: "iconbutton",
|
||||
icon: "refresh",
|
||||
icon: iconName,
|
||||
click: this.forceRestartController.bind(this),
|
||||
title: "Force Restart Controller",
|
||||
title: title,
|
||||
};
|
||||
const rtn = [buttonDecl];
|
||||
return rtn;
|
||||
});
|
||||
this.isCmdController = jotai.atom((get) => {
|
||||
const controllerMetaAtom = getBlockMetaKeyAtom(this.blockId, "controller");
|
||||
return get(controllerMetaAtom) == "cmd";
|
||||
});
|
||||
this.shellProcFullStatus = jotai.atom(null) as jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;
|
||||
const initialShellProcStatus = services.BlockService.GetControllerStatus(blockId);
|
||||
initialShellProcStatus.then((rts) => {
|
||||
this.updateShellProcStatus(rts);
|
||||
});
|
||||
this.shellProcStatusUnsubFn = waveEventSubscribe({
|
||||
eventType: "controllerstatus",
|
||||
scope: WOS.makeORef("block", blockId),
|
||||
handler: (event) => {
|
||||
let bcRTS: BlockControllerRuntimeStatus = event.data;
|
||||
this.updateShellProcStatus(bcRTS);
|
||||
},
|
||||
];
|
||||
});
|
||||
this.shellProcStatus = jotai.atom((get) => {
|
||||
const fullStatus = get(this.shellProcFullStatus);
|
||||
return fullStatus?.shellprocstatus ?? "init";
|
||||
});
|
||||
}
|
||||
|
||||
@ -199,6 +298,23 @@ class TermViewModel {
|
||||
});
|
||||
}
|
||||
|
||||
triggerRestartAtom() {
|
||||
globalStore.set(this.isRestarting, true);
|
||||
setTimeout(() => {
|
||||
globalStore.set(this.isRestarting, false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) {
|
||||
globalStore.set(this.shellProcFullStatus, fullStatus);
|
||||
const status = fullStatus?.shellprocstatus ?? "init";
|
||||
if (status == "running") {
|
||||
this.termRef.current?.setIsRunning?.(true);
|
||||
} else {
|
||||
this.termRef.current?.setIsRunning?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
getVDomModel(): VDomModel {
|
||||
const vdomBlockId = globalStore.get(this.vdomBlockId);
|
||||
if (!vdomBlockId) {
|
||||
@ -225,6 +341,9 @@ class TermViewModel {
|
||||
|
||||
dispose() {
|
||||
DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId));
|
||||
if (this.shellProcStatusUnsubFn) {
|
||||
this.shellProcStatusUnsubFn();
|
||||
}
|
||||
}
|
||||
|
||||
giveFocus(): boolean {
|
||||
@ -284,11 +403,9 @@ class TermViewModel {
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
if (this.shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) {
|
||||
// restart
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: this.blockId });
|
||||
prtn.catch((e) => console.log("error controller resync (enter)", this.blockId, e));
|
||||
const shellProcStatus = globalStore.get(this.shellProcStatus);
|
||||
if ((shellProcStatus == "done" || shellProcStatus == "init") && keyutil.checkKeyPressed(waveEvent, "Enter")) {
|
||||
this.forceRestartController();
|
||||
return false;
|
||||
}
|
||||
const globalKeys = getAllGlobalKeyBindings();
|
||||
@ -308,6 +425,10 @@ class TermViewModel {
|
||||
}
|
||||
|
||||
forceRestartController() {
|
||||
if (globalStore.get(this.isRestarting)) {
|
||||
return;
|
||||
}
|
||||
this.triggerRestartAtom();
|
||||
const termsize = {
|
||||
rows: this.termRef.current?.terminal?.rows,
|
||||
cols: this.termRef.current?.terminal?.cols,
|
||||
@ -387,6 +508,62 @@ class TermViewModel {
|
||||
label: "Force Restart Controller",
|
||||
click: this.forceRestartController.bind(this),
|
||||
});
|
||||
const isClearOnStart = blockData?.meta?.["cmd:clearonstart"];
|
||||
fullMenu.push({
|
||||
label: "Clear Output On Restart",
|
||||
submenu: [
|
||||
{
|
||||
label: "On",
|
||||
type: "checkbox",
|
||||
checked: isClearOnStart,
|
||||
click: () => {
|
||||
RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { "cmd:clearonstart": true },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Off",
|
||||
type: "checkbox",
|
||||
checked: !isClearOnStart,
|
||||
click: () => {
|
||||
RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { "cmd:clearonstart": false },
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const runOnStart = blockData?.meta?.["cmd:runonstart"];
|
||||
fullMenu.push({
|
||||
label: "Run On Startup",
|
||||
submenu: [
|
||||
{
|
||||
label: "On",
|
||||
type: "checkbox",
|
||||
checked: runOnStart,
|
||||
click: () => {
|
||||
RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { "cmd:runonstart": true },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Off",
|
||||
type: "checkbox",
|
||||
checked: !runOnStart,
|
||||
click: () => {
|
||||
RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { "cmd:runonstart": false },
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
if (blockData?.meta?.["term:vdomtoolbarblockid"]) {
|
||||
fullMenu.push({ type: "separator" });
|
||||
fullMenu.push({
|
||||
@ -538,8 +715,6 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
const connectElemRef = React.useRef<HTMLDivElement>(null);
|
||||
const termRef = React.useRef<TermWrap>(null);
|
||||
model.termRef = termRef;
|
||||
const spstatusRef = React.useRef<string>(null);
|
||||
model.shellProcStatusRef = spstatusRef;
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
|
||||
const termSettingsAtom = useSettingsPrefixAtom("term");
|
||||
const termSettings = jotai.useAtomValue(termSettingsAtom);
|
||||
@ -587,6 +762,12 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
useWebGl: !termSettings?.["term:disablewebgl"],
|
||||
}
|
||||
);
|
||||
const shellProcStatus = globalStore.get(model.shellProcStatus);
|
||||
if (shellProcStatus == "running") {
|
||||
termWrap.setIsRunning(true);
|
||||
} else if (shellProcStatus == "done") {
|
||||
termWrap.setIsRunning(false);
|
||||
}
|
||||
(window as any).term = termWrap;
|
||||
termRef.current = termWrap;
|
||||
const rszObs = new ResizeObserver(() => {
|
||||
@ -613,34 +794,6 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
termModeRef.current = termMode;
|
||||
}, [termMode]);
|
||||
|
||||
// set intitial controller status, and then subscribe for updates
|
||||
React.useEffect(() => {
|
||||
function updateShellProcStatus(status: string) {
|
||||
if (status == null) {
|
||||
return;
|
||||
}
|
||||
model.shellProcStatusRef.current = status;
|
||||
if (status == "running") {
|
||||
termRef.current?.setIsRunning(true);
|
||||
} else {
|
||||
termRef.current?.setIsRunning(false);
|
||||
}
|
||||
}
|
||||
const initialRTStatus = services.BlockService.GetControllerStatus(blockId);
|
||||
initialRTStatus.then((rts) => {
|
||||
updateShellProcStatus(rts?.shellprocstatus);
|
||||
});
|
||||
return waveEventSubscribe({
|
||||
eventType: "controllerstatus",
|
||||
scope: WOS.makeORef("block", blockId),
|
||||
handler: (event) => {
|
||||
console.log("term waveEvent handler", event);
|
||||
let bcRTS: BlockControllerRuntimeStatus = event.data;
|
||||
updateShellProcStatus(bcRTS?.shellprocstatus);
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
let stickerConfig = {
|
||||
charWidth: 8,
|
||||
charHeight: 16,
|
||||
|
@ -186,14 +186,14 @@ export class WaveAiModel implements ViewModel {
|
||||
elemtype: "iconbutton",
|
||||
icon: "globe",
|
||||
title: "Using Remote Antropic API (" + modelName + ")",
|
||||
disabled: true,
|
||||
noAction: true,
|
||||
});
|
||||
} else if (isCloud) {
|
||||
viewTextChildren.push({
|
||||
elemtype: "iconbutton",
|
||||
icon: "cloud",
|
||||
title: "Using Wave's AI Proxy (gpt-4o-mini)",
|
||||
disabled: true,
|
||||
noAction: true,
|
||||
});
|
||||
} else {
|
||||
const baseUrl = aiOpts.baseurl ?? "OpenAI Default Endpoint";
|
||||
@ -203,14 +203,14 @@ export class WaveAiModel implements ViewModel {
|
||||
elemtype: "iconbutton",
|
||||
icon: "location-dot",
|
||||
title: "Using Local Model @ " + baseUrl + " (" + modelName + ")",
|
||||
disabled: true,
|
||||
noAction: true,
|
||||
});
|
||||
} else {
|
||||
viewTextChildren.push({
|
||||
elemtype: "iconbutton",
|
||||
icon: "globe",
|
||||
title: "Using Remote Model @ " + baseUrl + " (" + modelName + ")",
|
||||
disabled: true,
|
||||
noAction: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
3
frontend/types/custom.d.ts
vendored
3
frontend/types/custom.d.ts
vendored
@ -154,11 +154,13 @@ declare global {
|
||||
elemtype: "iconbutton";
|
||||
icon: string | React.ReactNode;
|
||||
iconColor?: string;
|
||||
iconSpin?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
click?: (e: React.MouseEvent<any>) => void;
|
||||
longClick?: (e: React.MouseEvent<any>) => void;
|
||||
disabled?: boolean;
|
||||
noAction?: boolean;
|
||||
};
|
||||
|
||||
type HeaderTextButton = {
|
||||
@ -173,6 +175,7 @@ declare global {
|
||||
text: string;
|
||||
ref?: React.MutableRefObject<HTMLDivElement>;
|
||||
className?: string;
|
||||
noGrow?: boolean;
|
||||
onClick?: (e: React.MouseEvent<any>) => void;
|
||||
};
|
||||
|
||||
|
17
frontend/types/gotypes.d.ts
vendored
17
frontend/types/gotypes.d.ts
vendored
@ -45,7 +45,6 @@ declare global {
|
||||
// waveobj.Block
|
||||
type Block = WaveObj & {
|
||||
parentoref?: string;
|
||||
blockdef: BlockDef;
|
||||
runtimeopts?: RuntimeOpts;
|
||||
stickers?: StickerType[];
|
||||
subblockids?: string[];
|
||||
@ -56,6 +55,7 @@ declare global {
|
||||
blockid: string;
|
||||
shellprocstatus?: string;
|
||||
shellprocconnname?: string;
|
||||
shellprocexitcode: number;
|
||||
};
|
||||
|
||||
// waveobj.BlockDef
|
||||
@ -70,6 +70,7 @@ declare global {
|
||||
tabid: string;
|
||||
workspaceid: string;
|
||||
block: Block;
|
||||
files: WaveFile[];
|
||||
};
|
||||
|
||||
// webcmd.BlockInputWSCommand
|
||||
@ -329,9 +330,6 @@ declare global {
|
||||
|
||||
// waveobj.FileDef
|
||||
type FileDef = {
|
||||
filetype?: string;
|
||||
path?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
meta?: {[key: string]: any};
|
||||
};
|
||||
@ -430,10 +428,15 @@ declare global {
|
||||
"cmd:login"?: boolean;
|
||||
"cmd:runonstart"?: boolean;
|
||||
"cmd:clearonstart"?: boolean;
|
||||
"cmd:clearonrestart"?: boolean;
|
||||
"cmd:runonce"?: boolean;
|
||||
"cmd:closeonexit"?: boolean;
|
||||
"cmd:closeonexitforce"?: boolean;
|
||||
"cmd:closeonexitdelay"?: number;
|
||||
"cmd:env"?: {[key: string]: string};
|
||||
"cmd:cwd"?: string;
|
||||
"cmd:nowsh"?: boolean;
|
||||
"cmd:args"?: string[];
|
||||
"cmd:shell"?: boolean;
|
||||
"ai:*"?: boolean;
|
||||
"ai:preset"?: string;
|
||||
"ai:apitype"?: string;
|
||||
@ -446,6 +449,8 @@ declare global {
|
||||
"ai:maxtokens"?: number;
|
||||
"ai:timeoutms"?: number;
|
||||
"editor:*"?: boolean;
|
||||
"editor:minimapenabled"?: boolean;
|
||||
"editor:stickyscrollenabled"?: boolean;
|
||||
"editor:wordwrap"?: boolean;
|
||||
"graph:*"?: boolean;
|
||||
"graph:numpoints"?: number;
|
||||
@ -603,6 +608,7 @@ declare global {
|
||||
"term:copyonselect"?: boolean;
|
||||
"editor:minimapenabled"?: boolean;
|
||||
"editor:stickyscrollenabled"?: boolean;
|
||||
"editor:wordwrap"?: boolean;
|
||||
"web:*"?: boolean;
|
||||
"web:openlinksinternally"?: boolean;
|
||||
"web:defaulturl"?: string;
|
||||
@ -1119,6 +1125,7 @@ declare global {
|
||||
icon: string;
|
||||
color: string;
|
||||
tabids: string[];
|
||||
pinnedtabids: string[];
|
||||
activetabid: string;
|
||||
};
|
||||
|
||||
|
@ -87,11 +87,14 @@ async function initWaveWrap(initOpts: WaveInitOpts) {
|
||||
async function reinitWave() {
|
||||
console.log("Reinit Wave");
|
||||
getApi().sendLog("Reinit Wave");
|
||||
|
||||
// We use this hack to prevent a flicker in the tab bar when switching to a new tab. This class is set in setActiveTab in global.ts. See tab.scss for where this class is used.
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
document.body.classList.remove("nohover");
|
||||
}, 50);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
const client = await WOS.reloadWaveObject<Client>(WOS.makeORef("client", savedInitOpts.clientId));
|
||||
const waveWindow = await WOS.reloadWaveObject<WaveWindow>(WOS.makeORef("window", savedInitOpts.windowId));
|
||||
const ws = await WOS.reloadWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));
|
||||
|
22
package.json
22
package.json
@ -28,7 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^3.2.2",
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@eslint/js": "^9.16.0",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"@storybook/addon-essentials": "^8.4.6",
|
||||
"@storybook/addon-interactions": "^8.4.6",
|
||||
@ -46,7 +46,7 @@
|
||||
"@types/papaparse": "^5",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/prop-types": "^15",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react": "^18.3.13",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/semver": "^7",
|
||||
"@types/shell-quote": "^1",
|
||||
@ -56,17 +56,17 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8",
|
||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||
"@vitest/coverage-istanbul": "^2.1.6",
|
||||
"@vitest/coverage-istanbul": "^2.1.8",
|
||||
"electron": "^33.2.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"prettier": "^3.4.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-jsdoc": "^1.3.0",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"rollup-plugin-flow": "^1.1.1",
|
||||
"sass": "^1.81.0",
|
||||
"sass": "^1.82.0",
|
||||
"semver": "^7.6.3",
|
||||
"storybook": "^8.4.6",
|
||||
"storybook-dark-mode": "^4.0.2",
|
||||
@ -74,13 +74,13 @@
|
||||
"tslib": "^2.8.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.16.0",
|
||||
"vite": "^6.0.1",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^6.0.2",
|
||||
"vite-plugin-image-optimizer": "^1.1.8",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
"vite-plugin-svgr": "^4.3.0",
|
||||
"vite-tsconfig-paths": "^5.1.3",
|
||||
"vitest": "^2.1.6"
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.28",
|
||||
@ -112,7 +112,7 @@
|
||||
"jotai": "2.9.3",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"monaco-yaml": "^5.2.3",
|
||||
"overlayscrollbars": "^2.10.0",
|
||||
"overlayscrollbars": "^2.10.1",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"papaparse": "^5.4.1",
|
||||
"pngjs": "^7.0.0",
|
||||
@ -133,7 +133,7 @@
|
||||
"remark-github-blockquote-alert": "^1.3.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.5",
|
||||
"shell-quote": "^1.8.1",
|
||||
"shell-quote": "^1.8.2",
|
||||
"sprintf-js": "^1.1.3",
|
||||
"throttle-debounce": "^5.0.2",
|
||||
"tinycolor2": "^1.6.0",
|
||||
|
@ -20,11 +20,14 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/remote"
|
||||
"github.com/wavetermdev/waveterm/pkg/remote/conncontroller"
|
||||
"github.com/wavetermdev/waveterm/pkg/shellexec"
|
||||
"github.com/wavetermdev/waveterm/pkg/util/envutil"
|
||||
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
|
||||
"github.com/wavetermdev/waveterm/pkg/wavebase"
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wconfig"
|
||||
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshrpc"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
||||
"github.com/wavetermdev/waveterm/pkg/wsl"
|
||||
"github.com/wavetermdev/waveterm/pkg/wstore"
|
||||
@ -37,12 +40,14 @@ const (
|
||||
|
||||
const (
|
||||
BlockFile_Term = "term" // used for main pty output
|
||||
BlockFile_Cache = "cache:term:full" // for cached block
|
||||
BlockFile_VDom = "vdom" // used for alt html layout
|
||||
)
|
||||
|
||||
const (
|
||||
Status_Running = "running"
|
||||
Status_Done = "done"
|
||||
Status_Init = "init"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -71,12 +76,14 @@ type BlockController struct {
|
||||
ShellProc *shellexec.ShellProc
|
||||
ShellInputCh chan *BlockInputUnion
|
||||
ShellProcStatus string
|
||||
ShellProcExitCode int
|
||||
}
|
||||
|
||||
type BlockControllerRuntimeStatus struct {
|
||||
BlockId string `json:"blockid"`
|
||||
ShellProcStatus string `json:"shellprocstatus,omitempty"`
|
||||
ShellProcConnName string `json:"shellprocconnname,omitempty"`
|
||||
ShellProcExitCode int `json:"shellprocexitcode"`
|
||||
}
|
||||
|
||||
func (bc *BlockController) WithLock(f func()) {
|
||||
@ -93,6 +100,7 @@ func (bc *BlockController) GetRuntimeStatus() *BlockControllerRuntimeStatus {
|
||||
if bc.ShellProc != nil {
|
||||
rtn.ShellProcConnName = bc.ShellProc.ConnName
|
||||
}
|
||||
rtn.ShellProcExitCode = bc.ShellProcExitCode
|
||||
})
|
||||
return &rtn
|
||||
}
|
||||
@ -126,22 +134,29 @@ func (bc *BlockController) UpdateControllerAndSendUpdate(updateFn func() bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func HandleTruncateBlockFile(blockId string, blockFile string) error {
|
||||
func HandleTruncateBlockFile(blockId string) error {
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancelFn()
|
||||
err := filestore.WFS.WriteFile(ctx, blockId, blockFile, nil)
|
||||
err := filestore.WFS.WriteFile(ctx, blockId, BlockFile_Term, nil)
|
||||
if err == fs.ErrNotExist {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("error truncating blockfile: %w", err)
|
||||
}
|
||||
err = filestore.WFS.DeleteFile(ctx, blockId, BlockFile_Cache)
|
||||
if err == fs.ErrNotExist {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("error deleting cache file (continuing): %v\n", err)
|
||||
}
|
||||
wps.Broker.Publish(wps.WaveEvent{
|
||||
Event: wps.Event_BlockFile,
|
||||
Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, blockId).String()},
|
||||
Data: &wps.WSFileEventData{
|
||||
ZoneId: blockId,
|
||||
FileName: blockFile,
|
||||
FileName: BlockFile_Term,
|
||||
FileOp: wps.FileOp_Truncate,
|
||||
},
|
||||
})
|
||||
@ -174,16 +189,8 @@ func HandleAppendBlockFile(blockId string, blockFile string, data []byte) error
|
||||
func (bc *BlockController) resetTerminalState() {
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancelFn()
|
||||
var shouldTruncate bool
|
||||
blockData, getBlockDataErr := wstore.DBMustGet[*waveobj.Block](ctx, bc.BlockId)
|
||||
if getBlockDataErr == nil {
|
||||
shouldTruncate = blockData.Meta.GetBool(waveobj.MetaKey_CmdClearOnRestart, false)
|
||||
}
|
||||
if shouldTruncate {
|
||||
err := HandleTruncateBlockFile(bc.BlockId, BlockFile_Term)
|
||||
if err != nil {
|
||||
log.Printf("error truncating term blockfile: %v\n", err)
|
||||
}
|
||||
wfile, statErr := filestore.WFS.Stat(ctx, bc.BlockId, BlockFile_Term)
|
||||
if statErr == fs.ErrNotExist || wfile.Size == 0 {
|
||||
return
|
||||
}
|
||||
// controller type = "shell"
|
||||
@ -199,6 +206,66 @@ func (bc *BlockController) resetTerminalState() {
|
||||
}
|
||||
}
|
||||
|
||||
// for "cmd" type blocks
|
||||
func createCmdStrAndOpts(blockId string, blockMeta waveobj.MetaMapType) (string, *shellexec.CommandOptsType, error) {
|
||||
var cmdStr string
|
||||
var cmdOpts shellexec.CommandOptsType
|
||||
cmdOpts.Env = make(map[string]string)
|
||||
cmdStr = blockMeta.GetString(waveobj.MetaKey_Cmd, "")
|
||||
if cmdStr == "" {
|
||||
return "", nil, fmt.Errorf("missing cmd in block meta")
|
||||
}
|
||||
cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "")
|
||||
if cmdOpts.Cwd != "" {
|
||||
cwdPath, err := wavebase.ExpandHomeDir(cmdOpts.Cwd)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cmdOpts.Cwd = cwdPath
|
||||
}
|
||||
useShell := blockMeta.GetBool(waveobj.MetaKey_CmdShell, true)
|
||||
if !useShell {
|
||||
if strings.Contains(cmdStr, " ") {
|
||||
return "", nil, fmt.Errorf("cmd should not have spaces if cmd:shell is false (use cmd:args)")
|
||||
}
|
||||
cmdArgs := blockMeta.GetStringList(waveobj.MetaKey_CmdArgs)
|
||||
// shell escape the args
|
||||
for _, arg := range cmdArgs {
|
||||
cmdStr = cmdStr + " " + utilfn.ShellQuote(arg, false, -1)
|
||||
}
|
||||
}
|
||||
|
||||
// get the "env" file
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancelFn()
|
||||
_, envFileData, err := filestore.WFS.ReadFile(ctx, blockId, "env")
|
||||
if err == fs.ErrNotExist {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("error reading command env file: %w", err)
|
||||
}
|
||||
if len(envFileData) > 0 {
|
||||
envMap := envutil.EnvToMap(string(envFileData))
|
||||
for k, v := range envMap {
|
||||
cmdOpts.Env[k] = v
|
||||
}
|
||||
}
|
||||
cmdEnv := blockMeta.GetMap(waveobj.MetaKey_CmdEnv)
|
||||
for k, v := range cmdEnv {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := v.(string); ok {
|
||||
cmdOpts.Env[k] = v.(string)
|
||||
}
|
||||
if _, ok := v.(float64); ok {
|
||||
cmdOpts.Env[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
return cmdStr, &cmdOpts, nil
|
||||
}
|
||||
|
||||
func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj.MetaMapType) error {
|
||||
// create a circular blockfile for the output
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
@ -220,10 +287,9 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
|
||||
// TODO better sync here (don't let two starts happen at the same times)
|
||||
remoteName := blockMeta.GetString(waveobj.MetaKey_Connection, "")
|
||||
var cmdStr string
|
||||
cmdOpts := shellexec.CommandOptsType{
|
||||
Env: make(map[string]string),
|
||||
}
|
||||
var cmdOpts shellexec.CommandOptsType
|
||||
if bc.ControllerType == BlockController_Shell {
|
||||
cmdOpts.Env = make(map[string]string)
|
||||
cmdOpts.Interactive = true
|
||||
cmdOpts.Login = true
|
||||
cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "")
|
||||
@ -235,32 +301,12 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
|
||||
cmdOpts.Cwd = cwdPath
|
||||
}
|
||||
} else if bc.ControllerType == BlockController_Cmd {
|
||||
cmdStr = blockMeta.GetString(waveobj.MetaKey_Cmd, "")
|
||||
if cmdStr == "" {
|
||||
return fmt.Errorf("missing cmd in block meta")
|
||||
}
|
||||
cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "")
|
||||
if cmdOpts.Cwd != "" {
|
||||
cwdPath, err := wavebase.ExpandHomeDir(cmdOpts.Cwd)
|
||||
var cmdOptsPtr *shellexec.CommandOptsType
|
||||
cmdStr, cmdOptsPtr, err = createCmdStrAndOpts(bc.BlockId, blockMeta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmdOpts.Cwd = cwdPath
|
||||
}
|
||||
cmdOpts.Interactive = blockMeta.GetBool(waveobj.MetaKey_CmdInteractive, false)
|
||||
cmdOpts.Login = blockMeta.GetBool(waveobj.MetaKey_CmdLogin, false)
|
||||
cmdEnv := blockMeta.GetMap(waveobj.MetaKey_CmdEnv)
|
||||
for k, v := range cmdEnv {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := v.(string); ok {
|
||||
cmdOpts.Env[k] = v.(string)
|
||||
}
|
||||
if _, ok := v.(float64); ok {
|
||||
cmdOpts.Env[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
cmdOpts = *cmdOptsPtr
|
||||
} else {
|
||||
return fmt.Errorf("unknown controller type %q", bc.ControllerType)
|
||||
}
|
||||
@ -352,17 +398,21 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
|
||||
wshProxy := wshutil.MakeRpcProxy()
|
||||
wshProxy.SetRpcContext(&wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId})
|
||||
wshutil.DefaultRouter.RegisterRoute(wshutil.MakeControllerRouteId(bc.BlockId), wshProxy, true)
|
||||
ptyBuffer := wshutil.MakePtyBuffer(wshutil.WaveOSCPrefix, bc.ShellProc.Cmd, wshProxy.FromRemoteCh)
|
||||
ptyBuffer := wshutil.MakePtyBuffer(wshutil.WaveOSCPrefix, shellProc.Cmd, wshProxy.FromRemoteCh)
|
||||
go func() {
|
||||
// handles regular output from the pty (goes to the blockfile and xterm)
|
||||
defer panichandler.PanicHandler("blockcontroller:shellproc-pty-read-loop")
|
||||
defer func() {
|
||||
log.Printf("[shellproc] pty-read loop done\n")
|
||||
bc.ShellProc.Close()
|
||||
shellProc.Close()
|
||||
bc.WithLock(func() {
|
||||
// so no other events are sent
|
||||
bc.ShellInputCh = nil
|
||||
})
|
||||
shellProc.Cmd.Wait()
|
||||
exitCode := shellProc.Cmd.ExitCode()
|
||||
termMsg := fmt.Sprintf("\r\nprocess finished with exit code = %d\r\n\r\n", exitCode)
|
||||
HandleAppendBlockFile(bc.BlockId, BlockFile_Term, []byte(termMsg))
|
||||
// to stop the inputCh loop
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(shellInputCh) // don't use bc.ShellInputCh (it's nil)
|
||||
@ -391,14 +441,14 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
|
||||
defer panichandler.PanicHandler("blockcontroller:shellproc-input-loop")
|
||||
for ic := range shellInputCh {
|
||||
if len(ic.InputData) > 0 {
|
||||
bc.ShellProc.Cmd.Write(ic.InputData)
|
||||
shellProc.Cmd.Write(ic.InputData)
|
||||
}
|
||||
if ic.TermSize != nil {
|
||||
err = setTermSize(ctx, bc.BlockId, *ic.TermSize)
|
||||
if err != nil {
|
||||
log.Printf("error setting pty size: %v\n", err)
|
||||
}
|
||||
err = bc.ShellProc.Cmd.SetSize(ic.TermSize.Rows, ic.TermSize.Cols)
|
||||
err = shellProc.Cmd.SetSize(ic.TermSize.Rows, ic.TermSize.Cols)
|
||||
if err != nil {
|
||||
log.Printf("error setting pty size: %v\n", err)
|
||||
}
|
||||
@ -419,24 +469,49 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
|
||||
go func() {
|
||||
defer panichandler.PanicHandler("blockcontroller:shellproc-wait-loop")
|
||||
// wait for the shell to finish
|
||||
var exitCode int
|
||||
defer func() {
|
||||
wshutil.DefaultRouter.UnregisterRoute(wshutil.MakeControllerRouteId(bc.BlockId))
|
||||
bc.UpdateControllerAndSendUpdate(func() bool {
|
||||
bc.ShellProcStatus = Status_Done
|
||||
bc.ShellProcExitCode = exitCode
|
||||
return true
|
||||
})
|
||||
log.Printf("[shellproc] shell process wait loop done\n")
|
||||
}()
|
||||
waitErr := shellProc.Cmd.Wait()
|
||||
exitCode := shellexec.ExitCodeFromWaitErr(waitErr)
|
||||
termMsg := fmt.Sprintf("\r\nprocess finished with exit code = %d\r\n\r\n", exitCode)
|
||||
//HandleAppendBlockFile(bc.BlockId, BlockFile_Term, []byte("\r\n"))
|
||||
HandleAppendBlockFile(bc.BlockId, BlockFile_Term, []byte(termMsg))
|
||||
exitCode = shellProc.Cmd.ExitCode()
|
||||
shellProc.SetWaitErrorAndSignalDone(waitErr)
|
||||
go checkCloseOnExit(bc.BlockId, exitCode)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkCloseOnExit(blockId string, exitCode int) {
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancelFn()
|
||||
blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)
|
||||
if err != nil {
|
||||
log.Printf("error getting block data: %v\n", err)
|
||||
return
|
||||
}
|
||||
closeOnExit := blockData.Meta.GetBool(waveobj.MetaKey_CmdCloseOnExit, false)
|
||||
closeOnExitForce := blockData.Meta.GetBool(waveobj.MetaKey_CmdCloseOnExitForce, false)
|
||||
if !closeOnExitForce && !(closeOnExit && exitCode == 0) {
|
||||
return
|
||||
}
|
||||
delayMs := blockData.Meta.GetFloat(waveobj.MetaKey_CmdCloseOnExitDelay, 2000)
|
||||
if delayMs < 0 {
|
||||
delayMs = 0
|
||||
}
|
||||
time.Sleep(time.Duration(delayMs) * time.Millisecond)
|
||||
rpcClient := wshclient.GetBareRpcClient()
|
||||
err = wshclient.DeleteBlockCommand(rpcClient, wshrpc.CommandDeleteBlockData{BlockId: blockId}, nil)
|
||||
if err != nil {
|
||||
log.Printf("error deleting block data (close on exit): %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getBoolFromMeta(meta map[string]any, key string, def bool) bool {
|
||||
ival, found := meta[key]
|
||||
if !found || ival == nil {
|
||||
@ -474,20 +549,35 @@ func setTermSize(ctx context.Context, blockId string, termSize waveobj.TermSize)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bc *BlockController) run(bdata *waveobj.Block, blockMeta map[string]any, rtOpts *waveobj.RuntimeOpts) {
|
||||
func (bc *BlockController) run(bdata *waveobj.Block, blockMeta map[string]any, rtOpts *waveobj.RuntimeOpts, force bool) {
|
||||
curStatus := bc.GetRuntimeStatus()
|
||||
controllerName := bdata.Meta.GetString(waveobj.MetaKey_Controller, "")
|
||||
if controllerName != BlockController_Shell && controllerName != BlockController_Cmd {
|
||||
log.Printf("unknown controller %q\n", controllerName)
|
||||
return
|
||||
}
|
||||
runOnce := getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdRunOnce, false)
|
||||
runOnStart := getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdRunOnStart, true)
|
||||
if ((runOnStart || runOnce) && curStatus.ShellProcStatus == Status_Init) || force {
|
||||
if getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdClearOnStart, false) {
|
||||
err := HandleTruncateBlockFile(bc.BlockId, BlockFile_Term)
|
||||
err := HandleTruncateBlockFile(bc.BlockId)
|
||||
if err != nil {
|
||||
log.Printf("error truncating term blockfile: %v\n", err)
|
||||
}
|
||||
}
|
||||
runOnStart := getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdRunOnStart, true)
|
||||
if runOnStart {
|
||||
if runOnce {
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancelFn()
|
||||
metaUpdate := map[string]any{
|
||||
waveobj.MetaKey_CmdRunOnce: false,
|
||||
waveobj.MetaKey_CmdRunOnStart: false,
|
||||
}
|
||||
err := wstore.UpdateObjectMeta(ctx, waveobj.MakeORef(waveobj.OType_Block, bc.BlockId), metaUpdate)
|
||||
if err != nil {
|
||||
log.Printf("error updating block meta (in blockcontroller.run): %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
defer panichandler.PanicHandler("blockcontroller:run-shell-command")
|
||||
var termSize waveobj.TermSize
|
||||
@ -579,7 +669,7 @@ func getOrCreateBlockController(tabId string, blockId string, controllerName str
|
||||
ControllerType: controllerName,
|
||||
TabId: tabId,
|
||||
BlockId: blockId,
|
||||
ShellProcStatus: Status_Done,
|
||||
ShellProcStatus: Status_Init,
|
||||
}
|
||||
blockControllerMap[blockId] = bc
|
||||
createdController = true
|
||||
@ -587,7 +677,7 @@ func getOrCreateBlockController(tabId string, blockId string, controllerName str
|
||||
return bc
|
||||
}
|
||||
|
||||
func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts *waveobj.RuntimeOpts) error {
|
||||
func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts *waveobj.RuntimeOpts, force bool) error {
|
||||
if tabId == "" || blockId == "" {
|
||||
return fmt.Errorf("invalid tabId or blockId passed to ResyncController")
|
||||
}
|
||||
@ -595,6 +685,9 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting block: %w", err)
|
||||
}
|
||||
if force {
|
||||
StopBlockController(blockId)
|
||||
}
|
||||
connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "")
|
||||
controllerName := blockData.Meta.GetString(waveobj.MetaKey_Controller, "")
|
||||
curBc := GetBlockController(blockId)
|
||||
@ -619,16 +712,16 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts
|
||||
}
|
||||
}
|
||||
if curBc == nil {
|
||||
return startBlockController(ctx, tabId, blockId, rtOpts)
|
||||
return startBlockController(ctx, tabId, blockId, rtOpts, force)
|
||||
}
|
||||
bcStatus := curBc.GetRuntimeStatus()
|
||||
if bcStatus.ShellProcStatus != Status_Running {
|
||||
return startBlockController(ctx, tabId, blockId, rtOpts)
|
||||
if bcStatus.ShellProcStatus == Status_Init || bcStatus.ShellProcStatus == Status_Done {
|
||||
return startBlockController(ctx, tabId, blockId, rtOpts, force)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func startBlockController(ctx context.Context, tabId string, blockId string, rtOpts *waveobj.RuntimeOpts) error {
|
||||
func startBlockController(ctx context.Context, tabId string, blockId string, rtOpts *waveobj.RuntimeOpts, force bool) error {
|
||||
blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting block: %w", err)
|
||||
@ -649,8 +742,8 @@ func startBlockController(ctx context.Context, tabId string, blockId string, rtO
|
||||
}
|
||||
bc := getOrCreateBlockController(tabId, blockId, controllerName)
|
||||
bcStatus := bc.GetRuntimeStatus()
|
||||
if bcStatus.ShellProcStatus == Status_Done {
|
||||
go bc.run(blockData, blockData.Meta, rtOpts)
|
||||
if bcStatus.ShellProcStatus == Status_Init || bcStatus.ShellProcStatus == Status_Done {
|
||||
go bc.run(blockData, blockData.Meta, rtOpts, force)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -55,7 +55,7 @@ func (svc *WindowService) CreateWindow(ctx context.Context, winSize *waveobj.Win
|
||||
return nil, fmt.Errorf("error getting workspace: %w", err)
|
||||
}
|
||||
if len(ws.TabIds) == 0 {
|
||||
_, err = wcore.CreateTab(ctx, ws.OID, "", true)
|
||||
_, err = wcore.CreateTab(ctx, ws.OID, "", true, false)
|
||||
if err != nil {
|
||||
return window, fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package workspaceservice
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/wavetermdev/waveterm/pkg/blockcontroller"
|
||||
@ -68,16 +69,16 @@ func (svg *WorkspaceService) ListWorkspaces() (waveobj.WorkspaceList, error) {
|
||||
|
||||
func (svc *WorkspaceService) CreateTab_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"workspaceId", "tabName", "activateTab"},
|
||||
ArgNames: []string{"workspaceId", "tabName", "activateTab", "pinned"},
|
||||
ReturnDesc: "tabId",
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) {
|
||||
func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activateTab bool, pinned bool) (string, waveobj.UpdatesRtnType, error) {
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancelFn()
|
||||
ctx = waveobj.ContextWithUpdates(ctx)
|
||||
tabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab)
|
||||
tabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab, pinned)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
@ -93,17 +94,39 @@ func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activ
|
||||
return tabId, updates, nil
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) UpdateTabIds_Meta() tsgenmeta.MethodMeta {
|
||||
func (svc *WorkspaceService) ChangeTabPinning_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"uiContext", "workspaceId", "tabIds"},
|
||||
ArgNames: []string{"ctx", "workspaceId", "tabId", "pinned"},
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) UpdateTabIds(uiContext waveobj.UIContext, workspaceId string, tabIds []string) (waveobj.UpdatesRtnType, error) {
|
||||
func (svc *WorkspaceService) ChangeTabPinning(ctx context.Context, workspaceId string, tabId string, pinned bool) (waveobj.UpdatesRtnType, error) {
|
||||
log.Printf("ChangeTabPinning %s %s %v\n", workspaceId, tabId, pinned)
|
||||
ctx = waveobj.ContextWithUpdates(ctx)
|
||||
err := wcore.ChangeTabPinning(ctx, workspaceId, tabId, pinned)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error toggling tab pinning: %w", err)
|
||||
}
|
||||
updates := waveobj.ContextGetUpdatesRtn(ctx)
|
||||
go func() {
|
||||
defer panichandler.PanicHandler("WorkspaceService:ChangeTabPinning:SendUpdateEvents")
|
||||
wps.Broker.SendUpdateEvents(updates)
|
||||
}()
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) UpdateTabIds_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"uiContext", "workspaceId", "tabIds", "pinnedTabIds"},
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) UpdateTabIds(uiContext waveobj.UIContext, workspaceId string, tabIds []string, pinnedTabIds []string) (waveobj.UpdatesRtnType, error) {
|
||||
log.Printf("UpdateTabIds %s %v %v\n", workspaceId, tabIds, pinnedTabIds)
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancelFn()
|
||||
ctx = waveobj.ContextWithUpdates(ctx)
|
||||
err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds)
|
||||
err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds, pinnedTabIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating workspace tab ids: %w", err)
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@ -19,6 +20,7 @@ type ConnInterface interface {
|
||||
KillGraceful(time.Duration)
|
||||
Wait() error
|
||||
Start() error
|
||||
ExitCode() int
|
||||
StdinPipe() (io.WriteCloser, error)
|
||||
StdoutPipe() (io.ReadCloser, error)
|
||||
StderrPipe() (io.ReadCloser, error)
|
||||
@ -28,15 +30,37 @@ type ConnInterface interface {
|
||||
|
||||
type CmdWrap struct {
|
||||
Cmd *exec.Cmd
|
||||
WaitOnce *sync.Once
|
||||
WaitErr error
|
||||
pty.Pty
|
||||
}
|
||||
|
||||
func MakeCmdWrap(cmd *exec.Cmd, cmdPty pty.Pty) CmdWrap {
|
||||
return CmdWrap{
|
||||
Cmd: cmd,
|
||||
WaitOnce: &sync.Once{},
|
||||
Pty: cmdPty,
|
||||
}
|
||||
}
|
||||
|
||||
func (cw CmdWrap) Kill() {
|
||||
cw.Cmd.Process.Kill()
|
||||
}
|
||||
|
||||
func (cw CmdWrap) Wait() error {
|
||||
return cw.Cmd.Wait()
|
||||
cw.WaitOnce.Do(func() {
|
||||
cw.WaitErr = cw.Cmd.Wait()
|
||||
})
|
||||
return cw.WaitErr
|
||||
}
|
||||
|
||||
// only valid once Wait() has returned (or you know Cmd is done)
|
||||
func (cw CmdWrap) ExitCode() int {
|
||||
state := cw.Cmd.ProcessState
|
||||
if state == nil {
|
||||
return -1
|
||||
}
|
||||
return state.ExitCode()
|
||||
}
|
||||
|
||||
func (cw CmdWrap) KillGraceful(timeout time.Duration) {
|
||||
@ -95,9 +119,21 @@ type SessionWrap struct {
|
||||
Session *ssh.Session
|
||||
StartCmd string
|
||||
Tty pty.Tty
|
||||
WaitOnce *sync.Once
|
||||
WaitErr error
|
||||
pty.Pty
|
||||
}
|
||||
|
||||
func MakeSessionWrap(session *ssh.Session, startCmd string, sessionPty pty.Pty) SessionWrap {
|
||||
return SessionWrap{
|
||||
Session: session,
|
||||
StartCmd: startCmd,
|
||||
Tty: sessionPty,
|
||||
WaitOnce: &sync.Once{},
|
||||
Pty: sessionPty,
|
||||
}
|
||||
}
|
||||
|
||||
func (sw SessionWrap) Kill() {
|
||||
sw.Tty.Close()
|
||||
sw.Session.Close()
|
||||
@ -107,8 +143,19 @@ func (sw SessionWrap) KillGraceful(timeout time.Duration) {
|
||||
sw.Kill()
|
||||
}
|
||||
|
||||
func (sw SessionWrap) ExitCode() int {
|
||||
waitErr := sw.WaitErr
|
||||
if waitErr == nil {
|
||||
return -1
|
||||
}
|
||||
return ExitCodeFromWaitErr(waitErr)
|
||||
}
|
||||
|
||||
func (sw SessionWrap) Wait() error {
|
||||
return sw.Session.Wait()
|
||||
sw.WaitOnce.Do(func() {
|
||||
sw.WaitErr = sw.Session.Wait()
|
||||
})
|
||||
return sw.WaitErr
|
||||
}
|
||||
|
||||
func (sw SessionWrap) Start() error {
|
||||
|
@ -232,7 +232,8 @@ func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ShellProc{Cmd: CmdWrap{ecmd, cmdPty}, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
|
||||
cmdWrap := MakeCmdWrap(ecmd, cmdPty)
|
||||
return &ShellProc{Cmd: cmdWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
|
||||
}
|
||||
|
||||
func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) {
|
||||
@ -270,7 +271,7 @@ func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts Comm
|
||||
session.Stderr = remoteStdoutWrite
|
||||
|
||||
session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil)
|
||||
sessionWrap := SessionWrap{session, "", pipePty, pipePty}
|
||||
sessionWrap := MakeSessionWrap(session, "", pipePty)
|
||||
err = session.Shell()
|
||||
if err != nil {
|
||||
pipePty.Close()
|
||||
@ -381,8 +382,7 @@ func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts Comm
|
||||
}
|
||||
|
||||
session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil)
|
||||
|
||||
sessionWrap := SessionWrap{session, cmdCombined, pipePty, pipePty}
|
||||
sessionWrap := MakeSessionWrap(session, cmdCombined, pipePty)
|
||||
err = sessionWrap.Start()
|
||||
if err != nil {
|
||||
pipePty.Close()
|
||||
@ -469,7 +469,8 @@ func StartShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOpt
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ShellProc{Cmd: CmdWrap{ecmd, cmdPty}, CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
|
||||
cmdWrap := MakeCmdWrap(ecmd, cmdPty)
|
||||
return &ShellProc{Cmd: cmdWrap, CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
|
||||
}
|
||||
|
||||
func RunSimpleCmdInPty(ecmd *exec.Cmd, termSize waveobj.TermSize) ([]byte, error) {
|
||||
|
@ -80,33 +80,25 @@ func GetBool(v interface{}, field string) bool {
|
||||
|
||||
var needsQuoteRe = regexp.MustCompile(`[^\w@%:,./=+-]`)
|
||||
|
||||
// minimum maxlen=6
|
||||
// minimum maxlen=6, pass -1 for no max length
|
||||
func ShellQuote(val string, forceQuote bool, maxLen int) string {
|
||||
if maxLen < 6 {
|
||||
if maxLen != -1 && maxLen < 6 {
|
||||
maxLen = 6
|
||||
}
|
||||
rtn := val
|
||||
if needsQuoteRe.MatchString(val) {
|
||||
rtn = "'" + strings.ReplaceAll(val, "'", `'"'"'`) + "'"
|
||||
} else if forceQuote {
|
||||
rtn = "\"" + rtn + "\""
|
||||
}
|
||||
if maxLen == -1 || len(rtn) <= maxLen {
|
||||
return rtn
|
||||
}
|
||||
if strings.HasPrefix(rtn, "\"") || strings.HasPrefix(rtn, "'") {
|
||||
if len(rtn) > maxLen {
|
||||
return rtn[0:maxLen-4] + "..." + rtn[0:1]
|
||||
return rtn[0:maxLen-4] + "..." + rtn[len(rtn)-1:]
|
||||
}
|
||||
return rtn
|
||||
}
|
||||
if forceQuote {
|
||||
if len(rtn) > maxLen-2 {
|
||||
return "\"" + rtn[0:maxLen-5] + "...\""
|
||||
}
|
||||
return "\"" + rtn + "\""
|
||||
} else {
|
||||
if len(rtn) > maxLen {
|
||||
return rtn[0:maxLen-3] + "..."
|
||||
}
|
||||
return rtn
|
||||
}
|
||||
}
|
||||
|
||||
func EllipsisStr(s string, maxLen int) string {
|
||||
if maxLen < 4 {
|
||||
|
@ -43,10 +43,15 @@ const (
|
||||
MetaKey_CmdLogin = "cmd:login"
|
||||
MetaKey_CmdRunOnStart = "cmd:runonstart"
|
||||
MetaKey_CmdClearOnStart = "cmd:clearonstart"
|
||||
MetaKey_CmdClearOnRestart = "cmd:clearonrestart"
|
||||
MetaKey_CmdRunOnce = "cmd:runonce"
|
||||
MetaKey_CmdCloseOnExit = "cmd:closeonexit"
|
||||
MetaKey_CmdCloseOnExitForce = "cmd:closeonexitforce"
|
||||
MetaKey_CmdCloseOnExitDelay = "cmd:closeonexitdelay"
|
||||
MetaKey_CmdEnv = "cmd:env"
|
||||
MetaKey_CmdCwd = "cmd:cwd"
|
||||
MetaKey_CmdNoWsh = "cmd:nowsh"
|
||||
MetaKey_CmdArgs = "cmd:args"
|
||||
MetaKey_CmdShell = "cmd:shell"
|
||||
|
||||
MetaKey_AiClear = "ai:*"
|
||||
MetaKey_AiPresetKey = "ai:preset"
|
||||
@ -61,6 +66,8 @@ const (
|
||||
MetaKey_AiTimeoutMs = "ai:timeoutms"
|
||||
|
||||
MetaKey_EditorClear = "editor:*"
|
||||
MetaKey_EditorMinimapEnabled = "editor:minimapenabled"
|
||||
MetaKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled"
|
||||
MetaKey_EditorWordWrap = "editor:wordwrap"
|
||||
|
||||
MetaKey_GraphClear = "graph:*"
|
||||
|
@ -171,6 +171,7 @@ type Workspace struct {
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
TabIds []string `json:"tabids"`
|
||||
PinnedTabIds []string `json:"pinnedtabids"`
|
||||
ActiveTabId string `json:"activetabid"`
|
||||
Meta MetaMapType `json:"meta"`
|
||||
}
|
||||
@ -230,9 +231,6 @@ func (*LayoutState) GetOType() string {
|
||||
}
|
||||
|
||||
type FileDef struct {
|
||||
FileType string `json:"filetype,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
}
|
||||
@ -279,7 +277,6 @@ type Block struct {
|
||||
OID string `json:"oid"`
|
||||
ParentORef string `json:"parentoref,omitempty"`
|
||||
Version int `json:"version"`
|
||||
BlockDef *BlockDef `json:"blockdef"`
|
||||
RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"`
|
||||
Stickers []*StickerType `json:"stickers,omitempty"`
|
||||
Meta MetaMapType `json:"meta"`
|
||||
|
@ -42,10 +42,15 @@ type MetaTSType struct {
|
||||
CmdLogin bool `json:"cmd:login,omitempty"`
|
||||
CmdRunOnStart bool `json:"cmd:runonstart,omitempty"`
|
||||
CmdClearOnStart bool `json:"cmd:clearonstart,omitempty"`
|
||||
CmdClearOnRestart bool `json:"cmd:clearonrestart,omitempty"`
|
||||
CmdRunOnce bool `json:"cmd:runonce,omitempty"`
|
||||
CmdCloseOnExit bool `json:"cmd:closeonexit,omitempty"`
|
||||
CmdCloseOnExitForce bool `json:"cmd:closeonexitforce,omitempty"`
|
||||
CmdCloseOnExitDelay float64 `json:"cmd:closeonexitdelay,omitempty"`
|
||||
CmdEnv map[string]string `json:"cmd:env,omitempty"`
|
||||
CmdCwd string `json:"cmd:cwd,omitempty"`
|
||||
CmdNoWsh bool `json:"cmd:nowsh,omitempty"`
|
||||
CmdArgs []string `json:"cmd:args,omitempty"` // args for cmd (only if cmd:shell is false)
|
||||
CmdShell bool `json:"cmd:shell,omitempty"` // shell expansion for cmd+args (defaults to true)
|
||||
|
||||
// AI options match settings
|
||||
AiClear bool `json:"ai:*,omitempty"`
|
||||
@ -61,6 +66,8 @@ type MetaTSType struct {
|
||||
AiTimeoutMs float64 `json:"ai:timeoutms,omitempty"`
|
||||
|
||||
EditorClear bool `json:"editor:*,omitempty"`
|
||||
EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"`
|
||||
EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"`
|
||||
EditorWordWrap bool `json:"editor:wordwrap,omitempty"`
|
||||
|
||||
GraphClear bool `json:"graph:*,omitempty"`
|
||||
|
@ -30,6 +30,7 @@ const (
|
||||
|
||||
ConfigKey_EditorMinimapEnabled = "editor:minimapenabled"
|
||||
ConfigKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled"
|
||||
ConfigKey_EditorWordWrap = "editor:wordwrap"
|
||||
|
||||
ConfigKey_WebClear = "web:*"
|
||||
ConfigKey_WebOpenLinksInternally = "web:openlinksinternally"
|
||||
|
@ -57,6 +57,7 @@ type SettingsType struct {
|
||||
|
||||
EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"`
|
||||
EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"`
|
||||
EditorWordWrap bool `json:"editor:wordwrap,omitempty"`
|
||||
|
||||
WebClear bool `json:"web:*,omitempty"`
|
||||
WebOpenLinksInternally bool `json:"web:openlinksinternally,omitempty"`
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/wavetermdev/waveterm/pkg/blockcontroller"
|
||||
"github.com/wavetermdev/waveterm/pkg/filestore"
|
||||
"github.com/wavetermdev/waveterm/pkg/panichandler"
|
||||
"github.com/wavetermdev/waveterm/pkg/telemetry"
|
||||
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
|
||||
@ -41,7 +42,6 @@ func createSubBlockObj(ctx context.Context, parentBlockId string, blockDef *wave
|
||||
blockData := &waveobj.Block{
|
||||
OID: blockId,
|
||||
ParentORef: waveobj.MakeORef(waveobj.OType_Block, parentBlockId).String(),
|
||||
BlockDef: blockDef,
|
||||
RuntimeOpts: nil,
|
||||
Meta: blockDef.Meta,
|
||||
}
|
||||
@ -52,7 +52,19 @@ func createSubBlockObj(ctx context.Context, parentBlockId string, blockDef *wave
|
||||
})
|
||||
}
|
||||
|
||||
func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) {
|
||||
func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (rtnBlock *waveobj.Block, rtnErr error) {
|
||||
var blockCreated bool
|
||||
var newBlockOID string
|
||||
defer func() {
|
||||
if rtnErr == nil {
|
||||
return
|
||||
}
|
||||
// if there was an error, and we created the block, clean it up since the function failed
|
||||
if blockCreated && newBlockOID != "" {
|
||||
deleteBlockObj(ctx, newBlockOID)
|
||||
filestore.WFS.DeleteZone(ctx, newBlockOID)
|
||||
}
|
||||
}()
|
||||
if blockDef == nil {
|
||||
return nil, fmt.Errorf("blockDef is nil")
|
||||
}
|
||||
@ -63,6 +75,21 @@ func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef,
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating block: %w", err)
|
||||
}
|
||||
blockCreated = true
|
||||
newBlockOID = blockData.OID
|
||||
// upload the files if present
|
||||
if len(blockDef.Files) > 0 {
|
||||
for fileName, fileDef := range blockDef.Files {
|
||||
err := filestore.WFS.MakeFile(ctx, newBlockOID, fileName, fileDef.Meta, filestore.FileOptsType{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error making blockfile %q: %w", fileName, err)
|
||||
}
|
||||
err = filestore.WFS.WriteFile(ctx, newBlockOID, fileName, []byte(fileDef.Content))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error writing blockfile %q: %w", fileName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
defer panichandler.PanicHandler("CreateBlock:telemetry")
|
||||
blockView := blockDef.Meta.GetString(waveobj.MetaKey_View, "")
|
||||
@ -88,7 +115,6 @@ func createBlockObj(ctx context.Context, tabId string, blockDef *waveobj.BlockDe
|
||||
blockData := &waveobj.Block{
|
||||
OID: blockId,
|
||||
ParentORef: waveobj.MakeORef(waveobj.OType_Tab, tabId).String(),
|
||||
BlockDef: blockDef,
|
||||
RuntimeOpts: rtOpts,
|
||||
Meta: blockDef.Meta,
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wstore"
|
||||
)
|
||||
@ -62,7 +63,7 @@ func EnsureInitialData() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating default workspace: %w", err)
|
||||
}
|
||||
_, err = CreateTab(ctx, defaultWs.OID, "", true)
|
||||
_, err = CreateTab(ctx, defaultWs.OID, "", true, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
@ -90,6 +91,5 @@ func GetClientData(ctx context.Context) (*waveobj.Client, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting client data: %w", err)
|
||||
}
|
||||
log.Printf("clientData: %v\n", clientData)
|
||||
return clientData, nil
|
||||
}
|
||||
|
@ -176,7 +176,7 @@ func CheckAndFixWindow(ctx context.Context, windowId string) *waveobj.Window {
|
||||
}
|
||||
if len(ws.TabIds) == 0 {
|
||||
log.Printf("fixing workspace with no tabs %q (in checkAndFixWindow)\n", ws.OID)
|
||||
_, err = CreateTab(ctx, ws.OID, "", true)
|
||||
_, err = CreateTab(ctx, ws.OID, "", true, false)
|
||||
if err != nil {
|
||||
log.Printf("error creating tab (in checkAndFixWindow): %v\n", err)
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ func CreateWorkspace(ctx context.Context, name string, icon string, color string
|
||||
ws := &waveobj.Workspace{
|
||||
OID: uuid.NewString(),
|
||||
TabIds: []string{},
|
||||
PinnedTabIds: []string{},
|
||||
Name: name,
|
||||
Icon: icon,
|
||||
Color: color,
|
||||
@ -37,11 +38,13 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool,
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error getting workspace: %w", err)
|
||||
}
|
||||
if workspace.Name != "" && workspace.Icon != "" && !force && len(workspace.TabIds) > 0 {
|
||||
if workspace.Name != "" && workspace.Icon != "" && !force && len(workspace.TabIds) > 0 && len(workspace.PinnedTabIds) > 0 {
|
||||
log.Printf("Ignoring DeleteWorkspace for workspace %s as it is named\n", workspaceId)
|
||||
return false, nil
|
||||
}
|
||||
for _, tabId := range workspace.TabIds {
|
||||
|
||||
// delete all pinned and unpinned tabs
|
||||
for _, tabId := range append(workspace.TabIds, workspace.PinnedTabIds...) {
|
||||
log.Printf("deleting tab %s\n", tabId)
|
||||
_, err := DeleteTab(ctx, workspaceId, tabId, false)
|
||||
if err != nil {
|
||||
@ -60,7 +63,30 @@ func GetWorkspace(ctx context.Context, wsID string) (*waveobj.Workspace, error)
|
||||
return wstore.DBMustGet[*waveobj.Workspace](ctx, wsID)
|
||||
}
|
||||
|
||||
func createTabObj(ctx context.Context, workspaceId string, name string) (*waveobj.Tab, error) {
|
||||
// returns tabid
|
||||
func CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool, pinned bool) (string, error) {
|
||||
if tabName == "" {
|
||||
ws, err := GetWorkspace(ctx, workspaceId)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("workspace %s not found: %w", workspaceId, err)
|
||||
}
|
||||
tabName = "T" + fmt.Sprint(len(ws.TabIds)+len(ws.PinnedTabIds)+1)
|
||||
}
|
||||
tab, err := createTabObj(ctx, workspaceId, tabName, pinned)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
if activateTab {
|
||||
err = SetActiveTab(ctx, workspaceId, tab.OID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error setting active tab: %w", err)
|
||||
}
|
||||
}
|
||||
telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NewTab: 1}, "createtab")
|
||||
return tab.OID, nil
|
||||
}
|
||||
|
||||
func createTabObj(ctx context.Context, workspaceId string, name string, pinned bool) (*waveobj.Tab, error) {
|
||||
ws, err := GetWorkspace(ctx, workspaceId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workspace %s not found: %w", workspaceId, err)
|
||||
@ -75,36 +101,17 @@ func createTabObj(ctx context.Context, workspaceId string, name string) (*waveob
|
||||
layoutState := &waveobj.LayoutState{
|
||||
OID: layoutStateId,
|
||||
}
|
||||
if pinned {
|
||||
ws.PinnedTabIds = append(ws.PinnedTabIds, tab.OID)
|
||||
} else {
|
||||
ws.TabIds = append(ws.TabIds, tab.OID)
|
||||
}
|
||||
wstore.DBInsert(ctx, tab)
|
||||
wstore.DBInsert(ctx, layoutState)
|
||||
wstore.DBUpdate(ctx, ws)
|
||||
return tab, nil
|
||||
}
|
||||
|
||||
// returns tabid
|
||||
func CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool) (string, error) {
|
||||
if tabName == "" {
|
||||
ws, err := GetWorkspace(ctx, workspaceId)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("workspace %s not found: %w", workspaceId, err)
|
||||
}
|
||||
tabName = "T" + fmt.Sprint(len(ws.TabIds)+1)
|
||||
}
|
||||
tab, err := createTabObj(ctx, workspaceId, tabName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
if activateTab {
|
||||
err = SetActiveTab(ctx, workspaceId, tab.OID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error setting active tab: %w", err)
|
||||
}
|
||||
}
|
||||
telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NewTab: 1}, "createtab")
|
||||
return tab.OID, nil
|
||||
}
|
||||
|
||||
// Must delete all blocks individually first.
|
||||
// Also deletes LayoutState.
|
||||
// recursive: if true, will recursively close parent window, workspace, if they are empty.
|
||||
@ -114,38 +121,50 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive
|
||||
if ws == nil {
|
||||
return "", fmt.Errorf("workspace not found: %q", workspaceId)
|
||||
}
|
||||
|
||||
// ensure tab is in workspace
|
||||
tabIdx := utilfn.FindStringInSlice(ws.TabIds, tabId)
|
||||
tabIdxPinned := utilfn.FindStringInSlice(ws.PinnedTabIds, tabId)
|
||||
if tabIdx != -1 {
|
||||
ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...)
|
||||
} else if tabIdxPinned != -1 {
|
||||
ws.PinnedTabIds = append(ws.PinnedTabIds[:tabIdxPinned], ws.PinnedTabIds[tabIdxPinned+1:]...)
|
||||
} else {
|
||||
return "", fmt.Errorf("tab %s not found in workspace %s", tabId, workspaceId)
|
||||
}
|
||||
|
||||
// close blocks (sends events + stops block controllers)
|
||||
tab, _ := wstore.DBGet[*waveobj.Tab](ctx, tabId)
|
||||
if tab == nil {
|
||||
return "", fmt.Errorf("tab not found: %q", tabId)
|
||||
}
|
||||
|
||||
// close blocks (sends events + stops block controllers)
|
||||
for _, blockId := range tab.BlockIds {
|
||||
err := DeleteBlock(ctx, blockId, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error deleting block %s: %w", blockId, err)
|
||||
}
|
||||
}
|
||||
tabIdx := utilfn.FindStringInSlice(ws.TabIds, tabId)
|
||||
if tabIdx == -1 {
|
||||
return "", nil
|
||||
}
|
||||
ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...)
|
||||
|
||||
// if the tab is active, determine new active tab
|
||||
newActiveTabId := ws.ActiveTabId
|
||||
if len(ws.TabIds) > 0 {
|
||||
if ws.ActiveTabId == tabId {
|
||||
if len(ws.TabIds) > 0 && tabIdx != -1 {
|
||||
newActiveTabId = ws.TabIds[max(0, min(tabIdx-1, len(ws.TabIds)-1))]
|
||||
}
|
||||
} else if len(ws.PinnedTabIds) > 0 {
|
||||
newActiveTabId = ws.PinnedTabIds[0]
|
||||
} else {
|
||||
newActiveTabId = ""
|
||||
}
|
||||
}
|
||||
ws.ActiveTabId = newActiveTabId
|
||||
|
||||
wstore.DBUpdate(ctx, ws)
|
||||
wstore.DBDelete(ctx, waveobj.OType_Tab, tabId)
|
||||
wstore.DBDelete(ctx, waveobj.OType_LayoutState, tab.LayoutState)
|
||||
|
||||
// if no tabs remaining, close window
|
||||
if newActiveTabId == "" && recursive {
|
||||
log.Printf("no tabs remaining in workspace %s, closing window\n", workspaceId)
|
||||
windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId)
|
||||
if err != nil {
|
||||
return newActiveTabId, fmt.Errorf("unable to find window for workspace id %v: %w", workspaceId, err)
|
||||
@ -159,7 +178,7 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive
|
||||
}
|
||||
|
||||
func SetActiveTab(ctx context.Context, workspaceId string, tabId string) error {
|
||||
if tabId != "" {
|
||||
if tabId != "" && workspaceId != "" {
|
||||
workspace, err := GetWorkspace(ctx, workspaceId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("workspace %s not found: %w", workspaceId, err)
|
||||
@ -174,6 +193,30 @@ func SetActiveTab(ctx context.Context, workspaceId string, tabId string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ChangeTabPinning(ctx context.Context, workspaceId string, tabId string, pinned bool) error {
|
||||
if tabId != "" && workspaceId != "" {
|
||||
workspace, err := GetWorkspace(ctx, workspaceId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("workspace %s not found: %w", workspaceId, err)
|
||||
}
|
||||
if pinned && utilfn.FindStringInSlice(workspace.PinnedTabIds, tabId) == -1 {
|
||||
if utilfn.FindStringInSlice(workspace.TabIds, tabId) == -1 {
|
||||
return fmt.Errorf("tab %s not found in workspace %s", tabId, workspaceId)
|
||||
}
|
||||
workspace.TabIds = utilfn.RemoveElemFromSlice(workspace.TabIds, tabId)
|
||||
workspace.PinnedTabIds = append(workspace.PinnedTabIds, tabId)
|
||||
} else if !pinned && utilfn.FindStringInSlice(workspace.PinnedTabIds, tabId) != -1 {
|
||||
if utilfn.FindStringInSlice(workspace.PinnedTabIds, tabId) == -1 {
|
||||
return fmt.Errorf("tab %s not found in workspace %s", tabId, workspaceId)
|
||||
}
|
||||
workspace.PinnedTabIds = utilfn.RemoveElemFromSlice(workspace.PinnedTabIds, tabId)
|
||||
workspace.TabIds = append([]string{tabId}, workspace.TabIds...)
|
||||
}
|
||||
wstore.DBUpdate(ctx, workspace)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SendActiveTabUpdate(ctx context.Context, workspaceId string, newActiveTabId string) {
|
||||
eventbus.SendEventToElectron(eventbus.WSEventType{
|
||||
EventType: eventbus.WSEvent_ElectronUpdateActiveTab,
|
||||
@ -181,12 +224,13 @@ func SendActiveTabUpdate(ctx context.Context, workspaceId string, newActiveTabId
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []string) error {
|
||||
func UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []string, pinnedTabIds []string) error {
|
||||
ws, _ := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId)
|
||||
if ws == nil {
|
||||
return fmt.Errorf("workspace not found: %q", workspaceId)
|
||||
}
|
||||
ws.TabIds = tabIds
|
||||
ws.PinnedTabIds = pinnedTabIds
|
||||
wstore.DBUpdate(ctx, ws)
|
||||
return nil
|
||||
}
|
||||
|
@ -532,6 +532,7 @@ type BlockInfoData struct {
|
||||
TabId string `json:"tabid"`
|
||||
WorkspaceId string `json:"workspaceid"`
|
||||
Block *waveobj.Block `json:"block"`
|
||||
Files []*filestore.WaveFile `json:"files"`
|
||||
}
|
||||
|
||||
type WaveNotificationOptions struct {
|
||||
|
@ -231,10 +231,7 @@ func (ws *WshServer) ControllerStopCommand(ctx context.Context, blockId string)
|
||||
}
|
||||
|
||||
func (ws *WshServer) ControllerResyncCommand(ctx context.Context, data wshrpc.CommandControllerResyncData) error {
|
||||
if data.ForceRestart {
|
||||
blockcontroller.StopBlockController(data.BlockId)
|
||||
}
|
||||
return blockcontroller.ResyncController(ctx, data.TabId, data.BlockId, data.RtOpts)
|
||||
return blockcontroller.ResyncController(ctx, data.TabId, data.BlockId, data.RtOpts, data.ForceRestart)
|
||||
}
|
||||
|
||||
func (ws *WshServer) ControllerInputCommand(ctx context.Context, data wshrpc.CommandBlockInputData) error {
|
||||
@ -701,11 +698,16 @@ func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wsh
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error finding window for tab: %w", err)
|
||||
}
|
||||
fileList, err := filestore.WFS.ListFiles(ctx, blockId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error listing blockfiles: %w", err)
|
||||
}
|
||||
return &wshrpc.BlockInfoData{
|
||||
BlockId: blockId,
|
||||
TabId: tabId,
|
||||
WorkspaceId: workspaceId,
|
||||
Block: blockData,
|
||||
Files: fileList,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
@ -46,6 +46,10 @@ func (wc *WslCmd) GetProcessState() *os.ProcessState {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wc *WslCmd) ExitCode() int {
|
||||
return -1
|
||||
}
|
||||
|
||||
func (c *WslCmd) SetStdin(stdin io.Reader) {
|
||||
c.Stdin = stdin
|
||||
}
|
||||
|
@ -74,6 +74,13 @@ func (c *WslCmd) Wait() (err error) {
|
||||
}
|
||||
return c.waitErr
|
||||
}
|
||||
func (c *WslCmd) ExitCode() int {
|
||||
state := c.c.ProcessState
|
||||
if state == nil {
|
||||
return -1
|
||||
}
|
||||
return state.ExitCode()
|
||||
}
|
||||
func (c *WslCmd) GetProcess() *os.Process {
|
||||
return c.c.Process
|
||||
}
|
||||
|
@ -350,12 +350,28 @@ func DBFindTabForBlockId(ctx context.Context, blockId string) (string, error) {
|
||||
}
|
||||
|
||||
func DBFindWorkspaceForTabId(ctx context.Context, tabId string) (string, error) {
|
||||
log.Printf("DBFindWorkspaceForTabId tabId: %s\n", tabId)
|
||||
return WithTxRtn(ctx, func(tx *TxWrap) (string, error) {
|
||||
query := `
|
||||
WITH variable(value) AS (
|
||||
SELECT ?
|
||||
)
|
||||
SELECT w.oid
|
||||
FROM db_workspace w, json_each(data->'tabids') je
|
||||
WHERE je.value = ?`
|
||||
return tx.GetString(query, tabId), nil
|
||||
FROM db_workspace w, variable
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(w.data, '$.tabids') AS je
|
||||
WHERE je.value = variable.value
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(w.data, '$.pinnedtabids') AS je
|
||||
WHERE je.value = variable.value
|
||||
);
|
||||
`
|
||||
wsId := tx.GetString(query, tabId)
|
||||
log.Printf("DBFindWorkspaceForTabId wsId: %s\n", wsId)
|
||||
return wsId, nil
|
||||
})
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user