mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
Merge branch 'main' into evan/newton
This commit is contained in:
commit
05707de824
57
.github/workflows/build-macos-x64.yml
vendored
Normal file
57
.github/workflows/build-macos-x64.yml
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
name: "Build MacOS x64"
|
||||
on: workflow_dispatch
|
||||
env:
|
||||
WAVETERM_VERSION: 0.5.3
|
||||
GO_VERSION: '1.21.5'
|
||||
NODE_VERSION: '21.5.0'
|
||||
jobs:
|
||||
runbuild-x64:
|
||||
name: "Build MacOS x64"
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{env.GO_VERSION}}
|
||||
cache-dependency-path: |
|
||||
wavesrv/go.sum
|
||||
waveshell/go.sum
|
||||
- run: brew tap scripthaus-dev/scripthaus
|
||||
- run: brew install scripthaus
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{env.NODE_VERSION}}
|
||||
cache: 'yarn'
|
||||
- run: yarn --frozen-lockfile
|
||||
- run: scripthaus run build-package
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: waveterm-build-darwin-x64
|
||||
path: out/make/zip/darwin/x64/*.zip
|
||||
retention-days: 2
|
||||
runbuild-arm64:
|
||||
name: "Build MacOS arm64"
|
||||
runs-on: macos-latest-xlarge
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{env.GO_VERSION}}
|
||||
cache-dependency-path: |
|
||||
wavesrv/go.sum
|
||||
waveshell/go.sum
|
||||
- run: brew tap scripthaus-dev/scripthaus
|
||||
- run: brew install scripthaus
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{env.NODE_VERSION}}
|
||||
cache: 'yarn'
|
||||
- run: yarn --frozen-lockfile
|
||||
- run: scripthaus run build-package
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: waveterm-build-darwin-arm64
|
||||
path: out/make/zip/darwin/arm64/*.zip
|
||||
retention-days: 2
|
||||
|
||||
|
@ -7,6 +7,8 @@ github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/wavetermdev/ssh_config v0.0.0-20240109090616-36c8da3d7376 h1:tFhJgTu7lgd+hldLfPSzDCoWUpXI8wHKR3rxq5jTLkQ=
|
||||
github.com/wavetermdev/ssh_config v0.0.0-20240109090616-36c8da3d7376/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
23
package.json
23
package.json
@ -13,6 +13,7 @@
|
||||
"@tanstack/react-table": "^8.10.3",
|
||||
"@types/semver": "^7.5.6",
|
||||
"autobind-decorator": "^2.4.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"classnames": "^2.3.1",
|
||||
"dayjs": "^1.11.3",
|
||||
"dompurify": "^3.0.2",
|
||||
@ -26,7 +27,6 @@
|
||||
"papaparse": "^5.4.1",
|
||||
"react": "^18.1.0",
|
||||
"react-dom": "^18.1.0",
|
||||
"react-json-view": "^1.21.3",
|
||||
"react-markdown": "^9.0.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
@ -35,7 +35,8 @@
|
||||
"tsx-control-statements": "^4.1.1",
|
||||
"uuid": "^9.0.0",
|
||||
"winston": "^3.8.2",
|
||||
"xterm": "^5.0.0"
|
||||
"xterm": "^5.0.0",
|
||||
"xterm-addon-web-links": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.17.10",
|
||||
@ -49,17 +50,17 @@
|
||||
"@babel/preset-env": "^7.18.2",
|
||||
"@babel/preset-react": "^7.23.3",
|
||||
"@babel/preset-typescript": "^7.17.12",
|
||||
"@electron-forge/cli": "^6.0.0-beta.70",
|
||||
"@electron-forge/maker-deb": "^6.0.0-beta.70",
|
||||
"@electron-forge/maker-rpm": "^6.0.0-beta.70",
|
||||
"@electron-forge/maker-snap": "^6.4.2",
|
||||
"@electron-forge/maker-squirrel": "^6.0.0-beta.70",
|
||||
"@electron-forge/maker-zip": "^6.0.0-beta.70",
|
||||
"@electron-forge/cli": "^7.2.0",
|
||||
"@electron-forge/maker-deb": "^7.2.0",
|
||||
"@electron-forge/maker-rpm": "^7.2.0",
|
||||
"@electron-forge/maker-snap": "^7.2.0",
|
||||
"@electron-forge/maker-squirrel": "^7.2.0",
|
||||
"@electron-forge/maker-zip": "^7.2.0",
|
||||
"@electron/rebuild": "^3.4.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@types/classnames": "^2.3.1",
|
||||
"@types/electron": "^1.6.10",
|
||||
"@types/node": "20.10.3",
|
||||
"@types/node": "20.11.0",
|
||||
"@types/papaparse": "^5.3.10",
|
||||
"@types/react": "^18.0.12",
|
||||
"@types/sprintf-js": "^1.1.3",
|
||||
@ -70,7 +71,7 @@
|
||||
"babel-plugin-jsx-control-statements": "^4.1.2",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"css-loader": "^6.7.1",
|
||||
"electron": "27.1.3",
|
||||
"electron": "28.1.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"http-server": "^14.1.1",
|
||||
"less": "^4.1.2",
|
||||
@ -81,7 +82,7 @@
|
||||
"raw-loader": "^4.0.2",
|
||||
"react-split-it": "^2.0.0",
|
||||
"style-loader": "^3.3.1",
|
||||
"typescript": "^4.7.3",
|
||||
"typescript": "^5.0.0",
|
||||
"webpack": "^5.73.0",
|
||||
"webpack-bundle-analyzer": "^4.10.1",
|
||||
"webpack-cli": "^5.1.4",
|
||||
|
@ -476,17 +476,26 @@ a.a-block {
|
||||
}
|
||||
}
|
||||
|
||||
.icon.color-default,
|
||||
.icon.color-green {
|
||||
path,
|
||||
circle {
|
||||
fill: @tab-green;
|
||||
}
|
||||
|
||||
i {
|
||||
color: @tab-green;
|
||||
}
|
||||
}
|
||||
|
||||
.icon.color-red {
|
||||
path,
|
||||
circle {
|
||||
fill: @tab-red;
|
||||
}
|
||||
}
|
||||
|
||||
.icon.color-green {
|
||||
path,
|
||||
circle {
|
||||
fill: @tab-green;
|
||||
i {
|
||||
color: @tab-red;
|
||||
}
|
||||
}
|
||||
|
||||
@ -495,6 +504,10 @@ a.a-block {
|
||||
circle {
|
||||
fill: @tab-orange;
|
||||
}
|
||||
|
||||
i {
|
||||
color: @tab-orange;
|
||||
}
|
||||
}
|
||||
|
||||
.icon.color-blue {
|
||||
@ -502,6 +515,10 @@ a.a-block {
|
||||
circle {
|
||||
fill: @tab-blue;
|
||||
}
|
||||
|
||||
i {
|
||||
color: @tab-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.icon.color-yellow {
|
||||
@ -509,6 +526,10 @@ a.a-block {
|
||||
circle {
|
||||
fill: @tab-yellow;
|
||||
}
|
||||
|
||||
i {
|
||||
color: @tab-yellow;
|
||||
}
|
||||
}
|
||||
|
||||
.icon.color-pink {
|
||||
@ -516,6 +537,10 @@ a.a-block {
|
||||
circle {
|
||||
fill: @tab-pink;
|
||||
}
|
||||
|
||||
i {
|
||||
color: @tab-pink;
|
||||
}
|
||||
}
|
||||
|
||||
.icon.color-mint {
|
||||
@ -523,6 +548,10 @@ a.a-block {
|
||||
circle {
|
||||
fill: @tab-mint;
|
||||
}
|
||||
|
||||
i {
|
||||
color: @tab-mint;
|
||||
}
|
||||
}
|
||||
|
||||
.icon.color-cyan {
|
||||
@ -530,6 +559,10 @@ a.a-block {
|
||||
circle {
|
||||
fill: @tab-cyan;
|
||||
}
|
||||
|
||||
i {
|
||||
color: @tab-cyan;
|
||||
}
|
||||
}
|
||||
|
||||
.icon.color-violet {
|
||||
@ -537,6 +570,10 @@ a.a-block {
|
||||
circle {
|
||||
fill: @tab-violet;
|
||||
}
|
||||
|
||||
i {
|
||||
color: @tab-violet;
|
||||
}
|
||||
}
|
||||
|
||||
.icon.color-white {
|
||||
@ -544,6 +581,10 @@ a.a-block {
|
||||
circle {
|
||||
fill: @tab-white;
|
||||
}
|
||||
|
||||
i {
|
||||
color: @tab-white;
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon.status-connected {
|
||||
|
@ -16,14 +16,9 @@ import { PluginsView } from "./pluginsview/pluginsview";
|
||||
import { BookmarksView } from "./bookmarks/bookmarks";
|
||||
import { HistoryView } from "./history/history";
|
||||
import { ConnectionsView } from "./connections/connections";
|
||||
import {
|
||||
ScreenSettingsModal,
|
||||
SessionSettingsModal,
|
||||
LineSettingsModal,
|
||||
ClientSettingsModal,
|
||||
} from "./common/modals/settings";
|
||||
import { MainSideBar } from "./sidebar/sidebar";
|
||||
import { DisconnectedModal, ClientStopModal, ModalsProvider } from "./common/modals/modals";
|
||||
import { DisconnectedModal, ClientStopModal } from "./common/modals";
|
||||
import { ModalsProvider } from "./common/modals/provider";
|
||||
import { ErrorBoundary } from "./common/error/errorboundary";
|
||||
import "./app.less";
|
||||
|
||||
@ -74,7 +69,6 @@ class App extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
render() {
|
||||
let clientSettingsModal = GlobalModel.clientSettingsModal.get();
|
||||
let remotesModel = GlobalModel.remotesModel;
|
||||
let disconnected = !GlobalModel.ws.open.get() || !GlobalModel.waveSrvRunning.get();
|
||||
let hasClientStop = GlobalModel.getHasClientStop();
|
||||
|
@ -7,9 +7,12 @@ export const SCREEN_SETTINGS = "screenSettings";
|
||||
export const SESSION_SETTINGS = "sessionSettings";
|
||||
export const LINE_SETTINGS = "lineSettings";
|
||||
export const CLIENT_SETTINGS = "clientSettings";
|
||||
export const TAB_SWITCHER = "tabSwitcher";
|
||||
|
||||
export const LineContainer_Main = "main";
|
||||
export const LineContainer_History = "history";
|
||||
export const LineContainer_Sidebar = "sidebar";
|
||||
|
||||
export const ConfirmKey_HideShellPrompt = "hideshellprompt";
|
||||
|
||||
export const NoStrPos = -1;
|
||||
|
@ -188,14 +188,14 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #9e9e9e;
|
||||
color: @term-bright-white;
|
||||
transition: color 250ms cubic-bezier(0.4, 0, 0.23, 1);
|
||||
}
|
||||
input[type="checkbox"] + label > span {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 16px;
|
||||
margin-right: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
@ -205,10 +205,6 @@
|
||||
transition: all 250ms cubic-bezier(0.4, 0, 0.23, 1);
|
||||
}
|
||||
|
||||
input[type="checkbox"] + label:hover,
|
||||
input[type="checkbox"]:focus + label {
|
||||
color: #fff;
|
||||
}
|
||||
input[type="checkbox"] + label:hover > span,
|
||||
input[type="checkbox"]:focus + label > span {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
@ -356,7 +352,7 @@
|
||||
background-color: @markdown-highlight;
|
||||
color: @term-white;
|
||||
font-family: @terminal-font;
|
||||
display: inline-block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
code.inline {
|
||||
@ -407,6 +403,13 @@
|
||||
background-color: @markdown-highlight;
|
||||
margin: 4px 10px 4px 10px;
|
||||
padding: 6px 6px 6px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
pre.selected {
|
||||
border-style: solid;
|
||||
outline-width: 2px;
|
||||
border-color: @term-green;
|
||||
}
|
||||
|
||||
.title.is-1 {
|
||||
@ -837,6 +840,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.no-label {
|
||||
height: 34px;
|
||||
|
||||
input {
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wave-input-decoration {
|
||||
|
@ -11,6 +11,7 @@ import cn from "classnames";
|
||||
import { If } from "tsx-control-statements/components";
|
||||
import type { RemoteType } from "../../types/types";
|
||||
import ReactDOM from "react-dom";
|
||||
import { GlobalModel } from "../../model/model";
|
||||
|
||||
import { ReactComponent as CheckIcon } from "../assets/icons/line/check.svg";
|
||||
import { ReactComponent as CopyIcon } from "../assets/icons/history/copy.svg";
|
||||
@ -99,23 +100,57 @@ class Toggle extends React.Component<{ checked: boolean; onChange: (value: boole
|
||||
}
|
||||
|
||||
class Checkbox extends React.Component<
|
||||
{ checked: boolean; onChange: (value: boolean) => void; label: React.ReactNode; id: string },
|
||||
{}
|
||||
{
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label: React.ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
},
|
||||
{ checkedInternal: boolean }
|
||||
> {
|
||||
generatedId;
|
||||
static idCounter = 0;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
checkedInternal: this.props.checked !== undefined ? this.props.checked : Boolean(this.props.defaultChecked),
|
||||
};
|
||||
this.generatedId = `checkbox-${Checkbox.idCounter++}`;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.checked !== undefined && this.props.checked !== prevProps.checked) {
|
||||
this.setState({ checkedInternal: this.props.checked });
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = (e) => {
|
||||
const newChecked = e.target.checked;
|
||||
if (this.props.checked === undefined) {
|
||||
this.setState({ checkedInternal: newChecked });
|
||||
}
|
||||
this.props.onChange(newChecked);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { checked, onChange, label, id } = this.props;
|
||||
const { label, className, id } = this.props;
|
||||
const { checkedInternal } = this.state;
|
||||
const checkboxId = id || this.generatedId;
|
||||
|
||||
return (
|
||||
<div className="checkbox">
|
||||
<div className={cn("checkbox", className)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
aria-checked={checked}
|
||||
id={checkboxId}
|
||||
checked={checkedInternal}
|
||||
onChange={this.handleChange}
|
||||
aria-checked={checkedInternal}
|
||||
role="checkbox"
|
||||
/>
|
||||
<label htmlFor={id}>
|
||||
<label htmlFor={checkboxId}>
|
||||
<span></span>
|
||||
{label}
|
||||
</label>
|
||||
@ -231,6 +266,8 @@ interface ButtonProps {
|
||||
rightIcon?: React.ReactNode;
|
||||
color?: string;
|
||||
style?: React.CSSProperties;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
class Button extends React.Component<ButtonProps> {
|
||||
@ -257,6 +294,7 @@ class Button extends React.Component<ButtonProps> {
|
||||
onClick={this.handleClick}
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
autoFocus={this.props.autoFocus}
|
||||
>
|
||||
{leftIcon && <span className="icon-left">{leftIcon}</span>}
|
||||
{children}
|
||||
@ -283,25 +321,24 @@ export default IconButton;
|
||||
|
||||
interface LinkButtonProps extends ButtonProps {
|
||||
href: string;
|
||||
rel?: string;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
class LinkButton extends IconButton {
|
||||
class LinkButton extends React.Component<LinkButtonProps> {
|
||||
render() {
|
||||
// @ts-ignore
|
||||
const { href, target, leftIcon, rightIcon, children, theme, variant }: LinkButtonProps = this.props;
|
||||
const { leftIcon, rightIcon, children, className, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<a href={href} target={target} className={`wave-button link-button`}>
|
||||
<button {...this.props} className={`icon-button ${theme} ${variant}`}>
|
||||
<a {...rest} className={cn(`wave-button link-button`, className)}>
|
||||
{leftIcon && <span className="icon-left">{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className="icon-right">{rightIcon}</span>}
|
||||
</button>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface StatusProps {
|
||||
status: "green" | "red" | "gray" | "yellow";
|
||||
text: string;
|
||||
@ -332,7 +369,7 @@ interface TextFieldDecorationProps {
|
||||
endDecoration?: React.ReactNode;
|
||||
}
|
||||
interface TextFieldProps {
|
||||
label: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
@ -445,10 +482,11 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(`wave-textfield ${className || ""}`, {
|
||||
className={cn("wave-textfield", className, {
|
||||
focused: focused,
|
||||
error: error,
|
||||
disabled: disabled,
|
||||
"no-label": !label,
|
||||
})}
|
||||
onFocus={this.handleComponentFocus}
|
||||
onBlur={this.handleComponentBlur}
|
||||
@ -456,6 +494,7 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
|
||||
>
|
||||
{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,
|
||||
@ -465,6 +504,7 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</If>
|
||||
<input
|
||||
className={cn("wave-textfield-inner-input", { "offset-left": decoration?.startDecoration })}
|
||||
ref={this.inputRef}
|
||||
@ -789,9 +829,67 @@ function CodeRenderer(props: any): any {
|
||||
}
|
||||
|
||||
@mobxReact.observer
|
||||
class Markdown extends React.Component<{ text: string; style?: any; extraClassName?: string }, {}> {
|
||||
class CodeBlockMarkdown extends React.Component<
|
||||
{ children: React.ReactNode; blockText: string; 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 codeText = this.props.blockText;
|
||||
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 {
|
||||
let codeText = codeSelect ? props.node.children[0].children[0].value : props.children;
|
||||
if (codeText) {
|
||||
codeText = codeText.replace(/\n$/, ""); // remove trailing newline
|
||||
}
|
||||
if (codeSelect) {
|
||||
return (
|
||||
<CodeBlockMarkdown blockText={codeText} codeSelectSelectedIndex={codeSelectIndex}>
|
||||
{props.children}
|
||||
</CodeBlockMarkdown>
|
||||
);
|
||||
} else {
|
||||
let clickHandler = (e: React.MouseEvent<HTMLElement>) => {
|
||||
navigator.clipboard.writeText(codeText);
|
||||
};
|
||||
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),
|
||||
@ -800,7 +898,8 @@ class Markdown extends React.Component<{ text: string; style?: any; extraClassNa
|
||||
h4: (props) => HeaderRenderer(props, 4),
|
||||
h5: (props) => HeaderRenderer(props, 5),
|
||||
h6: (props) => HeaderRenderer(props, 6),
|
||||
code: CodeRenderer,
|
||||
code: (props) => CodeRenderer(props),
|
||||
pre: (props) => this.CodeBlockRenderer(props, codeSelect, curCodeSelectIndex),
|
||||
};
|
||||
return (
|
||||
<div className={cn("markdown content", this.props.extraClassName)} style={this.props.style}>
|
||||
@ -1082,16 +1181,18 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
|
||||
}
|
||||
|
||||
interface ModalHeaderProps {
|
||||
onClose: () => void;
|
||||
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>
|
||||
);
|
||||
|
||||
|
114
src/app/common/modals/about.less
Normal file
114
src/app/common/modals/about.less
Normal file
@ -0,0 +1,114 @@
|
||||
@import "../../../app/common/themes/themes.less";
|
||||
|
||||
.about-modal {
|
||||
.wave-modal-content {
|
||||
gap: 24px;
|
||||
|
||||
.wave-modal-body {
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
|
||||
.about-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
|
||||
.logo-wrapper {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
font-style: normal;
|
||||
line-height: 20px;
|
||||
|
||||
div:first-child {
|
||||
color: @term-bright-white;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
div:last-child {
|
||||
color: @term-white;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
div:first-child + div {
|
||||
color: @term-white;
|
||||
}
|
||||
}
|
||||
|
||||
.status.updated {
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
|
||||
i {
|
||||
color: @term-green;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status.outdated {
|
||||
div {
|
||||
i {
|
||||
color: @term-yellow;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.about-section:nth-child(3) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
|
||||
.wave-button-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.about-section:last-child {
|
||||
margin-bottom: 24px;
|
||||
color: @term-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
136
src/app/common/modals/about.tsx
Normal file
136
src/app/common/modals/about.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as React from "react";
|
||||
import * as mobxReact from "mobx-react";
|
||||
import * as mobx from "mobx";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import { GlobalModel } from "../../../model/model";
|
||||
import { Modal, LinkButton } from "../common";
|
||||
import * as util from "../../../util/util";
|
||||
|
||||
import logo from "../../assets/waveterm-logo-with-bg.svg";
|
||||
import "./about.less";
|
||||
|
||||
// @ts-ignore
|
||||
const VERSION = __WAVETERM_VERSION__;
|
||||
// @ts-ignore
|
||||
let BUILD = __WAVETERM_BUILD__;
|
||||
|
||||
@mobxReact.observer
|
||||
class AboutModal extends React.Component<{}, {}> {
|
||||
@boundMethod
|
||||
closeModal(): void {
|
||||
mobx.action(() => {
|
||||
GlobalModel.modalsModel.popModal();
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
isUpToDate(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
updateApp(): void {
|
||||
// GlobalCommandRunner.updateApp();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
getStatus(isUpToDate: boolean): JSX.Element {
|
||||
// TODO no up-to-date status reporting
|
||||
return (
|
||||
<div className="status updated">
|
||||
<div className="text-selectable">
|
||||
Client Version {VERSION} ({BUILD})
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isUpToDate) {
|
||||
return (
|
||||
<div className="status updated">
|
||||
<div>
|
||||
<i className="fa-sharp fa-solid fa-circle-check" />
|
||||
<span>Up to Date</span>
|
||||
</div>
|
||||
<div className="selectable">
|
||||
Client Version {VERSION} ({BUILD})
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="status outdated">
|
||||
<div>
|
||||
<i className="fa-sharp fa-solid fa-triangle-exclamation" />
|
||||
<span>Outdated Version</span>
|
||||
</div>
|
||||
<div className="selectable">
|
||||
Client Version {VERSION} ({BUILD})
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={this.updateApp} className="button color-green text-secondary">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal className="about-modal">
|
||||
<Modal.Header onClose={this.closeModal} title="About" />
|
||||
<div className="wave-modal-body">
|
||||
<div className="about-section">
|
||||
<div className="logo-wrapper">
|
||||
<img src={logo} alt="logo" />
|
||||
</div>
|
||||
<div className="text-wrapper">
|
||||
<div>Wave Terminal</div>
|
||||
<div className="text-standard">
|
||||
Modern Terminal for
|
||||
<br />
|
||||
Seamless Workflow
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-section text-standard">{this.getStatus(this.isUpToDate())}</div>
|
||||
<div className="about-section">
|
||||
<LinkButton
|
||||
className="secondary solid"
|
||||
href={util.makeExternLink("https://github.com/wavetermdev/waveterm")}
|
||||
target="_blank"
|
||||
leftIcon={<i className="fa-brands fa-github"></i>}
|
||||
>
|
||||
Github
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
className="secondary solid"
|
||||
href={util.makeExternLink("https://www.waveterm.dev/")}
|
||||
target="_blank"
|
||||
leftIcon={<i className="fa-sharp fa-light fa-globe"></i>}
|
||||
>
|
||||
Website
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
className="secondary solid"
|
||||
href={util.makeExternLink(
|
||||
"https://github.com/wavetermdev/waveterm/blob/main/acknowledgements/README.md"
|
||||
)}
|
||||
target="_blank"
|
||||
rel={"noopener"}
|
||||
leftIcon={<i className="fa-sharp fa-light fa-heart"></i>}
|
||||
>
|
||||
Acknowledgements
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="about-section text-standard">© 2023 Command Line Inc.</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { AboutModal };
|
11
src/app/common/modals/alert.less
Normal file
11
src/app/common/modals/alert.less
Normal file
@ -0,0 +1,11 @@
|
||||
@import "../../../app/common/themes/themes.less";
|
||||
|
||||
.alert-modal {
|
||||
width: 500px;
|
||||
|
||||
.wave-modal-content {
|
||||
.wave-modal-body {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
}
|
||||
}
|
75
src/app/common/modals/alert.tsx
Normal file
75
src/app/common/modals/alert.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as React from "react";
|
||||
import * as mobxReact from "mobx-react";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import { If } from "tsx-control-statements/components";
|
||||
import { Markdown, Modal, Button, Checkbox } from "../common";
|
||||
import { GlobalModel, GlobalCommandRunner } from "../../../model/model";
|
||||
|
||||
import "./alert.less";
|
||||
|
||||
@mobxReact.observer
|
||||
class AlertModal extends React.Component<{}, {}> {
|
||||
@boundMethod
|
||||
closeModal(): void {
|
||||
GlobalModel.cancelAlert();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleOK(): void {
|
||||
GlobalModel.confirmAlert();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleDontShowAgain(checked: boolean) {
|
||||
let message = GlobalModel.alertMessage.get();
|
||||
if (message.confirmflag == null) {
|
||||
return;
|
||||
}
|
||||
GlobalCommandRunner.clientSetConfirmFlag(message.confirmflag, checked);
|
||||
}
|
||||
|
||||
render() {
|
||||
let message = GlobalModel.alertMessage.get();
|
||||
let title = message?.title ?? (message?.confirm ? "Confirm" : "Alert");
|
||||
let isConfirm = message?.confirm ?? false;
|
||||
|
||||
return (
|
||||
<Modal className="alert-modal">
|
||||
<Modal.Header onClose={this.closeModal} title={title} />
|
||||
<div className="wave-modal-body">
|
||||
<If condition={message?.markdown}>
|
||||
<Markdown text={message?.message ?? ""} />
|
||||
</If>
|
||||
<If condition={!message?.markdown}>{message?.message}</If>
|
||||
<If condition={message.confirmflag}>
|
||||
<Checkbox
|
||||
onChange={this.handleDontShowAgain}
|
||||
label={"Don't show me this again"}
|
||||
className="dontshowagain-text"
|
||||
/>
|
||||
</If>
|
||||
</div>
|
||||
<div className="wave-modal-footer">
|
||||
<If condition={isConfirm}>
|
||||
<Button theme="secondary" onClick={this.closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button autoFocus={true} onClick={this.handleOK}>
|
||||
Ok
|
||||
</Button>
|
||||
</If>
|
||||
<If condition={!isConfirm}>
|
||||
<Button autoFocus={true} onClick={this.handleOK}>
|
||||
Ok
|
||||
</Button>
|
||||
</If>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { AlertModal };
|
11
src/app/common/modals/clientstop.less
Normal file
11
src/app/common/modals/clientstop.less
Normal file
@ -0,0 +1,11 @@
|
||||
@import "../../../app/common/themes/themes.less";
|
||||
|
||||
.clientstop-modal {
|
||||
.inner-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 30px;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
49
src/app/common/modals/clientstop.tsx
Normal file
49
src/app/common/modals/clientstop.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as React from "react";
|
||||
import * as mobxReact from "mobx-react";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import { If } from "tsx-control-statements/components";
|
||||
import { GlobalModel } from "../../../model/model";
|
||||
import { Modal, Button } from "../common";
|
||||
|
||||
import "./clientstop.less";
|
||||
|
||||
@mobxReact.observer
|
||||
class ClientStopModal extends React.Component<{}, {}> {
|
||||
@boundMethod
|
||||
refreshClient() {
|
||||
GlobalModel.refreshClient();
|
||||
}
|
||||
|
||||
render() {
|
||||
let model = GlobalModel;
|
||||
let cdata = model.clientData.get();
|
||||
return (
|
||||
<Modal className="clientstop-modal">
|
||||
<Modal.Header title="Client Not Ready" />
|
||||
<div className="wave-modal-body">
|
||||
<div className="modal-content">
|
||||
<div className="inner-content">
|
||||
<If condition={cdata == null}>
|
||||
<div>Cannot get client data.</div>
|
||||
</If>
|
||||
<div>
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={this.refreshClient}
|
||||
leftIcon={<i className="fa-sharp fa-solid fa-rotate"></i>}
|
||||
>
|
||||
Hard Refresh Client
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { ClientStopModal };
|
24
src/app/common/modals/createremoteconn.less
Normal file
24
src/app/common/modals/createremoteconn.less
Normal file
@ -0,0 +1,24 @@
|
||||
@import "../../../app/common/themes/themes.less";
|
||||
|
||||
.crconn-modal {
|
||||
width: 452px;
|
||||
min-height: 411px;
|
||||
|
||||
.wave-modal-content {
|
||||
gap: 24px;
|
||||
|
||||
.wave-modal-body {
|
||||
display: flex;
|
||||
padding: 0px 20px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
|
||||
> div {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
370
src/app/common/modals/createremoteconn.tsx
Normal file
370
src/app/common/modals/createremoteconn.tsx
Normal file
@ -0,0 +1,370 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as React from "react";
|
||||
import * as mobxReact from "mobx-react";
|
||||
import * as mobx from "mobx";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import { If } from "tsx-control-statements/components";
|
||||
import { GlobalModel, GlobalCommandRunner, RemotesModel } from "../../../model/model";
|
||||
import * as T from "../../../types/types";
|
||||
import { Modal, TextField, NumberField, InputDecoration, Dropdown, PasswordField, Tooltip } from "../common";
|
||||
import * as util from "../../../util/util";
|
||||
import * as appconst from "../../appconst";
|
||||
|
||||
import "./createremoteconn.less";
|
||||
|
||||
type OV<V> = mobx.IObservableValue<V>;
|
||||
|
||||
@mobxReact.observer
|
||||
class CreateRemoteConnModal extends React.Component<{}, {}> {
|
||||
tempAlias: OV<string>;
|
||||
tempHostName: OV<string>;
|
||||
tempPort: OV<string>;
|
||||
tempAuthMode: OV<string>;
|
||||
tempConnectMode: OV<string>;
|
||||
tempPassword: OV<string>;
|
||||
tempKeyFile: OV<string>;
|
||||
errorStr: OV<string>;
|
||||
remoteEdit: T.RemoteEditType;
|
||||
model: RemotesModel;
|
||||
|
||||
constructor(props: { remotesModel?: RemotesModel }) {
|
||||
super(props);
|
||||
this.model = GlobalModel.remotesModel;
|
||||
this.remoteEdit = this.model.remoteEdit.get();
|
||||
this.tempAlias = mobx.observable.box("", { name: "CreateRemote-alias" });
|
||||
this.tempHostName = mobx.observable.box("", { name: "CreateRemote-hostName" });
|
||||
this.tempPort = mobx.observable.box("", { name: "CreateRemote-port" });
|
||||
this.tempAuthMode = mobx.observable.box("none", { name: "CreateRemote-authMode" });
|
||||
this.tempConnectMode = mobx.observable.box("auto", { name: "CreateRemote-connectMode" });
|
||||
this.tempKeyFile = mobx.observable.box("", { name: "CreateRemote-keystr" });
|
||||
this.tempPassword = mobx.observable.box("", { name: "CreateRemote-password" });
|
||||
this.errorStr = mobx.observable.box(this.remoteEdit?.errorstr ?? null, { name: "CreateRemote-errorStr" });
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
GlobalModel.getClientData();
|
||||
}
|
||||
|
||||
remoteCName(): string {
|
||||
let hostName = this.tempHostName.get();
|
||||
if (hostName == "") {
|
||||
return "[no host]";
|
||||
}
|
||||
if (hostName.indexOf("@") == -1) {
|
||||
hostName = "[no user]@" + hostName;
|
||||
}
|
||||
return hostName;
|
||||
}
|
||||
|
||||
getErrorStr(): string {
|
||||
if (this.errorStr.get() != null) {
|
||||
return this.errorStr.get();
|
||||
}
|
||||
return this.remoteEdit?.errorstr ?? null;
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleOk(): void {
|
||||
this.showShellPrompt(this.submitRemote);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
showShellPrompt(cb: () => void): void {
|
||||
let prtn = GlobalModel.showAlert({
|
||||
message:
|
||||
"You are about to install WaveShell on a remote machine. Please be aware that WaveShell will be executed on the remote system.",
|
||||
confirm: true,
|
||||
confirmflag: appconst.ConfirmKey_HideShellPrompt,
|
||||
});
|
||||
prtn.then((confirm) => {
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
cb();
|
||||
});
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
submitRemote(): void {
|
||||
mobx.action(() => {
|
||||
this.errorStr.set(null);
|
||||
})();
|
||||
let authMode = this.tempAuthMode.get();
|
||||
let cname = this.tempHostName.get();
|
||||
if (cname == "") {
|
||||
this.errorStr.set("You must specify a 'user@host' value to create a new connection");
|
||||
return;
|
||||
}
|
||||
let kwargs: Record<string, string> = {};
|
||||
kwargs["alias"] = this.tempAlias.get();
|
||||
if (this.tempPort.get() != "" && this.tempPort.get() != "22") {
|
||||
kwargs["port"] = this.tempPort.get();
|
||||
}
|
||||
if (authMode == "key" || authMode == "key+password") {
|
||||
if (this.tempKeyFile.get() == "") {
|
||||
this.errorStr.set("When AuthMode is set to 'key', you must supply a valid key file name.");
|
||||
return;
|
||||
}
|
||||
kwargs["key"] = this.tempKeyFile.get();
|
||||
} else {
|
||||
kwargs["key"] = "";
|
||||
}
|
||||
if (authMode == "password" || authMode == "key+password") {
|
||||
if (this.tempPassword.get() == "") {
|
||||
this.errorStr.set("When AuthMode is set to 'password', you must supply a password.");
|
||||
return;
|
||||
}
|
||||
kwargs["password"] = this.tempPassword.get();
|
||||
} else {
|
||||
kwargs["password"] = "";
|
||||
}
|
||||
kwargs["connectmode"] = this.tempConnectMode.get();
|
||||
kwargs["visual"] = "1";
|
||||
kwargs["submit"] = "1";
|
||||
let prtn = GlobalCommandRunner.createRemote(cname, kwargs, false);
|
||||
prtn.then((crtn) => {
|
||||
if (crtn.success) {
|
||||
this.model.setRecentConnAdded(true);
|
||||
this.model.closeModal();
|
||||
|
||||
let crRtn = GlobalCommandRunner.screenSetRemote(cname, true, false);
|
||||
crRtn.then((crcrtn) => {
|
||||
if (crcrtn.success) {
|
||||
return;
|
||||
}
|
||||
mobx.action(() => {
|
||||
this.errorStr.set(crcrtn.error);
|
||||
})();
|
||||
});
|
||||
return;
|
||||
}
|
||||
mobx.action(() => {
|
||||
this.errorStr.set(crtn.error);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeKeyFile(value: string): void {
|
||||
mobx.action(() => {
|
||||
this.tempKeyFile.set(value);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangePassword(value: string): void {
|
||||
mobx.action(() => {
|
||||
this.tempPassword.set(value);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeAlias(value: string): void {
|
||||
mobx.action(() => {
|
||||
this.tempAlias.set(value);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeAuthMode(value: string): void {
|
||||
mobx.action(() => {
|
||||
this.tempAuthMode.set(value);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangePort(value: string): void {
|
||||
mobx.action(() => {
|
||||
this.tempPort.set(value);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeHostName(value: string): void {
|
||||
mobx.action(() => {
|
||||
this.tempHostName.set(value);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeConnectMode(value: string): void {
|
||||
mobx.action(() => {
|
||||
this.tempConnectMode.set(value);
|
||||
})();
|
||||
}
|
||||
|
||||
render() {
|
||||
let authMode = this.tempAuthMode.get();
|
||||
|
||||
if (this.remoteEdit == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal className="crconn-modal">
|
||||
<Modal.Header title="Add Connection" onClose={this.model.closeModal} />
|
||||
<div className="wave-modal-body">
|
||||
<div className="user-section">
|
||||
<TextField
|
||||
label="user@host"
|
||||
autoFocus={true}
|
||||
value={this.tempHostName.get()}
|
||||
onChange={this.handleChangeHostName}
|
||||
required={true}
|
||||
decoration={{
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<Tooltip
|
||||
message={`(Required) The user and host that you want to connect with. This is in the same format as
|
||||
you would pass to ssh, e.g. "ubuntu@test.mydomain.com".`}
|
||||
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-circle-question" />
|
||||
</Tooltip>
|
||||
</InputDecoration>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="alias-section">
|
||||
<TextField
|
||||
label="Alias"
|
||||
onChange={this.handleChangeAlias}
|
||||
value={this.tempAlias.get()}
|
||||
maxLength={100}
|
||||
decoration={{
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<Tooltip
|
||||
message={`(Optional) A short alias to use when selecting or displaying this connection.`}
|
||||
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-circle-question" />
|
||||
</Tooltip>
|
||||
</InputDecoration>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="port-section">
|
||||
<NumberField
|
||||
label="Port"
|
||||
placeholder="22"
|
||||
value={this.tempPort.get()}
|
||||
onChange={this.handleChangePort}
|
||||
decoration={{
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<Tooltip
|
||||
message={`(Optional) Defaults to 22. Set if the server you are connecting to listens to a non-standard
|
||||
SSH port.`}
|
||||
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-circle-question" />
|
||||
</Tooltip>
|
||||
</InputDecoration>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="authmode-section">
|
||||
<Dropdown
|
||||
label="Auth Mode"
|
||||
options={[
|
||||
{ value: "none", label: "none" },
|
||||
{ value: "key", label: "key" },
|
||||
{ value: "password", label: "password" },
|
||||
{ value: "key+password", label: "key+password" },
|
||||
]}
|
||||
value={this.tempAuthMode.get()}
|
||||
onChange={(val: string) => {
|
||||
this.tempAuthMode.set(val);
|
||||
}}
|
||||
decoration={{
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<Tooltip
|
||||
message={
|
||||
<ul>
|
||||
<li>
|
||||
<b>none</b> - no authentication, or authentication is already
|
||||
configured in your ssh config.
|
||||
</li>
|
||||
<li>
|
||||
<b>key</b> - use a private key.
|
||||
</li>
|
||||
<li>
|
||||
<b>password</b> - use a password.
|
||||
</li>
|
||||
<li>
|
||||
<b>key+password</b> - use a key with a passphrase.
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-circle-question" />
|
||||
</Tooltip>
|
||||
</InputDecoration>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<If condition={authMode == "key" || authMode == "key+password"}>
|
||||
<TextField
|
||||
label="SSH Keyfile"
|
||||
placeholder="keyfile path"
|
||||
onChange={this.handleChangeKeyFile}
|
||||
value={this.tempKeyFile.get()}
|
||||
maxLength={400}
|
||||
required={true}
|
||||
decoration={{
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<Tooltip
|
||||
message={`(Required) The path to your ssh key file.`}
|
||||
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-circle-question" />
|
||||
</Tooltip>
|
||||
</InputDecoration>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
<If condition={authMode == "password" || authMode == "key+password"}>
|
||||
<PasswordField
|
||||
label={authMode == "password" ? "SSH Password" : "Key Passphrase"}
|
||||
placeholder="password"
|
||||
onChange={this.handleChangePassword}
|
||||
value={this.tempPassword.get()}
|
||||
maxLength={400}
|
||||
/>
|
||||
</If>
|
||||
<div className="connectmode-section">
|
||||
<Dropdown
|
||||
label="Connect Mode"
|
||||
options={[
|
||||
{ value: "startup", label: "startup" },
|
||||
{ value: "auto", label: "auto" },
|
||||
{ value: "manual", label: "manual" },
|
||||
]}
|
||||
value={this.tempConnectMode.get()}
|
||||
onChange={(val: string) => {
|
||||
this.tempConnectMode.set(val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<If condition={!util.isBlank(this.getErrorStr() as string)}>
|
||||
<div className="settings-field settings-error">Error: {this.getErrorStr()}</div>
|
||||
</If>
|
||||
</div>
|
||||
<Modal.Footer onCancel={this.model.closeModal} onOk={this.handleOk} okLabel="Connect" />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { CreateRemoteConnModal };
|
47
src/app/common/modals/disconnected.less
Normal file
47
src/app/common/modals/disconnected.less
Normal file
@ -0,0 +1,47 @@
|
||||
@import "../../../app/common/themes/themes.less";
|
||||
|
||||
.disconnected-modal {
|
||||
.wave-modal-content {
|
||||
.wave-modal-body {
|
||||
padding: 0;
|
||||
|
||||
.modal-content {
|
||||
footer {
|
||||
.footer-text-link {
|
||||
color: @term-white;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inner-content {
|
||||
.log {
|
||||
height: 335px;
|
||||
margin-bottom: 20px;
|
||||
overflow: auto;
|
||||
|
||||
&::-webkit-scrollbar-track,
|
||||
&::-webkit-scrollbar-thumb,
|
||||
&::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
display: block;
|
||||
}
|
||||
|
||||
pre {
|
||||
color: @term-white;
|
||||
background-color: @term-black;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wave-modal-footer {
|
||||
button:first-child {
|
||||
color: @term-green;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
101
src/app/common/modals/disconnected.tsx
Normal file
101
src/app/common/modals/disconnected.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as React from "react";
|
||||
import * as mobxReact from "mobx-react";
|
||||
import * as mobx from "mobx";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import { GlobalModel } from "../../../model/model";
|
||||
import { Modal, Button } from "../common";
|
||||
|
||||
import "./disconnected.less";
|
||||
|
||||
const NumOfLines = 50;
|
||||
|
||||
@mobxReact.observer
|
||||
class DisconnectedModal extends React.Component<{}, {}> {
|
||||
logRef: any = React.createRef();
|
||||
logs: mobx.IObservableValue<string> = mobx.observable.box("");
|
||||
logInterval: NodeJS.Timeout = null;
|
||||
|
||||
@boundMethod
|
||||
restartServer() {
|
||||
GlobalModel.restartWaveSrv();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
tryReconnect() {
|
||||
GlobalModel.ws.connectNow("manual");
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchLogs();
|
||||
|
||||
this.logInterval = setInterval(() => {
|
||||
this.fetchLogs();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.logInterval) {
|
||||
clearInterval(this.logInterval);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.logRef.current != null) {
|
||||
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
fetchLogs() {
|
||||
GlobalModel.getLastLogs(
|
||||
NumOfLines,
|
||||
mobx.action((logs) => {
|
||||
this.logs.set(logs);
|
||||
if (this.logRef.current != null) {
|
||||
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal className="disconnected-modal">
|
||||
<Modal.Header title="Wave Client Disconnected" />
|
||||
<div className="wave-modal-body">
|
||||
<div className="modal-content">
|
||||
<div className="inner-content">
|
||||
<div className="log" ref={this.logRef}>
|
||||
<pre>{this.logs.get()}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wave-modal-footer">
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={this.tryReconnect}
|
||||
leftIcon={
|
||||
<span className="icon">
|
||||
<i className="fa-sharp fa-solid fa-rotate" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
Try Reconnect
|
||||
</Button>
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={this.restartServer}
|
||||
leftIcon={<i className="fa-sharp fa-solid fa-triangle-exclamation"></i>}
|
||||
>
|
||||
Restart Server
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { DisconnectedModal };
|
51
src/app/common/modals/editremoteconn.less
Normal file
51
src/app/common/modals/editremoteconn.less
Normal file
@ -0,0 +1,51 @@
|
||||
@import "../../../app/common/themes/themes.less";
|
||||
|
||||
.erconn-modal {
|
||||
width: 502px;
|
||||
min-height: 411px;
|
||||
|
||||
.wave-modal-content {
|
||||
gap: 20px;
|
||||
|
||||
.wave-modal-body {
|
||||
display: flex;
|
||||
padding: 0px 20px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
|
||||
> div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.name-actions-section {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
.name {
|
||||
color: @term-bright-white;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
|
||||
.wave-button {
|
||||
padding: 4px 15px;
|
||||
font-size: 11px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
312
src/app/common/modals/editremoteconn.tsx
Normal file
312
src/app/common/modals/editremoteconn.tsx
Normal file
@ -0,0 +1,312 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as React from "react";
|
||||
import * as mobxReact from "mobx-react";
|
||||
import * as mobx from "mobx";
|
||||
import { If } from "tsx-control-statements/components";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import { GlobalModel, GlobalCommandRunner, RemotesModel } from "../../../model/model";
|
||||
import * as T from "../../../types/types";
|
||||
import { Modal, TextField, InputDecoration, Dropdown, PasswordField, Tooltip } from "../common";
|
||||
import * as util from "../../../util/util";
|
||||
|
||||
import "./editremoteconn.less";
|
||||
|
||||
type OV<V> = mobx.IObservableValue<V>;
|
||||
|
||||
const PasswordUnchangedSentinel = "--unchanged--";
|
||||
|
||||
@mobxReact.observer
|
||||
class EditRemoteConnModal extends React.Component<{}, {}> {
|
||||
tempAlias: OV<string>;
|
||||
tempKeyFile: OV<string>;
|
||||
tempPassword: OV<string>;
|
||||
tempConnectMode: OV<string>;
|
||||
tempAuthMode: OV<string>;
|
||||
model: RemotesModel;
|
||||
|
||||
constructor(props: { remotesModel?: RemotesModel }) {
|
||||
super(props);
|
||||
this.model = GlobalModel.remotesModel;
|
||||
this.tempAlias = mobx.observable.box(null, { name: "EditRemoteSettings-tempAlias" });
|
||||
this.tempAuthMode = mobx.observable.box(null, { name: "EditRemoteSettings-tempAuthMode" });
|
||||
this.tempKeyFile = mobx.observable.box(null, { name: "EditRemoteSettings-tempKeyFile" });
|
||||
this.tempPassword = mobx.observable.box(null, { name: "EditRemoteSettings-tempPassword" });
|
||||
this.tempConnectMode = mobx.observable.box(null, { name: "EditRemoteSettings-tempConnectMode" });
|
||||
}
|
||||
|
||||
get selectedRemoteId() {
|
||||
return this.model.selectedRemoteId.get();
|
||||
}
|
||||
|
||||
get selectedRemote(): T.RemoteType {
|
||||
return GlobalModel.getRemote(this.selectedRemoteId);
|
||||
}
|
||||
|
||||
get remoteEdit(): T.RemoteEditType {
|
||||
return this.model.remoteEdit.get();
|
||||
}
|
||||
|
||||
get isAuthEditMode(): boolean {
|
||||
return this.model.isAuthEditMode();
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
mobx.action(() => {
|
||||
this.tempAlias.set(this.selectedRemote?.remotealias);
|
||||
this.tempKeyFile.set(this.remoteEdit?.keystr);
|
||||
this.tempPassword.set(this.remoteEdit?.haspassword ? PasswordUnchangedSentinel : "");
|
||||
this.tempConnectMode.set(this.selectedRemote?.connectmode);
|
||||
this.tempAuthMode.set(this.selectedRemote?.authtype);
|
||||
})();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.selectedRemote == null || this.selectedRemote.archived) {
|
||||
this.model.deSelectRemote();
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeKeyFile(value: string): void {
|
||||
mobx.action(() => {
|
||||
this.tempKeyFile.set(value);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangePassword(value: string): void {
|
||||
mobx.action(() => {
|
||||
this.tempPassword.set(value);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeAlias(value: string): void {
|
||||
mobx.action(() => {
|
||||
this.tempAlias.set(value);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeAuthMode(value: string): void {
|
||||
mobx.action(() => {
|
||||
this.tempAuthMode.set(value);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeConnectMode(value: string): void {
|
||||
mobx.action(() => {
|
||||
this.tempConnectMode.set(value);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
canResetPw(): boolean {
|
||||
if (this.remoteEdit == null) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(this.remoteEdit.haspassword) && this.tempPassword.get() != PasswordUnchangedSentinel;
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
resetPw(): void {
|
||||
mobx.action(() => {
|
||||
this.tempPassword.set(PasswordUnchangedSentinel);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
onFocusPassword(e: any) {
|
||||
if (this.tempPassword.get() == PasswordUnchangedSentinel) {
|
||||
e.target.select();
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
submitRemote(): void {
|
||||
let authMode = this.tempAuthMode.get();
|
||||
let kwargs: Record<string, string> = {};
|
||||
if (authMode == "key" || authMode == "key+password") {
|
||||
let keyStrEq = util.isStrEq(this.tempKeyFile.get(), this.remoteEdit?.keystr);
|
||||
if (!keyStrEq) {
|
||||
kwargs["key"] = this.tempKeyFile.get();
|
||||
}
|
||||
} else {
|
||||
if (!util.isBlank(this.tempKeyFile.get())) {
|
||||
kwargs["key"] = "";
|
||||
}
|
||||
}
|
||||
if (authMode == "password" || authMode == "key+password") {
|
||||
if (this.tempPassword.get() != PasswordUnchangedSentinel) {
|
||||
kwargs["password"] = this.tempPassword.get();
|
||||
}
|
||||
} else {
|
||||
if (this.remoteEdit?.haspassword) {
|
||||
kwargs["password"] = "";
|
||||
}
|
||||
}
|
||||
if (!util.isStrEq(this.tempAlias.get(), this.selectedRemote?.remotealias)) {
|
||||
kwargs["alias"] = this.tempAlias.get();
|
||||
}
|
||||
if (!util.isStrEq(this.tempConnectMode.get(), this.selectedRemote?.connectmode)) {
|
||||
kwargs["connectmode"] = this.tempConnectMode.get();
|
||||
}
|
||||
kwargs["visual"] = "1";
|
||||
kwargs["submit"] = "1";
|
||||
GlobalCommandRunner.editRemote(this.selectedRemote?.remoteid, kwargs);
|
||||
this.model.closeModal();
|
||||
}
|
||||
|
||||
renderAuthModeMessage(): any {
|
||||
let authMode = this.tempAuthMode.get();
|
||||
if (authMode == "none") {
|
||||
return (
|
||||
<span>
|
||||
This connection requires no authentication.
|
||||
<br />
|
||||
Or authentication is already configured in ssh_config.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (authMode == "key") {
|
||||
return <span>Use a public/private keypair.</span>;
|
||||
}
|
||||
if (authMode == "password") {
|
||||
return <span>Use a password.</span>;
|
||||
}
|
||||
if (authMode == "key+password") {
|
||||
return <span>Use a public/private keypair with a passphrase.</span>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
let authMode = this.tempAuthMode.get();
|
||||
if (this.remoteEdit === null || !this.isAuthEditMode) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Modal className="erconn-modal">
|
||||
<Modal.Header title="Edit Connection" onClose={this.model.closeModal} />
|
||||
<div className="wave-modal-body">
|
||||
<div className="name-actions-section">
|
||||
<div className="name text-primary">{util.getRemoteName(this.selectedRemote)}</div>
|
||||
</div>
|
||||
<div className="alias-section">
|
||||
<TextField
|
||||
label="Alias"
|
||||
onChange={this.handleChangeAlias}
|
||||
value={this.tempAlias.get()}
|
||||
maxLength={100}
|
||||
decoration={{
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<Tooltip
|
||||
message={`(Optional) A short alias to use when selecting or displaying this connection.`}
|
||||
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-circle-question" />
|
||||
</Tooltip>
|
||||
</InputDecoration>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="authmode-section">
|
||||
<Dropdown
|
||||
label="Auth Mode"
|
||||
options={[
|
||||
{ value: "none", label: "none" },
|
||||
{ value: "key", label: "key" },
|
||||
{ value: "password", label: "password" },
|
||||
{ value: "key+password", label: "key+password" },
|
||||
]}
|
||||
value={this.tempAuthMode.get()}
|
||||
onChange={this.handleChangeAuthMode}
|
||||
decoration={{
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<Tooltip
|
||||
message={
|
||||
<ul>
|
||||
<li>
|
||||
<b>none</b> - no authentication, or authentication is already
|
||||
configured in your ssh config.
|
||||
</li>
|
||||
<li>
|
||||
<b>key</b> - use a private key.
|
||||
</li>
|
||||
<li>
|
||||
<b>password</b> - use a password.
|
||||
</li>
|
||||
<li>
|
||||
<b>key+password</b> - use a key with a passphrase.
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-circle-question" />
|
||||
</Tooltip>
|
||||
</InputDecoration>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<If condition={authMode == "key" || authMode == "key+password"}>
|
||||
<TextField
|
||||
label="SSH Keyfile"
|
||||
placeholder="keyfile path"
|
||||
onChange={this.handleChangeKeyFile}
|
||||
value={this.tempKeyFile.get()}
|
||||
maxLength={400}
|
||||
required={true}
|
||||
decoration={{
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<Tooltip
|
||||
message={`(Required) The path to your ssh key file.`}
|
||||
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-circle-question" />
|
||||
</Tooltip>
|
||||
</InputDecoration>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
<If condition={authMode == "password" || authMode == "key+password"}>
|
||||
<PasswordField
|
||||
label={authMode == "password" ? "SSH Password" : "Key Passphrase"}
|
||||
placeholder="password"
|
||||
onChange={this.handleChangePassword}
|
||||
value={this.tempPassword.get()}
|
||||
maxLength={400}
|
||||
/>
|
||||
</If>
|
||||
<div className="connectmode-section">
|
||||
<Dropdown
|
||||
label="Connect Mode"
|
||||
options={[
|
||||
{ value: "startup", label: "startup" },
|
||||
{ value: "auto", label: "auto" },
|
||||
{ value: "manual", label: "manual" },
|
||||
]}
|
||||
value={this.tempConnectMode.get()}
|
||||
onChange={this.handleChangeConnectMode}
|
||||
/>
|
||||
</div>
|
||||
<If condition={!util.isBlank(this.remoteEdit?.errorstr)}>
|
||||
<div className="settings-field settings-error">Error: {this.remoteEdit?.errorstr}</div>
|
||||
</If>
|
||||
</div>
|
||||
<Modal.Footer onOk={this.submitRemote} onCancel={this.model.closeModal} okLabel="Save" />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { EditRemoteConnModal };
|
8
src/app/common/modals/index.tsx
Normal file
8
src/app/common/modals/index.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
export { AboutModal } from "./about";
|
||||
export { DisconnectedModal } from "./disconnected";
|
||||
export { ClientStopModal } from "./clientstop";
|
||||
export { AlertModal } from "./alert";
|
||||
export { CreateRemoteConnModal } from "./createremoteconn";
|
||||
export { ViewRemoteConnDetailModal } from "./viewremoteconndetail";
|
||||
export { EditRemoteConnModal } from "./editremoteconn";
|
||||
export { TabSwitcherModal } from "./tabswitcher";
|
@ -17,6 +17,10 @@
|
||||
}
|
||||
|
||||
.disconnected-modal {
|
||||
.wave-modal-content {
|
||||
.wave-modal-body {
|
||||
padding: 0;
|
||||
|
||||
.modal-content {
|
||||
footer {
|
||||
.footer-text-link {
|
||||
@ -27,14 +31,32 @@
|
||||
}
|
||||
|
||||
.inner-content {
|
||||
.ws-log {
|
||||
padding: 5px;
|
||||
background-color: @term-black;
|
||||
height: 250px;
|
||||
.log {
|
||||
height: 335px;
|
||||
margin-bottom: 20px;
|
||||
overflow: auto;
|
||||
|
||||
.ws-logline {
|
||||
&::-webkit-scrollbar-track,
|
||||
&::-webkit-scrollbar-thumb,
|
||||
&::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
display: block;
|
||||
}
|
||||
|
||||
pre {
|
||||
color: @term-white;
|
||||
background-color: @term-black;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wave-modal-footer {
|
||||
button:first-child {
|
||||
color: @term-green;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -453,6 +475,89 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tabswitcher-modal {
|
||||
width: 452px;
|
||||
min-height: 384px;
|
||||
|
||||
.wave-modal-content {
|
||||
.wave-modal-body {
|
||||
display: flex;
|
||||
padding: 0px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
|
||||
.textfield-wrapper {
|
||||
padding: 20px 20px 0px;
|
||||
|
||||
.wave-input-decoration.start-position {
|
||||
height: 100%;
|
||||
|
||||
.tabswitcher-search-prefix {
|
||||
opacity: 0.5;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-container {
|
||||
overflow: hidden;
|
||||
padding: 10px 0 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list-container-inner {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
overflow-y: scroll;
|
||||
padding: 0 16px 0 20px;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.options-list {
|
||||
width: 100%;
|
||||
|
||||
.search-option {
|
||||
padding: 5px 5px 5px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
div.tabname {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
div.icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.focused-option {
|
||||
border: 1px solid rgba(241, 246, 243, 0.15);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.screen-settings-tooltip .wave-tooltip-icon {
|
||||
i {
|
||||
font-size: 13px;
|
||||
@ -491,6 +596,10 @@
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
|
||||
.settings-input .hotkey {
|
||||
color: @text-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -564,11 +673,15 @@
|
||||
}
|
||||
|
||||
.alert-modal {
|
||||
width: 500px;
|
||||
width: 510px;
|
||||
|
||||
.wave-modal-content {
|
||||
.wave-modal-body {
|
||||
padding: 40px 20px;
|
||||
|
||||
.dontshowagain-text {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -23,12 +23,15 @@ import {
|
||||
Tooltip,
|
||||
Button,
|
||||
Status,
|
||||
Checkbox,
|
||||
} from "../common";
|
||||
import * as util from "../../../util/util";
|
||||
import * as textmeasure from "../../../util/textmeasure";
|
||||
import * as appconst from "../../appconst";
|
||||
import { ClientDataType } from "../../../types/types";
|
||||
import { Screen } from "../../../model/model";
|
||||
import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg";
|
||||
|
||||
import { ReactComponent as WarningIcon } from "../../assets/icons/line/triangle-exclamation.svg";
|
||||
import shield from "../../assets/icons/shield_check.svg";
|
||||
import help from "../../assets/icons/help_filled.svg";
|
||||
import github from "../../assets/icons/github.svg";
|
||||
@ -42,9 +45,11 @@ const VERSION = __WAVETERM_VERSION__;
|
||||
let BUILD = __WAVETERM_BUILD__;
|
||||
|
||||
type OV<V> = mobx.IObservableValue<V>;
|
||||
type OArr<V> = mobx.IObservableArray<V>;
|
||||
|
||||
const RemotePtyRows = 9;
|
||||
const RemotePtyCols = 80;
|
||||
const NumOfLines = 50;
|
||||
const PasswordUnchangedSentinel = "--unchanged--";
|
||||
|
||||
@mobxReact.observer
|
||||
@ -67,7 +72,8 @@ class ModalsProvider extends React.Component {
|
||||
@mobxReact.observer
|
||||
class DisconnectedModal extends React.Component<{}, {}> {
|
||||
logRef: any = React.createRef();
|
||||
showLog: mobx.IObservableValue<boolean> = mobx.observable.box(false);
|
||||
logs: mobx.IObservableValue<string> = mobx.observable.box("");
|
||||
logInterval: NodeJS.Timeout = null;
|
||||
|
||||
@boundMethod
|
||||
restartServer() {
|
||||
@ -80,8 +86,16 @@ class DisconnectedModal extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.logRef.current != null) {
|
||||
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
|
||||
this.fetchLogs();
|
||||
|
||||
this.logInterval = setInterval(() => {
|
||||
this.fetchLogs();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.logInterval) {
|
||||
clearInterval(this.logInterval);
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,58 +105,52 @@ class DisconnectedModal extends React.Component<{}, {}> {
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleShowLog(): void {
|
||||
mobx.action(() => {
|
||||
this.showLog.set(!this.showLog.get());
|
||||
})();
|
||||
fetchLogs() {
|
||||
GlobalModel.getLastLogs(
|
||||
NumOfLines,
|
||||
mobx.action((logs) => {
|
||||
this.logs.set(logs);
|
||||
if (this.logRef.current != null) {
|
||||
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
let model = GlobalModel;
|
||||
let logLine: string = null;
|
||||
let idx: number = 0;
|
||||
return (
|
||||
<div className="prompt-modal disconnected-modal modal is-active">
|
||||
<div className="modal-background"></div>
|
||||
<Modal className="disconnected-modal">
|
||||
<Modal.Header title="Wave Client Disconnected" />
|
||||
<div className="wave-modal-body">
|
||||
<div className="modal-content">
|
||||
<div className="message-header">
|
||||
<div className="modal-title">Wave Client Disconnected</div>
|
||||
</div>
|
||||
<If condition={this.showLog.get()}>
|
||||
<div className="inner-content">
|
||||
<div className="ws-log" ref={this.logRef}>
|
||||
<For each="logLine" index="idx" of={GlobalModel.ws.wsLog}>
|
||||
<div key={idx} className="ws-logline">
|
||||
{logLine}
|
||||
</div>
|
||||
</For>
|
||||
<div className="log" ref={this.logRef}>
|
||||
<pre>{this.logs.get()}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
<footer>
|
||||
<div className="footer-text-link" style={{ marginLeft: 10 }} onClick={this.handleShowLog}>
|
||||
<If condition={!this.showLog.get()}>
|
||||
<i className="fa-sharp fa-solid fa-plus" /> Show Log
|
||||
</If>
|
||||
<If condition={this.showLog.get()}>
|
||||
<i className="fa-sharp fa-solid fa-minus" /> Hide Log
|
||||
</If>
|
||||
</div>
|
||||
<div className="flex-spacer" />
|
||||
<button onClick={this.tryReconnect} className="button">
|
||||
</div>
|
||||
<div className="wave-modal-footer">
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={this.tryReconnect}
|
||||
leftIcon={
|
||||
<span className="icon">
|
||||
<i className="fa-sharp fa-solid fa-rotate" />
|
||||
</span>
|
||||
<span>Try Reconnect</span>
|
||||
</button>
|
||||
<button onClick={this.restartServer} className="button is-danger" style={{ marginLeft: 10 }}>
|
||||
<WarningIcon className="icon" />
|
||||
<span>Restart Server</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Try Reconnect
|
||||
</Button>
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={this.restartServer}
|
||||
leftIcon={<i className="fa-sharp fa-solid fa-triangle-exclamation"></i>}
|
||||
>
|
||||
Restart Server
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -210,6 +218,15 @@ class AlertModal extends React.Component<{}, {}> {
|
||||
GlobalModel.confirmAlert();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleDontShowAgain(checked: boolean) {
|
||||
let message = GlobalModel.alertMessage.get();
|
||||
if (message.confirmflag == null) {
|
||||
return;
|
||||
}
|
||||
GlobalCommandRunner.clientSetConfirmFlag(message.confirmflag, checked);
|
||||
}
|
||||
|
||||
render() {
|
||||
let message = GlobalModel.alertMessage.get();
|
||||
let title = message?.title ?? (message?.confirm ? "Confirm" : "Alert");
|
||||
@ -223,16 +240,27 @@ class AlertModal extends React.Component<{}, {}> {
|
||||
<Markdown text={message?.message ?? ""} />
|
||||
</If>
|
||||
<If condition={!message?.markdown}>{message?.message}</If>
|
||||
<If condition={message.confirmflag}>
|
||||
<Checkbox
|
||||
onChange={this.handleDontShowAgain}
|
||||
label={"Don't show me this again"}
|
||||
className="dontshowagain-text"
|
||||
/>
|
||||
</If>
|
||||
</div>
|
||||
<div className="wave-modal-footer">
|
||||
<If condition={isConfirm}>
|
||||
<Button theme="secondary" onClick={this.closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={this.handleOK}>Ok</Button>
|
||||
<Button autoFocus={true} onClick={this.handleOK}>
|
||||
Ok
|
||||
</Button>
|
||||
</If>
|
||||
<If condition={!isConfirm}>
|
||||
<Button onClick={this.handleOK}>Ok</Button>
|
||||
<Button autoFocus={true} onClick={this.handleOK}>
|
||||
Ok
|
||||
</Button>
|
||||
</If>
|
||||
</div>
|
||||
</Modal>
|
||||
@ -497,6 +525,10 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
|
||||
this.errorStr = mobx.observable.box(this.remoteEdit?.errorstr ?? null, { name: "CreateRemote-errorStr" });
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
GlobalModel.getClientData();
|
||||
}
|
||||
|
||||
remoteCName(): string {
|
||||
let hostName = this.tempHostName.get();
|
||||
if (hostName == "") {
|
||||
@ -515,6 +547,27 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
|
||||
return this.remoteEdit?.errorstr ?? null;
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleOk(): void {
|
||||
this.showShellPrompt(this.submitRemote);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
showShellPrompt(cb: () => void): void {
|
||||
let prtn = GlobalModel.showAlert({
|
||||
message:
|
||||
"You are about to install WaveShell on a remote machine. Please be aware that WaveShell will be executed on the remote system.",
|
||||
confirm: true,
|
||||
confirmflag: appconst.ConfirmKey_HideShellPrompt,
|
||||
});
|
||||
prtn.then((confirm) => {
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
cb();
|
||||
});
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
submitRemote(): void {
|
||||
mobx.action(() => {
|
||||
@ -552,26 +605,27 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
|
||||
kwargs["connectmode"] = this.tempConnectMode.get();
|
||||
kwargs["visual"] = "1";
|
||||
kwargs["submit"] = "1";
|
||||
let model = this.model;
|
||||
let prtn = GlobalCommandRunner.createRemote(cname, kwargs, false);
|
||||
prtn.then((crtn) => {
|
||||
if (crtn.success) {
|
||||
this.model.setRecentConnAdded(true);
|
||||
this.model.closeModal();
|
||||
|
||||
let crRtn = GlobalCommandRunner.screenSetRemote(cname, true, false);
|
||||
crRtn.then((crcrtn) => {
|
||||
if (crcrtn.success) {
|
||||
return;
|
||||
}
|
||||
mobx.action(() => {
|
||||
this.errorStr.set(crcrtn.error ?? null);
|
||||
this.errorStr.set(crcrtn.error);
|
||||
})();
|
||||
});
|
||||
return;
|
||||
}
|
||||
mobx.action(() => {
|
||||
this.errorStr.set(crtn.error ?? null);
|
||||
this.errorStr.set(crtn.error);
|
||||
})();
|
||||
});
|
||||
model.seRecentConnAdded(true);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
@ -789,7 +843,7 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
|
||||
<div className="settings-field settings-error">Error: {this.getErrorStr()}</div>
|
||||
</If>
|
||||
</div>
|
||||
<Modal.Footer onCancel={this.model.closeModal} onOk={this.submitRemote} okLabel="Connect" />
|
||||
<Modal.Footer onCancel={this.model.closeModal} onOk={this.handleOk} okLabel="Connect" />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -806,7 +860,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
@mobx.computed
|
||||
get selectedRemote(): T.RemoteType {
|
||||
getSelectedRemote(): T.RemoteType {
|
||||
const selectedRemoteId = this.model.selectedRemoteId.get();
|
||||
return GlobalModel.getRemote(selectedRemoteId);
|
||||
}
|
||||
@ -821,7 +875,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.selectedRemote == null || this.selectedRemote.archived) {
|
||||
if (this.getSelectedRemote() == null || this.getSelectedRemote().archived) {
|
||||
this.model.deSelectRemote();
|
||||
}
|
||||
}
|
||||
@ -885,7 +939,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
|
||||
|
||||
@boundMethod
|
||||
clickArchive(): void {
|
||||
if (this.selectedRemote && this.selectedRemote.status == "connected") {
|
||||
if (this.getSelectedRemote() && this.getSelectedRemote().status == "connected") {
|
||||
GlobalModel.showAlert({ message: "Cannot delete when connected. Disconnect and try again." });
|
||||
return;
|
||||
}
|
||||
@ -897,21 +951,22 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
if (this.selectedRemote) {
|
||||
GlobalCommandRunner.archiveRemote(this.selectedRemote.remoteid);
|
||||
if (this.getSelectedRemote()) {
|
||||
GlobalCommandRunner.archiveRemote(this.getSelectedRemote().remoteid);
|
||||
}
|
||||
GlobalModel.modalsModel.popModal();
|
||||
});
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
clickReinstall(): void {
|
||||
GlobalCommandRunner.installRemote(this.selectedRemote?.remoteid);
|
||||
GlobalCommandRunner.installRemote(this.getSelectedRemote().remoteid);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleClose(): void {
|
||||
this.model.closeModal();
|
||||
this.model.seRecentConnAdded(false);
|
||||
this.model.setRecentConnAdded(false);
|
||||
}
|
||||
|
||||
renderInstallStatus(remote: T.RemoteType): any {
|
||||
@ -990,7 +1045,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
|
||||
<Button theme="secondary" disabled={true}>
|
||||
Edit
|
||||
<Tooltip
|
||||
message={`Remotes imported from an ssh config file cannot be edited inside waveterm. To edit these, you must edit the config file and import it again.`}
|
||||
message={`Connections imported from an ssh config file cannot be edited inside waveterm. To edit these, you must edit the config file and import it again.`}
|
||||
icon={<i className="fa-sharp fa-regular fa-fw fa-ban" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-fw fa-ban" />
|
||||
@ -1003,7 +1058,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
|
||||
<Tooltip
|
||||
message={
|
||||
<span>
|
||||
Remotes imported from an ssh config file can be deleted, but will come back upon
|
||||
Connections imported from an ssh config file can be deleted, but will come back upon
|
||||
importing again. They will stay removed if you follow{" "}
|
||||
<a href="https://docs.waveterm.dev/features/sshconfig-imports">this procedure</a>.
|
||||
</span>
|
||||
@ -1072,7 +1127,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
render() {
|
||||
let remote = this.selectedRemote;
|
||||
let remote = this.getSelectedRemote();
|
||||
|
||||
if (remote == null) {
|
||||
return null;
|
||||
@ -1083,14 +1138,15 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
|
||||
let termFontSize = GlobalModel.termFontSize.get();
|
||||
let termWidth = textmeasure.termWidthFromCols(RemotePtyCols, termFontSize);
|
||||
let remoteAliasText = util.isBlank(remote.remotealias) ? "(none)" : remote.remotealias;
|
||||
let selectedRemoteStatus = this.getSelectedRemote().status;
|
||||
|
||||
return (
|
||||
<Modal className="rconndetail-modal">
|
||||
<Modal.Header title="Connection" onClose={this.model.closeModal} />
|
||||
<Modal.Header title="Connection" onClose={this.handleClose} />
|
||||
<div className="wave-modal-body">
|
||||
<div className="name-header-actions-wrapper">
|
||||
<div className="name text-primary name-wrapper">
|
||||
{getName(remote)} {getImportTooltip(remote)}
|
||||
{util.getRemoteName(remote)} {getImportTooltip(remote)}
|
||||
</div>
|
||||
<div className="header-actions">{this.renderHeaderBtns(remote)}</div>
|
||||
</div>
|
||||
@ -1161,7 +1217,18 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal.Footer onOk={this.model.closeModal} onCancel={this.model.closeModal} okLabel="Done" />
|
||||
<div className="wave-modal-footer">
|
||||
<Button
|
||||
theme="secondary"
|
||||
disabled={selectedRemoteStatus == "connecting"}
|
||||
onClick={this.handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={selectedRemoteStatus == "connecting"} onClick={this.handleClose}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -1343,7 +1410,7 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
|
||||
<Modal.Header title="Edit Connection" onClose={this.model.closeModal} />
|
||||
<div className="wave-modal-body">
|
||||
<div className="name-actions-section">
|
||||
<div className="name text-primary">{getName(this.selectedRemote)}</div>
|
||||
<div className="name text-primary">{util.getRemoteName(this.selectedRemote)}</div>
|
||||
</div>
|
||||
<div className="alias-section">
|
||||
<TextField
|
||||
@ -1459,14 +1526,309 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
|
||||
}
|
||||
}
|
||||
|
||||
const getName = (remote: T.RemoteType): string => {
|
||||
if (remote == null) {
|
||||
return "";
|
||||
}
|
||||
const { remotealias, remotecanonicalname } = remote;
|
||||
return remotealias ? `${remotealias} [${remotecanonicalname}]` : remotecanonicalname;
|
||||
type SwitcherDataType = {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
sessionIdx: number;
|
||||
screenId: string;
|
||||
screenIdx: number;
|
||||
screenName: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const MaxOptionsToDisplay = 100;
|
||||
|
||||
@mobxReact.observer
|
||||
class TabSwitcherModal extends React.Component<{}, {}> {
|
||||
screens: Map<string, OV<string>>[];
|
||||
sessions: Map<string, OV<string>>[];
|
||||
options: SwitcherDataType[] = [];
|
||||
sOptions: OArr<SwitcherDataType> = mobx.observable.array(null, {
|
||||
name: "TabSwitcherModal-sOptions",
|
||||
});
|
||||
focusedIdx: OV<number> = mobx.observable.box(0, { name: "TabSwitcherModal-selectedIdx" });
|
||||
activeSessionIdx: number;
|
||||
optionRefs = [];
|
||||
listWrapperRef = React.createRef<HTMLDivElement>();
|
||||
prevFocusedIdx = 0;
|
||||
|
||||
componentDidMount() {
|
||||
this.activeSessionIdx = GlobalModel.getActiveSession().sessionIdx.get();
|
||||
let oSessions = GlobalModel.sessionList;
|
||||
let oScreens = GlobalModel.screenMap;
|
||||
oScreens.forEach((oScreen) => {
|
||||
// Find the matching session in the observable array
|
||||
let foundSession = oSessions.find((s) => {
|
||||
if (s.sessionId === oScreen.sessionId && s.archived.get() == false) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (foundSession) {
|
||||
let data: SwitcherDataType = {
|
||||
sessionName: foundSession.name.get(),
|
||||
sessionId: foundSession.sessionId,
|
||||
sessionIdx: foundSession.sessionIdx.get(),
|
||||
screenName: oScreen.name.get(),
|
||||
screenId: oScreen.screenId,
|
||||
screenIdx: oScreen.screenIdx.get(),
|
||||
icon: this.getTabIcon(oScreen),
|
||||
color: this.getTabColor(oScreen),
|
||||
};
|
||||
this.options.push(data);
|
||||
}
|
||||
});
|
||||
|
||||
mobx.action(() => {
|
||||
this.sOptions.replace(this.sortOptions(this.options).slice(0, MaxOptionsToDisplay));
|
||||
})();
|
||||
|
||||
document.addEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
let currFocusedIdx = this.focusedIdx.get();
|
||||
|
||||
// Check if selectedIdx has changed
|
||||
if (currFocusedIdx !== this.prevFocusedIdx) {
|
||||
let optionElement = this.optionRefs[currFocusedIdx]?.current;
|
||||
|
||||
if (optionElement) {
|
||||
optionElement.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
// Update prevFocusedIdx for the next update cycle
|
||||
this.prevFocusedIdx = currFocusedIdx;
|
||||
}
|
||||
if (currFocusedIdx >= this.sOptions.length && this.sOptions.length > 0) {
|
||||
this.setFocusedIndex(this.sOptions.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
getTabIcon(screen: Screen): string {
|
||||
let tabIcon = "default";
|
||||
let screenOpts = screen.opts.get();
|
||||
if (screenOpts != null && !util.isBlank(screenOpts.tabicon)) {
|
||||
tabIcon = screenOpts.tabicon;
|
||||
}
|
||||
return tabIcon;
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
getTabColor(screen: Screen): string {
|
||||
let tabColor = "default";
|
||||
let screenOpts = screen.opts.get();
|
||||
if (screenOpts != null && !util.isBlank(screenOpts.tabcolor)) {
|
||||
tabColor = screenOpts.tabcolor;
|
||||
}
|
||||
return tabColor;
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleKeyDown(e) {
|
||||
if (e.key === "Escape") {
|
||||
this.closeModal();
|
||||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
let newIndex = this.calculateNewIndex(e.key === "ArrowUp");
|
||||
this.setFocusedIndex(newIndex);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
this.handleSelect(this.focusedIdx.get());
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
calculateNewIndex(isUpKey) {
|
||||
let currentIndex = this.focusedIdx.get();
|
||||
if (isUpKey) {
|
||||
return Math.max(currentIndex - 1, 0);
|
||||
} else {
|
||||
return Math.min(currentIndex + 1, this.sOptions.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
setFocusedIndex(index) {
|
||||
mobx.action(() => {
|
||||
this.focusedIdx.set(index);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
closeModal(): void {
|
||||
GlobalModel.modalsModel.popModal();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleSelect(index: number): void {
|
||||
const selectedOption = this.sOptions[index];
|
||||
if (selectedOption) {
|
||||
GlobalCommandRunner.switchScreen(selectedOption.screenId, selectedOption.sessionId);
|
||||
this.closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleSearch(val: string): void {
|
||||
let sOptions: SwitcherDataType[];
|
||||
if (val == "") {
|
||||
sOptions = this.sortOptions(this.options).slice(0, MaxOptionsToDisplay);
|
||||
} else {
|
||||
sOptions = this.filterOptions(val);
|
||||
sOptions = this.sortOptions(sOptions);
|
||||
if (sOptions.length > MaxOptionsToDisplay) {
|
||||
sOptions = sOptions.slice(0, MaxOptionsToDisplay);
|
||||
}
|
||||
}
|
||||
mobx.action(() => {
|
||||
this.sOptions.replace(sOptions);
|
||||
this.focusedIdx.set(0);
|
||||
})();
|
||||
}
|
||||
|
||||
@mobx.computed
|
||||
@boundMethod
|
||||
filterOptions(searchInput: string): SwitcherDataType[] {
|
||||
let filteredScreens = [];
|
||||
|
||||
for (let i = 0; i < this.options.length; i++) {
|
||||
let tab = this.options[i];
|
||||
let match = false;
|
||||
|
||||
if (searchInput.includes("/")) {
|
||||
let [sessionFilter, screenFilter] = searchInput.split("/").map((s) => s.trim().toLowerCase());
|
||||
match =
|
||||
tab.sessionName.toLowerCase().includes(sessionFilter) &&
|
||||
tab.screenName.toLowerCase().includes(screenFilter);
|
||||
} else {
|
||||
match =
|
||||
tab.sessionName.toLowerCase().includes(searchInput) ||
|
||||
tab.screenName.toLowerCase().includes(searchInput);
|
||||
}
|
||||
|
||||
// Add tab to filtered list if it matches the criteria
|
||||
if (match) {
|
||||
filteredScreens.push(tab);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredScreens;
|
||||
}
|
||||
|
||||
@mobx.computed
|
||||
@boundMethod
|
||||
sortOptions(options: SwitcherDataType[]): SwitcherDataType[] {
|
||||
return options.sort((a, b) => {
|
||||
let aInCurrentSession = a.sessionIdx === this.activeSessionIdx;
|
||||
let bInCurrentSession = b.sessionIdx === this.activeSessionIdx;
|
||||
|
||||
// Tabs in the current session are sorted by screenIdx
|
||||
if (aInCurrentSession && bInCurrentSession) {
|
||||
return a.screenIdx - b.screenIdx;
|
||||
}
|
||||
// a is in the current session and b is not, so a comes first
|
||||
else if (aInCurrentSession) {
|
||||
return -1;
|
||||
}
|
||||
// b is in the current session and a is not, so b comes first
|
||||
else if (bInCurrentSession) {
|
||||
return 1;
|
||||
}
|
||||
// Both are in different, non-current sessions - sort by sessionIdx and then by screenIdx
|
||||
else {
|
||||
if (a.sessionIdx === b.sessionIdx) {
|
||||
return a.screenIdx - b.screenIdx;
|
||||
} else {
|
||||
return a.sessionIdx - b.sessionIdx;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
renderIcon(option: SwitcherDataType): React.ReactNode {
|
||||
let tabIcon = option.icon;
|
||||
if (tabIcon === "default" || tabIcon === "square") {
|
||||
return <SquareIcon className="left-icon" />;
|
||||
}
|
||||
return <i className={`fa-sharp fa-solid fa-${tabIcon}`}></i>;
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
renderOption(option: SwitcherDataType, index: number): JSX.Element {
|
||||
if (!this.optionRefs[index]) {
|
||||
this.optionRefs[index] = React.createRef();
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={option.sessionId + "/" + option.screenId}
|
||||
ref={this.optionRefs[index]}
|
||||
className={cn("search-option unselectable", {
|
||||
"focused-option": this.focusedIdx.get() === index,
|
||||
})}
|
||||
onClick={() => this.handleSelect(index)}
|
||||
>
|
||||
<div className={cn("icon", "color-" + option.color)}>{this.renderIcon(option)}</div>
|
||||
<div className="tabname">
|
||||
#{option.sessionName} / {option.screenName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
let option: SwitcherDataType;
|
||||
let index: number;
|
||||
return (
|
||||
<Modal className="tabswitcher-modal">
|
||||
<div className="wave-modal-body">
|
||||
<div className="textfield-wrapper">
|
||||
<TextField
|
||||
onChange={this.handleSearch}
|
||||
maxLength={400}
|
||||
autoFocus={true}
|
||||
decoration={{
|
||||
startDecoration: (
|
||||
<InputDecoration position="start">
|
||||
<div className="tabswitcher-search-prefix">Switch to Tab:</div>
|
||||
</InputDecoration>
|
||||
),
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<Tooltip
|
||||
message={`Type to filter workspaces and tabs.`}
|
||||
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-circle-question" />
|
||||
</Tooltip>
|
||||
</InputDecoration>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="list-container">
|
||||
<div ref={this.listWrapperRef} className="list-container-inner">
|
||||
<div className="options-list">
|
||||
<For each="option" index="index" of={this.sOptions}>
|
||||
{this.renderOption(option, index)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getImportTooltip = (remote: T.RemoteType): React.ReactElement<any, any> => {
|
||||
if (remote.sshconfigsrc == "sshconfig-import") {
|
||||
return (
|
||||
@ -1493,4 +1855,5 @@ export {
|
||||
ViewRemoteConnDetailModal,
|
||||
EditRemoteConnModal,
|
||||
ModalsProvider,
|
||||
TabSwitcherModal,
|
||||
};
|
||||
|
26
src/app/common/modals/provider.tsx
Normal file
26
src/app/common/modals/provider.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as React from "react";
|
||||
import * as mobxReact from "mobx-react";
|
||||
import { GlobalModel } from "../../../model/model";
|
||||
import { TosModal } from "./tos";
|
||||
|
||||
@mobxReact.observer
|
||||
class ModalsProvider extends React.Component {
|
||||
render() {
|
||||
let store = GlobalModel.modalsModel.store.slice();
|
||||
if (GlobalModel.needsTos()) {
|
||||
return <TosModal />;
|
||||
}
|
||||
let rtn: JSX.Element[] = [];
|
||||
for (let i = 0; i < store.length; i++) {
|
||||
let entry = store[i];
|
||||
let Comp = entry.component;
|
||||
rtn.push(<Comp key={entry.uniqueKey} />);
|
||||
}
|
||||
return <>{rtn}</>;
|
||||
}
|
||||
}
|
||||
|
||||
export { ModalsProvider };
|
@ -4,11 +4,12 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
AboutModal,
|
||||
AlertModal,
|
||||
CreateRemoteConnModal,
|
||||
ViewRemoteConnDetailModal,
|
||||
EditRemoteConnModal,
|
||||
AlertModal,
|
||||
} from "./modals";
|
||||
TabSwitcherModal,
|
||||
} from "../modals";
|
||||
import { ScreenSettingsModal, SessionSettingsModal, LineSettingsModal, ClientSettingsModal } from "./settings";
|
||||
import * as constants from "../../appconst";
|
||||
|
||||
@ -22,6 +23,7 @@ const modalsRegistry: { [key: string]: () => React.ReactElement } = {
|
||||
[constants.SESSION_SETTINGS]: () => <SessionSettingsModal />,
|
||||
[constants.LINE_SETTINGS]: () => <LineSettingsModal />,
|
||||
[constants.CLIENT_SETTINGS]: () => <ClientSettingsModal />,
|
||||
[constants.TAB_SWITCHER]: () => <TabSwitcherModal />,
|
||||
};
|
||||
|
||||
export { modalsRegistry };
|
@ -17,7 +17,16 @@ import {
|
||||
Screen,
|
||||
Session,
|
||||
} from "../../../model/model";
|
||||
import { Toggle, InlineSettingsTextEdit, SettingsError, InfoMessage, Modal, Dropdown, Tooltip } from "../common";
|
||||
import {
|
||||
Toggle,
|
||||
InlineSettingsTextEdit,
|
||||
SettingsError,
|
||||
InfoMessage,
|
||||
Modal,
|
||||
Dropdown,
|
||||
Tooltip,
|
||||
Button,
|
||||
} from "../common";
|
||||
import { LineType, RendererPluginType, ClientDataType, CommandRtnType, RemoteType } from "../../../types/types";
|
||||
import { PluginModel } from "../../../plugins/plugins";
|
||||
import * as util from "../../../util/util";
|
||||
@ -219,7 +228,7 @@ class ScreenSettingsModal extends React.Component<{}, {}> {
|
||||
return;
|
||||
}
|
||||
if (this.screen.getScreenLines().lines.length == 0) {
|
||||
GlobalCommandRunner.screenDelete(this.screenId);
|
||||
GlobalCommandRunner.screenDelete(this.screenId, false);
|
||||
GlobalModel.modalsModel.popModal();
|
||||
return;
|
||||
}
|
||||
@ -229,7 +238,7 @@ class ScreenSettingsModal extends React.Component<{}, {}> {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
let prtn = GlobalCommandRunner.screenDelete(this.screenId);
|
||||
let prtn = GlobalCommandRunner.screenDelete(this.screenId, false);
|
||||
commandRtnHandler(prtn, this.errorMessage);
|
||||
GlobalModel.modalsModel.popModal();
|
||||
});
|
||||
@ -632,12 +641,6 @@ class LineSettingsModal extends React.Component<{}, {}> {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<div className="settings-label">Archived</div>
|
||||
<div className="settings-input">
|
||||
<Toggle checked={!!line.archived} onChange={this.handleChangeArchived} />
|
||||
</div>
|
||||
</div>
|
||||
<SettingsError errorMessage={this.errorMessage} />
|
||||
<div style={{ height: 50 }} />
|
||||
</div>
|
||||
|
84
src/app/common/modals/tabswitcher.less
Normal file
84
src/app/common/modals/tabswitcher.less
Normal file
@ -0,0 +1,84 @@
|
||||
@import "../../../app/common/themes/themes.less";
|
||||
|
||||
.tabswitcher-modal {
|
||||
width: 452px;
|
||||
min-height: 384px;
|
||||
|
||||
.wave-modal-content {
|
||||
.wave-modal-body {
|
||||
display: flex;
|
||||
padding: 0px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
|
||||
.textfield-wrapper {
|
||||
padding: 20px 20px 0px;
|
||||
|
||||
.wave-input-decoration.start-position {
|
||||
height: 100%;
|
||||
|
||||
.tabswitcher-search-prefix {
|
||||
opacity: 0.5;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-container {
|
||||
overflow: hidden;
|
||||
padding: 10px 0 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list-container-inner {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
overflow-y: scroll;
|
||||
padding: 0 16px 0 20px;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.options-list {
|
||||
width: 100%;
|
||||
|
||||
.search-option {
|
||||
padding: 5px 5px 5px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
div.tabname {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
div.icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.focused-option {
|
||||
border: 1px solid rgba(241, 246, 243, 0.15);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
324
src/app/common/modals/tabswitcher.tsx
Normal file
324
src/app/common/modals/tabswitcher.tsx
Normal file
@ -0,0 +1,324 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as React from "react";
|
||||
import * as mobxReact from "mobx-react";
|
||||
import * as mobx from "mobx";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import { For } from "tsx-control-statements/components";
|
||||
import cn from "classnames";
|
||||
import { GlobalModel, GlobalCommandRunner } from "../../../model/model";
|
||||
import { Modal, TextField, InputDecoration, Tooltip } from "../common";
|
||||
import * as util from "../../../util/util";
|
||||
import { Screen } from "../../../model/model";
|
||||
import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg";
|
||||
|
||||
import "./tabswitcher.less";
|
||||
|
||||
type OV<V> = mobx.IObservableValue<V>;
|
||||
type OArr<V> = mobx.IObservableArray<V>;
|
||||
|
||||
type SwitcherDataType = {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
sessionIdx: number;
|
||||
screenId: string;
|
||||
screenIdx: number;
|
||||
screenName: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const MaxOptionsToDisplay = 100;
|
||||
|
||||
@mobxReact.observer
|
||||
class TabSwitcherModal extends React.Component<{}, {}> {
|
||||
screens: Map<string, OV<string>>[];
|
||||
sessions: Map<string, OV<string>>[];
|
||||
options: SwitcherDataType[] = [];
|
||||
sOptions: OArr<SwitcherDataType> = mobx.observable.array(null, {
|
||||
name: "TabSwitcherModal-sOptions",
|
||||
});
|
||||
focusedIdx: OV<number> = mobx.observable.box(0, { name: "TabSwitcherModal-selectedIdx" });
|
||||
activeSessionIdx: number;
|
||||
optionRefs = [];
|
||||
listWrapperRef = React.createRef<HTMLDivElement>();
|
||||
prevFocusedIdx = 0;
|
||||
|
||||
componentDidMount() {
|
||||
this.activeSessionIdx = GlobalModel.getActiveSession().sessionIdx.get();
|
||||
let oSessions = GlobalModel.sessionList;
|
||||
let oScreens = GlobalModel.screenMap;
|
||||
oScreens.forEach((oScreen) => {
|
||||
// Find the matching session in the observable array
|
||||
let foundSession = oSessions.find((s) => {
|
||||
if (s.sessionId === oScreen.sessionId && s.archived.get() == false) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (foundSession) {
|
||||
let data: SwitcherDataType = {
|
||||
sessionName: foundSession.name.get(),
|
||||
sessionId: foundSession.sessionId,
|
||||
sessionIdx: foundSession.sessionIdx.get(),
|
||||
screenName: oScreen.name.get(),
|
||||
screenId: oScreen.screenId,
|
||||
screenIdx: oScreen.screenIdx.get(),
|
||||
icon: this.getTabIcon(oScreen),
|
||||
color: this.getTabColor(oScreen),
|
||||
};
|
||||
this.options.push(data);
|
||||
}
|
||||
});
|
||||
|
||||
mobx.action(() => {
|
||||
this.sOptions.replace(this.sortOptions(this.options).slice(0, MaxOptionsToDisplay));
|
||||
})();
|
||||
|
||||
document.addEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
let currFocusedIdx = this.focusedIdx.get();
|
||||
|
||||
// Check if selectedIdx has changed
|
||||
if (currFocusedIdx !== this.prevFocusedIdx) {
|
||||
let optionElement = this.optionRefs[currFocusedIdx]?.current;
|
||||
|
||||
if (optionElement) {
|
||||
optionElement.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
// Update prevFocusedIdx for the next update cycle
|
||||
this.prevFocusedIdx = currFocusedIdx;
|
||||
}
|
||||
if (currFocusedIdx >= this.sOptions.length && this.sOptions.length > 0) {
|
||||
this.setFocusedIndex(this.sOptions.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
getTabIcon(screen: Screen): string {
|
||||
let tabIcon = "default";
|
||||
let screenOpts = screen.opts.get();
|
||||
if (screenOpts != null && !util.isBlank(screenOpts.tabicon)) {
|
||||
tabIcon = screenOpts.tabicon;
|
||||
}
|
||||
return tabIcon;
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
getTabColor(screen: Screen): string {
|
||||
let tabColor = "default";
|
||||
let screenOpts = screen.opts.get();
|
||||
if (screenOpts != null && !util.isBlank(screenOpts.tabcolor)) {
|
||||
tabColor = screenOpts.tabcolor;
|
||||
}
|
||||
return tabColor;
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleKeyDown(e) {
|
||||
if (e.key === "Escape") {
|
||||
this.closeModal();
|
||||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
let newIndex = this.calculateNewIndex(e.key === "ArrowUp");
|
||||
this.setFocusedIndex(newIndex);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
this.handleSelect(this.focusedIdx.get());
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
calculateNewIndex(isUpKey) {
|
||||
let currentIndex = this.focusedIdx.get();
|
||||
if (isUpKey) {
|
||||
return Math.max(currentIndex - 1, 0);
|
||||
} else {
|
||||
return Math.min(currentIndex + 1, this.sOptions.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
setFocusedIndex(index) {
|
||||
mobx.action(() => {
|
||||
this.focusedIdx.set(index);
|
||||
})();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
closeModal(): void {
|
||||
GlobalModel.modalsModel.popModal();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleSelect(index: number): void {
|
||||
const selectedOption = this.sOptions[index];
|
||||
if (selectedOption) {
|
||||
GlobalCommandRunner.switchScreen(selectedOption.screenId, selectedOption.sessionId);
|
||||
this.closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleSearch(val: string): void {
|
||||
let sOptions: SwitcherDataType[];
|
||||
if (val == "") {
|
||||
sOptions = this.sortOptions(this.options).slice(0, MaxOptionsToDisplay);
|
||||
} else {
|
||||
sOptions = this.filterOptions(val);
|
||||
sOptions = this.sortOptions(sOptions);
|
||||
if (sOptions.length > MaxOptionsToDisplay) {
|
||||
sOptions = sOptions.slice(0, MaxOptionsToDisplay);
|
||||
}
|
||||
}
|
||||
mobx.action(() => {
|
||||
this.sOptions.replace(sOptions);
|
||||
this.focusedIdx.set(0);
|
||||
})();
|
||||
}
|
||||
|
||||
@mobx.computed
|
||||
@boundMethod
|
||||
filterOptions(searchInput: string): SwitcherDataType[] {
|
||||
let filteredScreens = [];
|
||||
|
||||
for (let i = 0; i < this.options.length; i++) {
|
||||
let tab = this.options[i];
|
||||
let match = false;
|
||||
|
||||
if (searchInput.includes("/")) {
|
||||
let [sessionFilter, screenFilter] = searchInput.split("/").map((s) => s.trim().toLowerCase());
|
||||
match =
|
||||
tab.sessionName.toLowerCase().includes(sessionFilter) &&
|
||||
tab.screenName.toLowerCase().includes(screenFilter);
|
||||
} else {
|
||||
match =
|
||||
tab.sessionName.toLowerCase().includes(searchInput) ||
|
||||
tab.screenName.toLowerCase().includes(searchInput);
|
||||
}
|
||||
|
||||
// Add tab to filtered list if it matches the criteria
|
||||
if (match) {
|
||||
filteredScreens.push(tab);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredScreens;
|
||||
}
|
||||
|
||||
@mobx.computed
|
||||
@boundMethod
|
||||
sortOptions(options: SwitcherDataType[]): SwitcherDataType[] {
|
||||
return options.sort((a, b) => {
|
||||
let aInCurrentSession = a.sessionIdx === this.activeSessionIdx;
|
||||
let bInCurrentSession = b.sessionIdx === this.activeSessionIdx;
|
||||
|
||||
// Tabs in the current session are sorted by screenIdx
|
||||
if (aInCurrentSession && bInCurrentSession) {
|
||||
return a.screenIdx - b.screenIdx;
|
||||
}
|
||||
// a is in the current session and b is not, so a comes first
|
||||
else if (aInCurrentSession) {
|
||||
return -1;
|
||||
}
|
||||
// b is in the current session and a is not, so b comes first
|
||||
else if (bInCurrentSession) {
|
||||
return 1;
|
||||
}
|
||||
// Both are in different, non-current sessions - sort by sessionIdx and then by screenIdx
|
||||
else {
|
||||
if (a.sessionIdx === b.sessionIdx) {
|
||||
return a.screenIdx - b.screenIdx;
|
||||
} else {
|
||||
return a.sessionIdx - b.sessionIdx;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
renderIcon(option: SwitcherDataType): React.ReactNode {
|
||||
let tabIcon = option.icon;
|
||||
if (tabIcon === "default" || tabIcon === "square") {
|
||||
return <SquareIcon className="left-icon" />;
|
||||
}
|
||||
return <i className={`fa-sharp fa-solid fa-${tabIcon}`}></i>;
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
renderOption(option: SwitcherDataType, index: number): JSX.Element {
|
||||
if (!this.optionRefs[index]) {
|
||||
this.optionRefs[index] = React.createRef();
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={option.sessionId + "/" + option.screenId}
|
||||
ref={this.optionRefs[index]}
|
||||
className={cn("search-option unselectable", {
|
||||
"focused-option": this.focusedIdx.get() === index,
|
||||
})}
|
||||
onClick={() => this.handleSelect(index)}
|
||||
>
|
||||
<div className={cn("icon", "color-" + option.color)}>{this.renderIcon(option)}</div>
|
||||
<div className="tabname">
|
||||
#{option.sessionName} / {option.screenName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
let option: SwitcherDataType;
|
||||
let index: number;
|
||||
return (
|
||||
<Modal className="tabswitcher-modal">
|
||||
<div className="wave-modal-body">
|
||||
<div className="textfield-wrapper">
|
||||
<TextField
|
||||
onChange={this.handleSearch}
|
||||
maxLength={400}
|
||||
autoFocus={true}
|
||||
decoration={{
|
||||
startDecoration: (
|
||||
<InputDecoration position="start">
|
||||
<div className="tabswitcher-search-prefix">Switch to Tab:</div>
|
||||
</InputDecoration>
|
||||
),
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<Tooltip
|
||||
message={`Type to filter workspaces and tabs.`}
|
||||
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-circle-question" />
|
||||
</Tooltip>
|
||||
</InputDecoration>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="list-container">
|
||||
<div ref={this.listWrapperRef} className="list-container-inner">
|
||||
<div className="options-list">
|
||||
<For each="option" index="index" of={this.sOptions}>
|
||||
{this.renderOption(option, index)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { TabSwitcherModal };
|
102
src/app/common/modals/tos.less
Normal file
102
src/app/common/modals/tos.less
Normal file
@ -0,0 +1,102 @@
|
||||
@import "../../../app/common/themes/themes.less";
|
||||
|
||||
.tos-modal {
|
||||
width: 640px;
|
||||
|
||||
.wave-modal-content .wave-modal-body {
|
||||
padding: 32px 48px;
|
||||
gap: 8px;
|
||||
|
||||
.wave-modal-body-inner {
|
||||
gap: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
header.tos-header {
|
||||
flex-direction: column;
|
||||
gap: var(--sizing-sm, 12px);
|
||||
border-bottom: none;
|
||||
padding: 0;
|
||||
|
||||
.modal-title {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
color: @term-white;
|
||||
text-align: center;
|
||||
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.content.tos-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 32px;
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.item-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
flex: 1 0 0;
|
||||
|
||||
.item-title {
|
||||
color: @term-bright-white;
|
||||
font-style: normal;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.item-text {
|
||||
color: @term-white;
|
||||
font-style: normal;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.item-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
.item-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
button {
|
||||
font-size: 12.5px !important;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
button.disabled-button {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
130
src/app/common/modals/tos.tsx
Normal file
130
src/app/common/modals/tos.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
// 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 { GlobalModel, GlobalCommandRunner } from "../../../model/model";
|
||||
import { Toggle, Modal, Button } from "../common";
|
||||
import * as util from "../../../util/util";
|
||||
import { ClientDataType } from "../../../types/types";
|
||||
|
||||
import shield from "../../assets/icons/shield_check.svg";
|
||||
import help from "../../assets/icons/help_filled.svg";
|
||||
import github from "../../assets/icons/github.svg";
|
||||
|
||||
import "./tos.less";
|
||||
|
||||
@mobxReact.observer
|
||||
class TosModal extends React.Component<{}, {}> {
|
||||
@boundMethod
|
||||
acceptTos(): void {
|
||||
GlobalCommandRunner.clientAcceptTos();
|
||||
GlobalModel.modalsModel.popModal();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeTelemetry(val: boolean): void {
|
||||
if (val) {
|
||||
GlobalCommandRunner.telemetryOn(false);
|
||||
} else {
|
||||
GlobalCommandRunner.telemetryOff(false);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let cdata: ClientDataType = GlobalModel.clientData.get();
|
||||
|
||||
return (
|
||||
<Modal className="tos-modal">
|
||||
<div className="wave-modal-body">
|
||||
<div className="wave-modal-body-inner">
|
||||
<header className="tos-header unselectable">
|
||||
<div className="modal-title">Welcome to Wave Terminal!</div>
|
||||
<div className="modal-subtitle">Lets set everything for you</div>
|
||||
</header>
|
||||
<div className="content tos-content unselectable">
|
||||
<div className="item">
|
||||
<img src={shield} alt="Privacy" />
|
||||
<div className="item-inner">
|
||||
<div className="item-title">Telemetry</div>
|
||||
<div className="item-text">
|
||||
We only collect minimal <i>anonymous</i> telemetry data to help us understand
|
||||
how many people are using Wave.
|
||||
</div>
|
||||
<div className="item-field" style={{ marginTop: 2 }}>
|
||||
<Toggle
|
||||
checked={!cdata.clientopts.notelemetry}
|
||||
onChange={this.handleChangeTelemetry}
|
||||
/>
|
||||
<div className="item-label">
|
||||
Telemetry {cdata.clientopts.notelemetry ? "Disabled" : "Enabled"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item">
|
||||
<a
|
||||
target="_blank"
|
||||
href={util.makeExternLink("https://discord.gg/XfvZ334gwU")}
|
||||
rel={"noopener"}
|
||||
>
|
||||
<img src={help} alt="Help" />
|
||||
</a>
|
||||
<div className="item-inner">
|
||||
<div className="item-title">Join our Community</div>
|
||||
<div className="item-text">
|
||||
Get help, submit feature requests, report bugs, or just chat with fellow
|
||||
terminal enthusiasts.
|
||||
<br />
|
||||
<a
|
||||
target="_blank"
|
||||
href={util.makeExternLink("https://discord.gg/XfvZ334gwU")}
|
||||
rel={"noopener"}
|
||||
>
|
||||
Join the Wave Discord Channel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item">
|
||||
<a
|
||||
target="_blank"
|
||||
href={util.makeExternLink("https://github.com/wavetermdev/waveterm")}
|
||||
rel={"noopener"}
|
||||
>
|
||||
<img src={github} alt="Github" />
|
||||
</a>
|
||||
<div className="item-inner">
|
||||
<div className="item-title">Support us on GitHub</div>
|
||||
<div className="item-text">
|
||||
We're <i>open source</i> and committed to providing a free terminal for
|
||||
individual users. Please show your support us by giving us a star on{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
href={util.makeExternLink("https://github.com/wavetermdev/waveterm")}
|
||||
rel={"noopener"}
|
||||
>
|
||||
Github (wavetermdev/waveterm)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="unselectable">
|
||||
<div className="item-text">
|
||||
By continuing, I accept the
|
||||
<a href="https://www.waveterm.dev/tos">Terms of Service</a>
|
||||
</div>
|
||||
<div className="button-wrapper">
|
||||
<Button onClick={this.acceptTos}>Continue</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { TosModal };
|
121
src/app/common/modals/viewremoteconndetail.less
Normal file
121
src/app/common/modals/viewremoteconndetail.less
Normal file
@ -0,0 +1,121 @@
|
||||
@import "../../../app/common/themes/themes.less";
|
||||
|
||||
.rconndetail-modal {
|
||||
width: 631px;
|
||||
min-height: 565px;
|
||||
|
||||
.wave-modal-content {
|
||||
display: flex;
|
||||
padding-bottom: 0px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.wave-modal-body {
|
||||
display: flex;
|
||||
padding: 0px 20px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-self: stretch;
|
||||
|
||||
.name-header-actions-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
.name-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.rconndetail-name {
|
||||
color: @term-bright-white;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
|
||||
.wave-button {
|
||||
padding: 4px 15px;
|
||||
font-size: 11px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remote-detail {
|
||||
.settings-field {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.settings-label {
|
||||
font-weight: bold;
|
||||
width: 12em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
color: @term-white;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-field:not(:first-child) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
height: 30px;
|
||||
padding: 3px 8px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
border-radius: 6px;
|
||||
background: rgba(241, 246, 243, 0.08);
|
||||
}
|
||||
|
||||
.terminal-wrapper {
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
|
||||
.terminal-connectelem {
|
||||
height: 163px !important; // Needed to override plugin height
|
||||
|
||||
.xterm-viewport {
|
||||
display: flex;
|
||||
padding: 6px 10px;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--element-separator, rgba(241, 246, 243, 0.15));
|
||||
background: #080a08;
|
||||
height: 163px !important; // Needed to override plugin height
|
||||
}
|
||||
|
||||
.xterm-screen {
|
||||
padding: 10px;
|
||||
width: 541px !important; // Needed to override plugin width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
421
src/app/common/modals/viewremoteconndetail.tsx
Normal file
421
src/app/common/modals/viewremoteconndetail.tsx
Normal file
@ -0,0 +1,421 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as React from "react";
|
||||
import * as mobxReact from "mobx-react";
|
||||
import * as mobx from "mobx";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import { If, 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 * as util from "../../../util/util";
|
||||
import * as textmeasure from "../../../util/textmeasure";
|
||||
|
||||
import "./viewremoteconndetail.less";
|
||||
|
||||
const RemotePtyRows = 9;
|
||||
const RemotePtyCols = 80;
|
||||
|
||||
@mobxReact.observer
|
||||
class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
|
||||
termRef: React.RefObject<any> = React.createRef();
|
||||
model: RemotesModel;
|
||||
|
||||
constructor(props: { remotesModel?: RemotesModel }) {
|
||||
super(props);
|
||||
this.model = GlobalModel.remotesModel;
|
||||
}
|
||||
|
||||
@mobx.computed
|
||||
getSelectedRemote(): T.RemoteType {
|
||||
const selectedRemoteId = this.model.selectedRemoteId.get();
|
||||
return GlobalModel.getRemote(selectedRemoteId);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
let elem = this.termRef.current;
|
||||
if (elem == null) {
|
||||
console.log("ERROR null term-remote element");
|
||||
return;
|
||||
}
|
||||
this.model.createTermWrap(elem);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.getSelectedRemote() == null || this.getSelectedRemote().archived) {
|
||||
this.model.deSelectRemote();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.model.disposeTerm();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
clickTermBlock(): void {
|
||||
if (this.model.remoteTermWrap != null) {
|
||||
this.model.remoteTermWrap.giveFocus();
|
||||
}
|
||||
}
|
||||
|
||||
getRemoteTypeStr(remote: T.RemoteType): string {
|
||||
if (!util.isBlank(remote.uname)) {
|
||||
let unameStr = remote.uname;
|
||||
unameStr = unameStr.replace("|", ", ");
|
||||
return remote.remotetype + " (" + unameStr + ")";
|
||||
}
|
||||
return remote.remotetype;
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
connectRemote(remoteId: string) {
|
||||
GlobalCommandRunner.connectRemote(remoteId);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
disconnectRemote(remoteId: string) {
|
||||
GlobalCommandRunner.disconnectRemote(remoteId);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
installRemote(remoteId: string) {
|
||||
GlobalCommandRunner.installRemote(remoteId);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
cancelInstall(remoteId: string) {
|
||||
GlobalCommandRunner.installCancelRemote(remoteId);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
openEditModal(): void {
|
||||
GlobalModel.remotesModel.startEditAuth();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
getStatus(status: string) {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return "green";
|
||||
case "disconnected":
|
||||
return "gray";
|
||||
default:
|
||||
return "red";
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
clickArchive(): void {
|
||||
if (this.getSelectedRemote() && this.getSelectedRemote().status == "connected") {
|
||||
GlobalModel.showAlert({ message: "Cannot delete when connected. Disconnect and try again." });
|
||||
return;
|
||||
}
|
||||
let prtn = GlobalModel.showAlert({
|
||||
message: "Are you sure you want to delete this connection?",
|
||||
confirm: true,
|
||||
});
|
||||
prtn.then((confirm) => {
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
if (this.getSelectedRemote()) {
|
||||
GlobalCommandRunner.archiveRemote(this.getSelectedRemote().remoteid);
|
||||
}
|
||||
GlobalModel.modalsModel.popModal();
|
||||
});
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
clickReinstall(): void {
|
||||
GlobalCommandRunner.installRemote(this.getSelectedRemote().remoteid);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleClose(): void {
|
||||
this.model.closeModal();
|
||||
this.model.setRecentConnAdded(false);
|
||||
}
|
||||
|
||||
renderInstallStatus(remote: T.RemoteType): any {
|
||||
let statusStr: string = null;
|
||||
if (remote.installstatus == "disconnected") {
|
||||
if (remote.needsmshellupgrade) {
|
||||
statusStr = "mshell " + remote.mshellversion + " - needs upgrade";
|
||||
} else if (util.isBlank(remote.mshellversion)) {
|
||||
statusStr = "mshell unknown";
|
||||
} else {
|
||||
statusStr = "mshell " + remote.mshellversion + " - current";
|
||||
}
|
||||
} else {
|
||||
statusStr = remote.installstatus;
|
||||
}
|
||||
if (statusStr == null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key="install-status" className="settings-field">
|
||||
<div className="settings-label"> Install Status</div>
|
||||
<div className="settings-input">{statusStr}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderHeaderBtns(remote: T.RemoteType): React.ReactNode {
|
||||
let buttons: React.ReactNode[] = [];
|
||||
const disconnectButton = (
|
||||
<Button theme="secondary" onClick={() => this.disconnectRemote(remote.remoteid)}>
|
||||
Disconnect Now
|
||||
</Button>
|
||||
);
|
||||
const connectButton = (
|
||||
<Button theme="secondary" onClick={() => this.connectRemote(remote.remoteid)}>
|
||||
Connect Now
|
||||
</Button>
|
||||
);
|
||||
const tryReconnectButton = (
|
||||
<Button theme="secondary" onClick={() => this.connectRemote(remote.remoteid)}>
|
||||
Try Reconnect
|
||||
</Button>
|
||||
);
|
||||
let updateAuthButton = (
|
||||
<Button theme="secondary" onClick={() => this.openEditModal()}>
|
||||
Edit
|
||||
</Button>
|
||||
);
|
||||
let cancelInstallButton = (
|
||||
<Button theme="secondary" onClick={() => this.cancelInstall(remote.remoteid)}>
|
||||
Cancel Install
|
||||
</Button>
|
||||
);
|
||||
let installNowButton = (
|
||||
<Button theme="secondary" onClick={() => this.installRemote(remote.remoteid)}>
|
||||
Install Now
|
||||
</Button>
|
||||
);
|
||||
let archiveButton = (
|
||||
<Button theme="secondary" onClick={() => this.clickArchive()}>
|
||||
Delete
|
||||
</Button>
|
||||
);
|
||||
const reinstallButton = (
|
||||
<Button theme="secondary" onClick={this.clickReinstall}>
|
||||
Reinstall
|
||||
</Button>
|
||||
);
|
||||
if (remote.local) {
|
||||
installNowButton = <></>;
|
||||
updateAuthButton = <></>;
|
||||
cancelInstallButton = <></>;
|
||||
}
|
||||
if (remote.sshconfigsrc == "sshconfig-import") {
|
||||
updateAuthButton = (
|
||||
<Button theme="secondary" disabled={true}>
|
||||
Edit
|
||||
<Tooltip
|
||||
message={`Connections imported from an ssh config file cannot be edited inside waveterm. To edit these, you must edit the config file and import it again.`}
|
||||
icon={<i className="fa-sharp fa-regular fa-fw fa-ban" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-fw fa-ban" />
|
||||
</Tooltip>
|
||||
</Button>
|
||||
);
|
||||
archiveButton = (
|
||||
<Button theme="secondary" onClick={() => this.clickArchive()}>
|
||||
Delete
|
||||
<Tooltip
|
||||
message={
|
||||
<span>
|
||||
Connections imported from an ssh config file can be deleted, but will come back upon
|
||||
importing again. They will stay removed if you follow{" "}
|
||||
<a href="https://docs.waveterm.dev/features/sshconfig-imports">this procedure</a>.
|
||||
</span>
|
||||
}
|
||||
icon={<i className="fa-sharp fa-regular fa-fw fa-triangle-exclamation" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-fw fa-triangle-exclamation" />
|
||||
</Tooltip>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (remote.status == "connected" || remote.status == "connecting") {
|
||||
buttons.push(disconnectButton);
|
||||
} else if (remote.status == "disconnected") {
|
||||
buttons.push(connectButton);
|
||||
} else if (remote.status == "error") {
|
||||
if (remote.needsmshellupgrade) {
|
||||
if (remote.installstatus == "connecting") {
|
||||
buttons.push(cancelInstallButton);
|
||||
} else {
|
||||
buttons.push(installNowButton);
|
||||
}
|
||||
} else {
|
||||
buttons.push(tryReconnectButton);
|
||||
}
|
||||
}
|
||||
buttons.push(reinstallButton);
|
||||
buttons.push(updateAuthButton);
|
||||
buttons.push(archiveButton);
|
||||
|
||||
let i = 0;
|
||||
let button: React.ReactNode = null;
|
||||
|
||||
return (
|
||||
<For each="button" of={buttons} index="i">
|
||||
<div key={i}>{button}</div>
|
||||
</For>
|
||||
);
|
||||
}
|
||||
|
||||
getMessage(remote: T.RemoteType): string {
|
||||
let message = "";
|
||||
if (remote.status == "connected") {
|
||||
message = "Connected and ready to run commands.";
|
||||
} else if (remote.status == "connecting") {
|
||||
message = remote.waitingforpassword ? "Connecting, waiting for user-input..." : "Connecting...";
|
||||
let connectTimeout = remote.connecttimeout ?? 0;
|
||||
message = message + " (" + connectTimeout + "s)";
|
||||
} else if (remote.status == "disconnected") {
|
||||
message = "Disconnected";
|
||||
} else if (remote.status == "error") {
|
||||
if (remote.noinitpk) {
|
||||
message = "Error, could not connect.";
|
||||
} else if (remote.needsmshellupgrade) {
|
||||
if (remote.installstatus == "connecting") {
|
||||
message = "Installing...";
|
||||
} else {
|
||||
message = "Error, needs install.";
|
||||
}
|
||||
} else {
|
||||
message = "Error";
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
render() {
|
||||
let remote = this.getSelectedRemote();
|
||||
|
||||
if (remote == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let model = this.model;
|
||||
let isTermFocused = this.model.remoteTermWrapFocus.get();
|
||||
let termFontSize = GlobalModel.termFontSize.get();
|
||||
let termWidth = textmeasure.termWidthFromCols(RemotePtyCols, termFontSize);
|
||||
let remoteAliasText = util.isBlank(remote.remotealias) ? "(none)" : remote.remotealias;
|
||||
let selectedRemoteStatus = this.getSelectedRemote().status;
|
||||
|
||||
return (
|
||||
<Modal className="rconndetail-modal">
|
||||
<Modal.Header title="Connection" onClose={this.handleClose} />
|
||||
<div className="wave-modal-body">
|
||||
<div className="name-header-actions-wrapper">
|
||||
<div className="name text-primary name-wrapper">
|
||||
{util.getRemoteName(remote)} {getImportTooltip(remote)}
|
||||
</div>
|
||||
<div className="header-actions">{this.renderHeaderBtns(remote)}</div>
|
||||
</div>
|
||||
<div className="remote-detail" style={{ overflow: "hidden" }}>
|
||||
<div className="settings-field">
|
||||
<div className="settings-label">Conn Id</div>
|
||||
<div className="settings-input">{remote.remoteid}</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<div className="settings-label">Type</div>
|
||||
<div className="settings-input">{this.getRemoteTypeStr(remote)}</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<div className="settings-label">Canonical Name</div>
|
||||
<div className="settings-input">
|
||||
{remote.remotecanonicalname}
|
||||
<If condition={!util.isBlank(remote.remotevars.port) && remote.remotevars.port != "22"}>
|
||||
<span style={{ marginLeft: 5 }}>(port {remote.remotevars.port})</span>
|
||||
</If>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field" style={{ minHeight: 24 }}>
|
||||
<div className="settings-label">Alias</div>
|
||||
<div className="settings-input">{remoteAliasText}</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<div className="settings-label">Auth Type</div>
|
||||
<div className="settings-input">
|
||||
<If condition={!remote.local}>{remote.authtype}</If>
|
||||
<If condition={remote.local}>local</If>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<div className="settings-label">Connect Mode</div>
|
||||
<div className="settings-input">{remote.connectmode}</div>
|
||||
</div>
|
||||
{this.renderInstallStatus(remote)}
|
||||
<div className="flex-spacer" style={{ minHeight: 20 }} />
|
||||
<div className="status">
|
||||
<Status status={this.getStatus(remote.status)} text={this.getMessage(remote)} />
|
||||
</div>
|
||||
<div
|
||||
key="term"
|
||||
className={cn(
|
||||
"terminal-wrapper",
|
||||
{ focus: isTermFocused },
|
||||
remote != null ? "status-" + remote.status : null
|
||||
)}
|
||||
>
|
||||
<If condition={!isTermFocused}>
|
||||
<div key="termblock" className="term-block" onClick={this.clickTermBlock}></div>
|
||||
</If>
|
||||
<If condition={model.showNoInputMsg.get()}>
|
||||
<div key="termtag" className="term-tag">
|
||||
input is only allowed while status is 'connecting'
|
||||
</div>
|
||||
</If>
|
||||
<div
|
||||
key="terminal"
|
||||
className="terminal-connectelem"
|
||||
ref={this.termRef}
|
||||
data-remoteid={remote.remoteid}
|
||||
style={{
|
||||
height: textmeasure.termHeightFromRows(RemotePtyRows, termFontSize),
|
||||
width: termWidth,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wave-modal-footer">
|
||||
<Button
|
||||
theme="secondary"
|
||||
disabled={selectedRemoteStatus == "connecting"}
|
||||
onClick={this.handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={selectedRemoteStatus == "connecting"} onClick={this.handleClose}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getImportTooltip(remote: T.RemoteType): React.ReactElement<any, any> {
|
||||
if (remote.sshconfigsrc == "sshconfig-import") {
|
||||
return (
|
||||
<Tooltip
|
||||
message={`This remote was imported from an SSH config file.`}
|
||||
icon={<i className="fa-sharp fa-solid fa-file-import" />}
|
||||
>
|
||||
<i className="fa-sharp fa-solid fa-file-import" />
|
||||
</Tooltip>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
|
||||
export { ViewRemoteConnDetailModal };
|
@ -11,6 +11,7 @@ import { GlobalModel, RemotesModel, GlobalCommandRunner } from "../../model/mode
|
||||
import { Button, IconButton, Status } from "../common/common";
|
||||
import * as T from "../../types/types";
|
||||
import * as util from "../../util/util";
|
||||
import * as appconst from "../appconst";
|
||||
|
||||
import "./connections.less";
|
||||
|
||||
@ -74,10 +75,31 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleImportSshConfig(): void {
|
||||
importSshConfig(): void {
|
||||
GlobalCommandRunner.importSshConfig();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleImportSshConfig(): void {
|
||||
this.showShellPrompt(this.importSshConfig);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
showShellPrompt(cb: () => void): void {
|
||||
let prtn = GlobalModel.showAlert({
|
||||
message:
|
||||
"You are about to install WaveShell on a remote machine. Please be aware that WaveShell will be executed on the remote system.",
|
||||
confirm: true,
|
||||
confirmflag: appconst.ConfirmKey_HideShellPrompt,
|
||||
});
|
||||
prtn.then((confirm) => {
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
cb();
|
||||
});
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleRead(remoteId: string): void {
|
||||
GlobalModel.remotesModel.openReadModal(remoteId);
|
||||
|
@ -354,6 +354,12 @@ class LineCmd extends React.Component<
|
||||
GlobalCommandRunner.lineBookmark(line.lineid);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
clickDelete() {
|
||||
let { line } = this.props;
|
||||
GlobalCommandRunner.lineDelete(line.lineid, true);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
clickMinimize() {
|
||||
mobx.action(() => {
|
||||
@ -659,6 +665,9 @@ class LineCmd extends React.Component<
|
||||
{this.renderMeta1(cmd)}
|
||||
<If condition={!hidePrompt}>{this.renderCmdText(cmd)}</If>
|
||||
</div>
|
||||
<div key="delete" title="Delete Line (⌘D)" className="line-icon" onClick={this.clickDelete}>
|
||||
<i className="fa-sharp fa-regular fa-trash" />
|
||||
</div>
|
||||
<div
|
||||
key="bookmark"
|
||||
title="Bookmark"
|
||||
|
219
src/app/workspace/cmdinput/aichat.tsx
Normal file
219
src/app/workspace/cmdinput/aichat.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
// 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 { GlobalModel } from "../../../model/model";
|
||||
import { isBlank } from "../../../util/util";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import cn from "classnames";
|
||||
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";
|
||||
|
||||
@mobxReact.observer
|
||||
class AIChat extends React.Component<{}, {}> {
|
||||
chatListKeyCount: number = 0;
|
||||
textAreaNumLines: mobx.IObservableValue<number> = mobx.observable.box(1, { name: "textAreaNumLines" });
|
||||
chatWindowScrollRef: React.RefObject<HTMLDivElement>;
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.chatWindowScrollRef = React.createRef();
|
||||
this.textAreaRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
let model = GlobalModel;
|
||||
let inputModel = model.inputModel;
|
||||
if (this.chatWindowScrollRef != null && this.chatWindowScrollRef.current != null) {
|
||||
this.chatWindowScrollRef.current.scrollTop = this.chatWindowScrollRef.current.scrollHeight;
|
||||
}
|
||||
if (this.textAreaRef.current != null) {
|
||||
this.textAreaRef.current.focus();
|
||||
inputModel.setCmdInfoChatRefs(this.textAreaRef, this.chatWindowScrollRef);
|
||||
}
|
||||
this.requestChatUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.chatWindowScrollRef != null && this.chatWindowScrollRef.current != null) {
|
||||
this.chatWindowScrollRef.current.scrollTop = this.chatWindowScrollRef.current.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
requestChatUpdate() {
|
||||
this.submitChatMessage("");
|
||||
}
|
||||
|
||||
submitChatMessage(messageStr: string) {
|
||||
let model = GlobalModel;
|
||||
let inputModel = model.inputModel;
|
||||
let curLine = inputModel.getCurLine();
|
||||
let prtn = GlobalModel.submitChatInfoCommand(messageStr, curLine, false);
|
||||
prtn.then((rtn) => {
|
||||
if (!rtn.success) {
|
||||
console.log("submit chat command error: " + rtn.error);
|
||||
}
|
||||
}).catch((error) => {});
|
||||
}
|
||||
|
||||
getLinePos(elem: any): { numLines: number; linePos: number } {
|
||||
let numLines = elem.value.split("\n").length;
|
||||
let linePos = elem.value.substr(0, elem.selectionStart).split("\n").length;
|
||||
return { numLines, linePos };
|
||||
}
|
||||
|
||||
@mobx.action
|
||||
@boundMethod
|
||||
onKeyDown(e: any) {
|
||||
mobx.action(() => {
|
||||
let model = GlobalModel;
|
||||
let inputModel = model.inputModel;
|
||||
let ctrlMod = e.getModifierState("Control") || e.getModifierState("Meta") || e.getModifierState("Shift");
|
||||
let resetCodeSelect = !ctrlMod;
|
||||
|
||||
if (e.code == "Enter") {
|
||||
e.preventDefault();
|
||||
if (!ctrlMod) {
|
||||
if (inputModel.getCodeSelectSelectedIndex() == -1) {
|
||||
let messageStr = e.target.value;
|
||||
this.submitChatMessage(messageStr);
|
||||
e.target.value = "";
|
||||
} else {
|
||||
inputModel.grabCodeSelectSelection();
|
||||
}
|
||||
} else {
|
||||
e.target.setRangeText("\n", e.target.selectionStart, e.target.selectionEnd, "end");
|
||||
}
|
||||
}
|
||||
if (e.code == "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
inputModel.closeAIAssistantChat();
|
||||
}
|
||||
if (e.code == "KeyL" && e.getModifierState("Control")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
inputModel.clearAIAssistantChat();
|
||||
}
|
||||
if (e.code == "ArrowUp") {
|
||||
if (this.getLinePos(e.target).linePos > 1) {
|
||||
// normal up arrow
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
inputModel.codeSelectSelectNextOldestCodeBlock();
|
||||
resetCodeSelect = false;
|
||||
}
|
||||
if (e.code == "ArrowDown") {
|
||||
if (inputModel.getCodeSelectSelectedIndex() == inputModel.codeSelectBottom) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
inputModel.codeSelectSelectNextNewestCodeBlock();
|
||||
resetCodeSelect = false;
|
||||
}
|
||||
|
||||
if (resetCodeSelect) {
|
||||
inputModel.codeSelectDeselectAll();
|
||||
}
|
||||
|
||||
// set height of textarea based on number of newlines
|
||||
this.textAreaNumLines.set(e.target.value.split(/\n/).length);
|
||||
})();
|
||||
}
|
||||
|
||||
renderError(err: string): any {
|
||||
return <div className="chat-msg-error">{err}</div>;
|
||||
}
|
||||
|
||||
renderChatMessage(chatItem: OpenAICmdInfoChatMessageType): any {
|
||||
let curKey = "chatmsg-" + this.chatListKeyCount;
|
||||
this.chatListKeyCount++;
|
||||
let senderClassName = chatItem.isassistantresponse ? "chat-msg-assistant" : "chat-msg-user";
|
||||
let msgClassName = "chat-msg " + senderClassName;
|
||||
let innerHTML: React.JSX.Element = (
|
||||
<span>
|
||||
<span style={{ display: "flex" }}>
|
||||
<i className="fa-sharp fa-solid fa-user" style={{ marginRight: "5px", marginTop: "1px" }}></i>
|
||||
<p style={{ marginRight: "5px" }}>You</p>
|
||||
</span>
|
||||
<p className="msg-text">{chatItem.userquery}</p>
|
||||
</span>
|
||||
);
|
||||
if (chatItem.isassistantresponse) {
|
||||
if (chatItem.assistantresponse.error != null && chatItem.assistantresponse.error != "") {
|
||||
innerHTML = this.renderError(chatItem.assistantresponse.error);
|
||||
} else {
|
||||
innerHTML = (
|
||||
<span>
|
||||
<span style={{ display: "flex" }}>
|
||||
<i
|
||||
className="fa-sharp fa-solid fa-headset"
|
||||
style={{ marginRight: "5px", marginTop: "1px" }}
|
||||
></i>
|
||||
<p style={{ marginRight: "5px" }}>ChatGPT</p>
|
||||
</span>
|
||||
<Markdown text={chatItem.assistantresponse.message} codeSelect />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={msgClassName} key={curKey}>
|
||||
{innerHTML}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderChatWindow(): any {
|
||||
let model = GlobalModel;
|
||||
let inputModel = model.inputModel;
|
||||
let chatMessageItems = inputModel.AICmdInfoChatItems.slice();
|
||||
let chitem: OpenAICmdInfoChatMessageType = null;
|
||||
return (
|
||||
<div className="chat-window" ref={this.chatWindowScrollRef}>
|
||||
<For each="chitem" index="idx" of={chatMessageItems}>
|
||||
{this.renderChatMessage(chitem)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
let model = GlobalModel;
|
||||
let inputModel = model.inputModel;
|
||||
|
||||
const termFontSize = 14;
|
||||
const textAreaMaxLines = 4;
|
||||
const textAreaLineHeight = termFontSize * 1.5;
|
||||
const textAreaPadding = 2 * 0.5 * termFontSize;
|
||||
let textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines + textAreaPadding;
|
||||
let textAreaInnerHeight = this.textAreaNumLines.get() * textAreaLineHeight + textAreaPadding;
|
||||
|
||||
return (
|
||||
<div className="cmd-aichat">
|
||||
{this.renderChatWindow()}
|
||||
<textarea
|
||||
key="main"
|
||||
ref={this.textAreaRef}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
id="chat-cmd-input"
|
||||
onKeyDown={this.onKeyDown}
|
||||
style={{ height: textAreaInnerHeight, maxHeight: textAreaMaxHeight, fontSize: termFontSize }}
|
||||
className={cn("chat-textarea")}
|
||||
placeholder="Send a Message to ChatGPT..."
|
||||
></textarea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { AIChat };
|
@ -42,6 +42,10 @@
|
||||
max-height: max(300px, 70%);
|
||||
}
|
||||
|
||||
&.has-aichat {
|
||||
max-height: max(300px, 70%);
|
||||
}
|
||||
|
||||
.remote-status-warning {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -197,6 +201,72 @@
|
||||
}
|
||||
}
|
||||
|
||||
.cmd-aichat {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-flow: column nowrap;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.chat-window {
|
||||
overflow-y: auto;
|
||||
margin-bottom: 5px;
|
||||
flex-shrink: 1;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.chat-textarea {
|
||||
color: @term-bright-white;
|
||||
background-color: @textarea-background;
|
||||
padding: 0.5em;
|
||||
resize: none;
|
||||
overflow: auto;
|
||||
overflow-wrap: anywhere;
|
||||
border-color: transparent;
|
||||
border: none;
|
||||
font-family: @terminal-font;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 1;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
margin-top:5px;
|
||||
margin-bottom:5px;
|
||||
}
|
||||
|
||||
.chat-msg-assistant {
|
||||
color: @term-white;
|
||||
}
|
||||
|
||||
.chat-msg-user {
|
||||
|
||||
.msg-text {
|
||||
font-family: @markdown-font;
|
||||
font-size: 14px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-msg-error {
|
||||
color: @term-bright-red;
|
||||
font-family: @markdown-font;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.grow-spacer {
|
||||
flex: 1 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.cmd-history {
|
||||
color: @term-white;
|
||||
margin-bottom: 5px;
|
||||
|
@ -19,6 +19,7 @@ import { Prompt } from "../../common/prompt/prompt";
|
||||
import { ReactComponent as ExecIcon } from "../../assets/icons/exec.svg";
|
||||
import { ReactComponent as RotateIcon } from "../../assets/icons/line/rotate.svg";
|
||||
import "./cmdinput.less";
|
||||
import { AIChat } from "./aichat";
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
@ -116,6 +117,7 @@ class CmdInput extends React.Component<{}, {}> {
|
||||
}
|
||||
let infoShow = inputModel.infoShow.get();
|
||||
let historyShow = !infoShow && inputModel.historyShow.get();
|
||||
let aiChatShow = inputModel.aIChatShow.get();
|
||||
let infoMsg = inputModel.infoMsg.get();
|
||||
let hasInfo = infoMsg != null;
|
||||
let focusVal = inputModel.physicalInputFocused.get();
|
||||
@ -127,11 +129,23 @@ class CmdInput extends React.Component<{}, {}> {
|
||||
numRunningLines = mobx.computed(() => win.getRunningCmdLines().length).get();
|
||||
}
|
||||
return (
|
||||
<div ref={this.cmdInputRef} className={cn("cmd-input", { "has-info": infoShow }, { active: focusVal })}>
|
||||
<div
|
||||
ref={this.cmdInputRef}
|
||||
className={cn(
|
||||
"cmd-input",
|
||||
{ "has-info": infoShow },
|
||||
{ "has-aichat": aiChatShow },
|
||||
{ active: focusVal }
|
||||
)}
|
||||
>
|
||||
<If condition={historyShow}>
|
||||
<div className="cmd-input-grow-spacer"></div>
|
||||
<HistoryInfo />
|
||||
</If>
|
||||
<If condition={aiChatShow}>
|
||||
<div className="cmd-input-grow-spacer"></div>
|
||||
<AIChat />
|
||||
</If>
|
||||
<InfoMsg key="infomsg" />
|
||||
<If condition={remote && remote.status != "connected"}>
|
||||
<div className="remote-status-warning">
|
||||
|
@ -230,6 +230,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
if (inputModel.inputMode.get() != null) {
|
||||
inputModel.resetInputMode();
|
||||
}
|
||||
inputModel.closeAIAssistantChat();
|
||||
return;
|
||||
}
|
||||
if (e.code == "KeyE" && e.getModifierState("Meta")) {
|
||||
@ -313,6 +314,10 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
scrollDiv(div, e.code == "PageUp" ? -amt : amt);
|
||||
}
|
||||
}
|
||||
if (e.code == "Space" && e.getModifierState("Control")) {
|
||||
e.preventDefault();
|
||||
inputModel.openAIAssistantChat();
|
||||
}
|
||||
// console.log(e.code, e.keyCode, e.key, event.which, ctrlMod, e);
|
||||
})();
|
||||
}
|
||||
|
@ -244,11 +244,15 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
overflow-x: hidden;
|
||||
overflow-x: scroll;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
overflow-x: overlay;
|
||||
&::-webkit-scrollbar-thumb,
|
||||
&::-webkit-scrollbar-track {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.screen-tab {
|
||||
@ -321,6 +325,7 @@
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 37px;
|
||||
|
||||
.icon {
|
||||
height: 2rem;
|
||||
|
@ -140,7 +140,7 @@ function getWaveSrvCmd() {
|
||||
let waveSrvPath = getWaveSrvPath();
|
||||
let waveHome = getWaveHomeDir();
|
||||
let logFile = path.join(waveHome, "wavesrv.log");
|
||||
return `${waveSrvPath} >> "${logFile}" 2>&1`;
|
||||
return `"${waveSrvPath}" >> "${logFile}" 2>&1`;
|
||||
}
|
||||
|
||||
function getWaveSrvCwd() {
|
||||
@ -173,10 +173,10 @@ let menuTemplate = [
|
||||
role: "appMenu",
|
||||
submenu: [
|
||||
{
|
||||
label: 'About Wave Terminal',
|
||||
label: "About Wave Terminal",
|
||||
click: () => {
|
||||
MainWindow?.webContents.send('menu-item-about');
|
||||
}
|
||||
MainWindow?.webContents.send("menu-item-about");
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "services" },
|
||||
@ -250,7 +250,7 @@ function createMainWindow(clientData) {
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
transparent: true,
|
||||
icon: (unamePlatform == "linux") ? "public/logos/wave-logo-dark.png" : undefined,
|
||||
icon: unamePlatform == "linux" ? "public/logos/wave-logo-dark.png" : undefined,
|
||||
webPreferences: {
|
||||
preload: path.join(getAppBasePath(), DistDir, "preload.js"),
|
||||
},
|
||||
@ -302,6 +302,11 @@ function createMainWindow(clientData) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (input.code == "KeyP" && input.meta) {
|
||||
win.webContents.send("p-cmd", mods);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (input.meta && (input.code == "ArrowUp" || input.code == "ArrowDown")) {
|
||||
if (input.code == "ArrowUp") {
|
||||
win.webContents.send("meta-arrowup");
|
||||
@ -480,6 +485,41 @@ electron.ipcMain.on("reload-window", (event) => {
|
||||
return;
|
||||
});
|
||||
|
||||
electron.ipcMain.on("open-external-link", async (_, url) => {
|
||||
try {
|
||||
await electron.shell.openExternal(url);
|
||||
} catch (err) {
|
||||
console.warn("error opening external link", err);
|
||||
}
|
||||
});
|
||||
|
||||
electron.ipcMain.on("get-last-logs", async (event, numberOfLines) => {
|
||||
try {
|
||||
const logPath = path.join(getWaveHomeDir(), "wavesrv.log");
|
||||
const lastLines = await readLastLinesOfFile(logPath, numberOfLines);
|
||||
event.reply("last-logs", lastLines);
|
||||
} catch (err) {
|
||||
console.error("Error reading log file:", err);
|
||||
event.reply("last-logs", "Error reading log file.");
|
||||
}
|
||||
});
|
||||
|
||||
function readLastLinesOfFile(filePath, lineCount) {
|
||||
return new Promise((resolve, reject) => {
|
||||
child_process.exec(`tail -n ${lineCount} "${filePath}"`, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(err.message);
|
||||
return;
|
||||
}
|
||||
if (stderr) {
|
||||
reject(stderr);
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getContextMenu(): any {
|
||||
let menu = new electron.Menu();
|
||||
let menuItem = new electron.MenuItem({ label: "Testing", click: () => console.log("click testing!") });
|
||||
@ -535,8 +575,8 @@ function sendWSSC() {
|
||||
}
|
||||
|
||||
function runWaveSrv() {
|
||||
let pResolve = null;
|
||||
let pReject = null;
|
||||
let pResolve: (value: unknown) => void;
|
||||
let pReject: (reason?: any) => void;
|
||||
let rtnPromise = new Promise((argResolve, argReject) => {
|
||||
pResolve = argResolve;
|
||||
pReject = argReject;
|
||||
@ -546,8 +586,9 @@ function runWaveSrv() {
|
||||
if (isDev) {
|
||||
envCopy[WaveDevVarName] = "1";
|
||||
}
|
||||
console.log("trying to run local server", getWaveSrvPath());
|
||||
let proc = child_process.spawn("bash", ["-c", getWaveSrvCmd()], {
|
||||
let waveSrvCmd = getWaveSrvCmd();
|
||||
console.log("trying to run local server", waveSrvCmd);
|
||||
let proc = child_process.spawn("bash", ["-c", waveSrvCmd], {
|
||||
cwd: getWaveSrvCwd(),
|
||||
env: envCopy,
|
||||
});
|
||||
@ -555,7 +596,7 @@ function runWaveSrv() {
|
||||
console.log("wavesrv exit", e);
|
||||
waveSrvProc = null;
|
||||
sendWSSC();
|
||||
pReject(new Error(sprintf("failed to start local server (%s)", getWaveSrvPath())));
|
||||
pReject(new Error(sprintf("failed to start local server (%s)", waveSrvCmd)));
|
||||
if (waveSrvShouldRestart) {
|
||||
waveSrvShouldRestart = false;
|
||||
this.runWaveSrv();
|
||||
|
@ -6,13 +6,19 @@ contextBridge.exposeInMainWorld("api", {
|
||||
getIsDev: () => ipcRenderer.sendSync("get-isdev"),
|
||||
getAuthKey: () => ipcRenderer.sendSync("get-authkey"),
|
||||
getWaveSrvStatus: () => ipcRenderer.sendSync("wavesrv-status"),
|
||||
getLastLogs: (numberOfLines, callback) => {
|
||||
ipcRenderer.send("get-last-logs", numberOfLines);
|
||||
ipcRenderer.once("last-logs", (event, data) => callback(data));
|
||||
},
|
||||
restartWaveSrv: () => ipcRenderer.sendSync("restart-server"),
|
||||
reloadWindow: () => ipcRenderer.sendSync("reload-window"),
|
||||
openExternalLink: (url) => ipcRenderer.send("open-external-link", url),
|
||||
onTCmd: (callback) => ipcRenderer.on("t-cmd", callback),
|
||||
onICmd: (callback) => ipcRenderer.on("i-cmd", callback),
|
||||
onLCmd: (callback) => ipcRenderer.on("l-cmd", callback),
|
||||
onHCmd: (callback) => ipcRenderer.on("h-cmd", callback),
|
||||
onWCmd: (callback) => ipcRenderer.on("w-cmd", callback),
|
||||
onPCmd: (callback) => ipcRenderer.on("p-cmd", callback),
|
||||
onMetaArrowUp: (callback) => ipcRenderer.on("meta-arrowup", callback),
|
||||
onMetaArrowDown: (callback) => ipcRenderer.on("meta-arrowdown", callback),
|
||||
onMetaPageUp: (callback) => ipcRenderer.on("meta-pageup", callback),
|
||||
|
@ -9,6 +9,8 @@ import { boundMethod } from "autobind-decorator";
|
||||
import { debounce } from "throttle-debounce";
|
||||
import {
|
||||
handleJsonFetchResponse,
|
||||
base64ToString,
|
||||
stringToBase64,
|
||||
base64ToArray,
|
||||
genMergeData,
|
||||
genMergeDataMap,
|
||||
@ -63,6 +65,7 @@ import type {
|
||||
CommandRtnType,
|
||||
WebCmd,
|
||||
WebRemote,
|
||||
OpenAICmdInfoChatMessageType,
|
||||
} from "../types/types";
|
||||
import * as T from "../types/types";
|
||||
import { WSControl } from "./ws";
|
||||
@ -78,7 +81,7 @@ import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import { getRendererContext, cmdStatusIsRunning } from "../app/line/lineutil";
|
||||
import { MagicLayout } from "../app/magiclayout";
|
||||
import { modalsRegistry } from "../app/common/modals/modalsRegistry";
|
||||
import { modalsRegistry } from "../app/common/modals/registry";
|
||||
import * as appconst from "../app/appconst";
|
||||
|
||||
dayjs.extend(customParseFormat);
|
||||
@ -193,10 +196,13 @@ type ElectronApi = {
|
||||
getWaveSrvStatus: () => boolean;
|
||||
restartWaveSrv: () => boolean;
|
||||
reloadWindow: () => void;
|
||||
openExternalLink: (url: string) => void;
|
||||
onTCmd: (callback: (mods: KeyModsType) => void) => void;
|
||||
onICmd: (callback: (mods: KeyModsType) => void) => void;
|
||||
onLCmd: (callback: (mods: KeyModsType) => void) => void;
|
||||
onHCmd: (callback: (mods: KeyModsType) => void) => void;
|
||||
onPCmd: (callback: (mods: KeyModsType) => void) => void;
|
||||
onWCmd: (callback: (mods: KeyModsType) => void) => void;
|
||||
onMenuItemAbout: (callback: () => void) => void;
|
||||
onMetaArrowUp: (callback: () => void) => void;
|
||||
onMetaArrowDown: (callback: () => void) => void;
|
||||
@ -207,6 +213,7 @@ type ElectronApi = {
|
||||
contextScreen: (screenOpts: { screenId: string }, position: { x: number; y: number }) => void;
|
||||
contextEditMenu: (position: { x: number; y: number }, opts: ContextMenuOpts) => void;
|
||||
onWaveSrvStatusChange: (callback: (status: boolean, pid: number) => void) => void;
|
||||
getLastLogs: (numOfLines: number, callback: (logs: any) => void) => void;
|
||||
};
|
||||
|
||||
function getApi(): ElectronApi {
|
||||
@ -334,7 +341,7 @@ class Cmd {
|
||||
type: "feinput",
|
||||
ck: this.screenId + "/" + this.lineId,
|
||||
remote: this.remote,
|
||||
inputdata64: btoa(data),
|
||||
inputdata64: stringToBase64(data),
|
||||
};
|
||||
GlobalModel.sendInputPacket(inputPacket);
|
||||
}
|
||||
@ -1228,7 +1235,18 @@ function getDefaultHistoryQueryOpts(): HistoryQueryOpts {
|
||||
class InputModel {
|
||||
historyShow: OV<boolean> = mobx.observable.box(false);
|
||||
infoShow: OV<boolean> = mobx.observable.box(false);
|
||||
aIChatShow: OV<boolean> = mobx.observable.box(false);
|
||||
cmdInputHeight: OV<number> = mobx.observable.box(0);
|
||||
aiChatTextAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
aiChatWindowRef: React.RefObject<HTMLDivElement>;
|
||||
codeSelectBlockRefArray: Array<React.RefObject<HTMLElement>>;
|
||||
codeSelectSelectedIndex: OV<number> = mobx.observable.box(-1);
|
||||
|
||||
AICmdInfoChatItems: mobx.IObservableArray<OpenAICmdInfoChatMessageType> = mobx.observable.array([], {
|
||||
name: "aicmdinfo-chat",
|
||||
});
|
||||
readonly codeSelectTop: number = -2;
|
||||
readonly codeSelectBottom: number = -1;
|
||||
|
||||
historyType: mobx.IObservableValue<HistoryTypeStrs> = mobx.observable.box("screen");
|
||||
historyLoading: mobx.IObservableValue<boolean> = mobx.observable.box(false);
|
||||
@ -1266,6 +1284,10 @@ class InputModel {
|
||||
this.filteredHistoryItems = mobx.computed(() => {
|
||||
return this._getFilteredHistoryItems();
|
||||
});
|
||||
mobx.action(() => {
|
||||
this.codeSelectSelectedIndex.set(-1);
|
||||
this.codeSelectBlockRefArray = [];
|
||||
})();
|
||||
}
|
||||
|
||||
setInputMode(inputMode: null | "comment" | "global"): void {
|
||||
@ -1390,6 +1412,11 @@ class InputModel {
|
||||
})();
|
||||
}
|
||||
|
||||
setOpenAICmdInfoChat(chat: OpenAICmdInfoChatMessageType[]): void {
|
||||
this.AICmdInfoChatItems.replace(chat);
|
||||
this.codeSelectBlockRefArray = [];
|
||||
}
|
||||
|
||||
setHistoryShow(show: boolean): void {
|
||||
if (this.historyShow.get() == show) {
|
||||
return;
|
||||
@ -1678,6 +1705,152 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
|
||||
setCmdInfoChatRefs(
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>,
|
||||
chatWindowRef: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
this.aiChatTextAreaRef = textAreaRef;
|
||||
this.aiChatWindowRef = chatWindowRef;
|
||||
}
|
||||
|
||||
setAIChatFocus() {
|
||||
if (this.aiChatTextAreaRef != null && this.aiChatTextAreaRef.current != null) {
|
||||
this.aiChatTextAreaRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
grabCodeSelectSelection() {
|
||||
if (
|
||||
this.codeSelectSelectedIndex.get() >= 0 &&
|
||||
this.codeSelectSelectedIndex.get() < this.codeSelectBlockRefArray.length
|
||||
) {
|
||||
let curBlockRef = this.codeSelectBlockRefArray[this.codeSelectSelectedIndex.get()];
|
||||
let codeText = curBlockRef.current.innerText;
|
||||
codeText = codeText.replace(/\n$/, ""); // remove trailing newline
|
||||
let newLineValue = this.getCurLine() + " " + codeText;
|
||||
this.setCurLine(newLineValue);
|
||||
this.giveFocus();
|
||||
}
|
||||
}
|
||||
|
||||
addCodeBlockToCodeSelect(blockRef: React.RefObject<HTMLElement>): number {
|
||||
let rtn = -1;
|
||||
rtn = this.codeSelectBlockRefArray.length;
|
||||
this.codeSelectBlockRefArray.push(blockRef);
|
||||
return rtn;
|
||||
}
|
||||
|
||||
setCodeSelectSelectedCodeBlock(blockIndex: number) {
|
||||
mobx.action(() => {
|
||||
if (blockIndex >= 0 && blockIndex < this.codeSelectBlockRefArray.length) {
|
||||
this.codeSelectSelectedIndex.set(blockIndex);
|
||||
let currentRef = this.codeSelectBlockRefArray[blockIndex].current;
|
||||
if (currentRef != null) {
|
||||
if (this.aiChatWindowRef != null && this.aiChatWindowRef.current != null) {
|
||||
let chatWindowTop = this.aiChatWindowRef.current.scrollTop;
|
||||
let chatWindowBottom = chatWindowTop + this.aiChatWindowRef.current.clientHeight - 100;
|
||||
let elemTop = currentRef.offsetTop;
|
||||
let elemBottom = elemTop - currentRef.offsetHeight;
|
||||
let elementIsInView = elemBottom < chatWindowBottom && elemTop > chatWindowTop;
|
||||
if (!elementIsInView) {
|
||||
this.aiChatWindowRef.current.scrollTop =
|
||||
elemBottom - this.aiChatWindowRef.current.clientHeight / 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.codeSelectBlockRefArray = [];
|
||||
this.setAIChatFocus();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
codeSelectSelectNextNewestCodeBlock() {
|
||||
// oldest code block = index 0 in array
|
||||
// this decrements codeSelectSelected index
|
||||
mobx.action(() => {
|
||||
if (this.codeSelectSelectedIndex.get() == this.codeSelectTop) {
|
||||
this.codeSelectSelectedIndex.set(this.codeSelectBottom);
|
||||
} else if (this.codeSelectSelectedIndex.get() == this.codeSelectBottom) {
|
||||
return;
|
||||
}
|
||||
let incBlockIndex = this.codeSelectSelectedIndex.get() + 1;
|
||||
if (this.codeSelectSelectedIndex.get() == this.codeSelectBlockRefArray.length - 1) {
|
||||
this.codeSelectDeselectAll();
|
||||
if (this.aiChatWindowRef != null && this.aiChatWindowRef.current != null) {
|
||||
this.aiChatWindowRef.current.scrollTop = this.aiChatWindowRef.current.scrollHeight;
|
||||
}
|
||||
}
|
||||
if (incBlockIndex >= 0 && incBlockIndex < this.codeSelectBlockRefArray.length) {
|
||||
this.setCodeSelectSelectedCodeBlock(incBlockIndex);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
codeSelectSelectNextOldestCodeBlock() {
|
||||
mobx.action(() => {
|
||||
if (this.codeSelectSelectedIndex.get() == this.codeSelectBottom) {
|
||||
if (this.codeSelectBlockRefArray.length > 0) {
|
||||
this.codeSelectSelectedIndex.set(this.codeSelectBlockRefArray.length);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else if (this.codeSelectSelectedIndex.get() == this.codeSelectTop) {
|
||||
return;
|
||||
}
|
||||
let decBlockIndex = this.codeSelectSelectedIndex.get() - 1;
|
||||
if (decBlockIndex < 0) {
|
||||
this.codeSelectDeselectAll(this.codeSelectTop);
|
||||
if (this.aiChatWindowRef != null && this.aiChatWindowRef.current != null) {
|
||||
this.aiChatWindowRef.current.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
if (decBlockIndex >= 0 && decBlockIndex < this.codeSelectBlockRefArray.length) {
|
||||
this.setCodeSelectSelectedCodeBlock(decBlockIndex);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
getCodeSelectSelectedIndex() {
|
||||
return this.codeSelectSelectedIndex.get();
|
||||
}
|
||||
|
||||
getCodeSelectRefArrayLength() {
|
||||
return this.codeSelectBlockRefArray.length;
|
||||
}
|
||||
|
||||
codeBlockIsSelected(blockIndex: number): boolean {
|
||||
return blockIndex == this.codeSelectSelectedIndex.get();
|
||||
}
|
||||
|
||||
codeSelectDeselectAll(direction: number = this.codeSelectBottom) {
|
||||
mobx.action(() => {
|
||||
this.codeSelectSelectedIndex.set(direction);
|
||||
this.codeSelectBlockRefArray = [];
|
||||
})();
|
||||
}
|
||||
|
||||
openAIAssistantChat(): void {
|
||||
this.aIChatShow.set(true);
|
||||
this.setAIChatFocus();
|
||||
}
|
||||
|
||||
closeAIAssistantChat(): void {
|
||||
this.aIChatShow.set(false);
|
||||
this.giveFocus();
|
||||
}
|
||||
|
||||
clearAIAssistantChat(): void {
|
||||
let prtn = GlobalModel.submitChatInfoCommand("", "", true);
|
||||
prtn.then((rtn) => {
|
||||
if (rtn.success) {
|
||||
} else {
|
||||
console.log("submit chat command error: " + rtn.error);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.log("submit chat command error: ", error);
|
||||
});
|
||||
}
|
||||
|
||||
hasScrollingInfoMsg(): boolean {
|
||||
if (!this.infoShow.get()) {
|
||||
return false;
|
||||
@ -1773,6 +1946,7 @@ class InputModel {
|
||||
resetInput(): void {
|
||||
mobx.action(() => {
|
||||
this.setHistoryShow(false);
|
||||
this.closeAIAssistantChat();
|
||||
this.infoShow.set(false);
|
||||
this.inputMode.set(null);
|
||||
this.resetHistory();
|
||||
@ -2864,7 +3038,7 @@ class RemotesModalModel {
|
||||
let inputPacket: RemoteInputPacketType = {
|
||||
type: "remoteinput",
|
||||
remoteid: remoteId,
|
||||
inputdata64: btoa(event.key),
|
||||
inputdata64: stringToBase64(event.key),
|
||||
};
|
||||
GlobalModel.sendInputPacket(inputPacket);
|
||||
}
|
||||
@ -2922,8 +3096,11 @@ class RemotesModel {
|
||||
return this.recentConnAddedState.get();
|
||||
}
|
||||
|
||||
seRecentConnAdded(value: boolean) {
|
||||
@boundMethod
|
||||
setRecentConnAdded(value: boolean) {
|
||||
mobx.action(() => {
|
||||
this.recentConnAddedState.set(value);
|
||||
})();
|
||||
}
|
||||
|
||||
deSelectRemote(): void {
|
||||
@ -2935,6 +3112,7 @@ class RemotesModel {
|
||||
|
||||
openReadModal(remoteId: string): void {
|
||||
mobx.action(() => {
|
||||
this.setRecentConnAdded(false);
|
||||
this.selectedRemoteId.set(remoteId);
|
||||
this.remoteEdit.set(null);
|
||||
GlobalModel.modalsModel.pushModal(appconst.VIEW_REMOTE);
|
||||
@ -3043,7 +3221,7 @@ class RemotesModel {
|
||||
let inputPacket: RemoteInputPacketType = {
|
||||
type: "remoteinput",
|
||||
remoteid: remoteId,
|
||||
inputdata64: btoa(event.key),
|
||||
inputdata64: stringToBase64(event.key),
|
||||
};
|
||||
GlobalModel.sendInputPacket(inputPacket);
|
||||
}
|
||||
@ -3207,6 +3385,8 @@ class Model {
|
||||
getApi().onICmd(this.onICmd.bind(this));
|
||||
getApi().onLCmd(this.onLCmd.bind(this));
|
||||
getApi().onHCmd(this.onHCmd.bind(this));
|
||||
getApi().onPCmd(this.onPCmd.bind(this));
|
||||
getApi().onWCmd(this.onWCmd.bind(this));
|
||||
getApi().onMenuItemAbout(this.onMenuItemAbout.bind(this));
|
||||
getApi().onMetaArrowUp(this.onMetaArrowUp.bind(this));
|
||||
getApi().onMetaArrowDown(this.onMetaArrowDown.bind(this));
|
||||
@ -3245,6 +3425,16 @@ class Model {
|
||||
getApi().reloadWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a new default browser window to the given url
|
||||
* @param {string} url The url to open
|
||||
*/
|
||||
openExternalLink(url: string): void {
|
||||
console.log("opening external link: " + url);
|
||||
getApi().openExternalLink(url);
|
||||
console.log("finished opening external link");
|
||||
}
|
||||
|
||||
refocus() {
|
||||
// givefocus() give back focus to cmd or input
|
||||
let activeScreen = this.getActiveScreen();
|
||||
@ -3276,6 +3466,13 @@ class Model {
|
||||
}
|
||||
|
||||
showAlert(alertMessage: AlertMessageType): Promise<boolean> {
|
||||
if (alertMessage.confirmflag != null) {
|
||||
let cdata = GlobalModel.clientData.get();
|
||||
let noConfirm = cdata.clientopts?.confirmflags?.[alertMessage.confirmflag];
|
||||
if (noConfirm) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
mobx.action(() => {
|
||||
this.alertMessage.set(alertMessage);
|
||||
GlobalModel.modalsModel.pushModal(appconst.ALERT);
|
||||
@ -3356,7 +3553,7 @@ class Model {
|
||||
// nothing for now
|
||||
}
|
||||
|
||||
docKeyDownHandler(e: any) {
|
||||
docKeyDownHandler(e: KeyboardEvent) {
|
||||
if (isModKeyPress(e)) {
|
||||
return;
|
||||
}
|
||||
@ -3418,6 +3615,54 @@ class Model {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.code == "KeyD" && e.getModifierState("Meta")) {
|
||||
let ranDelete = this.deleteActiveLine();
|
||||
if (ranDelete) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deleteActiveLine(): boolean {
|
||||
let activeScreen = this.getActiveScreen();
|
||||
if (activeScreen == null || activeScreen.getFocusType() != "cmd") {
|
||||
return false;
|
||||
}
|
||||
let selectedLine = activeScreen.selectedLine.get();
|
||||
if (selectedLine == null || selectedLine <= 0) {
|
||||
return false;
|
||||
}
|
||||
let line = activeScreen.getLineByNum(selectedLine);
|
||||
if (line == null) {
|
||||
return false;
|
||||
}
|
||||
let cmd = activeScreen.getCmd(line);
|
||||
if (cmd != null) {
|
||||
if (cmd.isRunning()) {
|
||||
let info: T.InfoType = { infomsg: "Cannot delete a running command" };
|
||||
this.inputModel.flashInfoMsg(info, 2000);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
GlobalCommandRunner.lineDelete(String(selectedLine), true);
|
||||
return true;
|
||||
}
|
||||
|
||||
onWCmd(e: any, mods: KeyModsType) {
|
||||
let activeScreen = this.getActiveScreen();
|
||||
if (activeScreen == null) {
|
||||
return;
|
||||
}
|
||||
let rtnp = this.showAlert({
|
||||
message: "Are you sure you want to delete this screen?",
|
||||
confirm: true,
|
||||
});
|
||||
rtnp.then((result) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
GlobalCommandRunner.screenDelete(activeScreen.screenId, true);
|
||||
});
|
||||
}
|
||||
|
||||
clearModals(): boolean {
|
||||
@ -3474,6 +3719,10 @@ class Model {
|
||||
})();
|
||||
}
|
||||
|
||||
getLastLogs(numbOfLines: number, cb: (logs: any) => void): void {
|
||||
getApi().getLastLogs(numbOfLines, cb);
|
||||
}
|
||||
|
||||
getContentHeight(context: RendererContext): number {
|
||||
let key = context.screenId + "/" + context.lineId;
|
||||
return this.termUsedRowsCache[key];
|
||||
@ -3535,6 +3784,10 @@ class Model {
|
||||
GlobalModel.historyViewModel.reSearch();
|
||||
}
|
||||
|
||||
onPCmd(e: any, mods: KeyModsType) {
|
||||
GlobalModel.modalsModel.pushModal(appconst.TAB_SWITCHER);
|
||||
}
|
||||
|
||||
getFocusedLine(): LineFocusType {
|
||||
if (this.inputModel.hasFocus()) {
|
||||
return { cmdInputFocus: true };
|
||||
@ -3711,8 +3964,8 @@ class Model {
|
||||
this.remotes.clear();
|
||||
}
|
||||
this.updateRemotes(update.remotes);
|
||||
// This code's purpose is to show view remote connection modal when a new connection is added
|
||||
if (update.remotes && update.remotes.length && this.remotesModel.recentConnAddedState.get()) {
|
||||
GlobalModel.remotesModel.closeModal();
|
||||
GlobalModel.remotesModel.openReadModal(update.remotes![0].remoteid);
|
||||
}
|
||||
}
|
||||
@ -3744,6 +3997,10 @@ class Model {
|
||||
this.remotesModel.openEditModal({ ...rview.remoteedit });
|
||||
}
|
||||
}
|
||||
if (interactive && "alertmessage" in update) {
|
||||
let alertMessage: AlertMessageType = update.alertmessage;
|
||||
this.showAlert(alertMessage);
|
||||
}
|
||||
if ("cmdline" in update) {
|
||||
this.inputModel.updateCmdLine(update.cmdline);
|
||||
}
|
||||
@ -3756,6 +4013,9 @@ class Model {
|
||||
this.sessionListLoaded.set(true);
|
||||
this.remotesLoaded.set(true);
|
||||
}
|
||||
if ("openaicmdinfochat" in update) {
|
||||
this.inputModel.setOpenAICmdInfoChat(update.openaicmdinfochat);
|
||||
}
|
||||
// console.log("run-update>", Date.now(), interactive, update);
|
||||
}
|
||||
|
||||
@ -3990,6 +4250,28 @@ class Model {
|
||||
return this.submitCommandPacket(pk, interactive);
|
||||
}
|
||||
|
||||
submitChatInfoCommand(chatMsg: string, curLineStr: string, clear: boolean): Promise<CommandRtnType> {
|
||||
let commandStr = "/chat " + chatMsg;
|
||||
let interactive = false;
|
||||
let pk: FeCmdPacketType = {
|
||||
type: "fecmd",
|
||||
metacmd: "eval",
|
||||
args: [commandStr],
|
||||
kwargs: {},
|
||||
uicontext: this.getUIContext(),
|
||||
interactive: interactive,
|
||||
rawstr: chatMsg,
|
||||
};
|
||||
pk.kwargs["nohist"] = "1";
|
||||
if (clear) {
|
||||
pk.kwargs["cmdinfoclear"] = "1";
|
||||
} else {
|
||||
pk.kwargs["cmdinfo"] = "1";
|
||||
}
|
||||
pk.kwargs["curline"] = curLineStr;
|
||||
return this.submitCommandPacket(pk, interactive);
|
||||
}
|
||||
|
||||
submitRawCommand(cmdStr: string, addToHistory: boolean, interactive: boolean): Promise<CommandRtnType> {
|
||||
let pk: FeCmdPacketType = {
|
||||
type: "fecmd",
|
||||
@ -4190,7 +4472,7 @@ class Model {
|
||||
return resp.text() as any;
|
||||
}
|
||||
contentType = resp.headers.get("Content-Type");
|
||||
fileInfo = JSON.parse(atob(resp.headers.get("X-FileInfo")));
|
||||
fileInfo = JSON.parse(base64ToString(resp.headers.get("X-FileInfo")));
|
||||
return resp.blob();
|
||||
})
|
||||
.then((blobOrText: any) => {
|
||||
@ -4268,11 +4550,15 @@ class CommandRunner {
|
||||
GlobalModel.submitCommand("session", null, [session], { nohist: "1" }, false);
|
||||
}
|
||||
|
||||
switchScreen(screen: string) {
|
||||
switchScreen(screen: string, session?: string) {
|
||||
mobx.action(() => {
|
||||
GlobalModel.activeMainView.set("session");
|
||||
})();
|
||||
GlobalModel.submitCommand("screen", null, [screen], { nohist: "1" }, false);
|
||||
let kwargs = { nohist: "1" };
|
||||
if (session != null) {
|
||||
kwargs["session"] = session;
|
||||
}
|
||||
GlobalModel.submitCommand("screen", null, [screen], kwargs, false);
|
||||
}
|
||||
|
||||
lineView(sessionId: string, screenId: string, lineNum?: number) {
|
||||
@ -4290,6 +4576,10 @@ class CommandRunner {
|
||||
return GlobalModel.submitCommand("line", "archive", [lineArg, archiveStr], kwargs, false);
|
||||
}
|
||||
|
||||
lineDelete(lineArg: string, interactive: boolean): Promise<CommandRtnType> {
|
||||
return GlobalModel.submitCommand("line", "delete", [lineArg], { nohist: "1" }, interactive);
|
||||
}
|
||||
|
||||
lineSet(lineArg: string, opts: { renderer?: string }): Promise<CommandRtnType> {
|
||||
let kwargs = { nohist: "1" };
|
||||
if ("renderer" in opts) {
|
||||
@ -4339,8 +4629,8 @@ class CommandRunner {
|
||||
);
|
||||
}
|
||||
|
||||
screenDelete(screenId: string): Promise<CommandRtnType> {
|
||||
return GlobalModel.submitCommand("screen", "delete", [screenId], { nohist: "1" }, false);
|
||||
screenDelete(screenId: string, interactive: boolean): Promise<CommandRtnType> {
|
||||
return GlobalModel.submitCommand("screen", "delete", [screenId], { nohist: "1" }, interactive);
|
||||
}
|
||||
|
||||
screenWebShare(screenId: string, shouldShare: boolean): Promise<CommandRtnType> {
|
||||
@ -4407,7 +4697,7 @@ class CommandRunner {
|
||||
}
|
||||
|
||||
importSshConfig() {
|
||||
GlobalModel.submitCommand("remote", "parse", null, null, false);
|
||||
GlobalModel.submitCommand("remote", "parse", null, { nohist: "1", visual: "1" }, true);
|
||||
}
|
||||
|
||||
screenSelectLine(lineArg: string, focusVal?: string) {
|
||||
@ -4573,6 +4863,12 @@ class CommandRunner {
|
||||
GlobalModel.submitCommand("client", "accepttos", null, { nohist: "1" }, true);
|
||||
}
|
||||
|
||||
clientSetConfirmFlag(flag: string, value: boolean): Promise<CommandRtnType> {
|
||||
let kwargs = { nohist: "1" };
|
||||
let valueStr = value ? "1" : "0";
|
||||
return GlobalModel.submitCommand("client", "setconfirmflag", [flag, valueStr], kwargs, false);
|
||||
}
|
||||
|
||||
editBookmark(bookmarkId: string, desc: string, cmdstr: string) {
|
||||
let kwargs = {
|
||||
nohist: "1",
|
||||
|
@ -3,7 +3,8 @@
|
||||
|
||||
import * as React from "react";
|
||||
import * as T from "../../types/types";
|
||||
import Editor from "@monaco-editor/react";
|
||||
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 { GlobalModel, GlobalCommandRunner } from "../../model/model";
|
||||
import Split from "react-split-it";
|
||||
@ -146,21 +147,24 @@ class SourceCodeRenderer extends React.Component<
|
||||
}
|
||||
};
|
||||
|
||||
handleEditorDidMount = (editor, monaco) => {
|
||||
handleEditorDidMount = (editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => {
|
||||
this.monacoEditor = editor;
|
||||
this.setInitialLanguage(editor);
|
||||
this.setEditorHeight();
|
||||
editor.onKeyDown((e) => {
|
||||
if (e.code === "KeyS" && (e.ctrlKey || e.metaKey) && this.state.isSave) {
|
||||
editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => {
|
||||
if (e.code === "KeyS" && e.metaKey && this.state.isSave) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.doSave();
|
||||
}
|
||||
if (e.code === "KeyD" && (e.ctrlKey || e.metaKey)) {
|
||||
if (e.code === "KeyD" && e.metaKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.doClose();
|
||||
}
|
||||
if (e.code === "KeyP" && (e.ctrlKey || e.metaKey)) {
|
||||
if (e.code === "KeyP" && e.metaKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.togglePreview();
|
||||
}
|
||||
});
|
||||
|
@ -3,10 +3,13 @@
|
||||
|
||||
import * as mobx from "mobx";
|
||||
import { Terminal } from "xterm";
|
||||
//TODO: replace with `@xterm/addon-web-links` when it's available as stable
|
||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||
import { sprintf } from "sprintf-js";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import { windowWidthToCols, windowHeightToRows } from "../../util/textmeasure";
|
||||
import { boundInt } from "../../util/util";
|
||||
import { GlobalModel } from "../../model/model"
|
||||
import type {
|
||||
TermContextUnion,
|
||||
TermOptsType,
|
||||
@ -96,6 +99,21 @@ class TermWrap {
|
||||
fontFamily: "JetBrains Mono",
|
||||
theme: { foreground: terminal.foreground, background: terminal.background },
|
||||
});
|
||||
this.terminal.loadAddon(new WebLinksAddon((e, uri) => {
|
||||
e.preventDefault();
|
||||
switch (GlobalModel.platform) {
|
||||
case "darwin":
|
||||
if (e.metaKey) {
|
||||
GlobalModel.openExternalLink(uri);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (e.ctrlKey) {
|
||||
GlobalModel.openExternalLink(uri);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}));
|
||||
this.terminal._core._inputHandler._parser.setErrorHandler((state) => {
|
||||
this.numParseErrors++;
|
||||
return state;
|
||||
|
@ -265,6 +265,20 @@ type ScreenLinesType = {
|
||||
cmds: CmdDataType[];
|
||||
};
|
||||
|
||||
type OpenAIPacketOutputType = {
|
||||
model: string;
|
||||
created: number;
|
||||
finish_reason: string;
|
||||
message: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type OpenAICmdInfoChatMessageType = {
|
||||
isassistantresponse?: boolean;
|
||||
assistantresponse?: OpenAIPacketOutputType;
|
||||
userquery?: string;
|
||||
};
|
||||
|
||||
type ModelUpdateType = {
|
||||
interactive: boolean;
|
||||
sessions?: SessionDataType[];
|
||||
@ -285,6 +299,8 @@ type ModelUpdateType = {
|
||||
clientdata?: ClientDataType;
|
||||
historyviewdata?: HistoryViewDataType;
|
||||
remoteview?: RemoteViewType;
|
||||
openaicmdinfochat?: OpenAICmdInfoChatMessageType[];
|
||||
alertmessage?: AlertMessageType;
|
||||
};
|
||||
|
||||
type HistoryViewDataType = {
|
||||
@ -472,10 +488,15 @@ type FeOptsType = {
|
||||
termfontsize: number;
|
||||
};
|
||||
|
||||
type ConfirmFlagsType = {
|
||||
[k: string]: boolean;
|
||||
};
|
||||
|
||||
type ClientOptsType = {
|
||||
notelemetry: boolean;
|
||||
noreleasecheck: boolean;
|
||||
acceptedtos: number;
|
||||
confirmflags: ConfirmFlagsType;
|
||||
};
|
||||
|
||||
type ReleaseInfoType = {
|
||||
@ -524,6 +545,7 @@ type AlertMessageType = {
|
||||
message: string;
|
||||
confirm?: boolean;
|
||||
markdown?: boolean;
|
||||
confirmflag?: string;
|
||||
};
|
||||
|
||||
type HistorySearchParams = {
|
||||
@ -756,4 +778,5 @@ export type {
|
||||
ModalStoreEntry,
|
||||
StrWithPos,
|
||||
CmdInputTextPacketType,
|
||||
OpenAICmdInfoChatMessageType,
|
||||
};
|
||||
|
@ -6,6 +6,7 @@ import { sprintf } from "sprintf-js";
|
||||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import type { RemoteType, CommandRtnType } from "../types/types";
|
||||
import base64 from "base64-js";
|
||||
|
||||
type OV<V> = mobx.IObservableValue<V>;
|
||||
|
||||
@ -70,6 +71,16 @@ function handleJsonFetchResponse(url: URL, resp: any): Promise<any> {
|
||||
return rtnData;
|
||||
}
|
||||
|
||||
function base64ToString(b64: string): string {
|
||||
let stringBytes = base64.toByteArray(b64)
|
||||
return new TextDecoder().decode(stringBytes)
|
||||
}
|
||||
|
||||
function stringToBase64(input: string): string {
|
||||
let stringBytes = new TextEncoder().encode(input)
|
||||
return base64.fromByteArray(stringBytes)
|
||||
}
|
||||
|
||||
function base64ToArray(b64: string): Uint8Array {
|
||||
let rawStr = atob(b64);
|
||||
let rtnArr = new Uint8Array(new ArrayBuffer(rawStr.length));
|
||||
@ -229,26 +240,6 @@ function genMergeDataMap<ObjType extends IObjType<DataType>, DataType extends ID
|
||||
return rtn;
|
||||
}
|
||||
|
||||
function parseEnv0(envStr64: string): Map<string, string> {
|
||||
let envStr = atob(envStr64);
|
||||
let parts = envStr.split("\x00");
|
||||
let rtn: Map<string, string> = new Map();
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
let part = parts[i];
|
||||
if (part == "") {
|
||||
continue;
|
||||
}
|
||||
let eqIdx = part.indexOf("=");
|
||||
if (eqIdx == -1) {
|
||||
continue;
|
||||
}
|
||||
let varName = part.substr(0, eqIdx);
|
||||
let varVal = part.substr(eqIdx + 1);
|
||||
rtn.set(varName, varVal);
|
||||
}
|
||||
return rtn;
|
||||
}
|
||||
|
||||
function boundInt(ival: number, minVal: number, maxVal: number): number {
|
||||
if (ival < minVal) {
|
||||
return minVal;
|
||||
@ -404,13 +395,22 @@ function commandRtnHandler(prtn: Promise<CommandRtnType>, errorMessage: OV<strin
|
||||
});
|
||||
}
|
||||
|
||||
function getRemoteName(remote: RemoteType): string {
|
||||
if (remote == null) {
|
||||
return "";
|
||||
}
|
||||
let { remotealias, remotecanonicalname } = remote;
|
||||
return remotealias ? `${remotealias} [${remotecanonicalname}]` : remotecanonicalname;
|
||||
}
|
||||
|
||||
export {
|
||||
handleJsonFetchResponse,
|
||||
base64ToString,
|
||||
stringToBase64,
|
||||
base64ToArray,
|
||||
genMergeData,
|
||||
genMergeDataMap,
|
||||
genMergeSimpleData,
|
||||
parseEnv0,
|
||||
boundInt,
|
||||
isModKeyPress,
|
||||
incObs,
|
||||
@ -428,4 +428,5 @@ export {
|
||||
getColorRGB,
|
||||
commandRtnHandler,
|
||||
getRemoteConnVal,
|
||||
getRemoteName,
|
||||
};
|
||||
|
@ -70,6 +70,8 @@ const PacketEOFStr = "EOF"
|
||||
|
||||
var TypeStrToFactory map[string]reflect.Type
|
||||
|
||||
const OpenAICmdInfoChatGreetingMessage = "Hello, may I help you with this command? \n(Press ESC to close and Ctrl+L to clear chat buffer)"
|
||||
|
||||
func init() {
|
||||
TypeStrToFactory = make(map[string]reflect.Type)
|
||||
TypeStrToFactory[RunPacketStr] = reflect.TypeOf(RunPacketType{})
|
||||
@ -729,6 +731,14 @@ type OpenAIUsageType struct {
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAICmdInfoPacketOutputType struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Created int64 `json:"created,omitempty"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIPacketType struct {
|
||||
Type string `json:"type"`
|
||||
Model string `json:"model,omitempty"`
|
||||
@ -843,6 +853,14 @@ func MakeWriteFileDonePacket(reqId string) *WriteFileDonePacketType {
|
||||
}
|
||||
}
|
||||
|
||||
type OpenAICmdInfoChatMessage struct {
|
||||
MessageID int `json:"messageid"`
|
||||
IsAssistantResponse bool `json:"isassistantresponse,omitempty"`
|
||||
AssistantResponse *OpenAICmdInfoPacketOutputType `json:"assistantresponse,omitempty"`
|
||||
UserQuery string `json:"userquery,omitempty"`
|
||||
UserEngineeredQuery string `json:"userengineeredquery,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIPromptMessageType struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
@ -927,14 +945,6 @@ func ParseJsonPacket(jsonBuf []byte) (PacketType, error) {
|
||||
return pk, nil
|
||||
}
|
||||
|
||||
func sanitizeBytes(buf []byte) {
|
||||
for idx, b := range buf {
|
||||
if b >= 127 || (b < 32 && b != 10 && b != 13) {
|
||||
buf[idx] = '?'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SendError struct {
|
||||
IsWriteError bool // fatal
|
||||
IsMarshalError bool // not fatal
|
||||
@ -970,7 +980,6 @@ func MarshalPacket(packet PacketType) ([]byte, error) {
|
||||
outBuf.Write(jsonBytes)
|
||||
outBuf.WriteByte('\n')
|
||||
outBytes := outBuf.Bytes()
|
||||
sanitizeBytes(outBytes)
|
||||
return outBytes, nil
|
||||
}
|
||||
|
||||
|
12
wavesrv/db/migrations/000029_canonicalport.down.sql
Normal file
12
wavesrv/db/migrations/000029_canonicalport.down.sql
Normal file
@ -0,0 +1,12 @@
|
||||
UPDATE remote
|
||||
SET remotecanonicalname = SUBSTR(remotecanonicalname, 1, INSTR(remotecanonicalname, ':') - 1)
|
||||
WHERE INSTR(remotecanonicalname, ':');
|
||||
|
||||
DELETE FROM remote
|
||||
WHERE remoteid NOT IN (
|
||||
SELECT remoteid FROM (
|
||||
SELECT MIN(archived), remoteid, remotecanonicalname
|
||||
FROM remote
|
||||
GROUP BY remotecanonicalname
|
||||
)
|
||||
);
|
3
wavesrv/db/migrations/000029_canonicalport.up.sql
Normal file
3
wavesrv/db/migrations/000029_canonicalport.up.sql
Normal file
@ -0,0 +1,3 @@
|
||||
UPDATE remote
|
||||
SET remotecanonicalname = remotecanonicalname || COALESCE( ":" || json_extract(sshopts, '$.sshport'), "")
|
||||
WHERE json_extract(sshopts, '$.sshport') != 22;
|
@ -33,3 +33,4 @@ require (
|
||||
)
|
||||
|
||||
replace github.com/wavetermdev/waveterm/waveshell => ../waveshell
|
||||
replace github.com/kevinburke/ssh_config => github.com/wavetermdev/ssh_config v0.0.0-20240109090616-36c8da3d7376
|
||||
|
@ -89,6 +89,7 @@ var ColorNames = []string{"yellow", "blue", "pink", "mint", "cyan", "violet", "o
|
||||
var TabIcons = []string{"square", "sparkle", "fire", "ghost", "cloud", "compass", "crown", "droplet", "graduation-cap", "heart", "file"}
|
||||
var RemoteColorNames = []string{"red", "green", "yellow", "blue", "magenta", "cyan", "white", "orange"}
|
||||
var RemoteSetArgs = []string{"alias", "connectmode", "key", "password", "autoinstall", "color"}
|
||||
var ConfirmFlags = []string{"hideshellprompt"}
|
||||
|
||||
var ScreenCmds = []string{"run", "comment", "cd", "cr", "clear", "sw", "reset", "signal", "chat"}
|
||||
var NoHistCmds = []string{"_compgen", "line", "history", "_killserver"}
|
||||
@ -115,8 +116,8 @@ var SetVarScopes = []SetVarScope{
|
||||
{ScopeName: "remote", VarNames: []string{}},
|
||||
}
|
||||
|
||||
var userHostRe = regexp.MustCompile("^(sudo@)?([a-z][a-z0-9._@-]*)@([a-z0-9][a-z0-9.-]*)(?::([0-9]+))?$")
|
||||
var remoteAliasRe = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_-]*$")
|
||||
var userHostRe = regexp.MustCompile(`^(sudo@)?([a-z][a-z0-9._@\\-]*)@([a-z0-9][a-z0-9.-]*)(?::([0-9]+))?$`)
|
||||
var remoteAliasRe = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
|
||||
var genericNameRe = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_ .()<>,/\"'\\[\\]{}=+$@!*-]*$")
|
||||
var rendererRe = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_.:-]*$")
|
||||
var positionRe = regexp.MustCompile("^((S?\\+|E?-)?[0-9]+|(\\+|-|S|E))$")
|
||||
@ -213,6 +214,7 @@ func init() {
|
||||
registerCmdFn("client:set", ClientSetCommand)
|
||||
registerCmdFn("client:notifyupdatewriter", ClientNotifyUpdateWriterCommand)
|
||||
registerCmdFn("client:accepttos", ClientAcceptTosCommand)
|
||||
registerCmdFn("client:setconfirmflag", ClientConfirmFlagCommand)
|
||||
|
||||
registerCmdFn("sidebar:open", SidebarOpenCommand)
|
||||
registerCmdFn("sidebar:close", SidebarCloseCommand)
|
||||
@ -1255,8 +1257,15 @@ func parseRemoteEditArgs(isNew bool, pk *scpacket.FeCommandPacketType, isLocal b
|
||||
if portVal == 0 && uhPort != 0 {
|
||||
portVal = uhPort
|
||||
}
|
||||
if portVal < 0 || portVal > 65535 {
|
||||
// 0 is used as a sentinel value for the default in this case
|
||||
return nil, fmt.Errorf("invalid port argument, \"%d\" is not in the range of 1 to 65535", portVal)
|
||||
}
|
||||
sshOpts.SSHPort = portVal
|
||||
canonicalName = remoteUser + "@" + remoteHost
|
||||
if portVal != 0 && portVal != 22 {
|
||||
canonicalName = canonicalName + ":" + strconv.Itoa(portVal)
|
||||
}
|
||||
if isSudo {
|
||||
canonicalName = "sudo@" + canonicalName
|
||||
}
|
||||
@ -1511,6 +1520,47 @@ type HostInfoType struct {
|
||||
Ignore bool
|
||||
}
|
||||
|
||||
func createSshImportSummary(changeList map[string][]string) string {
|
||||
totalNumChanges := len(changeList["create"]) + len(changeList["delete"]) + len(changeList["update"]) + len(changeList["createErr"]) + len(changeList["deleteErr"]) + len(changeList["updateErr"])
|
||||
if totalNumChanges == 0 {
|
||||
return "No changes made from ssh config import"
|
||||
}
|
||||
remoteStatusMsgs := map[string]string{
|
||||
"delete": "Deleted %d connection%s: %s",
|
||||
"create": "Created %d connection%s: %s",
|
||||
"update": "Edited %d connection%s: %s",
|
||||
"deleteErr": "Error deleting %d connection%s: %s",
|
||||
"createErr": "Error creating %d connection%s: %s",
|
||||
"updateErr": "Error editing %d connection%s: %s",
|
||||
}
|
||||
|
||||
changeTypeKeys := []string{"delete", "create", "update", "deleteErr", "createErr", "updateErr"}
|
||||
|
||||
var outMsgs []string
|
||||
for _, changeTypeKey := range changeTypeKeys {
|
||||
changes := changeList[changeTypeKey]
|
||||
if len(changes) > 0 {
|
||||
rawStatusMsg := remoteStatusMsgs[changeTypeKey]
|
||||
var pluralize string
|
||||
if len(changes) == 1 {
|
||||
pluralize = ""
|
||||
} else {
|
||||
pluralize = "s"
|
||||
}
|
||||
newMsg := fmt.Sprintf(rawStatusMsg, len(changes), pluralize, strings.Join(changes, ", "))
|
||||
outMsgs = append(outMsgs, newMsg)
|
||||
}
|
||||
}
|
||||
|
||||
var pluralize string
|
||||
if totalNumChanges == 1 {
|
||||
pluralize = ""
|
||||
} else {
|
||||
pluralize = "s"
|
||||
}
|
||||
return fmt.Sprintf("%d connection%s changed:\n\n%s", totalNumChanges, pluralize, strings.Join(outMsgs, "\n\n"))
|
||||
}
|
||||
|
||||
func NewHostInfo(hostName string) (*HostInfoType, error) {
|
||||
userName, _ := ssh_config.GetStrict(hostName, "User")
|
||||
if userName == "" {
|
||||
@ -1528,18 +1578,18 @@ func NewHostInfo(hostName string) (*HostInfoType, error) {
|
||||
|
||||
portStr, _ := ssh_config.GetStrict(hostName, "Port")
|
||||
var portVal int
|
||||
if portStr != "" {
|
||||
if portStr != "" && portStr != "22" {
|
||||
canonicalName = canonicalName + ":" + portStr
|
||||
var err error
|
||||
portVal, err = strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
// do not make assumptions about port if incorrectly configured
|
||||
return nil, fmt.Errorf("could not parse \"%s\" (%s) - %s could not be converted to a valid port\n", hostName, canonicalName, portStr)
|
||||
}
|
||||
if int(int16(portVal)) != portVal {
|
||||
if portVal <= 0 || portVal > 65535 {
|
||||
return nil, fmt.Errorf("could not parse port \"%d\": number is not valid for a port\n", portVal)
|
||||
}
|
||||
}
|
||||
|
||||
identityFile, _ := ssh_config.GetStrict(hostName, "IdentityFile")
|
||||
passwordAuth, _ := ssh_config.GetStrict(hostName, "PasswordAuthentication")
|
||||
|
||||
@ -1579,6 +1629,7 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
|
||||
localConfig := filepath.Join(home, ".ssh", "config")
|
||||
systemConfig := filepath.Join("/", "ssh", "config")
|
||||
sshConfigFiles := []string{localConfig, systemConfig}
|
||||
ssh_config.ReloadConfigs()
|
||||
hostPatterns, hostPatternsErr := resolveSshConfigPatterns(sshConfigFiles)
|
||||
if hostPatternsErr != nil {
|
||||
return nil, hostPatternsErr
|
||||
@ -1600,6 +1651,8 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
|
||||
hostInfoInConfig[hostInfo.CanonicalName] = hostInfo
|
||||
}
|
||||
|
||||
remoteChangeList := make(map[string][]string)
|
||||
|
||||
// remove all previously imported remotes that
|
||||
// no longer have a canonical pattern in the config files
|
||||
for importedRemoteCanonicalName, importedRemote := range previouslyImportedRemotes {
|
||||
@ -1608,17 +1661,17 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
|
||||
if !importedRemote.Archived && (hostInfo == nil || hostInfo.Ignore) {
|
||||
err = remote.ArchiveRemote(ctx, importedRemote.RemoteId)
|
||||
if err != nil {
|
||||
remoteChangeList["deleteErr"] = append(remoteChangeList["deleteErr"], importedRemote.RemoteCanonicalName)
|
||||
log.Printf("sshconfig-import: failed to remove remote \"%s\" (%s)\n", importedRemote.RemoteAlias, importedRemote.RemoteCanonicalName)
|
||||
} else {
|
||||
remoteChangeList["delete"] = append(remoteChangeList["delete"], importedRemote.RemoteCanonicalName)
|
||||
log.Printf("sshconfig-import: archived remote \"%s\" (%s)\n", importedRemote.RemoteAlias, importedRemote.RemoteCanonicalName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var updatedRemotes []string
|
||||
for _, hostInfo := range parsedHostData {
|
||||
previouslyImportedRemote := previouslyImportedRemotes[hostInfo.CanonicalName]
|
||||
updatedRemotes = append(updatedRemotes, hostInfo.CanonicalName)
|
||||
if hostInfo.Ignore {
|
||||
log.Printf("sshconfig-import: ignore remote[%s] as specified in config file\n", hostInfo.CanonicalName)
|
||||
continue
|
||||
@ -1626,27 +1679,31 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
|
||||
if previouslyImportedRemote != nil && !previouslyImportedRemote.Archived {
|
||||
// this already existed and was created via import
|
||||
// it needs to be updated instead of created
|
||||
|
||||
editMap := make(map[string]interface{})
|
||||
editMap[sstore.RemoteField_Alias] = hostInfo.Host
|
||||
editMap[sstore.RemoteField_ConnectMode] = hostInfo.ConnectMode
|
||||
// changing port is unique to imports because it lets us avoid conflicts
|
||||
// if the port is changed in the ssh config
|
||||
editMap[sstore.RemoteField_SSHPort] = hostInfo.Port
|
||||
if hostInfo.SshKeyFile != "" {
|
||||
editMap[sstore.RemoteField_SSHKey] = hostInfo.SshKeyFile
|
||||
}
|
||||
msh := remote.GetRemoteById(previouslyImportedRemote.RemoteId)
|
||||
if msh == nil {
|
||||
remoteChangeList["updateErr"] = append(remoteChangeList["updateErr"], hostInfo.CanonicalName)
|
||||
log.Printf("strange, msh for remote %s [%s] not found\n", hostInfo.CanonicalName, previouslyImportedRemote.RemoteId)
|
||||
continue
|
||||
} else {
|
||||
}
|
||||
|
||||
if msh.Remote.ConnectMode == hostInfo.ConnectMode && msh.Remote.SSHOpts.SSHIdentity == hostInfo.SshKeyFile && msh.Remote.RemoteAlias == hostInfo.Host {
|
||||
// silently skip this one. it didn't fail, but no changes were needed
|
||||
continue
|
||||
}
|
||||
|
||||
err := msh.UpdateRemote(ctx, editMap)
|
||||
if err != nil {
|
||||
remoteChangeList["updateErr"] = append(remoteChangeList["updateErr"], hostInfo.CanonicalName)
|
||||
log.Printf("error updating remote[%s]: %v\n", hostInfo.CanonicalName, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
remoteChangeList["update"] = append(remoteChangeList["update"], hostInfo.CanonicalName)
|
||||
log.Printf("sshconfig-import: found previously imported remote with canonical name \"%s\": it has been updated\n", hostInfo.CanonicalName)
|
||||
} else {
|
||||
sshOpts := &sstore.SSHOpts{
|
||||
@ -1675,21 +1732,31 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
|
||||
}
|
||||
err := remote.AddRemote(ctx, r, false)
|
||||
if err != nil {
|
||||
remoteChangeList["createErr"] = append(remoteChangeList["createErr"], hostInfo.CanonicalName)
|
||||
log.Printf("sshconfig-import: failed to add remote \"%s\" (%s): it is being skipped\n", hostInfo.Host, hostInfo.CanonicalName)
|
||||
continue
|
||||
}
|
||||
remoteChangeList["create"] = append(remoteChangeList["create"], hostInfo.CanonicalName)
|
||||
log.Printf("sshconfig-import: created remote \"%s\" (%s)\n", hostInfo.Host, hostInfo.CanonicalName)
|
||||
}
|
||||
}
|
||||
|
||||
update := &sstore.ModelUpdate{Remotes: remote.GetAllRemoteRuntimeState()}
|
||||
update.Info = &sstore.InfoMsgType{}
|
||||
if len(updatedRemotes) == 0 {
|
||||
update.Info.InfoMsg = "no connections imported from ssh config."
|
||||
} else {
|
||||
update.Info.InfoMsg = fmt.Sprintf("imported %d connection(s) from ssh config file: %s\n", len(updatedRemotes), strings.Join(updatedRemotes, ", "))
|
||||
outMsg := createSshImportSummary(remoteChangeList)
|
||||
visualEdit := resolveBool(pk.Kwargs["visual"], false)
|
||||
if visualEdit {
|
||||
update := &sstore.ModelUpdate{}
|
||||
update.AlertMessage = &sstore.AlertMessageType{
|
||||
Title: "SSH Config Import",
|
||||
Message: outMsg,
|
||||
Markdown: true,
|
||||
}
|
||||
return update, nil
|
||||
} else {
|
||||
update := &sstore.ModelUpdate{}
|
||||
update.Info = &sstore.InfoMsgType{}
|
||||
update.Info.InfoMsg = outMsg
|
||||
return update, nil
|
||||
}
|
||||
}
|
||||
|
||||
func ScreenShowAllCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
|
||||
@ -1934,6 +2001,114 @@ func doOpenAICompletion(cmd *sstore.CmdType, opts *sstore.OpenAIOptsType, prompt
|
||||
return
|
||||
}
|
||||
|
||||
func writePacketToUpdateBus(ctx context.Context, cmd *sstore.CmdType, pk *packet.OpenAICmdInfoChatMessage) {
|
||||
update, err := sstore.UpdateWithAddNewOpenAICmdInfoPacket(ctx, cmd.ScreenId, pk)
|
||||
if err != nil {
|
||||
log.Printf("Open AI Update packet err: %v\n", err)
|
||||
}
|
||||
sstore.MainBus.SendScreenUpdate(cmd.ScreenId, update)
|
||||
}
|
||||
|
||||
func updateAsstResponseAndWriteToUpdateBus(ctx context.Context, cmd *sstore.CmdType, pk *packet.OpenAICmdInfoChatMessage, messageID int) {
|
||||
update, err := sstore.UpdateWithUpdateOpenAICmdInfoPacket(ctx, cmd.ScreenId, messageID, pk)
|
||||
if err != nil {
|
||||
log.Printf("Open AI Update packet err: %v\n", err)
|
||||
}
|
||||
sstore.MainBus.SendScreenUpdate(cmd.ScreenId, update)
|
||||
}
|
||||
|
||||
func getCmdInfoEngineeredPrompt(userQuery string, curLineStr string) string {
|
||||
rtn := "You are an expert on the command line terminal. Your task is to help me write a command."
|
||||
if curLineStr != "" {
|
||||
rtn += "My current command is: " + curLineStr
|
||||
}
|
||||
rtn += ". My question is: " + userQuery + "."
|
||||
return rtn
|
||||
}
|
||||
|
||||
func doOpenAICmdInfoCompletion(cmd *sstore.CmdType, clientId string, opts *sstore.OpenAIOptsType, prompt []packet.OpenAIPromptMessageType, curLineStr string) {
|
||||
var hadError bool
|
||||
log.Println("had error: ", hadError)
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), OpenAIStreamTimeout)
|
||||
defer cancelFn()
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r != nil {
|
||||
panicMsg := fmt.Sprintf("panic: %v", r)
|
||||
log.Printf("panic in doOpenAICompletion: %s\n", panicMsg)
|
||||
hadError = true
|
||||
}
|
||||
}()
|
||||
var ch chan *packet.OpenAIPacketType
|
||||
var err error
|
||||
if opts.APIToken == "" {
|
||||
var conn *websocket.Conn
|
||||
ch, conn, err = openai.RunCloudCompletionStream(ctx, clientId, opts, prompt)
|
||||
if conn != nil {
|
||||
defer conn.Close()
|
||||
}
|
||||
} else {
|
||||
ch, err = openai.RunCompletionStream(ctx, opts, prompt)
|
||||
}
|
||||
asstOutputPk := &packet.OpenAICmdInfoPacketOutputType{
|
||||
Model: "",
|
||||
Created: 0,
|
||||
FinishReason: "",
|
||||
Message: "",
|
||||
}
|
||||
asstOutputMessageID := sstore.ScreenMemGetCmdInfoMessageCount(cmd.ScreenId)
|
||||
asstMessagePk := &packet.OpenAICmdInfoChatMessage{IsAssistantResponse: true, AssistantResponse: asstOutputPk, MessageID: asstOutputMessageID}
|
||||
if err != nil {
|
||||
asstOutputPk.Error = fmt.Sprintf("Error calling OpenAI API: %v", err)
|
||||
writePacketToUpdateBus(ctx, cmd, asstMessagePk)
|
||||
return
|
||||
}
|
||||
writePacketToUpdateBus(ctx, cmd, asstMessagePk)
|
||||
doneWaitingForPackets := false
|
||||
for !doneWaitingForPackets {
|
||||
select {
|
||||
case <-time.After(OpenAIPacketTimeout):
|
||||
// timeout reading from channel
|
||||
hadError = true
|
||||
doneWaitingForPackets = true
|
||||
asstOutputPk.Error = "timeout waiting for server response"
|
||||
updateAsstResponseAndWriteToUpdateBus(ctx, cmd, asstMessagePk, asstOutputMessageID)
|
||||
break
|
||||
case pk, ok := <-ch:
|
||||
if ok {
|
||||
// got a packet
|
||||
if pk.Error != "" {
|
||||
hadError = true
|
||||
asstOutputPk.Error = pk.Error
|
||||
}
|
||||
if pk.Model != "" && pk.Index == 0 {
|
||||
asstOutputPk.Model = pk.Model
|
||||
asstOutputPk.Created = pk.Created
|
||||
asstOutputPk.FinishReason = pk.FinishReason
|
||||
if pk.Text != "" {
|
||||
asstOutputPk.Message += pk.Text
|
||||
}
|
||||
}
|
||||
if pk.Index == 0 {
|
||||
if pk.FinishReason != "" {
|
||||
asstOutputPk.FinishReason = pk.FinishReason
|
||||
}
|
||||
if pk.Text != "" {
|
||||
asstOutputPk.Message += pk.Text
|
||||
}
|
||||
}
|
||||
asstMessagePk.AssistantResponse = asstOutputPk
|
||||
updateAsstResponseAndWriteToUpdateBus(ctx, cmd, asstMessagePk, asstOutputMessageID)
|
||||
|
||||
} else {
|
||||
// channel closed
|
||||
doneWaitingForPackets = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func doOpenAIStreamCompletion(cmd *sstore.CmdType, clientId string, opts *sstore.OpenAIOptsType, prompt []packet.OpenAIPromptMessageType) {
|
||||
var outputPos int64
|
||||
var hadError bool
|
||||
@ -2019,6 +2194,23 @@ func doOpenAIStreamCompletion(cmd *sstore.CmdType, clientId string, opts *sstore
|
||||
return
|
||||
}
|
||||
|
||||
func BuildOpenAIPromptArrayWithContext(messages []*packet.OpenAICmdInfoChatMessage) []packet.OpenAIPromptMessageType {
|
||||
rtn := make([]packet.OpenAIPromptMessageType, 0)
|
||||
for _, msg := range messages {
|
||||
content := msg.UserEngineeredQuery
|
||||
if msg.UserEngineeredQuery == "" {
|
||||
content = msg.UserQuery
|
||||
}
|
||||
msgRole := sstore.OpenAIRoleUser
|
||||
if msg.IsAssistantResponse {
|
||||
msgRole = sstore.OpenAIRoleAssistant
|
||||
content = msg.AssistantResponse.Message
|
||||
}
|
||||
rtn = append(rtn, packet.OpenAIPromptMessageType{Role: msgRole, Content: content})
|
||||
}
|
||||
return rtn
|
||||
}
|
||||
|
||||
func OpenAICommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
|
||||
ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen)
|
||||
if err != nil {
|
||||
@ -2044,9 +2236,6 @@ func OpenAICommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstor
|
||||
opts.MaxTokens = openai.DefaultMaxTokens
|
||||
}
|
||||
promptStr := firstArg(pk)
|
||||
if promptStr == "" {
|
||||
return nil, fmt.Errorf("openai error, prompt string is blank")
|
||||
}
|
||||
ptermVal := defaultStr(pk.Kwargs["wterm"], DefaultPTERM)
|
||||
pkTermOpts, err := GetUITermOpts(pk.UIContext.WinSize, ptermVal)
|
||||
if err != nil {
|
||||
@ -2057,11 +2246,40 @@ func OpenAICommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstor
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openai error, cannot make dyn cmd")
|
||||
}
|
||||
if resolveBool(pk.Kwargs["cmdinfo"], false) {
|
||||
if promptStr == "" {
|
||||
// this is requesting an update without wanting an openai query
|
||||
update, err := sstore.UpdateWithCurrentOpenAICmdInfoChat(cmd.ScreenId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting update for CmdInfoChat %v", err)
|
||||
}
|
||||
return update, nil
|
||||
}
|
||||
curLineStr := defaultStr(pk.Kwargs["curline"], "")
|
||||
userQueryPk := &packet.OpenAICmdInfoChatMessage{UserQuery: promptStr, MessageID: sstore.ScreenMemGetCmdInfoMessageCount(cmd.ScreenId)}
|
||||
engineeredQuery := getCmdInfoEngineeredPrompt(promptStr, curLineStr)
|
||||
userQueryPk.UserEngineeredQuery = engineeredQuery
|
||||
writePacketToUpdateBus(ctx, cmd, userQueryPk)
|
||||
prompt := BuildOpenAIPromptArrayWithContext(sstore.ScreenMemGetCmdInfoChat(cmd.ScreenId).Messages)
|
||||
go doOpenAICmdInfoCompletion(cmd, clientData.ClientId, opts, prompt, curLineStr)
|
||||
update := &sstore.ModelUpdate{}
|
||||
return update, nil
|
||||
}
|
||||
prompt := []packet.OpenAIPromptMessageType{{Role: sstore.OpenAIRoleUser, Content: promptStr}}
|
||||
if resolveBool(pk.Kwargs["cmdinfoclear"], false) {
|
||||
update, err := sstore.UpdateWithClearOpenAICmdInfo(cmd.ScreenId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error clearing CmdInfoChat: %v", err)
|
||||
}
|
||||
return update, nil
|
||||
}
|
||||
if promptStr == "" {
|
||||
return nil, fmt.Errorf("openai error, prompt string is blank")
|
||||
}
|
||||
line, err := sstore.AddOpenAILine(ctx, ids.ScreenId, DefaultUserId, cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot add new line: %v", err)
|
||||
}
|
||||
prompt := []packet.OpenAIPromptMessageType{{Role: sstore.OpenAIRoleUser, Content: promptStr}}
|
||||
if resolveBool(pk.Kwargs["stream"], true) {
|
||||
go doOpenAIStreamCompletion(cmd, clientData.ClientId, opts, prompt)
|
||||
} else {
|
||||
@ -3441,7 +3659,7 @@ func LineDeleteCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s
|
||||
}
|
||||
err = sstore.DeleteLinesByIds(ctx, ids.ScreenId, lineIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("/line:delete error purging lines: %v", err)
|
||||
return nil, fmt.Errorf("/line:delete error deleting lines: %v", err)
|
||||
}
|
||||
update := &sstore.ModelUpdate{}
|
||||
for _, lineId := range lineIds {
|
||||
@ -3452,6 +3670,11 @@ func LineDeleteCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s
|
||||
}
|
||||
update.Lines = append(update.Lines, lineObj)
|
||||
}
|
||||
screen, err := sstore.FixupScreenSelectedLine(ctx, ids.ScreenId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("/line:delete error fixing up screen: %v", err)
|
||||
}
|
||||
update.Screens = []*sstore.ScreenType{screen}
|
||||
return update, nil
|
||||
}
|
||||
|
||||
@ -4019,6 +4242,57 @@ func ClientAcceptTosCommand(ctx context.Context, pk *scpacket.FeCommandPacketTyp
|
||||
return update, nil
|
||||
}
|
||||
|
||||
var confirmKeyRe = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
||||
|
||||
// confirm flags must be all lowercase and only contain letters, numbers, and underscores (and start with letter)
|
||||
func ClientConfirmFlagCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
|
||||
// Check for valid arguments length
|
||||
if len(pk.Args) < 2 {
|
||||
return nil, fmt.Errorf("invalid arguments: expected at least 2, got %d", len(pk.Args))
|
||||
}
|
||||
|
||||
// Extract confirmKey and value from pk.Args
|
||||
confirmKey := pk.Args[0]
|
||||
if !confirmKeyRe.MatchString(confirmKey) {
|
||||
return nil, fmt.Errorf("invalid confirm flag key: %s", confirmKey)
|
||||
}
|
||||
value := resolveBool(pk.Args[1], true)
|
||||
validKey := utilfn.ContainsStr(ConfirmFlags, confirmKey)
|
||||
if !validKey {
|
||||
return nil, fmt.Errorf("invalid confirm flag key: %s", confirmKey)
|
||||
}
|
||||
|
||||
clientData, err := sstore.EnsureClientData(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot retrieve client data: %v", err)
|
||||
}
|
||||
|
||||
// Initialize ConfirmFlags if it's nil
|
||||
if clientData.ClientOpts.ConfirmFlags == nil {
|
||||
clientData.ClientOpts.ConfirmFlags = make(map[string]bool)
|
||||
}
|
||||
|
||||
// Set the confirm flag
|
||||
clientData.ClientOpts.ConfirmFlags[confirmKey] = value
|
||||
|
||||
err = sstore.SetClientOpts(ctx, clientData.ClientOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client data: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve updated client data
|
||||
clientData, err = sstore.EnsureClientData(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot retrieve updated client data: %v", err)
|
||||
}
|
||||
|
||||
update := &sstore.ModelUpdate{
|
||||
ClientData: clientData,
|
||||
}
|
||||
|
||||
return update, nil
|
||||
}
|
||||
|
||||
func validateOpenAIAPIToken(key string) error {
|
||||
if len(key) > MaxOpenAIAPITokenLen {
|
||||
return fmt.Errorf("invalid openai token, too long")
|
||||
|
@ -11,10 +11,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/remote"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/scpacket"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/sstore"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -21,6 +21,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/alessio/shellescape"
|
||||
"github.com/armon/circbuf"
|
||||
"github.com/creack/pty"
|
||||
"github.com/google/uuid"
|
||||
@ -66,9 +67,9 @@ func MakeLocalMShellCommandStr(isSudo bool) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
if isSudo {
|
||||
return fmt.Sprintf(`%s; sudo %s --server`, PrintPingPacket, mshellPath), nil
|
||||
return fmt.Sprintf(`%s; sudo %s --server`, PrintPingPacket, shellescape.Quote(mshellPath)), nil
|
||||
} else {
|
||||
return fmt.Sprintf(`%s; %s --server`, PrintPingPacket, mshellPath), nil
|
||||
return fmt.Sprintf(`%s; %s --server`, PrintPingPacket, shellescape.Quote(mshellPath)), nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -1125,6 +1126,9 @@ func addScVarsToState(state *packet.ShellState) *packet.ShellState {
|
||||
envMap := shexec.DeclMapFromState(&rtn)
|
||||
envMap["PROMPT"] = &shexec.DeclareDeclType{Name: "PROMPT", Value: "1", Args: "x"}
|
||||
envMap["PROMPT_VERSION"] = &shexec.DeclareDeclType{Name: "PROMPT_VERSION", Value: scbase.WaveVersion, Args: "x"}
|
||||
if _, exists := envMap["LANG"]; !exists {
|
||||
envMap["LANG"] = &shexec.DeclareDeclType{Name: "LANG", Value: scbase.DetermineLang(), Args: "x"}
|
||||
}
|
||||
rtn.ShellVars = shexec.SerializeDeclMap(envMap)
|
||||
return &rtn
|
||||
}
|
||||
|
@ -377,3 +377,30 @@ func MacOSRelease() string {
|
||||
})
|
||||
return osRelease
|
||||
}
|
||||
|
||||
var osLangOnce = &sync.Once{}
|
||||
var osLang string
|
||||
|
||||
func determineLang() string {
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancelFn()
|
||||
if runtime.GOOS == "darwin" {
|
||||
out, err := exec.CommandContext(ctx, "defaults", "read", "-g", "AppleLocale").CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("error executing 'defaults read -g AppleLocale': %v\n", err)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out)) + ".UTF-8"
|
||||
} else {
|
||||
// this is specifically to get the wavesrv LANG so waveshell
|
||||
// on a remote uses the same LANG
|
||||
return os.Getenv("LANG")
|
||||
}
|
||||
}
|
||||
|
||||
func DetermineLang() string {
|
||||
osLangOnce.Do(func() {
|
||||
osLang = determineLang()
|
||||
})
|
||||
return osLang
|
||||
}
|
||||
|
@ -240,6 +240,7 @@ func (c *parseContext) tokenizeDQ() ([]*WordType, bool) {
|
||||
|
||||
// returns (words, eofexit)
|
||||
// backticks (WordTypeBQ) handle backslash in a special way, but that seems to mainly effect execution (not completion)
|
||||
//
|
||||
// de_backslash => removes initial backslash in \`, \\, and \$ before execution
|
||||
func (c *parseContext) tokenizeRaw() ([]*WordType, bool) {
|
||||
state := &tokenizeOutputState{}
|
||||
|
@ -748,6 +748,7 @@ func InsertScreen(ctx context.Context, sessionId string, origScreenName string,
|
||||
return nil, txErr
|
||||
}
|
||||
update.Sessions = []*SessionType{bareSession}
|
||||
update.OpenAICmdInfoChat = ScreenMemGetCmdInfoChat(newScreenId).Messages
|
||||
}
|
||||
return update, nil
|
||||
}
|
||||
@ -854,6 +855,29 @@ func GetCmdByScreenId(ctx context.Context, screenId string, lineId string) (*Cmd
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateWithClearOpenAICmdInfo(screenId string) (*ModelUpdate, error) {
|
||||
ScreenMemClearCmdInfoChat(screenId)
|
||||
return UpdateWithCurrentOpenAICmdInfoChat(screenId)
|
||||
}
|
||||
|
||||
func UpdateWithAddNewOpenAICmdInfoPacket(ctx context.Context, screenId string, pk *packet.OpenAICmdInfoChatMessage) (*ModelUpdate, error) {
|
||||
ScreenMemAddCmdInfoChatMessage(screenId, pk)
|
||||
return UpdateWithCurrentOpenAICmdInfoChat(screenId)
|
||||
}
|
||||
|
||||
func UpdateWithCurrentOpenAICmdInfoChat(screenId string) (*ModelUpdate, error) {
|
||||
cmdInfoUpdate := ScreenMemGetCmdInfoChat(screenId).Messages
|
||||
return &ModelUpdate{OpenAICmdInfoChat: cmdInfoUpdate}, nil
|
||||
}
|
||||
|
||||
func UpdateWithUpdateOpenAICmdInfoPacket(ctx context.Context, screenId string, messageID int, pk *packet.OpenAICmdInfoChatMessage) (*ModelUpdate, error) {
|
||||
err := ScreenMemUpdateCmdInfoChatMessage(screenId, messageID, pk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return UpdateWithCurrentOpenAICmdInfoChat(screenId)
|
||||
}
|
||||
|
||||
func UpdateCmdDoneInfo(ctx context.Context, ck base.CommandKey, donePk *packet.CmdDonePacketType, status string) (*ModelUpdate, error) {
|
||||
if donePk == nil {
|
||||
return nil, fmt.Errorf("invalid cmddone packet")
|
||||
@ -1039,6 +1063,7 @@ func SwitchScreenById(ctx context.Context, sessionId string, screenId string) (*
|
||||
memState := GetScreenMemState(screenId)
|
||||
if memState != nil {
|
||||
update.CmdLine = &memState.CmdInputText
|
||||
update.OpenAICmdInfoChat = ScreenMemGetCmdInfoChat(screenId).Messages
|
||||
}
|
||||
return update, nil
|
||||
}
|
||||
@ -1704,7 +1729,6 @@ const (
|
||||
RemoteField_ConnectMode = "connectmode" // string
|
||||
RemoteField_SSHKey = "sshkey" // string
|
||||
RemoteField_SSHPassword = "sshpassword" // string
|
||||
RemoteField_SSHPort = "sshport" // string
|
||||
RemoteField_Color = "color" // string
|
||||
)
|
||||
|
||||
@ -1736,10 +1760,6 @@ func UpdateRemote(ctx context.Context, remoteId string, editMap map[string]inter
|
||||
query = `UPDATE remote SET sshopts = json_set(sshopts, '$.sshpassword', ?) WHERE remoteid = ?`
|
||||
tx.Exec(query, sshPassword, remoteId)
|
||||
}
|
||||
if sshPort, found := editMap[RemoteField_SSHPort]; found {
|
||||
query = `UPDATE remote SET sshopts = json_set(sshopts, '$.sshport', ?) WHERE remoteid = ?`
|
||||
tx.Exec(query, sshPort, remoteId)
|
||||
}
|
||||
if color, found := editMap[RemoteField_Color]; found {
|
||||
query = `UPDATE remote SET remoteopts = json_set(remoteopts, '$.color', ?) WHERE remoteid = ?`
|
||||
tx.Exec(query, color, remoteId)
|
||||
@ -2044,6 +2064,29 @@ func SetLineArchivedById(ctx context.Context, screenId string, lineId string, ar
|
||||
return txErr
|
||||
}
|
||||
|
||||
// returns updated screen (only if updated)
|
||||
func FixupScreenSelectedLine(ctx context.Context, screenId string) (*ScreenType, error) {
|
||||
return WithTxRtn(ctx, func(tx *TxWrap) (*ScreenType, error) {
|
||||
query := `SELECT selectedline FROM screen WHERE screenid = ?`
|
||||
sline := tx.GetInt(query, screenId)
|
||||
query = `SELECT linenum FROM line WHERE screenid = ? AND linenum = ?`
|
||||
if tx.Exists(query, screenId, sline) {
|
||||
// selected line is valid
|
||||
return nil, nil
|
||||
}
|
||||
query = `SELECT min(linenum) FROM line WHERE screenid = ? AND linenum > ?`
|
||||
newSLine := tx.GetInt(query, screenId, sline)
|
||||
if newSLine == 0 {
|
||||
query = `SELECT max(linenum) FROM line WHERE screenid = ? AND linenum < ?`
|
||||
newSLine = tx.GetInt(query, screenId, sline)
|
||||
}
|
||||
// newSLine might be 0, but that's ok (because that means there are no lines)
|
||||
query = `UPDATE screen SET selectedline = ? WHERE screenid = ?`
|
||||
tx.Exec(query, newSLine, screenId)
|
||||
return GetScreenById(tx.Context(), screenId)
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteLinesByIds(ctx context.Context, screenId string, lineIds []string) error {
|
||||
txErr := WithTx(ctx, func(tx *TxWrap) error {
|
||||
isWS := isWebShare(tx, screenId)
|
||||
@ -2051,9 +2094,8 @@ func DeleteLinesByIds(ctx context.Context, screenId string, lineIds []string) er
|
||||
query := `SELECT status FROM cmd WHERE screenid = ? AND lineid = ?`
|
||||
cmdStatus := tx.GetString(query, screenId, lineId)
|
||||
if cmdStatus == CmdStatusRunning {
|
||||
return fmt.Errorf("cannot delete line[%s:%s], cmd is running", screenId, lineId)
|
||||
return fmt.Errorf("cannot delete line[%s], cmd is running", lineId)
|
||||
}
|
||||
|
||||
query = `DELETE FROM line WHERE screenid = ? AND lineid = ?`
|
||||
tx.Exec(query, screenId, lineId)
|
||||
query = `DELETE FROM cmd WHERE screenid = ? AND lineid = ?`
|
||||
@ -2061,7 +2103,6 @@ func DeleteLinesByIds(ctx context.Context, screenId string, lineIds []string) er
|
||||
// don't delete history anymore, just remove lineid reference
|
||||
query = `UPDATE history SET lineid = '', linenum = 0 WHERE screenid = ? AND lineid = ?`
|
||||
tx.Exec(query, screenId, lineId)
|
||||
|
||||
if isWS {
|
||||
insertScreenLineUpdate(tx, screenId, lineId, UpdateType_LineDel)
|
||||
}
|
||||
|
@ -5,9 +5,11 @@
|
||||
package sstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/utilfn"
|
||||
)
|
||||
|
||||
@ -43,11 +45,109 @@ func isIndicatorGreater(i1 string, i2 string) bool {
|
||||
return screenIndicatorLevels[i1] > screenIndicatorLevels[i2]
|
||||
}
|
||||
|
||||
type OpenAICmdInfoChatStore struct {
|
||||
MessageCount int `json:"messagecount"`
|
||||
Messages []*packet.OpenAICmdInfoChatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type ScreenMemState struct {
|
||||
NumRunningCommands int `json:"numrunningcommands,omitempty"`
|
||||
IndicatorType string `json:"indicatortype,omitempty"`
|
||||
CmdInputText utilfn.StrWithPos `json:"cmdinputtext,omitempty"`
|
||||
CmdInputSeqNum int `json:"cmdinputseqnum,omitempty"`
|
||||
AICmdInfoChat *OpenAICmdInfoChatStore `json:"aicmdinfochat,omitempty"`
|
||||
}
|
||||
|
||||
func ScreenMemDeepCopyCmdInfoChatStore(store *OpenAICmdInfoChatStore) *OpenAICmdInfoChatStore {
|
||||
rtnMessages := []*packet.OpenAICmdInfoChatMessage{}
|
||||
for index := 0; index < len(store.Messages); index++ {
|
||||
messageToCopy := *store.Messages[index]
|
||||
if messageToCopy.AssistantResponse != nil {
|
||||
assistantResponseCopy := *messageToCopy.AssistantResponse
|
||||
messageToCopy.AssistantResponse = &assistantResponseCopy
|
||||
}
|
||||
rtnMessages = append(rtnMessages, &messageToCopy)
|
||||
}
|
||||
rtn := &OpenAICmdInfoChatStore{MessageCount: store.MessageCount, Messages: rtnMessages}
|
||||
return rtn
|
||||
}
|
||||
|
||||
func ScreenMemInitCmdInfoChat(screenId string) {
|
||||
greetingMessagePk := &packet.OpenAICmdInfoChatMessage{
|
||||
MessageID: 0,
|
||||
IsAssistantResponse: true,
|
||||
AssistantResponse: &packet.OpenAICmdInfoPacketOutputType{
|
||||
Message: packet.OpenAICmdInfoChatGreetingMessage,
|
||||
},
|
||||
}
|
||||
ScreenMemStore[screenId].AICmdInfoChat = &OpenAICmdInfoChatStore{MessageCount: 1, Messages: []*packet.OpenAICmdInfoChatMessage{greetingMessagePk}}
|
||||
}
|
||||
|
||||
func ScreenMemClearCmdInfoChat(screenId string) {
|
||||
MemLock.Lock()
|
||||
defer MemLock.Unlock()
|
||||
if ScreenMemStore[screenId] == nil {
|
||||
ScreenMemStore[screenId] = &ScreenMemState{}
|
||||
}
|
||||
ScreenMemInitCmdInfoChat(screenId)
|
||||
}
|
||||
|
||||
func ScreenMemAddCmdInfoChatMessage(screenId string, msg *packet.OpenAICmdInfoChatMessage) {
|
||||
MemLock.Lock()
|
||||
defer MemLock.Unlock()
|
||||
if ScreenMemStore[screenId] == nil {
|
||||
ScreenMemStore[screenId] = &ScreenMemState{}
|
||||
}
|
||||
if ScreenMemStore[screenId].AICmdInfoChat == nil {
|
||||
log.Printf("AICmdInfoChat is null, creating")
|
||||
ScreenMemInitCmdInfoChat(screenId)
|
||||
}
|
||||
|
||||
CmdInfoChat := ScreenMemStore[screenId].AICmdInfoChat
|
||||
CmdInfoChat.Messages = append(CmdInfoChat.Messages, msg)
|
||||
CmdInfoChat.MessageCount++
|
||||
}
|
||||
|
||||
func ScreenMemGetCmdInfoMessageCount(screenId string) int {
|
||||
MemLock.Lock()
|
||||
defer MemLock.Unlock()
|
||||
if ScreenMemStore[screenId] == nil {
|
||||
ScreenMemStore[screenId] = &ScreenMemState{}
|
||||
}
|
||||
if ScreenMemStore[screenId].AICmdInfoChat == nil {
|
||||
ScreenMemInitCmdInfoChat(screenId)
|
||||
}
|
||||
return ScreenMemStore[screenId].AICmdInfoChat.MessageCount
|
||||
}
|
||||
|
||||
func ScreenMemGetCmdInfoChat(screenId string) *OpenAICmdInfoChatStore {
|
||||
MemLock.Lock()
|
||||
defer MemLock.Unlock()
|
||||
if ScreenMemStore[screenId] == nil {
|
||||
ScreenMemStore[screenId] = &ScreenMemState{}
|
||||
}
|
||||
if ScreenMemStore[screenId].AICmdInfoChat == nil {
|
||||
ScreenMemInitCmdInfoChat(screenId)
|
||||
}
|
||||
return ScreenMemDeepCopyCmdInfoChatStore(ScreenMemStore[screenId].AICmdInfoChat)
|
||||
}
|
||||
|
||||
func ScreenMemUpdateCmdInfoChatMessage(screenId string, messageID int, msg *packet.OpenAICmdInfoChatMessage) error {
|
||||
MemLock.Lock()
|
||||
defer MemLock.Unlock()
|
||||
if ScreenMemStore[screenId] == nil {
|
||||
ScreenMemStore[screenId] = &ScreenMemState{}
|
||||
}
|
||||
if ScreenMemStore[screenId].AICmdInfoChat == nil {
|
||||
ScreenMemInitCmdInfoChat(screenId)
|
||||
}
|
||||
CmdInfoChat := ScreenMemStore[screenId].AICmdInfoChat
|
||||
if messageID >= 0 && messageID < len(CmdInfoChat.Messages) {
|
||||
CmdInfoChat.Messages[messageID] = msg
|
||||
} else {
|
||||
return fmt.Errorf("ScreenMemUpdateCmdInfoChatMessage: error: Message Id out of range: %d", messageID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ScreenMemSetCmdInputText(screenId string, sp utilfn.StrWithPos, seqNum int) {
|
||||
|
@ -22,7 +22,7 @@ import (
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
)
|
||||
|
||||
const MaxMigration = 28
|
||||
const MaxMigration = 29
|
||||
const MigratePrimaryScreenVersion = 9
|
||||
const CmdScreenSpecialMigration = 13
|
||||
const CmdLineSpecialMigration = 20
|
||||
|
@ -270,6 +270,7 @@ type ClientOptsType struct {
|
||||
NoTelemetry bool `json:"notelemetry,omitempty"`
|
||||
NoReleaseCheck bool `json:"noreleasecheck,omitempty"`
|
||||
AcceptedTos int64 `json:"acceptedtos,omitempty"`
|
||||
ConfirmFlags map[string]bool `json:"confirmflags,omitempty"`
|
||||
}
|
||||
|
||||
type FeOptsType struct {
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/utilfn"
|
||||
)
|
||||
|
||||
@ -60,6 +61,8 @@ type ModelUpdate struct {
|
||||
RemoteView *RemoteViewType `json:"remoteview,omitempty"`
|
||||
ScreenTombstones []*ScreenTombstoneType `json:"screentombstones,omitempty"`
|
||||
SessionTombstones []*SessionTombstoneType `json:"sessiontombstones,omitempty"`
|
||||
OpenAICmdInfoChat []*packet.OpenAICmdInfoChatMessage `json:"openaicmdinfochat,omitempty"`
|
||||
AlertMessage *AlertMessageType `json:"alertmessage,omitempty"`
|
||||
}
|
||||
|
||||
func (*ModelUpdate) UpdateType() string {
|
||||
@ -128,6 +131,13 @@ type RemoteEditType struct {
|
||||
HasPassword bool `json:"haspassword,omitempty"`
|
||||
}
|
||||
|
||||
type AlertMessageType struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Confirm bool `json:"confirm,omitempty"`
|
||||
Markdown bool `json:"markdown,omitempty"`
|
||||
}
|
||||
|
||||
type InfoMsgType struct {
|
||||
InfoTitle string `json:"infotitle"`
|
||||
InfoError string `json:"infoerror,omitempty"`
|
||||
|
Loading…
Reference in New Issue
Block a user