Split up modals.tsx file (#220)

* init

* diconnected and clientstop modals

* alert and createremoteconn modals

* tabswitcher, view and edit remoteconn modals

* migrate clientstop modal to Modal

* LinkButton

* import styles for createremoteconn modal

* import tabswitcher modal styles

* disable buttons while status is 'connecting'

* minor refactor

* apply changes to viewremoteconn modal from main
This commit is contained in:
Red J Adaya
2024-01-11 09:42:03 -08:00
committed by GitHub
parent 8f39f0fc5e
commit 077cb2be03
24 changed files with 2531 additions and 13 deletions
+2 -1
View File
@@ -17,7 +17,8 @@ import { BookmarksView } from "./bookmarks/bookmarks";
import { HistoryView } from "./history/history";
import { ConnectionsView } from "./connections/connections";
import { MainSideBar } from "./sidebar/sidebar";
import { DisconnectedModal, ClientStopModal, ModalsProvider } from "./common/modals/modals";
import { DisconnectedModal, ClientStopModal } from "./common/modals";
import { ModalsProvider } from "./common/modals/provider";
import { ErrorBoundary } from "./common/error/errorboundary";
import "./app.less";
+9 -9
View File
@@ -266,6 +266,7 @@ interface ButtonProps {
color?: string;
style?: React.CSSProperties;
autoFocus?: boolean;
className?: string;
}
class Button extends React.Component<ButtonProps> {
@@ -319,25 +320,24 @@ export default IconButton;
interface LinkButtonProps extends ButtonProps {
href: string;
rel?: string;
target?: string;
}
class LinkButton extends IconButton {
class LinkButton extends React.Component<LinkButtonProps> {
render() {
// @ts-ignore
const { href, target, leftIcon, rightIcon, children, theme, variant }: LinkButtonProps = this.props;
const { leftIcon, rightIcon, children, className, ...rest } = this.props;
return (
<a href={href} target={target} className={`wave-button link-button`}>
<button {...this.props} className={`icon-button ${theme} ${variant}`}>
{leftIcon && <span className="icon-left">{leftIcon}</span>}
{children}
{rightIcon && <span className="icon-right">{rightIcon}</span>}
</button>
<a {...rest} className={cn(`wave-button link-button`, className)}>
{leftIcon && <span className="icon-left">{leftIcon}</span>}
{children}
{rightIcon && <span className="icon-right">{rightIcon}</span>}
</a>
);
}
}
interface StatusProps {
status: "green" | "red" | "gray" | "yellow";
text: string;
+114
View File
@@ -0,0 +1,114 @@
@import "../../../app/common/themes/themes.less";
.about-modal {
.wave-modal-content {
gap: 24px;
.wave-modal-body {
margin-bottom: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
.about-section {
display: flex;
align-items: center;
gap: 16px;
align-self: stretch;
width: 100%;
.logo-wrapper {
width: 72px;
height: 72px;
flex-shrink: 0;
img {
border-radius: 10px;
}
}
.text-wrapper {
display: flex;
align-items: flex-start;
flex-direction: column;
gap: 4px;
align-self: stretch;
font-style: normal;
line-height: 20px;
div:first-child {
color: @term-bright-white;
font-size: 14.5px;
}
div:last-child {
color: @term-white;
text-align: left;
}
}
.status {
div {
display: flex;
align-items: center;
margin-bottom: 5px;
i {
font-size: 16px;
margin-right: 10px;
}
}
div:first-child + div {
color: @term-white;
}
}
.status.updated {
div {
display: flex;
align-items: center;
margin-bottom: 5px;
i {
color: @term-green;
}
}
}
.status.outdated {
div {
i {
color: @term-yellow;
}
}
button {
margin-top: 5px;
}
}
}
.about-section:nth-child(3) {
display: flex;
align-items: flex-start;
gap: 10px;
.wave-button-link {
display: flex;
align-items: center;
i {
font-size: 16px;
}
}
}
.about-section:last-child {
margin-bottom: 24px;
color: @term-white;
}
}
}
}
+136
View File
@@ -0,0 +1,136 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { GlobalModel } from "../../../model/model";
import { Modal, LinkButton } from "../common";
import * as util from "../../../util/util";
import logo from "../../assets/waveterm-logo-with-bg.svg";
import "./about.less";
// @ts-ignore
const VERSION = __WAVETERM_VERSION__;
// @ts-ignore
let BUILD = __WAVETERM_BUILD__;
@mobxReact.observer
class AboutModal extends React.Component<{}, {}> {
@boundMethod
closeModal(): void {
mobx.action(() => {
GlobalModel.modalsModel.popModal();
})();
}
@boundMethod
isUpToDate(): boolean {
return true;
}
@boundMethod
updateApp(): void {
// GlobalCommandRunner.updateApp();
}
@boundMethod
getStatus(isUpToDate: boolean): JSX.Element {
// TODO no up-to-date status reporting
return (
<div className="status updated">
<div className="text-selectable">
Client Version {VERSION} ({BUILD})
</div>
</div>
);
if (isUpToDate) {
return (
<div className="status updated">
<div>
<i className="fa-sharp fa-solid fa-circle-check" />
<span>Up to Date</span>
</div>
<div className="selectable">
Client Version {VERSION} ({BUILD})
</div>
</div>
);
}
return (
<div className="status outdated">
<div>
<i className="fa-sharp fa-solid fa-triangle-exclamation" />
<span>Outdated Version</span>
</div>
<div className="selectable">
Client Version {VERSION} ({BUILD})
</div>
<div>
<button onClick={this.updateApp} className="button color-green text-secondary">
Update
</button>
</div>
</div>
);
}
render() {
return (
<Modal className="about-modal">
<Modal.Header onClose={this.closeModal} title="About" />
<div className="wave-modal-body">
<div className="about-section">
<div className="logo-wrapper">
<img src={logo} alt="logo" />
</div>
<div className="text-wrapper">
<div>Wave Terminal</div>
<div className="text-standard">
Modern Terminal for
<br />
Seamless Workflow
</div>
</div>
</div>
<div className="about-section text-standard">{this.getStatus(this.isUpToDate())}</div>
<div className="about-section">
<LinkButton
className="secondary solid"
href={util.makeExternLink("https://github.com/wavetermdev/waveterm")}
target="_blank"
leftIcon={<i className="fa-brands fa-github"></i>}
>
Github
</LinkButton>
<LinkButton
className="secondary solid"
href={util.makeExternLink("https://www.waveterm.dev/")}
target="_blank"
leftIcon={<i className="fa-sharp fa-light fa-globe"></i>}
>
Website
</LinkButton>
<LinkButton
className="secondary solid"
href={util.makeExternLink(
"https://github.com/wavetermdev/waveterm/blob/main/acknowledgements/README.md"
)}
target="_blank"
rel={"noopener"}
leftIcon={<i className="fa-sharp fa-light fa-heart"></i>}
>
Acknowledgements
</LinkButton>
</div>
<div className="about-section text-standard">&copy; 2023 Command Line Inc.</div>
</div>
</Modal>
);
}
}
export { AboutModal };
+11
View File
@@ -0,0 +1,11 @@
@import "../../../app/common/themes/themes.less";
.alert-modal {
width: 500px;
.wave-modal-content {
.wave-modal-body {
padding: 40px 20px;
}
}
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import { boundMethod } from "autobind-decorator";
import { If } from "tsx-control-statements/components";
import { Markdown, Modal, Button, Checkbox } from "../common";
import { GlobalModel, GlobalCommandRunner } from "../../../model/model";
import "./alert.less";
@mobxReact.observer
class AlertModal extends React.Component<{}, {}> {
@boundMethod
closeModal(): void {
GlobalModel.cancelAlert();
}
@boundMethod
handleOK(): void {
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");
let isConfirm = message?.confirm ?? false;
return (
<Modal className="alert-modal">
<Modal.Header onClose={this.closeModal} title={title} />
<div className="wave-modal-body">
<If condition={message?.markdown}>
<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>
</If>
<If condition={!isConfirm}>
<Button autoFocus={true} onClick={this.handleOK}>
Ok
</Button>
</If>
</div>
</Modal>
);
}
}
export { AlertModal };
+11
View File
@@ -0,0 +1,11 @@
@import "../../../app/common/themes/themes.less";
.clientstop-modal {
.inner-content {
display: flex;
flex-direction: column;
padding: 30px;
gap: 20px;
align-items: center;
}
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import { boundMethod } from "autobind-decorator";
import { If } from "tsx-control-statements/components";
import { GlobalModel } from "../../../model/model";
import { Modal, Button } from "../common";
import "./clientstop.less";
@mobxReact.observer
class ClientStopModal extends React.Component<{}, {}> {
@boundMethod
refreshClient() {
GlobalModel.refreshClient();
}
render() {
let model = GlobalModel;
let cdata = model.clientData.get();
return (
<Modal className="clientstop-modal">
<Modal.Header title="Client Not Ready" />
<div className="wave-modal-body">
<div className="modal-content">
<div className="inner-content">
<If condition={cdata == null}>
<div>Cannot get client data.</div>
</If>
<div>
<Button
theme="secondary"
onClick={this.refreshClient}
leftIcon={<i className="fa-sharp fa-solid fa-rotate"></i>}
>
Hard Refresh Client
</Button>
</div>
</div>
</div>
</div>
</Modal>
);
}
}
export { ClientStopModal };
@@ -0,0 +1,24 @@
@import "../../../app/common/themes/themes.less";
.crconn-modal {
width: 452px;
min-height: 411px;
.wave-modal-content {
gap: 24px;
.wave-modal-body {
display: flex;
padding: 0px 20px;
flex-direction: column;
align-items: flex-start;
gap: 12px;
align-self: stretch;
width: 100%;
> div {
width: 100%;
}
}
}
}
+370
View File
@@ -0,0 +1,370 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
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 { GlobalModel, GlobalCommandRunner, RemotesModel } from "../../../model/model";
import * as T from "../../../types/types";
import { Modal, TextField, NumberField, InputDecoration, Dropdown, PasswordField, Tooltip } from "../common";
import * as util from "../../../util/util";
import * as appconst from "../../appconst";
import "./createremoteconn.less";
type OV<V> = mobx.IObservableValue<V>;
@mobxReact.observer
class CreateRemoteConnModal extends React.Component<{}, {}> {
tempAlias: OV<string>;
tempHostName: OV<string>;
tempPort: OV<string>;
tempAuthMode: OV<string>;
tempConnectMode: OV<string>;
tempPassword: OV<string>;
tempKeyFile: OV<string>;
errorStr: OV<string>;
remoteEdit: T.RemoteEditType;
model: RemotesModel;
constructor(props: { remotesModel?: RemotesModel }) {
super(props);
this.model = GlobalModel.remotesModel;
this.remoteEdit = this.model.remoteEdit.get();
this.tempAlias = mobx.observable.box("", { name: "CreateRemote-alias" });
this.tempHostName = mobx.observable.box("", { name: "CreateRemote-hostName" });
this.tempPort = mobx.observable.box("", { name: "CreateRemote-port" });
this.tempAuthMode = mobx.observable.box("none", { name: "CreateRemote-authMode" });
this.tempConnectMode = mobx.observable.box("auto", { name: "CreateRemote-connectMode" });
this.tempKeyFile = mobx.observable.box("", { name: "CreateRemote-keystr" });
this.tempPassword = mobx.observable.box("", { name: "CreateRemote-password" });
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 == "") {
return "[no host]";
}
if (hostName.indexOf("@") == -1) {
hostName = "[no user]@" + hostName;
}
return hostName;
}
getErrorStr(): string {
if (this.errorStr.get() != null) {
return this.errorStr.get();
}
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(() => {
this.errorStr.set(null);
})();
let authMode = this.tempAuthMode.get();
let cname = this.tempHostName.get();
if (cname == "") {
this.errorStr.set("You must specify a 'user@host' value to create a new connection");
return;
}
let kwargs: Record<string, string> = {};
kwargs["alias"] = this.tempAlias.get();
if (this.tempPort.get() != "" && this.tempPort.get() != "22") {
kwargs["port"] = this.tempPort.get();
}
if (authMode == "key" || authMode == "key+password") {
if (this.tempKeyFile.get() == "") {
this.errorStr.set("When AuthMode is set to 'key', you must supply a valid key file name.");
return;
}
kwargs["key"] = this.tempKeyFile.get();
} else {
kwargs["key"] = "";
}
if (authMode == "password" || authMode == "key+password") {
if (this.tempPassword.get() == "") {
this.errorStr.set("When AuthMode is set to 'password', you must supply a password.");
return;
}
kwargs["password"] = this.tempPassword.get();
} else {
kwargs["password"] = "";
}
kwargs["connectmode"] = this.tempConnectMode.get();
kwargs["visual"] = "1";
kwargs["submit"] = "1";
let prtn = GlobalCommandRunner.createRemote(cname, kwargs, false);
prtn.then((crtn) => {
if (crtn.success) {
this.model.setRecentConnAdded(true);
this.model.closeModal();
let crRtn = GlobalCommandRunner.screenSetRemote(cname, true, false);
crRtn.then((crcrtn) => {
if (crcrtn.success) {
return;
}
mobx.action(() => {
this.errorStr.set(crcrtn.error);
})();
});
return;
}
mobx.action(() => {
this.errorStr.set(crtn.error);
})();
});
}
@boundMethod
handleChangeKeyFile(value: string): void {
mobx.action(() => {
this.tempKeyFile.set(value);
})();
}
@boundMethod
handleChangePassword(value: string): void {
mobx.action(() => {
this.tempPassword.set(value);
})();
}
@boundMethod
handleChangeAlias(value: string): void {
mobx.action(() => {
this.tempAlias.set(value);
})();
}
@boundMethod
handleChangeAuthMode(value: string): void {
mobx.action(() => {
this.tempAuthMode.set(value);
})();
}
@boundMethod
handleChangePort(value: string): void {
mobx.action(() => {
this.tempPort.set(value);
})();
}
@boundMethod
handleChangeHostName(value: string): void {
mobx.action(() => {
this.tempHostName.set(value);
})();
}
@boundMethod
handleChangeConnectMode(value: string): void {
mobx.action(() => {
this.tempConnectMode.set(value);
})();
}
render() {
let authMode = this.tempAuthMode.get();
if (this.remoteEdit == null) {
return null;
}
return (
<Modal className="crconn-modal">
<Modal.Header title="Add Connection" onClose={this.model.closeModal} />
<div className="wave-modal-body">
<div className="user-section">
<TextField
label="user@host"
autoFocus={true}
value={this.tempHostName.get()}
onChange={this.handleChangeHostName}
required={true}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={`(Required) The user and host that you want to connect with. This is in the same format as
you would pass to ssh, e.g. "ubuntu@test.mydomain.com".`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<div className="alias-section">
<TextField
label="Alias"
onChange={this.handleChangeAlias}
value={this.tempAlias.get()}
maxLength={100}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={`(Optional) A short alias to use when selecting or displaying this connection.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<div className="port-section">
<NumberField
label="Port"
placeholder="22"
value={this.tempPort.get()}
onChange={this.handleChangePort}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={`(Optional) Defaults to 22. Set if the server you are connecting to listens to a non-standard
SSH port.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<div className="authmode-section">
<Dropdown
label="Auth Mode"
options={[
{ value: "none", label: "none" },
{ value: "key", label: "key" },
{ value: "password", label: "password" },
{ value: "key+password", label: "key+password" },
]}
value={this.tempAuthMode.get()}
onChange={(val: string) => {
this.tempAuthMode.set(val);
}}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={
<ul>
<li>
<b>none</b> - no authentication, or authentication is already
configured in your ssh config.
</li>
<li>
<b>key</b> - use a private key.
</li>
<li>
<b>password</b> - use a password.
</li>
<li>
<b>key+password</b> - use a key with a passphrase.
</li>
</ul>
}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<If condition={authMode == "key" || authMode == "key+password"}>
<TextField
label="SSH Keyfile"
placeholder="keyfile path"
onChange={this.handleChangeKeyFile}
value={this.tempKeyFile.get()}
maxLength={400}
required={true}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={`(Required) The path to your ssh key file.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</If>
<If condition={authMode == "password" || authMode == "key+password"}>
<PasswordField
label={authMode == "password" ? "SSH Password" : "Key Passphrase"}
placeholder="password"
onChange={this.handleChangePassword}
value={this.tempPassword.get()}
maxLength={400}
/>
</If>
<div className="connectmode-section">
<Dropdown
label="Connect Mode"
options={[
{ value: "startup", label: "startup" },
{ value: "auto", label: "auto" },
{ value: "manual", label: "manual" },
]}
value={this.tempConnectMode.get()}
onChange={(val: string) => {
this.tempConnectMode.set(val);
}}
/>
</div>
<If condition={!util.isBlank(this.getErrorStr() as string)}>
<div className="settings-field settings-error">Error: {this.getErrorStr()}</div>
</If>
</div>
<Modal.Footer onCancel={this.model.closeModal} onOk={this.handleOk} okLabel="Connect" />
</Modal>
);
}
}
export { CreateRemoteConnModal };
+47
View File
@@ -0,0 +1,47 @@
@import "../../../app/common/themes/themes.less";
.disconnected-modal {
.wave-modal-content {
.wave-modal-body {
padding: 0;
.modal-content {
footer {
.footer-text-link {
color: @term-white;
cursor: pointer;
}
}
}
.inner-content {
.log {
height: 335px;
margin-bottom: 20px;
overflow: auto;
&::-webkit-scrollbar-track,
&::-webkit-scrollbar-thumb,
&::-webkit-scrollbar-corner {
display: none;
}
&:hover::-webkit-scrollbar-thumb {
display: block;
}
pre {
color: @term-white;
background-color: @term-black;
}
}
}
}
.wave-modal-footer {
button:first-child {
color: @term-green;
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { GlobalModel } from "../../../model/model";
import { Modal, Button } from "../common";
import "./disconnected.less";
const NumOfLines = 50;
@mobxReact.observer
class DisconnectedModal extends React.Component<{}, {}> {
logRef: any = React.createRef();
logs: mobx.IObservableValue<string> = mobx.observable.box("");
logInterval: NodeJS.Timeout = null;
@boundMethod
restartServer() {
GlobalModel.restartWaveSrv();
}
@boundMethod
tryReconnect() {
GlobalModel.ws.connectNow("manual");
}
componentDidMount() {
this.fetchLogs();
this.logInterval = setInterval(() => {
this.fetchLogs();
}, 5000);
}
componentWillUnmount() {
if (this.logInterval) {
clearInterval(this.logInterval);
}
}
componentDidUpdate() {
if (this.logRef.current != null) {
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
}
}
fetchLogs() {
GlobalModel.getLastLogs(
NumOfLines,
mobx.action((logs) => {
this.logs.set(logs);
if (this.logRef.current != null) {
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
}
})
);
}
render() {
return (
<Modal className="disconnected-modal">
<Modal.Header title="Wave Client Disconnected" />
<div className="wave-modal-body">
<div className="modal-content">
<div className="inner-content">
<div className="log" ref={this.logRef}>
<pre>{this.logs.get()}</pre>
</div>
</div>
</div>
</div>
<div className="wave-modal-footer">
<Button
theme="secondary"
onClick={this.tryReconnect}
leftIcon={
<span className="icon">
<i className="fa-sharp fa-solid fa-rotate" />
</span>
}
>
Try Reconnect
</Button>
<Button
theme="secondary"
onClick={this.restartServer}
leftIcon={<i className="fa-sharp fa-solid fa-triangle-exclamation"></i>}
>
Restart Server
</Button>
</div>
</Modal>
);
}
}
export { DisconnectedModal };
+51
View File
@@ -0,0 +1,51 @@
@import "../../../app/common/themes/themes.less";
.erconn-modal {
width: 502px;
min-height: 411px;
.wave-modal-content {
gap: 20px;
.wave-modal-body {
display: flex;
padding: 0px 20px;
flex-direction: column;
align-items: flex-start;
gap: 12px;
align-self: stretch;
width: 100%;
> div {
width: 100%;
}
.name-actions-section {
margin-bottom: 10px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
.name {
color: @term-bright-white;
font-size: 15px;
font-weight: 500;
line-height: 20px;
}
.header-actions {
display: flex;
justify-content: flex-end;
align-items: flex-start;
.wave-button {
padding: 4px 15px;
font-size: 11px;
margin-right: 8px;
}
}
}
}
}
}
+312
View File
@@ -0,0 +1,312 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { If } from "tsx-control-statements/components";
import { boundMethod } from "autobind-decorator";
import { GlobalModel, GlobalCommandRunner, RemotesModel } from "../../../model/model";
import * as T from "../../../types/types";
import { Modal, TextField, InputDecoration, Dropdown, PasswordField, Tooltip } from "../common";
import * as util from "../../../util/util";
import "./editremoteconn.less";
type OV<V> = mobx.IObservableValue<V>;
const PasswordUnchangedSentinel = "--unchanged--";
@mobxReact.observer
class EditRemoteConnModal extends React.Component<{}, {}> {
tempAlias: OV<string>;
tempKeyFile: OV<string>;
tempPassword: OV<string>;
tempConnectMode: OV<string>;
tempAuthMode: OV<string>;
model: RemotesModel;
constructor(props: { remotesModel?: RemotesModel }) {
super(props);
this.model = GlobalModel.remotesModel;
this.tempAlias = mobx.observable.box(null, { name: "EditRemoteSettings-tempAlias" });
this.tempAuthMode = mobx.observable.box(null, { name: "EditRemoteSettings-tempAuthMode" });
this.tempKeyFile = mobx.observable.box(null, { name: "EditRemoteSettings-tempKeyFile" });
this.tempPassword = mobx.observable.box(null, { name: "EditRemoteSettings-tempPassword" });
this.tempConnectMode = mobx.observable.box(null, { name: "EditRemoteSettings-tempConnectMode" });
}
get selectedRemoteId() {
return this.model.selectedRemoteId.get();
}
get selectedRemote(): T.RemoteType {
return GlobalModel.getRemote(this.selectedRemoteId);
}
get remoteEdit(): T.RemoteEditType {
return this.model.remoteEdit.get();
}
get isAuthEditMode(): boolean {
return this.model.isAuthEditMode();
}
componentDidMount(): void {
mobx.action(() => {
this.tempAlias.set(this.selectedRemote?.remotealias);
this.tempKeyFile.set(this.remoteEdit?.keystr);
this.tempPassword.set(this.remoteEdit?.haspassword ? PasswordUnchangedSentinel : "");
this.tempConnectMode.set(this.selectedRemote?.connectmode);
this.tempAuthMode.set(this.selectedRemote?.authtype);
})();
}
componentDidUpdate() {
if (this.selectedRemote == null || this.selectedRemote.archived) {
this.model.deSelectRemote();
}
}
@boundMethod
handleChangeKeyFile(value: string): void {
mobx.action(() => {
this.tempKeyFile.set(value);
})();
}
@boundMethod
handleChangePassword(value: string): void {
mobx.action(() => {
this.tempPassword.set(value);
})();
}
@boundMethod
handleChangeAlias(value: string): void {
mobx.action(() => {
this.tempAlias.set(value);
})();
}
@boundMethod
handleChangeAuthMode(value: string): void {
mobx.action(() => {
this.tempAuthMode.set(value);
})();
}
@boundMethod
handleChangeConnectMode(value: string): void {
mobx.action(() => {
this.tempConnectMode.set(value);
})();
}
@boundMethod
canResetPw(): boolean {
if (this.remoteEdit == null) {
return false;
}
return Boolean(this.remoteEdit.haspassword) && this.tempPassword.get() != PasswordUnchangedSentinel;
}
@boundMethod
resetPw(): void {
mobx.action(() => {
this.tempPassword.set(PasswordUnchangedSentinel);
})();
}
@boundMethod
onFocusPassword(e: any) {
if (this.tempPassword.get() == PasswordUnchangedSentinel) {
e.target.select();
}
}
@boundMethod
submitRemote(): void {
let authMode = this.tempAuthMode.get();
let kwargs: Record<string, string> = {};
if (authMode == "key" || authMode == "key+password") {
let keyStrEq = util.isStrEq(this.tempKeyFile.get(), this.remoteEdit?.keystr);
if (!keyStrEq) {
kwargs["key"] = this.tempKeyFile.get();
}
} else {
if (!util.isBlank(this.tempKeyFile.get())) {
kwargs["key"] = "";
}
}
if (authMode == "password" || authMode == "key+password") {
if (this.tempPassword.get() != PasswordUnchangedSentinel) {
kwargs["password"] = this.tempPassword.get();
}
} else {
if (this.remoteEdit?.haspassword) {
kwargs["password"] = "";
}
}
if (!util.isStrEq(this.tempAlias.get(), this.selectedRemote?.remotealias)) {
kwargs["alias"] = this.tempAlias.get();
}
if (!util.isStrEq(this.tempConnectMode.get(), this.selectedRemote?.connectmode)) {
kwargs["connectmode"] = this.tempConnectMode.get();
}
kwargs["visual"] = "1";
kwargs["submit"] = "1";
GlobalCommandRunner.editRemote(this.selectedRemote?.remoteid, kwargs);
this.model.closeModal();
}
renderAuthModeMessage(): any {
let authMode = this.tempAuthMode.get();
if (authMode == "none") {
return (
<span>
This connection requires no authentication.
<br />
Or authentication is already configured in ssh_config.
</span>
);
}
if (authMode == "key") {
return <span>Use a public/private keypair.</span>;
}
if (authMode == "password") {
return <span>Use a password.</span>;
}
if (authMode == "key+password") {
return <span>Use a public/private keypair with a passphrase.</span>;
}
return null;
}
render() {
let authMode = this.tempAuthMode.get();
if (this.remoteEdit === null || !this.isAuthEditMode) {
return null;
}
return (
<Modal className="erconn-modal">
<Modal.Header title="Edit Connection" onClose={this.model.closeModal} />
<div className="wave-modal-body">
<div className="name-actions-section">
<div className="name text-primary">{util.getRemoteName(this.selectedRemote)}</div>
</div>
<div className="alias-section">
<TextField
label="Alias"
onChange={this.handleChangeAlias}
value={this.tempAlias.get()}
maxLength={100}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={`(Optional) A short alias to use when selecting or displaying this connection.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<div className="authmode-section">
<Dropdown
label="Auth Mode"
options={[
{ value: "none", label: "none" },
{ value: "key", label: "key" },
{ value: "password", label: "password" },
{ value: "key+password", label: "key+password" },
]}
value={this.tempAuthMode.get()}
onChange={this.handleChangeAuthMode}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={
<ul>
<li>
<b>none</b> - no authentication, or authentication is already
configured in your ssh config.
</li>
<li>
<b>key</b> - use a private key.
</li>
<li>
<b>password</b> - use a password.
</li>
<li>
<b>key+password</b> - use a key with a passphrase.
</li>
</ul>
}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<If condition={authMode == "key" || authMode == "key+password"}>
<TextField
label="SSH Keyfile"
placeholder="keyfile path"
onChange={this.handleChangeKeyFile}
value={this.tempKeyFile.get()}
maxLength={400}
required={true}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={`(Required) The path to your ssh key file.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</If>
<If condition={authMode == "password" || authMode == "key+password"}>
<PasswordField
label={authMode == "password" ? "SSH Password" : "Key Passphrase"}
placeholder="password"
onChange={this.handleChangePassword}
value={this.tempPassword.get()}
maxLength={400}
/>
</If>
<div className="connectmode-section">
<Dropdown
label="Connect Mode"
options={[
{ value: "startup", label: "startup" },
{ value: "auto", label: "auto" },
{ value: "manual", label: "manual" },
]}
value={this.tempConnectMode.get()}
onChange={this.handleChangeConnectMode}
/>
</div>
<If condition={!util.isBlank(this.remoteEdit?.errorstr)}>
<div className="settings-field settings-error">Error: {this.remoteEdit?.errorstr}</div>
</If>
</div>
<Modal.Footer onOk={this.submitRemote} onCancel={this.model.closeModal} okLabel="Save" />
</Modal>
);
}
}
export { EditRemoteConnModal };
+8
View File
@@ -0,0 +1,8 @@
export { AboutModal } from "./about";
export { DisconnectedModal } from "./disconnected";
export { ClientStopModal } from "./clientstop";
export { AlertModal } from "./alert";
export { CreateRemoteConnModal } from "./createremoteconn";
export { ViewRemoteConnDetailModal } from "./viewremoteconndetail";
export { EditRemoteConnModal } from "./editremoteconn";
export { TabSwitcherModal } from "./tabswitcher";
+26
View File
@@ -0,0 +1,26 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import { GlobalModel } from "../../../model/model";
import { TosModal } from "./tos";
@mobxReact.observer
class ModalsProvider extends React.Component {
render() {
let store = GlobalModel.modalsModel.store.slice();
if (GlobalModel.needsTos()) {
return <TosModal />;
}
let rtn: JSX.Element[] = [];
for (let i = 0; i < store.length; i++) {
let entry = store[i];
let Comp = entry.component;
rtn.push(<Comp key={entry.uniqueKey} />);
}
return <>{rtn}</>;
}
}
export { ModalsProvider };
@@ -4,12 +4,12 @@
import * as React from "react";
import {
AboutModal,
AlertModal,
CreateRemoteConnModal,
ViewRemoteConnDetailModal,
EditRemoteConnModal,
AlertModal,
TabSwitcherModal,
} from "./modals";
} from "../modals";
import { ScreenSettingsModal, SessionSettingsModal, LineSettingsModal, ClientSettingsModal } from "./settings";
import * as constants from "../../appconst";
+84
View File
@@ -0,0 +1,84 @@
@import "../../../app/common/themes/themes.less";
.tabswitcher-modal {
width: 452px;
min-height: 384px;
.wave-modal-content {
.wave-modal-body {
display: flex;
padding: 0px;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
width: 100%;
.textfield-wrapper {
padding: 20px 20px 0px;
.wave-input-decoration.start-position {
height: 100%;
.tabswitcher-search-prefix {
opacity: 0.5;
font-size: 13px;
}
}
}
.list-container {
overflow: hidden;
padding: 10px 0 20px;
width: 100%;
}
.list-container-inner {
width: 100%;
max-height: 300px;
overflow-y: scroll;
padding: 0 16px 0 20px;
&::-webkit-scrollbar-thumb {
display: none;
}
&:hover::-webkit-scrollbar-thumb {
display: block;
}
.options-list {
width: 100%;
.search-option {
padding: 5px 5px 5px 8px;
display: flex;
align-items: center;
border: 1px solid transparent;
width: 100%;
overflow: hidden;
div.tabname {
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 5px;
}
div.icon {
flex-shrink: 0;
width: 20px;
margin-right: 6px;
}
}
.focused-option {
border: 1px solid rgba(241, 246, 243, 0.15);
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
}
}
}
}
}
}
+324
View File
@@ -0,0 +1,324 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { For } from "tsx-control-statements/components";
import cn from "classnames";
import { GlobalModel, GlobalCommandRunner } from "../../../model/model";
import { Modal, TextField, InputDecoration, Tooltip } from "../common";
import * as util from "../../../util/util";
import { Screen } from "../../../model/model";
import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg";
import "./tabswitcher.less";
type OV<V> = mobx.IObservableValue<V>;
type OArr<V> = mobx.IObservableArray<V>;
type SwitcherDataType = {
sessionId: string;
sessionName: string;
sessionIdx: number;
screenId: string;
screenIdx: number;
screenName: string;
icon: string;
color: string;
};
const MaxOptionsToDisplay = 100;
@mobxReact.observer
class TabSwitcherModal extends React.Component<{}, {}> {
screens: Map<string, OV<string>>[];
sessions: Map<string, OV<string>>[];
options: SwitcherDataType[] = [];
sOptions: OArr<SwitcherDataType> = mobx.observable.array(null, {
name: "TabSwitcherModal-sOptions",
});
focusedIdx: OV<number> = mobx.observable.box(0, { name: "TabSwitcherModal-selectedIdx" });
activeSessionIdx: number;
optionRefs = [];
listWrapperRef = React.createRef<HTMLDivElement>();
prevFocusedIdx = 0;
componentDidMount() {
this.activeSessionIdx = GlobalModel.getActiveSession().sessionIdx.get();
let oSessions = GlobalModel.sessionList;
let oScreens = GlobalModel.screenMap;
oScreens.forEach((oScreen) => {
// Find the matching session in the observable array
let foundSession = oSessions.find((s) => {
if (s.sessionId === oScreen.sessionId && s.archived.get() == false) {
return true;
}
return false;
});
if (foundSession) {
let data: SwitcherDataType = {
sessionName: foundSession.name.get(),
sessionId: foundSession.sessionId,
sessionIdx: foundSession.sessionIdx.get(),
screenName: oScreen.name.get(),
screenId: oScreen.screenId,
screenIdx: oScreen.screenIdx.get(),
icon: this.getTabIcon(oScreen),
color: this.getTabColor(oScreen),
};
this.options.push(data);
}
});
mobx.action(() => {
this.sOptions.replace(this.sortOptions(this.options).slice(0, MaxOptionsToDisplay));
})();
document.addEventListener("keydown", this.handleKeyDown);
}
componentWillUnmount() {
document.removeEventListener("keydown", this.handleKeyDown);
}
componentDidUpdate() {
let currFocusedIdx = this.focusedIdx.get();
// Check if selectedIdx has changed
if (currFocusedIdx !== this.prevFocusedIdx) {
let optionElement = this.optionRefs[currFocusedIdx]?.current;
if (optionElement) {
optionElement.scrollIntoView({ block: "nearest" });
}
// Update prevFocusedIdx for the next update cycle
this.prevFocusedIdx = currFocusedIdx;
}
if (currFocusedIdx >= this.sOptions.length && this.sOptions.length > 0) {
this.setFocusedIndex(this.sOptions.length - 1);
}
}
@boundMethod
getTabIcon(screen: Screen): string {
let tabIcon = "default";
let screenOpts = screen.opts.get();
if (screenOpts != null && !util.isBlank(screenOpts.tabicon)) {
tabIcon = screenOpts.tabicon;
}
return tabIcon;
}
@boundMethod
getTabColor(screen: Screen): string {
let tabColor = "default";
let screenOpts = screen.opts.get();
if (screenOpts != null && !util.isBlank(screenOpts.tabcolor)) {
tabColor = screenOpts.tabcolor;
}
return tabColor;
}
@boundMethod
handleKeyDown(e) {
if (e.key === "Escape") {
this.closeModal();
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
let newIndex = this.calculateNewIndex(e.key === "ArrowUp");
this.setFocusedIndex(newIndex);
} else if (e.key === "Enter") {
e.preventDefault();
this.handleSelect(this.focusedIdx.get());
}
}
@boundMethod
calculateNewIndex(isUpKey) {
let currentIndex = this.focusedIdx.get();
if (isUpKey) {
return Math.max(currentIndex - 1, 0);
} else {
return Math.min(currentIndex + 1, this.sOptions.length - 1);
}
}
@boundMethod
setFocusedIndex(index) {
mobx.action(() => {
this.focusedIdx.set(index);
})();
}
@boundMethod
closeModal(): void {
GlobalModel.modalsModel.popModal();
}
@boundMethod
handleSelect(index: number): void {
const selectedOption = this.sOptions[index];
if (selectedOption) {
GlobalCommandRunner.switchScreen(selectedOption.screenId, selectedOption.sessionId);
this.closeModal();
}
}
@boundMethod
handleSearch(val: string): void {
let sOptions: SwitcherDataType[];
if (val == "") {
sOptions = this.sortOptions(this.options).slice(0, MaxOptionsToDisplay);
} else {
sOptions = this.filterOptions(val);
sOptions = this.sortOptions(sOptions);
if (sOptions.length > MaxOptionsToDisplay) {
sOptions = sOptions.slice(0, MaxOptionsToDisplay);
}
}
mobx.action(() => {
this.sOptions.replace(sOptions);
this.focusedIdx.set(0);
})();
}
@mobx.computed
@boundMethod
filterOptions(searchInput: string): SwitcherDataType[] {
let filteredScreens = [];
for (let i = 0; i < this.options.length; i++) {
let tab = this.options[i];
let match = false;
if (searchInput.includes("/")) {
let [sessionFilter, screenFilter] = searchInput.split("/").map((s) => s.trim().toLowerCase());
match =
tab.sessionName.toLowerCase().includes(sessionFilter) &&
tab.screenName.toLowerCase().includes(screenFilter);
} else {
match =
tab.sessionName.toLowerCase().includes(searchInput) ||
tab.screenName.toLowerCase().includes(searchInput);
}
// Add tab to filtered list if it matches the criteria
if (match) {
filteredScreens.push(tab);
}
}
return filteredScreens;
}
@mobx.computed
@boundMethod
sortOptions(options: SwitcherDataType[]): SwitcherDataType[] {
return options.sort((a, b) => {
let aInCurrentSession = a.sessionIdx === this.activeSessionIdx;
let bInCurrentSession = b.sessionIdx === this.activeSessionIdx;
// Tabs in the current session are sorted by screenIdx
if (aInCurrentSession && bInCurrentSession) {
return a.screenIdx - b.screenIdx;
}
// a is in the current session and b is not, so a comes first
else if (aInCurrentSession) {
return -1;
}
// b is in the current session and a is not, so b comes first
else if (bInCurrentSession) {
return 1;
}
// Both are in different, non-current sessions - sort by sessionIdx and then by screenIdx
else {
if (a.sessionIdx === b.sessionIdx) {
return a.screenIdx - b.screenIdx;
} else {
return a.sessionIdx - b.sessionIdx;
}
}
});
}
@boundMethod
renderIcon(option: SwitcherDataType): React.ReactNode {
let tabIcon = option.icon;
if (tabIcon === "default" || tabIcon === "square") {
return <SquareIcon className="left-icon" />;
}
return <i className={`fa-sharp fa-solid fa-${tabIcon}`}></i>;
}
@boundMethod
renderOption(option: SwitcherDataType, index: number): JSX.Element {
if (!this.optionRefs[index]) {
this.optionRefs[index] = React.createRef();
}
return (
<div
key={option.sessionId + "/" + option.screenId}
ref={this.optionRefs[index]}
className={cn("search-option unselectable", {
"focused-option": this.focusedIdx.get() === index,
})}
onClick={() => this.handleSelect(index)}
>
<div className={cn("icon", "color-" + option.color)}>{this.renderIcon(option)}</div>
<div className="tabname">
#{option.sessionName} / {option.screenName}
</div>
</div>
);
}
render() {
let option: SwitcherDataType;
let index: number;
return (
<Modal className="tabswitcher-modal">
<div className="wave-modal-body">
<div className="textfield-wrapper">
<TextField
onChange={this.handleSearch}
maxLength={400}
autoFocus={true}
decoration={{
startDecoration: (
<InputDecoration position="start">
<div className="tabswitcher-search-prefix">Switch to Tab:</div>
</InputDecoration>
),
endDecoration: (
<InputDecoration>
<Tooltip
message={`Type to filter workspaces and tabs.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<div className="list-container">
<div ref={this.listWrapperRef} className="list-container-inner">
<div className="options-list">
<For each="option" index="index" of={this.sOptions}>
{this.renderOption(option, index)}
</For>
</div>
</div>
</div>
</div>
</Modal>
);
}
}
export { TabSwitcherModal };
+102
View File
@@ -0,0 +1,102 @@
@import "../../../app/common/themes/themes.less";
.tos-modal {
width: 640px;
.wave-modal-content .wave-modal-body {
padding: 32px 48px;
gap: 8px;
.wave-modal-body-inner {
gap: 24px;
display: flex;
flex-direction: column;
header.tos-header {
flex-direction: column;
gap: var(--sizing-sm, 12px);
border-bottom: none;
padding: 0;
.modal-title {
text-align: center;
font-size: 20px;
font-weight: 300;
}
.modal-subtitle {
color: @term-white;
text-align: center;
font-style: normal;
font-weight: 300;
line-height: 20px;
}
}
.content.tos-content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 32px;
width: 100%;
margin-bottom: 0;
.item {
display: flex;
width: 100%;
align-items: center;
gap: 16px;
.item-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
flex: 1 0 0;
.item-title {
color: @term-bright-white;
font-style: normal;
line-height: 20px;
}
.item-text {
color: @term-white;
font-style: normal;
line-height: 20px;
}
.item-field {
display: flex;
align-items: center;
gap: 8px;
}
}
}
}
footer {
.item-text {
text-align: center;
}
.button-wrapper {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
button {
font-size: 12.5px !important;
margin-top: 16px;
}
button.disabled-button {
cursor: default;
}
}
}
}
}
}

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