merge dev-v0.9

This commit is contained in:
sawka 2024-10-23 22:55:52 -07:00
commit 455af19387
132 changed files with 7131 additions and 1494 deletions

2
.gitattributes vendored
View File

@ -1 +1 @@
* text=auto * text=auto eol=lf

View File

@ -7,6 +7,7 @@ on:
types: [published] types: [published]
jobs: jobs:
publish: publish:
if: ${{ startsWith(github.ref, 'refs/tags/') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -16,10 +17,24 @@ jobs:
version: 3.x version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish from staging - name: Publish from staging
if: startsWith(github.ref, 'refs/tags/')
run: "task artifacts:publish:${{ github.ref_name }}" run: "task artifacts:publish:${{ github.ref_name }}"
env: env:
AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}" AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}" AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}"
AWS_DEFAULT_REGION: us-west-2 AWS_DEFAULT_REGION: us-west-2
shell: bash shell: bash
bump-winget:
if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, 'beta') }}
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Task
uses: arduino/setup-task@v2
with:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Submit Winget version bump
run: "task artifacts:publish:winget:${{ github.ref_name }}"
env:
GITHUB_TOKEN: ${{ secrets.WINGET_BUMP_PAT }}
shell: pwsh

View File

@ -29,6 +29,12 @@ Also available as a Homebrew Cask for macOS:
brew install --cask wave brew install --cask wave
``` ```
Also available via the Windows Package Manager:
```Powershell
winget install CommandLine.Wave
```
### Minimum requirements ### Minimum requirements
Wave Terminal and WSH run on the following platforms: Wave Terminal and WSH run on the following platforms:

View File

@ -13,6 +13,7 @@ vars:
DATE: '{{if eq OS "windows"}}powershell Get-Date -UFormat{{else}}date{{end}}' DATE: '{{if eq OS "windows"}}powershell Get-Date -UFormat{{else}}date{{end}}'
ARTIFACTS_BUCKET: waveterm-github-artifacts/staging-w2 ARTIFACTS_BUCKET: waveterm-github-artifacts/staging-w2
RELEASES_BUCKET: dl.waveterm.dev/releases-w2 RELEASES_BUCKET: dl.waveterm.dev/releases-w2
WINGET_PACKAGE: CommandLine.Wave
tasks: tasks:
electron:dev: electron:dev:
@ -184,8 +185,8 @@ tasks:
generate: generate:
desc: Generate Typescript bindings for the Go backend. desc: Generate Typescript bindings for the Go backend.
cmds: cmds:
- go run cmd/generatets/main-generatets.go - NO_PANIC=1 go run cmd/generatets/main-generatets.go
- go run cmd/generatego/main-generatego.go - NO_PANIC=1 go run cmd/generatego/main-generatego.go
sources: sources:
- "cmd/generatego/*.go" - "cmd/generatego/*.go"
- "cmd/generatets/*.go" - "cmd/generatets/*.go"
@ -230,6 +231,16 @@ tasks:
fi fi
done done
artifacts:publish:winget:*:
desc: Submits a version bump request to WinGet for the latest release.
status:
- exit {{if contains UP_VERSION "beta"}}0{{else}}1{{end}}
vars:
UP_VERSION: '{{ replace "v" "" (index .MATCH 0)}}'
cmd: |
iwr https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
.\wingetcreate.exe update {{.WINGET_PACKAGE}} -s -v {{.UP_VERSION}} -u "https://{{.RELEASES_BUCKET}}/{{.APP_NAME}}-win32-x64-{{.UP_VERSION}}.exe" -t $env:GITHUB_TOKEN
yarn: yarn:
desc: Runs `yarn` desc: Runs `yarn`
internal: true internal: true

View File

@ -29,6 +29,7 @@ func GenerateWshClient() error {
"github.com/wavetermdev/waveterm/pkg/waveobj", "github.com/wavetermdev/waveterm/pkg/waveobj",
"github.com/wavetermdev/waveterm/pkg/wconfig", "github.com/wavetermdev/waveterm/pkg/wconfig",
"github.com/wavetermdev/waveterm/pkg/wps", "github.com/wavetermdev/waveterm/pkg/wps",
"github.com/wavetermdev/waveterm/pkg/vdom",
}) })
wshDeclMap := wshrpc.GenerateWshCommandDeclMap() wshDeclMap := wshrpc.GenerateWshCommandDeclMap()
for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) { for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) {

View File

@ -159,11 +159,11 @@ func shutdownActivityUpdate() {
func createMainWshClient() { func createMainWshClient() {
rpc := wshserver.GetMainRpcClient() rpc := wshserver.GetMainRpcClient()
wshutil.DefaultRouter.RegisterRoute(wshutil.DefaultRoute, rpc) wshutil.DefaultRouter.RegisterRoute(wshutil.DefaultRoute, rpc, true)
wps.Broker.SetClient(wshutil.DefaultRouter) wps.Broker.SetClient(wshutil.DefaultRouter)
localConnWsh := wshutil.MakeWshRpc(nil, nil, wshrpc.RpcContext{Conn: wshrpc.LocalConnName}, &wshremote.ServerImpl{}) localConnWsh := wshutil.MakeWshRpc(nil, nil, wshrpc.RpcContext{Conn: wshrpc.LocalConnName}, &wshremote.ServerImpl{})
go wshremote.RunSysInfoLoop(localConnWsh, wshrpc.LocalConnName) go wshremote.RunSysInfoLoop(localConnWsh, wshrpc.LocalConnName)
wshutil.DefaultRouter.RegisterRoute(wshutil.MakeConnectionRouteId(wshrpc.LocalConnName), localConnWsh) wshutil.DefaultRouter.RegisterRoute(wshutil.MakeConnectionRouteId(wshrpc.LocalConnName), localConnWsh, true)
} }
func main() { func main() {
@ -182,7 +182,7 @@ func main() {
log.Printf("error validating service map: %v\n", err) log.Printf("error validating service map: %v\n", err)
return return
} }
err = wavebase.EnsureWaveHomeDir() err = wavebase.EnsureWaveDataDir()
if err != nil { if err != nil {
log.Printf("error ensuring wave home dir: %v\n", err) log.Printf("error ensuring wave home dir: %v\n", err)
return return
@ -197,6 +197,13 @@ func main() {
log.Printf("error ensuring wave config dir: %v\n", err) log.Printf("error ensuring wave config dir: %v\n", err)
return return
} }
// TODO: rather than ensure this dir exists, we should let the editor recursively create parent dirs on save
err = wavebase.EnsureWavePresetsDir()
if err != nil {
log.Printf("error ensuring wave presets dir: %v\n", err)
return
}
waveLock, err := wavebase.AcquireWaveLock() waveLock, err := wavebase.AcquireWaveLock()
if err != nil { if err != nil {
log.Printf("error acquiring wave lock (another instance of Wave is likely running): %v\n", err) log.Printf("error acquiring wave lock (another instance of Wave is likely running): %v\n", err)
@ -209,7 +216,8 @@ func main() {
} }
}() }()
log.Printf("wave version: %s (%s)\n", WaveVersion, BuildTime) log.Printf("wave version: %s (%s)\n", WaveVersion, BuildTime)
log.Printf("wave home dir: %s\n", wavebase.GetWaveHomeDir()) log.Printf("wave data dir: %s\n", wavebase.GetWaveDataDir())
log.Printf("wave config dir: %s\n", wavebase.GetWaveConfigDir())
err = filestore.InitFilestore() err = filestore.InitFilestore()
if err != nil { if err != nil {
log.Printf("error initializing filestore: %v\n", err) log.Printf("error initializing filestore: %v\n", err)

View File

@ -14,7 +14,7 @@ import (
func Page(ctx context.Context, props map[string]any) any { func Page(ctx context.Context, props map[string]any) any {
clicked, setClicked := vdom.UseState(ctx, false) clicked, setClicked := vdom.UseState(ctx, false)
var clickedDiv *vdom.Elem var clickedDiv *vdom.VDomElem
if clicked { if clicked {
clickedDiv = vdom.Bind(`<div>clicked</div>`, nil) clickedDiv = vdom.Bind(`<div>clicked</div>`, nil)
} }
@ -35,7 +35,7 @@ func Page(ctx context.Context, props map[string]any) any {
} }
func Button(ctx context.Context, props map[string]any) any { func Button(ctx context.Context, props map[string]any) any {
ref := vdom.UseRef(ctx, nil) ref := vdom.UseVDomRef(ctx)
clName, setClName := vdom.UseState(ctx, "button") clName, setClName := vdom.UseState(ctx, "button")
vdom.UseEffect(ctx, func() func() { vdom.UseEffect(ctx, func() func() {
fmt.Printf("Button useEffect\n") fmt.Printf("Button useEffect\n")

View File

@ -5,6 +5,7 @@ package cmd
import ( import (
"fmt" "fmt"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote"
@ -25,17 +26,24 @@ func init() {
} }
func connStatus() error { func connStatus() error {
resp, err := wshclient.ConnStatusCommand(RpcClient, nil) var allResp []wshrpc.ConnStatus
sshResp, err := wshclient.ConnStatusCommand(RpcClient, nil)
if err != nil { if err != nil {
return fmt.Errorf("getting connection status: %w", err) return fmt.Errorf("getting ssh connection status: %w", err)
} }
if len(resp) == 0 { allResp = append(allResp, sshResp...)
wslResp, err := wshclient.WslStatusCommand(RpcClient, nil)
if err != nil {
return fmt.Errorf("getting wsl connection status: %w", err)
}
allResp = append(allResp, wslResp...)
if len(allResp) == 0 {
WriteStdout("no connections\n") WriteStdout("no connections\n")
return nil return nil
} }
WriteStdout("%-30s %-12s\n", "connection", "status") WriteStdout("%-30s %-12s\n", "connection", "status")
WriteStdout("----------------------------------------------\n") WriteStdout("----------------------------------------------\n")
for _, conn := range resp { for _, conn := range allResp {
str := fmt.Sprintf("%-30s %-12s", conn.Connection, conn.Status) str := fmt.Sprintf("%-30s %-12s", conn.Connection, conn.Status)
if conn.Error != "" { if conn.Error != "" {
str += fmt.Sprintf(" (%s)", conn.Error) str += fmt.Sprintf(" (%s)", conn.Error)
@ -110,7 +118,7 @@ func connRun(cmd *cobra.Command, args []string) error {
} }
connName = args[1] connName = args[1]
_, err := remote.ParseOpts(connName) _, err := remote.ParseOpts(connName)
if err != nil { if err != nil && !strings.HasPrefix(connName, "wsl://") {
return fmt.Errorf("cannot parse connection name: %w", err) return fmt.Errorf("cannot parse connection name: %w", err)
} }
} }

View File

@ -4,10 +4,22 @@
package cmd package cmd
import ( import (
"encoding/json"
"fmt"
"io"
"log"
"net"
"os" "os"
"sync/atomic"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/util/packetparser"
"github.com/wavetermdev/waveterm/pkg/wavebase"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote"
"github.com/wavetermdev/waveterm/pkg/wshutil"
) )
var serverCmd = &cobra.Command{ var serverCmd = &cobra.Command{
@ -15,18 +27,163 @@ var serverCmd = &cobra.Command{
Hidden: true, Hidden: true,
Short: "remote server to power wave blocks", Short: "remote server to power wave blocks",
Args: cobra.NoArgs, Args: cobra.NoArgs,
Run: serverRun, RunE: serverRun,
PreRunE: preRunSetupRpcClient,
} }
var connServerRouter bool
func init() { func init() {
serverCmd.Flags().BoolVar(&connServerRouter, "router", false, "run in local router mode")
rootCmd.AddCommand(serverCmd) rootCmd.AddCommand(serverCmd)
} }
func serverRun(cmd *cobra.Command, args []string) { func MakeRemoteUnixListener() (net.Listener, error) {
serverAddr := wavebase.GetRemoteDomainSocketName()
os.Remove(serverAddr) // ignore error
rtn, err := net.Listen("unix", serverAddr)
if err != nil {
return nil, fmt.Errorf("error creating listener at %v: %v", serverAddr, err)
}
os.Chmod(serverAddr, 0700)
log.Printf("Server [unix-domain] listening on %s\n", serverAddr)
return rtn, nil
}
func handleNewListenerConn(conn net.Conn, router *wshutil.WshRouter) {
var routeIdContainer atomic.Pointer[string]
proxy := wshutil.MakeRpcProxy()
go func() {
writeErr := wshutil.AdaptOutputChToStream(proxy.ToRemoteCh, conn)
if writeErr != nil {
log.Printf("error writing to domain socket: %v\n", writeErr)
}
}()
go func() {
// when input is closed, close the connection
defer func() {
conn.Close()
routeIdPtr := routeIdContainer.Load()
if routeIdPtr != nil && *routeIdPtr != "" {
router.UnregisterRoute(*routeIdPtr)
disposeMsg := &wshutil.RpcMessage{
Command: wshrpc.Command_Dispose,
Data: wshrpc.CommandDisposeData{
RouteId: *routeIdPtr,
},
Source: *routeIdPtr,
AuthToken: proxy.GetAuthToken(),
}
disposeBytes, _ := json.Marshal(disposeMsg)
router.InjectMessage(disposeBytes, *routeIdPtr)
}
}()
wshutil.AdaptStreamToMsgCh(conn, proxy.FromRemoteCh)
}()
routeId, err := proxy.HandleClientProxyAuth(router)
if err != nil {
log.Printf("error handling client proxy auth: %v\n", err)
conn.Close()
return
}
router.RegisterRoute(routeId, proxy, false)
routeIdContainer.Store(&routeId)
}
func runListener(listener net.Listener, router *wshutil.WshRouter) {
defer func() {
log.Printf("listener closed, exiting\n")
time.Sleep(500 * time.Millisecond)
wshutil.DoShutdown("", 1, true)
}()
for {
conn, err := listener.Accept()
if err == io.EOF {
break
}
if err != nil {
log.Printf("error accepting connection: %v\n", err)
continue
}
go handleNewListenerConn(conn, router)
}
}
func setupConnServerRpcClientWithRouter(router *wshutil.WshRouter) (*wshutil.WshRpc, error) {
jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName)
if jwtToken == "" {
return nil, fmt.Errorf("no jwt token found for connserver")
}
rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken)
if err != nil {
return nil, fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err)
}
authRtn, err := router.HandleProxyAuth(jwtToken)
if err != nil {
return nil, fmt.Errorf("error handling proxy auth: %v", err)
}
inputCh := make(chan []byte, wshutil.DefaultInputChSize)
outputCh := make(chan []byte, wshutil.DefaultOutputChSize)
connServerClient := wshutil.MakeWshRpc(inputCh, outputCh, *rpcCtx, &wshremote.ServerImpl{LogWriter: os.Stdout})
connServerClient.SetAuthToken(authRtn.AuthToken)
router.RegisterRoute(authRtn.RouteId, connServerClient, false)
wshclient.RouteAnnounceCommand(connServerClient, nil)
return connServerClient, nil
}
func serverRunRouter() error {
router := wshutil.NewWshRouter()
termProxy := wshutil.MakeRpcProxy()
rawCh := make(chan []byte, wshutil.DefaultOutputChSize)
go packetparser.Parse(os.Stdin, termProxy.FromRemoteCh, rawCh)
go func() {
for msg := range termProxy.ToRemoteCh {
packetparser.WritePacket(os.Stdout, msg)
}
}()
go func() {
// just ignore and drain the rawCh (stdin)
// when stdin is closed, shutdown
defer wshutil.DoShutdown("", 0, true)
for range rawCh {
// ignore
}
}()
go func() {
for msg := range termProxy.FromRemoteCh {
// send this to the router
router.InjectMessage(msg, wshutil.UpstreamRoute)
}
}()
router.SetUpstreamClient(termProxy)
// now set up the domain socket
unixListener, err := MakeRemoteUnixListener()
if err != nil {
return fmt.Errorf("cannot create unix listener: %v", err)
}
client, err := setupConnServerRpcClientWithRouter(router)
if err != nil {
return fmt.Errorf("error setting up connserver rpc client: %v", err)
}
go runListener(unixListener, router)
// run the sysinfo loop
wshremote.RunSysInfoLoop(client, client.GetRpcContext().Conn)
select {}
}
func serverRunNormal() error {
err := setupRpcClient(&wshremote.ServerImpl{LogWriter: os.Stdout})
if err != nil {
return err
}
WriteStdout("running wsh connserver (%s)\n", RpcContext.Conn) WriteStdout("running wsh connserver (%s)\n", RpcContext.Conn)
go wshremote.RunSysInfoLoop(RpcClient, RpcContext.Conn) go wshremote.RunSysInfoLoop(RpcClient, RpcContext.Conn)
RpcClient.SetServerImpl(&wshremote.ServerImpl{LogWriter: os.Stdout})
select {} // run forever select {} // run forever
} }
func serverRun(cmd *cobra.Command, args []string) error {
if connServerRouter {
return serverRunRouter()
} else {
return serverRunNormal()
}
}

View File

@ -0,0 +1,47 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"encoding/json"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
)
var debugCmd = &cobra.Command{
Use: "debug",
Short: "debug commands",
PersistentPreRunE: preRunSetupRpcClient,
Hidden: true,
}
var debugBlockIdsCmd = &cobra.Command{
Use: "block",
Short: "list sub-blockids for block",
RunE: debugBlockIdsRun,
Hidden: true,
}
func init() {
debugCmd.AddCommand(debugBlockIdsCmd)
rootCmd.AddCommand(debugCmd)
}
func debugBlockIdsRun(cmd *cobra.Command, args []string) error {
oref, err := resolveBlockArg()
if err != nil {
return err
}
blockInfo, err := wshclient.BlockInfoCommand(RpcClient, oref.OID, nil)
if err != nil {
return err
}
barr, err := json.MarshalIndent(blockInfo, "", " ")
if err != nil {
return err
}
WriteStdout("%s\n", string(barr))
return nil
}

View File

@ -65,11 +65,11 @@ func editorRun(cmd *cobra.Command, args []string) {
return return
} }
doneCh := make(chan bool) doneCh := make(chan bool)
RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) { RpcClient.EventListener.On(wps.Event_BlockClose, func(event *wps.WaveEvent) {
if event.HasScope(blockRef.String()) { if event.HasScope(blockRef.String()) {
close(doneCh) close(doneCh)
} }
}) })
wshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: "blockclose", Scopes: []string{blockRef.String()}}, nil) wshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{blockRef.String()}}, nil)
<-doneCh <-doneCh
} }

View File

@ -4,40 +4,85 @@
package cmd package cmd
import ( import (
"fmt" "log"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/vdom"
"github.com/wavetermdev/waveterm/pkg/vdom/vdomclient"
"github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wshutil"
) )
var htmlCmdNewBlock bool
func init() { func init() {
htmlCmd.Flags().BoolVarP(&htmlCmdNewBlock, "newblock", "n", false, "create a new block")
rootCmd.AddCommand(htmlCmd) rootCmd.AddCommand(htmlCmd)
} }
var htmlCmd = &cobra.Command{ var htmlCmd = &cobra.Command{
Use: "html", Use: "html",
Hidden: true, Hidden: true,
Short: "Launch a demo html-mode terminal", Short: "launch demo vdom application",
Run: htmlRun, RunE: htmlRun,
PreRunE: preRunSetupRpcClient,
} }
func htmlRun(cmd *cobra.Command, args []string) { func MakeVDom() *vdom.VDomElem {
defer wshutil.DoShutdown("normal exit", 0, true) vdomStr := `
setTermHtmlMode() <div>
for { <h1 style="color:red; background-color: #bind:$.bgcolor; border-radius: 4px; padding: 5px;">hello vdom world</h1>
var buf [1]byte <div><bind key="$.text"/> | num[<bind key="$.num"/>]</div>
_, err := WrappedStdin.Read(buf[:]) <div>
<button data-text="hello" onClick='#globalevent:clickinc'>increment</button>
</div>
<div>
<wave:markdown text="*hello from markdown*"/>
</div>
</div>
`
elem := vdom.Bind(vdomStr, nil)
return elem
}
func GlobalEventHandler(client *vdomclient.Client, event vdom.VDomEvent) {
if event.EventType == "clickinc" {
client.SetAtomVal("num", client.GetAtomVal("num").(int)+1)
return
}
}
func htmlRun(cmd *cobra.Command, args []string) error {
WriteStderr("running wsh html %q\n", RpcContext.BlockId)
client, err := vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true})
if err != nil { if err != nil {
wshutil.DoShutdown(fmt.Sprintf("stdin closed/error (%v)", err), 1, true) return err
} }
if buf[0] == 0x03 { client.SetGlobalEventHandler(GlobalEventHandler)
wshutil.DoShutdown("read Ctrl-C from stdin", 1, true) log.Printf("created client: %v\n", client)
break client.SetAtomVal("bgcolor", "#0000ff77")
} client.SetAtomVal("text", "initial text")
if buf[0] == 'x' { client.SetAtomVal("num", 0)
wshutil.DoShutdown("read 'x' from stdin", 0, true) client.SetRootElem(MakeVDom())
break err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: htmlCmdNewBlock})
if err != nil {
return err
} }
log.Printf("created context\n")
go func() {
<-client.DoneCh
wshutil.DoShutdown("vdom closed by FE", 0, true)
}()
log.Printf("created vdom context\n")
go func() {
time.Sleep(5 * time.Second)
log.Printf("updating text\n")
client.SetAtomVal("text", "updated text")
err := client.SendAsyncInitiation()
if err != nil {
log.Printf("error sending async initiation: %v\n", err)
} }
}()
<-client.DoneCh
return nil
} }

View File

@ -71,6 +71,22 @@ func preRunSetupRpcClient(cmd *cobra.Command, args []string) error {
return nil return nil
} }
func resolveBlockArg() (*waveobj.ORef, error) {
oref := blockArg
if oref == "" {
return nil, fmt.Errorf("blockid is required")
}
err := validateEasyORef(oref)
if err != nil {
return nil, err
}
fullORef, err := resolveSimpleId(oref)
if err != nil {
return nil, fmt.Errorf("resolving blockid: %w", err)
}
return fullORef, nil
}
// returns the wrapped stdin and a new rpc client (that wraps the stdin input and stdout output) // returns the wrapped stdin and a new rpc client (that wraps the stdin input and stdout output)
func setupRpcClient(serverImpl wshutil.ServerImpl) error { func setupRpcClient(serverImpl wshutil.ServerImpl) error {
jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName)
@ -101,7 +117,7 @@ func setupRpcClient(serverImpl wshutil.ServerImpl) error {
func setTermHtmlMode() { func setTermHtmlMode() {
wshutil.SetExtraShutdownFunc(extraShutdownFn) wshutil.SetExtraShutdownFunc(extraShutdownFn)
cmd := &wshrpc.CommandSetMetaData{ cmd := &wshrpc.CommandSetMetaData{
Meta: map[string]any{"term:mode": "html"}, Meta: map[string]any{"term:mode": "vdom"},
} }
err := RpcClient.SendCommand(wshrpc.Command_SetMeta, cmd, nil) err := RpcClient.SendCommand(wshrpc.Command_SetMeta, cmd, nil)
if err != nil { if err != nil {

View File

@ -28,9 +28,9 @@ var webOpenCmd = &cobra.Command{
} }
var webGetCmd = &cobra.Command{ var webGetCmd = &cobra.Command{
Use: "get [--inner] [--all] [--json] blockid css-selector", Use: "get [--inner] [--all] [--json] css-selector",
Short: "get the html for a css selector", Short: "get the html for a css selector",
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(1),
Hidden: true, Hidden: true,
RunE: webGetRun, RunE: webGetRun,
} }
@ -51,7 +51,7 @@ func init() {
} }
func webGetRun(cmd *cobra.Command, args []string) error { func webGetRun(cmd *cobra.Command, args []string) error {
oref := args[0] oref := blockArg
if oref == "" { if oref == "" {
return fmt.Errorf("blockid not specified") return fmt.Errorf("blockid not specified")
} }
@ -67,14 +67,14 @@ func webGetRun(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("getting block info: %w", err) return fmt.Errorf("getting block info: %w", err)
} }
if blockInfo.Meta.GetString(waveobj.MetaKey_View, "") != "web" { if blockInfo.Block.Meta.GetString(waveobj.MetaKey_View, "") != "web" {
return fmt.Errorf("block %s is not a web block", fullORef.OID) return fmt.Errorf("block %s is not a web block", fullORef.OID)
} }
data := wshrpc.CommandWebSelectorData{ data := wshrpc.CommandWebSelectorData{
WindowId: blockInfo.WindowId, WindowId: blockInfo.WindowId,
BlockId: fullORef.OID, BlockId: fullORef.OID,
TabId: blockInfo.TabId, TabId: blockInfo.TabId,
Selector: args[1], Selector: args[0],
Opts: &wshrpc.WebSelectorOpts{ Opts: &wshrpc.WebSelectorOpts{
Inner: webGetInner, Inner: webGetInner,
All: webGetAll, All: webGetAll,

60
cmd/wsh/cmd/wshcmd-wsl.go Normal file
View File

@ -0,0 +1,60 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"strings"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
)
var distroName string
var wslCmd = &cobra.Command{
Use: "wsl [-d <Distro>]",
Short: "connect this terminal to a local wsl connection",
Args: cobra.NoArgs,
Run: wslRun,
PreRunE: preRunSetupRpcClient,
}
func init() {
wslCmd.Flags().StringVarP(&distroName, "distribution", "d", "", "Run the specified distribution")
rootCmd.AddCommand(wslCmd)
}
func wslRun(cmd *cobra.Command, args []string) {
var err error
if distroName == "" {
// get default distro from the host
distroName, err = wshclient.WslDefaultDistroCommand(RpcClient, nil)
if err != nil {
WriteStderr("[error] %s\n", err)
return
}
}
if !strings.HasPrefix(distroName, "wsl://") {
distroName = "wsl://" + distroName
}
blockId := RpcContext.BlockId
if blockId == "" {
WriteStderr("[error] cannot determine blockid (not in JWT)\n")
return
}
data := wshrpc.CommandSetMetaData{
ORef: waveobj.MakeORef(waveobj.OType_Block, blockId),
Meta: map[string]any{
waveobj.MetaKey_Connection: distroName,
},
}
err = wshclient.SetMetaCommand(RpcClient, data, nil)
if err != nil {
WriteStderr("[error] setting switching connection: %v\n", err)
return
}
WriteStderr("switched connection to %q\n", distroName)
}

View File

@ -0,0 +1 @@
-- we don't need to remove parentoref

View File

@ -0,0 +1,4 @@
UPDATE db_block
SET data = json_set(db_block.data, '$.parentoref', 'tab:' || db_tab.oid)
FROM db_tab
WHERE db_block.oid IN (SELECT value FROM json_each(db_tab.data, '$.blockids'));

View File

@ -1,6 +1,6 @@
import { getWebServerEndpoint } from "@/util/endpoints";
import { fetch } from "@/util/fetchutil";
import { ipcMain } from "electron"; import { ipcMain } from "electron";
import { getWebServerEndpoint } from "../frontend/util/endpoints";
import { fetch } from "../frontend/util/fetchutil";
const docsiteWebUrl = "https://docs.waveterm.dev/"; const docsiteWebUrl = "https://docs.waveterm.dev/";
let docsiteUrl: string; let docsiteUrl: string;

54
emain/emain-activity.ts Normal file
View File

@ -0,0 +1,54 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
// for activity updates
let wasActive = true;
let wasInFg = true;
let globalIsQuitting = false;
let globalIsStarting = true;
let globalIsRelaunching = false;
let forceQuit = false;
export function setWasActive(val: boolean) {
wasActive = val;
}
export function setWasInFg(val: boolean) {
wasInFg = val;
}
export function getActivityState(): { wasActive: boolean; wasInFg: boolean } {
return { wasActive, wasInFg };
}
export function setGlobalIsQuitting(val: boolean) {
globalIsQuitting = val;
}
export function getGlobalIsQuitting(): boolean {
return globalIsQuitting;
}
export function setGlobalIsStarting(val: boolean) {
globalIsStarting = val;
}
export function getGlobalIsStarting(): boolean {
return globalIsStarting;
}
export function setGlobalIsRelaunching(val: boolean) {
globalIsRelaunching = val;
}
export function getGlobalIsRelaunching(): boolean {
return globalIsRelaunching;
}
export function setForceQuit(val: boolean) {
forceQuit = val;
}
export function getForceQuit(): boolean {
return forceQuit;
}

168
emain/emain-util.ts Normal file
View File

@ -0,0 +1,168 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as electron from "electron";
import { getWebServerEndpoint } from "../frontend/util/endpoints";
export const WaveAppPathVarName = "WAVETERM_APP_PATH";
export function delay(ms): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function setCtrlShift(wc: Electron.WebContents, state: boolean) {
wc.send("control-shift-state-update", state);
}
export function handleCtrlShiftFocus(sender: Electron.WebContents, focused: boolean) {
if (!focused) {
setCtrlShift(sender, false);
}
}
export function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: WaveKeyboardEvent) {
if (waveEvent.type == "keyup") {
if (waveEvent.key === "Control" || waveEvent.key === "Shift") {
setCtrlShift(sender, false);
}
if (waveEvent.key == "Meta") {
if (waveEvent.control && waveEvent.shift) {
setCtrlShift(sender, true);
}
}
return;
}
if (waveEvent.type == "keydown") {
if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") {
if (waveEvent.control && waveEvent.shift && !waveEvent.meta) {
// Set the control and shift without the Meta key
setCtrlShift(sender, true);
} else {
// Unset if Meta is pressed
setCtrlShift(sender, false);
}
}
return;
}
}
export function shNavHandler(event: Electron.Event<Electron.WebContentsWillNavigateEventParams>, url: string) {
if (url.startsWith("http://127.0.0.1:5173/index.html") || url.startsWith("http://localhost:5173/index.html")) {
// this is a dev-mode hot-reload, ignore it
console.log("allowing hot-reload of index.html");
return;
}
event.preventDefault();
if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) {
console.log("open external, shNav", url);
electron.shell.openExternal(url);
} else {
console.log("navigation canceled", url);
}
}
export function shFrameNavHandler(event: Electron.Event<Electron.WebContentsWillFrameNavigateEventParams>) {
if (!event.frame?.parent) {
// only use this handler to process iframe events (non-iframe events go to shNavHandler)
return;
}
const url = event.url;
console.log(`frame-navigation url=${url} frame=${event.frame.name}`);
if (event.frame.name == "webview") {
// "webview" links always open in new window
// this will *not* effect the initial load because srcdoc does not count as an electron navigation
console.log("open external, frameNav", url);
event.preventDefault();
electron.shell.openExternal(url);
return;
}
if (
event.frame.name == "pdfview" &&
(url.startsWith("blob:file:///") || url.startsWith(getWebServerEndpoint() + "/wave/stream-file?"))
) {
// allowed
return;
}
event.preventDefault();
console.log("frame navigation canceled");
}
function isWindowFullyVisible(bounds: electron.Rectangle): boolean {
const displays = electron.screen.getAllDisplays();
// Helper function to check if a point is inside any display
function isPointInDisplay(x: number, y: number) {
for (const display of displays) {
const { x: dx, y: dy, width, height } = display.bounds;
if (x >= dx && x < dx + width && y >= dy && y < dy + height) {
return true;
}
}
return false;
}
// Check all corners of the window
const topLeft = isPointInDisplay(bounds.x, bounds.y);
const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y);
const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height);
const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height);
return topLeft && topRight && bottomLeft && bottomRight;
}
function findDisplayWithMostArea(bounds: electron.Rectangle): electron.Display {
const displays = electron.screen.getAllDisplays();
let maxArea = 0;
let bestDisplay = null;
for (let display of displays) {
const { x, y, width, height } = display.bounds;
const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x));
const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y));
const overlapArea = overlapX * overlapY;
if (overlapArea > maxArea) {
maxArea = overlapArea;
bestDisplay = display;
}
}
return bestDisplay;
}
function adjustBoundsToFitDisplay(bounds: electron.Rectangle, display: electron.Display): electron.Rectangle {
const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea;
let { x, y, width, height } = bounds;
// Adjust width and height to fit within the display's work area
width = Math.min(width, dWidth);
height = Math.min(height, dHeight);
// Adjust x to ensure the window fits within the display
if (x < dx) {
x = dx;
} else if (x + width > dx + dWidth) {
x = dx + dWidth - width;
}
// Adjust y to ensure the window fits within the display
if (y < dy) {
y = dy;
} else if (y + height > dy + dHeight) {
y = dy + dHeight - height;
}
return { x, y, width, height };
}
export function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle {
if (!isWindowFullyVisible(bounds)) {
let targetDisplay = findDisplayWithMostArea(bounds);
if (!targetDisplay) {
targetDisplay = electron.screen.getPrimaryDisplay();
}
return adjustBoundsToFitDisplay(bounds, targetDisplay);
}
return bounds;
}

558
emain/emain-viewmgr.ts Normal file
View File

@ -0,0 +1,558 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as electron from "electron";
import * as path from "path";
import { debounce } from "throttle-debounce";
import { ClientService, FileService, ObjectService, WindowService } from "../frontend/app/store/services";
import * as keyutil from "../frontend/util/keyutil";
import { configureAuthKeyRequestInjection } from "./authkey";
import { getGlobalIsQuitting, getGlobalIsStarting, setWasActive, setWasInFg } from "./emain-activity";
import {
delay,
ensureBoundsAreVisible,
handleCtrlShiftFocus,
handleCtrlShiftState,
shFrameNavHandler,
shNavHandler,
} from "./emain-util";
import { getElectronAppBasePath, isDevVite } from "./platform";
import { updater } from "./updater";
let MaxCacheSize = 10;
let HotSpareTab: WaveTabView = null;
const waveWindowMap = new Map<string, WaveBrowserWindow>(); // waveWindowId -> WaveBrowserWindow
let focusedWaveWindow = null; // on blur we do not set this to null (but on destroy we do)
const wcvCache = new Map<string, WaveTabView>();
const wcIdToWaveTabMap = new Map<number, WaveTabView>();
export function setMaxTabCacheSize(size: number) {
console.log("setMaxTabCacheSize", size);
MaxCacheSize = size;
}
function computeBgColor(fullConfig: FullConfigType): string {
const settings = fullConfig?.settings;
const isTransparent = settings?.["window:transparent"] ?? false;
const isBlur = !isTransparent && (settings?.["window:blur"] ?? false);
if (isTransparent) {
return "#00000000";
} else if (isBlur) {
return "#00000000";
} else {
return "#222222";
}
}
function createBareTabView(fullConfig: FullConfigType): WaveTabView {
console.log("createBareTabView");
const tabView = new electron.WebContentsView({
webPreferences: {
preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"),
webviewTag: true,
},
}) as WaveTabView;
tabView.createdTs = Date.now();
tabView.savedInitOpts = null;
tabView.initPromise = new Promise((resolve, _) => {
tabView.initResolve = resolve;
});
tabView.initPromise.then(() => {
console.log("tabview init", Date.now() - tabView.createdTs + "ms");
});
tabView.waveReadyPromise = new Promise((resolve, _) => {
tabView.waveReadyResolve = resolve;
});
wcIdToWaveTabMap.set(tabView.webContents.id, tabView);
if (isDevVite) {
tabView.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html}`);
} else {
tabView.webContents.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html"));
}
tabView.webContents.on("destroyed", () => {
wcIdToWaveTabMap.delete(tabView.webContents.id);
removeWaveTabView(tabView.waveWindowId, tabView.waveTabId);
});
tabView.setBackgroundColor(computeBgColor(fullConfig));
return tabView;
}
function positionTabOffScreen(tabView: WaveTabView, winBounds: Electron.Rectangle) {
if (tabView == null) {
return;
}
tabView.setBounds({
x: -10000,
y: -10000,
width: winBounds.width,
height: winBounds.height,
});
}
async function repositionTabsSlowly(
newTabView: WaveTabView,
oldTabView: WaveTabView,
delayMs: number,
winBounds: Electron.Rectangle
) {
if (newTabView == null) {
return;
}
newTabView.setBounds({
x: winBounds.width - 10,
y: winBounds.height - 10,
width: winBounds.width,
height: winBounds.height,
});
await delay(delayMs);
newTabView.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height });
oldTabView?.setBounds({
x: -10000,
y: -10000,
width: winBounds.width,
height: winBounds.height,
});
}
function positionTabOnScreen(tabView: WaveTabView, winBounds: Electron.Rectangle) {
if (tabView == null) {
return;
}
tabView.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height });
}
export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabView {
return wcIdToWaveTabMap.get(webContentsId);
}
export function getWaveWindowByWebContentsId(webContentsId: number): WaveBrowserWindow {
const tabView = wcIdToWaveTabMap.get(webContentsId);
if (tabView == null) {
return null;
}
return waveWindowMap.get(tabView.waveWindowId);
}
export function getWaveWindowById(windowId: string): WaveBrowserWindow {
return waveWindowMap.get(windowId);
}
export function getAllWaveWindows(): WaveBrowserWindow[] {
return Array.from(waveWindowMap.values());
}
export function getFocusedWaveWindow(): WaveBrowserWindow {
return focusedWaveWindow;
}
export function ensureHotSpareTab(fullConfig: FullConfigType) {
console.log("ensureHotSpareTab");
if (HotSpareTab == null) {
HotSpareTab = createBareTabView(fullConfig);
}
}
export function destroyWindow(waveWindow: WaveBrowserWindow) {
if (waveWindow == null) {
return;
}
for (const tabView of waveWindow.allTabViews.values()) {
destroyTab(tabView);
}
waveWindowMap.delete(waveWindow.waveWindowId);
}
export function destroyTab(tabView: WaveTabView) {
if (tabView == null) {
return;
}
tabView.webContents.close();
wcIdToWaveTabMap.delete(tabView.webContents.id);
removeWaveTabView(tabView.waveWindowId, tabView.waveTabId);
const waveWindow = waveWindowMap.get(tabView.waveWindowId);
if (waveWindow) {
waveWindow.allTabViews.delete(tabView.waveTabId);
}
}
function getSpareTab(fullConfig: FullConfigType): WaveTabView {
setTimeout(ensureHotSpareTab, 500);
if (HotSpareTab != null) {
const rtn = HotSpareTab;
HotSpareTab = null;
console.log("getSpareTab: returning hotspare");
return rtn;
} else {
console.log("getSpareTab: creating new tab");
return createBareTabView(fullConfig);
}
}
function getWaveTabView(waveWindowId: string, waveTabId: string): WaveTabView | undefined {
const cacheKey = waveWindowId + "|" + waveTabId;
const rtn = wcvCache.get(cacheKey);
if (rtn) {
rtn.lastUsedTs = Date.now();
}
return rtn;
}
function setWaveTabView(waveWindowId: string, waveTabId: string, wcv: WaveTabView): void {
const cacheKey = waveWindowId + "|" + waveTabId;
wcvCache.set(cacheKey, wcv);
checkAndEvictCache();
}
function removeWaveTabView(waveWindowId: string, waveTabId: string): void {
const cacheKey = waveWindowId + "|" + waveTabId;
wcvCache.delete(cacheKey);
}
function forceRemoveAllTabsForWindow(waveWindowId: string): void {
const keys = Array.from(wcvCache.keys());
for (const key of keys) {
if (key.startsWith(waveWindowId)) {
wcvCache.delete(key);
}
}
}
function checkAndEvictCache(): void {
if (wcvCache.size <= MaxCacheSize) {
return;
}
const sorted = Array.from(wcvCache.values()).sort((a, b) => {
// Prioritize entries which are active
if (a.isActiveTab && !b.isActiveTab) {
return -1;
}
// Otherwise, sort by lastUsedTs
return a.lastUsedTs - b.lastUsedTs;
});
for (let i = 0; i < sorted.length - MaxCacheSize; i++) {
if (sorted[i].isActiveTab) {
// don't evict WaveTabViews that are currently showing in a window
continue;
}
const tabView = sorted[i];
destroyTab(tabView);
}
}
export function clearTabCache() {
const wcVals = Array.from(wcvCache.values());
for (let i = 0; i < wcVals.length; i++) {
const tabView = wcVals[i];
if (tabView.isActiveTab) {
continue;
}
destroyTab(tabView);
}
}
// returns [tabview, initialized]
function getOrCreateWebViewForTab(fullConfig: FullConfigType, windowId: string, tabId: string): [WaveTabView, boolean] {
let tabView = getWaveTabView(windowId, tabId);
if (tabView) {
return [tabView, true];
}
tabView = getSpareTab(fullConfig);
tabView.lastUsedTs = Date.now();
tabView.waveTabId = tabId;
tabView.waveWindowId = windowId;
setWaveTabView(windowId, tabId, tabView);
tabView.webContents.on("will-navigate", shNavHandler);
tabView.webContents.on("will-frame-navigate", shFrameNavHandler);
tabView.webContents.on("did-attach-webview", (event, wc) => {
wc.setWindowOpenHandler((details) => {
tabView.webContents.send("webview-new-window", wc.id, details);
return { action: "deny" };
});
});
tabView.webContents.on("before-input-event", (e, input) => {
const waveEvent = keyutil.adaptFromElectronKeyEvent(input);
// console.log("WIN bie", tabView.waveTabId.substring(0, 8), waveEvent.type, waveEvent.code);
handleCtrlShiftState(tabView.webContents, waveEvent);
setWasActive(true);
});
tabView.webContents.on("zoom-changed", (e) => {
tabView.webContents.send("zoom-changed");
});
tabView.webContents.setWindowOpenHandler(({ url, frameName }) => {
if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) {
console.log("openExternal fallback", url);
electron.shell.openExternal(url);
}
console.log("window-open denied", url);
return { action: "deny" };
});
tabView.webContents.on("focus", () => {
setWasInFg(true);
setWasActive(true);
if (getGlobalIsStarting()) {
return;
}
});
tabView.webContents.on("blur", () => {
handleCtrlShiftFocus(tabView.webContents, false);
});
configureAuthKeyRequestInjection(tabView.webContents.session);
return [tabView, false];
}
async function mainResizeHandler(_: any, windowId: string, win: WaveBrowserWindow) {
if (win == null || win.isDestroyed() || win.fullScreen) {
return;
}
const bounds = win.getBounds();
try {
await WindowService.SetWindowPosAndSize(
windowId,
{ x: bounds.x, y: bounds.y },
{ width: bounds.width, height: bounds.height }
);
} catch (e) {
console.log("error resizing window", e);
}
}
type WindowOpts = {
unamePlatform: string;
};
function createBaseWaveBrowserWindow(
waveWindow: WaveWindow,
fullConfig: FullConfigType,
opts: WindowOpts
): WaveBrowserWindow {
let winWidth = waveWindow?.winsize?.width;
let winHeight = waveWindow?.winsize?.height;
let winPosX = waveWindow.pos.x;
let winPosY = waveWindow.pos.y;
if (winWidth == null || winWidth == 0) {
const primaryDisplay = electron.screen.getPrimaryDisplay();
const { width } = primaryDisplay.workAreaSize;
winWidth = width - winPosX - 100;
if (winWidth > 2000) {
winWidth = 2000;
}
}
if (winHeight == null || winHeight == 0) {
const primaryDisplay = electron.screen.getPrimaryDisplay();
const { height } = primaryDisplay.workAreaSize;
winHeight = height - winPosY - 100;
if (winHeight > 1200) {
winHeight = 1200;
}
}
let winBounds = {
x: winPosX,
y: winPosY,
width: winWidth,
height: winHeight,
};
winBounds = ensureBoundsAreVisible(winBounds);
const settings = fullConfig?.settings;
const winOpts: Electron.BaseWindowConstructorOptions = {
titleBarStyle:
opts.unamePlatform === "darwin" ? "hiddenInset" : settings["window:nativetitlebar"] ? "default" : "hidden",
titleBarOverlay:
opts.unamePlatform !== "darwin"
? {
symbolColor: "white",
color: "#00000000",
}
: false,
x: winBounds.x,
y: winBounds.y,
width: winBounds.width,
height: winBounds.height,
minWidth: 400,
minHeight: 300,
icon:
opts.unamePlatform == "linux"
? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png")
: undefined,
show: false,
autoHideMenuBar: !settings?.["window:showmenubar"],
};
const isTransparent = settings?.["window:transparent"] ?? false;
const isBlur = !isTransparent && (settings?.["window:blur"] ?? false);
if (isTransparent) {
winOpts.transparent = true;
} else if (isBlur) {
switch (opts.unamePlatform) {
case "win32": {
winOpts.backgroundMaterial = "acrylic";
break;
}
case "darwin": {
winOpts.vibrancy = "fullscreen-ui";
break;
}
}
} else {
winOpts.backgroundColor = "#222222";
}
const bwin = new electron.BaseWindow(winOpts);
const win: WaveBrowserWindow = bwin as WaveBrowserWindow;
win.waveWindowId = waveWindow.oid;
win.alreadyClosed = false;
win.allTabViews = new Map<string, WaveTabView>();
win.on(
// @ts-expect-error
"resize",
debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win))
);
win.on("resize", () => {
if (win.isDestroyed() || win.fullScreen) {
return;
}
positionTabOnScreen(win.activeTabView, win.getContentBounds());
});
win.on(
// @ts-expect-error
"move",
debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win))
);
win.on("enter-full-screen", async () => {
const tabView = win.activeTabView;
if (tabView) {
tabView.webContents.send("fullscreen-change", true);
}
});
win.on("leave-full-screen", async () => {
const tabView = win.activeTabView;
if (tabView) {
tabView.webContents.send("fullscreen-change", false);
}
});
win.on("focus", () => {
focusedWaveWindow = win;
console.log("focus win", win.waveWindowId);
ClientService.FocusWindow(win.waveWindowId);
});
win.on("blur", () => {
if (focusedWaveWindow == win) {
focusedWaveWindow = null;
}
});
win.on("close", (e) => {
if (getGlobalIsQuitting() || updater?.status == "installing") {
return;
}
const numWindows = waveWindowMap.size;
if (numWindows == 1) {
return;
}
const choice = electron.dialog.showMessageBoxSync(win, {
type: "question",
buttons: ["Cancel", "Yes"],
title: "Confirm",
message: "Are you sure you want to close this window (all tabs and blocks will be deleted)?",
});
if (choice === 0) {
e.preventDefault();
}
});
win.on("closed", () => {
if (getGlobalIsQuitting() || updater?.status == "installing") {
return;
}
const numWindows = waveWindowMap.size;
if (numWindows == 0) {
return;
}
if (!win.alreadyClosed) {
WindowService.CloseWindow(waveWindow.oid, true);
}
destroyWindow(win);
});
waveWindowMap.set(waveWindow.oid, win);
return win;
}
export function getLastFocusedWaveWindow(): WaveBrowserWindow {
return focusedWaveWindow;
}
// note, this does not *show* the window.
// to show, await win.readyPromise and then win.show()
export function createBrowserWindow(
clientId: string,
waveWindow: WaveWindow,
fullConfig: FullConfigType,
opts: WindowOpts
): WaveBrowserWindow {
const bwin = createBaseWaveBrowserWindow(waveWindow, fullConfig, opts);
// TODO fix null activetabid if it exists
if (waveWindow.activetabid != null) {
setActiveTab(bwin, waveWindow.activetabid);
}
return bwin;
}
export async function setActiveTab(waveWindow: WaveBrowserWindow, tabId: string) {
const windowId = waveWindow.waveWindowId;
await ObjectService.SetActiveTab(waveWindow.waveWindowId, tabId);
const fullConfig = await FileService.GetFullConfig();
const [tabView, tabInitialized] = getOrCreateWebViewForTab(fullConfig, windowId, tabId);
setTabViewIntoWindow(waveWindow, tabView, tabInitialized);
}
async function setTabViewIntoWindow(bwin: WaveBrowserWindow, tabView: WaveTabView, tabInitialized: boolean) {
const curTabView: WaveTabView = bwin.getContentView() as any;
const clientData = await ClientService.GetClientData();
if (curTabView != null) {
curTabView.isActiveTab = false;
}
if (bwin.activeTabView == tabView) {
return;
}
const oldActiveView = bwin.activeTabView;
tabView.isActiveTab = true;
if (oldActiveView != null) {
oldActiveView.isActiveTab = false;
}
bwin.activeTabView = tabView;
bwin.allTabViews.set(tabView.waveTabId, tabView);
if (!tabInitialized) {
console.log("initializing a new tab");
await tabView.initPromise;
bwin.contentView.addChildView(tabView);
const initOpts = {
tabId: tabView.waveTabId,
clientId: clientData.oid,
windowId: bwin.waveWindowId,
activate: true,
};
tabView.savedInitOpts = { ...initOpts };
tabView.savedInitOpts.activate = false;
let startTime = Date.now();
tabView.webContents.send("wave-init", initOpts);
console.log("before wave ready");
await tabView.waveReadyPromise;
// positionTabOnScreen(tabView, bwin.getContentBounds());
console.log("wave-ready init time", Date.now() - startTime + "ms");
// positionTabOffScreen(oldActiveView, bwin.getContentBounds());
repositionTabsSlowly(tabView, oldActiveView, 100, bwin.getContentBounds());
} else {
console.log("reusing an existing tab");
repositionTabsSlowly(tabView, oldActiveView, 35, bwin.getContentBounds());
tabView.webContents.send("wave-init", tabView.savedInitOpts); // reinit
}
// something is causing the new tab to lose focus so it requires manual refocusing
tabView.webContents.focus();
setTimeout(() => {
if (bwin.activeTabView == tabView && !tabView.webContents.isFocused()) {
tabView.webContents.focus();
}
}, 10);
setTimeout(() => {
if (bwin.activeTabView == tabView && !tabView.webContents.isFocused()) {
tabView.webContents.focus();
}
}, 30);
}

128
emain/emain-wavesrv.ts Normal file
View File

@ -0,0 +1,128 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as electron from "electron";
import * as child_process from "node:child_process";
import * as readline from "readline";
import { WebServerEndpointVarName, WSServerEndpointVarName } from "../frontend/util/endpoints";
import { AuthKey, AuthKeyEnv } from "./authkey";
import { setForceQuit } from "./emain-activity";
import { WaveAppPathVarName } from "./emain-util";
import {
getElectronAppUnpackedBasePath,
getWaveConfigDir,
getWaveDataDir,
getWaveSrvCwd,
getWaveSrvPath,
WaveConfigHomeVarName,
WaveDataHomeVarName,
} from "./platform";
import { updater } from "./updater";
export const WaveSrvReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID";
let isWaveSrvDead = false;
let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null;
let WaveVersion = "unknown"; // set by WAVESRV-ESTART
let WaveBuildTime = 0; // set by WAVESRV-ESTART
export function getWaveVersion(): { version: string; buildTime: number } {
return { version: WaveVersion, buildTime: WaveBuildTime };
}
let waveSrvReadyResolve = (value: boolean) => {};
const waveSrvReady: Promise<boolean> = new Promise((resolve, _) => {
waveSrvReadyResolve = resolve;
});
export function getWaveSrvReady(): Promise<boolean> {
return waveSrvReady;
}
export function getWaveSrvProc(): child_process.ChildProcessWithoutNullStreams | null {
return waveSrvProc;
}
export function getIsWaveSrvDead(): boolean {
return isWaveSrvDead;
}
export function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promise<boolean> {
let pResolve: (value: boolean) => void;
let pReject: (reason?: any) => void;
const rtnPromise = new Promise<boolean>((argResolve, argReject) => {
pResolve = argResolve;
pReject = argReject;
});
const envCopy = { ...process.env };
envCopy[WaveAppPathVarName] = getElectronAppUnpackedBasePath();
envCopy[WaveSrvReadySignalPidVarName] = process.pid.toString();
envCopy[AuthKeyEnv] = AuthKey;
envCopy[WaveDataHomeVarName] = getWaveDataDir();
envCopy[WaveConfigHomeVarName] = getWaveConfigDir();
const waveSrvCmd = getWaveSrvPath();
console.log("trying to run local server", waveSrvCmd);
const proc = child_process.spawn(getWaveSrvPath(), {
cwd: getWaveSrvCwd(),
env: envCopy,
});
proc.on("exit", (e) => {
if (updater?.status == "installing") {
return;
}
console.log("wavesrv exited, shutting down");
setForceQuit(true);
isWaveSrvDead = true;
electron.app.quit();
});
proc.on("spawn", (e) => {
console.log("spawned wavesrv");
waveSrvProc = proc;
pResolve(true);
});
proc.on("error", (e) => {
console.log("error running wavesrv", e);
pReject(e);
});
const rlStdout = readline.createInterface({
input: proc.stdout,
terminal: false,
});
rlStdout.on("line", (line) => {
console.log(line);
});
const rlStderr = readline.createInterface({
input: proc.stderr,
terminal: false,
});
rlStderr.on("line", (line) => {
if (line.includes("WAVESRV-ESTART")) {
const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.\-]+) buildtime:(\d+)/gm.exec(
line
);
if (startParams == null) {
console.log("error parsing WAVESRV-ESTART line", line);
electron.app.quit();
return;
}
process.env[WSServerEndpointVarName] = startParams[1];
process.env[WebServerEndpointVarName] = startParams[2];
WaveVersion = startParams[3];
WaveBuildTime = parseInt(startParams[4]);
waveSrvReadyResolve(true);
return;
}
if (line.startsWith("WAVESRV-EVENT:")) {
const evtJson = line.slice("WAVESRV-EVENT:".length);
try {
const evtMsg: WSEventType = JSON.parse(evtJson);
handleWSEvent(evtMsg);
} catch (e) {
console.log("error handling WAVESRV-EVENT", e);
}
return;
}
console.log(line);
});
return rtnPromise;
}

View File

@ -1,13 +1,13 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { BrowserWindow, ipcMain, webContents, WebContents } from "electron"; import { ipcMain, webContents, WebContents } from "electron";
export function getWebContentsByBlockId(win: BrowserWindow, tabId: string, blockId: string): Promise<WebContents> { export function getWebContentsByBlockId(ww: WaveBrowserWindow, tabId: string, blockId: string): Promise<WebContents> {
const prtn = new Promise<WebContents>((resolve, reject) => { const prtn = new Promise<WebContents>((resolve, reject) => {
const randId = Math.floor(Math.random() * 1000000000).toString(); const randId = Math.floor(Math.random() * 1000000000).toString();
const respCh = `getWebContentsByBlockId-${randId}`; const respCh = `getWebContentsByBlockId-${randId}`;
win.webContents.send("webcontentsid-from-blockid", blockId, respCh); ww?.activeTabView?.webContents.send("webcontentsid-from-blockid", blockId, respCh);
ipcMain.once(respCh, (event, webContentsId) => { ipcMain.once(respCh, (event, webContentsId) => {
if (webContentsId == null) { if (webContentsId == null) {
resolve(null); resolve(null);

View File

@ -1,12 +1,11 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import electron from "electron"; import { Notification } from "electron";
import { RpcResponseHelper, WshClient } from "../frontend/app/store/wshclient"; import { RpcResponseHelper, WshClient } from "../frontend/app/store/wshclient";
import { getWaveWindowById } from "./emain-viewmgr";
import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; import { getWebContentsByBlockId, webGetSelector } from "./emain-web";
type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise<void> };
export class ElectronWshClientType extends WshClient { export class ElectronWshClientType extends WshClient {
constructor() { constructor() {
super("electron"); super("electron");
@ -16,12 +15,11 @@ export class ElectronWshClientType extends WshClient {
if (!data.tabid || !data.blockid || !data.windowid) { if (!data.tabid || !data.blockid || !data.windowid) {
throw new Error("tabid and blockid are required"); throw new Error("tabid and blockid are required");
} }
const windows = electron.BrowserWindow.getAllWindows(); const ww = getWaveWindowById(data.windowid);
const win = windows.find((w) => (w as WaveBrowserWindow).waveWindowId === data.windowid); if (ww == null) {
if (win == null) {
throw new Error(`no window found with id ${data.windowid}`); throw new Error(`no window found with id ${data.windowid}`);
} }
const wc = await getWebContentsByBlockId(win, data.tabid, data.blockid); const wc = await getWebContentsByBlockId(ww, data.tabid, data.blockid);
if (wc == null) { if (wc == null) {
throw new Error(`no webcontents found with blockid ${data.blockid}`); throw new Error(`no webcontents found with blockid ${data.blockid}`);
} }
@ -30,7 +28,7 @@ export class ElectronWshClientType extends WshClient {
} }
async handle_notify(rh: RpcResponseHelper, notificationOptions: WaveNotificationOptions) { async handle_notify(rh: RpcResponseHelper, notificationOptions: WaveNotificationOptions) {
new electron.Notification({ new Notification({
title: notificationOptions.title, title: notificationOptions.title,
body: notificationOptions.body, body: notificationOptions.body,
silent: notificationOptions.silent, silent: notificationOptions.silent,

View File

@ -7,71 +7,70 @@ import fs from "fs";
import * as child_process from "node:child_process"; import * as child_process from "node:child_process";
import * as path from "path"; import * as path from "path";
import { PNG } from "pngjs"; import { PNG } from "pngjs";
import * as readline from "readline";
import { sprintf } from "sprintf-js"; import { sprintf } from "sprintf-js";
import { Readable } from "stream"; import { Readable } from "stream";
import { debounce } from "throttle-debounce";
import * as util from "util"; import * as util from "util";
import winston from "winston"; import winston from "winston";
import * as services from "../frontend/app/store/services"; import * as services from "../frontend/app/store/services";
import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil"; import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil";
import { WSServerEndpointVarName, WebServerEndpointVarName, getWebServerEndpoint } from "../frontend/util/endpoints"; import { getWebServerEndpoint } from "../frontend/util/endpoints";
import { fetch } from "../frontend/util/fetchutil"; import { fetch } from "../frontend/util/fetchutil";
import * as keyutil from "../frontend/util/keyutil"; import * as keyutil from "../frontend/util/keyutil";
import { fireAndForget } from "../frontend/util/util"; import { fireAndForget } from "../frontend/util/util";
import { AuthKey, AuthKeyEnv, configureAuthKeyRequestInjection } from "./authkey"; import { AuthKey, configureAuthKeyRequestInjection } from "./authkey";
import { initDocsite } from "./docsite"; import { initDocsite } from "./docsite";
import {
getActivityState,
getForceQuit,
getGlobalIsRelaunching,
setForceQuit,
setGlobalIsQuitting,
setGlobalIsRelaunching,
setGlobalIsStarting,
setWasActive,
setWasInFg,
} from "./emain-activity";
import { handleCtrlShiftState } from "./emain-util";
import {
createBrowserWindow,
ensureHotSpareTab,
getAllWaveWindows,
getFocusedWaveWindow,
getLastFocusedWaveWindow,
getWaveTabViewByWebContentsId,
getWaveWindowById,
getWaveWindowByWebContentsId,
setActiveTab,
setMaxTabCacheSize,
} from "./emain-viewmgr";
import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, getWaveVersion, runWaveSrv } from "./emain-wavesrv";
import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; import { ElectronWshClient, initElectronWshClient } from "./emain-wsh";
import { getLaunchSettings } from "./launchsettings"; import { getLaunchSettings } from "./launchsettings";
import { getAppMenu } from "./menu"; import { getAppMenu } from "./menu";
import { import {
getElectronAppBasePath, getElectronAppBasePath,
getElectronAppUnpackedBasePath, getElectronAppUnpackedBasePath,
getWaveHomeDir, getWaveConfigDir,
getWaveSrvCwd, getWaveDataDir,
getWaveSrvPath,
isDev, isDev,
isDevVite,
unameArch, unameArch,
unamePlatform, unamePlatform,
} from "./platform"; } from "./platform";
import { configureAutoUpdater, updater } from "./updater"; import { configureAutoUpdater, updater } from "./updater";
const electronApp = electron.app; const electronApp = electron.app;
let WaveVersion = "unknown"; // set by WAVESRV-ESTART
let WaveBuildTime = 0; // set by WAVESRV-ESTART
let forceQuit = false;
let isWaveSrvDead = false;
const WaveAppPathVarName = "WAVETERM_APP_PATH"; const waveDataDir = getWaveDataDir();
const WaveSrvReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID"; const waveConfigDir = getWaveConfigDir();
electron.nativeTheme.themeSource = "dark"; electron.nativeTheme.themeSource = "dark";
type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise<void> };
let waveSrvReadyResolve = (value: boolean) => {};
const waveSrvReady: Promise<boolean> = new Promise((resolve, _) => {
waveSrvReadyResolve = resolve;
});
let globalIsQuitting = false;
let globalIsStarting = true;
let globalIsRelaunching = false;
// for activity updates
let wasActive = true;
let wasInFg = true;
let webviewFocusId: number = null; // set to the getWebContentsId of the webview that has focus (null if not focused) let webviewFocusId: number = null; // set to the getWebContentsId of the webview that has focus (null if not focused)
let webviewKeys: string[] = []; // the keys to trap when webview has focus let webviewKeys: string[] = []; // the keys to trap when webview has focus
let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null;
const waveHome = getWaveHomeDir();
const oldConsoleLog = console.log; const oldConsoleLog = console.log;
const loggerTransports: winston.transport[] = [ const loggerTransports: winston.transport[] = [
new winston.transports.File({ filename: path.join(getWaveHomeDir(), "waveapp.log"), level: "info" }), new winston.transports.File({ filename: path.join(waveDataDir, "waveapp.log"), level: "info" }),
]; ];
if (isDev) { if (isDev) {
loggerTransports.push(new winston.transports.Console()); loggerTransports.push(new winston.transports.Console());
@ -79,7 +78,7 @@ if (isDev) {
const loggerConfig = { const loggerConfig = {
level: "info", level: "info",
format: winston.format.combine( format: winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }),
winston.format.printf((info) => `${info.timestamp} ${info.message}`) winston.format.printf((info) => `${info.timestamp} ${info.message}`)
), ),
transports: loggerTransports, transports: loggerTransports,
@ -95,8 +94,9 @@ function log(...msg: any[]) {
console.log = log; console.log = log;
console.log( console.log(
sprintf( sprintf(
"waveterm-app starting, WAVETERM_HOME=%s, electronpath=%s gopath=%s arch=%s/%s", "waveterm-app starting, data_dir=%s, config_dir=%s electronpath=%s gopath=%s arch=%s/%s",
waveHome, waveDataDir,
waveConfigDir,
getElectronAppBasePath(), getElectronAppBasePath(),
getElectronAppUnpackedBasePath(), getElectronAppUnpackedBasePath(),
unamePlatform, unamePlatform,
@ -107,125 +107,6 @@ if (isDev) {
console.log("waveterm-app WAVETERM_DEV set"); console.log("waveterm-app WAVETERM_DEV set");
} }
function getWindowForEvent(event: Electron.IpcMainEvent): Electron.BrowserWindow {
const windowId = event.sender.id;
return electron.BrowserWindow.fromId(windowId);
}
function setCtrlShift(wc: Electron.WebContents, state: boolean) {
wc.send("control-shift-state-update", state);
}
function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: WaveKeyboardEvent) {
if (waveEvent.type == "keyup") {
if (waveEvent.key === "Control" || waveEvent.key === "Shift") {
setCtrlShift(sender, false);
}
if (waveEvent.key == "Meta") {
if (waveEvent.control && waveEvent.shift) {
setCtrlShift(sender, true);
}
}
return;
}
if (waveEvent.type == "keydown") {
if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") {
if (waveEvent.control && waveEvent.shift && !waveEvent.meta) {
// Set the control and shift without the Meta key
setCtrlShift(sender, true);
} else {
// Unset if Meta is pressed
setCtrlShift(sender, false);
}
}
return;
}
}
function handleCtrlShiftFocus(sender: Electron.WebContents, focused: boolean) {
if (!focused) {
setCtrlShift(sender, false);
}
}
function runWaveSrv(): Promise<boolean> {
let pResolve: (value: boolean) => void;
let pReject: (reason?: any) => void;
const rtnPromise = new Promise<boolean>((argResolve, argReject) => {
pResolve = argResolve;
pReject = argReject;
});
const envCopy = { ...process.env };
envCopy[WaveAppPathVarName] = getElectronAppUnpackedBasePath();
envCopy[WaveSrvReadySignalPidVarName] = process.pid.toString();
envCopy[AuthKeyEnv] = AuthKey;
const waveSrvCmd = getWaveSrvPath();
console.log("trying to run local server", waveSrvCmd);
const proc = child_process.spawn(getWaveSrvPath(), {
cwd: getWaveSrvCwd(),
env: envCopy,
});
proc.on("exit", (e) => {
if (updater?.status == "installing") {
return;
}
console.log("wavesrv exited, shutting down");
forceQuit = true;
isWaveSrvDead = true;
electronApp.quit();
});
proc.on("spawn", (e) => {
console.log("spawned wavesrv");
waveSrvProc = proc;
pResolve(true);
});
proc.on("error", (e) => {
console.log("error running wavesrv", e);
pReject(e);
});
const rlStdout = readline.createInterface({
input: proc.stdout,
terminal: false,
});
rlStdout.on("line", (line) => {
console.log(line);
});
const rlStderr = readline.createInterface({
input: proc.stderr,
terminal: false,
});
rlStderr.on("line", (line) => {
if (line.includes("WAVESRV-ESTART")) {
const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.\-]+) buildtime:(\d+)/gm.exec(
line
);
if (startParams == null) {
console.log("error parsing WAVESRV-ESTART line", line);
electronApp.quit();
return;
}
process.env[WSServerEndpointVarName] = startParams[1];
process.env[WebServerEndpointVarName] = startParams[2];
WaveVersion = startParams[3];
WaveBuildTime = parseInt(startParams[4]);
waveSrvReadyResolve(true);
return;
}
if (line.startsWith("WAVESRV-EVENT:")) {
const evtJson = line.slice("WAVESRV-EVENT:".length);
try {
const evtMsg: WSEventType = JSON.parse(evtJson);
handleWSEvent(evtMsg);
} catch (e) {
console.log("error handling WAVESRV-EVENT", e);
}
return;
}
console.log(line);
});
return rtnPromise;
}
async function handleWSEvent(evtMsg: WSEventType) { async function handleWSEvent(evtMsg: WSEventType) {
console.log("handleWSEvent", evtMsg?.eventtype); console.log("handleWSEvent", evtMsg?.eventtype);
if (evtMsg.eventtype == "electron:newwindow") { if (evtMsg.eventtype == "electron:newwindow") {
@ -236,391 +117,21 @@ async function handleWSEvent(evtMsg: WSEventType) {
} }
const clientData = await services.ClientService.GetClientData(); const clientData = await services.ClientService.GetClientData();
const fullConfig = await services.FileService.GetFullConfig(); const fullConfig = await services.FileService.GetFullConfig();
const newWin = createBrowserWindow(clientData.oid, windowData, fullConfig); const newWin = createBrowserWindow(clientData.oid, windowData, fullConfig, { unamePlatform });
await newWin.readyPromise; await newWin.waveReadyPromise;
newWin.show(); newWin.show();
} else if (evtMsg.eventtype == "electron:closewindow") { } else if (evtMsg.eventtype == "electron:closewindow") {
if (evtMsg.data === undefined) return; if (evtMsg.data === undefined) return;
const windows = electron.BrowserWindow.getAllWindows(); const ww = getWaveWindowById(evtMsg.data);
for (const window of windows) { if (ww != null) {
if ((window as any).waveWindowId === evtMsg.data) { ww.alreadyClosed = true;
// Bypass the "Are you sure?" dialog, since this event is called when there's no more tabs for the window. ww.destroy(); // bypass the "are you sure?" dialog
window.destroy();
}
} }
} else { } else {
console.log("unhandled electron ws eventtype", evtMsg.eventtype); console.log("unhandled electron ws eventtype", evtMsg.eventtype);
} }
} }
async function persistWindowBounds(windowId: string, bounds: electron.Rectangle) {
try {
await services.WindowService.SetWindowPosAndSize(
windowId,
{ x: bounds.x, y: bounds.y },
{ width: bounds.width, height: bounds.height }
);
} catch (e) {
console.log("error resizing window", e);
}
}
async function mainResizeHandler(_: any, windowId: string, win: WaveBrowserWindow) {
if (win == null || win.isDestroyed() || win.fullScreen) {
return;
}
const bounds = win.getBounds();
await persistWindowBounds(windowId, bounds);
}
function shNavHandler(event: Electron.Event<Electron.WebContentsWillNavigateEventParams>, url: string) {
if (url.startsWith("http://127.0.0.1:5173/index.html") || url.startsWith("http://localhost:5173/index.html")) {
// this is a dev-mode hot-reload, ignore it
console.log("allowing hot-reload of index.html");
return;
}
event.preventDefault();
if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) {
console.log("open external, shNav", url);
electron.shell.openExternal(url);
} else {
console.log("navigation canceled", url);
}
}
function shFrameNavHandler(event: Electron.Event<Electron.WebContentsWillFrameNavigateEventParams>) {
if (!event.frame?.parent) {
// only use this handler to process iframe events (non-iframe events go to shNavHandler)
return;
}
const url = event.url;
console.log(`frame-navigation url=${url} frame=${event.frame.name}`);
if (event.frame.name == "webview") {
// "webview" links always open in new window
// this will *not* effect the initial load because srcdoc does not count as an electron navigation
console.log("open external, frameNav", url);
event.preventDefault();
electron.shell.openExternal(url);
return;
}
if (
event.frame.name == "pdfview" &&
(url.startsWith("blob:file:///") || url.startsWith(getWebServerEndpoint() + "/wave/stream-file?"))
) {
// allowed
return;
}
event.preventDefault();
console.log("frame navigation canceled");
}
function computeNewWinBounds(waveWindow: WaveWindow): Electron.Rectangle {
const targetWidth = waveWindow.winsize?.width || 2000;
const targetHeight = waveWindow.winsize?.height || 1080;
const primaryDisplay = electron.screen.getPrimaryDisplay();
const workArea = primaryDisplay.workArea;
const targetPadding = 100;
const minPadding = 10;
let rtn = {
x: workArea.x + targetPadding,
y: workArea.y + targetPadding,
width: targetWidth,
height: targetHeight,
};
const spareWidth = workArea.width - targetWidth;
if (spareWidth < 2 * minPadding) {
rtn.x = workArea.x + minPadding;
rtn.width = workArea.width - 2 * minPadding;
} else if (spareWidth > 2 * targetPadding) {
rtn.x = workArea.x + targetPadding;
} else {
rtn.x = workArea.y + Math.floor(spareWidth / 2);
}
const spareHeight = workArea.height - targetHeight;
if (spareHeight < 2 * minPadding) {
rtn.y = workArea.y + minPadding;
rtn.height = workArea.height - 2 * minPadding;
} else if (spareHeight > 2 * targetPadding) {
rtn.y = workArea.y + targetPadding;
} else {
rtn.y = workArea.y + Math.floor(spareHeight / 2);
}
return rtn;
}
function computeWinBounds(waveWindow: WaveWindow): Electron.Rectangle {
if (waveWindow.isnew) {
return computeNewWinBounds(waveWindow);
}
let winWidth = waveWindow?.winsize?.width;
let winHeight = waveWindow?.winsize?.height;
let winPosX = waveWindow.pos.x;
let winPosY = waveWindow.pos.y;
if (winWidth == null || winWidth == 0) {
const primaryDisplay = electron.screen.getPrimaryDisplay();
const { width } = primaryDisplay.workAreaSize;
winWidth = width - winPosX - 100;
if (winWidth > 2000) {
winWidth = 2000;
}
}
if (winHeight == null || winHeight == 0) {
const primaryDisplay = electron.screen.getPrimaryDisplay();
const { height } = primaryDisplay.workAreaSize;
winHeight = height - winPosY - 100;
if (winHeight > 1200) {
winHeight = 1200;
}
}
let winBounds = {
x: winPosX,
y: winPosY,
width: winWidth,
height: winHeight,
};
return winBounds;
}
// note, this does not *show* the window.
// to show, await win.readyPromise and then win.show()
function createBrowserWindow(clientId: string, waveWindow: WaveWindow, fullConfig: FullConfigType): WaveBrowserWindow {
let winBounds = computeWinBounds(waveWindow);
winBounds = ensureBoundsAreVisible(winBounds);
persistWindowBounds(waveWindow.oid, winBounds);
const settings = fullConfig?.settings;
const winOpts: Electron.BrowserWindowConstructorOptions = {
titleBarStyle:
unamePlatform === "darwin" ? "hiddenInset" : settings["window:nativetitlebar"] ? "default" : "hidden",
titleBarOverlay:
unamePlatform !== "darwin"
? {
symbolColor: "white",
color: "#00000000",
}
: false,
x: winBounds.x,
y: winBounds.y,
width: winBounds.width,
height: winBounds.height,
minWidth: 400,
minHeight: 300,
icon:
unamePlatform == "linux"
? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png")
: undefined,
webPreferences: {
preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"),
webviewTag: true,
},
show: false,
autoHideMenuBar: !settings?.["window:showmenubar"],
};
const isTransparent = settings?.["window:transparent"] ?? false;
const isBlur = !isTransparent && (settings?.["window:blur"] ?? false);
if (isTransparent) {
winOpts.transparent = true;
} else if (isBlur) {
switch (unamePlatform) {
case "win32": {
winOpts.backgroundMaterial = "acrylic";
break;
}
case "darwin": {
winOpts.vibrancy = "fullscreen-ui";
break;
}
}
} else {
winOpts.backgroundColor = "#222222";
}
const bwin = new electron.BrowserWindow(winOpts);
(bwin as any).waveWindowId = waveWindow.oid;
let readyResolve: (value: void) => void;
(bwin as any).readyPromise = new Promise((resolve, _) => {
readyResolve = resolve;
});
const win: WaveBrowserWindow = bwin as WaveBrowserWindow;
const usp = new URLSearchParams();
usp.set("clientid", clientId);
usp.set("windowid", waveWindow.oid);
const indexHtml = "index.html";
if (isDevVite) {
console.log("running as dev server");
win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html?${usp.toString()}`);
} else {
console.log("running as file");
win.loadFile(path.join(getElectronAppBasePath(), "frontend", indexHtml), { search: usp.toString() });
}
win.once("ready-to-show", () => {
readyResolve();
});
win.webContents.on("will-navigate", shNavHandler);
win.webContents.on("will-frame-navigate", shFrameNavHandler);
win.webContents.on("did-attach-webview", (event, wc) => {
wc.setWindowOpenHandler((details) => {
win.webContents.send("webview-new-window", wc.id, details);
return { action: "deny" };
});
});
win.webContents.on("before-input-event", (e, input) => {
const waveEvent = keyutil.adaptFromElectronKeyEvent(input);
// console.log("WIN bie", waveEvent.type, waveEvent.code);
handleCtrlShiftState(win.webContents, waveEvent);
if (win.isFocused()) {
wasActive = true;
}
});
win.on(
// @ts-expect-error
"resize",
debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win))
);
win.on(
// @ts-expect-error
"move",
debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win))
);
win.on("focus", () => {
wasInFg = true;
wasActive = true;
if (globalIsStarting) {
return;
}
console.log("focus", waveWindow.oid);
services.ClientService.FocusWindow(waveWindow.oid);
});
win.on("blur", () => {
handleCtrlShiftFocus(win.webContents, false);
});
win.on("enter-full-screen", async () => {
win.webContents.send("fullscreen-change", true);
});
win.on("leave-full-screen", async () => {
win.webContents.send("fullscreen-change", false);
});
win.on("close", (e) => {
if (globalIsQuitting || updater?.status == "installing") {
return;
}
const numWindows = electron.BrowserWindow.getAllWindows().length;
if (numWindows == 1) {
return;
}
const choice = electron.dialog.showMessageBoxSync(win, {
type: "question",
buttons: ["Cancel", "Yes"],
title: "Confirm",
message: "Are you sure you want to close this window (all tabs and blocks will be deleted)?",
});
if (choice === 0) {
e.preventDefault();
}
});
win.on("closed", () => {
if (globalIsQuitting || updater?.status == "installing") {
return;
}
const numWindows = electron.BrowserWindow.getAllWindows().length;
if (numWindows == 0) {
return;
}
services.WindowService.CloseWindow(waveWindow.oid);
});
win.webContents.on("zoom-changed", (e) => {
win.webContents.send("zoom-changed");
});
win.webContents.setWindowOpenHandler(({ url, frameName }) => {
if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) {
console.log("openExternal fallback", url);
electron.shell.openExternal(url);
}
console.log("window-open denied", url);
return { action: "deny" };
});
configureAuthKeyRequestInjection(win.webContents.session);
return win;
}
function isWindowFullyVisible(bounds: electron.Rectangle): boolean {
const displays = electron.screen.getAllDisplays();
// Helper function to check if a point is inside any display
function isPointInDisplay(x: number, y: number) {
for (const display of displays) {
const { x: dx, y: dy, width, height } = display.bounds;
if (x >= dx && x < dx + width && y >= dy && y < dy + height) {
return true;
}
}
return false;
}
// Check all corners of the window
const topLeft = isPointInDisplay(bounds.x, bounds.y);
const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y);
const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height);
const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height);
return topLeft && topRight && bottomLeft && bottomRight;
}
function findDisplayWithMostArea(bounds: electron.Rectangle): electron.Display {
const displays = electron.screen.getAllDisplays();
let maxArea = 0;
let bestDisplay = null;
for (let display of displays) {
const { x, y, width, height } = display.bounds;
const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x));
const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y));
const overlapArea = overlapX * overlapY;
if (overlapArea > maxArea) {
maxArea = overlapArea;
bestDisplay = display;
}
}
return bestDisplay;
}
function adjustBoundsToFitDisplay(bounds: electron.Rectangle, display: electron.Display): electron.Rectangle {
const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea;
let { x, y, width, height } = bounds;
// Adjust width and height to fit within the display's work area
width = Math.min(width, dWidth);
height = Math.min(height, dHeight);
// Adjust x to ensure the window fits within the display
if (x < dx) {
x = dx;
} else if (x + width > dx + dWidth) {
x = dx + dWidth - width;
}
// Adjust y to ensure the window fits within the display
if (y < dy) {
y = dy;
} else if (y + height > dy + dHeight) {
y = dy + dHeight - height;
}
return { x, y, width, height };
}
function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle {
if (!isWindowFullyVisible(bounds)) {
let targetDisplay = findDisplayWithMostArea(bounds);
if (!targetDisplay) {
targetDisplay = electron.screen.getPrimaryDisplay();
}
return adjustBoundsToFitDisplay(bounds, targetDisplay);
}
return bounds;
}
// Listen for the open-external event from the renderer process // Listen for the open-external event from the renderer process
electron.ipcMain.on("open-external", (event, url) => { electron.ipcMain.on("open-external", (event, url) => {
if (url && typeof url === "string") { if (url && typeof url === "string") {
@ -712,7 +223,7 @@ function getUrlInSession(session: Electron.Session, url: string): Promise<UrlInS
electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => { electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => {
const menu = new electron.Menu(); const menu = new electron.Menu();
const win = electron.BrowserWindow.fromWebContents(event.sender.hostWebContents); const win = getWaveWindowByWebContentsId(event.sender.hostWebContents.id);
if (win == null) { if (win == null) {
return; return;
} }
@ -733,19 +244,66 @@ electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent,
); );
const { x, y } = electron.screen.getCursorScreenPoint(); const { x, y } = electron.screen.getCursorScreenPoint();
const windowPos = win.getPosition(); const windowPos = win.getPosition();
menu.popup({ window: win, x: x - windowPos[0], y: y - windowPos[1] }); menu.popup();
}); });
electron.ipcMain.on("download", (event, payload) => { electron.ipcMain.on("download", (event, payload) => {
const window = electron.BrowserWindow.fromWebContents(event.sender);
const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?path=" + encodeURIComponent(payload.filePath); const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?path=" + encodeURIComponent(payload.filePath);
window.webContents.downloadURL(streamingUrl); event.sender.downloadURL(streamingUrl);
});
electron.ipcMain.on("set-active-tab", async (event, tabId) => {
const ww = getWaveWindowByWebContentsId(event.sender.id);
console.log("set-active-tab", tabId, ww?.waveWindowId);
await setActiveTab(ww, tabId);
});
electron.ipcMain.on("create-tab", async (event, opts) => {
const senderWc = event.sender;
const tabView = getWaveTabViewByWebContentsId(senderWc.id);
if (tabView == null) {
return;
}
const waveWindowId = tabView.waveWindowId;
const waveWindow = (await services.ObjectService.GetObject("window:" + waveWindowId)) as WaveWindow;
if (waveWindow == null) {
return;
}
const newTabId = await services.ObjectService.AddTabToWorkspace(waveWindowId, null, true);
const ww = getWaveWindowById(waveWindowId);
if (ww == null) {
return;
}
await setActiveTab(ww, newTabId);
event.returnValue = true;
return null;
});
electron.ipcMain.on("close-tab", async (event, tabId) => {
const tabView = getWaveTabViewByWebContentsId(event.sender.id);
if (tabView == null) {
return;
}
const rtn = await services.WindowService.CloseTab(tabView.waveWindowId, tabId, true);
if (rtn?.closewindow) {
const ww = getWaveWindowById(tabView.waveWindowId);
ww.alreadyClosed = true;
ww?.destroy(); // bypass the "are you sure?" dialog
} else if (rtn?.newactivetabid) {
setActiveTab(getWaveWindowById(tabView.waveWindowId), rtn.newactivetabid);
}
event.returnValue = true;
return null;
}); });
electron.ipcMain.on("get-cursor-point", (event) => { electron.ipcMain.on("get-cursor-point", (event) => {
const window = electron.BrowserWindow.fromWebContents(event.sender); const tabView = getWaveTabViewByWebContentsId(event.sender.id);
if (tabView == null) {
event.returnValue = null;
return;
}
const screenPoint = electron.screen.getCursorScreenPoint(); const screenPoint = electron.screen.getCursorScreenPoint();
const windowRect = window.getContentBounds(); const windowRect = tabView.getBounds();
const retVal: Electron.Point = { const retVal: Electron.Point = {
x: screenPoint.x - windowRect.x, x: screenPoint.x - windowRect.x,
y: screenPoint.y - windowRect.y, y: screenPoint.y - windowRect.y,
@ -758,7 +316,7 @@ electron.ipcMain.on("get-env", (event, varName) => {
}); });
electron.ipcMain.on("get-about-modal-details", (event) => { electron.ipcMain.on("get-about-modal-details", (event) => {
event.returnValue = { version: WaveVersion, buildTime: WaveBuildTime } as AboutModalDetails; event.returnValue = getWaveVersion() as AboutModalDetails;
}); });
const hasBeforeInputRegisteredMap = new Map<number, boolean>(); const hasBeforeInputRegisteredMap = new Map<number, boolean>();
@ -825,8 +383,8 @@ if (unamePlatform !== "darwin") {
const overlayBuffer = overlay.toPNG(); const overlayBuffer = overlay.toPNG();
const png = PNG.sync.read(overlayBuffer); const png = PNG.sync.read(overlayBuffer);
const color = fac.prepareResult(fac.getColorFromArray4(png.data)); const color = fac.prepareResult(fac.getColorFromArray4(png.data));
const window = electron.BrowserWindow.fromWebContents(event.sender); const ww = getWaveWindowByWebContentsId(event.sender.id);
window.setTitleBarOverlay({ ww.setTitleBarOverlay({
color: unamePlatform === "linux" ? color.rgba : "#00000000", // Windows supports a true transparent overlay, so we don't need to set a background color. color: unamePlatform === "linux" ? color.rgba : "#00000000", // Windows supports a true transparent overlay, so we don't need to set a background color.
symbolColor: color.isDark ? "white" : "black", symbolColor: color.isDark ? "white" : "black",
}); });
@ -848,13 +406,14 @@ async function createNewWaveWindow(): Promise<void> {
const clientData = await services.ClientService.GetClientData(); const clientData = await services.ClientService.GetClientData();
const fullConfig = await services.FileService.GetFullConfig(); const fullConfig = await services.FileService.GetFullConfig();
let recreatedWindow = false; let recreatedWindow = false;
if (electron.BrowserWindow.getAllWindows().length === 0 && clientData?.windowids?.length >= 1) { const allWindows = getAllWaveWindows();
if (allWindows.length === 0 && clientData?.windowids?.length >= 1) {
// reopen the first window // reopen the first window
const existingWindowId = clientData.windowids[0]; const existingWindowId = clientData.windowids[0];
const existingWindowData = (await services.ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow; const existingWindowData = (await services.ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow;
if (existingWindowData != null) { if (existingWindowData != null) {
const win = createBrowserWindow(clientData.oid, existingWindowData, fullConfig); const win = createBrowserWindow(clientData.oid, existingWindowData, fullConfig, { unamePlatform });
await win.readyPromise; await win.waveReadyPromise;
win.show(); win.show();
recreatedWindow = true; recreatedWindow = true;
} }
@ -863,16 +422,37 @@ async function createNewWaveWindow(): Promise<void> {
return; return;
} }
const newWindow = await services.ClientService.MakeWindow(); const newWindow = await services.ClientService.MakeWindow();
const newBrowserWindow = createBrowserWindow(clientData.oid, newWindow, fullConfig); const newBrowserWindow = createBrowserWindow(clientData.oid, newWindow, fullConfig, { unamePlatform });
await newBrowserWindow.readyPromise; await newBrowserWindow.waveReadyPromise;
newBrowserWindow.show(); newBrowserWindow.show();
} }
electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-ready") => {
const tabView = getWaveTabViewByWebContentsId(event.sender.id);
if (tabView == null || tabView.initResolve == null) {
return;
}
if (status === "ready") {
console.log("initResolve");
tabView.initResolve();
if (tabView.savedInitOpts) {
tabView.webContents.send("wave-init", tabView.savedInitOpts);
}
} else if (status === "wave-ready") {
console.log("waveReadyResolve");
tabView.waveReadyResolve();
}
});
electron.ipcMain.on("fe-log", (event, logStr: string) => {
console.log("fe-log", logStr);
});
function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string, readStream: Readable) { function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string, readStream: Readable) {
if (defaultFileName == null || defaultFileName == "") { if (defaultFileName == null || defaultFileName == "") {
defaultFileName = "image"; defaultFileName = "image";
} }
const window = electron.BrowserWindow.getFocusedWindow(); // Get the current window context const ww = getFocusedWaveWindow();
const mimeToExtension: { [key: string]: string } = { const mimeToExtension: { [key: string]: string } = {
"image/png": "png", "image/png": "png",
"image/jpeg": "jpg", "image/jpeg": "jpg",
@ -891,7 +471,7 @@ function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string
} }
defaultFileName = addExtensionIfNeeded(defaultFileName, mimeType); defaultFileName = addExtensionIfNeeded(defaultFileName, mimeType);
electron.dialog electron.dialog
.showSaveDialog(window, { .showSaveDialog(ww, {
title: "Save Image", title: "Save Image",
defaultPath: defaultFileName, defaultPath: defaultFileName,
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "heic"] }], filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "heic"] }],
@ -922,20 +502,19 @@ function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string
electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow));
electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => { electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => {
const window = electron.BrowserWindow.fromWebContents(event.sender);
if (menuDefArr?.length === 0) { if (menuDefArr?.length === 0) {
return; return;
} }
const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : instantiateAppMenu(); const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : instantiateAppMenu();
const { x, y } = electron.screen.getCursorScreenPoint(); // const { x, y } = electron.screen.getCursorScreenPoint();
const windowPos = window.getPosition(); // const windowPos = window.getPosition();
menu.popup();
menu.popup({ window, x: x - windowPos[0], y: y - windowPos[1] });
event.returnValue = true; event.returnValue = true;
}); });
async function logActiveState() { async function logActiveState() {
const activeState = { fg: wasInFg, active: wasActive, open: true }; const astate = getActivityState();
const activeState = { fg: astate.wasInFg, active: astate.wasActive, open: true };
const url = new URL(getWebServerEndpoint() + "/wave/log-active-state"); const url = new URL(getWebServerEndpoint() + "/wave/log-active-state");
try { try {
const resp = await fetch(url, { method: "post", body: JSON.stringify(activeState) }); const resp = await fetch(url, { method: "post", body: JSON.stringify(activeState) });
@ -947,8 +526,9 @@ async function logActiveState() {
console.log("error logging active state", e); console.log("error logging active state", e);
} finally { } finally {
// for next iteration // for next iteration
wasInFg = electron.BrowserWindow.getFocusedWindow()?.isFocused() ?? false; const ww = getFocusedWaveWindow();
wasActive = false; setWasInFg(ww?.isFocused() ?? false);
setWasActive(false);
} }
} }
@ -966,7 +546,9 @@ function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electro
label: menuDef.label, label: menuDef.label,
type: menuDef.type, type: menuDef.type,
click: (_, window) => { click: (_, window) => {
(window as electron.BrowserWindow)?.webContents?.send("contextmenu-click", menuDef.id); const ww = window as WaveBrowserWindow;
const tabView = ww.activeTabView;
tabView?.webContents?.send("contextmenu-click", menuDef.id);
}, },
checked: menuDef.checked, checked: menuDef.checked,
}; };
@ -980,7 +562,11 @@ function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electro
} }
function instantiateAppMenu(): electron.Menu { function instantiateAppMenu(): electron.Menu {
return getAppMenu({ createNewWaveWindow, relaunchBrowserWindows }); return getAppMenu({
createNewWaveWindow,
relaunchBrowserWindows,
getLastFocusedWaveWindow: getLastFocusedWaveWindow,
});
} }
function makeAppMenu() { function makeAppMenu() {
@ -989,7 +575,7 @@ function makeAppMenu() {
} }
electronApp.on("window-all-closed", () => { electronApp.on("window-all-closed", () => {
if (globalIsRelaunching) { if (getGlobalIsRelaunching()) {
return; return;
} }
if (unamePlatform !== "darwin") { if (unamePlatform !== "darwin") {
@ -997,32 +583,32 @@ electronApp.on("window-all-closed", () => {
} }
}); });
electronApp.on("before-quit", (e) => { electronApp.on("before-quit", (e) => {
globalIsQuitting = true; setGlobalIsQuitting(true);
updater?.stop(); updater?.stop();
if (unamePlatform == "win32") { if (unamePlatform == "win32") {
// win32 doesn't have a SIGINT, so we just let electron die, which // win32 doesn't have a SIGINT, so we just let electron die, which
// ends up killing wavesrv via closing it's stdin. // ends up killing wavesrv via closing it's stdin.
return; return;
} }
waveSrvProc?.kill("SIGINT"); getWaveSrvProc()?.kill("SIGINT");
shutdownWshrpc(); shutdownWshrpc();
if (forceQuit) { if (getForceQuit()) {
return; return;
} }
e.preventDefault(); e.preventDefault();
const allWindows = electron.BrowserWindow.getAllWindows(); const allWindows = getAllWaveWindows();
for (const window of allWindows) { for (const window of allWindows) {
window.hide(); window.hide();
} }
if (isWaveSrvDead) { if (getIsWaveSrvDead()) {
console.log("wavesrv is dead, quitting immediately"); console.log("wavesrv is dead, quitting immediately");
forceQuit = true; setForceQuit(true);
electronApp.quit(); electronApp.quit();
return; return;
} }
setTimeout(() => { setTimeout(() => {
console.log("waiting for wavesrv to exit..."); console.log("waiting for wavesrv to exit...");
forceQuit = true; setForceQuit(true);
electronApp.quit(); electronApp.quit();
}, 3000); }, 3000);
}); });
@ -1051,13 +637,13 @@ process.on("uncaughtException", (error) => {
}); });
async function relaunchBrowserWindows(): Promise<void> { async function relaunchBrowserWindows(): Promise<void> {
globalIsRelaunching = true; setGlobalIsRelaunching(true);
const windows = electron.BrowserWindow.getAllWindows(); const windows = getAllWaveWindows();
for (const window of windows) { for (const window of windows) {
window.removeAllListeners(); window.removeAllListeners();
window.close(); window.close();
} }
globalIsRelaunching = false; setGlobalIsRelaunching(false);
const clientData = await services.ClientService.GetClientData(); const clientData = await services.ClientService.GetClientData();
const fullConfig = await services.FileService.GetFullConfig(); const fullConfig = await services.FileService.GetFullConfig();
@ -1065,16 +651,16 @@ async function relaunchBrowserWindows(): Promise<void> {
for (const windowId of clientData.windowids.slice().reverse()) { for (const windowId of clientData.windowids.slice().reverse()) {
const windowData: WaveWindow = (await services.ObjectService.GetObject("window:" + windowId)) as WaveWindow; const windowData: WaveWindow = (await services.ObjectService.GetObject("window:" + windowId)) as WaveWindow;
if (windowData == null) { if (windowData == null) {
services.WindowService.CloseWindow(windowId).catch((e) => { services.WindowService.CloseWindow(windowId, true).catch((e) => {
/* ignore */ /* ignore */
}); });
continue; continue;
} }
const win = createBrowserWindow(clientData.oid, windowData, fullConfig); const win = createBrowserWindow(clientData.oid, windowData, fullConfig, { unamePlatform });
wins.push(win); wins.push(win);
} }
for (const win of wins) { for (const win of wins) {
await win.readyPromise; await win.waveReadyPromise;
console.log("show window", win.waveWindowId); console.log("show window", win.waveWindowId);
win.show(); win.show();
} }
@ -1087,7 +673,6 @@ async function appMain() {
console.log("disabling hardware acceleration, per launch settings"); console.log("disabling hardware acceleration, per launch settings");
electronApp.disableHardwareAcceleration(); electronApp.disableHardwareAcceleration();
} }
const startTs = Date.now(); const startTs = Date.now();
const instanceLock = electronApp.requestSingleInstanceLock(); const instanceLock = electronApp.requestSingleInstanceLock();
if (!instanceLock) { if (!instanceLock) {
@ -1095,20 +680,18 @@ async function appMain() {
electronApp.quit(); electronApp.quit();
return; return;
} }
const waveHomeDir = getWaveHomeDir();
if (!fs.existsSync(waveHomeDir)) {
fs.mkdirSync(waveHomeDir);
}
makeAppMenu(); makeAppMenu();
try { try {
await runWaveSrv(); await runWaveSrv(handleWSEvent);
} catch (e) { } catch (e) {
console.log(e.toString()); console.log(e.toString());
} }
const ready = await waveSrvReady; const ready = await getWaveSrvReady();
console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms"); console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms");
await electronApp.whenReady(); await electronApp.whenReady();
configureAuthKeyRequestInjection(electron.session.defaultSession); configureAuthKeyRequestInjection(electron.session.defaultSession);
const fullConfig = await services.FileService.GetFullConfig();
ensureHotSpareTab(fullConfig);
await relaunchBrowserWindows(); await relaunchBrowserWindows();
await initDocsite(); await initDocsite();
setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe
@ -1120,10 +703,14 @@ async function appMain() {
} }
await configureAutoUpdater(); await configureAutoUpdater();
globalIsStarting = false; setGlobalIsStarting(false);
if (fullConfig?.settings?.["window:maxtabcachesize"] != null) {
setMaxTabCacheSize(fullConfig.settings["window:maxtabcachesize"]);
}
electronApp.on("activate", async () => { electronApp.on("activate", async () => {
if (electron.BrowserWindow.getAllWindows().length === 0) { const allWindows = getAllWaveWindows();
if (allWindows.length === 0) {
await createNewWaveWindow(); await createNewWaveWindow();
} }
}); });

View File

@ -1,6 +1,6 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { getWaveHomeDir } from "./platform"; import { getWaveConfigDir } from "./platform";
/** /**
* Get settings directly from the Wave Home directory on launch. * Get settings directly from the Wave Home directory on launch.
@ -8,7 +8,7 @@ import { getWaveHomeDir } from "./platform";
* @returns The initial launch settings for the application. * @returns The initial launch settings for the application.
*/ */
export function getLaunchSettings(): SettingsType { export function getLaunchSettings(): SettingsType {
const settingsPath = path.join(getWaveHomeDir(), "config", "settings.json"); const settingsPath = path.join(getWaveConfigDir(), "settings.json");
try { try {
const settingsContents = fs.readFileSync(settingsPath, "utf8"); const settingsContents = fs.readFileSync(settingsPath, "utf8");
return JSON.parse(settingsContents); return JSON.parse(settingsContents);

View File

@ -3,20 +3,26 @@
import * as electron from "electron"; import * as electron from "electron";
import { fireAndForget } from "../frontend/util/util"; import { fireAndForget } from "../frontend/util/util";
import { clearTabCache, getFocusedWaveWindow } from "./emain-viewmgr";
import { unamePlatform } from "./platform"; import { unamePlatform } from "./platform";
import { updater } from "./updater"; import { updater } from "./updater";
type AppMenuCallbacks = { type AppMenuCallbacks = {
createNewWaveWindow: () => Promise<void>; createNewWaveWindow: () => Promise<void>;
relaunchBrowserWindows: () => Promise<void>; relaunchBrowserWindows: () => Promise<void>;
getLastFocusedWaveWindow: () => WaveBrowserWindow;
}; };
function getWindowWebContents(window: electron.BaseWindow): electron.WebContents { function getWindowWebContents(window: electron.BaseWindow): electron.WebContents {
if (window == null) { if (window == null) {
return null; return null;
} }
if (window instanceof electron.BrowserWindow) { if (window instanceof electron.BaseWindow) {
return window.webContents; const waveWin = window as WaveBrowserWindow;
if (waveWin.activeTabView) {
return waveWin.activeTabView.webContents;
}
return null;
} }
return null; return null;
} }
@ -32,7 +38,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
role: "close", role: "close",
accelerator: "", // clear the accelerator accelerator: "", // clear the accelerator
click: () => { click: () => {
electron.BrowserWindow.getFocusedWindow()?.close(); getFocusedWaveWindow()?.close();
}, },
}, },
]; ];
@ -112,9 +118,14 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
}, },
]; ];
const devToolsAccel = unamePlatform === "darwin" ? "Option+Command+I" : "Alt+Meta+I";
const viewMenu: Electron.MenuItemConstructorOptions[] = [ const viewMenu: Electron.MenuItemConstructorOptions[] = [
{ {
role: "forceReload", label: "Reload Tab",
accelerator: "Shift+CommandOrControl+R",
click: (_, window) => {
getWindowWebContents(window)?.reloadIgnoringCache();
},
}, },
{ {
label: "Relaunch All Windows", label: "Relaunch All Windows",
@ -123,7 +134,18 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
}, },
}, },
{ {
role: "toggleDevTools", label: "Clear Tab Cache",
click: () => {
clearTabCache();
},
},
{
label: "Toggle DevTools",
accelerator: devToolsAccel,
click: (_, window) => {
let wc = getWindowWebContents(window);
wc?.toggleDevTools();
},
}, },
{ {
type: "separator", type: "separator",
@ -143,6 +165,9 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
if (wc == null) { if (wc == null) {
return; return;
} }
if (wc.getZoomFactor() >= 5) {
return;
}
wc.setZoomFactor(wc.getZoomFactor() + 0.2); wc.setZoomFactor(wc.getZoomFactor() + 0.2);
}, },
}, },
@ -154,6 +179,9 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
if (wc == null) { if (wc == null) {
return; return;
} }
if (wc.getZoomFactor() >= 5) {
return;
}
wc.setZoomFactor(wc.getZoomFactor() + 0.2); wc.setZoomFactor(wc.getZoomFactor() + 0.2);
}, },
visible: false, visible: false,
@ -167,9 +195,28 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
if (wc == null) { if (wc == null) {
return; return;
} }
if (wc.getZoomFactor() <= 0.2) {
return;
}
wc.setZoomFactor(wc.getZoomFactor() - 0.2); wc.setZoomFactor(wc.getZoomFactor() - 0.2);
}, },
}, },
{
label: "Zoom Out (hidden)",
accelerator: "CommandOrControl+Shift+-",
click: (_, window) => {
const wc = getWindowWebContents(window);
if (wc == null) {
return;
}
if (wc.getZoomFactor() <= 0.2) {
return;
}
wc.setZoomFactor(wc.getZoomFactor() - 0.2);
},
visible: false,
acceleratorWorksWhenHidden: true,
},
{ {
type: "separator", type: "separator",
}, },

View File

@ -2,12 +2,18 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { app, ipcMain } from "electron"; import { app, ipcMain } from "electron";
import envPaths from "env-paths";
import { existsSync, mkdirSync } from "fs";
import os from "os"; import os from "os";
import path from "path"; import path from "path";
import { WaveDevVarName, WaveDevViteVarName } from "../frontend/util/isdev"; import { WaveDevVarName, WaveDevViteVarName } from "../frontend/util/isdev";
import * as keyutil from "../frontend/util/keyutil"; import * as keyutil from "../frontend/util/keyutil";
const WaveHomeVarName = "WAVETERM_HOME"; // This is a little trick to ensure that Electron puts all its runtime data into a subdirectory to avoid conflicts with our own data.
// On macOS, it will store to ~/Library/Application \Support/waveterm/electron
// On Linux, it will store to ~/.config/waveterm/electron
// On Windows, it will store to %LOCALAPPDATA%/waveterm/electron
app.setName("waveterm/electron");
const isDev = !app.isPackaged; const isDev = !app.isPackaged;
const isDevVite = isDev && process.env.ELECTRON_RENDERER_URL; const isDevVite = isDev && process.env.ELECTRON_RENDERER_URL;
@ -18,35 +24,100 @@ if (isDevVite) {
process.env[WaveDevViteVarName] = "1"; process.env[WaveDevViteVarName] = "1";
} }
const waveDirNamePrefix = "waveterm";
const waveDirNameSuffix = isDev ? "dev" : "";
const waveDirName = `${waveDirNamePrefix}${waveDirNameSuffix ? `-${waveDirNameSuffix}` : ""}`;
const paths = envPaths("waveterm", { suffix: waveDirNameSuffix });
app.setName(isDev ? "Wave (Dev)" : "Wave"); app.setName(isDev ? "Wave (Dev)" : "Wave");
const unamePlatform = process.platform; const unamePlatform = process.platform;
const unameArch: string = process.arch; const unameArch: string = process.arch;
keyutil.setKeyUtilPlatform(unamePlatform); keyutil.setKeyUtilPlatform(unamePlatform);
ipcMain.on("get-is-dev", (event) => { const WaveConfigHomeVarName = "WAVETERM_CONFIG_HOME";
event.returnValue = isDev; const WaveDataHomeVarName = "WAVETERM_DATA_HOME";
}); const WaveHomeVarName = "WAVETERM_HOME";
ipcMain.on("get-platform", (event, url) => {
event.returnValue = unamePlatform;
});
ipcMain.on("get-user-name", (event) => {
const userInfo = os.userInfo();
event.returnValue = userInfo.username;
});
ipcMain.on("get-host-name", (event) => {
event.returnValue = os.hostname();
});
ipcMain.on("get-webview-preload", (event) => {
event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs");
});
// must match golang /**
function getWaveHomeDir() { * Gets the path to the old Wave home directory (defaults to `~/.waveterm`).
const override = process.env[WaveHomeVarName]; * @returns The path to the directory if it exists and contains valid data for the current app, otherwise null.
if (override) { */
return override; function getWaveHomeDir(): string {
let home = process.env[WaveHomeVarName];
if (!home) {
const homeDir = app.getPath("home");
if (homeDir) {
home = path.join(homeDir, `.${waveDirName}`);
} }
return path.join(os.homedir(), isDev ? ".waveterm-dev" : ".waveterm"); }
// If home exists and it has `wave.lock` in it, we know it has valid data from Wave >=v0.8. Otherwise, it could be for WaveLegacy (<v0.8)
if (home && existsSync(home) && existsSync(path.join(home, "wave.lock"))) {
return home;
}
return null;
}
/**
* Ensure the given path exists, creating it recursively if it doesn't.
* @param path The path to ensure.
* @returns The same path, for chaining.
*/
function ensurePathExists(path: string): string {
if (!existsSync(path)) {
mkdirSync(path, { recursive: true });
}
return path;
}
/**
* Gets the path to the directory where Wave configurations are stored. Creates the directory if it does not exist.
* Handles backwards compatibility with the old Wave Home directory model, where configurations and data were stored together.
* @returns The path where configurations should be stored.
*/
function getWaveConfigDir(): string {
// If wave home dir exists, use it for backwards compatibility
const waveHomeDir = getWaveHomeDir();
if (waveHomeDir) {
return path.join(waveHomeDir, "config");
}
const override = process.env[WaveConfigHomeVarName];
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
let retVal: string;
if (override) {
retVal = override;
} else if (xdgConfigHome) {
retVal = path.join(xdgConfigHome, waveDirName);
} else {
retVal = path.join(app.getPath("home"), ".config", waveDirName);
}
return ensurePathExists(retVal);
}
/**
* Gets the path to the directory where Wave data is stored. Creates the directory if it does not exist.
* Handles backwards compatibility with the old Wave Home directory model, where configurations and data were stored together.
* @returns The path where data should be stored.
*/
function getWaveDataDir(): string {
// If wave home dir exists, use it for backwards compatibility
const waveHomeDir = getWaveHomeDir();
if (waveHomeDir) {
return waveHomeDir;
}
const override = process.env[WaveDataHomeVarName];
const xdgDataHome = process.env.XDG_DATA_HOME;
let retVal: string;
if (override) {
retVal = override;
} else if (xdgDataHome) {
retVal = path.join(xdgDataHome, waveDirName);
} else {
retVal = paths.data;
}
return ensurePathExists(retVal);
} }
function getElectronAppBasePath(): string { function getElectronAppBasePath(): string {
@ -69,17 +140,43 @@ function getWaveSrvPath(): string {
} }
function getWaveSrvCwd(): string { function getWaveSrvCwd(): string {
return getWaveHomeDir(); return getWaveDataDir();
} }
ipcMain.on("get-is-dev", (event) => {
event.returnValue = isDev;
});
ipcMain.on("get-platform", (event, url) => {
event.returnValue = unamePlatform;
});
ipcMain.on("get-user-name", (event) => {
const userInfo = os.userInfo();
event.returnValue = userInfo.username;
});
ipcMain.on("get-host-name", (event) => {
event.returnValue = os.hostname();
});
ipcMain.on("get-webview-preload", (event) => {
event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs");
});
ipcMain.on("get-data-dir", (event) => {
event.returnValue = getWaveDataDir();
});
ipcMain.on("get-config-dir", (event) => {
event.returnValue = getWaveConfigDir();
});
export { export {
getElectronAppBasePath, getElectronAppBasePath,
getElectronAppUnpackedBasePath, getElectronAppUnpackedBasePath,
getWaveHomeDir, getWaveConfigDir,
getWaveDataDir,
getWaveSrvCwd, getWaveSrvCwd,
getWaveSrvPath, getWaveSrvPath,
isDev, isDev,
isDevVite, isDevVite,
unameArch, unameArch,
unamePlatform, unamePlatform,
WaveConfigHomeVarName,
WaveDataHomeVarName,
}; };

View File

@ -10,6 +10,8 @@ contextBridge.exposeInMainWorld("api", {
getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"), getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"),
getUserName: () => ipcRenderer.sendSync("get-user-name"), getUserName: () => ipcRenderer.sendSync("get-user-name"),
getHostName: () => ipcRenderer.sendSync("get-host-name"), getHostName: () => ipcRenderer.sendSync("get-host-name"),
getDataDir: () => ipcRenderer.sendSync("get-data-dir"),
getConfigDir: () => ipcRenderer.sendSync("get-config-dir"),
getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"), getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"),
getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"), getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"),
getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"), getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"),
@ -38,6 +40,12 @@ contextBridge.exposeInMainWorld("api", {
registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys), registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys),
onControlShiftStateUpdate: (callback) => onControlShiftStateUpdate: (callback) =>
ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)), ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)),
setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId),
createTab: () => ipcRenderer.send("create-tab"),
closeTab: (tabId) => ipcRenderer.send("close-tab", tabId),
setWindowInitStatus: (status) => ipcRenderer.send("set-window-init-status", status),
onWaveInit: (callback) => ipcRenderer.on("wave-init", (_event, initOpts) => callback(initOpts)),
sendLog: (log) => ipcRenderer.send("fe-log", log),
onQuicklook: (filePath: string) => ipcRenderer.send("quicklook", filePath), onQuicklook: (filePath: string) => ipcRenderer.send("quicklook", filePath),
}); });

View File

@ -1,15 +1,16 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { RpcApi } from "@/app/store/wshclientapi"; import { dialog, ipcMain, Notification } from "electron";
import { BrowserWindow, dialog, ipcMain, Notification } from "electron";
import { autoUpdater } from "electron-updater"; import { autoUpdater } from "electron-updater";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import path from "path"; import path from "path";
import YAML from "yaml"; import YAML from "yaml";
import { FileService } from "../frontend/app/store/services"; import { FileService } from "../frontend/app/store/services";
import { RpcApi } from "../frontend/app/store/wshclientapi";
import { isDev } from "../frontend/util/isdev"; import { isDev } from "../frontend/util/isdev";
import { fireAndForget } from "../frontend/util/util"; import { fireAndForget } from "../frontend/util/util";
import { getAllWaveWindows, getFocusedWaveWindow } from "./emain-viewmgr";
import { ElectronWshClient } from "./emain-wsh"; import { ElectronWshClient } from "./emain-wsh";
export let updater: Updater; export let updater: Updater;
@ -109,8 +110,11 @@ export class Updater {
private set status(value: UpdaterStatus) { private set status(value: UpdaterStatus) {
this._status = value; this._status = value;
BrowserWindow.getAllWindows().forEach((window) => { getAllWaveWindows().forEach((window) => {
window.webContents.send("app-update-status", value); const allTabs = Array.from(window.allTabViews.values());
allTabs.forEach((tab) => {
tab.webContents.send("app-update-status", value);
});
}); });
} }
@ -159,7 +163,7 @@ export class Updater {
type: "info", type: "info",
message: "There are currently no updates available.", message: "There are currently no updates available.",
}; };
dialog.showMessageBox(BrowserWindow.getFocusedWindow(), dialogOpts); dialog.showMessageBox(getFocusedWaveWindow(), dialogOpts);
} }
// Only update the last check time if this is an automatic check. This ensures the interval remains consistent. // Only update the last check time if this is an automatic check. This ensures the interval remains consistent.
@ -179,11 +183,10 @@ export class Updater {
detail: "A new version has been downloaded. Restart the application to apply the updates.", detail: "A new version has been downloaded. Restart the application to apply the updates.",
}; };
const allWindows = BrowserWindow.getAllWindows(); const allWindows = getAllWaveWindows();
if (allWindows.length > 0) { if (allWindows.length > 0) {
await dialog const focusedWindow = getFocusedWaveWindow();
.showMessageBox(BrowserWindow.getFocusedWindow() ?? allWindows[0], dialogOpts) await dialog.showMessageBox(focusedWindow ?? allWindows[0], dialogOpts).then(({ response }) => {
.then(({ response }) => {
if (response === 0) { if (response === 0) {
this.installUpdate(); this.installUpdate();
} }

View File

@ -1,3 +1,6 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { getWebServerEndpoint } from "@/util/endpoints"; import { getWebServerEndpoint } from "@/util/endpoints";
import * as util from "@/util/util"; import * as util from "@/util/util";
import useResizeObserver from "@react-hook/resize-observer"; import useResizeObserver from "@react-hook/resize-observer";
@ -72,7 +75,7 @@ function processBackgroundUrls(cssText: string): string {
export function AppBackground() { export function AppBackground() {
const bgRef = useRef<HTMLDivElement>(null); const bgRef = useRef<HTMLDivElement>(null);
const tabId = useAtomValue(atoms.activeTabId); const tabId = useAtomValue(atoms.staticTabId);
const [tabData] = useWaveObjectValue<Tab>(WOS.makeORef("tab", tabId)); const [tabData] = useWaveObjectValue<Tab>(WOS.makeORef("tab", tabId));
const bgAttr = tabData?.meta?.bg; const bgAttr = tabData?.meta?.bg;
const style: CSSProperties = {}; const style: CSSProperties = {};

View File

@ -18,6 +18,10 @@ body {
transform: translateZ(0); transform: translateZ(0);
} }
.is-transparent {
background-color: transparent;
}
a.plain-link { a.plain-link {
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }

View File

@ -23,7 +23,10 @@ import { CenteredDiv } from "./element/quickelems";
const dlog = debug("wave:app"); const dlog = debug("wave:app");
const focusLog = debug("wave:focus"); const focusLog = debug("wave:focus");
const App = () => { const App = ({ onFirstRender }: { onFirstRender: () => void }) => {
useEffect(() => {
onFirstRender();
}, []);
return ( return (
<Provider store={globalStore}> <Provider store={globalStore}>
<AppInner /> <AppInner />
@ -115,18 +118,20 @@ function AppSettingsUpdater() {
(windowSettings?.["window:transparent"] || windowSettings?.["window:blur"]) ?? false; (windowSettings?.["window:transparent"] || windowSettings?.["window:blur"]) ?? false;
const opacity = util.boundNumber(windowSettings?.["window:opacity"] ?? 0.8, 0, 1); const opacity = util.boundNumber(windowSettings?.["window:opacity"] ?? 0.8, 0, 1);
let baseBgColor = windowSettings?.["window:bgcolor"]; let baseBgColor = windowSettings?.["window:bgcolor"];
let mainDiv = document.getElementById("main");
// console.log("window settings", windowSettings, isTransparentOrBlur, opacity, baseBgColor, mainDiv);
if (isTransparentOrBlur) { if (isTransparentOrBlur) {
document.body.classList.add("is-transparent"); mainDiv.classList.add("is-transparent");
const rootStyles = getComputedStyle(document.documentElement); const rootStyles = getComputedStyle(document.documentElement);
if (baseBgColor == null) { if (baseBgColor == null) {
baseBgColor = rootStyles.getPropertyValue("--main-bg-color").trim(); baseBgColor = rootStyles.getPropertyValue("--main-bg-color").trim();
} }
const color = new Color(baseBgColor); const color = new Color(baseBgColor);
const rgbaColor = color.alpha(opacity).string(); const rgbaColor = color.alpha(opacity).string();
document.body.style.backgroundColor = rgbaColor; mainDiv.style.backgroundColor = rgbaColor;
} else { } else {
document.body.classList.remove("is-transparent"); mainDiv.classList.remove("is-transparent");
document.body.style.opacity = null; mainDiv.style.opacity = null;
} }
}, [windowSettings]); }, [windowSettings]);
return null; return null;

View File

@ -5,6 +5,8 @@ import { BlockComponentModel2, BlockProps } from "@/app/block/blocktypes";
import { PlotView } from "@/app/view/plotview/plotview"; import { PlotView } from "@/app/view/plotview/plotview";
import { PreviewModel, PreviewView, makePreviewModel } from "@/app/view/preview/preview"; import { PreviewModel, PreviewView, makePreviewModel } from "@/app/view/preview/preview";
import { SysinfoView, SysinfoViewModel, makeSysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; import { SysinfoView, SysinfoViewModel, makeSysinfoViewModel } from "@/app/view/sysinfo/sysinfo";
import { VDomModel } from "@/app/view/term/vdom-model";
import { VDomView, makeVDomModel } from "@/app/view/vdom/vdom-view";
import { ErrorBoundary } from "@/element/errorboundary"; import { ErrorBoundary } from "@/element/errorboundary";
import { CenteredDiv } from "@/element/quickelems"; import { CenteredDiv } from "@/element/quickelems";
import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index"; import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index";
@ -30,6 +32,7 @@ import { BlockFrame } from "./blockframe";
import { blockViewToIcon, blockViewToName } from "./blockutil"; import { blockViewToIcon, blockViewToName } from "./blockutil";
type FullBlockProps = { type FullBlockProps = {
isSubBlock?: boolean;
preview: boolean; preview: boolean;
nodeModel: NodeModel; nodeModel: NodeModel;
viewModel: ViewModel; viewModel: ViewModel;
@ -37,7 +40,7 @@ type FullBlockProps = {
function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel { function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel {
if (blockView === "term") { if (blockView === "term") {
return makeTerminalModel(blockId); return makeTerminalModel(blockId, nodeModel);
} }
if (blockView === "preview") { if (blockView === "preview") {
return makePreviewModel(blockId, nodeModel); return makePreviewModel(blockId, nodeModel);
@ -52,6 +55,9 @@ function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel)
// "cpuplot" is for backwards compatibility with already-opened widgets // "cpuplot" is for backwards compatibility with already-opened widgets
return makeSysinfoViewModel(blockId, blockView); return makeSysinfoViewModel(blockId, blockView);
} }
if (blockView == "vdom") {
return makeVDomModel(blockId, nodeModel);
}
if (blockView === "help") { if (blockView === "help") {
return makeHelpViewModel(blockId, nodeModel); return makeHelpViewModel(blockId, nodeModel);
} }
@ -110,6 +116,9 @@ function getViewElem(
if (blockView == "chat") { if (blockView == "chat") {
return <Chat key={blockId} model={viewModel as ChatModel} />; return <Chat key={blockId} model={viewModel as ChatModel} />;
} }
if (blockView == "vdom") {
return <VDomView key={blockId} blockId={blockId} model={viewModel as VDomModel} />;
}
return <CenteredDiv>Invalid View "{blockView}"</CenteredDiv>; return <CenteredDiv>Invalid View "{blockView}"</CenteredDiv>;
} }
@ -147,6 +156,26 @@ const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => {
); );
}); });
const BlockSubBlock = memo(({ nodeModel, viewModel }: FullBlockProps) => {
const [blockData] = useWaveObjectValue<Block>(makeORef("block", nodeModel.blockId));
const blockRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const viewElem = useMemo(
() => getViewElem(nodeModel.blockId, blockRef, contentRef, blockData?.meta?.view, viewModel),
[nodeModel.blockId, blockData?.meta?.view, viewModel]
);
if (!blockData) {
return null;
}
return (
<div key="content" className="block-content" ref={contentRef}>
<ErrorBoundary>
<Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{viewElem}</Suspense>
</ErrorBoundary>
</div>
);
});
const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
counterInc("render-BlockFull"); counterInc("render-BlockFull");
const focusElemRef = useRef<HTMLInputElement>(null); const focusElemRef = useRef<HTMLInputElement>(null);
@ -265,7 +294,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
const Block = memo((props: BlockProps) => { const Block = memo((props: BlockProps) => {
counterInc("render-Block"); counterInc("render-Block");
counterInc("render-Block-" + props.nodeModel.blockId.substring(0, 8)); counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8));
const [blockData, loading] = useWaveObjectValue<Block>(makeORef("block", props.nodeModel.blockId)); const [blockData, loading] = useWaveObjectValue<Block>(makeORef("block", props.nodeModel.blockId));
const bcm = getBlockComponentModel(props.nodeModel.blockId); const bcm = getBlockComponentModel(props.nodeModel.blockId);
let viewModel = bcm?.viewModel; let viewModel = bcm?.viewModel;
@ -276,6 +305,7 @@ const Block = memo((props: BlockProps) => {
useEffect(() => { useEffect(() => {
return () => { return () => {
unregisterBlockComponentModel(props.nodeModel.blockId); unregisterBlockComponentModel(props.nodeModel.blockId);
viewModel?.dispose?.();
}; };
}, []); }, []);
if (loading || isBlank(props.nodeModel.blockId) || blockData == null) { if (loading || isBlank(props.nodeModel.blockId) || blockData == null) {
@ -284,6 +314,9 @@ const Block = memo((props: BlockProps) => {
if (props.preview) { if (props.preview) {
return <BlockPreview {...props} viewModel={viewModel} />; return <BlockPreview {...props} viewModel={viewModel} />;
} }
if (props.isSubBlock) {
return <BlockSubBlock {...props} viewModel={viewModel} />;
}
return <BlockFull {...props} viewModel={viewModel} />; return <BlockFull {...props} viewModel={viewModel} />;
}); });

View File

@ -26,9 +26,8 @@ import {
useBlockAtom, useBlockAtom,
WOS, WOS,
} from "@/app/store/global"; } from "@/app/store/global";
import * as services from "@/app/store/services";
import { RpcApi } from "@/app/store/wshclientapi"; import { RpcApi } from "@/app/store/wshclientapi";
import { WindowRpcClient } from "@/app/store/wshrpcutil"; import { TabRpcClient } from "@/app/store/wshrpcutil";
import { ErrorBoundary } from "@/element/errorboundary"; import { ErrorBoundary } from "@/element/errorboundary";
import { IconButton } from "@/element/iconbutton"; import { IconButton } from "@/element/iconbutton";
import { MagnifyIcon } from "@/element/magnify"; import { MagnifyIcon } from "@/element/magnify";
@ -60,17 +59,17 @@ function handleHeaderContextMenu(
onMagnifyToggle(); onMagnifyToggle();
}, },
}, },
{ // {
label: "Move to New Window", // label: "Move to New Window",
click: () => { // click: () => {
const currentTabId = globalStore.get(atoms.activeTabId); // const currentTabId = globalStore.get(atoms.staticTabId);
try { // try {
services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid); // services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid);
} catch (e) { // } catch (e) {
console.error("error moving block to new window", e); // console.error("error moving block to new window", e);
} // }
}, // },
}, // },
{ type: "separator" }, { type: "separator" },
{ {
label: "Copy BlockId", label: "Copy BlockId",
@ -321,7 +320,7 @@ const ConnStatusOverlay = React.memo(
}, [width, connStatus, setShowError]); }, [width, connStatus, setShowError]);
const handleTryReconnect = React.useCallback(() => { const handleTryReconnect = React.useCallback(() => {
const prtn = RpcApi.ConnConnectCommand(WindowRpcClient, connName, { timeout: 60000 }); const prtn = RpcApi.ConnConnectCommand(TabRpcClient, connName, { timeout: 60000 });
prtn.catch((e) => console.log("error reconnecting", connName, e)); prtn.catch((e) => console.log("error reconnecting", connName, e));
}, [connName]); }, [connName]);
@ -437,7 +436,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
const connName = blockData?.meta?.connection; const connName = blockData?.meta?.connection;
if (!util.isBlank(connName)) { if (!util.isBlank(connName)) {
console.log("ensure conn", nodeModel.blockId, connName); console.log("ensure conn", nodeModel.blockId, connName);
RpcApi.ConnEnsureCommand(WindowRpcClient, connName, { timeout: 60000 }).catch((e) => { RpcApi.ConnEnsureCommand(TabRpcClient, connName, { timeout: 60000 }).catch((e) => {
console.log("error ensuring connection", nodeModel.blockId, connName, e); console.log("error ensuring connection", nodeModel.blockId, connName, e);
}); });
} }
@ -521,6 +520,7 @@ const ChangeConnectionBlockModal = React.memo(
const connStatusAtom = getConnStatusAtom(connection); const connStatusAtom = getConnStatusAtom(connection);
const connStatus = jotai.useAtomValue(connStatusAtom); const connStatus = jotai.useAtomValue(connStatusAtom);
const [connList, setConnList] = React.useState<Array<string>>([]); const [connList, setConnList] = React.useState<Array<string>>([]);
const [wslList, setWslList] = React.useState<Array<string>>([]);
const allConnStatus = jotai.useAtomValue(atoms.allConnStatus); const allConnStatus = jotai.useAtomValue(atoms.allConnStatus);
const [rowIndex, setRowIndex] = React.useState(0); const [rowIndex, setRowIndex] = React.useState(0);
const connStatusMap = new Map<string, ConnStatus>(); const connStatusMap = new Map<string, ConnStatus>();
@ -536,10 +536,22 @@ const ChangeConnectionBlockModal = React.memo(
setConnList([]); setConnList([]);
return; return;
} }
const prtn = RpcApi.ConnListCommand(WindowRpcClient, { timeout: 2000 }); const prtn = RpcApi.ConnListCommand(TabRpcClient, { timeout: 2000 });
prtn.then((newConnList) => { prtn.then((newConnList) => {
setConnList(newConnList ?? []); setConnList(newConnList ?? []);
}).catch((e) => console.log("unable to load conn list from backend. using blank list: ", e)); }).catch((e) => console.log("unable to load conn list from backend. using blank list: ", e));
const p2rtn = RpcApi.WslListCommand(TabRpcClient, { timeout: 2000 });
p2rtn
.then((newWslList) => {
console.log(newWslList);
setWslList(newWslList ?? []);
})
.catch((e) => {
// removing this log and failing silentyly since it will happen
// if a system isn't using the wsl. and would happen every time the
// typeahead was opened. good candidate for verbose log level.
//console.log("unable to load wsl list from backend. using blank list: ", e)
});
}, [changeConnModalOpen, setConnList]); }, [changeConnModalOpen, setConnList]);
const changeConnection = React.useCallback( const changeConnection = React.useCallback(
@ -557,12 +569,12 @@ const ChangeConnectionBlockModal = React.memo(
} else { } else {
newCwd = "~"; newCwd = "~";
} }
await RpcApi.SetMetaCommand(WindowRpcClient, { await RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", blockId), oref: WOS.makeORef("block", blockId),
meta: { connection: connName, file: newCwd }, meta: { connection: connName, file: newCwd },
}); });
try { try {
await RpcApi.ConnEnsureCommand(WindowRpcClient, connName, { timeout: 60000 }); await RpcApi.ConnEnsureCommand(TabRpcClient, connName, { timeout: 60000 });
} catch (e) { } catch (e) {
console.log("error connecting", blockId, connName, e); console.log("error connecting", blockId, connName, e);
} }
@ -588,6 +600,15 @@ const ChangeConnectionBlockModal = React.memo(
filteredList.push(conn); filteredList.push(conn);
} }
} }
const filteredWslList: Array<string> = [];
for (const conn of wslList) {
if (conn === connSelected) {
createNew = false;
}
if (conn.includes(connSelected)) {
filteredWslList.push(conn);
}
}
// priority handles special suggestions when necessary // priority handles special suggestions when necessary
// for instance, when reconnecting // for instance, when reconnecting
const newConnectionSuggestion: SuggestionConnectionItem = { const newConnectionSuggestion: SuggestionConnectionItem = {
@ -608,7 +629,7 @@ const ChangeConnectionBlockModal = React.memo(
label: `Reconnect to ${connStatus.connection}`, label: `Reconnect to ${connStatus.connection}`,
value: "", value: "",
onSelect: async (_: string) => { onSelect: async (_: string) => {
const prtn = RpcApi.ConnConnectCommand(WindowRpcClient, connStatus.connection, { timeout: 60000 }); const prtn = RpcApi.ConnConnectCommand(TabRpcClient, connStatus.connection, { timeout: 60000 });
prtn.catch((e) => console.log("error reconnecting", connStatus.connection, e)); prtn.catch((e) => console.log("error reconnecting", connStatus.connection, e));
}, },
}; };
@ -637,6 +658,20 @@ const ChangeConnectionBlockModal = React.memo(
label: localName, label: localName,
}); });
} }
for (const wslConn of filteredWslList) {
const connStatus = connStatusMap.get(wslConn);
const connColorNum = computeConnColorNum(connStatus);
localSuggestion.items.push({
status: "connected",
icon: "arrow-right-arrow-left",
iconColor:
connStatus?.status == "connected"
? `var(--conn-icon-color-${connColorNum})`
: "var(--grey-text-color)",
value: "wsl://" + wslConn,
label: "wsl://" + wslConn,
});
}
const remoteItems = filteredList.map((connName) => { const remoteItems = filteredList.map((connName) => {
const connStatus = connStatusMap.get(connName); const connStatus = connStatusMap.get(connName);
const connColorNum = computeConnColorNum(connStatus); const connColorNum = computeConnColorNum(connStatus);

View File

@ -3,6 +3,7 @@
import { NodeModel } from "@/layout/index"; import { NodeModel } from "@/layout/index";
export interface BlockProps { export interface BlockProps {
isSubBlock?: boolean;
preview: boolean; preview: boolean;
nodeModel: NodeModel; nodeModel: NodeModel;
} }

View File

@ -3,7 +3,7 @@
import { CopyButton } from "@/app/element/copybutton"; import { CopyButton } from "@/app/element/copybutton";
import { RpcApi } from "@/app/store/wshclientapi"; import { RpcApi } from "@/app/store/wshclientapi";
import { WindowRpcClient } from "@/app/store/wshrpcutil"; import { TabRpcClient } from "@/app/store/wshrpcutil";
import { getWebServerEndpoint } from "@/util/endpoints"; import { getWebServerEndpoint } from "@/util/endpoints";
import { isBlank, makeConnRoute, useAtomValueSafe } from "@/util/util"; import { isBlank, makeConnRoute, useAtomValueSafe } from "@/util/util";
import { clsx } from "clsx"; import { clsx } from "clsx";
@ -143,7 +143,7 @@ const MarkdownImg = ({
} }
const resolveFn = async () => { const resolveFn = async () => {
const route = makeConnRoute(resolveOpts.connName); const route = makeConnRoute(resolveOpts.connName);
const fileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [resolveOpts.baseDir, props.src], { const fileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [resolveOpts.baseDir, props.src], {
route: route, route: route,
}); });
const usp = new URLSearchParams(); const usp = new URLSearchParams();

View File

@ -1,3 +1,6 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react"; import * as React from "react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { debounce } from "throttle-debounce"; import { debounce } from "throttle-debounce";
@ -56,6 +59,49 @@ export function useDimensionsWithCallbackRef<T extends HTMLElement>(
return [refCallback, ref, domRect]; return [refCallback, ref, domRect];
} }
export function useOnResize<T extends HTMLElement>(
ref: React.RefObject<T>,
callback: (domRect: DOMRectReadOnly) => void,
debounceMs: number = null
) {
const isFirst = React.useRef(true);
const rszObjRef = React.useRef<ResizeObserver>(null);
const oldHtmlElem = React.useRef<T>(null);
const setDomRectDebounced = React.useCallback(debounceMs == null ? callback : debounce(debounceMs, callback), [
debounceMs,
callback,
]);
React.useEffect(() => {
if (!rszObjRef.current) {
rszObjRef.current = new ResizeObserver((entries) => {
for (const entry of entries) {
if (isFirst.current) {
isFirst.current = false;
callback(entry.contentRect);
} else {
setDomRectDebounced(entry.contentRect);
}
}
});
}
if (ref.current) {
rszObjRef.current.observe(ref.current);
oldHtmlElem.current = ref.current;
}
return () => {
if (oldHtmlElem.current) {
rszObjRef.current?.unobserve(oldHtmlElem.current);
oldHtmlElem.current = null;
}
};
}, [ref.current, callback]);
React.useEffect(() => {
return () => {
rszObjRef.current?.disconnect();
};
}, []);
}
// will not react to ref changes // will not react to ref changes
// pass debounceMs of null to not debounce // pass debounceMs of null to not debounce
export function useDimensionsWithExistingRef<T extends HTMLElement>( export function useDimensionsWithExistingRef<T extends HTMLElement>(

View File

@ -2,12 +2,12 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { import {
getLayoutModelForActiveTab,
getLayoutModelForTabById, getLayoutModelForTabById,
LayoutTreeActionType, LayoutTreeActionType,
LayoutTreeInsertNodeAction, LayoutTreeInsertNodeAction,
newLayoutNode, newLayoutNode,
} from "@/layout/index"; } from "@/layout/index";
import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks";
import { getWebServerEndpoint } from "@/util/endpoints"; import { getWebServerEndpoint } from "@/util/endpoints";
import { fetch } from "@/util/fetchutil"; import { fetch } from "@/util/fetchutil";
import { getPrefixedSettings, isBlank } from "@/util/util"; import { getPrefixedSettings, isBlank } from "@/util/util";
@ -26,6 +26,7 @@ const Counters = new Map<string, number>();
const ConnStatusMap = new Map<string, PrimitiveAtom<ConnStatus>>(); const ConnStatusMap = new Map<string, PrimitiveAtom<ConnStatus>>();
type GlobalInitOptions = { type GlobalInitOptions = {
tabId: string;
platform: NodeJS.Platform; platform: NodeJS.Platform;
windowId: string; windowId: string;
clientId: string; clientId: string;
@ -46,10 +47,9 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom<string>; const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom<string>;
const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom<string>; const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom<string>;
const uiContextAtom = atom((get) => { const uiContextAtom = atom((get) => {
const windowData = get(windowDataAtom);
const uiContext: UIContext = { const uiContext: UIContext = {
windowid: get(atoms.windowId), windowid: initOpts.windowId,
activetabid: windowData?.activetabid, activetabid: initOpts.tabId,
}; };
return uiContext; return uiContext;
}) as Atom<UIContext>; }) as Atom<UIContext>;
@ -63,7 +63,6 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
// do nothing // do nothing
} }
const showAboutModalAtom = atom(false) as PrimitiveAtom<boolean>;
try { try {
getApi().onMenuItemAbout(() => { getApi().onMenuItemAbout(() => {
modalsModel.pushModal("AboutModal"); modalsModel.pushModal("AboutModal");
@ -99,18 +98,10 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
return get(fullConfigAtom)?.settings ?? {}; return get(fullConfigAtom)?.settings ?? {};
}) as Atom<SettingsType>; }) as Atom<SettingsType>;
const tabAtom: Atom<Tab> = atom((get) => { const tabAtom: Atom<Tab> = atom((get) => {
const windowData = get(windowDataAtom); return WOS.getObjectValue(WOS.makeORef("tab", initOpts.tabId), get);
if (windowData == null) {
return null;
}
return WOS.getObjectValue(WOS.makeORef("tab", windowData.activetabid), get);
}); });
const activeTabIdAtom: Atom<string> = atom((get) => { const staticTabIdAtom: Atom<string> = atom((get) => {
const windowData = get(windowDataAtom); return initOpts.tabId;
if (windowData == null) {
return null;
}
return windowData.activetabid;
}); });
const controlShiftDelayAtom = atom(false); const controlShiftDelayAtom = atom(false);
const updaterStatusAtom = atom<UpdaterStatus>("up-to-date") as PrimitiveAtom<UpdaterStatus>; const updaterStatusAtom = atom<UpdaterStatus>("up-to-date") as PrimitiveAtom<UpdaterStatus>;
@ -151,7 +142,6 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
const flashErrorsAtom = atom<FlashErrorType[]>([]); const flashErrorsAtom = atom<FlashErrorType[]>([]);
atoms = { atoms = {
// initialized in wave.ts (will not be null inside of application) // initialized in wave.ts (will not be null inside of application)
windowId: windowIdAtom,
clientId: clientIdAtom, clientId: clientIdAtom,
uiContext: uiContextAtom, uiContext: uiContextAtom,
client: clientAtom, client: clientAtom,
@ -160,7 +150,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
fullConfigAtom, fullConfigAtom,
settingsAtom, settingsAtom,
tabAtom, tabAtom,
activeTabId: activeTabIdAtom, staticTabId: staticTabIdAtom,
isFullScreen: isFullScreenAtom, isFullScreen: isFullScreenAtom,
controlShiftDelayAtom, controlShiftDelayAtom,
updaterStatusAtom, updaterStatusAtom,
@ -228,8 +218,56 @@ function useBlockCache<T>(blockId: string, name: string, makeFn: () => T): T {
return value as T; return value as T;
} }
function getBlockMetaKeyAtom<T extends keyof MetaType>(blockId: string, key: T): Atom<MetaType[T]> {
const blockCache = getSingleBlockAtomCache(blockId);
const metaAtomName = "#meta-" + key;
let metaAtom = blockCache.get(metaAtomName);
if (metaAtom != null) {
return metaAtom;
}
metaAtom = atom((get) => {
let blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId));
let blockData = get(blockAtom);
return blockData?.meta?.[key];
});
blockCache.set(metaAtomName, metaAtom);
return metaAtom;
}
function useBlockMetaKeyAtom<T extends keyof MetaType>(blockId: string, key: T): MetaType[T] {
return useAtomValue(getBlockMetaKeyAtom(blockId, key));
}
const settingsAtomCache = new Map<string, Atom<any>>(); const settingsAtomCache = new Map<string, Atom<any>>();
function makeOverrideConfigAtom<T extends keyof SettingsType>(blockId: string, key: T): Atom<SettingsType[T]> {
const blockCache = getSingleBlockAtomCache(blockId);
const overrideAtomName = "#settingsoverride-" + key;
let overrideAtom = blockCache.get(overrideAtomName);
if (overrideAtom != null) {
return overrideAtom;
}
overrideAtom = atom((get) => {
const blockMetaKeyAtom = getBlockMetaKeyAtom(blockId, key as any);
const metaKeyVal = get(blockMetaKeyAtom);
if (metaKeyVal != null) {
return metaKeyVal;
}
const settingsKeyAtom = getSettingsKeyAtom(key);
const settingsVal = get(settingsKeyAtom);
if (settingsVal != null) {
return settingsVal;
}
return null;
});
blockCache.set(overrideAtomName, overrideAtom);
return overrideAtom;
}
function useOverrideConfigAtom<T extends keyof SettingsType>(blockId: string, key: T): SettingsType[T] {
return useAtomValue(makeOverrideConfigAtom(blockId, key));
}
function getSettingsKeyAtom<T extends keyof SettingsType>(key: T): Atom<SettingsType[T]> { function getSettingsKeyAtom<T extends keyof SettingsType>(key: T): Atom<SettingsType[T]> {
let settingsKeyAtom = settingsAtomCache.get(key) as Atom<SettingsType[T]>; let settingsKeyAtom = settingsAtomCache.get(key) as Atom<SettingsType[T]>;
if (settingsKeyAtom == null) { if (settingsKeyAtom == null) {
@ -245,6 +283,10 @@ function getSettingsKeyAtom<T extends keyof SettingsType>(key: T): Atom<Settings
return settingsKeyAtom; return settingsKeyAtom;
} }
function useSettingsKeyAtom<T extends keyof SettingsType>(key: T): SettingsType[T] {
return useAtomValue(getSettingsKeyAtom(key));
}
function useSettingsPrefixAtom(prefix: string): Atom<SettingsType> { function useSettingsPrefixAtom(prefix: string): Atom<SettingsType> {
// TODO: use a shallow equal here to make this more efficient // TODO: use a shallow equal here to make this more efficient
let settingsPrefixAtom = settingsAtomCache.get(prefix + ":") as Atom<SettingsType>; let settingsPrefixAtom = settingsAtomCache.get(prefix + ":") as Atom<SettingsType>;
@ -263,12 +305,17 @@ function useSettingsPrefixAtom(prefix: string): Atom<SettingsType> {
const blockAtomCache = new Map<string, Map<string, Atom<any>>>(); const blockAtomCache = new Map<string, Map<string, Atom<any>>>();
function useBlockAtom<T>(blockId: string, name: string, makeFn: () => Atom<T>): Atom<T> { function getSingleBlockAtomCache(blockId: string): Map<string, Atom<any>> {
let blockCache = blockAtomCache.get(blockId); let blockCache = blockAtomCache.get(blockId);
if (blockCache == null) { if (blockCache == null) {
blockCache = new Map<string, Atom<any>>(); blockCache = new Map<string, Atom<any>>();
blockAtomCache.set(blockId, blockCache); blockAtomCache.set(blockId, blockCache);
} }
return blockCache;
}
function useBlockAtom<T>(blockId: string, name: string, makeFn: () => Atom<T>): Atom<T> {
const blockCache = getSingleBlockAtomCache(blockId);
let atom = blockCache.get(name); let atom = blockCache.get(name);
if (atom == null) { if (atom == null) {
atom = makeFn(); atom = makeFn();
@ -301,8 +348,8 @@ async function createBlock(blockDef: BlockDef, magnified = false): Promise<strin
magnified, magnified,
focused: true, focused: true,
}; };
const activeTabId = globalStore.get(atoms.uiContext).activetabid; const tabId = globalStore.get(atoms.staticTabId);
const layoutModel = getLayoutModelForTabById(activeTabId); const layoutModel = getLayoutModelForTabById(tabId);
layoutModel.treeReducer(insertNodeAction); layoutModel.treeReducer(insertNodeAction);
return blockId; return blockId;
} }
@ -339,7 +386,7 @@ async function fetchWaveFile(
} }
function setNodeFocus(nodeId: string) { function setNodeFocus(nodeId: string) {
const layoutModel = getLayoutModelForActiveTab(); const layoutModel = getLayoutModelForStaticTab();
layoutModel.focusNode(nodeId); layoutModel.focusNode(nodeId);
} }
@ -414,7 +461,7 @@ function refocusNode(blockId: string) {
if (blockId == null) { if (blockId == null) {
return; return;
} }
const layoutModel = getLayoutModelForActiveTab(); const layoutModel = getLayoutModelForStaticTab();
const layoutNodeId = layoutModel.getNodeByBlockId(blockId); const layoutNodeId = layoutModel.getNodeByBlockId(blockId);
if (layoutNodeId?.id == null) { if (layoutNodeId?.id == null) {
return; return;
@ -522,15 +569,21 @@ function removeFlashError(id: string) {
}); });
} }
async function createTab(): Promise<void> {
await getApi().createTab();
}
export { export {
atoms, atoms,
counterInc, counterInc,
countersClear, countersClear,
countersPrint, countersPrint,
createBlock, createBlock,
createTab,
fetchWaveFile, fetchWaveFile,
getApi, getApi,
getBlockComponentModel, getBlockComponentModel,
getBlockMetaKeyAtom,
getConnStatusAtom, getConnStatusAtom,
getHostName, getHostName,
getObjectId, getObjectId,
@ -541,6 +594,7 @@ export {
initGlobalWaveEventSubs, initGlobalWaveEventSubs,
isDev, isDev,
loadConnStatus, loadConnStatus,
makeOverrideConfigAtom,
openLink, openLink,
PLATFORM, PLATFORM,
pushFlashError, pushFlashError,
@ -554,6 +608,9 @@ export {
useBlockAtom, useBlockAtom,
useBlockCache, useBlockCache,
useBlockDataLoaded, useBlockDataLoaded,
useBlockMetaKeyAtom,
useOverrideConfigAtom,
useSettingsKeyAtom,
useSettingsPrefixAtom, useSettingsPrefixAtom,
WOS, WOS,
}; };

View File

@ -1,24 +1,32 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { atoms, createBlock, getApi, getBlockComponentModel, globalStore, refocusNode, WOS } from "@/app/store/global"; import {
import * as services from "@/app/store/services"; atoms,
createBlock,
createTab,
getApi,
getBlockComponentModel,
globalStore,
refocusNode,
WOS,
} from "@/app/store/global";
import { import {
deleteLayoutModelForTab, deleteLayoutModelForTab,
getLayoutModelForActiveTab,
getLayoutModelForTab, getLayoutModelForTab,
getLayoutModelForTabById, getLayoutModelForTabById,
NavigateDirection, NavigateDirection,
} from "@/layout/index"; } from "@/layout/index";
import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks";
import * as keyutil from "@/util/keyutil"; import * as keyutil from "@/util/keyutil";
import * as jotai from "jotai"; import * as jotai from "jotai";
const simpleControlShiftAtom = jotai.atom(false); const simpleControlShiftAtom = jotai.atom(false);
const globalKeyMap = new Map<string, (waveEvent: WaveKeyboardEvent) => boolean>(); const globalKeyMap = new Map<string, (waveEvent: WaveKeyboardEvent) => boolean>();
function getFocusedBlockInActiveTab() { function getFocusedBlockInStaticTab() {
const activeTabId = globalStore.get(atoms.activeTabId); const tabId = globalStore.get(atoms.staticTabId);
const layoutModel = getLayoutModelForTabById(activeTabId); const layoutModel = getLayoutModelForTabById(tabId);
const focusedNode = globalStore.get(layoutModel.focusedNode); const focusedNode = globalStore.get(layoutModel.focusedNode);
return focusedNode.data?.blockId; return focusedNode.data?.blockId;
} }
@ -70,7 +78,7 @@ function genericClose(tabId: string) {
} }
if (tabData.blockids == null || tabData.blockids.length == 0) { if (tabData.blockids == null || tabData.blockids.length == 0) {
// close tab // close tab
services.WindowService.CloseTab(tabId); getApi().closeTab(tabId);
deleteLayoutModelForTab(tabId); deleteLayoutModelForTab(tabId);
return; return;
} }
@ -79,7 +87,7 @@ function genericClose(tabId: string) {
} }
function switchBlockByBlockNum(index: number) { function switchBlockByBlockNum(index: number) {
const layoutModel = getLayoutModelForActiveTab(); const layoutModel = getLayoutModelForStaticTab();
if (!layoutModel) { if (!layoutModel) {
return; return;
} }
@ -92,21 +100,24 @@ function switchBlockInDirection(tabId: string, direction: NavigateDirection) {
} }
function switchTabAbs(index: number) { function switchTabAbs(index: number) {
console.log("switchTabAbs", index);
const ws = globalStore.get(atoms.workspace); const ws = globalStore.get(atoms.workspace);
const waveWindow = globalStore.get(atoms.waveWindow);
const newTabIdx = index - 1; const newTabIdx = index - 1;
if (newTabIdx < 0 || newTabIdx >= ws.tabids.length) { if (newTabIdx < 0 || newTabIdx >= ws.tabids.length) {
return; return;
} }
const newActiveTabId = ws.tabids[newTabIdx]; const newActiveTabId = ws.tabids[newTabIdx];
services.ObjectService.SetActiveTab(newActiveTabId); getApi().setActiveTab(newActiveTabId);
} }
function switchTab(offset: number) { function switchTab(offset: number) {
console.log("switchTab", offset);
const ws = globalStore.get(atoms.workspace); const ws = globalStore.get(atoms.workspace);
const activeTabId = globalStore.get(atoms.tabAtom).oid; const curTabId = globalStore.get(atoms.staticTabId);
let tabIdx = -1; let tabIdx = -1;
for (let i = 0; i < ws.tabids.length; i++) { for (let i = 0; i < ws.tabids.length; i++) {
if (ws.tabids[i] == activeTabId) { if (ws.tabids[i] == curTabId) {
tabIdx = i; tabIdx = i;
break; break;
} }
@ -116,11 +127,11 @@ function switchTab(offset: number) {
} }
const newTabIdx = (tabIdx + offset + ws.tabids.length) % ws.tabids.length; const newTabIdx = (tabIdx + offset + ws.tabids.length) % ws.tabids.length;
const newActiveTabId = ws.tabids[newTabIdx]; const newActiveTabId = ws.tabids[newTabIdx];
services.ObjectService.SetActiveTab(newActiveTabId); getApi().setActiveTab(newActiveTabId);
} }
function handleCmdI() { function handleCmdI() {
const layoutModel = getLayoutModelForActiveTab(); const layoutModel = getLayoutModelForStaticTab();
const focusedNode = globalStore.get(layoutModel.focusedNode); const focusedNode = globalStore.get(layoutModel.focusedNode);
if (focusedNode == null) { if (focusedNode == null) {
// focus a node // focus a node
@ -141,7 +152,7 @@ async function handleCmdN() {
controller: "shell", controller: "shell",
}, },
}; };
const layoutModel = getLayoutModelForActiveTab(); const layoutModel = getLayoutModelForStaticTab();
const focusedNode = globalStore.get(layoutModel.focusedNode); const focusedNode = globalStore.get(layoutModel.focusedNode);
if (focusedNode != null) { if (focusedNode != null) {
const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", focusedNode.data?.blockId)); const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", focusedNode.data?.blockId));
@ -163,7 +174,7 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
if (handled) { if (handled) {
return true; return true;
} }
const layoutModel = getLayoutModelForActiveTab(); const layoutModel = getLayoutModelForStaticTab();
const focusedNode = globalStore.get(layoutModel.focusedNode); const focusedNode = globalStore.get(layoutModel.focusedNode);
const blockId = focusedNode?.data?.blockId; const blockId = focusedNode?.data?.blockId;
if (blockId != null && shouldDispatchToBlock(waveEvent)) { if (blockId != null && shouldDispatchToBlock(waveEvent)) {
@ -225,18 +236,16 @@ function registerGlobalKeys() {
return true; return true;
}); });
globalKeyMap.set("Cmd:t", () => { globalKeyMap.set("Cmd:t", () => {
const workspace = globalStore.get(atoms.workspace); createTab();
const newTabName = `T${workspace.tabids.length + 1}`;
services.ObjectService.AddTabToWorkspace(newTabName, true);
return true; return true;
}); });
globalKeyMap.set("Cmd:w", () => { globalKeyMap.set("Cmd:w", () => {
const tabId = globalStore.get(atoms.activeTabId); const tabId = globalStore.get(atoms.staticTabId);
genericClose(tabId); genericClose(tabId);
return true; return true;
}); });
globalKeyMap.set("Cmd:m", () => { globalKeyMap.set("Cmd:m", () => {
const layoutModel = getLayoutModelForActiveTab(); const layoutModel = getLayoutModelForStaticTab();
const focusedNode = globalStore.get(layoutModel.focusedNode); const focusedNode = globalStore.get(layoutModel.focusedNode);
if (focusedNode != null) { if (focusedNode != null) {
layoutModel.magnifyNodeToggle(focusedNode.id); layoutModel.magnifyNodeToggle(focusedNode.id);
@ -244,27 +253,27 @@ function registerGlobalKeys() {
return true; return true;
}); });
globalKeyMap.set("Ctrl:Shift:ArrowUp", () => { globalKeyMap.set("Ctrl:Shift:ArrowUp", () => {
const tabId = globalStore.get(atoms.activeTabId); const tabId = globalStore.get(atoms.staticTabId);
switchBlockInDirection(tabId, NavigateDirection.Up); switchBlockInDirection(tabId, NavigateDirection.Up);
return true; return true;
}); });
globalKeyMap.set("Ctrl:Shift:ArrowDown", () => { globalKeyMap.set("Ctrl:Shift:ArrowDown", () => {
const tabId = globalStore.get(atoms.activeTabId); const tabId = globalStore.get(atoms.staticTabId);
switchBlockInDirection(tabId, NavigateDirection.Down); switchBlockInDirection(tabId, NavigateDirection.Down);
return true; return true;
}); });
globalKeyMap.set("Ctrl:Shift:ArrowLeft", () => { globalKeyMap.set("Ctrl:Shift:ArrowLeft", () => {
const tabId = globalStore.get(atoms.activeTabId); const tabId = globalStore.get(atoms.staticTabId);
switchBlockInDirection(tabId, NavigateDirection.Left); switchBlockInDirection(tabId, NavigateDirection.Left);
return true; return true;
}); });
globalKeyMap.set("Ctrl:Shift:ArrowRight", () => { globalKeyMap.set("Ctrl:Shift:ArrowRight", () => {
const tabId = globalStore.get(atoms.activeTabId); const tabId = globalStore.get(atoms.staticTabId);
switchBlockInDirection(tabId, NavigateDirection.Right); switchBlockInDirection(tabId, NavigateDirection.Right);
return true; return true;
}); });
globalKeyMap.set("Cmd:g", () => { globalKeyMap.set("Cmd:g", () => {
const bcm = getBlockComponentModel(getFocusedBlockInActiveTab()); const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
if (bcm.openSwitchConnection != null) { if (bcm.openSwitchConnection != null) {
bcm.openSwitchConnection(); bcm.openSwitchConnection();
return true; return true;

View File

@ -10,7 +10,9 @@ class BlockServiceType {
GetControllerStatus(arg2: string): Promise<BlockControllerRuntimeStatus> { GetControllerStatus(arg2: string): Promise<BlockControllerRuntimeStatus> {
return WOS.callBackendService("block", "GetControllerStatus", Array.from(arguments)) return WOS.callBackendService("block", "GetControllerStatus", Array.from(arguments))
} }
SaveTerminalState(arg2: string, arg3: string, arg4: string, arg5: number): Promise<void> {
// save the terminal state to a blockfile
SaveTerminalState(blockId: string, state: string, stateType: string, ptyOffset: number, termSize: TermSize): Promise<void> {
return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments)) return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments))
} }
SaveWaveAiData(arg2: string, arg3: OpenAIPromptMessageType[]): Promise<void> { SaveWaveAiData(arg2: string, arg3: OpenAIPromptMessageType[]): Promise<void> {
@ -88,7 +90,7 @@ export const FileService = new FileServiceType();
// objectservice.ObjectService (object) // objectservice.ObjectService (object)
class ObjectServiceType { class ObjectServiceType {
// @returns tabId (and object updates) // @returns tabId (and object updates)
AddTabToWorkspace(tabName: string, activateTab: boolean): Promise<string> { AddTabToWorkspace(windowId: string, tabName: string, activateTab: boolean): Promise<string> {
return WOS.callBackendService("object", "AddTabToWorkspace", Array.from(arguments)) return WOS.callBackendService("object", "AddTabToWorkspace", Array.from(arguments))
} }
@ -113,7 +115,7 @@ class ObjectServiceType {
} }
// @returns object updates // @returns object updates
SetActiveTab(tabId: string): Promise<void> { SetActiveTab(uiContext: string, tabId: string): Promise<void> {
return WOS.callBackendService("object", "SetActiveTab", Array.from(arguments)) return WOS.callBackendService("object", "SetActiveTab", Array.from(arguments))
} }
@ -152,10 +154,10 @@ export const UserInputService = new UserInputServiceType();
// windowservice.WindowService (window) // windowservice.WindowService (window)
class WindowServiceType { class WindowServiceType {
// @returns object updates // @returns object updates
CloseTab(arg3: string): Promise<void> { CloseTab(arg2: string, arg3: string, arg4: boolean): Promise<CloseTabRtnType> {
return WOS.callBackendService("window", "CloseTab", Array.from(arguments)) return WOS.callBackendService("window", "CloseTab", Array.from(arguments))
} }
CloseWindow(arg2: string): Promise<void> { CloseWindow(arg2: string, arg3: boolean): Promise<void> {
return WOS.callBackendService("window", "CloseWindow", Array.from(arguments)) return WOS.callBackendService("window", "CloseWindow", Array.from(arguments))
} }

View File

@ -3,6 +3,7 @@
// WaveObjectStore // WaveObjectStore
import { waveEventSubscribe } from "@/app/store/wps";
import { getWebServerEndpoint } from "@/util/endpoints"; import { getWebServerEndpoint } from "@/util/endpoints";
import { fetch } from "@/util/fetchutil"; import { fetch } from "@/util/fetchutil";
import { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from "jotai"; import { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from "jotai";
@ -76,6 +77,16 @@ function debugLogBackendCall(methodName: string, durationStr: string, args: any[
console.log("[service]", methodName, durationStr); console.log("[service]", methodName, durationStr);
} }
function wpsSubscribeToObject(oref: string): () => void {
return waveEventSubscribe({
eventType: "waveobj:update",
scope: oref,
handler: (event) => {
updateWaveObject(event.data);
},
});
}
function callBackendService(service: string, method: string, args: any[], noUIContext?: boolean): Promise<any> { function callBackendService(service: string, method: string, args: any[], noUIContext?: boolean): Promise<any> {
const startTs = Date.now(); const startTs = Date.now();
let uiContext: UIContext = null; let uiContext: UIContext = null;
@ -130,6 +141,19 @@ function clearWaveObjectCache() {
const defaultHoldTime = 5000; // 5-seconds const defaultHoldTime = 5000; // 5-seconds
function reloadWaveObject<T extends WaveObj>(oref: string): Promise<T> {
let wov = waveObjectValueCache.get(oref);
if (wov === undefined) {
wov = getWaveObjectValue<T>(oref, true);
return wov.pendingPromise;
}
const prtn = GetObject<T>(oref);
prtn.then((val) => {
globalStore.set(wov.dataAtom, { value: val, loading: false });
});
return prtn;
}
function createWaveValueObject<T extends WaveObj>(oref: string, shouldFetch: boolean): WaveObjectValue<T> { function createWaveValueObject<T extends WaveObj>(oref: string, shouldFetch: boolean): WaveObjectValue<T> {
const wov = { pendingPromise: null, dataAtom: null, refCount: 0, holdTime: Date.now() + 5000 }; const wov = { pendingPromise: null, dataAtom: null, refCount: 0, holdTime: Date.now() + 5000 };
wov.dataAtom = atom({ value: null, loading: true }); wov.dataAtom = atom({ value: null, loading: true });
@ -290,8 +314,11 @@ export {
getWaveObjectLoadingAtom, getWaveObjectLoadingAtom,
loadAndPinWaveObject, loadAndPinWaveObject,
makeORef, makeORef,
reloadWaveObject,
setObjectValue, setObjectValue,
splitORef,
updateWaveObject, updateWaveObject,
updateWaveObjects, updateWaveObjects,
useWaveObjectValue, useWaveObjectValue,
wpsSubscribeToObject,
}; };

View File

@ -37,7 +37,7 @@ class WSControl {
opening: boolean = false; opening: boolean = false;
reconnectTimes: number = 0; reconnectTimes: number = 0;
msgQueue: any[] = []; msgQueue: any[] = [];
windowId: string; tabId: string;
messageCallback: WSEventCallback; messageCallback: WSEventCallback;
watchSessionId: string = null; watchSessionId: string = null;
watchScreenId: string = null; watchScreenId: string = null;
@ -50,13 +50,13 @@ class WSControl {
constructor( constructor(
baseHostPort: string, baseHostPort: string,
windowId: string, tabId: string,
messageCallback: WSEventCallback, messageCallback: WSEventCallback,
electronOverrideOpts?: ElectronOverrideOpts electronOverrideOpts?: ElectronOverrideOpts
) { ) {
this.baseHostPort = baseHostPort; this.baseHostPort = baseHostPort;
this.messageCallback = messageCallback; this.messageCallback = messageCallback;
this.windowId = windowId; this.tabId = tabId;
this.open = false; this.open = false;
this.eoOpts = electronOverrideOpts; this.eoOpts = electronOverrideOpts;
setInterval(this.sendPing.bind(this), 5000); setInterval(this.sendPing.bind(this), 5000);
@ -75,7 +75,7 @@ class WSControl {
dlog("try reconnect:", desc); dlog("try reconnect:", desc);
this.opening = true; this.opening = true;
this.wsConn = newWebSocket( this.wsConn = newWebSocket(
this.baseHostPort + "/ws?windowid=" + this.windowId, this.baseHostPort + "/ws?tabid=" + this.tabId,
this.eoOpts this.eoOpts
? { ? {
[AuthKeyHeader]: this.eoOpts.authKey, [AuthKeyHeader]: this.eoOpts.authKey,
@ -231,11 +231,11 @@ class WSControl {
let globalWS: WSControl; let globalWS: WSControl;
function initGlobalWS( function initGlobalWS(
baseHostPort: string, baseHostPort: string,
windowId: string, tabId: string,
messageCallback: WSEventCallback, messageCallback: WSEventCallback,
electronOverrideOpts?: ElectronOverrideOpts electronOverrideOpts?: ElectronOverrideOpts
) { ) {
globalWS = new WSControl(baseHostPort, windowId, messageCallback, electronOverrideOpts); globalWS = new WSControl(baseHostPort, tabId, messageCallback, electronOverrideOpts);
} }
function sendRawRpcMessage(msg: RpcMessage) { function sendRawRpcMessage(msg: RpcMessage) {

View File

@ -18,6 +18,10 @@ class RpcResponseHelper {
this.done = cmdMsg.reqid == null; this.done = cmdMsg.reqid == null;
} }
getSource(): string {
return this.cmdMsg?.source;
}
sendResponse(msg: RpcMessage) { sendResponse(msg: RpcMessage) {
if (this.done || util.isBlank(this.cmdMsg.reqid)) { if (this.done || util.isBlank(this.cmdMsg.reqid)) {
return; return;

View File

@ -67,11 +67,26 @@ class RpcApiType {
return client.wshRpcCall("createblock", data, opts); return client.wshRpcCall("createblock", data, opts);
} }
// command "createsubblock" [call]
CreateSubBlockCommand(client: WshClient, data: CommandCreateSubBlockData, opts?: RpcOpts): Promise<ORef> {
return client.wshRpcCall("createsubblock", data, opts);
}
// command "deleteblock" [call] // command "deleteblock" [call]
DeleteBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise<void> { DeleteBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("deleteblock", data, opts); return client.wshRpcCall("deleteblock", data, opts);
} }
// command "deletesubblock" [call]
DeleteSubBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("deletesubblock", data, opts);
}
// command "dispose" [call]
DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("dispose", data, opts);
}
// command "eventpublish" [call] // command "eventpublish" [call]
EventPublishCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise<void> { EventPublishCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("eventpublish", data, opts); return client.wshRpcCall("eventpublish", data, opts);
@ -217,11 +232,46 @@ class RpcApiType {
return client.wshRpcCall("test", data, opts); return client.wshRpcCall("test", data, opts);
} }
// command "vdomasyncinitiation" [call]
VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("vdomasyncinitiation", data, opts);
}
// command "vdomcreatecontext" [call]
VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise<ORef> {
return client.wshRpcCall("vdomcreatecontext", data, opts);
}
// command "vdomrender" [call]
VDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): Promise<VDomBackendUpdate> {
return client.wshRpcCall("vdomrender", data, opts);
}
// command "waitforroute" [call]
WaitForRouteCommand(client: WshClient, data: CommandWaitForRouteData, opts?: RpcOpts): Promise<boolean> {
return client.wshRpcCall("waitforroute", data, opts);
}
// command "webselector" [call] // command "webselector" [call]
WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise<string[]> { WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise<string[]> {
return client.wshRpcCall("webselector", data, opts); return client.wshRpcCall("webselector", data, opts);
} }
// command "wsldefaultdistro" [call]
WslDefaultDistroCommand(client: WshClient, opts?: RpcOpts): Promise<string> {
return client.wshRpcCall("wsldefaultdistro", null, opts);
}
// command "wsllist" [call]
WslListCommand(client: WshClient, opts?: RpcOpts): Promise<string[]> {
return client.wshRpcCall("wsllist", null, opts);
}
// command "wslstatus" [call]
WslStatusCommand(client: WshClient, opts?: RpcOpts): Promise<ConnStatus[]> {
return client.wshRpcCall("wslstatus", null, opts);
}
} }
export const RpcApi = new RpcApiType(); export const RpcApi = new RpcApiType();

View File

@ -15,14 +15,14 @@ type RouteInfo = {
destRouteId: string; destRouteId: string;
}; };
function makeWindowRouteId(windowId: string): string {
return `window:${windowId}`;
}
function makeFeBlockRouteId(feBlockId: string): string { function makeFeBlockRouteId(feBlockId: string): string {
return `feblock:${feBlockId}`; return `feblock:${feBlockId}`;
} }
function makeTabRouteId(tabId: string): string {
return `tab:${tabId}`;
}
class WshRouter { class WshRouter {
routeMap: Map<string, AbstractWshClient>; // routeid -> client routeMap: Map<string, AbstractWshClient>; // routeid -> client
upstreamClient: AbstractWshClient; upstreamClient: AbstractWshClient;
@ -149,4 +149,4 @@ class WshRouter {
} }
} }
export { makeFeBlockRouteId, makeWindowRouteId, WshRouter }; export { makeFeBlockRouteId, makeTabRouteId, WshRouter };

View File

@ -3,12 +3,12 @@
import { wpsReconnectHandler } from "@/app/store/wps"; import { wpsReconnectHandler } from "@/app/store/wps";
import { WshClient } from "@/app/store/wshclient"; import { WshClient } from "@/app/store/wshclient";
import { makeWindowRouteId, WshRouter } from "@/app/store/wshrouter"; import { makeTabRouteId, WshRouter } from "@/app/store/wshrouter";
import { getWSServerEndpoint } from "@/util/endpoints"; import { getWSServerEndpoint } from "@/util/endpoints";
import { addWSReconnectHandler, ElectronOverrideOpts, globalWS, initGlobalWS, WSControl } from "./ws"; import { addWSReconnectHandler, ElectronOverrideOpts, globalWS, initGlobalWS, WSControl } from "./ws";
let DefaultRouter: WshRouter; let DefaultRouter: WshRouter;
let WindowRpcClient: WshClient; let TabRpcClient: WshClient;
async function* rpcResponseGenerator( async function* rpcResponseGenerator(
openRpcs: Map<string, ClientRpcEntry>, openRpcs: Map<string, ClientRpcEntry>,
@ -126,15 +126,15 @@ function shutdownWshrpc() {
globalWS?.shutdown(); globalWS?.shutdown();
} }
function initWshrpc(windowId: string): WSControl { function initWshrpc(tabId: string): WSControl {
DefaultRouter = new WshRouter(new UpstreamWshRpcProxy()); DefaultRouter = new WshRouter(new UpstreamWshRpcProxy());
const handleFn = (event: WSEventType) => { const handleFn = (event: WSEventType) => {
DefaultRouter.recvRpcMessage(event.data); DefaultRouter.recvRpcMessage(event.data);
}; };
initGlobalWS(getWSServerEndpoint(), windowId, handleFn); initGlobalWS(getWSServerEndpoint(), tabId, handleFn);
globalWS.connectNow("connectWshrpc"); globalWS.connectNow("connectWshrpc");
WindowRpcClient = new WshClient(makeWindowRouteId(windowId)); TabRpcClient = new WshClient(makeTabRouteId(tabId));
DefaultRouter.registerRoute(WindowRpcClient.routeId, WindowRpcClient); DefaultRouter.registerRoute(TabRpcClient.routeId, TabRpcClient);
addWSReconnectHandler(() => { addWSReconnectHandler(() => {
DefaultRouter.reannounceRoutes(); DefaultRouter.reannounceRoutes();
}); });
@ -149,12 +149,4 @@ class UpstreamWshRpcProxy implements AbstractWshClient {
} }
} }
export { export { DefaultRouter, initElectronWshrpc, initWshrpc, sendRpcCommand, sendRpcResponse, shutdownWshrpc, TabRpcClient };
DefaultRouter,
initElectronWshrpc,
initWshrpc,
sendRpcCommand,
sendRpcResponse,
shutdownWshrpc,
WindowRpcClient,
};

View File

@ -5,7 +5,7 @@ import { Button } from "@/app/element/button";
import { modalsModel } from "@/app/store/modalmodel"; import { modalsModel } from "@/app/store/modalmodel";
import { WindowDrag } from "@/element/windowdrag"; import { WindowDrag } from "@/element/windowdrag";
import { deleteLayoutModelForTab } from "@/layout/index"; import { deleteLayoutModelForTab } from "@/layout/index";
import { atoms, getApi, isDev, PLATFORM } from "@/store/global"; import { atoms, createTab, getApi, isDev, PLATFORM } from "@/store/global";
import * as services from "@/store/services"; import * as services from "@/store/services";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { OverlayScrollbars } from "overlayscrollbars"; import { OverlayScrollbars } from "overlayscrollbars";
@ -134,10 +134,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
const updateStatusButtonRef = useRef<HTMLButtonElement>(null); const updateStatusButtonRef = useRef<HTMLButtonElement>(null);
const configErrorButtonRef = useRef<HTMLElement>(null); const configErrorButtonRef = useRef<HTMLElement>(null);
const prevAllLoadedRef = useRef<boolean>(false); const prevAllLoadedRef = useRef<boolean>(false);
const activeTabId = useAtomValue(atoms.staticTabId);
const windowData = useAtomValue(atoms.waveWindow);
const { activetabid } = windowData;
const isFullScreen = useAtomValue(atoms.isFullScreen); const isFullScreen = useAtomValue(atoms.isFullScreen);
const settings = useAtomValue(atoms.settingsAtom); const settings = useAtomValue(atoms.settingsAtom);
@ -483,17 +480,12 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
const handleSelectTab = (tabId: string) => { const handleSelectTab = (tabId: string) => {
if (!draggingTabDataRef.current.dragged) { if (!draggingTabDataRef.current.dragged) {
services.ObjectService.SetActiveTab(tabId); getApi().setActiveTab(tabId);
} }
}; };
const handleAddTab = () => { const handleAddTab = () => {
const newTabName = `T${tabIds.length + 1}`; createTab();
services.ObjectService.AddTabToWorkspace(newTabName, true).then((tabId) => {
setTabIds([...tabIds, tabId]);
setNewTabId(tabId);
});
services.ObjectService.GetObject;
tabsWrapperRef.current.style.transition; tabsWrapperRef.current.style.transition;
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease"); tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease");
@ -509,7 +501,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => { const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => {
event?.stopPropagation(); event?.stopPropagation();
services.WindowService.CloseTab(tabId); getApi().closeTab(tabId);
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease"); tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease");
deleteLayoutModelForTab(tabId); deleteLayoutModelForTab(tabId);
}; };
@ -525,7 +517,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
}, []); }, []);
const isBeforeActive = (tabId: string) => { const isBeforeActive = (tabId: string) => {
return tabIds.indexOf(tabId) === tabIds.indexOf(activetabid) - 1; return tabIds.indexOf(tabId) === tabIds.indexOf(activeTabId) - 1;
}; };
function onEllipsisClick() { function onEllipsisClick() {
@ -560,7 +552,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
id={tabId} id={tabId}
isFirst={index === 0} isFirst={index === 0}
onSelect={() => handleSelectTab(tabId)} onSelect={() => handleSelectTab(tabId)}
active={activetabid === tabId} active={activeTabId === tabId}
onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])} onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])}
onClose={(event) => handleCloseTab(event, tabId)} onClose={(event) => handleCloseTab(event, tabId)}
onLoaded={() => handleTabLoaded(tabId)} onLoaded={() => handleTabLoaded(tabId)}

View File

@ -6,7 +6,7 @@ import { TypeAheadModal } from "@/app/modals/typeaheadmodal";
import { ContextMenuModel } from "@/app/store/contextmenu"; import { ContextMenuModel } from "@/app/store/contextmenu";
import { tryReinjectKey } from "@/app/store/keymodel"; import { tryReinjectKey } from "@/app/store/keymodel";
import { RpcApi } from "@/app/store/wshclientapi"; import { RpcApi } from "@/app/store/wshclientapi";
import { WindowRpcClient } from "@/app/store/wshrpcutil"; import { TabRpcClient } from "@/app/store/wshrpcutil";
import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor";
import { Markdown } from "@/element/markdown"; import { Markdown } from "@/element/markdown";
import { NodeModel } from "@/layout/index"; import { NodeModel } from "@/layout/index";
@ -496,7 +496,7 @@ export class PreviewModel implements ViewModel {
async getParentInfo(fileInfo: FileInfo): Promise<FileInfo | undefined> { async getParentInfo(fileInfo: FileInfo): Promise<FileInfo | undefined> {
const conn = globalStore.get(this.connection); const conn = globalStore.get(this.connection);
try { try {
const parentFileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [fileInfo.path, ".."], { const parentFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.path, ".."], {
route: makeConnRoute(conn), route: makeConnRoute(conn),
}); });
return parentFileInfo; return parentFileInfo;
@ -517,7 +517,7 @@ export class PreviewModel implements ViewModel {
} }
const conn = globalStore.get(this.connection); const conn = globalStore.get(this.connection);
try { try {
const newFileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [fileInfo.path, ".."], { const newFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.path, ".."], {
route: makeConnRoute(conn), route: makeConnRoute(conn),
}); });
if (newFileInfo.path != "" && newFileInfo.notfound) { if (newFileInfo.path != "" && newFileInfo.notfound) {
@ -600,7 +600,7 @@ export class PreviewModel implements ViewModel {
} }
const conn = globalStore.get(this.connection); const conn = globalStore.get(this.connection);
try { try {
const newFileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [fileInfo.dir, filePath], { const newFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.dir, filePath], {
route: makeConnRoute(conn), route: makeConnRoute(conn),
}); });
this.updateOpenFileModalAndError(false); this.updateOpenFileModalAndError(false);

View File

@ -13,7 +13,7 @@ import * as React from "react";
import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions"; import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions";
import { waveEventSubscribe } from "@/app/store/wps"; import { waveEventSubscribe } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi"; import { RpcApi } from "@/app/store/wshclientapi";
import { WindowRpcClient } from "@/app/store/wshrpcutil"; import { TabRpcClient } from "@/app/store/wshrpcutil";
import { atoms } from "@/store/global"; import { atoms } from "@/store/global";
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
import "./sysinfo.less"; import "./sysinfo.less";
@ -175,7 +175,7 @@ class SysinfoViewModel {
this.incrementCount = jotai.atom(null, async (get, set) => { this.incrementCount = jotai.atom(null, async (get, set) => {
const meta = get(this.blockAtom).meta; const meta = get(this.blockAtom).meta;
const count = meta.count ?? 0; const count = meta.count ?? 0;
await RpcApi.SetMetaCommand(WindowRpcClient, { await RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId), oref: WOS.makeORef("block", this.blockId),
meta: { count: count + 1 }, meta: { count: count + 1 },
}); });
@ -203,7 +203,7 @@ class SysinfoViewModel {
try { try {
const numPoints = globalStore.get(this.numPoints); const numPoints = globalStore.get(this.numPoints);
const connName = globalStore.get(this.connection); const connName = globalStore.get(this.connection);
const initialData = await RpcApi.EventReadHistoryCommand(WindowRpcClient, { const initialData = await RpcApi.EventReadHistoryCommand(TabRpcClient, {
event: "sysinfo", event: "sysinfo",
scope: connName, scope: connName,
maxitems: numPoints, maxitems: numPoints,
@ -245,7 +245,7 @@ class SysinfoViewModel {
type: "radio", type: "radio",
checked: currentlySelected == plotType, checked: currentlySelected == plotType,
click: async () => { click: async () => {
await RpcApi.SetMetaCommand(WindowRpcClient, { await RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId), oref: WOS.makeORef("block", this.blockId),
meta: { "graph:metrics": dataTypes, "sysinfo:type": plotType }, meta: { "graph:metrics": dataTypes, "sysinfo:type": plotType },
}); });

View File

@ -0,0 +1,76 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { atoms, globalStore } from "@/app/store/global";
import { makeORef, splitORef } from "@/app/store/wos";
import { RpcResponseHelper, WshClient } from "@/app/store/wshclient";
import { RpcApi } from "@/app/store/wshclientapi";
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
import { TermViewModel } from "@/app/view/term/term";
import { isBlank } from "@/util/util";
import debug from "debug";
const dlog = debug("wave:vdom");
export class TermWshClient extends WshClient {
blockId: string;
model: TermViewModel;
constructor(blockId: string, model: TermViewModel) {
super(makeFeBlockRouteId(blockId));
this.blockId = blockId;
this.model = model;
}
async handle_vdomcreatecontext(rh: RpcResponseHelper, data: VDomCreateContext) {
const source = rh.getSource();
if (isBlank(source)) {
throw new Error("source cannot be blank");
}
console.log("vdom-create", source, data);
const tabId = globalStore.get(atoms.staticTabId);
if (data.target?.newblock) {
const oref = await RpcApi.CreateBlockCommand(this, {
tabid: tabId,
blockdef: {
meta: {
view: "vdom",
"vdom:route": rh.getSource(),
},
},
magnified: data.target?.magnified,
});
return oref;
} else {
// in the terminal
// check if there is a current active vdom block
const oldVDomBlockId = globalStore.get(this.model.vdomBlockId);
const oref = await RpcApi.CreateSubBlockCommand(this, {
parentblockid: this.blockId,
blockdef: {
meta: {
view: "vdom",
"vdom:route": rh.getSource(),
},
},
});
const [_, newVDomBlockId] = splitORef(oref);
if (!isBlank(oldVDomBlockId)) {
// dispose of the old vdom block
setTimeout(() => {
RpcApi.DeleteSubBlockCommand(this, { blockid: oldVDomBlockId });
}, 500);
}
setTimeout(() => {
RpcApi.SetMetaCommand(this, {
oref: makeORef("block", this.model.blockId),
meta: {
"term:mode": "vdom",
"term:vdomblockid": newVDomBlockId,
},
});
}, 50);
return oref;
}
}
}

View File

@ -76,7 +76,7 @@
} }
} }
&.term-mode-html { &.term-mode-vdom {
.term-connectelem { .term-connectelem {
display: none; display: none;
} }

View File

@ -1,16 +1,28 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { Block } from "@/app/block/block";
import { getAllGlobalKeyBindings } from "@/app/store/keymodel"; import { getAllGlobalKeyBindings } from "@/app/store/keymodel";
import { waveEventSubscribe } from "@/app/store/wps"; import { waveEventSubscribe } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi"; import { RpcApi } from "@/app/store/wshclientapi";
import { WindowRpcClient } from "@/app/store/wshrpcutil"; import { makeFeBlockRouteId } from "@/app/store/wshrouter";
import { VDomView } from "@/app/view/term/vdom"; import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
import { WOS, atoms, getConnStatusAtom, getSettingsKeyAtom, globalStore, useSettingsPrefixAtom } from "@/store/global"; import { TermWshClient } from "@/app/view/term/term-wsh";
import { VDomModel } from "@/app/view/term/vdom-model";
import { NodeModel } from "@/layout/index";
import {
WOS,
atoms,
getBlockComponentModel,
getConnStatusAtom,
getSettingsKeyAtom,
globalStore,
useSettingsPrefixAtom,
} from "@/store/global";
import * as services from "@/store/services"; import * as services from "@/store/services";
import * as keyutil from "@/util/keyutil"; import * as keyutil from "@/util/keyutil";
import * as util from "@/util/util";
import clsx from "clsx"; import clsx from "clsx";
import debug from "debug";
import * as jotai from "jotai"; import * as jotai from "jotai";
import * as React from "react"; import * as React from "react";
import { TermStickers } from "./termsticker"; import { TermStickers } from "./termsticker";
@ -19,118 +31,101 @@ import { computeTheme } from "./termutil";
import { TermWrap } from "./termwrap"; import { TermWrap } from "./termwrap";
import "./xterm.css"; import "./xterm.css";
const keyMap = { const dlog = debug("wave:term");
Enter: "\r",
Backspace: "\x7f",
Tab: "\t",
Escape: "\x1b",
ArrowUp: "\x1b[A",
ArrowDown: "\x1b[B",
ArrowRight: "\x1b[C",
ArrowLeft: "\x1b[D",
Insert: "\x1b[2~",
Delete: "\x1b[3~",
Home: "\x1b[1~",
End: "\x1b[4~",
PageUp: "\x1b[5~",
PageDown: "\x1b[6~",
};
function keyboardEventToASCII(event: React.KeyboardEvent<HTMLInputElement>): string {
// check modifiers
// if no modifiers are set, just send the key
if (!event.altKey && !event.ctrlKey && !event.metaKey) {
if (event.key == null || event.key == "") {
return "";
}
if (keyMap[event.key] != null) {
return keyMap[event.key];
}
if (event.key.length == 1) {
return event.key;
} else {
console.log("not sending keyboard event", event.key, event);
}
}
// if meta or alt is set, there is no ASCII representation
if (event.metaKey || event.altKey) {
return "";
}
// if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value
if (event.ctrlKey) {
if (
(event.key.length === 1 && event.key >= "A" && event.key <= "Z") ||
(event.key >= "a" && event.key <= "z")
) {
const key = event.key.toUpperCase();
return String.fromCharCode(key.charCodeAt(0) - 64);
}
}
return "";
}
type InitialLoadDataType = { type InitialLoadDataType = {
loaded: boolean; loaded: boolean;
heldData: Uint8Array[]; heldData: Uint8Array[];
}; };
function vdomText(text: string): VDomElem {
return {
tag: "#text",
text: text,
};
}
const testVDom: VDomElem = {
id: "testid1",
tag: "div",
children: [
{
id: "testh1",
tag: "h1",
children: [vdomText("Hello World")],
},
{
id: "testp",
tag: "p",
children: [vdomText("This is a paragraph (from VDOM)")],
},
],
};
class TermViewModel { class TermViewModel {
viewType: string; viewType: string;
nodeModel: NodeModel;
connected: boolean; connected: boolean;
termRef: React.RefObject<TermWrap>; termRef: React.RefObject<TermWrap>;
blockAtom: jotai.Atom<Block>; blockAtom: jotai.Atom<Block>;
termMode: jotai.Atom<string>; termMode: jotai.Atom<string>;
htmlElemFocusRef: React.RefObject<HTMLInputElement>;
blockId: string; blockId: string;
viewIcon: jotai.Atom<string>; viewIcon: jotai.Atom<string>;
viewName: jotai.Atom<string>; viewName: jotai.Atom<string>;
viewText: jotai.Atom<HeaderElem[]>;
blockBg: jotai.Atom<MetaType>; blockBg: jotai.Atom<MetaType>;
manageConnection: jotai.Atom<boolean>; manageConnection: jotai.Atom<boolean>;
connStatus: jotai.Atom<ConnStatus>; connStatus: jotai.Atom<ConnStatus>;
termWshClient: TermWshClient;
shellProcStatusRef: React.MutableRefObject<string>;
vdomBlockId: jotai.Atom<string>;
constructor(blockId: string) { constructor(blockId: string, nodeModel: NodeModel) {
this.viewType = "term"; this.viewType = "term";
this.blockId = blockId; this.blockId = blockId;
this.termWshClient = new TermWshClient(blockId, this);
DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.termWshClient);
this.nodeModel = nodeModel;
this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`); this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
this.vdomBlockId = jotai.atom((get) => {
const blockData = get(this.blockAtom);
return blockData?.meta?.["term:vdomblockid"];
});
this.termMode = jotai.atom((get) => { this.termMode = jotai.atom((get) => {
const blockData = get(this.blockAtom); const blockData = get(this.blockAtom);
return blockData?.meta?.["term:mode"] ?? "term"; return blockData?.meta?.["term:mode"] ?? "term";
}); });
this.viewIcon = jotai.atom((get) => { this.viewIcon = jotai.atom((get) => {
const termMode = get(this.termMode);
if (termMode == "vdom") {
return "bolt";
}
return "terminal"; return "terminal";
}); });
this.viewName = jotai.atom((get) => { this.viewName = jotai.atom((get) => {
const blockData = get(this.blockAtom); const blockData = get(this.blockAtom);
const termMode = get(this.termMode);
if (termMode == "vdom") {
return "Wave App";
}
if (blockData?.meta?.controller == "cmd") { if (blockData?.meta?.controller == "cmd") {
return "Command"; return "Command";
} }
return "Terminal"; return "Terminal";
}); });
this.manageConnection = jotai.atom(true); this.viewText = jotai.atom((get) => {
const termMode = get(this.termMode);
if (termMode == "vdom") {
return [
{
elemtype: "iconbutton",
icon: "square-terminal",
title: "Switch back to Terminal",
click: () => {
this.setTermMode("term");
},
},
];
} else {
const vdomBlockId = get(this.vdomBlockId);
if (vdomBlockId) {
return [
{
elemtype: "iconbutton",
icon: "bolt",
title: "Switch to Wave App",
click: () => {
this.setTermMode("vdom");
},
},
];
}
}
return null;
});
this.manageConnection = jotai.atom((get) => {
const termMode = get(this.termMode);
if (termMode == "vdom") {
return false;
}
return true;
});
this.blockBg = jotai.atom((get) => { this.blockBg = jotai.atom((get) => {
const blockData = get(this.blockAtom); const blockData = get(this.blockAtom);
const fullConfig = get(atoms.fullConfigAtom); const fullConfig = get(atoms.fullConfigAtom);
@ -152,6 +147,32 @@ class TermViewModel {
}); });
} }
setTermMode(mode: "term" | "vdom") {
if (mode == "term") {
mode = null;
}
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { "term:mode": mode },
});
}
getVDomModel(): VDomModel {
const vdomBlockId = globalStore.get(this.vdomBlockId);
if (!vdomBlockId) {
return null;
}
const bcm = getBlockComponentModel(vdomBlockId);
if (!bcm) {
return null;
}
return bcm.viewModel as VDomModel;
}
dispose() {
DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId));
}
giveFocus(): boolean { giveFocus(): boolean {
let termMode = globalStore.get(this.termMode); let termMode = globalStore.get(this.termMode);
if (termMode == "term") { if (termMode == "term") {
@ -159,17 +180,74 @@ class TermViewModel {
this.termRef.current.terminal.focus(); this.termRef.current.terminal.focus();
return true; return true;
} }
} else {
if (this.htmlElemFocusRef?.current) {
this.htmlElemFocusRef.current.focus();
return true;
}
} }
return false; return false;
} }
keyDownHandler(waveEvent: WaveKeyboardEvent): boolean {
if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) {
const blockAtom = WOS.getWaveObjectAtom<Block>(`block:${this.blockId}`);
const blockData = globalStore.get(blockAtom);
const newTermMode = blockData?.meta?.["term:mode"] == "vdom" ? null : "vdom";
const vdomBlockId = globalStore.get(this.vdomBlockId);
if (newTermMode == "vdom" && !vdomBlockId) {
return;
}
this.setTermMode(newTermMode);
return true;
}
const blockData = globalStore.get(this.blockAtom);
if (blockData.meta?.["term:mode"] == "vdom") {
const vdomModel = this.getVDomModel();
return vdomModel?.keyDownHandler(waveEvent);
}
return false;
}
handleTerminalKeydown(event: KeyboardEvent): boolean {
const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event);
if (waveEvent.type != "keydown") {
return true;
}
if (this.keyDownHandler(waveEvent)) {
event.preventDefault();
event.stopPropagation();
return false;
}
// deal with terminal specific keybindings
if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) {
const p = navigator.clipboard.readText();
p.then((text) => {
this.termRef.current?.terminal.paste(text);
});
event.preventDefault();
event.stopPropagation();
return false;
} else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) {
const sel = this.termRef.current?.terminal.getSelection();
navigator.clipboard.writeText(sel);
event.preventDefault();
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));
return false;
}
const globalKeys = getAllGlobalKeyBindings();
for (const key of globalKeys) {
if (keyutil.checkKeyPressed(waveEvent, key)) {
return false;
}
}
return true;
}
setTerminalTheme(themeName: string) { setTerminalTheme(themeName: string) {
RpcApi.SetMetaCommand(WindowRpcClient, { RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId), oref: WOS.makeORef("block", this.blockId),
meta: { "term:theme": themeName }, meta: { "term:theme": themeName },
}); });
@ -202,8 +280,8 @@ class TermViewModel {
rows: this.termRef.current?.terminal?.rows, rows: this.termRef.current?.terminal?.rows,
cols: this.termRef.current?.terminal?.cols, cols: this.termRef.current?.terminal?.cols,
}; };
const prtn = RpcApi.ControllerResyncCommand(WindowRpcClient, { const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, {
tabid: globalStore.get(atoms.activeTabId), tabid: globalStore.get(atoms.staticTabId),
blockid: this.blockId, blockid: this.blockId,
forcerestart: true, forcerestart: true,
rtopts: { termsize: termsize }, rtopts: { termsize: termsize },
@ -215,8 +293,8 @@ class TermViewModel {
} }
} }
function makeTerminalModel(blockId: string): TermViewModel { function makeTerminalModel(blockId: string, nodeModel: NodeModel): TermViewModel {
return new TermViewModel(blockId); return new TermViewModel(blockId, nodeModel);
} }
interface TerminalViewProps { interface TerminalViewProps {
@ -246,64 +324,69 @@ const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) =>
return null; return null;
}); });
const TermVDomNodeSingleId = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => {
React.useEffect(() => {
const unsub = waveEventSubscribe({
eventType: "blockclose",
scope: WOS.makeORef("block", vdomBlockId),
handler: (event) => {
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", blockId),
meta: {
"term:mode": null,
"term:vdomblockid": null,
},
});
},
});
return () => {
unsub();
};
}, []);
const isFocusedAtom = jotai.atom((get) => {
return get(model.nodeModel.isFocused) && get(model.termMode) == "vdom";
});
let vdomNodeModel = {
blockId: vdomBlockId,
isFocused: isFocusedAtom,
onClose: () => {
if (vdomBlockId != null) {
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId });
}
},
};
return (
<div key="htmlElem" className="term-htmlelem">
<Block key="vdom" isSubBlock={true} preview={false} nodeModel={vdomNodeModel} />
</div>
);
};
const TermVDomNode = ({ blockId, model }: TerminalViewProps) => {
const vdomBlockId = jotai.useAtomValue(model.vdomBlockId);
if (vdomBlockId == null) {
return null;
}
return <TermVDomNodeSingleId key={vdomBlockId} vdomBlockId={vdomBlockId} blockId={blockId} model={model} />;
};
const TerminalView = ({ blockId, model }: TerminalViewProps) => { const TerminalView = ({ blockId, model }: TerminalViewProps) => {
const viewRef = React.createRef<HTMLDivElement>(); const viewRef = React.useRef<HTMLDivElement>(null);
const connectElemRef = React.useRef<HTMLDivElement>(null); const connectElemRef = React.useRef<HTMLDivElement>(null);
const termRef = React.useRef<TermWrap>(null); const termRef = React.useRef<TermWrap>(null);
model.termRef = termRef; model.termRef = termRef;
const shellProcStatusRef = React.useRef<string>(null); const spstatusRef = React.useRef<string>(null);
const htmlElemFocusRef = React.useRef<HTMLInputElement>(null); model.shellProcStatusRef = spstatusRef;
model.htmlElemFocusRef = htmlElemFocusRef;
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId)); const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const termSettingsAtom = useSettingsPrefixAtom("term"); const termSettingsAtom = useSettingsPrefixAtom("term");
const termSettings = jotai.useAtomValue(termSettingsAtom); const termSettings = jotai.useAtomValue(termSettingsAtom);
let termMode = blockData?.meta?.["term:mode"] ?? "term";
if (termMode != "term" && termMode != "vdom") {
termMode = "term";
}
const termModeRef = React.useRef(termMode);
React.useEffect(() => { React.useEffect(() => {
function handleTerminalKeydown(event: KeyboardEvent): boolean {
const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event);
if (waveEvent.type != "keydown") {
return true;
}
// deal with terminal specific keybindings
if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) {
event.preventDefault();
event.stopPropagation();
RpcApi.SetMetaCommand(WindowRpcClient, {
oref: WOS.makeORef("block", blockId),
meta: { "term:mode": null },
});
return false;
}
if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) {
const p = navigator.clipboard.readText();
p.then((text) => {
termRef.current?.terminal.paste(text);
});
event.preventDefault();
event.stopPropagation();
return false;
} else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) {
const sel = termRef.current?.terminal.getSelection();
navigator.clipboard.writeText(sel);
event.preventDefault();
event.stopPropagation();
return false;
}
if (shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) {
// restart
const tabId = globalStore.get(atoms.activeTabId);
const prtn = RpcApi.ControllerResyncCommand(WindowRpcClient, { tabid: tabId, blockid: blockId });
prtn.catch((e) => console.log("error controller resync (enter)", blockId, e));
return false;
}
const globalKeys = getAllGlobalKeyBindings();
for (const key of globalKeys) {
if (keyutil.checkKeyPressed(waveEvent, key)) {
return false;
}
}
return true;
}
const fullConfig = globalStore.get(atoms.fullConfigAtom); const fullConfig = globalStore.get(atoms.fullConfigAtom);
const termTheme = computeTheme(fullConfig, blockData?.meta?.["term:theme"]); const termTheme = computeTheme(fullConfig, blockData?.meta?.["term:theme"]);
const themeCopy = { ...termTheme }; const themeCopy = { ...termTheme };
@ -335,7 +418,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
scrollback: termScrollback, scrollback: termScrollback,
}, },
{ {
keydownHandler: handleTerminalKeydown, keydownHandler: model.handleTerminalKeydown.bind(model),
useWebGl: !termSettings?.["term:disablewebgl"], useWebGl: !termSettings?.["term:disablewebgl"],
} }
); );
@ -352,29 +435,13 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
}; };
}, [blockId, termSettings]); }, [blockId, termSettings]);
const handleHtmlKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { React.useEffect(() => {
const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); if (termModeRef.current == "vdom" && termMode == "term") {
if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { // focus the terminal
// reset term:mode model.giveFocus();
RpcApi.SetMetaCommand(WindowRpcClient, {
oref: WOS.makeORef("block", blockId),
meta: { "term:mode": null },
});
return false;
}
const asciiVal = keyboardEventToASCII(event);
if (asciiVal.length == 0) {
return false;
}
const b64data = util.stringToBase64(asciiVal);
RpcApi.ControllerInputCommand(WindowRpcClient, { blockid: blockId, inputdata64: b64data });
return true;
};
let termMode = blockData?.meta?.["term:mode"] ?? "term";
if (termMode != "term" && termMode != "html") {
termMode = "term";
} }
termModeRef.current = termMode;
}, [termMode]);
// set intitial controller status, and then subscribe for updates // set intitial controller status, and then subscribe for updates
React.useEffect(() => { React.useEffect(() => {
@ -382,7 +449,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
if (status == null) { if (status == null) {
return; return;
} }
shellProcStatusRef.current = status; model.shellProcStatusRef.current = status;
if (status == "running") { if (status == "running") {
termRef.current?.setIsRunning(true); termRef.current?.setIsRunning(true);
} else { } else {
@ -418,28 +485,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
<TermThemeUpdater blockId={blockId} termRef={termRef} /> <TermThemeUpdater blockId={blockId} termRef={termRef} />
<TermStickers config={stickerConfig} /> <TermStickers config={stickerConfig} />
<div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div> <div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div>
<div <TermVDomNode key="vdom" blockId={blockId} model={model} />
key="htmlElem"
className="term-htmlelem"
onClick={() => {
if (htmlElemFocusRef.current != null) {
htmlElemFocusRef.current.focus();
}
}}
>
<div key="htmlElemFocus" className="term-htmlelem-focus">
<input
type="text"
value={""}
ref={htmlElemFocusRef}
onKeyDown={handleHtmlKeyDown}
onChange={() => {}}
/>
</div>
<div key="htmlElemContent" className="term-htmlelem-content">
<VDomView rootNode={testVDom} />
</div>
</div>
</div> </div>
); );
}; };

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { RpcApi } from "@/app/store/wshclientapi"; import { RpcApi } from "@/app/store/wshclientapi";
import { WindowRpcClient } from "@/app/store/wshrpcutil"; import { TabRpcClient } from "@/app/store/wshrpcutil";
import { createBlock } from "@/store/global"; import { createBlock } from "@/store/global";
import { getWebServerEndpoint } from "@/util/endpoints"; import { getWebServerEndpoint } from "@/util/endpoints";
import { stringToBase64 } from "@/util/util"; import { stringToBase64 } from "@/util/util";
@ -101,7 +101,7 @@ function TermSticker({ sticker, config }: { sticker: StickerType; config: Sticke
console.log("clickHandler", sticker.clickcmd, sticker.clickblockdef); console.log("clickHandler", sticker.clickcmd, sticker.clickblockdef);
if (sticker.clickcmd) { if (sticker.clickcmd) {
const b64data = stringToBase64(sticker.clickcmd); const b64data = stringToBase64(sticker.clickcmd);
RpcApi.ControllerInputCommand(WindowRpcClient, { blockid: config.blockId, inputdata64: b64data }); RpcApi.ControllerInputCommand(TabRpcClient, { blockid: config.blockId, inputdata64: b64data });
} }
if (sticker.clickblockdef) { if (sticker.clickblockdef) {
createBlock(sticker.clickblockdef); createBlock(sticker.clickblockdef);

View File

@ -4,7 +4,7 @@
import { getFileSubject } from "@/app/store/wps"; import { getFileSubject } from "@/app/store/wps";
import { sendWSCommand } from "@/app/store/ws"; import { sendWSCommand } from "@/app/store/ws";
import { RpcApi } from "@/app/store/wshclientapi"; import { RpcApi } from "@/app/store/wshclientapi";
import { WindowRpcClient } from "@/app/store/wshrpcutil"; import { TabRpcClient } from "@/app/store/wshrpcutil";
import { PLATFORM, WOS, atoms, fetchWaveFile, getSettingsKeyAtom, globalStore, openLink } from "@/store/global"; import { PLATFORM, WOS, atoms, fetchWaveFile, getSettingsKeyAtom, globalStore, openLink } from "@/store/global";
import * as services from "@/store/services"; import * as services from "@/store/services";
import * as util from "@/util/util"; import * as util from "@/util/util";
@ -168,7 +168,7 @@ export class TermWrap {
handleTermData(data: string) { handleTermData(data: string) {
const b64data = util.stringToBase64(data); const b64data = util.stringToBase64(data);
RpcApi.ControllerInputCommand(WindowRpcClient, { blockid: this.blockId, inputdata64: b64data }); RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data });
} }
addFocusListener(focusFn: () => void) { addFocusListener(focusFn: () => void) {
@ -216,7 +216,21 @@ export class TermWrap {
if (cacheFile != null) { if (cacheFile != null) {
ptyOffset = cacheFile.meta["ptyoffset"] ?? 0; ptyOffset = cacheFile.meta["ptyoffset"] ?? 0;
if (cacheData.byteLength > 0) { if (cacheData.byteLength > 0) {
const curTermSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };
const fileTermSize: TermSize = cacheFile.meta["termsize"];
let didResize = false;
if (
fileTermSize != null &&
(fileTermSize.rows != curTermSize.rows || fileTermSize.cols != curTermSize.cols)
) {
console.log("terminal restore size mismatch, temp resize", fileTermSize, curTermSize);
this.terminal.resize(fileTermSize.cols, fileTermSize.rows);
didResize = true;
}
this.doTerminalWrite(cacheData, ptyOffset); this.doTerminalWrite(cacheData, ptyOffset);
if (didResize) {
this.terminal.resize(curTermSize.cols, curTermSize.rows);
}
} }
} }
const { data: mainData, fileInfo: mainFile } = await fetchWaveFile(this.blockId, TermFileName, ptyOffset); const { data: mainData, fileInfo: mainFile } = await fetchWaveFile(this.blockId, TermFileName, ptyOffset);
@ -230,10 +244,10 @@ export class TermWrap {
async resyncController(reason: string) { async resyncController(reason: string) {
dlog("resync controller", this.blockId, reason); dlog("resync controller", this.blockId, reason);
const tabId = globalStore.get(atoms.activeTabId); const tabId = globalStore.get(atoms.staticTabId);
const rtOpts: RuntimeOpts = { termsize: { rows: this.terminal.rows, cols: this.terminal.cols } }; const rtOpts: RuntimeOpts = { termsize: { rows: this.terminal.rows, cols: this.terminal.cols } };
try { try {
await RpcApi.ControllerResyncCommand(WindowRpcClient, { await RpcApi.ControllerResyncCommand(TabRpcClient, {
tabid: tabId, tabid: tabId,
blockid: this.blockId, blockid: this.blockId,
rtopts: rtOpts, rtopts: rtOpts,
@ -268,8 +282,9 @@ export class TermWrap {
return; return;
} }
const serializedOutput = this.serializeAddon.serialize(); const serializedOutput = this.serializeAddon.serialize();
console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length); const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };
services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset); console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length, termSize);
services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset, termSize);
this.dataBytesProcessed = 0; this.dataBytesProcessed = 0;
} }

View File

@ -0,0 +1,595 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { getBlockMetaKeyAtom, globalStore, WOS } from "@/app/store/global";
import { makeORef } from "@/app/store/wos";
import { waveEventSubscribe } from "@/app/store/wps";
import { RpcResponseHelper, WshClient } from "@/app/store/wshclient";
import { RpcApi } from "@/app/store/wshclientapi";
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
import { NodeModel } from "@/layout/index";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import debug from "debug";
import * as jotai from "jotai";
const dlog = debug("wave:vdom");
type AtomContainer = {
val: any;
beVal: any;
usedBy: Set<string>;
};
type RefContainer = {
refFn: (elem: HTMLElement) => void;
vdomRef: VDomRef;
elem: HTMLElement;
updated: boolean;
};
function makeVDomIdMap(vdom: VDomElem, idMap: Map<string, VDomElem>) {
if (vdom == null) {
return;
}
if (vdom.waveid != null) {
idMap.set(vdom.waveid, vdom);
}
if (vdom.children == null) {
return;
}
for (let child of vdom.children) {
makeVDomIdMap(child, idMap);
}
}
function convertEvent(e: React.SyntheticEvent, fromProp: string): any {
if (e == null) {
return null;
}
if (fromProp == "onClick") {
return { type: "click" };
}
if (fromProp == "onKeyDown") {
const waveKeyEvent = adaptFromReactOrNativeKeyEvent(e as React.KeyboardEvent);
return waveKeyEvent;
}
if (fromProp == "onFocus") {
return { type: "focus" };
}
if (fromProp == "onBlur") {
return { type: "blur" };
}
return { type: "unknown" };
}
class VDomWshClient extends WshClient {
model: VDomModel;
constructor(model: VDomModel) {
super(makeFeBlockRouteId(model.blockId));
this.model = model;
}
handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) {
console.log("async-initiation", rh.getSource(), data);
this.model.queueUpdate(true);
}
}
export class VDomModel {
blockId: string;
nodeModel: NodeModel;
viewType: string;
viewIcon: jotai.Atom<string>;
viewName: jotai.Atom<string>;
viewRef: React.RefObject<HTMLDivElement> = { current: null };
vdomRoot: jotai.PrimitiveAtom<VDomElem> = jotai.atom();
atoms: Map<string, AtomContainer> = new Map(); // key is atomname
refs: Map<string, RefContainer> = new Map(); // key is refid
batchedEvents: VDomEvent[] = [];
messages: VDomMessage[] = [];
needsResync: boolean = true;
vdomNodeVersion: WeakMap<VDomElem, jotai.PrimitiveAtom<number>> = new WeakMap();
compoundAtoms: Map<string, jotai.PrimitiveAtom<{ [key: string]: any }>> = new Map();
rootRefId: string = crypto.randomUUID();
backendRoute: jotai.Atom<string>;
backendOpts: VDomBackendOpts;
shouldDispose: boolean;
disposed: boolean;
hasPendingRequest: boolean;
needsUpdate: boolean;
maxNormalUpdateIntervalMs: number = 100;
needsImmediateUpdate: boolean;
lastUpdateTs: number = 0;
queuedUpdate: { timeoutId: any; ts: number; quick: boolean };
contextActive: jotai.PrimitiveAtom<boolean>;
wshClient: VDomWshClient;
persist: jotai.Atom<boolean>;
routeGoneUnsub: () => void;
routeConfirmed: boolean = false;
constructor(blockId: string, nodeModel: NodeModel) {
this.viewType = "vdom";
this.blockId = blockId;
this.nodeModel = nodeModel;
this.contextActive = jotai.atom(false);
this.reset();
this.viewIcon = jotai.atom("bolt");
this.viewName = jotai.atom("Wave App");
this.backendRoute = jotai.atom((get) => {
const blockData = get(WOS.getWaveObjectAtom<Block>(makeORef("block", this.blockId)));
return blockData?.meta?.["vdom:route"];
});
this.persist = getBlockMetaKeyAtom(this.blockId, "vdom:persist");
this.wshClient = new VDomWshClient(this);
DefaultRouter.registerRoute(this.wshClient.routeId, this.wshClient);
const curBackendRoute = globalStore.get(this.backendRoute);
if (curBackendRoute) {
this.queueUpdate(true);
}
this.routeGoneUnsub = waveEventSubscribe({
eventType: "route:gone",
scope: curBackendRoute,
handler: (event: WaveEvent) => {
this.disposed = true;
const shouldPersist = globalStore.get(this.persist);
if (!shouldPersist) {
this.nodeModel?.onClose?.();
}
},
});
RpcApi.WaitForRouteCommand(TabRpcClient, { routeid: curBackendRoute, waitms: 4000 }, { timeout: 5000 }).then(
(routeOk: boolean) => {
if (routeOk) {
this.routeConfirmed = true;
this.queueUpdate(true);
} else {
this.disposed = true;
const shouldPersist = globalStore.get(this.persist);
if (!shouldPersist) {
this.nodeModel?.onClose?.();
}
}
}
);
}
dispose() {
DefaultRouter.unregisterRoute(this.wshClient.routeId);
this.routeGoneUnsub?.();
}
reset() {
globalStore.set(this.vdomRoot, null);
this.atoms.clear();
this.refs.clear();
this.batchedEvents = [];
this.messages = [];
this.needsResync = true;
this.vdomNodeVersion = new WeakMap();
this.compoundAtoms.clear();
this.rootRefId = crypto.randomUUID();
this.backendOpts = {};
this.shouldDispose = false;
this.disposed = false;
this.hasPendingRequest = false;
this.needsUpdate = false;
this.maxNormalUpdateIntervalMs = 100;
this.needsImmediateUpdate = false;
this.lastUpdateTs = 0;
this.queuedUpdate = null;
globalStore.set(this.contextActive, false);
}
getBackendRoute(): string {
const blockData = globalStore.get(WOS.getWaveObjectAtom<Block>(makeORef("block", this.blockId)));
return blockData?.meta?.["vdom:route"];
}
keyDownHandler(e: WaveKeyboardEvent): boolean {
if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, "Ctrl:c")) {
this.shouldDispose = true;
this.queueUpdate(true);
return true;
}
if (this.backendOpts?.globalkeyboardevents) {
if (e.cmd || e.meta) {
return false;
}
this.batchedEvents.push({
waveid: null,
eventtype: "onKeyDown",
eventdata: e,
});
this.queueUpdate();
return true;
}
return false;
}
hasRefUpdates() {
for (let ref of this.refs.values()) {
if (ref.updated) {
return true;
}
}
return false;
}
getRefUpdates(): VDomRefUpdate[] {
let updates: VDomRefUpdate[] = [];
for (let ref of this.refs.values()) {
if (ref.updated || (ref.vdomRef.trackposition && ref.elem != null)) {
const ru: VDomRefUpdate = {
refid: ref.vdomRef.refid,
hascurrent: ref.vdomRef.hascurrent,
};
if (ref.vdomRef.trackposition && ref.elem != null) {
ru.position = {
offsetheight: ref.elem.offsetHeight,
offsetwidth: ref.elem.offsetWidth,
scrollheight: ref.elem.scrollHeight,
scrollwidth: ref.elem.scrollWidth,
scrolltop: ref.elem.scrollTop,
boundingclientrect: ref.elem.getBoundingClientRect(),
};
}
updates.push(ru);
ref.updated = false;
}
}
return updates;
}
queueUpdate(quick: boolean = false, delay: number = 10) {
if (this.disposed) {
return;
}
this.needsUpdate = true;
let nowTs = Date.now();
if (delay > this.maxNormalUpdateIntervalMs) {
delay = this.maxNormalUpdateIntervalMs;
}
if (quick) {
if (this.queuedUpdate) {
if (this.queuedUpdate.quick || this.queuedUpdate.ts <= nowTs) {
return;
}
clearTimeout(this.queuedUpdate.timeoutId);
this.queuedUpdate = null;
}
let timeoutId = setTimeout(() => {
this._sendRenderRequest(true);
}, 0);
this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs, quick: true };
return;
}
if (this.queuedUpdate) {
return;
}
let lastUpdateDiff = nowTs - this.lastUpdateTs;
let timeoutMs: number = null;
if (lastUpdateDiff >= this.maxNormalUpdateIntervalMs) {
// it has been a while since the last update, so use delay
timeoutMs = delay;
} else {
timeoutMs = this.maxNormalUpdateIntervalMs - lastUpdateDiff;
}
if (timeoutMs < delay) {
timeoutMs = delay;
}
let timeoutId = setTimeout(() => {
this._sendRenderRequest(false);
}, timeoutMs);
this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs + timeoutMs, quick: false };
}
async _sendRenderRequest(force: boolean) {
this.queuedUpdate = null;
if (this.disposed || !this.routeConfirmed) {
return;
}
if (this.hasPendingRequest) {
if (force) {
this.needsImmediateUpdate = true;
}
return;
}
if (!force && !this.needsUpdate) {
return;
}
const backendRoute = globalStore.get(this.backendRoute);
if (backendRoute == null) {
console.log("vdom-model", "no backend route");
return;
}
this.hasPendingRequest = true;
this.needsImmediateUpdate = false;
try {
const feUpdate = this.createFeUpdate();
dlog("fe-update", feUpdate);
const beUpdate = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: backendRoute });
this.handleBackendUpdate(beUpdate);
} finally {
this.lastUpdateTs = Date.now();
this.hasPendingRequest = false;
}
if (this.needsImmediateUpdate) {
this.queueUpdate(true);
}
}
getAtomContainer(atomName: string): AtomContainer {
let container = this.atoms.get(atomName);
if (container == null) {
container = {
val: null,
beVal: null,
usedBy: new Set(),
};
this.atoms.set(atomName, container);
}
return container;
}
getOrCreateRefContainer(vdomRef: VDomRef): RefContainer {
let container = this.refs.get(vdomRef.refid);
if (container == null) {
container = {
refFn: (elem: HTMLElement) => {
container.elem = elem;
const hasElem = elem != null;
if (vdomRef.hascurrent != hasElem) {
container.updated = true;
vdomRef.hascurrent = hasElem;
}
},
vdomRef: vdomRef,
elem: null,
updated: false,
};
this.refs.set(vdomRef.refid, container);
}
return container;
}
tagUseAtoms(waveId: string, atomNames: Set<string>) {
for (let atomName of atomNames) {
let container = this.getAtomContainer(atomName);
container.usedBy.add(waveId);
}
}
tagUnuseAtoms(waveId: string, atomNames: Set<string>) {
for (let atomName of atomNames) {
let container = this.getAtomContainer(atomName);
container.usedBy.delete(waveId);
}
}
getVDomNodeVersionAtom(vdom: VDomElem) {
let atom = this.vdomNodeVersion.get(vdom);
if (atom == null) {
atom = jotai.atom(0);
this.vdomNodeVersion.set(vdom, atom);
}
return atom;
}
incVDomNodeVersion(vdom: VDomElem) {
if (vdom == null) {
return;
}
const atom = this.getVDomNodeVersionAtom(vdom);
globalStore.set(atom, globalStore.get(atom) + 1);
}
addErrorMessage(message: string) {
this.messages.push({
messagetype: "error",
message: message,
});
}
handleRenderUpdates(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) {
if (!update.renderupdates) {
return;
}
for (let renderUpdate of update.renderupdates) {
if (renderUpdate.updatetype == "root") {
globalStore.set(this.vdomRoot, renderUpdate.vdom);
continue;
}
if (renderUpdate.updatetype == "append") {
let parent = idMap.get(renderUpdate.waveid);
if (parent == null) {
this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);
continue;
}
if (parent.children == null) {
parent.children = [];
}
parent.children.push(renderUpdate.vdom);
this.incVDomNodeVersion(parent);
continue;
}
if (renderUpdate.updatetype == "replace") {
let parent = idMap.get(renderUpdate.waveid);
if (parent == null) {
this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);
continue;
}
if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) {
this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`);
continue;
}
parent.children[renderUpdate.index] = renderUpdate.vdom;
this.incVDomNodeVersion(parent);
continue;
}
if (renderUpdate.updatetype == "remove") {
let parent = idMap.get(renderUpdate.waveid);
if (parent == null) {
this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);
continue;
}
if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) {
this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`);
continue;
}
parent.children.splice(renderUpdate.index, 1);
this.incVDomNodeVersion(parent);
continue;
}
if (renderUpdate.updatetype == "insert") {
let parent = idMap.get(renderUpdate.waveid);
if (parent == null) {
this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);
continue;
}
if (parent.children == null) {
parent.children = [];
}
if (renderUpdate.index < 0 || parent.children.length < renderUpdate.index) {
this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`);
continue;
}
parent.children.splice(renderUpdate.index, 0, renderUpdate.vdom);
this.incVDomNodeVersion(parent);
continue;
}
this.addErrorMessage(`Unknown updatetype ${renderUpdate.updatetype}`);
}
}
setAtomValue(atomName: string, value: any, fromBe: boolean, idMap: Map<string, VDomElem>) {
dlog("setAtomValue", atomName, value, fromBe);
let container = this.getAtomContainer(atomName);
container.val = value;
if (fromBe) {
container.beVal = value;
}
for (let id of container.usedBy) {
this.incVDomNodeVersion(idMap.get(id));
}
}
handleStateSync(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) {
if (update.statesync == null) {
return;
}
for (let sync of update.statesync) {
this.setAtomValue(sync.atom, sync.value, true, idMap);
}
}
getRefElem(refId: string): HTMLElement {
if (refId == this.rootRefId) {
return this.viewRef.current;
}
const ref = this.refs.get(refId);
return ref?.elem;
}
handleRefOperations(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) {
if (update.refoperations == null) {
return;
}
for (let refOp of update.refoperations) {
const elem = this.getRefElem(refOp.refid);
if (elem == null) {
this.addErrorMessage(`Could not find ref with id ${refOp.refid}`);
continue;
}
if (refOp.op == "focus") {
if (elem == null) {
this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`);
continue;
}
try {
elem.focus();
} catch (e) {
this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: ${e.message}`);
}
} else {
this.addErrorMessage(`Unknown ref operation ${refOp.refid} ${refOp.op}`);
}
}
}
handleBackendUpdate(update: VDomBackendUpdate) {
if (update == null) {
return;
}
globalStore.set(this.contextActive, true);
const idMap = new Map<string, VDomElem>();
const vdomRoot = globalStore.get(this.vdomRoot);
if (update.opts != null) {
this.backendOpts = update.opts;
}
makeVDomIdMap(vdomRoot, idMap);
this.handleRenderUpdates(update, idMap);
this.handleStateSync(update, idMap);
this.handleRefOperations(update, idMap);
if (update.messages) {
for (let message of update.messages) {
console.log("vdom-message", this.blockId, message.messagetype, message.message);
if (message.stacktrace) {
console.log("vdom-message-stacktrace", message.stacktrace);
}
}
}
}
callVDomFunc(fnDecl: VDomFunc, e: any, compId: string, propName: string) {
const eventData = convertEvent(e, propName);
if (fnDecl.globalevent) {
const waveEvent: VDomEvent = {
waveid: null,
eventtype: fnDecl.globalevent,
eventdata: eventData,
};
this.batchedEvents.push(waveEvent);
} else {
const vdomEvent: VDomEvent = {
waveid: compId,
eventtype: propName,
eventdata: eventData,
};
this.batchedEvents.push(vdomEvent);
}
this.queueUpdate();
}
createFeUpdate(): VDomFrontendUpdate {
const blockORef = makeORef("block", this.blockId);
const blockAtom = WOS.getWaveObjectAtom<Block>(blockORef);
const blockData = globalStore.get(blockAtom);
const isBlockFocused = globalStore.get(this.nodeModel.isFocused);
const renderContext: VDomRenderContext = {
blockid: this.blockId,
focused: isBlockFocused,
width: this.viewRef?.current?.offsetWidth ?? 0,
height: this.viewRef?.current?.offsetHeight ?? 0,
rootrefid: this.rootRefId,
background: false,
};
const feUpdate: VDomFrontendUpdate = {
type: "frontendupdate",
ts: Date.now(),
blockid: this.blockId,
rendercontext: renderContext,
dispose: this.shouldDispose,
resync: this.needsResync,
events: this.batchedEvents,
refupdates: this.getRefUpdates(),
};
this.needsResync = false;
this.batchedEvents = [];
if (this.shouldDispose) {
this.disposed = true;
}
return feUpdate;
}
}

View File

@ -1,9 +1,30 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { Markdown } from "@/app/element/markdown";
import { VDomModel } from "@/app/view/term/vdom-model";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import debug from "debug";
import * as jotai from "jotai";
import * as React from "react"; import * as React from "react";
const TextTag = "#text";
const FragmentTag = "#fragment";
const WaveTextTag = "wave:text";
const WaveNullTag = "wave:null";
const VDomObjType_Ref = "ref";
const VDomObjType_Binding = "binding";
const VDomObjType_Func = "func";
const dlog = debug("wave:vdom");
type VDomReactTagType = (props: { elem: VDomElem; model: VDomModel }) => JSX.Element;
const WaveTagMap: Record<string, VDomReactTagType> = {
"wave:markdown": WaveMarkdown,
};
const AllowedTags: { [tagName: string]: boolean } = { const AllowedTags: { [tagName: string]: boolean } = {
div: true, div: true,
b: true, b: true,
@ -30,38 +51,38 @@ const AllowedTags: { [tagName: string]: boolean } = {
form: true, form: true,
}; };
function convertVDomFunc(fnDecl: VDomFuncType, compId: string, propName: string): (e: any) => void { function convertVDomFunc(model: VDomModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void {
return (e: any) => { return (e: any) => {
if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) { if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) {
let waveEvent = adaptFromReactOrNativeKeyEvent(e); let waveEvent = adaptFromReactOrNativeKeyEvent(e);
for (let keyDesc of fnDecl["#keys"]) { for (let keyDesc of fnDecl.keys || []) {
if (checkKeyPressed(waveEvent, keyDesc)) { if (checkKeyPressed(waveEvent, keyDesc)) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
callFunc(e, compId, propName); model.callVDomFunc(fnDecl, e, compId, propName);
return; return;
} }
} }
return; return;
} }
if (fnDecl["#preventDefault"]) { if (fnDecl.preventdefault) {
e.preventDefault(); e.preventDefault();
} }
if (fnDecl["#stopPropagation"]) { if (fnDecl.stoppropagation) {
e.stopPropagation(); e.stopPropagation();
} }
callFunc(e, compId, propName); model.callVDomFunc(fnDecl, e, compId, propName);
}; };
} }
function convertElemToTag(elem: VDomElem): JSX.Element | string { function convertElemToTag(elem: VDomElem, model: VDomModel): JSX.Element | string {
if (elem == null) { if (elem == null) {
return null; return null;
} }
if (elem.tag == "#text") { if (elem.tag == TextTag) {
return elem.text; return elem.text;
} }
return React.createElement(VDomTag, { elem: elem, key: elem.id }); return React.createElement(VDomTag, { key: elem.waveid, elem, model });
} }
function isObject(v: any): boolean { function isObject(v: any): boolean {
@ -72,19 +93,35 @@ function isArray(v: any): boolean {
return Array.isArray(v); return Array.isArray(v);
} }
function callFunc(e: any, compId: string, propName: string) { function resolveBinding(binding: VDomBinding, model: VDomModel): [any, string[]] {
console.log("callfunc", compId, propName); const bindName = binding.bind;
if (bindName == null || bindName == "") {
return [null, []];
}
// for now we only recognize $.[atomname] bindings
if (!bindName.startsWith("$.")) {
return [null, []];
}
const atomName = bindName.substring(2);
if (atomName == "") {
return [null, []];
}
const atom = model.getAtomContainer(atomName);
if (atom == null) {
return [null, []];
}
return [atom.val, [atomName]];
} }
function updateRefFunc(elem: any, ref: VDomRefType) { type GenericPropsType = { [key: string]: any };
console.log("updateref", ref["#ref"], elem);
}
function VDomTag({ elem }: { elem: VDomElem }) { // returns props, and a set of atom keys used in the props
if (!AllowedTags[elem.tag]) { function convertProps(elem: VDomElem, model: VDomModel): [GenericPropsType, Set<string>] {
return <div>{"Invalid Tag <" + elem.tag + ">"}</div>; let props: GenericPropsType = {};
let atomKeys = new Set<string>();
if (elem.props == null) {
return [props, atomKeys];
} }
let props = {};
for (let key in elem.props) { for (let key in elem.props) {
let val = elem.props[key]; let val = elem.props[key];
if (val == null) { if (val == null) {
@ -94,36 +131,153 @@ function VDomTag({ elem }: { elem: VDomElem }) {
if (val == null) { if (val == null) {
continue; continue;
} }
if (isObject(val) && "#ref" in val) { if (isObject(val) && val.type == VDomObjType_Ref) {
props[key] = (elem: HTMLElement) => { const valRef = val as VDomRef;
updateRefFunc(elem, val); const refContainer = model.getOrCreateRefContainer(valRef);
}; props[key] = refContainer.refFn;
} }
continue; continue;
} }
if (isObject(val) && "#func" in val) { if (isObject(val) && val.type == VDomObjType_Func) {
props[key] = convertVDomFunc(val, elem.id, key); const valFunc = val as VDomFunc;
props[key] = convertVDomFunc(model, valFunc, elem.waveid, key);
continue; continue;
} }
if (isObject(val) && val.type == VDomObjType_Binding) {
const [propVal, atomDeps] = resolveBinding(val as VDomBinding, model);
props[key] = propVal;
for (let atomDep of atomDeps) {
atomKeys.add(atomDep);
} }
continue;
}
if (key == "style" && isObject(val)) {
// assuming the entire style prop wasn't bound, look through the individual keys and bind them
for (let styleKey in val) {
let styleVal = val[styleKey];
if (isObject(styleVal) && styleVal.type == VDomObjType_Binding) {
const [stylePropVal, styleAtomDeps] = resolveBinding(styleVal as VDomBinding, model);
val[styleKey] = stylePropVal;
for (let styleAtomDep of styleAtomDeps) {
atomKeys.add(styleAtomDep);
}
}
}
// fallthrough to set props[key] = val
}
props[key] = val;
}
return [props, atomKeys];
}
function convertChildren(elem: VDomElem, model: VDomModel): (string | JSX.Element)[] {
let childrenComps: (string | JSX.Element)[] = []; let childrenComps: (string | JSX.Element)[] = [];
if (elem.children) { if (elem.children == null) {
return childrenComps;
}
for (let child of elem.children) { for (let child of elem.children) {
if (child == null) { if (child == null) {
continue; continue;
} }
childrenComps.push(convertElemToTag(child)); childrenComps.push(convertElemToTag(child, model));
} }
}
if (elem.tag == "#fragment") {
return childrenComps; return childrenComps;
} }
function stringSetsEqual(set1: Set<string>, set2: Set<string>): boolean {
if (set1.size != set2.size) {
return false;
}
for (let elem of set1) {
if (!set2.has(elem)) {
return false;
}
}
return true;
}
function useVDom(model: VDomModel, elem: VDomElem): GenericPropsType {
const version = jotai.useAtomValue(model.getVDomNodeVersionAtom(elem));
const [oldAtomKeys, setOldAtomKeys] = React.useState<Set<string>>(new Set());
let [props, atomKeys] = convertProps(elem, model);
React.useEffect(() => {
if (stringSetsEqual(atomKeys, oldAtomKeys)) {
return;
}
model.tagUnuseAtoms(elem.waveid, oldAtomKeys);
model.tagUseAtoms(elem.waveid, atomKeys);
setOldAtomKeys(atomKeys);
}, [atomKeys]);
React.useEffect(() => {
return () => {
model.tagUnuseAtoms(elem.waveid, oldAtomKeys);
};
}, []);
return props;
}
function WaveMarkdown({ elem, model }: { elem: VDomElem; model: VDomModel }) {
const props = useVDom(model, elem);
return (
<Markdown text={props?.text} style={props?.style} className={props?.className} scrollable={props?.scrollable} />
);
}
function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) {
const props = useVDom(model, elem);
if (elem.tag == WaveNullTag) {
return null;
}
if (elem.tag == WaveTextTag) {
return props.text;
}
const waveTag = WaveTagMap[elem.tag];
if (waveTag) {
return waveTag({ elem, model });
}
if (!AllowedTags[elem.tag]) {
return <div>{"Invalid Tag <" + elem.tag + ">"}</div>;
}
let childrenComps = convertChildren(elem, model);
if (elem.tag == FragmentTag) {
return childrenComps;
}
props.key = "e-" + elem.waveid;
return React.createElement(elem.tag, props, childrenComps); return React.createElement(elem.tag, props, childrenComps);
} }
function VDomView({ rootNode }: { rootNode: VDomElem }) { function vdomText(text: string): VDomElem {
let rtn = convertElemToTag(rootNode); return {
tag: "#text",
text: text,
};
}
const testVDom: VDomElem = {
waveid: "testid1",
tag: "div",
children: [
{
waveid: "testh1",
tag: "h1",
children: [vdomText("Hello World")],
},
{
waveid: "testp",
tag: "p",
children: [vdomText("This is a paragraph (from VDOM)")],
},
],
};
function VDomRoot({ model }: { model: VDomModel }) {
let rootNode = jotai.useAtomValue(model.vdomRoot);
if (model.viewRef.current == null || rootNode == null) {
return null;
}
dlog("render", rootNode);
let rtn = convertElemToTag(rootNode, model);
return <div className="vdom">{rtn}</div>; return <div className="vdom">{rtn}</div>;
} }
export { VDomView }; export { VDomRoot };

View File

@ -0,0 +1,28 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { VDomRoot } from "@/app/view/term/vdom";
import { VDomModel } from "@/app/view/term/vdom-model";
import { NodeModel } from "@/layout/index";
import { useRef } from "react";
function makeVDomModel(blockId: string, nodeModel: NodeModel): VDomModel {
return new VDomModel(blockId, nodeModel);
}
type VDomViewProps = {
model: VDomModel;
blockId: string;
};
function VDomView({ blockId, model }: VDomViewProps) {
let viewRef = useRef(null);
model.viewRef = viewRef;
return (
<div className="vdom-view" ref={viewRef}>
<VDomRoot model={model} />
</div>
);
}
export { makeVDomModel, VDomView };

View File

@ -5,8 +5,8 @@ import { Button } from "@/app/element/button";
import { Markdown } from "@/app/element/markdown"; import { Markdown } from "@/app/element/markdown";
import { TypingIndicator } from "@/app/element/typingindicator"; import { TypingIndicator } from "@/app/element/typingindicator";
import { RpcApi } from "@/app/store/wshclientapi"; import { RpcApi } from "@/app/store/wshclientapi";
import { WindowRpcClient } from "@/app/store/wshrpcutil"; import { TabRpcClient } from "@/app/store/wshrpcutil";
import { atoms, fetchWaveFile, globalStore, WOS } from "@/store/global"; import { atoms, createBlock, fetchWaveFile, getApi, globalStore, WOS } from "@/store/global";
import { BlockService, ObjectService } from "@/store/services"; import { BlockService, ObjectService } from "@/store/services";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import { fireAndForget, isBlank, makeIconClass } from "@/util/util"; import { fireAndForget, isBlank, makeIconClass } from "@/util/util";
@ -182,12 +182,7 @@ export class WaveAiModel implements ViewModel {
}); });
} }
} }
const dropdownItems = Object.entries(presets)
viewTextChildren.push({
elemtype: "menubutton",
text: presetName,
title: "Select AI Configuration",
items: Object.entries(presets)
.sort((a, b) => (a[1]["display:order"] > b[1]["display:order"] ? 1 : -1)) .sort((a, b) => (a[1]["display:order"] > b[1]["display:order"] ? 1 : -1))
.map( .map(
(preset) => (preset) =>
@ -201,7 +196,27 @@ export class WaveAiModel implements ViewModel {
}); });
}), }),
}) as MenuItem }) as MenuItem
), );
dropdownItems.push({
label: "Add AI preset...",
onClick: () => {
fireAndForget(async () => {
const path = `${getApi().getConfigDir()}/presets/ai.json`;
const blockDef: BlockDef = {
meta: {
view: "preview",
file: path,
},
};
await createBlock(blockDef, true);
});
},
});
viewTextChildren.push({
elemtype: "menubutton",
text: presetName,
title: "Select AI Configuration",
items: dropdownItems,
}); });
return viewTextChildren; return viewTextChildren;
}); });
@ -274,7 +289,7 @@ export class WaveAiModel implements ViewModel {
}; };
let fullMsg = ""; let fullMsg = "";
try { try {
const aiGen = RpcApi.StreamWaveAiCommand(WindowRpcClient, beMsg, { timeout: opts.timeoutms }); const aiGen = RpcApi.StreamWaveAiCommand(TabRpcClient, beMsg, { timeout: opts.timeoutms });
for await (const msg of aiGen) { for await (const msg of aiGen) {
fullMsg += msg.text ?? ""; fullMsg += msg.text ?? "";
globalStore.set(this.updateLastMessageAtom, msg.text ?? "", true); globalStore.set(this.updateLastMessageAtom, msg.text ?? "", true);

View File

@ -5,7 +5,7 @@ import { getApi, getSettingsKeyAtom, openLink } from "@/app/store/global";
import { getSimpleControlShiftAtom } from "@/app/store/keymodel"; import { getSimpleControlShiftAtom } from "@/app/store/keymodel";
import { ObjectService } from "@/app/store/services"; import { ObjectService } from "@/app/store/services";
import { RpcApi } from "@/app/store/wshclientapi"; import { RpcApi } from "@/app/store/wshclientapi";
import { WindowRpcClient } from "@/app/store/wshrpcutil"; import { TabRpcClient } from "@/app/store/wshrpcutil";
import { NodeModel } from "@/layout/index"; import { NodeModel } from "@/layout/index";
import { WOS, globalStore } from "@/store/global"; import { WOS, globalStore } from "@/store/global";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
@ -369,17 +369,17 @@ export class WebViewModel implements ViewModel {
if (url != null && url != "") { if (url != null && url != "") {
switch (scope) { switch (scope) {
case "block": case "block":
await RpcApi.SetMetaCommand(WindowRpcClient, { await RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId), oref: WOS.makeORef("block", this.blockId),
meta: { pinnedurl: url }, meta: { pinnedurl: url },
}); });
break; break;
case "global": case "global":
await RpcApi.SetMetaCommand(WindowRpcClient, { await RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId), oref: WOS.makeORef("block", this.blockId),
meta: { pinnedurl: "" }, meta: { pinnedurl: "" },
}); });
await RpcApi.SetConfigCommand(WindowRpcClient, { "web:defaulturl": url }); await RpcApi.SetConfigCommand(TabRpcClient, { "web:defaulturl": url });
break; break;
} }
} }

View File

@ -94,20 +94,18 @@ const Widget = memo(({ widget }: { widget: WidgetConfigType }) => {
}); });
const WorkspaceElem = memo(() => { const WorkspaceElem = memo(() => {
const windowData = useAtomValue(atoms.waveWindow); const tabId = useAtomValue(atoms.staticTabId);
const activeTabId = windowData?.activetabid;
const ws = useAtomValue(atoms.workspace); const ws = useAtomValue(atoms.workspace);
return ( return (
<div className="workspace"> <div className="workspace">
<TabBar key={ws.oid} workspace={ws} /> <TabBar key={ws.oid} workspace={ws} />
<div className="workspace-tabcontent"> <div className="workspace-tabcontent">
<ErrorBoundary key={activeTabId}> <ErrorBoundary key={tabId}>
{activeTabId == "" ? ( {tabId == "" ? (
<CenteredDiv>No Active Tab</CenteredDiv> <CenteredDiv>No Active Tab</CenteredDiv>
) : ( ) : (
<> <>
<TabContent key={activeTabId} tabId={activeTabId} /> <TabContent key={tabId} tabId={tabId} />
<Widgets /> <Widgets />
<ModalsRenderer /> <ModalsRenderer />
</> </>

View File

@ -5,7 +5,7 @@ import { TileLayout } from "./lib/TileLayout";
import { LayoutModel } from "./lib/layoutModel"; import { LayoutModel } from "./lib/layoutModel";
import { import {
deleteLayoutModelForTab, deleteLayoutModelForTab,
getLayoutModelForActiveTab, getLayoutModelForStaticTab,
getLayoutModelForTab, getLayoutModelForTab,
getLayoutModelForTabById, getLayoutModelForTabById,
useDebouncedNodeInnerRect, useDebouncedNodeInnerRect,
@ -37,7 +37,7 @@ import { DropDirection, LayoutTreeActionType, NavigateDirection } from "./lib/ty
export { export {
deleteLayoutModelForTab, deleteLayoutModelForTab,
DropDirection, DropDirection,
getLayoutModelForActiveTab, getLayoutModelForStaticTab,
getLayoutModelForTab, getLayoutModelForTab,
getLayoutModelForTabById, getLayoutModelForTabById,
LayoutModel, LayoutModel,

View File

@ -128,7 +128,6 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr
</Suspense> </Suspense>
); );
} }
export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent; export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent;
interface DisplayNodesWrapperProps { interface DisplayNodesWrapperProps {
@ -247,6 +246,7 @@ const DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => {
magnified: addlProps?.isMagnifiedNode, magnified: addlProps?.isMagnifiedNode,
"last-magnified": addlProps?.isLastMagnifiedNode, "last-magnified": addlProps?.isLastMagnifiedNode,
})} })}
key={node.id}
ref={tileNodeRef} ref={tileNodeRef}
id={node.id} id={node.id}
style={addlProps?.transform} style={addlProps?.transform}

View File

@ -39,6 +39,10 @@ export function withLayoutTreeStateAtomFromTab(tabAtom: Atom<Tab>): WritableLayo
const stateAtom = getLayoutStateAtomFromTab(tabAtom, get); const stateAtom = getLayoutStateAtomFromTab(tabAtom, get);
if (!stateAtom) return; if (!stateAtom) return;
const waveObjVal = get(stateAtom); const waveObjVal = get(stateAtom);
if (waveObjVal == null) {
console.log("in withLayoutTreeStateAtomFromTab, waveObjVal is null", value);
return;
}
waveObjVal.rootnode = value.rootNode; waveObjVal.rootnode = value.rootNode;
waveObjVal.magnifiednodeid = value.magnifiedNodeId; waveObjVal.magnifiednodeid = value.magnifiedNodeId;
waveObjVal.focusednodeid = value.focusedNodeId; waveObjVal.focusednodeid = value.focusedNodeId;

View File

@ -1,9 +1,9 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { useOnResize } from "@/app/hook/useDimensions";
import { atoms, globalStore, WOS } from "@/app/store/global"; import { atoms, globalStore, WOS } from "@/app/store/global";
import { fireAndForget } from "@/util/util"; import { fireAndForget } from "@/util/util";
import useResizeObserver from "@react-hook/resize-observer";
import { Atom, useAtomValue } from "jotai"; import { Atom, useAtomValue } from "jotai";
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useState } from "react"; import { CSSProperties, useCallback, useEffect, useLayoutEffect, useState } from "react";
import { debounce } from "throttle-debounce"; import { debounce } from "throttle-debounce";
@ -36,8 +36,8 @@ export function getLayoutModelForTabById(tabId: string) {
return getLayoutModelForTab(tabAtom); return getLayoutModelForTab(tabAtom);
} }
export function getLayoutModelForActiveTab() { export function getLayoutModelForStaticTab() {
const tabId = globalStore.get(atoms.activeTabId); const tabId = globalStore.get(atoms.staticTabId);
return getLayoutModelForTabById(tabId); return getLayoutModelForTabById(tabId);
} }
@ -53,7 +53,8 @@ export function useTileLayout(tabAtom: Atom<Tab>, tileContent: TileLayoutContent
// Use tab data to ensure we can reload if the tab is disposed and remade (such as during Hot Module Reloading) // Use tab data to ensure we can reload if the tab is disposed and remade (such as during Hot Module Reloading)
useAtomValue(tabAtom); useAtomValue(tabAtom);
const layoutModel = useLayoutModel(tabAtom); const layoutModel = useLayoutModel(tabAtom);
useResizeObserver(layoutModel?.displayContainerRef, layoutModel?.onContainerResize);
useOnResize(layoutModel?.displayContainerRef, layoutModel?.onContainerResize);
// Once the TileLayout is mounted, re-run the state update to get all the nodes to flow in the layout. // Once the TileLayout is mounted, re-run the state update to get all the nodes to flow in the layout.
useEffect(() => fireAndForget(async () => layoutModel.onTreeStateAtomUpdated(true)), []); useEffect(() => fireAndForget(async () => layoutModel.onTreeStateAtomUpdated(true)), []);

View File

@ -7,16 +7,15 @@ import type * as rxjs from "rxjs";
declare global { declare global {
type GlobalAtomsType = { type GlobalAtomsType = {
windowId: jotai.Atom<string>; // readonly
clientId: jotai.Atom<string>; // readonly clientId: jotai.Atom<string>; // readonly
client: jotai.Atom<Client>; // driven from WOS client: jotai.Atom<Client>; // driven from WOS
uiContext: jotai.Atom<UIContext>; // driven from windowId, activetabid, etc. uiContext: jotai.Atom<UIContext>; // driven from windowId, tabId
waveWindow: jotai.Atom<WaveWindow>; // driven from WOS waveWindow: jotai.Atom<WaveWindow>; // driven from WOS
workspace: jotai.Atom<Workspace>; // driven from WOS workspace: jotai.Atom<Workspace>; // driven from WOS
fullConfigAtom: jotai.PrimitiveAtom<FullConfigType>; // driven from WOS, settings -- updated via WebSocket fullConfigAtom: jotai.PrimitiveAtom<FullConfigType>; // driven from WOS, settings -- updated via WebSocket
settingsAtom: jotai.Atom<SettingsType>; // derrived from fullConfig settingsAtom: jotai.Atom<SettingsType>; // derrived from fullConfig
tabAtom: jotai.Atom<Tab>; // driven from WOS tabAtom: jotai.Atom<Tab>; // driven from WOS
activeTabId: jotai.Atom<string>; // derrived from windowDataAtom staticTabId: jotai.Atom<string>;
isFullScreen: jotai.PrimitiveAtom<boolean>; isFullScreen: jotai.PrimitiveAtom<boolean>;
controlShiftDelayAtom: jotai.PrimitiveAtom<boolean>; controlShiftDelayAtom: jotai.PrimitiveAtom<boolean>;
prefersReducedMotionAtom: jotai.Atom<boolean>; prefersReducedMotionAtom: jotai.Atom<boolean>;
@ -50,6 +49,13 @@ declare global {
blockId: string; blockId: string;
}; };
type WaveInitOpts = {
tabId: string;
clientId: string;
windowId: string;
activate: boolean;
};
type ElectronApi = { type ElectronApi = {
getAuthKey(): string; getAuthKey(): string;
getIsDev(): boolean; getIsDev(): boolean;
@ -58,6 +64,8 @@ declare global {
getEnv: (varName: string) => string; getEnv: (varName: string) => string;
getUserName: () => string; getUserName: () => string;
getHostName: () => string; getHostName: () => string;
getDataDir: () => string;
getConfigDir: () => string;
getWebviewPreload: () => string; getWebviewPreload: () => string;
getAboutModalDetails: () => AboutModalDetails; getAboutModalDetails: () => AboutModalDetails;
getDocsiteUrl: () => string; getDocsiteUrl: () => string;
@ -78,6 +86,12 @@ declare global {
setWebviewFocus: (focusedId: number) => void; // focusedId si the getWebContentsId of the webview setWebviewFocus: (focusedId: number) => void; // focusedId si the getWebContentsId of the webview
registerGlobalWebviewKeys: (keys: string[]) => void; registerGlobalWebviewKeys: (keys: string[]) => void;
onControlShiftStateUpdate: (callback: (state: boolean) => void) => void; onControlShiftStateUpdate: (callback: (state: boolean) => void) => void;
setActiveTab: (tabId: string) => void;
createTab: () => void;
closeTab: (tabId: string) => void;
setWindowInitStatus: (status: "ready" | "wave-ready") => void;
onWaveInit: (callback: (initOpts: WaveInitOpts) => void) => void;
sendLog: (log: string) => void;
onQuicklook: (filePath: string) => void; onQuicklook: (filePath: string) => void;
}; };
@ -263,6 +277,7 @@ declare global {
getSettingsMenuItems?: () => ContextMenuItem[]; getSettingsMenuItems?: () => ContextMenuItem[];
giveFocus?: () => boolean; giveFocus?: () => boolean;
keyDownHandler?: (e: WaveKeyboardEvent) => boolean; keyDownHandler?: (e: WaveKeyboardEvent) => boolean;
dispose?: () => void;
} }
type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error" | "installing"; type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error" | "installing";
@ -334,6 +349,27 @@ declare global {
msgFn: (msg: RpcMessage) => void; msgFn: (msg: RpcMessage) => void;
}; };
type WaveBrowserWindow = Electron.BaseWindow & {
waveWindowId: string;
waveReadyPromise: Promise<void>;
allTabViews: Map<string, WaveTabView>;
activeTabView: WaveTabView;
alreadyClosed: boolean;
};
type WaveTabView = Electron.WebContentsView & {
isActiveTab: boolean;
waveWindowId: string; // set when showing in an active window
waveTabId: string; // always set, WaveTabViews are unique per tab
lastUsedTs: number; // ts milliseconds
createdTs: number; // ts milliseconds
initPromise: Promise<void>;
savedInitOpts: WaveInitOpts;
waveReadyPromise: Promise<void>;
initResolve: () => void;
waveReadyResolve: () => void;
};
type TimeSeriesMeta = { type TimeSeriesMeta = {
name?: string; name?: string;
color?: string; color?: string;

View File

@ -7,9 +7,11 @@ declare global {
// waveobj.Block // waveobj.Block
type Block = WaveObj & { type Block = WaveObj & {
parentoref?: string;
blockdef: BlockDef; blockdef: BlockDef;
runtimeopts?: RuntimeOpts; runtimeopts?: RuntimeOpts;
stickers?: StickerType[]; stickers?: StickerType[];
subblockids?: string[];
}; };
// blockcontroller.BlockControllerRuntimeStatus // blockcontroller.BlockControllerRuntimeStatus
@ -30,7 +32,7 @@ declare global {
blockid: string; blockid: string;
tabid: string; tabid: string;
windowid: string; windowid: string;
meta: MetaType; block: Block;
}; };
// webcmd.BlockInputWSCommand // webcmd.BlockInputWSCommand
@ -47,6 +49,12 @@ declare global {
hasoldhistory?: boolean; hasoldhistory?: boolean;
}; };
// windowservice.CloseTabRtnType
type CloseTabRtnType = {
closewindow?: boolean;
newactivetabid?: string;
};
// wshrpc.CommandAppendIJsonData // wshrpc.CommandAppendIJsonData
type CommandAppendIJsonData = { type CommandAppendIJsonData = {
zoneid: string; zoneid: string;
@ -57,6 +65,7 @@ declare global {
// wshrpc.CommandAuthenticateRtnData // wshrpc.CommandAuthenticateRtnData
type CommandAuthenticateRtnData = { type CommandAuthenticateRtnData = {
routeid: string; routeid: string;
authtoken?: string;
}; };
// wshrpc.CommandBlockInputData // wshrpc.CommandBlockInputData
@ -89,11 +98,22 @@ declare global {
magnified?: boolean; magnified?: boolean;
}; };
// wshrpc.CommandCreateSubBlockData
type CommandCreateSubBlockData = {
parentblockid: string;
blockdef: BlockDef;
};
// wshrpc.CommandDeleteBlockData // wshrpc.CommandDeleteBlockData
type CommandDeleteBlockData = { type CommandDeleteBlockData = {
blockid: string; blockid: string;
}; };
// wshrpc.CommandDisposeData
type CommandDisposeData = {
routeid: string;
};
// wshrpc.CommandEventReadHistoryData // wshrpc.CommandEventReadHistoryData
type CommandEventReadHistoryData = { type CommandEventReadHistoryData = {
event: string; event: string;
@ -155,6 +175,12 @@ declare global {
meta: MetaType; meta: MetaType;
}; };
// wshrpc.CommandWaitForRouteData
type CommandWaitForRouteData = {
routeid: string;
waitms: number;
};
// wshrpc.CommandWebSelectorData // wshrpc.CommandWebSelectorData
type CommandWebSelectorData = { type CommandWebSelectorData = {
windowid: string; windowid: string;
@ -186,6 +212,16 @@ declare global {
count: number; count: number;
}; };
// vdom.DomRect
type DomRect = {
top: number;
left: number;
right: number;
bottom: number;
width: number;
height: number;
};
// waveobj.FileDef // waveobj.FileDef
type FileDef = { type FileDef = {
filetype?: string; filetype?: string;
@ -319,6 +355,12 @@ declare global {
"term:localshellpath"?: string; "term:localshellpath"?: string;
"term:localshellopts"?: string[]; "term:localshellopts"?: string[];
"term:scrollback"?: number; "term:scrollback"?: number;
"term:vdomblockid"?: string;
"vdom:*"?: boolean;
"vdom:initialized"?: boolean;
"vdom:correlationid"?: string;
"vdom:route"?: string;
"vdom:persist"?: boolean;
count?: number; count?: number;
}; };
@ -397,6 +439,7 @@ declare global {
resid?: string; resid?: string;
timeout?: number; timeout?: number;
route?: string; route?: string;
authtoken?: string;
source?: string; source?: string;
cont?: boolean; cont?: boolean;
cancel?: boolean; cancel?: boolean;
@ -473,6 +516,7 @@ declare global {
"window:showmenubar"?: boolean; "window:showmenubar"?: boolean;
"window:nativetitlebar"?: boolean; "window:nativetitlebar"?: boolean;
"window:disablehardwareacceleration"?: boolean; "window:disablehardwareacceleration"?: boolean;
"window:maxtabcachesize"?: number;
"telemetry:*"?: boolean; "telemetry:*"?: boolean;
"telemetry:enabled"?: boolean; "telemetry:enabled"?: boolean;
"conn:*"?: boolean; "conn:*"?: boolean;
@ -582,27 +626,155 @@ declare global {
checkboxstat?: boolean; checkboxstat?: boolean;
}; };
// vdom.Elem // vdom.VDomAsyncInitiationRequest
type VDomAsyncInitiationRequest = {
type: "asyncinitiationrequest";
ts: number;
blockid?: string;
};
// vdom.VDomBackendOpts
type VDomBackendOpts = {
closeonctrlc?: boolean;
globalkeyboardevents?: boolean;
};
// vdom.VDomBackendUpdate
type VDomBackendUpdate = {
type: "backendupdate";
ts: number;
blockid: string;
opts?: VDomBackendOpts;
renderupdates?: VDomRenderUpdate[];
statesync?: VDomStateSync[];
refoperations?: VDomRefOperation[];
messages?: VDomMessage[];
};
// vdom.VDomBinding
type VDomBinding = {
type: "binding";
bind: string;
};
// vdom.VDomCreateContext
type VDomCreateContext = {
type: "createcontext";
ts: number;
meta?: MetaType;
target?: VDomTarget;
persist?: boolean;
};
// vdom.VDomElem
type VDomElem = { type VDomElem = {
id?: string; waveid?: string;
tag: string; tag: string;
props?: {[key: string]: any}; props?: {[key: string]: any};
children?: VDomElem[]; children?: VDomElem[];
text?: string; text?: string;
}; };
// vdom.VDomFuncType // vdom.VDomEvent
type VDomFuncType = { type VDomEvent = {
#func: string; waveid: string;
#stopPropagation?: boolean; eventtype: string;
#preventDefault?: boolean; eventdata: any;
#keys?: string[];
}; };
// vdom.VDomRefType // vdom.VDomFrontendUpdate
type VDomRefType = { type VDomFrontendUpdate = {
#ref: string; type: "frontendupdate";
current: any; ts: number;
blockid: string;
correlationid?: string;
dispose?: boolean;
resync?: boolean;
rendercontext?: VDomRenderContext;
events?: VDomEvent[];
statesync?: VDomStateSync[];
refupdates?: VDomRefUpdate[];
messages?: VDomMessage[];
};
// vdom.VDomFunc
type VDomFunc = {
type: "func";
stoppropagation?: boolean;
preventdefault?: boolean;
globalevent?: string;
keys?: string[];
};
// vdom.VDomMessage
type VDomMessage = {
messagetype: string;
message: string;
stacktrace?: string;
params?: any[];
};
// vdom.VDomRef
type VDomRef = {
type: "ref";
refid: string;
trackposition?: boolean;
position?: VDomRefPosition;
hascurrent?: boolean;
};
// vdom.VDomRefOperation
type VDomRefOperation = {
refid: string;
op: string;
params?: any[];
};
// vdom.VDomRefPosition
type VDomRefPosition = {
offsetheight: number;
offsetwidth: number;
scrollheight: number;
scrollwidth: number;
scrolltop: number;
boundingclientrect: DomRect;
};
// vdom.VDomRefUpdate
type VDomRefUpdate = {
refid: string;
hascurrent: boolean;
position?: VDomRefPosition;
};
// vdom.VDomRenderContext
type VDomRenderContext = {
blockid: string;
focused: boolean;
width: number;
height: number;
rootrefid: string;
background?: boolean;
};
// vdom.VDomRenderUpdate
type VDomRenderUpdate = {
updatetype: "root"|"append"|"replace"|"remove"|"insert";
waveid?: string;
vdom: VDomElem;
index?: number;
};
// vdom.VDomStateSync
type VDomStateSync = {
atom: string;
value: any;
};
// vdom.VDomTarget
type VDomTarget = {
newblock?: boolean;
magnified?: boolean;
}; };
type WSCommandType = { type WSCommandType = {

View File

@ -241,6 +241,56 @@ function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent {
return rtn; return rtn;
} }
const keyMap = {
Enter: "\r",
Backspace: "\x7f",
Tab: "\t",
Escape: "\x1b",
ArrowUp: "\x1b[A",
ArrowDown: "\x1b[B",
ArrowRight: "\x1b[C",
ArrowLeft: "\x1b[D",
Insert: "\x1b[2~",
Delete: "\x1b[3~",
Home: "\x1b[1~",
End: "\x1b[4~",
PageUp: "\x1b[5~",
PageDown: "\x1b[6~",
};
function keyboardEventToASCII(event: WaveKeyboardEvent): string {
// check modifiers
// if no modifiers are set, just send the key
if (!event.alt && !event.control && !event.meta) {
if (event.key == null || event.key == "") {
return "";
}
if (keyMap[event.key] != null) {
return keyMap[event.key];
}
if (event.key.length == 1) {
return event.key;
} else {
console.log("not sending keyboard event", event.key, event);
}
}
// if meta or alt is set, there is no ASCII representation
if (event.meta || event.alt) {
return "";
}
// if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value
if (event.control) {
if (
(event.key.length === 1 && event.key >= "A" && event.key <= "Z") ||
(event.key >= "a" && event.key <= "z")
) {
const key = event.key.toUpperCase();
return String.fromCharCode(key.charCodeAt(0) - 64);
}
}
return "";
}
export { export {
adaptFromElectronKeyEvent, adaptFromElectronKeyEvent,
adaptFromReactOrNativeKeyEvent, adaptFromReactOrNativeKeyEvent,
@ -248,6 +298,7 @@ export {
getKeyUtilPlatform, getKeyUtilPlatform,
isCharacterKeyEvent, isCharacterKeyEvent,
isInputEvent, isInputEvent,
keyboardEventToASCII,
keydownWrapper, keydownWrapper,
parseKeyDescription, parseKeyDescription,
setKeyUtilPlatform, setKeyUtilPlatform,

View File

@ -8,11 +8,11 @@ import {
registerGlobalKeys, registerGlobalKeys,
} from "@/app/store/keymodel"; } from "@/app/store/keymodel";
import { modalsModel } from "@/app/store/modalmodel"; import { modalsModel } from "@/app/store/modalmodel";
import { FileService, ObjectService } from "@/app/store/services"; import { FileService } from "@/app/store/services";
import { RpcApi } from "@/app/store/wshclientapi"; import { RpcApi } from "@/app/store/wshclientapi";
import { initWshrpc, WindowRpcClient } from "@/app/store/wshrpcutil"; import { initWshrpc, TabRpcClient } from "@/app/store/wshrpcutil";
import { loadMonaco } from "@/app/view/codeeditor/codeeditor"; import { loadMonaco } from "@/app/view/codeeditor/codeeditor";
import { getLayoutModelForActiveTab } from "@/layout/index"; import { getLayoutModelForStaticTab } from "@/layout/index";
import { import {
atoms, atoms,
countersClear, countersClear,
@ -32,18 +32,9 @@ import { createElement } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
const platform = getApi().getPlatform(); const platform = getApi().getPlatform();
const urlParams = new URLSearchParams(window.location.search); document.title = `Wave Terminal`;
const windowId = urlParams.get("windowid"); let savedInitOpts: WaveInitOpts = null;
const clientId = urlParams.get("clientid");
console.log("Wave Starting");
console.log("clientid", clientId, "windowid", windowId);
initGlobal({ clientId, windowId, platform, environment: "renderer" });
setKeyUtilPlatform(platform);
loadFonts();
(window as any).WOS = WOS; (window as any).WOS = WOS;
(window as any).globalStore = globalStore; (window as any).globalStore = globalStore;
(window as any).globalAtoms = atoms; (window as any).globalAtoms = atoms;
@ -51,29 +42,109 @@ loadFonts();
(window as any).isFullScreen = false; (window as any).isFullScreen = false;
(window as any).countersPrint = countersPrint; (window as any).countersPrint = countersPrint;
(window as any).countersClear = countersClear; (window as any).countersClear = countersClear;
(window as any).getLayoutModelForActiveTab = getLayoutModelForActiveTab; (window as any).getLayoutModelForStaticTab = getLayoutModelForStaticTab;
(window as any).pushFlashError = pushFlashError; (window as any).pushFlashError = pushFlashError;
(window as any).modalsModel = modalsModel; (window as any).modalsModel = modalsModel;
document.title = `Wave (${windowId.substring(0, 8)})`; async function initBare() {
getApi().sendLog("Init Bare");
document.body.style.visibility = "hidden";
document.body.style.opacity = "0";
document.body.classList.add("is-transparent");
getApi().onWaveInit(initWaveWrap);
setKeyUtilPlatform(platform);
loadFonts();
document.fonts.ready.then(() => {
console.log("Init Bare Done");
getApi().setWindowInitStatus("ready");
});
}
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", initBare);
console.log("DOMContentLoaded");
async function initWaveWrap(initOpts: WaveInitOpts) {
try {
if (savedInitOpts) {
await reinitWave();
return;
}
savedInitOpts = initOpts;
await initWave(initOpts);
} catch (e) {
getApi().sendLog("Error in initWave " + e.message);
console.error("Error in initWave", e);
} finally {
document.body.style.visibility = null;
document.body.style.opacity = null;
document.body.classList.remove("is-transparent");
}
}
async function reinitWave() {
console.log("Reinit Wave");
getApi().sendLog("Reinit Wave");
const client = await WOS.reloadWaveObject<Client>(WOS.makeORef("client", savedInitOpts.clientId));
const waveWindow = await WOS.reloadWaveObject<WaveWindow>(WOS.makeORef("window", savedInitOpts.windowId));
await WOS.reloadWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));
const initialTab = await WOS.reloadWaveObject<Tab>(WOS.makeORef("tab", savedInitOpts.tabId));
await WOS.reloadWaveObject<LayoutState>(WOS.makeORef("layout", initialTab.layoutstate));
document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change
getApi().setWindowInitStatus("wave-ready");
}
function loadAllWorkspaceTabs(ws: Workspace) {
if (ws == null || ws.tabids == null) {
return;
}
ws.tabids.forEach((tabid) => {
WOS.getObjectValue<Tab>(WOS.makeORef("tab", tabid));
});
}
async function initWave(initOpts: WaveInitOpts) {
getApi().sendLog("Init Wave " + JSON.stringify(initOpts));
console.log(
"Wave Init",
"tabid",
initOpts.tabId,
"clientid",
initOpts.clientId,
"windowid",
initOpts.windowId,
"platform",
platform
);
initGlobal({
tabId: initOpts.tabId,
clientId: initOpts.clientId,
windowId: initOpts.windowId,
platform,
environment: "renderer",
});
(window as any).globalAtoms = atoms;
// Init WPS event handlers // Init WPS event handlers
const globalWS = initWshrpc(windowId); const globalWS = initWshrpc(initOpts.tabId);
(window as any).globalWS = globalWS; (window as any).globalWS = globalWS;
(window as any).WindowRpcClient = WindowRpcClient; (window as any).TabRpcClient = TabRpcClient;
await loadConnStatus(); await loadConnStatus();
initGlobalWaveEventSubs(); initGlobalWaveEventSubs();
subscribeToConnEvents(); subscribeToConnEvents();
// ensures client/window/workspace are loaded into the cache before rendering // ensures client/window/workspace are loaded into the cache before rendering
const client = await WOS.loadAndPinWaveObject<Client>(WOS.makeORef("client", clientId)); const [client, waveWindow, initialTab] = await Promise.all([
const waveWindow = await WOS.loadAndPinWaveObject<WaveWindow>(WOS.makeORef("window", windowId)); WOS.loadAndPinWaveObject<Client>(WOS.makeORef("client", initOpts.clientId)),
await WOS.loadAndPinWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid)); WOS.loadAndPinWaveObject<WaveWindow>(WOS.makeORef("window", initOpts.windowId)),
const initialTab = await WOS.loadAndPinWaveObject<Tab>(WOS.makeORef("tab", waveWindow.activetabid)); WOS.loadAndPinWaveObject<Tab>(WOS.makeORef("tab", initOpts.tabId)),
await WOS.loadAndPinWaveObject<LayoutState>(WOS.makeORef("layout", initialTab.layoutstate)); ]);
const [ws, layoutState] = await Promise.all([
WOS.loadAndPinWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid)),
WOS.reloadWaveObject<LayoutState>(WOS.makeORef("layout", initialTab.layoutstate)),
]);
loadAllWorkspaceTabs(ws);
WOS.wpsSubscribeToObject(WOS.makeORef("workspace", waveWindow.workspaceid));
document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change
registerGlobalKeys(); registerGlobalKeys();
registerElectronReinjectKeyHandler(); registerElectronReinjectKeyHandler();
@ -82,15 +153,16 @@ document.addEventListener("DOMContentLoaded", async () => {
const fullConfig = await FileService.GetFullConfig(); const fullConfig = await FileService.GetFullConfig();
console.log("fullconfig", fullConfig); console.log("fullconfig", fullConfig);
globalStore.set(atoms.fullConfigAtom, fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig);
const prtn = ObjectService.SetActiveTab(waveWindow.activetabid); // no need to wait console.log("Wave First Render");
prtn.catch((e) => { let firstRenderResolveFn: () => void = null;
console.log("error on initial SetActiveTab", e); let firstRenderPromise = new Promise<void>((resolve) => {
firstRenderResolveFn = resolve;
}); });
const reactElem = createElement(App, null, null); const reactElem = createElement(App, { onFirstRender: firstRenderResolveFn }, null);
const elem = document.getElementById("main"); const elem = document.getElementById("main");
const root = createRoot(elem); const root = createRoot(elem);
document.fonts.ready.then(() => {
console.log("Wave First Render");
root.render(reactElem); root.render(reactElem);
}); await firstRenderPromise;
}); console.log("Wave First Render Done");
getApi().setWindowInitStatus("wave-ready");
}

3
go.mod
View File

@ -21,6 +21,7 @@ require (
github.com/shirou/gopsutil/v4 v4.24.9 github.com/shirou/gopsutil/v4 v4.24.9
github.com/skeema/knownhosts v1.3.0 github.com/skeema/knownhosts v1.3.0
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b
github.com/wavetermdev/htmltoken v0.1.0 github.com/wavetermdev/htmltoken v0.1.0
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.28.0
golang.org/x/sys v0.26.0 golang.org/x/sys v0.26.0
@ -36,9 +37,11 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect github.com/tklauser/numcpus v0.6.1 // indirect
github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/atomic v1.7.0 // indirect go.uber.org/atomic v1.7.0 // indirect
golang.org/x/net v0.29.0 // indirect golang.org/x/net v0.29.0 // indirect

11
go.sum
View File

@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/0xrawsec/golang-utils v1.3.2 h1:ww4jrtHRSnX9xrGzJYbalx5nXoZewy4zPxiY+ubJgtg=
github.com/0xrawsec/golang-utils v1.3.2/go.mod h1:m7AzHXgdSAkFCD9tWWsApxNVxMlyy7anpPVOyT/yM7E=
github.com/alexflint/go-filemutex v1.3.0 h1:LgE+nTUWnQCyRKbpoceKZsPQbs84LivvgwUymZXdOcM= github.com/alexflint/go-filemutex v1.3.0 h1:LgE+nTUWnQCyRKbpoceKZsPQbs84LivvgwUymZXdOcM=
github.com/alexflint/go-filemutex v1.3.0/go.mod h1:U0+VA/i30mGBlLCrFPGtTe9y6wGQfNAWPBTekHQ+c8A= github.com/alexflint/go-filemutex v1.3.0/go.mod h1:U0+VA/i30mGBlLCrFPGtTe9y6wGQfNAWPBTekHQ+c8A=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@ -62,6 +64,8 @@ github.com/sawka/txwrap v0.2.0 h1:V3LfvKVLULxcYSxdMguLwFyQFMEU9nFDJopg0ZkL+94=
github.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA= github.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA=
github.com/shirou/gopsutil/v4 v4.24.9 h1:KIV+/HaHD5ka5f570RZq+2SaeFsb/pq+fp2DGNWYoOI= github.com/shirou/gopsutil/v4 v4.24.9 h1:KIV+/HaHD5ka5f570RZq+2SaeFsb/pq+fp2DGNWYoOI=
github.com/shirou/gopsutil/v4 v4.24.9/go.mod h1:3fkaHNeYsUFCGZ8+9vZVWtbyM1k2eRnlL+bWO8Bxa/Q= github.com/shirou/gopsutil/v4 v4.24.9/go.mod h1:3fkaHNeYsUFCGZ8+9vZVWtbyM1k2eRnlL+bWO8Bxa/Q=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY=
github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
@ -71,12 +75,17 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 h1:XQpsQG5lqRJlx4mUVHcJvyyc1rdTI9nHvwrdfcuy8aM=
github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117/go.mod h1:mx0TjbqsaDD9DUT5gA1s3hw47U6RIbbIBfvGzR85K0g=
github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b h1:wFBKF5k5xbJQU8bYgcSoQ/ScvmYyq6KHUabAuVUjOWM=
github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b/go.mod h1:N1CYNinssZru+ikvYTgVbVeSi21thHUTCoJ9xMvWe+s=
github.com/wavetermdev/htmltoken v0.1.0 h1:RMdA9zTfnYa5jRC4RRG3XNoV5NOP8EDxpaVPjuVz//Q= github.com/wavetermdev/htmltoken v0.1.0 h1:RMdA9zTfnYa5jRC4RRG3XNoV5NOP8EDxpaVPjuVz//Q=
github.com/wavetermdev/htmltoken v0.1.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= github.com/wavetermdev/htmltoken v0.1.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk=
github.com/wavetermdev/ssh_config v0.0.0-20240306041034-17e2087ebde2 h1:onqZrJVap1sm15AiIGTfWzdr6cEF0KdtddeuuOVhzyY= github.com/wavetermdev/ssh_config v0.0.0-20240306041034-17e2087ebde2 h1:onqZrJVap1sm15AiIGTfWzdr6cEF0KdtddeuuOVhzyY=
@ -91,6 +100,7 @@ golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -102,5 +112,6 @@ golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color scheme" content="light dark" />
<title>Wave</title> <title>Wave</title>
<link rel="stylesheet" href="/fontawesome/css/fontawesome.min.css" /> <link rel="stylesheet" href="/fontawesome/css/fontawesome.min.css" />
<link rel="stylesheet" href="/fontawesome/css/brands.min.css" /> <link rel="stylesheet" href="/fontawesome/css/brands.min.css" />
@ -11,7 +12,7 @@
<link rel="stylesheet" href="/fontawesome/css/sharp-regular.min.css" /> <link rel="stylesheet" href="/fontawesome/css/sharp-regular.min.css" />
<script type="module" src="frontend/wave.ts"></script> <script type="module" src="frontend/wave.ts"></script>
</head> </head>
<body> <body class="init">
<div id="main"></div> <div id="main"></div>
</body> </body>
</html> </html>

View File

@ -102,6 +102,7 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"debug": "^4.3.7", "debug": "^4.3.7",
"electron-updater": "6.3.9", "electron-updater": "6.3.9",
"env-paths": "^3.0.0",
"fast-average-color": "^9.4.0", "fast-average-color": "^9.4.0",
"htl": "^0.3.1", "htl": "^0.3.1",
"html-to-image": "^1.11.11", "html-to-image": "^1.11.11",

View File

@ -11,6 +11,7 @@ import (
"io" "io"
"io/fs" "io/fs"
"log" "log"
"strings"
"sync" "sync"
"time" "time"
@ -24,6 +25,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wshutil"
"github.com/wavetermdev/waveterm/pkg/wsl"
"github.com/wavetermdev/waveterm/pkg/wstore" "github.com/wavetermdev/waveterm/pkg/wstore"
) )
@ -34,7 +36,7 @@ const (
const ( const (
BlockFile_Term = "term" // used for main pty output BlockFile_Term = "term" // used for main pty output
BlockFile_Html = "html" // used for alt html layout BlockFile_VDom = "vdom" // used for alt html layout
) )
const ( const (
@ -262,7 +264,30 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
return fmt.Errorf("unknown controller type %q", bc.ControllerType) return fmt.Errorf("unknown controller type %q", bc.ControllerType)
} }
var shellProc *shellexec.ShellProc var shellProc *shellexec.ShellProc
if remoteName != "" { if strings.HasPrefix(remoteName, "wsl://") {
wslName := strings.TrimPrefix(remoteName, "wsl://")
credentialCtx, cancelFunc := context.WithTimeout(context.Background(), 60*time.Second)
defer cancelFunc()
wslConn := wsl.GetWslConn(credentialCtx, wslName, false)
connStatus := wslConn.DeriveConnStatus()
if connStatus.Status != conncontroller.Status_Connected {
return fmt.Errorf("not connected, cannot start shellproc")
}
// create jwt
if !blockMeta.GetBool(waveobj.MetaKey_CmdNoWsh, false) {
jwtStr, err := wshutil.MakeClientJWTToken(wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId, Conn: wslConn.GetName()}, wslConn.GetDomainSocketName())
if err != nil {
return fmt.Errorf("error making jwt token: %w", err)
}
cmdOpts.Env[wshutil.WaveJwtTokenVarName] = jwtStr
}
shellProc, err = shellexec.StartWslShellProc(ctx, rc.TermSize, cmdStr, cmdOpts, wslConn)
if err != nil {
return err
}
} else if remoteName != "" {
credentialCtx, cancelFunc := context.WithTimeout(context.Background(), 60*time.Second) credentialCtx, cancelFunc := context.WithTimeout(context.Background(), 60*time.Second)
defer cancelFunc() defer cancelFunc()
@ -325,7 +350,7 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
// we don't need to authenticate this wshProxy since it is coming direct // we don't need to authenticate this wshProxy since it is coming direct
wshProxy := wshutil.MakeRpcProxy() wshProxy := wshutil.MakeRpcProxy()
wshProxy.SetRpcContext(&wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId}) wshProxy.SetRpcContext(&wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId})
wshutil.DefaultRouter.RegisterRoute(wshutil.MakeControllerRouteId(bc.BlockId), wshProxy) wshutil.DefaultRouter.RegisterRoute(wshutil.MakeControllerRouteId(bc.BlockId), wshProxy, true)
ptyBuffer := wshutil.MakePtyBuffer(wshutil.WaveOSCPrefix, bc.ShellProc.Cmd, wshProxy.FromRemoteCh) ptyBuffer := wshutil.MakePtyBuffer(wshutil.WaveOSCPrefix, bc.ShellProc.Cmd, wshProxy.FromRemoteCh)
go func() { go func() {
// handles regular output from the pty (goes to the blockfile and xterm) // handles regular output from the pty (goes to the blockfile and xterm)
@ -359,9 +384,6 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
} }
}() }()
go func() { go func() {
defer func() {
log.Printf("[shellproc] shellInputCh loop done\n")
}()
// handles input from the shellInputCh, sent to pty // handles input from the shellInputCh, sent to pty
// use shellInputCh instead of bc.ShellInputCh (because we want to be attached to *this* ch. bc.ShellInputCh can be updated) // use shellInputCh instead of bc.ShellInputCh (because we want to be attached to *this* ch. bc.ShellInputCh can be updated)
for ic := range shellInputCh { for ic := range shellInputCh {
@ -497,6 +519,15 @@ func CheckConnStatus(blockId string) error {
if connName == "" { if connName == "" {
return nil return nil
} }
if strings.HasPrefix(connName, "wsl://") {
distroName := strings.TrimPrefix(connName, "wsl://")
conn := wsl.GetWslConn(context.Background(), distroName, false)
connStatus := conn.DeriveConnStatus()
if connStatus.Status != conncontroller.Status_Connected {
return fmt.Errorf("not connected: %s", connStatus.Status)
}
return nil
}
opts, err := remote.ParseOpts(connName) opts, err := remote.ParseOpts(connName)
if err != nil { if err != nil {
return fmt.Errorf("error parsing connection name: %w", err) return fmt.Errorf("error parsing connection name: %w", err)

View File

@ -10,8 +10,6 @@ import (
"os" "os"
"sync" "sync"
"time" "time"
"github.com/wavetermdev/waveterm/pkg/waveobj"
) )
const ( const (
@ -28,20 +26,18 @@ type WSEventType struct {
type WindowWatchData struct { type WindowWatchData struct {
WindowWSCh chan any WindowWSCh chan any
WaveWindowId string TabId string
WatchedORefs map[waveobj.ORef]bool
} }
var globalLock = &sync.Mutex{} var globalLock = &sync.Mutex{}
var wsMap = make(map[string]*WindowWatchData) // websocketid => WindowWatchData var wsMap = make(map[string]*WindowWatchData) // websocketid => WindowWatchData
func RegisterWSChannel(connId string, windowId string, ch chan any) { func RegisterWSChannel(connId string, tabId string, ch chan any) {
globalLock.Lock() globalLock.Lock()
defer globalLock.Unlock() defer globalLock.Unlock()
wsMap[connId] = &WindowWatchData{ wsMap[connId] = &WindowWatchData{
WindowWSCh: ch, WindowWSCh: ch,
WaveWindowId: windowId, TabId: tabId,
WatchedORefs: make(map[waveobj.ORef]bool),
} }
} }
@ -56,7 +52,7 @@ func getWindowWatchesForWindowId(windowId string) []*WindowWatchData {
defer globalLock.Unlock() defer globalLock.Unlock()
var watches []*WindowWatchData var watches []*WindowWatchData
for _, wdata := range wsMap { for _, wdata := range wsMap {
if wdata.WaveWindowId == windowId { if wdata.TabId == windowId {
watches = append(watches, wdata) watches = append(watches, wdata)
} }
} }

View File

@ -50,7 +50,7 @@ func InitFilestore() error {
} }
func GetDBName() string { func GetDBName() string {
waveHome := wavebase.GetWaveHomeDir() waveHome := wavebase.GetWaveDataDir()
return filepath.Join(waveHome, wavebase.WaveDBDir, FilestoreDBName) return filepath.Join(waveHome, wavebase.WaveDBDir, FilestoreDBName)
} }

View File

@ -1,3 +1,6 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package remote package remote
import ( import (

View File

@ -38,7 +38,14 @@ func (bs *BlockService) GetControllerStatus(ctx context.Context, blockId string)
return bc.GetRuntimeStatus(), nil return bc.GetRuntimeStatus(), nil
} }
func (bs *BlockService) SaveTerminalState(ctx context.Context, blockId string, state string, stateType string, ptyOffset int64) error { func (*BlockService) SaveTerminalState_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{
Desc: "save the terminal state to a blockfile",
ArgNames: []string{"ctx", "blockId", "state", "stateType", "ptyOffset", "termSize"},
}
}
func (bs *BlockService) SaveTerminalState(ctx context.Context, blockId string, state string, stateType string, ptyOffset int64, termSize waveobj.TermSize) error {
_, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) _, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)
if err != nil { if err != nil {
return err return err
@ -52,7 +59,11 @@ func (bs *BlockService) SaveTerminalState(ctx context.Context, blockId string, s
if err != nil { if err != nil {
return fmt.Errorf("cannot save terminal state: %w", err) return fmt.Errorf("cannot save terminal state: %w", err)
} }
err = filestore.WFS.WriteMeta(ctx, blockId, "cache:term:"+stateType, filestore.FileMeta{"ptyoffset": ptyOffset}, true) fileMeta := filestore.FileMeta{
"ptyoffset": ptyOffset,
"termsize": termSize,
}
err = filestore.WFS.WriteMeta(ctx, blockId, "cache:term:"+stateType, fileMeta, true)
if err != nil { if err != nil {
return fmt.Errorf("cannot save terminal state meta: %w", err) return fmt.Errorf("cannot save terminal state meta: %w", err)
} }

View File

@ -17,6 +17,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wcore"
"github.com/wavetermdev/waveterm/pkg/wlayout" "github.com/wavetermdev/waveterm/pkg/wlayout"
"github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wsl"
"github.com/wavetermdev/waveterm/pkg/wstore" "github.com/wavetermdev/waveterm/pkg/wstore"
) )
@ -77,7 +78,9 @@ func (cs *ClientService) MakeWindow(ctx context.Context) (*waveobj.Window, error
} }
func (cs *ClientService) GetAllConnStatus(ctx context.Context) ([]wshrpc.ConnStatus, error) { func (cs *ClientService) GetAllConnStatus(ctx context.Context) ([]wshrpc.ConnStatus, error) {
return conncontroller.GetAllConnStatus(), nil sshStatuses := conncontroller.GetAllConnStatus()
wslStatuses := wsl.GetAllConnStatus()
return append(sshStatuses, wslStatuses...), nil
} }
// moves the window to the front of the windowId stack // moves the window to the front of the windowId stack

View File

@ -13,6 +13,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wcore"
"github.com/wavetermdev/waveterm/pkg/wlayout" "github.com/wavetermdev/waveterm/pkg/wlayout"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wstore" "github.com/wavetermdev/waveterm/pkg/wstore"
) )
@ -74,25 +75,28 @@ func (svc *ObjectService) GetObjects(orefStrArr []string) ([]waveobj.WaveObj, er
func (svc *ObjectService) AddTabToWorkspace_Meta() tsgenmeta.MethodMeta { func (svc *ObjectService) AddTabToWorkspace_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{ return tsgenmeta.MethodMeta{
ArgNames: []string{"uiContext", "tabName", "activateTab"}, ArgNames: []string{"windowId", "tabName", "activateTab"},
ReturnDesc: "tabId", ReturnDesc: "tabId",
} }
} }
func (svc *ObjectService) AddTabToWorkspace(uiContext waveobj.UIContext, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) { func (svc *ObjectService) AddTabToWorkspace(windowId string, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) {
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn() defer cancelFn()
ctx = waveobj.ContextWithUpdates(ctx) ctx = waveobj.ContextWithUpdates(ctx)
tabId, err := wcore.CreateTab(ctx, uiContext.WindowId, tabName, activateTab) tabId, err := wcore.CreateTab(ctx, windowId, tabName, activateTab)
if err != nil { if err != nil {
return "", nil, fmt.Errorf("error creating tab: %w", err) return "", nil, fmt.Errorf("error creating tab: %w", err)
} }
err = wlayout.ApplyPortableLayout(ctx, tabId, wlayout.GetNewTabLayout()) err = wlayout.ApplyPortableLayout(ctx, tabId, wlayout.GetNewTabLayout())
if err != nil { if err != nil {
return "", nil, fmt.Errorf("error applying new tab layout: %w", err) return "", nil, fmt.Errorf("error applying new tab layout: %w", err)
} }
return tabId, waveobj.ContextGetUpdatesRtn(ctx), nil updates := waveobj.ContextGetUpdatesRtn(ctx)
go func() {
wps.Broker.SendUpdateEvents(updates)
}()
return tabId, updates, nil
} }
func (svc *ObjectService) UpdateWorkspaceTabIds_Meta() tsgenmeta.MethodMeta { func (svc *ObjectService) UpdateWorkspaceTabIds_Meta() tsgenmeta.MethodMeta {
@ -118,11 +122,11 @@ func (svc *ObjectService) SetActiveTab_Meta() tsgenmeta.MethodMeta {
} }
} }
func (svc *ObjectService) SetActiveTab(uiContext waveobj.UIContext, tabId string) (waveobj.UpdatesRtnType, error) { func (svc *ObjectService) SetActiveTab(windowId string, tabId string) (waveobj.UpdatesRtnType, error) {
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn() defer cancelFn()
ctx = waveobj.ContextWithUpdates(ctx) ctx = waveobj.ContextWithUpdates(ctx)
err := wstore.SetActiveTab(ctx, uiContext.WindowId, tabId) err := wstore.SetActiveTab(ctx, windowId, tabId)
if err != nil { if err != nil {
return nil, fmt.Errorf("error setting active tab: %w", err) return nil, fmt.Errorf("error setting active tab: %w", err)
} }
@ -137,9 +141,14 @@ func (svc *ObjectService) SetActiveTab(uiContext waveobj.UIContext, tabId string
return nil, fmt.Errorf("error getting tab blocks: %w", err) return nil, fmt.Errorf("error getting tab blocks: %w", err)
} }
updates := waveobj.ContextGetUpdatesRtn(ctx) updates := waveobj.ContextGetUpdatesRtn(ctx)
updates = append(updates, waveobj.MakeUpdate(tab)) go func() {
updates = append(updates, waveobj.MakeUpdates(blocks)...) wps.Broker.SendUpdateEvents(updates)
return updates, nil }()
var extraUpdates waveobj.UpdatesRtnType
extraUpdates = append(extraUpdates, updates...)
extraUpdates = append(extraUpdates, waveobj.MakeUpdate(tab))
extraUpdates = append(extraUpdates, waveobj.MakeUpdates(blocks)...)
return extraUpdates, nil
} }
func (svc *ObjectService) UpdateTabName_Meta() tsgenmeta.MethodMeta { func (svc *ObjectService) UpdateTabName_Meta() tsgenmeta.MethodMeta {
@ -192,7 +201,7 @@ func (svc *ObjectService) DeleteBlock(uiContext waveobj.UIContext, blockId strin
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn() defer cancelFn()
ctx = waveobj.ContextWithUpdates(ctx) ctx = waveobj.ContextWithUpdates(ctx)
err := wcore.DeleteBlock(ctx, uiContext.ActiveTabId, blockId) err := wcore.DeleteBlock(ctx, blockId)
if err != nil { if err != nil {
return nil, fmt.Errorf("error deleting block: %w", err) return nil, fmt.Errorf("error deleting block: %w", err)
} }

View File

@ -16,6 +16,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wcore"
"github.com/wavetermdev/waveterm/pkg/wlayout" "github.com/wavetermdev/waveterm/pkg/wlayout"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wstore" "github.com/wavetermdev/waveterm/pkg/wstore"
) )
@ -46,19 +47,25 @@ func (ws *WindowService) SetWindowPosAndSize(ctx context.Context, windowId strin
return waveobj.ContextGetUpdatesRtn(ctx), nil return waveobj.ContextGetUpdatesRtn(ctx), nil
} }
func (svc *WindowService) CloseTab(ctx context.Context, uiContext waveobj.UIContext, tabId string) (waveobj.UpdatesRtnType, error) { type CloseTabRtnType struct {
CloseWindow bool `json:"closewindow,omitempty"`
NewActiveTabId string `json:"newactivetabid,omitempty"`
}
// returns the new active tabid
func (svc *WindowService) CloseTab(ctx context.Context, windowId string, tabId string, fromElectron bool) (*CloseTabRtnType, waveobj.UpdatesRtnType, error) {
ctx = waveobj.ContextWithUpdates(ctx) ctx = waveobj.ContextWithUpdates(ctx)
window, err := wstore.DBMustGet[*waveobj.Window](ctx, uiContext.WindowId) window, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId)
if err != nil { if err != nil {
return nil, fmt.Errorf("error getting window: %w", err) return nil, nil, fmt.Errorf("error getting window: %w", err)
} }
tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId) tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId)
if err != nil { if err != nil {
return nil, fmt.Errorf("error getting tab: %w", err) return nil, nil, fmt.Errorf("error getting tab: %w", err)
} }
ws, err := wstore.DBMustGet[*waveobj.Workspace](ctx, window.WorkspaceId) ws, err := wstore.DBMustGet[*waveobj.Workspace](ctx, window.WorkspaceId)
if err != nil { if err != nil {
return nil, fmt.Errorf("error getting workspace: %w", err) return nil, nil, fmt.Errorf("error getting workspace: %w", err)
} }
tabIndex := -1 tabIndex := -1
for i, id := range ws.TabIds { for i, id := range ws.TabIds {
@ -73,26 +80,36 @@ func (svc *WindowService) CloseTab(ctx context.Context, uiContext waveobj.UICont
} }
}() }()
if err := wcore.DeleteTab(ctx, window.WorkspaceId, tabId); err != nil { if err := wcore.DeleteTab(ctx, window.WorkspaceId, tabId); err != nil {
return nil, fmt.Errorf("error closing tab: %w", err) return nil, nil, fmt.Errorf("error closing tab: %w", err)
} }
rtn := &CloseTabRtnType{}
if window.ActiveTabId == tabId && tabIndex != -1 { if window.ActiveTabId == tabId && tabIndex != -1 {
if len(ws.TabIds) == 1 { if len(ws.TabIds) == 1 {
svc.CloseWindow(ctx, uiContext.WindowId) rtn.CloseWindow = true
svc.CloseWindow(ctx, windowId, fromElectron)
if !fromElectron {
eventbus.SendEventToElectron(eventbus.WSEventType{ eventbus.SendEventToElectron(eventbus.WSEventType{
EventType: eventbus.WSEvent_ElectronCloseWindow, EventType: eventbus.WSEvent_ElectronCloseWindow,
Data: uiContext.WindowId, Data: windowId,
}) })
}
} else { } else {
if tabIndex < len(ws.TabIds)-1 { if tabIndex < len(ws.TabIds)-1 {
newActiveTabId := ws.TabIds[tabIndex+1] newActiveTabId := ws.TabIds[tabIndex+1]
wstore.SetActiveTab(ctx, uiContext.WindowId, newActiveTabId) wstore.SetActiveTab(ctx, windowId, newActiveTabId)
rtn.NewActiveTabId = newActiveTabId
} else { } else {
newActiveTabId := ws.TabIds[tabIndex-1] newActiveTabId := ws.TabIds[tabIndex-1]
wstore.SetActiveTab(ctx, uiContext.WindowId, newActiveTabId) wstore.SetActiveTab(ctx, windowId, newActiveTabId)
rtn.NewActiveTabId = newActiveTabId
} }
} }
} }
return waveobj.ContextGetUpdatesRtn(ctx), nil updates := waveobj.ContextGetUpdatesRtn(ctx)
go func() {
wps.Broker.SendUpdateEvents(updates)
}()
return rtn, updates, nil
} }
func (svc *WindowService) MoveBlockToNewWindow_Meta() tsgenmeta.MethodMeta { func (svc *WindowService) MoveBlockToNewWindow_Meta() tsgenmeta.MethodMeta {
@ -148,7 +165,7 @@ func (svc *WindowService) MoveBlockToNewWindow(ctx context.Context, currentTabId
return waveobj.ContextGetUpdatesRtn(ctx), nil return waveobj.ContextGetUpdatesRtn(ctx), nil
} }
func (svc *WindowService) CloseWindow(ctx context.Context, windowId string) error { func (svc *WindowService) CloseWindow(ctx context.Context, windowId string, fromElectron bool) error {
ctx = waveobj.ContextWithUpdates(ctx) ctx = waveobj.ContextWithUpdates(ctx)
window, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId) window, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId)
if err != nil { if err != nil {
@ -159,8 +176,7 @@ func (svc *WindowService) CloseWindow(ctx context.Context, windowId string) erro
return fmt.Errorf("error getting workspace: %w", err) return fmt.Errorf("error getting workspace: %w", err)
} }
for _, tabId := range workspace.TabIds { for _, tabId := range workspace.TabIds {
uiContext := waveobj.UIContext{WindowId: windowId} _, _, err := svc.CloseTab(ctx, windowId, tabId, fromElectron)
_, err := svc.CloseTab(ctx, uiContext, tabId)
if err != nil { if err != nil {
return fmt.Errorf("error closing tab: %w", err) return fmt.Errorf("error closing tab: %w", err)
} }

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/creack/pty" "github.com/creack/pty"
"github.com/wavetermdev/waveterm/pkg/wsl"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@ -129,3 +130,42 @@ func (sw SessionWrap) StderrPipe() (io.ReadCloser, error) {
func (sw SessionWrap) SetSize(h int, w int) error { func (sw SessionWrap) SetSize(h int, w int) error {
return sw.Session.WindowChange(h, w) return sw.Session.WindowChange(h, w)
} }
type WslCmdWrap struct {
*wsl.WslCmd
Tty pty.Tty
pty.Pty
}
func (wcw WslCmdWrap) Kill() {
wcw.Tty.Close()
wcw.Close()
}
func (wcw WslCmdWrap) KillGraceful(timeout time.Duration) {
process := wcw.WslCmd.GetProcess()
if process == nil {
return
}
processState := wcw.WslCmd.GetProcessState()
if processState != nil && processState.Exited() {
return
}
process.Signal(os.Interrupt)
go func() {
time.Sleep(timeout)
process := wcw.WslCmd.GetProcess()
processState := wcw.WslCmd.GetProcessState()
if processState == nil || !processState.Exited() {
process.Kill() // force kill if it is already not exited
}
}()
}
/**
* SetSize does nothing for WslCmdWrap as there
* is no pty to manage.
**/
func (wcw WslCmdWrap) SetSize(w int, h int) error {
return nil
}

View File

@ -5,6 +5,7 @@ package shellexec
import ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -25,6 +26,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wavebase"
"github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wshutil"
"github.com/wavetermdev/waveterm/pkg/wsl"
) )
const DefaultGracefulKillWait = 400 * time.Millisecond const DefaultGracefulKillWait = 400 * time.Millisecond
@ -141,6 +143,96 @@ func (pp *PipePty) WriteString(s string) (n int, err error) {
return pp.Write([]byte(s)) return pp.Write([]byte(s))
} }
func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *wsl.WslConn) (*ShellProc, error) {
client := conn.GetClient()
shellPath := cmdOpts.ShellPath
if shellPath == "" {
remoteShellPath, err := wsl.DetectShell(conn.Context, client)
if err != nil {
return nil, err
}
shellPath = remoteShellPath
}
var shellOpts []string
log.Printf("detected shell: %s", shellPath)
err := wsl.InstallClientRcFiles(conn.Context, client)
if err != nil {
log.Printf("error installing rc files: %v", err)
return nil, err
}
homeDir := wsl.GetHomeDir(conn.Context, client)
shellOpts = append(shellOpts, "~", "-d", client.Name())
if isZshShell(shellPath) {
shellOpts = append(shellOpts, fmt.Sprintf(`ZDOTDIR="%s/.waveterm/%s"`, homeDir, shellutil.ZshIntegrationDir))
}
var subShellOpts []string
if cmdStr == "" {
/* transform command in order to inject environment vars */
if isBashShell(shellPath) {
log.Printf("recognized as bash shell")
// add --rcfile
// cant set -l or -i with --rcfile
subShellOpts = append(subShellOpts, "--rcfile", fmt.Sprintf(`%s/.waveterm/%s/.bashrc`, homeDir, shellutil.BashIntegrationDir))
} else if isFishShell(shellPath) {
carg := fmt.Sprintf(`"set -x PATH \"%s\"/.waveterm/%s $PATH"`, homeDir, shellutil.WaveHomeBinDir)
subShellOpts = append(subShellOpts, "-C", carg)
} else if wsl.IsPowershell(shellPath) {
// powershell is weird about quoted path executables and requires an ampersand first
shellPath = "& " + shellPath
subShellOpts = append(subShellOpts, "-ExecutionPolicy", "Bypass", "-NoExit", "-File", homeDir+fmt.Sprintf("/.waveterm/%s/wavepwsh.ps1", shellutil.PwshIntegrationDir))
} else {
if cmdOpts.Login {
subShellOpts = append(subShellOpts, "-l")
}
if cmdOpts.Interactive {
subShellOpts = append(subShellOpts, "-i")
}
// can't set environment vars this way
// will try to do later if possible
}
} else {
shellPath = cmdStr
if cmdOpts.Login {
subShellOpts = append(subShellOpts, "-l")
}
if cmdOpts.Interactive {
subShellOpts = append(subShellOpts, "-i")
}
subShellOpts = append(subShellOpts, "-c", cmdStr)
}
jwtToken, ok := cmdOpts.Env[wshutil.WaveJwtTokenVarName]
if !ok {
return nil, fmt.Errorf("no jwt token provided to connection")
}
if remote.IsPowershell(shellPath) {
shellOpts = append(shellOpts, "--", fmt.Sprintf(`$env:%s=%s;`, wshutil.WaveJwtTokenVarName, jwtToken))
} else {
shellOpts = append(shellOpts, "--", fmt.Sprintf(`%s=%s`, wshutil.WaveJwtTokenVarName, jwtToken))
}
shellOpts = append(shellOpts, shellPath)
shellOpts = append(shellOpts, subShellOpts...)
log.Printf("full cmd is: %s %s", "wsl.exe", strings.Join(shellOpts, " "))
ecmd := exec.Command("wsl.exe", shellOpts...)
if termSize.Rows == 0 || termSize.Cols == 0 {
termSize.Rows = shellutil.DefaultTermRows
termSize.Cols = shellutil.DefaultTermCols
}
if termSize.Rows <= 0 || termSize.Cols <= 0 {
return nil, fmt.Errorf("invalid term size: %v", termSize)
}
cmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)})
if err != nil {
return nil, err
}
return &ShellProc{Cmd: CmdWrap{ecmd, cmdPty}, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
}
func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) { func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) {
client := conn.GetClient() client := conn.GetClient()
shellPath := cmdOpts.ShellPath shellPath := cmdOpts.ShellPath
@ -289,7 +381,7 @@ func StartShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOpt
// cant set -l or -i with --rcfile // cant set -l or -i with --rcfile
shellOpts = append(shellOpts, "--rcfile", shellutil.GetBashRcFileOverride()) shellOpts = append(shellOpts, "--rcfile", shellutil.GetBashRcFileOverride())
} else if isFishShell(shellPath) { } else if isFishShell(shellPath) {
wshBinDir := filepath.Join(wavebase.GetWaveHomeDir(), shellutil.WaveHomeBinDir) wshBinDir := filepath.Join(wavebase.GetWaveDataDir(), shellutil.WaveHomeBinDir)
quotedWshBinDir := utilfn.ShellQuote(wshBinDir, false, 300) quotedWshBinDir := utilfn.ShellQuote(wshBinDir, false, 300)
shellOpts = append(shellOpts, "-C", fmt.Sprintf("set -x PATH %s $PATH", quotedWshBinDir)) shellOpts = append(shellOpts, "-C", fmt.Sprintf("set -x PATH %s $PATH", quotedWshBinDir))
} else if remote.IsPowershell(shellPath) { } else if remote.IsPowershell(shellPath) {

View File

@ -42,9 +42,13 @@ var ExtraTypes = []any{
wshutil.RpcMessage{}, wshutil.RpcMessage{},
wshrpc.WshServerCommandMeta{}, wshrpc.WshServerCommandMeta{},
userinput.UserInputRequest{}, userinput.UserInputRequest{},
vdom.Elem{}, vdom.VDomCreateContext{},
vdom.VDomFuncType{}, vdom.VDomElem{},
vdom.VDomRefType{}, vdom.VDomFunc{},
vdom.VDomRef{},
vdom.VDomBinding{},
vdom.VDomFrontendUpdate{},
vdom.VDomBackendUpdate{},
waveobj.MetaTSType{}, waveobj.MetaTSType{},
} }

View File

@ -0,0 +1,58 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package packetparser
import (
"bufio"
"bytes"
"fmt"
"io"
)
type PacketParser struct {
Reader io.Reader
Ch chan []byte
}
func Parse(input io.Reader, packetCh chan []byte, rawCh chan []byte) error {
bufReader := bufio.NewReader(input)
defer close(packetCh)
defer close(rawCh)
for {
line, err := bufReader.ReadBytes('\n')
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if len(line) <= 1 {
// just a blank line
continue
}
if bytes.HasPrefix(line, []byte{'#', '#', 'N', '{'}) && bytes.HasSuffix(line, []byte{'}', '\n'}) {
// strip off the leading "##" and trailing "\n" (single byte)
packetCh <- line[3 : len(line)-1]
} else {
rawCh <- line
}
}
}
func WritePacket(output io.Writer, packet []byte) error {
if len(packet) < 2 {
return nil
}
if packet[0] != '{' || packet[len(packet)-1] != '}' {
return fmt.Errorf("invalid packet, must start with '{' and end with '}'")
}
fullPacket := make([]byte, 0, len(packet)+5)
// we add the extra newline to make sure the ## appears at the beginning of the line
// since writer isn't buffered, we want to send this all at once
fullPacket = append(fullPacket, '\n', '#', '#', 'N')
fullPacket = append(fullPacket, packet...)
fullPacket = append(fullPacket, '\n')
_, err := output.Write(fullPacket)
return err
}

15
pkg/util/panic/panic.go Normal file
View File

@ -0,0 +1,15 @@
package panic
import (
"log"
"os"
)
var shouldPanic = len(os.Getenv("NO_PANIC")) == 0
// Wraps log.Panic, ignored if NO_PANIC is set
func Panic(message string) {
if shouldPanic {
log.Panic(message)
}
}

View File

@ -149,7 +149,7 @@ func WaveshellLocalEnvVars(termType string) map[string]string {
rtn["TERM_PROGRAM"] = "waveterm" rtn["TERM_PROGRAM"] = "waveterm"
rtn["WAVETERM"], _ = os.Executable() rtn["WAVETERM"], _ = os.Executable()
rtn["WAVETERM_VERSION"] = wavebase.WaveVersion rtn["WAVETERM_VERSION"] = wavebase.WaveVersion
rtn["WAVETERM_WSHBINDIR"] = filepath.Join(wavebase.GetWaveHomeDir(), WaveHomeBinDir) rtn["WAVETERM_WSHBINDIR"] = filepath.Join(wavebase.GetWaveDataDir(), WaveHomeBinDir)
return rtn return rtn
} }
@ -202,15 +202,15 @@ func InitCustomShellStartupFiles() error {
} }
func GetBashRcFileOverride() string { func GetBashRcFileOverride() string {
return filepath.Join(wavebase.GetWaveHomeDir(), BashIntegrationDir, ".bashrc") return filepath.Join(wavebase.GetWaveDataDir(), BashIntegrationDir, ".bashrc")
} }
func GetWavePowershellEnv() string { func GetWavePowershellEnv() string {
return filepath.Join(wavebase.GetWaveHomeDir(), PwshIntegrationDir, "wavepwsh.ps1") return filepath.Join(wavebase.GetWaveDataDir(), PwshIntegrationDir, "wavepwsh.ps1")
} }
func GetZshZDotDir() string { func GetZshZDotDir() string {
return filepath.Join(wavebase.GetWaveHomeDir(), ZshIntegrationDir) return filepath.Join(wavebase.GetWaveDataDir(), ZshIntegrationDir)
} }
func GetWshBaseName(version string, goos string, goarch string) string { func GetWshBaseName(version string, goos string, goarch string) string {
@ -289,9 +289,9 @@ func InitRcFiles(waveHome string, wshBinDir string) error {
func initCustomShellStartupFilesInternal() error { func initCustomShellStartupFilesInternal() error {
log.Printf("initializing wsh and shell startup files\n") log.Printf("initializing wsh and shell startup files\n")
waveHome := wavebase.GetWaveHomeDir() waveDataHome := wavebase.GetWaveDataDir()
binDir := filepath.Join(waveHome, WaveHomeBinDir) binDir := filepath.Join(waveDataHome, WaveHomeBinDir)
err := InitRcFiles(waveHome, `$WAVETERM_WSHBINDIR`) err := InitRcFiles(waveDataHome, `$WAVETERM_WSHBINDIR`)
if err != nil { if err != nil {
return err return err
} }

View File

@ -0,0 +1,89 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package utilfn
import (
"reflect"
)
// this is a shallow equal, but with special handling for numeric types
// it will up convert to float64 and compare
func JsonValEqual(a, b any) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
typeA := reflect.TypeOf(a)
typeB := reflect.TypeOf(b)
if typeA == typeB && typeA.Comparable() {
return a == b
}
if IsNumericType(a) && IsNumericType(b) {
return CompareAsFloat64(a, b)
}
if typeA != typeB {
return false
}
// for slices and maps, compare their pointers
valA := reflect.ValueOf(a)
valB := reflect.ValueOf(b)
switch valA.Kind() {
case reflect.Slice, reflect.Map:
return valA.Pointer() == valB.Pointer()
}
return false
}
// Helper to check if a value is a numeric type
func IsNumericType(val any) bool {
switch val.(type) {
case int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64,
float32, float64:
return true
default:
return false
}
}
// Helper to handle numeric comparisons as float64
func CompareAsFloat64(a, b any) bool {
valA, okA := ToFloat64(a)
valB, okB := ToFloat64(b)
return okA && okB && valA == valB
}
// Convert various numeric types to float64 for comparison
func ToFloat64(val any) (float64, bool) {
switch v := val.(type) {
case int:
return float64(v), true
case int8:
return float64(v), true
case int16:
return float64(v), true
case int32:
return float64(v), true
case int64:
return float64(v), true
case uint:
return float64(v), true
case uint8:
return float64(v), true
case uint16:
return float64(v), true
case uint32:
return float64(v), true
case uint64:
return float64(v), true
case float32:
return float64(v), true
case float64:
return v, true
default:
return 0, false
}
}

View File

@ -0,0 +1,159 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package cssparser
import (
"fmt"
"strings"
"unicode"
)
type Parser struct {
Input string
Pos int
Length int
InQuote bool
QuoteChar rune
OpenParens int
Debug bool
}
func MakeParser(input string) *Parser {
return &Parser{
Input: input,
Length: len(input),
}
}
func (p *Parser) Parse() (map[string]string, error) {
result := make(map[string]string)
lastProp := ""
for {
p.skipWhitespace()
if p.eof() {
break
}
propName, err := p.parseIdentifierColon(lastProp)
if err != nil {
return nil, err
}
lastProp = propName
p.skipWhitespace()
value, err := p.parseValue(propName)
if err != nil {
return nil, err
}
result[propName] = value
p.skipWhitespace()
if p.eof() {
break
}
if !p.expectChar(';') {
break
}
}
p.skipWhitespace()
if !p.eof() {
return nil, fmt.Errorf("bad style attribute, unexpected character %q at pos %d", string(p.Input[p.Pos]), p.Pos+1)
}
return result, nil
}
func (p *Parser) parseIdentifierColon(lastProp string) (string, error) {
start := p.Pos
for !p.eof() {
c := p.peekChar()
if isIdentChar(c) || c == '-' {
p.advance()
} else {
break
}
}
attrName := p.Input[start:p.Pos]
p.skipWhitespace()
if p.eof() {
return "", fmt.Errorf("bad style attribute, expected colon after property %q, got EOF, at pos %d", attrName, p.Pos+1)
}
if attrName == "" {
return "", fmt.Errorf("bad style attribute, invalid property name after property %q, at pos %d", lastProp, p.Pos+1)
}
if !p.expectChar(':') {
return "", fmt.Errorf("bad style attribute, bad property name starting with %q, expected colon, got %q, at pos %d", attrName, string(p.Input[p.Pos]), p.Pos+1)
}
return attrName, nil
}
func (p *Parser) parseValue(propName string) (string, error) {
start := p.Pos
quotePos := 0
parenPosStack := make([]int, 0)
for !p.eof() {
c := p.peekChar()
if p.InQuote {
if c == p.QuoteChar {
p.InQuote = false
} else if c == '\\' {
p.advance()
}
} else {
if c == '"' || c == '\'' {
p.InQuote = true
p.QuoteChar = c
quotePos = p.Pos
} else if c == '(' {
p.OpenParens++
parenPosStack = append(parenPosStack, p.Pos)
} else if c == ')' {
if p.OpenParens == 0 {
return "", fmt.Errorf("unmatched ')' at pos %d", p.Pos+1)
}
p.OpenParens--
parenPosStack = parenPosStack[:len(parenPosStack)-1]
} else if c == ';' && p.OpenParens == 0 {
break
}
}
p.advance()
}
if p.eof() && p.InQuote {
return "", fmt.Errorf("bad style attribute, while parsing attribute %q, unmatched quote at pos %d", propName, quotePos+1)
}
if p.eof() && p.OpenParens > 0 {
return "", fmt.Errorf("bad style attribute, while parsing property %q, unmatched '(' at pos %d", propName, parenPosStack[len(parenPosStack)-1]+1)
}
return strings.TrimSpace(p.Input[start:p.Pos]), nil
}
func isIdentChar(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
func (p *Parser) skipWhitespace() {
for !p.eof() && unicode.IsSpace(p.peekChar()) {
p.advance()
}
}
func (p *Parser) expectChar(expected rune) bool {
if !p.eof() && p.peekChar() == expected {
p.advance()
return true
}
return false
}
func (p *Parser) peekChar() rune {
if p.Pos >= p.Length {
return 0
}
return rune(p.Input[p.Pos])
}
func (p *Parser) advance() {
p.Pos++
}
func (p *Parser) eof() bool {
return p.Pos >= p.Length
}

View File

@ -0,0 +1,81 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package cssparser
import (
"fmt"
"log"
"testing"
)
func compareMaps(a, b map[string]string) error {
if len(a) != len(b) {
return fmt.Errorf("map length mismatch: %d != %d", len(a), len(b))
}
for k, v := range a {
if b[k] != v {
return fmt.Errorf("value mismatch for key %s: %q != %q", k, v, b[k])
}
}
return nil
}
func TestParse1(t *testing.T) {
style := `background: url("example;with;semicolons.jpg"); color: red; margin-right: 5px; content: "hello;world";`
p := MakeParser(style)
parsed, err := p.Parse()
if err != nil {
t.Fatalf("Parse failed: %v", err)
return
}
expected := map[string]string{
"background": `url("example;with;semicolons.jpg")`,
"color": "red",
"margin-right": "5px",
"content": `"hello;world"`,
}
if err := compareMaps(parsed, expected); err != nil {
t.Fatalf("Parsed map does not match expected: %v", err)
}
style = `margin-right: calc(10px + 5px); color: red; font-family: "Arial";`
p = MakeParser(style)
parsed, err = p.Parse()
if err != nil {
t.Fatalf("Parse failed: %v", err)
return
}
expected = map[string]string{
"margin-right": `calc(10px + 5px)`,
"color": "red",
"font-family": `"Arial"`,
}
if err := compareMaps(parsed, expected); err != nil {
t.Fatalf("Parsed map does not match expected: %v", err)
}
}
func TestParserErrors(t *testing.T) {
style := `hello more: bad;`
p := MakeParser(style)
_, err := p.Parse()
if err == nil {
t.Fatalf("expected error, got nil")
}
log.Printf("got expected error: %v\n", err)
style = `background: url("example.jpg`
p = MakeParser(style)
_, err = p.Parse()
if err == nil {
t.Fatalf("expected error, got nil")
}
log.Printf("got expected error: %v\n", err)
style = `foo: url(...`
p = MakeParser(style)
_, err = p.Parse()
if err == nil {
t.Fatalf("expected error, got nil")
}
log.Printf("got expected error: %v\n", err)
}

View File

@ -15,35 +15,6 @@ import (
// ReactNode types = nil | string | Elem // ReactNode types = nil | string | Elem
const TextTag = "#text"
const FragmentTag = "#fragment"
const ChildrenPropKey = "children"
const KeyPropKey = "key"
// doubles as VDOM structure
type Elem struct {
Id string `json:"id,omitempty"` // used for vdom
Tag string `json:"tag"`
Props map[string]any `json:"props,omitempty"`
Children []Elem `json:"children,omitempty"`
Text string `json:"text,omitempty"`
}
type VDomRefType struct {
RefId string `json:"#ref"`
Current any `json:"current"`
}
// can be used to set preventDefault/stopPropagation
type VDomFuncType struct {
Fn any `json:"-"` // the actual function to call (called via reflection)
FuncType string `json:"#func"`
StopPropagation bool `json:"#stopPropagation,omitempty"`
PreventDefault bool `json:"#preventDefault,omitempty"`
Keys []string `json:"#keys,omitempty"` // special for keyDown events a list of keys to "capture"
}
// generic hook structure // generic hook structure
type Hook struct { type Hook struct {
Init bool // is initialized Init bool // is initialized
@ -56,7 +27,7 @@ type Hook struct {
type CFunc = func(ctx context.Context, props map[string]any) any type CFunc = func(ctx context.Context, props map[string]any) any
func (e *Elem) Key() string { func (e *VDomElem) Key() string {
keyVal, ok := e.Props[KeyPropKey] keyVal, ok := e.Props[KeyPropKey]
if !ok { if !ok {
return "" return ""
@ -68,8 +39,8 @@ func (e *Elem) Key() string {
return "" return ""
} }
func TextElem(text string) Elem { func TextElem(text string) VDomElem {
return Elem{Tag: TextTag, Text: text} return VDomElem{Tag: TextTag, Text: text}
} }
func mergeProps(props *map[string]any, newProps map[string]any) { func mergeProps(props *map[string]any, newProps map[string]any) {
@ -85,8 +56,8 @@ func mergeProps(props *map[string]any, newProps map[string]any) {
} }
} }
func E(tag string, parts ...any) *Elem { func E(tag string, parts ...any) *VDomElem {
rtn := &Elem{Tag: tag} rtn := &VDomElem{Tag: tag}
for _, part := range parts { for _, part := range parts {
if part == nil { if part == nil {
continue continue
@ -135,19 +106,44 @@ func UseState[T any](ctx context.Context, initialVal T) (T, func(T)) {
} }
setVal := func(newVal T) { setVal := func(newVal T) {
hookVal.Val = newVal hookVal.Val = newVal
vc.Root.AddRenderWork(vc.Comp.Id) vc.Root.AddRenderWork(vc.Comp.WaveId)
} }
return rtnVal, setVal return rtnVal, setVal
} }
func UseRef(ctx context.Context, initialVal any) *VDomRefType { func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) {
vc, hookVal := getHookFromCtx(ctx) vc, hookVal := getHookFromCtx(ctx)
if !hookVal.Init { if !hookVal.Init {
hookVal.Init = true hookVal.Init = true
refId := vc.Comp.Id + ":" + strconv.Itoa(hookVal.Idx) closedWaveId := vc.Comp.WaveId
hookVal.Val = &VDomRefType{RefId: refId, Current: initialVal} hookVal.UnmountFn = func() {
atom := vc.Root.GetAtom(atomName)
delete(atom.UsedBy, closedWaveId)
} }
refVal, ok := hookVal.Val.(*VDomRefType) }
atom := vc.Root.GetAtom(atomName)
atom.UsedBy[vc.Comp.WaveId] = true
atomVal, ok := atom.Val.(T)
if !ok {
panic(fmt.Sprintf("UseAtom %q value type mismatch (expected %T, got %T)", atomName, atomVal, atom.Val))
}
setVal := func(newVal T) {
atom.Val = newVal
for waveId := range atom.UsedBy {
vc.Root.AddRenderWork(waveId)
}
}
return atomVal, setVal
}
func UseVDomRef(ctx context.Context) *VDomRef {
vc, hookVal := getHookFromCtx(ctx)
if !hookVal.Init {
hookVal.Init = true
refId := vc.Comp.WaveId + ":" + strconv.Itoa(hookVal.Idx)
hookVal.Val = &VDomRef{Type: ObjectType_Ref, RefId: refId}
}
refVal, ok := hookVal.Val.(*VDomRef)
if !ok { if !ok {
panic("UseRef hook value is not a ref (possible out of order or conditional hooks)") panic("UseRef hook value is not a ref (possible out of order or conditional hooks)")
} }
@ -159,7 +155,7 @@ func UseId(ctx context.Context) string {
if vc == nil { if vc == nil {
panic("UseId must be called within a component (no context)") panic("UseId must be called within a component (no context)")
} }
return vc.Comp.Id return vc.Comp.WaveId
} }
func depsEqual(deps1 []any, deps2 []any) bool { func depsEqual(deps1 []any, deps2 []any) bool {
@ -181,7 +177,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) {
hookVal.Init = true hookVal.Init = true
hookVal.Fn = fn hookVal.Fn = fn
hookVal.Deps = deps hookVal.Deps = deps
vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx)
return return
} }
if depsEqual(hookVal.Deps, deps) { if depsEqual(hookVal.Deps, deps) {
@ -189,7 +185,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) {
} }
hookVal.Fn = fn hookVal.Fn = fn
hookVal.Deps = deps hookVal.Deps = deps
vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx)
} }
func numToString[T any](value T) (string, bool) { func numToString[T any](value T) (string, bool) {
@ -207,24 +203,24 @@ func numToString[T any](value T) (string, bool) {
} }
} }
func partToElems(part any) []Elem { func partToElems(part any) []VDomElem {
if part == nil { if part == nil {
return nil return nil
} }
switch part := part.(type) { switch part := part.(type) {
case string: case string:
return []Elem{TextElem(part)} return []VDomElem{TextElem(part)}
case *Elem: case *VDomElem:
if part == nil { if part == nil {
return nil return nil
} }
return []Elem{*part} return []VDomElem{*part}
case Elem: case VDomElem:
return []Elem{part} return []VDomElem{part}
case []Elem: case []VDomElem:
return part return part
case []*Elem: case []*VDomElem:
var rtn []Elem var rtn []VDomElem
for _, e := range part { for _, e := range part {
if e == nil { if e == nil {
continue continue
@ -235,11 +231,11 @@ func partToElems(part any) []Elem {
} }
sval, ok := numToString(part) sval, ok := numToString(part)
if ok { if ok {
return []Elem{TextElem(sval)} return []VDomElem{TextElem(sval)}
} }
partVal := reflect.ValueOf(part) partVal := reflect.ValueOf(part)
if partVal.Kind() == reflect.Slice { if partVal.Kind() == reflect.Slice {
var rtn []Elem var rtn []VDomElem
for i := 0; i < partVal.Len(); i++ { for i := 0; i < partVal.Len(); i++ {
subPart := partVal.Index(i).Interface() subPart := partVal.Index(i).Interface()
rtn = append(rtn, partToElems(subPart)...) rtn = append(rtn, partToElems(subPart)...)
@ -248,14 +244,14 @@ func partToElems(part any) []Elem {
} }
stringer, ok := part.(fmt.Stringer) stringer, ok := part.(fmt.Stringer)
if ok { if ok {
return []Elem{TextElem(stringer.String())} return []VDomElem{TextElem(stringer.String())}
} }
jsonStr, jsonErr := json.Marshal(part) jsonStr, jsonErr := json.Marshal(part)
if jsonErr == nil { if jsonErr == nil {
return []Elem{TextElem(string(jsonStr))} return []VDomElem{TextElem(string(jsonStr))}
} }
typeText := "invalid:" + reflect.TypeOf(part).String() typeText := "invalid:" + reflect.TypeOf(part).String()
return []Elem{TextElem(typeText)} return []VDomElem{TextElem(typeText)}
} }
func isWaveTag(tag string) bool { func isWaveTag(tag string) bool {

View File

@ -13,10 +13,10 @@ type ChildKey struct {
} }
type Component struct { type Component struct {
Id string WaveId string
Tag string Tag string
Key string Key string
Elem *Elem Elem *VDomElem
Mounted bool Mounted bool
// hooks // hooks

View File

@ -4,17 +4,25 @@
package vdom package vdom
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"strings" "strings"
"github.com/wavetermdev/htmltoken" "github.com/wavetermdev/htmltoken"
"github.com/wavetermdev/waveterm/pkg/vdom/cssparser"
) )
// can tokenize and bind HTML to Elems // can tokenize and bind HTML to Elems
func appendChildToStack(stack []*Elem, child *Elem) { const Html_BindPrefix = "#bind:"
const Html_ParamPrefix = "#param:"
const Html_GlobalEventPrefix = "#globalevent"
const Html_BindParamTagName = "bindparam"
const Html_BindTagName = "bind"
func appendChildToStack(stack []*VDomElem, child *VDomElem) {
if child == nil { if child == nil {
return return
} }
@ -25,14 +33,14 @@ func appendChildToStack(stack []*Elem, child *Elem) {
parent.Children = append(parent.Children, *child) parent.Children = append(parent.Children, *child)
} }
func pushElemStack(stack []*Elem, elem *Elem) []*Elem { func pushElemStack(stack []*VDomElem, elem *VDomElem) []*VDomElem {
if elem == nil { if elem == nil {
return stack return stack
} }
return append(stack, elem) return append(stack, elem)
} }
func popElemStack(stack []*Elem) []*Elem { func popElemStack(stack []*VDomElem) []*VDomElem {
if len(stack) <= 1 { if len(stack) <= 1 {
return stack return stack
} }
@ -41,14 +49,14 @@ func popElemStack(stack []*Elem) []*Elem {
return stack[:len(stack)-1] return stack[:len(stack)-1]
} }
func curElemTag(stack []*Elem) string { func curElemTag(stack []*VDomElem) string {
if len(stack) == 0 { if len(stack) == 0 {
return "" return ""
} }
return stack[len(stack)-1].Tag return stack[len(stack)-1].Tag
} }
func finalizeStack(stack []*Elem) *Elem { func finalizeStack(stack []*VDomElem) *VDomElem {
if len(stack) == 0 { if len(stack) == 0 {
return nil return nil
} }
@ -65,7 +73,20 @@ func finalizeStack(stack []*Elem) *Elem {
return rtnElem return rtnElem
} }
func getAttr(token htmltoken.Token, key string) string { func attrVal(attr htmltoken.Attribute) (any, error) {
// if !attr.IsJson {
// return attr.Val, nil
// }
var val any
err := json.Unmarshal([]byte(attr.Val), &val)
if err != nil {
return nil, fmt.Errorf("error parsing json attr %q: %v", attr.Key, err)
}
return val, nil
}
// returns value, isjson
func getAttrString(token htmltoken.Token, key string) string {
for _, attr := range token.Attr { for _, attr := range token.Attr {
if attr.Key == key { if attr.Key == key {
return attr.Val return attr.Val
@ -74,8 +95,38 @@ func getAttr(token htmltoken.Token, key string) string {
return "" return ""
} }
func tokenToElem(token htmltoken.Token, data map[string]any) *Elem { func attrToProp(attrVal string, isJson bool, params map[string]any) any {
elem := &Elem{Tag: token.Data} if strings.HasPrefix(attrVal, Html_ParamPrefix) {
bindKey := attrVal[len(Html_ParamPrefix):]
bindVal, ok := params[bindKey]
if !ok {
return nil
}
return bindVal
}
if strings.HasPrefix(attrVal, Html_BindPrefix) {
bindKey := attrVal[len(Html_BindPrefix):]
if bindKey == "" {
return nil
}
return &VDomBinding{Type: ObjectType_Binding, Bind: bindKey}
}
if strings.HasPrefix(attrVal, Html_GlobalEventPrefix) {
splitArr := strings.Split(attrVal, ":")
if len(splitArr) < 2 {
return nil
}
eventName := splitArr[1]
if eventName == "" {
return nil
}
return &VDomFunc{Type: ObjectType_Func, GlobalEvent: eventName}
}
return attrVal
}
func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem {
elem := &VDomElem{Tag: token.Data}
if len(token.Attr) > 0 { if len(token.Attr) > 0 {
elem.Props = make(map[string]any) elem.Props = make(map[string]any)
} }
@ -83,16 +134,8 @@ func tokenToElem(token htmltoken.Token, data map[string]any) *Elem {
if attr.Key == "" || attr.Val == "" { if attr.Key == "" || attr.Val == "" {
continue continue
} }
if strings.HasPrefix(attr.Val, "#bind:") { propVal := attrToProp(attr.Val, false, params)
bindKey := attr.Val[6:] elem.Props[attr.Key] = propVal
bindVal, ok := data[bindKey]
if !ok {
continue
}
elem.Props[attr.Key] = bindVal
continue
}
elem.Props[attr.Key] = attr.Val
} }
return elem return elem
} }
@ -177,12 +220,101 @@ func processTextStr(s string) string {
return strings.TrimSpace(s) return strings.TrimSpace(s)
} }
func Bind(htmlStr string, data map[string]any) *Elem { func makePathStr(elemPath []string) string {
return strings.Join(elemPath, " ")
}
func capitalizeAscii(s string) string {
if s == "" || s[0] < 'a' || s[0] > 'z' {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func toReactName(input string) string {
// Check for CSS custom properties (variables) which start with '--'
if strings.HasPrefix(input, "--") {
return input
}
parts := strings.Split(input, "-")
result := ""
index := 0
if parts[0] == "" && len(parts) > 1 {
// handle vendor prefixes
prefix := parts[1]
if prefix == "ms" {
result += "ms"
} else {
result += capitalizeAscii(prefix)
}
index = 2 // Skip the empty string and prefix
} else {
result += parts[0]
index = 1
}
// Convert remaining parts to CamelCase
for ; index < len(parts); index++ {
if parts[index] != "" {
result += capitalizeAscii(parts[index])
}
}
return result
}
func convertStyleToReactStyles(styleMap map[string]string, params map[string]any) map[string]any {
if len(styleMap) == 0 {
return nil
}
rtn := make(map[string]any)
for key, val := range styleMap {
rtn[toReactName(key)] = attrToProp(val, false, params)
}
return rtn
}
func fixStyleAttribute(elem *VDomElem, params map[string]any, elemPath []string) error {
styleText, ok := elem.Props["style"].(string)
if !ok {
return nil
}
parser := cssparser.MakeParser(styleText)
m, err := parser.Parse()
if err != nil {
return fmt.Errorf("%v (at %s)", err, makePathStr(elemPath))
}
elem.Props["style"] = convertStyleToReactStyles(m, params)
return nil
}
func fixupStyleAttributes(elem *VDomElem, params map[string]any, elemPath []string) {
if elem == nil {
return
}
// call fixStyleAttribute, and walk children
elemCountMap := make(map[string]int)
if len(elemPath) == 0 {
elemPath = append(elemPath, elem.Tag)
}
fixStyleAttribute(elem, params, elemPath)
for i := range elem.Children {
child := &elem.Children[i]
elemCountMap[child.Tag]++
subPath := child.Tag
if elemCountMap[child.Tag] > 1 {
subPath = fmt.Sprintf("%s[%d]", child.Tag, elemCountMap[child.Tag])
}
elemPath = append(elemPath, subPath)
fixupStyleAttributes(&elem.Children[i], params, elemPath)
elemPath = elemPath[:len(elemPath)-1]
}
}
func Bind(htmlStr string, params map[string]any) *VDomElem {
htmlStr = processWhitespace(htmlStr) htmlStr = processWhitespace(htmlStr)
r := strings.NewReader(htmlStr) r := strings.NewReader(htmlStr)
iter := htmltoken.NewTokenizer(r) iter := htmltoken.NewTokenizer(r)
var elemStack []*Elem var elemStack []*VDomElem
elemStack = append(elemStack, &Elem{Tag: FragmentTag}) elemStack = append(elemStack, &VDomElem{Tag: FragmentTag})
var tokenErr error var tokenErr error
outer: outer:
for { for {
@ -190,15 +322,15 @@ outer:
token := iter.Token() token := iter.Token()
switch tokenType { switch tokenType {
case htmltoken.StartTagToken: case htmltoken.StartTagToken:
if token.Data == "bind" { if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName {
tokenErr = errors.New("bind tag must be self closing") tokenErr = errors.New("bind tags must be self closing")
break outer break outer
} }
elem := tokenToElem(token, data) elem := tokenToElem(token, params)
elemStack = pushElemStack(elemStack, elem) elemStack = pushElemStack(elemStack, elem)
case htmltoken.EndTagToken: case htmltoken.EndTagToken:
if token.Data == "bind" { if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName {
tokenErr = errors.New("bind tag must be self closing") tokenErr = errors.New("bind tags must be self closing")
break outer break outer
} }
if len(elemStack) <= 1 { if len(elemStack) <= 1 {
@ -211,16 +343,22 @@ outer:
} }
elemStack = popElemStack(elemStack) elemStack = popElemStack(elemStack)
case htmltoken.SelfClosingTagToken: case htmltoken.SelfClosingTagToken:
if token.Data == "bind" { if token.Data == Html_BindParamTagName {
keyAttr := getAttr(token, "key") keyAttr := getAttrString(token, "key")
dataVal := data[keyAttr] dataVal := params[keyAttr]
elemList := partToElems(dataVal) elemList := partToElems(dataVal)
for _, elem := range elemList { for _, elem := range elemList {
appendChildToStack(elemStack, &elem) appendChildToStack(elemStack, &elem)
} }
continue continue
} }
elem := tokenToElem(token, data) if token.Data == Html_BindTagName {
keyAttr := getAttrString(token, "key")
binding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr}
appendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{"text": binding}})
continue
}
elem := tokenToElem(token, params)
appendChildToStack(elemStack, elem) appendChildToStack(elemStack, elem)
case htmltoken.TextToken: case htmltoken.TextToken:
if token.Data == "" { if token.Data == "" {
@ -249,5 +387,7 @@ outer:
errTextElem := TextElem(tokenErr.Error()) errTextElem := TextElem(tokenErr.Error())
appendChildToStack(elemStack, &errTextElem) appendChildToStack(elemStack, &errTextElem)
} }
return finalizeStack(elemStack) rtn := finalizeStack(elemStack)
fixupStyleAttributes(rtn, params, nil)
return rtn
} }

View File

@ -10,6 +10,7 @@ import (
"reflect" "reflect"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
) )
type vdomContextKeyType struct{} type vdomContextKeyType struct{}
@ -22,13 +23,20 @@ type VDomContextVal struct {
HookIdx int HookIdx int
} }
type Atom struct {
Val any
Dirty bool
UsedBy map[string]bool // component waveid -> true
}
type RootElem struct { type RootElem struct {
OuterCtx context.Context OuterCtx context.Context
Root *Component Root *Component
CFuncs map[string]CFunc CFuncs map[string]CFunc
CompMap map[string]*Component // component id -> component CompMap map[string]*Component // component waveid -> component
EffectWorkQueue []*EffectWorkElem EffectWorkQueue []*EffectWorkElem
NeedsRenderMap map[string]bool NeedsRenderMap map[string]bool
Atoms map[string]*Atom
} }
const ( const (
@ -57,9 +65,49 @@ func MakeRoot() *RootElem {
Root: nil, Root: nil,
CFuncs: make(map[string]CFunc), CFuncs: make(map[string]CFunc),
CompMap: make(map[string]*Component), CompMap: make(map[string]*Component),
Atoms: make(map[string]*Atom),
} }
} }
func (r *RootElem) GetAtom(name string) *Atom {
atom, ok := r.Atoms[name]
if !ok {
atom = &Atom{UsedBy: make(map[string]bool)}
r.Atoms[name] = atom
}
return atom
}
func (r *RootElem) GetAtomVal(name string) any {
atom := r.GetAtom(name)
return atom.Val
}
func (r *RootElem) GetStateSync(full bool) []VDomStateSync {
stateSync := make([]VDomStateSync, 0)
for atomName, atom := range r.Atoms {
if atom.Dirty || full {
stateSync = append(stateSync, VDomStateSync{Atom: atomName, Value: atom.Val})
atom.Dirty = false
}
}
return stateSync
}
func (r *RootElem) SetAtomVal(name string, val any, markDirty bool) {
atom := r.GetAtom(name)
if !markDirty {
atom.Val = val
return
}
// try to avoid setting the value and marking as dirty if it's the "same"
if utilfn.JsonValEqual(val, atom.Val) {
return
}
atom.Val = val
atom.Dirty = true
}
func (r *RootElem) SetOuterCtx(ctx context.Context) { func (r *RootElem) SetOuterCtx(ctx context.Context) {
r.OuterCtx = ctx r.OuterCtx = ctx
} }
@ -68,30 +116,60 @@ func (r *RootElem) RegisterComponent(name string, cfunc CFunc) {
r.CFuncs[name] = cfunc r.CFuncs[name] = cfunc
} }
func (r *RootElem) Render(elem *Elem) { func (r *RootElem) Render(elem *VDomElem) {
log.Printf("Render %s\n", elem.Tag) log.Printf("Render %s\n", elem.Tag)
r.render(elem, &r.Root) r.render(elem, &r.Root)
} }
func (r *RootElem) Event(id string, propName string) { func (vdf *VDomFunc) CallFn() {
if vdf.Fn == nil {
return
}
rval := reflect.ValueOf(vdf.Fn)
if rval.Kind() != reflect.Func {
return
}
rval.Call(nil)
}
func callVDomFn(fnVal any, data any) {
if fnVal == nil {
return
}
fn := fnVal
if vdf, ok := fnVal.(*VDomFunc); ok {
fn = vdf.Fn
}
if fn == nil {
return
}
rval := reflect.ValueOf(fn)
if rval.Kind() != reflect.Func {
return
}
rtype := rval.Type()
if rtype.NumIn() == 0 {
rval.Call(nil)
return
}
if rtype.NumIn() == 1 {
rval.Call([]reflect.Value{reflect.ValueOf(data)})
return
}
}
func (r *RootElem) Event(id string, propName string, data any) {
comp := r.CompMap[id] comp := r.CompMap[id]
if comp == nil || comp.Elem == nil { if comp == nil || comp.Elem == nil {
return return
} }
fnVal := comp.Elem.Props[propName] fnVal := comp.Elem.Props[propName]
if fnVal == nil { callVDomFn(fnVal, data)
return
}
fn, ok := fnVal.(func())
if !ok {
return
}
fn()
} }
// this will be called by the frontend to say the DOM has been mounted // this will be called by the frontend to say the DOM has been mounted
// it will eventually send any updated "refs" to the backend as well // it will eventually send any updated "refs" to the backend as well
func (r *RootElem) runWork() { func (r *RootElem) RunWork() {
workQueue := r.EffectWorkQueue workQueue := r.EffectWorkQueue
r.EffectWorkQueue = nil r.EffectWorkQueue = nil
// first, run effect cleanups // first, run effect cleanups
@ -123,7 +201,7 @@ func (r *RootElem) runWork() {
} }
} }
func (r *RootElem) render(elem *Elem, comp **Component) { func (r *RootElem) render(elem *VDomElem, comp **Component) {
if elem == nil || elem.Tag == "" { if elem == nil || elem.Tag == "" {
r.unmount(comp) r.unmount(comp)
return return
@ -171,13 +249,13 @@ func (r *RootElem) unmount(comp **Component) {
r.unmount(&child) r.unmount(&child)
} }
} }
delete(r.CompMap, (*comp).Id) delete(r.CompMap, (*comp).WaveId)
*comp = nil *comp = nil
} }
func (r *RootElem) createComp(tag string, key string, comp **Component) { func (r *RootElem) createComp(tag string, key string, comp **Component) {
*comp = &Component{Id: uuid.New().String(), Tag: tag, Key: key} *comp = &Component{WaveId: uuid.New().String(), Tag: tag, Key: key}
r.CompMap[(*comp).Id] = *comp r.CompMap[(*comp).WaveId] = *comp
} }
func (r *RootElem) renderText(text string, comp **Component) { func (r *RootElem) renderText(text string, comp **Component) {
@ -186,7 +264,7 @@ func (r *RootElem) renderText(text string, comp **Component) {
} }
} }
func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Component { func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*Component) []*Component {
newChildren := make([]*Component, len(elems)) newChildren := make([]*Component, len(elems))
curCM := make(map[ChildKey]*Component) curCM := make(map[ChildKey]*Component)
usedMap := make(map[*Component]bool) usedMap := make(map[*Component]bool)
@ -217,7 +295,7 @@ func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Com
return newChildren return newChildren
} }
func (r *RootElem) renderSimple(elem *Elem, comp **Component) { func (r *RootElem) renderSimple(elem *VDomElem, comp **Component) {
if (*comp).Comp != nil { if (*comp).Comp != nil {
r.unmount(&(*comp).Comp) r.unmount(&(*comp).Comp)
} }
@ -243,7 +321,7 @@ func getRenderContext(ctx context.Context) *VDomContextVal {
return v.(*VDomContextVal) return v.(*VDomContextVal)
} }
func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) { func (r *RootElem) renderComponent(cfunc CFunc, elem *VDomElem, comp **Component) {
if (*comp).Children != nil { if (*comp).Children != nil {
for _, child := range (*comp).Children { for _, child := range (*comp).Children {
r.unmount(&child) r.unmount(&child)
@ -262,11 +340,11 @@ func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) {
r.unmount(&(*comp).Comp) r.unmount(&(*comp).Comp)
return return
} }
var rtnElem *Elem var rtnElem *VDomElem
if len(rtnElemArr) == 1 { if len(rtnElemArr) == 1 {
rtnElem = &rtnElemArr[0] rtnElem = &rtnElemArr[0]
} else { } else {
rtnElem = &Elem{Tag: FragmentTag, Children: rtnElemArr} rtnElem = &VDomElem{Tag: FragmentTag, Children: rtnElemArr}
} }
r.render(rtnElem, &(*comp).Comp) r.render(rtnElem, &(*comp).Comp)
} }
@ -282,7 +360,7 @@ func convertPropsToVDom(props map[string]any) map[string]any {
} }
val := reflect.ValueOf(v) val := reflect.ValueOf(v)
if val.Kind() == reflect.Func { if val.Kind() == reflect.Func {
vdomProps[k] = VDomFuncType{FuncType: "server"} vdomProps[k] = VDomFunc{Type: ObjectType_Func}
continue continue
} }
vdomProps[k] = v vdomProps[k] = v
@ -290,8 +368,8 @@ func convertPropsToVDom(props map[string]any) map[string]any {
return vdomProps return vdomProps
} }
func convertBaseToVDom(c *Component) *Elem { func convertBaseToVDom(c *Component) *VDomElem {
elem := &Elem{Id: c.Id, Tag: c.Tag} elem := &VDomElem{WaveId: c.WaveId, Tag: c.Tag}
if c.Elem != nil { if c.Elem != nil {
elem.Props = convertPropsToVDom(c.Elem.Props) elem.Props = convertPropsToVDom(c.Elem.Props)
} }
@ -304,12 +382,12 @@ func convertBaseToVDom(c *Component) *Elem {
return elem return elem
} }
func convertToVDom(c *Component) *Elem { func convertToVDom(c *Component) *VDomElem {
if c == nil { if c == nil {
return nil return nil
} }
if c.Tag == TextTag { if c.Tag == TextTag {
return &Elem{Tag: TextTag, Text: c.Text} return &VDomElem{Tag: TextTag, Text: c.Text}
} }
if isBaseTag(c.Tag) { if isBaseTag(c.Tag) {
return convertBaseToVDom(c) return convertBaseToVDom(c)
@ -318,11 +396,11 @@ func convertToVDom(c *Component) *Elem {
} }
} }
func (r *RootElem) makeVDom(comp *Component) *Elem { func (r *RootElem) makeVDom(comp *Component) *VDomElem {
vdomElem := convertToVDom(comp) vdomElem := convertToVDom(comp)
return vdomElem return vdomElem
} }
func (r *RootElem) MakeVDom() *Elem { func (r *RootElem) MakeVDom() *VDomElem {
return r.makeVDom(r.Root) return r.makeVDom(r.Root)
} }

View File

@ -18,7 +18,7 @@ type TestContext struct {
func Page(ctx context.Context, props map[string]any) any { func Page(ctx context.Context, props map[string]any) any {
clicked, setClicked := UseState(ctx, false) clicked, setClicked := UseState(ctx, false)
var clickedDiv *Elem var clickedDiv *VDomElem
if clicked { if clicked {
clickedDiv = Bind(`<div>clicked</div>`, nil) clickedDiv = Bind(`<div>clicked</div>`, nil)
} }
@ -30,8 +30,8 @@ func Page(ctx context.Context, props map[string]any) any {
` `
<div> <div>
<h1>hello world</h1> <h1>hello world</h1>
<Button onClick="#bind:clickFn">hello</Button> <Button onClick="#param:clickFn">hello</Button>
<bind key="clickedDiv"/> <bindparam key="clickedDiv"/>
</div> </div>
`, `,
map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv}, map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv},
@ -39,7 +39,7 @@ func Page(ctx context.Context, props map[string]any) any {
} }
func Button(ctx context.Context, props map[string]any) any { func Button(ctx context.Context, props map[string]any) any {
ref := UseRef(ctx, nil) ref := UseVDomRef(ctx)
clName, setClName := UseState(ctx, "button") clName, setClName := UseState(ctx, "button")
UseEffect(ctx, func() func() { UseEffect(ctx, func() func() {
fmt.Printf("Button useEffect\n") fmt.Printf("Button useEffect\n")
@ -52,8 +52,8 @@ func Button(ctx context.Context, props map[string]any) any {
testContext.ButtonId = compId testContext.ButtonId = compId
} }
return Bind(` return Bind(`
<div className="#bind:clName" ref="#bind:ref" onClick="#bind:onClick"> <div className="#param:clName" ref="#param:ref" onClick="#param:onClick">
<bind key="children"/> <bindparam key="children"/>
</div> </div>
`, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]}) `, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]})
} }
@ -85,10 +85,10 @@ func Test1(t *testing.T) {
t.Fatalf("root.Root is nil") t.Fatalf("root.Root is nil")
} }
printVDom(root) printVDom(root)
root.runWork() root.RunWork()
printVDom(root) printVDom(root)
root.Event(testContext.ButtonId, "onClick") root.Event(testContext.ButtonId, "onClick", nil)
root.runWork() root.RunWork()
printVDom(root) printVDom(root)
} }
@ -111,8 +111,8 @@ func TestBind(t *testing.T) {
elem = Bind(` elem = Bind(`
<div> <div>
<h1>hello world</h1> <h1>hello world</h1>
<Button onClick="#bind:clickFn">hello</Button> <Button onClick="#param:clickFn">hello</Button>
<bind key="clickedDiv"/> <bindparam key="clickedDiv"/>
</div> </div>
`, nil) `, nil)
jsonBytes, _ = json.MarshalIndent(elem, "", " ") jsonBytes, _ = json.MarshalIndent(elem, "", " ")

201
pkg/vdom/vdom_types.go Normal file
View File

@ -0,0 +1,201 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package vdom
import (
"time"
"github.com/wavetermdev/waveterm/pkg/waveobj"
)
const TextTag = "#text"
const WaveTextTag = "wave:text"
const WaveNullTag = "wave:null"
const FragmentTag = "#fragment"
const BindTag = "#bind"
const ChildrenPropKey = "children"
const KeyPropKey = "key"
const ObjectType_Ref = "ref"
const ObjectType_Binding = "binding"
const ObjectType_Func = "func"
// vdom element
type VDomElem struct {
WaveId string `json:"waveid,omitempty"` // required, except for #text nodes
Tag string `json:"tag"`
Props map[string]any `json:"props,omitempty"`
Children []VDomElem `json:"children,omitempty"`
Text string `json:"text,omitempty"`
}
//// protocol messages
type VDomCreateContext struct {
Type string `json:"type" tstype:"\"createcontext\""`
Ts int64 `json:"ts"`
Meta waveobj.MetaMapType `json:"meta,omitempty"`
Target *VDomTarget `json:"target,omitempty"`
Persist bool `json:"persist,omitempty"`
}
type VDomAsyncInitiationRequest struct {
Type string `json:"type" tstype:"\"asyncinitiationrequest\""`
Ts int64 `json:"ts"`
BlockId string `json:"blockid,omitempty"`
}
func MakeAsyncInitiationRequest(blockId string) VDomAsyncInitiationRequest {
return VDomAsyncInitiationRequest{
Type: "asyncinitiationrequest",
Ts: time.Now().UnixMilli(),
BlockId: blockId,
}
}
type VDomFrontendUpdate struct {
Type string `json:"type" tstype:"\"frontendupdate\""`
Ts int64 `json:"ts"`
BlockId string `json:"blockid"`
CorrelationId string `json:"correlationid,omitempty"`
Dispose bool `json:"dispose,omitempty"` // the vdom context was closed
Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads
RenderContext VDomRenderContext `json:"rendercontext,omitempty"`
Events []VDomEvent `json:"events,omitempty"`
StateSync []VDomStateSync `json:"statesync,omitempty"`
RefUpdates []VDomRefUpdate `json:"refupdates,omitempty"`
Messages []VDomMessage `json:"messages,omitempty"`
}
type VDomBackendUpdate struct {
Type string `json:"type" tstype:"\"backendupdate\""`
Ts int64 `json:"ts"`
BlockId string `json:"blockid"`
Opts *VDomBackendOpts `json:"opts,omitempty"`
RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"`
StateSync []VDomStateSync `json:"statesync,omitempty"`
RefOperations []VDomRefOperation `json:"refoperations,omitempty"`
Messages []VDomMessage `json:"messages,omitempty"`
}
///// prop types
// used in props
type VDomBinding struct {
Type string `json:"type" tstype:"\"binding\""`
Bind string `json:"bind"`
}
// used in props
type VDomFunc struct {
Fn any `json:"-"` // server side function (called with reflection)
Type string `json:"type" tstype:"\"func\""`
StopPropagation bool `json:"stoppropagation,omitempty"`
PreventDefault bool `json:"preventdefault,omitempty"`
GlobalEvent string `json:"globalevent,omitempty"`
Keys []string `json:"keys,omitempty"` // special for keyDown events a list of keys to "capture"
}
// used in props
type VDomRef struct {
Type string `json:"type" tstype:"\"ref\""`
RefId string `json:"refid"`
TrackPosition bool `json:"trackposition,omitempty"`
Position *VDomRefPosition `json:"position,omitempty"`
HasCurrent bool `json:"hascurrent,omitempty"`
}
type DomRect struct {
Top float64 `json:"top"`
Left float64 `json:"left"`
Right float64 `json:"right"`
Bottom float64 `json:"bottom"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
type VDomRefPosition struct {
OffsetHeight int `json:"offsetheight"`
OffsetWidth int `json:"offsetwidth"`
ScrollHeight int `json:"scrollheight"`
ScrollWidth int `json:"scrollwidth"`
ScrollTop int `json:"scrolltop"`
BoundingClientRect DomRect `json:"boundingclientrect"`
}
///// subbordinate protocol types
type VDomEvent struct {
WaveId string `json:"waveid"` // empty for global events
EventType string `json:"eventtype"`
EventData any `json:"eventdata"`
}
type VDomRenderContext struct {
BlockId string `json:"blockid"`
Focused bool `json:"focused"`
Width int `json:"width"`
Height int `json:"height"`
RootRefId string `json:"rootrefid"`
Background bool `json:"background,omitempty"`
}
type VDomStateSync struct {
Atom string `json:"atom"`
Value any `json:"value"`
}
type VDomRefUpdate struct {
RefId string `json:"refid"`
HasCurrent bool `json:"hascurrent"`
Position *VDomRefPosition `json:"position,omitempty"`
}
type VDomBackendOpts struct {
CloseOnCtrlC bool `json:"closeonctrlc,omitempty"`
GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"`
}
type VDomRenderUpdate struct {
UpdateType string `json:"updatetype" tstype:"\"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\""`
WaveId string `json:"waveid,omitempty"`
VDom VDomElem `json:"vdom"`
Index *int `json:"index,omitempty"`
}
type VDomRefOperation struct {
RefId string `json:"refid"`
Op string `json:"op" tsype:"\"focus\""`
Params []any `json:"params,omitempty"`
}
type VDomMessage struct {
MessageType string `json:"messagetype"`
Message string `json:"message"`
StackTrace string `json:"stacktrace,omitempty"`
Params []any `json:"params,omitempty"`
}
// target -- to support new targets in the future, like toolbars, partial blocks, splits, etc.
// default is vdom context inside of a terminal block
type VDomTarget struct {
NewBlock bool `json:"newblock,omitempty"`
Magnified bool `json:"magnified,omitempty"`
}
// matches WaveKeyboardEvent
type VDomKeyboardEvent struct {
Type string `json:"type"`
Key string `json:"key"`
Code string `json:"code"`
Shift bool `json:"shift,omitempty"`
Control bool `json:"ctrl,omitempty"`
Alt bool `json:"alt,omitempty"`
Meta bool `json:"meta,omitempty"`
Cmd bool `json:"cmd,omitempty"`
Option bool `json:"option,omitempty"`
Repeat bool `json:"repeat,omitempty"`
Location int `json:"location,omitempty"`
}

View File

@ -0,0 +1,234 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package vdomclient
import (
"context"
"fmt"
"log"
"os"
"sync"
"time"
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/pkg/vdom"
"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"
)
type Client struct {
Lock *sync.Mutex
Root *vdom.RootElem
RootElem *vdom.VDomElem
RpcClient *wshutil.WshRpc
RpcContext *wshrpc.RpcContext
ServerImpl *VDomServerImpl
IsDone bool
RouteId string
VDomContextBlockId string
DoneReason string
DoneCh chan struct{}
Opts vdom.VDomBackendOpts
GlobalEventHandler func(client *Client, event vdom.VDomEvent)
}
type VDomServerImpl struct {
Client *Client
BlockId string
}
func (*VDomServerImpl) WshServerImpl() {}
func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) {
if feUpdate.Dispose {
log.Printf("got dispose from frontend\n")
impl.Client.doShutdown("got dispose from frontend")
return nil, nil
}
if impl.Client.GetIsDone() {
return nil, nil
}
// set atoms
for _, ss := range feUpdate.StateSync {
impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false)
}
// run events
for _, event := range feUpdate.Events {
if event.WaveId == "" {
if impl.Client.GlobalEventHandler != nil {
impl.Client.GlobalEventHandler(impl.Client, event)
}
} else {
impl.Client.Root.Event(event.WaveId, event.EventType, event.EventData)
}
}
if feUpdate.Resync {
return impl.Client.fullRender()
}
return impl.Client.incrementalRender()
}
func (c *Client) GetIsDone() bool {
c.Lock.Lock()
defer c.Lock.Unlock()
return c.IsDone
}
func (c *Client) doShutdown(reason string) {
c.Lock.Lock()
defer c.Lock.Unlock()
if c.IsDone {
return
}
c.DoneReason = reason
c.IsDone = true
close(c.DoneCh)
}
func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) {
c.GlobalEventHandler = handler
}
func MakeClient(opts *vdom.VDomBackendOpts) (*Client, error) {
client := &Client{
Lock: &sync.Mutex{},
Root: vdom.MakeRoot(),
DoneCh: make(chan struct{}),
}
if opts != nil {
client.Opts = *opts
}
jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName)
if jwtToken == "" {
return nil, fmt.Errorf("no %s env var set", wshutil.WaveJwtTokenVarName)
}
rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken)
if err != nil {
return nil, fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err)
}
client.RpcContext = rpcCtx
if client.RpcContext == nil || client.RpcContext.BlockId == "" {
return nil, fmt.Errorf("no block id in rpc context")
}
client.ServerImpl = &VDomServerImpl{BlockId: client.RpcContext.BlockId, Client: client}
sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken)
if err != nil {
return nil, fmt.Errorf("error extracting socket name from %s: %v", wshutil.WaveJwtTokenVarName, err)
}
rpcClient, err := wshutil.SetupDomainSocketRpcClient(sockName, client.ServerImpl)
if err != nil {
return nil, fmt.Errorf("error setting up domain socket rpc client: %v", err)
}
client.RpcClient = rpcClient
authRtn, err := wshclient.AuthenticateCommand(client.RpcClient, jwtToken, &wshrpc.RpcOpts{NoResponse: true})
if err != nil {
return nil, fmt.Errorf("error authenticating rpc connection: %v", err)
}
client.RouteId = authRtn.RouteId
return client, nil
}
func (c *Client) SetRootElem(elem *vdom.VDomElem) {
c.RootElem = elem
}
func (c *Client) CreateVDomContext(target *vdom.VDomTarget) error {
blockORef, err := wshclient.VDomCreateContextCommand(
c.RpcClient,
vdom.VDomCreateContext{Target: target},
&wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)},
)
if err != nil {
return err
}
c.VDomContextBlockId = blockORef.OID
log.Printf("created vdom context: %v\n", blockORef)
gotRoute, err := wshclient.WaitForRouteCommand(c.RpcClient, wshrpc.CommandWaitForRouteData{
RouteId: wshutil.MakeFeBlockRouteId(blockORef.OID),
WaitMs: 4000,
}, &wshrpc.RpcOpts{Timeout: 5000})
if err != nil {
return fmt.Errorf("error waiting for vdom context route: %v", err)
}
if !gotRoute {
return fmt.Errorf("vdom context route could not be established")
}
wshclient.EventSubCommand(c.RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{
blockORef.String(),
}}, nil)
c.RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) {
c.doShutdown("got blockclose event")
})
return nil
}
func (c *Client) SendAsyncInitiation() error {
if c.VDomContextBlockId == "" {
return fmt.Errorf("no vdom context block id")
}
if c.GetIsDone() {
return fmt.Errorf("client is done")
}
return wshclient.VDomAsyncInitiationCommand(
c.RpcClient,
vdom.MakeAsyncInitiationRequest(c.RpcContext.BlockId),
&wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.VDomContextBlockId)},
)
}
func (c *Client) SetAtomVals(m map[string]any) {
for k, v := range m {
c.Root.SetAtomVal(k, v, true)
}
}
func (c *Client) SetAtomVal(name string, val any) {
c.Root.SetAtomVal(name, val, true)
}
func (c *Client) GetAtomVal(name string) any {
return c.Root.GetAtomVal(name)
}
func makeNullVDom() *vdom.VDomElem {
return &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag}
}
func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) {
c.Root.RunWork()
c.Root.Render(c.RootElem)
renderedVDom := c.Root.MakeVDom()
if renderedVDom == nil {
renderedVDom = makeNullVDom()
}
return &vdom.VDomBackendUpdate{
Type: "backendupdate",
Ts: time.Now().UnixMilli(),
BlockId: c.RpcContext.BlockId,
Opts: &c.Opts,
RenderUpdates: []vdom.VDomRenderUpdate{
{UpdateType: "root", VDom: *renderedVDom},
},
StateSync: c.Root.GetStateSync(true),
}, nil
}
func (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) {
c.Root.RunWork()
renderedVDom := c.Root.MakeVDom()
if renderedVDom == nil {
renderedVDom = makeNullVDom()
}
return &vdom.VDomBackendUpdate{
Type: "backendupdate",
Ts: time.Now().UnixMilli(),
BlockId: c.RpcContext.BlockId,
RenderUpdates: []vdom.VDomRenderUpdate{
{UpdateType: "root", VDom: *renderedVDom},
},
StateSync: c.Root.GetStateSync(false),
}, nil
}

View File

@ -14,8 +14,8 @@ import (
) )
func AcquireWaveLock() (FDLock, error) { func AcquireWaveLock() (FDLock, error) {
homeDir := GetWaveHomeDir() dataHomeDir := GetWaveDataDir()
lockFileName := filepath.Join(homeDir, WaveLockFile) lockFileName := filepath.Join(dataHomeDir, WaveLockFile)
log.Printf("[base] acquiring lock on %s\n", lockFileName) log.Printf("[base] acquiring lock on %s\n", lockFileName)
fd, err := os.OpenFile(lockFileName, os.O_RDWR|os.O_CREATE, 0600) fd, err := os.OpenFile(lockFileName, os.O_RDWR|os.O_CREATE, 0600)
if err != nil { if err != nil {

View File

@ -14,8 +14,8 @@ import (
) )
func AcquireWaveLock() (FDLock, error) { func AcquireWaveLock() (FDLock, error) {
homeDir := GetWaveHomeDir() dataHomeDir := GetWaveDataDir()
lockFileName := filepath.Join(homeDir, WaveLockFile) lockFileName := filepath.Join(dataHomeDir, WaveLockFile)
log.Printf("[base] acquiring lock on %s\n", lockFileName) log.Printf("[base] acquiring lock on %s\n", lockFileName)
m, err := filemutex.New(lockFileName) m, err := filemutex.New(lockFileName)
if err != nil { if err != nil {

View File

@ -17,22 +17,26 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/wavetermdev/waveterm/pkg/util/panic"
) )
// set by main-server.go // set by main-server.go
var WaveVersion = "0.0.0" var WaveVersion = "0.0.0"
var BuildTime = "0" var BuildTime = "0"
const DefaultWaveHome = "~/.waveterm" const WaveConfigHomeEnvVar = "WAVETERM_CONFIG_HOME"
const DevWaveHome = "~/.waveterm-dev" const WaveDataHomeEnvVar = "WAVETERM_DATA_HOME"
const WaveHomeVarName = "WAVETERM_HOME"
const WaveDevVarName = "WAVETERM_DEV" const WaveDevVarName = "WAVETERM_DEV"
const WaveLockFile = "wave.lock" const WaveLockFile = "wave.lock"
const DomainSocketBaseName = "wave.sock" const DomainSocketBaseName = "wave.sock"
const RemoteDomainSocketBaseName = "wave-remote.sock"
const WaveDBDir = "db" const WaveDBDir = "db"
const JwtSecret = "waveterm" // TODO generate and store this const JwtSecret = "waveterm" // TODO generate and store this
const ConfigDir = "config" const ConfigDir = "config"
var RemoteWaveHome = ExpandHomeDirSafe("~/.waveterm")
const WaveAppPathVarName = "WAVETERM_APP_PATH" const WaveAppPathVarName = "WAVETERM_APP_PATH"
const AppPathBinDir = "bin" const AppPathBinDir = "bin"
@ -97,30 +101,43 @@ func ReplaceHomeDir(pathStr string) string {
} }
func GetDomainSocketName() string { func GetDomainSocketName() string {
return filepath.Join(GetWaveHomeDir(), DomainSocketBaseName) return filepath.Join(GetWaveDataDir(), DomainSocketBaseName)
} }
func GetWaveHomeDir() string { func GetRemoteDomainSocketName() string {
homeVar := os.Getenv(WaveHomeVarName) return filepath.Join(RemoteWaveHome, RemoteDomainSocketBaseName)
if homeVar != "" {
return ExpandHomeDirSafe(homeVar)
}
if IsDevMode() {
return ExpandHomeDirSafe(DevWaveHome)
}
return ExpandHomeDirSafe(DefaultWaveHome)
} }
func EnsureWaveHomeDir() error { func GetWaveDataDir() string {
return CacheEnsureDir(GetWaveHomeDir(), "wavehome", 0700, "wave home directory") retVal, found := os.LookupEnv(WaveDataHomeEnvVar)
if !found {
panic.Panic(WaveDataHomeEnvVar + " not set")
}
return retVal
}
func GetWaveConfigDir() string {
retVal, found := os.LookupEnv(WaveConfigHomeEnvVar)
if !found {
panic.Panic(WaveConfigHomeEnvVar + " not set")
}
return retVal
}
func EnsureWaveDataDir() error {
return CacheEnsureDir(GetWaveDataDir(), "wavehome", 0700, "wave home directory")
} }
func EnsureWaveDBDir() error { func EnsureWaveDBDir() error {
return CacheEnsureDir(filepath.Join(GetWaveHomeDir(), WaveDBDir), "wavedb", 0700, "wave db directory") return CacheEnsureDir(filepath.Join(GetWaveDataDir(), WaveDBDir), "wavedb", 0700, "wave db directory")
} }
func EnsureWaveConfigDir() error { func EnsureWaveConfigDir() error {
return CacheEnsureDir(filepath.Join(GetWaveHomeDir(), ConfigDir), "waveconfig", 0700, "wave config directory") return CacheEnsureDir(GetWaveConfigDir(), "waveconfig", 0700, "wave config directory")
}
func EnsureWavePresetsDir() error {
return CacheEnsureDir(filepath.Join(GetWaveConfigDir(), "presets"), "wavepresets", 0700, "wave presets directory")
} }
func CacheEnsureDir(dirName string, cacheKey string, perm os.FileMode, dirDesc string) error { func CacheEnsureDir(dirName string, cacheKey string, perm os.FileMode, dirDesc string) error {

Some files were not shown because too many files have changed in this diff Show More