add connection modal (#64)

* various improvements

* animation menu on close

* finish custom dropdown

* minor change

* minor fix

* add error support for dropdown

* create remote modal

* NumberField component and more on add remote connection modal implementation

* extend TextField instead. dobounce should be handled by the user not the component

* PasswordField

* remove used code. fix ssh key file field issue

* style buttons. fix modal height issue

* use react portal for dropdown menu

* remove debugging code

* tooltip implementation

* put back modal background

* (sawka) add autofocus attribute to textfield.  change label color to color-secondary.  fix file menu name.  fix typescript error in basicrenderer
This commit is contained in:
Red J Adaya 2023-11-10 08:28:37 +08:00 committed by GitHub
parent 1a566d06aa
commit a0479c4683
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1164 additions and 105 deletions

View File

@ -24,7 +24,13 @@ import {
import { RemotesModal } from "./connections/connections";
import { TosModal } from "./common/modals/modals";
import { MainSideBar } from "./sidebar/sidebar";
import { DisconnectedModal, ClientStopModal, AlertModal, AboutModal } from "./common/modals/modals";
import {
DisconnectedModal,
ClientStopModal,
AlertModal,
AboutModal,
CreateRemoteConnModal,
} from "./common/modals/modals";
import { ErrorBoundary } from "./common/error/errorboundary";
import "./app.less";
@ -79,7 +85,10 @@ class App extends React.Component<{}, {}> {
let sessionSettingsModal = GlobalModel.sessionSettingsModal.get();
let lineSettingsModal = GlobalModel.lineSettingsModal.get();
let clientSettingsModal = GlobalModel.clientSettingsModal.get();
let remotesModal = GlobalModel.remotesModalModel.isOpen();
let remotesModel = GlobalModel.remotesModalModel;
let remotesModal = remotesModel.isOpen();
let selectedRemoteId = remotesModel.selectedRemoteId.get();
let remoteEdit = remotesModel.remoteEdit.get();
let disconnected = !GlobalModel.ws.open.get() || !GlobalModel.waveSrvRunning.get();
let hasClientStop = GlobalModel.getHasClientStop();
let dcWait = this.dcWait.get();
@ -127,6 +136,9 @@ class App extends React.Component<{}, {}> {
<If condition={GlobalModel.aboutModalOpen.get()}>
<AboutModal />
</If>
<If condition={remoteEdit !== null && !remoteEdit.old}>
<CreateRemoteConnModal model={remotesModel} remoteEdit={remoteEdit} />
</If>
<If condition={screenSettingsModal != null}>
<ScreenSettingsModal
key={screenSettingsModal.sessionId + ":" + screenSettingsModal.screenId}

View File

@ -257,20 +257,28 @@
}
}
.button.is-wave-green {
.wave-button {
display: flex;
padding: 6px 16px !important;
color: @term-bright-white !important;
color: @term-white !important;
align-items: center;
gap: 4px;
border-radius: 6px !important;
height: auto !important;
&:hover {
color: @term-white !important;
}
}
.wave-button.is-wave-green {
color: @term-bright-white !important;
background: @term-green !important;
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5),
0px 0px 0.5px 0px rgba(255, 255, 255, 0.8) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.6) inset;
&:hover {
background-color: @term-green;
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5),
0px 0px 0.5px 0px rgba(255, 255, 255, 0.8) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.6) inset;
color: @term-bright-white !important;
box-shadow: none;
}
@ -625,34 +633,18 @@
}
}
.textfield {
display: flex;
align-items: center;
border: 1px solid @term-white;
border-radius: 6px;
.wave-dropdown {
position: relative;
margin-bottom: 1rem;
background-color: transparent;
height: 44px;
min-width: 412px;
gap: 6px;
width: 412px;
border: 1px solid var(--element-separator, rgba(241, 246, 243, 0.15));
border-radius: 6px;
background: var(--element-hover-2, rgba(255, 255, 255, 0.06));
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5),
0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset;
&.focused {
border-color: @term-green;
}
&.error {
border-color: @term-red;
}
.textfield-inner {
display: flex;
align-items: flex-end;
height: 100%;
position: relative;
flex-grow: 1;
.textfield-label {
&-label {
position: absolute;
left: 16px;
top: 16px;
@ -666,12 +658,184 @@
top: 5px;
}
&.start {
&.offset-left {
left: 42px;
}
}
&-display {
position: absolute;
left: 16px;
bottom: 5px;
&.offset-left {
left: 42px;
}
}
&-arrow {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
transition: transform 0.3s;
pointer-events: none;
i {
font-size: 14px;
}
}
&-arrow-rotate {
transform: translateY(-50%) rotate(180deg); // Rotate the arrow when dropdown is open
}
&-item {
display: flex;
min-width: 120px;
padding: 5px 8px;
justify-content: space-between;
align-items: center;
align-self: stretch;
border-radius: 6px;
&-highlighted,
&:hover {
background: var(--element-active, rgba(241, 246, 243, 0.08));
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5),
0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset;
}
}
.wave-input-decoration {
position: absolute;
top: 0;
height: 100%;
}
.wave-input-decoration.end-position {
margin-right: 44px;
right: 0;
}
.wave-input-decoration.start-position {
left: 0;
}
&-error {
border-color: @term-red;
}
&:focus {
border-color: @term-green;
}
}
.wave-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 0;
margin-top: 2px;
padding: 0;
max-height: 200px;
overflow-y: auto;
padding: 6px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
border-radius: 6px;
background: var(--olive-dark-1, #151715);
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.5), 0px 3px 8px 0px rgba(0, 0, 0, 0.35), 0px 0px 0.5px 0px #fff inset,
0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset;
animation-fill-mode: forwards;
z-index: 1000;
}
.wave-dropdown-menu-close {
z-index: 0;
animation: waveDropdownMenuFadeOut 0.3s ease-out;
}
.wave-textfield.wave-password {
.wave-textfield-inner-eye {
position: absolute;
right: 16px;
top: 52%;
transform: translateY(-50%);
transition: transform 0.3s;
i {
font-size: 14px;
}
}
.wave-input-decoration {
position: absolute;
top: 0;
height: 100%;
}
.wave-input-decoration.end-position {
margin-right: 47px;
right: 0;
}
.wave-input-decoration.start-position {
left: 0;
}
}
.wave-textfield {
display: flex;
align-items: center;
border-radius: 6px;
position: relative;
height: 44px;
min-width: 412px;
gap: 6px;
border: 1px solid var(--element-separator, rgba(241, 246, 243, 0.15));
border-radius: 6px;
background: var(--element-hover-2, rgba(255, 255, 255, 0.06));
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5),
0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset;
&.focused {
border-color: @term-green;
}
&.error {
border-color: @term-red;
}
&-inner {
display: flex;
align-items: flex-end;
height: 100%;
position: relative;
flex-grow: 1;
&-label {
position: absolute;
left: 16px;
top: 16px;
font-size: 12.5px;
transition: all 0.3s;
color: @text-secondary;
line-height: 10px;
&.float {
font-size: 10px;
top: 5px;
}
&.offset-left {
left: 0;
}
}
.textfield-input {
&-input {
width: 100%;
height: 30px;
border: none;
@ -682,23 +846,51 @@
color: @term-bright-white;
line-height: 20px;
&.start {
&.offset-left {
padding: 5px 16px 5px 0;
}
}
}
}
.wave-input-decoration {
display: flex;
align-items: center;
justify-content: center;
i {
font-size: 16px;
}
}
.input-decoration {
.wave-input-decoration.start-position {
margin: 0 4px 0 16px;
}
.wave-input-decoration.end-position {
margin: 0 16px 0 8px;
}
.wave-tooltip {
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
margin: 8px;
position: absolute;
z-index: 1000;
flex-direction: row;
align-items: flex-start;
gap: 10px;
padding: 10px;
border: 1px solid #777;
background-color: #444;
border-radius: 5px;
overflow: hidden;
max-width: 300px;
i {
display: inline;
font-size: 16px;
fill: @base-color;
padding-top: 0.2em;
}
}
.inline-edit {

View File

@ -10,7 +10,7 @@ import remarkGfm from "remark-gfm";
import cn from "classnames";
import { If } from "tsx-control-statements/components";
import type { RemoteType } from "../../types/types";
import { debounce } from "throttle-debounce";
import ReactDOM from "react-dom";
import { ReactComponent as CheckIcon } from "../assets/icons/line/check.svg";
import { ReactComponent as CopyIcon } from "../assets/icons/history/copy.svg";
@ -125,14 +125,95 @@ class Checkbox extends React.Component<
}
interface InputDecorationProps {
position?: "start" | "end";
children: React.ReactNode;
}
@mobxReact.observer
class InputDecoration extends React.Component<InputDecorationProps, {}> {
render() {
const { children } = this.props;
return <div className="input-decoration">{children}</div>;
const { children, position = "end" } = this.props;
return (
<div
className={cn("wave-input-decoration", {
"start-position": position === "start",
"end-position": position === "end",
})}
>
{children}
</div>
);
}
}
interface TooltipProps {
message: React.ReactNode;
icon?: React.ReactNode; // Optional icon property
children: React.ReactNode;
}
interface TooltipState {
isVisible: boolean;
}
@mobxReact.observer
class Tooltip extends React.Component<TooltipProps, TooltipState> {
iconRef: React.RefObject<HTMLDivElement>;
constructor(props: TooltipProps) {
super(props);
this.state = {
isVisible: false,
};
this.iconRef = React.createRef();
}
@boundMethod
showBubble() {
this.setState({ isVisible: true });
}
@boundMethod
hideBubble() {
this.setState({ isVisible: false });
}
@boundMethod
calculatePosition() {
// Get the position of the icon element
const iconElement = this.iconRef.current;
if (iconElement) {
const rect = iconElement.getBoundingClientRect();
return {
top: `${rect.bottom + window.scrollY - 29.5}px`,
left: `${rect.left + window.scrollX + rect.width / 2 - 19}px`,
};
}
return {};
}
@boundMethod
renderBubble() {
if (!this.state.isVisible) return null;
const style = this.calculatePosition();
return ReactDOM.createPortal(
<div className="wave-tooltip" style={style}>
{this.props.icon && <div className="wave-tooltip-icon">{this.props.icon}</div>}
<div className="wave-tooltip-message">{this.props.message}</div>
</div>,
document.getElementById("app")!
);
}
render() {
return (
<div onMouseEnter={this.showBubble} onMouseLeave={this.hideBubble} ref={this.iconRef}>
{this.props.children}
{this.renderBubble()}
</div>
);
}
}
@ -149,6 +230,8 @@ interface TextFieldProps {
defaultValue?: string;
decoration?: TextFieldDecorationProps;
required?: boolean;
maxLength?: number;
autoFocus?: boolean;
}
interface TextFieldState {
@ -207,14 +290,9 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
this.setState((prevState) => ({ showHelpText: !prevState.showHelpText }));
}
debouncedOnChange = debounce(300, (value) => {
const { onChange } = this.props;
onChange?.(value);
});
@boundMethod
handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const { required } = this.props;
const { required, onChange } = this.props;
const inputValue = e.target.value;
// Check if value is empty and the field is required
@ -229,31 +307,31 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
this.setState({ internalValue: inputValue });
}
this.debouncedOnChange(inputValue);
onChange && onChange(inputValue);
}
render() {
const { label, value, placeholder, decoration, className } = this.props;
const { label, value, placeholder, decoration, className, maxLength, autoFocus } = this.props;
const { focused, internalValue, error } = this.state;
// Decide if the input should behave as controlled or uncontrolled
const inputValue = value !== undefined ? value : internalValue;
return (
<div className={cn(`textfield ${className || ""}`, { focused: focused, error: error })}>
<div className={cn(`wave-textfield ${className || ""}`, { focused: focused, error: error })}>
{decoration?.startDecoration && <>{decoration.startDecoration}</>}
<div className="textfield-inner">
<div className="wave-textfield-inner">
<label
className={cn("textfield-label", {
className={cn("wave-textfield-inner-label", {
float: this.state.hasContent || this.state.focused || placeholder,
start: decoration?.startDecoration,
"offset-left": decoration?.startDecoration,
})}
htmlFor={label}
>
{label}
</label>
<input
className={cn("textfield-input", { start: decoration?.startDecoration })}
className={cn("wave-textfield-inner-input", { "offset-left": decoration?.startDecoration })}
ref={this.inputRef}
id={label}
value={inputValue}
@ -261,9 +339,152 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
onFocus={this.handleFocus}
onBlur={this.handleBlur}
placeholder={placeholder}
maxLength={maxLength}
autoFocus={autoFocus}
/>
</div>
{decoration?.endDecoration && <div>{decoration.endDecoration}</div>}
{decoration?.endDecoration && <>{decoration.endDecoration}</>}
</div>
);
}
}
class NumberField extends TextField {
@boundMethod
handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const { required, onChange } = this.props;
const inputValue = e.target.value;
// Allow only numeric input
if (inputValue === "" || /^\d*$/.test(inputValue)) {
// Update the internal state only if the component is not controlled.
if (this.props.value === undefined) {
const isError = required ? inputValue.trim() === "" : false;
this.setState({
internalValue: inputValue,
error: isError,
hasContent: Boolean(inputValue),
});
}
onChange && onChange(inputValue);
}
}
@boundMethod
handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
// Allow backspace, delete, tab, escape, and enter
if (
[46, 8, 9, 27, 13].includes(event.keyCode) ||
// Allow: Ctrl+A, Ctrl+C, Ctrl+X
((event.keyCode === 65 || event.keyCode === 67 || event.keyCode === 88) && event.ctrlKey === true) ||
// Allow: home, end, left, right
(event.keyCode >= 35 && event.keyCode <= 39)
) {
return; // let it happen, don't do anything
}
// Ensure that it is a number and stop the keypress
if (
(event.shiftKey || event.keyCode < 48 || event.keyCode > 57) &&
(event.keyCode < 96 || event.keyCode > 105)
) {
event.preventDefault();
}
}
render() {
// Use the render method from TextField but add the onKeyDown handler
const renderedTextField = super.render();
return React.cloneElement(renderedTextField, {
onKeyDown: this.handleKeyDown,
});
}
}
interface PasswordFieldState extends TextFieldState {
passwordVisible: boolean;
}
@mobxReact.observer
class PasswordField extends TextField {
state: PasswordFieldState;
constructor(props) {
super(props);
this.state = {
...this.state,
passwordVisible: false,
};
}
@boundMethod
togglePasswordVisibility() {
//@ts-ignore
this.setState((prevState) => ({
//@ts-ignore
passwordVisible: !prevState.passwordVisible,
}));
}
@boundMethod
handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
// Call the parent handleInputChange method
super.handleInputChange(e);
}
render() {
const { decoration, className, placeholder, maxLength, label } = this.props;
const { focused, internalValue, error, passwordVisible } = this.state;
const inputValue = this.props.value !== undefined ? this.props.value : internalValue;
// The input should always receive the real value
const inputProps = {
className: cn("wave-textfield-inner-input", { "offset-left": decoration?.startDecoration }),
ref: this.inputRef,
id: label,
value: inputValue, // Always use the real value here
onChange: this.handleInputChange,
onFocus: this.handleFocus,
onBlur: this.handleBlur,
placeholder: placeholder,
maxLength: maxLength,
};
return (
<div className={cn(`wave-textfield wave-password ${className || ""}`, { focused: focused, error: error })}>
{decoration?.startDecoration && <>{decoration.startDecoration}</>}
<div className="wave-textfield-inner">
<label
className={cn("wave-textfield-inner-label", {
float: this.state.hasContent || this.state.focused || placeholder,
"offset-left": decoration?.startDecoration,
})}
htmlFor={label}
>
{label}
</label>
<If condition={passwordVisible}>
<input {...inputProps} type="text" />
</If>
<If condition={!passwordVisible}>
<input {...inputProps} type="password" />
</If>
<div
className="wave-textfield-inner-eye"
onClick={this.togglePasswordVisibility}
style={{ cursor: "pointer" }}
>
<If condition={passwordVisible}>
<i className="fa-sharp fa-solid fa-eye"></i>
</If>
<If condition={!passwordVisible}>
<i className="fa-sharp fa-solid fa-eye-slash"></i>
</If>
</div>
</div>
{decoration?.endDecoration && <>{decoration.endDecoration}</>}
</div>
);
}
@ -387,7 +608,9 @@ class InlineSettingsTextEdit extends React.Component<
title="Cancel (Esc)"
className="button is-prompt-danger is-outlined is-small"
>
<span className="icon is-small"><i className="fa-sharp fa-solid fa-xmark"/></span>
<span className="icon is-small">
<i className="fa-sharp fa-solid fa-xmark" />
</span>
</div>
</div>
<div className="control">
@ -396,7 +619,9 @@ class InlineSettingsTextEdit extends React.Component<
title="Confirm (Enter)"
className="button is-prompt-green is-outlined is-small"
>
<span className="icon is-small"><i className="fa-sharp fa-solid fa-check"/></span>
<span className="icon is-small">
<i className="fa-sharp fa-solid fa-check" />
</span>
</div>
</div>
</div>
@ -407,7 +632,7 @@ class InlineSettingsTextEdit extends React.Component<
<div onClick={this.clickEdit} className={cn("settings-input inline-edit", "edit-not-active")}>
{this.props.text}
<If condition={this.props.showIcon}>
<i className="fa-sharp fa-solid fa-pen"/>
<i className="fa-sharp fa-solid fa-pen" />
</If>
</div>
);
@ -498,6 +723,247 @@ class SettingsError extends React.Component<{ errorMessage: OV<string> }, {}> {
}
}
interface DropdownDecorationProps {
startDecoration?: React.ReactNode;
endDecoration?: React.ReactNode;
}
interface DropdownProps {
label: string;
options: { value: string; label: string }[];
value?: string;
className?: string;
onChange: (value: string) => void;
placeholder?: string;
decoration?: DropdownDecorationProps;
defaultValue?: string;
required?: boolean;
}
interface DropdownState {
isOpen: boolean;
internalValue: string;
highlightedIndex: number;
isTouched: boolean;
}
@mobxReact.observer
class Dropdown extends React.Component<DropdownProps, DropdownState> {
wrapperRef: React.RefObject<HTMLDivElement>;
menuRef: React.RefObject<HTMLDivElement>;
timeoutId: any;
constructor(props: DropdownProps) {
super(props);
this.state = {
isOpen: false,
internalValue: props.defaultValue || "",
highlightedIndex: -1,
isTouched: false,
};
this.wrapperRef = React.createRef();
this.menuRef = React.createRef();
}
componentDidMount() {
document.addEventListener("mousedown", this.handleClickOutside);
}
componentWillUnmount() {
document.removeEventListener("mousedown", this.handleClickOutside);
}
componentDidUpdate(prevProps: Readonly<DropdownProps>, prevState: Readonly<DropdownState>, snapshot?: any): void {
// If the dropdown was open but now is closed, start the timeout
if (prevState.isOpen && !this.state.isOpen) {
this.timeoutId = setTimeout(() => {
if (this.menuRef.current) {
this.menuRef.current.style.display = "none";
}
}, 300); // Time is equal to the animation duration
}
// If the dropdown is now open, cancel any existing timeout and show the menu
else if (!prevState.isOpen && this.state.isOpen) {
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId); // Cancel any existing timeout
this.timeoutId = null;
}
if (this.menuRef.current) {
this.menuRef.current.style.display = "inline-flex";
}
}
}
@boundMethod
handleClickOutside(event: MouseEvent) {
// Check if the click is outside both the wrapper and the menu
if (
this.wrapperRef.current &&
!this.wrapperRef.current.contains(event.target as Node) &&
this.menuRef.current &&
!this.menuRef.current.contains(event.target as Node)
) {
this.setState({ isOpen: false });
}
}
@boundMethod
handleClick() {
this.toggleDropdown();
}
@boundMethod
handleFocus() {
this.setState({ isTouched: true });
}
@boundMethod
handleKeyDown(event: React.KeyboardEvent) {
const { options } = this.props;
const { isOpen, highlightedIndex } = this.state;
switch (event.key) {
case "Enter":
case " ":
if (isOpen) {
const option = options[highlightedIndex];
if (option) {
this.handleSelect(option.value, undefined);
}
} else {
this.toggleDropdown();
}
break;
case "Escape":
this.setState({ isOpen: false });
break;
case "ArrowUp":
if (isOpen) {
this.setState((prevState) => ({
highlightedIndex:
prevState.highlightedIndex > 0 ? prevState.highlightedIndex - 1 : options.length - 1,
}));
}
break;
case "ArrowDown":
if (isOpen) {
this.setState((prevState) => ({
highlightedIndex:
prevState.highlightedIndex < options.length - 1 ? prevState.highlightedIndex + 1 : 0,
}));
}
break;
case "Tab":
this.setState({ isOpen: false });
break;
}
}
@boundMethod
handleSelect(value: string, event?: React.MouseEvent | React.KeyboardEvent) {
const { onChange } = this.props;
if (event) {
event.stopPropagation(); // This stops the event from bubbling up to the wrapper
}
if (!("value" in this.props)) {
this.setState({ internalValue: value });
}
onChange(value);
this.setState({ isOpen: false, isTouched: true });
}
@boundMethod
toggleDropdown() {
this.setState((prevState) => ({ isOpen: !prevState.isOpen, isTouched: true }));
}
@boundMethod
calculatePosition(): React.CSSProperties {
if (this.wrapperRef.current) {
const rect = this.wrapperRef.current.getBoundingClientRect();
return {
position: "absolute",
top: `${rect.bottom + window.scrollY}px`,
left: `${rect.left + window.scrollX}px`,
width: `${rect.width}px`,
};
}
return {};
}
render() {
const { label, options, value, placeholder, decoration, className, required } = this.props;
const { isOpen, internalValue, highlightedIndex, isTouched } = this.state;
const currentValue = value !== undefined ? value : internalValue;
const selectedOptionLabel =
options.find((option) => option.value === currentValue)?.label || placeholder || internalValue;
// Determine if the dropdown should be marked as having an error
const isError =
required &&
(value === undefined || value === "") &&
(internalValue === undefined || internalValue === "") &&
isTouched;
// Determine if the label should float
const shouldLabelFloat = !!value || !!internalValue || !!placeholder || isOpen;
const dropdownMenu = isOpen
? ReactDOM.createPortal(
<div className={cn("wave-dropdown-menu")} ref={this.menuRef} style={this.calculatePosition()}>
{options.map((option, index) => (
<div
key={option.value}
className={cn("wave-dropdown-item", {
"wave-dropdown-item-highlighted": index === highlightedIndex,
})}
onClick={(e) => this.handleSelect(option.value, e)}
onMouseEnter={() => this.setState({ highlightedIndex: index })}
onMouseLeave={() => this.setState({ highlightedIndex: -1 })}
>
{option.label}
</div>
))}
</div>,
document.getElementById("app")!
)
: null;
return (
<div
className={cn(`wave-dropdown ${className || ""}`, {
"wave-dropdown-error": isError,
})}
ref={this.wrapperRef}
tabIndex={0}
onKeyDown={this.handleKeyDown}
onClick={this.handleClick}
onFocus={this.handleFocus}
>
{decoration?.startDecoration && <>{decoration.startDecoration}</>}
<div
className={cn("wave-dropdown-label", {
float: shouldLabelFloat,
"offset-left": decoration?.startDecoration,
})}
>
{label}
</div>
<div className={cn("wave-dropdown-display", { "offset-left": decoration?.startDecoration })}>
{selectedOptionLabel}
</div>
<div className={cn("wave-dropdown-arrow", { "wave-dropdown-arrow-rotate": isOpen })}>
<i className="fa-sharp fa-solid fa-chevron-down"></i>
</div>
{dropdownMenu}
{decoration?.endDecoration && <>{decoration.endDecoration}</>}
</div>
);
}
}
export {
CmdStrCode,
Toggle,
@ -508,6 +974,10 @@ export {
InfoMessage,
Markdown,
SettingsError,
Dropdown,
TextField,
InputDecoration,
NumberField,
PasswordField,
Tooltip,
};

View File

@ -182,7 +182,7 @@
}
.modal.wave-modal {
.modal-content {
.wave-modal-content {
display: flex;
flex-direction: column;
align-items: flex-start;
@ -192,14 +192,14 @@
box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.35), 0px 10px 24px 0px rgba(0, 0, 0, 0.45),
0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset;
.modal-content-wrapper {
.wave-modal-content-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
width: 100%;
header {
.wave-modal-header {
width: 100%;
display: flex;
align-items: center;
@ -208,14 +208,14 @@
line-height: 20px;
border-bottom: 1px solid rgba(250, 250, 250, 0.1);
.modal-title {
.wave-modal-title {
color: @term-bright-white;
font-style: normal;
line-height: 20px;
font-size: 13px;
}
.close-icon-wrapper {
.wave-modal-close {
display: flex;
padding: 4px;
align-items: center;
@ -223,7 +223,7 @@
}
}
.content {
.wave-modal-body {
display: flex;
flex-direction: column;
align-items: flex-start;
@ -235,7 +235,7 @@
}
.modal.tos-modal {
.modal-content.tos-modal-content {
.modal-content.wave-modal-content {
padding: 32px 48px;
gap: 8px;
@ -324,13 +324,13 @@
}
.modal.about-modal {
.modal-content.about-modal-content {
.about-wave-modal-content {
width: 401px;
.about-content {
.about-wave-modal-body {
margin-bottom: 0;
.wave-section {
.wave-modal-section {
.logo-wrapper {
width: 72px;
height: 72px;
@ -403,7 +403,7 @@
}
}
.wave-section:nth-child(3) {
.wave-modal-section:nth-child(3) {
display: flex;
align-items: flex-start;
gap: 10px;
@ -418,7 +418,7 @@
}
}
.wave-section:last-child {
.wave-modal-section:last-child {
margin-bottom: 24px;
color: @term-white;
}
@ -426,6 +426,48 @@
}
}
.wave-modal.crconn-modal {
.wave-modal-content.crconn-wave-modal-content {
width: 452px;
min-height: 411px;
overflow: visible;
.wave-modal-content-inner.crconn-wave-modal-content-inner {
display: flex;
padding-bottom: 0px;
flex-direction: column;
align-items: center;
gap: 20px;
flex-shrink: 0;
.crconn-wave-modal-body {
display: flex;
padding: 0px 20px;
flex-direction: column;
align-items: flex-start;
gap: 12px;
align-self: stretch;
width: 100%;
}
}
.crconn-wave-modal-footer {
display: flex;
justify-content: flex-end;
width: 100%;
padding: 0 20px 20px;
.action-buttons {
display: flex;
div.button {
margin-right: 8px;
}
}
}
}
}
.wave-button {
display: flex;
padding: 6px 16px;
@ -465,7 +507,7 @@
cursor: pointer;
}
.wave-section {
.wave-modal-section {
display: flex;
align-items: center;
gap: 16px;

View File

@ -9,11 +9,13 @@ import { If, For } from "tsx-control-statements/components";
import cn from "classnames";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel, GlobalCommandRunner } from "../../../model/model";
import { Markdown } from "../common";
import { GlobalModel, GlobalCommandRunner, RemotesModalModel } from "../../../model/model";
import * as T from "../../../types/types";
import { Markdown, InfoMessage } from "../common";
import * as util from "../../../util/util";
import { Toggle, Checkbox } from "../common";
import { ClientDataType } from "../../../types/types";
import { TextField, NumberField, InputDecoration, Dropdown, PasswordField, Tooltip } from "../common";
import close from "../../assets/icons/close.svg";
import { ReactComponent as WarningIcon } from "../../assets/icons/line/triangle-exclamation.svg";
@ -22,6 +24,7 @@ import shield from "../../assets/icons/shield_check.svg";
import help from "../../assets/icons/help_filled.svg";
import github from "../../assets/icons/github.svg";
import logo from "../../assets/waveterm-logo-with-bg.svg";
import { ReactComponent as AngleDownIcon } from "../../assets/icons/history/angle-down.svg";
dayjs.extend(localizedFormat);
@ -255,9 +258,9 @@ class TosModal extends React.Component<{}, {}> {
return (
<div className={cn("modal tos-modal wave-modal is-active")}>
<div className="modal-background" />
<div className="modal-content tos-modal-content">
<div className="modal-content-wrapper">
<div className="modal-background wave-modal-background" />
<div className="modal-content wave-modal-content tos-wave-modal-content">
<div className="modal-content-inner wave-modal-content-inner tos-wave-modal-content-inner">
<header className="tos-header unselectable">
<div className="modal-title">Welcome to Wave Terminal!</div>
<div className="modal-subtitle">Lets set everything for you</div>
@ -332,7 +335,7 @@ class TosModal extends React.Component<{}, {}> {
<div className="button-wrapper">
<button
onClick={this.acceptTos}
className={cn("button is-wave-green is-outlined is-small", {
className={cn("button wave-button is-wave-green is-outlined is-small", {
"disabled-button": !this.state.isChecked,
})}
disabled={!this.state.isChecked}
@ -406,17 +409,17 @@ class AboutModal extends React.Component<{}, {}> {
render() {
return (
<div className={cn("modal about-modal wave-modal is-active")}>
<div className="modal-background" />
<div className="modal-content about-modal-content unselectable">
<div className="modal-content-wrapper">
<header className="common-header">
<div className="modal-title">About</div>
<div className="close-icon-wrapper" onClick={this.closeModal}>
<div className="modal-background wave-modal-background" />
<div className="modal-content wave-modal-content about-wave-modal-content">
<div className="modal-content-inner wave-modal-content-inner about-wave-modal-content-inner">
<header className="wave-modal-header about-wave-modal-header">
<div className="wave-modal-title about-wave-modal-title">About</div>
<div className="wave-modal-close about-wave-modal-close" onClick={this.closeModal}>
<img src={close} alt="Close (Escape)" />
</div>
</header>
<div className="content about-content">
<section className="wave-section about-section">
<div className="wave-modal-body about-wave-modal-body">
<section className="wave-modal-section about-section">
<div className="logo-wrapper">
<img src={logo} alt="logo" />
</div>
@ -425,10 +428,10 @@ class AboutModal extends React.Component<{}, {}> {
<div className="text-standard">Modern Terminal for Seamless Workflow</div>
</div>
</section>
<section className="wave-section about-section text-standard">
<section className="wave-modal-section about-section text-standard">
{this.getStatus(this.isUpToDate())}
</section>
<section className="wave-section about-section">
<section className="wave-modal-section about-section">
<a
className="wave-button wave-button-link color-standard"
href={util.makeExternLink("https://github.com/wavetermdev/waveterm")}
@ -456,7 +459,7 @@ class AboutModal extends React.Component<{}, {}> {
License
</a>
</section>
<section className="wave-section about-section text-standard">
<section className="wave-modal-section about-section text-standard">
Copyright © 2023 Command Line Inc.
</section>
</div>
@ -467,4 +470,333 @@ class AboutModal extends React.Component<{}, {}> {
}
}
export { LoadingSpinner, ClientStopModal, AlertModal, DisconnectedModal, TosModal, AboutModal };
@mobxReact.observer
class CreateRemoteConnModal extends React.Component<{ model: RemotesModalModel; remoteEdit: T.RemoteEditType }, {}> {
tempAlias: OV<string>;
tempHostName: OV<string>;
tempPort: OV<string>;
tempAuthMode: OV<string>;
tempConnectMode: OV<string>;
tempManualMode: OV<boolean>;
tempPassword: OV<string>;
tempKeyFile: OV<string>;
errorStr: OV<string>;
constructor(props: any) {
super(props);
let { remoteEdit } = this.props;
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(remoteEdit.errorstr, { name: "CreateRemote-errorStr" });
}
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.props.remoteEdit.errorstr;
}
@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 model = this.props.model;
let prtn = GlobalCommandRunner.createRemote(cname, kwargs, false);
prtn.then((crtn) => {
if (crtn.success) {
let crRtn = GlobalCommandRunner.screenSetRemote(cname, true, false);
crRtn.then((crcrtn) => {
if (crcrtn.success) {
model.closeModal();
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
handleChangePort(value: string): void {
mobx.action(() => {
this.tempPort.set(value);
})();
}
@boundMethod
handleChangeHostName(value: string): void {
mobx.action(() => {
this.tempHostName.set(value);
})();
}
render() {
let { model, remoteEdit } = this.props;
let authMode = this.tempAuthMode.get();
return (
<div className={cn("modal wave-modal crconn-modal is-active")}>
<div className="modal-background wave-modal-background" />
<div className="modal-content wave-modal-content crconn-wave-modal-content">
<div className="wave-modal-content-inner crconn-wave-modal-content-inner">
<header className="wave-modal-header crconn-wave-modal-header">
<div className="wave-modal-title crconn-wave-modal-title">Add Connection</div>
<div className="wave-modal-close crconn-wave-modal-close" onClick={model.cancelEditAuth}>
<img src={close} alt="Close (Escape)" />
</div>
</header>
<div className="wave-modal-body crconn-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: "key", label: "key" },
{ 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())}>
<div className="settings-field settings-error">Error: {this.getErrorStr()}</div>
</If>
</div>
<footer className="wave-modal-footer crconn-wave-modal-footer">
<div className="action-buttons">
<div onClick={model.cancelEditAuth} className="button wave-button is-plain">
Cancel
</div>
<button
onClick={this.submitRemote}
className="button wave-button is-wave-green text-standard"
>
Connect
</button>
</div>
</footer>
</div>
</div>
</div>
);
}
}
export { LoadingSpinner, ClientStopModal, AlertModal, DisconnectedModal, TosModal, AboutModal, CreateRemoteConnModal };

View File

@ -1129,6 +1129,12 @@ class RemotesModal extends React.Component<{ model: RemotesModalModel }, {}> {
let selectedRemote = GlobalModel.getRemote(selectedRemoteId);
let remoteEdit = model.remoteEdit.get();
let onlyAddNewRemote = model.onlyAddNewRemote.get();
// @TODO: this is a hack to determine which create modal to show
if (remoteEdit && !remoteEdit.old) {
return null;
}
return (
<div className={cn("modal remotes-modal settings-modal prompt-modal is-active")}>
<div className="modal-background" />
@ -1179,7 +1185,15 @@ class RemotesModal extends React.Component<{ model: RemotesModalModel }, {}> {
}
@mobxReact.observer
class ConnectionDropdown extends React.Component<{ curRemote: T.RemoteType, onSelectRemote?: (cname: string) => void, allowNewConn: boolean, onNewConn?: () => void }, {}> {
class ConnectionDropdown extends React.Component<
{
curRemote: T.RemoteType;
onSelectRemote?: (cname: string) => void;
allowNewConn: boolean;
onNewConn?: () => void;
},
{}
> {
connDropdownActive: OV<boolean> = mobx.observable.box(false, { name: "connDropdownActive" });
@boundMethod
@ -1219,39 +1233,31 @@ class ConnectionDropdown extends React.Component<{ curRemote: T.RemoteType, onSe
<div className="conn-dd-trigger">
<If condition={curRemote != null}>
<div className="lefticon">
<GlobeIcon className="globe-icon"/>
<StatusCircleIcon className={cn("status-icon", "status-" + curRemote.status)}/>
<GlobeIcon className="globe-icon" />
<StatusCircleIcon className={cn("status-icon", "status-" + curRemote.status)} />
</div>
<div className="conntext">
<If condition={util.isBlank(curRemote.remotealias)}>
<div className="text-standard conntext-solo">
{curRemote.remotecanonicalname}
</div>
<div className="text-standard conntext-solo">{curRemote.remotecanonicalname}</div>
</If>
<If condition={!util.isBlank(curRemote.remotealias)}>
<div className="text-secondary conntext-1">
{curRemote.remotealias}
</div>
<div className="text-caption conntext-2">
{curRemote.remotecanonicalname}
</div>
<div className="text-secondary conntext-1">{curRemote.remotealias}</div>
<div className="text-caption conntext-2">{curRemote.remotecanonicalname}</div>
</If>
</div>
<div className="dd-control">
<ArrowsUpDownIcon className="icon"/>
<ArrowsUpDownIcon className="icon" />
</div>
</If>
<If condition={curRemote == null}>
<div className="lefticon">
<GlobeIcon className="globe-icon"/>
<GlobeIcon className="globe-icon" />
</div>
<div className="conntext">
<div className="text-standard conntext-solo">
(no connection)
</div>
<div className="text-standard conntext-solo">(no connection)</div>
</div>
<div className="dd-control">
<ArrowsUpDownIcon className="icon"/>
<ArrowsUpDownIcon className="icon" />
</div>
</If>
</div>
@ -1259,9 +1265,13 @@ class ConnectionDropdown extends React.Component<{ curRemote: T.RemoteType, onSe
<div className="dropdown-menu" role="menu">
<div className="dropdown-content conn-dd-menu">
<For each="remote" of={allRemotes}>
<div className="dropdown-item" key={remote.remoteid} onClick={() => this.selectRemote(remote.remotecanonicalname)}>
<div
className="dropdown-item"
key={remote.remoteid}
onClick={() => this.selectRemote(remote.remotecanonicalname)}
>
<div className="status-div">
<CircleIcon className={cn("status-icon", "status-" + remote.status)}/>
<CircleIcon className={cn("status-icon", "status-" + remote.status)} />
</div>
<If condition={util.isBlank(remote.remotealias)}>
<div className="text-standard">{remote.remotecanonicalname}</div>
@ -1275,7 +1285,7 @@ class ConnectionDropdown extends React.Component<{ curRemote: T.RemoteType, onSe
<If condition={this.props.allowNewConn}>
<div className="dropdown-item" onClick={this.clickNewConnection}>
<div className="add-div">
<AddIcon className="add-icon"/>
<AddIcon className="add-icon" />
</div>
<div className="text-standard">New Connection</div>
</div>

View File

@ -101,7 +101,7 @@ class NewTabSettings extends React.Component<{ screen: Screen }, {}> {
@boundMethod
clickNewConnection(): void {
GlobalModel.remotesModalModel.openModalForEdit({ remoteedit: true }, true);
GlobalModel.remotesModalModel.openModalForEdit({ remoteedit: true, old: false }, true);
}
renderTabIconSelector(): React.ReactNode {
@ -171,9 +171,8 @@ class NewTabSettings extends React.Component<{ screen: Screen }, {}> {
return (
<div className="newtab-container">
<div className="newtab-section name-section">
<div className="text-standard">Name</div>
<TextField
label="Title"
label="Name"
required={true}
defaultValue={screen.name.get() ?? ""}
onChange={this.updateName}

View File

@ -188,7 +188,7 @@ let menuTemplate = [
],
},
{
label: "Filemenu",
label: "File",
submenu: [{ role: "close" }, { role: "forceReload" }],
},
{

View File

@ -3290,7 +3290,7 @@ class Model {
if (rview.remoteshowall) {
this.remotesModalModel.openModal();
} else if (rview.remoteedit != null) {
this.remotesModalModel.openModalForEdit(rview.remoteedit, false);
this.remotesModalModel.openModalForEdit({ ...rview.remoteedit, old: true }, false);
} else if (rview.ptyremoteid) {
this.remotesModalModel.openModal(rview.ptyremoteid);
}

View File

@ -56,7 +56,7 @@ class SimpleBlobRendererModel {
this.savedHeight = params.savedHeight;
this.ptyDataSource = params.ptyDataSource;
if (this.isClosed) {
this.dataBlob = new Blob();
this.dataBlob = (new Blob() as T.ExtBlob);
this.dataBlob.notFound = false; // TODO
} else {
if (this.isDone.get()) {

View File

@ -309,6 +309,8 @@ type RemoteEditType = {
infostr?: string;
keystr?: string;
haspassword?: boolean;
// @TODO: this is a hack to determine which create modal to show
old?: boolean;
};
type InfoType = {