Merge branch 'main' into evan/newton

This commit is contained in:
Evan Simkowitz 2024-01-12 12:09:57 -08:00
commit 05707de824
No known key found for this signature in database
61 changed files with 5352 additions and 928 deletions

57
.github/workflows/build-macos-x64.yml vendored Normal file
View 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

View File

@ -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/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/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/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= 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/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

@ -13,6 +13,7 @@
"@tanstack/react-table": "^8.10.3", "@tanstack/react-table": "^8.10.3",
"@types/semver": "^7.5.6", "@types/semver": "^7.5.6",
"autobind-decorator": "^2.4.0", "autobind-decorator": "^2.4.0",
"base64-js": "^1.5.1",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"dayjs": "^1.11.3", "dayjs": "^1.11.3",
"dompurify": "^3.0.2", "dompurify": "^3.0.2",
@ -26,7 +27,6 @@
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"react": "^18.1.0", "react": "^18.1.0",
"react-dom": "^18.1.0", "react-dom": "^18.1.0",
"react-json-view": "^1.21.3",
"react-markdown": "^9.0.0", "react-markdown": "^9.0.0",
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
@ -35,7 +35,8 @@
"tsx-control-statements": "^4.1.1", "tsx-control-statements": "^4.1.1",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"winston": "^3.8.2", "winston": "^3.8.2",
"xterm": "^5.0.0" "xterm": "^5.0.0",
"xterm-addon-web-links": "^0.9.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.17.10", "@babel/cli": "^7.17.10",
@ -49,17 +50,17 @@
"@babel/preset-env": "^7.18.2", "@babel/preset-env": "^7.18.2",
"@babel/preset-react": "^7.23.3", "@babel/preset-react": "^7.23.3",
"@babel/preset-typescript": "^7.17.12", "@babel/preset-typescript": "^7.17.12",
"@electron-forge/cli": "^6.0.0-beta.70", "@electron-forge/cli": "^7.2.0",
"@electron-forge/maker-deb": "^6.0.0-beta.70", "@electron-forge/maker-deb": "^7.2.0",
"@electron-forge/maker-rpm": "^6.0.0-beta.70", "@electron-forge/maker-rpm": "^7.2.0",
"@electron-forge/maker-snap": "^6.4.2", "@electron-forge/maker-snap": "^7.2.0",
"@electron-forge/maker-squirrel": "^6.0.0-beta.70", "@electron-forge/maker-squirrel": "^7.2.0",
"@electron-forge/maker-zip": "^6.0.0-beta.70", "@electron-forge/maker-zip": "^7.2.0",
"@electron/rebuild": "^3.4.0", "@electron/rebuild": "^3.4.0",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@types/classnames": "^2.3.1", "@types/classnames": "^2.3.1",
"@types/electron": "^1.6.10", "@types/electron": "^1.6.10",
"@types/node": "20.10.3", "@types/node": "20.11.0",
"@types/papaparse": "^5.3.10", "@types/papaparse": "^5.3.10",
"@types/react": "^18.0.12", "@types/react": "^18.0.12",
"@types/sprintf-js": "^1.1.3", "@types/sprintf-js": "^1.1.3",
@ -70,7 +71,7 @@
"babel-plugin-jsx-control-statements": "^4.1.2", "babel-plugin-jsx-control-statements": "^4.1.2",
"copy-webpack-plugin": "^11.0.0", "copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.1", "css-loader": "^6.7.1",
"electron": "27.1.3", "electron": "28.1.3",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"http-server": "^14.1.1", "http-server": "^14.1.1",
"less": "^4.1.2", "less": "^4.1.2",
@ -81,7 +82,7 @@
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"react-split-it": "^2.0.0", "react-split-it": "^2.0.0",
"style-loader": "^3.3.1", "style-loader": "^3.3.1",
"typescript": "^4.7.3", "typescript": "^5.0.0",
"webpack": "^5.73.0", "webpack": "^5.73.0",
"webpack-bundle-analyzer": "^4.10.1", "webpack-bundle-analyzer": "^4.10.1",
"webpack-cli": "^5.1.4", "webpack-cli": "^5.1.4",

View File

