merge 'main' into use-ssh-library--add-user-input

This merge is rather large, so it needs some explaining. Most of it was
a straightforward merge, but there were a few markdown edits i made that
had to manually be resolved.

There were also a few path changes to be made in the userinput.tsx file.
This commit is contained in:
Sylvia Crowe 2024-02-08 13:06:56 -08:00
commit 75778963c5
72 changed files with 3149 additions and 2671 deletions

View File

@ -10,7 +10,7 @@
# Wave Terminal
A open-source, cross-platform, modern terminal for seamless workflows.
A open-source, cross-platform, AI-integrated, modern terminal for seamless workflows.
Wave isn't just another terminal emulator; it's a rethink on how terminals are built. Wave combines command line with the power of the open web to help veteran CLI users and new developers alike.
@ -18,6 +18,7 @@ Wave isn't just another terminal emulator; it's a rethink on how terminals are b
* Persistent sessions that can restore state across network disconnections and reboots
* Searchable contextual command history across all remote sessions (saved locally)
* Workspaces, tabs, and command blocks to keep you organized
* AI Integration with ChatGPT (or ChatGPT compatible APIs) to help write commands and get answers inline
## Installation
@ -35,6 +36,7 @@ brew install --cask wave
* Homepage — https://www.waveterm.dev
* Download Page — https://www.waveterm.dev/download
* Documentation — https://docs.waveterm.dev/
* Blog — https://blog.waveterm.dev/
* Quick Start Guide — https://docs.waveterm.dev/quickstart/
* Discord Community — https://discord.gg/XfvZ334gwU

View File

@ -9,7 +9,7 @@ import { If, For } from "tsx-control-statements/components";
import cn from "classnames";
import type { BookmarkType } from "../../types/types";
import { GlobalModel } from "../../model/model";
import { CmdStrCode, Markdown } from "../common/common";
import { CmdStrCode, Markdown } from "../common/elements";
import { ReactComponent as XmarkIcon } from "../assets/icons/line/xmark.svg";
import { ReactComponent as CopyIcon } from "../assets/icons/favourites/copy.svg";

View File

@ -7,7 +7,7 @@ import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import cn from "classnames";
import { GlobalModel, GlobalCommandRunner, MinFontSize, MaxFontSize, RemotesModel } from "../../model/model";
import { Toggle, InlineSettingsTextEdit, SettingsError, Dropdown } from "../common/common";
import { Toggle, InlineSettingsTextEdit, SettingsError, Dropdown } from "../common/elements";
import { CommandRtnType, ClientDataType } from "../../types/types";
import { commandRtnHandler, isBlank } from "../../util/util";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,123 @@
@import "../../../app/common/themes/themes.less";
.wave-button {
background: none;
color: inherit;
border: none;
font: inherit;
cursor: pointer;
outline: inherit;
display: flex;
padding: 6px 16px;
align-items: center;
gap: 4px;
border-radius: 6px;
height: auto;
&:hover {
color: @term-white;
}
i {
fill: rgba(255, 255, 255, 0.12);
}
&.primary {
color: @term-green;
background: none;
i {
fill: @term-green;
}
&.solid {
color: @term-bright-white;
background: @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;
i {
fill: @term-white;
}
}
&.outlined {
border: 1px solid @term-green;
}
&.ghost {
// Styles for .ghost are already defined above
}
&:hover {
color: @term-bright-white;
}
}
&.secondary {
color: @term-white;
background: none;
&.solid {
background: rgba(255, 255, 255, 0.09);
box-shadow: none;
}
&.outlined {
border: 1px solid rgba(255, 255, 255, 0.09);
}
&.ghost {
padding: 6px 10px;
i {
fill: @term-green;
}
}
}
&.color-yellow {
&.solid {
border-color: @warning-yellow;
background-color: mix(@warning-yellow, @term-white, 50%);
box-shadow: none;
}
&.outlined {
color: @warning-yellow;
border-color: @warning-yellow;
&:hover {
color: @term-white;
border-color: @term-white;
}
}
&.ghost {
}
}
&.color-red {
&.solid {
border-color: @term-red;
background-color: mix(@term-red, @term-white, 50%);
box-shadow: none;
}
&.outlined {
color: @term-red;
border-color: @term-red;
}
&.ghost {
}
}
&.disabled {
opacity: 0.5;
}
&.link-button {
cursor: pointer;
}
}

View File

@ -0,0 +1,63 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import { boundMethod } from "autobind-decorator";
import cn from "classnames";
import "./button.less";
type ButtonVariantType = "outlined" | "solid" | "ghost";
type ButtonThemeType = "primary" | "secondary";
interface ButtonProps {
theme?: ButtonThemeType;
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
variant?: ButtonVariantType;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
color?: string;
style?: React.CSSProperties;
autoFocus?: boolean;
className?: string;
}
class Button extends React.Component<ButtonProps> {
static defaultProps = {
theme: "primary",
variant: "solid",
color: "",
style: {},
};
@boundMethod
handleClick() {
if (this.props.onClick && !this.props.disabled) {
this.props.onClick();
}
}
render() {
const { leftIcon, rightIcon, theme, children, disabled, variant, color, style, autoFocus, className } =
this.props;
return (
<button
className={cn("wave-button", theme, variant, color, { disabled: disabled }, className)}
onClick={this.handleClick}
disabled={disabled}
style={style}
autoFocus={autoFocus}
>
{leftIcon && <span className="icon-left">{leftIcon}</span>}
{children}
{rightIcon && <span className="icon-right">{rightIcon}</span>}
</button>
);
}
}
export { Button };
export type { ButtonProps };

View File

@ -0,0 +1,68 @@
@import "../../../app/common/themes/themes.less";
.checkbox {
display: flex;
input[type="checkbox"] {
height: 0;
width: 0;
}
input[type="checkbox"] + label {
position: relative;
display: flex;
align-items: center;
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: 10px;
width: 20px;
height: 20px;
background: transparent;
border: 2px solid #9e9e9e;
border-radius: 2px;
cursor: pointer;
transition: all 250ms cubic-bezier(0.4, 0, 0.23, 1);
}
input[type="checkbox"] + label:hover > span,
input[type="checkbox"]:focus + label > span {
background: rgba(255, 255, 255, 0.1);
}
input[type="checkbox"]:checked + label > ins {
height: 100%;
}
input[type="checkbox"]:checked + label > span {
border: 10px solid @term-green;
}
input[type="checkbox"]:checked + label > span:before {
content: "";
position: absolute;
top: -2px;
left: 3px;
width: 7px;
height: 12px;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(45deg);
transform-origin: 0% 100%;
animation: checkbox-check 500ms cubic-bezier(0.4, 0, 0.23, 1);
}
@keyframes checkbox-check {
0% {
opacity: 0;
}
33% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
}

View File

@ -0,0 +1,70 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobx from "mobx";
import cn from "classnames";
import "./checkbox.less";
class Checkbox extends React.Component<
{
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 ?? 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 { label, className, id } = this.props;
const { checkedInternal } = this.state;
const checkboxId = id || this.generatedId;
return (
<div className={cn("checkbox", className)}>
<input
type="checkbox"
id={checkboxId}
checked={checkedInternal}
onChange={this.handleChange}
aria-checked={checkedInternal}
role="checkbox"
/>
<label htmlFor={checkboxId}>
<span></span>
{label}
</label>
</div>
);
}
}
export { Checkbox };

View File

@ -0,0 +1,102 @@
@import "../../../app/common/themes/themes.less";
.cmdstr-code {
position: relative;
display: flex;
flex-direction: row;
padding: 0px 10px 0px 0;
&.is-large {
.use-button {
height: 28px;
width: 28px;
}
.code-div code {
}
}
&.limit-height .code-div {
max-height: 58px;
}
&.limit-height.is-large .code-div {
max-height: 68px;
}
.use-button {
flex-grow: 0;
padding: 3px;
border-radius: 3px 0 0 3px;
height: 22px;
width: 22px;
display: flex;
align-items: center;
justify-content: center;
align-self: flex-start;
cursor: pointer;
}
.code-div {
background-color: @term-black;
display: flex;
flex-direction: row;
min-width: 100px;
overflow: auto;
border-left: 1px solid #777;
code {
flex-shrink: 0;
min-width: 100px;
color: @term-white;
white-space: pre;
padding: 2px 8px 2px 8px;
background-color: @term-black;
font-size: 1em;
font-family: @fixed-font;
}
}
.copy-control {
width: 0;
position: relative;
display: block;
visibility: hidden;
.inner-copy {
position: absolute;
bottom: -1px;
right: -20px;
padding: 2px;
padding-left: 4px;
cursor: pointer;
width: 20px;
&:hover {
color: @term-white;
}
}
}
&:hover .copy-control {
visibility: visible !important;
}
}
.copied-indicator {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: @term-white;
opacity: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
pointer-events: none;
animation-name: fade-in-out;
animation-duration: 0.3s;
}

View File

@ -0,0 +1,66 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import { boundMethod } from "autobind-decorator";
import cn from "classnames";
import { If } from "tsx-control-statements/components";
import { ReactComponent as CheckIcon } from "../../assets/icons/line/check.svg";
import { ReactComponent as CopyIcon } from "../../assets/icons/history/copy.svg";
import "./cmdstrcode.less";
class CmdStrCode extends React.Component<
{
cmdstr: string;
onUse: () => void;
onCopy: () => void;
isCopied: boolean;
fontSize: "normal" | "large";
limitHeight: boolean;
},
{}
> {
@boundMethod
handleUse(e: any) {
e.stopPropagation();
if (this.props.onUse != null) {
this.props.onUse();
}
}
@boundMethod
handleCopy(e: any) {
e.stopPropagation();
if (this.props.onCopy != null) {
this.props.onCopy();
}
}
render() {
let { isCopied, cmdstr, fontSize, limitHeight } = this.props;
return (
<div className={cn("cmdstr-code", { "is-large": fontSize == "large" }, { "limit-height": limitHeight })}>
<If condition={isCopied}>
<div key="copied" className="copied-indicator">
<div>copied</div>
</div>
</If>
<div key="use" className="use-button hoverEffect" title="Use Command" onClick={this.handleUse}>
<CheckIcon className="icon" />
</div>
<div key="code" className="code-div">
<code>{cmdstr}</code>
</div>
<div key="copy" className="copy-control hoverEffect">
<div className="inner-copy" onClick={this.handleCopy} title="copy">
<CopyIcon className="icon" />
</div>
</div>
</div>
);
}
}
export { CmdStrCode };

