mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-21 16:38:23 +01:00
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:
commit
75778963c5
@ -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
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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
123
src/app/common/elements/button.less
Normal file
123
src/app/common/elements/button.less
Normal 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;
|
||||
}
|
||||
}
|
63
src/app/common/elements/button.tsx
Normal file
63
src/app/common/elements/button.tsx
Normal 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 };
|
68
src/app/common/elements/checkbox.less
Normal file
68
src/app/common/elements/checkbox.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
70
src/app/common/elements/checkbox.tsx
Normal file
70
src/app/common/elements/checkbox.tsx
Normal 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 };
|
102
src/app/common/elements/cmdstrcode.less
Normal file
102
src/app/common/elements/cmdstrcode.less
Normal 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;
|
||||
}
|
66
src/app/common/elements/cmdstrcode.tsx
Normal file
66
src/app/common/elements/cmdstrcode.tsx
Normal 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 };
|
10
src/app/common/elements/cmdtext.tsx
Normal file
10
src/app/common/elements/cmdtext.tsx
Normal 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>⌘{text}</span>;
|
||||
}
|
||||
|
||||
export { renderCmdText };
|
127
src/app/common/elements/dropdown.less
Normal file
127
src/app/common/elements/dropdown.less
Normal 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;
|
||||
}
|
259
src/app/common/elements/dropdown.tsx
Normal file
259
src/app/common/elements/dropdown.tsx
Normal 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 };
|
21
src/app/common/elements/iconbutton.tsx
Normal file
21
src/app/common/elements/iconbutton.tsx
Normal 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 };
|
20
src/app/common/elements/index.tsx
Normal file
20
src/app/common/elements/index.tsx
Normal 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";
|
40
src/app/common/elements/inlinesettingstextedit.less
Normal file
40
src/app/common/elements/inlinesettingstextedit.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
149
src/app/common/elements/inlinesettingstextedit.tsx
Normal file
149
src/app/common/elements/inlinesettingstextedit.tsx
Normal 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 };
|
19
src/app/common/elements/inputdecoration.less
Normal file
19
src/app/common/elements/inputdecoration.less
Normal 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;
|
||||
}
|
32
src/app/common/elements/inputdecoration.tsx
Normal file
32
src/app/common/elements/inputdecoration.tsx
Normal 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 };
|
28
src/app/common/elements/linkbutton.tsx
Normal file
28
src/app/common/elements/linkbutton.tsx
Normal 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 };
|
92
src/app/common/elements/markdown.less
Normal file
92
src/app/common/elements/markdown.less
Normal 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;
|
||||
}
|
105
src/app/common/elements/markdown.tsx
Normal file
105
src/app/common/elements/markdown.tsx
Normal 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 };
|
79
src/app/common/elements/modal.less
Normal file
79
src/app/common/elements/modal.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
81
src/app/common/elements/modal.tsx
Normal file
81
src/app/common/elements/modal.tsx
Normal 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 };
|
39
src/app/common/elements/numberfield.tsx
Normal file
39
src/app/common/elements/numberfield.tsx
Normal 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 };
|
30
src/app/common/elements/passwordfield.less
Normal file
30
src/app/common/elements/passwordfield.less
Normal 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;
|
||||
}
|
||||
}
|
106
src/app/common/elements/passwordfield.tsx
Normal file
106
src/app/common/elements/passwordfield.tsx
Normal 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 };
|
9
src/app/common/elements/resizablesidebar.less
Normal file
9
src/app/common/elements/resizablesidebar.less
Normal file
@ -0,0 +1,9 @@
|
||||
@import "../../../app/common/themes/themes.less";
|
||||
|
||||
.sidebar-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 5px;
|
||||
cursor: col-resize;
|
||||
}
|
171
src/app/common/elements/resizablesidebar.tsx
Normal file
171
src/app/common/elements/resizablesidebar.tsx
Normal 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 };
|
36
src/app/common/elements/settingserror.tsx
Normal file
36
src/app/common/elements/settingserror.tsx
Normal 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 };
|
28
src/app/common/elements/showwaveshellinstallprompt.tsx
Normal file
28
src/app/common/elements/showwaveshellinstallprompt.tsx
Normal 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 };
|
30
src/app/common/elements/status.less
Normal file
30
src/app/common/elements/status.less
Normal 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;
|
||||
}
|
||||
}
|
34
src/app/common/elements/status.tsx
Normal file
34
src/app/common/elements/status.tsx
Normal 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 };
|
82
src/app/common/elements/textfield.less
Normal file
82
src/app/common/elements/textfield.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
173
src/app/common/elements/textfield.tsx
Normal file
173
src/app/common/elements/textfield.tsx
Normal 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 };
|
47
src/app/common/elements/toggle.less
Normal file
47
src/app/common/elements/toggle.less
Normal 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);
|
||||
}
|
||||
}
|
28
src/app/common/elements/toggle.tsx
Normal file
28
src/app/common/elements/toggle.tsx
Normal 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 };
|
23
src/app/common/elements/tooltip.less
Normal file
23
src/app/common/elements/tooltip.less
Normal 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;
|
||||
}
|
||||
}
|
84
src/app/common/elements/tooltip.tsx
Normal file
84
src/app/common/elements/tooltip.tsx
Normal 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 };
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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() {
|
||||
|
@ -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";
|
||||
|
@ -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
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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();
|
||||
|
@ -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 };
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -24,6 +24,11 @@ const (
|
||||
R_RemoteConnected = 16
|
||||
)
|
||||
|
||||
const (
|
||||
ConnectedRemote = "connected"
|
||||
LocalRemote = "local"
|
||||
)
|
||||
|
||||
type resolvedIds struct {
|
||||
SessionId string
|
||||
ScreenId string
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user