@ -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 { .icon.color-red {
path, path,
circle { circle {
fill: @tab-red; fill: @tab-red;
} }
}
.icon.color-green { i {
path, color: @tab-red;
circle {
fill: @tab-green;
} }
} }
@ -495,6 +504,10 @@ a.a-block {
circle { circle {
fill: @tab-orange; fill: @tab-orange;
} }
i {
color: @tab-orange;
}
} }
.icon.color-blue { .icon.color-blue {
@ -502,6 +515,10 @@ a.a-block {
circle { circle {
fill: @tab-blue; fill: @tab-blue;
} }
i {
color: @tab-blue;
}
} }
.icon.color-yellow { .icon.color-yellow {
@ -509,6 +526,10 @@ a.a-block {
circle { circle {
fill: @tab-yellow; fill: @tab-yellow;
} }
i {
color: @tab-yellow;
}
} }
.icon.color-pink { .icon.color-pink {
@ -516,6 +537,10 @@ a.a-block {
circle { circle {
fill: @tab-pink; fill: @tab-pink;
} }
i {
color: @tab-pink;
}
} }
.icon.color-mint { .icon.color-mint {
@ -523,6 +548,10 @@ a.a-block {
circle { circle {
fill: @tab-mint; fill: @tab-mint;
} }
i {
color: @tab-mint;
}
} }
.icon.color-cyan { .icon.color-cyan {
@ -530,6 +559,10 @@ a.a-block {
circle { circle {
fill: @tab-cyan; fill: @tab-cyan;
} }
i {
color: @tab-cyan;
}
} }
.icon.color-violet { .icon.color-violet {
@ -537,6 +570,10 @@ a.a-block {
circle { circle {
fill: @tab-violet; fill: @tab-violet;
} }
i {
color: @tab-violet;
}
} }
.icon.color-white { .icon.color-white {
@ -544,6 +581,10 @@ a.a-block {
circle { circle {
fill: @tab-white; fill: @tab-white;
} }
i {
color: @tab-white;
}
} }
.status-icon.status-connected { .status-icon.status-connected {

View File

@ -16,14 +16,9 @@ import { PluginsView } from "./pluginsview/pluginsview";
import { BookmarksView } from "./bookmarks/bookmarks"; import { BookmarksView } from "./bookmarks/bookmarks";
import { HistoryView } from "./history/history"; import { HistoryView } from "./history/history";
import { ConnectionsView } from "./connections/connections"; import { ConnectionsView } from "./connections/connections";
import {
ScreenSettingsModal,
SessionSettingsModal,
LineSettingsModal,
ClientSettingsModal,
} from "./common/modals/settings";
import { MainSideBar } from "./sidebar/sidebar"; 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 { ErrorBoundary } from "./common/error/errorboundary";
import "./app.less"; import "./app.less";
@ -74,7 +69,6 @@ class App extends React.Component<{}, {}> {
} }
render() { render() {
let clientSettingsModal = GlobalModel.clientSettingsModal.get();
let remotesModel = GlobalModel.remotesModel; let remotesModel = GlobalModel.remotesModel;
let disconnected = !GlobalModel.ws.open.get() || !GlobalModel.waveSrvRunning.get(); let disconnected = !GlobalModel.ws.open.get() || !GlobalModel.waveSrvRunning.get();
let hasClientStop = GlobalModel.getHasClientStop(); let hasClientStop = GlobalModel.getHasClientStop();

View File

@ -7,9 +7,12 @@ export const SCREEN_SETTINGS = "screenSettings";
export const SESSION_SETTINGS = "sessionSettings"; export const SESSION_SETTINGS = "sessionSettings";
export const LINE_SETTINGS = "lineSettings"; export const LINE_SETTINGS = "lineSettings";
export const CLIENT_SETTINGS = "clientSettings"; export const CLIENT_SETTINGS = "clientSettings";
export const TAB_SWITCHER = "tabSwitcher";
export const LineContainer_Main = "main"; export const LineContainer_Main = "main";
export const LineContainer_History = "history"; export const LineContainer_History = "history";
export const LineContainer_Sidebar = "sidebar"; export const LineContainer_Sidebar = "sidebar";
export const ConfirmKey_HideShellPrompt = "hideshellprompt";
export const NoStrPos = -1; export const NoStrPos = -1;

View File

@ -188,14 +188,14 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
color: #9e9e9e; color: @term-bright-white;
transition: color 250ms cubic-bezier(0.4, 0, 0.23, 1); transition: color 250ms cubic-bezier(0.4, 0, 0.23, 1);
} }
input[type="checkbox"] + label > span { input[type="checkbox"] + label > span {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin-right: 16px; margin-right: 10px;
width: 20px; width: 20px;
height: 20px; height: 20px;
background: transparent; background: transparent;
@ -205,10 +205,6 @@
transition: all 250ms cubic-bezier(0.4, 0, 0.23, 1); 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"] + label:hover > span,
input[type="checkbox"]:focus + label > span { input[type="checkbox"]:focus + label > span {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
@ -356,7 +352,7 @@
background-color: @markdown-highlight; background-color: @markdown-highlight;
color: @term-white; color: @term-white;
font-family: @terminal-font; font-family: @terminal-font;
display: inline-block; border-radius: 4px;
} }
code.inline { code.inline {
@ -407,6 +403,13 @@
background-color: @markdown-highlight; background-color: @markdown-highlight;
margin: 4px 10px 4px 10px; margin: 4px 10px 4px 10px;
padding: 6px 6px 6px 10px; padding: 6px 6px 6px 10px;
border-radius: 4px;
}
pre.selected {
border-style: solid;
outline-width: 2px;
border-color: @term-green;
} }
.title.is-1 { .title.is-1 {
@ -837,6 +840,14 @@
} }
} }
} }
&.no-label {
height: 34px;
input {
height: 32px;
}
}
} }
.wave-input-decoration { .wave-input-decoration {

View File

@ -11,6 +11,7 @@ import cn from "classnames";
import { If } from "tsx-control-statements/components"; import { If } from "tsx-control-statements/components";
import type { RemoteType } from "../../types/types"; import type { RemoteType } from "../../types/types";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { GlobalModel } from "../../model/model";
import { ReactComponent as CheckIcon } from "../assets/icons/line/check.svg"; import { ReactComponent as CheckIcon } from "../assets/icons/line/check.svg";
import { ReactComponent as CopyIcon } from "../assets/icons/history/copy.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< 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() { render() {
const { checked, onChange, label, id } = this.props; const { label, className, id } = this.props;
const { checkedInternal } = this.state;
const checkboxId = id || this.generatedId;
return ( return (
<div className="checkbox"> <div className={cn("checkbox", className)}>
<input <input
type="checkbox" type="checkbox"
id={id} id={checkboxId}
checked={checked} checked={checkedInternal}
onChange={(e) => onChange(e.target.checked)} onChange={this.handleChange}
aria-checked={checked} aria-checked={checkedInternal}
role="checkbox" role="checkbox"
/> />
<label htmlFor={id}> <label htmlFor={checkboxId}>
<span></span> <span></span>
{label} {label}
</label> </label>
@ -231,6 +266,8 @@ interface ButtonProps {
rightIcon?: React.ReactNode; rightIcon?: React.ReactNode;
color?: string; color?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
autoFocus?: boolean;
className?: string;
} }
class Button extends React.Component<ButtonProps> { class Button extends React.Component<ButtonProps> {
@ -257,6 +294,7 @@ class Button extends React.Component<ButtonProps> {
onClick={this.handleClick} onClick={this.handleClick}
disabled={disabled} disabled={disabled}
style={style} style={style}
autoFocus={this.props.autoFocus}
> >
{leftIcon && <span className="icon-left">{leftIcon}</span>} {leftIcon && <span className="icon-left">{leftIcon}</span>}
{children} {children}
@ -283,25 +321,24 @@ export default IconButton;
interface LinkButtonProps extends ButtonProps { interface LinkButtonProps extends ButtonProps {
href: string; href: string;
rel?: string;
target?: string; target?: string;
} }
class LinkButton extends IconButton { class LinkButton extends React.Component<LinkButtonProps> {
render() { render() {
// @ts-ignore const { leftIcon, rightIcon, children, className, ...rest } = this.props;
const { href, target, leftIcon, rightIcon, children, theme, variant }: LinkButtonProps = this.props;
return ( return (
<a href={href} target={target} className={`wave-button link-button`}> <a {...rest} className={cn(`wave-button link-button`, className)}>
<button {...this.props} className={`icon-button ${theme} ${variant}`}> {leftIcon && <span className="icon-left">{leftIcon}</span>}
{leftIcon && <span className="icon-left">{leftIcon}</span>} {children}
{children} {rightIcon && <span className="icon-right">{rightIcon}</span>}
{rightIcon && <span className="icon-right">{rightIcon}</span>}
</button>
</a> </a>
); );
} }
} }
interface StatusProps { interface StatusProps {
status: "green" | "red" | "gray" | "yellow"; status: "green" | "red" | "gray" | "yellow";
text: string; text: string;
@ -332,7 +369,7 @@ interface TextFieldDecorationProps {
endDecoration?: React.ReactNode; endDecoration?: React.ReactNode;
} }
interface TextFieldProps { interface TextFieldProps {
label: string; label?: string;
value?: string; value?: string;
className?: string; className?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
@ -445,10 +482,11 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
return ( return (
<div <div
className={cn(`wave-textfield ${className || ""}`, { className={cn("wave-textfield", className, {
focused: focused, focused: focused,
error: error, error: error,
disabled: disabled, disabled: disabled,
"no-label": !label,
})} })}
onFocus={this.handleComponentFocus} onFocus={this.handleComponentFocus}
onBlur={this.handleComponentBlur} onBlur={this.handleComponentBlur}
@ -456,15 +494,17 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
> >
{decoration?.startDecoration && <>{decoration.startDecoration}</>} {decoration?.startDecoration && <>{decoration.startDecoration}</>}
<div className="wave-textfield-inner"> <div className="wave-textfield-inner">
<label <If condition={label}>
className={cn("wave-textfield-inner-label", { <label
float: this.state.hasContent || this.state.focused || placeholder, className={cn("wave-textfield-inner-label", {
"offset-left": decoration?.startDecoration, float: this.state.hasContent || this.state.focused || placeholder,
})} "offset-left": decoration?.startDecoration,
htmlFor={label} })}
> htmlFor={label}
{label} >
</label> {label}
</label>
</If>
<input <input
className={cn("wave-textfield-inner-input", { "offset-left": decoration?.startDecoration })} className={cn("wave-textfield-inner-input", { "offset-left": decoration?.startDecoration })}
ref={this.inputRef} ref={this.inputRef}
@ -774,7 +814,7 @@ class InfoMessage extends React.Component<{ width: number; children: React.React
function LinkRenderer(props: any): any { function LinkRenderer(props: any): any {
let newUrl = "https://extern?" + encodeURIComponent(props.href); let newUrl = "https://extern?" + encodeURIComponent(props.href);
return ( return (
<a href={newUrl} target="_blank" rel={"noopener"}> <a href={newUrl} target="_blank" rel={"noopener"}>
{props.children} {props.children}
</a> </a>
); );
@ -789,9 +829,67 @@ function CodeRenderer(props: any): any {
} }
@mobxReact.observer @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() { render() {
let text = this.props.text; let text = this.props.text;
let codeSelect = this.props.codeSelect;
let curCodeSelectIndex = GlobalModel.inputModel.getCodeSelectSelectedIndex();
let markdownComponents = { let markdownComponents = {
a: LinkRenderer, a: LinkRenderer,
h1: (props) => HeaderRenderer(props, 1), h1: (props) => HeaderRenderer(props, 1),
@ -800,7 +898,8 @@ class Markdown extends React.Component<{ text: string; style?: any; extraClassNa
h4: (props) => HeaderRenderer(props, 4), h4: (props) => HeaderRenderer(props, 4),
h5: (props) => HeaderRenderer(props, 5), h5: (props) => HeaderRenderer(props, 5),
h6: (props) => HeaderRenderer(props, 6), h6: (props) => HeaderRenderer(props, 6),
code: CodeRenderer, code: (props) => CodeRenderer(props),
pre: (props) => this.CodeBlockRenderer(props, codeSelect, curCodeSelectIndex),
}; };
return ( return (
<div className={cn("markdown content", this.props.extraClassName)} style={this.props.style}> <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 { interface ModalHeaderProps {
onClose: () => void; onClose?: () => void;
title: string; title: string;
} }
const ModalHeader: React.FC<ModalHeaderProps> = ({ onClose, title }) => ( const ModalHeader: React.FC<ModalHeaderProps> = ({ onClose, title }) => (
<div className="wave-modal-header"> <div className="wave-modal-header">
{<div className="wave-modal-title">{title}</div>} {<div className="wave-modal-title">{title}</div>}
<IconButton theme="secondary" variant="ghost" onClick={onClose}> <If condition={onClose}>
<i className="fa-sharp fa-solid fa-xmark"></i> <IconButton theme="secondary" variant="ghost" onClick={onClose}>
</IconButton> <i className="fa-sharp fa-solid fa-xmark"></i>
</IconButton>
</If>
</div> </div>
); );
@ -1141,7 +1242,7 @@ class Modal extends React.Component<ModalProps> {
} }
render() { render() {
return ReactDOM.createPortal(this.renderModal(), document.getElementById("app") ); return ReactDOM.createPortal(this.renderModal(), document.getElementById("app"));
} }
} }

View File

@ -0,0 +1,114 @@
@import "../../../app/common/themes/themes.less";
.about-modal {
.wave-modal-content {
gap: 24px;
.wave-modal-body {
margin-bottom: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
.about-section {
display: flex;
align-items: center;
gap: 16px;
align-self: stretch;
width: 100%;
.logo-wrapper {
width: 72px;
height: 72px;
flex-shrink: 0;
img {
border-radius: 10px;
}
}
.text-wrapper {
display: flex;
align-items: flex-start;
flex-direction: column;
gap: 4px;
align-self: stretch;
font-style: normal;
line-height: 20px;
div:first-child {
color: @term-bright-white;
font-size: 14.5px;
}
div:last-child {
color: @term-white;
text-align: left;
}
}
.status {
div {
display: flex;
align-items: center;
margin-bottom: 5px;
i {
font-size: 16px;
margin-right: 10px;
}
}
div:first-child + div {
color: @term-white;
}
}
.status.updated {
div {
display: flex;
align-items: center;
margin-bottom: 5px;
i {
color: @term-green;
}
}
}
.status.outdated {
div {
i {
color: @term-yellow;
}
}
button {
margin-top: 5px;
}
}
}
.about-section:nth-child(3) {
display: flex;
align-items: flex-start;
gap: 10px;
.wave-button-link {
display: flex;
align-items: center;
i {
font-size: 16px;
}
}
}
.about-section:last-child {
margin-bottom: 24px;
color: @term-white;
}
}
}
}

View File

@ -0,0 +1,136 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { GlobalModel } from "../../../model/model";
import { Modal, LinkButton } from "../common";
import * as util from "../../../util/util";
import logo from "../../assets/waveterm-logo-with-bg.svg";
import "./about.less";
// @ts-ignore
const VERSION = __WAVETERM_VERSION__;
// @ts-ignore
let BUILD = __WAVETERM_BUILD__;
@mobxReact.observer
class AboutModal extends React.Component<{}, {}> {
@boundMethod
closeModal(): void {
mobx.action(() => {
GlobalModel.modalsModel.popModal();
})();
}
@boundMethod
isUpToDate(): boolean {
return true;
}
@boundMethod
updateApp(): void {
// GlobalCommandRunner.updateApp();
}
@boundMethod
getStatus(isUpToDate: boolean): JSX.Element {
// TODO no up-to-date status reporting
return (
<div className="status updated">
<div className="text-selectable">
Client Version {VERSION} ({BUILD})
</div>
</div>
);
if (isUpToDate) {
return (
<div className="status updated">
<div>
<i className="fa-sharp fa-solid fa-circle-check" />
<span>Up to Date</span>
</div>
<div className="selectable">
Client Version {VERSION} ({BUILD})
</div>
</div>
);
}
return (
<div className="status outdated">
<div>
<i className="fa-sharp fa-solid fa-triangle-exclamation" />
<span>Outdated Version</span>
</div>
<div className="selectable">
Client Version {VERSION} ({BUILD})
</div>
<div>
<button onClick={this.updateApp} className="button color-green text-secondary">
Update
</button>
</div>
</div>
);
}
render() {
return (
<Modal className="about-modal">
<Modal.Header onClose={this.closeModal} title="About" />
<div className="wave-modal-body">
<div className="about-section">
<div className="logo-wrapper">
<img src={logo} alt="logo" />
</div>
<div className="text-wrapper">
<div>Wave Terminal</div>
<div className="text-standard">
Modern Terminal for
<br />
Seamless Workflow
</div>
</div>
</div>
<div className="about-section text-standard">{this.getStatus(this.isUpToDate())}</div>
<div className="about-section">
<LinkButton
className="secondary solid"
href={util.makeExternLink("https://github.com/wavetermdev/waveterm")}
target="_blank"
leftIcon={<i className="fa-brands fa-github"></i>}
>
Github
</LinkButton>
<LinkButton
className="secondary solid"
href={util.makeExternLink("https://www.waveterm.dev/")}
target="_blank"
leftIcon={<i className="fa-sharp fa-light fa-globe"></i>}
>
Website
</LinkButton>
<LinkButton
className="secondary solid"
href={util.makeExternLink(
"https://github.com/wavetermdev/waveterm/blob/main/acknowledgements/README.md"
)}
target="_blank"
rel={"noopener"}
leftIcon={<i className="fa-sharp fa-light fa-heart"></i>}
>
Acknowledgements
</LinkButton>
</div>
<div className="about-section text-standard">&copy; 2023 Command Line Inc.</div>
</div>
</Modal>
);
}
}
export { AboutModal };

View File

@ -0,0 +1,11 @@
@import "../../../app/common/themes/themes.less";
.alert-modal {
width: 500px;
.wave-modal-content {
.wave-modal-body {
padding: 40px 20px;
}
}
}

View File

@ -0,0 +1,75 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import { boundMethod } from "autobind-decorator";
import { If } from "tsx-control-statements/components";
import { Markdown, Modal, Button, Checkbox } from "../common";
import { GlobalModel, GlobalCommandRunner } from "../../../model/model";
import "./alert.less";
@mobxReact.observer
class AlertModal extends React.Component<{}, {}> {
@boundMethod
closeModal(): void {
GlobalModel.cancelAlert();
}
@boundMethod
handleOK(): void {
GlobalModel.confirmAlert();
}
@boundMethod
handleDontShowAgain(checked: boolean) {
let message = GlobalModel.alertMessage.get();
if (message.confirmflag == null) {
return;
}
GlobalCommandRunner.clientSetConfirmFlag(message.confirmflag, checked);
}
render() {
let message = GlobalModel.alertMessage.get();
let title = message?.title ?? (message?.confirm ? "Confirm" : "Alert");
let isConfirm = message?.confirm ?? false;
return (
<Modal className="alert-modal">
<Modal.Header onClose={this.closeModal} title={title} />
<div className="wave-modal-body">
<If condition={message?.markdown}>
<Markdown text={message?.message ?? ""} />
</If>
<If condition={!message?.markdown}>{message?.message}</If>
<If condition={message.confirmflag}>
<Checkbox
onChange={this.handleDontShowAgain}
label={"Don't show me this again"}
className="dontshowagain-text"
/>
</If>
</div>
<div className="wave-modal-footer">
<If condition={isConfirm}>
<Button theme="secondary" onClick={this.closeModal}>
Cancel
</Button>
<Button autoFocus={true} onClick={this.handleOK}>
Ok
</Button>
</If>
<If condition={!isConfirm}>
<Button autoFocus={true} onClick={this.handleOK}>
Ok
</Button>
</If>
</div>
</Modal>
);
}
}
export { AlertModal };

View File

@ -0,0 +1,11 @@
@import "../../../app/common/themes/themes.less";
.clientstop-modal {
.inner-content {
display: flex;
flex-direction: column;
padding: 30px;
gap: 20px;
align-items: center;
}
}

View File

@ -0,0 +1,49 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import { boundMethod } from "autobind-decorator";
import { If } from "tsx-control-statements/components";
import { GlobalModel } from "../../../model/model";
import { Modal, Button } from "../common";
import "./clientstop.less";
@mobxReact.observer
class ClientStopModal extends React.Component<{}, {}> {
@boundMethod
refreshClient() {
GlobalModel.refreshClient();
}
render() {
let model = GlobalModel;
let cdata = model.clientData.get();
return (
<Modal className="clientstop-modal">
<Modal.Header title="Client Not Ready" />
<div className="wave-modal-body">
<div className="modal-content">
<div className="inner-content">
<If condition={cdata == null}>
<div>Cannot get client data.</div>
</If>
<div>
<Button
theme="secondary"
onClick={this.refreshClient}
leftIcon={<i className="fa-sharp fa-solid fa-rotate"></i>}
>
Hard Refresh Client
</Button>
</div>
</div>
</div>
</div>
</Modal>
);
}
}
export { ClientStopModal };

View 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%;
}
}
}
}

View File

@ -0,0 +1,370 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { If } from "tsx-control-statements/components";
import { GlobalModel, GlobalCommandRunner, RemotesModel } from "../../../model/model";
import * as T from "../../../types/types";
import { Modal, TextField, NumberField, InputDecoration, Dropdown, PasswordField, Tooltip } from "../common";
import * as util from "../../../util/util";
import * as appconst from "../../appconst";
import "./createremoteconn.less";
type OV<V> = mobx.IObservableValue<V>;
@mobxReact.observer
class CreateRemoteConnModal extends React.Component<{}, {}> {
tempAlias: OV<string>;
tempHostName: OV<string>;
tempPort: OV<string>;
tempAuthMode: OV<string>;
tempConnectMode: OV<string>;
tempPassword: OV<string>;
tempKeyFile: OV<string>;
errorStr: OV<string>;
remoteEdit: T.RemoteEditType;
model: RemotesModel;
constructor(props: { remotesModel?: RemotesModel }) {
super(props);
this.model = GlobalModel.remotesModel;
this.remoteEdit = this.model.remoteEdit.get();
this.tempAlias = mobx.observable.box("", { name: "CreateRemote-alias" });
this.tempHostName = mobx.observable.box("", { name: "CreateRemote-hostName" });
this.tempPort = mobx.observable.box("", { name: "CreateRemote-port" });
this.tempAuthMode = mobx.observable.box("none", { name: "CreateRemote-authMode" });
this.tempConnectMode = mobx.observable.box("auto", { name: "CreateRemote-connectMode" });
this.tempKeyFile = mobx.observable.box("", { name: "CreateRemote-keystr" });
this.tempPassword = mobx.observable.box("", { name: "CreateRemote-password" });
this.errorStr = mobx.observable.box(this.remoteEdit?.errorstr ?? null, { name: "CreateRemote-errorStr" });
}
componentDidMount(): void {
GlobalModel.getClientData();
}
remoteCName(): string {
let hostName = this.tempHostName.get();
if (hostName == "") {
return "[no host]";
}
if (hostName.indexOf("@") == -1) {
hostName = "[no user]@" + hostName;
}
return hostName;
}
getErrorStr(): string {
if (this.errorStr.get() != null) {
return this.errorStr.get();
}
return this.remoteEdit?.errorstr ?? null;
}
@boundMethod
handleOk(): void {
this.showShellPrompt(this.submitRemote);
}
@boundMethod
showShellPrompt(cb: () => void): void {
let prtn = GlobalModel.showAlert({
message:
"You are about to install WaveShell on a remote machine. Please be aware that WaveShell will be executed on the remote system.",
confirm: true,
confirmflag: appconst.ConfirmKey_HideShellPrompt,
});
prtn.then((confirm) => {
if (!confirm) {
return;
}
cb();
});
}
@boundMethod
submitRemote(): void {
mobx.action(() => {
this.errorStr.set(null);
})();
let authMode = this.tempAuthMode.get();
let cname = this.tempHostName.get();
if (cname == "") {
this.errorStr.set("You must specify a 'user@host' value to create a new connection");
return;
}
let kwargs: Record<string, string> = {};
kwargs["alias"] = this.tempAlias.get();
if (this.tempPort.get() != "" && this.tempPort.get() != "22") {
kwargs["port"] = this.tempPort.get();
}
if (authMode == "key" || authMode == "key+password") {
if (this.tempKeyFile.get() == "") {
this.errorStr.set("When AuthMode is set to 'key', you must supply a valid key file name.");
return;
}
kwargs["key"] = this.tempKeyFile.get();
} else {
kwargs["key"] = "";
}
if (authMode == "password" || authMode == "key+password") {
if (this.tempPassword.get() == "") {
this.errorStr.set("When AuthMode is set to 'password', you must supply a password.");
return;
}
kwargs["password"] = this.tempPassword.get();
} else {
kwargs["password"] = "";
}
kwargs["connectmode"] = this.tempConnectMode.get();
kwargs["visual"] = "1";
kwargs["submit"] = "1";
let prtn = GlobalCommandRunner.createRemote(cname, kwargs, false);
prtn.then((crtn) => {
if (crtn.success) {
this.model.setRecentConnAdded(true);
this.model.closeModal();
let crRtn = GlobalCommandRunner.screenSetRemote(cname, true, false);
crRtn.then((crcrtn) => {
if (crcrtn.success) {
return;
}
mobx.action(() => {
this.errorStr.set(crcrtn.error);
})();
});
return;
}
mobx.action(() => {
this.errorStr.set(crtn.error);
})();
});
}
@boundMethod
handleChangeKeyFile(value: string): void {
mobx.action(() => {
this.tempKeyFile.set(value);
})();
}
@boundMethod
handleChangePassword(value: string): void {
mobx.action(() => {
this.tempPassword.set(value);
})();
}
@boundMethod
handleChangeAlias(value: string): void {
mobx.action(() => {
this.tempAlias.set(value);
})();
}
@boundMethod
handleChangeAuthMode(value: string): void {
mobx.action(() => {
this.tempAuthMode.set(value);
})();
}
@boundMethod
handleChangePort(value: string): void {
mobx.action(() => {
this.tempPort.set(value);
})();
}
@boundMethod
handleChangeHostName(value: string): void {
mobx.action(() => {
this.tempHostName.set(value);
})();
}
@boundMethod
handleChangeConnectMode(value: string): void {
mobx.action(() => {
this.tempConnectMode.set(value);
})();
}
render() {
let authMode = this.tempAuthMode.get();
if (this.remoteEdit == null) {
return null;
}
return (
<Modal className="crconn-modal">
<Modal.Header title="Add Connection" onClose={this.model.closeModal} />
<div className="wave-modal-body">
<div className="user-section">
<TextField
label="user@host"
autoFocus={true}
value={this.tempHostName.get()}
onChange={this.handleChangeHostName}
required={true}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={`(Required) The user and host that you want to connect with. This is in the same format as
you would pass to ssh, e.g. "ubuntu@test.mydomain.com".`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<div className="alias-section">
<TextField
label="Alias"
onChange={this.handleChangeAlias}
value={this.tempAlias.get()}
maxLength={100}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={`(Optional) A short alias to use when selecting or displaying this connection.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<div className="port-section">
<NumberField
label="Port"
placeholder="22"
value={this.tempPort.get()}
onChange={this.handleChangePort}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={`(Optional) Defaults to 22. Set if the server you are connecting to listens to a non-standard
SSH port.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<div className="authmode-section">
<Dropdown
label="Auth Mode"
options={[
{ value: "none", label: "none" },
{ value: "key", label: "key" },
{ value: "password", label: "password" },
{ value: "key+password", label: "key+password" },
]}
value={this.tempAuthMode.get()}
onChange={(val: string) => {
this.tempAuthMode.set(val);
}}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={
<ul>
<li>
<b>none</b> - no authentication, or authentication is already
configured in your ssh config.
</li>
<li>
<b>key</b> - use a private key.
</li>
<li>
<b>password</b> - use a password.
</li>
<li>
<b>key+password</b> - use a key with a passphrase.
</li>
</ul>
}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<If condition={authMode == "key" || authMode == "key+password"}>
<TextField
label="SSH Keyfile"
placeholder="keyfile path"
onChange={this.handleChangeKeyFile}
value={this.tempKeyFile.get()}
maxLength={400}
required={true}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={`(Required) The path to your ssh key file.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</If>
<If condition={authMode == "password" || authMode == "key+password"}>
<PasswordField
label={authMode == "password" ? "SSH Password" : "Key Passphrase"}
placeholder="password"
onChange={this.handleChangePassword}
value={this.tempPassword.get()}
maxLength={400}
/>
</If>
<div className="connectmode-section">
<Dropdown
label="Connect Mode"
options={[
{ value: "startup", label: "startup" },
{ value: "auto", label: "auto" },
{ value: "manual", label: "manual" },
]}
value={this.tempConnectMode.get()}
onChange={(val: string) => {
this.tempConnectMode.set(val);
}}
/>
</div>
<If condition={!util.isBlank(this.getErrorStr() as string)}>
<div className="settings-field settings-error">Error: {this.getErrorStr()}</div>
</If>
</div>
<Modal.Footer onCancel={this.model.closeModal} onOk={this.handleOk} okLabel="Connect" />
</Modal>
);
}
}
export { CreateRemoteConnModal };

View File

@ -0,0 +1,47 @@
@import "../../../app/common/themes/themes.less";
.disconnected-modal {
.wave-modal-content {
.wave-modal-body {
padding: 0;
.modal-content {
footer {
.footer-text-link {
color: @term-white;
cursor: pointer;
}
}
}
.inner-content {
.log {
height: 335px;
margin-bottom: 20px;
overflow: auto;
&::-webkit-scrollbar-track,
&::-webkit-scrollbar-thumb,
&::-webkit-scrollbar-corner {
display: none;
}
&:hover::-webkit-scrollbar-thumb {
display: block;
}
pre {
color: @term-white;
background-color: @term-black;
}
}
}
}
.wave-modal-footer {
button:first-child {
color: @term-green;
}
}
}
}

View File

@ -0,0 +1,101 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { GlobalModel } from "../../../model/model";
import { Modal, Button } from "../common";
import "./disconnected.less";
const NumOfLines = 50;
@mobxReact.observer
class DisconnectedModal extends React.Component<{}, {}> {
logRef: any = React.createRef();
logs: mobx.IObservableValue<string> = mobx.observable.box("");
logInterval: NodeJS.Timeout = null;
@boundMethod
restartServer() {
GlobalModel.restartWaveSrv();
}
@boundMethod
tryReconnect() {
GlobalModel.ws.connectNow("manual");
}
componentDidMount() {
this.fetchLogs();
this.logInterval = setInterval(() => {
this.fetchLogs();
}, 5000);
}
componentWillUnmount() {
if (this.logInterval) {
clearInterval(this.logInterval);
}
}
componentDidUpdate() {
if (this.logRef.current != null) {
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
}
}
fetchLogs() {
GlobalModel.getLastLogs(
NumOfLines,
mobx.action((logs) => {
this.logs.set(logs);
if (this.logRef.current != null) {
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
}
})
);
}
render() {
return (
<Modal className="disconnected-modal">
<Modal.Header title="Wave Client Disconnected" />
<div className="wave-modal-body">
<div className="modal-content">
<div className="inner-content">
<div className="log" ref={this.logRef}>
<pre>{this.logs.get()}</pre>
</div>
</div>
</div>
</div>
<div className="wave-modal-footer">
<Button
theme="secondary"
onClick={this.tryReconnect}
leftIcon={
<span className="icon">
<i className="fa-sharp fa-solid fa-rotate" />
</span>
}
>
Try Reconnect
</Button>
<Button
theme="secondary"
onClick={this.restartServer}
leftIcon={<i className="fa-sharp fa-solid fa-triangle-exclamation"></i>}
>
Restart Server
</Button>
</div>
</Modal>
);
}
}
export { DisconnectedModal };

View File

@ -0,0 +1,51 @@
@import "../../../app/common/themes/themes.less";
.erconn-modal {
width: 502px;
min-height: 411px;
.wave-modal-content {
gap: 20px;
.wave-modal-body {
display: flex;
padding: 0px 20px;
flex-direction: column;
align-items: flex-start;
gap: 12px;
align-self: stretch;
width: 100%;
> div {
width: 100%;
}
.name-actions-section {
margin-bottom: 10px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
.name {
color: @term-bright-white;
font-size: 15px;
font-weight: 500;
line-height: 20px;
}
.header-actions {
display: flex;
justify-content: flex-end;
align-items: flex-start;
.wave-button {
padding: 4px 15px;
font-size: 11px;
margin-right: 8px;
}
}
}
}
}
}

View File

@ -0,0 +1,312 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { If } from "tsx-control-statements/components";
import { boundMethod } from "autobind-decorator";
import { GlobalModel, GlobalCommandRunner, RemotesModel } from "../../../model/model";
import * as T from "../../../types/types";
import { Modal, TextField, InputDecoration, Dropdown, PasswordField, Tooltip } from "../common";
import * as util from "../../../util/util";
import "./editremoteconn.less";
type OV<V> = mobx.IObservableValue<V>;
const PasswordUnchangedSentinel = "--unchanged--";
@mobxReact.observer
class EditRemoteConnModal extends React.Component<{}, {}> {
tempAlias: OV<string>;
tempKeyFile: OV<string>;
tempPassword: OV<string>;
tempConnectMode: OV<string>;
tempAuthMode: OV<string>;
model: RemotesModel;
constructor(props: { remotesModel?: RemotesModel }) {
super(props);
this.model = GlobalModel.remotesModel;
this.tempAlias = mobx.observable.box(null, { name: "EditRemoteSettings-tempAlias" });
this.tempAuthMode = mobx.observable.box(null, { name: "EditRemoteSettings-tempAuthMode" });
this.tempKeyFile = mobx.observable.box(null, { name: "EditRemoteSettings-tempKeyFile" });
this.tempPassword = mobx.observable.box(null, { name: "EditRemoteSettings-tempPassword" });
this.tempConnectMode = mobx.observable.box(null, { name: "EditRemoteSettings-tempConnectMode" });
}
get selectedRemoteId() {
return this.model.selectedRemoteId.get();
}
get selectedRemote(): T.RemoteType {
return GlobalModel.getRemote(this.selectedRemoteId);
}
get remoteEdit(): T.RemoteEditType {
return this.model.remoteEdit.get();
}
get isAuthEditMode(): boolean {
return this.model.isAuthEditMode();
}
componentDidMount(): void {
mobx.action(() => {
this.tempAlias.set(this.selectedRemote?.remotealias);
this.tempKeyFile.set(this.remoteEdit?.keystr);
this.tempPassword.set(this.remoteEdit?.haspassword ? PasswordUnchangedSentinel : "");
this.tempConnectMode.set(this.selectedRemote?.connectmode);
this.tempAuthMode.set(this.selectedRemote?.authtype);
})();
}
componentDidUpdate() {
if (this.selectedRemote == null || this.selectedRemote.archived) {
this.model.deSelectRemote();
}
}
@boundMethod
handleChangeKeyFile(value: string): void {
mobx.action(() => {
this.tempKeyFile.set(value);
})();
}
@boundMethod
handleChangePassword(value: string): void {
mobx.action(() => {
this.tempPassword.set(value);
})();
}
@boundMethod
handleChangeAlias(value: string): void {
mobx.action(() => {
this.tempAlias.set(value);
})();
}
@boundMethod
handleChangeAuthMode(value: string): void {
mobx.action(() => {
this.tempAuthMode.set(value);
})();
}
@boundMethod
handleChangeConnectMode(value: string): void {
mobx.action(() => {
this.tempConnectMode.set(value);
})();
}
@boundMethod
canResetPw(): boolean {
if (this.remoteEdit == null) {
return false;
}
return Boolean(this.remoteEdit.haspassword) && this.tempPassword.get() != PasswordUnchangedSentinel;
}
@boundMethod
resetPw(): void {
mobx.action(() => {
this.tempPassword.set(PasswordUnchangedSentinel);
})();
}
@boundMethod
onFocusPassword(e: any) {
if (this.tempPassword.get() == PasswordUnchangedSentinel) {
e.target.select();
}
}
@boundMethod
submitRemote(): void {
let authMode = this.tempAuthMode.get();
let kwargs: Record<string, string> = {};
if (authMode == "key" || authMode == "key+password") {
let keyStrEq = util.isStrEq(this.tempKeyFile.get(), this.remoteEdit?.keystr);
if (!keyStrEq) {
kwargs["key"] = this.tempKeyFile.get();
}
} else {
if (!util.isBlank(this.tempKeyFile.get())) {
kwargs["key"] = "";
}
}
if (authMode == "password" || authMode == "key+password") {
if (this.tempPassword.get() != PasswordUnchangedSentinel) {
kwargs["password"] = this.tempPassword.get();
}
} else {
if (this.remoteEdit?.haspassword) {
kwargs["password"] = "";
}
}
if (!util.isStrEq(this.tempAlias.get(), this.selectedRemote?.remotealias)) {
kwargs["alias"] = this.tempAlias.get();
}
if (!util.isStrEq(this.tempConnectMode.get(), this.selectedRemote?.connectmode)) {
kwargs["connectmode"] = this.tempConnectMode.get();
}
kwargs["visual"] = "1";
kwargs["submit"] = "1";
GlobalCommandRunner.editRemote(this.selectedRemote?.remoteid, kwargs);
this.model.closeModal();
}
renderAuthModeMessage(): any {
let authMode = this.tempAuthMode.get();
if (authMode == "none") {
return (
<span>
This connection requires no authentication.
<br />
Or authentication is already configured in ssh_config.
</span>
);
}
if (authMode == "key") {
return <span>Use a public/private keypair.</span>;
}
if (authMode == "password") {
return <span>Use a password.</span>;
}
if (authMode == "key+password") {
return <span>Use a public/private keypair with a passphrase.</span>;
}
return null;
}
render() {
let authMode = this.tempAuthMode.get();
if (this.remoteEdit === null || !this.isAuthEditMode) {
return null;
}
return (
<Modal className="erconn-modal">
<Modal.Header title="Edit Connection" onClose={this.model.closeModal} />
<div className="wave-modal-body">
<div className="name-actions-section">
<div className="name text-primary">{util.getRemoteName(this.selectedRemote)}</div>
</div>
<div className="alias-section">
<TextField
label="Alias"
onChange={this.handleChangeAlias}
value={this.tempAlias.get()}
maxLength={100}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={`(Optional) A short alias to use when selecting or displaying this connection.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<div className="authmode-section">
<Dropdown
label="Auth Mode"
options={[
{ value: "none", label: "none" },
{ value: "key", label: "key" },
{ value: "password", label: "password" },
{ value: "key+password", label: "key+password" },
]}
value={this.tempAuthMode.get()}
onChange={this.handleChangeAuthMode}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={
<ul>
<li>
<b>none</b> - no authentication, or authentication is already
configured in your ssh config.
</li>
<li>
<b>key</b> - use a private key.
</li>
<li>
<b>password</b> - use a password.
</li>
<li>
<b>key+password</b> - use a key with a passphrase.
</li>
</ul>
}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<If condition={authMode == "key" || authMode == "key+password"}>
<TextField
label="SSH Keyfile"
placeholder="keyfile path"
onChange={this.handleChangeKeyFile}
value={this.tempKeyFile.get()}
maxLength={400}
required={true}
decoration={{
endDecoration: (
<InputDecoration>
<Tooltip
message={`(Required) The path to your ssh key file.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</If>
<If condition={authMode == "password" || authMode == "key+password"}>
<PasswordField
label={authMode == "password" ? "SSH Password" : "Key Passphrase"}
placeholder="password"
onChange={this.handleChangePassword}
value={this.tempPassword.get()}
maxLength={400}
/>
</If>
<div className="connectmode-section">
<Dropdown
label="Connect Mode"
options={[
{ value: "startup", label: "startup" },
{ value: "auto", label: "auto" },
{ value: "manual", label: "manual" },
]}
value={this.tempConnectMode.get()}
onChange={this.handleChangeConnectMode}
/>
</div>
<If condition={!util.isBlank(this.remoteEdit?.errorstr)}>
<div className="settings-field settings-error">Error: {this.remoteEdit?.errorstr}</div>
</If>
</div>
<Modal.Footer onOk={this.submitRemote} onCancel={this.model.closeModal} okLabel="Save" />
</Modal>
);
}
}
export { EditRemoteConnModal };

View File

@ -0,0 +1,8 @@
export { AboutModal } from "./about";
export { DisconnectedModal } from "./disconnected";
export { ClientStopModal } from "./clientstop";
export { AlertModal } from "./alert";
export { CreateRemoteConnModal } from "./createremoteconn";
export { ViewRemoteConnDetailModal } from "./viewremoteconndetail";
export { EditRemoteConnModal } from "./editremoteconn";
export { TabSwitcherModal } from "./tabswitcher";

View File

@ -17,24 +17,46 @@
} }
.disconnected-modal { .disconnected-modal {
.modal-content { .wave-modal-content {
footer { .wave-modal-body {
.footer-text-link { padding: 0;
color: @term-white;
cursor: pointer; .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;
}
}
} }
} }
}
.inner-content { .wave-modal-footer {
.ws-log { button:first-child {
padding: 5px; color: @term-green;
background-color: @term-black;
height: 250px;
overflow: auto;
.ws-logline {
color: @term-white;
} }
} }
} }
@ -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 { .screen-settings-tooltip .wave-tooltip-icon {
i { i {
font-size: 13px; font-size: 13px;
@ -491,6 +596,10 @@
gap: 4px; gap: 4px;
align-self: stretch; align-self: stretch;
width: 100%; width: 100%;
.settings-input .hotkey {
color: @text-secondary;
}
} }
} }
} }
@ -564,11 +673,15 @@
} }
.alert-modal { .alert-modal {
width: 500px; width: 510px;
.wave-modal-content { .wave-modal-content {
.wave-modal-body { .wave-modal-body {
padding: 40px 20px; padding: 40px 20px;
.dontshowagain-text {
margin-top: 15px;
}
} }
} }
} }

View File

@ -23,12 +23,15 @@ import {
Tooltip, Tooltip,
Button, Button,
Status, Status,
Checkbox,
} from "../common"; } from "../common";
import * as util from "../../../util/util"; import * as util from "../../../util/util";
import * as textmeasure from "../../../util/textmeasure"; import * as textmeasure from "../../../util/textmeasure";
import * as appconst from "../../appconst";
import { ClientDataType } from "../../../types/types"; 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 shield from "../../assets/icons/shield_check.svg";
import help from "../../assets/icons/help_filled.svg"; import help from "../../assets/icons/help_filled.svg";
import github from "../../assets/icons/github.svg"; import github from "../../assets/icons/github.svg";
@ -42,9 +45,11 @@ const VERSION = __WAVETERM_VERSION__;
let BUILD = __WAVETERM_BUILD__; let BUILD = __WAVETERM_BUILD__;
type OV<V> = mobx.IObservableValue<V>; type OV<V> = mobx.IObservableValue<V>;
type OArr<V> = mobx.IObservableArray<V>;
const RemotePtyRows = 9; const RemotePtyRows = 9;
const RemotePtyCols = 80; const RemotePtyCols = 80;
const NumOfLines = 50;
const PasswordUnchangedSentinel = "--unchanged--"; const PasswordUnchangedSentinel = "--unchanged--";
@mobxReact.observer @mobxReact.observer
@ -67,7 +72,8 @@ class ModalsProvider extends React.Component {
@mobxReact.observer @mobxReact.observer
class DisconnectedModal extends React.Component<{}, {}> { class DisconnectedModal extends React.Component<{}, {}> {
logRef: any = React.createRef(); logRef: any = React.createRef();
showLog: mobx.IObservableValue<boolean> = mobx.observable.box(false); logs: mobx.IObservableValue<string> = mobx.observable.box("");
logInterval: NodeJS.Timeout = null;
@boundMethod @boundMethod
restartServer() { restartServer() {
@ -80,8 +86,16 @@ class DisconnectedModal extends React.Component<{}, {}> {
} }
componentDidMount() { componentDidMount() {
if (this.logRef.current != null) { this.fetchLogs();
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
this.logInterval = setInterval(() => {
this.fetchLogs();
}, 5000);
}
componentWillUnmount() {
if (this.logInterval) {
clearInterval(this.logInterval);
} }
} }
@ -91,58 +105,52 @@ class DisconnectedModal extends React.Component<{}, {}> {
} }
} }
@boundMethod fetchLogs() {
handleShowLog(): void { GlobalModel.getLastLogs(
mobx.action(() => { NumOfLines,
this.showLog.set(!this.showLog.get()); mobx.action((logs) => {
})(); this.logs.set(logs);
if (this.logRef.current != null) {
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
}
})
);
} }
render() { render() {
let model = GlobalModel;
let logLine: string = null;
let idx: number = 0;
return ( return (
<div className="prompt-modal disconnected-modal modal is-active"> <Modal className="disconnected-modal">
<div className="modal-background"></div> <Modal.Header title="Wave Client Disconnected" />
<div className="modal-content"> <div className="wave-modal-body">
<div className="message-header"> <div className="modal-content">
<div className="modal-title">Wave Client Disconnected</div>
</div>
<If condition={this.showLog.get()}>
<div className="inner-content"> <div className="inner-content">
<div className="ws-log" ref={this.logRef}> <div className="log" ref={this.logRef}>
<For each="logLine" index="idx" of={GlobalModel.ws.wsLog}> <pre>{this.logs.get()}</pre>
<div key={idx} className="ws-logline">
{logLine}
</div>
</For>
</div> </div>
</div> </div>
</If> </div>
<footer> </div>
<div className="footer-text-link" style={{ marginLeft: 10 }} onClick={this.handleShowLog}> <div className="wave-modal-footer">
<If condition={!this.showLog.get()}> <Button
<i className="fa-sharp fa-solid fa-plus" /> Show Log theme="secondary"
</If> onClick={this.tryReconnect}
<If condition={this.showLog.get()}> leftIcon={
<i className="fa-sharp fa-solid fa-minus" /> Hide Log
</If>
</div>
<div className="flex-spacer" />
<button onClick={this.tryReconnect} className="button">
<span className="icon"> <span className="icon">
<i className="fa-sharp fa-solid fa-rotate" /> <i className="fa-sharp fa-solid fa-rotate" />
</span> </span>
<span>Try Reconnect</span> }
</button> >
<button onClick={this.restartServer} className="button is-danger" style={{ marginLeft: 10 }}> Try Reconnect
<WarningIcon className="icon" /> </Button>
<span>Restart Server</span> <Button
</button> theme="secondary"
</footer> onClick={this.restartServer}
leftIcon={<i className="fa-sharp fa-solid fa-triangle-exclamation"></i>}
>
Restart Server
</Button>
</div> </div>
</div> </Modal>
); );
} }
} }
@ -210,6 +218,15 @@ class AlertModal extends React.Component<{}, {}> {
GlobalModel.confirmAlert(); GlobalModel.confirmAlert();
} }
@boundMethod
handleDontShowAgain(checked: boolean) {
let message = GlobalModel.alertMessage.get();
if (message.confirmflag == null) {
return;
}
GlobalCommandRunner.clientSetConfirmFlag(message.confirmflag, checked);
}
render() { render() {
let message = GlobalModel.alertMessage.get(); let message = GlobalModel.alertMessage.get();
let title = message?.title ?? (message?.confirm ? "Confirm" : "Alert"); let title = message?.title ?? (message?.confirm ? "Confirm" : "Alert");
@ -223,16 +240,27 @@ class AlertModal extends React.Component<{}, {}> {
<Markdown text={message?.message ?? ""} /> <Markdown text={message?.message ?? ""} />
</If> </If>
<If condition={!message?.markdown}>{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>
<div className="wave-modal-footer"> <div className="wave-modal-footer">
<If condition={isConfirm}> <If condition={isConfirm}>
<Button theme="secondary" onClick={this.closeModal}> <Button theme="secondary" onClick={this.closeModal}>
Cancel Cancel
</Button> </Button>
<Button onClick={this.handleOK}>Ok</Button> <Button autoFocus={true} onClick={this.handleOK}>
Ok
</Button>
</If> </If>
<If condition={!isConfirm}> <If condition={!isConfirm}>
<Button onClick={this.handleOK}>Ok</Button> <Button autoFocus={true} onClick={this.handleOK}>
Ok
</Button>
</If> </If>
</div> </div>
</Modal> </Modal>
@ -497,6 +525,10 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
this.errorStr = mobx.observable.box(this.remoteEdit?.errorstr ?? null, { name: "CreateRemote-errorStr" }); this.errorStr = mobx.observable.box(this.remoteEdit?.errorstr ?? null, { name: "CreateRemote-errorStr" });
} }
componentDidMount(): void {
GlobalModel.getClientData();
}
remoteCName(): string { remoteCName(): string {
let hostName = this.tempHostName.get(); let hostName = this.tempHostName.get();
if (hostName == "") { if (hostName == "") {
@ -515,6 +547,27 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
return this.remoteEdit?.errorstr ?? null; 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 @boundMethod
submitRemote(): void { submitRemote(): void {
mobx.action(() => { mobx.action(() => {
@ -552,26 +605,27 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
kwargs["connectmode"] = this.tempConnectMode.get(); kwargs["connectmode"] = this.tempConnectMode.get();
kwargs["visual"] = "1"; kwargs["visual"] = "1";
kwargs["submit"] = "1"; kwargs["submit"] = "1";
let model = this.model;
let prtn = GlobalCommandRunner.createRemote(cname, kwargs, false); let prtn = GlobalCommandRunner.createRemote(cname, kwargs, false);
prtn.then((crtn) => { prtn.then((crtn) => {
if (crtn.success) { if (crtn.success) {
this.model.setRecentConnAdded(true);
this.model.closeModal();
let crRtn = GlobalCommandRunner.screenSetRemote(cname, true, false); let crRtn = GlobalCommandRunner.screenSetRemote(cname, true, false);
crRtn.then((crcrtn) => { crRtn.then((crcrtn) => {
if (crcrtn.success) { if (crcrtn.success) {
return; return;
} }
mobx.action(() => { mobx.action(() => {
this.errorStr.set(crcrtn.error ?? null); this.errorStr.set(crcrtn.error);
})(); })();
}); });
return; return;
} }
mobx.action(() => { mobx.action(() => {
this.errorStr.set(crtn.error ?? null); this.errorStr.set(crtn.error);
})(); })();
}); });
model.seRecentConnAdded(true);
} }
@boundMethod @boundMethod
@ -789,7 +843,7 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
<div className="settings-field settings-error">Error: {this.getErrorStr()}</div> <div className="settings-field settings-error">Error: {this.getErrorStr()}</div>
</If> </If>
</div> </div>
<Modal.Footer onCancel={this.model.closeModal} onOk={this.submitRemote} okLabel="Connect" /> <Modal.Footer onCancel={this.model.closeModal} onOk={this.handleOk} okLabel="Connect" />
</Modal> </Modal>
); );
} }
@ -806,7 +860,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
} }
@mobx.computed @mobx.computed
get selectedRemote(): T.RemoteType { getSelectedRemote(): T.RemoteType {
const selectedRemoteId = this.model.selectedRemoteId.get(); const selectedRemoteId = this.model.selectedRemoteId.get();
return GlobalModel.getRemote(selectedRemoteId); return GlobalModel.getRemote(selectedRemoteId);
} }
@ -821,7 +875,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
} }
componentDidUpdate() { componentDidUpdate() {
if (this.selectedRemote == null || this.selectedRemote.archived) { if (this.getSelectedRemote() == null || this.getSelectedRemote().archived) {
this.model.deSelectRemote(); this.model.deSelectRemote();
} }
} }
@ -885,7 +939,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
@boundMethod @boundMethod
clickArchive(): void { 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." }); GlobalModel.showAlert({ message: "Cannot delete when connected. Disconnect and try again." });
return; return;
} }
@ -897,21 +951,22 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
if (!confirm) { if (!confirm) {
return; return;
} }
if (this.selectedRemote) { if (this.getSelectedRemote()) {
GlobalCommandRunner.archiveRemote(this.selectedRemote.remoteid); GlobalCommandRunner.archiveRemote(this.getSelectedRemote().remoteid);
} }
GlobalModel.modalsModel.popModal();
}); });
} }
@boundMethod @boundMethod
clickReinstall(): void { clickReinstall(): void {
GlobalCommandRunner.installRemote(this.selectedRemote?.remoteid); GlobalCommandRunner.installRemote(this.getSelectedRemote().remoteid);
} }
@boundMethod @boundMethod
handleClose(): void { handleClose(): void {
this.model.closeModal(); this.model.closeModal();
this.model.seRecentConnAdded(false); this.model.setRecentConnAdded(false);
} }
renderInstallStatus(remote: T.RemoteType): any { renderInstallStatus(remote: T.RemoteType): any {
@ -990,7 +1045,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
<Button theme="secondary" disabled={true}> <Button theme="secondary" disabled={true}>
Edit Edit
<Tooltip <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" />} icon={<i className="fa-sharp fa-regular fa-fw fa-ban" />}
> >
<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 <Tooltip
message={ message={
<span> <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{" "} importing again. They will stay removed if you follow{" "}
<a href="https://docs.waveterm.dev/features/sshconfig-imports">this procedure</a>. <a href="https://docs.waveterm.dev/features/sshconfig-imports">this procedure</a>.
</span> </span>
@ -1072,7 +1127,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
} }
render() { render() {
let remote = this.selectedRemote; let remote = this.getSelectedRemote();
if (remote == null) { if (remote == null) {
return null; return null;
@ -1083,14 +1138,15 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
let termFontSize = GlobalModel.termFontSize.get(); let termFontSize = GlobalModel.termFontSize.get();
let termWidth = textmeasure.termWidthFromCols(RemotePtyCols, termFontSize); let termWidth = textmeasure.termWidthFromCols(RemotePtyCols, termFontSize);
let remoteAliasText = util.isBlank(remote.remotealias) ? "(none)" : remote.remotealias; let remoteAliasText = util.isBlank(remote.remotealias) ? "(none)" : remote.remotealias;
let selectedRemoteStatus = this.getSelectedRemote().status;
return ( return (
<Modal className="rconndetail-modal"> <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="wave-modal-body">
<div className="name-header-actions-wrapper"> <div className="name-header-actions-wrapper">
<div className="name text-primary name-wrapper"> <div className="name text-primary name-wrapper">
{getName(remote)}&nbsp; {getImportTooltip(remote)} {util.getRemoteName(remote)}&nbsp; {getImportTooltip(remote)}
</div> </div>
<div className="header-actions">{this.renderHeaderBtns(remote)}</div> <div className="header-actions">{this.renderHeaderBtns(remote)}</div>
</div> </div>
@ -1161,7 +1217,18 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
</div> </div>
</div> </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> </Modal>
); );
} }
@ -1343,7 +1410,7 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
<Modal.Header title="Edit Connection" onClose={this.model.closeModal} /> <Modal.Header title="Edit Connection" onClose={this.model.closeModal} />
<div className="wave-modal-body"> <div className="wave-modal-body">
<div className="name-actions-section"> <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>
<div className="alias-section"> <div className="alias-section">
<TextField <TextField
@ -1459,14 +1526,309 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
} }
} }
const getName = (remote: T.RemoteType): string => { type SwitcherDataType = {
if (remote == null) { sessionId: string;
return ""; sessionName: string;
} sessionIdx: number;
const { remotealias, remotecanonicalname } = remote; screenId: string;
return remotealias ? `${remotealias} [${remotecanonicalname}]` : remotecanonicalname; 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> => { const getImportTooltip = (remote: T.RemoteType): React.ReactElement<any, any> => {
if (remote.sshconfigsrc == "sshconfig-import") { if (remote.sshconfigsrc == "sshconfig-import") {
return ( return (
@ -1493,4 +1855,5 @@ export {
ViewRemoteConnDetailModal, ViewRemoteConnDetailModal,
EditRemoteConnModal, EditRemoteConnModal,
ModalsProvider, ModalsProvider,
TabSwitcherModal,
}; };

View File

@ -0,0 +1,26 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import { GlobalModel } from "../../../model/model";
import { TosModal } from "./tos";
@mobxReact.observer
class ModalsProvider extends React.Component {
render() {
let store = GlobalModel.modalsModel.store.slice();
if (GlobalModel.needsTos()) {
return <TosModal />;
}
let rtn: JSX.Element[] = [];
for (let i = 0; i < store.length; i++) {
let entry = store[i];
let Comp = entry.component;
rtn.push(<Comp key={entry.uniqueKey} />);
}
return <>{rtn}</>;
}
}
export { ModalsProvider };

View File

@ -4,11 +4,12 @@
import * as React from "react"; import * as React from "react";
import { import {
AboutModal, AboutModal,
AlertModal,
CreateRemoteConnModal, CreateRemoteConnModal,
ViewRemoteConnDetailModal, ViewRemoteConnDetailModal,
EditRemoteConnModal, EditRemoteConnModal,
AlertModal, TabSwitcherModal,
} from "./modals"; } from "../modals";
import { ScreenSettingsModal, SessionSettingsModal, LineSettingsModal, ClientSettingsModal } from "./settings"; import { ScreenSettingsModal, SessionSettingsModal, LineSettingsModal, ClientSettingsModal } from "./settings";
import * as constants from "../../appconst"; import * as constants from "../../appconst";
@ -22,6 +23,7 @@ const modalsRegistry: { [key: string]: () => React.ReactElement } = {
[constants.SESSION_SETTINGS]: () => <SessionSettingsModal />, [constants.SESSION_SETTINGS]: () => <SessionSettingsModal />,
[constants.LINE_SETTINGS]: () => <LineSettingsModal />, [constants.LINE_SETTINGS]: () => <LineSettingsModal />,
[constants.CLIENT_SETTINGS]: () => <ClientSettingsModal />, [constants.CLIENT_SETTINGS]: () => <ClientSettingsModal />,
[constants.TAB_SWITCHER]: () => <TabSwitcherModal />,
}; };
export { modalsRegistry }; export { modalsRegistry };

View File

@ -17,7 +17,16 @@ import {
Screen, Screen,
Session, Session,
} from "../../../model/model"; } 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 { LineType, RendererPluginType, ClientDataType, CommandRtnType, RemoteType } from "../../../types/types";
import { PluginModel } from "../../../plugins/plugins"; import { PluginModel } from "../../../plugins/plugins";
import * as util from "../../../util/util"; import * as util from "../../../util/util";
@ -219,7 +228,7 @@ class ScreenSettingsModal extends React.Component<{}, {}> {
return; return;
} }
if (this.screen.getScreenLines().lines.length == 0) { if (this.screen.getScreenLines().lines.length == 0) {
GlobalCommandRunner.screenDelete(this.screenId); GlobalCommandRunner.screenDelete(this.screenId, false);
GlobalModel.modalsModel.popModal(); GlobalModel.modalsModel.popModal();
return; return;
} }
@ -229,7 +238,7 @@ class ScreenSettingsModal extends React.Component<{}, {}> {
if (!result) { if (!result) {
return; return;
} }
let prtn = GlobalCommandRunner.screenDelete(this.screenId); let prtn = GlobalCommandRunner.screenDelete(this.screenId, false);
commandRtnHandler(prtn, this.errorMessage); commandRtnHandler(prtn, this.errorMessage);
GlobalModel.modalsModel.popModal(); GlobalModel.modalsModel.popModal();
}); });
@ -632,12 +641,6 @@ class LineSettingsModal extends React.Component<{}, {}> {
/> />
</div> </div>
</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} /> <SettingsError errorMessage={this.errorMessage} />
<div style={{ height: 50 }} /> <div style={{ height: 50 }} />
</div> </div>

View File

@ -0,0 +1,84 @@
@import "../../../app/common/themes/themes.less";
.tabswitcher-modal {
width: 452px;
min-height: 384px;
.wave-modal-content {
.wave-modal-body {
display: flex;
padding: 0px;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
width: 100%;
.textfield-wrapper {
padding: 20px 20px 0px;
.wave-input-decoration.start-position {
height: 100%;
.tabswitcher-search-prefix {
opacity: 0.5;
font-size: 13px;
}
}
}
.list-container {
overflow: hidden;
padding: 10px 0 20px;
width: 100%;
}
.list-container-inner {
width: 100%;
max-height: 300px;
overflow-y: scroll;
padding: 0 16px 0 20px;
&::-webkit-scrollbar-thumb {
display: none;
}
&:hover::-webkit-scrollbar-thumb {
display: block;
}
.options-list {
width: 100%;
.search-option {
padding: 5px 5px 5px 8px;
display: flex;
align-items: center;
border: 1px solid transparent;
width: 100%;
overflow: hidden;
div.tabname {
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 5px;
}
div.icon {
flex-shrink: 0;
width: 20px;
margin-right: 6px;
}
}
.focused-option {
border: 1px solid rgba(241, 246, 243, 0.15);
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
}
}
}
}
}
}

View File

@ -0,0 +1,324 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { For } from "tsx-control-statements/components";
import cn from "classnames";
import { GlobalModel, GlobalCommandRunner } from "../../../model/model";
import { Modal, TextField, InputDecoration, Tooltip } from "../common";
import * as util from "../../../util/util";
import { Screen } from "../../../model/model";
import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg";
import "./tabswitcher.less";
type OV<V> = mobx.IObservableValue<V>;
type OArr<V> = mobx.IObservableArray<V>;
type SwitcherDataType = {
sessionId: string;
sessionName: string;
sessionIdx: number;
screenId: string;
screenIdx: number;
screenName: string;
icon: string;
color: string;
};
const MaxOptionsToDisplay = 100;
@mobxReact.observer
class TabSwitcherModal extends React.Component<{}, {}> {
screens: Map<string, OV<string>>[];
sessions: Map<string, OV<string>>[];
options: SwitcherDataType[] = [];
sOptions: OArr<SwitcherDataType> = mobx.observable.array(null, {
name: "TabSwitcherModal-sOptions",
});
focusedIdx: OV<number> = mobx.observable.box(0, { name: "TabSwitcherModal-selectedIdx" });
activeSessionIdx: number;
optionRefs = [];
listWrapperRef = React.createRef<HTMLDivElement>();
prevFocusedIdx = 0;
componentDidMount() {
this.activeSessionIdx = GlobalModel.getActiveSession().sessionIdx.get();
let oSessions = GlobalModel.sessionList;
let oScreens = GlobalModel.screenMap;
oScreens.forEach((oScreen) => {
// Find the matching session in the observable array
let foundSession = oSessions.find((s) => {
if (s.sessionId === oScreen.sessionId && s.archived.get() == false) {
return true;
}
return false;
});
if (foundSession) {
let data: SwitcherDataType = {
sessionName: foundSession.name.get(),
sessionId: foundSession.sessionId,
sessionIdx: foundSession.sessionIdx.get(),
screenName: oScreen.name.get(),
screenId: oScreen.screenId,
screenIdx: oScreen.screenIdx.get(),
icon: this.getTabIcon(oScreen),
color: this.getTabColor(oScreen),
};
this.options.push(data);
}
});
mobx.action(() => {
this.sOptions.replace(this.sortOptions(this.options).slice(0, MaxOptionsToDisplay));
})();
document.addEventListener("keydown", this.handleKeyDown);
}
componentWillUnmount() {
document.removeEventListener("keydown", this.handleKeyDown);
}
componentDidUpdate() {
let currFocusedIdx = this.focusedIdx.get();
// Check if selectedIdx has changed
if (currFocusedIdx !== this.prevFocusedIdx) {
let optionElement = this.optionRefs[currFocusedIdx]?.current;
if (optionElement) {
optionElement.scrollIntoView({ block: "nearest" });
}
// Update prevFocusedIdx for the next update cycle
this.prevFocusedIdx = currFocusedIdx;
}
if (currFocusedIdx >= this.sOptions.length && this.sOptions.length > 0) {
this.setFocusedIndex(this.sOptions.length - 1);
}
}
@boundMethod
getTabIcon(screen: Screen): string {
let tabIcon = "default";
let screenOpts = screen.opts.get();
if (screenOpts != null && !util.isBlank(screenOpts.tabicon)) {
tabIcon = screenOpts.tabicon;
}
return tabIcon;
}
@boundMethod
getTabColor(screen: Screen): string {
let tabColor = "default";
let screenOpts = screen.opts.get();
if (screenOpts != null && !util.isBlank(screenOpts.tabcolor)) {
tabColor = screenOpts.tabcolor;
}
return tabColor;
}
@boundMethod
handleKeyDown(e) {
if (e.key === "Escape") {
this.closeModal();
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
let newIndex = this.calculateNewIndex(e.key === "ArrowUp");
this.setFocusedIndex(newIndex);
} else if (e.key === "Enter") {
e.preventDefault();
this.handleSelect(this.focusedIdx.get());
}
}
@boundMethod
calculateNewIndex(isUpKey) {
let currentIndex = this.focusedIdx.get();
if (isUpKey) {
return Math.max(currentIndex - 1, 0);
} else {
return Math.min(currentIndex + 1, this.sOptions.length - 1);
}
}
@boundMethod
setFocusedIndex(index) {
mobx.action(() => {
this.focusedIdx.set(index);
})();
}
@boundMethod
closeModal(): void {
GlobalModel.modalsModel.popModal();
}
@boundMethod
handleSelect(index: number): void {
const selectedOption = this.sOptions[index];
if (selectedOption) {
GlobalCommandRunner.switchScreen(selectedOption.screenId, selectedOption.sessionId);
this.closeModal();
}
}
@boundMethod
handleSearch(val: string): void {
let sOptions: SwitcherDataType[];
if (val == "") {
sOptions = this.sortOptions(this.options).slice(0, MaxOptionsToDisplay);
} else {
sOptions = this.filterOptions(val);
sOptions = this.sortOptions(sOptions);
if (sOptions.length > MaxOptionsToDisplay) {
sOptions = sOptions.slice(0, MaxOptionsToDisplay);
}
}
mobx.action(() => {
this.sOptions.replace(sOptions);
this.focusedIdx.set(0);
})();
}
@mobx.computed
@boundMethod
filterOptions(searchInput: string): SwitcherDataType[] {
let filteredScreens = [];
for (let i = 0; i < this.options.length; i++) {
let tab = this.options[i];
let match = false;
if (searchInput.includes("/")) {
let [sessionFilter, screenFilter] = searchInput.split("/").map((s) => s.trim().toLowerCase());
match =
tab.sessionName.toLowerCase().includes(sessionFilter) &&
tab.screenName.toLowerCase().includes(screenFilter);
} else {
match =
tab.sessionName.toLowerCase().includes(searchInput) ||
tab.screenName.toLowerCase().includes(searchInput);
}
// Add tab to filtered list if it matches the criteria
if (match) {
filteredScreens.push(tab);
}
}
return filteredScreens;
}
@mobx.computed
@boundMethod
sortOptions(options: SwitcherDataType[]): SwitcherDataType[] {
return options.sort((a, b) => {
let aInCurrentSession = a.sessionIdx === this.activeSessionIdx;
let bInCurrentSession = b.sessionIdx === this.activeSessionIdx;
// Tabs in the current session are sorted by screenIdx
if (aInCurrentSession && bInCurrentSession) {
return a.screenIdx - b.screenIdx;
}
// a is in the current session and b is not, so a comes first
else if (aInCurrentSession) {
return -1;
}
// b is in the current session and a is not, so b comes first
else if (bInCurrentSession) {
return 1;
}
// Both are in different, non-current sessions - sort by sessionIdx and then by screenIdx
else {
if (a.sessionIdx === b.sessionIdx) {
return a.screenIdx - b.screenIdx;
} else {
return a.sessionIdx - b.sessionIdx;
}
}
});
}
@boundMethod
renderIcon(option: SwitcherDataType): React.ReactNode {
let tabIcon = option.icon;
if (tabIcon === "default" || tabIcon === "square") {
return <SquareIcon className="left-icon" />;
}
return <i className={`fa-sharp fa-solid fa-${tabIcon}`}></i>;
}
@boundMethod
renderOption(option: SwitcherDataType, index: number): JSX.Element {
if (!this.optionRefs[index]) {
this.optionRefs[index] = React.createRef();
}
return (
<div
key={option.sessionId + "/" + option.screenId}
ref={this.optionRefs[index]}
className={cn("search-option unselectable", {
"focused-option": this.focusedIdx.get() === index,
})}
onClick={() => this.handleSelect(index)}
>
<div className={cn("icon", "color-" + option.color)}>{this.renderIcon(option)}</div>
<div className="tabname">
#{option.sessionName} / {option.screenName}
</div>
</div>
);
}
render() {
let option: SwitcherDataType;
let index: number;
return (
<Modal className="tabswitcher-modal">
<div className="wave-modal-body">
<div className="textfield-wrapper">
<TextField
onChange={this.handleSearch}
maxLength={400}
autoFocus={true}
decoration={{
startDecoration: (
<InputDecoration position="start">
<div className="tabswitcher-search-prefix">Switch to Tab:</div>
</InputDecoration>
),
endDecoration: (
<InputDecoration>
<Tooltip
message={`Type to filter workspaces and tabs.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<div className="list-container">
<div ref={this.listWrapperRef} className="list-container-inner">
<div className="options-list">
<For each="option" index="index" of={this.sOptions}>
{this.renderOption(option, index)}
</For>
</div>
</div>
</div>
</div>
</Modal>
);
}
}
export { TabSwitcherModal };

View File

@ -0,0 +1,102 @@
@import "../../../app/common/themes/themes.less";
.tos-modal {
width: 640px;
.wave-modal-content .wave-modal-body {
padding: 32px 48px;
gap: 8px;
.wave-modal-body-inner {
gap: 24px;
display: flex;
flex-direction: column;
header.tos-header {
flex-direction: column;
gap: var(--sizing-sm, 12px);
border-bottom: none;
padding: 0;
.modal-title {
text-align: center;
font-size: 20px;
font-weight: 300;
}
.modal-subtitle {
color: @term-white;
text-align: center;
font-style: normal;
font-weight: 300;
line-height: 20px;
}
}
.content.tos-content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 32px;
width: 100%;
margin-bottom: 0;
.item {
display: flex;
width: 100%;
align-items: center;
gap: 16px;
.item-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
flex: 1 0 0;
.item-title {
color: @term-bright-white;
font-style: normal;
line-height: 20px;
}
.item-text {
color: @term-white;
font-style: normal;
line-height: 20px;
}
.item-field {
display: flex;
align-items: center;
gap: 8px;
}
}
}
}
footer {
.item-text {
text-align: center;
}
.button-wrapper {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
button {
font-size: 12.5px !important;
margin-top: 16px;
}
button.disabled-button {
cursor: default;
}
}
}
}
}
}

View 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&nbsp;Discord&nbsp;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&nbsp;(wavetermdev/waveterm)
</a>
</div>
</div>
</div>
</div>
<footer className="unselectable">
<div className="item-text">
By continuing, I accept the&nbsp;
<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 };

View 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
}
}
}
}
}
}
}

View 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)}&nbsp; {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 };

View File

@ -11,6 +11,7 @@ import { GlobalModel, RemotesModel, GlobalCommandRunner } from "../../model/mode
import { Button, IconButton, Status } from "../common/common"; import { Button, IconButton, Status } from "../common/common";
import * as T from "../../types/types"; import * as T from "../../types/types";
import * as util from "../../util/util"; import * as util from "../../util/util";
import * as appconst from "../appconst";
import "./connections.less"; import "./connections.less";
@ -74,10 +75,31 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered
} }
@boundMethod @boundMethod
handleImportSshConfig(): void { importSshConfig(): void {
GlobalCommandRunner.importSshConfig(); 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 @boundMethod
handleRead(remoteId: string): void { handleRead(remoteId: string): void {
GlobalModel.remotesModel.openReadModal(remoteId); GlobalModel.remotesModel.openReadModal(remoteId);
@ -163,8 +185,8 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered
onClick={() => this.handleRead(item.remoteid)} // Moved onClick here onClick={() => this.handleRead(item.remoteid)} // Moved onClick here
> >
<td className="col-name"> <td className="col-name">
<Status status={this.getStatus(item.status)} text=""></Status> <Status status={this.getStatus(item.status)} text=""></Status>
{this.getName(item)}&nbsp;{this.getImportSymbol(item)} {this.getName(item)}&nbsp;{this.getImportSymbol(item)}
</td> </td>
<td className="col-type"> <td className="col-type">
<div>{item.remotetype}</div> <div>{item.remotetype}</div>

View File

@ -354,6 +354,12 @@ class LineCmd extends React.Component<
GlobalCommandRunner.lineBookmark(line.lineid); GlobalCommandRunner.lineBookmark(line.lineid);
} }
@boundMethod
clickDelete() {
let { line } = this.props;
GlobalCommandRunner.lineDelete(line.lineid, true);
}
@boundMethod @boundMethod
clickMinimize() { clickMinimize() {
mobx.action(() => { mobx.action(() => {
@ -659,6 +665,9 @@ class LineCmd extends React.Component<
{this.renderMeta1(cmd)} {this.renderMeta1(cmd)}
<If condition={!hidePrompt}>{this.renderCmdText(cmd)}</If> <If condition={!hidePrompt}>{this.renderCmdText(cmd)}</If>
</div> </div>
<div key="delete" title="Delete Line (&#x2318;D)" className="line-icon" onClick={this.clickDelete}>
<i className="fa-sharp fa-regular fa-trash" />
</div>
<div <div
key="bookmark" key="bookmark"
title="Bookmark" title="Bookmark"

View 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 };

View File

@ -42,6 +42,10 @@
max-height: max(300px, 70%); max-height: max(300px, 70%);
} }
&.has-aichat {
max-height: max(300px, 70%);
}
.remote-status-warning { .remote-status-warning {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -196,6 +200,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 { .cmd-history {
color: @term-white; color: @term-white;

View File

@ -19,6 +19,7 @@ import { Prompt } from "../../common/prompt/prompt";
import { ReactComponent as ExecIcon } from "../../assets/icons/exec.svg"; import { ReactComponent as ExecIcon } from "../../assets/icons/exec.svg";
import { ReactComponent as RotateIcon } from "../../assets/icons/line/rotate.svg"; import { ReactComponent as RotateIcon } from "../../assets/icons/line/rotate.svg";
import "./cmdinput.less"; import "./cmdinput.less";
import { AIChat } from "./aichat";
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
@ -116,6 +117,7 @@ class CmdInput extends React.Component<{}, {}> {
} }
let infoShow = inputModel.infoShow.get(); let infoShow = inputModel.infoShow.get();
let historyShow = !infoShow && inputModel.historyShow.get(); let historyShow = !infoShow && inputModel.historyShow.get();
let aiChatShow = inputModel.aIChatShow.get();
let infoMsg = inputModel.infoMsg.get(); let infoMsg = inputModel.infoMsg.get();
let hasInfo = infoMsg != null; let hasInfo = infoMsg != null;
let focusVal = inputModel.physicalInputFocused.get(); let focusVal = inputModel.physicalInputFocused.get();
@ -127,11 +129,23 @@ class CmdInput extends React.Component<{}, {}> {
numRunningLines = mobx.computed(() => win.getRunningCmdLines().length).get(); numRunningLines = mobx.computed(() => win.getRunningCmdLines().length).get();
} }
return ( 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}> <If condition={historyShow}>
<div className="cmd-input-grow-spacer"></div> <div className="cmd-input-grow-spacer"></div>
<HistoryInfo /> <HistoryInfo />
</If> </If>
<If condition={aiChatShow}>
<div className="cmd-input-grow-spacer"></div>
<AIChat />
</If>
<InfoMsg key="infomsg" /> <InfoMsg key="infomsg" />
<If condition={remote && remote.status != "connected"}> <If condition={remote && remote.status != "connected"}>
<div className="remote-status-warning"> <div className="remote-status-warning">

View File

@ -230,6 +230,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
if (inputModel.inputMode.get() != null) { if (inputModel.inputMode.get() != null) {
inputModel.resetInputMode(); inputModel.resetInputMode();
} }
inputModel.closeAIAssistantChat();
return; return;
} }
if (e.code == "KeyE" && e.getModifierState("Meta")) { 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); 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); // console.log(e.code, e.keyCode, e.key, event.which, ctrlMod, e);
})(); })();
} }

View File

@ -244,11 +244,15 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
overflow-x: hidden; overflow-x: scroll;
&:hover, &::-webkit-scrollbar-thumb,
&:focus { &::-webkit-scrollbar-track {
overflow-x: overlay; display: none;
}
&:hover::-webkit-scrollbar-thumb {
display: block;
} }
.screen-tab { .screen-tab {
@ -321,6 +325,7 @@
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
height: 37px;
.icon { .icon {
height: 2rem; height: 2rem;

View File

@ -140,7 +140,7 @@ function getWaveSrvCmd() {
let waveSrvPath = getWaveSrvPath(); let waveSrvPath = getWaveSrvPath();
let waveHome = getWaveHomeDir(); let waveHome = getWaveHomeDir();
let logFile = path.join(waveHome, "wavesrv.log"); let logFile = path.join(waveHome, "wavesrv.log");
return `${waveSrvPath} >> "${logFile}" 2>&1`; return `"${waveSrvPath}" >> "${logFile}" 2>&1`;
} }
function getWaveSrvCwd() { function getWaveSrvCwd() {
@ -173,10 +173,10 @@ let menuTemplate = [
role: "appMenu", role: "appMenu",
submenu: [ submenu: [
{ {
label: 'About Wave Terminal', label: "About Wave Terminal",
click: () => { click: () => {
MainWindow?.webContents.send('menu-item-about'); MainWindow?.webContents.send("menu-item-about");
} },
}, },
{ type: "separator" }, { type: "separator" },
{ role: "services" }, { role: "services" },
@ -250,7 +250,7 @@ function createMainWindow(clientData) {
minWidth: 800, minWidth: 800,
minHeight: 600, minHeight: 600,
transparent: true, transparent: true,
icon: (unamePlatform == "linux") ? "public/logos/wave-logo-dark.png" : undefined, icon: unamePlatform == "linux" ? "public/logos/wave-logo-dark.png" : undefined,
webPreferences: { webPreferences: {
preload: path.join(getAppBasePath(), DistDir, "preload.js"), preload: path.join(getAppBasePath(), DistDir, "preload.js"),
}, },
@ -302,6 +302,11 @@ function createMainWindow(clientData) {
e.preventDefault(); e.preventDefault();
return; 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.meta && (input.code == "ArrowUp" || input.code == "ArrowDown")) {
if (input.code == "ArrowUp") { if (input.code == "ArrowUp") {
win.webContents.send("meta-arrowup"); win.webContents.send("meta-arrowup");
@ -480,6 +485,41 @@ electron.ipcMain.on("reload-window", (event) => {
return; 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 { function getContextMenu(): any {
let menu = new electron.Menu(); let menu = new electron.Menu();
let menuItem = new electron.MenuItem({ label: "Testing", click: () => console.log("click testing!") }); let menuItem = new electron.MenuItem({ label: "Testing", click: () => console.log("click testing!") });
@ -535,8 +575,8 @@ function sendWSSC() {
} }
function runWaveSrv() { function runWaveSrv() {
let pResolve = null; let pResolve: (value: unknown) => void;
let pReject = null; let pReject: (reason?: any) => void;
let rtnPromise = new Promise((argResolve, argReject) => { let rtnPromise = new Promise((argResolve, argReject) => {
pResolve = argResolve; pResolve = argResolve;
pReject = argReject; pReject = argReject;
@ -546,8 +586,9 @@ function runWaveSrv() {
if (isDev) { if (isDev) {
envCopy[WaveDevVarName] = "1"; envCopy[WaveDevVarName] = "1";
} }
console.log("trying to run local server", getWaveSrvPath()); let waveSrvCmd = getWaveSrvCmd();
let proc = child_process.spawn("bash", ["-c", getWaveSrvCmd()], { console.log("trying to run local server", waveSrvCmd);
let proc = child_process.spawn("bash", ["-c", waveSrvCmd], {
cwd: getWaveSrvCwd(), cwd: getWaveSrvCwd(),
env: envCopy, env: envCopy,
}); });
@ -555,7 +596,7 @@ function runWaveSrv() {
console.log("wavesrv exit", e); console.log("wavesrv exit", e);
waveSrvProc = null; waveSrvProc = null;
sendWSSC(); 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) { if (waveSrvShouldRestart) {
waveSrvShouldRestart = false; waveSrvShouldRestart = false;
this.runWaveSrv(); this.runWaveSrv();

View File

@ -6,13 +6,19 @@ contextBridge.exposeInMainWorld("api", {
getIsDev: () => ipcRenderer.sendSync("get-isdev"), getIsDev: () => ipcRenderer.sendSync("get-isdev"),
getAuthKey: () => ipcRenderer.sendSync("get-authkey"), getAuthKey: () => ipcRenderer.sendSync("get-authkey"),
getWaveSrvStatus: () => ipcRenderer.sendSync("wavesrv-status"), 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"), restartWaveSrv: () => ipcRenderer.sendSync("restart-server"),
reloadWindow: () => ipcRenderer.sendSync("reload-window"), reloadWindow: () => ipcRenderer.sendSync("reload-window"),
openExternalLink: (url) => ipcRenderer.send("open-external-link", url),
onTCmd: (callback) => ipcRenderer.on("t-cmd", callback), onTCmd: (callback) => ipcRenderer.on("t-cmd", callback),
onICmd: (callback) => ipcRenderer.on("i-cmd", callback), onICmd: (callback) => ipcRenderer.on("i-cmd", callback),
onLCmd: (callback) => ipcRenderer.on("l-cmd", callback), onLCmd: (callback) => ipcRenderer.on("l-cmd", callback),
onHCmd: (callback) => ipcRenderer.on("h-cmd", callback), onHCmd: (callback) => ipcRenderer.on("h-cmd", callback),
onWCmd: (callback) => ipcRenderer.on("w-cmd", callback), onWCmd: (callback) => ipcRenderer.on("w-cmd", callback),
onPCmd: (callback) => ipcRenderer.on("p-cmd", callback),
onMetaArrowUp: (callback) => ipcRenderer.on("meta-arrowup", callback), onMetaArrowUp: (callback) => ipcRenderer.on("meta-arrowup", callback),
onMetaArrowDown: (callback) => ipcRenderer.on("meta-arrowdown", callback), onMetaArrowDown: (callback) => ipcRenderer.on("meta-arrowdown", callback),
onMetaPageUp: (callback) => ipcRenderer.on("meta-pageup", callback), onMetaPageUp: (callback) => ipcRenderer.on("meta-pageup", callback),

View File

@ -9,6 +9,8 @@ import { boundMethod } from "autobind-decorator";
import { debounce } from "throttle-debounce"; import { debounce } from "throttle-debounce";
import { import {
handleJsonFetchResponse, handleJsonFetchResponse,
base64ToString,
stringToBase64,
base64ToArray, base64ToArray,
genMergeData, genMergeData,
genMergeDataMap, genMergeDataMap,
@ -63,6 +65,7 @@ import type {
CommandRtnType, CommandRtnType,
WebCmd, WebCmd,
WebRemote, WebRemote,
OpenAICmdInfoChatMessageType,
} from "../types/types"; } from "../types/types";
import * as T from "../types/types"; import * as T from "../types/types";
import { WSControl } from "./ws"; import { WSControl } from "./ws";
@ -78,7 +81,7 @@ import localizedFormat from "dayjs/plugin/localizedFormat";
import customParseFormat from "dayjs/plugin/customParseFormat"; import customParseFormat from "dayjs/plugin/customParseFormat";
import { getRendererContext, cmdStatusIsRunning } from "../app/line/lineutil"; import { getRendererContext, cmdStatusIsRunning } from "../app/line/lineutil";
import { MagicLayout } from "../app/magiclayout"; 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"; import * as appconst from "../app/appconst";
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
@ -193,10 +196,13 @@ type ElectronApi = {
getWaveSrvStatus: () => boolean; getWaveSrvStatus: () => boolean;
restartWaveSrv: () => boolean; restartWaveSrv: () => boolean;
reloadWindow: () => void; reloadWindow: () => void;
openExternalLink: (url: string) => void;
onTCmd: (callback: (mods: KeyModsType) => void) => void; onTCmd: (callback: (mods: KeyModsType) => void) => void;
onICmd: (callback: (mods: KeyModsType) => void) => void; onICmd: (callback: (mods: KeyModsType) => void) => void;
onLCmd: (callback: (mods: KeyModsType) => void) => void; onLCmd: (callback: (mods: KeyModsType) => void) => void;
onHCmd: (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; onMenuItemAbout: (callback: () => void) => void;
onMetaArrowUp: (callback: () => void) => void; onMetaArrowUp: (callback: () => void) => void;
onMetaArrowDown: (callback: () => void) => void; onMetaArrowDown: (callback: () => void) => void;
@ -207,6 +213,7 @@ type ElectronApi = {
contextScreen: (screenOpts: { screenId: string }, position: { x: number; y: number }) => void; contextScreen: (screenOpts: { screenId: string }, position: { x: number; y: number }) => void;
contextEditMenu: (position: { x: number; y: number }, opts: ContextMenuOpts) => void; contextEditMenu: (position: { x: number; y: number }, opts: ContextMenuOpts) => void;
onWaveSrvStatusChange: (callback: (status: boolean, pid: number) => void) => void; onWaveSrvStatusChange: (callback: (status: boolean, pid: number) => void) => void;
getLastLogs: (numOfLines: number, callback: (logs: any) => void) => void;
}; };
function getApi(): ElectronApi { function getApi(): ElectronApi {
@ -334,7 +341,7 @@ class Cmd {
type: "feinput", type: "feinput",
ck: this.screenId + "/" + this.lineId, ck: this.screenId + "/" + this.lineId,
remote: this.remote, remote: this.remote,
inputdata64: btoa(data), inputdata64: stringToBase64(data),
}; };
GlobalModel.sendInputPacket(inputPacket); GlobalModel.sendInputPacket(inputPacket);
} }
@ -1228,7 +1235,18 @@ function getDefaultHistoryQueryOpts(): HistoryQueryOpts {
class InputModel { class InputModel {
historyShow: OV<boolean> = mobx.observable.box(false); historyShow: OV<boolean> = mobx.observable.box(false);
infoShow: 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); 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"); historyType: mobx.IObservableValue<HistoryTypeStrs> = mobx.observable.box("screen");
historyLoading: mobx.IObservableValue<boolean> = mobx.observable.box(false); historyLoading: mobx.IObservableValue<boolean> = mobx.observable.box(false);
@ -1266,6 +1284,10 @@ class InputModel {
this.filteredHistoryItems = mobx.computed(() => { this.filteredHistoryItems = mobx.computed(() => {
return this._getFilteredHistoryItems(); return this._getFilteredHistoryItems();
}); });
mobx.action(() => {
this.codeSelectSelectedIndex.set(-1);
this.codeSelectBlockRefArray = [];
})();
} }
setInputMode(inputMode: null | "comment" | "global"): void { 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 { setHistoryShow(show: boolean): void {
if (this.historyShow.get() == show) { if (this.historyShow.get() == show) {
return; 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 { hasScrollingInfoMsg(): boolean {
if (!this.infoShow.get()) { if (!this.infoShow.get()) {
return false; return false;
@ -1773,6 +1946,7 @@ class InputModel {
resetInput(): void { resetInput(): void {
mobx.action(() => { mobx.action(() => {
this.setHistoryShow(false); this.setHistoryShow(false);
this.closeAIAssistantChat();
this.infoShow.set(false); this.infoShow.set(false);
this.inputMode.set(null); this.inputMode.set(null);
this.resetHistory(); this.resetHistory();
@ -2864,7 +3038,7 @@ class RemotesModalModel {
let inputPacket: RemoteInputPacketType = { let inputPacket: RemoteInputPacketType = {
type: "remoteinput", type: "remoteinput",
remoteid: remoteId, remoteid: remoteId,
inputdata64: btoa(event.key), inputdata64: stringToBase64(event.key),
}; };
GlobalModel.sendInputPacket(inputPacket); GlobalModel.sendInputPacket(inputPacket);
} }
@ -2922,8 +3096,11 @@ class RemotesModel {
return this.recentConnAddedState.get(); return this.recentConnAddedState.get();
} }
seRecentConnAdded(value: boolean) { @boundMethod
this.recentConnAddedState.set(value); setRecentConnAdded(value: boolean) {
mobx.action(() => {
this.recentConnAddedState.set(value);
})();
} }
deSelectRemote(): void { deSelectRemote(): void {
@ -2935,6 +3112,7 @@ class RemotesModel {
openReadModal(remoteId: string): void { openReadModal(remoteId: string): void {
mobx.action(() => { mobx.action(() => {
this.setRecentConnAdded(false);
this.selectedRemoteId.set(remoteId); this.selectedRemoteId.set(remoteId);
this.remoteEdit.set(null); this.remoteEdit.set(null);
GlobalModel.modalsModel.pushModal(appconst.VIEW_REMOTE); GlobalModel.modalsModel.pushModal(appconst.VIEW_REMOTE);
@ -3043,7 +3221,7 @@ class RemotesModel {
let inputPacket: RemoteInputPacketType = { let inputPacket: RemoteInputPacketType = {
type: "remoteinput", type: "remoteinput",
remoteid: remoteId, remoteid: remoteId,
inputdata64: btoa(event.key), inputdata64: stringToBase64(event.key),
}; };
GlobalModel.sendInputPacket(inputPacket); GlobalModel.sendInputPacket(inputPacket);
} }
@ -3207,6 +3385,8 @@ class Model {
getApi().onICmd(this.onICmd.bind(this)); getApi().onICmd(this.onICmd.bind(this));
getApi().onLCmd(this.onLCmd.bind(this)); getApi().onLCmd(this.onLCmd.bind(this));
getApi().onHCmd(this.onHCmd.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().onMenuItemAbout(this.onMenuItemAbout.bind(this));
getApi().onMetaArrowUp(this.onMetaArrowUp.bind(this)); getApi().onMetaArrowUp(this.onMetaArrowUp.bind(this));
getApi().onMetaArrowDown(this.onMetaArrowDown.bind(this)); getApi().onMetaArrowDown(this.onMetaArrowDown.bind(this));
@ -3245,6 +3425,16 @@ class Model {
getApi().reloadWindow(); 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() { refocus() {
// givefocus() give back focus to cmd or input // givefocus() give back focus to cmd or input
let activeScreen = this.getActiveScreen(); let activeScreen = this.getActiveScreen();
@ -3276,6 +3466,13 @@ class Model {
} }
showAlert(alertMessage: AlertMessageType): Promise<boolean> { 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(() => { mobx.action(() => {
this.alertMessage.set(alertMessage); this.alertMessage.set(alertMessage);
GlobalModel.modalsModel.pushModal(appconst.ALERT); GlobalModel.modalsModel.pushModal(appconst.ALERT);
@ -3356,7 +3553,7 @@ class Model {
// nothing for now // nothing for now
} }
docKeyDownHandler(e: any) { docKeyDownHandler(e: KeyboardEvent) {
if (isModKeyPress(e)) { if (isModKeyPress(e)) {
return; 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 { clearModals(): boolean {
@ -3474,6 +3719,10 @@ class Model {
})(); })();
} }
getLastLogs(numbOfLines: number, cb: (logs: any) => void): void {
getApi().getLastLogs(numbOfLines, cb);
}
getContentHeight(context: RendererContext): number { getContentHeight(context: RendererContext): number {
let key = context.screenId + "/" + context.lineId; let key = context.screenId + "/" + context.lineId;
return this.termUsedRowsCache[key]; return this.termUsedRowsCache[key];
@ -3535,6 +3784,10 @@ class Model {
GlobalModel.historyViewModel.reSearch(); GlobalModel.historyViewModel.reSearch();
} }
onPCmd(e: any, mods: KeyModsType) {
GlobalModel.modalsModel.pushModal(appconst.TAB_SWITCHER);
}
getFocusedLine(): LineFocusType { getFocusedLine(): LineFocusType {
if (this.inputModel.hasFocus()) { if (this.inputModel.hasFocus()) {
return { cmdInputFocus: true }; return { cmdInputFocus: true };
@ -3711,8 +3964,8 @@ class Model {
this.remotes.clear(); this.remotes.clear();
} }
this.updateRemotes(update.remotes); 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()) { if (update.remotes && update.remotes.length && this.remotesModel.recentConnAddedState.get()) {
GlobalModel.remotesModel.closeModal();
GlobalModel.remotesModel.openReadModal(update.remotes![0].remoteid); GlobalModel.remotesModel.openReadModal(update.remotes![0].remoteid);
} }
} }
@ -3744,6 +3997,10 @@ class Model {
this.remotesModel.openEditModal({ ...rview.remoteedit }); this.remotesModel.openEditModal({ ...rview.remoteedit });
} }
} }
if (interactive && "alertmessage" in update) {
let alertMessage: AlertMessageType = update.alertmessage;
this.showAlert(alertMessage);
}
if ("cmdline" in update) { if ("cmdline" in update) {
this.inputModel.updateCmdLine(update.cmdline); this.inputModel.updateCmdLine(update.cmdline);
} }
@ -3756,6 +4013,9 @@ class Model {
this.sessionListLoaded.set(true); this.sessionListLoaded.set(true);
this.remotesLoaded.set(true); this.remotesLoaded.set(true);
} }
if ("openaicmdinfochat" in update) {
this.inputModel.setOpenAICmdInfoChat(update.openaicmdinfochat);
}
// console.log("run-update>", Date.now(), interactive, update); // console.log("run-update>", Date.now(), interactive, update);
} }
@ -3990,6 +4250,28 @@ class Model {
return this.submitCommandPacket(pk, interactive); 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> { submitRawCommand(cmdStr: string, addToHistory: boolean, interactive: boolean): Promise<CommandRtnType> {
let pk: FeCmdPacketType = { let pk: FeCmdPacketType = {
type: "fecmd", type: "fecmd",
@ -4190,7 +4472,7 @@ class Model {
return resp.text() as any; return resp.text() as any;
} }
contentType = resp.headers.get("Content-Type"); 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(); return resp.blob();
}) })
.then((blobOrText: any) => { .then((blobOrText: any) => {
@ -4268,11 +4550,15 @@ class CommandRunner {
GlobalModel.submitCommand("session", null, [session], { nohist: "1" }, false); GlobalModel.submitCommand("session", null, [session], { nohist: "1" }, false);
} }
switchScreen(screen: string) { switchScreen(screen: string, session?: string) {
mobx.action(() => { mobx.action(() => {
GlobalModel.activeMainView.set("session"); 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) { lineView(sessionId: string, screenId: string, lineNum?: number) {
@ -4290,6 +4576,10 @@ class CommandRunner {
return GlobalModel.submitCommand("line", "archive", [lineArg, archiveStr], kwargs, false); 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> { lineSet(lineArg: string, opts: { renderer?: string }): Promise<CommandRtnType> {
let kwargs = { nohist: "1" }; let kwargs = { nohist: "1" };
if ("renderer" in opts) { if ("renderer" in opts) {
@ -4339,8 +4629,8 @@ class CommandRunner {
); );
} }
screenDelete(screenId: string): Promise<CommandRtnType> { screenDelete(screenId: string, interactive: boolean): Promise<CommandRtnType> {
return GlobalModel.submitCommand("screen", "delete", [screenId], { nohist: "1" }, false); return GlobalModel.submitCommand("screen", "delete", [screenId], { nohist: "1" }, interactive);
} }
screenWebShare(screenId: string, shouldShare: boolean): Promise<CommandRtnType> { screenWebShare(screenId: string, shouldShare: boolean): Promise<CommandRtnType> {
@ -4407,7 +4697,7 @@ class CommandRunner {
} }
importSshConfig() { importSshConfig() {
GlobalModel.submitCommand("remote", "parse", null, null, false); GlobalModel.submitCommand("remote", "parse", null, { nohist: "1", visual: "1" }, true);
} }
screenSelectLine(lineArg: string, focusVal?: string) { screenSelectLine(lineArg: string, focusVal?: string) {
@ -4573,6 +4863,12 @@ class CommandRunner {
GlobalModel.submitCommand("client", "accepttos", null, { nohist: "1" }, true); 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) { editBookmark(bookmarkId: string, desc: string, cmdstr: string) {
let kwargs = { let kwargs = {
nohist: "1", nohist: "1",

View File

@ -3,7 +3,8 @@
import * as React from "react"; import * as React from "react";
import * as T from "../../types/types"; 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 { Markdown } from "../../app/common/common";
import { GlobalModel, GlobalCommandRunner } from "../../model/model"; import { GlobalModel, GlobalCommandRunner } from "../../model/model";
import Split from "react-split-it"; 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.monacoEditor = editor;
this.setInitialLanguage(editor); this.setInitialLanguage(editor);
this.setEditorHeight(); this.setEditorHeight();
editor.onKeyDown((e) => { editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => {
if (e.code === "KeyS" && (e.ctrlKey || e.metaKey) && this.state.isSave) { if (e.code === "KeyS" && e.metaKey && this.state.isSave) {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
this.doSave(); this.doSave();
} }
if (e.code === "KeyD" && (e.ctrlKey || e.metaKey)) { if (e.code === "KeyD" && e.metaKey) {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
this.doClose(); this.doClose();
} }
if (e.code === "KeyP" && (e.ctrlKey || e.metaKey)) { if (e.code === "KeyP" && e.metaKey) {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
this.togglePreview(); this.togglePreview();
} }
}); });

View File

@ -3,10 +3,13 @@
import * as mobx from "mobx"; import * as mobx from "mobx";
import { Terminal } from "xterm"; 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 { sprintf } from "sprintf-js";
import { boundMethod } from "autobind-decorator"; import { boundMethod } from "autobind-decorator";
import { windowWidthToCols, windowHeightToRows } from "../../util/textmeasure"; import { windowWidthToCols, windowHeightToRows } from "../../util/textmeasure";
import { boundInt } from "../../util/util"; import { boundInt } from "../../util/util";
import { GlobalModel } from "../../model/model"
import type { import type {
TermContextUnion, TermContextUnion,
TermOptsType, TermOptsType,
@ -96,6 +99,21 @@ class TermWrap {
fontFamily: "JetBrains Mono", fontFamily: "JetBrains Mono",
theme: { foreground: terminal.foreground, background: terminal.background }, 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.terminal._core._inputHandler._parser.setErrorHandler((state) => {
this.numParseErrors++; this.numParseErrors++;
return state; return state;

View File

@ -265,6 +265,20 @@ type ScreenLinesType = {
cmds: CmdDataType[]; 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 = { type ModelUpdateType = {
interactive: boolean; interactive: boolean;
sessions?: SessionDataType[]; sessions?: SessionDataType[];
@ -285,6 +299,8 @@ type ModelUpdateType = {
clientdata?: ClientDataType; clientdata?: ClientDataType;
historyviewdata?: HistoryViewDataType; historyviewdata?: HistoryViewDataType;
remoteview?: RemoteViewType; remoteview?: RemoteViewType;
openaicmdinfochat?: OpenAICmdInfoChatMessageType[];
alertmessage?: AlertMessageType;
}; };
type HistoryViewDataType = { type HistoryViewDataType = {
@ -472,10 +488,15 @@ type FeOptsType = {
termfontsize: number; termfontsize: number;
}; };
type ConfirmFlagsType = {
[k: string]: boolean;
};
type ClientOptsType = { type ClientOptsType = {
notelemetry: boolean; notelemetry: boolean;
noreleasecheck: boolean; noreleasecheck: boolean;
acceptedtos: number; acceptedtos: number;
confirmflags: ConfirmFlagsType;
}; };
type ReleaseInfoType = { type ReleaseInfoType = {
@ -524,6 +545,7 @@ type AlertMessageType = {
message: string; message: string;
confirm?: boolean; confirm?: boolean;
markdown?: boolean; markdown?: boolean;
confirmflag?: string;
}; };
type HistorySearchParams = { type HistorySearchParams = {
@ -756,4 +778,5 @@ export type {
ModalStoreEntry, ModalStoreEntry,
StrWithPos, StrWithPos,
CmdInputTextPacketType, CmdInputTextPacketType,
OpenAICmdInfoChatMessageType,
}; };

View File

@ -6,6 +6,7 @@ import { sprintf } from "sprintf-js";
import dayjs from "dayjs"; import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat"; import localizedFormat from "dayjs/plugin/localizedFormat";
import type { RemoteType, CommandRtnType } from "../types/types"; import type { RemoteType, CommandRtnType } from "../types/types";
import base64 from "base64-js";
type OV<V> = mobx.IObservableValue<V>; type OV<V> = mobx.IObservableValue<V>;
@ -70,6 +71,16 @@ function handleJsonFetchResponse(url: URL, resp: any): Promise<any> {
return rtnData; 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 { function base64ToArray(b64: string): Uint8Array {
let rawStr = atob(b64); let rawStr = atob(b64);
let rtnArr = new Uint8Array(new ArrayBuffer(rawStr.length)); let rtnArr = new Uint8Array(new ArrayBuffer(rawStr.length));
@ -229,26 +240,6 @@ function genMergeDataMap<ObjType extends IObjType<DataType>, DataType extends ID
return rtn; 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 { function boundInt(ival: number, minVal: number, maxVal: number): number {
if (ival < minVal) { if (ival < minVal) {
return 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 { export {
handleJsonFetchResponse, handleJsonFetchResponse,
base64ToString,
stringToBase64,
base64ToArray, base64ToArray,
genMergeData, genMergeData,
genMergeDataMap, genMergeDataMap,
genMergeSimpleData, genMergeSimpleData,
parseEnv0,
boundInt, boundInt,
isModKeyPress, isModKeyPress,
incObs, incObs,
@ -428,4 +428,5 @@ export {
getColorRGB, getColorRGB,
commandRtnHandler, commandRtnHandler,
getRemoteConnVal, getRemoteConnVal,
getRemoteName,
}; };

View File

@ -70,6 +70,8 @@ const PacketEOFStr = "EOF"
var TypeStrToFactory map[string]reflect.Type 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() { func init() {
TypeStrToFactory = make(map[string]reflect.Type) TypeStrToFactory = make(map[string]reflect.Type)
TypeStrToFactory[RunPacketStr] = reflect.TypeOf(RunPacketType{}) TypeStrToFactory[RunPacketStr] = reflect.TypeOf(RunPacketType{})
@ -729,6 +731,14 @@ type OpenAIUsageType struct {
TotalTokens int `json:"total_tokens,omitempty"` 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 OpenAIPacketType struct {
Type string `json:"type"` Type string `json:"type"`
Model string `json:"model,omitempty"` 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 { type OpenAIPromptMessageType struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
@ -927,14 +945,6 @@ func ParseJsonPacket(jsonBuf []byte) (PacketType, error) {
return pk, nil 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 { type SendError struct {
IsWriteError bool // fatal IsWriteError bool // fatal
IsMarshalError bool // not fatal IsMarshalError bool // not fatal
@ -970,7 +980,6 @@ func MarshalPacket(packet PacketType) ([]byte, error) {
outBuf.Write(jsonBytes) outBuf.Write(jsonBytes)
outBuf.WriteByte('\n') outBuf.WriteByte('\n')
outBytes := outBuf.Bytes() outBytes := outBuf.Bytes()
sanitizeBytes(outBytes)
return outBytes, nil return outBytes, nil
} }

View 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
)
);

View File

@ -0,0 +1,3 @@
UPDATE remote
SET remotecanonicalname = remotecanonicalname || COALESCE( ":" || json_extract(sshopts, '$.sshport'), "")
WHERE json_extract(sshopts, '$.sshport') != 22;

View File

@ -33,3 +33,4 @@ require (
) )
replace github.com/wavetermdev/waveterm/waveshell => ../waveshell replace github.com/wavetermdev/waveterm/waveshell => ../waveshell
replace github.com/kevinburke/ssh_config => github.com/wavetermdev/ssh_config v0.0.0-20240109090616-36c8da3d7376

View File

@ -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 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 RemoteColorNames = []string{"red", "green", "yellow", "blue", "magenta", "cyan", "white", "orange"}
var RemoteSetArgs = []string{"alias", "connectmode", "key", "password", "autoinstall", "color"} 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 ScreenCmds = []string{"run", "comment", "cd", "cr", "clear", "sw", "reset", "signal", "chat"}
var NoHistCmds = []string{"_compgen", "line", "history", "_killserver"} var NoHistCmds = []string{"_compgen", "line", "history", "_killserver"}
@ -115,8 +116,8 @@ var SetVarScopes = []SetVarScope{
{ScopeName: "remote", VarNames: []string{}}, {ScopeName: "remote", VarNames: []string{}},
} }
var userHostRe = regexp.MustCompile("^(sudo@)?([a-z][a-z0-9._@-]*)@([a-z0-9][a-z0-9.-]*)(?::([0-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-Z][a-zA-Z0-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 genericNameRe = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_ .()<>,/\"'\\[\\]{}=+$@!*-]*$")
var rendererRe = 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))$") var positionRe = regexp.MustCompile("^((S?\\+|E?-)?[0-9]+|(\\+|-|S|E))$")
@ -213,6 +214,7 @@ func init() {
registerCmdFn("client:set", ClientSetCommand) registerCmdFn("client:set", ClientSetCommand)
registerCmdFn("client:notifyupdatewriter", ClientNotifyUpdateWriterCommand) registerCmdFn("client:notifyupdatewriter", ClientNotifyUpdateWriterCommand)
registerCmdFn("client:accepttos", ClientAcceptTosCommand) registerCmdFn("client:accepttos", ClientAcceptTosCommand)
registerCmdFn("client:setconfirmflag", ClientConfirmFlagCommand)
registerCmdFn("sidebar:open", SidebarOpenCommand) registerCmdFn("sidebar:open", SidebarOpenCommand)
registerCmdFn("sidebar:close", SidebarCloseCommand) registerCmdFn("sidebar:close", SidebarCloseCommand)
@ -1255,8 +1257,15 @@ func parseRemoteEditArgs(isNew bool, pk *scpacket.FeCommandPacketType, isLocal b
if portVal == 0 && uhPort != 0 { if portVal == 0 && uhPort != 0 {
portVal = uhPort 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 sshOpts.SSHPort = portVal
canonicalName = remoteUser + "@" + remoteHost canonicalName = remoteUser + "@" + remoteHost
if portVal != 0 && portVal != 22 {
canonicalName = canonicalName + ":" + strconv.Itoa(portVal)
}
if isSudo { if isSudo {
canonicalName = "sudo@" + canonicalName canonicalName = "sudo@" + canonicalName
} }
@ -1511,6 +1520,47 @@ type HostInfoType struct {
Ignore bool 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) { func NewHostInfo(hostName string) (*HostInfoType, error) {
userName, _ := ssh_config.GetStrict(hostName, "User") userName, _ := ssh_config.GetStrict(hostName, "User")
if userName == "" { if userName == "" {
@ -1528,18 +1578,18 @@ func NewHostInfo(hostName string) (*HostInfoType, error) {
portStr, _ := ssh_config.GetStrict(hostName, "Port") portStr, _ := ssh_config.GetStrict(hostName, "Port")
var portVal int var portVal int
if portStr != "" { if portStr != "" && portStr != "22" {
canonicalName = canonicalName + ":" + portStr
var err error var err error
portVal, err = strconv.Atoi(portStr) portVal, err = strconv.Atoi(portStr)
if err != nil { if err != nil {
// do not make assumptions about port if incorrectly configured // 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) 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) return nil, fmt.Errorf("could not parse port \"%d\": number is not valid for a port\n", portVal)
} }
} }
identityFile, _ := ssh_config.GetStrict(hostName, "IdentityFile") identityFile, _ := ssh_config.GetStrict(hostName, "IdentityFile")
passwordAuth, _ := ssh_config.GetStrict(hostName, "PasswordAuthentication") passwordAuth, _ := ssh_config.GetStrict(hostName, "PasswordAuthentication")
@ -1579,6 +1629,7 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
localConfig := filepath.Join(home, ".ssh", "config") localConfig := filepath.Join(home, ".ssh", "config")
systemConfig := filepath.Join("/", "ssh", "config") systemConfig := filepath.Join("/", "ssh", "config")
sshConfigFiles := []string{localConfig, systemConfig} sshConfigFiles := []string{localConfig, systemConfig}
ssh_config.ReloadConfigs()
hostPatterns, hostPatternsErr := resolveSshConfigPatterns(sshConfigFiles) hostPatterns, hostPatternsErr := resolveSshConfigPatterns(sshConfigFiles)
if hostPatternsErr != nil { if hostPatternsErr != nil {
return nil, hostPatternsErr return nil, hostPatternsErr
@ -1600,6 +1651,8 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
hostInfoInConfig[hostInfo.CanonicalName] = hostInfo hostInfoInConfig[hostInfo.CanonicalName] = hostInfo
} }
remoteChangeList := make(map[string][]string)
// remove all previously imported remotes that // remove all previously imported remotes that
// no longer have a canonical pattern in the config files // no longer have a canonical pattern in the config files
for importedRemoteCanonicalName, importedRemote := range previouslyImportedRemotes { for importedRemoteCanonicalName, importedRemote := range previouslyImportedRemotes {
@ -1608,17 +1661,17 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
if !importedRemote.Archived && (hostInfo == nil || hostInfo.Ignore) { if !importedRemote.Archived && (hostInfo == nil || hostInfo.Ignore) {
err = remote.ArchiveRemote(ctx, importedRemote.RemoteId) err = remote.ArchiveRemote(ctx, importedRemote.RemoteId)
if err != nil { 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) log.Printf("sshconfig-import: failed to remove remote \"%s\" (%s)\n", importedRemote.RemoteAlias, importedRemote.RemoteCanonicalName)
} else { } else {
remoteChangeList["delete"] = append(remoteChangeList["delete"], importedRemote.RemoteCanonicalName)
log.Printf("sshconfig-import: archived remote \"%s\" (%s)\n", importedRemote.RemoteAlias, importedRemote.RemoteCanonicalName) log.Printf("sshconfig-import: archived remote \"%s\" (%s)\n", importedRemote.RemoteAlias, importedRemote.RemoteCanonicalName)
} }
} }
} }
var updatedRemotes []string
for _, hostInfo := range parsedHostData { for _, hostInfo := range parsedHostData {
previouslyImportedRemote := previouslyImportedRemotes[hostInfo.CanonicalName] previouslyImportedRemote := previouslyImportedRemotes[hostInfo.CanonicalName]
updatedRemotes = append(updatedRemotes, hostInfo.CanonicalName)
if hostInfo.Ignore { if hostInfo.Ignore {
log.Printf("sshconfig-import: ignore remote[%s] as specified in config file\n", hostInfo.CanonicalName) log.Printf("sshconfig-import: ignore remote[%s] as specified in config file\n", hostInfo.CanonicalName)
continue continue
@ -1626,27 +1679,31 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
if previouslyImportedRemote != nil && !previouslyImportedRemote.Archived { if previouslyImportedRemote != nil && !previouslyImportedRemote.Archived {
// this already existed and was created via import // this already existed and was created via import
// it needs to be updated instead of created // it needs to be updated instead of created
editMap := make(map[string]interface{}) editMap := make(map[string]interface{})
editMap[sstore.RemoteField_Alias] = hostInfo.Host editMap[sstore.RemoteField_Alias] = hostInfo.Host
editMap[sstore.RemoteField_ConnectMode] = hostInfo.ConnectMode 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 != "" { if hostInfo.SshKeyFile != "" {
editMap[sstore.RemoteField_SSHKey] = hostInfo.SshKeyFile editMap[sstore.RemoteField_SSHKey] = hostInfo.SshKeyFile
} }
msh := remote.GetRemoteById(previouslyImportedRemote.RemoteId) msh := remote.GetRemoteById(previouslyImportedRemote.RemoteId)
if msh == nil { 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) log.Printf("strange, msh for remote %s [%s] not found\n", hostInfo.CanonicalName, previouslyImportedRemote.RemoteId)
continue continue
} else {
err := msh.UpdateRemote(ctx, editMap)
if err != nil {
log.Printf("error updating remote[%s]: %v\n", hostInfo.CanonicalName, err)
continue
}
} }
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) log.Printf("sshconfig-import: found previously imported remote with canonical name \"%s\": it has been updated\n", hostInfo.CanonicalName)
} else { } else {
sshOpts := &sstore.SSHOpts{ sshOpts := &sstore.SSHOpts{
@ -1675,21 +1732,31 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
} }
err := remote.AddRemote(ctx, r, false) err := remote.AddRemote(ctx, r, false)
if err != nil { 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) log.Printf("sshconfig-import: failed to add remote \"%s\" (%s): it is being skipped\n", hostInfo.Host, hostInfo.CanonicalName)
continue continue
} }
remoteChangeList["create"] = append(remoteChangeList["create"], hostInfo.CanonicalName)
log.Printf("sshconfig-import: created remote \"%s\" (%s)\n", hostInfo.Host, hostInfo.CanonicalName) log.Printf("sshconfig-import: created remote \"%s\" (%s)\n", hostInfo.Host, hostInfo.CanonicalName)
} }
} }
update := &sstore.ModelUpdate{Remotes: remote.GetAllRemoteRuntimeState()} outMsg := createSshImportSummary(remoteChangeList)
update.Info = &sstore.InfoMsgType{} visualEdit := resolveBool(pk.Kwargs["visual"], false)
if len(updatedRemotes) == 0 { if visualEdit {
update.Info.InfoMsg = "no connections imported from ssh config." update := &sstore.ModelUpdate{}
update.AlertMessage = &sstore.AlertMessageType{
Title: "SSH Config Import",
Message: outMsg,
Markdown: true,
}
return update, nil
} else { } else {
update.Info.InfoMsg = fmt.Sprintf("imported %d connection(s) from ssh config file: %s\n", len(updatedRemotes), strings.Join(updatedRemotes, ", ")) update := &sstore.ModelUpdate{}
update.Info = &sstore.InfoMsgType{}
update.Info.InfoMsg = outMsg
return update, nil
} }
return update, nil
} }
func ScreenShowAllCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) { 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 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) { func doOpenAIStreamCompletion(cmd *sstore.CmdType, clientId string, opts *sstore.OpenAIOptsType, prompt []packet.OpenAIPromptMessageType) {
var outputPos int64 var outputPos int64
var hadError bool var hadError bool
@ -2019,6 +2194,23 @@ func doOpenAIStreamCompletion(cmd *sstore.CmdType, clientId string, opts *sstore
return 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) { func OpenAICommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen) ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen)
if err != nil { if err != nil {
@ -2044,9 +2236,6 @@ func OpenAICommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstor
opts.MaxTokens = openai.DefaultMaxTokens opts.MaxTokens = openai.DefaultMaxTokens
} }
promptStr := firstArg(pk) promptStr := firstArg(pk)
if promptStr == "" {
return nil, fmt.Errorf("openai error, prompt string is blank")
}
ptermVal := defaultStr(pk.Kwargs["wterm"], DefaultPTERM) ptermVal := defaultStr(pk.Kwargs["wterm"], DefaultPTERM)
pkTermOpts, err := GetUITermOpts(pk.UIContext.WinSize, ptermVal) pkTermOpts, err := GetUITermOpts(pk.UIContext.WinSize, ptermVal)
if err != nil { if err != nil {
@ -2057,11 +2246,40 @@ func OpenAICommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstor
if err != nil { if err != nil {
return nil, fmt.Errorf("openai error, cannot make dyn cmd") 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) line, err := sstore.AddOpenAILine(ctx, ids.ScreenId, DefaultUserId, cmd)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot add new line: %v", err) return nil, fmt.Errorf("cannot add new line: %v", err)
} }
prompt := []packet.OpenAIPromptMessageType{{Role: sstore.OpenAIRoleUser, Content: promptStr}}
if resolveBool(pk.Kwargs["stream"], true) { if resolveBool(pk.Kwargs["stream"], true) {
go doOpenAIStreamCompletion(cmd, clientData.ClientId, opts, prompt) go doOpenAIStreamCompletion(cmd, clientData.ClientId, opts, prompt)
} else { } else {
@ -3441,7 +3659,7 @@ func LineDeleteCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s
} }
err = sstore.DeleteLinesByIds(ctx, ids.ScreenId, lineIds) err = sstore.DeleteLinesByIds(ctx, ids.ScreenId, lineIds)
if err != nil { 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{} update := &sstore.ModelUpdate{}
for _, lineId := range lineIds { for _, lineId := range lineIds {
@ -3452,6 +3670,11 @@ func LineDeleteCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s
} }
update.Lines = append(update.Lines, lineObj) 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 return update, nil
} }
@ -4019,6 +4242,57 @@ func ClientAcceptTosCommand(ctx context.Context, pk *scpacket.FeCommandPacketTyp
return update, nil 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 { func validateOpenAIAPIToken(key string) error {
if len(key) > MaxOpenAIAPITokenLen { if len(key) > MaxOpenAIAPITokenLen {
return fmt.Errorf("invalid openai token, too long") return fmt.Errorf("invalid openai token, too long")

View File

@ -11,10 +11,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/wavesrv/pkg/remote" "github.com/wavetermdev/waveterm/wavesrv/pkg/remote"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scpacket" "github.com/wavetermdev/waveterm/wavesrv/pkg/scpacket"
"github.com/wavetermdev/waveterm/wavesrv/pkg/sstore" "github.com/wavetermdev/waveterm/wavesrv/pkg/sstore"
"github.com/google/uuid"
) )
const ( const (

View File

@ -21,6 +21,7 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/alessio/shellescape"
"github.com/armon/circbuf" "github.com/armon/circbuf"
"github.com/creack/pty" "github.com/creack/pty"
"github.com/google/uuid" "github.com/google/uuid"
@ -66,9 +67,9 @@ func MakeLocalMShellCommandStr(isSudo bool) (string, error) {
return "", err return "", err
} }
if isSudo { 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 { } 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 := shexec.DeclMapFromState(&rtn)
envMap["PROMPT"] = &shexec.DeclareDeclType{Name: "PROMPT", Value: "1", Args: "x"} envMap["PROMPT"] = &shexec.DeclareDeclType{Name: "PROMPT", Value: "1", Args: "x"}
envMap["PROMPT_VERSION"] = &shexec.DeclareDeclType{Name: "PROMPT_VERSION", Value: scbase.WaveVersion, 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) rtn.ShellVars = shexec.SerializeDeclMap(envMap)
return &rtn return &rtn
} }

View File

@ -377,3 +377,30 @@ func MacOSRelease() string {
}) })
return osRelease 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
}

View File

@ -240,7 +240,8 @@ func (c *parseContext) tokenizeDQ() ([]*WordType, bool) {
// returns (words, eofexit) // returns (words, eofexit)
// backticks (WordTypeBQ) handle backslash in a special way, but that seems to mainly effect execution (not completion) // 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 //
// de_backslash => removes initial backslash in \`, \\, and \$ before execution
func (c *parseContext) tokenizeRaw() ([]*WordType, bool) { func (c *parseContext) tokenizeRaw() ([]*WordType, bool) {
state := &tokenizeOutputState{} state := &tokenizeOutputState{}
isExpSubShell := c.QC.cur() == WordTypeDP isExpSubShell := c.QC.cur() == WordTypeDP

View File

@ -748,6 +748,7 @@ func InsertScreen(ctx context.Context, sessionId string, origScreenName string,
return nil, txErr return nil, txErr
} }
update.Sessions = []*SessionType{bareSession} update.Sessions = []*SessionType{bareSession}
update.OpenAICmdInfoChat = ScreenMemGetCmdInfoChat(newScreenId).Messages
} }
return update, nil 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) { func UpdateCmdDoneInfo(ctx context.Context, ck base.CommandKey, donePk *packet.CmdDonePacketType, status string) (*ModelUpdate, error) {
if donePk == nil { if donePk == nil {
return nil, fmt.Errorf("invalid cmddone packet") return nil, fmt.Errorf("invalid cmddone packet")
@ -1039,6 +1063,7 @@ func SwitchScreenById(ctx context.Context, sessionId string, screenId string) (*
memState := GetScreenMemState(screenId) memState := GetScreenMemState(screenId)
if memState != nil { if memState != nil {
update.CmdLine = &memState.CmdInputText update.CmdLine = &memState.CmdInputText
update.OpenAICmdInfoChat = ScreenMemGetCmdInfoChat(screenId).Messages
} }
return update, nil return update, nil
} }
@ -1704,7 +1729,6 @@ const (
RemoteField_ConnectMode = "connectmode" // string RemoteField_ConnectMode = "connectmode" // string
RemoteField_SSHKey = "sshkey" // string RemoteField_SSHKey = "sshkey" // string
RemoteField_SSHPassword = "sshpassword" // string RemoteField_SSHPassword = "sshpassword" // string
RemoteField_SSHPort = "sshport" // string
RemoteField_Color = "color" // 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 = ?` query = `UPDATE remote SET sshopts = json_set(sshopts, '$.sshpassword', ?) WHERE remoteid = ?`
tx.Exec(query, sshPassword, 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 { if color, found := editMap[RemoteField_Color]; found {
query = `UPDATE remote SET remoteopts = json_set(remoteopts, '$.color', ?) WHERE remoteid = ?` query = `UPDATE remote SET remoteopts = json_set(remoteopts, '$.color', ?) WHERE remoteid = ?`
tx.Exec(query, color, remoteId) tx.Exec(query, color, remoteId)
@ -2044,6 +2064,29 @@ func SetLineArchivedById(ctx context.Context, screenId string, lineId string, ar
return txErr 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 { func DeleteLinesByIds(ctx context.Context, screenId string, lineIds []string) error {
txErr := WithTx(ctx, func(tx *TxWrap) error { txErr := WithTx(ctx, func(tx *TxWrap) error {
isWS := isWebShare(tx, screenId) 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 = ?` query := `SELECT status FROM cmd WHERE screenid = ? AND lineid = ?`
cmdStatus := tx.GetString(query, screenId, lineId) cmdStatus := tx.GetString(query, screenId, lineId)
if cmdStatus == CmdStatusRunning { 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 = ?` query = `DELETE FROM line WHERE screenid = ? AND lineid = ?`
tx.Exec(query, screenId, lineId) tx.Exec(query, screenId, lineId)
query = `DELETE FROM cmd WHERE screenid = ? AND 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 // don't delete history anymore, just remove lineid reference
query = `UPDATE history SET lineid = '', linenum = 0 WHERE screenid = ? AND lineid = ?` query = `UPDATE history SET lineid = '', linenum = 0 WHERE screenid = ? AND lineid = ?`
tx.Exec(query, screenId, lineId) tx.Exec(query, screenId, lineId)
if isWS { if isWS {
insertScreenLineUpdate(tx, screenId, lineId, UpdateType_LineDel) insertScreenLineUpdate(tx, screenId, lineId, UpdateType_LineDel)
} }

View File

@ -5,9 +5,11 @@
package sstore package sstore
import ( import (
"fmt"
"log" "log"
"sync" "sync"
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
"github.com/wavetermdev/waveterm/wavesrv/pkg/utilfn" "github.com/wavetermdev/waveterm/wavesrv/pkg/utilfn"
) )
@ -43,11 +45,109 @@ func isIndicatorGreater(i1 string, i2 string) bool {
return screenIndicatorLevels[i1] > screenIndicatorLevels[i2] return screenIndicatorLevels[i1] > screenIndicatorLevels[i2]
} }
type OpenAICmdInfoChatStore struct {
MessageCount int `json:"messagecount"`
Messages []*packet.OpenAICmdInfoChatMessage `json:"messages"`
}
type ScreenMemState struct { type ScreenMemState struct {
NumRunningCommands int `json:"numrunningcommands,omitempty"` NumRunningCommands int `json:"numrunningcommands,omitempty"`
IndicatorType string `json:"indicatortype,omitempty"` IndicatorType string `json:"indicatortype,omitempty"`
CmdInputText utilfn.StrWithPos `json:"cmdinputtext,omitempty"` CmdInputText utilfn.StrWithPos `json:"cmdinputtext,omitempty"`
CmdInputSeqNum int `json:"cmdinputseqnum,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) { func ScreenMemSetCmdInputText(screenId string, sp utilfn.StrWithPos, seqNum int) {

View File

@ -22,7 +22,7 @@ import (
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
) )
const MaxMigration = 28 const MaxMigration = 29
const MigratePrimaryScreenVersion = 9 const MigratePrimaryScreenVersion = 9
const CmdScreenSpecialMigration = 13 const CmdScreenSpecialMigration = 13
const CmdLineSpecialMigration = 20 const CmdLineSpecialMigration = 20

View File

@ -267,9 +267,10 @@ func (tdata *TelemetryData) Scan(val interface{}) error {
} }
type ClientOptsType struct { type ClientOptsType struct {
NoTelemetry bool `json:"notelemetry,omitempty"` NoTelemetry bool `json:"notelemetry,omitempty"`
NoReleaseCheck bool `json:"noreleasecheck,omitempty"` NoReleaseCheck bool `json:"noreleasecheck,omitempty"`
AcceptedTos int64 `json:"acceptedtos,omitempty"` AcceptedTos int64 `json:"acceptedtos,omitempty"`
ConfirmFlags map[string]bool `json:"confirmflags,omitempty"`
} }
type FeOptsType struct { type FeOptsType struct {

View File

@ -8,6 +8,7 @@ import (
"log" "log"
"sync" "sync"
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
"github.com/wavetermdev/waveterm/wavesrv/pkg/utilfn" "github.com/wavetermdev/waveterm/wavesrv/pkg/utilfn"
) )
@ -38,28 +39,30 @@ func (*PtyDataUpdate) UpdateType() string {
func (pdu *PtyDataUpdate) Clean() {} func (pdu *PtyDataUpdate) Clean() {}
type ModelUpdate struct { type ModelUpdate struct {
Sessions []*SessionType `json:"sessions,omitempty"` Sessions []*SessionType `json:"sessions,omitempty"`
ActiveSessionId string `json:"activesessionid,omitempty"` ActiveSessionId string `json:"activesessionid,omitempty"`
Screens []*ScreenType `json:"screens,omitempty"` Screens []*ScreenType `json:"screens,omitempty"`
ScreenLines *ScreenLinesType `json:"screenlines,omitempty"` ScreenLines *ScreenLinesType `json:"screenlines,omitempty"`
Line *LineType `json:"line,omitempty"` Line *LineType `json:"line,omitempty"`
Lines []*LineType `json:"lines,omitempty"` Lines []*LineType `json:"lines,omitempty"`
Cmd *CmdType `json:"cmd,omitempty"` Cmd *CmdType `json:"cmd,omitempty"`
CmdLine *utilfn.StrWithPos `json:"cmdline,omitempty"` CmdLine *utilfn.StrWithPos `json:"cmdline,omitempty"`
Info *InfoMsgType `json:"info,omitempty"` Info *InfoMsgType `json:"info,omitempty"`
ClearInfo bool `json:"clearinfo,omitempty"` ClearInfo bool `json:"clearinfo,omitempty"`
Remotes []RemoteRuntimeState `json:"remotes,omitempty"` Remotes []RemoteRuntimeState `json:"remotes,omitempty"`
History *HistoryInfoType `json:"history,omitempty"` History *HistoryInfoType `json:"history,omitempty"`
Interactive bool `json:"interactive"` Interactive bool `json:"interactive"`
Connect bool `json:"connect,omitempty"` Connect bool `json:"connect,omitempty"`
MainView string `json:"mainview,omitempty"` MainView string `json:"mainview,omitempty"`
Bookmarks []*BookmarkType `json:"bookmarks,omitempty"` Bookmarks []*BookmarkType `json:"bookmarks,omitempty"`
SelectedBookmark string `json:"selectedbookmark,omitempty"` SelectedBookmark string `json:"selectedbookmark,omitempty"`
HistoryViewData *HistoryViewData `json:"historyviewdata,omitempty"` HistoryViewData *HistoryViewData `json:"historyviewdata,omitempty"`
ClientData *ClientData `json:"clientdata,omitempty"` ClientData *ClientData `json:"clientdata,omitempty"`
RemoteView *RemoteViewType `json:"remoteview,omitempty"` RemoteView *RemoteViewType `json:"remoteview,omitempty"`
ScreenTombstones []*ScreenTombstoneType `json:"screentombstones,omitempty"` ScreenTombstones []*ScreenTombstoneType `json:"screentombstones,omitempty"`
SessionTombstones []*SessionTombstoneType `json:"sessiontombstones,omitempty"` SessionTombstones []*SessionTombstoneType `json:"sessiontombstones,omitempty"`
OpenAICmdInfoChat []*packet.OpenAICmdInfoChatMessage `json:"openaicmdinfochat,omitempty"`
AlertMessage *AlertMessageType `json:"alertmessage,omitempty"`
} }
func (*ModelUpdate) UpdateType() string { func (*ModelUpdate) UpdateType() string {
@ -128,6 +131,13 @@ type RemoteEditType struct {
HasPassword bool `json:"haspassword,omitempty"` 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 { type InfoMsgType struct {
InfoTitle string `json:"infotitle"` InfoTitle string `json:"infotitle"`
InfoError string `json:"infoerror,omitempty"` InfoError string `json:"infoerror,omitempty"`

1246
yarn.lock

File diff suppressed because it is too large Load Diff