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_Sidebar = "sidebar";
export const ConfirmKey_HideShellPrompt = "hideshellprompt";
export const NoStrPos = -1;

View File

@ -188,14 +188,14 @@
position: relative;
display: flex;
align-items: center;
color: #9e9e9e;
color: @term-bright-white;
transition: color 250ms cubic-bezier(0.4, 0, 0.23, 1);
}
input[type="checkbox"] + label > span {
display: flex;
justify-content: center;
align-items: center;
margin-right: 16px;
margin-right: 10px;
width: 20px;
height: 20px;
background: transparent;
@ -205,10 +205,6 @@
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"]:focus + label > span {
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<
{ 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() {
const { checked, onChange, label, id } = this.props;
const { label, className, id } = this.props;
const { checkedInternal } = this.state;
const checkboxId = id || this.generatedId;
return (
<div className="checkbox">
<div className={cn("checkbox", className)}>
<input
type="checkbox"
id={id}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
aria-checked={checked}
id={checkboxId}
checked={checkedInternal}
onChange={this.handleChange}
aria-checked={checkedInternal}
role="checkbox"
/>
<label htmlFor={id}>
<label htmlFor={checkboxId}>
<span></span>
{label}
</label>

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import { GlobalModel, RemotesModel, GlobalCommandRunner } from "../../model/mode
import { Button, IconButton, Status } from "../common/common";
import * as T from "../../types/types";
import * as util from "../../util/util";
import * as appconst from "../appconst";
import "./connections.less";
@ -74,10 +75,31 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered
}
@boundMethod
handleImportSshConfig(): void {
importSshConfig(): void {
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
handleRead(remoteId: string): void {
GlobalModel.remotesModel.openReadModal(remoteId);
@ -163,8 +185,8 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered
onClick={() => this.handleRead(item.remoteid)} // Moved onClick here
>
<td className="col-name">
<Status status={this.getStatus(item.status)} text=""></Status>
{this.getName(item)}&nbsp;{this.getImportSymbol(item)}
<Status status={this.getStatus(item.status)} text=""></Status>
{this.getName(item)}&nbsp;{this.getImportSymbol(item)}
</td>
<td className="col-type">
<div>{item.remotetype}</div>

View File

@ -3287,6 +3287,13 @@ class Model {
}
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(() => {
this.alertMessage.set(alertMessage);
GlobalModel.modalsModel.pushModal(appconst.ALERT);
@ -4652,6 +4659,12 @@ class CommandRunner {
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) {
let kwargs = {
nohist: "1",

View File

@ -473,10 +473,15 @@ type FeOptsType = {
termfontsize: number;
};
type ConfirmFlagsType = {
[k: string]: boolean;
};
type ClientOptsType = {
notelemetry: boolean;
noreleasecheck: boolean;
acceptedtos: number;
confirmflags: ConfirmFlagsType;
};
type ReleaseInfoType = {
@ -525,6 +530,7 @@ type AlertMessageType = {
message: string;
confirm?: boolean;
markdown?: boolean;
confirmflag?: string;
};
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 RemoteColorNames = []string{"red", "green", "yellow", "blue", "magenta", "cyan", "white", "orange"}
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 NoHistCmds = []string{"_compgen", "line", "history", "_killserver"}
@ -213,6 +214,7 @@ func init() {
registerCmdFn("client:set", ClientSetCommand)
registerCmdFn("client:notifyupdatewriter", ClientNotifyUpdateWriterCommand)
registerCmdFn("client:accepttos", ClientAcceptTosCommand)
registerCmdFn("client:setconfirmflag", ClientConfirmFlagCommand)
registerCmdFn("sidebar:open", SidebarOpenCommand)
registerCmdFn("sidebar:close", SidebarCloseCommand)
@ -4089,6 +4091,57 @@ func ClientAcceptTosCommand(ctx context.Context, pk *scpacket.FeCommandPacketTyp
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 {
if len(key) > MaxOpenAIAPITokenLen {
return fmt.Errorf("invalid openai token, too long")

View File

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

View File

@ -240,7 +240,8 @@ func (c *parseContext) tokenizeDQ() ([]*WordType, bool) {
// returns (words, eofexit)
// 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) {
state := &tokenizeOutputState{}
isExpSubShell := c.QC.cur() == WordTypeDP

View File

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