View File

@ -0,0 +1,10 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
function renderCmdText(text: string): any {
return <span>&#x2318;{text}</span>;
}
export { renderCmdText };

View File

@ -0,0 +1,127 @@
@import "../../../app/common/themes/themes.less";
.wave-dropdown {
position: relative;
height: 44px;
min-width: 150px;
width: 100%;
border: 1px solid rgba(241, 246, 243, 0.15);
border-radius: 6px;
background: 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;
&.no-label {
height: 34px;
}
&-label {
position: absolute;
left: 16px;
top: 16px;
font-size: 12.5px;
transition: all 0.3s;
color: @term-white;
line-height: 10px;
&.float {
font-size: 10px;
top: 5px;
}
&.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: 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;
margin-top: 2px;
max-height: 200px;
overflow-y: auto;
padding: 6px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
border-radius: 6px;
background: #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;
}

View File

@ -0,0 +1,259 @@
// 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 cn from "classnames";
import { If } from "tsx-control-statements/components";
import ReactDOM from "react-dom";
import "./dropdown.less";
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 ?? 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 unselectable", {
"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,
"no-label": !label,
})}
ref={this.wrapperRef}
tabIndex={0}
onKeyDown={this.handleKeyDown}
onClick={this.handleClick}
onFocus={this.handleFocus}
>
{decoration?.startDecoration && <>{decoration.startDecoration}</>}
<If condition={label}>
<div
className={cn("wave-dropdown-label unselectable", {
float: shouldLabelFloat,
"offset-left": decoration?.startDecoration,
})}
>
{label}
</div>
</If>
<div
className={cn("wave-dropdown-display unselectable", { "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 { Dropdown };

View File

@ -0,0 +1,21 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import { Button } from "./button";
class IconButton extends Button {
render() {
const { children, theme, variant = "solid", ...rest } = this.props;
const className = `wave-button icon-button ${theme} ${variant}`;
return (
<button {...rest} className={className}>
{children}
</button>
);
}
}
export default IconButton;
export { IconButton };

View File

@ -0,0 +1,20 @@
export { Button } from "./button";
export { Checkbox } from "./checkbox";
export { CmdStrCode } from "./cmdstrcode";
export { renderCmdText } from "./cmdtext";
export { Dropdown } from "./dropdown";
export { IconButton } from "./iconbutton";
export { InlineSettingsTextEdit } from "./inlinesettingstextedit";
export { InputDecoration } from "./inputdecoration";
export { LinkButton } from "./linkbutton";
export { Markdown } from "./markdown";
export { Modal } from "./modal";
export { NumberField } from "./numberfield";
export { PasswordField } from "./passwordfield";
export { ResizableSidebar } from "./resizablesidebar";
export { SettingsError } from "./settingserror";
export { ShowWaveShellInstallPrompt } from "./showwaveshellinstallprompt";
export { Status } from "./status";
export { TextField } from "./textfield";
export { Toggle } from "./toggle";
export { Tooltip } from "./tooltip";

View File

@ -0,0 +1,40 @@
@import "../../../app/common/themes/themes.less";
.inline-edit {
.icon {
display: inline;
width: 12px;
height: 12px;
margin-left: 1em;
vertical-align: middle;
font-size: 14px;
}
.button {
padding-top: 0;
}
&.edit-not-active {
cursor: pointer;
i.fa-pen {
margin-left: 5px;
}
&:hover {
text-decoration: underline;
text-decoration-style: dotted;
}
}
&.edit-active {
input.input {
padding: 0;
height: 20px;
}
.button {
height: 20px;
}
}
}

View File

@ -0,0 +1,149 @@
// 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 cn from "classnames";
import { If } from "tsx-control-statements/components";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../../util/keyutil";
import "./inlinesettingstextedit.less";
type OV<V> = mobx.IObservableValue<V>;
@mobxReact.observer
class InlineSettingsTextEdit extends React.Component<
{
text: string;
value: string;
onChange: (val: string) => void;
maxLength: number;
placeholder: string;
showIcon?: boolean;
},
{}
> {
isEditing: OV<boolean> = mobx.observable.box(false, { name: "inlineedit-isEditing" });
tempText: OV<string>;
shouldFocus: boolean = false;
inputRef: React.RefObject<any> = React.createRef();
componentDidUpdate(): void {
if (this.shouldFocus) {
this.shouldFocus = false;
if (this.inputRef.current != null) {
this.inputRef.current.focus();
}
}
}
@boundMethod
handleChangeText(e: any): void {
mobx.action(() => {
this.tempText.set(e.target.value);
})();
}
@boundMethod
confirmChange(): void {
mobx.action(() => {
let newText = this.tempText.get();
this.isEditing.set(false);
this.tempText = null;
this.props.onChange(newText);
})();
}
@boundMethod
cancelChange(): void {
mobx.action(() => {
this.isEditing.set(false);
this.tempText = null;
})();
}
@boundMethod
handleKeyDown(e: any): void {
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
if (checkKeyPressed(waveEvent, "Enter")) {
e.preventDefault();
e.stopPropagation();
this.confirmChange();
return;
}
if (checkKeyPressed(waveEvent, "Escape")) {
e.preventDefault();
e.stopPropagation();
this.cancelChange();
return;
}
return;
}
@boundMethod
clickEdit(): void {
mobx.action(() => {
this.isEditing.set(true);
this.shouldFocus = true;
this.tempText = mobx.observable.box(this.props.value, { name: "inlineedit-tempText" });
})();
}
render() {
if (this.isEditing.get()) {
return (
<div className={cn("settings-input inline-edit", "edit-active")}>
<div className="field has-addons">
<div className="control">
<input
ref={this.inputRef}
className="input"
type="text"
onKeyDown={this.handleKeyDown}
placeholder={this.props.placeholder}
onChange={this.handleChangeText}
value={this.tempText.get()}
maxLength={this.props.maxLength}
/>
</div>
<div className="control">
<div
onClick={this.cancelChange}
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>
</div>
</div>
<div className="control">
<div
onClick={this.confirmChange}
title="Confirm (Enter)"
className="button is-wave-green is-outlined is-small"
>
<span className="icon is-small">
<i className="fa-sharp fa-solid fa-check" />
</span>
</div>
</div>
</div>
</div>
);
} else {
return (
<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" />
</If>
</div>
);
}
}
}
export { InlineSettingsTextEdit };

View File

@ -0,0 +1,19 @@
@import "../../../app/common/themes/themes.less";
.wave-input-decoration {
display: flex;
align-items: center;
justify-content: center;
i {
font-size: 13px;
}
}
.wave-input-decoration.start-position {
margin: 0 4px 0 16px;
}
.wave-input-decoration.end-position {
margin: 0 16px 0 8px;
}

View File

@ -0,0 +1,32 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import cn from "classnames";
import "./inputdecoration.less";
interface InputDecorationProps {
position?: "start" | "end";
children: React.ReactNode;
}
@mobxReact.observer
class InputDecoration extends React.Component<InputDecorationProps, {}> {
render() {
const { children, position = "end" } = this.props;
return (
<div
className={cn("wave-input-decoration", {
"start-position": position === "start",
"end-position": position === "end",
})}
>
{children}
</div>
);
}
}
export { InputDecoration };

View File

@ -0,0 +1,28 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import cn from "classnames";
import { ButtonProps } from "./button";
interface LinkButtonProps extends ButtonProps {
href: string;
rel?: string;
target?: string;
}
class LinkButton extends React.Component<LinkButtonProps> {
render() {
const { leftIcon, rightIcon, children, className, ...rest } = this.props;
return (
<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>
);
}
}
export { LinkButton };

View File

@ -0,0 +1,92 @@
@import "../../../app/common/themes/themes.less";
.markdown {
color: @term-white;
margin-bottom: 10px;
font-family: @markdown-font;
font-size: 14px;
overflow-wrap: break-word;
code {
background-color: @markdown-highlight;
color: @term-white;
font-family: @terminal-font;
border-radius: 4px;
}
code.inline {
padding-top: 0;
padding-bottom: 0;
font-family: @terminal-font;
}
.title {
color: @term-white;
margin-top: 16px;
margin-bottom: 8px;
}
strong {
color: @term-white;
}
a {
color: #32afff;
}
table {
tr th {
color: @term-white;
}
}
ul {
list-style-type: disc;
list-style-position: outside;
margin-left: 16px;
}
ol {
list-style-position: outside;
margin-left: 19px;
}
blockquote {
margin: 4px 10px 4px 10px;
border-radius: 3px;
background-color: @markdown-highlight;
padding: 2px 4px 2px 6px;
}
pre {
background-color: @markdown-highlight;
margin: 4px 10px 4px 10px;
padding: 6px 6px 6px 10px;
border-radius: 4px;
}
pre.selected {
outline: 2px solid @term-green;
}
.title.is-1 {
border-bottom: 1px solid #777;
padding-bottom: 6px;
}
.title.is-2 {
border-bottom: 1px solid #777;
padding-bottom: 6px;
}
.title.is-3 {
}
.title.is-4 {
}
.title.is-5 {
}
.title.is-6 {
}
}
.markdown > *:first-child {
margin-top: 0 !important;
}

View File

@ -0,0 +1,105 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import cn from "classnames";
import { GlobalModel } from "../../../model/model";
import "./markdown.less";
function LinkRenderer(props: any): any {
let newUrl = "https://extern?" + encodeURIComponent(props.href);
return (
<a href={newUrl} target="_blank" rel={"noopener"}>
{props.children}
</a>
);
}
function HeaderRenderer(props: any, hnum: number): any {
return <div className={cn("title", "is-" + hnum)}>{props.children}</div>;
}
function CodeRenderer(props: any): any {
return <code className={cn({ inline: props.inline })}>{props.children}</code>;
}
@mobxReact.observer
class CodeBlockMarkdown extends React.Component<{ children: React.ReactNode; codeSelectSelectedIndex?: number }, {}> {
blockIndex: number;
blockRef: React.RefObject<HTMLPreElement>;
constructor(props) {
super(props);
this.blockRef = React.createRef();
this.blockIndex = GlobalModel.inputModel.addCodeBlockToCodeSelect(this.blockRef);
}
render() {
let clickHandler: (e: React.MouseEvent<HTMLElement>, blockIndex: number) => void;
let inputModel = GlobalModel.inputModel;
clickHandler = (e: React.MouseEvent<HTMLElement>, blockIndex: number) => {
inputModel.setCodeSelectSelectedCodeBlock(blockIndex);
};
let selected = this.blockIndex == this.props.codeSelectSelectedIndex;
return (
<pre
ref={this.blockRef}
className={cn({ selected: selected })}
onClick={(event) => clickHandler(event, this.blockIndex)}
>
{this.props.children}
</pre>
);
}
}
@mobxReact.observer
class Markdown extends React.Component<
{ text: string; style?: any; extraClassName?: string; codeSelect?: boolean },
{}
> {
CodeBlockRenderer(props: any, codeSelect: boolean, codeSelectIndex: number): any {
if (codeSelect) {
return <CodeBlockMarkdown codeSelectSelectedIndex={codeSelectIndex}>{props.children}</CodeBlockMarkdown>;
} else {
const clickHandler = (e: React.MouseEvent<HTMLElement>) => {
let blockText = (e.target as HTMLElement).innerText;
if (blockText) {
blockText = blockText.replace(/\n$/, ""); // remove trailing newline
navigator.clipboard.writeText(blockText);
}
};
return <pre onClick={(event) => clickHandler(event)}>{props.children}</pre>;
}
}
render() {
let text = this.props.text;
let codeSelect = this.props.codeSelect;
let curCodeSelectIndex = GlobalModel.inputModel.getCodeSelectSelectedIndex();
let markdownComponents = {
a: LinkRenderer,
h1: (props) => HeaderRenderer(props, 1),
h2: (props) => HeaderRenderer(props, 2),
h3: (props) => HeaderRenderer(props, 3),
h4: (props) => HeaderRenderer(props, 4),
h5: (props) => HeaderRenderer(props, 5),
h6: (props) => HeaderRenderer(props, 6),
code: (props) => CodeRenderer(props),
pre: (props) => this.CodeBlockRenderer(props, codeSelect, curCodeSelectIndex),
};
return (
<div className={cn("markdown content", this.props.extraClassName)} style={this.props.style}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{text}
</ReactMarkdown>
</div>
);
}
}
export { Markdown };

View File

@ -0,0 +1,79 @@
@import "../../../app/common/themes/themes.less";
.wave-modal-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
z-index: 500;
.wave-modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(21, 23, 21, 0.7);
z-index: 1;
}
}
.wave-modal {
z-index: 2;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
border-radius: 10px;
background: #151715;
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;
.wave-modal-content {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
.wave-modal-header {
width: 100%;
display: flex;
align-items: center;
padding: 12px 14px 12px 20px;
justify-content: space-between;
line-height: 20px;
border-bottom: 1px solid rgba(250, 250, 250, 0.1);
.wave-modal-title {
color: #eceeec;
font-size: 15px;
}
button {
i {
font-size: 18px;
}
}
}
.wave-modal-body {
width: 100%;
padding: 0px 20px;
}
.wave-modal-footer {
display: flex;
justify-content: flex-end;
width: 100%;
padding: 0 20px 20px;
button:last-child {
margin-left: 8px;
}
}
}
}

View File

@ -0,0 +1,81 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobx from "mobx";
import { If } from "tsx-control-statements/components";
import ReactDOM from "react-dom";
import { Button } from "./button";
import { IconButton } from "./iconbutton";
import "./modal.less";
type OV<V> = mobx.IObservableValue<V>;
interface ModalHeaderProps {
onClose?: () => void;
title: string;
}
const ModalHeader: React.FC<ModalHeaderProps> = ({ onClose, title }) => (
<div className="wave-modal-header">
{<div className="wave-modal-title">{title}</div>}
<If condition={onClose}>
<IconButton theme="secondary" variant="ghost" onClick={onClose}>
<i className="fa-sharp fa-solid fa-xmark"></i>
</IconButton>
</If>
</div>
);
interface ModalFooterProps {
onCancel?: () => void;
onOk?: () => void;
cancelLabel?: string;
okLabel?: string;
}
const ModalFooter: React.FC<ModalFooterProps> = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok" }) => (
<div className="wave-modal-footer">
{onCancel && (
<Button theme="secondary" onClick={onCancel}>
{cancelLabel}
</Button>
)}
{onOk && <Button onClick={onOk}>{okLabel}</Button>}
</div>
);
interface ModalProps {
className?: string;
children?: React.ReactNode;
onClickBackdrop?: () => void;
}
class Modal extends React.Component<ModalProps> {
static Header = ModalHeader;
static Footer = ModalFooter;
renderBackdrop(onClick: (() => void) | undefined) {
return <div className="wave-modal-backdrop" onClick={onClick}></div>;
}
renderModal() {
const { className, children } = this.props;
return (
<div className="wave-modal-container">
{this.renderBackdrop(this.props.onClickBackdrop)}
<div className={`wave-modal ${className}`}>
<div className="wave-modal-content">{children}</div>
</div>
</div>
);
}
render() {
return ReactDOM.createPortal(this.renderModal(), document.getElementById("app"));
}
}
export { Modal };

