From 9de25e4869d056637ce1ce3e4c43e3ba4b158969 Mon Sep 17 00:00:00 2001 From: Red J Adaya Date: Thu, 4 Apr 2024 00:55:36 +0800 Subject: [PATCH 01/15] truncate labels (#544) --- src/app/workspace/screen/screenview.less | 131 ---------------------- src/app/workspace/workspace.less | 135 +++++++++++++++++++++++ src/app/workspace/workspaceview.tsx | 4 +- 3 files changed, 137 insertions(+), 133 deletions(-) diff --git a/src/app/workspace/screen/screenview.less b/src/app/workspace/screen/screenview.less index ae2bd411a..5fc4849f1 100644 --- a/src/app/workspace/screen/screenview.less +++ b/src/app/workspace/screen/screenview.less @@ -159,134 +159,3 @@ } } } - -.newtab-container { - margin: 8px 16px 0 16px; - - .newtab-section { - display: flex; - padding: 10px 16px; - flex-direction: column; - align-items: flex-start; - gap: 8px; - align-self: stretch; - - &.conn-section { - gap: 8px; - } - } - - .cr-help-text { - color: var(--screen-view-text-caption-color); - margin-left: 5px; - } - - .newtab-spacer { - height: 1px; - background: var(--app-border-color); - } - - .control-iconlist { - display: flex; - margin-left: -2px; - padding: 8px 0 8px 2px; - align-items: flex-start; - gap: 14px; - - &.tabicon-list { - gap: 12px; - } - - .icondiv { - width: 20px; - height: 20px; - cursor: pointer; - position: relative; - font-size: 14px; - - &.tabicon { - display: flex; - align-items: center; - width: 22px; - } - - .icon { - width: 20px; - height: 20px; - } - - i { - padding-left: 3px; - padding-right: 3px; - } - - .icon.square-icon { - position: relative; - top: 3px; - width: 16px; - height: 16px; - } - - .check-icon { - width: 12px; - height: 12px; - position: absolute; - top: 4px; - left: 4px; - - path { - fill: black; - } - } - - .status-div { - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - padding: 3px; - - svg.status-icon { - width: 10px; - height: 10px; - } - } - - .add-div { - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - - svg.add-icon { - width: 16px; - height: 16px; - - path { - fill: var(--app-text-primary-color); - } - } - } - - .text-standard { - color: var(--app-text-secondary-color); - } - - .ellipsis { - text-overflow: ellipsis; - } - - &:hover { - background-color: rgba(241, 246, 243, 0.08); - } - - .icon.color-white + .check-icon { - path { - fill: black; - } - } - } - } -} diff --git a/src/app/workspace/workspace.less b/src/app/workspace/workspace.less index 03e3693eb..6582a191b 100644 --- a/src/app/workspace/workspace.less +++ b/src/app/workspace/workspace.less @@ -53,3 +53,138 @@ } } } + +.newtab-container { + margin: 8px 16px 0 16px; + + .newtab-section { + display: flex; + padding: 10px 16px; + flex-direction: column; + align-items: flex-start; + gap: 8px; + align-self: stretch; + + .truncate { + max-width: 100%; + } + + &.conn-section { + gap: 8px; + } + } + + .cr-help-text { + color: var(--screen-view-text-caption-color); + margin-left: 5px; + } + + .newtab-spacer { + height: 1px; + background: var(--app-border-color); + } + + .control-iconlist { + display: flex; + margin-left: -2px; + padding: 8px 0 8px 2px; + align-items: flex-start; + gap: 14px; + + &.tabicon-list { + gap: 12px; + } + + .icondiv { + width: 20px; + height: 20px; + cursor: pointer; + position: relative; + font-size: 14px; + + &.tabicon { + display: flex; + align-items: center; + width: 22px; + } + + .icon { + width: 20px; + height: 20px; + } + + i { + padding-left: 3px; + padding-right: 3px; + } + + .icon.square-icon { + position: relative; + top: 3px; + width: 16px; + height: 16px; + } + + .check-icon { + width: 12px; + height: 12px; + position: absolute; + top: 4px; + left: 4px; + + path { + fill: black; + } + } + + .status-div { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 3px; + + svg.status-icon { + width: 10px; + height: 10px; + } + } + + .add-div { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + + svg.add-icon { + width: 16px; + height: 16px; + + path { + fill: var(--app-text-primary-color); + } + } + } + + .text-standard { + color: var(--app-text-secondary-color); + } + + .ellipsis { + text-overflow: ellipsis; + } + + &:hover { + background-color: rgba(241, 246, 243, 0.08); + } + + .icon.color-white + .check-icon { + path { + fill: black; + } + } + } + } +} diff --git a/src/app/workspace/workspaceview.tsx b/src/app/workspace/workspaceview.tsx index eeb27201b..48dc89fe3 100644 --- a/src/app/workspace/workspaceview.tsx +++ b/src/app/workspace/workspaceview.tsx @@ -162,13 +162,13 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> {
-
+
You're connected to "{getRemoteStrWithAlias(rptr)}". Do you want to change it?
-
+
To change connection from the command line use `cr [alias|user@host]`
From 097623ab51f37bbf07a8ffef3f74334c91231254 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 4 Apr 2024 15:08:45 -0700 Subject: [PATCH 02/15] have initial run-command return faster to the frontend for quicker updating (#549) * have initial run-command return faster to the frontend for quicker updating. cuts time from 70-80ms down to 20ms for an average command * remove wlogs * more logging cleanup * fix focus for when start cmd returns an error --- waveshell/pkg/server/server.go | 4 + waveshell/pkg/utilfn/ansi.go | 4 + wavesrv/cmd/main-server.go | 2 + wavesrv/pkg/cmdrunner/cmdrunner.go | 33 +++---- wavesrv/pkg/remote/remote.go | 137 ++++++++++++++++++++++------- wavesrv/pkg/sstore/dbops.go | 19 +++- 6 files changed, 148 insertions(+), 51 deletions(-) diff --git a/waveshell/pkg/server/server.go b/waveshell/pkg/server/server.go index 89e588a54..192b39dc7 100644 --- a/waveshell/pkg/server/server.go +++ b/waveshell/pkg/server/server.go @@ -748,6 +748,10 @@ func (m *MServer) runCommand(runPacket *packet.RunPacketType) { m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("invalid shellstate version: %w", err)) return } + if runPacket.Command == "wave:testerror" { + m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("test error")) + return + } ecmd, err := shexec.MakeMShellSingleCmd() if err != nil { m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("server run packets require valid ck: %s", err)) diff --git a/waveshell/pkg/utilfn/ansi.go b/waveshell/pkg/utilfn/ansi.go index 276989816..34d897f6b 100644 --- a/waveshell/pkg/utilfn/ansi.go +++ b/waveshell/pkg/utilfn/ansi.go @@ -10,3 +10,7 @@ func AnsiResetColor() string { func AnsiGreenColor() string { return "\033[32m" } + +func AnsiRedColor() string { + return "\033[31m" +} diff --git a/wavesrv/cmd/main-server.go b/wavesrv/cmd/main-server.go index 41e37de71..d62a93a11 100644 --- a/wavesrv/cmd/main-server.go +++ b/wavesrv/cmd/main-server.go @@ -1018,6 +1018,8 @@ func main() { wlog.GlobalSubsystem = base.ProcessType_WaveSrv wlog.LogConsumer = wlog.LogWithLogger + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + if len(os.Args) >= 2 && os.Args[1] == "--test" { log.Printf("running test fn\n") err := test() diff --git a/wavesrv/pkg/cmdrunner/cmdrunner.go b/wavesrv/pkg/cmdrunner/cmdrunner.go index 62056e560..c16e65027 100644 --- a/wavesrv/pkg/cmdrunner/cmdrunner.go +++ b/wavesrv/pkg/cmdrunner/cmdrunner.go @@ -1242,12 +1242,13 @@ func deferWriteCmdStatus(ctx context.Context, cmd *sstore.CmdType, startTime tim exitCode = 1 } ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId) - donePk := packet.MakeCmdDonePacket(ck) - donePk.Ts = time.Now().UnixMilli() - donePk.ExitCode = exitCode - donePk.DurationMs = duration.Milliseconds() + doneInfo := sstore.CmdDoneDataValues{ + Ts: time.Now().UnixMilli(), + ExitCode: exitCode, + DurationMs: duration.Milliseconds(), + } update := scbus.MakeUpdatePacket() - err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, donePk, cmdStatus) + err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, doneInfo, cmdStatus) if err != nil { // nothing to do log.Printf("error updating cmddoneinfo: %v\n", err) @@ -2623,12 +2624,13 @@ func doOpenAICompletion(cmd *sstore.CmdType, opts *sstore.OpenAIOptsType, prompt exitCode = 1 } ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId) - donePk := packet.MakeCmdDonePacket(ck) - donePk.Ts = time.Now().UnixMilli() - donePk.ExitCode = exitCode - donePk.DurationMs = duration.Milliseconds() + doneInfo := sstore.CmdDoneDataValues{ + Ts: time.Now().UnixMilli(), + ExitCode: exitCode, + DurationMs: duration.Milliseconds(), + } update := scbus.MakeUpdatePacket() - err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, donePk, cmdStatus) + err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, doneInfo, cmdStatus) if err != nil { // nothing to do log.Printf("error updating cmddoneinfo (in openai): %v\n", err) @@ -2783,12 +2785,13 @@ func doOpenAIStreamCompletion(cmd *sstore.CmdType, clientId string, opts *sstore exitCode = 1 } ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId) - donePk := packet.MakeCmdDonePacket(ck) - donePk.Ts = time.Now().UnixMilli() - donePk.ExitCode = exitCode - donePk.DurationMs = duration.Milliseconds() + doneInfo := sstore.CmdDoneDataValues{ + Ts: time.Now().UnixMilli(), + ExitCode: exitCode, + DurationMs: duration.Milliseconds(), + } update := scbus.MakeUpdatePacket() - err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, donePk, cmdStatus) + err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, doneInfo, cmdStatus) if err != nil { // nothing to do log.Printf("error updating cmddoneinfo (in openai): %v\n", err) diff --git a/wavesrv/pkg/remote/remote.go b/wavesrv/pkg/remote/remote.go index 0328905cd..d01b7dc43 100644 --- a/wavesrv/pkg/remote/remote.go +++ b/wavesrv/pkg/remote/remote.go @@ -2012,30 +2012,33 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru removeCmdWait(runPacket.CK) } }() - + runningCmdType := &RunCmdType{ + CK: runPacket.CK, + SessionId: sessionId, + ScreenId: screenId, + RemotePtr: remotePtr, + RunPacket: runPacket, + EphemeralOpts: rcOpts.EphemeralOpts, + } // RegisterRpc + WaitForResponse is used to get any waveshell side errors // waveshell will either return an error (in a ResponsePacketType) or a CmdStartPacketType msh.ServerProc.Output.RegisterRpc(runPacket.ReqId) - err = shexec.SendRunPacketAndRunData(ctx, msh.ServerProc.Input, runPacket) - if err != nil { - return nil, nil, fmt.Errorf("sending run packet to remote: %w", err) - } - rtnPk := msh.ServerProc.Output.WaitForResponse(ctx, runPacket.ReqId) - if rtnPk == nil { - return nil, nil, ctx.Err() - } - startPk, ok := rtnPk.(*packet.CmdStartPacketType) - if !ok { - respPk, ok := rtnPk.(*packet.ResponsePacketType) - if !ok { - return nil, nil, fmt.Errorf("invalid response received from server for run packet: %s", packet.AsString(rtnPk)) - } - if respPk.Error != "" { - return nil, nil, respPk.Err() - } - return nil, nil, fmt.Errorf("invalid response received from server for run packet: %s", packet.AsString(rtnPk)) - } - + go func() { + startPk, err := msh.sendRunPacketAndReturnResponse(runPacket) + runCmdUpdateFn(runPacket.CK, func() { + if err != nil { + // the cmd failed (never started) + msh.handleCmdStartError(runningCmdType, err) + return + } + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + err = sstore.UpdateCmdStartInfo(ctx, runPacket.CK, startPk.Pid, startPk.MShellPid) + if err != nil { + log.Printf("error updating cmd start info (in remote.RunCommand): %v\n", err) + } + }) + }() // command is now successfully runnning status := sstore.CmdStatusRunning if runPacket.Detached { @@ -2051,8 +2054,6 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru StatePtr: *statePtr, TermOpts: makeTermOpts(runPacket), Status: status, - CmdPid: startPk.Pid, - RemotePid: startPk.MShellPid, ExitCode: 0, DurationMs: 0, RunOut: nil, @@ -2065,18 +2066,36 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru return nil, nil, fmt.Errorf("cannot create local ptyout file for running command: %v", err) } } - runningCmdType := &RunCmdType{ - CK: runPacket.CK, - SessionId: sessionId, - ScreenId: screenId, - RemotePtr: remotePtr, - RunPacket: runPacket, - EphemeralOpts: rcOpts.EphemeralOpts} msh.AddRunningCmd(runningCmdType) - return cmd, func() { removeCmdWait(runPacket.CK) }, nil } +// no context because it is called as a goroutine +func (msh *MShellProc) sendRunPacketAndReturnResponse(runPacket *packet.RunPacketType) (*packet.CmdStartPacketType, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + err := shexec.SendRunPacketAndRunData(ctx, msh.ServerProc.Input, runPacket) + if err != nil { + return nil, fmt.Errorf("sending run packet to remote: %w", err) + } + rtnPk := msh.ServerProc.Output.WaitForResponse(ctx, runPacket.ReqId) + if rtnPk == nil { + return nil, ctx.Err() + } + startPk, ok := rtnPk.(*packet.CmdStartPacketType) + if !ok { + respPk, ok := rtnPk.(*packet.ResponsePacketType) + if !ok { + return nil, fmt.Errorf("invalid response received from server for run packet: %s", packet.AsString(rtnPk)) + } + if respPk.Error != "" { + return nil, respPk.Err() + } + return nil, fmt.Errorf("invalid response received from server for run packet: %s", packet.AsString(rtnPk)) + } + return startPk, nil +} + // helper func to construct the proper error given what information we have func makePSCLineError(existingPSC base.CommandKey, line *sstore.LineType, lineErr error) error { if lineErr != nil { @@ -2342,6 +2361,42 @@ func (msh *MShellProc) updateRIWithFinalState(ctx context.Context, rct *RunCmdTy return sstore.UpdateRemoteState(ctx, rct.SessionId, rct.ScreenId, rct.RemotePtr, feState, nil, newStateDiff) } +func (msh *MShellProc) handleCmdStartError(rct *RunCmdType, startErr error) { + if rct == nil { + log.Printf("handleCmdStartError, no rct\n") + return + } + defer msh.RemoveRunningCmd(rct.CK) + if rct.EphemeralOpts != nil { + // nothing to do for ephemeral commands besides remove the running command + return + } + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + update := scbus.MakeUpdatePacket() + errOutputStr := fmt.Sprintf("%serror: %v%s\n", utilfn.AnsiRedColor(), startErr, utilfn.AnsiResetColor()) + msh.writeToCmdPtyOut(ctx, rct.ScreenId, rct.CK.GetCmdId(), []byte(errOutputStr)) + doneInfo := sstore.CmdDoneDataValues{ + Ts: time.Now().UnixMilli(), + ExitCode: 1, + DurationMs: 0, + } + err := sstore.UpdateCmdDoneInfo(ctx, update, rct.CK, doneInfo, sstore.CmdStatusError) + if err != nil { + log.Printf("error updating cmddone info (in handleCmdStartError): %v\n", err) + return + } + screen, err := sstore.UpdateScreenFocusForDoneCmd(ctx, rct.CK.GetGroupId(), rct.CK.GetCmdId()) + if err != nil { + log.Printf("error trying to update screen focus type (in handleCmdDonePacket): %v\n", err) + // fall-through (nothing to do) + } + if screen != nil { + update.AddUpdate(*screen) + } + scbus.MainUpdateBus.DoUpdate(update) +} + func (msh *MShellProc) handleCmdDonePacket(rct *RunCmdType, donePk *packet.CmdDonePacketType) { if rct == nil { log.Printf("cmddone packet received, but no running command found for it %q\n", donePk.CK) @@ -2359,7 +2414,12 @@ func (msh *MShellProc) handleCmdDonePacket(rct *RunCmdType, donePk *packet.CmdDo update := scbus.MakeUpdatePacket() if rct.EphemeralOpts == nil { // only update DB for non-ephemeral commands - err := sstore.UpdateCmdDoneInfo(ctx, update, donePk.CK, donePk, sstore.CmdStatusDone) + cmdDoneInfo := sstore.CmdDoneDataValues{ + Ts: donePk.Ts, + ExitCode: donePk.ExitCode, + DurationMs: donePk.DurationMs, + } + err := sstore.UpdateCmdDoneInfo(ctx, update, donePk.CK, cmdDoneInfo, sstore.CmdStatusDone) if err != nil { log.Printf("error updating cmddone info (in handleCmdDonePacket): %v\n", err) return @@ -2453,6 +2513,19 @@ func (msh *MShellProc) ResetDataPos(ck base.CommandKey) { msh.DataPosMap.Delete(ck) } +func (msh *MShellProc) writeToCmdPtyOut(ctx context.Context, screenId string, lineId string, data []byte) error { + dataPos := msh.DataPosMap.Get(base.MakeCommandKey(screenId, lineId)) + update, err := sstore.AppendToCmdPtyBlob(ctx, screenId, lineId, data, dataPos) + if err != nil { + return err + } + utilfn.IncSyncMap(msh.DataPosMap, base.MakeCommandKey(screenId, lineId), int64(len(data))) + if update != nil { + scbus.MainUpdateBus.DoScreenUpdate(screenId, update) + } + return nil +} + func (msh *MShellProc) handleDataPacket(rct *RunCmdType, dataPk *packet.DataPacketType, dataPosMap *utilfn.SyncMap[base.CommandKey, int64]) { if rct == nil { log.Printf("error handling data packet: no running cmd found %s\n", dataPk.CK) diff --git a/wavesrv/pkg/sstore/dbops.go b/wavesrv/pkg/sstore/dbops.go index ca35f87a7..f28a3da61 100644 --- a/wavesrv/pkg/sstore/dbops.go +++ b/wavesrv/pkg/sstore/dbops.go @@ -743,10 +743,21 @@ func UpdateCmdForRestart(ctx context.Context, ck base.CommandKey, ts int64, cmdP }) } -func UpdateCmdDoneInfo(ctx context.Context, update *scbus.ModelUpdatePacketType, ck base.CommandKey, donePk *packet.CmdDonePacketType, status string) error { - if donePk == nil { - return fmt.Errorf("invalid cmddone packet") - } +func UpdateCmdStartInfo(ctx context.Context, ck base.CommandKey, cmdPid int, mshellPid int) error { + return WithTx(ctx, func(tx *TxWrap) error { + query := `UPDATE cmd SET cmdpid = ?, remotepid = ? WHERE screenid = ? AND lineid = ?` + tx.Exec(query, cmdPid, mshellPid, ck.GetGroupId(), lineIdFromCK(ck)) + return nil + }) +} + +type CmdDoneDataValues struct { + Ts int64 + ExitCode int + DurationMs int64 +} + +func UpdateCmdDoneInfo(ctx context.Context, update *scbus.ModelUpdatePacketType, ck base.CommandKey, donePk CmdDoneDataValues, status string) error { if ck.IsEmpty() { return fmt.Errorf("cannot update cmddoneinfo, empty ck") } From 0fe767cdf3c7e0d60f891ff0e981788291821918 Mon Sep 17 00:00:00 2001 From: Cole Lashley Date: Thu, 4 Apr 2024 16:58:26 -0700 Subject: [PATCH 03/15] Bugfixes for ai chat code select (#537) * added uuid to code select to fix some render related bugs * added input popup type, and fixed aichat computed condition * fixed stash artifacts --- src/app/common/elements/markdown.tsx | 27 ++++++++++++++++++++++----- src/app/workspace/cmdinput/aichat.tsx | 14 +++++++++++--- src/models/input.ts | 27 ++++++++++++++++++++++++++- src/models/model.ts | 2 -- 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/app/common/elements/markdown.tsx b/src/app/common/elements/markdown.tsx index 5762b05d1..0ffc2a7d3 100644 --- a/src/app/common/elements/markdown.tsx +++ b/src/app/common/elements/markdown.tsx @@ -7,8 +7,10 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import cn from "classnames"; import { GlobalModel } from "@/models"; +import { v4 as uuidv4 } from "uuid"; import "./markdown.less"; +import { boundMethod } from "autobind-decorator"; function LinkRenderer(props: any): any { let newUrl = "https://extern?" + encodeURIComponent(props.href); @@ -28,14 +30,17 @@ function CodeRenderer(props: any): any { } @mobxReact.observer -class CodeBlockMarkdown extends React.Component<{ children: React.ReactNode; codeSelectSelectedIndex?: number }, {}> { +class CodeBlockMarkdown extends React.Component< + { children: React.ReactNode; codeSelectSelectedIndex?: number; uuid: string }, + {} +> { blockIndex: number; blockRef: React.RefObject; constructor(props) { super(props); this.blockRef = React.createRef(); - this.blockIndex = GlobalModel.inputModel.addCodeBlockToCodeSelect(this.blockRef); + this.blockIndex = GlobalModel.inputModel.addCodeBlockToCodeSelect(this.blockRef, this.props.uuid); } render() { @@ -62,9 +67,21 @@ class Markdown extends React.Component< { text: string; style?: any; extraClassName?: string; codeSelect?: boolean }, {} > { - CodeBlockRenderer(props: any, codeSelect: boolean, codeSelectIndex: number): any { + curUuid: string; + + constructor(props) { + super(props); + this.curUuid = uuidv4(); + } + + @boundMethod + CodeBlockRenderer(props: any, codeSelect: boolean, codeSelectIndex: number, curUuid: string): any { if (codeSelect) { - return {props.children}; + return ( + + {props.children} + + ); } else { const clickHandler = (e: React.MouseEvent) => { let blockText = (e.target as HTMLElement).innerText; @@ -90,7 +107,7 @@ class Markdown extends React.Component< h5: (props) => HeaderRenderer(props, 5), h6: (props) => HeaderRenderer(props, 6), code: (props) => CodeRenderer(props), - pre: (props) => this.CodeBlockRenderer(props, codeSelect, curCodeSelectIndex), + pre: (props) => this.CodeBlockRenderer(props, codeSelect, curCodeSelectIndex, this.curUuid), }; return (
diff --git a/src/app/workspace/cmdinput/aichat.tsx b/src/app/workspace/cmdinput/aichat.tsx index 8f386b3cd..7fef6c78c 100644 --- a/src/app/workspace/cmdinput/aichat.tsx +++ b/src/app/workspace/cmdinput/aichat.tsx @@ -250,11 +250,19 @@ class AIChat extends React.Component<{}, {}> { const textAreaPadding = 2 * 0.5 * termFontSize; let textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines + textAreaPadding; let textAreaInnerHeight = this.textAreaNumLines.get() * textAreaLineHeight + textAreaPadding; - let isFocused = this.isFocused.get(); - + let renderKeybindings = mobx + .computed(() => { + return ( + this.isFocused.get() || + GlobalModel.inputModel.hasFocus() || + (GlobalModel.getActiveScreen().getFocusType() == "input" && + GlobalModel.activeMainView.get() == "session") + ); + }) + .get(); return (
- +
diff --git a/src/models/input.ts b/src/models/input.ts index e07bfe4f4..99e58b16e 100644 --- a/src/models/input.ts +++ b/src/models/input.ts @@ -32,6 +32,8 @@ class InputModel { aiChatWindowRef: React.RefObject; codeSelectBlockRefArray: Array>; codeSelectSelectedIndex: OV = mobx.observable.box(-1); + codeSelectUuid: string; + inputPopUpType: OV = mobx.observable.box("none"); AICmdInfoChatItems: mobx.IObservableArray = mobx.observable.array([], { name: "aicmdinfo-chat", @@ -80,6 +82,7 @@ class InputModel { this.codeSelectSelectedIndex.set(-1); this.codeSelectBlockRefArray = []; })(); + this.codeSelectUuid = ""; } setInputMode(inputMode: null | "comment" | "global"): void { @@ -180,6 +183,10 @@ class InputModel { if (document.activeElement == historyInputElem) { return true; } + let aiChatInputElem = document.querySelector(".cmd-input chat-cmd-input"); + if (document.activeElement == aiChatInputElem) { + return true; + } return false; } @@ -224,6 +231,12 @@ class InputModel { })(); } + setInputPopUpType(type: string) { + this.inputPopUpType = type; + this.aIChatShow.set(type == "aichat"); + this.historyShow.set(type == "history"); + } + setOpenAICmdInfoChat(chat: OpenAICmdInfoChatMessageType[]): void { this.AICmdInfoChatItems.replace(chat); this.codeSelectBlockRefArray = []; @@ -234,6 +247,11 @@ class InputModel { return; } mobx.action(() => { + if (show) { + this.setInputPopUpType("history"); + } else { + this.setInputPopUpType("none"); + } this.historyShow.set(show); if (this.hasFocus()) { this.giveFocus(); @@ -522,6 +540,7 @@ class InputModel { } setAIChatFocus() { + console.log("setting ai chat focus"); if (this.aiChatTextAreaRef?.current != null) { this.aiChatTextAreaRef.current.focus(); } @@ -540,8 +559,12 @@ class InputModel { } } - addCodeBlockToCodeSelect(blockRef: React.RefObject): number { + addCodeBlockToCodeSelect(blockRef: React.RefObject, uuid: string): number { let rtn = -1; + if (uuid != this.codeSelectUuid) { + this.codeSelectUuid = uuid; + this.codeSelectBlockRefArray = []; + } rtn = this.codeSelectBlockRefArray.length; this.codeSelectBlockRefArray.push(blockRef); return rtn; @@ -641,6 +664,7 @@ class InputModel { openAIAssistantChat(): void { mobx.action(() => { + this.setInputPopUpType("aichat"); this.aIChatShow.set(true); this.setAIChatFocus(); })(); @@ -653,6 +677,7 @@ class InputModel { return; } mobx.action(() => { + this.setInputPopUpType("none"); this.aIChatShow.set(false); if (giveFocus) { this.giveFocus(); diff --git a/src/models/model.ts b/src/models/model.ts index 29c4cdb19..8175de327 100644 --- a/src/models/model.ts +++ b/src/models/model.ts @@ -823,7 +823,6 @@ class Model { } onMetaArrowDown(): void { - console.log("meta arrow down?"); GlobalCommandRunner.screenSelectLine("+1"); } @@ -836,7 +835,6 @@ class Model { } onSwitchSessionCmd(digit: number) { - console.log("switching to ", digit); GlobalCommandRunner.switchSession(String(digit)); } From 1c237011816109d4487f6538d346917868acb80d Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Thu, 4 Apr 2024 19:29:43 -0700 Subject: [PATCH 04/15] Clean up styling and focus behavior for cmdinput (#546) * Clean up cmdinput * Remove unused css styles, clicking on textarea will focus back to textarea without closing history * cleanup logic for activating textarea * actions buttons should always show, should properly disable inactive views * clicking actions toggles the view * remove titlebar spacer, clean up padding * Make AIChat and HistoryInfo share a common layout * fix ai chat scroll * clean up formatting * fix chat textarea resizing * align prompt and input * update infomsg to use auxview * update comments * fix widths and key error * add todo * adjust padding for input, remove debug * Don't capture clicks on the prompt area --- public/themes/default.css | 32 +- public/themes/light.css | 32 +- src/app/line/line.less | 5 - src/app/sidebar/main.tsx | 2 +- src/app/workspace/cmdinput/aichat.less | 70 +++ src/app/workspace/cmdinput/aichat.tsx | 156 +++-- src/app/workspace/cmdinput/auxview.less | 51 ++ src/app/workspace/cmdinput/auxview.tsx | 50 ++ src/app/workspace/cmdinput/cmdinput.less | 605 ++++--------------- src/app/workspace/cmdinput/cmdinput.tsx | 88 +-- src/app/workspace/cmdinput/historyinfo.less | 60 ++ src/app/workspace/cmdinput/historyinfo.tsx | 100 +-- src/app/workspace/cmdinput/infomsg.less | 49 ++ src/app/workspace/cmdinput/infomsg.tsx | 22 +- src/app/workspace/cmdinput/textareainput.tsx | 13 +- src/models/input.ts | 164 ++--- src/util/textmeasure.ts | 20 +- 17 files changed, 724 insertions(+), 795 deletions(-) create mode 100644 src/app/workspace/cmdinput/aichat.less create mode 100644 src/app/workspace/cmdinput/auxview.less create mode 100644 src/app/workspace/cmdinput/auxview.tsx create mode 100644 src/app/workspace/cmdinput/historyinfo.less create mode 100644 src/app/workspace/cmdinput/infomsg.less diff --git a/public/themes/default.css b/public/themes/default.css index c015dbbfd..9233a226d 100644 --- a/public/themes/default.css +++ b/public/themes/default.css @@ -42,16 +42,19 @@ --app-bg-color: black; --app-accent-color: rgb(88, 193, 66); --app-accent-bg-color: rgba(88, 193, 66, 0.25); + --app-accent-bg-darker-color: rgba(88, 193, 66, 0.5); --app-text-color: rgb(211, 215, 207); --app-text-primary-color: rgb(255, 255, 255); --app-text-secondary-color: rgb(195, 200, 194); --app-text-disabled-color: rgb(173, 173, 173); + --app-text-bg-color: rgb(23, 23, 23); + --app-text-bg-disabled-color: rgb(51, 51, 51); --app-border-color: rgb(51, 51, 51); - --app-maincontent-bg-color: #333; + --app-maincontent-bg-color: rgb(51, 51, 51); --app-panel-bg-color: rgba(21, 23, 21, 1); --app-panel-bg-color-dev: rgb(21, 23, 48); --app-icon-color: rgb(139, 145, 138); - --app-icon-hover-color: #fff; + --app-icon-hover-color: rgb(255, 255, 255); --app-selected-mask-color: rgba(255, 255, 255, 0.06); /* global status colors */ @@ -122,18 +125,18 @@ /* line colors */ --line-sidebar-message-color: rgb(196, 160, 0); - --line-background: rgba(21, 23, 21, 1); - --line-avatar-color: #eceeec; + --line-background: var(--app-panel-bg-color); + --line-avatar-color: rgb(236, 238, 236); --line-text-color: rgb(211, 215, 207); --line-svg-fill-color: rgb(150, 152, 150); - --line-svg-hover-fill-color: #eceeec; + --line-svg-hover-fill-color: rgb(236, 238, 236); --line-separator-color: rgb(126, 126, 126); --line-error-color: var(--app-error-color); --line-warning-color: var(--app-warning-color); - --line-base-soft-blue-color: #729fcf; + --line-base-soft-blue-color: rgb(114, 159, 207); --line-active-border-color: var(--app-accent-color); --line-selected-bg-color: rgba(255, 255, 255, 0.05); - --line-selected-border-left-color: #777777; + --line-selected-border-left-color: rgb(119, 119, 119); --line-selected-error-border-color: rgba(204, 0, 0, 0.8); --line-selected-error-bg-color: rgb(19, 4, 3); --line-error-bg-color: rgba(200, 0, 0, 0.1); @@ -152,22 +155,21 @@ /* table colors */ --table-border-color: rgba(241, 246, 243, 0.15); --table-thead-border-top-color: rgba(250, 250, 250, 0.1); - --table-thead-bright-border-color: #ccc; + --table-thead-bright-border-color: rgb(204, 204, 204); --table-thead-bg-color: rgba(250, 250, 250, 0.02); --table-tr-border-bottom-color: rgba(241, 246, 243, 0.15); --table-tr-hover-bg-color: rgba(255, 255, 255, 0.06); - --table-tr-selected-bg-color: #222; - --table-tr-selected-hover-bg-color: #333; + --table-tr-selected-bg-color: rgb(34, 34, 34); + --table-tr-selected-hover-bg-color: var(--app-maincontent-bg-color); /* cmdinput colors */ - --cmdinput-textarea-bg-color: #171717; + --cmdinput-bg-color: var(--app-text-bg-color); --cmdinput-text-error-color: var(--term-red); --cmdinput-history-item-error-color: var(--term-bright-red); --cmdinput-history-item-selected-error-color: var(--term-bright-red); - --cmdinput-button-bg-color: rgb(88, 193, 66); - --cmdinput-comment-button-bg-color: rgb(57, 113, 255); - --cmdinput-disabled-icon-color: rgb(76, 81, 75, 1); - --cmdinput-history-bg-color: rgb(21, 23, 21, 1); + --cmdinput-button-bg-color: var(--tab-green); + --cmdinput-disabled-bg-color: var(--app-text-bg-disabled-color); + --cmdinput-history-bg-color: var(--app-bg-color); /* screen view color */ --screen-view-text-caption-color: rgb(139, 145, 138); diff --git a/public/themes/light.css b/public/themes/light.css index 7431c68af..30a9194ef 100644 --- a/public/themes/light.css +++ b/public/themes/light.css @@ -5,20 +5,20 @@ @import url("./term-light.css"); :root { - --app-bg-color: #fefefe; + --app-bg-color: rgb(254, 254, 254); --app-accent-color: rgb(75, 166, 57); --app-accent-bg-color: rgba(75, 166, 57, 0.2); - --app-text-color: #000; + --app-text-color: rgb(0, 0, 0); --app-text-primary-color: rgb(0, 0, 0, 0.9); --app-text-secondary-color: rgb(0, 0, 0, 0.7); --app-border-color: rgb(139 145 138); - --app-panel-bg-color: #e0e0e0; - --app-panel-bg-color-dev: #e0e0e0; - --app-icon-color: rgb(80, 80, 80); - --app-icon-hover-color: rgb(100, 100, 100); + --app-panel-bg-color: rgb(224, 224, 224); + --app-panel-bg-color-dev: rgb(224, 224, 224); + --app-icon-color: rgb(110, 110, 110); + --app-icon-hover-color: rgb(80, 80, 80); --app-selected-mask-color: rgba(0, 0, 0, 0.06); - --input-bg-color: #eeeeee; + --input-bg-color: rgb(238, 238, 238); /* tab color */ --tab-white: rgb(0, 0, 0, 0.6); @@ -30,8 +30,8 @@ --table-thead-bg-color: rgba(250, 250, 250, 0.15); --table-tr-border-bottom-color: rgba(0, 0, 0, 0.15); --table-tr-hover-bg-color: rgba(0, 0, 0, 0.15); - --table-tr-selected-bg-color: #dddddd; - --table-tr-selected-hover-bg-color: #cccccc; + --table-tr-selected-bg-color: rgb(221, 221, 221); + --table-tr-selected-hover-bg-color: rgb(204, 204, 204); /* form colors */ --form-element-border-color: rgba(0, 0, 0, 0.3); @@ -41,8 +41,8 @@ --form-element-label-color: rgba(0, 0, 0, 0.6); --form-element-secondary-color: rgba(0, 0, 0, 0.09); --form-element-icon-color: rgb(0, 0, 0, 0.6); - --form-element-disabled-text-color: #b7b7b7; - --form-element-placeholder-color: #b7b7b7; + --form-element-disabled-text-color: rgb(183, 183, 183); + --form-element-placeholder-color: rgb(183, 183, 183); --markdown-bg-color: rgb(0, 0, 0, 0.1); @@ -50,8 +50,6 @@ --modal-header-bottom-border-color: rgba(0, 0, 0, 0.3); /* cmd input */ - --cmdinput-textarea-bg-color: rgba(0, 0, 0, 0.1); - --cmdinput-textarea-border-color: var(--form-element-border-color); /* scroll colors */ --scrollbar-background-color: var(--app-bg-color); @@ -64,15 +62,15 @@ --line-actions-inactive-color: rgba(0, 0, 0, 0.3); --line-actions-active-color: rgba(0, 0, 0, 1); - --logo-button-hover-bg-color: #f0f0f0; + --logo-button-hover-bg-color: rgb(240, 240, 240); --xterm-viewport-border-color: rgba(0, 0, 0, 0.3); - --datepicker-cell-hover-bg-color: #f0f0f0; + --datepicker-cell-hover-bg-color: rgb(240, 240, 240); --datepicker-cell-other-text-color: rgba(0, 0, 0, 0.3); --datepicker-header-fade-color: rgba(0, 0, 0, 0.4); - --datepicker-year-header-bg-color: #f5f5f5; - --datepicker-year-header-border-color: #dcdcdc; + --datepicker-year-header-bg-color: rgb(245, 245, 245); + --datepicker-year-header-border-color: rgb(220, 220, 220); /* toggle colors */ --toggle-thumb-color: var(--app-bg-color); diff --git a/src/app/line/line.less b/src/app/line/line.less index 532ac3490..c05603ac9 100644 --- a/src/app/line/line.less +++ b/src/app/line/line.less @@ -106,11 +106,6 @@ } } - &.top-border { - border-top: 1px solid #777; - padding: 10px; - } - &:hover .meta .termopts { display: block; } diff --git a/src/app/sidebar/main.tsx b/src/app/sidebar/main.tsx index 1ed22ce6f..80828fe27 100644 --- a/src/app/sidebar/main.tsx +++ b/src/app/sidebar/main.tsx @@ -242,7 +242,7 @@ class MainSideBar extends React.Component { } render() { - let mainView = GlobalModel.activeMainView.get(); + const mainView = GlobalModel.activeMainView.get(); const historyActive = mainView == "history"; const connectionsActive = mainView == "connections"; const settingsActive = mainView == "clientsettings"; diff --git a/src/app/workspace/cmdinput/aichat.less b/src/app/workspace/cmdinput/aichat.less new file mode 100644 index 000000000..fe42fba72 --- /dev/null +++ b/src/app/workspace/cmdinput/aichat.less @@ -0,0 +1,70 @@ +.cmd-aichat { + padding-bottom: 0 !important; + .auxview-content { + flex-flow: column nowrap; + height: 100%; + + .chat-window { + overflow-y: auto; + margin-bottom: 5px; + flex: 1 1 auto; + flex-direction: column-reverse; + } + + .chat-input { + padding: 0.5em 0.5em 0.5em 0.5em; + flex: 1 0 auto; + + .chat-textarea { + color: var(--app-text-primary-color); + background-color: var(--cmdinput-textarea-bg); + resize: none; + width: 100%; + border: transparent; + outline: none; + overflow: auto; + overflow-wrap: anywhere; + font-family: var(--termfontfamily); + font-weight: normal; + line-height: var(--termlineheight); + } + } + + .chat-msg { + margin-top: calc(var(--termpad) * 2); + margin-bottom: calc(var(--termpad) * 2); + + .chat-msg-header { + display: flex; + margin-bottom: 2px; + + i { + margin-right: 0.5em; + } + + .chat-username { + font-weight: bold; + margin-right: 5px; + } + } + } + + .chat-msg-assistant { + color: var(--app-text-color); + } + + .chat-msg-user { + .msg-text { + font-family: var(--markdown-font); + font-size: 14px; + white-space: pre-wrap; + } + } + + .chat-msg-error { + color: var(--cmdinput-text-error); + font-family: var(--markdown-font); + font-size: 14px; + } + } +} diff --git a/src/app/workspace/cmdinput/aichat.tsx b/src/app/workspace/cmdinput/aichat.tsx index 7fef6c78c..5adfbf56d 100644 --- a/src/app/workspace/cmdinput/aichat.tsx +++ b/src/app/workspace/cmdinput/aichat.tsx @@ -5,18 +5,18 @@ import * as React from "react"; import * as mobxReact from "mobx-react"; import * as mobx from "mobx"; import { GlobalModel } from "@/models"; -import { isBlank } from "@/util/util"; import { boundMethod } from "autobind-decorator"; -import cn from "classnames"; import { If, For } from "tsx-control-statements/components"; import { Markdown } from "@/elements"; -import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; +import { AuxiliaryCmdView } from "./auxview"; + +import "./aichat.less"; class AIChatKeybindings extends React.Component<{ AIChatObject: AIChat }, {}> { componentDidMount(): void { - let AIChatObject = this.props.AIChatObject; - let keybindManager = GlobalModel.keybindManager; - let inputModel = GlobalModel.inputModel; + const AIChatObject = this.props.AIChatObject; + const keybindManager = GlobalModel.keybindManager; + const inputModel = GlobalModel.inputModel; keybindManager.registerKeybinding("pane", "aichat", "generic:confirm", (waveEvent) => { AIChatObject.onEnterKeyPressed(); @@ -54,10 +54,10 @@ class AIChatKeybindings extends React.Component<{ AIChatObject: AIChat }, {}> { @mobxReact.observer class AIChat extends React.Component<{}, {}> { chatListKeyCount: number = 0; - textAreaNumLines: mobx.IObservableValue = mobx.observable.box(1, { name: "textAreaNumLines" }); chatWindowScrollRef: React.RefObject; textAreaRef: React.RefObject; isFocused: OV; + termFontSize: number = 14; constructor(props: any) { super(props); @@ -69,8 +69,7 @@ class AIChat extends React.Component<{}, {}> { } componentDidMount() { - let model = GlobalModel; - let inputModel = model.inputModel; + const inputModel = GlobalModel.inputModel; if (this.chatWindowScrollRef != null && this.chatWindowScrollRef.current != null) { this.chatWindowScrollRef.current.scrollTop = this.chatWindowScrollRef.current.scrollHeight; } @@ -79,6 +78,7 @@ class AIChat extends React.Component<{}, {}> { inputModel.setCmdInfoChatRefs(this.textAreaRef, this.chatWindowScrollRef); } this.requestChatUpdate(); + this.onTextAreaChange(null); } componentDidUpdate() { @@ -92,20 +92,18 @@ class AIChat extends React.Component<{}, {}> { } submitChatMessage(messageStr: string) { - let model = GlobalModel; - let inputModel = model.inputModel; - let curLine = inputModel.getCurLine(); - let prtn = GlobalModel.submitChatInfoCommand(messageStr, curLine, false); + const curLine = GlobalModel.inputModel.getCurLine(); + const prtn = GlobalModel.submitChatInfoCommand(messageStr, curLine, false); prtn.then((rtn) => { if (!rtn.success) { console.log("submit chat command error: " + rtn.error); } - }).catch((error) => {}); + }).catch((_) => {}); } getLinePos(elem: any): { numLines: number; linePos: number } { - let numLines = elem.value.split("\n").length; - let linePos = elem.value.substr(0, elem.selectionStart).split("\n").length; + const numLines = elem.value.split("\n").length; + const linePos = elem.value.substr(0, elem.selectionStart).split("\n").length; return { numLines, linePos }; } @@ -121,22 +119,32 @@ class AIChat extends React.Component<{}, {}> { })(); } + // Adjust the height of the textarea to fit the text onTextAreaChange(e: any) { - // set height of textarea based on number of newlines - mobx.action(() => { - this.textAreaNumLines.set(e.target.value.split(/\n/).length); - GlobalModel.inputModel.codeSelectDeselectAll(); - })(); + // Calculate the bounding height of the text area + const textAreaMaxLines = 4; + const textAreaLineHeight = this.termFontSize * 1.5; + const textAreaMinHeight = textAreaLineHeight; + const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines; + + // Get the height of the wrapped text area content. Courtesy of https://stackoverflow.com/questions/995168/textarea-to-resize-based-on-content-length + this.textAreaRef.current.style.height = "1px"; + const scrollHeight: number = this.textAreaRef.current.scrollHeight; + + // Set the new height of the text area, bounded by the min and max height. + const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight); + this.textAreaRef.current.style.height = newHeight + "px"; + GlobalModel.inputModel.codeSelectDeselectAll(); } onEnterKeyPressed() { - let inputModel = GlobalModel.inputModel; - let currentRef = this.textAreaRef.current; + const inputModel = GlobalModel.inputModel; + const currentRef = this.textAreaRef.current; if (currentRef == null) { return; } if (inputModel.getCodeSelectSelectedIndex() == -1) { - let messageStr = currentRef.value; + const messageStr = currentRef.value; this.submitChatMessage(messageStr); currentRef.value = ""; } else { @@ -145,7 +153,7 @@ class AIChat extends React.Component<{}, {}> { } onExpandInputPressed() { - let currentRef = this.textAreaRef.current; + const currentRef = this.textAreaRef.current; if (currentRef == null) { return; } @@ -154,7 +162,7 @@ class AIChat extends React.Component<{}, {}> { } onArrowUpPressed(): boolean { - let currentRef = this.textAreaRef.current; + const currentRef = this.textAreaRef.current; if (currentRef == null) { return false; } @@ -168,8 +176,8 @@ class AIChat extends React.Component<{}, {}> { } onArrowDownPressed(): boolean { - let currentRef = this.textAreaRef.current; - let inputModel = GlobalModel.inputModel; + const currentRef = this.textAreaRef.current; + const inputModel = GlobalModel.inputModel; if (currentRef == null) { return false; } @@ -190,10 +198,10 @@ class AIChat extends React.Component<{}, {}> { } renderChatMessage(chatItem: OpenAICmdInfoChatMessageType): any { - let curKey = "chatmsg-" + this.chatListKeyCount; + const curKey = "chatmsg-" + this.chatListKeyCount; this.chatListKeyCount++; - let senderClassName = chatItem.isassistantresponse ? "chat-msg-assistant" : "chat-msg-user"; - let msgClassName = "chat-msg " + senderClassName; + const senderClassName = chatItem.isassistantresponse ? "chat-msg-assistant" : "chat-msg-user"; + const msgClassName = "chat-msg " + senderClassName; let innerHTML: React.JSX.Element = (
@@ -226,31 +234,10 @@ class AIChat extends React.Component<{}, {}> { ); } - renderChatWindow(): any { - let model = GlobalModel; - let inputModel = model.inputModel; - let chatMessageItems = inputModel.AICmdInfoChatItems.slice(); - let chitem: OpenAICmdInfoChatMessageType = null; - return ( -
- - {this.renderChatMessage(chitem)} - -
- ); - } - render() { - let model = GlobalModel; - let inputModel = model.inputModel; - - const termFontSize = 14; - const textAreaMaxLines = 4; - const textAreaLineHeight = termFontSize * 1.5; - const textAreaPadding = 2 * 0.5 * termFontSize; - let textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines + textAreaPadding; - let textAreaInnerHeight = this.textAreaNumLines.get() * textAreaLineHeight + textAreaPadding; - let renderKeybindings = mobx + const chatMessageItems = GlobalModel.inputModel.AICmdInfoChatItems.slice(); + const chitem: OpenAICmdInfoChatMessageType = null; + const renderKeybindings = mobx .computed(() => { return ( this.isFocused.get() || @@ -260,42 +247,39 @@ class AIChat extends React.Component<{}, {}> { ); }) .get(); + return ( -
+ GlobalModel.inputModel.closeAIAssistantChat(true)} + iconClass="fa-sharp fa-solid fa-sparkles" + > -
-
- -
-
Wave AI
-
-
inputModel.closeAIAssistantChat(true)} - > - -
+
+ + {this.renderChatMessage(chitem)} +
-
- {this.renderChatWindow()} - -
+
+ +
+ ); } } diff --git a/src/app/workspace/cmdinput/auxview.less b/src/app/workspace/cmdinput/auxview.less new file mode 100644 index 000000000..374d9940a --- /dev/null +++ b/src/app/workspace/cmdinput/auxview.less @@ -0,0 +1,51 @@ +// For the additonal views, we want less padding on the top and bottom than we want for the base-cmdinput div +.auxview { + padding: var(--termpad) calc(var(--termpad) * 2) var(--termpad) calc(var(--termpad) * 3 - 1px); + overflow: auto; + flex-shrink: 1; + width: 100%; + + --auxview-titlebar-height: 18px; + + .auxview-titlebar { + position: absolute; + z-index: 22; + top: 0; + left: 0; + background-color: var(--app-panel-bg-color); + color: var(--term-blue); + padding: 6px 10px 6px 10px; + display: flex; + flex-direction: row; + width: 100%; + border-bottom: 1px solid var(--app-border-color); + font: var(--base-font); + user-select: none; + cursor: default; + line-height: var(--auxview-titlebar-height); + + .title-string { + font-weight: bold; + } + + .close-button { + cursor: pointer; + i { + color: var(--app-icon-color); + + &:hover { + color: var(--app-icon-hover-color); + } + } + } + + div:not(.close-button, .flex-spacer) { + margin-right: 10px; + } + } + + .auxview-content { + display: flex; + padding-top: calc(var(--auxview-titlebar-height) + 6px); + } +} diff --git a/src/app/workspace/cmdinput/auxview.tsx b/src/app/workspace/cmdinput/auxview.tsx new file mode 100644 index 000000000..281d43de4 --- /dev/null +++ b/src/app/workspace/cmdinput/auxview.tsx @@ -0,0 +1,50 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import cn from "classnames"; +import { If } from "tsx-control-statements/components"; + +import "./auxview.less"; + +export class AuxiliaryCmdView extends React.Component< + { + title: string; + className?: string; + iconClass?: string; + titleBarContents?: React.ReactElement[]; + children?: React.ReactNode; + onClose?: React.MouseEventHandler; + }, + {} +> { + render() { + const { title, className, iconClass, titleBarContents, children, onClose } = this.props; + + return ( +
+
+ +
+ +
+
+
{title}
+ + {titleBarContents} + +
+ + +
+ +
+
+
+ +
{children}
+
+
+ ); + } +} diff --git a/src/app/workspace/cmdinput/cmdinput.less b/src/app/workspace/cmdinput/cmdinput.less index 973159f73..97d02351e 100644 --- a/src/app/workspace/cmdinput/cmdinput.less +++ b/src/app/workspace/cmdinput/cmdinput.less @@ -7,127 +7,17 @@ position: absolute; bottom: 0; width: 100%; - padding: calc(var(--termpad) * 2) calc(var(--termpad) * 2) calc(var(--termpad) * 2) calc(var(--termpad) * 3 - 1px); z-index: 100; border-top: 2px solid var(--app-border-color); background-color: var(--app-bg-color); - &.has-info, + // Apply a border between the base cmdinput and any views shown above it + // TODO: use a generic selector for this &.has-aichat, - &.has-history { - .cmdinput-actions { - display: none; - } - } - - .titlebar-spacer { - height: 31px; - } - - .cmdinput-conn { - position: absolute; - top: 0; - left: 0; - border-radius: 0 0 4px 0; - background-color: rgba(88, 193, 66, 0.3); - padding: 2px 10px 4px 10px; - font-size: calc(var(--termfontsize)); - cursor: pointer; - - i { - margin-left: 5px; - } - - &:hover { - background-color: rgba(88, 193, 66, 0.5); - } - } - - .cmdinput-actions { - position: absolute; - border-radius: 4px; - padding-left: 4px; - padding-right: 4px; - font-size: calc(var(--termfontsize) + 2px); - line-height: 1.2; - - // we want to align to 2nd line of meta. that's 2xPad + 1xLineHightSm - // height of actions is 1xLineHeight + 8px (2x2px padding on icons) - top: calc(var(--termpad)); - right: calc(var(--termpad) * 2); - - display: flex; - flex-direction: row; - align-items: center; - - .cmdinput-icon { - display: inline-flex; - color: var(--app-icon-hover-color); - opacity: 0.5; - - .centered-icon { - .positional-icon-visible; - } - - &.running-cmds { - .rotate { - fill: var(--app-warning-color); - } - } - - &.active { - opacity: 1; - } - - &:hover { - opacity: 1; - } - - padding: 4px 6px; - cursor: pointer; - } - - .line-icon + .line-icon:not(.line-icon-shrink-left) { - margin-left: 3px; - } - } - - .cmdinput-titlebar { - position: absolute; - z-index: 22; - top: 0; - left: 0; - background-color: var(--app-panel-bg-color); - color: var(--term-blue); - padding: 6px 10px 6px 10px; - display: flex; - flex-direction: row; - width: 100%; - overflow-x: auto; - border-bottom: 1px solid var(--app-border-color); - font: var(--base-font); - - .title-icon { - margin-right: 10px; - } - - .title-string { - font-weight: bold; - } - - .close-button { - cursor: pointer; - i { - color: var(--app-icon-color); - - &:hover { - color: var(--app-icon-hover-color); - } - } - } - - .spacer { - flex: 0 0 10px; + &.has-history, + &.has-info { + .base-cmdinput { + border-top: 1px solid var(--app-border-color); } } @@ -135,22 +25,12 @@ padding-top: var(--termpad); } - .focus-indicator { - height: 90%; - top: 5%; - left: 4px; - } - &.has-history, &.has-aichat { padding-top: var(--termpad); height: max(300px, 70%); } - &.has-remote { - max-height: max(300px, 70%); - } - .remote-status-warning { display: flex; flex-direction: row; @@ -164,394 +44,153 @@ } } - .input-minmax-control { - position: absolute; - top: 5px; - right: 5px; - color: var(--term-foreground); - - padding: 5px; - cursor: pointer; - } - .cmd-input-grow-spacer { flex-grow: 1; } - .base-cmdinput:not(:first-child) { - margin-top: var(--termpad); - } - - .cmd-input-context { - color: var(--term-bright-white); - white-space: nowrap; - display: flex; - justify-content: space-between; - align-items: center; - font-family: var(--termfontfamily); - font-size: var(--termfontsize); - line-height: var(--termlineheight); - margin-left: 2px; - } - - .cmd-input-filter { - opacity: 0.5; - &:hover { - opacity: 1; - } - - .avatar { - display: inline-block; - width: 1em; - height: 1em; - margin: 0 0.5em; - vertical-align: text-top; - fill: var(--term-foreground); - } - - .warning { - fill: var(--term-yellow); - } - } - - .cmd-input-field { + .base-cmdinput { position: relative; - padding-right: var(--termpad); - font-family: var(--termfontfamily); - font-weight: normal; - line-height: var(--termlineheight); - font-size: var(--termfontsize); + // Rather than apply the padding to the whole container, we will apply it to the inner contents directly. + // This is more fragile, but allows us to capture a larger target area for the individual components. + --padding-top: var(--termpad); + --padding-sides: calc(var(--termpad) * 2); - .cmd-hints { - position: absolute; - bottom: -14px; - right: 0px; - } - .control { - padding: 1em 2px; - } - - .textareainput-div { - position: relative; - - &.control { - padding: var(--termpad) 0; - } - - .shelltag { - position: absolute; - // 13px = 10px height + 3px padding. subtract termpad to account for textareainput-div padding (2px not sure?) - bottom: calc(-13px + var(--termpad)); - right: 0px; - font-size: 10px; - color: var(--app-text-secondary-color); - line-height: 1; - padding: 1px 8px 3px 8px; - background-color: var(--cmdinput-textarea-bg-color); - border-radius: 0 0 5px 5px; - } - } - - textarea { - color: var(--app-text-primary-color); - background-color: var(--cmdinput-textarea-bg-color); - padding: var(--termpad); - resize: none; - overflow: auto; - overflow-wrap: anywhere; - border-color: transparent; - border: none; + .cmd-input-context { + color: var(--term-bright-white); + white-space: nowrap; + display: flex; + justify-content: space-between; + align-items: center; font-family: var(--termfontfamily); + font-size: var(--termfontsize); + line-height: var(--termlineheight); + + // We don't want to pad the bottom or it will push the input field down. + padding: var(--padding-top) var(--padding-sides) 0 var(--padding-sides); + margin-left: 2px; + } + + .cmd-input-field { + position: relative; + font-family: var(--termfontfamily); + font-weight: normal; line-height: var(--termlineheight); font-size: var(--termfontsize); - border-radius: 4px 4px 0 4px; // 0 is for shelltag + border: none; + cursor: text; - &.display-disabled { - background-color: #444; + // We don't want to pad the top or it will push the input field down. + padding: 0 var(--padding-sides) var(--padding-top) var(--padding-sides); + + .cmd-hints { + position: absolute; + bottom: -14px; + right: 0px; + } + .control { + padding: 1em 2px; } - &:focus { + .textareainput-div { + position: relative; + + &.control { + padding: var(--termpad) 0; + } + + .shelltag { + position: absolute; + // 13px = 10px height + 3px padding. subtract termpad to account for textareainput-div padding (2px not sure?) + bottom: calc(-13px + var(--termpad)); + right: 0; + font-size: 10px; + color: var(--app-text-secondary-color); + line-height: 1; + user-select: none; + } + } + + textarea { + color: var(--app-text-primary-color); + background-color: var(--app-bg-color); + padding: var(--termpad) 0; + resize: none; + overflow: auto; + overflow-wrap: anywhere; + font-family: var(--termfontfamily); + line-height: var(--termlineheight); + font-size: var(--termfontsize); border: none; + box-shadow: none; + } + + input.history-input { + border: 0; + padding: 0; + height: 0; + } + + .cmd-quick-context .button { + background-color: var(--app-bg-color) !important; + color: var(--app-text-color); + } + + &.inputmode-global .cmd-quick-context .button { + color: var(--app-bg-color); + background-color: var(--cmdinput-button-bg-color) !important; + } + + &.inputmode-comment .cmd-quick-context .button { + color: var(--app-bg-color); + background-color: var(--cmdinput-comment-button-bg-color) !important; } } - input.history-input { - border: 0; - padding: 0; - height: 0; - } - - .cmd-quick-context .button { - background-color: #000 !important; - color: var(--app-text-color); - } - - &.inputmode-global .cmd-quick-context .button { - color: var(--app-bg-color); - background-color: var(--cmdinput-button-bg-color) !important; - } - - &.inputmode-comment .cmd-quick-context .button { - color: var(--app-bg-color); - background-color: var(--cmdinput-comment-button-bg-color) !important; - } - - .cmd-exec { + .cmdinput-actions { position: absolute; - right: 0; - .icon { - vertical-align: bottom; - margin-right: 1em; - width: 2.5em; - height: 2.5em; - cursor: pointer; - border-radius: 50%; - fill: var(--app-accent-color); - padding: 0.25em; - } - .icon.disabled { - fill: var(--cmdinput-disabled-icon-color); - cursor: default; - } - .cmd-btn { - display: inline-block; - margin-right: 0; - padding: 0.2em 0.7rem; - border-radius: 4px; - vertical-align: super; + font-size: calc(var(--termfontsize) + 2px); + line-height: 1.2; - .hint-elem { - cursor: pointer; - opacity: 0.5; + // Align to the same bounds as the input field + top: var(--padding-top); + right: var(--padding-sides); - &:hover { - opacity: 1; + display: flex; + flex-direction: row; + align-items: center; + + .cmdinput-icon { + display: inline-flex; + color: var(--app-icon-hover-color); + opacity: 0.5; + + .centered-icon { + .positional-icon-visible; + } + + &.running-cmds { + .rotate { + fill: var(--app-warning-color); } } - } - } - } - .cmd-aichat { - display: flex; - justify-content: flex-end; - flex-flow: column nowrap; - margin-bottom: 10px; - flex-shrink: 1; - overflow-y: auto; - - .chat-window { - overflow-y: auto; - margin-bottom: 5px; - flex-shrink: 1; - flex-direction: column-reverse; - } - - .chat-textarea { - color: var(--app-text-primary-color); - background-color: var(--cmdinput-textarea-bg); - padding: 0.5em; - resize: none; - overflow: auto; - overflow-wrap: anywhere; - border-color: transparent; - border: none; - font-family: var(--termfontfamily); - font-weight: normal; - flex-shrink: 0; - flex-grow: 1; - border-radius: 4px; - - &:focus { - border: none; - outline: none; - } - } - - .chat-msg { - margin-top: calc(var(--termpad) * 2); - margin-bottom: calc(var(--termpad) * 2); - - .chat-msg-header { - display: flex; - margin-bottom: 2px; - - i { - margin-right: 0.5em; + &.active { + opacity: 1; } - .chat-username { - font-weight: bold; - margin-right: 5px; - } - } - } - - .chat-msg-assistant { - color: var(--app-text-color); - } - - .chat-msg-user { - .msg-text { - font-family: var(--markdown-font); - font-size: 14px; - white-space: pre-wrap; - } - } - - .chat-msg-error { - color: var(--cmdinput-text-error); - font-family: var(--markdown-font); - font-size: 14px; - } - - .grow-spacer { - flex: 1 0 10px; - } - } - - .cmd-history { - color: var(--app-text-color); - font-family: var(--termfontfamily); - font-size: var(--termfontsize); - overflow: auto; - flex-shrink: 1; - - .history-title { - div:first-child { - margin-left: var(--termpad); - } - - .history-opt { - white-space: nowrap; - } - - .history-clickable-opt { - cursor: pointer; - &:hover { - color: var(--app-text-primary-color); + opacity: 1; } - } - .history-clickable-opt { - white-space: nowrap; + // This aligns the icons with the prompt field. + // We don't need right padding because the whole input field is already padded. + padding: 2px 0 0 12px; cursor: pointer; } - } - .history-items { - color: var(--app-text-color); - - padding-bottom: 6px; - - .history-line { - white-space: pre; + .line-icon + .line-icon:not(.line-icon-shrink-left) { + margin-left: 3px; } - - .history-item.history-haderror { - color: var(--cmdinput-history-item-error-color); - } - - .history-line:first-child { - margin-left: 0 !important; - } - - .history-item { - padding-left: 5px; - cursor: pointer; - - &:hover { - background-color: #222; - } - } - - .history-item.is-selected { - font-weight: bold; - color: var(--app-text-primary-color); - background-color: #444; - } - - .history-item.is-selected.history-haderror { - color: var(--cmdinput-history-item-selected-error-color); - } - } - } - - .cmd-input-info { - flex-shrink: 1; - overflow-y: auto; - margin-bottom: var(--termpad); - font-family: var(--termfontfamily); - font-size: var(--termfontsize); - line-height: var(--termlineheight); - - .info-title { - position: absolute; - z-index: 102; - top: 5px; - left: 0; - font-size: calc(var(--termfontsize) + 2px); - background-color: var(--app-bg-color); - color: var(--term-blue); - padding-bottom: 4px; - padding-left: calc(var(--termpad) * 2); - display: flex; - flex-direction: row; - width: 100%; - overflow-x: auto; - border-bottom: 1px solid var(--app-border-color); - } - - .info-title + .info-msg, - .info-title + .info-lines, - .info-title + .info-comps, - .info-title + .info-error { - margin-top: 26px; - } - - .info-msg { - color: var(--term-blue); - padding-bottom: 2px; - - a { - color: var(--term-blue); - } - } - - .info-lines { - color: var(--app-text-color); - white-space: pre; - padding-bottom: 6px; - } - - .info-comps { - display: flex; - flex-direction: row; - flex-wrap: wrap; - padding-bottom: 5px; - font-weight: normal; - font-family: var(--termfontfamily); - font-size: var(--termfontsize); - - .info-comp { - min-width: 200px; - color: var(--term-foreground); - margin-right: 10px; - - &.has-space { - text-decoration: underline dotted #777; - } - } - - .metacmd-comp { - color: var(--term-bright-green); - } - } - - .info-error { - color: var(--cmdinput-text-error-color); - padding-bottom: 2px; } } } diff --git a/src/app/workspace/cmdinput/cmdinput.tsx b/src/app/workspace/cmdinput/cmdinput.tsx index 5588206ed..6410f56a4 100644 --- a/src/app/workspace/cmdinput/cmdinput.tsx +++ b/src/app/workspace/cmdinput/cmdinput.tsx @@ -61,12 +61,17 @@ class CmdInput extends React.Component<{}, {}> { } @boundMethod - cmdInputClick(e: any): void { + baseCmdInputClick(e: React.MouseEvent): void { if (this.promptRef.current != null) { if (this.promptRef.current.contains(e.target)) { return; } } + if ((e.target as HTMLDivElement).classList.contains("cmd-input-context")) { + e.stopPropagation(); + return; + } + GlobalModel.inputModel.setHistoryFocus(false); GlobalModel.inputModel.giveFocus(); } @@ -74,8 +79,12 @@ class CmdInput extends React.Component<{}, {}> { clickAIAction(e: any): void { e.preventDefault(); e.stopPropagation(); - let inputModel = GlobalModel.inputModel; - inputModel.openAIAssistantChat(); + const inputModel = GlobalModel.inputModel; + if (inputModel.aIChatShow.get()) { + inputModel.closeAIAssistantChat(true); + } else { + inputModel.openAIAssistantChat(); + } } @boundMethod @@ -160,6 +169,9 @@ class CmdInput extends React.Component<{}, {}> { } let shellInitMsg: string = null; let hidePrompt = false; + + const openView = inputModel.getOpenView(); + const hasOpenView = openView ? `has-${openView}` : null; if (ri == null) { let shellStr = "shell"; if (!util.isBlank(remote?.defaultshelltype)) { @@ -172,42 +184,7 @@ class CmdInput extends React.Component<{}, {}> { } } return ( -
-
- 0}> -
this.toggleFilter(screen)} - > - {numRunningLines}{" "} - - - -
-
-
- -
-
- -
-
+
@@ -252,7 +229,38 @@ class CmdInput extends React.Component<{}, {}> {
-
+
+
+ 0}> +
this.toggleFilter(screen)} + > + {numRunningLines}{" "} + + + +
+
+
+ +
+
+ +
+
diff --git a/src/app/workspace/cmdinput/historyinfo.less b/src/app/workspace/cmdinput/historyinfo.less new file mode 100644 index 000000000..882520458 --- /dev/null +++ b/src/app/workspace/cmdinput/historyinfo.less @@ -0,0 +1,60 @@ +.cmd-history { + color: var(--app-text-color); + font-family: var(--termfontfamily); + font-size: var(--termfontsize); + + .auxview-titlebar { + .history-opt { + white-space: nowrap; + } + + .history-clickable-opt { + cursor: pointer; + white-space: nowrap; + cursor: pointer; + + &:hover { + color: var(--app-text-primary-color); + } + } + } + + .history-items { + color: var(--app-text-color); + width: 100%; + + .history-item { + cursor: pointer; + border-radius: 5px; + + .history-line { + white-space: pre; + + &:first-child { + margin-left: 0 !important; + } + } + + &:hover { + background-color: var(--table-tr-hover-bg-color); + } + + &.history-haderror { + color: var(--cmdinput-history-item-error-color); + } + + &.is-selected { + font-weight: bold; + color: var(--app-text-primary-color); + background-color: var(--table-tr-selected-bg-color); + &:hover { + background-color: var(--table-tr-selected-hover-bg-color); + } + } + + &.is-selected.history-haderror { + color: var(--cmdinput-history-item-selected-error-color); + } + } + } +} diff --git a/src/app/workspace/cmdinput/historyinfo.tsx b/src/app/workspace/cmdinput/historyinfo.tsx index 9481f3228..e188139b8 100644 --- a/src/app/workspace/cmdinput/historyinfo.tsx +++ b/src/app/workspace/cmdinput/historyinfo.tsx @@ -13,6 +13,9 @@ import localizedFormat from "dayjs/plugin/localizedFormat"; import { GlobalModel } from "@/models"; import { isBlank } from "@/util/util"; +import "./historyinfo.less"; +import { AuxiliaryCmdView } from "./auxview"; + dayjs.extend(localizedFormat); const TDots = "â‹®"; @@ -43,7 +46,7 @@ class HItem extends React.Component< if (hitem.remote == null || isBlank(hitem.remote.remoteid)) { return sprintf("%-15s ", ""); } - let r = GlobalModel.getRemote(hitem.remote.remoteid); + const r = GlobalModel.getRemote(hitem.remote.remoteid); if (r == null) { return sprintf("%-15s ", "???"); } @@ -71,15 +74,15 @@ class HItem extends React.Component< if (!opts.limitRemote) { remoteStr = this.renderRemote(hitem); } - let selectedStr = isSelected ? "*" : " "; - let lineNumStr = hitem.linenum > 0 ? "(" + hitem.linenum + ")" : ""; + const selectedStr = isSelected ? "*" : " "; + const lineNumStr = hitem.linenum > 0 ? "(" + hitem.linenum + ")" : ""; if (isBlank(opts.queryType) || opts.queryType == "screen") { return selectedStr + sprintf("%7s", lineNumStr) + " " + remoteStr; } if (opts.queryType == "session") { let screenStr = ""; if (!isBlank(hitem.screenid)) { - let scrName = scrNames[hitem.screenid]; + const scrName = scrNames[hitem.screenid]; if (scrName != null) { screenStr = "[" + truncateWithTDots(scrName, 15) + "]"; } @@ -89,19 +92,18 @@ class HItem extends React.Component< if (opts.queryType == "global") { let sessionStr = ""; if (!isBlank(hitem.sessionid)) { - let sessionName = snames[hitem.sessionid]; + const sessionName = snames[hitem.sessionid]; if (sessionName != null) { sessionStr = "#" + truncateWithTDots(sessionName, 15); } } let screenStr = ""; if (!isBlank(hitem.screenid)) { - let scrName = scrNames[hitem.screenid]; + const scrName = scrNames[hitem.screenid]; if (scrName != null) { screenStr = "[" + truncateWithTDots(scrName, 13) + "]"; } } - let ssStr = sessionStr + screenStr; return ( selectedStr + sprintf("%15s ", sessionStr) + @@ -116,12 +118,12 @@ class HItem extends React.Component< } render() { - let { hitem, isSelected, opts, snames, scrNames } = this.props; - let lines = hitem.cmdstr.split("\n"); + const { hitem, isSelected, opts, snames, scrNames } = this.props; + const lines = hitem.cmdstr.split("\n"); let line: string = ""; let idx = 0; - let infoText = this.renderHInfoText(hitem, opts, isSelected, snames, scrNames); - let infoTextSpacer = sprintf("%" + infoText.length + "s", ""); + const infoText = this.renderHInfoText(hitem, opts, isSelected, snames, scrNames); + const infoTextSpacer = sprintf("%" + infoText.length + "s", ""); return (
{ containingText: mobx.IObservableValue = mobx.observable.box(""); componentDidMount() { - let inputModel = GlobalModel.inputModel; + const inputModel = GlobalModel.inputModel; let hitem = inputModel.getHistorySelectedItem(); if (hitem == null) { hitem = inputModel.getFirstHistoryItem(); @@ -170,15 +172,15 @@ class HistoryInfo extends React.Component<{}, {}> { @boundMethod handleItemClick(hitem: HistoryItem) { - let inputModel = GlobalModel.inputModel; - let selItem = inputModel.getHistorySelectedItem(); + const inputModel = GlobalModel.inputModel; + const selItem = inputModel.getHistorySelectedItem(); + inputModel.setHistoryFocus(true); if (this.lastClickHNum == hitem.historynum && selItem != null && selItem.historynum == hitem.historynum) { inputModel.grabSelectedHistoryItem(); return; } - inputModel.giveFocus(); inputModel.setHistorySelectionNum(hitem.historynum); - let now = Date.now(); + const now = Date.now(); this.lastClickHNum = hitem.historynum; this.lastClickTs = now; setTimeout(() => { @@ -191,24 +193,41 @@ class HistoryInfo extends React.Component<{}, {}> { @boundMethod handleClickType() { - let inputModel = GlobalModel.inputModel; + const inputModel = GlobalModel.inputModel; + inputModel.setHistoryFocus(true); inputModel.toggleHistoryType(); } @boundMethod handleClickRemote() { - let inputModel = GlobalModel.inputModel; + const inputModel = GlobalModel.inputModel; + inputModel.setHistoryFocus(true); inputModel.toggleRemoteType(); } + @boundMethod + getTitleBarContents(): React.ReactElement[] { + const opts = GlobalModel.inputModel.historyQueryOpts.get(); + + return [ +
+ [for {opts.queryType} ⌘S] +
, +
+ [containing '{opts.queryStr}'] +
, +
+ [{opts.limitRemote ? "this" : "any"} remote ⌘R] +
, + ]; + } + render() { - let inputModel = GlobalModel.inputModel; - let idx: number = 0; - let selItem = inputModel.getHistorySelectedItem(); - let hitems = inputModel.getFilteredHistoryItems(); - hitems = hitems.slice().reverse(); + const inputModel = GlobalModel.inputModel; + const selItem = inputModel.getHistorySelectedItem(); + const hitems = inputModel.getFilteredHistoryItems().slice().reverse(); + const opts = inputModel.historyQueryOpts.get(); let hitem: HistoryItem = null; - let opts = inputModel.historyQueryOpts.get(); let snames: Record = {}; let scrNames: Record = {}; if (opts.queryType == "global") { @@ -218,29 +237,13 @@ class HistoryInfo extends React.Component<{}, {}> { scrNames = GlobalModel.getScreenNames(); } return ( -
-
-
- -
-
History
-
-
- [for {opts.queryType} ⌘S] -
-
-
- [containing '{opts.queryStr}'] -
-
-
- [{opts.limitRemote ? "this" : "any"} remote ⌘R] -
-
-
- -
-
+
{ { "show-sessions": opts.queryType == "global" } )} > -
[no history] 0}> @@ -264,7 +266,7 @@ class HistoryInfo extends React.Component<{}, {}> {
-
+
); } } diff --git a/src/app/workspace/cmdinput/infomsg.less b/src/app/workspace/cmdinput/infomsg.less new file mode 100644 index 000000000..4e08f5b9e --- /dev/null +++ b/src/app/workspace/cmdinput/infomsg.less @@ -0,0 +1,49 @@ +.cmd-input-info { + font-family: var(--termfontfamily); + font-size: var(--termfontsize); + line-height: var(--termlineheight); + + .info-msg { + color: var(--term-blue); + padding-bottom: 2px; + + a { + color: var(--term-blue); + } + } + + .info-lines { + color: var(--app-text-color); + white-space: pre; + padding-bottom: 6px; + } + + .info-comps { + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding-bottom: 5px; + font-weight: normal; + font-family: var(--termfontfamily); + font-size: var(--termfontsize); + + .info-comp { + min-width: 200px; + color: var(--term-foreground); + margin-right: 10px; + + &.has-space { + text-decoration: underline dotted #777; + } + } + + .metacmd-comp { + color: var(--term-bright-green); + } + } + + .info-error { + color: var(--cmdinput-text-error-color); + padding-bottom: 2px; + } +} diff --git a/src/app/workspace/cmdinput/infomsg.tsx b/src/app/workspace/cmdinput/infomsg.tsx index 95b217a71..6c21864e9 100644 --- a/src/app/workspace/cmdinput/infomsg.tsx +++ b/src/app/workspace/cmdinput/infomsg.tsx @@ -9,6 +9,9 @@ import dayjs from "dayjs"; import localizedFormat from "dayjs/plugin/localizedFormat"; import { GlobalModel } from "@/models"; import * as appconst from "@/app/appconst"; +import { AuxiliaryCmdView } from "./auxview"; + +import "./infomsg.less"; dayjs.extend(localizedFormat); @@ -40,29 +43,22 @@ class InfoMsg extends React.Component<{}, {}> { } render() { - let model = GlobalModel; - let inputModel = model.inputModel; - let infoMsg = inputModel.infoMsg.get(); - let infoShow = inputModel.infoShow.get(); + const inputModel = GlobalModel.inputModel; + const infoMsg: InfoType = inputModel.infoMsg.get(); + const infoShow: boolean = inputModel.infoShow.get(); let line: string = null; let istr: string = null; let idx: number = 0; let titleStr = null; - let remoteEditKey = "inforemoteedit"; if (infoMsg != null) { titleStr = infoMsg.infotitle; } - let activeScreen = model.getActiveScreen(); if (!infoShow) { return null; } + return ( -
- -
- {titleStr} -
-
+
@@ -108,7 +104,7 @@ class InfoMsg extends React.Component<{}, {}> {
to reset, run: /reset:cwd
-
+
); } } diff --git a/src/app/workspace/cmdinput/textareainput.tsx b/src/app/workspace/cmdinput/textareainput.tsx index 4b1dd8203..db1575d6f 100644 --- a/src/app/workspace/cmdinput/textareainput.tsx +++ b/src/app/workspace/cmdinput/textareainput.tsx @@ -287,7 +287,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () setFocus(): void { const inputModel = GlobalModel.inputModel; - if (inputModel.historyShow.get()) { + if (inputModel.historyFocus.get()) { this.historyInputRef.current.focus(); } else { this.mainInputRef.current.focus(); @@ -551,7 +551,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () @boundMethod handleMainFocus(e: any) { const inputModel = GlobalModel.inputModel; - if (inputModel.historyShow.get()) { + if (inputModel.historyFocus.get()) { e.preventDefault(); if (this.historyInputRef.current != null) { this.historyInputRef.current.focus(); @@ -578,7 +578,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () @boundMethod handleHistoryFocus(e: any) { const inputModel = GlobalModel.inputModel; - if (!inputModel.historyShow.get()) { + if (!inputModel.historyFocus.get()) { e.preventDefault(); if (this.mainInputRef.current != null) { this.mainInputRef.current.focus(); @@ -616,7 +616,9 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () if (numLines > 1 || longLine || inputModel.inputExpanded.get()) { displayLines = 5; } - const disabled = inputModel.historyShow.get(); + + // TODO: invert logic here. We should track focus on the main textarea and assume aux view is focused if not. + const disabled = inputModel.historyFocus.get(); if (disabled) { displayLines = 1; } @@ -661,7 +663,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () - +
{shellType}
= mobx.observable.box(false); + historyFocus: OV = mobx.observable.box(false); infoShow: OV = mobx.observable.box(false); aIChatShow: OV = mobx.observable.box(false); cmdInputHeight: OV = mobx.observable.box(0); @@ -92,7 +93,7 @@ class InputModel { } toggleHistoryType(): void { - let opts = mobx.toJS(this.historyQueryOpts.get()); + const opts = mobx.toJS(this.historyQueryOpts.get()); let htype = opts.queryType; if (htype == "screen") { htype = "session"; @@ -105,7 +106,7 @@ class InputModel { } toggleRemoteType(): void { - let opts = mobx.toJS(this.historyQueryOpts.get()); + const opts = mobx.toJS(this.historyQueryOpts.get()); if (opts.limitRemote) { opts.limitRemote = false; opts.limitRemoteInstance = false; @@ -139,21 +140,21 @@ class InputModel { } _focusCmdInput(): void { - let elem = document.getElementById("main-cmd-input"); + const elem = document.getElementById("main-cmd-input"); if (elem != null) { elem.focus(); } } _focusHistoryInput(): void { - let elem: HTMLElement = document.querySelector(".cmd-input input.history-input"); + const elem: HTMLElement = document.querySelector(".cmd-input input.history-input"); if (elem != null) { elem.focus(); } } giveFocus(): void { - if (this.historyShow.get()) { + if (this.historyFocus.get()) { this._focusHistoryInput(); } else { this._focusCmdInput(); @@ -165,7 +166,7 @@ class InputModel { this.physicalInputFocused.set(isFocused); })(); if (isFocused) { - let screen = this.globalModel.getActiveScreen(); + const screen = this.globalModel.getActiveScreen(); if (screen != null) { if (screen.focusType.get() != "input") { GlobalCommandRunner.screenSetFocus("input"); @@ -175,11 +176,11 @@ class InputModel { } hasFocus(): boolean { - let mainInputElem = document.getElementById("main-cmd-input"); + const mainInputElem = document.getElementById("main-cmd-input"); if (document.activeElement == mainInputElem) { return true; } - let historyInputElem = document.querySelector(".cmd-input input.history-input"); + const historyInputElem = document.querySelector(".cmd-input input.history-input"); if (document.activeElement == historyInputElem) { return true; } @@ -190,6 +191,19 @@ class InputModel { return false; } + getOpenView(): string { + if (this.historyShow.get()) { + return "history"; + } + if (this.aIChatShow.get()) { + return "aichat"; + } + if (this.infoShow.get()) { + return "info"; + } + return null; + } + setHistoryType(htype: HistoryTypeStrs): void { if (this.historyQueryOpts.get().queryType == htype) { return; @@ -201,20 +215,19 @@ class InputModel { if (oldItem == null) { return 0; } - let newItems = this.getFilteredHistoryItems(); + const newItems = this.getFilteredHistoryItems(); if (newItems.length == 0) { return 0; } let bestIdx = 0; - for (let i = 0; i < newItems.length; i++) { + for (const [i, item] of newItems.entries()) { // still start at i=0 to catch the historynum equality case - let item = newItems[i]; if (item.historynum == oldItem.historynum) { bestIdx = i; break; } - let bestTsDiff = Math.abs(item.ts - newItems[bestIdx].ts); - let curTsDiff = Math.abs(item.ts - oldItem.ts); + const bestTsDiff = Math.abs(item.ts - newItems[bestIdx].ts); + const curTsDiff = Math.abs(item.ts - oldItem.ts); if (curTsDiff < bestTsDiff) { bestIdx = i; } @@ -224,9 +237,9 @@ class InputModel { setHistoryQueryOpts(opts: HistoryQueryOpts): void { mobx.action(() => { - let oldItem = this.getHistorySelectedItem(); + const oldItem = this.getHistorySelectedItem(); this.historyQueryOpts.set(opts); - let bestIndex = this.findBestNewIndex(oldItem); + const bestIndex = this.findBestNewIndex(oldItem); setTimeout(() => this.setHistoryIndex(bestIndex, true), 10); })(); } @@ -253,17 +266,28 @@ class InputModel { this.setInputPopUpType("none"); } this.historyShow.set(show); + this.historyFocus.set(show); if (this.hasFocus()) { this.giveFocus(); } })(); } + setHistoryFocus(focus: boolean): void { + if (this.historyFocus.get() == focus) { + return; + } + mobx.action(() => { + this.historyFocus.set(focus); + this.giveFocus(); + })(); + } + isHistoryLoaded(): boolean { if (this.historyLoading.get()) { return false; } - let hitems = this.historyItems.get(); + const hitems = this.historyItems.get(); return hitems != null; } @@ -294,6 +318,7 @@ class InputModel { if (!this.historyShow.get()) { mobx.action(() => { this.setHistoryShow(true); + this.aIChatShow.set(false); this.infoShow.set(false); this.dropModHistory(true); this.giveFocus(); @@ -311,11 +336,11 @@ class InputModel { } getHistorySelectedItem(): HistoryItem { - let hidx = this.historyIndex.get(); + const hidx = this.historyIndex.get(); if (hidx == 0) { return null; } - let hitems = this.getFilteredHistoryItems(); + const hitems = this.getFilteredHistoryItems(); if (hidx > hitems.length) { return null; } @@ -323,7 +348,7 @@ class InputModel { } getFirstHistoryItem(): HistoryItem { - let hitems = this.getFilteredHistoryItems(); + const hitems = this.getFilteredHistoryItems(); if (hitems.length == 0) { return null; } @@ -331,9 +356,9 @@ class InputModel { } setHistorySelectionNum(hnum: string): void { - let hitems = this.getFilteredHistoryItems(); - for (let i = 0; i < hitems.length; i++) { - if (hitems[i].historynum == hnum) { + const hitems = this.getFilteredHistoryItems(); + for (const [i, hitem] of hitems.entries()) { + if (hitem.historynum == hnum) { this.setHistoryIndex(i + 1); return; } @@ -342,8 +367,8 @@ class InputModel { setHistoryInfo(hinfo: HistoryInfoType): void { mobx.action(() => { - let oldItem = this.getHistorySelectedItem(); - let hitems: HistoryItem[] = hinfo.items ?? []; + const oldItem = this.getHistorySelectedItem(); + const hitems: HistoryItem[] = hinfo.items ?? []; this.historyItems.set(hitems); this.historyLoading.set(false); this.historyQueryOpts.get().queryType = hinfo.historytype; @@ -352,7 +377,7 @@ class InputModel { this.historyQueryOpts.get().limitRemoteInstance = false; } if (this.historyAfterLoadIndex == -1) { - let bestIndex = this.findBestNewIndex(oldItem); + const bestIndex = this.findBestNewIndex(oldItem); setTimeout(() => this.setHistoryIndex(bestIndex, true), 100); } else if (this.historyAfterLoadIndex) { if (hitems.length >= this.historyAfterLoadIndex) { @@ -371,10 +396,10 @@ class InputModel { } _getFilteredHistoryItems(): HistoryItem[] { - let hitems: HistoryItem[] = this.historyItems.get() ?? []; - let rtn: HistoryItem[] = []; - let opts = mobx.toJS(this.historyQueryOpts.get()); - let ctx = this.globalModel.getUIContext(); + const hitems: HistoryItem[] = this.historyItems.get() ?? []; + const rtn: HistoryItem[] = []; + const opts: HistoryQueryOpts = mobx.toJS(this.historyQueryOpts.get()); + const ctx = this.globalModel.getUIContext(); let curRemote: RemotePtrType = ctx.remote; if (curRemote == null) { curRemote = { ownerid: "", name: "", remoteid: "" }; @@ -411,7 +436,7 @@ class InputModel { if (isBlank(hitem.cmdstr)) { continue; } - let idx = hitem.cmdstr.indexOf(opts.queryStr); + const idx = hitem.cmdstr.indexOf(opts.queryStr); if (idx == -1) { continue; } @@ -423,24 +448,24 @@ class InputModel { } scrollHistoryItemIntoView(hnum: string): void { - let elem: HTMLElement = document.querySelector(".cmd-history .hnum-" + hnum); + const elem: HTMLElement = document.querySelector(".cmd-history .hnum-" + hnum); if (elem == null) { return; } - let historyDiv = elem.closest(".cmd-history"); + const historyDiv = elem.closest(".cmd-history"); if (historyDiv == null) { return; } - let buffer = 15; + const buffer = 15; let titleHeight = 24; - let titleDiv: HTMLElement = document.querySelector(".cmd-history .history-title"); + const titleDiv: HTMLElement = document.querySelector(".cmd-history .history-title"); if (titleDiv != null) { titleHeight = titleDiv.offsetHeight + 2; } - let elemOffset = elem.offsetTop; - let elemHeight = elem.clientHeight; - let topPos = historyDiv.scrollTop; - let endPos = topPos + historyDiv.clientHeight; + const elemOffset = elem.offsetTop; + const elemHeight = elem.clientHeight; + const topPos = historyDiv.scrollTop; + const endPos = topPos + historyDiv.clientHeight; if (elemOffset + elemHeight + buffer > endPos) { if (elemHeight + buffer > historyDiv.clientHeight - titleHeight) { historyDiv.scrollTop = elemOffset - titleHeight; @@ -459,7 +484,7 @@ class InputModel { } grabSelectedHistoryItem(): void { - let hitem = this.getHistorySelectedItem(); + const hitem = this.getHistorySelectedItem(); if (hitem == null) { this.resetHistory(); return; @@ -498,9 +523,8 @@ class InputModel { if (!this.isHistoryLoaded()) { return; } - let hitems = this.getFilteredHistoryItems(); - let idx = this.historyIndex.get(); - idx += amt; + const hitems = this.getFilteredHistoryItems(); + let idx = this.historyIndex.get() + amt; if (idx < 0) { idx = 0; } @@ -540,7 +564,6 @@ class InputModel { } setAIChatFocus() { - console.log("setting ai chat focus"); if (this.aiChatTextAreaRef?.current != null) { this.aiChatTextAreaRef.current.focus(); } @@ -551,9 +574,8 @@ class InputModel { this.codeSelectSelectedIndex.get() >= 0 && this.codeSelectSelectedIndex.get() < this.codeSelectBlockRefArray.length ) { - let curBlockRef = this.codeSelectBlockRefArray[this.codeSelectSelectedIndex.get()]; - let codeText = curBlockRef.current.innerText; - codeText = codeText.replace(/\n$/, ""); // remove trailing newline + const curBlockRef = this.codeSelectBlockRefArray[this.codeSelectSelectedIndex.get()]; + const codeText = curBlockRef.current.innerText.replace(/\n$/, ""); // remove trailing newline this.setCurLine(codeText); this.giveFocus(); } @@ -574,23 +596,21 @@ class InputModel { mobx.action(() => { if (blockIndex >= 0 && blockIndex < this.codeSelectBlockRefArray.length) { this.codeSelectSelectedIndex.set(blockIndex); - let currentRef = this.codeSelectBlockRefArray[blockIndex].current; - if (currentRef != null) { - if (this.aiChatWindowRef?.current != null) { - let chatWindowTop = this.aiChatWindowRef.current.scrollTop; - let chatWindowBottom = chatWindowTop + this.aiChatWindowRef.current.clientHeight - 100; - let elemTop = currentRef.offsetTop; - let elemBottom = elemTop - currentRef.offsetHeight; - let elementIsInView = elemBottom < chatWindowBottom && elemTop > chatWindowTop; - if (!elementIsInView) { - this.aiChatWindowRef.current.scrollTop = - elemBottom - this.aiChatWindowRef.current.clientHeight / 3; - } + const currentRef = this.codeSelectBlockRefArray[blockIndex].current; + if (currentRef != null && this.aiChatWindowRef?.current != null) { + const chatWindowTop = this.aiChatWindowRef.current.scrollTop; + const chatWindowBottom = chatWindowTop + this.aiChatWindowRef.current.clientHeight - 100; + const elemTop = currentRef.offsetTop; + let elemBottom = elemTop - currentRef.offsetHeight; + const elementIsInView = elemBottom < chatWindowBottom && elemTop > chatWindowTop; + if (!elementIsInView) { + this.aiChatWindowRef.current.scrollTop = + elemBottom - this.aiChatWindowRef.current.clientHeight / 3; } } - this.codeSelectBlockRefArray = []; - this.setAIChatFocus(); } + this.codeSelectBlockRefArray = []; + this.setAIChatFocus(); })(); } @@ -603,7 +623,7 @@ class InputModel { } else if (this.codeSelectSelectedIndex.get() == this.codeSelectBottom) { return; } - let incBlockIndex = this.codeSelectSelectedIndex.get() + 1; + const incBlockIndex = this.codeSelectSelectedIndex.get() + 1; if (this.codeSelectSelectedIndex.get() == this.codeSelectBlockRefArray.length - 1) { this.codeSelectDeselectAll(); if (this.aiChatWindowRef?.current != null) { @@ -627,7 +647,7 @@ class InputModel { } else if (this.codeSelectSelectedIndex.get() == this.codeSelectTop) { return; } - let decBlockIndex = this.codeSelectSelectedIndex.get() - 1; + const decBlockIndex = this.codeSelectSelectedIndex.get() - 1; if (decBlockIndex < 0) { this.codeSelectDeselectAll(this.codeSelectTop); if (this.aiChatWindowRef?.current != null) { @@ -666,6 +686,8 @@ class InputModel { mobx.action(() => { this.setInputPopUpType("aichat"); this.aIChatShow.set(true); + this.historyShow.set(false); + this.infoShow.set(false); this.setAIChatFocus(); })(); } @@ -686,7 +708,7 @@ class InputModel { } clearAIAssistantChat(): void { - let prtn = this.globalModel.submitChatInfoCommand("", "", true); + const prtn = this.globalModel.submitChatInfoCommand("", "", true); prtn.then((rtn) => { if (!rtn.success) { console.log("submit chat command error: " + rtn.error); @@ -700,11 +722,11 @@ class InputModel { if (!this.infoShow.get()) { return false; } - let info = this.infoMsg.get(); + const info = this.infoMsg.get(); if (info == null) { return false; } - let div = document.querySelector(".cmd-input-info"); + const div = document.querySelector(".cmd-input-info"); if (div == null) { return false; } @@ -736,7 +758,7 @@ class InputModel { this.setHistoryShow(false); return; } - let isShowing = this.infoShow.get(); + const isShowing = this.infoShow.get(); if (isShowing) { this.infoShow.set(false); } else { @@ -750,7 +772,7 @@ class InputModel { @boundMethod uiSubmitCommand(): void { mobx.action(() => { - let commandStr = this.getCurLine(); + const commandStr = this.getCurLine(); if (commandStr.trim() == "") { return; } @@ -771,7 +793,7 @@ class InputModel { } setCurLine(val: string): void { - let hidx = this.historyIndex.get(); + const hidx = this.historyIndex.get(); mobx.action(() => { if (this.modHistory.length <= hidx) { this.modHistory.length = hidx + 1; @@ -803,15 +825,15 @@ class InputModel { } getCurLine(): string { - let hidx = this.historyIndex.get(); + const hidx = this.historyIndex.get(); if (hidx < this.modHistory.length && this.modHistory[hidx] != null) { return this.modHistory[hidx]; } - let hitems = this.getFilteredHistoryItems(); + const hitems = this.getFilteredHistoryItems(); if (hidx == 0 || hitems == null || hidx > hitems.length) { return ""; } - let hitem = hitems[hidx - 1]; + const hitem = hitems[hidx - 1]; if (hitem == null) { return ""; } diff --git a/src/util/textmeasure.ts b/src/util/textmeasure.ts index 0d7c24c2e..5254dc2ff 100644 --- a/src/util/textmeasure.ts +++ b/src/util/textmeasure.ts @@ -23,7 +23,7 @@ function getMonoFontSize(fontSize: number): MonoFontSize { if (MonoFontSizes[fontSize] != null) { return MonoFontSizes[fontSize]; } - let size = measureText("W", { pre: true, mono: true, fontSize: fontSize }); + const size = measureText("W", { pre: true, mono: true, fontSize: fontSize }); if (size.height != 0 && size.width != 0) { MonoFontSizes[fontSize] = size; } @@ -38,7 +38,7 @@ function measureText(text: string, textOpts: { pre?: boolean; mono?: boolean; fo if (textOpts == null) { throw new Error("invalid textOpts passed to measureText (null)"); } - let textElem = document.createElement("span"); + const textElem = document.createElement("span"); if (textOpts.pre) { textElem.classList.add("pre"); } @@ -53,19 +53,19 @@ function measureText(text: string, textOpts: { pre?: boolean; mono?: boolean; fo } } textElem.innerText = text; - let measureDiv = document.getElementById("measure"); + const measureDiv = document.getElementById("measure"); if (measureDiv == null) { throw new Error("cannot measure text, no #measure div"); } measureDiv.replaceChildren(textElem); - let height = Math.ceil(textElem.offsetHeight); - let width = textElem.offsetWidth; - let pad = Math.floor(height / 2); + const height = Math.ceil(textElem.offsetHeight); + const width = textElem.offsetWidth; + const pad = Math.floor(height / 2); return { width, height, pad, fontSize: textOpts.fontSize }; } function windowWidthToCols(width: number, fontSize: number): number { - let dr = getMonoFontSize(fontSize); + const dr = getMonoFontSize(fontSize); let cols = Math.trunc((width - MagicLayout.ScreenMaxContentWidthBuffer) / dr.width) - 1; cols = boundInt(cols, MinTermCols, MaxTermCols); return cols; @@ -80,7 +80,7 @@ function windowHeightToRows(lhe: LineHeightEnv, height: number): number { } function termWidthFromCols(cols: number, fontSize: number): number { - let dr = getMonoFontSize(fontSize); + const dr = getMonoFontSize(fontSize); return Math.ceil(dr.width * cols) + MagicLayout.TermWidthBuffer; } @@ -89,12 +89,12 @@ function termWidthFromCols(cols: number, fontSize: number): number { // works out to `realHeight = round(ceil(height * dpr) * rows / dpr) / rows` // their calculation is based off the "totalRows" (so that argument has been added) function termHeightFromRows(rows: number, fontSize: number, totalRows: number): number { - let dr = getMonoFontSize(fontSize); + const dr = getMonoFontSize(fontSize); const dpr = window.devicePixelRatio; if (totalRows == null || totalRows == 0) { totalRows = rows > 25 ? rows : 25; } - let realHeight = Math.round((Math.ceil(dr.height * dpr) * totalRows) / dpr) / totalRows; + const realHeight = Math.round((Math.ceil(dr.height * dpr) * totalRows) / dpr) / totalRows; return Math.ceil(realHeight * rows); } From 181e14f55c658e397a18a649f4bcfc362e444404 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 5 Apr 2024 10:52:04 -0700 Subject: [PATCH 05/15] try to detect and return mimetype with stream file info (#552) --- waveshell/pkg/packet/packet.go | 1 + waveshell/pkg/server/server.go | 12 +++++++----- waveshell/pkg/utilfn/utilfn.go | 24 ++++++++++++++++++++++++ wavesrv/cmd/main-server.go | 15 +++++++++------ 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/waveshell/pkg/packet/packet.go b/waveshell/pkg/packet/packet.go index 22e9828fe..d3c6e38e5 100644 --- a/waveshell/pkg/packet/packet.go +++ b/waveshell/pkg/packet/packet.go @@ -485,6 +485,7 @@ type FileInfo struct { ModTs int64 `json:"modts"` IsDir bool `json:"isdir,omitempty"` Perm int `json:"perm"` + MimeType string `json:"mimetype,omitempty"` NotFound bool `json:"notfound,omitempty"` // when NotFound is set, Perm will be set to permission for directory } diff --git a/waveshell/pkg/server/server.go b/waveshell/pkg/server/server.go index 192b39dc7..520c7d1b5 100644 --- a/waveshell/pkg/server/server.go +++ b/waveshell/pkg/server/server.go @@ -575,12 +575,14 @@ func (m *MServer) streamFile(pk *packet.StreamFilePacketType) { m.Sender.SendPacket(resp) return } + mimeType := utilfn.DetectMimeType(pk.Path) resp.Info = &packet.FileInfo{ - Name: pk.Path, - Size: finfo.Size(), - ModTs: finfo.ModTime().UnixMilli(), - IsDir: finfo.IsDir(), - Perm: int(finfo.Mode().Perm()), + Name: pk.Path, + Size: finfo.Size(), + ModTs: finfo.ModTime().UnixMilli(), + IsDir: finfo.IsDir(), + MimeType: mimeType, + Perm: int(finfo.Mode().Perm()), } if pk.StatOnly { resp.Done = true diff --git a/waveshell/pkg/utilfn/utilfn.go b/waveshell/pkg/utilfn/utilfn.go index 8e8638e36..608e36cc8 100644 --- a/waveshell/pkg/utilfn/utilfn.go +++ b/waveshell/pkg/utilfn/utilfn.go @@ -13,6 +13,8 @@ import ( "io" "math" mathrand "math/rand" + "net/http" + "os" "regexp" "sort" "strings" @@ -611,3 +613,25 @@ func CopyToChannel(outputCh chan<- []byte, reader io.Reader) error { } } } + +// on error just returns "" +// does not return "application/octet-stream" as this is considered a detection failure +func DetectMimeType(path string) string { + fd, err := os.Open(path) + if err != nil { + return "" + } + defer fd.Close() + buf := make([]byte, 512) + // ignore the error (EOF / UnexpectedEOF is fine, just process how much we got back) + n, _ := io.ReadAtLeast(fd, buf, 512) + if n == 0 { + return "" + } + buf = buf[:n] + rtn := http.DetectContentType(buf) + if rtn == "application/octet-stream" { + return "" + } + return rtn +} diff --git a/wavesrv/cmd/main-server.go b/wavesrv/cmd/main-server.go index d62a93a11..e139a8163 100644 --- a/wavesrv/cmd/main-server.go +++ b/wavesrv/cmd/main-server.go @@ -508,11 +508,8 @@ func HandleReadFile(w http.ResponseWriter, r *http.Request) { qvals := r.URL.Query() screenId := qvals.Get("screenid") lineId := qvals.Get("lineid") - path := qvals.Get("path") // validate path? - contentType := qvals.Get("mimetype") - if contentType == "" { - contentType = "application/octet-stream" - } + path := qvals.Get("path") // validate path? + contentType := qvals.Get("mimetype") // force a mimetype if screenId == "" || lineId == "" { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("must specify sessionid, screenid, and lineid")) @@ -533,7 +530,7 @@ func HandleReadFile(w http.ResponseWriter, r *http.Request) { w.Write([]byte(fmt.Sprintf(ErrorInvalidLineId, err))) return } - if !ContentTypeHeaderValidRe.MatchString(contentType) { + if contentType != "" && !ContentTypeHeaderValidRe.MatchString(contentType) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("invalid mimetype specified")) return @@ -599,6 +596,12 @@ func HandleReadFile(w http.ResponseWriter, r *http.Request) { return } infoJson, _ := json.Marshal(resp.Info) + if contentType == "" && resp.Info.MimeType != "" { + contentType = resp.Info.MimeType + } + if contentType == "" { + contentType = "application/octet-stream" + } w.Header().Set("X-FileInfo", base64.StdEncoding.EncodeToString(infoJson)) w.Header().Set(ContentTypeHeaderKey, contentType) w.WriteHeader(http.StatusOK) From 84cea373a8802aa042c54d765c88e80b70995a3c Mon Sep 17 00:00:00 2001 From: Sylvie Crowe <107814465+oneirocosm@users.noreply.github.com> Date: Fri, 5 Apr 2024 10:54:12 -0700 Subject: [PATCH 06/15] SSH Bugfixes Early April 2024 (#551) * fix: add vix for missing known_hosts file In a recent cleanup, I accidentally deleted this fix from before. This adds it back. * chore: clarify that the ssh should use private key --- src/app/common/modals/createremoteconn.tsx | 2 +- src/app/common/modals/editremoteconn.tsx | 2 +- wavesrv/pkg/remote/sshclient.go | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/common/modals/createremoteconn.tsx b/src/app/common/modals/createremoteconn.tsx index d8016f736..55872df56 100644 --- a/src/app/common/modals/createremoteconn.tsx +++ b/src/app/common/modals/createremoteconn.tsx @@ -312,7 +312,7 @@ class CreateRemoteConnModal extends React.Component<{}, {}> { endDecoration: ( } > diff --git a/src/app/common/modals/editremoteconn.tsx b/src/app/common/modals/editremoteconn.tsx index e63008cae..d8d18136b 100644 --- a/src/app/common/modals/editremoteconn.tsx +++ b/src/app/common/modals/editremoteconn.tsx @@ -338,7 +338,7 @@ class EditRemoteConnModal extends React.Component<{}, {}> { endDecoration: ( } > diff --git a/wavesrv/pkg/remote/sshclient.go b/wavesrv/pkg/remote/sshclient.go index 8da49ed5c..ee56aba16 100644 --- a/wavesrv/pkg/remote/sshclient.go +++ b/wavesrv/pkg/remote/sshclient.go @@ -412,6 +412,12 @@ func createHostKeyCallback(opts *sstore.SSHOpts) (ssh.HostKeyCallback, error) { } } + if basicCallback == nil { + basicCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error { + return &knownhosts.KeyError{} + } + } + waveHostKeyCallback := func(hostname string, remote net.Addr, key ssh.PublicKey) error { err := basicCallback(hostname, remote, key) if err == nil { From 5a6575a393bf4423dc95ba334347a05061e69aa5 Mon Sep 17 00:00:00 2001 From: Red J Adaya Date: Sat, 6 Apr 2024 03:06:04 +0800 Subject: [PATCH 07/15] Copy button (#550) * cop button * cleanup * fix wrong type * updates to try to set the cmdinput position (as well as text). fix button alignment, change checkmark to green (and extend), and remove the transition from parent component and move to copy (sawka) --- src/app/common/elements/button.tsx | 10 ++-- src/app/common/elements/copybutton.less | 7 +++ src/app/common/elements/copybutton.tsx | 51 ++++++++++++++++++++ src/app/common/elements/index.tsx | 1 + src/app/history/history.less | 4 ++ src/app/history/history.tsx | 34 +++---------- src/app/workspace/cmdinput/textareainput.tsx | 14 ++++-- 7 files changed, 85 insertions(+), 36 deletions(-) create mode 100644 src/app/common/elements/copybutton.less create mode 100644 src/app/common/elements/copybutton.tsx diff --git a/src/app/common/elements/button.tsx b/src/app/common/elements/button.tsx index d411f47be..325317437 100644 --- a/src/app/common/elements/button.tsx +++ b/src/app/common/elements/button.tsx @@ -6,7 +6,7 @@ import "./button.less"; interface ButtonProps { children: React.ReactNode; - onClick?: () => void; + onClick?: (e: React.MouseEvent) => void; disabled?: boolean; leftIcon?: React.ReactNode; rightIcon?: React.ReactNode; @@ -14,6 +14,7 @@ interface ButtonProps { autoFocus?: boolean; className?: string; termInline?: boolean; + title?: string; } class Button extends React.Component { @@ -23,14 +24,14 @@ class Button extends React.Component { }; @boundMethod - handleClick() { + handleClick(e) { if (this.props.onClick && !this.props.disabled) { - this.props.onClick(); + this.props.onClick(e); } } render() { - const { leftIcon, rightIcon, children, disabled, style, autoFocus, termInline, className } = this.props; + const { leftIcon, rightIcon, children, disabled, style, autoFocus, termInline, className, title } = this.props; return ( + ); + } +} + +export { CopyButton }; diff --git a/src/app/common/elements/index.tsx b/src/app/common/elements/index.tsx index 75f99280e..cf87b853e 100644 --- a/src/app/common/elements/index.tsx +++ b/src/app/common/elements/index.tsx @@ -18,3 +18,4 @@ export { Toggle } from "./toggle"; export { Tooltip } from "./tooltip"; export { TabIcon } from "./tabicon"; export { DatePicker } from "./datepicker"; +export { CopyButton } from "./copybutton"; diff --git a/src/app/history/history.less b/src/app/history/history.less index a386e7536..e323e0dd1 100644 --- a/src/app/history/history.less +++ b/src/app/history/history.less @@ -357,6 +357,10 @@ cursor: pointer; } + .wave-button { + padding: 5px 5px; + } + visibility: hidden; } diff --git a/src/app/history/history.tsx b/src/app/history/history.tsx index 1d1481d18..7aecebd07 100644 --- a/src/app/history/history.tsx +++ b/src/app/history/history.tsx @@ -14,7 +14,7 @@ import localizedFormat from "dayjs/plugin/localizedFormat"; import customParseFormat from "dayjs/plugin/customParseFormat"; import { Line } from "@/app/line/linecomps"; import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; -import { TextField, Dropdown, Button, DatePicker } from "@/elements"; +import { TextField, Dropdown, Button, CopyButton } from "@/elements"; import { ReactComponent as ChevronLeftIcon } from "@/assets/icons/history/chevron-left.svg"; import { ReactComponent as ChevronRightIcon } from "@/assets/icons/history/chevron-right.svg"; @@ -22,8 +22,6 @@ import { ReactComponent as RightIcon } from "@/assets/icons/history/right.svg"; import { ReactComponent as SearchIcon } from "@/assets/icons/history/search.svg"; import { ReactComponent as TrashIcon } from "@/assets/icons/trash.svg"; import { ReactComponent as CheckedCheckbox } from "@/assets/icons/checked-checkbox.svg"; -import { ReactComponent as CheckIcon } from "@/assets/icons/line/check.svg"; -import { ReactComponent as CopyIcon } from "@/assets/icons/history/copy.svg"; import "./history.less"; import { MainView } from "../common/elements/mainview"; @@ -115,7 +113,6 @@ class HistoryCmdStr extends React.Component< cmdstr: string; onUse: () => void; onCopy: () => void; - isCopied: boolean; fontSize: "normal" | "large"; limitHeight: boolean; }, @@ -138,24 +135,17 @@ class HistoryCmdStr extends React.Component< } render() { - const { isCopied, cmdstr, fontSize, limitHeight } = this.props; + const { cmdstr, fontSize, limitHeight } = this.props; return (
- -
-
copied
-
-
{cmdstr}
-
- -
-
- -
+ +
); @@ -190,7 +180,6 @@ class HistoryView extends React.Component<{}, {}> { tableRszObs: ResizeObserver; sessionDropdownActive: OV = mobx.observable.box(false, { name: "sessionDropdownActive" }); remoteDropdownActive: OV = mobx.observable.box(false, { name: "remoteDropdownActive" }); - copiedItemId: OV = mobx.observable.box(null, { name: "copiedItemId" }); @boundMethod handleNext() { @@ -377,14 +366,6 @@ class HistoryView extends React.Component<{}, {}> { return; } navigator.clipboard.writeText(item.cmdstr); - mobx.action(() => { - this.copiedItemId.set(item.historyid); - })(); - setTimeout(() => { - mobx.action(() => { - this.copiedItemId.set(null); - })(); - }, 600); } @boundMethod @@ -394,7 +375,7 @@ class HistoryView extends React.Component<{}, {}> { } mobx.action(() => { GlobalModel.showSessionView(); - GlobalModel.inputModel.setCurLine(item.cmdstr); + GlobalModel.inputModel.updateCmdLine({ str: item.cmdstr, pos: item.cmdstr.length }); setTimeout(() => GlobalModel.inputModel.giveFocus(), 50); })(); } @@ -569,7 +550,6 @@ class HistoryView extends React.Component<{}, {}> { cmdstr={item.cmdstr} onUse={() => this.handleUse(item)} onCopy={() => this.handleCopy(item)} - isCopied={this.copiedItemId.get() == item.historyid} fontSize="normal" limitHeight={true} /> diff --git a/src/app/workspace/cmdinput/textareainput.tsx b/src/app/workspace/cmdinput/textareainput.tsx index db1575d6f..abac0f0a7 100644 --- a/src/app/workspace/cmdinput/textareainput.tsx +++ b/src/app/workspace/cmdinput/textareainput.tsx @@ -253,9 +253,9 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () controlRef: React.RefObject = React.createRef(); lastHeight: number = 0; lastSP: StrWithPos = { str: "", pos: appconst.NoStrPos }; - version: OV = mobx.observable.box(0); // forces render updates - mainInputFocused: OV = mobx.observable.box(true); - historyFocused: OV = mobx.observable.box(false); + version: OV = mobx.observable.box(0, { name: "textAreaInput-version" }); // forces render updates + mainInputFocused: OV = mobx.observable.box(true, { name: "textAreaInput-mainInputFocused" }); + historyFocused: OV = mobx.observable.box(false, { name: "textAreaInput-historyFocused" }); incVersion(): void { const v = this.version.get(); @@ -288,9 +288,13 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () setFocus(): void { const inputModel = GlobalModel.inputModel; if (inputModel.historyFocus.get()) { - this.historyInputRef.current.focus(); + if (this.historyInputRef.current != null && document.activeElement != this.historyInputRef.current) { + this.historyInputRef.current.focus(); + } } else { - this.mainInputRef.current.focus(); + if (this.mainInputRef.current != null && document.activeElement != this.mainInputRef.current) { + this.mainInputRef.current.focus(); + } } } From eed234a131e8ab286410e63dde6e5913d762bf56 Mon Sep 17 00:00:00 2001 From: Cole Lashley Date: Fri, 5 Apr 2024 13:01:51 -0700 Subject: [PATCH 08/15] added codeedit keybinding fix (#554) --- src/app/workspace/cmdinput/textareainput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/workspace/cmdinput/textareainput.tsx b/src/app/workspace/cmdinput/textareainput.tsx index abac0f0a7..354cae508 100644 --- a/src/app/workspace/cmdinput/textareainput.tsx +++ b/src/app/workspace/cmdinput/textareainput.tsx @@ -652,7 +652,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () } } } - const isMainInputFocused = this.mainInputFocused.get(); + const isMainInputFocused = inputModel.hasFocus() && this.mainInputFocused.get(); const isHistoryFocused = this.historyFocused.get(); return (
Date: Fri, 5 Apr 2024 17:39:27 -0700 Subject: [PATCH 09/15] Clean up the input model's auxiliary view logic (#553) * Clean up the input model's auxiliary view logic * fix * save work * rename appconst * fix keybindings * remove debugs * Add comments * fix focus order * givefocus whenever focus var is updated, don't update if nothign changes * remove debug statements * one more debug * revert unnecessary newline * remove cmdinput placeholder to allow for better window resizing --- src/app/appconst.ts | 4 + src/app/workspace/cmdinput/aichat.tsx | 8 +- src/app/workspace/cmdinput/cmdinput.less | 3 +- src/app/workspace/cmdinput/cmdinput.tsx | 38 +-- src/app/workspace/cmdinput/historyinfo.tsx | 8 +- src/app/workspace/cmdinput/infomsg.tsx | 2 +- src/app/workspace/cmdinput/textareainput.tsx | 77 ++---- src/app/workspace/workspaceview.tsx | 1 - src/models/input.ts | 237 +++++++++---------- src/models/model.ts | 2 +- src/types/custom.d.ts | 1 + 11 files changed, 158 insertions(+), 223 deletions(-) diff --git a/src/app/appconst.ts b/src/app/appconst.ts index 0acecdfca..062b36e9b 100644 --- a/src/app/appconst.ts +++ b/src/app/appconst.ts @@ -64,3 +64,7 @@ export enum StatusIndicatorLevel { // matches packet.go export const ErrorCode_InvalidCwd = "ERRCWD"; + +export const InputAuxView_History = "history"; +export const InputAuxView_Info = "info"; +export const InputAuxView_AIChat = "aichat"; diff --git a/src/app/workspace/cmdinput/aichat.tsx b/src/app/workspace/cmdinput/aichat.tsx index 5adfbf56d..72e1072da 100644 --- a/src/app/workspace/cmdinput/aichat.tsx +++ b/src/app/workspace/cmdinput/aichat.tsx @@ -27,7 +27,7 @@ class AIChatKeybindings extends React.Component<{ AIChatObject: AIChat }, {}> { return true; }); keybindManager.registerKeybinding("pane", "aichat", "generic:cancel", (waveEvent) => { - inputModel.closeAIAssistantChat(true); + inputModel.closeAuxView(); return true; }); keybindManager.registerKeybinding("pane", "aichat", "aichat:clearHistory", (waveEvent) => { @@ -70,7 +70,7 @@ class AIChat extends React.Component<{}, {}> { componentDidMount() { const inputModel = GlobalModel.inputModel; - if (this.chatWindowScrollRef != null && this.chatWindowScrollRef.current != null) { + if (this.chatWindowScrollRef?.current != null) { this.chatWindowScrollRef.current.scrollTop = this.chatWindowScrollRef.current.scrollHeight; } if (this.textAreaRef.current != null) { @@ -82,7 +82,7 @@ class AIChat extends React.Component<{}, {}> { } componentDidUpdate() { - if (this.chatWindowScrollRef != null && this.chatWindowScrollRef.current != null) { + if (this.chatWindowScrollRef?.current != null) { this.chatWindowScrollRef.current.scrollTop = this.chatWindowScrollRef.current.scrollHeight; } } @@ -252,7 +252,7 @@ class AIChat extends React.Component<{}, {}> { GlobalModel.inputModel.closeAIAssistantChat(true)} + onClose={() => GlobalModel.inputModel.closeAuxView()} iconClass="fa-sharp fa-solid fa-sparkles" > diff --git a/src/app/workspace/cmdinput/cmdinput.less b/src/app/workspace/cmdinput/cmdinput.less index 97d02351e..66785d4e4 100644 --- a/src/app/workspace/cmdinput/cmdinput.less +++ b/src/app/workspace/cmdinput/cmdinput.less @@ -4,12 +4,11 @@ max-height: max(300px, 40%); display: flex; flex-direction: column; - position: absolute; - bottom: 0; width: 100%; z-index: 100; border-top: 2px solid var(--app-border-color); background-color: var(--app-bg-color); + position: relative; // Apply a border between the base cmdinput and any views shown above it // TODO: use a generic selector for this diff --git a/src/app/workspace/cmdinput/cmdinput.tsx b/src/app/workspace/cmdinput/cmdinput.tsx index 6410f56a4..002116d87 100644 --- a/src/app/workspace/cmdinput/cmdinput.tsx +++ b/src/app/workspace/cmdinput/cmdinput.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import * as mobxReact from "mobx-react"; import * as mobx from "mobx"; import { boundMethod } from "autobind-decorator"; -import { If } from "tsx-control-statements/components"; +import { Choose, If, When } from "tsx-control-statements/components"; import cn from "classnames"; import dayjs from "dayjs"; import localizedFormat from "dayjs/plugin/localizedFormat"; @@ -18,6 +18,7 @@ import { Prompt } from "@/common/prompt/prompt"; import { CenteredIcon, RotateIcon } from "@/common/icons/icons"; import { AIChat } from "./aichat"; import * as util from "@/util/util"; +import * as appconst from "@/app/appconst"; import "./cmdinput.less"; @@ -71,7 +72,7 @@ class CmdInput extends React.Component<{}, {}> { e.stopPropagation(); return; } - GlobalModel.inputModel.setHistoryFocus(false); + GlobalModel.inputModel.setAuxViewFocus(false); GlobalModel.inputModel.giveFocus(); } @@ -80,8 +81,8 @@ class CmdInput extends React.Component<{}, {}> { e.preventDefault(); e.stopPropagation(); const inputModel = GlobalModel.inputModel; - if (inputModel.aIChatShow.get()) { - inputModel.closeAIAssistantChat(true); + if (inputModel.getActiveAuxView() === appconst.InputAuxView_AIChat) { + inputModel.closeAuxView(); } else { inputModel.openAIAssistantChat(); } @@ -93,7 +94,7 @@ class CmdInput extends React.Component<{}, {}> { e.stopPropagation(); const inputModel = GlobalModel.inputModel; - if (inputModel.historyShow.get()) { + if (inputModel.getActiveAuxView() === appconst.InputAuxView_History) { inputModel.resetHistory(); } else { inputModel.openHistory(); @@ -155,9 +156,6 @@ class CmdInput extends React.Component<{}, {}> { remote = GlobalModel.getRemote(rptr.remoteid); } feState = feState || {}; - const infoShow = inputModel.infoShow.get(); - const historyShow = !infoShow && inputModel.historyShow.get(); - const aiChatShow = inputModel.aIChatShow.get(); const focusVal = inputModel.physicalInputFocused.get(); const inputMode: string = inputModel.inputMode.get(); const textAreaInputKey = screen == null ? "null" : screen.screenId; @@ -170,7 +168,7 @@ class CmdInput extends React.Component<{}, {}> { let shellInitMsg: string = null; let hidePrompt = false; - const openView = inputModel.getOpenView(); + const openView = inputModel.getActiveAuxView(); const hasOpenView = openView ? `has-${openView}` : null; if (ri == null) { let shellStr = "shell"; @@ -185,15 +183,19 @@ class CmdInput extends React.Component<{}, {}> { } return (
- -
- -
- -
- -
- + + +
+ +
+ +
+ +
+ + + +
WARNING:  diff --git a/src/app/workspace/cmdinput/historyinfo.tsx b/src/app/workspace/cmdinput/historyinfo.tsx index e188139b8..cbe6cdf84 100644 --- a/src/app/workspace/cmdinput/historyinfo.tsx +++ b/src/app/workspace/cmdinput/historyinfo.tsx @@ -167,14 +167,14 @@ class HistoryInfo extends React.Component<{}, {}> { @boundMethod handleClose() { - GlobalModel.inputModel.toggleInfoMsg(); + GlobalModel.inputModel.closeAuxView(); } @boundMethod handleItemClick(hitem: HistoryItem) { const inputModel = GlobalModel.inputModel; const selItem = inputModel.getHistorySelectedItem(); - inputModel.setHistoryFocus(true); + inputModel.setAuxViewFocus(false); if (this.lastClickHNum == hitem.historynum && selItem != null && selItem.historynum == hitem.historynum) { inputModel.grabSelectedHistoryItem(); return; @@ -194,14 +194,14 @@ class HistoryInfo extends React.Component<{}, {}> { @boundMethod handleClickType() { const inputModel = GlobalModel.inputModel; - inputModel.setHistoryFocus(true); + inputModel.setAuxViewFocus(true); inputModel.toggleHistoryType(); } @boundMethod handleClickRemote() { const inputModel = GlobalModel.inputModel; - inputModel.setHistoryFocus(true); + inputModel.setAuxViewFocus(true); inputModel.toggleRemoteType(); } diff --git a/src/app/workspace/cmdinput/infomsg.tsx b/src/app/workspace/cmdinput/infomsg.tsx index 6c21864e9..f7143f91a 100644 --- a/src/app/workspace/cmdinput/infomsg.tsx +++ b/src/app/workspace/cmdinput/infomsg.tsx @@ -45,7 +45,7 @@ class InfoMsg extends React.Component<{}, {}> { render() { const inputModel = GlobalModel.inputModel; const infoMsg: InfoType = inputModel.infoMsg.get(); - const infoShow: boolean = inputModel.infoShow.get(); + const infoShow = inputModel.getActiveAuxView() == appconst.InputAuxView_Info; let line: string = null; let istr: string = null; let idx: number = 0; diff --git a/src/app/workspace/cmdinput/textareainput.tsx b/src/app/workspace/cmdinput/textareainput.tsx index 354cae508..b0fe26304 100644 --- a/src/app/workspace/cmdinput/textareainput.tsx +++ b/src/app/workspace/cmdinput/textareainput.tsx @@ -152,11 +152,7 @@ class CmdInputKeybindings extends React.Component<{ inputObject: TextAreaInput } }); keybindManager.registerKeybinding("pane", "cmdinput", "generic:cancel", (waveEvent) => { GlobalModel.closeTabSettings(); - inputModel.toggleInfoMsg(); - if (inputModel.inputMode.get() != null) { - inputModel.resetInputMode(); - } - inputModel.closeAIAssistantChat(true); + inputModel.closeAuxView(); return true; }); keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:expandInput", (waveEvent) => { @@ -254,8 +250,6 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () lastHeight: number = 0; lastSP: StrWithPos = { str: "", pos: appconst.NoStrPos }; version: OV = mobx.observable.box(0, { name: "textAreaInput-version" }); // forces render updates - mainInputFocused: OV = mobx.observable.box(true, { name: "textAreaInput-mainInputFocused" }); - historyFocused: OV = mobx.observable.box(false, { name: "textAreaInput-historyFocused" }); incVersion(): void { const v = this.version.get(); @@ -286,16 +280,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () } setFocus(): void { - const inputModel = GlobalModel.inputModel; - if (inputModel.historyFocus.get()) { - if (this.historyInputRef.current != null && document.activeElement != this.historyInputRef.current) { - this.historyInputRef.current.focus(); - } - } else { - if (this.mainInputRef.current != null && document.activeElement != this.mainInputRef.current) { - this.mainInputRef.current.focus(); - } - } + GlobalModel.inputModel.giveFocus(); } getTextAreaMaxCols(): number { @@ -536,7 +521,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () if (selStart > value.length || selEnd > value.length) { return; } - const newValue = value.substr(0, selStart) + clipText + value.substr(selEnd); + const newValue = value.substring(0, selStart) + clipText + value.substring(selEnd); const cmdLineUpdate = { str: newValue, pos: selStart + clipText.length }; GlobalModel.inputModel.updateCmdLine(cmdLineUpdate); }); @@ -553,19 +538,9 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () } @boundMethod - handleMainFocus(e: any) { - const inputModel = GlobalModel.inputModel; - if (inputModel.historyFocus.get()) { - e.preventDefault(); - if (this.historyInputRef.current != null) { - this.historyInputRef.current.focus(); - } - return; - } - inputModel.setPhysicalInputFocused(true); - mobx.action(() => { - this.mainInputFocused.set(true); - })(); + handleFocus(e: any) { + e.preventDefault(); + GlobalModel.inputModel.giveFocus(); } @boundMethod @@ -574,25 +549,6 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () return; } GlobalModel.inputModel.setPhysicalInputFocused(false); - mobx.action(() => { - this.mainInputFocused.set(false); - })(); - } - - @boundMethod - handleHistoryFocus(e: any) { - const inputModel = GlobalModel.inputModel; - if (!inputModel.historyFocus.get()) { - e.preventDefault(); - if (this.mainInputRef.current != null) { - this.mainInputRef.current.focus(); - } - return; - } - inputModel.setPhysicalInputFocused(true); - mobx.action(() => { - this.historyFocused.set(true); - })(); } @boundMethod @@ -601,9 +557,6 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () return; } GlobalModel.inputModel.setPhysicalInputFocused(false); - mobx.action(() => { - this.historyFocused.set(false); - })(); } render() { @@ -621,9 +574,8 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () displayLines = 5; } - // TODO: invert logic here. We should track focus on the main textarea and assume aux view is focused if not. - const disabled = inputModel.historyFocus.get(); - if (disabled) { + const auxViewFocused = inputModel.getAuxViewFocus(); + if (auxViewFocused) { displayLines = 1; } const activeScreen = GlobalModel.getActiveScreen(); @@ -639,7 +591,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () const screen = GlobalModel.getActiveScreen(); if (screen != null) { const ri = screen.getCurRemoteInstance(); - if (ri != null && ri.shelltype != null) { + if (ri?.shelltype != null) { shellType = ri.shelltype; } if (shellType == "") { @@ -652,15 +604,14 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () } } } - const isMainInputFocused = inputModel.hasFocus() && this.mainInputFocused.get(); - const isHistoryFocused = this.historyFocused.get(); + const isHistoryFocused = auxViewFocused && inputModel.getActiveAuxView() == appconst.InputAuxView_History; return (
- + @@ -677,7 +628,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () autoComplete="off" autoCorrect="off" id="main-cmd-input" - onFocus={this.handleMainFocus} + onFocus={this.handleFocus} onBlur={this.handleMainBlur} style={{ height: computedInnerHeight, minHeight: computedInnerHeight, fontSize: termFontSize }} value={curLine} @@ -685,7 +636,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () onChange={this.onChange} onSelect={this.onSelect} placeholder="Type here..." - className={cn("textarea", { "display-disabled": disabled })} + className={cn("textarea", { "display-disabled": auxViewFocused })} > { session={session} screen={activeScreen} /> -
diff --git a/src/models/input.ts b/src/models/input.ts index 88b34fb48..3957fa2e2 100644 --- a/src/models/input.ts +++ b/src/models/input.ts @@ -8,6 +8,7 @@ import { isBlank } from "@/util/util"; import * as appconst from "@/app/appconst"; import { Model } from "./model"; import { GlobalCommandRunner } from "./global"; +import { app } from "electron"; function getDefaultHistoryQueryOpts(): HistoryQueryOpts { return { @@ -24,10 +25,8 @@ function getDefaultHistoryQueryOpts(): HistoryQueryOpts { class InputModel { globalModel: Model; - historyShow: OV = mobx.observable.box(false); - historyFocus: OV = mobx.observable.box(false); - infoShow: OV = mobx.observable.box(false); - aIChatShow: OV = mobx.observable.box(false); + activeAuxView: OV = mobx.observable.box(null); + auxViewFocus: OV = mobx.observable.box(false); cmdInputHeight: OV = mobx.observable.box(0); aiChatTextAreaRef: React.RefObject; aiChatWindowRef: React.RefObject; @@ -139,26 +138,39 @@ class InputModel { })(); } - _focusCmdInput(): void { - const elem = document.getElementById("main-cmd-input"); - if (elem != null) { - elem.focus(); - } - } - - _focusHistoryInput(): void { - const elem: HTMLElement = document.querySelector(".cmd-input input.history-input"); - if (elem != null) { - elem.focus(); - } - } - + // Focuses the main input or the auxiliary view, depending on the active auxiliary view giveFocus(): void { - if (this.historyFocus.get()) { - this._focusHistoryInput(); - } else { - this._focusCmdInput(); - } + // Override active view to the main input if aux view does not have focus + const activeAuxView = this.getAuxViewFocus() ? this.getActiveAuxView() : null; + mobx.action(() => { + switch (activeAuxView) { + case appconst.InputAuxView_History: { + const elem: HTMLElement = document.querySelector(".cmd-input input.history-input"); + if (elem != null) { + elem.focus(); + } + break; + } + case "aichat": + this.setAIChatFocus(); + break; + case null: { + const elem = document.getElementById("main-cmd-input"); + if (elem != null) { + elem.focus(); + } + this.setPhysicalInputFocused(true); + break; + } + default: { + const elem: HTMLElement = document.querySelector(".cmd-input .auxview"); + if (elem != null) { + elem.focus(); + } + break; + } + } + })(); } setPhysicalInputFocused(isFocused: boolean): void { @@ -191,19 +203,6 @@ class InputModel { return false; } - getOpenView(): string { - if (this.historyShow.get()) { - return "history"; - } - if (this.aIChatShow.get()) { - return "aichat"; - } - if (this.infoShow.get()) { - return "info"; - } - return null; - } - setHistoryType(htype: HistoryTypeStrs): void { if (this.historyQueryOpts.get().queryType == htype) { return; @@ -244,45 +243,11 @@ class InputModel { })(); } - setInputPopUpType(type: string) { - this.inputPopUpType = type; - this.aIChatShow.set(type == "aichat"); - this.historyShow.set(type == "history"); - } - setOpenAICmdInfoChat(chat: OpenAICmdInfoChatMessageType[]): void { this.AICmdInfoChatItems.replace(chat); this.codeSelectBlockRefArray = []; } - setHistoryShow(show: boolean): void { - if (this.historyShow.get() == show) { - return; - } - mobx.action(() => { - if (show) { - this.setInputPopUpType("history"); - } else { - this.setInputPopUpType("none"); - } - this.historyShow.set(show); - this.historyFocus.set(show); - if (this.hasFocus()) { - this.giveFocus(); - } - })(); - } - - setHistoryFocus(focus: boolean): void { - if (this.historyFocus.get() == focus) { - return; - } - mobx.action(() => { - this.historyFocus.set(focus); - this.giveFocus(); - })(); - } - isHistoryLoaded(): boolean { if (this.historyLoading.get()) { return false; @@ -315,14 +280,9 @@ class InputModel { this.loadHistory(true, 0, "screen"); return; } - if (!this.historyShow.get()) { - mobx.action(() => { - this.setHistoryShow(true); - this.aIChatShow.set(false); - this.infoShow.set(false); - this.dropModHistory(true); - this.giveFocus(); - })(); + if (this.getActiveAuxView() != appconst.InputAuxView_History) { + this.dropModHistory(true); + this.setActiveAuxView(appconst.InputAuxView_History); } } @@ -495,6 +455,51 @@ class InputModel { })(); } + // Closes the auxiliary view if it is open, focuses the main input + closeAuxView(): void { + if (this.activeAuxView.get() == null) { + return; + } + this.setActiveAuxView(null); + } + + // Gets the active auxiliary view, or null if none + getActiveAuxView(): InputAuxViewType { + return this.activeAuxView.get(); + } + + // Sets the active auxiliary view + setActiveAuxView(view: InputAuxViewType): void { + if (view == this.activeAuxView.get()) { + return; + } + mobx.action(() => { + this.auxViewFocus.set(view != null); + this.activeAuxView.set(view); + })(); + this.giveFocus(); + } + + // Gets the focus state of the auxiliary view. If true, the view will get focus. Otherwise, the main input will get focus. + // If the auxiliary view is not open, this will return false. + getAuxViewFocus(): boolean { + if (this.getActiveAuxView() == null) { + return false; + } + return this.auxViewFocus.get(); + } + + // Sets the focus state of the auxiliary view. If true, the view will get focus. Otherwise, the main input will get focus. + setAuxViewFocus(focus: boolean): void { + if (this.getAuxViewFocus() == focus) { + return; + } + mobx.action(() => { + this.auxViewFocus.set(focus); + })(); + this.giveFocus(); + } + setHistoryIndex(hidx: number, force?: boolean): void { if (hidx < 0) { return; @@ -504,7 +509,7 @@ class InputModel { } mobx.action(() => { this.historyIndex.set(hidx); - if (this.historyShow.get()) { + if (this.getActiveAuxView() == appconst.InputAuxView_History) { let hitem = this.getHistorySelectedItem(); if (hitem == null) { hitem = this.getFirstHistoryItem(); @@ -538,16 +543,18 @@ class InputModel { this._clearInfoTimeout(); mobx.action(() => { this.infoMsg.set(info); - if (info == null) { - this.infoShow.set(false); - } else { - this.infoShow.set(true); - this.setHistoryShow(false); - } })(); + + if (info == null && this.getActiveAuxView() == appconst.InputAuxView_Info) { + this.setActiveAuxView(null); + } else { + this.setActiveAuxView(appconst.InputAuxView_Info); + } + if (info != null && timeoutMs) { this.infoTimeoutId = setTimeout(() => { - if (this.historyShow.get()) { + console.log("clearing info msg"); + if (this.activeAuxView.get() != appconst.InputAuxView_Info) { return; } this.clearInfoMsg(false); @@ -683,28 +690,7 @@ class InputModel { } openAIAssistantChat(): void { - mobx.action(() => { - this.setInputPopUpType("aichat"); - this.aIChatShow.set(true); - this.historyShow.set(false); - this.infoShow.set(false); - this.setAIChatFocus(); - })(); - } - - // pass true to give focus to the input (e.g. if this is an 'active' close of the chat) - // when resetting the input (when switching screens, don't give focus) - closeAIAssistantChat(giveFocus: boolean): void { - if (!this.aIChatShow.get()) { - return; - } - mobx.action(() => { - this.setInputPopUpType("none"); - this.aIChatShow.set(false); - if (giveFocus) { - this.giveFocus(); - } - })(); + this.setActiveAuxView(appconst.InputAuxView_AIChat); } clearAIAssistantChat(): void { @@ -719,7 +705,7 @@ class InputModel { } hasScrollingInfoMsg(): boolean { - if (!this.infoShow.get()) { + if (this.activeAuxView.get() !== appconst.InputAuxView_Info) { return false; } const info = this.infoMsg.get(); @@ -742,9 +728,11 @@ class InputModel { clearInfoMsg(setNull: boolean): void { this._clearInfoTimeout(); + + if (this.getActiveAuxView() == appconst.InputAuxView_Info) { + this.setActiveAuxView(null); + } mobx.action(() => { - this.setHistoryShow(false); - this.infoShow.set(false); if (setNull) { this.infoMsg.set(null); } @@ -753,20 +741,11 @@ class InputModel { toggleInfoMsg(): void { this._clearInfoTimeout(); - mobx.action(() => { - if (this.historyShow.get()) { - this.setHistoryShow(false); - return; - } - const isShowing = this.infoShow.get(); - if (isShowing) { - this.infoShow.set(false); - } else { - if (this.infoMsg.get() != null) { - this.infoShow.set(true); - } - } - })(); + if (this.activeAuxView.get() == appconst.InputAuxView_Info) { + this.setActiveAuxView(null); + } else if (this.infoMsg.get() != null) { + this.setActiveAuxView(appconst.InputAuxView_Info); + } } @boundMethod @@ -804,9 +783,7 @@ class InputModel { resetInput(): void { mobx.action(() => { - this.setHistoryShow(false); - this.closeAIAssistantChat(false); - this.infoShow.set(false); + this.setActiveAuxView(null); this.inputMode.set(null); this.resetHistory(); this.dropModHistory(false); @@ -854,7 +831,9 @@ class InputModel { resetHistory(): void { mobx.action(() => { - this.setHistoryShow(false); + if (this.getActiveAuxView() == appconst.InputAuxView_History) { + this.setActiveAuxView(null); + } this.historyLoading.set(false); this.historyType.set("screen"); this.historyItems.set(null); diff --git a/src/models/model.ts b/src/models/model.ts index 8175de327..0bf49e82c 100644 --- a/src/models/model.ts +++ b/src/models/model.ts @@ -1082,7 +1082,7 @@ class Model { this.ws.watchScreen(newActiveSessionId, newActiveScreenId); this.closeTabSettings(); const activeScreen = this.getActiveScreen(); - if (activeScreen != null && activeScreen.getCurRemoteInstance() != null) { + if (activeScreen?.getCurRemoteInstance() != null) { setTimeout(() => { GlobalCommandRunner.syncShellState(); }, 100); diff --git a/src/types/custom.d.ts b/src/types/custom.d.ts index b7c0a6bd8..30003e0a2 100644 --- a/src/types/custom.d.ts +++ b/src/types/custom.d.ts @@ -15,6 +15,7 @@ declare global { type LineContainerStrs = "main" | "sidebar" | "history"; type AppUpdateStatusType = "unavailable" | "ready"; type NativeThemeSource = "system" | "light" | "dark"; + type InputAuxViewType = null | "history" | "info" | "aichat"; type OV = mobx.IObservableValue; type OArr = mobx.IObservableArray; From 70088afdb5aa4b3f77d577b34d7895499f0fed11 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 5 Apr 2024 22:42:22 -0700 Subject: [PATCH 10/15] daystr custom func (#555) * working on daystr funcs * daystr custom function --- wavesrv/pkg/telemetry/telemetry.go | 96 +++++++++++++++++++++++++ wavesrv/pkg/telemetry/telemetry_test.go | 41 +++++++++++ 2 files changed, 137 insertions(+) create mode 100644 wavesrv/pkg/telemetry/telemetry_test.go diff --git a/wavesrv/pkg/telemetry/telemetry.go b/wavesrv/pkg/telemetry/telemetry.go index 865d949aa..a8418aa06 100644 --- a/wavesrv/pkg/telemetry/telemetry.go +++ b/wavesrv/pkg/telemetry/telemetry.go @@ -6,7 +6,10 @@ package telemetry import ( "context" "database/sql/driver" + "fmt" "log" + "regexp" + "strconv" "time" "github.com/wavetermdev/waveterm/wavesrv/pkg/dbutil" @@ -81,6 +84,99 @@ func GetCurDayStr() string { return dayStr } +func GetRelDayStr(relDays int) string { + now := time.Now() + dayStr := now.AddDate(0, 0, relDays).Format("2006-01-02") + return dayStr +} + +// accepts a custom format string to return a daystr +// can be either a prefix, a delta, or a prefix w/ a delta +// if no prefix is given, "today" is assumed +// examples: today-2d, bow, bom+1m-1d (that's end of the month), 2024-04-01+1w +// +// prefixes: +// +// yyyy-mm-dd +// today +// yesterday +// bom (beginning of month) +// bow (beginning of week -- sunday) +// +// deltas: +// +// +[n]d, -[n]d (e.g. +1d, -5d) +// +[n]w, -[n]w (e.g. +2w) +// +[n]m, -[n]m (e.g. -1m) +// deltas can be combined e.g. +1w-2d +func GetCustomDayStr(format string) (string, error) { + m := customDayStrRe.FindStringSubmatch(format) + if m == nil { + return "", fmt.Errorf("invalid daystr format") + } + prefix, deltas := m[1], m[2] + if prefix == "" { + prefix = "today" + } + var rtnTime time.Time + now := time.Now() + switch prefix { + case "today": + rtnTime = now + case "yesterday": + rtnTime = now.AddDate(0, 0, -1) + case "bom": + rtnTime = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + case "bow": + weekday := now.Weekday() + if weekday == time.Sunday { + rtnTime = now + } else { + rtnTime = now.AddDate(0, 0, -int(weekday)) + } + default: + m = daystrRe.FindStringSubmatch(prefix) + if m == nil { + return "", fmt.Errorf("invalid prefix format") + } + year, month, day := m[1], m[2], m[3] + yearInt, monthInt, dayInt := atoiNoErr(year), atoiNoErr(month), atoiNoErr(day) + if yearInt == 0 || monthInt == 0 || dayInt == 0 { + return "", fmt.Errorf("invalid prefix format") + } + rtnTime = time.Date(yearInt, time.Month(monthInt), dayInt, 0, 0, 0, 0, now.Location()) + } + for _, delta := range regexp.MustCompile(`[+-]\d+[dwm]`).FindAllString(deltas, -1) { + deltaVal, err := strconv.Atoi(delta[1 : len(delta)-1]) + if err != nil { + return "", fmt.Errorf("invalid delta format") + } + if delta[0] == '-' { + deltaVal = -deltaVal + } + switch delta[len(delta)-1] { + case 'd': + rtnTime = rtnTime.AddDate(0, 0, deltaVal) + case 'w': + rtnTime = rtnTime.AddDate(0, 0, deltaVal*7) + case 'm': + rtnTime = rtnTime.AddDate(0, deltaVal, 0) + } + } + return rtnTime.Format("2006-01-02"), nil +} + +func atoiNoErr(str string) int { + val, err := strconv.Atoi(str) + if err != nil { + return 0 + } + return val +} + +var customDayStrRe = regexp.MustCompile(`^((?:\d{4}-\d{2}-\d{2})|today|yesterday|bom|bow)?((?:[+-]\d+[dwm])*)$`) +var daystrRe = regexp.MustCompile(`^(\d{4})-(\d{2})-(\d{2})$`) + func UpdateCurrentActivity(ctx context.Context, update ActivityUpdate) error { now := time.Now() dayStr := GetCurDayStr() diff --git a/wavesrv/pkg/telemetry/telemetry_test.go b/wavesrv/pkg/telemetry/telemetry_test.go new file mode 100644 index 000000000..875f834e5 --- /dev/null +++ b/wavesrv/pkg/telemetry/telemetry_test.go @@ -0,0 +1,41 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package telemetry + +import ( + "testing" + "time" +) + +func testCustomDaystr(t *testing.T, customDayStr string, expectedDayStr string, shouldErr bool) { + rtn, err := GetCustomDayStr(customDayStr) + if err != nil { + if !shouldErr { + t.Errorf("unexpected error: %v", err) + } + } else { + if rtn != expectedDayStr { + t.Errorf("for %q expected %q, got %q", customDayStr, expectedDayStr, rtn) + } + } +} + +func TestDaystrCustom(t *testing.T) { + now := time.Now() + bom := now.AddDate(0, 0, -now.Day()+1) + testCustomDaystr(t, "today", GetCurDayStr(), false) + testCustomDaystr(t, "yesterday", GetRelDayStr(-1), false) + testCustomDaystr(t, "bom", bom.Format("2006-01-02"), false) + bow := now.AddDate(0, 0, -int(now.Weekday())) + testCustomDaystr(t, "bow", bow.Format("2006-01-02"), false) + testCustomDaystr(t, "today-1d", GetRelDayStr(-1), false) + testCustomDaystr(t, "today+1d", GetRelDayStr(1), false) + testCustomDaystr(t, "today-1w", GetRelDayStr(-7), false) + day1 := bom.AddDate(0, 1, -1) + testCustomDaystr(t, "bom+1m-1d", day1.Format("2006-01-02"), false) + testCustomDaystr(t, "foo", "", true) + testCustomDaystr(t, "2000-1-1", "", true) + testCustomDaystr(t, "2024-01-01+1w", "2024-01-08", false) + testCustomDaystr(t, "2024-01-01+1m+1w-1d", "2024-02-07", false) +} From 37e56acf630b42bff160f971943c5dba1c39b138 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 8 Apr 2024 10:17:26 -0700 Subject: [PATCH 11/15] Cleanup unused variables in workspaceview (#557) --- src/app/workspace/workspaceview.tsx | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/app/workspace/workspaceview.tsx b/src/app/workspace/workspaceview.tsx index c8e9ae934..10aa8dd47 100644 --- a/src/app/workspace/workspaceview.tsx +++ b/src/app/workspace/workspaceview.tsx @@ -13,7 +13,6 @@ import { CmdInput } from "./cmdinput/cmdinput"; import { ScreenView } from "./screen/screenview"; import { ScreenTabs } from "./screen/tabs"; import { ErrorBoundary } from "@/common/error/errorboundary"; -import * as textmeasure from "@/util/textmeasure"; import { boundMethod } from "autobind-decorator"; import type { Screen } from "@/models"; import { Button } from "@/elements"; @@ -34,7 +33,7 @@ Are you sure you want to delete this tab? class SessionKeybindings extends React.Component<{}, {}> { componentDidMount() { - let keybindManager = GlobalModel.keybindManager; + const keybindManager = GlobalModel.keybindManager; keybindManager.registerKeybinding("mainview", "session", "app:toggleSidebar", (waveEvent) => { GlobalModel.handleToggleSidebar(); return true; @@ -96,7 +95,7 @@ class SessionKeybindings extends React.Component<{}, {}> { @mobxReact.observer class TabSettingsPulldownKeybindings extends React.Component<{}, {}> { componentDidMount() { - let keybindManager = GlobalModel.keybindManager; + const keybindManager = GlobalModel.keybindManager; keybindManager.registerKeybinding("pane", "tabsettings", "generic:cancel", (waveEvent) => { GlobalModel.closeTabSettings(); return true; @@ -127,13 +126,13 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> { GlobalModel.modalsModel.popModal(); return; } - let message = ScreenDeleteMessage; - let alertRtn = GlobalModel.showAlert({ message: message, confirm: true, markdown: true }); + const message = ScreenDeleteMessage; + const alertRtn = GlobalModel.showAlert({ message: message, confirm: true, markdown: true }); alertRtn.then((result) => { if (!result) { return; } - let prtn = GlobalCommandRunner.screenDelete(screen.screenId, false); + const prtn = GlobalCommandRunner.screenDelete(screen.screenId, false); util.commandRtnHandler(prtn, this.errorMessage); GlobalModel.modalsModel.popModal(); }); @@ -151,8 +150,8 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> { } render() { - let { screen } = this.props; - let rptr = screen.curRemote.get(); + const { screen } = this.props; + const rptr = screen.curRemote.get(); const termThemes = getTermThemes(GlobalModel.termThemes); const currTermTheme = GlobalModel.getTermTheme()[screen.screenId] ?? termThemes[0].label; return ( @@ -271,18 +270,13 @@ class WorkspaceView extends React.Component<{}, {}> { } render() { - const model = GlobalModel; - const session = model.getActiveSession(); + const session = GlobalModel.getActiveSession(); let activeScreen: Screen = null; let sessionId: string = "none"; if (session != null) { sessionId = session.sessionId; activeScreen = session.getActiveScreen(); } - let cmdInputHeight = model.inputModel.cmdInputHeight.get(); - if (cmdInputHeight == 0) { - cmdInputHeight = textmeasure.baseCmdInputHeight(GlobalModel.lineHeightEnv); // this is the base size of cmdInput (measured using devtools) - } const isHidden = GlobalModel.activeMainView.get() != "session"; const mainSidebarModel = GlobalModel.mainSidebarModel; @@ -303,9 +297,9 @@ class WorkspaceView extends React.Component<{}, {}> {
-
+
+ From af7cc866d30d09c0394b7b4202b6fe934856b5b5 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 8 Apr 2024 13:15:33 -0700 Subject: [PATCH 12/15] Make cmdinput prompt smaller, properly handle select events to take priority over onclick (#558) --- src/app/workspace/cmdinput/cmdinput.less | 1 + src/app/workspace/cmdinput/cmdinput.tsx | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/workspace/cmdinput/cmdinput.less b/src/app/workspace/cmdinput/cmdinput.less index 66785d4e4..fd3489c06 100644 --- a/src/app/workspace/cmdinput/cmdinput.less +++ b/src/app/workspace/cmdinput/cmdinput.less @@ -49,6 +49,7 @@ .base-cmdinput { position: relative; + cursor: text; // Rather than apply the padding to the whole container, we will apply it to the inner contents directly. // This is more fragile, but allows us to capture a larger target area for the individual components. --padding-top: var(--termpad); diff --git a/src/app/workspace/cmdinput/cmdinput.tsx b/src/app/workspace/cmdinput/cmdinput.tsx index 002116d87..df763774f 100644 --- a/src/app/workspace/cmdinput/cmdinput.tsx +++ b/src/app/workspace/cmdinput/cmdinput.tsx @@ -62,7 +62,7 @@ class CmdInput extends React.Component<{}, {}> { } @boundMethod - baseCmdInputClick(e: React.MouseEvent): void { + baseCmdInputClick(e: React.SyntheticEvent): void { if (this.promptRef.current != null) { if (this.promptRef.current.contains(e.target)) { return; @@ -231,7 +231,12 @@ class CmdInput extends React.Component<{}, {}> {
-
+
0}>
Date: Mon, 8 Apr 2024 13:47:03 -0700 Subject: [PATCH 13/15] undo text cursor on prompt area (#559) --- src/app/workspace/cmdinput/cmdinput.less | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/workspace/cmdinput/cmdinput.less b/src/app/workspace/cmdinput/cmdinput.less index fd3489c06..66785d4e4 100644 --- a/src/app/workspace/cmdinput/cmdinput.less +++ b/src/app/workspace/cmdinput/cmdinput.less @@ -49,7 +49,6 @@ .base-cmdinput { position: relative; - cursor: text; // Rather than apply the padding to the whole container, we will apply it to the inner contents directly. // This is more fragile, but allows us to capture a larger target area for the individual components. --padding-top: var(--termpad); From 6919dbfb5f77077ec6bf700b5fffddea50d1417b Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 9 Apr 2024 11:33:23 -0700 Subject: [PATCH 14/15] force our exit trap to always run (for rtnstate commands) (#556) * add command validation to shellapi. mock out bash/zsh versions * implement validate command fn bash and zsh * test validate command * change rtnstate commands to always end with a builtin, so we always get our exit trap to run * simplify the rtnstate modification, don't add the 'wait' (as this is a different problem/feature) * update schema --- waveshell/pkg/shellapi/bashapi.go | 17 +++++++++++ waveshell/pkg/shellapi/bashparser.go | 11 +++++-- waveshell/pkg/shellapi/shellapi.go | 2 ++ waveshell/pkg/shellapi/zshapi.go | 35 ++++++++++++++++------ waveshell/pkg/shellapi/zshapi_test.go | 34 ++++++++++++++++++++++ waveshell/pkg/shexec/client.go | 3 +- waveshell/pkg/shexec/shexec.go | 42 ++++++++------------------- waveshell/pkg/utilfn/utilfn.go | 38 ++++++++++++++++++++++++ wavesrv/db/schema.sql | 10 +++---- wavesrv/pkg/remote/remote.go | 2 +- 10 files changed, 145 insertions(+), 49 deletions(-) diff --git a/waveshell/pkg/shellapi/bashapi.go b/waveshell/pkg/shellapi/bashapi.go index d2cbedfa0..8f1d18126 100644 --- a/waveshell/pkg/shellapi/bashapi.go +++ b/waveshell/pkg/shellapi/bashapi.go @@ -6,6 +6,7 @@ package shellapi import ( "bytes" "context" + "errors" "fmt" "os/exec" "runtime" @@ -266,6 +267,22 @@ func (bashShellApi) MakeShellStateDiff(oldState *packet.ShellState, oldStateHash return rtn, nil } +func (bashShellApi) ValidateCommandSyntax(cmdStr string) error { + ctx, cancelFn := context.WithTimeout(context.Background(), ValidateTimeout) + defer cancelFn() + cmd := exec.CommandContext(ctx, GetLocalBashPath(), "-n", "-c", cmdStr) + output, err := cmd.CombinedOutput() + if err == nil { + return nil + } + errStr := utilfn.GetFirstLine(string(output)) + errStr = strings.TrimPrefix(errStr, "bash: -c: ") + if len(errStr) == 0 { + return errors.New("bash syntax error") + } + return errors.New(errStr) +} + func (bashShellApi) ApplyShellStateDiff(oldState *packet.ShellState, diff *packet.ShellStateDiff) (*packet.ShellState, error) { if oldState == nil { return nil, fmt.Errorf("cannot apply diff, oldState is nil") diff --git a/waveshell/pkg/shellapi/bashparser.go b/waveshell/pkg/shellapi/bashparser.go index f56b33b37..889171ae4 100644 --- a/waveshell/pkg/shellapi/bashparser.go +++ b/waveshell/pkg/shellapi/bashparser.go @@ -214,9 +214,16 @@ func bashParseDeclareOutput(state *packet.ShellState, declareBytes []byte, pvarB firstParseErr = err } } - if decl != nil && !BashNoStoreVarNames[decl.Name] { - declMap[decl.Name] = decl + if decl == nil { + continue } + if BashNoStoreVarNames[decl.Name] { + continue + } + if strings.HasPrefix(decl.Name, "_wavetemp_") { + continue + } + declMap[decl.Name] = decl } pvarMap := parseExtVarOutput(pvarBytes, "", "") utilfn.CombineMaps(declMap, pvarMap) diff --git a/waveshell/pkg/shellapi/shellapi.go b/waveshell/pkg/shellapi/shellapi.go index 81c481269..c948a56ee 100644 --- a/waveshell/pkg/shellapi/shellapi.go +++ b/waveshell/pkg/shellapi/shellapi.go @@ -28,6 +28,7 @@ import ( ) const GetVersionTimeout = 5 * time.Second +const ValidateTimeout = 2 * time.Second const GetGitBranchCmdStr = `printf "GITBRANCH %s\x00" "$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"` const GetK8sContextCmdStr = `printf "K8SCONTEXT %s\x00" "$(kubectl config current-context 2>/dev/null)"` const GetK8sNamespaceCmdStr = `printf "K8SNAMESPACE %s\x00" "$(kubectl config view --minify --output 'jsonpath={..namespace}' 2>/dev/null)"` @@ -69,6 +70,7 @@ type ShellStateOutput struct { type ShellApi interface { GetShellType() string MakeExitTrap(fdNum int) (string, []byte) + ValidateCommandSyntax(cmdStr string) error GetLocalMajorVersion() string GetLocalShellPath() string GetRemoteShellPath() string diff --git a/waveshell/pkg/shellapi/zshapi.go b/waveshell/pkg/shellapi/zshapi.go index 71cbdf4b4..461543c70 100644 --- a/waveshell/pkg/shellapi/zshapi.go +++ b/waveshell/pkg/shellapi/zshapi.go @@ -211,27 +211,41 @@ type ZshMap = map[ZshParamKey]string type zshShellApi struct{} -func (z zshShellApi) GetShellType() string { +func (zshShellApi) GetShellType() string { return packet.ShellType_zsh } -func (z zshShellApi) MakeExitTrap(fdNum int) (string, []byte) { +func (zshShellApi) MakeExitTrap(fdNum int) (string, []byte) { return MakeZshExitTrap(fdNum) } -func (z zshShellApi) GetLocalMajorVersion() string { +func (zshShellApi) GetLocalMajorVersion() string { return GetLocalZshMajorVersion() } -func (z zshShellApi) GetLocalShellPath() string { +func (zshShellApi) GetLocalShellPath() string { return "/bin/zsh" } -func (z zshShellApi) GetRemoteShellPath() string { +func (zshShellApi) GetRemoteShellPath() string { return "zsh" } -func (z zshShellApi) MakeRunCommand(cmdStr string, opts RunCommandOpts) string { +func (zshShellApi) ValidateCommandSyntax(cmdStr string) error { + ctx, cancelFn := context.WithTimeout(context.Background(), ValidateTimeout) + defer cancelFn() + cmd := exec.CommandContext(ctx, GetLocalZshPath(), "-n", "-c", cmdStr) + output, err := cmd.CombinedOutput() + if err == nil { + return nil + } + if len(output) == 0 { + return errors.New("zsh syntax error") + } + return errors.New(utilfn.GetFirstLine(string(output))) +} + +func (zshShellApi) MakeRunCommand(cmdStr string, opts RunCommandOpts) string { if !opts.Sudo { return cmdStr } @@ -242,7 +256,7 @@ func (z zshShellApi) MakeRunCommand(cmdStr string, opts RunCommandOpts) string { } } -func (z zshShellApi) MakeShExecCommand(cmdStr string, rcFileName string, usePty bool) *exec.Cmd { +func (zshShellApi) MakeShExecCommand(cmdStr string, rcFileName string, usePty bool) *exec.Cmd { return exec.Command(GetLocalZshPath(), "-l", "-i", "-c", cmdStr) } @@ -274,7 +288,7 @@ func (z zshShellApi) GetShellState(ctx context.Context, outCh chan ShellStateOut outCh <- ShellStateOutput{ShellState: rtn, Stats: stats} } -func (z zshShellApi) GetBaseShellOpts() string { +func (zshShellApi) GetBaseShellOpts() string { return BaseZshOpts } @@ -343,6 +357,9 @@ func (z zshShellApi) MakeRcFileStr(pk *packet.RunPacketType) string { if strings.HasPrefix(varDecl.Name, "ZFTP_") { continue } + if strings.HasPrefix(varDecl.Name, "_wavetemp_") { + continue + } if varDecl.IsExtVar { continue } @@ -709,7 +726,7 @@ func makeZshFuncsStrForShellState(fnMap map[ZshParamKey]string) string { return buf.String() } -func (z zshShellApi) ParseShellStateOutput(outputBytes []byte) (*packet.ShellState, *packet.ShellStateStats, error) { +func (zshShellApi) ParseShellStateOutput(outputBytes []byte) (*packet.ShellState, *packet.ShellStateStats, error) { if scbase.IsDevMode() && DebugState { writeStateToFile(packet.ShellType_zsh, outputBytes) } diff --git a/waveshell/pkg/shellapi/zshapi_test.go b/waveshell/pkg/shellapi/zshapi_test.go index 557ac99ec..b5d1762e4 100644 --- a/waveshell/pkg/shellapi/zshapi_test.go +++ b/waveshell/pkg/shellapi/zshapi_test.go @@ -2,7 +2,9 @@ package shellapi import ( "fmt" + "log" "testing" + "time" ) func testSingleDecl(declStr string) { @@ -45,3 +47,35 @@ func TestZshSafeDeclName(t *testing.T) { t.Errorf("should not be safe") } } + +func testValidate(t *testing.T, shell string, cmd string, expectErr bool) { + var sapi ShellApi + if shell == "bash" { + sapi = bashShellApi{} + } else if shell == "zsh" { + sapi = zshShellApi{} + } else { + t.Errorf("unknown shell %q", shell) + return + } + tstart := time.Now() + err := sapi.ValidateCommandSyntax(cmd) + log.Printf("shell:%s dur:%v err: %v\n", shell, time.Since(tstart), err) + if expectErr && err == nil { + t.Errorf("cmd %q, expected error", cmd) + } + if !expectErr && err != nil { + t.Errorf("cmd %q, unexpected error: %v", cmd, err) + } +} + +func TestValidate(t *testing.T) { + testValidate(t, "zsh", "echo foo", false) + testValidate(t, "zsh", "foo >& &", true) + testValidate(t, "zsh", "cd .", false) + testValidate(t, "zsh", "echo foo | grep foo", false) + testValidate(t, "zsh", "x; echo \"hello", true) + testValidate(t, "bash", "echo foo", false) + testValidate(t, "bash", "foo >& &", true) + testValidate(t, "bash", "cd .; echo \"", true) +} diff --git a/waveshell/pkg/shexec/client.go b/waveshell/pkg/shexec/client.go index 9efcf2250..04aea9b68 100644 --- a/waveshell/pkg/shexec/client.go +++ b/waveshell/pkg/shexec/client.go @@ -12,6 +12,7 @@ import ( "github.com/wavetermdev/waveterm/waveshell/pkg/base" "github.com/wavetermdev/waveterm/waveshell/pkg/packet" + "github.com/wavetermdev/waveterm/waveshell/pkg/utilfn" "golang.org/x/crypto/ssh" "golang.org/x/mod/semver" ) @@ -274,7 +275,7 @@ func (cproc *ClientProc) ProxySingleOutput(ck base.CommandKey, sender *packet.Pa cmdDuration := endTs.Sub(cproc.StartTs) donePacket := packet.MakeCmdDonePacket(ck) donePacket.Ts = endTs.UnixMilli() - donePacket.ExitCode = GetExitCode(exitErr) + donePacket.ExitCode = utilfn.GetExitCode(exitErr) donePacket.DurationMs = int64(cmdDuration / time.Millisecond) sender.SendPacket(donePacket) } diff --git a/waveshell/pkg/shexec/shexec.go b/waveshell/pkg/shexec/shexec.go index 9ff6211da..7a5bdef51 100644 --- a/waveshell/pkg/shexec/shexec.go +++ b/waveshell/pkg/shexec/shexec.go @@ -31,6 +31,7 @@ import ( "github.com/wavetermdev/waveterm/waveshell/pkg/shellapi" "github.com/wavetermdev/waveterm/waveshell/pkg/shellenv" "github.com/wavetermdev/waveterm/waveshell/pkg/shellutil" + "github.com/wavetermdev/waveterm/waveshell/pkg/utilfn" "github.com/wavetermdev/waveterm/waveshell/pkg/wlog" "golang.org/x/mod/semver" "golang.org/x/sys/unix" @@ -826,6 +827,10 @@ func RunCommandSimple(pk *packet.RunPacketType, sender *packet.PacketSender, fro var rtnStateWriter *os.File rcFileStr := sapi.MakeRcFileStr(pk) if pk.ReturnState { + err := sapi.ValidateCommandSyntax(pk.Command) + if err != nil { + return nil, err + } pr, pw, err := os.Pipe() if err != nil { return nil, fmt.Errorf("cannot create returnstate pipe: %v", err) @@ -894,7 +899,12 @@ func RunCommandSimple(pk *packet.RunPacketType, sender *packet.PacketSender, fro os.Remove(cmd.TmpRcFileName) }() } - cmd.Cmd = sapi.MakeShExecCommand(pk.Command, rcFileName, pk.UsePty) + fullCmdStr := pk.Command + if pk.ReturnState { + // this ensures that the last command is a shell buitin so we always get our exit trap to run + fullCmdStr = fullCmdStr + "\nexit $? 2> /dev/null" + } + cmd.Cmd = sapi.MakeShExecCommand(fullCmdStr, rcFileName, pk.UsePty) if !pk.StateComplete { cmd.Cmd.Env = os.Environ() } @@ -1075,34 +1085,6 @@ func copyToCirFile(dest *cirfile.File, src io.Reader) error { } } -func GetCmdExitCode(cmd *exec.Cmd, err error) int { - if cmd == nil || cmd.ProcessState == nil { - return GetExitCode(err) - } - status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus) - if !ok { - return cmd.ProcessState.ExitCode() - } - signaled := status.Signaled() - if signaled { - signal := status.Signal() - return 128 + int(signal) - } - exitStatus := status.ExitStatus() - return exitStatus -} - -func GetExitCode(err error) int { - if err == nil { - return 0 - } - if exitErr, ok := err.(*exec.ExitError); ok { - return exitErr.ExitCode() - } else { - return -1 - } -} - func (c *ShExecType) ProcWait() error { exitErr := c.Cmd.Wait() c.Lock.Lock() @@ -1139,7 +1121,7 @@ func (c *ShExecType) WaitForCommand() *packet.CmdDonePacketType { endTs := time.Now() cmdDuration := endTs.Sub(c.StartTs) donePacket.Ts = endTs.UnixMilli() - donePacket.ExitCode = GetCmdExitCode(c.Cmd, exitErr) + donePacket.ExitCode = utilfn.GetCmdExitCode(c.Cmd, exitErr) donePacket.DurationMs = int64(cmdDuration / time.Millisecond) if c.FileNames != nil { os.Remove(c.FileNames.StdinFifo) // best effort (no need to check error) diff --git a/waveshell/pkg/utilfn/utilfn.go b/waveshell/pkg/utilfn/utilfn.go index 608e36cc8..b9ef0e5c6 100644 --- a/waveshell/pkg/utilfn/utilfn.go +++ b/waveshell/pkg/utilfn/utilfn.go @@ -15,9 +15,11 @@ import ( mathrand "math/rand" "net/http" "os" + "os/exec" "regexp" "sort" "strings" + "syscall" "unicode/utf8" ) @@ -635,3 +637,39 @@ func DetectMimeType(path string) string { } return rtn } + +func GetCmdExitCode(cmd *exec.Cmd, err error) int { + if cmd == nil || cmd.ProcessState == nil { + return GetExitCode(err) + } + status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus) + if !ok { + return cmd.ProcessState.ExitCode() + } + signaled := status.Signaled() + if signaled { + signal := status.Signal() + return 128 + int(signal) + } + exitStatus := status.ExitStatus() + return exitStatus +} + +func GetExitCode(err error) int { + if err == nil { + return 0 + } + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } else { + return -1 + } +} + +func GetFirstLine(s string) string { + idx := strings.Index(s, "\n") + if idx == -1 { + return s + } + return s[0:idx] +} diff --git a/wavesrv/db/schema.sql b/wavesrv/db/schema.sql index dcdfea604..d7225ff70 100644 --- a/wavesrv/db/schema.sql +++ b/wavesrv/db/schema.sql @@ -27,7 +27,7 @@ CREATE TABLE remote_instance ( festate json NOT NULL, statebasehash varchar(36) NOT NULL, statediffhasharr json NOT NULL -); +, shelltype varchar(20) NOT NULL DEFAULT 'bash'); CREATE TABLE state_base ( basehash varchar(36) PRIMARY KEY, ts bigint NOT NULL, @@ -55,10 +55,8 @@ CREATE TABLE remote ( lastconnectts bigint NOT NULL, local boolean NOT NULL, archived boolean NOT NULL, - remoteidx int NOT NULL, - statevars json NOT NULL DEFAULT '{}', - sshconfigsrc varchar(36) NOT NULL DEFAULT 'waveterm-manual', - openaiopts json NOT NULL DEFAULT '{}'); + remoteidx int NOT NULL +, statevars json NOT NULL DEFAULT '{}', openaiopts json NOT NULL DEFAULT '{}', sshconfigsrc varchar(36) NOT NULL DEFAULT 'waveterm-manual', shellpref varchar(20) NOT NULL DEFAULT 'detect'); CREATE TABLE history ( historyid varchar(36) PRIMARY KEY, ts bigint NOT NULL, @@ -203,7 +201,7 @@ CREATE TABLE IF NOT EXISTS "cmd" ( rtnstate boolean NOT NULL, rtnbasehash varchar(36) NOT NULL, rtndiffhasharr json NOT NULL, - runout json NOT NULL, + runout json NOT NULL, restartts bigint NOT NULL DEFAULT 0, PRIMARY KEY (screenid, lineid) ); CREATE TABLE cmd_migrate20 ( diff --git a/wavesrv/pkg/remote/remote.go b/wavesrv/pkg/remote/remote.go index d01b7dc43..99de0f9b6 100644 --- a/wavesrv/pkg/remote/remote.go +++ b/wavesrv/pkg/remote/remote.go @@ -1735,7 +1735,7 @@ func (msh *MShellProc) Launch(interactive bool) { msh.WriteToPtyBuffer("connected to %s\n", remoteCopy.RemoteCanonicalName) go func() { exitErr := cproc.Cmd.Wait() - exitCode := shexec.GetExitCode(exitErr) + exitCode := utilfn.GetExitCode(exitErr) msh.WithLock(func() { if msh.Status == StatusConnected || msh.Status == StatusConnecting { msh.Status = StatusDisconnected From 73e5515e17535a717244f413fc142cd3dc401747 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 9 Apr 2024 11:48:34 -0700 Subject: [PATCH 15/15] when the window gets focus, if our mainview is session (and no modals are open), refocus either the cmdinput or the cmd (#562) --- src/models/modals.ts | 4 ++++ src/models/model.ts | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/src/models/modals.ts b/src/models/modals.ts index 4c4dcfd99..cbd0554a4 100644 --- a/src/models/modals.ts +++ b/src/models/modals.ts @@ -24,6 +24,10 @@ class ModalsModel { })(); callback && callback(); } + + hasOpenModals(): boolean { + return this.store.length > 0; + } } export { ModalsModel }; diff --git a/src/models/model.ts b/src/models/model.ts index 0bf49e82c..898871b79 100644 --- a/src/models/model.ts +++ b/src/models/model.ts @@ -203,6 +203,7 @@ class Model { getApi().onNativeThemeUpdated(this.onNativeThemeUpdated.bind(this)); document.addEventListener("keydown", this.docKeyDownHandler.bind(this)); document.addEventListener("selectionchange", this.docSelectionChangeHandler.bind(this)); + window.addEventListener("focus", this.windowFocus.bind(this)); setTimeout(() => this.getClientDataLoop(1), 10); this.lineHeightEnv = { // defaults @@ -229,6 +230,12 @@ class Model { }); } + windowFocus(): void { + if (this.activeMainView.get() == "session" && !this.modalsModel.hasOpenModals()) { + this.refocus(); + } + } + fetchTerminalThemes() { const url = new URL(this.getBaseHostPort() + "/config/terminal-themes"); fetch(url, { method: "get", body: null, headers: this.getFetchHeaders() })