Screen MemStore (#197)

* working on an in-memory store for screen information

* nostrpos sentinel

* textareainput now tracks selection (to update backend)

* make websocket connections much safer.  add a defer/panic handler for each ws message handled on backend.  don't allow client to reconnect to backend ws handler more than once per second (handles issue with lots of fast fails)

* use onSelect to have frontend textarea sync state to backend ScreenMem store

* restore cmdline when switching screens

* prettier
This commit is contained in:
Mike Sawka 2023-12-26 12:59:25 -08:00 committed by GitHub
parent 9980a6b204
commit 6a1b2c8bd4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 346 additions and 121 deletions

View File

@ -11,3 +11,5 @@ export const CLIENT_SETTINGS = "clientSettings";
export const LineContainer_Main = "main";
export const LineContainer_History = "history";
export const LineContainer_Sidebar = "sidebar";
export const NoStrPos = -1;

View File

@ -127,10 +127,7 @@ class CmdInput extends React.Component<{}, {}> {
numRunningLines = mobx.computed(() => win.getRunningCmdLines().length).get();
}
return (
<div
ref={this.cmdInputRef}
className={cn("cmd-input", { "has-info": infoShow }, { active: focusVal })}
>
<div ref={this.cmdInputRef} className={cn("cmd-input", { "has-info": infoShow }, { active: focusVal })}>
<If condition={historyShow}>
<div className="cmd-input-grow-spacer"></div>
<HistoryInfo />
@ -153,7 +150,9 @@ class CmdInput extends React.Component<{}, {}> {
</If>
<div key="prompt" className="cmd-input-context">
<div className="has-text-white">
<span ref={this.promptRef}><Prompt rptr={rptr} festate={feState} /></span>
<span ref={this.promptRef}>
<Prompt rptr={rptr} festate={feState} />
</span>
</div>
<If condition={numRunningLines > 0}>
<div onClick={() => this.toggleFilter(screen)} className="cmd-input-filter">
@ -176,7 +175,11 @@ class CmdInput extends React.Component<{}, {}> {
<div className="button is-static">{inputMode}</div>
</div>
</If>
<TextAreaInput key={textAreaInputKey} onHeightChange={this.handleInnerHeightUpdate} />
<TextAreaInput
key={textAreaInputKey}
screen={screen}
onHeightChange={this.handleInnerHeightUpdate}
/>
<div className="control cmd-exec">
{/**<div onClick={inputModel.toggleExpandInput} className="hint-item color-white">
{inputModel.inputExpanded.get() ? "shrink" : "expand"} input ({renderCmdText("E")})

View File

@ -4,11 +4,15 @@
import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import type * as T from "../../../types/types";
import { boundMethod } from "autobind-decorator";
import cn from "classnames";
import { GlobalModel, GlobalCommandRunner } from "../../../model/model";
import { GlobalModel, GlobalCommandRunner, Screen } from "../../../model/model";
import { getMonoFontSize } from "../../../util/textmeasure";
import { isModKeyPress, hasNoModifiers } from "../../../util/util";
import * as appconst from "../../appconst";
type OV<T> = mobx.IObservableValue<T>;
function pageSize(div: any): number {
if (div == null) {
@ -35,21 +39,44 @@ function scrollDiv(div: any, amt: number) {
}
@mobxReact.observer
class TextAreaInput extends React.Component<{ onHeightChange: () => void }, {}> {
class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () => void }, {}> {
lastTab: boolean = false;
lastHistoryUpDown: boolean = false;
lastTabCurLine: mobx.IObservableValue<string> = mobx.observable.box(null);
lastTabCurLine: OV<string> = mobx.observable.box(null);
lastFocusType: string = null;
mainInputRef: React.RefObject<any>;
historyInputRef: React.RefObject<any>;
controlRef: React.RefObject<any>;
mainInputRef: React.RefObject<HTMLTextAreaElement> = React.createRef();
historyInputRef: React.RefObject<HTMLInputElement> = React.createRef();
controlRef: React.RefObject<HTMLDivElement> = React.createRef();
lastHeight: number = 0;
lastSP: T.StrWithPos = { str: "", pos: appconst.NoStrPos };
version: OV<number> = mobx.observable.box(0); // forces render updates
constructor(props) {
super(props);
this.mainInputRef = React.createRef();
this.historyInputRef = React.createRef();
this.controlRef = React.createRef();
incVersion(): void {
let v = this.version.get();
mobx.action(() => this.version.set(v + 1))();
}
getCurSP(): T.StrWithPos {
let textarea = this.mainInputRef.current;
if (textarea == null) {
return this.lastSP;
}
let str = textarea.value;
let pos = textarea.selectionStart;
let endPos = textarea.selectionEnd;
if (pos != endPos) {
return { str, pos: appconst.NoStrPos };
}
return { str, pos };
}
updateSP(): void {
let curSP = this.getCurSP();
if (curSP.str == this.lastSP.str && curSP.pos == this.lastSP.pos) {
return;
}
this.lastSP = curSP;
GlobalModel.sendCmdInputText(this.props.screen.screenId, curSP);
}
setFocus(): void {
@ -100,6 +127,7 @@ class TextAreaInput extends React.Component<{ onHeightChange: () => void }, {}>
this.lastFocusType = focusType;
}
this.checkHeight(false);
this.updateSP();
}
componentDidUpdate() {
@ -112,10 +140,11 @@ class TextAreaInput extends React.Component<{ onHeightChange: () => void }, {}>
this.lastFocusType = focusType;
}
let inputModel = GlobalModel.inputModel;
if (inputModel.forceCursorPos.get() != null) {
let fcpos = inputModel.forceCursorPos.get();
if (fcpos != null && fcpos != appconst.NoStrPos) {
if (this.mainInputRef.current != null) {
this.mainInputRef.current.selectionStart = inputModel.forceCursorPos.get();
this.mainInputRef.current.selectionEnd = inputModel.forceCursorPos.get();
this.mainInputRef.current.selectionStart = fcpos;
this.mainInputRef.current.selectionEnd = fcpos;
}
mobx.action(() => inputModel.forceCursorPos.set(null))();
}
@ -124,6 +153,7 @@ class TextAreaInput extends React.Component<{ onHeightChange: () => void }, {}>
this.setFocus();
}
this.checkHeight(true);
this.updateSP();
}
getLinePos(elem: any): { numLines: number; linePos: number } {
@ -294,6 +324,11 @@ class TextAreaInput extends React.Component<{ onHeightChange: () => void }, {}>
})();
}
@boundMethod
onSelect(e: any) {
this.incVersion();
}
@boundMethod
onHistoryKeyDown(e: any) {
let inputModel = GlobalModel.inputModel;
@ -386,7 +421,7 @@ class TextAreaInput extends React.Component<{ onHeightChange: () => void }, {}>
}
let cutValue = value.substr(0, selStart);
let restValue = value.substr(selStart);
let cmdLineUpdate = { cmdline: restValue, cursorpos: 0 };
let cmdLineUpdate = { str: restValue, pos: 0 };
navigator.clipboard.writeText(cutValue);
GlobalModel.inputModel.updateCmdLine(cmdLineUpdate);
}
@ -439,7 +474,7 @@ class TextAreaInput extends React.Component<{ onHeightChange: () => void }, {}>
let cutValue = value.slice(cutSpot, selStart);
let prevValue = value.slice(0, cutSpot);
let restValue = value.slice(selStart);
let cmdLineUpdate = { cmdline: prevValue + restValue, cursorpos: prevValue.length };
let cmdLineUpdate = { str: prevValue + restValue, pos: prevValue.length };
navigator.clipboard.writeText(cutValue);
GlobalModel.inputModel.updateCmdLine(cmdLineUpdate);
}
@ -459,7 +494,7 @@ class TextAreaInput extends React.Component<{ onHeightChange: () => void }, {}>
return;
}
let newValue = value.substr(0, selStart) + clipText + value.substr(selEnd);
let cmdLineUpdate = { cmdline: newValue, cursorpos: selStart + clipText.length };
let cmdLineUpdate = { str: newValue, pos: selStart + clipText.length };
GlobalModel.inputModel.updateCmdLine(cmdLineUpdate);
});
}
@ -525,6 +560,7 @@ class TextAreaInput extends React.Component<{ onHeightChange: () => void }, {}>
let numLines = curLine.split("\n").length;
let maxCols = this.getTextAreaMaxCols();
let longLine = false;
let version = this.version.get(); // to force reactions
if (maxCols != 0 && curLine.length >= maxCols - 4) {
longLine = true;
}
@ -559,6 +595,7 @@ class TextAreaInput extends React.Component<{ onHeightChange: () => void }, {}>
value={curLine}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
onSelect={this.onSelect}
className={cn("textarea", { "display-disabled": disabled })}
></textarea>
<input

View File

@ -1444,10 +1444,12 @@ class InputModel {
}
}
updateCmdLine(cmdLine: CmdLineUpdateType): void {
updateCmdLine(cmdLine: T.StrWithPos): void {
mobx.action(() => {
this.setCurLine(cmdLine.cmdline);
this.forceCursorPos.set(cmdLine.cursorpos);
this.setCurLine(cmdLine.str);
if (cmdLine.pos != appconst.NoStrPos) {
this.forceCursorPos.set(cmdLine.pos);
}
})();
}
@ -3164,6 +3166,7 @@ class Model {
showLinks: OV<boolean> = mobx.observable.box(true, {
name: "model-showLinks",
});
packetSeqNum: number = 0;
constructor() {
this.clientId = getApi().getId();
@ -3217,6 +3220,11 @@ class Model {
setTimeout(() => this.getClientDataLoop(1), 10);
}
getNextPacketSeqNum(): number {
this.packetSeqNum++;
return this.packetSeqNum;
}
getPlatform(): string {
if (this.platform != null) {
return this.platform;
@ -3618,6 +3626,12 @@ class Model {
let newContext = this.getUIContext();
if (oldContext.sessionid != newContext.sessionid || oldContext.screenid != newContext.screenid) {
this.inputModel.resetInput();
if ("cmdline" in genUpdate) {
// TODO a bit of a hack since this update gets applied in runUpdate_internal.
// we then undo that update with the resetInput, and then redo it with the line below
// not sure how else to handle this for now though
this.inputModel.updateCmdLine(genUpdate.cmdline);
}
} else if (remotePtrToString(oldContext.remote) != remotePtrToString(newContext.remote)) {
this.inputModel.resetHistory();
}
@ -3996,10 +4010,7 @@ class Model {
getActiveIds(): [string, string] {
let activeSession = this.getActiveSession();
let activeScreen = this.getActiveScreen();
return [
activeSession == null ? null : activeSession.sessionId,
activeScreen == null ? null : activeScreen.screenId,
];
return [activeSession?.sessionId, activeScreen?.screenId];
}
_loadScreenLinesAsync(newWin: ScreenLines) {
@ -4118,6 +4129,16 @@ class Model {
this.ws.pushMessage(inputPacket);
}
sendCmdInputText(screenId: string, sp: T.StrWithPos) {
let pk: T.CmdInputTextPacketType = {
type: "cmdinputtext",
seqnum: this.getNextPacketSeqNum(),
screenid: screenId,
text: sp,
};
this.ws.pushMessage(pk);
}
resolveUserIdToName(userid: string): string {
return "@[unknown]";
}

View File

@ -20,6 +20,7 @@ class WSControl {
wsLog: mobx.IObservableArray<string> = mobx.observable.array([], { name: "wsLog" });
authKey: string;
baseHostPort: string;
lastReconnectTime: number = 0;
constructor(baseHostPort: string, clientId: string, authKey: string, messageCallback: (any) => void) {
this.baseHostPort = baseHostPort;
@ -51,6 +52,7 @@ class WSControl {
if (this.open.get()) {
return;
}
this.lastReconnectTime = Date.now();
this.log(sprintf("try reconnect (%s)", desc));
this.opening = true;
this.wsConn = new WebSocket(this.baseHostPort + "/ws?clientid=" + this.clientId);
@ -78,6 +80,9 @@ class WSControl {
if (this.reconnectTimes < timeoutArr.length) {
timeout = timeoutArr[this.reconnectTimes];
}
if (Date.now() - this.lastReconnectTime < 500) {
timeout = 1;
}
if (timeout > 0) {
this.log(sprintf("sleeping %ds", timeout));
}

View File

@ -210,6 +210,13 @@ type WatchScreenPacketType = {
authkey: string;
};
type CmdInputTextPacketType = {
type: string;
seqnum: number;
screenid: string;
text: StrWithPos;
};
type TermWinSize = {
rows: number;
cols: number;
@ -267,7 +274,7 @@ type ModelUpdateType = {
lines?: LineType[];
cmd?: CmdDataType;
info?: InfoType;
cmdline?: CmdLineUpdateType;
cmdline?: StrWithPos;
remotes?: RemoteType[];
history?: HistoryInfoType;
connect?: boolean;
@ -666,6 +673,11 @@ type ModalStoreEntry = {
uniqueKey: string;
};
type StrWithPos = {
str: string;
pos: number;
};
export type {
SessionDataType,
LineStateType,
@ -741,4 +753,6 @@ export type {
ExtFile,
LineContainerStrs,
ModalStoreEntry,
StrWithPos,
CmdInputTextPacketType,
};

View File

@ -2142,7 +2142,7 @@ func CompGenCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (ssto
return nil, nil
}
update := &sstore.ModelUpdate{
CmdLine: &sstore.CmdLineType{CmdLine: newSP.Str, CursorPos: newSP.Pos},
CmdLine: &utilfn.StrWithPos{Str: newSP.Str, Pos: newSP.Pos},
}
return update, nil
}

View File

@ -12,12 +12,14 @@ import (
"github.com/wavetermdev/waveterm/waveshell/pkg/base"
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
"github.com/wavetermdev/waveterm/wavesrv/pkg/sstore"
"github.com/wavetermdev/waveterm/wavesrv/pkg/utilfn"
)
const FeCommandPacketStr = "fecmd"
const WatchScreenPacketStr = "watchscreen"
const FeInputPacketStr = "feinput"
const RemoteInputPacketStr = "remoteinput"
const CmdInputTextPacketStr = "cmdinputtext"
type FeCommandPacketType struct {
Type string `json:"type"`
@ -83,11 +85,27 @@ type WatchScreenPacketType struct {
AuthKey string `json:"authkey"`
}
type CmdInputTextPacketType struct {
Type string `json:"type"`
SeqNum int `json:"seqnum"`
ScreenId string `json:"screenid"`
Text utilfn.StrWithPos `json:"text"`
}
func init() {
packet.RegisterPacketType(FeCommandPacketStr, reflect.TypeOf(FeCommandPacketType{}))
packet.RegisterPacketType(WatchScreenPacketStr, reflect.TypeOf(WatchScreenPacketType{}))
packet.RegisterPacketType(FeInputPacketStr, reflect.TypeOf(FeInputPacketType{}))
packet.RegisterPacketType(RemoteInputPacketStr, reflect.TypeOf(RemoteInputPacketType{}))
packet.RegisterPacketType(CmdInputTextPacketStr, reflect.TypeOf(CmdInputTextPacketType{}))
}
func (*CmdInputTextPacketType) GetType() string {
return CmdInputTextPacketStr
}
func MakeCmdInputTextPacket(screenId string) *CmdInputTextPacketType {
return &CmdInputTextPacketType{Type: CmdInputTextPacketStr, ScreenId: screenId}
}
func (*FeCommandPacketType) GetType() string {

View File

@ -7,6 +7,7 @@ import (
"context"
"fmt"
"log"
"runtime/debug"
"sync"
"time"
@ -214,6 +215,76 @@ func (ws *WSState) handleWatchScreen(wsPk *scpacket.WatchScreenPacketType) error
return nil
}
func (ws *WSState) processMessage(msgBytes []byte) error {
defer func() {
r := recover()
if r == nil {
return
}
log.Printf("[scws] panic in processMessage: %v\n", r)
debug.PrintStack()
}()
pk, err := packet.ParseJsonPacket(msgBytes)
if err != nil {
return fmt.Errorf("error unmarshalling ws message: %w", err)
}
if pk.GetType() == scpacket.WatchScreenPacketStr {
wsPk := pk.(*scpacket.WatchScreenPacketType)
err := ws.handleWatchScreen(wsPk)
if err != nil {
return fmt.Errorf("client:%s error %w", ws.ClientId, err)
}
return nil
}
isAuth := ws.IsAuthenticated()
if !isAuth {
return fmt.Errorf("cannot process ws-packet[%s], not authenticated", pk.GetType())
}
if pk.GetType() == scpacket.FeInputPacketStr {
feInputPk := pk.(*scpacket.FeInputPacketType)
if feInputPk.Remote.OwnerId != "" {
return fmt.Errorf("error cannot send input to remote with ownerid")
}
if feInputPk.Remote.RemoteId == "" {
return fmt.Errorf("error invalid input packet, remoteid is not set")
}
err := RemoteInputMapQueue.Enqueue(feInputPk.Remote.RemoteId, func() {
sendErr := sendCmdInput(feInputPk)
if sendErr != nil {
log.Printf("[scws] sending command input: %v\n", err)
}
})
if err != nil {
return fmt.Errorf("[error] could not queue sendCmdInput: %w", err)
}
return nil
}
if pk.GetType() == scpacket.RemoteInputPacketStr {
inputPk := pk.(*scpacket.RemoteInputPacketType)
if inputPk.RemoteId == "" {
return fmt.Errorf("error invalid remoteinput packet, remoteid is not set")
}
go func() {
sendErr := remote.SendRemoteInput(inputPk)
if sendErr != nil {
log.Printf("[scws] error processing remote input: %v\n", err)
}
}()
return nil
}
if pk.GetType() == scpacket.CmdInputTextPacketStr {
cmdInputPk := pk.(*scpacket.CmdInputTextPacketType)
if cmdInputPk.ScreenId == "" {
return fmt.Errorf("error invalid cmdinput packet, screenid is not set")
}
// no need for goroutine for memory ops
sstore.ScreenMemSetCmdInputText(cmdInputPk.ScreenId, cmdInputPk.Text, cmdInputPk.SeqNum)
return nil
}
return fmt.Errorf("got ws bad message: %v", pk.GetType())
}
func (ws *WSState) RunWSRead() {
shell := ws.GetShell()
if shell == nil {
@ -221,62 +292,11 @@ func (ws *WSState) RunWSRead() {
}
shell.WriteJson(map[string]interface{}{"type": "hello"}) // let client know we accepted this connection, ignore error
for msgBytes := range shell.ReadChan {
pk, err := packet.ParseJsonPacket(msgBytes)
err := ws.processMessage(msgBytes)
if err != nil {
log.Printf("error unmarshalling ws message: %v\n", err)
continue
// TODO send errors back to client? likely unrecoverable
log.Printf("[scws] %v\n", err)
}
if pk.GetType() == scpacket.WatchScreenPacketStr {
wsPk := pk.(*scpacket.WatchScreenPacketType)
err := ws.handleWatchScreen(wsPk)
if err != nil {
// TODO send errors back to client, likely unrecoverable
log.Printf("[ws %s] error %v\n", ws.ClientId, err)
}
continue
}
isAuth := ws.IsAuthenticated()
if !isAuth {
log.Printf("[error] cannot process ws-packet[%s], not authenticated\n", pk.GetType())
continue
}
if pk.GetType() == scpacket.FeInputPacketStr {
feInputPk := pk.(*scpacket.FeInputPacketType)
if feInputPk.Remote.OwnerId != "" {
log.Printf("[error] cannot send input to remote with ownerid\n")
continue
}
if feInputPk.Remote.RemoteId == "" {
log.Printf("[error] invalid input packet, remoteid is not set\n")
continue
}
err := RemoteInputMapQueue.Enqueue(feInputPk.Remote.RemoteId, func() {
err = sendCmdInput(feInputPk)
if err != nil {
log.Printf("[error] sending command input: %v\n", err)
}
})
if err != nil {
log.Printf("[error] could not queue sendCmdInput: %v\n", err)
continue
}
continue
}
if pk.GetType() == scpacket.RemoteInputPacketStr {
inputPk := pk.(*scpacket.RemoteInputPacketType)
if inputPk.RemoteId == "" {
log.Printf("[error] invalid remoteinput packet, remoteid is not set\n")
continue
}
go func() {
err = remote.SendRemoteInput(inputPk)
if err != nil {
log.Printf("[error] processing remote input: %v\n", err)
}
}()
continue
}
log.Printf("got ws bad message: %v\n", pk.GetType())
}
}

View File

@ -1015,7 +1015,12 @@ func SwitchScreenById(ctx context.Context, sessionId string, screenId string) (*
if err != nil {
return nil, err
}
return &ModelUpdate{ActiveSessionId: sessionId, Sessions: []*SessionType{bareSession}}, nil
update := &ModelUpdate{ActiveSessionId: sessionId, Sessions: []*SessionType{bareSession}}
memState := GetScreenMemState(screenId)
if memState != nil {
update.CmdLine = &memState.CmdInputText
}
return update, nil
}
// screen may not exist at this point (so don't query screen table)

View File

@ -0,0 +1,96 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
// in-memory storage for waveterm server
package sstore
import (
"log"
"sync"
"github.com/wavetermdev/waveterm/wavesrv/pkg/utilfn"
)
// global lock for all memory operations
// memory ops are very fast, so this is not a bottleneck
var MemLock *sync.Mutex = &sync.Mutex{}
var ScreenMemStore map[string]*ScreenMemState = make(map[string]*ScreenMemState) // map of screenid -> ScreenMemState
const (
ScreenIndicator_None = ""
ScreenIndicator_Error = "error"
ScreenIndicator_Success = "success"
ScreenIndicator_Output = "output"
)
var screenIndicatorLevels map[string]int = map[string]int{
ScreenIndicator_None: 0,
ScreenIndicator_Output: 1,
ScreenIndicator_Success: 2,
ScreenIndicator_Error: 3,
}
func dumpScreenMemStore() {
MemLock.Lock()
defer MemLock.Unlock()
for k, v := range ScreenMemStore {
log.Printf(" ScreenMemStore[%s] = %+v\n", k, v)
}
}
// returns true if i1 > i2
func isIndicatorGreater(i1 string, i2 string) bool {
return screenIndicatorLevels[i1] > screenIndicatorLevels[i2]
}
type ScreenMemState struct {
NumRunningCommands int `json:"numrunningcommands,omitempty"`
IndicatorType string `json:"indicatortype,omitempty"`
CmdInputText utilfn.StrWithPos `json:"cmdinputtext,omitempty"`
CmdInputSeqNum int `json:"cmdinputseqnum,omitempty"`
}
func ScreenMemSetCmdInputText(screenId string, sp utilfn.StrWithPos, seqNum int) {
MemLock.Lock()
defer MemLock.Unlock()
if ScreenMemStore[screenId] == nil {
ScreenMemStore[screenId] = &ScreenMemState{}
}
if seqNum <= ScreenMemStore[screenId].CmdInputSeqNum {
return
}
ScreenMemStore[screenId].CmdInputText = sp
ScreenMemStore[screenId].CmdInputSeqNum = seqNum
}
func ScreenMemSetNumRunningCommands(screenId string, num int) {
MemLock.Lock()
defer MemLock.Unlock()
if ScreenMemStore[screenId] == nil {
ScreenMemStore[screenId] = &ScreenMemState{}
}
ScreenMemStore[screenId].NumRunningCommands = num
}
func ScreenMemCombineIndicator(screenId string, indicator string) {
MemLock.Lock()
defer MemLock.Unlock()
if ScreenMemStore[screenId] == nil {
ScreenMemStore[screenId] = &ScreenMemState{}
}
if isIndicatorGreater(indicator, ScreenMemStore[screenId].IndicatorType) {
ScreenMemStore[screenId].IndicatorType = indicator
}
}
// safe because we return a copy
func GetScreenMemState(screenId string) *ScreenMemState {
MemLock.Lock()
defer MemLock.Unlock()
ptr := ScreenMemStore[screenId]
if ptr == nil {
return nil
}
rtn := *ptr
return &rtn
}

View File

@ -7,6 +7,8 @@ import (
"fmt"
"log"
"sync"
"github.com/wavetermdev/waveterm/wavesrv/pkg/utilfn"
)
var MainBus *UpdateBus = MakeUpdateBus()
@ -43,7 +45,7 @@ type ModelUpdate struct {
Line *LineType `json:"line,omitempty"`
Lines []*LineType `json:"lines,omitempty"`
Cmd *CmdType `json:"cmd,omitempty"`
CmdLine *CmdLineType `json:"cmdline,omitempty"`
CmdLine *utilfn.StrWithPos `json:"cmdline,omitempty"`
Info *InfoMsgType `json:"info,omitempty"`
ClearInfo bool `json:"clearinfo,omitempty"`
Remotes []interface{} `json:"remotes,omitempty"` // []*remote.RemoteState
@ -144,11 +146,6 @@ type HistoryInfoType struct {
Show bool `json:"show"`
}
type CmdLineType struct {
CmdLine string `json:"cmdline"`
CursorPos int `json:"cursorpos"`
}
type UpdateChannel struct {
ScreenId string
ClientId string

View File

@ -146,9 +146,12 @@ func IsPrefix(strs []string, test string) bool {
return false
}
// sentinel value for StrWithPos.Pos to indicate no position
const NoStrPos = -1
type StrWithPos struct {
Str string
Pos int // this is a 'rune' position (not a byte position)
Str string `json:"str"`
Pos int `json:"pos"` // this is a 'rune' position (not a byte position)
}
func (sp StrWithPos) String() string {
@ -158,22 +161,26 @@ func (sp StrWithPos) String() string {
func ParseToSP(s string) StrWithPos {
idx := strings.Index(s, "[*]")
if idx == -1 {
return StrWithPos{Str: s}
return StrWithPos{Str: s, Pos: NoStrPos}
}
return StrWithPos{Str: s[0:idx] + s[idx+3:], Pos: utf8.RuneCountInString(s[0:idx])}
}
func strWithCursor(str string, pos int) string {
if pos == NoStrPos {
return str
}
if pos < 0 {
// invalid position
return "[*]_" + str
}
if pos >= len(str) {
if pos > len(str) {
// invalid position
return str + "_[*]"
}
if pos == len(str) {
return str + "[*]"
}
var rtn []rune
for _, ch := range str {
if len(rtn) == pos {