View File

@ -0,0 +1,39 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import { boundMethod } from "autobind-decorator";
import { TextField } from "./textfield";
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);
}
}
render() {
// Use the render method from TextField but add the onKeyDown handler
const renderedTextField = super.render();
return React.cloneElement(renderedTextField);
}
}
export { NumberField };

View File

@ -0,0 +1,30 @@
@import "../../../app/common/themes/themes.less";
.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;
}
}

View File

@ -0,0 +1,106 @@
// 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 cn from "classnames";
import { If } from "tsx-control-statements/components";
import { TextFieldState, TextField } from "./textfield";
import "./passwordfield.less";
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 ?? 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,
"no-label": !label,
})}
>
{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>
);
}
}
export { PasswordField };

View File

@ -0,0 +1,9 @@
@import "../../../app/common/themes/themes.less";
.sidebar-handle {
position: absolute;
top: 0;
bottom: 0;
width: 5px;
cursor: col-resize;
}

View File

@ -0,0 +1,171 @@
// 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 cn from "classnames";
import { GlobalModel, GlobalCommandRunner } from "../../../model/model";
import { MagicLayout } from "../../magiclayout";
import "./resizablesidebar.less";
type OV<V> = mobx.IObservableValue<V>;
interface ResizableSidebarProps {
parentRef: React.RefObject<HTMLElement>;
position: "left" | "right";
enableSnap?: boolean;
className?: string;
children?: (toggleCollapsed: () => void) => React.ReactNode;
toggleCollapse?: () => void;
}
@mobxReact.observer
class ResizableSidebar extends React.Component<ResizableSidebarProps> {
resizeStartWidth: number = 0;
startX: number = 0;
prevDelta: number = 0;
prevDragDirection: string = null;
disposeReaction: any;
@boundMethod
startResizing(event: React.MouseEvent<HTMLDivElement>) {
event.preventDefault();
const { parentRef, position } = this.props;
const parentRect = parentRef.current?.getBoundingClientRect();
if (!parentRect) return;
if (position === "right") {
this.startX = parentRect.right - event.clientX;
} else {
this.startX = event.clientX - parentRect.left;
}
const mainSidebarModel = GlobalModel.mainSidebarModel;
const collapsed = mainSidebarModel.getCollapsed();
this.resizeStartWidth = mainSidebarModel.getWidth();
document.addEventListener("mousemove", this.onMouseMove);
document.addEventListener("mouseup", this.stopResizing);
document.body.style.cursor = "col-resize";
mobx.action(() => {
mainSidebarModel.setTempWidthAndTempCollapsed(this.resizeStartWidth, collapsed);
mainSidebarModel.isDragging.set(true);
})();
}
@boundMethod
onMouseMove(event: MouseEvent) {
event.preventDefault();
const { parentRef, enableSnap, position } = this.props;
const parentRect = parentRef.current?.getBoundingClientRect();
const mainSidebarModel = GlobalModel.mainSidebarModel;
if (!mainSidebarModel.isDragging.get() || !parentRect) return;
let delta: number, newWidth: number;
if (position === "right") {
delta = parentRect.right - event.clientX - this.startX;
} else {
delta = event.clientX - parentRect.left - this.startX;
}
newWidth = this.resizeStartWidth + delta;
if (enableSnap) {
const minWidth = MagicLayout.MainSidebarMinWidth;
const snapPoint = minWidth + MagicLayout.MainSidebarSnapThreshold;
const dragResistance = MagicLayout.MainSidebarDragResistance;
let dragDirection: string;
if (delta - this.prevDelta > 0) {
dragDirection = "+";
} else if (delta - this.prevDelta == 0) {
if (this.prevDragDirection == "+") {
dragDirection = "+";
} else {
dragDirection = "-";
}
} else {
dragDirection = "-";
}
this.prevDelta = delta;
this.prevDragDirection = dragDirection;
if (newWidth - dragResistance > minWidth && newWidth < snapPoint && dragDirection == "+") {
newWidth = snapPoint;
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false);
} else if (newWidth + dragResistance < snapPoint && dragDirection == "-") {
newWidth = minWidth;
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, true);
} else if (newWidth > snapPoint) {
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false);
}
} else {
if (newWidth <= MagicLayout.MainSidebarMinWidth) {
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, true);
} else {
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false);
}
}
}
@boundMethod
stopResizing() {
let mainSidebarModel = GlobalModel.mainSidebarModel;
GlobalCommandRunner.clientSetSidebar(
mainSidebarModel.tempWidth.get(),
mainSidebarModel.tempCollapsed.get()
).finally(() => {
mobx.action(() => {
mainSidebarModel.isDragging.set(false);
})();
});
document.removeEventListener("mousemove", this.onMouseMove);
document.removeEventListener("mouseup", this.stopResizing);
document.body.style.cursor = "";
}
@boundMethod
toggleCollapsed() {
const mainSidebarModel = GlobalModel.mainSidebarModel;
const tempCollapsed = mainSidebarModel.getCollapsed();
const width = mainSidebarModel.getWidth(true);
mainSidebarModel.setTempWidthAndTempCollapsed(width, !tempCollapsed);
GlobalCommandRunner.clientSetSidebar(width, !tempCollapsed);
}
render() {
const { className, children } = this.props;
const mainSidebarModel = GlobalModel.mainSidebarModel;
const width = mainSidebarModel.getWidth();
const isCollapsed = mainSidebarModel.getCollapsed();
return (
<div className={cn("sidebar", className, { collapsed: isCollapsed })} style={{ width }}>
<div className="sidebar-content">{children(this.toggleCollapsed)}</div>
<div
className="sidebar-handle"
style={{
[this.props.position === "left" ? "right" : "left"]: 0,
}}
onMouseDown={this.startResizing}
onDoubleClick={this.toggleCollapsed}
></div>
</div>
);
}
}
export { ResizableSidebar };

