mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-21 16:38:23 +01:00
merge dev-v0.9
This commit is contained in:
commit
455af19387
2
.gitattributes
vendored
2
.gitattributes
vendored
@ -1 +1 @@
|
||||
* text=auto
|
||||
* text=auto eol=lf
|
17
.github/workflows/publish-release.yml
vendored
17
.github/workflows/publish-release.yml
vendored
@ -7,6 +7,7 @@ on:
|
||||
types: [published]
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@ -16,10 +17,24 @@ jobs:
|
||||
version: 3.x
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish from staging
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: "task artifacts:publish:${{ github.ref_name }}"
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}"
|
||||
AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}"
|
||||
AWS_DEFAULT_REGION: us-west-2
|
||||
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
|
||||
|
@ -29,6 +29,12 @@ Also available as a Homebrew Cask for macOS:
|
||||
brew install --cask wave
|
||||
```
|
||||
|
||||
Also available via the Windows Package Manager:
|
||||
|
||||
```Powershell
|
||||
winget install CommandLine.Wave
|
||||
```
|
||||
|
||||
### Minimum requirements
|
||||
|
||||
Wave Terminal and WSH run on the following platforms:
|
||||
|
15
Taskfile.yml
15
Taskfile.yml
@ -13,6 +13,7 @@ vars:
|
||||
DATE: '{{if eq OS "windows"}}powershell Get-Date -UFormat{{else}}date{{end}}'
|
||||
ARTIFACTS_BUCKET: waveterm-github-artifacts/staging-w2
|
||||
RELEASES_BUCKET: dl.waveterm.dev/releases-w2
|
||||
WINGET_PACKAGE: CommandLine.Wave
|
||||
|
||||
tasks:
|
||||
electron:dev:
|
||||
@ -184,8 +185,8 @@ tasks:
|
||||
generate:
|
||||
desc: Generate Typescript bindings for the Go backend.
|
||||
cmds:
|
||||
- go run cmd/generatets/main-generatets.go
|
||||
- go run cmd/generatego/main-generatego.go
|
||||
- NO_PANIC=1 go run cmd/generatets/main-generatets.go
|
||||
- NO_PANIC=1 go run cmd/generatego/main-generatego.go
|
||||
sources:
|
||||
- "cmd/generatego/*.go"
|
||||
- "cmd/generatets/*.go"
|
||||
@ -230,6 +231,16 @@ tasks:
|
||||
fi
|
||||
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:
|
||||
desc: Runs `yarn`
|
||||
internal: true
|
||||
|
@ -29,6 +29,7 @@ func GenerateWshClient() error {
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj",
|
||||
"github.com/wavetermdev/waveterm/pkg/wconfig",
|
||||
"github.com/wavetermdev/waveterm/pkg/wps",
|
||||
"github.com/wavetermdev/waveterm/pkg/vdom",
|
||||
})
|
||||
wshDeclMap := wshrpc.GenerateWshCommandDeclMap()
|
||||
for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) {
|
||||
|
@ -159,11 +159,11 @@ func shutdownActivityUpdate() {
|
||||
|
||||
func createMainWshClient() {
|
||||
rpc := wshserver.GetMainRpcClient()
|
||||
wshutil.DefaultRouter.RegisterRoute(wshutil.DefaultRoute, rpc)
|
||||
wshutil.DefaultRouter.RegisterRoute(wshutil.DefaultRoute, rpc, true)
|
||||
wps.Broker.SetClient(wshutil.DefaultRouter)
|
||||
localConnWsh := wshutil.MakeWshRpc(nil, nil, wshrpc.RpcContext{Conn: wshrpc.LocalConnName}, &wshremote.ServerImpl{})
|
||||
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() {
|
||||
@ -182,7 +182,7 @@ func main() {
|
||||
log.Printf("error validating service map: %v\n", err)
|
||||
return
|
||||
}
|
||||
err = wavebase.EnsureWaveHomeDir()
|
||||
err = wavebase.EnsureWaveDataDir()
|
||||
if err != nil {
|
||||
log.Printf("error ensuring wave home dir: %v\n", err)
|
||||
return
|
||||
@ -197,6 +197,13 @@ func main() {
|
||||
log.Printf("error ensuring wave config dir: %v\n", err)
|
||||
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()
|
||||
if err != nil {
|
||||
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 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()
|
||||
if err != nil {
|
||||
log.Printf("error initializing filestore: %v\n", err)
|
||||
|
@ -14,7 +14,7 @@ import (
|
||||
|
||||
func Page(ctx context.Context, props map[string]any) any {
|
||||
clicked, setClicked := vdom.UseState(ctx, false)
|
||||
var clickedDiv *vdom.Elem
|
||||
var clickedDiv *vdom.VDomElem
|
||||
if clicked {
|
||||
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 {
|
||||
ref := vdom.UseRef(ctx, nil)
|
||||
ref := vdom.UseVDomRef(ctx)
|
||||
clName, setClName := vdom.UseState(ctx, "button")
|
||||
vdom.UseEffect(ctx, func() func() {
|
||||
fmt.Printf("Button useEffect\n")
|
||||
|
@ -5,6 +5,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/wavetermdev/waveterm/pkg/remote"
|
||||
@ -25,17 +26,24 @@ func init() {
|
||||
}
|
||||
|
||||
func connStatus() error {
|
||||
resp, err := wshclient.ConnStatusCommand(RpcClient, nil)
|
||||
var allResp []wshrpc.ConnStatus
|
||||
sshResp, err := wshclient.ConnStatusCommand(RpcClient, 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")
|
||||
return nil
|
||||
}
|
||||
WriteStdout("%-30s %-12s\n", "connection", "status")
|
||||
WriteStdout("----------------------------------------------\n")
|
||||
for _, conn := range resp {
|
||||
for _, conn := range allResp {
|
||||
str := fmt.Sprintf("%-30s %-12s", conn.Connection, conn.Status)
|
||||
if conn.Error != "" {
|
||||
str += fmt.Sprintf(" (%s)", conn.Error)
|
||||
@ -110,7 +118,7 @@ func connRun(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
connName = args[1]
|
||||
_, err := remote.ParseOpts(connName)
|
||||
if err != nil {
|
||||
if err != nil && !strings.HasPrefix(connName, "wsl://") {
|
||||
return fmt.Errorf("cannot parse connection name: %w", err)
|
||||
}
|
||||
}
|
||||
|
@ -4,29 +4,186 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"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/wshutil"
|
||||
)
|
||||
|
||||
var serverCmd = &cobra.Command{
|
||||
Use: "connserver",
|
||||
Hidden: true,
|
||||
Short: "remote server to power wave blocks",
|
||||
Args: cobra.NoArgs,
|
||||
Run: serverRun,
|
||||
PreRunE: preRunSetupRpcClient,
|
||||
Use: "connserver",
|
||||
Hidden: true,
|
||||
Short: "remote server to power wave blocks",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: serverRun,
|
||||
}
|
||||
|
||||
var connServerRouter bool
|
||||
|
||||
func init() {
|
||||
serverCmd.Flags().BoolVar(&connServerRouter, "router", false, "run in local router mode")
|
||||
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)
|
||||
go wshremote.RunSysInfoLoop(RpcClient, RpcContext.Conn)
|
||||
RpcClient.SetServerImpl(&wshremote.ServerImpl{LogWriter: os.Stdout})
|
||||
|
||||
select {} // run forever
|
||||
}
|
||||
|
||||
func serverRun(cmd *cobra.Command, args []string) error {
|
||||
if connServerRouter {
|
||||
return serverRunRouter()
|
||||
} else {
|
||||
return serverRunNormal()
|
||||
}
|
||||
}
|
||||
|
47
cmd/wsh/cmd/wshcmd-debug.go
Normal file
47
cmd/wsh/cmd/wshcmd-debug.go
Normal 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
|
||||
}
|
@ -65,11 +65,11 @@ func editorRun(cmd *cobra.Command, args []string) {
|
||||
return
|
||||
}
|
||||
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()) {
|
||||
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
|
||||
}
|
||||
|
@ -4,40 +4,85 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/wavetermdev/waveterm/pkg/vdom"
|
||||
"github.com/wavetermdev/waveterm/pkg/vdom/vdomclient"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
||||
)
|
||||
|
||||
var htmlCmdNewBlock bool
|
||||
|
||||
func init() {
|
||||
htmlCmd.Flags().BoolVarP(&htmlCmdNewBlock, "newblock", "n", false, "create a new block")
|
||||
rootCmd.AddCommand(htmlCmd)
|
||||
}
|
||||
|
||||
var htmlCmd = &cobra.Command{
|
||||
Use: "html",
|
||||
Hidden: true,
|
||||
Short: "Launch a demo html-mode terminal",
|
||||
Run: htmlRun,
|
||||
PreRunE: preRunSetupRpcClient,
|
||||
Use: "html",
|
||||
Hidden: true,
|
||||
Short: "launch demo vdom application",
|
||||
RunE: htmlRun,
|
||||
}
|
||||
|
||||
func htmlRun(cmd *cobra.Command, args []string) {
|
||||
defer wshutil.DoShutdown("normal exit", 0, true)
|
||||
setTermHtmlMode()
|
||||
for {
|
||||
var buf [1]byte
|
||||
_, err := WrappedStdin.Read(buf[:])
|
||||
if err != nil {
|
||||
wshutil.DoShutdown(fmt.Sprintf("stdin closed/error (%v)", err), 1, true)
|
||||
}
|
||||
if buf[0] == 0x03 {
|
||||
wshutil.DoShutdown("read Ctrl-C from stdin", 1, true)
|
||||
break
|
||||
}
|
||||
if buf[0] == 'x' {
|
||||
wshutil.DoShutdown("read 'x' from stdin", 0, true)
|
||||
break
|
||||
}
|
||||
func MakeVDom() *vdom.VDomElem {
|
||||
vdomStr := `
|
||||
<div>
|
||||
<h1 style="color:red; background-color: #bind:$.bgcolor; border-radius: 4px; padding: 5px;">hello vdom world</h1>
|
||||
<div><bind key="$.text"/> | num[<bind key="$.num"/>]</div>
|
||||
<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 {
|
||||
return err
|
||||
}
|
||||
client.SetGlobalEventHandler(GlobalEventHandler)
|
||||
log.Printf("created client: %v\n", client)
|
||||
client.SetAtomVal("bgcolor", "#0000ff77")
|
||||
client.SetAtomVal("text", "initial text")
|
||||
client.SetAtomVal("num", 0)
|
||||
client.SetRootElem(MakeVDom())
|
||||
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
|
||||
}
|
||||
|
@ -71,6 +71,22 @@ func preRunSetupRpcClient(cmd *cobra.Command, args []string) error {
|
||||
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)
|
||||
func setupRpcClient(serverImpl wshutil.ServerImpl) error {
|
||||
jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName)
|
||||
@ -101,7 +117,7 @@ func setupRpcClient(serverImpl wshutil.ServerImpl) error {
|
||||
func setTermHtmlMode() {
|
||||
wshutil.SetExtraShutdownFunc(extraShutdownFn)
|
||||
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)
|
||||
if err != nil {
|
||||
|
@ -28,9 +28,9 @@ var webOpenCmd = &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",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Args: cobra.ExactArgs(1),
|
||||
Hidden: true,
|
||||
RunE: webGetRun,
|
||||
}
|
||||
@ -51,7 +51,7 @@ func init() {
|
||||
}
|
||||
|
||||
func webGetRun(cmd *cobra.Command, args []string) error {
|
||||
oref := args[0]
|
||||
oref := blockArg
|
||||
if oref == "" {
|
||||
return fmt.Errorf("blockid not specified")
|
||||
}
|
||||
@ -67,14 +67,14 @@ func webGetRun(cmd *cobra.Command, args []string) error {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
data := wshrpc.CommandWebSelectorData{
|
||||
WindowId: blockInfo.WindowId,
|
||||
BlockId: fullORef.OID,
|
||||
TabId: blockInfo.TabId,
|
||||
Selector: args[1],
|
||||
Selector: args[0],
|
||||
Opts: &wshrpc.WebSelectorOpts{
|
||||
Inner: webGetInner,
|
||||
All: webGetAll,
|
||||
|
60
cmd/wsh/cmd/wshcmd-wsl.go
Normal file
60
cmd/wsh/cmd/wshcmd-wsl.go
Normal 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)
|
||||
}
|
1
db/migrations-wstore/000005_blockparent.down.sql
Normal file
1
db/migrations-wstore/000005_blockparent.down.sql
Normal file
@ -0,0 +1 @@
|
||||
-- we don't need to remove parentoref
|
4
db/migrations-wstore/000005_blockparent.up.sql
Normal file
4
db/migrations-wstore/000005_blockparent.up.sql
Normal 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'));
|
@ -1,6 +1,6 @@
|
||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||
import { fetch } from "@/util/fetchutil";
|
||||
import { ipcMain } from "electron";
|
||||
import { getWebServerEndpoint } from "../frontend/util/endpoints";
|
||||
import { fetch } from "../frontend/util/fetchutil";
|
||||
|
||||
const docsiteWebUrl = "https://docs.waveterm.dev/";
|
||||
let docsiteUrl: string;
|
||||
|
54
emain/emain-activity.ts
Normal file
54
emain/emain-activity.ts
Normal 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
168
emain/emain-util.ts
Normal 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
558
emain/emain-viewmgr.ts
Normal 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
128
emain/emain-wavesrv.ts
Normal 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;
|
||||
}
|
@ -1,13 +1,13 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// 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 randId = Math.floor(Math.random() * 1000000000).toString();
|
||||
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) => {
|
||||
if (webContentsId == null) {
|
||||
resolve(null);
|
||||
|
@ -1,12 +1,11 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import electron from "electron";
|
||||
import { Notification } from "electron";
|
||||
import { RpcResponseHelper, WshClient } from "../frontend/app/store/wshclient";
|
||||
import { getWaveWindowById } from "./emain-viewmgr";
|
||||
import { getWebContentsByBlockId, webGetSelector } from "./emain-web";
|
||||
|
||||
type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise<void> };
|
||||
|
||||
export class ElectronWshClientType extends WshClient {
|
||||
constructor() {
|
||||
super("electron");
|
||||
@ -16,12 +15,11 @@ export class ElectronWshClientType extends WshClient {
|
||||
if (!data.tabid || !data.blockid || !data.windowid) {
|
||||
throw new Error("tabid and blockid are required");
|
||||
}
|
||||
const windows = electron.BrowserWindow.getAllWindows();
|
||||
const win = windows.find((w) => (w as WaveBrowserWindow).waveWindowId === data.windowid);
|
||||
if (win == null) {
|
||||
const ww = getWaveWindowById(data.windowid);
|
||||
if (ww == null) {
|
||||
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) {
|
||||
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) {
|
||||
new electron.Notification({
|
||||
new Notification({
|
||||
title: notificationOptions.title,
|
||||
body: notificationOptions.body,
|
||||
silent: notificationOptions.silent,
|
||||
|
749
emain/emain.ts
749
emain/emain.ts
@ -7,71 +7,70 @@ import fs from "fs";
|
||||
import * as child_process from "node:child_process";
|
||||
import * as path from "path";
|
||||
import { PNG } from "pngjs";
|
||||
import * as readline from "readline";
|
||||
import { sprintf } from "sprintf-js";
|
||||
import { Readable } from "stream";
|
||||
import { debounce } from "throttle-debounce";
|
||||
import * as util from "util";
|
||||
import winston from "winston";
|
||||
import * as services from "../frontend/app/store/services";
|
||||
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 * as keyutil from "../frontend/util/keyutil";
|
||||
import { fireAndForget } from "../frontend/util/util";
|
||||
import { AuthKey, AuthKeyEnv, configureAuthKeyRequestInjection } from "./authkey";
|
||||
import { AuthKey, configureAuthKeyRequestInjection } from "./authkey";
|
||||
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 { getLaunchSettings } from "./launchsettings";
|
||||
import { getAppMenu } from "./menu";
|
||||
import {
|
||||
getElectronAppBasePath,
|
||||
getElectronAppUnpackedBasePath,
|
||||
getWaveHomeDir,
|
||||
getWaveSrvCwd,
|
||||
getWaveSrvPath,
|
||||
getWaveConfigDir,
|
||||
getWaveDataDir,
|
||||
isDev,
|
||||
isDevVite,
|
||||
unameArch,
|
||||
unamePlatform,
|
||||
} from "./platform";
|
||||
import { configureAutoUpdater, updater } from "./updater";
|
||||
|
||||
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 WaveSrvReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID";
|
||||
const waveDataDir = getWaveDataDir();
|
||||
const waveConfigDir = getWaveConfigDir();
|
||||
|
||||
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 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 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) {
|
||||
loggerTransports.push(new winston.transports.Console());
|
||||
@ -79,7 +78,7 @@ if (isDev) {
|
||||
const loggerConfig = {
|
||||
level: "info",
|
||||
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}`)
|
||||
),
|
||||
transports: loggerTransports,
|
||||
@ -95,8 +94,9 @@ function log(...msg: any[]) {
|
||||
console.log = log;
|
||||
console.log(
|
||||
sprintf(
|
||||
"waveterm-app starting, WAVETERM_HOME=%s, electronpath=%s gopath=%s arch=%s/%s",
|
||||
waveHome,
|
||||
"waveterm-app starting, data_dir=%s, config_dir=%s electronpath=%s gopath=%s arch=%s/%s",
|
||||
waveDataDir,
|
||||
waveConfigDir,
|
||||
getElectronAppBasePath(),
|
||||
getElectronAppUnpackedBasePath(),
|
||||
unamePlatform,
|
||||
@ -107,125 +107,6 @@ if (isDev) {
|
||||
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) {
|
||||
console.log("handleWSEvent", evtMsg?.eventtype);
|
||||
if (evtMsg.eventtype == "electron:newwindow") {
|
||||
@ -236,391 +117,21 @@ async function handleWSEvent(evtMsg: WSEventType) {
|
||||
}
|
||||
const clientData = await services.ClientService.GetClientData();
|
||||
const fullConfig = await services.FileService.GetFullConfig();
|
||||
const newWin = createBrowserWindow(clientData.oid, windowData, fullConfig);
|
||||
await newWin.readyPromise;
|
||||
const newWin = createBrowserWindow(clientData.oid, windowData, fullConfig, { unamePlatform });
|
||||
await newWin.waveReadyPromise;
|
||||
newWin.show();
|
||||
} else if (evtMsg.eventtype == "electron:closewindow") {
|
||||
if (evtMsg.data === undefined) return;
|
||||
const windows = electron.BrowserWindow.getAllWindows();
|
||||
for (const window of windows) {
|
||||
if ((window as any).waveWindowId === evtMsg.data) {
|
||||
// Bypass the "Are you sure?" dialog, since this event is called when there's no more tabs for the window.
|
||||
window.destroy();
|
||||
}
|
||||
const ww = getWaveWindowById(evtMsg.data);
|
||||
if (ww != null) {
|
||||
ww.alreadyClosed = true;
|
||||
ww.destroy(); // bypass the "are you sure?" dialog
|
||||
}
|
||||
} else {
|
||||
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
|
||||
electron.ipcMain.on("open-external", (event, url) => {
|
||||
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 }) => {
|
||||
const menu = new electron.Menu();
|
||||
const win = electron.BrowserWindow.fromWebContents(event.sender.hostWebContents);
|
||||
const win = getWaveWindowByWebContentsId(event.sender.hostWebContents.id);
|
||||
if (win == null) {
|
||||
return;
|
||||
}
|
||||
@ -733,19 +244,66 @@ electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent,
|
||||
);
|
||||
const { x, y } = electron.screen.getCursorScreenPoint();
|
||||
const windowPos = win.getPosition();
|
||||
menu.popup({ window: win, x: x - windowPos[0], y: y - windowPos[1] });
|
||||
menu.popup();
|
||||
});
|
||||
|
||||
electron.ipcMain.on("download", (event, payload) => {
|
||||
const window = electron.BrowserWindow.fromWebContents(event.sender);
|
||||
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) => {
|
||||
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 windowRect = window.getContentBounds();
|
||||
const windowRect = tabView.getBounds();
|
||||
const retVal: Electron.Point = {
|
||||
x: screenPoint.x - windowRect.x,
|
||||
y: screenPoint.y - windowRect.y,
|
||||
@ -758,7 +316,7 @@ electron.ipcMain.on("get-env", (event, varName) => {
|
||||
});
|
||||
|
||||
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>();
|
||||
@ -825,8 +383,8 @@ if (unamePlatform !== "darwin") {
|
||||
const overlayBuffer = overlay.toPNG();
|
||||
const png = PNG.sync.read(overlayBuffer);
|
||||
const color = fac.prepareResult(fac.getColorFromArray4(png.data));
|
||||
const window = electron.BrowserWindow.fromWebContents(event.sender);
|
||||
window.setTitleBarOverlay({
|
||||
const ww = getWaveWindowByWebContentsId(event.sender.id);
|
||||
ww.setTitleBarOverlay({
|
||||
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",
|
||||
});
|
||||
@ -848,13 +406,14 @@ async function createNewWaveWindow(): Promise<void> {
|
||||
const clientData = await services.ClientService.GetClientData();
|
||||
const fullConfig = await services.FileService.GetFullConfig();
|
||||
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
|
||||
const existingWindowId = clientData.windowids[0];
|
||||
const existingWindowData = (await services.ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow;
|
||||
if (existingWindowData != null) {
|
||||
const win = createBrowserWindow(clientData.oid, existingWindowData, fullConfig);
|
||||
await win.readyPromise;
|
||||
const win = createBrowserWindow(clientData.oid, existingWindowData, fullConfig, { unamePlatform });
|
||||
await win.waveReadyPromise;
|
||||
win.show();
|
||||
recreatedWindow = true;
|
||||
}
|
||||
@ -863,16 +422,37 @@ async function createNewWaveWindow(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const newWindow = await services.ClientService.MakeWindow();
|
||||
const newBrowserWindow = createBrowserWindow(clientData.oid, newWindow, fullConfig);
|
||||
await newBrowserWindow.readyPromise;
|
||||
const newBrowserWindow = createBrowserWindow(clientData.oid, newWindow, fullConfig, { unamePlatform });
|
||||
await newBrowserWindow.waveReadyPromise;
|
||||
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) {
|
||||
if (defaultFileName == null || defaultFileName == "") {
|
||||
defaultFileName = "image";
|
||||
}
|
||||
const window = electron.BrowserWindow.getFocusedWindow(); // Get the current window context
|
||||
const ww = getFocusedWaveWindow();
|
||||
const mimeToExtension: { [key: string]: string } = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
@ -891,7 +471,7 @@ function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string
|
||||
}
|
||||
defaultFileName = addExtensionIfNeeded(defaultFileName, mimeType);
|
||||
electron.dialog
|
||||
.showSaveDialog(window, {
|
||||
.showSaveDialog(ww, {
|
||||
title: "Save Image",
|
||||
defaultPath: defaultFileName,
|
||||
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("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => {
|
||||
const window = electron.BrowserWindow.fromWebContents(event.sender);
|
||||
if (menuDefArr?.length === 0) {
|
||||
return;
|
||||
}
|
||||
const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : instantiateAppMenu();
|
||||
const { x, y } = electron.screen.getCursorScreenPoint();
|
||||
const windowPos = window.getPosition();
|
||||
|
||||
menu.popup({ window, x: x - windowPos[0], y: y - windowPos[1] });
|
||||
// const { x, y } = electron.screen.getCursorScreenPoint();
|
||||
// const windowPos = window.getPosition();
|
||||
menu.popup();
|
||||
event.returnValue = true;
|
||||
});
|
||||
|
||||
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");
|
||||
try {
|
||||
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);
|
||||
} finally {
|
||||
// for next iteration
|
||||
wasInFg = electron.BrowserWindow.getFocusedWindow()?.isFocused() ?? false;
|
||||
wasActive = false;
|
||||
const ww = getFocusedWaveWindow();
|
||||
setWasInFg(ww?.isFocused() ?? false);
|
||||
setWasActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -966,7 +546,9 @@ function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electro
|
||||
label: menuDef.label,
|
||||
type: menuDef.type,
|
||||
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,
|
||||
};
|
||||
@ -980,7 +562,11 @@ function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electro
|
||||
}
|
||||
|
||||
function instantiateAppMenu(): electron.Menu {
|
||||
return getAppMenu({ createNewWaveWindow, relaunchBrowserWindows });
|
||||
return getAppMenu({
|
||||
createNewWaveWindow,
|
||||
relaunchBrowserWindows,
|
||||
getLastFocusedWaveWindow: getLastFocusedWaveWindow,
|
||||
});
|
||||
}
|
||||
|
||||
function makeAppMenu() {
|
||||
@ -989,7 +575,7 @@ function makeAppMenu() {
|
||||
}
|
||||
|
||||
electronApp.on("window-all-closed", () => {
|
||||
if (globalIsRelaunching) {
|
||||
if (getGlobalIsRelaunching()) {
|
||||
return;
|
||||
}
|
||||
if (unamePlatform !== "darwin") {
|
||||
@ -997,32 +583,32 @@ electronApp.on("window-all-closed", () => {
|
||||
}
|
||||
});
|
||||
electronApp.on("before-quit", (e) => {
|
||||
globalIsQuitting = true;
|
||||
setGlobalIsQuitting(true);
|
||||
updater?.stop();
|
||||
if (unamePlatform == "win32") {
|
||||
// win32 doesn't have a SIGINT, so we just let electron die, which
|
||||
// ends up killing wavesrv via closing it's stdin.
|
||||
return;
|
||||
}
|
||||
waveSrvProc?.kill("SIGINT");
|
||||
getWaveSrvProc()?.kill("SIGINT");
|
||||
shutdownWshrpc();
|
||||
if (forceQuit) {
|
||||
if (getForceQuit()) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const allWindows = electron.BrowserWindow.getAllWindows();
|
||||
const allWindows = getAllWaveWindows();
|
||||
for (const window of allWindows) {
|
||||
window.hide();
|
||||
}
|
||||
if (isWaveSrvDead) {
|
||||
if (getIsWaveSrvDead()) {
|
||||
console.log("wavesrv is dead, quitting immediately");
|
||||
forceQuit = true;
|
||||
setForceQuit(true);
|
||||
electronApp.quit();
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
console.log("waiting for wavesrv to exit...");
|
||||
forceQuit = true;
|
||||
setForceQuit(true);
|
||||
electronApp.quit();
|
||||
}, 3000);
|
||||
});
|
||||
@ -1051,13 +637,13 @@ process.on("uncaughtException", (error) => {
|
||||
});
|
||||
|
||||
async function relaunchBrowserWindows(): Promise<void> {
|
||||
globalIsRelaunching = true;
|
||||
const windows = electron.BrowserWindow.getAllWindows();
|
||||
setGlobalIsRelaunching(true);
|
||||
const windows = getAllWaveWindows();
|
||||
for (const window of windows) {
|
||||
window.removeAllListeners();
|
||||
window.close();
|
||||
}
|
||||
globalIsRelaunching = false;
|
||||
setGlobalIsRelaunching(false);
|
||||
|
||||
const clientData = await services.ClientService.GetClientData();
|
||||
const fullConfig = await services.FileService.GetFullConfig();
|
||||
@ -1065,16 +651,16 @@ async function relaunchBrowserWindows(): Promise<void> {
|
||||
for (const windowId of clientData.windowids.slice().reverse()) {
|
||||
const windowData: WaveWindow = (await services.ObjectService.GetObject("window:" + windowId)) as WaveWindow;
|
||||
if (windowData == null) {
|
||||
services.WindowService.CloseWindow(windowId).catch((e) => {
|
||||
services.WindowService.CloseWindow(windowId, true).catch((e) => {
|
||||
/* ignore */
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const win = createBrowserWindow(clientData.oid, windowData, fullConfig);
|
||||
const win = createBrowserWindow(clientData.oid, windowData, fullConfig, { unamePlatform });
|
||||
wins.push(win);
|
||||
}
|
||||
for (const win of wins) {
|
||||
await win.readyPromise;
|
||||
await win.waveReadyPromise;
|
||||
console.log("show window", win.waveWindowId);
|
||||
win.show();
|
||||
}
|
||||
@ -1087,7 +673,6 @@ async function appMain() {
|
||||
console.log("disabling hardware acceleration, per launch settings");
|
||||
electronApp.disableHardwareAcceleration();
|
||||
}
|
||||
|
||||
const startTs = Date.now();
|
||||
const instanceLock = electronApp.requestSingleInstanceLock();
|
||||
if (!instanceLock) {
|
||||
@ -1095,20 +680,18 @@ async function appMain() {
|
||||
electronApp.quit();
|
||||
return;
|
||||
}
|
||||
const waveHomeDir = getWaveHomeDir();
|
||||
if (!fs.existsSync(waveHomeDir)) {
|
||||
fs.mkdirSync(waveHomeDir);
|
||||
}
|
||||
makeAppMenu();
|
||||
try {
|
||||
await runWaveSrv();
|
||||
await runWaveSrv(handleWSEvent);
|
||||
} catch (e) {
|
||||
console.log(e.toString());
|
||||
}
|
||||
const ready = await waveSrvReady;
|
||||
const ready = await getWaveSrvReady();
|
||||
console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms");
|
||||
await electronApp.whenReady();
|
||||
configureAuthKeyRequestInjection(electron.session.defaultSession);
|
||||
const fullConfig = await services.FileService.GetFullConfig();
|
||||
ensureHotSpareTab(fullConfig);
|
||||
await relaunchBrowserWindows();
|
||||
await initDocsite();
|
||||
setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe
|
||||
@ -1120,10 +703,14 @@ async function appMain() {
|
||||
}
|
||||
await configureAutoUpdater();
|
||||
|
||||
globalIsStarting = false;
|
||||
setGlobalIsStarting(false);
|
||||
if (fullConfig?.settings?.["window:maxtabcachesize"] != null) {
|
||||
setMaxTabCacheSize(fullConfig.settings["window:maxtabcachesize"]);
|
||||
}
|
||||
|
||||
electronApp.on("activate", async () => {
|
||||
if (electron.BrowserWindow.getAllWindows().length === 0) {
|
||||
const allWindows = getAllWaveWindows();
|
||||
if (allWindows.length === 0) {
|
||||
await createNewWaveWindow();
|
||||
}
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getWaveHomeDir } from "./platform";
|
||||
import { getWaveConfigDir } from "./platform";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function getLaunchSettings(): SettingsType {
|
||||
const settingsPath = path.join(getWaveHomeDir(), "config", "settings.json");
|
||||
const settingsPath = path.join(getWaveConfigDir(), "settings.json");
|
||||
try {
|
||||
const settingsContents = fs.readFileSync(settingsPath, "utf8");
|
||||
return JSON.parse(settingsContents);
|
||||
|
@ -3,20 +3,26 @@
|
||||
|
||||
import * as electron from "electron";
|
||||
import { fireAndForget } from "../frontend/util/util";
|
||||
import { clearTabCache, getFocusedWaveWindow } from "./emain-viewmgr";
|
||||
import { unamePlatform } from "./platform";
|
||||
import { updater } from "./updater";
|
||||
|
||||
type AppMenuCallbacks = {
|
||||
createNewWaveWindow: () => Promise<void>;
|
||||
relaunchBrowserWindows: () => Promise<void>;
|
||||
getLastFocusedWaveWindow: () => WaveBrowserWindow;
|
||||
};
|
||||
|
||||
function getWindowWebContents(window: electron.BaseWindow): electron.WebContents {
|
||||
if (window == null) {
|
||||
return null;
|
||||
}
|
||||
if (window instanceof electron.BrowserWindow) {
|
||||
return window.webContents;
|
||||
if (window instanceof electron.BaseWindow) {
|
||||
const waveWin = window as WaveBrowserWindow;
|
||||
if (waveWin.activeTabView) {
|
||||
return waveWin.activeTabView.webContents;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -32,7 +38,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
role: "close",
|
||||
accelerator: "", // clear the accelerator
|
||||
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[] = [
|
||||
{
|
||||
role: "forceReload",
|
||||
label: "Reload Tab",
|
||||
accelerator: "Shift+CommandOrControl+R",
|
||||
click: (_, window) => {
|
||||
getWindowWebContents(window)?.reloadIgnoringCache();
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
@ -143,6 +165,9 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
if (wc == null) {
|
||||
return;
|
||||
}
|
||||
if (wc.getZoomFactor() >= 5) {
|
||||
return;
|
||||
}
|
||||
wc.setZoomFactor(wc.getZoomFactor() + 0.2);
|
||||
},
|
||||
},
|
||||
@ -154,6 +179,9 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
if (wc == null) {
|
||||
return;
|
||||
}
|
||||
if (wc.getZoomFactor() >= 5) {
|
||||
return;
|
||||
}
|
||||
wc.setZoomFactor(wc.getZoomFactor() + 0.2);
|
||||
},
|
||||
visible: false,
|
||||
@ -167,9 +195,28 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
if (wc == null) {
|
||||
return;
|
||||
}
|
||||
if (wc.getZoomFactor() <= 0.2) {
|
||||
return;
|
||||
}
|
||||
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",
|
||||
},
|
||||
|
@ -2,12 +2,18 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { app, ipcMain } from "electron";
|
||||
import envPaths from "env-paths";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { WaveDevVarName, WaveDevViteVarName } from "../frontend/util/isdev";
|
||||
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 isDevVite = isDev && process.env.ELECTRON_RENDERER_URL;
|
||||
@ -18,35 +24,100 @@ if (isDevVite) {
|
||||
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");
|
||||
const unamePlatform = process.platform;
|
||||
const unameArch: string = process.arch;
|
||||
keyutil.setKeyUtilPlatform(unamePlatform);
|
||||
|
||||
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");
|
||||
});
|
||||
const WaveConfigHomeVarName = "WAVETERM_CONFIG_HOME";
|
||||
const WaveDataHomeVarName = "WAVETERM_DATA_HOME";
|
||||
const WaveHomeVarName = "WAVETERM_HOME";
|
||||
|
||||
// must match golang
|
||||
function getWaveHomeDir() {
|
||||
const override = process.env[WaveHomeVarName];
|
||||
if (override) {
|
||||
return override;
|
||||
/**
|
||||
* Gets the path to the old Wave home directory (defaults to `~/.waveterm`).
|
||||
* @returns The path to the directory if it exists and contains valid data for the current app, otherwise null.
|
||||
*/
|
||||
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 {
|
||||
@ -69,17 +140,43 @@ function getWaveSrvPath(): 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 {
|
||||
getElectronAppBasePath,
|
||||
getElectronAppUnpackedBasePath,
|
||||
getWaveHomeDir,
|
||||
getWaveConfigDir,
|
||||
getWaveDataDir,
|
||||
getWaveSrvCwd,
|
||||
getWaveSrvPath,
|
||||
isDev,
|
||||
isDevVite,
|
||||
unameArch,
|
||||
unamePlatform,
|
||||
WaveConfigHomeVarName,
|
||||
WaveDataHomeVarName,
|
||||
};
|
||||
|
@ -10,6 +10,8 @@ contextBridge.exposeInMainWorld("api", {
|
||||
getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"),
|
||||
getUserName: () => ipcRenderer.sendSync("get-user-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"),
|
||||
getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"),
|
||||
getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"),
|
||||
@ -38,6 +40,12 @@ contextBridge.exposeInMainWorld("api", {
|
||||
registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys),
|
||||
onControlShiftStateUpdate: (callback) =>
|
||||
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),
|
||||
});
|
||||
|
||||
|
@ -1,15 +1,16 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { BrowserWindow, dialog, ipcMain, Notification } from "electron";
|
||||
import { dialog, ipcMain, Notification } from "electron";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import YAML from "yaml";
|
||||
import { FileService } from "../frontend/app/store/services";
|
||||
import { RpcApi } from "../frontend/app/store/wshclientapi";
|
||||
import { isDev } from "../frontend/util/isdev";
|
||||
import { fireAndForget } from "../frontend/util/util";
|
||||
import { getAllWaveWindows, getFocusedWaveWindow } from "./emain-viewmgr";
|
||||
import { ElectronWshClient } from "./emain-wsh";
|
||||
|
||||
export let updater: Updater;
|
||||
@ -109,8 +110,11 @@ export class Updater {
|
||||
|
||||
private set status(value: UpdaterStatus) {
|
||||
this._status = value;
|
||||
BrowserWindow.getAllWindows().forEach((window) => {
|
||||
window.webContents.send("app-update-status", value);
|
||||
getAllWaveWindows().forEach((window) => {
|
||||
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",
|
||||
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.
|
||||
@ -179,15 +183,14 @@ export class Updater {
|
||||
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) {
|
||||
await dialog
|
||||
.showMessageBox(BrowserWindow.getFocusedWindow() ?? allWindows[0], dialogOpts)
|
||||
.then(({ response }) => {
|
||||
if (response === 0) {
|
||||
this.installUpdate();
|
||||
}
|
||||
});
|
||||
const focusedWindow = getFocusedWaveWindow();
|
||||
await dialog.showMessageBox(focusedWindow ?? allWindows[0], dialogOpts).then(({ response }) => {
|
||||
if (response === 0) {
|
||||
this.installUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||
import * as util from "@/util/util";
|
||||
import useResizeObserver from "@react-hook/resize-observer";
|
||||
@ -72,7 +75,7 @@ function processBackgroundUrls(cssText: string): string {
|
||||
|
||||
export function AppBackground() {
|
||||
const bgRef = useRef<HTMLDivElement>(null);
|
||||
const tabId = useAtomValue(atoms.activeTabId);
|
||||
const tabId = useAtomValue(atoms.staticTabId);
|
||||
const [tabData] = useWaveObjectValue<Tab>(WOS.makeORef("tab", tabId));
|
||||
const bgAttr = tabData?.meta?.bg;
|
||||
const style: CSSProperties = {};
|
||||
|
@ -18,6 +18,10 @@ body {
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.is-transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
a.plain-link {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
@ -23,7 +23,10 @@ import { CenteredDiv } from "./element/quickelems";
|
||||
const dlog = debug("wave:app");
|
||||
const focusLog = debug("wave:focus");
|
||||
|
||||
const App = () => {
|
||||
const App = ({ onFirstRender }: { onFirstRender: () => void }) => {
|
||||
useEffect(() => {
|
||||
onFirstRender();
|
||||
}, []);
|
||||
return (
|
||||
<Provider store={globalStore}>
|
||||
<AppInner />
|
||||
@ -115,18 +118,20 @@ function AppSettingsUpdater() {
|
||||
(windowSettings?.["window:transparent"] || windowSettings?.["window:blur"]) ?? false;
|
||||
const opacity = util.boundNumber(windowSettings?.["window:opacity"] ?? 0.8, 0, 1);
|
||||
let baseBgColor = windowSettings?.["window:bgcolor"];
|
||||
let mainDiv = document.getElementById("main");
|
||||
// console.log("window settings", windowSettings, isTransparentOrBlur, opacity, baseBgColor, mainDiv);
|
||||
if (isTransparentOrBlur) {
|
||||
document.body.classList.add("is-transparent");
|
||||
mainDiv.classList.add("is-transparent");
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
if (baseBgColor == null) {
|
||||
baseBgColor = rootStyles.getPropertyValue("--main-bg-color").trim();
|
||||
}
|
||||
const color = new Color(baseBgColor);
|
||||
const rgbaColor = color.alpha(opacity).string();
|
||||
document.body.style.backgroundColor = rgbaColor;
|
||||
mainDiv.style.backgroundColor = rgbaColor;
|
||||
} else {
|
||||
document.body.classList.remove("is-transparent");
|
||||
document.body.style.opacity = null;
|
||||
mainDiv.classList.remove("is-transparent");
|
||||
mainDiv.style.opacity = null;
|
||||
}
|
||||
}, [windowSettings]);
|
||||
return null;
|
||||
|
@ -5,6 +5,8 @@ import { BlockComponentModel2, BlockProps } from "@/app/block/blocktypes";
|
||||
import { PlotView } from "@/app/view/plotview/plotview";
|
||||
import { PreviewModel, PreviewView, makePreviewModel } from "@/app/view/preview/preview";
|
||||
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 { CenteredDiv } from "@/element/quickelems";
|
||||
import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index";
|
||||
@ -30,6 +32,7 @@ import { BlockFrame } from "./blockframe";
|
||||
import { blockViewToIcon, blockViewToName } from "./blockutil";
|
||||
|
||||
type FullBlockProps = {
|
||||
isSubBlock?: boolean;
|
||||
preview: boolean;
|
||||
nodeModel: NodeModel;
|
||||
viewModel: ViewModel;
|
||||
@ -37,7 +40,7 @@ type FullBlockProps = {
|
||||
|
||||
function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel {
|
||||
if (blockView === "term") {
|
||||
return makeTerminalModel(blockId);
|
||||
return makeTerminalModel(blockId, nodeModel);
|
||||
}
|
||||
if (blockView === "preview") {
|
||||
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
|
||||
return makeSysinfoViewModel(blockId, blockView);
|
||||
}
|
||||
if (blockView == "vdom") {
|
||||
return makeVDomModel(blockId, nodeModel);
|
||||
}
|
||||
if (blockView === "help") {
|
||||
return makeHelpViewModel(blockId, nodeModel);
|
||||
}
|
||||
@ -110,6 +116,9 @@ function getViewElem(
|
||||
if (blockView == "chat") {
|
||||
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>;
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
counterInc("render-BlockFull");
|
||||
const focusElemRef = useRef<HTMLInputElement>(null);
|
||||
@ -265,7 +294,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
|
||||
|
||||
const Block = memo((props: BlockProps) => {
|
||||
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 bcm = getBlockComponentModel(props.nodeModel.blockId);
|
||||
let viewModel = bcm?.viewModel;
|
||||
@ -276,6 +305,7 @@ const Block = memo((props: BlockProps) => {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unregisterBlockComponentModel(props.nodeModel.blockId);
|
||||
viewModel?.dispose?.();
|
||||
};
|
||||
}, []);
|
||||
if (loading || isBlank(props.nodeModel.blockId) || blockData == null) {
|
||||
@ -284,6 +314,9 @@ const Block = memo((props: BlockProps) => {
|
||||
if (props.preview) {
|
||||
return <BlockPreview {...props} viewModel={viewModel} />;
|
||||
}
|
||||
if (props.isSubBlock) {
|
||||
return <BlockSubBlock {...props} viewModel={viewModel} />;
|
||||
}
|
||||
return <BlockFull {...props} viewModel={viewModel} />;
|
||||
});
|
||||
|
||||
|
@ -26,9 +26,8 @@ import {
|
||||
useBlockAtom,
|
||||
WOS,
|
||||
} from "@/app/store/global";
|
||||
import * as services from "@/app/store/services";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { WindowRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { ErrorBoundary } from "@/element/errorboundary";
|
||||
import { IconButton } from "@/element/iconbutton";
|
||||
import { MagnifyIcon } from "@/element/magnify";
|
||||
@ -60,17 +59,17 @@ function handleHeaderContextMenu(
|
||||
onMagnifyToggle();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Move to New Window",
|
||||
click: () => {
|
||||
const currentTabId = globalStore.get(atoms.activeTabId);
|
||||
try {
|
||||
services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid);
|
||||
} catch (e) {
|
||||
console.error("error moving block to new window", e);
|
||||
}
|
||||
},
|
||||
},
|
||||
// {
|
||||
// label: "Move to New Window",
|
||||
// click: () => {
|
||||
// const currentTabId = globalStore.get(atoms.staticTabId);
|
||||
// try {
|
||||
// services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid);
|
||||
// } catch (e) {
|
||||
// console.error("error moving block to new window", e);
|
||||
// }
|
||||
// },
|
||||
// },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Copy BlockId",
|
||||
@ -321,7 +320,7 @@ const ConnStatusOverlay = React.memo(
|
||||
}, [width, connStatus, setShowError]);
|
||||
|
||||
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));
|
||||
}, [connName]);
|
||||
|
||||
@ -437,7 +436,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
|
||||
const connName = blockData?.meta?.connection;
|
||||
if (!util.isBlank(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);
|
||||
});
|
||||
}
|
||||
@ -521,6 +520,7 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
const connStatusAtom = getConnStatusAtom(connection);
|
||||
const connStatus = jotai.useAtomValue(connStatusAtom);
|
||||
const [connList, setConnList] = React.useState<Array<string>>([]);
|
||||
const [wslList, setWslList] = React.useState<Array<string>>([]);
|
||||
const allConnStatus = jotai.useAtomValue(atoms.allConnStatus);
|
||||
const [rowIndex, setRowIndex] = React.useState(0);
|
||||
const connStatusMap = new Map<string, ConnStatus>();
|
||||
@ -536,10 +536,22 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
setConnList([]);
|
||||
return;
|
||||
}
|
||||
const prtn = RpcApi.ConnListCommand(WindowRpcClient, { timeout: 2000 });
|
||||
const prtn = RpcApi.ConnListCommand(TabRpcClient, { timeout: 2000 });
|
||||
prtn.then((newConnList) => {
|
||||
setConnList(newConnList ?? []);
|
||||
}).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]);
|
||||
|
||||
const changeConnection = React.useCallback(
|
||||
@ -557,12 +569,12 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
} else {
|
||||
newCwd = "~";
|
||||
}
|
||||
await RpcApi.SetMetaCommand(WindowRpcClient, {
|
||||
await RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", blockId),
|
||||
meta: { connection: connName, file: newCwd },
|
||||
});
|
||||
try {
|
||||
await RpcApi.ConnEnsureCommand(WindowRpcClient, connName, { timeout: 60000 });
|
||||
await RpcApi.ConnEnsureCommand(TabRpcClient, connName, { timeout: 60000 });
|
||||
} catch (e) {
|
||||
console.log("error connecting", blockId, connName, e);
|
||||
}
|
||||
@ -588,6 +600,15 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
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
|
||||
// for instance, when reconnecting
|
||||
const newConnectionSuggestion: SuggestionConnectionItem = {
|
||||
@ -608,7 +629,7 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
label: `Reconnect to ${connStatus.connection}`,
|
||||
value: "",
|
||||
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));
|
||||
},
|
||||
};
|
||||
@ -637,6 +658,20 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
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 connStatus = connStatusMap.get(connName);
|
||||
const connColorNum = computeConnColorNum(connStatus);
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
import { NodeModel } from "@/layout/index";
|
||||
export interface BlockProps {
|
||||
isSubBlock?: boolean;
|
||||
preview: boolean;
|
||||
nodeModel: NodeModel;
|
||||
}
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
import { CopyButton } from "@/app/element/copybutton";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { WindowRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||
import { isBlank, makeConnRoute, useAtomValueSafe } from "@/util/util";
|
||||
import { clsx } from "clsx";
|
||||
@ -143,7 +143,7 @@ const MarkdownImg = ({
|
||||
}
|
||||
const resolveFn = async () => {
|
||||
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,
|
||||
});
|
||||
const usp = new URLSearchParams();
|
||||
|
@ -1,3 +1,6 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as React from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { debounce } from "throttle-debounce";
|
||||
@ -56,6 +59,49 @@ export function useDimensionsWithCallbackRef<T extends HTMLElement>(
|
||||
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
|
||||
// pass debounceMs of null to not debounce
|
||||
export function useDimensionsWithExistingRef<T extends HTMLElement>(
|
||||
|
@ -2,12 +2,12 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import {
|
||||
getLayoutModelForActiveTab,
|
||||
getLayoutModelForTabById,
|
||||
LayoutTreeActionType,
|
||||
LayoutTreeInsertNodeAction,
|
||||
newLayoutNode,
|
||||
} from "@/layout/index";
|
||||
import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks";
|
||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||
import { fetch } from "@/util/fetchutil";
|
||||
import { getPrefixedSettings, isBlank } from "@/util/util";
|
||||
@ -26,6 +26,7 @@ const Counters = new Map<string, number>();
|
||||
const ConnStatusMap = new Map<string, PrimitiveAtom<ConnStatus>>();
|
||||
|
||||
type GlobalInitOptions = {
|
||||
tabId: string;
|
||||
platform: NodeJS.Platform;
|
||||
windowId: string;
|
||||
clientId: string;
|
||||
@ -46,10 +47,9 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
||||
const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom<string>;
|
||||
const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom<string>;
|
||||
const uiContextAtom = atom((get) => {
|
||||
const windowData = get(windowDataAtom);
|
||||
const uiContext: UIContext = {
|
||||
windowid: get(atoms.windowId),
|
||||
activetabid: windowData?.activetabid,
|
||||
windowid: initOpts.windowId,
|
||||
activetabid: initOpts.tabId,
|
||||
};
|
||||
return uiContext;
|
||||
}) as Atom<UIContext>;
|
||||
@ -63,7 +63,6 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
const showAboutModalAtom = atom(false) as PrimitiveAtom<boolean>;
|
||||
try {
|
||||
getApi().onMenuItemAbout(() => {
|
||||
modalsModel.pushModal("AboutModal");
|
||||
@ -99,18 +98,10 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
||||
return get(fullConfigAtom)?.settings ?? {};
|
||||
}) as Atom<SettingsType>;
|
||||
const tabAtom: Atom<Tab> = atom((get) => {
|
||||
const windowData = get(windowDataAtom);
|
||||
if (windowData == null) {
|
||||
return null;
|
||||
}
|
||||
return WOS.getObjectValue(WOS.makeORef("tab", windowData.activetabid), get);
|
||||
return WOS.getObjectValue(WOS.makeORef("tab", initOpts.tabId), get);
|
||||
});
|
||||
const activeTabIdAtom: Atom<string> = atom((get) => {
|
||||
const windowData = get(windowDataAtom);
|
||||
if (windowData == null) {
|
||||
return null;
|
||||
}
|
||||
return windowData.activetabid;
|
||||
const staticTabIdAtom: Atom<string> = atom((get) => {
|
||||
return initOpts.tabId;
|
||||
});
|
||||
const controlShiftDelayAtom = atom(false);
|
||||
const updaterStatusAtom = atom<UpdaterStatus>("up-to-date") as PrimitiveAtom<UpdaterStatus>;
|
||||
@ -151,7 +142,6 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
||||
const flashErrorsAtom = atom<FlashErrorType[]>([]);
|
||||
atoms = {
|
||||
// initialized in wave.ts (will not be null inside of application)
|
||||
windowId: windowIdAtom,
|
||||
clientId: clientIdAtom,
|
||||
uiContext: uiContextAtom,
|
||||
client: clientAtom,
|
||||
@ -160,7 +150,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
||||
fullConfigAtom,
|
||||
settingsAtom,
|
||||
tabAtom,
|
||||
activeTabId: activeTabIdAtom,
|
||||
staticTabId: staticTabIdAtom,
|
||||
isFullScreen: isFullScreenAtom,
|
||||
controlShiftDelayAtom,
|
||||
updaterStatusAtom,
|
||||
@ -228,8 +218,56 @@ function useBlockCache<T>(blockId: string, name: string, makeFn: () => T): 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>>();
|
||||
|
||||
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]> {
|
||||
let settingsKeyAtom = settingsAtomCache.get(key) as Atom<SettingsType[T]>;
|
||||
if (settingsKeyAtom == null) {
|
||||
@ -245,6 +283,10 @@ function getSettingsKeyAtom<T extends keyof SettingsType>(key: T): Atom<Settings
|
||||
return settingsKeyAtom;
|
||||
}
|
||||
|
||||
function useSettingsKeyAtom<T extends keyof SettingsType>(key: T): SettingsType[T] {
|
||||
return useAtomValue(getSettingsKeyAtom(key));
|
||||
}
|
||||
|
||||
function useSettingsPrefixAtom(prefix: string): Atom<SettingsType> {
|
||||
// TODO: use a shallow equal here to make this more efficient
|
||||
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>>>();
|
||||
|
||||
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);
|
||||
if (blockCache == null) {
|
||||
blockCache = new Map<string, Atom<any>>();
|
||||
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);
|
||||
if (atom == null) {
|
||||
atom = makeFn();
|
||||
@ -301,8 +348,8 @@ async function createBlock(blockDef: BlockDef, magnified = false): Promise<strin
|
||||
magnified,
|
||||
focused: true,
|
||||
};
|
||||
const activeTabId = globalStore.get(atoms.uiContext).activetabid;
|
||||
const layoutModel = getLayoutModelForTabById(activeTabId);
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
const layoutModel = getLayoutModelForTabById(tabId);
|
||||
layoutModel.treeReducer(insertNodeAction);
|
||||
return blockId;
|
||||
}
|
||||
@ -339,7 +386,7 @@ async function fetchWaveFile(
|
||||
}
|
||||
|
||||
function setNodeFocus(nodeId: string) {
|
||||
const layoutModel = getLayoutModelForActiveTab();
|
||||
const layoutModel = getLayoutModelForStaticTab();
|
||||
layoutModel.focusNode(nodeId);
|
||||
}
|
||||
|
||||
@ -414,7 +461,7 @@ function refocusNode(blockId: string) {
|
||||
if (blockId == null) {
|
||||
return;
|
||||
}
|
||||
const layoutModel = getLayoutModelForActiveTab();
|
||||
const layoutModel = getLayoutModelForStaticTab();
|
||||
const layoutNodeId = layoutModel.getNodeByBlockId(blockId);
|
||||
if (layoutNodeId?.id == null) {
|
||||
return;
|
||||
@ -522,15 +569,21 @@ function removeFlashError(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
async function createTab(): Promise<void> {
|
||||
await getApi().createTab();
|
||||
}
|
||||
|
||||
export {
|
||||
atoms,
|
||||
counterInc,
|
||||
countersClear,
|
||||
countersPrint,
|
||||
createBlock,
|
||||
createTab,
|
||||
fetchWaveFile,
|
||||
getApi,
|
||||
getBlockComponentModel,
|
||||
getBlockMetaKeyAtom,
|
||||
getConnStatusAtom,
|
||||
getHostName,
|
||||
getObjectId,
|
||||
@ -541,6 +594,7 @@ export {
|
||||
initGlobalWaveEventSubs,
|
||||
isDev,
|
||||
loadConnStatus,
|
||||
makeOverrideConfigAtom,
|
||||
openLink,
|
||||
PLATFORM,
|
||||
pushFlashError,
|
||||
@ -554,6 +608,9 @@ export {
|
||||
useBlockAtom,
|
||||
useBlockCache,
|
||||
useBlockDataLoaded,
|
||||
useBlockMetaKeyAtom,
|
||||
useOverrideConfigAtom,
|
||||
useSettingsKeyAtom,
|
||||
useSettingsPrefixAtom,
|
||||
WOS,
|
||||
};
|
||||
|
@ -1,24 +1,32 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { atoms, createBlock, getApi, getBlockComponentModel, globalStore, refocusNode, WOS } from "@/app/store/global";
|
||||
import * as services from "@/app/store/services";
|
||||
import {
|
||||
atoms,
|
||||
createBlock,
|
||||
createTab,
|
||||
getApi,
|
||||
getBlockComponentModel,
|
||||
globalStore,
|
||||
refocusNode,
|
||||
WOS,
|
||||
} from "@/app/store/global";
|
||||
import {
|
||||
deleteLayoutModelForTab,
|
||||
getLayoutModelForActiveTab,
|
||||
getLayoutModelForTab,
|
||||
getLayoutModelForTabById,
|
||||
NavigateDirection,
|
||||
} from "@/layout/index";
|
||||
import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks";
|
||||
import * as keyutil from "@/util/keyutil";
|
||||
import * as jotai from "jotai";
|
||||
|
||||
const simpleControlShiftAtom = jotai.atom(false);
|
||||
const globalKeyMap = new Map<string, (waveEvent: WaveKeyboardEvent) => boolean>();
|
||||
|
||||
function getFocusedBlockInActiveTab() {
|
||||
const activeTabId = globalStore.get(atoms.activeTabId);
|
||||
const layoutModel = getLayoutModelForTabById(activeTabId);
|
||||
function getFocusedBlockInStaticTab() {
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
const layoutModel = getLayoutModelForTabById(tabId);
|
||||
const focusedNode = globalStore.get(layoutModel.focusedNode);
|
||||
return focusedNode.data?.blockId;
|
||||
}
|
||||
@ -70,7 +78,7 @@ function genericClose(tabId: string) {
|
||||
}
|
||||
if (tabData.blockids == null || tabData.blockids.length == 0) {
|
||||
// close tab
|
||||
services.WindowService.CloseTab(tabId);
|
||||
getApi().closeTab(tabId);
|
||||
deleteLayoutModelForTab(tabId);
|
||||
return;
|
||||
}
|
||||
@ -79,7 +87,7 @@ function genericClose(tabId: string) {
|
||||
}
|
||||
|
||||
function switchBlockByBlockNum(index: number) {
|
||||
const layoutModel = getLayoutModelForActiveTab();
|
||||
const layoutModel = getLayoutModelForStaticTab();
|
||||
if (!layoutModel) {
|
||||
return;
|
||||
}
|
||||
@ -92,21 +100,24 @@ function switchBlockInDirection(tabId: string, direction: NavigateDirection) {
|
||||
}
|
||||
|
||||
function switchTabAbs(index: number) {
|
||||
console.log("switchTabAbs", index);
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
const waveWindow = globalStore.get(atoms.waveWindow);
|
||||
const newTabIdx = index - 1;
|
||||
if (newTabIdx < 0 || newTabIdx >= ws.tabids.length) {
|
||||
return;
|
||||
}
|
||||
const newActiveTabId = ws.tabids[newTabIdx];
|
||||
services.ObjectService.SetActiveTab(newActiveTabId);
|
||||
getApi().setActiveTab(newActiveTabId);
|
||||
}
|
||||
|
||||
function switchTab(offset: number) {
|
||||
console.log("switchTab", offset);
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
const activeTabId = globalStore.get(atoms.tabAtom).oid;
|
||||
const curTabId = globalStore.get(atoms.staticTabId);
|
||||
let tabIdx = -1;
|
||||
for (let i = 0; i < ws.tabids.length; i++) {
|
||||
if (ws.tabids[i] == activeTabId) {
|
||||
if (ws.tabids[i] == curTabId) {
|
||||
tabIdx = i;
|
||||
break;
|
||||
}
|
||||
@ -116,11 +127,11 @@ function switchTab(offset: number) {
|
||||
}
|
||||
const newTabIdx = (tabIdx + offset + ws.tabids.length) % ws.tabids.length;
|
||||
const newActiveTabId = ws.tabids[newTabIdx];
|
||||
services.ObjectService.SetActiveTab(newActiveTabId);
|
||||
getApi().setActiveTab(newActiveTabId);
|
||||
}
|
||||
|
||||
function handleCmdI() {
|
||||
const layoutModel = getLayoutModelForActiveTab();
|
||||
const layoutModel = getLayoutModelForStaticTab();
|
||||
const focusedNode = globalStore.get(layoutModel.focusedNode);
|
||||
if (focusedNode == null) {
|
||||
// focus a node
|
||||
@ -141,7 +152,7 @@ async function handleCmdN() {
|
||||
controller: "shell",
|
||||
},
|
||||
};
|
||||
const layoutModel = getLayoutModelForActiveTab();
|
||||
const layoutModel = getLayoutModelForStaticTab();
|
||||
const focusedNode = globalStore.get(layoutModel.focusedNode);
|
||||
if (focusedNode != null) {
|
||||
const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", focusedNode.data?.blockId));
|
||||
@ -163,7 +174,7 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
|
||||
if (handled) {
|
||||
return true;
|
||||
}
|
||||
const layoutModel = getLayoutModelForActiveTab();
|
||||
const layoutModel = getLayoutModelForStaticTab();
|
||||
const focusedNode = globalStore.get(layoutModel.focusedNode);
|
||||
const blockId = focusedNode?.data?.blockId;
|
||||
if (blockId != null && shouldDispatchToBlock(waveEvent)) {
|
||||
@ -225,18 +236,16 @@ function registerGlobalKeys() {
|
||||
return true;
|
||||
});
|
||||
globalKeyMap.set("Cmd:t", () => {
|
||||
const workspace = globalStore.get(atoms.workspace);
|
||||
const newTabName = `T${workspace.tabids.length + 1}`;
|
||||
services.ObjectService.AddTabToWorkspace(newTabName, true);
|
||||
createTab();
|
||||
return true;
|
||||
});
|
||||
globalKeyMap.set("Cmd:w", () => {
|
||||
const tabId = globalStore.get(atoms.activeTabId);
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
genericClose(tabId);
|
||||
return true;
|
||||
});
|
||||
globalKeyMap.set("Cmd:m", () => {
|
||||
const layoutModel = getLayoutModelForActiveTab();
|
||||
const layoutModel = getLayoutModelForStaticTab();
|
||||
const focusedNode = globalStore.get(layoutModel.focusedNode);
|
||||
if (focusedNode != null) {
|
||||
layoutModel.magnifyNodeToggle(focusedNode.id);
|
||||
@ -244,27 +253,27 @@ function registerGlobalKeys() {
|
||||
return true;
|
||||
});
|
||||
globalKeyMap.set("Ctrl:Shift:ArrowUp", () => {
|
||||
const tabId = globalStore.get(atoms.activeTabId);
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
switchBlockInDirection(tabId, NavigateDirection.Up);
|
||||
return true;
|
||||
});
|
||||
globalKeyMap.set("Ctrl:Shift:ArrowDown", () => {
|
||||
const tabId = globalStore.get(atoms.activeTabId);
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
switchBlockInDirection(tabId, NavigateDirection.Down);
|
||||
return true;
|
||||
});
|
||||
globalKeyMap.set("Ctrl:Shift:ArrowLeft", () => {
|
||||
const tabId = globalStore.get(atoms.activeTabId);
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
switchBlockInDirection(tabId, NavigateDirection.Left);
|
||||
return true;
|
||||
});
|
||||
globalKeyMap.set("Ctrl:Shift:ArrowRight", () => {
|
||||
const tabId = globalStore.get(atoms.activeTabId);
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
switchBlockInDirection(tabId, NavigateDirection.Right);
|
||||
return true;
|
||||
});
|
||||
globalKeyMap.set("Cmd:g", () => {
|
||||
const bcm = getBlockComponentModel(getFocusedBlockInActiveTab());
|
||||
const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
|
||||
if (bcm.openSwitchConnection != null) {
|
||||
bcm.openSwitchConnection();
|
||||
return true;
|
||||
|
@ -10,7 +10,9 @@ class BlockServiceType {
|
||||
GetControllerStatus(arg2: string): Promise<BlockControllerRuntimeStatus> {
|
||||
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))
|
||||
}
|
||||
SaveWaveAiData(arg2: string, arg3: OpenAIPromptMessageType[]): Promise<void> {
|
||||
@ -88,7 +90,7 @@ export const FileService = new FileServiceType();
|
||||
// objectservice.ObjectService (object)
|
||||
class ObjectServiceType {
|
||||
// @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))
|
||||
}
|
||||
|
||||
@ -113,7 +115,7 @@ class ObjectServiceType {
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
SetActiveTab(tabId: string): Promise<void> {
|
||||
SetActiveTab(uiContext: string, tabId: string): Promise<void> {
|
||||
return WOS.callBackendService("object", "SetActiveTab", Array.from(arguments))
|
||||
}
|
||||
|
||||
@ -152,10 +154,10 @@ export const UserInputService = new UserInputServiceType();
|
||||
// windowservice.WindowService (window)
|
||||
class WindowServiceType {
|
||||
// @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))
|
||||
}
|
||||
CloseWindow(arg2: string): Promise<void> {
|
||||
CloseWindow(arg2: string, arg3: boolean): Promise<void> {
|
||||
return WOS.callBackendService("window", "CloseWindow", Array.from(arguments))
|
||||
}
|
||||
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
// WaveObjectStore
|
||||
|
||||
import { waveEventSubscribe } from "@/app/store/wps";
|
||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||
import { fetch } from "@/util/fetchutil";
|
||||
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);
|
||||
}
|
||||
|
||||
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> {
|
||||
const startTs = Date.now();
|
||||
let uiContext: UIContext = null;
|
||||
@ -130,6 +141,19 @@ function clearWaveObjectCache() {
|
||||
|
||||
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> {
|
||||
const wov = { pendingPromise: null, dataAtom: null, refCount: 0, holdTime: Date.now() + 5000 };
|
||||
wov.dataAtom = atom({ value: null, loading: true });
|
||||
@ -290,8 +314,11 @@ export {
|
||||
getWaveObjectLoadingAtom,
|
||||
loadAndPinWaveObject,
|
||||
makeORef,
|
||||
reloadWaveObject,
|
||||
setObjectValue,
|
||||
splitORef,
|
||||
updateWaveObject,
|
||||
updateWaveObjects,
|
||||
useWaveObjectValue,
|
||||
wpsSubscribeToObject,
|
||||
};
|
||||
|
@ -37,7 +37,7 @@ class WSControl {
|
||||
opening: boolean = false;
|
||||
reconnectTimes: number = 0;
|
||||
msgQueue: any[] = [];
|
||||
windowId: string;
|
||||
tabId: string;
|
||||
messageCallback: WSEventCallback;
|
||||
watchSessionId: string = null;
|
||||
watchScreenId: string = null;
|
||||
@ -50,13 +50,13 @@ class WSControl {
|
||||
|
||||
constructor(
|
||||
baseHostPort: string,
|
||||
windowId: string,
|
||||
tabId: string,
|
||||
messageCallback: WSEventCallback,
|
||||
electronOverrideOpts?: ElectronOverrideOpts
|
||||
) {
|
||||
this.baseHostPort = baseHostPort;
|
||||
this.messageCallback = messageCallback;
|
||||
this.windowId = windowId;
|
||||
this.tabId = tabId;
|
||||
this.open = false;
|
||||
this.eoOpts = electronOverrideOpts;
|
||||
setInterval(this.sendPing.bind(this), 5000);
|
||||
@ -75,7 +75,7 @@ class WSControl {
|
||||
dlog("try reconnect:", desc);
|
||||
this.opening = true;
|
||||
this.wsConn = newWebSocket(
|
||||
this.baseHostPort + "/ws?windowid=" + this.windowId,
|
||||
this.baseHostPort + "/ws?tabid=" + this.tabId,
|
||||
this.eoOpts
|
||||
? {
|
||||
[AuthKeyHeader]: this.eoOpts.authKey,
|
||||
@ -231,11 +231,11 @@ class WSControl {
|
||||
let globalWS: WSControl;
|
||||
function initGlobalWS(
|
||||
baseHostPort: string,
|
||||
windowId: string,
|
||||
tabId: string,
|
||||
messageCallback: WSEventCallback,
|
||||
electronOverrideOpts?: ElectronOverrideOpts
|
||||
) {
|
||||
globalWS = new WSControl(baseHostPort, windowId, messageCallback, electronOverrideOpts);
|
||||
globalWS = new WSControl(baseHostPort, tabId, messageCallback, electronOverrideOpts);
|
||||
}
|
||||
|
||||
function sendRawRpcMessage(msg: RpcMessage) {
|
||||
|
@ -18,6 +18,10 @@ class RpcResponseHelper {
|
||||
this.done = cmdMsg.reqid == null;
|
||||
}
|
||||
|
||||
getSource(): string {
|
||||
return this.cmdMsg?.source;
|
||||
}
|
||||
|
||||
sendResponse(msg: RpcMessage) {
|
||||
if (this.done || util.isBlank(this.cmdMsg.reqid)) {
|
||||
return;
|
||||
|
@ -67,11 +67,26 @@ class RpcApiType {
|
||||
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]
|
||||
DeleteBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise<void> {
|
||||
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]
|
||||
EventPublishCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise<void> {
|
||||
return client.wshRpcCall("eventpublish", data, opts);
|
||||
@ -217,11 +232,46 @@ class RpcApiType {
|
||||
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]
|
||||
WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise<string[]> {
|
||||
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();
|
||||
|
@ -15,14 +15,14 @@ type RouteInfo = {
|
||||
destRouteId: string;
|
||||
};
|
||||
|
||||
function makeWindowRouteId(windowId: string): string {
|
||||
return `window:${windowId}`;
|
||||
}
|
||||
|
||||
function makeFeBlockRouteId(feBlockId: string): string {
|
||||
return `feblock:${feBlockId}`;
|
||||
}
|
||||
|
||||
function makeTabRouteId(tabId: string): string {
|
||||
return `tab:${tabId}`;
|
||||
}
|
||||
|
||||
class WshRouter {
|
||||
routeMap: Map<string, AbstractWshClient>; // routeid -> client
|
||||
upstreamClient: AbstractWshClient;
|
||||
@ -149,4 +149,4 @@ class WshRouter {
|
||||
}
|
||||
}
|
||||
|
||||
export { makeFeBlockRouteId, makeWindowRouteId, WshRouter };
|
||||
export { makeFeBlockRouteId, makeTabRouteId, WshRouter };
|
||||
|
@ -3,12 +3,12 @@
|
||||
|
||||
import { wpsReconnectHandler } from "@/app/store/wps";
|
||||
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 { addWSReconnectHandler, ElectronOverrideOpts, globalWS, initGlobalWS, WSControl } from "./ws";
|
||||
|
||||
let DefaultRouter: WshRouter;
|
||||
let WindowRpcClient: WshClient;
|
||||
let TabRpcClient: WshClient;
|
||||
|
||||
async function* rpcResponseGenerator(
|
||||
openRpcs: Map<string, ClientRpcEntry>,
|
||||
@ -126,15 +126,15 @@ function shutdownWshrpc() {
|
||||
globalWS?.shutdown();
|
||||
}
|
||||
|
||||
function initWshrpc(windowId: string): WSControl {
|
||||
function initWshrpc(tabId: string): WSControl {
|
||||
DefaultRouter = new WshRouter(new UpstreamWshRpcProxy());
|
||||
const handleFn = (event: WSEventType) => {
|
||||
DefaultRouter.recvRpcMessage(event.data);
|
||||
};
|
||||
initGlobalWS(getWSServerEndpoint(), windowId, handleFn);
|
||||
initGlobalWS(getWSServerEndpoint(), tabId, handleFn);
|
||||
globalWS.connectNow("connectWshrpc");
|
||||
WindowRpcClient = new WshClient(makeWindowRouteId(windowId));
|
||||
DefaultRouter.registerRoute(WindowRpcClient.routeId, WindowRpcClient);
|
||||
TabRpcClient = new WshClient(makeTabRouteId(tabId));
|
||||
DefaultRouter.registerRoute(TabRpcClient.routeId, TabRpcClient);
|
||||
addWSReconnectHandler(() => {
|
||||
DefaultRouter.reannounceRoutes();
|
||||
});
|
||||
@ -149,12 +149,4 @@ class UpstreamWshRpcProxy implements AbstractWshClient {
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
DefaultRouter,
|
||||
initElectronWshrpc,
|
||||
initWshrpc,
|
||||
sendRpcCommand,
|
||||
sendRpcResponse,
|
||||
shutdownWshrpc,
|
||||
WindowRpcClient,
|
||||
};
|
||||
export { DefaultRouter, initElectronWshrpc, initWshrpc, sendRpcCommand, sendRpcResponse, shutdownWshrpc, TabRpcClient };
|
||||
|
@ -5,7 +5,7 @@ import { Button } from "@/app/element/button";
|
||||
import { modalsModel } from "@/app/store/modalmodel";
|
||||
import { WindowDrag } from "@/element/windowdrag";
|
||||
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 { useAtomValue } from "jotai";
|
||||
import { OverlayScrollbars } from "overlayscrollbars";
|
||||
@ -134,10 +134,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
|
||||
const updateStatusButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const configErrorButtonRef = useRef<HTMLElement>(null);
|
||||
const prevAllLoadedRef = useRef<boolean>(false);
|
||||
|
||||
const windowData = useAtomValue(atoms.waveWindow);
|
||||
const { activetabid } = windowData;
|
||||
|
||||
const activeTabId = useAtomValue(atoms.staticTabId);
|
||||
const isFullScreen = useAtomValue(atoms.isFullScreen);
|
||||
|
||||
const settings = useAtomValue(atoms.settingsAtom);
|
||||
@ -483,17 +480,12 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
|
||||
|
||||
const handleSelectTab = (tabId: string) => {
|
||||
if (!draggingTabDataRef.current.dragged) {
|
||||
services.ObjectService.SetActiveTab(tabId);
|
||||
getApi().setActiveTab(tabId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTab = () => {
|
||||
const newTabName = `T${tabIds.length + 1}`;
|
||||
services.ObjectService.AddTabToWorkspace(newTabName, true).then((tabId) => {
|
||||
setTabIds([...tabIds, tabId]);
|
||||
setNewTabId(tabId);
|
||||
});
|
||||
services.ObjectService.GetObject;
|
||||
createTab();
|
||||
tabsWrapperRef.current.style.transition;
|
||||
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) => {
|
||||
event?.stopPropagation();
|
||||
services.WindowService.CloseTab(tabId);
|
||||
getApi().closeTab(tabId);
|
||||
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease");
|
||||
deleteLayoutModelForTab(tabId);
|
||||
};
|
||||
@ -525,7 +517,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
|
||||
}, []);
|
||||
|
||||
const isBeforeActive = (tabId: string) => {
|
||||
return tabIds.indexOf(tabId) === tabIds.indexOf(activetabid) - 1;
|
||||
return tabIds.indexOf(tabId) === tabIds.indexOf(activeTabId) - 1;
|
||||
};
|
||||
|
||||
function onEllipsisClick() {
|
||||
@ -560,7 +552,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
|
||||
id={tabId}
|
||||
isFirst={index === 0}
|
||||
onSelect={() => handleSelectTab(tabId)}
|
||||
active={activetabid === tabId}
|
||||
active={activeTabId === tabId}
|
||||
onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])}
|
||||
onClose={(event) => handleCloseTab(event, tabId)}
|
||||
onLoaded={() => handleTabLoaded(tabId)}
|
||||
|
@ -6,7 +6,7 @@ import { TypeAheadModal } from "@/app/modals/typeaheadmodal";
|
||||
import { ContextMenuModel } from "@/app/store/contextmenu";
|
||||
import { tryReinjectKey } from "@/app/store/keymodel";
|
||||
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 { Markdown } from "@/element/markdown";
|
||||
import { NodeModel } from "@/layout/index";
|
||||
@ -496,7 +496,7 @@ export class PreviewModel implements ViewModel {
|
||||
async getParentInfo(fileInfo: FileInfo): Promise<FileInfo | undefined> {
|
||||
const conn = globalStore.get(this.connection);
|
||||
try {
|
||||
const parentFileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [fileInfo.path, ".."], {
|
||||
const parentFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.path, ".."], {
|
||||
route: makeConnRoute(conn),
|
||||
});
|
||||
return parentFileInfo;
|
||||
@ -517,7 +517,7 @@ export class PreviewModel implements ViewModel {
|
||||
}
|
||||
const conn = globalStore.get(this.connection);
|
||||
try {
|
||||
const newFileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [fileInfo.path, ".."], {
|
||||
const newFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.path, ".."], {
|
||||
route: makeConnRoute(conn),
|
||||
});
|
||||
if (newFileInfo.path != "" && newFileInfo.notfound) {
|
||||
@ -600,7 +600,7 @@ export class PreviewModel implements ViewModel {
|
||||
}
|
||||
const conn = globalStore.get(this.connection);
|
||||
try {
|
||||
const newFileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [fileInfo.dir, filePath], {
|
||||
const newFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.dir, filePath], {
|
||||
route: makeConnRoute(conn),
|
||||
});
|
||||
this.updateOpenFileModalAndError(false);
|
||||
|
@ -13,7 +13,7 @@ import * as React from "react";
|
||||
import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions";
|
||||
import { waveEventSubscribe } from "@/app/store/wps";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { WindowRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { atoms } from "@/store/global";
|
||||
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
||||
import "./sysinfo.less";
|
||||
@ -175,7 +175,7 @@ class SysinfoViewModel {
|
||||
this.incrementCount = jotai.atom(null, async (get, set) => {
|
||||
const meta = get(this.blockAtom).meta;
|
||||
const count = meta.count ?? 0;
|
||||
await RpcApi.SetMetaCommand(WindowRpcClient, {
|
||||
await RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { count: count + 1 },
|
||||
});
|
||||
@ -203,7 +203,7 @@ class SysinfoViewModel {
|
||||
try {
|
||||
const numPoints = globalStore.get(this.numPoints);
|
||||
const connName = globalStore.get(this.connection);
|
||||
const initialData = await RpcApi.EventReadHistoryCommand(WindowRpcClient, {
|
||||
const initialData = await RpcApi.EventReadHistoryCommand(TabRpcClient, {
|
||||
event: "sysinfo",
|
||||
scope: connName,
|
||||
maxitems: numPoints,
|
||||
@ -245,7 +245,7 @@ class SysinfoViewModel {
|
||||
type: "radio",
|
||||
checked: currentlySelected == plotType,
|
||||
click: async () => {
|
||||
await RpcApi.SetMetaCommand(WindowRpcClient, {
|
||||
await RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { "graph:metrics": dataTypes, "sysinfo:type": plotType },
|
||||
});
|
||||
|
76
frontend/app/view/term/term-wsh.tsx
Normal file
76
frontend/app/view/term/term-wsh.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -76,7 +76,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.term-mode-html {
|
||||
&.term-mode-vdom {
|
||||
.term-connectelem {
|
||||
display: none;
|
||||
}
|
||||
|
@ -1,16 +1,28 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Block } from "@/app/block/block";
|
||||
import { getAllGlobalKeyBindings } from "@/app/store/keymodel";
|
||||
import { waveEventSubscribe } from "@/app/store/wps";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { WindowRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { VDomView } from "@/app/view/term/vdom";
|
||||
import { WOS, atoms, getConnStatusAtom, getSettingsKeyAtom, globalStore, useSettingsPrefixAtom } from "@/store/global";
|
||||
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
|
||||
import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
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 keyutil from "@/util/keyutil";
|
||||
import * as util from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
import debug from "debug";
|
||||
import * as jotai from "jotai";
|
||||
import * as React from "react";
|
||||
import { TermStickers } from "./termsticker";
|
||||
@ -19,118 +31,101 @@ import { computeTheme } from "./termutil";
|
||||
import { TermWrap } from "./termwrap";
|
||||
import "./xterm.css";
|
||||
|
||||
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: 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 "";
|
||||
}
|
||||
const dlog = debug("wave:term");
|
||||
|
||||
type InitialLoadDataType = {
|
||||
loaded: boolean;
|
||||
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 {
|
||||
viewType: string;
|
||||
nodeModel: NodeModel;
|
||||
connected: boolean;
|
||||
termRef: React.RefObject<TermWrap>;
|
||||
blockAtom: jotai.Atom<Block>;
|
||||
termMode: jotai.Atom<string>;
|
||||
htmlElemFocusRef: React.RefObject<HTMLInputElement>;
|
||||
blockId: string;
|
||||
viewIcon: jotai.Atom<string>;
|
||||
viewName: jotai.Atom<string>;
|
||||
viewText: jotai.Atom<HeaderElem[]>;
|
||||
blockBg: jotai.Atom<MetaType>;
|
||||
manageConnection: jotai.Atom<boolean>;
|
||||
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.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.vdomBlockId = jotai.atom((get) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
return blockData?.meta?.["term:vdomblockid"];
|
||||
});
|
||||
this.termMode = jotai.atom((get) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
return blockData?.meta?.["term:mode"] ?? "term";
|
||||
});
|
||||
this.viewIcon = jotai.atom((get) => {
|
||||
const termMode = get(this.termMode);
|
||||
if (termMode == "vdom") {
|
||||
return "bolt";
|
||||
}
|
||||
return "terminal";
|
||||
});
|
||||
this.viewName = jotai.atom((get) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
const termMode = get(this.termMode);
|
||||
if (termMode == "vdom") {
|
||||
return "Wave App";
|
||||
}
|
||||
if (blockData?.meta?.controller == "cmd") {
|
||||
return "Command";
|
||||
}
|
||||
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) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
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 {
|
||||
let termMode = globalStore.get(this.termMode);
|
||||
if (termMode == "term") {
|
||||
@ -159,17 +180,74 @@ class TermViewModel {
|
||||
this.termRef.current.terminal.focus();
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
if (this.htmlElemFocusRef?.current) {
|
||||
this.htmlElemFocusRef.current.focus();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
RpcApi.SetMetaCommand(WindowRpcClient, {
|
||||
RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { "term:theme": themeName },
|
||||
});
|
||||
@ -202,8 +280,8 @@ class TermViewModel {
|
||||
rows: this.termRef.current?.terminal?.rows,
|
||||
cols: this.termRef.current?.terminal?.cols,
|
||||
};
|
||||
const prtn = RpcApi.ControllerResyncCommand(WindowRpcClient, {
|
||||
tabid: globalStore.get(atoms.activeTabId),
|
||||
const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, {
|
||||
tabid: globalStore.get(atoms.staticTabId),
|
||||
blockid: this.blockId,
|
||||
forcerestart: true,
|
||||
rtopts: { termsize: termsize },
|
||||
@ -215,8 +293,8 @@ class TermViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
function makeTerminalModel(blockId: string): TermViewModel {
|
||||
return new TermViewModel(blockId);
|
||||
function makeTerminalModel(blockId: string, nodeModel: NodeModel): TermViewModel {
|
||||
return new TermViewModel(blockId, nodeModel);
|
||||
}
|
||||
|
||||
interface TerminalViewProps {
|
||||
@ -246,64 +324,69 @@ const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) =>
|
||||
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 viewRef = React.createRef<HTMLDivElement>();
|
||||
const viewRef = React.useRef<HTMLDivElement>(null);
|
||||
const connectElemRef = React.useRef<HTMLDivElement>(null);
|
||||
const termRef = React.useRef<TermWrap>(null);
|
||||
model.termRef = termRef;
|
||||
const shellProcStatusRef = React.useRef<string>(null);
|
||||
const htmlElemFocusRef = React.useRef<HTMLInputElement>(null);
|
||||
model.htmlElemFocusRef = htmlElemFocusRef;
|
||||
const spstatusRef = React.useRef<string>(null);
|
||||
model.shellProcStatusRef = spstatusRef;
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
|
||||
const termSettingsAtom = useSettingsPrefixAtom("term");
|
||||
const termSettings = jotai.useAtomValue(termSettingsAtom);
|
||||
let termMode = blockData?.meta?.["term:mode"] ?? "term";
|
||||
if (termMode != "term" && termMode != "vdom") {
|
||||
termMode = "term";
|
||||
}
|
||||
const termModeRef = React.useRef(termMode);
|
||||
|
||||
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 termTheme = computeTheme(fullConfig, blockData?.meta?.["term:theme"]);
|
||||
const themeCopy = { ...termTheme };
|
||||
@ -335,7 +418,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
scrollback: termScrollback,
|
||||
},
|
||||
{
|
||||
keydownHandler: handleTerminalKeydown,
|
||||
keydownHandler: model.handleTerminalKeydown.bind(model),
|
||||
useWebGl: !termSettings?.["term:disablewebgl"],
|
||||
}
|
||||
);
|
||||
@ -352,29 +435,13 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
};
|
||||
}, [blockId, termSettings]);
|
||||
|
||||
const handleHtmlKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event);
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) {
|
||||
// reset term:mode
|
||||
RpcApi.SetMetaCommand(WindowRpcClient, {
|
||||
oref: WOS.makeORef("block", blockId),
|
||||
meta: { "term:mode": null },
|
||||
});
|
||||
return false;
|
||||
React.useEffect(() => {
|
||||
if (termModeRef.current == "vdom" && termMode == "term") {
|
||||
// focus the terminal
|
||||
model.giveFocus();
|
||||
}
|
||||
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
|
||||
React.useEffect(() => {
|
||||
@ -382,7 +449,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
if (status == null) {
|
||||
return;
|
||||
}
|
||||
shellProcStatusRef.current = status;
|
||||
model.shellProcStatusRef.current = status;
|
||||
if (status == "running") {
|
||||
termRef.current?.setIsRunning(true);
|
||||
} else {
|
||||
@ -418,28 +485,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
<TermThemeUpdater blockId={blockId} termRef={termRef} />
|
||||
<TermStickers config={stickerConfig} />
|
||||
<div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div>
|
||||
<div
|
||||
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>
|
||||
<TermVDomNode key="vdom" blockId={blockId} model={model} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { WindowRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { createBlock } from "@/store/global";
|
||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||
import { stringToBase64 } from "@/util/util";
|
||||
@ -101,7 +101,7 @@ function TermSticker({ sticker, config }: { sticker: StickerType; config: Sticke
|
||||
console.log("clickHandler", sticker.clickcmd, sticker.clickblockdef);
|
||||
if (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) {
|
||||
createBlock(sticker.clickblockdef);
|
||||
|
@ -4,7 +4,7 @@
|
||||
import { getFileSubject } from "@/app/store/wps";
|
||||
import { sendWSCommand } from "@/app/store/ws";
|
||||
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 * as services from "@/store/services";
|
||||
import * as util from "@/util/util";
|
||||
@ -168,7 +168,7 @@ export class TermWrap {
|
||||
|
||||
handleTermData(data: string) {
|
||||
const b64data = util.stringToBase64(data);
|
||||
RpcApi.ControllerInputCommand(WindowRpcClient, { blockid: this.blockId, inputdata64: b64data });
|
||||
RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data });
|
||||
}
|
||||
|
||||
addFocusListener(focusFn: () => void) {
|
||||
@ -216,7 +216,21 @@ export class TermWrap {
|
||||
if (cacheFile != null) {
|
||||
ptyOffset = cacheFile.meta["ptyoffset"] ?? 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);
|
||||
if (didResize) {
|
||||
this.terminal.resize(curTermSize.cols, curTermSize.rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
const { data: mainData, fileInfo: mainFile } = await fetchWaveFile(this.blockId, TermFileName, ptyOffset);
|
||||
@ -230,10 +244,10 @@ export class TermWrap {
|
||||
|
||||
async resyncController(reason: string) {
|
||||
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 } };
|
||||
try {
|
||||
await RpcApi.ControllerResyncCommand(WindowRpcClient, {
|
||||
await RpcApi.ControllerResyncCommand(TabRpcClient, {
|
||||
tabid: tabId,
|
||||
blockid: this.blockId,
|
||||
rtopts: rtOpts,
|
||||
@ -268,8 +282,9 @@ export class TermWrap {
|
||||
return;
|
||||
}
|
||||
const serializedOutput = this.serializeAddon.serialize();
|
||||
console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length);
|
||||
services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset);
|
||||
const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };
|
||||
console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length, termSize);
|
||||
services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset, termSize);
|
||||
this.dataBytesProcessed = 0;
|
||||
}
|
||||
|
||||
|
595
frontend/app/view/term/vdom-model.tsx
Normal file
595
frontend/app/view/term/vdom-model.tsx
Normal 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;
|
||||
}
|
||||
}
|
@ -1,9 +1,30 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// 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 debug from "debug";
|
||||
import * as jotai from "jotai";
|
||||
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 } = {
|
||||
div: true,
|
||||
b: true,
|
||||
@ -30,38 +51,38 @@ const AllowedTags: { [tagName: string]: boolean } = {
|
||||
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) => {
|
||||
if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) {
|
||||
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
|
||||
for (let keyDesc of fnDecl["#keys"]) {
|
||||
for (let keyDesc of fnDecl.keys || []) {
|
||||
if (checkKeyPressed(waveEvent, keyDesc)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
callFunc(e, compId, propName);
|
||||
model.callVDomFunc(fnDecl, e, compId, propName);
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (fnDecl["#preventDefault"]) {
|
||||
if (fnDecl.preventdefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (fnDecl["#stopPropagation"]) {
|
||||
if (fnDecl.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) {
|
||||
return null;
|
||||
}
|
||||
if (elem.tag == "#text") {
|
||||
if (elem.tag == TextTag) {
|
||||
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 {
|
||||
@ -72,19 +93,35 @@ function isArray(v: any): boolean {
|
||||
return Array.isArray(v);
|
||||
}
|
||||
|
||||
function callFunc(e: any, compId: string, propName: string) {
|
||||
console.log("callfunc", compId, propName);
|
||||
}
|
||||
|
||||
function updateRefFunc(elem: any, ref: VDomRefType) {
|
||||
console.log("updateref", ref["#ref"], elem);
|
||||
}
|
||||
|
||||
function VDomTag({ elem }: { elem: VDomElem }) {
|
||||
if (!AllowedTags[elem.tag]) {
|
||||
return <div>{"Invalid Tag <" + elem.tag + ">"}</div>;
|
||||
function resolveBinding(binding: VDomBinding, model: VDomModel): [any, string[]] {
|
||||
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]];
|
||||
}
|
||||
|
||||
type GenericPropsType = { [key: string]: any };
|
||||
|
||||
// returns props, and a set of atom keys used in the props
|
||||
function convertProps(elem: VDomElem, model: VDomModel): [GenericPropsType, Set<string>] {
|
||||
let props: GenericPropsType = {};
|
||||
let atomKeys = new Set<string>();
|
||||
if (elem.props == null) {
|
||||
return [props, atomKeys];
|
||||
}
|
||||
let props = {};
|
||||
for (let key in elem.props) {
|
||||
let val = elem.props[key];
|
||||
if (val == null) {
|
||||
@ -94,36 +131,153 @@ function VDomTag({ elem }: { elem: VDomElem }) {
|
||||
if (val == null) {
|
||||
continue;
|
||||
}
|
||||
if (isObject(val) && "#ref" in val) {
|
||||
props[key] = (elem: HTMLElement) => {
|
||||
updateRefFunc(elem, val);
|
||||
};
|
||||
if (isObject(val) && val.type == VDomObjType_Ref) {
|
||||
const valRef = val as VDomRef;
|
||||
const refContainer = model.getOrCreateRefContainer(valRef);
|
||||
props[key] = refContainer.refFn;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isObject(val) && "#func" in val) {
|
||||
props[key] = convertVDomFunc(val, elem.id, key);
|
||||
if (isObject(val) && val.type == VDomObjType_Func) {
|
||||
const valFunc = val as VDomFunc;
|
||||
props[key] = convertVDomFunc(model, valFunc, elem.waveid, key);
|
||||
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)[] = [];
|
||||
if (elem.children) {
|
||||
for (let child of elem.children) {
|
||||
if (child == null) {
|
||||
continue;
|
||||
}
|
||||
childrenComps.push(convertElemToTag(child));
|
||||
}
|
||||
}
|
||||
if (elem.tag == "#fragment") {
|
||||
if (elem.children == null) {
|
||||
return childrenComps;
|
||||
}
|
||||
for (let child of elem.children) {
|
||||
if (child == null) {
|
||||
continue;
|
||||
}
|
||||
childrenComps.push(convertElemToTag(child, model));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
function VDomView({ rootNode }: { rootNode: VDomElem }) {
|
||||
let rtn = convertElemToTag(rootNode);
|
||||
function vdomText(text: string): VDomElem {
|
||||
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>;
|
||||
}
|
||||
|
||||
export { VDomView };
|
||||
export { VDomRoot };
|
||||
|
28
frontend/app/view/vdom/vdom-view.tsx
Normal file
28
frontend/app/view/vdom/vdom-view.tsx
Normal 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 };
|
@ -5,8 +5,8 @@ import { Button } from "@/app/element/button";
|
||||
import { Markdown } from "@/app/element/markdown";
|
||||
import { TypingIndicator } from "@/app/element/typingindicator";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { WindowRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { atoms, fetchWaveFile, globalStore, WOS } from "@/store/global";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { atoms, createBlock, fetchWaveFile, getApi, globalStore, WOS } from "@/store/global";
|
||||
import { BlockService, ObjectService } from "@/store/services";
|
||||
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
|
||||
import { fireAndForget, isBlank, makeIconClass } from "@/util/util";
|
||||
@ -182,26 +182,41 @@ export class WaveAiModel implements ViewModel {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownItems = Object.entries(presets)
|
||||
.sort((a, b) => (a[1]["display:order"] > b[1]["display:order"] ? 1 : -1))
|
||||
.map(
|
||||
(preset) =>
|
||||
({
|
||||
label: preset[1]["display:name"],
|
||||
onClick: () =>
|
||||
fireAndForget(async () => {
|
||||
await ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), {
|
||||
...preset[1],
|
||||
"ai:preset": preset[0],
|
||||
});
|
||||
}),
|
||||
}) 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: Object.entries(presets)
|
||||
.sort((a, b) => (a[1]["display:order"] > b[1]["display:order"] ? 1 : -1))
|
||||
.map(
|
||||
(preset) =>
|
||||
({
|
||||
label: preset[1]["display:name"],
|
||||
onClick: () =>
|
||||
fireAndForget(async () => {
|
||||
await ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), {
|
||||
...preset[1],
|
||||
"ai:preset": preset[0],
|
||||
});
|
||||
}),
|
||||
}) as MenuItem
|
||||
),
|
||||
items: dropdownItems,
|
||||
});
|
||||
return viewTextChildren;
|
||||
});
|
||||
@ -274,7 +289,7 @@ export class WaveAiModel implements ViewModel {
|
||||
};
|
||||
let fullMsg = "";
|
||||
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) {
|
||||
fullMsg += msg.text ?? "";
|
||||
globalStore.set(this.updateLastMessageAtom, msg.text ?? "", true);
|
||||
|
@ -5,7 +5,7 @@ import { getApi, getSettingsKeyAtom, openLink } from "@/app/store/global";
|
||||
import { getSimpleControlShiftAtom } from "@/app/store/keymodel";
|
||||
import { ObjectService } from "@/app/store/services";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { WindowRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { NodeModel } from "@/layout/index";
|
||||
import { WOS, globalStore } from "@/store/global";
|
||||
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
|
||||
@ -369,17 +369,17 @@ export class WebViewModel implements ViewModel {
|
||||
if (url != null && url != "") {
|
||||
switch (scope) {
|
||||
case "block":
|
||||
await RpcApi.SetMetaCommand(WindowRpcClient, {
|
||||
await RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { pinnedurl: url },
|
||||
});
|
||||
break;
|
||||
case "global":
|
||||
await RpcApi.SetMetaCommand(WindowRpcClient, {
|
||||
await RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { pinnedurl: "" },
|
||||
});
|
||||
await RpcApi.SetConfigCommand(WindowRpcClient, { "web:defaulturl": url });
|
||||
await RpcApi.SetConfigCommand(TabRpcClient, { "web:defaulturl": url });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -94,20 +94,18 @@ const Widget = memo(({ widget }: { widget: WidgetConfigType }) => {
|
||||
});
|
||||
|
||||
const WorkspaceElem = memo(() => {
|
||||
const windowData = useAtomValue(atoms.waveWindow);
|
||||
const activeTabId = windowData?.activetabid;
|
||||
const tabId = useAtomValue(atoms.staticTabId);
|
||||
const ws = useAtomValue(atoms.workspace);
|
||||
|
||||
return (
|
||||
<div className="workspace">
|
||||
<TabBar key={ws.oid} workspace={ws} />
|
||||
<div className="workspace-tabcontent">
|
||||
<ErrorBoundary key={activeTabId}>
|
||||
{activeTabId == "" ? (
|
||||
<ErrorBoundary key={tabId}>
|
||||
{tabId == "" ? (
|
||||
<CenteredDiv>No Active Tab</CenteredDiv>
|
||||
) : (
|
||||
<>
|
||||
<TabContent key={activeTabId} tabId={activeTabId} />
|
||||
<TabContent key={tabId} tabId={tabId} />
|
||||
<Widgets />
|
||||
<ModalsRenderer />
|
||||
</>
|
||||
|
@ -5,7 +5,7 @@ import { TileLayout } from "./lib/TileLayout";
|
||||
import { LayoutModel } from "./lib/layoutModel";
|
||||
import {
|
||||
deleteLayoutModelForTab,
|
||||
getLayoutModelForActiveTab,
|
||||
getLayoutModelForStaticTab,
|
||||
getLayoutModelForTab,
|
||||
getLayoutModelForTabById,
|
||||
useDebouncedNodeInnerRect,
|
||||
@ -37,7 +37,7 @@ import { DropDirection, LayoutTreeActionType, NavigateDirection } from "./lib/ty
|
||||
export {
|
||||
deleteLayoutModelForTab,
|
||||
DropDirection,
|
||||
getLayoutModelForActiveTab,
|
||||
getLayoutModelForStaticTab,
|
||||
getLayoutModelForTab,
|
||||
getLayoutModelForTabById,
|
||||
LayoutModel,
|
||||
|
@ -128,7 +128,6 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent;
|
||||
|
||||
interface DisplayNodesWrapperProps {
|
||||
@ -247,6 +246,7 @@ const DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => {
|
||||
magnified: addlProps?.isMagnifiedNode,
|
||||
"last-magnified": addlProps?.isLastMagnifiedNode,
|
||||
})}
|
||||
key={node.id}
|
||||
ref={tileNodeRef}
|
||||
id={node.id}
|
||||
style={addlProps?.transform}
|
||||
|
@ -39,6 +39,10 @@ export function withLayoutTreeStateAtomFromTab(tabAtom: Atom<Tab>): WritableLayo
|
||||
const stateAtom = getLayoutStateAtomFromTab(tabAtom, get);
|
||||
if (!stateAtom) return;
|
||||
const waveObjVal = get(stateAtom);
|
||||
if (waveObjVal == null) {
|
||||
console.log("in withLayoutTreeStateAtomFromTab, waveObjVal is null", value);
|
||||
return;
|
||||
}
|
||||
waveObjVal.rootnode = value.rootNode;
|
||||
waveObjVal.magnifiednodeid = value.magnifiedNodeId;
|
||||
waveObjVal.focusednodeid = value.focusedNodeId;
|
||||
|
@ -1,9 +1,9 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useOnResize } from "@/app/hook/useDimensions";
|
||||
import { atoms, globalStore, WOS } from "@/app/store/global";
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import useResizeObserver from "@react-hook/resize-observer";
|
||||
import { Atom, useAtomValue } from "jotai";
|
||||
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { debounce } from "throttle-debounce";
|
||||
@ -36,8 +36,8 @@ export function getLayoutModelForTabById(tabId: string) {
|
||||
return getLayoutModelForTab(tabAtom);
|
||||
}
|
||||
|
||||
export function getLayoutModelForActiveTab() {
|
||||
const tabId = globalStore.get(atoms.activeTabId);
|
||||
export function getLayoutModelForStaticTab() {
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
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)
|
||||
useAtomValue(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.
|
||||
useEffect(() => fireAndForget(async () => layoutModel.onTreeStateAtomUpdated(true)), []);
|
||||
|
42
frontend/types/custom.d.ts
vendored
42
frontend/types/custom.d.ts
vendored
@ -7,16 +7,15 @@ import type * as rxjs from "rxjs";
|
||||
|
||||
declare global {
|
||||
type GlobalAtomsType = {
|
||||
windowId: jotai.Atom<string>; // readonly
|
||||
clientId: jotai.Atom<string>; // readonly
|
||||
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
|
||||
workspace: jotai.Atom<Workspace>; // driven from WOS
|
||||
fullConfigAtom: jotai.PrimitiveAtom<FullConfigType>; // driven from WOS, settings -- updated via WebSocket
|
||||
settingsAtom: jotai.Atom<SettingsType>; // derrived from fullConfig
|
||||
tabAtom: jotai.Atom<Tab>; // driven from WOS
|
||||
activeTabId: jotai.Atom<string>; // derrived from windowDataAtom
|
||||
staticTabId: jotai.Atom<string>;
|
||||
isFullScreen: jotai.PrimitiveAtom<boolean>;
|
||||
controlShiftDelayAtom: jotai.PrimitiveAtom<boolean>;
|
||||
prefersReducedMotionAtom: jotai.Atom<boolean>;
|
||||
@ -50,6 +49,13 @@ declare global {
|
||||
blockId: string;
|
||||
};
|
||||
|
||||
type WaveInitOpts = {
|
||||
tabId: string;
|
||||
clientId: string;
|
||||
windowId: string;
|
||||
activate: boolean;
|
||||
};
|
||||
|
||||
type ElectronApi = {
|
||||
getAuthKey(): string;
|
||||
getIsDev(): boolean;
|
||||
@ -58,6 +64,8 @@ declare global {
|
||||
getEnv: (varName: string) => string;
|
||||
getUserName: () => string;
|
||||
getHostName: () => string;
|
||||
getDataDir: () => string;
|
||||
getConfigDir: () => string;
|
||||
getWebviewPreload: () => string;
|
||||
getAboutModalDetails: () => AboutModalDetails;
|
||||
getDocsiteUrl: () => string;
|
||||
@ -78,6 +86,12 @@ declare global {
|
||||
setWebviewFocus: (focusedId: number) => void; // focusedId si the getWebContentsId of the webview
|
||||
registerGlobalWebviewKeys: (keys: string[]) => 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;
|
||||
};
|
||||
|
||||
@ -263,6 +277,7 @@ declare global {
|
||||
getSettingsMenuItems?: () => ContextMenuItem[];
|
||||
giveFocus?: () => boolean;
|
||||
keyDownHandler?: (e: WaveKeyboardEvent) => boolean;
|
||||
dispose?: () => void;
|
||||
}
|
||||
|
||||
type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error" | "installing";
|
||||
@ -334,6 +349,27 @@ declare global {
|
||||
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 = {
|
||||
name?: string;
|
||||
color?: string;
|
||||
|
198
frontend/types/gotypes.d.ts
vendored
198
frontend/types/gotypes.d.ts
vendored
@ -7,9 +7,11 @@ declare global {
|
||||
|
||||
// waveobj.Block
|
||||
type Block = WaveObj & {
|
||||
parentoref?: string;
|
||||
blockdef: BlockDef;
|
||||
runtimeopts?: RuntimeOpts;
|
||||
stickers?: StickerType[];
|
||||
subblockids?: string[];
|
||||
};
|
||||
|
||||
// blockcontroller.BlockControllerRuntimeStatus
|
||||
@ -30,7 +32,7 @@ declare global {
|
||||
blockid: string;
|
||||
tabid: string;
|
||||
windowid: string;
|
||||
meta: MetaType;
|
||||
block: Block;
|
||||
};
|
||||
|
||||
// webcmd.BlockInputWSCommand
|
||||
@ -47,6 +49,12 @@ declare global {
|
||||
hasoldhistory?: boolean;
|
||||
};
|
||||
|
||||
// windowservice.CloseTabRtnType
|
||||
type CloseTabRtnType = {
|
||||
closewindow?: boolean;
|
||||
newactivetabid?: string;
|
||||
};
|
||||
|
||||
// wshrpc.CommandAppendIJsonData
|
||||
type CommandAppendIJsonData = {
|
||||
zoneid: string;
|
||||
@ -57,6 +65,7 @@ declare global {
|
||||
// wshrpc.CommandAuthenticateRtnData
|
||||
type CommandAuthenticateRtnData = {
|
||||
routeid: string;
|
||||
authtoken?: string;
|
||||
};
|
||||
|
||||
// wshrpc.CommandBlockInputData
|
||||
@ -89,11 +98,22 @@ declare global {
|
||||
magnified?: boolean;
|
||||
};
|
||||
|
||||
// wshrpc.CommandCreateSubBlockData
|
||||
type CommandCreateSubBlockData = {
|
||||
parentblockid: string;
|
||||
blockdef: BlockDef;
|
||||
};
|
||||
|
||||
// wshrpc.CommandDeleteBlockData
|
||||
type CommandDeleteBlockData = {
|
||||
blockid: string;
|
||||
};
|
||||
|
||||
// wshrpc.CommandDisposeData
|
||||
type CommandDisposeData = {
|
||||
routeid: string;
|
||||
};
|
||||
|
||||
// wshrpc.CommandEventReadHistoryData
|
||||
type CommandEventReadHistoryData = {
|
||||
event: string;
|
||||
@ -155,6 +175,12 @@ declare global {
|
||||
meta: MetaType;
|
||||
};
|
||||
|
||||
// wshrpc.CommandWaitForRouteData
|
||||
type CommandWaitForRouteData = {
|
||||
routeid: string;
|
||||
waitms: number;
|
||||
};
|
||||
|
||||
// wshrpc.CommandWebSelectorData
|
||||
type CommandWebSelectorData = {
|
||||
windowid: string;
|
||||
@ -186,6 +212,16 @@ declare global {
|
||||
count: number;
|
||||
};
|
||||
|
||||
// vdom.DomRect
|
||||
type DomRect = {
|
||||
top: number;
|
||||
left: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// waveobj.FileDef
|
||||
type FileDef = {
|
||||
filetype?: string;
|
||||
@ -319,6 +355,12 @@ declare global {
|
||||
"term:localshellpath"?: string;
|
||||
"term:localshellopts"?: string[];
|
||||
"term:scrollback"?: number;
|
||||
"term:vdomblockid"?: string;
|
||||
"vdom:*"?: boolean;
|
||||
"vdom:initialized"?: boolean;
|
||||
"vdom:correlationid"?: string;
|
||||
"vdom:route"?: string;
|
||||
"vdom:persist"?: boolean;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
@ -397,6 +439,7 @@ declare global {
|
||||
resid?: string;
|
||||
timeout?: number;
|
||||
route?: string;
|
||||
authtoken?: string;
|
||||
source?: string;
|
||||
cont?: boolean;
|
||||
cancel?: boolean;
|
||||
@ -473,6 +516,7 @@ declare global {
|
||||
"window:showmenubar"?: boolean;
|
||||
"window:nativetitlebar"?: boolean;
|
||||
"window:disablehardwareacceleration"?: boolean;
|
||||
"window:maxtabcachesize"?: number;
|
||||
"telemetry:*"?: boolean;
|
||||
"telemetry:enabled"?: boolean;
|
||||
"conn:*"?: boolean;
|
||||
@ -582,27 +626,155 @@ declare global {
|
||||
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 = {
|
||||
id?: string;
|
||||
waveid?: string;
|
||||
tag: string;
|
||||
props?: {[key: string]: any};
|
||||
children?: VDomElem[];
|
||||
text?: string;
|
||||
};
|
||||
|
||||
// vdom.VDomFuncType
|
||||
type VDomFuncType = {
|
||||
#func: string;
|
||||
#stopPropagation?: boolean;
|
||||
#preventDefault?: boolean;
|
||||
#keys?: string[];
|
||||
// vdom.VDomEvent
|
||||
type VDomEvent = {
|
||||
waveid: string;
|
||||
eventtype: string;
|
||||
eventdata: any;
|
||||
};
|
||||
|
||||
// vdom.VDomRefType
|
||||
type VDomRefType = {
|
||||
#ref: string;
|
||||
current: any;
|
||||
// vdom.VDomFrontendUpdate
|
||||
type VDomFrontendUpdate = {
|
||||
type: "frontendupdate";
|
||||
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 = {
|
||||
|
@ -241,6 +241,56 @@ function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent {
|
||||
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 {
|
||||
adaptFromElectronKeyEvent,
|
||||
adaptFromReactOrNativeKeyEvent,
|
||||
@ -248,6 +298,7 @@ export {
|
||||
getKeyUtilPlatform,
|
||||
isCharacterKeyEvent,
|
||||
isInputEvent,
|
||||
keyboardEventToASCII,
|
||||
keydownWrapper,
|
||||
parseKeyDescription,
|
||||
setKeyUtilPlatform,
|
||||
|
140
frontend/wave.ts
140
frontend/wave.ts
@ -8,11 +8,11 @@ import {
|
||||
registerGlobalKeys,
|
||||
} from "@/app/store/keymodel";
|
||||
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 { initWshrpc, WindowRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { initWshrpc, TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { loadMonaco } from "@/app/view/codeeditor/codeeditor";
|
||||
import { getLayoutModelForActiveTab } from "@/layout/index";
|
||||
import { getLayoutModelForStaticTab } from "@/layout/index";
|
||||
import {
|
||||
atoms,
|
||||
countersClear,
|
||||
@ -32,18 +32,9 @@ import { createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
const platform = getApi().getPlatform();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const windowId = urlParams.get("windowid");
|
||||
const clientId = urlParams.get("clientid");
|
||||
document.title = `Wave Terminal`;
|
||||
let savedInitOpts: WaveInitOpts = null;
|
||||
|
||||
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).globalStore = globalStore;
|
||||
(window as any).globalAtoms = atoms;
|
||||
@ -51,29 +42,109 @@ loadFonts();
|
||||
(window as any).isFullScreen = false;
|
||||
(window as any).countersPrint = countersPrint;
|
||||
(window as any).countersClear = countersClear;
|
||||
(window as any).getLayoutModelForActiveTab = getLayoutModelForActiveTab;
|
||||
(window as any).getLayoutModelForStaticTab = getLayoutModelForStaticTab;
|
||||
(window as any).pushFlashError = pushFlashError;
|
||||
(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 () => {
|
||||
console.log("DOMContentLoaded");
|
||||
document.addEventListener("DOMContentLoaded", initBare);
|
||||
|
||||
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
|
||||
const globalWS = initWshrpc(windowId);
|
||||
const globalWS = initWshrpc(initOpts.tabId);
|
||||
(window as any).globalWS = globalWS;
|
||||
(window as any).WindowRpcClient = WindowRpcClient;
|
||||
(window as any).TabRpcClient = TabRpcClient;
|
||||
await loadConnStatus();
|
||||
initGlobalWaveEventSubs();
|
||||
subscribeToConnEvents();
|
||||
|
||||
// ensures client/window/workspace are loaded into the cache before rendering
|
||||
const client = await WOS.loadAndPinWaveObject<Client>(WOS.makeORef("client", clientId));
|
||||
const waveWindow = await WOS.loadAndPinWaveObject<WaveWindow>(WOS.makeORef("window", windowId));
|
||||
await WOS.loadAndPinWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));
|
||||
const initialTab = await WOS.loadAndPinWaveObject<Tab>(WOS.makeORef("tab", waveWindow.activetabid));
|
||||
await WOS.loadAndPinWaveObject<LayoutState>(WOS.makeORef("layout", initialTab.layoutstate));
|
||||
const [client, waveWindow, initialTab] = await Promise.all([
|
||||
WOS.loadAndPinWaveObject<Client>(WOS.makeORef("client", initOpts.clientId)),
|
||||
WOS.loadAndPinWaveObject<WaveWindow>(WOS.makeORef("window", initOpts.windowId)),
|
||||
WOS.loadAndPinWaveObject<Tab>(WOS.makeORef("tab", initOpts.tabId)),
|
||||
]);
|
||||
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();
|
||||
registerElectronReinjectKeyHandler();
|
||||
@ -82,15 +153,16 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const fullConfig = await FileService.GetFullConfig();
|
||||
console.log("fullconfig", fullConfig);
|
||||
globalStore.set(atoms.fullConfigAtom, fullConfig);
|
||||
const prtn = ObjectService.SetActiveTab(waveWindow.activetabid); // no need to wait
|
||||
prtn.catch((e) => {
|
||||
console.log("error on initial SetActiveTab", e);
|
||||
console.log("Wave First Render");
|
||||
let firstRenderResolveFn: () => void = null;
|
||||
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 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
3
go.mod
@ -21,6 +21,7 @@ require (
|
||||
github.com/shirou/gopsutil/v4 v4.24.9
|
||||
github.com/skeema/knownhosts v1.3.0
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b
|
||||
github.com/wavetermdev/htmltoken v0.1.0
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/sys v0.26.0
|
||||
@ -36,9 +37,11 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // 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/tklauser/go-sysconf v0.3.12 // 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
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
golang.org/x/net v0.29.0 // indirect
|
||||
|
11
go.sum
11
go.sum
@ -1,5 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
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/go.mod h1:U0+VA/i30mGBlLCrFPGtTe9y6wGQfNAWPBTekHQ+c8A=
|
||||
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/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/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/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M=
|
||||
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/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.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/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/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
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/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk=
|
||||
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/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-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.8.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=
|
||||
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.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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color scheme" content="light dark" />
|
||||
<title>Wave</title>
|
||||
<link rel="stylesheet" href="/fontawesome/css/fontawesome.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" />
|
||||
<script type="module" src="frontend/wave.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="init">
|
||||
<div id="main"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -102,6 +102,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"debug": "^4.3.7",
|
||||
"electron-updater": "6.3.9",
|
||||
"env-paths": "^3.0.0",
|
||||
"fast-average-color": "^9.4.0",
|
||||
"htl": "^0.3.1",
|
||||
"html-to-image": "^1.11.11",
|
||||
|
@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@ -24,6 +25,7 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshrpc"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
||||
"github.com/wavetermdev/waveterm/pkg/wsl"
|
||||
"github.com/wavetermdev/waveterm/pkg/wstore"
|
||||
)
|
||||
|
||||
@ -34,7 +36,7 @@ const (
|
||||
|
||||
const (
|
||||
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 (
|
||||
@ -262,7 +264,30 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
|
||||
return fmt.Errorf("unknown controller type %q", bc.ControllerType)
|
||||
}
|
||||
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)
|
||||
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
|
||||
wshProxy := wshutil.MakeRpcProxy()
|
||||
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)
|
||||
go func() {
|
||||
// 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() {
|
||||
defer func() {
|
||||
log.Printf("[shellproc] shellInputCh loop done\n")
|
||||
}()
|
||||
// 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)
|
||||
for ic := range shellInputCh {
|
||||
@ -497,6 +519,15 @@ func CheckConnStatus(blockId string) error {
|
||||
if connName == "" {
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing connection name: %w", err)
|
||||
|
@ -10,8 +10,6 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -27,21 +25,19 @@ type WSEventType struct {
|
||||
}
|
||||
|
||||
type WindowWatchData struct {
|
||||
WindowWSCh chan any
|
||||
WaveWindowId string
|
||||
WatchedORefs map[waveobj.ORef]bool
|
||||
WindowWSCh chan any
|
||||
TabId string
|
||||
}
|
||||
|
||||
var globalLock = &sync.Mutex{}
|
||||
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()
|
||||
defer globalLock.Unlock()
|
||||
wsMap[connId] = &WindowWatchData{
|
||||
WindowWSCh: ch,
|
||||
WaveWindowId: windowId,
|
||||
WatchedORefs: make(map[waveobj.ORef]bool),
|
||||
WindowWSCh: ch,
|
||||
TabId: tabId,
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,7 +52,7 @@ func getWindowWatchesForWindowId(windowId string) []*WindowWatchData {
|
||||
defer globalLock.Unlock()
|
||||
var watches []*WindowWatchData
|
||||
for _, wdata := range wsMap {
|
||||
if wdata.WaveWindowId == windowId {
|
||||
if wdata.TabId == windowId {
|
||||
watches = append(watches, wdata)
|
||||
}
|
||||
}
|
||||
|
@ -50,7 +50,7 @@ func InitFilestore() error {
|
||||
}
|
||||
|
||||
func GetDBName() string {
|
||||
waveHome := wavebase.GetWaveHomeDir()
|
||||
waveHome := wavebase.GetWaveDataDir()
|
||||
return filepath.Join(waveHome, wavebase.WaveDBDir, FilestoreDBName)
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package remote
|
||||
|
||||
import (
|
||||
|
@ -38,7 +38,14 @@ func (bs *BlockService) GetControllerStatus(ctx context.Context, blockId string)
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -52,7 +59,11 @@ func (bs *BlockService) SaveTerminalState(ctx context.Context, blockId string, s
|
||||
if err != nil {
|
||||
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 {
|
||||
return fmt.Errorf("cannot save terminal state meta: %w", err)
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/wcore"
|
||||
"github.com/wavetermdev/waveterm/pkg/wlayout"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshrpc"
|
||||
"github.com/wavetermdev/waveterm/pkg/wsl"
|
||||
"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) {
|
||||
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
|
||||
|
@ -13,6 +13,7 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wcore"
|
||||
"github.com/wavetermdev/waveterm/pkg/wlayout"
|
||||
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||
"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 {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"uiContext", "tabName", "activateTab"},
|
||||
ArgNames: []string{"windowId", "tabName", "activateTab"},
|
||||
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)
|
||||
defer cancelFn()
|
||||
ctx = waveobj.ContextWithUpdates(ctx)
|
||||
tabId, err := wcore.CreateTab(ctx, uiContext.WindowId, tabName, activateTab)
|
||||
tabId, err := wcore.CreateTab(ctx, windowId, tabName, activateTab)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
|
||||
err = wlayout.ApplyPortableLayout(ctx, tabId, wlayout.GetNewTabLayout())
|
||||
if err != nil {
|
||||
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 {
|
||||
@ -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)
|
||||
defer cancelFn()
|
||||
ctx = waveobj.ContextWithUpdates(ctx)
|
||||
err := wstore.SetActiveTab(ctx, uiContext.WindowId, tabId)
|
||||
err := wstore.SetActiveTab(ctx, windowId, tabId)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
updates := waveobj.ContextGetUpdatesRtn(ctx)
|
||||
updates = append(updates, waveobj.MakeUpdate(tab))
|
||||
updates = append(updates, waveobj.MakeUpdates(blocks)...)
|
||||
return updates, nil
|
||||
go func() {
|
||||
wps.Broker.SendUpdateEvents(updates)
|
||||
}()
|
||||
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 {
|
||||
@ -192,7 +201,7 @@ func (svc *ObjectService) DeleteBlock(uiContext waveobj.UIContext, blockId strin
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancelFn()
|
||||
ctx = waveobj.ContextWithUpdates(ctx)
|
||||
err := wcore.DeleteBlock(ctx, uiContext.ActiveTabId, blockId)
|
||||
err := wcore.DeleteBlock(ctx, blockId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error deleting block: %w", err)
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wcore"
|
||||
"github.com/wavetermdev/waveterm/pkg/wlayout"
|
||||
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||
"github.com/wavetermdev/waveterm/pkg/wstore"
|
||||
)
|
||||
|
||||
@ -46,19 +47,25 @@ func (ws *WindowService) SetWindowPosAndSize(ctx context.Context, windowId strin
|
||||
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)
|
||||
window, err := wstore.DBMustGet[*waveobj.Window](ctx, uiContext.WindowId)
|
||||
window, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId)
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting workspace: %w", err)
|
||||
return nil, nil, fmt.Errorf("error getting workspace: %w", err)
|
||||
}
|
||||
tabIndex := -1
|
||||
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 {
|
||||
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 len(ws.TabIds) == 1 {
|
||||
svc.CloseWindow(ctx, uiContext.WindowId)
|
||||
eventbus.SendEventToElectron(eventbus.WSEventType{
|
||||
EventType: eventbus.WSEvent_ElectronCloseWindow,
|
||||
Data: uiContext.WindowId,
|
||||
})
|
||||
rtn.CloseWindow = true
|
||||
svc.CloseWindow(ctx, windowId, fromElectron)
|
||||
if !fromElectron {
|
||||
eventbus.SendEventToElectron(eventbus.WSEventType{
|
||||
EventType: eventbus.WSEvent_ElectronCloseWindow,
|
||||
Data: windowId,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if tabIndex < len(ws.TabIds)-1 {
|
||||
newActiveTabId := ws.TabIds[tabIndex+1]
|
||||
wstore.SetActiveTab(ctx, uiContext.WindowId, newActiveTabId)
|
||||
wstore.SetActiveTab(ctx, windowId, newActiveTabId)
|
||||
rtn.NewActiveTabId = newActiveTabId
|
||||
} else {
|
||||
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 {
|
||||
@ -148,7 +165,7 @@ func (svc *WindowService) MoveBlockToNewWindow(ctx context.Context, currentTabId
|
||||
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)
|
||||
window, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId)
|
||||
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)
|
||||
}
|
||||
for _, tabId := range workspace.TabIds {
|
||||
uiContext := waveobj.UIContext{WindowId: windowId}
|
||||
_, err := svc.CloseTab(ctx, uiContext, tabId)
|
||||
_, _, err := svc.CloseTab(ctx, windowId, tabId, fromElectron)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error closing tab: %w", err)
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"github.com/wavetermdev/waveterm/pkg/wsl"
|
||||
"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 {
|
||||
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
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ package shellexec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@ -25,6 +26,7 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/wavebase"
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
||||
"github.com/wavetermdev/waveterm/pkg/wsl"
|
||||
)
|
||||
|
||||
const DefaultGracefulKillWait = 400 * time.Millisecond
|
||||
@ -141,6 +143,96 @@ func (pp *PipePty) WriteString(s string) (n int, err error) {
|
||||
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) {
|
||||
client := conn.GetClient()
|
||||
shellPath := cmdOpts.ShellPath
|
||||
@ -289,7 +381,7 @@ func StartShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOpt
|
||||
// cant set -l or -i with --rcfile
|
||||
shellOpts = append(shellOpts, "--rcfile", shellutil.GetBashRcFileOverride())
|
||||
} else if isFishShell(shellPath) {
|
||||
wshBinDir := filepath.Join(wavebase.GetWaveHomeDir(), shellutil.WaveHomeBinDir)
|
||||
wshBinDir := filepath.Join(wavebase.GetWaveDataDir(), shellutil.WaveHomeBinDir)
|
||||
quotedWshBinDir := utilfn.ShellQuote(wshBinDir, false, 300)
|
||||
shellOpts = append(shellOpts, "-C", fmt.Sprintf("set -x PATH %s $PATH", quotedWshBinDir))
|
||||
} else if remote.IsPowershell(shellPath) {
|
||||
|
@ -42,9 +42,13 @@ var ExtraTypes = []any{
|
||||
wshutil.RpcMessage{},
|
||||
wshrpc.WshServerCommandMeta{},
|
||||
userinput.UserInputRequest{},
|
||||
vdom.Elem{},
|
||||
vdom.VDomFuncType{},
|
||||
vdom.VDomRefType{},
|
||||
vdom.VDomCreateContext{},
|
||||
vdom.VDomElem{},
|
||||
vdom.VDomFunc{},
|
||||
vdom.VDomRef{},
|
||||
vdom.VDomBinding{},
|
||||
vdom.VDomFrontendUpdate{},
|
||||
vdom.VDomBackendUpdate{},
|
||||
waveobj.MetaTSType{},
|
||||
}
|
||||
|
||||
|
58
pkg/util/packetparser/packetparser.go
Normal file
58
pkg/util/packetparser/packetparser.go
Normal 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
15
pkg/util/panic/panic.go
Normal 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)
|
||||
}
|
||||
}
|
@ -149,7 +149,7 @@ func WaveshellLocalEnvVars(termType string) map[string]string {
|
||||
rtn["TERM_PROGRAM"] = "waveterm"
|
||||
rtn["WAVETERM"], _ = os.Executable()
|
||||
rtn["WAVETERM_VERSION"] = wavebase.WaveVersion
|
||||
rtn["WAVETERM_WSHBINDIR"] = filepath.Join(wavebase.GetWaveHomeDir(), WaveHomeBinDir)
|
||||
rtn["WAVETERM_WSHBINDIR"] = filepath.Join(wavebase.GetWaveDataDir(), WaveHomeBinDir)
|
||||
return rtn
|
||||
}
|
||||
|
||||
@ -202,15 +202,15 @@ func InitCustomShellStartupFiles() error {
|
||||
}
|
||||
|
||||
func GetBashRcFileOverride() string {
|
||||
return filepath.Join(wavebase.GetWaveHomeDir(), BashIntegrationDir, ".bashrc")
|
||||
return filepath.Join(wavebase.GetWaveDataDir(), BashIntegrationDir, ".bashrc")
|
||||
}
|
||||
|
||||
func GetWavePowershellEnv() string {
|
||||
return filepath.Join(wavebase.GetWaveHomeDir(), PwshIntegrationDir, "wavepwsh.ps1")
|
||||
return filepath.Join(wavebase.GetWaveDataDir(), PwshIntegrationDir, "wavepwsh.ps1")
|
||||
}
|
||||
|
||||
func GetZshZDotDir() string {
|
||||
return filepath.Join(wavebase.GetWaveHomeDir(), ZshIntegrationDir)
|
||||
return filepath.Join(wavebase.GetWaveDataDir(), ZshIntegrationDir)
|
||||
}
|
||||
|
||||
func GetWshBaseName(version string, goos string, goarch string) string {
|
||||
@ -289,9 +289,9 @@ func InitRcFiles(waveHome string, wshBinDir string) error {
|
||||
|
||||
func initCustomShellStartupFilesInternal() error {
|
||||
log.Printf("initializing wsh and shell startup files\n")
|
||||
waveHome := wavebase.GetWaveHomeDir()
|
||||
binDir := filepath.Join(waveHome, WaveHomeBinDir)
|
||||
err := InitRcFiles(waveHome, `$WAVETERM_WSHBINDIR`)
|
||||
waveDataHome := wavebase.GetWaveDataDir()
|
||||
binDir := filepath.Join(waveDataHome, WaveHomeBinDir)
|
||||
err := InitRcFiles(waveDataHome, `$WAVETERM_WSHBINDIR`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
89
pkg/util/utilfn/compare.go
Normal file
89
pkg/util/utilfn/compare.go
Normal 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
|
||||
}
|
||||
}
|
159
pkg/vdom/cssparser/cssparser.go
Normal file
159
pkg/vdom/cssparser/cssparser.go
Normal 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
|
||||
}
|
81
pkg/vdom/cssparser/cssparser_test.go
Normal file
81
pkg/vdom/cssparser/cssparser_test.go
Normal 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)
|
||||
}
|
108
pkg/vdom/vdom.go
108
pkg/vdom/vdom.go
@ -15,35 +15,6 @@ import (
|
||||
|
||||
// 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
|
||||
type Hook struct {
|
||||
Init bool // is initialized
|
||||
@ -56,7 +27,7 @@ type Hook struct {
|
||||
|
||||
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]
|
||||
if !ok {
|
||||
return ""
|
||||
@ -68,8 +39,8 @@ func (e *Elem) Key() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func TextElem(text string) Elem {
|
||||
return Elem{Tag: TextTag, Text: text}
|
||||
func TextElem(text string) VDomElem {
|
||||
return VDomElem{Tag: TextTag, Text: text}
|
||||
}
|
||||
|
||||
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 {
|
||||
rtn := &Elem{Tag: tag}
|
||||
func E(tag string, parts ...any) *VDomElem {
|
||||
rtn := &VDomElem{Tag: tag}
|
||||
for _, part := range parts {
|
||||
if part == nil {
|
||||
continue
|
||||
@ -135,19 +106,44 @@ func UseState[T any](ctx context.Context, initialVal T) (T, func(T)) {
|
||||
}
|
||||
setVal := func(newVal T) {
|
||||
hookVal.Val = newVal
|
||||
vc.Root.AddRenderWork(vc.Comp.Id)
|
||||
vc.Root.AddRenderWork(vc.Comp.WaveId)
|
||||
}
|
||||
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)
|
||||
if !hookVal.Init {
|
||||
hookVal.Init = true
|
||||
refId := vc.Comp.Id + ":" + strconv.Itoa(hookVal.Idx)
|
||||
hookVal.Val = &VDomRefType{RefId: refId, Current: initialVal}
|
||||
closedWaveId := vc.Comp.WaveId
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
@ -181,7 +177,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) {
|
||||
hookVal.Init = true
|
||||
hookVal.Fn = fn
|
||||
hookVal.Deps = deps
|
||||
vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx)
|
||||
vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx)
|
||||
return
|
||||
}
|
||||
if depsEqual(hookVal.Deps, deps) {
|
||||
@ -189,7 +185,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) {
|
||||
}
|
||||
hookVal.Fn = fn
|
||||
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) {
|
||||
@ -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 {
|
||||
return nil
|
||||
}
|
||||
switch part := part.(type) {
|
||||
case string:
|
||||
return []Elem{TextElem(part)}
|
||||
case *Elem:
|
||||
return []VDomElem{TextElem(part)}
|
||||
case *VDomElem:
|
||||
if part == nil {
|
||||
return nil
|
||||
}
|
||||
return []Elem{*part}
|
||||
case Elem:
|
||||
return []Elem{part}
|
||||
case []Elem:
|
||||
return []VDomElem{*part}
|
||||
case VDomElem:
|
||||
return []VDomElem{part}
|
||||
case []VDomElem:
|
||||
return part
|
||||
case []*Elem:
|
||||
var rtn []Elem
|
||||
case []*VDomElem:
|
||||
var rtn []VDomElem
|
||||
for _, e := range part {
|
||||
if e == nil {
|
||||
continue
|
||||
@ -235,11 +231,11 @@ func partToElems(part any) []Elem {
|
||||
}
|
||||
sval, ok := numToString(part)
|
||||
if ok {
|
||||
return []Elem{TextElem(sval)}
|
||||
return []VDomElem{TextElem(sval)}
|
||||
}
|
||||
partVal := reflect.ValueOf(part)
|
||||
if partVal.Kind() == reflect.Slice {
|
||||
var rtn []Elem
|
||||
var rtn []VDomElem
|
||||
for i := 0; i < partVal.Len(); i++ {
|
||||
subPart := partVal.Index(i).Interface()
|
||||
rtn = append(rtn, partToElems(subPart)...)
|
||||
@ -248,14 +244,14 @@ func partToElems(part any) []Elem {
|
||||
}
|
||||
stringer, ok := part.(fmt.Stringer)
|
||||
if ok {
|
||||
return []Elem{TextElem(stringer.String())}
|
||||
return []VDomElem{TextElem(stringer.String())}
|
||||
}
|
||||
jsonStr, jsonErr := json.Marshal(part)
|
||||
if jsonErr == nil {
|
||||
return []Elem{TextElem(string(jsonStr))}
|
||||
return []VDomElem{TextElem(string(jsonStr))}
|
||||
}
|
||||
typeText := "invalid:" + reflect.TypeOf(part).String()
|
||||
return []Elem{TextElem(typeText)}
|
||||
return []VDomElem{TextElem(typeText)}
|
||||
}
|
||||
|
||||
func isWaveTag(tag string) bool {
|
||||
|
@ -13,10 +13,10 @@ type ChildKey struct {
|
||||
}
|
||||
|
||||
type Component struct {
|
||||
Id string
|
||||
WaveId string
|
||||
Tag string
|
||||
Key string
|
||||
Elem *Elem
|
||||
Elem *VDomElem
|
||||
Mounted bool
|
||||
|
||||
// hooks
|
||||
|
@ -4,17 +4,25 @@
|
||||
package vdom
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/wavetermdev/htmltoken"
|
||||
"github.com/wavetermdev/waveterm/pkg/vdom/cssparser"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
@ -25,14 +33,14 @@ func appendChildToStack(stack []*Elem, child *Elem) {
|
||||
parent.Children = append(parent.Children, *child)
|
||||
}
|
||||
|
||||
func pushElemStack(stack []*Elem, elem *Elem) []*Elem {
|
||||
func pushElemStack(stack []*VDomElem, elem *VDomElem) []*VDomElem {
|
||||
if elem == nil {
|
||||
return stack
|
||||
}
|
||||
return append(stack, elem)
|
||||
}
|
||||
|
||||
func popElemStack(stack []*Elem) []*Elem {
|
||||
func popElemStack(stack []*VDomElem) []*VDomElem {
|
||||
if len(stack) <= 1 {
|
||||
return stack
|
||||
}
|
||||
@ -41,14 +49,14 @@ func popElemStack(stack []*Elem) []*Elem {
|
||||
return stack[:len(stack)-1]
|
||||
}
|
||||
|
||||
func curElemTag(stack []*Elem) string {
|
||||
func curElemTag(stack []*VDomElem) string {
|
||||
if len(stack) == 0 {
|
||||
return ""
|
||||
}
|
||||
return stack[len(stack)-1].Tag
|
||||
}
|
||||
|
||||
func finalizeStack(stack []*Elem) *Elem {
|
||||
func finalizeStack(stack []*VDomElem) *VDomElem {
|
||||
if len(stack) == 0 {
|
||||
return nil
|
||||
}
|
||||
@ -65,7 +73,20 @@ func finalizeStack(stack []*Elem) *Elem {
|
||||
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 {
|
||||
if attr.Key == key {
|
||||
return attr.Val
|
||||
@ -74,8 +95,38 @@ func getAttr(token htmltoken.Token, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func tokenToElem(token htmltoken.Token, data map[string]any) *Elem {
|
||||
elem := &Elem{Tag: token.Data}
|
||||
func attrToProp(attrVal string, isJson bool, params map[string]any) any {
|
||||
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 {
|
||||
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 == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(attr.Val, "#bind:") {
|
||||
bindKey := attr.Val[6:]
|
||||
bindVal, ok := data[bindKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
elem.Props[attr.Key] = bindVal
|
||||
continue
|
||||
}
|
||||
elem.Props[attr.Key] = attr.Val
|
||||
propVal := attrToProp(attr.Val, false, params)
|
||||
elem.Props[attr.Key] = propVal
|
||||
}
|
||||
return elem
|
||||
}
|
||||
@ -177,12 +220,101 @@ func processTextStr(s string) string {
|
||||
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)
|
||||
r := strings.NewReader(htmlStr)
|
||||
iter := htmltoken.NewTokenizer(r)
|
||||
var elemStack []*Elem
|
||||
elemStack = append(elemStack, &Elem{Tag: FragmentTag})
|
||||
var elemStack []*VDomElem
|
||||
elemStack = append(elemStack, &VDomElem{Tag: FragmentTag})
|
||||
var tokenErr error
|
||||
outer:
|
||||
for {
|
||||
@ -190,15 +322,15 @@ outer:
|
||||
token := iter.Token()
|
||||
switch tokenType {
|
||||
case htmltoken.StartTagToken:
|
||||
if token.Data == "bind" {
|
||||
tokenErr = errors.New("bind tag must be self closing")
|
||||
if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName {
|
||||
tokenErr = errors.New("bind tags must be self closing")
|
||||
break outer
|
||||
}
|
||||
elem := tokenToElem(token, data)
|
||||
elem := tokenToElem(token, params)
|
||||
elemStack = pushElemStack(elemStack, elem)
|
||||
case htmltoken.EndTagToken:
|
||||
if token.Data == "bind" {
|
||||
tokenErr = errors.New("bind tag must be self closing")
|
||||
if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName {
|
||||
tokenErr = errors.New("bind tags must be self closing")
|
||||
break outer
|
||||
}
|
||||
if len(elemStack) <= 1 {
|
||||
@ -211,16 +343,22 @@ outer:
|
||||
}
|
||||
elemStack = popElemStack(elemStack)
|
||||
case htmltoken.SelfClosingTagToken:
|
||||
if token.Data == "bind" {
|
||||
keyAttr := getAttr(token, "key")
|
||||
dataVal := data[keyAttr]
|
||||
if token.Data == Html_BindParamTagName {
|
||||
keyAttr := getAttrString(token, "key")
|
||||
dataVal := params[keyAttr]
|
||||
elemList := partToElems(dataVal)
|
||||
for _, elem := range elemList {
|
||||
appendChildToStack(elemStack, &elem)
|
||||
}
|
||||
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)
|
||||
case htmltoken.TextToken:
|
||||
if token.Data == "" {
|
||||
@ -249,5 +387,7 @@ outer:
|
||||
errTextElem := TextElem(tokenErr.Error())
|
||||
appendChildToStack(elemStack, &errTextElem)
|
||||
}
|
||||
return finalizeStack(elemStack)
|
||||
rtn := finalizeStack(elemStack)
|
||||
fixupStyleAttributes(rtn, params, nil)
|
||||
return rtn
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"reflect"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
|
||||
)
|
||||
|
||||
type vdomContextKeyType struct{}
|
||||
@ -22,13 +23,20 @@ type VDomContextVal struct {
|
||||
HookIdx int
|
||||
}
|
||||
|
||||
type Atom struct {
|
||||
Val any
|
||||
Dirty bool
|
||||
UsedBy map[string]bool // component waveid -> true
|
||||
}
|
||||
|
||||
type RootElem struct {
|
||||
OuterCtx context.Context
|
||||
Root *Component
|
||||
CFuncs map[string]CFunc
|
||||
CompMap map[string]*Component // component id -> component
|
||||
CompMap map[string]*Component // component waveid -> component
|
||||
EffectWorkQueue []*EffectWorkElem
|
||||
NeedsRenderMap map[string]bool
|
||||
Atoms map[string]*Atom
|
||||
}
|
||||
|
||||
const (
|
||||
@ -57,9 +65,49 @@ func MakeRoot() *RootElem {
|
||||
Root: nil,
|
||||
CFuncs: make(map[string]CFunc),
|
||||
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) {
|
||||
r.OuterCtx = ctx
|
||||
}
|
||||
@ -68,30 +116,60 @@ func (r *RootElem) RegisterComponent(name string, cfunc CFunc) {
|
||||
r.CFuncs[name] = cfunc
|
||||
}
|
||||
|
||||
func (r *RootElem) Render(elem *Elem) {
|
||||
func (r *RootElem) Render(elem *VDomElem) {
|
||||
log.Printf("Render %s\n", elem.Tag)
|
||||
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]
|
||||
if comp == nil || comp.Elem == nil {
|
||||
return
|
||||
}
|
||||
fnVal := comp.Elem.Props[propName]
|
||||
if fnVal == nil {
|
||||
return
|
||||
}
|
||||
fn, ok := fnVal.(func())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fn()
|
||||
callVDomFn(fnVal, data)
|
||||
}
|
||||
|
||||
// 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
|
||||
func (r *RootElem) runWork() {
|
||||
func (r *RootElem) RunWork() {
|
||||
workQueue := r.EffectWorkQueue
|
||||
r.EffectWorkQueue = nil
|
||||
// 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 == "" {
|
||||
r.unmount(comp)
|
||||
return
|
||||
@ -171,13 +249,13 @@ func (r *RootElem) unmount(comp **Component) {
|
||||
r.unmount(&child)
|
||||
}
|
||||
}
|
||||
delete(r.CompMap, (*comp).Id)
|
||||
delete(r.CompMap, (*comp).WaveId)
|
||||
*comp = nil
|
||||
}
|
||||
|
||||
func (r *RootElem) createComp(tag string, key string, comp **Component) {
|
||||
*comp = &Component{Id: uuid.New().String(), Tag: tag, Key: key}
|
||||
r.CompMap[(*comp).Id] = *comp
|
||||
*comp = &Component{WaveId: uuid.New().String(), Tag: tag, Key: key}
|
||||
r.CompMap[(*comp).WaveId] = *comp
|
||||
}
|
||||
|
||||
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))
|
||||
curCM := make(map[ChildKey]*Component)
|
||||
usedMap := make(map[*Component]bool)
|
||||
@ -217,7 +295,7 @@ func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Com
|
||||
return newChildren
|
||||
}
|
||||
|
||||
func (r *RootElem) renderSimple(elem *Elem, comp **Component) {
|
||||
func (r *RootElem) renderSimple(elem *VDomElem, comp **Component) {
|
||||
if (*comp).Comp != nil {
|
||||
r.unmount(&(*comp).Comp)
|
||||
}
|
||||
@ -243,7 +321,7 @@ func getRenderContext(ctx context.Context) *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 {
|
||||
for _, child := range (*comp).Children {
|
||||
r.unmount(&child)
|
||||
@ -262,11 +340,11 @@ func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) {
|
||||
r.unmount(&(*comp).Comp)
|
||||
return
|
||||
}
|
||||
var rtnElem *Elem
|
||||
var rtnElem *VDomElem
|
||||
if len(rtnElemArr) == 1 {
|
||||
rtnElem = &rtnElemArr[0]
|
||||
} else {
|
||||
rtnElem = &Elem{Tag: FragmentTag, Children: rtnElemArr}
|
||||
rtnElem = &VDomElem{Tag: FragmentTag, Children: rtnElemArr}
|
||||
}
|
||||
r.render(rtnElem, &(*comp).Comp)
|
||||
}
|
||||
@ -282,7 +360,7 @@ func convertPropsToVDom(props map[string]any) map[string]any {
|
||||
}
|
||||
val := reflect.ValueOf(v)
|
||||
if val.Kind() == reflect.Func {
|
||||
vdomProps[k] = VDomFuncType{FuncType: "server"}
|
||||
vdomProps[k] = VDomFunc{Type: ObjectType_Func}
|
||||
continue
|
||||
}
|
||||
vdomProps[k] = v
|
||||
@ -290,8 +368,8 @@ func convertPropsToVDom(props map[string]any) map[string]any {
|
||||
return vdomProps
|
||||
}
|
||||
|
||||
func convertBaseToVDom(c *Component) *Elem {
|
||||
elem := &Elem{Id: c.Id, Tag: c.Tag}
|
||||
func convertBaseToVDom(c *Component) *VDomElem {
|
||||
elem := &VDomElem{WaveId: c.WaveId, Tag: c.Tag}
|
||||
if c.Elem != nil {
|
||||
elem.Props = convertPropsToVDom(c.Elem.Props)
|
||||
}
|
||||
@ -304,12 +382,12 @@ func convertBaseToVDom(c *Component) *Elem {
|
||||
return elem
|
||||
}
|
||||
|
||||
func convertToVDom(c *Component) *Elem {
|
||||
func convertToVDom(c *Component) *VDomElem {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if c.Tag == TextTag {
|
||||
return &Elem{Tag: TextTag, Text: c.Text}
|
||||
return &VDomElem{Tag: TextTag, Text: c.Text}
|
||||
}
|
||||
if isBaseTag(c.Tag) {
|
||||
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)
|
||||
return vdomElem
|
||||
}
|
||||
|
||||
func (r *RootElem) MakeVDom() *Elem {
|
||||
func (r *RootElem) MakeVDom() *VDomElem {
|
||||
return r.makeVDom(r.Root)
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ type TestContext struct {
|
||||
|
||||
func Page(ctx context.Context, props map[string]any) any {
|
||||
clicked, setClicked := UseState(ctx, false)
|
||||
var clickedDiv *Elem
|
||||
var clickedDiv *VDomElem
|
||||
if clicked {
|
||||
clickedDiv = Bind(`<div>clicked</div>`, nil)
|
||||
}
|
||||
@ -30,8 +30,8 @@ func Page(ctx context.Context, props map[string]any) any {
|
||||
`
|
||||
<div>
|
||||
<h1>hello world</h1>
|
||||
<Button onClick="#bind:clickFn">hello</Button>
|
||||
<bind key="clickedDiv"/>
|
||||
<Button onClick="#param:clickFn">hello</Button>
|
||||
<bindparam key="clickedDiv"/>
|
||||
</div>
|
||||
`,
|
||||
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 {
|
||||
ref := UseRef(ctx, nil)
|
||||
ref := UseVDomRef(ctx)
|
||||
clName, setClName := UseState(ctx, "button")
|
||||
UseEffect(ctx, func() func() {
|
||||
fmt.Printf("Button useEffect\n")
|
||||
@ -52,8 +52,8 @@ func Button(ctx context.Context, props map[string]any) any {
|
||||
testContext.ButtonId = compId
|
||||
}
|
||||
return Bind(`
|
||||
<div className="#bind:clName" ref="#bind:ref" onClick="#bind:onClick">
|
||||
<bind key="children"/>
|
||||
<div className="#param:clName" ref="#param:ref" onClick="#param:onClick">
|
||||
<bindparam key="children"/>
|
||||
</div>
|
||||
`, 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")
|
||||
}
|
||||
printVDom(root)
|
||||
root.runWork()
|
||||
root.RunWork()
|
||||
printVDom(root)
|
||||
root.Event(testContext.ButtonId, "onClick")
|
||||
root.runWork()
|
||||
root.Event(testContext.ButtonId, "onClick", nil)
|
||||
root.RunWork()
|
||||
printVDom(root)
|
||||
}
|
||||
|
||||
@ -111,8 +111,8 @@ func TestBind(t *testing.T) {
|
||||
elem = Bind(`
|
||||
<div>
|
||||
<h1>hello world</h1>
|
||||
<Button onClick="#bind:clickFn">hello</Button>
|
||||
<bind key="clickedDiv"/>
|
||||
<Button onClick="#param:clickFn">hello</Button>
|
||||
<bindparam key="clickedDiv"/>
|
||||
</div>
|
||||
`, nil)
|
||||
jsonBytes, _ = json.MarshalIndent(elem, "", " ")
|
||||
|
201
pkg/vdom/vdom_types.go
Normal file
201
pkg/vdom/vdom_types.go
Normal 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"`
|
||||
}
|
234
pkg/vdom/vdomclient/vdomclient.go
Normal file
234
pkg/vdom/vdomclient/vdomclient.go
Normal 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
|
||||
}
|
@ -14,8 +14,8 @@ import (
|
||||
)
|
||||
|
||||
func AcquireWaveLock() (FDLock, error) {
|
||||
homeDir := GetWaveHomeDir()
|
||||
lockFileName := filepath.Join(homeDir, WaveLockFile)
|
||||
dataHomeDir := GetWaveDataDir()
|
||||
lockFileName := filepath.Join(dataHomeDir, WaveLockFile)
|
||||
log.Printf("[base] acquiring lock on %s\n", lockFileName)
|
||||
fd, err := os.OpenFile(lockFileName, os.O_RDWR|os.O_CREATE, 0600)
|
||||
if err != nil {
|
||||
|
@ -14,8 +14,8 @@ import (
|
||||
)
|
||||
|
||||
func AcquireWaveLock() (FDLock, error) {
|
||||
homeDir := GetWaveHomeDir()
|
||||
lockFileName := filepath.Join(homeDir, WaveLockFile)
|
||||
dataHomeDir := GetWaveDataDir()
|
||||
lockFileName := filepath.Join(dataHomeDir, WaveLockFile)
|
||||
log.Printf("[base] acquiring lock on %s\n", lockFileName)
|
||||
m, err := filemutex.New(lockFileName)
|
||||
if err != nil {
|
||||
|
@ -17,22 +17,26 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wavetermdev/waveterm/pkg/util/panic"
|
||||
)
|
||||
|
||||
// set by main-server.go
|
||||
var WaveVersion = "0.0.0"
|
||||
var BuildTime = "0"
|
||||
|
||||
const DefaultWaveHome = "~/.waveterm"
|
||||
const DevWaveHome = "~/.waveterm-dev"
|
||||
const WaveHomeVarName = "WAVETERM_HOME"
|
||||
const WaveConfigHomeEnvVar = "WAVETERM_CONFIG_HOME"
|
||||
const WaveDataHomeEnvVar = "WAVETERM_DATA_HOME"
|
||||
const WaveDevVarName = "WAVETERM_DEV"
|
||||
const WaveLockFile = "wave.lock"
|
||||
const DomainSocketBaseName = "wave.sock"
|
||||
const RemoteDomainSocketBaseName = "wave-remote.sock"
|
||||
const WaveDBDir = "db"
|
||||
const JwtSecret = "waveterm" // TODO generate and store this
|
||||
const ConfigDir = "config"
|
||||
|
||||
var RemoteWaveHome = ExpandHomeDirSafe("~/.waveterm")
|
||||
|
||||
const WaveAppPathVarName = "WAVETERM_APP_PATH"
|
||||
const AppPathBinDir = "bin"
|
||||
|
||||
@ -97,30 +101,43 @@ func ReplaceHomeDir(pathStr string) string {
|
||||
}
|
||||
|
||||
func GetDomainSocketName() string {
|
||||
return filepath.Join(GetWaveHomeDir(), DomainSocketBaseName)
|
||||
return filepath.Join(GetWaveDataDir(), DomainSocketBaseName)
|
||||
}
|
||||
|
||||
func GetWaveHomeDir() string {
|
||||
homeVar := os.Getenv(WaveHomeVarName)
|
||||
if homeVar != "" {
|
||||
return ExpandHomeDirSafe(homeVar)
|
||||
}
|
||||
if IsDevMode() {
|
||||
return ExpandHomeDirSafe(DevWaveHome)
|
||||
}
|
||||
return ExpandHomeDirSafe(DefaultWaveHome)
|
||||
func GetRemoteDomainSocketName() string {
|
||||
return filepath.Join(RemoteWaveHome, RemoteDomainSocketBaseName)
|
||||
}
|
||||
|
||||
func EnsureWaveHomeDir() error {
|
||||
return CacheEnsureDir(GetWaveHomeDir(), "wavehome", 0700, "wave home directory")
|
||||
func GetWaveDataDir() string {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user