Merge pull request #1146 from wavetermdev/dev-v0.9

merging v0.9 changes back to main
This commit is contained in:
Mike Sawka 2024-10-25 18:47:45 -07:00 committed by GitHub
commit 3e82ef85e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
137 changed files with 7669 additions and 1663 deletions

2
.gitattributes vendored
View File

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

View File

@ -159,7 +159,7 @@ tasks:
vars:
GOOS: darwin
GOARCH: arm64
- cp dist/bin/wsh-{{.VERSION}}-darwin.arm64 ~/.waveterm-dev/bin/wsh
- cp dist/bin/wsh-{{.VERSION}}-darwin.arm64 ~/Library/Application\ Support/waveterm-dev/bin/wsh
build:wsh:internal:
vars:
@ -185,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"

View File

@ -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) {

View File

@ -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)

View File

@ -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")

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

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

View File

@ -65,11 +65,11 @@ func editorRun(cmd *cobra.Command, args []string) {
return
}
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
}

View File

@ -4,40 +4,191 @@
package cmd
import (
"fmt"
"context"
"log"
"time"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/vdom"
"github.com/wavetermdev/waveterm/pkg/vdom/vdomclient"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
"github.com/wavetermdev/waveterm/pkg/wshutil"
)
var htmlCmdNewBlock bool
var GlobalVDomClient *vdomclient.Client
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[:])
func StyleTag(ctx context.Context, props map[string]any) any {
return vdom.Bind(`
<style>
.root {
padding: 10px;
}
.background {
display: flex;
align-items: center;
width: 100%;
.background-inner {
max-width: 300px;
.bg-item {
cursor: pointer;
padding: 8px 12px;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
&:hover {
background-color: var(--button-grey-hover-bg);
}
.bg-preview {
width: 20px;
height: 20px;
margin-right: 10px;
border-radius: 50%;
border: 1px solid #777;
}
.bg-label {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
</style>
`, nil)
}
func BgItemTag(ctx context.Context, props map[string]any) any {
clickFn := func() {
log.Printf("bg item clicked %q\n", props["bg"])
blockInfo, err := wshclient.BlockInfoCommand(GlobalVDomClient.RpcClient, GlobalVDomClient.RpcContext.BlockId, nil)
if err != nil {
wshutil.DoShutdown(fmt.Sprintf("stdin closed/error (%v)", err), 1, true)
log.Printf("error getting block info: %v\n", err)
return
}
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
log.Printf("block info: tabid=%q\n", blockInfo.TabId)
err = wshclient.SetMetaCommand(GlobalVDomClient.RpcClient, wshrpc.CommandSetMetaData{
ORef: waveobj.ORef{OType: "tab", OID: blockInfo.TabId},
Meta: map[string]any{"bg": props["bg"]},
}, nil)
if err != nil {
log.Printf("error setting meta: %v\n", err)
}
// wshclient.SetMetaCommand(GlobalVDomClient.RpcClient)
}
params := map[string]any{
"bg": props["bg"],
"label": props["label"],
"clickHandler": clickFn,
}
return vdom.Bind(`
<div className="bg-item" onClick="#param:clickHandler">
<div className="bg-preview" style="background: #param:bg"></div>
<div className="bg-label"><bindparam key="label"/></div>
</div>`, params)
}
func AllBgItemsTag(ctx context.Context, props map[string]any) any {
items := []map[string]any{
{"bg": nil, "label": "default"},
{"bg": "#ff0000", "label": "red"},
{"bg": "#00ff00", "label": "green"},
{"bg": "#0000ff", "label": "blue"},
}
bgElems := make([]*vdom.VDomElem, 0)
for _, item := range items {
elem := vdom.E("BgItemTag", item)
bgElems = append(bgElems, elem)
}
return vdom.Bind(`
<div className="background">
<div className="background-inner">
<bindparam key="bgElems"/>
</div>
</div>
`, map[string]any{"bgElems": bgElems})
}
func MakeVDom() *vdom.VDomElem {
vdomStr := `
<div className="root">
<StyleTag/>
<h1>Set Background</h1>
<div>
<wave:markdown text="*quick vdom application to set background colors*"/>
</div>
<div>
<AllBgItemsTag/>
</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
}
GlobalVDomClient = client
client.SetGlobalEventHandler(GlobalEventHandler)
log.Printf("created client: %v\n", client)
client.RegisterComponent("StyleTag", StyleTag)
client.RegisterComponent("BgItemTag", BgItemTag)
client.RegisterComponent("AllBgItemsTag", AllBgItemsTag)
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
}

View File

@ -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 {

View File

@ -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
View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { getWebServerEndpoint } from "@/util/endpoints";
import { fetch } from "@/util/fetchutil";
import { ipcMain } from "electron";
import { 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
View File

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

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

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

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

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

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

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

View File

@ -1,13 +1,13 @@
// Copyright 2024, Command Line Inc.
// 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);

View File

@ -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,

View File

@ -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();
}
});

View File

@ -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);

View File

@ -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",
},

View File

@ -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,
};

View File

@ -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),
});

View File

@ -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();
}
});
}
}

View File

@ -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 = {};

View File

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

View File

@ -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 />
@ -65,6 +68,9 @@ async function getClipboardURL(): Promise<URL> {
return null;
}
const url = new URL(clipboardText);
if (!url.protocol.startsWith("http")) {
return null;
}
return url;
} catch (e) {
return null;
@ -115,18 +121,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;

View File

@ -1,13 +1,22 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { BlockComponentModel2, BlockProps } from "@/app/block/blocktypes";
import {
BlockComponentModel2,
BlockNodeModel,
BlockProps,
FullBlockProps,
FullSubBlockProps,
SubBlockProps,
} 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 { VDomView, makeVDomModel } from "@/app/view/vdom/vdom";
import { VDomModel } from "@/app/view/vdom/vdom-model";
import { ErrorBoundary } from "@/element/errorboundary";
import { CenteredDiv } from "@/element/quickelems";
import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index";
import { useDebouncedNodeInnerRect } from "@/layout/index";
import {
counterInc,
getBlockComponentModel,
@ -28,13 +37,7 @@ import "./block.less";
import { BlockFrame } from "./blockframe";
import { blockViewToIcon, blockViewToName } from "./blockutil";
type FullBlockProps = {
preview: boolean;
nodeModel: NodeModel;
viewModel: ViewModel;
};
function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel {
function makeViewModel(blockId: string, blockView: string, nodeModel: BlockNodeModel): ViewModel {
if (blockView === "term") {
return makeTerminalModel(blockId, nodeModel);
}
@ -51,6 +54,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);
}
@ -100,6 +106,9 @@ function getViewElem(
if (blockView == "tips") {
return <QuickTipsView key={blockId} model={viewModel as QuickTipsViewModel} />;
}
if (blockView == "vdom") {
return <VDomView key={blockId} blockId={blockId} model={viewModel as VDomModel} />;
}
return <CenteredDiv>Invalid View "{blockView}"</CenteredDiv>;
}
@ -137,6 +146,26 @@ const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => {
);
});
const BlockSubBlock = memo(({ nodeModel, viewModel }: FullSubBlockProps) => {
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);
@ -255,7 +284,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;
@ -266,6 +295,7 @@ const Block = memo((props: BlockProps) => {
useEffect(() => {
return () => {
unregisterBlockComponentModel(props.nodeModel.blockId);
viewModel?.dispose?.();
};
}, []);
if (loading || isBlank(props.nodeModel.blockId) || blockData == null) {
@ -277,4 +307,26 @@ const Block = memo((props: BlockProps) => {
return <BlockFull {...props} viewModel={viewModel} />;
});
export { Block };
const SubBlock = memo((props: SubBlockProps) => {
counterInc("render-Block");
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;
if (viewModel == null || viewModel.viewType != blockData?.meta?.view) {
viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view, props.nodeModel);
registerBlockComponentModel(props.nodeModel.blockId, { viewModel });
}
useEffect(() => {
return () => {
unregisterBlockComponentModel(props.nodeModel.blockId);
viewModel?.dispose?.();
};
}, []);
if (loading || isBlank(props.nodeModel.blockId) || blockData == null) {
return null;
}
return <BlockSubBlock {...props} viewModel={viewModel} />;
});
export { Block, SubBlock };

View File

@ -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);

View File

@ -2,11 +2,35 @@
// SPDX-License-Identifier: Apache-2.0
import { NodeModel } from "@/layout/index";
import { Atom } from "jotai";
export interface BlockNodeModel {
blockId: string;
isFocused: Atom<boolean>;
onClose: () => void;
focusNode: () => void;
}
export type FullBlockProps = {
preview: boolean;
nodeModel: NodeModel;
viewModel: ViewModel;
};
export interface BlockProps {
preview: boolean;
nodeModel: NodeModel;
}
export type FullSubBlockProps = {
nodeModel: BlockNodeModel;
viewModel: ViewModel;
};
export interface SubBlockProps {
nodeModel: BlockNodeModel;
}
export interface BlockComponentModel2 {
onClick?: () => void;
onFocusCapture?: React.FocusEventHandler<HTMLDivElement>;

View File

@ -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();

View File

@ -40,6 +40,7 @@
align-items: center;
margin-left: 5px;
margin-right: 5px;
align-self: flex-start;
&:first-child {
margin-left: 0;
@ -53,10 +54,11 @@
font: var(--fixed-font);
font-size: 0.85em;
color: var(--keybinding-color);
background-color: var(--keybinding-bg-color);
background-color: var(--highlight-bg-color);
border-radius: 4px;
border: 1px solid var(--keybinding-border-color);
box-shadow: none;
white-space: nowrap;
}
.icon-wrap {
@ -66,6 +68,7 @@
font-size: 12px;
border-radius: 2px;
margin-right: 5px;
align-self: flex-start;
svg {
position: relative;

View File

@ -145,14 +145,44 @@ const QuickTips = () => {
</div>
</div>
<div className="tip-section-header">More Tips</div>
<div className="tip">
<div className="icon-wrap">
<i className="fa-solid fa-sharp fa-computer-mouse fa-fw" />
</div>
Right click the tabs to change backgrounds or rename.
</div>
<div className="tip">
<div className="icon-wrap">
<i className="fa-solid fa-sharp fa-cog fa-fw" />
</div>
Click the gear in the web view to set your homepage
</div>
<div className="tip">
<div className="icon-wrap">
<i className="fa-solid fa-sharp fa-cog fa-fw" />
</div>
Click the gear in the terminal to set your terminal theme and font size
</div>
<div className="tip-section-header">Need More Help?</div>
<div className="tip">
<div className="icon-wrap">
<i className="fa-brands fa-discord fa-fw" />
</div>
<div>
<div>
<a target="_blank" href="https://discord.gg/XfvZ334gwU" rel="noopener">
Join Our Discord
</a>
</div>
<a target="_blank" href="https://discord.gg/XfvZ334gwU" rel="noopener">
Join Our Discord
</a>
</div>
</div>
<div className="tip">
<div className="icon-wrap">
<i className="fa-solid fa-sharp fa-sliders fa-fw" />
</div>
<div>
<a target="_blank" href="https://docs.waveterm.dev/config" rel="noopener">
Configuration Options
</a>
</div>
</div>
</div>

View File

@ -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>(

View File

@ -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);
}
@ -410,11 +457,21 @@ function getBlockComponentModel(blockId: string): BlockComponentModel {
return blockComponentModelMap.get(blockId);
}
function getFocusedBlockId(): string {
const layoutModel = getLayoutModelForStaticTab();
const focusedLayoutNode = globalStore.get(layoutModel.focusedNode);
return focusedLayoutNode?.data?.blockId;
}
// pass null to refocus the currently focused block
function refocusNode(blockId: string) {
if (blockId == null) {
return;
blockId = getFocusedBlockId();
if (blockId == null) {
return;
}
}
const layoutModel = getLayoutModelForActiveTab();
const layoutModel = getLayoutModelForStaticTab();
const layoutNodeId = layoutModel.getNodeByBlockId(blockId);
if (layoutNodeId?.id == null) {
return;
@ -522,15 +579,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 +604,7 @@ export {
initGlobalWaveEventSubs,
isDev,
loadConnStatus,
makeOverrideConfigAtom,
openLink,
PLATFORM,
pushFlashError,
@ -554,6 +618,9 @@ export {
useBlockAtom,
useBlockCache,
useBlockDataLoaded,
useBlockMetaKeyAtom,
useOverrideConfigAtom,
useSettingsKeyAtom,
useSettingsPrefixAtom,
WOS,
};

View File

@ -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;

View File

@ -90,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))
}
@ -115,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))
}
@ -154,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))
}

View File

@ -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,
};

View File

@ -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) {

View File

@ -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;

View File

@ -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();

View File

@ -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 };

View File

@ -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 };

View File

@ -9,7 +9,7 @@ import { clsx } from "clsx";
import * as React from "react";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { atoms, globalStore } from "@/app/store/global";
import { atoms, globalStore, refocusNode } from "@/app/store/global";
import "./tab.less";
interface TabProps {
@ -69,8 +69,8 @@ const Tab = React.memo(
};
}, []);
const handleDoubleClick = (event) => {
event.stopPropagation();
const handleRenameTab = (event) => {
event?.stopPropagation();
setIsEditable(true);
editableTimeoutRef.current = setTimeout(() => {
if (editableRef.current) {
@ -86,6 +86,7 @@ const Tab = React.memo(
editableRef.current.innerText = newText;
setIsEditable(false);
services.ObjectService.UpdateTabName(id, newText);
setTimeout(() => refocusNode(null), 10);
};
const handleKeyDown = (event) => {
@ -114,7 +115,7 @@ const Tab = React.memo(
editableRef.current.blur();
event.preventDefault();
event.stopPropagation();
} else if (curLen >= 10 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) {
} else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
}
@ -155,6 +156,7 @@ const Tab = React.memo(
const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
return aOrder - bOrder;
});
menu.push({ label: "Rename Tab", click: () => handleRenameTab(null) });
menu.push({ label: "Copy TabId", click: () => navigator.clipboard.writeText(id) });
menu.push({ type: "separator" });
if (bgPresets.length > 0) {
@ -198,7 +200,7 @@ const Tab = React.memo(
ref={editableRef}
className={clsx("name", { focused: isEditable })}
contentEditable={isEditable}
onDoubleClick={handleDoubleClick}
onDoubleClick={handleRenameTab}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
suppressContentEditableWarning={true}

View File

@ -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)}

View File

@ -1,16 +1,16 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { BlockNodeModel } from "@/app/block/blocktypes";
import { getApi } from "@/app/store/global";
import { WebView, WebViewModel } from "@/app/view/webview/webview";
import { NodeModel } from "@/layout/index";
import { fireAndForget } from "@/util/util";
import { atom, useAtomValue } from "jotai";
import { useCallback } from "react";
import "./helpview.less";
class HelpViewModel extends WebViewModel {
constructor(blockId: string, nodeModel: NodeModel) {
constructor(blockId: string, nodeModel: BlockNodeModel) {
super(blockId, nodeModel);
this.getSettingsMenuItems = undefined;
this.viewText = atom((get) => {
@ -44,7 +44,7 @@ class HelpViewModel extends WebViewModel {
}
}
function makeHelpViewModel(blockId: string, nodeModel: NodeModel) {
function makeHelpViewModel(blockId: string, nodeModel: BlockNodeModel) {
return new HelpViewModel(blockId, nodeModel);
}

View File

@ -1,15 +1,15 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { BlockNodeModel } from "@/app/block/blocktypes";
import { CenteredDiv } from "@/app/element/quickelems";
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";
import { atoms, createBlock, getConnStatusAtom, getSettingsKeyAtom, globalStore, refocusNode } from "@/store/global";
import * as services from "@/store/services";
import * as WOS from "@/store/wos";
@ -98,7 +98,7 @@ function isStreamingType(mimeType: string): boolean {
export class PreviewModel implements ViewModel {
viewType: string;
blockId: string;
nodeModel: NodeModel;
nodeModel: BlockNodeModel;
blockAtom: Atom<Block>;
viewIcon: Atom<string | IconButtonDecl>;
viewName: Atom<string>;
@ -141,7 +141,7 @@ export class PreviewModel implements ViewModel {
directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean;
codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean;
constructor(blockId: string, nodeModel: NodeModel) {
constructor(blockId: string, nodeModel: BlockNodeModel) {
this.viewType = "preview";
this.blockId = blockId;
this.nodeModel = nodeModel;
@ -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);
@ -733,7 +733,7 @@ export class PreviewModel implements ViewModel {
}
}
function makePreviewModel(blockId: string, nodeModel: NodeModel): PreviewModel {
function makePreviewModel(blockId: string, nodeModel: BlockNodeModel): PreviewModel {
const previewModel = new PreviewModel(blockId, nodeModel);
return previewModel;
}

View File

@ -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 },
});

View File

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

View File

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

View File

@ -1,15 +1,19 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Block, SubBlock } from "@/app/block/block";
import { BlockNodeModel } from "@/app/block/blocktypes";
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 { NodeModel } from "@/layout/index";
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/vdom/vdom-model";
import {
WOS,
atoms,
getBlockComponentModel,
getConnStatusAtom,
getSettingsKeyAtom,
globalStore,
@ -18,8 +22,8 @@ import {
} 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";
@ -28,122 +32,103 @@ 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: BlockNodeModel;
connected: boolean;
termRef: React.RefObject<TermWrap>;
blockAtom: jotai.Atom<Block>;
termMode: jotai.Atom<string>;
htmlElemFocusRef: React.RefObject<HTMLInputElement>;
blockId: string;
nodeModel: NodeModel;
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>;
fontSizeAtom: jotai.Atom<number>;
termThemeNameAtom: jotai.Atom<string>;
constructor(blockId: string, nodeModel: NodeModel) {
constructor(blockId: string, nodeModel: BlockNodeModel) {
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);
@ -184,6 +169,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") {
@ -191,17 +202,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 },
});
@ -235,7 +303,7 @@ class TermViewModel {
type: "checkbox",
checked: overrideFontSize == fontSize,
click: () => {
RpcApi.SetMetaCommand(WindowRpcClient, {
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { "term:fontsize": fontSize },
});
@ -248,7 +316,7 @@ class TermViewModel {
type: "checkbox",
checked: overrideFontSize == null,
click: () => {
RpcApi.SetMetaCommand(WindowRpcClient, {
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { "term:fontsize": null },
});
@ -270,8 +338,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 },
@ -283,7 +351,7 @@ class TermViewModel {
}
}
function makeTerminalModel(blockId: string, nodeModel: NodeModel): TermViewModel {
function makeTerminalModel(blockId: string, nodeModel: BlockNodeModel): TermViewModel {
return new TermViewModel(blockId, nodeModel);
}
@ -314,66 +382,74 @@ 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,
focusNode: () => {
model.nodeModel.focusNode();
},
onClose: () => {
if (vdomBlockId != null) {
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId });
}
},
};
return (
<div key="htmlElem" className="term-htmlelem">
<SubBlock key="vdom" 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);
const termFontSize = jotai.useAtomValue(model.fontSizeAtom);
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 };
@ -406,7 +482,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
scrollback: termScrollback,
},
{
keydownHandler: handleTerminalKeydown,
keydownHandler: model.handleTerminalKeydown.bind(model),
useWebGl: !termSettings?.["term:disablewebgl"],
}
);
@ -428,29 +504,13 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
};
}, [blockId, termSettings, termFontSize]);
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(() => {
@ -458,7 +518,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 {
@ -494,28 +554,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>
);
};

View File

@ -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);

View File

@ -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) {
@ -244,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,

View File

@ -1,129 +0,0 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import * as React from "react";
const AllowedTags: { [tagName: string]: boolean } = {
div: true,
b: true,
i: true,
p: true,
s: true,
span: true,
a: true,
img: true,
h1: true,
h2: true,
h3: true,
h4: true,
h5: true,
h6: true,
ul: true,
ol: true,
li: true,
input: true,
button: true,
textarea: true,
select: true,
option: true,
form: true,
};
function convertVDomFunc(fnDecl: VDomFuncType, 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"]) {
if (checkKeyPressed(waveEvent, keyDesc)) {
e.preventDefault();
e.stopPropagation();
callFunc(e, compId, propName);
return;
}
}
return;
}
if (fnDecl["#preventDefault"]) {
e.preventDefault();
}
if (fnDecl["#stopPropagation"]) {
e.stopPropagation();
}
callFunc(e, compId, propName);
};
}
function convertElemToTag(elem: VDomElem): JSX.Element | string {
if (elem == null) {
return null;
}
if (elem.tag == "#text") {
return elem.text;
}
return React.createElement(VDomTag, { elem: elem, key: elem.id });
}
function isObject(v: any): boolean {
return v != null && !Array.isArray(v) && typeof v === "object";
}
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>;
}
let props = {};
for (let key in elem.props) {
let val = elem.props[key];
if (val == null) {
continue;
}
if (key == "ref") {
if (val == null) {
continue;
}
if (isObject(val) && "#ref" in val) {
props[key] = (elem: HTMLElement) => {
updateRefFunc(elem, val);
};
}
continue;
}
if (isObject(val) && "#func" in val) {
props[key] = convertVDomFunc(val, elem.id, key);
continue;
}
}
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") {
return childrenComps;
}
return React.createElement(elem.tag, props, childrenComps);
}
function VDomView({ rootNode }: { rootNode: VDomElem }) {
let rtn = convertElemToTag(rootNode);
return <div className="vdom">{rtn}</div>;
}
export { VDomView };

View File

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

View File

@ -0,0 +1,58 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { VDomModel } from "@/app/view/vdom/vdom-model";
import type { CssNode, List, ListItem } from "css-tree";
import * as csstree from "css-tree";
const TextTag = "#text";
// TODO support binding
export function getTextChildren(elem: VDomElem): string {
if (elem.tag == TextTag) {
return elem.text;
}
if (!elem.children) {
return null;
}
const textArr = elem.children.map((child) => {
return getTextChildren(child);
});
return textArr.join("");
}
export function convertVDomId(model: VDomModel, id: string): string {
return model.blockId + "::" + id;
}
export function validateAndWrapCss(model: VDomModel, cssText: string, wrapperClassName: string) {
try {
const ast = csstree.parse(cssText);
csstree.walk(ast, {
enter(node: CssNode, item: ListItem<CssNode>, list: List<CssNode>) {
// Remove disallowed @rules
const blockedRules = ["import", "font-face", "keyframes", "namespace", "supports"];
if (node.type === "Atrule" && blockedRules.includes(node.name)) {
list.remove(item);
}
// Remove :root selectors
if (
node.type === "Selector" &&
node.children.some((child) => child.type === "PseudoClassSelector" && child.name === "root")
) {
list.remove(item);
}
if (node.type === "IdSelector") {
node.name = convertVDomId(model, node.name);
}
},
});
const sanitizedCss = csstree.generate(ast);
return `.${wrapperClassName} { ${sanitizedCss} }`;
} catch (error) {
// TODO better error handling
console.error("CSS processing error:", error);
return null;
}
}

View File

@ -0,0 +1,5 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.view-vdom {
}

View File

@ -0,0 +1,354 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Markdown } from "@/app/element/markdown";
import { VDomModel } from "@/app/view/vdom/vdom-model";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import clsx from "clsx";
import debug from "debug";
import * as jotai from "jotai";
import * as React from "react";
import { BlockNodeModel } from "@/app/block/blocktypes";
import { convertVDomId, getTextChildren, validateAndWrapCss } from "@/app/view/vdom/vdom-utils";
import "./vdom.less";
const TextTag = "#text";
const FragmentTag = "#fragment";
const WaveTextTag = "wave:text";
const WaveNullTag = "wave:null";
const StyleTagName = "style";
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 AllowedSimpleTags: { [tagName: string]: boolean } = {
div: true,
b: true,
i: true,
p: true,
s: true,
span: true,
a: true,
img: true,
h1: true,
h2: true,
h3: true,
h4: true,
h5: true,
h6: true,
ul: true,
ol: true,
li: true,
input: true,
button: true,
textarea: true,
select: true,
option: true,
form: true,
label: true,
table: true,
thead: true,
tbody: true,
tr: true,
th: true,
td: true,
hr: true,
br: true,
pre: true,
code: true,
};
const IdAttributes = {
id: true,
for: true,
"aria-labelledby": true,
"aria-describedby": true,
"aria-controls": true,
"aria-owns": true,
form: true,
headers: true,
usemap: true,
list: true,
};
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 || []) {
if (checkKeyPressed(waveEvent, keyDesc)) {
e.preventDefault();
e.stopPropagation();
model.callVDomFunc(fnDecl, e, compId, propName);
return;
}
}
return;
}
if (fnDecl.preventdefault) {
e.preventDefault();
}
if (fnDecl.stoppropagation) {
e.stopPropagation();
}
model.callVDomFunc(fnDecl, e, compId, propName);
};
}
function convertElemToTag(elem: VDomElem, model: VDomModel): JSX.Element | string {
if (elem == null) {
return null;
}
if (elem.tag == TextTag) {
return elem.text;
}
return React.createElement(VDomTag, { key: elem.waveid, elem, model });
}
function isObject(v: any): boolean {
return v != null && !Array.isArray(v) && typeof v === "object";
}
function isArray(v: any): boolean {
return Array.isArray(v);
}
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];
}
for (let key in elem.props) {
let val = elem.props[key];
if (val == null) {
continue;
}
if (key == "ref") {
if (val == null) {
continue;
}
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) && 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
}
if (IdAttributes[key]) {
props[key] = convertVDomId(model, val);
continue;
}
props[key] = val;
}
return [props, atomKeys];
}
function convertChildren(elem: VDomElem, model: VDomModel): (string | JSX.Element)[] {
let childrenComps: (string | JSX.Element)[] = [];
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 StyleTag({ elem, model }: { elem: VDomElem; model: VDomModel }) {
const styleText = getTextChildren(elem);
if (styleText == null) {
return null;
}
const wrapperClassName = "vdom-" + model.blockId;
// TODO handle errors
const sanitizedCss = validateAndWrapCss(model, styleText, wrapperClassName);
if (sanitizedCss == null) {
return null;
}
return <style>{sanitizedCss}</style>;
}
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 (elem.tag == StyleTagName) {
return <StyleTag elem={elem} model={model} />;
}
if (!AllowedSimpleTags[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 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>;
}
function makeVDomModel(blockId: string, nodeModel: BlockNodeModel): VDomModel {
return new VDomModel(blockId, nodeModel);
}
type VDomViewProps = {
model: VDomModel;
blockId: string;
};
function VDomView({ blockId, model }: VDomViewProps) {
let viewRef = React.useRef(null);
model.viewRef = viewRef;
const vdomClass = "vdom-" + blockId;
return (
<div className={clsx("vdom-view", vdomClass)} ref={viewRef}>
<VDomRoot model={model} />
</div>
);
}
export { makeVDomModel, VDomView };

View File

@ -5,8 +5,8 @@ import { Button } from "@/app/element/button";
import { Markdown } from "@/app/element/markdown";
import { 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);

View File

@ -1,12 +1,12 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { BlockNodeModel } from "@/app/block/blocktypes";
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 { NodeModel } from "@/layout/index";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { WOS, globalStore } from "@/store/global";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import { fireAndForget } from "@/util/util";
@ -44,12 +44,12 @@ export class WebViewModel implements ViewModel {
refreshIcon: PrimitiveAtom<string>;
webviewRef: React.RefObject<WebviewTag>;
urlInputRef: React.RefObject<HTMLInputElement>;
nodeModel: NodeModel;
nodeModel: BlockNodeModel;
endIconButtons?: Atom<IconButtonDecl[]>;
mediaPlaying: PrimitiveAtom<boolean>;
mediaMuted: PrimitiveAtom<boolean>;
constructor(blockId: string, nodeModel: NodeModel) {
constructor(blockId: string, nodeModel: BlockNodeModel) {
this.nodeModel = nodeModel;
this.viewType = "web";
this.blockId = blockId;
@ -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;
}
}
@ -459,7 +459,7 @@ export class WebViewModel implements ViewModel {
}
}
function makeWebViewModel(blockId: string, nodeModel: NodeModel): WebViewModel {
function makeWebViewModel(blockId: string, nodeModel: BlockNodeModel): WebViewModel {
const webviewModel = new WebViewModel(blockId, nodeModel);
return webviewModel;
}

View File

@ -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 />
</>

View File

@ -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,

View File

@ -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}

View File

@ -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;

View File

@ -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)), []);

View File

@ -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;
};
@ -262,6 +276,7 @@ declare global {
getSettingsMenuItems?: () => ContextMenuItem[];
giveFocus?: () => boolean;
keyDownHandler?: (e: WaveKeyboardEvent) => boolean;
dispose?: () => void;
}
type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error" | "installing";
@ -333,6 +348,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;

View File

@ -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
@ -45,6 +47,13 @@ declare global {
windowids: string[];
tosagreed?: number;
hasoldhistory?: boolean;
nexttabid?: number;
};
// windowservice.CloseTabRtnType
type CloseTabRtnType = {
closewindow?: boolean;
newactivetabid?: string;
};
// wshrpc.CommandAppendIJsonData
@ -57,6 +66,7 @@ declare global {
// wshrpc.CommandAuthenticateRtnData
type CommandAuthenticateRtnData = {
routeid: string;
authtoken?: string;
};
// wshrpc.CommandBlockInputData
@ -89,11 +99,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 +176,12 @@ declare global {
meta: MetaType;
};
// wshrpc.CommandWaitForRouteData
type CommandWaitForRouteData = {
routeid: string;
waitms: number;
};
// wshrpc.CommandWebSelectorData
type CommandWebSelectorData = {
windowid: string;
@ -186,6 +213,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 +356,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 +440,7 @@ declare global {
resid?: string;
timeout?: number;
route?: string;
authtoken?: string;
source?: string;
cont?: boolean;
cancel?: boolean;
@ -473,6 +517,7 @@ declare global {
"window:showmenubar"?: boolean;
"window:nativetitlebar"?: boolean;
"window:disablehardwareacceleration"?: boolean;
"window:maxtabcachesize"?: number;
"telemetry:*"?: boolean;
"telemetry:enabled"?: boolean;
"conn:*"?: boolean;
@ -582,27 +627,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 = {

View File

@ -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,

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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>

View File

@ -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",

View File

@ -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)

View File

@ -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)
}
}

View File

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

View File

@ -422,9 +422,9 @@ func (conn *SSHConn) WithLock(fn func()) {
}
func (conn *SSHConn) connectInternal(ctx context.Context) error {
client, err := remote.ConnectToClient(ctx, conn.Opts) //todo specify or remove opts
client, _, err := remote.ConnectToClient(ctx, conn.Opts, nil, 0)
if err != nil {
log.Printf("error: failed to connect to client %s: %v\n", conn.GetName(), err)
log.Printf("error: failed to connect to client %s: %s\n", conn.GetName(), err)
return err
}
fmtAddr := knownhosts.Normalize(fmt.Sprintf("%s@%s", client.User(), client.RemoteAddr().String()))

View File

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

View File

@ -11,6 +11,7 @@ import (
"encoding/base64"
"fmt"
"log"
"math"
"net"
"os"
"os/exec"
@ -31,6 +32,8 @@ import (
xknownhosts "golang.org/x/crypto/ssh/knownhosts"
)
const SshProxyJumpMaxDepth = 10
type UserInputCancelError struct {
Err error
}
@ -41,6 +44,24 @@ func (uice UserInputCancelError) Error() string {
return uice.Err.Error()
}
type ConnectionDebugInfo struct {
CurrentClient *ssh.Client
NextOpts *SSHOpts
JumpNum int32
}
type ConnectionError struct {
*ConnectionDebugInfo
Err error
}
func (ce ConnectionError) Error() string {
if ce.CurrentClient == nil {
return fmt.Sprintf("Connecting to %+#v, Error: %v", ce.NextOpts, ce.Err)
}
return fmt.Sprintf("Connecting from %v to %+#v (jump number %d), Error: %v", ce.CurrentClient, ce.NextOpts, ce.JumpNum, ce.Err)
}
// This exists to trick the ssh library into continuing to try
// different public keys even when the current key cannot be
// properly parsed
@ -68,7 +89,7 @@ func createDummySigner() ([]ssh.Signer, error) {
// they were successes. An error in this function prevents any other
// keys from being attempted. But if there's an error because of a dummy
// file, the library can still try again with a new key.
func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, authSockSignersExt []ssh.Signer, agentClient agent.ExtendedAgent) func() ([]ssh.Signer, error) {
func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, authSockSignersExt []ssh.Signer, agentClient agent.ExtendedAgent, debugInfo *ConnectionDebugInfo) func() ([]ssh.Signer, error) {
var identityFiles []string
existingKeys := make(map[string][]byte)
@ -103,7 +124,7 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords,
}
if len(*identityFilesPtr) == 0 {
return nil, fmt.Errorf("no identity files remaining")
return nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: fmt.Errorf("no identity files remaining")}
}
identityFile := (*identityFilesPtr)[0]
*identityFilesPtr = (*identityFilesPtr)[1:]
@ -123,7 +144,7 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords,
PrivateKey: unencryptedPrivateKey,
})
}
return []ssh.Signer{signer}, err
return []ssh.Signer{signer}, nil
}
}
if _, ok := err.(*ssh.PassphraseMissingError); !ok {
@ -148,7 +169,8 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords,
if err != nil {
// this is an error where we actually do want to stop
// trying keys
return nil, UserInputCancelError{Err: err}
return nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: UserInputCancelError{Err: err}}
}
unencryptedPrivateKey, err = ssh.ParseRawPrivateKeyWithPassphrase(privateKey, []byte([]byte(response.Text)))
if err != nil {
@ -165,11 +187,11 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords,
PrivateKey: unencryptedPrivateKey,
})
}
return []ssh.Signer{signer}, err
return []ssh.Signer{signer}, nil
}
}
func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string) func() (secret string, err error) {
func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string, debugInfo *ConnectionDebugInfo) func() (secret string, err error) {
return func() (secret string, err error) {
ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)
defer cancelFn()
@ -185,13 +207,13 @@ func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisp
}
response, err := userinput.GetUserInput(ctx, request)
if err != nil {
return "", err
return "", ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
}
return response.Text, nil
}
}
func createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteName string) func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
func createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteName string, debugInfo *ConnectionDebugInfo) func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
return func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
if len(questions) != len(echos) {
return nil, fmt.Errorf("bad response from server: questions has len %d, echos has len %d", len(questions), len(echos))
@ -200,7 +222,7 @@ func createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteNam
echo := echos[i]
answer, err := promptChallengeQuestion(connCtx, question, echo, remoteName)
if err != nil {
return nil, err
return nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
}
answers = append(answers, answer)
}
@ -336,12 +358,9 @@ func lineContainsMatch(line []byte, matches [][]byte) bool {
return false
}
func createHostKeyCallback(opts *SSHOpts) (ssh.HostKeyCallback, HostKeyAlgorithms, error) {
ssh_config.ReloadConfigs()
rawUserKnownHostsFiles, _ := ssh_config.GetStrict(opts.SSHHost, "UserKnownHostsFile")
userKnownHostsFiles := strings.Fields(rawUserKnownHostsFiles) // TODO - smarter splitting escaped spaces and quotes
rawGlobalKnownHostsFiles, _ := ssh_config.GetStrict(opts.SSHHost, "GlobalKnownHostsFile")
globalKnownHostsFiles := strings.Fields(rawGlobalKnownHostsFiles) // TODO - smarter splitting escaped spaces and quotes
func createHostKeyCallback(sshKeywords *SshKeywords) (ssh.HostKeyCallback, HostKeyAlgorithms, error) {
globalKnownHostsFiles := sshKeywords.GlobalKnownHostsFile
userKnownHostsFiles := sshKeywords.UserKnownHostsFile
osUser, err := user.Current()
if err != nil {
@ -485,6 +504,7 @@ func createHostKeyCallback(opts *SSHOpts) (ssh.HostKeyCallback, HostKeyAlgorithm
"%s\n\n"+
"**Offending Keys** \n"+
"%s", key.Type(), correctKeyFingerprint, strings.Join(bulletListKnownHosts, " \n"), strings.Join(offendingKeysFmt, " \n"))
log.Print(errorMsg)
//update := scbus.MakeUpdatePacket()
// create update into alert message
@ -504,29 +524,7 @@ func createHostKeyCallback(opts *SSHOpts) (ssh.HostKeyCallback, HostKeyAlgorithm
return waveHostKeyCallback, hostKeyAlgorithms, nil
}
func DialContext(ctx context.Context, network string, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
d := net.Dialer{Timeout: config.Timeout}
conn, err := d.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
c, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
if err != nil {
return nil, err
}
return ssh.NewClient(c, chans, reqs), nil
}
func ConnectToClient(connCtx context.Context, opts *SSHOpts) (*ssh.Client, error) {
sshConfigKeywords, err := findSshConfigKeywords(opts.SSHHost)
if err != nil {
return nil, err
}
sshKeywords, err := combineSshKeywords(opts, sshConfigKeywords)
if err != nil {
return nil, err
}
func createClientConfig(connCtx context.Context, sshKeywords *SshKeywords, debugInfo *ConnectionDebugInfo) (*ssh.ClientConfig, error) {
remoteName := sshKeywords.User + "@" + xknownhosts.Normalize(sshKeywords.HostName+":"+sshKeywords.Port)
var authSockSigners []ssh.Signer
@ -539,9 +537,9 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts) (*ssh.Client, error
authSockSigners, _ = agentClient.Signers()
}
publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(connCtx, sshKeywords, authSockSigners, agentClient))
keyboardInteractive := ssh.KeyboardInteractive(createInteractiveKbdInteractiveChallenge(connCtx, remoteName))
passwordCallback := ssh.PasswordCallback(createInteractivePasswordCallbackPrompt(connCtx, remoteName))
publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(connCtx, sshKeywords, authSockSigners, agentClient, debugInfo))
keyboardInteractive := ssh.KeyboardInteractive(createInteractiveKbdInteractiveChallenge(connCtx, remoteName, debugInfo))
passwordCallback := ssh.PasswordCallback(createInteractivePasswordCallbackPrompt(connCtx, remoteName, debugInfo))
// exclude gssapi-with-mic and hostbased until implemented
authMethodMap := map[string]ssh.AuthMethod{
@ -570,19 +568,90 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts) (*ssh.Client, error
authMethods = append(authMethods, authMethod)
}
hostKeyCallback, hostKeyAlgorithms, err := createHostKeyCallback(opts)
hostKeyCallback, hostKeyAlgorithms, err := createHostKeyCallback(sshKeywords)
if err != nil {
return nil, err
}
networkAddr := sshKeywords.HostName + ":" + sshKeywords.Port
clientConfig := &ssh.ClientConfig{
return &ssh.ClientConfig{
User: sshKeywords.User,
Auth: authMethods,
HostKeyCallback: hostKeyCallback,
HostKeyAlgorithms: hostKeyAlgorithms(networkAddr),
}, nil
}
func connectInternal(ctx context.Context, networkAddr string, clientConfig *ssh.ClientConfig, currentClient *ssh.Client) (*ssh.Client, error) {
var clientConn net.Conn
var err error
if currentClient == nil {
d := net.Dialer{Timeout: clientConfig.Timeout}
clientConn, err = d.DialContext(ctx, "tcp", networkAddr)
if err != nil {
return nil, err
}
} else {
clientConn, err = currentClient.DialContext(ctx, "tcp", networkAddr)
if err != nil {
return nil, err
}
}
return DialContext(connCtx, "tcp", networkAddr, clientConfig)
c, chans, reqs, err := ssh.NewClientConn(clientConn, networkAddr, clientConfig)
if err != nil {
return nil, err
}
return ssh.NewClient(c, chans, reqs), nil
}
func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh.Client, jumpNum int32) (*ssh.Client, int32, error) {
debugInfo := &ConnectionDebugInfo{
CurrentClient: currentClient,
NextOpts: opts,
JumpNum: jumpNum,
}
if jumpNum > SshProxyJumpMaxDepth {
return nil, jumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: fmt.Errorf("ProxyJump %d exceeds Wave's max depth of %d", jumpNum, SshProxyJumpMaxDepth)}
}
// todo print final warning if logging gets turned off
sshConfigKeywords, err := findSshConfigKeywords(opts.SSHHost)
if err != nil {
return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
}
sshKeywords, err := combineSshKeywords(opts, sshConfigKeywords)
if err != nil {
return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
}
for _, proxyName := range sshKeywords.ProxyJump {
proxyOpts, err := ParseOpts(proxyName)
if err != nil {
return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
}
// ensure no overflow (this will likely never happen)
if jumpNum < math.MaxInt32 {
jumpNum += 1
}
debugInfo.CurrentClient, jumpNum, err = ConnectToClient(connCtx, proxyOpts, debugInfo.CurrentClient, jumpNum)
if err != nil {
// do not add a context on a recursive call
// (this can cause a recursive nested context that's arbitrarily deep)
return nil, jumpNum, err
}
}
clientConfig, err := createClientConfig(connCtx, sshKeywords, debugInfo)
if err != nil {
return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
}
networkAddr := sshKeywords.HostName + ":" + sshKeywords.Port
client, err := connectInternal(connCtx, networkAddr, clientConfig, debugInfo.CurrentClient)
if err != nil {
return client, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
}
return client, debugInfo.JumpNum, nil
}
type SshKeywords struct {
@ -597,6 +666,9 @@ type SshKeywords struct {
PreferredAuthentications []string
AddKeysToAgent bool
IdentityAgent string
ProxyJump []string
UserKnownHostsFile []string
GlobalKnownHostsFile []string
}
func combineSshKeywords(opts *SSHOpts, configKeywords *SshKeywords) (*SshKeywords, error) {
@ -641,6 +713,9 @@ func combineSshKeywords(opts *SSHOpts, configKeywords *SshKeywords) (*SshKeyword
sshKeywords.PreferredAuthentications = configKeywords.PreferredAuthentications
sshKeywords.AddKeysToAgent = configKeywords.AddKeysToAgent
sshKeywords.IdentityAgent = configKeywords.IdentityAgent
sshKeywords.ProxyJump = configKeywords.ProxyJump
sshKeywords.UserKnownHostsFile = configKeywords.UserKnownHostsFile
sshKeywords.GlobalKnownHostsFile = configKeywords.GlobalKnownHostsFile
return sshKeywords, nil
}
@ -740,6 +815,23 @@ func findSshConfigKeywords(hostPattern string) (*SshKeywords, error) {
sshKeywords.IdentityAgent = agentPath
}
proxyJumpRaw, err := ssh_config.GetStrict(hostPattern, "ProxyJump")
if err != nil {
return nil, err
}
proxyJumpSplit := strings.Split(proxyJumpRaw, ",")
for _, proxyJumpName := range proxyJumpSplit {
proxyJumpName = strings.TrimSpace(proxyJumpName)
if proxyJumpName == "" || strings.ToLower(proxyJumpName) == "none" {
continue
}
sshKeywords.ProxyJump = append(sshKeywords.ProxyJump, proxyJumpName)
}
rawUserKnownHostsFile, _ := ssh_config.GetStrict(hostPattern, "UserKnownHostsFile")
sshKeywords.UserKnownHostsFile = strings.Fields(rawUserKnownHostsFile) // TODO - smarter splitting escaped spaces and quotes
rawGlobalKnownHostsFile, _ := ssh_config.GetStrict(hostPattern, "GlobalKnownHostsFile")
sshKeywords.GlobalKnownHostsFile = strings.Fields(rawGlobalKnownHostsFile) // TODO - smarter splitting escaped spaces and quotes
return sshKeywords, nil
}

View File

@ -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

View File

@ -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)
}

View File

@ -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)
}

View File

@ -9,6 +9,7 @@ import (
"time"
"github.com/creack/pty"
"github.com/wavetermdev/waveterm/pkg/wsl"
"golang.org/x/crypto/ssh"
)
@ -135,3 +136,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
}

View File

@ -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) {

View File

@ -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{},
}

View File

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

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

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

View File

@ -149,7 +149,7 @@ func WaveshellLocalEnvVars(termType string) map[string]string {
rtn["TERM_PROGRAM"] = "waveterm"
rtn["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
}

View File

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

View File

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

View File

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

View File

@ -15,35 +15,6 @@ import (
// ReactNode types = nil | string | Elem
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 {

View File

@ -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

View File

@ -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
}

View File

@ -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)
}

View File

@ -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, "", " ")

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