View File

@ -0,0 +1,36 @@
// 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";
type OV<V> = mobx.IObservableValue<V>;
@mobxReact.observer
class SettingsError extends React.Component<{ errorMessage: OV<string> }, {}> {
@boundMethod
dismissError(): void {
mobx.action(() => {
this.props.errorMessage.set(null);
})();
}
render() {
if (this.props.errorMessage.get() == null) {
return null;
}
return (
<div className="settings-field settings-error">
<div>Error: {this.props.errorMessage.get()}</div>
<div className="flex-spacer" />
<div onClick={this.dismissError} className="error-dismiss">
<i className="fa-sharp fa-solid fa-xmark" />
</div>
</div>
);
}
}
export { SettingsError };

View File

@ -0,0 +1,28 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { GlobalModel } from "../../../model/model";
import * as appconst from "../../appconst";
function ShowWaveShellInstallPrompt(callbackFn: () => void) {
let message: string = `
In order to use Wave's advanced features like unified history and persistent sessions, Wave installs a small, open-source helper program called WaveShell on your remote machine. WaveShell does not open any external ports and only communicates with your *local* Wave terminal instance over ssh. For more information please see [the docs](https://docs.waveterm.dev/reference/waveshell).
`;
message = message.trim();
let prtn = GlobalModel.showAlert({
message: message,
confirm: true,
markdown: true,
confirmflag: appconst.ConfirmKey_HideShellPrompt,
});
prtn.then((confirm) => {
if (!confirm) {
return;
}
if (callbackFn) {
callbackFn();
}
});
}
export { ShowWaveShellInstallPrompt };

View File

@ -0,0 +1,30 @@
@import "../../../app/common/themes/themes.less";
.wave-status-container {
display: flex;
align-items: center;
.dot {
height: 6px;
width: 6px;
border-radius: 50%;
display: inline-block;
margin-right: 8px;
}
.dot.green {
background-color: @status-connected;
}
.dot.red {
background-color: @status-error;
}
.dot.gray {
background-color: @status-disconnected;
}
.dot.yellow {
background-color: @status-connecting;
}
}

View File

@ -0,0 +1,34 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import { boundMethod } from "autobind-decorator";
import "./status.less";
interface StatusProps {
status: "green" | "red" | "gray" | "yellow";
text: string;
}
class Status extends React.Component<StatusProps> {
@boundMethod
renderDot() {
const { status } = this.props;
return <div className={`dot ${status}`} />;
}
render() {
const { text } = this.props;
return (
<div className="wave-status-container">
{this.renderDot()}
<span>{text}</span>
</div>
);
}
}
export { Status };

View File

@ -0,0 +1,82 @@
@import "../../../app/common/themes/themes.less";
.wave-textfield {
display: flex;
align-items: center;
border-radius: 6px;
position: relative;
height: 44px;
min-width: 412px;
gap: 6px;
border: 1px solid rgba(241, 246, 243, 0.15);
background: 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;
&:hover {
cursor: text;
}
&.focused {
border-color: @term-green;
}
&.disabled {
opacity: 0.75;
}
&.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;
}
}
&-input {
width: 100%;
height: 30px;
border: none;
padding: 5px 0 5px 16px;
font-size: 16px;
outline: none;
background-color: transparent;
color: @term-bright-white;
line-height: 20px;
&.offset-left {
padding: 5px 16px 5px 0;
}
}
}
&.no-label {
height: 34px;
input {
height: 32px;
}
}
}

View File

@ -0,0 +1,173 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import { boundMethod } from "autobind-decorator";
import cn from "classnames";
import { If } from "tsx-control-statements/components";
import "./textfield.less";
interface TextFieldDecorationProps {
startDecoration?: React.ReactNode;
endDecoration?: React.ReactNode;
}
interface TextFieldProps {
label?: string;
value?: string;
className?: string;
onChange?: (value: string) => void;
placeholder?: string;
defaultValue?: string;
decoration?: TextFieldDecorationProps;
required?: boolean;
maxLength?: number;
autoFocus?: boolean;
disabled?: boolean;
}
interface TextFieldState {
focused: boolean;
internalValue: string;
error: boolean;
showHelpText: boolean;
hasContent: boolean;
}
class TextField extends React.Component<TextFieldProps, TextFieldState> {
inputRef: React.RefObject<HTMLInputElement>;
state: TextFieldState;
constructor(props: TextFieldProps) {
super(props);
const hasInitialContent = Boolean(props.value || props.defaultValue);
this.state = {
focused: false,
hasContent: hasInitialContent,
internalValue: props.defaultValue || "",
error: false,
showHelpText: false,
};
this.inputRef = React.createRef();
}
componentDidUpdate(prevProps: TextFieldProps) {
// Only update the focus state if using as controlled
if (this.props.value !== undefined && this.props.value !== prevProps.value) {
this.setState({ focused: Boolean(this.props.value) });
}
}
// Method to handle focus at the component level
@boundMethod
handleComponentFocus() {
if (this.inputRef.current && !this.inputRef.current.contains(document.activeElement)) {
this.inputRef.current.focus();
}
}
// Method to handle blur at the component level
@boundMethod
handleComponentBlur() {
if (this.inputRef.current?.contains(document.activeElement)) {
this.inputRef.current.blur();
}
}
@boundMethod
handleFocus() {
this.setState({ focused: true });
}
@boundMethod
handleBlur() {
const { required } = this.props;
if (this.inputRef.current) {
const value = this.inputRef.current.value;
if (required && !value) {
this.setState({ error: true, focused: false });
} else {
this.setState({ error: false, focused: false });
}
}
}
@boundMethod
handleHelpTextClick() {
this.setState((prevState) => ({ showHelpText: !prevState.showHelpText }));
}
@boundMethod
handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const { required, onChange } = this.props;
const inputValue = e.target.value;
// Check if value is empty and the field is required
if (required && !inputValue) {
this.setState({ error: true, hasContent: false });
} else {
this.setState({ error: false, hasContent: Boolean(inputValue) });
}
// Update the internal state for uncontrolled version
if (this.props.value === undefined) {
this.setState({ internalValue: inputValue });
}
onChange && onChange(inputValue);
}
render() {
const { label, value, placeholder, decoration, className, maxLength, autoFocus, disabled } = this.props;
const { focused, internalValue, error } = this.state;
// Decide if the input should behave as controlled or uncontrolled
const inputValue = value ?? internalValue;
return (
<div
className={cn("wave-textfield", className, {
focused: focused,
error: error,
disabled: disabled,
"no-label": !label,
})}
onFocus={this.handleComponentFocus}
onBlur={this.handleComponentBlur}
tabIndex={-1}
>
{decoration?.startDecoration && <>{decoration.startDecoration}</>}
<div className="wave-textfield-inner">
<If condition={label}>
<label
className={cn("wave-textfield-inner-label", {
float: this.state.hasContent || this.state.focused || placeholder,
"offset-left": decoration?.startDecoration,
})}
htmlFor={label}
>
{label}
</label>
</If>
<input
className={cn("wave-textfield-inner-input", { "offset-left": decoration?.startDecoration })}
ref={this.inputRef}
id={label}
value={inputValue}
onChange={this.handleInputChange}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
placeholder={placeholder}
maxLength={maxLength}
autoFocus={autoFocus}
disabled={disabled}
/>
</div>
{decoration?.endDecoration && <>{decoration.endDecoration}</>}
</div>
);
}
}
export { TextField };
export type { TextFieldProps, TextFieldDecorationProps, TextFieldState };

View File

@ -0,0 +1,47 @@
@import "../../../app/common/themes/themes.less";
.checkbox-toggle {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
content: "";
cursor: pointer;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: #333;
transition: 0.5s;
border-radius: 33px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: @term-white;
transition: 0.5s;
border-radius: 50%;
}
input:checked + .slider {
background-color: @term-green;
}
input:checked + .slider:before {
transform: translateX(18px);
}
}

View File

@ -0,0 +1,28 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import { boundMethod } from "autobind-decorator";
import "./toggle.less";
class Toggle extends React.Component<{ checked: boolean; onChange: (value: boolean) => void }, {}> {
@boundMethod
handleChange(e: any): void {
let { onChange } = this.props;
if (onChange != null) {
onChange(e.target.checked);
}
}
render() {
return (
<label className="checkbox-toggle">
<input type="checkbox" checked={this.props.checked} onChange={this.handleChange} />
<span className="slider" />
</label>
);
}
}
export { Toggle };

View File

@ -0,0 +1,23 @@
@import "../../../app/common/themes/themes.less";
.wave-tooltip {
display: flex;
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;
width: 300px;
i {
display: inline;
font-size: 13px;
fill: @base-color;
padding-top: 0.2em;
}
}

View File

