Add a modal confirmation before installing WaveShell (#212)

* init

* integrate showShellPrompt flag

* renive debugging code

* remove debugging code

* run gofmt. add migration files.

* remove debugging code

* remove migrations and adjust code. show prompt on import ssh configs as well.

* fix show/hide logic

* reset mmap.go

* use resolveBool and utilfn.ContainsStr

* make AlertModal take a generic 'confirmkey' instead of hard coding hideShellPrompt

* rename confirmkey to confirmflag (to be consistent).  move confirmflag checking into the alertmodal.  short circuit with Promise.resolve(true) if noConfirm checked.

* disable buttons while status is 'connecting'

* minor refactor
This commit is contained in:
Red J Adaya 2024-01-11 07:00:18 +08:00 committed by GitHub
parent 00e709d515
commit 8f39f0fc5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 219 additions and 34 deletions

View File

@ -13,4 +13,6 @@ export const LineContainer_Main = "main";
export const LineContainer_History = "history"; export const LineContainer_History = "history";
export const LineContainer_Sidebar = "sidebar"; export const LineContainer_Sidebar = "sidebar";
export const ConfirmKey_HideShellPrompt = "hideshellprompt";
export const NoStrPos = -1; export const NoStrPos = -1;

View File

@ -188,14 +188,14 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
color: #9e9e9e; color: @term-bright-white;
transition: color 250ms cubic-bezier(0.4, 0, 0.23, 1); transition: color 250ms cubic-bezier(0.4, 0, 0.23, 1);
} }
input[type="checkbox"] + label > span { input[type="checkbox"] + label > span {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin-right: 16px; margin-right: 10px;
width: 20px; width: 20px;
height: 20px; height: 20px;
background: transparent; background: transparent;
@ -205,10 +205,6 @@
transition: all 250ms cubic-bezier(0.4, 0, 0.23, 1); transition: all 250ms cubic-bezier(0.4, 0, 0.23, 1);
} }
input[type="checkbox"] + label:hover,
input[type="checkbox"]:focus + label {
color: #fff;
}
input[type="checkbox"] + label:hover > span, input[type="checkbox"] + label:hover > span,
input[type="checkbox"]:focus + label > span { input[type="checkbox"]:focus + label > span {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);

View File

@ -99,23 +99,57 @@ class Toggle extends React.Component<{ checked: boolean; onChange: (value: boole
} }
class Checkbox extends React.Component< class Checkbox extends React.Component<
{ checked: boolean; onChange: (value: boolean) => void; label: React.ReactNode; id: string }, {
{} checked?: boolean;
defaultChecked?: boolean;
onChange: (value: boolean) => void;
label: React.ReactNode;
className?: string;
id?: string;
},
{ checkedInternal: boolean }
> { > {
generatedId;
static idCounter = 0;
constructor(props) {
super(props);
this.state = {
checkedInternal: this.props.checked !== undefined ? this.props.checked : Boolean(this.props.defaultChecked),
};
this.generatedId = `checkbox-${Checkbox.idCounter++}`;
}
componentDidUpdate(prevProps) {
if (this.props.checked !== undefined && this.props.checked !== prevProps.checked) {
this.setState({ checkedInternal: this.props.checked });
}
}
handleChange = (e) => {
const newChecked = e.target.checked;
if (this.props.checked === undefined) {
this.setState({ checkedInternal: newChecked });
}
this.props.onChange(newChecked);
};
render() { render() {
const { checked, onChange, label, id } = this.props; const { label, className, id } = this.props;
const { checkedInternal } = this.state;
const checkboxId = id || this.generatedId;
return ( return (
<div className="checkbox"> <div className={cn("checkbox", className)}>
<input <input
type="checkbox" type="checkbox"
id={id} id={checkboxId}
checked={checked} checked={checkedInternal}
onChange={(e) => onChange(e.target.checked)} onChange={this.handleChange}
aria-checked={checked} aria-checked={checkedInternal}
role="checkbox" role="checkbox"
/> />
<label htmlFor={id}> <label htmlFor={checkboxId}>
<span></span> <span></span>
{label} {label}
</label> </label>

View File

@ -673,11 +673,15 @@
} }
.alert-modal { .alert-modal {
width: 500px; width: 510px;
.wave-modal-content { .wave-modal-content {
.wave-modal-body { .wave-modal-body {
padding: 40px 20px; padding: 40px 20px;
.dontshowagain-text {
margin-top: 15px;
}
} }
} }
} }

View File

@ -23,9 +23,11 @@ import {
Tooltip, Tooltip,
Button, Button,
Status, Status,
Checkbox,
} from "../common"; } from "../common";
import * as util from "../../../util/util"; import * as util from "../../../util/util";
import * as textmeasure from "../../../util/textmeasure"; import * as textmeasure from "../../../util/textmeasure";
import * as appconst from "../../appconst";
import { ClientDataType } from "../../../types/types"; import { ClientDataType } from "../../../types/types";
import { Screen } from "../../../model/model"; import { Screen } from "../../../model/model";
import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg"; import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg";
@ -216,6 +218,15 @@ class AlertModal extends React.Component<{}, {}> {
GlobalModel.confirmAlert(); GlobalModel.confirmAlert();
} }
@boundMethod
handleDontShowAgain(checked: boolean) {
let message = GlobalModel.alertMessage.get();
if (message.confirmflag == null) {
return;
}
GlobalCommandRunner.clientSetConfirmFlag(message.confirmflag, checked);
}
render() { render() {
let message = GlobalModel.alertMessage.get(); let message = GlobalModel.alertMessage.get();
let title = message?.title ?? (message?.confirm ? "Confirm" : "Alert"); let title = message?.title ?? (message?.confirm ? "Confirm" : "Alert");
@ -229,16 +240,27 @@ class AlertModal extends React.Component<{}, {}> {
<Markdown text={message?.message ?? ""} /> <Markdown text={message?.message ?? ""} />
</If> </If>
<If condition={!message?.markdown}>{message?.message}</If> <If condition={!message?.markdown}>{message?.message}</If>
<If condition={message.confirmflag}>
<Checkbox
onChange={this.handleDontShowAgain}
label={"Don't show me this again"}
className="dontshowagain-text"
/>
</If>
</div> </div>
<div className="wave-modal-footer"> <div className="wave-modal-footer">
<If condition={isConfirm}> <If condition={isConfirm}>
<Button theme="secondary" onClick={this.closeModal}> <Button theme="secondary" onClick={this.closeModal}>
Cancel Cancel
</Button> </Button>
<Button autoFocus={true} onClick={this.handleOK}>Ok</Button> <Button autoFocus={true} onClick={this.handleOK}>
Ok
</Button>
</If> </If>
<If condition={!isConfirm}> <If condition={!isConfirm}>
<Button autoFocus={true} onClick={this.handleOK}>Ok</Button> <Button autoFocus={true} onClick={this.handleOK}>
Ok
</Button>
</If> </If>
</div> </div>
</Modal> </Modal>
@ -503,6 +525,10 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
this.errorStr = mobx.observable.box(this.remoteEdit?.errorstr ?? null, { name: "CreateRemote-errorStr" }); this.errorStr = mobx.observable.box(this.remoteEdit?.errorstr ?? null, { name: "CreateRemote-errorStr" });
} }
componentDidMount(): void {
GlobalModel.getClientData();
}
remoteCName(): string { remoteCName(): string {
let hostName = this.tempHostName.get(); let hostName = this.tempHostName.get();
if (hostName == "") { if (hostName == "") {
@ -521,6 +547,27 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
return this.remoteEdit?.errorstr ?? null; return this.remoteEdit?.errorstr ?? null;
} }
@boundMethod
handleOk(): void {
this.showShellPrompt(this.submitRemote);
}
@boundMethod
showShellPrompt(cb: () => void): void {
let prtn = GlobalModel.showAlert({
message:
"You are about to install WaveShell on a remote machine. Please be aware that WaveShell will be executed on the remote system.",
confirm: true,
confirmflag: appconst.ConfirmKey_HideShellPrompt,
});
prtn.then((confirm) => {
if (!confirm) {
return;
}
cb();
});
}
@boundMethod @boundMethod
submitRemote(): void { submitRemote(): void {
mobx.action(() => { mobx.action(() => {
@ -581,12 +628,6 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
}); });
} }
@boundMethod
handleClose(): void {
this.model.closeModal();
this.model.setRecentConnAdded(false);
}
@boundMethod @boundMethod
handleChangeKeyFile(value: string): void { handleChangeKeyFile(value: string): void {
mobx.action(() => { mobx.action(() => {
@ -802,7 +843,7 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
<div className="settings-field settings-error">Error: {this.getErrorStr()}</div> <div className="settings-field settings-error">Error: {this.getErrorStr()}</div>
</If> </If>
</div> </div>
<Modal.Footer onCancel={this.handleClose} onOk={this.submitRemote} okLabel="Connect" /> <Modal.Footer onCancel={this.model.closeModal} onOk={this.handleOk} okLabel="Connect" />
</Modal> </Modal>
); );
} }
@ -1097,6 +1138,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
let termFontSize = GlobalModel.termFontSize.get(); let termFontSize = GlobalModel.termFontSize.get();
let termWidth = textmeasure.termWidthFromCols(RemotePtyCols, termFontSize); let termWidth = textmeasure.termWidthFromCols(RemotePtyCols, termFontSize);
let remoteAliasText = util.isBlank(remote.remotealias) ? "(none)" : remote.remotealias; let remoteAliasText = util.isBlank(remote.remotealias) ? "(none)" : remote.remotealias;
let selectedRemoteStatus = this.getSelectedRemote().status;
return ( return (
<Modal className="rconndetail-modal"> <Modal className="rconndetail-modal">
@ -1175,7 +1217,18 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
</div> </div>
</div> </div>
</div> </div>
<Modal.Footer onOk={this.handleClose} onCancel={this.handleClose} okLabel="Done" /> <div className="wave-modal-footer">
<Button
theme="secondary"
disabled={selectedRemoteStatus == "connecting"}
onClick={this.handleClose}
>
Cancel
</Button>
<Button disabled={selectedRemoteStatus == "connecting"} onClick={this.handleClose}>
Done
</Button>
</div>
</Modal> </Modal>
); );
} }

View File

@ -11,6 +11,7 @@ import { GlobalModel, RemotesModel, GlobalCommandRunner } from "../../model/mode
import { Button, IconButton, Status } from "../common/common"; import { Button, IconButton, Status } from "../common/common";
import * as T from "../../types/types"; import * as T from "../../types/types";
import * as util from "../../util/util"; import * as util from "../../util/util";
import * as appconst from "../appconst";
import "./connections.less"; import "./connections.less";
@ -74,10 +75,31 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered
} }
@boundMethod @boundMethod
handleImportSshConfig(): void { importSshConfig(): void {
GlobalCommandRunner.importSshConfig(); GlobalCommandRunner.importSshConfig();
} }
@boundMethod
handleImportSshConfig(): void {
this.showShellPrompt(this.importSshConfig);
}
@boundMethod
showShellPrompt(cb: () => void): void {
let prtn = GlobalModel.showAlert({
message:
"You are about to install WaveShell on a remote machine. Please be aware that WaveShell will be executed on the remote system.",
confirm: true,
confirmflag: appconst.ConfirmKey_HideShellPrompt,
});
prtn.then((confirm) => {
if (!confirm) {
return;
}
cb();
});
}
@boundMethod @boundMethod
handleRead(remoteId: string): void { handleRead(remoteId: string): void {
GlobalModel.remotesModel.openReadModal(remoteId); GlobalModel.remotesModel.openReadModal(remoteId);
@ -163,8 +185,8 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered
onClick={() => this.handleRead(item.remoteid)} // Moved onClick here onClick={() => this.handleRead(item.remoteid)} // Moved onClick here
> >
<td className="col-name"> <td className="col-name">
<Status status={this.getStatus(item.status)} text=""></Status> <Status status={this.getStatus(item.status)} text=""></Status>
{this.getName(item)}&nbsp;{this.getImportSymbol(item)} {this.getName(item)}&nbsp;{this.getImportSymbol(item)}
</td> </td>
<td className="col-type"> <td className="col-type">
<div>{item.remotetype}</div> <div>{item.remotetype}</div>

View File

@ -3287,6 +3287,13 @@ class Model {
} }
showAlert(alertMessage: AlertMessageType): Promise<boolean> { showAlert(alertMessage: AlertMessageType): Promise<boolean> {
if (alertMessage.confirmflag != null) {
let cdata = GlobalModel.clientData.get();
let noConfirm = cdata.clientopts?.confirmflags?.[alertMessage.confirmflag];
if (noConfirm) {
return Promise.resolve(true);
}
}
mobx.action(() => { mobx.action(() => {
this.alertMessage.set(alertMessage); this.alertMessage.set(alertMessage);
GlobalModel.modalsModel.pushModal(appconst.ALERT); GlobalModel.modalsModel.pushModal(appconst.ALERT);
@ -4652,6 +4659,12 @@ class CommandRunner {
GlobalModel.submitCommand("client", "accepttos", null, { nohist: "1" }, true); GlobalModel.submitCommand("client", "accepttos", null, { nohist: "1" }, true);
} }
clientSetConfirmFlag(flag: string, value: boolean): Promise<CommandRtnType> {
let kwargs = { nohist: "1" };
let valueStr = value ? "1" : "0";
return GlobalModel.submitCommand("client", "setconfirmflag", [flag, valueStr], kwargs, false);
}
editBookmark(bookmarkId: string, desc: string, cmdstr: string) { editBookmark(bookmarkId: string, desc: string, cmdstr: string) {
let kwargs = { let kwargs = {
nohist: "1", nohist: "1",

View File

@ -473,10 +473,15 @@ type FeOptsType = {
termfontsize: number; termfontsize: number;
}; };
type ConfirmFlagsType = {
[k: string]: boolean;
};
type ClientOptsType = { type ClientOptsType = {
notelemetry: boolean; notelemetry: boolean;
noreleasecheck: boolean; noreleasecheck: boolean;
acceptedtos: number; acceptedtos: number;
confirmflags: ConfirmFlagsType;
}; };
type ReleaseInfoType = { type ReleaseInfoType = {
@ -525,6 +530,7 @@ type AlertMessageType = {
message: string; message: string;
confirm?: boolean; confirm?: boolean;
markdown?: boolean; markdown?: boolean;
confirmflag?: string;
}; };
type HistorySearchParams = { type HistorySearchParams = {

View File

@ -89,6 +89,7 @@ var ColorNames = []string{"yellow", "blue", "pink", "mint", "cyan", "violet", "o
var TabIcons = []string{"square", "sparkle", "fire", "ghost", "cloud", "compass", "crown", "droplet", "graduation-cap", "heart", "file"} var TabIcons = []string{"square", "sparkle", "fire", "ghost", "cloud", "compass", "crown", "droplet", "graduation-cap", "heart", "file"}
var RemoteColorNames = []string{"red", "green", "yellow", "blue", "magenta", "cyan", "white", "orange"} var RemoteColorNames = []string{"red", "green", "yellow", "blue", "magenta", "cyan", "white", "orange"}
var RemoteSetArgs = []string{"alias", "connectmode", "key", "password", "autoinstall", "color"} var RemoteSetArgs = []string{"alias", "connectmode", "key", "password", "autoinstall", "color"}
var ConfirmFlags = []string{"hideshellprompt"}
var ScreenCmds = []string{"run", "comment", "cd", "cr", "clear", "sw", "reset", "signal", "chat"} var ScreenCmds = []string{"run", "comment", "cd", "cr", "clear", "sw", "reset", "signal", "chat"}
var NoHistCmds = []string{"_compgen", "line", "history", "_killserver"} var NoHistCmds = []string{"_compgen", "line", "history", "_killserver"}
@ -213,6 +214,7 @@ func init() {
registerCmdFn("client:set", ClientSetCommand) registerCmdFn("client:set", ClientSetCommand)
registerCmdFn("client:notifyupdatewriter", ClientNotifyUpdateWriterCommand) registerCmdFn("client:notifyupdatewriter", ClientNotifyUpdateWriterCommand)
registerCmdFn("client:accepttos", ClientAcceptTosCommand) registerCmdFn("client:accepttos", ClientAcceptTosCommand)
registerCmdFn("client:setconfirmflag", ClientConfirmFlagCommand)
registerCmdFn("sidebar:open", SidebarOpenCommand) registerCmdFn("sidebar:open", SidebarOpenCommand)
registerCmdFn("sidebar:close", SidebarCloseCommand) registerCmdFn("sidebar:close", SidebarCloseCommand)
@ -4089,6 +4091,57 @@ func ClientAcceptTosCommand(ctx context.Context, pk *scpacket.FeCommandPacketTyp
return update, nil return update, nil
} }
var confirmKeyRe = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
// confirm flags must be all lowercase and only contain letters, numbers, and underscores (and start with letter)
func ClientConfirmFlagCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
// Check for valid arguments length
if len(pk.Args) < 2 {
return nil, fmt.Errorf("invalid arguments: expected at least 2, got %d", len(pk.Args))
}
// Extract confirmKey and value from pk.Args
confirmKey := pk.Args[0]
if !confirmKeyRe.MatchString(confirmKey) {
return nil, fmt.Errorf("invalid confirm flag key: %s", confirmKey)
}
value := resolveBool(pk.Args[1], true)
validKey := utilfn.ContainsStr(ConfirmFlags, confirmKey)
if !validKey {
return nil, fmt.Errorf("invalid confirm flag key: %s", confirmKey)
}
clientData, err := sstore.EnsureClientData(ctx)
if err != nil {
return nil, fmt.Errorf("cannot retrieve client data: %v", err)
}
// Initialize ConfirmFlags if it's nil
if clientData.ClientOpts.ConfirmFlags == nil {
clientData.ClientOpts.ConfirmFlags = make(map[string]bool)
}
// Set the confirm flag
clientData.ClientOpts.ConfirmFlags[confirmKey] = value
err = sstore.SetClientOpts(ctx, clientData.ClientOpts)
if err != nil {
return nil, fmt.Errorf("error updating client data: %v", err)
}
// Retrieve updated client data
clientData, err = sstore.EnsureClientData(ctx)
if err != nil {
return nil, fmt.Errorf("cannot retrieve updated client data: %v", err)
}
update := &sstore.ModelUpdate{
ClientData: clientData,
}
return update, nil
}
func validateOpenAIAPIToken(key string) error { func validateOpenAIAPIToken(key string) error {
if len(key) > MaxOpenAIAPITokenLen { if len(key) > MaxOpenAIAPITokenLen {
return fmt.Errorf("invalid openai token, too long") return fmt.Errorf("invalid openai token, too long")

View File

@ -11,10 +11,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/wavesrv/pkg/remote" "github.com/wavetermdev/waveterm/wavesrv/pkg/remote"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scpacket" "github.com/wavetermdev/waveterm/wavesrv/pkg/scpacket"
"github.com/wavetermdev/waveterm/wavesrv/pkg/sstore" "github.com/wavetermdev/waveterm/wavesrv/pkg/sstore"
"github.com/google/uuid"
) )
const ( const (

View File

@ -240,7 +240,8 @@ func (c *parseContext) tokenizeDQ() ([]*WordType, bool) {
// returns (words, eofexit) // returns (words, eofexit)
// backticks (WordTypeBQ) handle backslash in a special way, but that seems to mainly effect execution (not completion) // backticks (WordTypeBQ) handle backslash in a special way, but that seems to mainly effect execution (not completion)
// de_backslash => removes initial backslash in \`, \\, and \$ before execution //
// de_backslash => removes initial backslash in \`, \\, and \$ before execution
func (c *parseContext) tokenizeRaw() ([]*WordType, bool) { func (c *parseContext) tokenizeRaw() ([]*WordType, bool) {
state := &tokenizeOutputState{} state := &tokenizeOutputState{}
isExpSubShell := c.QC.cur() == WordTypeDP isExpSubShell := c.QC.cur() == WordTypeDP

View File

@ -267,9 +267,10 @@ func (tdata *TelemetryData) Scan(val interface{}) error {
} }
type ClientOptsType struct { type ClientOptsType struct {
NoTelemetry bool `json:"notelemetry,omitempty"` NoTelemetry bool `json:"notelemetry,omitempty"`
NoReleaseCheck bool `json:"noreleasecheck,omitempty"` NoReleaseCheck bool `json:"noreleasecheck,omitempty"`
AcceptedTos int64 `json:"acceptedtos,omitempty"` AcceptedTos int64 `json:"acceptedtos,omitempty"`
ConfirmFlags map[string]bool `json:"confirmflags,omitempty"`
} }
type FeOptsType struct { type FeOptsType struct {