@ -0,0 +1,84 @@
// 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 cn from "classnames";
import ReactDOM from "react-dom";
import "./tooltip.less";
interface TooltipProps {
message: React.ReactNode;
icon?: React.ReactNode; // Optional icon property
children: React.ReactNode;
className?: string;
}
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}px`,
left: `${rect.left + window.scrollX + rect.width / 2 - 17.5}px`,
};
}
return {};
}
@boundMethod
renderBubble() {
if (!this.state.isVisible) return null;
const style = this.calculatePosition();
return ReactDOM.createPortal(
<div className={cn("wave-tooltip", this.props.className)} 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>
);
}
}
export { Tooltip };

View File

@ -6,7 +6,7 @@ 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 { Modal, LinkButton } from "../elements";
import * as util from "../../../util/util";
import logo from "../../assets/waveterm-logo-with-bg.svg";

View File

@ -5,7 +5,7 @@ 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 { Markdown, Modal, Button, Checkbox } from "../elements";
import { GlobalModel, GlobalCommandRunner } from "../../../model/model";
import "./alert.less";

View File

@ -6,7 +6,7 @@ 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 { Modal, Button } from "../elements";
import "./clientstop.less";

View File

@ -8,9 +8,17 @@ 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, ShowWaveShellInstallPrompt } from "../common";
import {
Modal,
TextField,
NumberField,
InputDecoration,
Dropdown,
PasswordField,
Tooltip,
ShowWaveShellInstallPrompt,
} from "../elements";
import * as util from "../../../util/util";
import * as appconst from "../../appconst";
import "./createremoteconn.less";

View File

@ -6,7 +6,7 @@ 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 { Modal, Button } from "../elements";
import "./disconnected.less";

View File

@ -8,7 +8,7 @@ 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 { Modal, TextField, InputDecoration, Dropdown, PasswordField, Tooltip } from "../elements";
import * as util from "../../../util/util";
import "./editremoteconn.less";

View File

@ -6,7 +6,7 @@ import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { GlobalModel, GlobalCommandRunner } from "../../../model/model";
import { SettingsError, Modal, Dropdown } from "../common";
import { SettingsError, Modal, Dropdown } from "../elements";
import { LineType, RendererPluginType } from "../../../types/types";
import { PluginModel } from "../../../plugins/plugins";
import { commandRtnHandler } from "../../../util/util";

View File

@ -8,7 +8,7 @@ import { boundMethod } from "autobind-decorator";
import { If, For } from "tsx-control-statements/components";
import cn from "classnames";
import { GlobalModel, GlobalCommandRunner, TabColors, TabIcons, Screen } from "../../../model/model";
import { Toggle, InlineSettingsTextEdit, SettingsError, Modal, Dropdown, Tooltip } from "../common";
import { Toggle, InlineSettingsTextEdit, SettingsError, Modal, Dropdown, Tooltip } from "../elements";
import { RemoteType } from "../../../types/types";
import * as util from "../../../util/util";
import { commandRtnHandler } from "../../../util/util";

View File

@ -14,6 +14,15 @@
gap: 4px;
align-self: stretch;
width: 100%;
.settings-label > div:first-child {
margin-right: 5px;
}
}
}
}
.session-settings-tooltip i {
font-size: 12px;
margin-left: 0.5px;
}

View File

@ -6,7 +6,7 @@ import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { GlobalModel, GlobalCommandRunner, Session } from "../../../model/model";
import { Toggle, InlineSettingsTextEdit, SettingsError, InfoMessage, Modal } from "../common";
import { Toggle, InlineSettingsTextEdit, SettingsError, Modal, Tooltip } from "../elements";
import * as util from "../../../util/util";
import { commandRtnHandler } from "../../../util/util";
@ -111,10 +111,14 @@ class SessionSettingsModal extends React.Component<{}, {}> {
<div className="settings-field">
<div className="settings-label">
<div>Archived</div>
<InfoMessage width={400}>
Archive will hide the workspace from the active menu. Commands and output will be
retained in history.
</InfoMessage>
<Tooltip
className="session-settings-tooltip"
message="Archive will hide the workspace from the active menu. Commands and output will be
retained in history."
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
{<i className="fa-sharp fa-regular fa-circle-question" />}
</Tooltip>
</div>
<div className="settings-input">
<Toggle checked={this.session.archived.get()} onChange={this.handleChangeArchived} />
@ -123,9 +127,13 @@ class SessionSettingsModal extends React.Component<{}, {}> {
<div className="settings-field">
<div className="settings-label">
<div>Actions</div>
<InfoMessage width={400}>
Delete will remove the workspace, removing all commands and output from history.
</InfoMessage>
<Tooltip
className="session-settings-tooltip"
message="Delete will remove the workspace, removing all commands and output from history."
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
{<i className="fa-sharp fa-regular fa-circle-question" />}
</Tooltip>
</div>
<div className="settings-input">
<div

View File

@ -8,7 +8,7 @@ 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 { Modal, TextField, InputDecoration, Tooltip } from "../elements";
import * as util from "../../../util/util";
import { Screen } from "../../../model/model";
import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg";

View File

@ -5,7 +5,7 @@ import * as React from "react";
import * as mobxReact from "mobx-react";
import { boundMethod } from "autobind-decorator";
import { GlobalModel, GlobalCommandRunner } from "../../../model/model";
import { Toggle, Modal, Button } from "../common";
import { Toggle, Modal, Button } from "../elements";
import * as util from "../../../util/util";
import { ClientDataType } from "../../../types/types";

View File

@ -9,7 +9,7 @@ import { If, For } from "tsx-control-statements/components";
import cn from "classnames";
import { GlobalModel, GlobalCommandRunner, RemotesModel } from "../../../model/model";
import * as T from "../../../types/types";
import { Modal, Tooltip, Button, Status } from "../common";
import { Modal, Tooltip, Button, Status } from "../elements";
import * as util from "../../../util/util";
import * as textmeasure from "../../../util/textmeasure";

View File

@ -8,7 +8,7 @@ import { boundMethod } from "autobind-decorator";
import { If, For } from "tsx-control-statements/components";
import cn from "classnames";
import { GlobalModel, RemotesModel, GlobalCommandRunner } from "../../model/model";
import { Button, IconButton, Status, ShowWaveShellInstallPrompt } from "../common/common";
import { Button, Status, ShowWaveShellInstallPrompt } from "../common/elements";
import * as T from "../../types/types";
import * as util from "../../util/util";
import * as appconst from "../appconst";

View File

@ -14,7 +14,7 @@ import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import customParseFormat from "dayjs/plugin/customParseFormat";
import { Line } from "../line/linecomps";
import { CmdStrCode } from "../common/common";
import { CmdStrCode } from "../common/elements";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../util/keyutil";
import { ReactComponent as FavoritesIcon } from "../assets/icons/favourites.svg";

View File

@ -23,7 +23,7 @@ import type {
import cn from "classnames";
import type { LineContainerModel } from "../../model/model";
import { renderCmdText } from "../common/common";
import { renderCmdText } from "../common/elements";
import { SimpleBlobRenderer } from "../../plugins/core/basicrenderer";
import { IncrementalRenderer } from "../../plugins/core/incrementalrenderer";
import { TerminalRenderer } from "../../plugins/terminal/terminal";

View File

@ -7,7 +7,7 @@ import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { GlobalModel } from "../../model/model";
import { PluginModel } from "../../plugins/plugins";
import { Markdown } from "../common/common";
import { Markdown } from "../common/elements";
import { ReactComponent as XmarkIcon } from "../assets/icons/line/xmark.svg";
@ -22,7 +22,7 @@ class PluginsView extends React.Component<{}, {}> {
renderPluginIcon(plugin): any {
let Comp = plugin.iconComp;
return <Comp/>;
return <Comp />;
}
render() {

View File

@ -19,7 +19,7 @@ import { ReactComponent as SettingsIcon } from "../assets/icons/settings.svg";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel, GlobalCommandRunner, Session, VERSION } from "../../model/model";
import { isBlank, openLink } from "../../util/util";
import { ResizableSidebar } from "../common/common";
import { ResizableSidebar } from "../common/elements";
import * as constants from "../appconst";
import "./sidebar.less";

View File

@ -12,7 +12,7 @@ import { Prompt } from "../../common/prompt/prompt";
import { TextAreaInput } from "./textareainput";
import { If, For } from "tsx-control-statements/components";
import type { OpenAICmdInfoChatMessageType } from "../../../types/types";
import { Markdown } from "../../common/common";
import { Markdown } from "../../common/elements";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../../util/keyutil";
@mobxReact.observer

View File

@ -11,7 +11,7 @@ import dayjs from "dayjs";
import type { RemoteType, RemoteInstanceType, RemotePtrType } from "../../../types/types";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel, GlobalCommandRunner, Screen } from "../../../model/model";
import { renderCmdText } from "../../common/common";
import { renderCmdText } from "../../common/elements";
import { TextAreaInput } from "./textareainput";
import { InfoMsg } from "./infomsg";
import { HistoryInfo } from "./historyinfo";

View File

@ -10,16 +10,24 @@ import { If, For } from "tsx-control-statements/components";
import cn from "classnames";
import { debounce } from "throttle-debounce";
import dayjs from "dayjs";
import { GlobalCommandRunner, TabColors, TabIcons, ForwardLineContainer, GlobalModel, ScreenLines, Screen, Session } from "../../../model/model";
import {
GlobalCommandRunner,
TabColors,
TabIcons,
ForwardLineContainer,
GlobalModel,
ScreenLines,
Screen,
Session,
} from "../../../model/model";
import type { LineType, RenderModeType, LineFactoryProps } from "../../../types/types";
import * as T from "../../../types/types";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { Button } from "../../common/common";
import { Button, TextField, Dropdown } from "../../common/elements";
import { getRemoteStr } from "../../common/prompt/prompt";
import { Line } from "../../line/linecomps";
import { LinesView } from "../../line/linesview";
import * as util from "../../../util/util";
import { TextField, Dropdown } from "../../common/common";
import { ReactComponent as EllipseIcon } from "../../assets/icons/ellipse.svg";
import { ReactComponent as Check12Icon } from "../../assets/icons/check12.svg";
import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg";

View File

@ -8,7 +8,7 @@ import { boundMethod } from "autobind-decorator";
import cn from "classnames";
import { GlobalModel, GlobalCommandRunner, Screen } from "../../../model/model";
import { ActionsIcon, StatusIndicator, CenteredIcon } from "../../common/icons/icons";
import { renderCmdText } from "../../common/common";
import { renderCmdText } from "../../common/elements";
import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg";
import * as constants from "../../appconst";
import { Reorder } from "framer-motion";

View File

@ -5,7 +5,7 @@ import * as React from "react";
import * as T from "../../types/types";
import Editor, { Monaco } from "@monaco-editor/react";
import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api";
import { Markdown } from "../../app/common/common";
import { Markdown } from "../../app/common/elements";
import { GlobalModel, GlobalCommandRunner } from "../../model/model";
import Split from "react-split-it";
import loader from "@monaco-editor/loader";

View File

@ -6,7 +6,7 @@ import * as mobx from "mobx";
import * as mobxReact from "mobx-react";
import * as T from "../../types/types";
import { sprintf } from "sprintf-js";
import { Markdown } from "../../app/common/common";
import { Markdown } from "../../app/common/elements";
import "./markdown.less";
@ -17,7 +17,13 @@ const DefaultMaxMarkdownWidth = 1000;
@mobxReact.observer
class SimpleMarkdownRenderer extends React.Component<
{ data: T.ExtBlob; context: T.RendererContext; opts: T.RendererOpts; savedHeight: number, lineState: T.LineStateType },
{
data: T.ExtBlob;
context: T.RendererContext;
opts: T.RendererOpts;
savedHeight: number;
lineState: T.LineStateType;
},
{}
> {
markdownText: OV<string> = mobx.observable.box(null, { name: "markdownText" });
@ -74,7 +80,10 @@ class SimpleMarkdownRenderer extends React.Component<
maxHeight: opts.maxSize.height,
}}
>
<Markdown text={this.markdownText.get()} style={{ maxHeight: opts.maxSize.height, maxWidth: DefaultMaxMarkdownWidth }} />
<Markdown
text={this.markdownText.get()}
style={{ maxHeight: opts.maxSize.height, maxWidth: DefaultMaxMarkdownWidth }}
/>
</div>
</div>
);

View File

@ -8,7 +8,7 @@ import * as T from "../../types/types";
import { debounce } from "throttle-debounce";
import { boundMethod } from "autobind-decorator";
import { PacketDataBuffer } from "../core/ptydata";
import { Markdown } from "../../app/common/common";
import { Markdown } from "../../app/common/elements";
import "./openai.less";
@ -207,7 +207,7 @@ class OpenAIRenderer extends React.Component<{ model: OpenAIRendererModel }> {
<div
style={{
maxHeight: opts.maxSize.height,
paddingRight: 5
paddingRight: 5,
}}
>
<Markdown text={message} style={{ maxHeight: opts.maxSize.height }} />
@ -238,16 +238,16 @@ class OpenAIRenderer extends React.Component<{ model: OpenAIRendererModel }> {
if (model.loading.get() && model.savedHeight >= 0 && model.isDone) {
styleVal = {
height: model.savedHeight,
maxHeight: model.opts.maxSize.height
maxHeight: model.opts.maxSize.height,
};
} else {
let maxWidth = model.opts.maxSize.width
if(maxWidth > 1000) {
maxWidth = 1000
let maxWidth = model.opts.maxSize.width;
if (maxWidth > 1000) {
maxWidth = 1000;
}
styleVal = {
maxWidth: maxWidth,
maxHeight: model.opts.maxSize.height
maxHeight: model.opts.maxSize.height,
};
}
let version = model.version.get();

View File

@ -11,6 +11,9 @@ type RemoteStatusTypeStrs = "connected" | "connecting" | "disconnected" | "error
type LineContainerStrs = "main" | "sidebar" | "history";
type OV<V> = mobx.IObservableValue<V>;
type OArr<V> = mobx.IObservableArray<V>;
type OMap<K, V> = mobx.ObservableMap<K, V>;
type CV<V> = mobx.IComputedValue<V>;
type SessionDataType = {
sessionid: string;
@ -832,6 +835,7 @@ export type {
CmdInputTextPacketType,
OpenAICmdInfoChatMessageType,
ScreenStatusIndicatorUpdateType,
OV,
};
export { StatusIndicatorLevel };

View File

@ -156,16 +156,12 @@ func (p *PacketParser) trySendRpcResponse(pk PacketType) bool {
return false
}
p.Lock.Lock()
defer p.Lock.Unlock()
entry := p.RpcMap[respId]
p.Lock.Unlock()
if entry == nil {
return false
}
// nonblocking send
select {
case entry.RespCh <- respPk:
default:
}
entry.RespCh <- respPk
return true
}

View File

@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net/url"
@ -27,6 +28,7 @@ import (
"github.com/kevinburke/ssh_config"
"github.com/wavetermdev/waveterm/waveshell/pkg/base"
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
"github.com/wavetermdev/waveterm/waveshell/pkg/server"
"github.com/wavetermdev/waveterm/waveshell/pkg/shellenv"
"github.com/wavetermdev/waveterm/waveshell/pkg/shellutil"
"github.com/wavetermdev/waveterm/waveshell/pkg/shexec"
@ -199,6 +201,8 @@ func init() {
registerCmdFn("remote:reset", RemoteResetCommand)
registerCmdFn("remote:parse", RemoteConfigParseCommand)
registerCmdFn("copyfile", CopyFileCommand)
registerCmdFn("screen:resize", ScreenResizeCommand)
registerCmdFn("line", LineCommand)
@ -697,6 +701,8 @@ func EvalCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.
newPk, rtnErr := EvalMetaCommand(ctxWithHistory, pk)
if rtnErr == nil {
update, rtnErr = HandleCommand(ctxWithHistory, newPk)
} else {
return nil, fmt.Errorf("error in Eval Meta Command: %v", rtnErr)
}
if !resolveBool(pk.Kwargs["nohist"], false) {
// TODO should this be "pk" or "newPk" (2nd arg)
@ -1102,6 +1108,553 @@ func SidebarRemoveCommand(ctx context.Context, pk *scpacket.FeCommandPacketType)
return &sstore.ModelUpdate{Screens: []*sstore.ScreenType{screen}}, nil
}
func prettyPrintByteSize(size int64) string {
gbSize := float64(size) / float64(1073741824)
if gbSize > 1 {
return fmt.Sprintf("%.2f Gigabytes", gbSize)
}
mbSize := float64(size) / float64(1048576)
if mbSize > 1 {
return fmt.Sprintf("%.2f Megabytes", mbSize)
}
kbSize := float64(size) / float64(1024)
if kbSize > 1 {
return fmt.Sprintf("%.2f Kilobytes", kbSize)
}
return fmt.Sprintf("%v Bytes", size)
}
// this can only be called in a defer func, because recover() only works inside of a defe
func deferWriteCmdStatus(ctx context.Context, cmd *sstore.CmdType, startTime time.Time, exitSuccess bool, outputPos int64) {
r := recover()
if r != nil {
panicMsg := fmt.Sprintf("panic: %v", r)
log.Printf("panic: %v\n", panicMsg)
writeStringToPty(ctx, cmd, panicMsg, &outputPos)
}
duration := time.Since(startTime)
cmdStatus := sstore.CmdStatusDone
var exitCode int
if !exitSuccess {
cmdStatus = sstore.CmdStatusError
exitCode = 1
}
ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId)
donePk := packet.MakeCmdDonePacket(ck)
donePk.Ts = time.Now().UnixMilli()
donePk.ExitCode = exitCode
donePk.DurationMs = duration.Milliseconds()
update, err := sstore.UpdateCmdDoneInfo(context.Background(), ck, donePk, cmdStatus)
if err != nil {
// nothing to do
log.Printf("error updating cmddoneinfo (in openai): %v\n", err)
return
}
sstore.MainBus.SendScreenUpdate(cmd.ScreenId, update)
}
func checkForWriteReady(ctx context.Context, iter *packet.RpcResponseIter) (string, error) {
readyIf, err := iter.Next(ctx)
if err != nil {
return "", fmt.Errorf("error getting write ready response: %v\r\n", err)
}
readyPk, ok := readyIf.(*packet.WriteFileReadyPacketType)
if !ok {
return "", fmt.Errorf("bad write ready packet received %v", readyIf)
}
if readyPk.Error != "" {
return "", fmt.Errorf("ready error: %v", readyPk.Error)
}
return readyPk.RespId, nil
}
func checkForWriteFinished(ctx context.Context, iter *packet.RpcResponseIter) error {
doneIf, err := iter.Next(ctx)
if err != nil {
return fmt.Errorf("error while getting done response: %v", err)
}
writeDonePk, ok := doneIf.(*packet.WriteFileDonePacketType)
if !ok {
return fmt.Errorf("bad done packet received: %T", doneIf)
}
if writeDonePk.Error != "" {
return fmt.Errorf("done error: %v", writeDonePk.Error)
}
return nil
}
func doCopyLocalFileToRemote(ctx context.Context, cmd *sstore.CmdType, remote_msh *remote.MShellProc, localPath string, destPath string, outputPos int64) {
var exitSuccess bool
startTime := time.Now()
defer func() {
deferWriteCmdStatus(ctx, cmd, startTime, exitSuccess, outputPos)
}()
localFile, err := os.Open(localPath)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("Error, unable to open file %v: %v\r\n", localFile, localPath), &outputPos)
return
}
defer localFile.Close()
writePk := packet.MakeWriteFilePacket()
writePk.ReqId = uuid.New().String()
writePk.Path = destPath
iter, err := remote_msh.WriteFile(ctx, writePk)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("Error starting file write: %v\r\n", err), &outputPos)
return
}
defer iter.Close()
_, err = checkForWriteReady(ctx, iter)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("Write ready packet error: %v\r\n", err), &outputPos)
return
}
fileStat, err := localFile.Stat()
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("error: could not get file stat: %v", err), &outputPos)
return
}
fileSizeBytes := fileStat.Size()
bytesWritten := int64(0)
lastFileTransferPercentage := float64(0)
fileTransferPercentage := float64(0)
writeStringToPty(ctx, cmd, fmt.Sprintf("Source File Size: %s\r\n", prettyPrintByteSize(fileSizeBytes)), &outputPos)
writeStringToPty(ctx, cmd, "[", &outputPos)
var buffer [server.MaxFileDataPacketSize]byte
bufSlice := buffer[:]
for {
dataPk := packet.MakeFileDataPacket(writePk.ReqId)
bytesRead, err := io.ReadFull(localFile, bufSlice)
if err == io.ErrUnexpectedEOF || err == io.EOF {
dataPk.Eof = true
} else if err != nil {
dataErr := fmt.Sprintf("error reading file data: %v", err)
dataPk.Error = dataErr
remote_msh.SendFileData(dataPk)
writeStringToPty(ctx, cmd, dataErr, &outputPos)
return
}
if bytesRead > 0 {
dataPk.Data = make([]byte, bytesRead)
copy(dataPk.Data, bufSlice[0:bytesRead])
bytesWritten += int64(len(dataPk.Data))
fileTransferPercentage = float64(bytesWritten) / float64(fileSizeBytes)
if fileTransferPercentage-lastFileTransferPercentage > float64(0.05) {
writeStringToPty(ctx, cmd, "-", &outputPos)
lastFileTransferPercentage = fileTransferPercentage
}
}
remote_msh.SendFileData(dataPk)
if dataPk.Eof {
break
}
}
err = checkForWriteFinished(ctx, iter)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("Write finished packet error %v", err), &outputPos)
return
}
writeStringToPty(ctx, cmd, "] done. \r\n", &outputPos)
writeStringToPty(ctx, cmd, fmt.Sprintf("Finished transferring. Transferred %v bytes\r\n", fileSizeBytes), &outputPos)
exitSuccess = true
}
func getStatusBarString(filePercentageInt int) string {
statusBarString := "\x1b[2k\r["
for count := 0; count < 20; count++ {
if (filePercentageInt - count*5) > 0 {
statusBarString += "-"
} else {
statusBarString += " "
}
}
if filePercentageInt < 100 {
statusBarString += fmt.Sprintf("] %v%%", filePercentageInt)
} else {
statusBarString += "]"
}
return statusBarString
}
func doCopyRemoteFileToRemote(ctx context.Context, cmd *sstore.CmdType, sourceMsh *remote.MShellProc, destMsh *remote.MShellProc, sourcePath string, destPath string, outputPos int64) {
var exitSuccess bool
startTime := time.Now()
defer func() {
deferWriteCmdStatus(ctx, cmd, startTime, exitSuccess, outputPos)
}()
streamPk := packet.MakeStreamFilePacket()
streamPk.ReqId = uuid.New().String()
streamPk.Path = sourcePath
sourceStreamIter, err := sourceMsh.StreamFile(ctx, streamPk)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("Error getting file data packet: %v\r\n", err), &outputPos)
return
}
defer sourceStreamIter.Close()
respIf, err := sourceStreamIter.Next(ctx)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("Error getting next packet: %v\r\n", err), &outputPos)
return
}
resp, ok := respIf.(*packet.StreamFileResponseType)
if !ok {
writeStringToPty(ctx, cmd, fmt.Sprintf("Error in getting packet response: %v\r\n", err), &outputPos)
return
}
if resp == nil || resp.Error != "" {
writeStringToPty(ctx, cmd, fmt.Sprintf("Response packet has error: %v\r\n", err), &outputPos)
return
}
fileSizeBytes := resp.Info.Size
if fileSizeBytes == 0 {
writeStringToPty(ctx, cmd, "Source file does not exist or is empty - exiting\r\n", &outputPos)
return
}
writeStringToPty(ctx, cmd, fmt.Sprintf("Source File Size: %v\r\n", prettyPrintByteSize(fileSizeBytes)), &outputPos)
writePk := packet.MakeWriteFilePacket()
writePk.ReqId = uuid.New().String()
writePk.Path = destPath
destWriteIter, err := destMsh.WriteFile(ctx, writePk)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("Error starting file write: %v\r\n", err), &outputPos)
return
}
defer destWriteIter.Close()
_, err = checkForWriteReady(ctx, destWriteIter)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("Write ready packet error: %v\r\n", err), &outputPos)
return
}
bytesWritten := int64(0)
lastFilePercentageInt := int(0)
fileTransferPercentage := float64(0)
writeStringToPty(ctx, cmd, "[", &outputPos)
for {
dataPkIf, err := sourceStreamIter.Next(ctx)
if err != nil {
log.Printf("error in read-file while getting data: %v\n", err)
return
}
if dataPkIf == nil {
break
}
dataPk, ok := dataPkIf.(*packet.FileDataPacketType)
if !ok {
writeStringToPty(ctx, cmd, fmt.Sprintf("error in read-file, invalid data packet type: %T\r\n", dataPkIf), &outputPos)
return
}
if dataPk.Error != "" {
writeStringToPty(ctx, cmd, fmt.Sprintf("in read-file, data packet error: %s\r\n", dataPk.Error), &outputPos)
return
}
writeDataPk := packet.MakeFileDataPacket(writePk.ReqId)
writeDataPk.Eof = dataPk.Eof
writeDataPk.Error = dataPk.Error
writeDataPk.Type = dataPk.Type
writeDataPk.Data = make([]byte, int64(len(dataPk.Data)))
copy(writeDataPk.Data, dataPk.Data)
err = destMsh.SendFileData(writeDataPk)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("error sending file to dest: %v\r\n", err), &outputPos)
return
}
bytesWritten += int64(len(dataPk.Data))
fileTransferPercentage = float64(bytesWritten) / float64(fileSizeBytes)
filePercentageInt := int(fileTransferPercentage * 100)
if filePercentageInt-lastFilePercentageInt > 5 {
statusBarString := getStatusBarString(filePercentageInt)
writeStringToPty(ctx, cmd, statusBarString, &outputPos)
lastFilePercentageInt = filePercentageInt
}
}
err = checkForWriteFinished(ctx, destWriteIter)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("\r\nWrite finished packet error %v", err), &outputPos)
return
}
writeStringToPty(ctx, cmd, getStatusBarString(100), &outputPos)
writeStringToPty(ctx, cmd, " done. \r\n", &outputPos)
writeStringToPty(ctx, cmd, fmt.Sprintf("Finished transferring. Transferred %v bytes\r\n", bytesWritten), &outputPos)
exitSuccess = true
}
func doCopyLocalFileToLocal(ctx context.Context, cmd *sstore.CmdType, sourcePath string, destPath string, outputPos int64) {
var exitSuccess bool
var bytesWritten int64
startTime := time.Now()
defer func() {
deferWriteCmdStatus(ctx, cmd, startTime, exitSuccess, outputPos)
}()
sourceFile, err := os.Open(sourcePath)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("error opening source file %v", err), &outputPos)
return
}
defer sourceFile.Close()
sourceFileStat, err := sourceFile.Stat()
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("error getting filestat %v", err), &outputPos)
return
}
fileSizeBytes := sourceFileStat.Size()
writeStringToPty(ctx, cmd, fmt.Sprintf("Source File Size: %v\r\n", prettyPrintByteSize(fileSizeBytes)), &outputPos)
destFile, err := os.Create(destPath)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("error creating dest file %v", err), &outputPos)
return
}
defer destFile.Close()
bytesWritten, err = io.Copy(destFile, sourceFile)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("error copying files %v", err), &outputPos)
return
}
writeStringToPty(ctx, cmd, fmt.Sprintf("Finished transferring. Transferred %v bytes\r\n", bytesWritten), &outputPos)
exitSuccess = true
}
func doCopyRemoteFileToLocal(ctx context.Context, cmd *sstore.CmdType, remote_msh *remote.MShellProc, sourcePath string, localPath string, outputPos int64) {
var exitSuccess bool
startTime := time.Now()
defer func() {
deferWriteCmdStatus(ctx, cmd, startTime, exitSuccess, outputPos)
}()
streamPk := packet.MakeStreamFilePacket()
streamPk.ReqId = uuid.New().String()
streamPk.Path = sourcePath
iter, err := remote_msh.StreamFile(ctx, streamPk)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("Error getting file data packet: %v\r\n", err), &outputPos)
return
}
defer iter.Close()
respIf, err := iter.Next(ctx)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("Error getting next packet: %v\r\n", err), &outputPos)
return
}
resp, ok := respIf.(*packet.StreamFileResponseType)
if !ok {
writeStringToPty(ctx, cmd, fmt.Sprintf("Error in getting packet response: %v\r\n", err), &outputPos)
return
}
if resp == nil || resp.Error != "" {
writeStringToPty(ctx, cmd, fmt.Sprintf("Response packet has error: %v\r\n", err), &outputPos)
return
}
fileSizeBytes := resp.Info.Size
if fileSizeBytes == 0 {
writeStringToPty(ctx, cmd, "Source file doesn't exist or file is empty - exiting\r\n", &outputPos)
return
}
writeStringToPty(ctx, cmd, fmt.Sprintf("Source File Size: %s\r\n", prettyPrintByteSize(fileSizeBytes)), &outputPos)
localFile, err := os.Create(localPath)
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("Error creating file on local %v\r\n", err), &outputPos)
return
}
defer localFile.Close()
bytesWritten := int64(0)
lastFileTransferPercentage := float64(0)
fileTransferPercentage := float64(0)
writeStringToPty(ctx, cmd, "[", &outputPos)
for {
dataPkIf, err := iter.Next(ctx)
if err != nil {
log.Printf("error in read-file while getting data: %v\n", err)
return
}
if dataPkIf == nil {
break
}
dataPk, ok := dataPkIf.(*packet.FileDataPacketType)
if !ok {
writeStringToPty(ctx, cmd, fmt.Sprintf("error in read-file, invalid data packet type: %T\r\n", dataPkIf), &outputPos)
return
}
if dataPk.Error != "" {
writeStringToPty(ctx, cmd, fmt.Sprintf("in read-file, data packet error: %s", dataPk.Error), &outputPos)
return
}
localFile.Write(dataPk.Data)
bytesWritten += int64(len(dataPk.Data))
fileTransferPercentage = float64(bytesWritten) / float64(fileSizeBytes)
if fileTransferPercentage-lastFileTransferPercentage > float64(0.05) {
writeStringToPty(ctx, cmd, "-", &outputPos)
lastFileTransferPercentage = fileTransferPercentage
}
}
writeStringToPty(ctx, cmd, "] done. \r\n", &outputPos)
writeStringToPty(ctx, cmd, fmt.Sprintf("Finished transferring. Transferred %v bytes\n", fileSizeBytes), &outputPos)
exitSuccess = true
}
func writeStringToPty(ctx context.Context, cmd *sstore.CmdType, outputString string, outputPos *int64) {
outBytes := []byte(outputString)
update, err := sstore.AppendToCmdPtyBlob(ctx, cmd.ScreenId, cmd.LineId, outBytes, *outputPos)
*outputPos += int64(len(outBytes))
if err != nil {
log.Printf("error writing to pty: %v", err)
}
sstore.MainBus.SendScreenUpdate(cmd.ScreenId, update)
err = sstore.SetStatusIndicatorLevel(ctx, cmd.ScreenId, sstore.StatusIndicatorLevel_Output, false)
if err != nil {
// This is not a fatal error, so just log it
log.Printf("error setting status indicator level to output in writeStringToPty: %v\n", err)
}
}
func parseCopyFileParam(info string) (remote string, path string, err error) {
stringsList := strings.Split(info, ":")
if len(stringsList) == 1 {
// use cur remote
return "", stringsList[0], nil
} else if len(stringsList) == 2 {
remote := strings.Trim(stringsList[0], "[] ")
return remote, stringsList[1], nil
} else {
return "error", "error", fmt.Errorf("malformed arguments")
}
}
func CopyFileCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
if len(pk.Args) == 0 {
return nil, fmt.Errorf("usage: /copyfile [file to copy] local=[path to copy to on local]")
}
ids, err := resolveUiIds(ctx, pk, R_Screen|R_Session|R_RemoteConnected)
if err != nil {
return nil, fmt.Errorf("failed to resolve connected remote id: %v", err)
}
sourceInfo := pk.Args[0]
sourceRemote, sourcePath, err := parseCopyFileParam(sourceInfo)
var sourceRemoteId *ResolvedRemote
var destRemoteId *ResolvedRemote
if err != nil {
return nil, fmt.Errorf("error: malformed arguments - usage: [remote]:path ")
} else if sourceRemote == "" {
// use cur remote
sourceRemote = ConnectedRemote
sourceRemoteId = ids.Remote
if ids.Remote.RemoteCopy.IsLocal() {
sourceRemote = LocalRemote
}
} else {
pk.Kwargs["remote"] = sourceRemote
sourceIds, err := resolveUiIds(ctx, pk, R_Remote)
if err != nil {
return nil, fmt.Errorf("error resolving remote id %v", err)
}
sourceRemoteId = sourceIds.Remote
}
destInfo := pk.Args[1]
destRemote, destPath, err := parseCopyFileParam(destInfo)
if err != nil {
return nil, fmt.Errorf("error: malformed arguments - usage: [remote]:path ")
} else if destRemote == "" {
destRemote = ConnectedRemote
destRemoteId = ids.Remote
if ids.Remote.RemoteCopy.IsLocal() {
destRemote = LocalRemote
}
} else {
pk.Kwargs["remote"] = destRemote
destIds, err := resolveUiIds(ctx, pk, R_Remote)
if err != nil {
return nil, fmt.Errorf("error resolving remote id %v", err)
}
destRemoteId = destIds.Remote
}
if destPath == "" {
return nil, fmt.Errorf("error: malformed arguments - usage: [remote]:path ")
}
var sourceFullPath string
var destFullPath string
sourceMsh := sourceRemoteId.MShell
if sourceMsh == nil {
return nil, fmt.Errorf("failure getting source remote mshell")
}
sourceRRState := sourceMsh.GetRemoteRuntimeState()
sourcePathWithHome, err := sourceRRState.ExpandHomeDir(sourcePath)
if err != nil {
return nil, fmt.Errorf("expand home dir err: %v", err)
}
sourceFullPath = sourcePathWithHome
if (sourceRemote == ConnectedRemote || sourceRemote == LocalRemote) && !filepath.IsAbs(sourcePathWithHome) && sourceRemoteId.FeState != nil {
sourceCwd := sourceRemoteId.FeState["cwd"]
if sourceCwd != "" {
sourceFullPath = filepath.Join(sourceCwd, sourcePathWithHome)
}
}
if destPath[len(destPath)-1:] == "/" {
sourceFileName := filepath.Base(sourceFullPath)
destPath = filepath.Join(destPath, sourceFileName)
}
destMsh := destRemoteId.MShell
if destMsh == nil {
return nil, fmt.Errorf("failure getting dest remote mshell")
}
destRRState := destMsh.GetRemoteRuntimeState()
destPathWithHome, err := destRRState.ExpandHomeDir(destPath)
if err != nil {
return nil, fmt.Errorf("expand home dir err: %v", err)
}
destFullPath = destPathWithHome
if (destRemote == ConnectedRemote || destRemote == LocalRemote) && !filepath.IsAbs(destPathWithHome) && destRemoteId.FeState != nil {
destCwd := destRemoteId.FeState["cwd"]
if destCwd != "" {
destFullPath = filepath.Join(destCwd, destPathWithHome)
}
}
var outputPos int64
outputStr := fmt.Sprintf("Copying [%v]:%v to [%v]:%v\r\n", sourceRemoteId.DisplayName, sourceFullPath, destRemoteId.DisplayName, destFullPath)
termopts := sstore.TermOpts{Rows: shellutil.DefaultTermRows, Cols: shellutil.DefaultTermCols, FlexRows: true, MaxPtySize: remote.DefaultMaxPtySize}
cmd, err := makeDynCmd(ctx, "copy file", ids, pk.GetRawStr(), termopts)
writeStringToPty(ctx, cmd, outputStr, &outputPos)
if err != nil {
// TODO tricky error since the command was a success, but we can't show the output
return nil, err
}
update, err := addLineForCmd(ctx, "/copy file", false, ids, cmd, "", nil)
if err != nil {
// TODO tricky error since the command was a success, but we can't show the output
return nil, err
}
update.Interactive = pk.Interactive
if destRemote != ConnectedRemote && destRemoteId != nil && !destRemoteId.RState.IsConnected() {
writeStringToPty(ctx, cmd, fmt.Sprintf("Attempting to autoconnect to remote %v\r\n", destRemote), &outputPos)
err = destRemoteId.MShell.TryAutoConnect()
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("Couldn't connect to remote %v\r\n", sourceRemote), &outputPos)
} else {
writeStringToPty(ctx, cmd, "Auto connect successful\r\n", &outputPos)
}
}
if sourceRemote != LocalRemote && sourceRemoteId != nil && !sourceRemoteId.RState.IsConnected() {
writeStringToPty(ctx, cmd, fmt.Sprintf("Attempting to autoconnect to remote %v\r\n", sourceRemote), &outputPos)
err = sourceRemoteId.MShell.TryAutoConnect()
if err != nil {
writeStringToPty(ctx, cmd, fmt.Sprintf("Couldn't connect to remote %v\r\n", sourceRemote), &outputPos)
} else {
writeStringToPty(ctx, cmd, "Auto connect successful\r\n", &outputPos)
}
}
sstore.MainBus.SendScreenUpdate(cmd.ScreenId, update)
update = &sstore.ModelUpdate{}
if destRemote == LocalRemote && sourceRemote == LocalRemote {
go doCopyLocalFileToLocal(context.Background(), cmd, sourceFullPath, destFullPath, outputPos)
} else if destRemote == LocalRemote && sourceRemote != LocalRemote {
go doCopyRemoteFileToLocal(context.Background(), cmd, sourceMsh, sourceFullPath, destFullPath, outputPos)
} else if destRemote != LocalRemote && sourceRemote == LocalRemote {
go doCopyLocalFileToRemote(context.Background(), cmd, destMsh, sourceFullPath, destFullPath, outputPos)
} else if destRemote != LocalRemote && sourceRemote != LocalRemote {
go doCopyRemoteFileToRemote(context.Background(), cmd, sourceMsh, destMsh, sourceFullPath, destFullPath, outputPos)
}
return update, nil
}
func RemoteInstallCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen|R_Remote)
if err != nil {
@ -2463,6 +3016,7 @@ func addLineForCmd(ctx context.Context, metaCmd string, shouldFocus bool, ids re
Cmd: cmd,
Screens: []*sstore.ScreenType{screen},
}
sstore.IncrementNumRunningCmds_Update(update, cmd.ScreenId, 1)
updateHistoryContext(ctx, rtnLine, cmd, cmd.FeState)
return update, nil
}
@ -3422,6 +3976,7 @@ func LineRestartCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (
NoCreateCmdPtyFile: true,
}
cmd, callback, err := remote.RunCommand(ctx, rcOpts, runPacket)
sstore.IncrementNumRunningCmds(cmd.ScreenId, 1)
if callback != nil {
defer callback()
}

View File

@ -24,6 +24,11 @@ const (
R_RemoteConnected = 16
)
const (
ConnectedRemote = "connected"
LocalRemote = "local"
)
type resolvedIds struct {
SessionId string
ScreenId string

View File

@ -50,6 +50,7 @@ const RemoteTermRows = 8
const RemoteTermCols = 80
const PtyReadBufSize = 100
const RemoteConnectTimeout = 15 * time.Second
const RpcIterChannelSize = 100
var envVarsToStrip map[string]bool = map[string]bool{
"PROMPT": true,
@ -665,7 +666,12 @@ func (msh *MShellProc) GetRemoteRuntimeState() RemoteRuntimeState {
if vars["remoteuser"] == "root" || vars["sudo"] == "1" {
vars["isroot"] = "1"
}
state.RemoteVars = vars
varsCopy := make(map[string]string)
// deep copy so that concurrent calls don't collide on this data
for key, value := range vars {
varsCopy[key] = value
}
state.RemoteVars = varsCopy
state.ActiveShells = msh.StateMap.GetShells()
return state
}
@ -1203,6 +1209,10 @@ func (msh *MShellProc) ReInit(ctx context.Context, shellType string) (*packet.Sh
return ssPk, nil
}
func (msh *MShellProc) WriteFile(ctx context.Context, writePk *packet.WriteFilePacketType) (*packet.RpcResponseIter, error) {
return msh.PacketRpcIter(ctx, writePk)
}
func (msh *MShellProc) StreamFile(ctx context.Context, streamPk *packet.StreamFilePacketType) (*packet.RpcResponseIter, error) {
return msh.PacketRpcIter(ctx, streamPk)
}
@ -1886,7 +1896,6 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru
RunPacket: runPacket,
})
go pushNumRunningCmdsUpdate(&runPacket.CK, 1)
return cmd, func() { removeCmdWait(runPacket.CK) }, nil
}
@ -1925,7 +1934,7 @@ func (msh *MShellProc) PacketRpcIter(ctx context.Context, pk packet.RpcPacketTyp
return nil, fmt.Errorf("PacketRpc passed nil packet")
}
reqId := pk.GetReqId()
msh.ServerProc.Output.RegisterRpc(reqId)
msh.ServerProc.Output.RegisterRpcSz(reqId, RpcIterChannelSize)
err := msh.ServerProc.Input.SendPacketCtx(ctx, pk)
if err != nil {
return nil, err
@ -2064,8 +2073,6 @@ func (msh *MShellProc) handleCmdDonePacket(donePk *packet.CmdDonePacketType) {
// fall-through (nothing to do)
}
}
go pushNumRunningCmdsUpdate(&donePk.CK, -1)
sstore.MainBus.SendUpdate(update)
return
}

View File

@ -950,6 +950,7 @@ func UpdateCmdDoneInfo(ctx context.Context, ck base.CommandKey, donePk *packet.C
// This is not a fatal error, so just log it
log.Printf("error setting status indicator level after done packet: %v\n", err)
}
IncrementNumRunningCmds_Update(update, screenId, -1)
return update, nil
}

View File

@ -1040,6 +1040,10 @@ type RemoteType struct {
OpenAIOpts *OpenAIOptsType `json:"openaiopts,omitempty"`
}
func (r *RemoteType) IsLocal() bool {
return r.Local && !r.IsSudo()
}
func (r *RemoteType) IsSudo() bool {
return r.SSHOpts != nil && r.SSHOpts.IsSudo
}